Merge branch 'main' into feat/mera-b2c-perimeter
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped

Конфликты (add/add + content) разрешены в пользу main: лэндинг #2615 и рефакторинг RouteGuard (вынос GuardedRoute в отдельный чанк, хелпер isPublicPath) уже содержат всё, что добавляла заглушка этой ветки.

Плюс закрыт разрыв на стыке #2615 и этого PR: футер лэндинга ссылается на политику ПДн через next/link, Next с basePath эмитит её как /trade-in/mera-public/privacy — такого handle в блоке периметра не было, ссылка уходила в catch-all 404. Добавлен узкий handle ровно на поддерево лэндинга (НЕ /trade-in/*) + два регресс-теста в смоук на то, что matcher не шире задуманного.
This commit is contained in:
bot-backend 2026-08-02 15:12:06 +03:00
commit 537b0ac06e
89 changed files with 10542 additions and 3409 deletions

View file

@ -234,13 +234,28 @@ meraocenka.ru {
output file /var/log/caddy/meraocenka.ru.log
}
# Единственная публичная страница этого этапа — заглушка "скоро".
# rewrite добавляет basePath-префикс только для Caddy→backend хопа.
# Корень домена → лэндинг МЕРЫ (#2615 заменил заглушку этого этапа на
# полноценную страницу). rewrite добавляет basePath-префикс только для
# Caddy→backend хопа, пользователь /trade-in никогда не видит.
handle / {
rewrite * /trade-in/mera-public
reverse_proxy tradein-frontend:3000
}
# Подстраницы САМОГО лэндинга. Нужны с момента мержа #2615: футер ссылается
# на политику обработки ПДн через next/link (`PRIVACY_PATH`), а Next с
# basePath эмитит её как /trade-in/mera-public/privacy. Без этого handle
# ссылка уходила бы в catch-all 404 ниже — то есть обязательный по 152-ФЗ
# документ был бы недоступен с публичной страницы.
#
# Matcher намеренно узкий — ровно поддерево лэндинга, НЕ /trade-in/*.
# B2B-дерево (/trade-in/v2, /trade-in/api/*, /trade-in/admin/*, /history)
# под него не подпадает и по-прежнему отдаёт 404. Регресс-тест на это —
# в scripts/smoke-mera-perimeter.sh.
handle /trade-in/mera-public/* {
reverse_proxy tradein-frontend:3000
}
# Next.js уже эмитит ссылки на статику с /trade-in-префиксом (тот же
# basePath) — passthrough без rewrite. Нужны для рендера страницы (JS/CSS
# чанки), сами по себе не содержат ни B2B-данных, ни секретов.

View file

@ -120,6 +120,28 @@ def test_migrations_are_transactional() -> None:
), f"Миграции без обёртки BEGIN;/COMMIT;: {broken} (.claude/rules/sql.md → Structure)."
def test_no_concurrent_index_in_migrations() -> None:
"""Ни одной CREATE/DROP INDEX CONCURRENTLY в data/sql/auth/*.sql.
Red => миграция гарантированно падает на проде: CONCURRENTLY нельзя выполнять внутри
транзакционного блока (Postgres: 25001 «CREATE INDEX CONCURRENTLY cannot run inside a
transaction block»), а обёртка BEGIN;/COMMIT; здесь обязательна для всех файлов
(test_migrations_are_transactional). Две проверки по отдельности зелёные, а вместе
невыполнимые поэтому запрет нужен явный: комбинация ловится только здесь.
Нужен CONCURRENTLY на большой таблице это отдельный ручной прогон вне auto-apply,
а не файл в этом каталоге.
"""
hits: list[str] = []
for path in _auth_sql_files():
text = path.read_text(encoding="utf-8")
for line_no, line in enumerate(text.splitlines(), start=1):
if line.lstrip().startswith("--"):
continue # комментарий может объяснять запрет, не нарушая его
if re.search(r"\bCONCURRENTLY\b", line, re.IGNORECASE):
hits.append(f"{path.name}:{line_no}: {line.strip()}")
assert not hits, "CONCURRENTLY внутри BEGIN/COMMIT — упадёт на деплое: " + "; ".join(hits)
def test_no_password_material_in_auth_sql() -> None:
"""Ни в data/sql/auth, ни в ops/db-bootstrap нет plaintext-паролей и bcrypt-хешей.

View file

@ -0,0 +1,408 @@
-- auth/004: продуктовые роли + org-иерархия + трёхзначный access_state вместо булева is_active.
--
-- ⚠️ ЭТА МИГРАЦИЯ СОЗНАТЕЛЬНО ОТМЕНЯЕТ РЕШЕНИЯ, ЗАПИСАННЫЕ В 001 И 002.
-- Это не рассинхрон и не ошибка автора: решение владельца продукта от 2026-07-31 принято
-- ПОСЛЕ того, как 001-003 были написаны и применены на проде. Применённую миграцию править
-- нельзя (повторно она не выполнится — трекинг в _schema_migrations), поэтому актуальная
-- правда живёт здесь, а в 001/002 остаются исторические формулировки:
-- * 001:15-19 «Здесь НЕТ колонки role — сознательно» → ОТМЕНЕНО, см. WHY-1;
-- * 002:22-33 «users — INSERT/DELETE НЕ выдаются, сознательно» → ОТМЕНЕНО ЧАСТИЧНО: INSERT
-- выдаётся (без него переезд не состоится), DELETE — по-прежнему нет, см. Часть 4;
-- * 002:26-27 «идентичность требует nextval» (грант USAGE на sequence) → ФАКТИЧЕСКИ
-- НЕВЕРНО, гранта не требуется; проверено, разбор в Части 4;
-- * 003:78-90 «открытая развилка про trial-экран, решается в PR-2/3» → ЗАКРЫТА, см. WHY-2.
-- Ориентир для читателя: актуальное состояние колонок описано COMMENT'ами в БД, они
-- переписаны здесь. Заголовок 001 — археология, а не спецификация.
--
-- WHY-1 — продуктовые роли переезжают в `auth` (отмена решения 001):
-- 001 строилась на схеме «идентичность общая, полномочия у продукта»: auth.users знает, КТО
-- человек, tradein_users знает, ЧТО ему можно. Владелец выбрал другой сценарий — ПОЛНЫЙ
-- переезд: tradein_users (БД tradein) в итоге удаляется, auth.users остаётся единственным
-- реестром людей. Как только реестр один, роль перестаёт быть «знанием продукта»: без неё в
-- auth.users нельзя ни завести сотрудника, ни собрать раздел «Команда», ни ответить на вопрос
-- «чьи заявки видит этот менеджер» — а спросить больше не у кого, второй таблицы не будет.
-- Промежуточный вариант (человек в auth.users, его роль в tradein_users) — это два реестра,
-- которые кто-то обязан держать синхронными руками; их расхождение выглядит как «пользователь
-- есть, но он никто» и чинится только вручную по факту жалобы.
-- Цена решения ровно та, которую 001 и называла: новая роль в любом из продуктов = миграция
-- этой БД. Принято сознательно — это дешевле, чем двойной реестр людей.
--
-- WHY-2 — три состояния доступа вместо булева is_active (закрытие развилки из 003):
-- Булев флаг схлопывает два РАЗНЫХ события в одно значение: «пробный период закончился» и
-- «доступ закрыт владельцем». Для пользователя разница видимая и она уже реализована в
-- сегодняшнем стеке: expired-аккаунт доходит до фронта и видит осмысленный экран «пробный
-- доступ закончился» (auth/roles.yaml → expired: paths: [] + deny "/**"; frontend
-- NoAccessScreen variant="trial"), а закрытый — просто не входит. Переключившись на единую
-- форму входа с булевым is_active, мы бы потеряли trial-экран МОЛЧА: состояние перестало бы
-- существовать, и ни один тест бы не упал. Ровно это и было записано как открытая развилка в
-- 003:78-90. Решение: состояний три.
-- active — доступ есть, обычный вход.
-- trial_expired — пароль ВЕРНЫЙ, но пробный период истёк: логин отвечает 403 с отдельным
-- кодом и текстом «пробный доступ закончился», сессия НЕ выдаётся.
-- disabled — жёсткая блокировка: generic 401, для пользователя неотличимо от «неверный
-- пароль».
-- Неверный пароль в ЛЮБОМ состоянии → generic 401. Иначе отдельный 403 превращается в оракул
-- существования логина: перебором можно перечислить аккаунты, не зная ни одного пароля.
-- Осмысленный ответ полагается только тому, кто пароль уже доказал.
-- text + CHECK, а не enum-тип: добавить четвёртое состояние — это ALTER одного констрейнта в
-- обычной миграции, тогда как ALTER TYPE ... ADD VALUE нельзя использовать в той же
-- транзакции, где значение добавлено (PG16), и enum тянет за собой отдельный тип в дампах.
-- Enum-типов в репозитории нет вовсе — не заводим первый ради трёх значений.
--
-- WHAT:
-- 1. role — text NOT NULL + CHECK ('admin','manager','employee'). Тип, набор значений
-- и отсутствие DEFAULT — зеркало tradein_users.role (м.192:42).
-- 2. manager_id — self-FK ON DELETE SET NULL + иерархический CHECK + запрет self-manager +
-- partial index. Зеркало м.192:43/50-52/84-86, чтобы код «Меры» переехал на
-- auth.users без правок.
-- 3. access_state — text NOT NULL DEFAULT 'active' + CHECK на три значения; backfill из
-- is_active, точечный перевод user2 («Брусника») в trial_expired, затем
-- DROP COLUMN is_active.
-- 4. Гранты auth_app — INSERT на users (DELETE НЕ выдаётся) + сужение табличного UPDATE (002:80) до
-- column-level: новые колонки role/access_state не должны попасть под него
-- молча.
--
-- IDEMPOTENCY:
-- ADD COLUMN IF NOT EXISTS / DROP COLUMN IF EXISTS / CREATE INDEX IF NOT EXISTS; констрейнты —
-- через DO-блок с проверкой pg_constraint (в PostgreSQL нет ADD CONSTRAINT IF NOT EXISTS для
-- CHECK/FK, паттерн из м.193:80-90); GRANT идемпотентен по определению; UPDATE-backfill'ы
-- отфильтрованы так, что второй прогон не находит строк (детали у каждого блока).
-- Проверка pg_constraint здесь фильтрует ДОПОЛНИТЕЛЬНО по conrelid (в отличие от м.193, где
-- только conname): имена констрейнтов уникальны в пределах таблицы, а не БД — одноимённый
-- констрейнт на соседней таблице заставил бы миграцию молча пропустить создание своего.
--
-- ⚠️ ПОСЛЕ 004 ФАЙЛЫ 001 И 003 БОЛЬШЕ НЕ ПЕРЕИГРЫВАЮТСЯ ПООТДЕЛЬНОСТИ.
-- Обе ссылаются на колонку is_active, которой после этой миграции нет, и обе падают на уже
-- мигрированной БД с «column is_active does not exist»:
-- * 001 — на `COMMENT ON COLUMN users.is_active` (001:75). CREATE TABLE IF NOT EXISTS
-- пропускается, а COMMENT выполняется всегда — то есть ручной `psql -f 001` падает
-- РАНЬШЕ 003, вопреки интуиции «ломается только сид».
-- * 003 — на INSERT со списком колонок, включающим is_active (а если бы и не упал —
-- role NOT NULL без DEFAULT не даст вставить строку).
-- Это следствие требования «применённые миграции не правим», а не регресс. Поддерживаемый
-- сценарий восстановления — прогон каталога ЦЕЛИКОМ по возрастанию номеров (001→002→003→004)
-- на пустой БД; он рабочий, порядок гарантирован сортировкой имён в deploy.yml. Нужно добить
-- сид на живой БД — пиши новый файл 00N, не переигрывай 003.
--
-- Dependencies: 001_identity_schema.sql (users), 002_auth_app_role.sql (роль auth_app — гранты
-- Части 4 её предполагают), 003_users_seed.sql (13 строк, которым backfill проставляет role).
-- Deploy order: применяется на прод авто-циклом deploy.yml по data/sql/auth/*.sql. Python-кода в
-- этом PR нет и поведение прода не меняется — в БД `auth` пока никто не ходит; код логина,
-- чтение role/access_state и удаление tradein_users — отдельные PR'ы ПОСЛЕ (см.
-- .claude/rules/sql.md «Migration order»: схема первой).
BEGIN;
-- ---------------------------------------------------------------------------------------------
-- Часть 1: role
-- ---------------------------------------------------------------------------------------------
-- DEFAULT сознательно НЕТ (как в м.192): роль — осознанное решение того, кто заводит человека.
-- С дефолтом INSERT, забывший указать роль, тихо создал бы работающий аккаунт с полномочиями
-- «по умолчанию»; без дефолта он падает на NOT NULL — это и есть нужное поведение.
-- Колонка добавляется NULLable, заполняется backfill'ом ниже и только потом получает NOT NULL:
-- прямой ADD COLUMN ... NOT NULL без DEFAULT упал бы на 13 уже существующих строках сида.
ALTER TABLE users ADD COLUMN IF NOT EXISTS role text;
-- Backfill. Источник истины — м.193:101-113 (org-карта владельца продукта от 2026-07-30),
-- сверено построчно по файлу, не по памяти. Роли не являются секретом: они уже лежат в git
-- (м.193 и auth/roles.yaml) — запрет на git касается паролей и хешей, не полномочий.
-- `role IS NULL` в каждом WHERE даёт сразу две вещи: идемпотентность (второй прогон не находит
-- строк) и защиту от отката ручных решений — повышение сотрудника до manager, сделанное после
-- первого прогона, повторным применением файла не вернётся к seed-значению.
UPDATE users SET role = 'admin' WHERE role IS NULL AND username = 'admin';
UPDATE users SET role = 'manager' WHERE role IS NULL AND username IN ('kopylov', 'praktika');
-- Catch-all — ПОСЛЕДНИМ и именно employee: любая строка, попавшая в auth.users мимо сида
-- (ручная вставка, восстановление из дампа, будущий аккаунт), получает НАИМЕНЕЕ
-- привилегированную роль. Fail-safe: ошибка в этом месте не должна раздавать admin.
UPDATE users SET role = 'employee' WHERE role IS NULL;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'users_role_ck' AND conrelid = 'users'::regclass
) THEN
ALTER TABLE users
ADD CONSTRAINT users_role_ck CHECK (role IN ('admin', 'manager', 'employee'));
END IF;
END $$;
-- SET NOT NULL идемпотентен (на уже NOT NULL колонке — no-op) и стоит ПОСЛЕ backfill: на строке
-- с NULL он упал бы, а catch-all выше гарантирует, что таких строк не осталось.
ALTER TABLE users ALTER COLUMN role SET NOT NULL;
-- ---------------------------------------------------------------------------------------------
-- Часть 2: manager_id (org-иерархия)
-- ---------------------------------------------------------------------------------------------
-- FK и CHECK объявлены ОТДЕЛЬНЫМИ шагами, а не inline в ADD COLUMN (как в м.192, где это было
-- частью CREATE TABLE IF NOT EXISTS — «всё или ничего»). Причина: `ADD COLUMN IF NOT EXISTS ...
-- REFERENCES ...` пропускает ВЕСЬ оператор, если колонка уже есть, — на БД, где manager_id
-- когда-то завели руками без FK, миграция отчиталась бы об успехе и оставила связь без
-- ссылочной целостности. Раздельные идемпотентные шаги такого состояния не допускают.
-- Имя FK задано явно тем же, которое сгенерировал бы PostgreSQL для inline-формы, — чтобы схема
-- на проде и схема из чистой сборки не различались именами констрейнтов.
ALTER TABLE users ADD COLUMN IF NOT EXISTS manager_id bigint;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'users_manager_id_fkey' AND conrelid = 'users'::regclass
) THEN
-- ON DELETE SET NULL (зеркало м.192:43): удаление менеджера не должно каскадом сносить
-- его сотрудников — они остаются в реестре без привязки, и это чинится назначением
-- нового менеджера, а не восстановлением строк из бэкапа.
ALTER TABLE users
ADD CONSTRAINT users_manager_id_fkey
FOREIGN KEY (manager_id) REFERENCES users(id) ON DELETE SET NULL;
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'users_role_manager_hierarchy_ck' AND conrelid = 'users'::regclass
) THEN
ALTER TABLE users
ADD CONSTRAINT users_role_manager_hierarchy_ck CHECK (
role NOT IN ('admin', 'manager') OR manager_id IS NULL
);
END IF;
END $$;
-- Запрет self-manager. users_role_manager_hierarchy_ck выше держит только admin/manager; для
-- employee self-FK допускает ссылку строки на саму себя, и `UPDATE users SET manager_id = id`
-- прошёл бы. Через сегодняшний API это недостижимо (team.py:398-406 требует role='manager' у
-- цели, PATCH manager_id вообще не меняет), но 004 делает auth.users ЕДИНСТВЕННЫМ реестром — в
-- него начнёт писать и «Птица», у которой этой валидации нет, а любой будущий WITH RECURSIVE по
-- manager_id на такой строке зациклится. Строчный CHECK ловит самый вероятный случай (опечатка
-- или копипаста собственного id) и стоит ноль.
-- Чего этот констрейнт НЕ ловит: взаимную пару employee↔employee (A.manager_id=B,
-- B.manager_id=A) и ссылку на строку с role<>'manager' — оба требуют чтения ДРУГОЙ строки,
-- строчным CHECK'ом это не выражается (нужен триггер или FK на несуществующий уникальный ключ
-- (id, role)). Инвариант зафиксирован COMMENT'ом к колонке — он живёт в приложении.
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'users_manager_not_self_ck' AND conrelid = 'users'::regclass
) THEN
ALTER TABLE users
ADD CONSTRAINT users_manager_not_self_ck CHECK (
manager_id IS NULL OR manager_id <> id
);
END IF;
END $$;
-- Partial index (зеркало м.192:84-86): у admin/manager и у свободных слотов manager_id = NULL,
-- и эти строки никогда не участвуют в выборке «сотрудники этого менеджера». Индексировать NULL'ы
-- значит платить за большую часть таблицы, которая по этому пути не читается.
CREATE INDEX IF NOT EXISTS users_manager_id_idx
ON users (manager_id)
WHERE manager_id IS NOT NULL;
-- ---------------------------------------------------------------------------------------------
-- Часть 3: access_state вместо is_active
-- ---------------------------------------------------------------------------------------------
-- DEFAULT 'active' здесь, в отличие от role, уместен: «доступ есть» — это состояние, в котором
-- заводят любого нового сотрудника, и молчаливый дефолт не расширяет ничьих полномочий.
ALTER TABLE users ADD COLUMN IF NOT EXISTS access_state text NOT NULL DEFAULT 'active';
-- CHECK ставится СРАЗУ после колонки, до backfill'а: тогда он проверяет и сам backfill —
-- опечатка в значении ниже уронит миграцию, а не просочится в данные.
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'users_access_state_ck' AND conrelid = 'users'::regclass
) THEN
ALTER TABLE users
ADD CONSTRAINT users_access_state_ck CHECK (
access_state IN ('active', 'trial_expired', 'disabled')
);
END IF;
END $$;
-- Backfill из is_active — под проверкой существования колонки, потому что в конце этого же
-- блока она удаляется: повторный прогон файла обязан пройти без ошибок, а прямое обращение к
-- несуществующей колонке — ошибка парсинга, не «0 строк».
-- EXECUTE (динамический SQL), а не обычные UPDATE внутри IF: обычные операторы уцелели бы лишь
-- благодаря ленивой подготовке операторов в PL/pgSQL (невыполненная ветка не разбирается). Это
-- рабочая, но недокументированная в самом файле деталь реализации; EXECUTE делает независимость
-- от отсутствующей колонки явной для читателя.
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_attribute
WHERE attrelid = 'users'::regclass
AND attname = 'is_active'
AND NOT attisdropped
) THEN
-- Механическое отображение старой семантики: булев «доступ закрыт» = жёсткая блокировка.
-- `access_state = 'active'` в WHERE — не мёртвое условие: оно фиксирует, что переписывается
-- только значение, доставшееся из DEFAULT, и никогда — уже осмысленно проставленное.
EXECUTE $q$
UPDATE users
SET access_state = 'disabled'
WHERE is_active = false
AND access_state = 'active'
$q$;
-- Точечно: user2 («Брусника», доступ закрыт владельцем 2026-07-30) — не disabled, а
-- trial_expired. Основание: в auth/roles.yaml у него role=expired, то есть исторически он
-- видит trial-экран, а не отказ входа; решение владельца от 2026-07-31 эту семантику
-- сохраняет.
-- Условие `access_state = 'disabled'` — это защита от затирания ручного решения:
-- переводится РОВНО то значение, которое механическая ветка выше только что и вывела.
-- Если к моменту повторного прогона владелец уже открыл «Бруснике» доступ (active) или
-- перевёл её в другое состояние, WHERE не сматчится и решение человека переживёт миграцию.
-- Безусловный UPDATE по username возвращал бы аккаунт в trial_expired после каждого
-- прогона, и разбор «почему у клиента снова экран пробного периода» стоил бы часов при
-- нулевой пользе. Хардкод одного username оправдан: это разовая фиксация конкретного
-- исторического факта, а не правило — общего признака «пробный доступ» в схеме до сих пор
-- не было, выводить его задним числом не из чего.
EXECUTE $q$
UPDATE users
SET access_state = 'trial_expired'
WHERE username = 'user2'
AND access_state = 'disabled'
$q$;
END IF;
END $$;
-- Снятие is_active. Деструктивный шаг — но именно он и есть смысл решения: оставить обе колонки
-- значило бы два источника правды о доступе, расходящихся при первой же правке через UI.
-- Безопасно: на момент этого PR БД `auth` не читается ни одним работающим кодом (Caddy basic_auth
-- + tradein_users по-прежнему обслуживают прод), а данные колонки полностью перенесены выше.
-- DROP обязан жить именно здесь, а не в 003: 003 применён на проде и правке не подлежит.
ALTER TABLE users DROP COLUMN IF EXISTS is_active;
-- ---------------------------------------------------------------------------------------------
-- Часть 4: гранты auth_app под режим единственного реестра (отмена решения 002:22-33)
-- + сужение унаследованного табличного UPDATE до column-level
-- ---------------------------------------------------------------------------------------------
-- 002 намеренно не выдавала INSERT/DELETE на users, и её аргумент был верным для своего момента:
-- в PR-1 не существовало ни кода, ни UI создания аккаунтов, а грант «на будущее» — это открытая
-- операция, которой никто не пользуется и которую никто не тестирует. Аргумент перестаёт
-- применяться ровно сейчас: после полного переезда auth.users — единственный реестр людей, а
-- раздел «Команда» «Меры» (tradein-mvp/backend/app/api/v1/team.py: POST /employees заводит
-- сотрудника, PATCH правит) — единственный интерфейс, которым сотрудника заводят и убирают.
-- Без INSERT переезд физически не состоится: сегодняшний INSERT идёт в tradein_users, а её не
-- станет.
-- DELETE здесь НЕ выдаётся, хотя первая редакция этой миграции его содержала. Причина отказа:
-- DELETE-эндпоинта в team.py нет (только POST /employees и PATCH — проверено), то есть потребителя
-- у права нет ни одного, а 002:22-33 отклоняла ровно такие гранты-на-будущее. Симметричный
-- контраргумент («снять неиспользуемое право дешевле, чем добавлять его в момент релиза») здесь не
-- перевешивает: DELETE по users каскадит на sessions (001:94), то есть цена ошибки в коде выше
-- обычной, а добавить строку GRANT в миграцию того PR, где появится DELETE-хендлер, стоит ровно
-- столько же. Право выдаётся вместе с кодом, который им пользуется, — не раньше.
-- DELETE ≠ закрытие доступа. Закрытие — это access_state ('disabled' / 'trial_expired'):
-- обратимо, сохраняет строку и историю. Именно оно, а не удаление строки, закрывает сегодняшний
-- сценарий «Команды»; удаление понадобилось бы только чтобы убрать ошибочно заведённый слот.
GRANT INSERT ON users TO auth_app;
-- Гранта на последовательность users_id_seq здесь НЕТ — и это не забывчивость.
-- 002:26-27 записала как факт, что «идентичность требует nextval», то есть INSERT из auth_app
-- якобы упадёт с «permission denied for sequence» без USAGE на последовательности. Для
-- `GENERATED ALWAYS AS IDENTITY` (001:53) это неверно: PostgreSQL подставляет не вызов
-- nextval('...'), а узел NextValueExpr, который дёргает nextval_internal(seqid,
-- check_permissions := false) — ACL последовательности не проверяется вовсе. Это документированное
-- отличие identity от serial, и оно проверено живьём на postgres:16, а не выведено из
-- документации: после `REVOKE ALL ON SEQUENCE users_id_seq FROM app` INSERT в identity-таблицу
-- прошёл и вернул id, тогда как в контрольной таблице с bigserial тот же INSERT в тех же
-- условиях упал ровно с «permission denied for sequence».
-- Отсюда два следствия. Первое: грант не нужен — он выдал бы auth_app право звать
-- nextval('users_id_seq') напрямую (жечь идентификаторы) и читать last_value (число заведённых
-- аккаунтов), при том что ни один путь кода этого не делает; это прямо противоречило бы
-- REVOKE ALL ON ALL SEQUENCES из 002:73. Второе: «живая проверка» вида «auth_app сделал INSERT,
-- значит грант рабочий» ничего не доказывает — тот же INSERT проходит и после REVOKE, поэтому
-- проверять надо обратное (REVOKE, затем INSERT).
-- Если users.id когда-нибудь переведут на обычный DEFAULT nextval(...) — грант станет
-- обязательным, и его придётся добавить той же миграцией, что меняет колонку.
-- Сужение UPDATE до column-level. 002:80 выдала ТАБЛИЧНЫЙ `GRANT SELECT, UPDATE ON users`,
-- обосновав его узко («смена пароля самим пользователем и проставление хеша админом»), — но
-- табличный UPDATE автоматически распространяется на любые колонки, добавленные позже. Не сузь
-- мы его здесь, auth_app молча получил бы право писать role и access_state, и периметр 002
-- расширился бы ровно тем, что 004 добавила, без единой строки GRANT.
-- Почему это важно именно для этих двух колонок: любая SQL-инъекция или логическая ошибка в
-- UPDATE-эндпоинте (сегодня такой ровно один — team.py PATCH /employees, COALESCE-список полей
-- по WHERE id = :id) из «испортил профиль» превращалась бы в `SET role='admin' WHERE id=<свой>`
-- или `SET access_state='active' WHERE username='user2'` — тихое повышение до админа и тихое
-- снятие блокировки, без смены пароля, то есть без внешнего признака компрометации. Это ровно
-- тот класс, ради которого 002 и заводила отдельную роль (002:5-6).
-- role в список НЕ включена сознательно: сегодня её не пишет никто (team.py POST вставляет
-- литерал 'employee', PATCH в SET-списке role/manager_id не имеет вовсе). Появится админский
-- путь смены роли — добавится одной строкой новой миграции; это дешевле, чем держать открытым
-- право на эскалацию привилегий «на всякий случай».
-- manager_id по той же причине не включён: назначение сотрудника менеджеру сегодня делается
-- только при создании (INSERT), а не UPDATE'ом.
-- access_state включён — блокировка/разблокировка через «Команду» (сегодняшний
-- `is_active = COALESCE(...)` в PATCH) переезжает именно в эту колонку.
-- REVOKE перед GRANT обязателен и идемпотентен: REVOKE табличной привилегии снимает и
-- колоночные, поэтому повторный прогон файла даёт то же состояние (внутри одной транзакции,
-- то есть без окна «прав нет» для работающего приложения).
REVOKE UPDATE ON users FROM auth_app;
GRANT UPDATE (password_hash, display_name, org_name, email, access_state, updated_at)
ON users TO auth_app;
-- ---------------------------------------------------------------------------------------------
-- COMMENT'ы: переписываем то, что 004 сделала неверным в 001
-- ---------------------------------------------------------------------------------------------
COMMENT ON TABLE users IS
'Единый реестр людей для «Меры» (trade-in) и «Птицы» (Site Finder): идентичность И '
'полномочия. Решение владельца продукта 2026-07-31 — ПОЛНЫЙ переезд: tradein_users '
'удаляется, второго реестра не будет. Прежняя формулировка («роли остаются в продуктовых '
'БД», 001) отменена миграцией 004 — см. её заголовок.';
COMMENT ON COLUMN users.role IS
'Полномочия: admin | manager | employee. Зеркало tradein_users.role (tradein м.192) — код '
'«Меры» должен переехать на эту таблицу без правок в проверках роли. DEFAULT намеренно нет: '
'роль выбирает тот, кто заводит человека; INSERT без роли обязан падать, а не создавать '
'аккаунт с полномочиями «по умолчанию».';
COMMENT ON COLUMN users.manager_id IS
'Self-FK на users(id), ON DELETE SET NULL: удаление менеджера оставляет его сотрудников в '
'реестре без привязки, а не сносит их каскадом. NULL для admin/manager (top-level роли, '
'констрейнт users_role_manager_hierarchy_ck) и для employee без организации. '
'ИНВАРИАНТЫ, КОТОРЫЕ БД НЕ ПРОВЕРЯЕТ (обязан держать КАЖДЫЙ пишущий сюда код — реестр общий '
'для «Меры» и «Птицы»): цель ссылки обязана иметь role = ''manager''; циклы (A→B, B→A) '
'запрещены — рекурсивный обход иерархии на них зациклится. Схемой ловится только ссылка '
'строки на саму себя (users_manager_not_self_ck): остальное требует чтения другой строки и '
'строчным CHECK не выражается. Отсутствие проверки в БД — не разрешение.';
COMMENT ON COLUMN users.access_state IS
'Состояние доступа, три значения — заменило булев is_active (миграция 004). '
'active: вход разрешён. '
'trial_expired: пробный период истёк — при ВЕРНОМ пароле логин отвечает 403 с отдельным '
'кодом и текстом «пробный доступ закончился», сессия не выдаётся (аккаунт видит осмысленный '
'экран, а не «неверный пароль»). '
'disabled: доступ закрыт — generic 401, неотличимо от неверного пароля. '
'Неверный пароль в любом состоянии → generic 401: иначе отдельный ответ для trial_expired '
'стал бы оракулом существования логина. Булев флаг схлопывал бы trial_expired и disabled в '
'одно значение, и trial-экран исчез бы молча. '
'ИНВАРИАНТ ДЛЯ API (в БД не выразим): перевод ПОСЛЕДНЕГО active-админа в любое другое '
'состояние обязан отклоняться на уровне приложения. Констрейнт с role не связан, '
'UPDATE ... SET access_state = ''disabled'' WHERE username = ''admin'' в БД проходит, а после '
'перехода на единую форму входа это self-lockout: не остаётся аккаунта, способного открыть '
'доступ обратно через UI, восстановление — только psql на прод-БД. Сегодня путь закрыт тем, '
'что «Команда» не отдаёт строки с role = ''admin'' никому (team.py); любой новый админский '
'экран, пишущий access_state, обязан проверку восстановить.';
COMMENT ON CONSTRAINT users_role_manager_hierarchy_ck ON users IS
'admin/manager обязаны иметь manager_id IS NULL — это top-level роли, «начальника» у них в '
'этой модели нет (зеркало tradein м.192). Для employee manager_id любой, включая NULL '
'(свободный слот без организации допустим).';
COMMENT ON CONSTRAINT users_manager_not_self_ck ON users IS
'Строка не может быть собственным менеджером (manager_id <> id). Ловит опечатку/копипасту '
'id при ручной правке и у второго потребителя реестра («Птица»), где валидации «Команды» '
'нет. Взаимные пары и ссылку на не-менеджера строчный CHECK не ловит — см. COMMENT к '
'users.manager_id.';
COMMENT ON CONSTRAINT users_access_state_ck ON users IS
'Фиксирует ровно три состояния доступа. Расширение — новой миграцией с ALTER этого '
'констрейнта; тип text + CHECK выбран вместо enum именно ради дешёвого расширения.';
COMMIT;

View file

@ -77,7 +77,6 @@
|---|---|---|
| `TRADEIN_POSTGRES_PASSWORD` / `TRADEIN_POSTGRES_USER` | Пароль/юзер БД `tradein` | **E** |
| `TRADEIN_READER_PASSWORD` | Пароль роли `gendesign_reader` (ETL #976, `ops/db-bootstrap/set_gendesign_reader_password.sql`) | **E** |
| `YANDEX_GEOCODER_API_KEY` | Yandex Geocoder (25k req/day) | **D** |
| `DADATA_API_TOKEN` / `DADATA_API_SECRET` | DaData `/clean/address` enrichment | **D** |
| `SCRAPER_PROXY_URL` (+ legacy `AVITO_PROXY_URL`, `CIAN_PROXY_URL`, `YANDEX_PROXY_URL` и их `*_ROTATE_URL`) | Мобильный прокси для скраперов (содержит user:pass в URL) | **G** (proxy creds) |
| `CIAN_LOGIN_EMAIL` / `CIAN_LOGIN_PASSWORD` | Cian browser auto-login (#639, Variant B) | **D** |
@ -147,14 +146,14 @@ bcrypt-хеши — односторонние, не plaintext-секреты,
3. Frontend: обновить `GLITCHTIP_FRONTEND_DSN` (build-arg `NEXT_PUBLIC_GLITCHTIP_DSN`) → требует **rebuild frontend образа** (запекается на build-time) → `workflow_dispatch` или push в `frontend/**`.
4. Vault entry.
### Класс D — 3rd-party API keys (`OBJECTIVE_API_KEY`, `OPENAI_API_KEY`, `YANDEX_GEOCODER_API_KEY`, `DADATA_*`, `CIAN_LOGIN_*`)
### Класс D — 3rd-party API keys (`OBJECTIVE_API_KEY`, `OPENAI_API_KEY`, `DADATA_*`, `CIAN_LOGIN_*`)
**Downtime:** нет (фичи gracefully degrade при пустом ключе — см. config-комментарии).
1. Перевыпустить/ротировать ключ в кабинете провайдера (Объектив / OpenAI / Yandex Cloud / DaData / Cian-аккаунт).
1. Перевыпустить/ротировать ключ в кабинете провайдера (Объектив / OpenAI / DaData / Cian-аккаунт).
2. Где живёт:
- `OBJECTIVE_API_KEY`, `OPENAI_API_KEY` — Forgejo secret → deploy пишет в main `.env.runtime`.
- `YANDEX_GEOCODER_API_KEY`, `DADATA_*`, `CIAN_LOGIN_*` — tradein `.env.runtime` (правится **на VPS вручную**, не из CI).
- `DADATA_*`, `CIAN_LOGIN_*` — tradein `.env.runtime` (правится **на VPS вручную**, не из CI).
3. Обновить значение `sed`-ом (НЕ перезапись файла) и `up -d --force-recreate --no-deps backend worker beat` (main) / `... backend scraper` (tradein).
4. Vault entry.

View file

@ -2,9 +2,12 @@
# Регресс-тест публичного B2C-периметра МЕРА (ЭТАП 1 плана B2C-запуска).
#
# Проверяет инварианты периметра (см. корневой Caddyfile):
# 1. meraocenka.ru отдаёт 200 анонимно (публичная заглушка).
# 2. meraocenka.ru/v2 (B2B-путь) отдаёт 404 — allowlist-by-default,
# НЕ был случайно проброшен на B2B-дерево tradein-frontend.
# 1. meraocenka.ru отдаёт 200 анонимно (публичный лэндинг).
# 1b. Подстраница лэндинга /trade-in/mera-public/privacy отдаёт 200 —
# политика ПДн, на которую ссылается футер.
# 2. meraocenka.ru/v2 и /trade-in/v2, /trade-in/api/* (B2B-пути) отдают 404 —
# allowlist-by-default, НЕ были случайно проброшены на B2B-дерево
# tradein-frontend. Проверяются обе формы — с basePath-префиксом и без.
# 3. trade-in API (/me, /history, /admin/*) отдаёт 401 анониму — данные B2B
# закрыты. Именно API, а не страница: см. комментарий у проверки ниже.
# 4. gendsgn.ru/api/v1/admin/* отдаёт 401 анониму (gate Site Finder).
@ -43,9 +46,22 @@ echo "== МЕРА B2C perimeter smoke (ЭТАП 1) =="
# 1. Публичный домен отдаёт 200 анонимно.
check "meraocenka.ru root — public 200" "$BASE_MERA/" 200
# 1b. Подстраница лэндинга (политика ПДн) доступна — на неё ссылается футер.
# Путь приезжает с basePath: next/link + basePath=/trade-in эмитит именно
# /trade-in/mera-public/privacy. Если этот handle выпадет из Caddyfile,
# обязательный по 152-ФЗ документ станет недоступен с публичной страницы.
check "meraocenka.ru privacy — public 200" "$BASE_MERA/trade-in/mera-public/privacy" 200
# 2. B2B-путь на публичном домене — 404 (allowlist-by-default), не 200/401.
check "meraocenka.ru/v2 — B2B path must 404" "$BASE_MERA/v2" 404
# 2b. Те же B2B-пути в basePath-форме — 404. Это регресс-тест именно на
# matcher `handle /trade-in/mera-public/*`: расширь его случайно до
# `/trade-in/*` — и B2B-дерево уедет наружу через публичный домен, а
# проверка 2 (/v2 без префикса) этого НЕ заметит.
check "meraocenka.ru/trade-in/v2 — B2B path must 404" "$BASE_MERA/trade-in/v2" 404
check "meraocenka.ru/trade-in/api/* — must 404 (не проксируем API)" "$BASE_MERA/trade-in/api/v1/me" 404
# 3. B2B-данные trade-in по-прежнему закрыты анониму.
#
# ВНИМАНИЕ: проверять СТРАНИЦУ (/trade-in/v2) больше нельзя — она отдаёт 200.

View file

@ -6,12 +6,6 @@ DATABASE_URL=postgresql+psycopg://tradein:tradein@postgres:5432/tradein
CORS_ORIGINS=["http://localhost:8080","http://localhost:3000"]
ENVIRONMENT=dev
# Yandex Geocoder API key (25k req/day free tier).
# Required for backfill scripts (scripts/backfill_house_coords.py + audit_address_mismatch.py).
# Empty = Nominatim fallback для backend геокодинга; backfill scripts требуют этот ключ
# и упадут с SystemExit без него.
YANDEX_GEOCODER_API_KEY=
# DaData /clean/address — обогащение target адреса в estimate flow (PR Q1).
# Возвращает canonical-форму, kadastr_num, ФИАС, координаты, ближайшее метро.
# Demo tier: 100 req/день — хватит для тестов и low-traffic prod.

View file

@ -57,8 +57,7 @@ import /opt/gendesign/tradein-mvp/deploy/Caddyfile.tradein-fragment
shell-скриптом deploy через `source .env.runtime` перед `compose up`.
2. `/opt/gendesign/tradein-mvp/backend/.env.runtime` — переменные внутри
контейнера `tradein-backend` (читаются через `env_file:` в compose). Сюда
попадают `YANDEX_GEOCODER_API_KEY`, `COOKIE_ENCRYPTION_KEY`
всё, что нужно scripts/backfill_house_coords.py и application code внутри
попадают `COOKIE_ENCRYPTION_KEY` и остальные application-секреты внутри
контейнера.
```bash
@ -66,7 +65,6 @@ import /opt/gendesign/tradein-mvp/deploy/Caddyfile.tradein-fragment
TRADEIN_POSTGRES_USER=tradein
TRADEIN_POSTGRES_PASSWORD=<сгенерировать openssl rand -hex 32>
TRADEIN_CONTACT_EMAIL=tradein@gendsgn.ru
YANDEX_GEOCODER_API_KEY= # пусто пока, Nominatim fallback работает
# Encryption key for Cian session cookies (pgp_sym_encrypt / Stage 9 Calculator).
# Empty = Valuation Calculator scraper disabled + /api/v1/cookies/upload returns 503.
@ -77,10 +75,9 @@ COOKIE_ENCRYPTION_KEY=<64-char hex>
```bash
# /opt/gendesign/tradein-mvp/backend/.env.runtime — те же ключи которые
# читаются ВНУТРИ container'а (scripts/backfill_house_coords.py, app/*).
# читаются ВНУТРИ container'а (app/*, scripts/*.py).
# Может быть симлинком на ../.env.runtime если переменные совпадают:
# ln -s ../.env.runtime /opt/gendesign/tradein-mvp/backend/.env.runtime
YANDEX_GEOCODER_API_KEY=<key или пусто>
COOKIE_ENCRYPTION_KEY=<64-char hex>
GENDESIGN_FDW_PASSWORD=<password или пусто>
GLITCHTIP_DSN=<dsn или пусто>
@ -200,7 +197,6 @@ cat > tradein-mvp/.env.runtime <<EOF
TRADEIN_POSTGRES_USER=tradein
TRADEIN_POSTGRES_PASSWORD=$(openssl rand -hex 32)
TRADEIN_CONTACT_EMAIL=tradein@gendsgn.ru
YANDEX_GEOCODER_API_KEY=
EOF
chmod 600 tradein-mvp/.env.runtime

View file

@ -70,6 +70,7 @@ from app.core.db import SessionLocal, get_db
from app.schemas.trade_in import ScheduleConfig, ScheduleConfigUpdate
from app.services import cian_session as cian_session_svc
from app.services import domclick_session as domclick_session_svc
from app.services import proxy_rotation as proxy_rotation_svc
from app.services import scrape_runs as runs_mod
from app.services.geocoder import geocode
from app.services.scheduler import has_running_run
@ -276,7 +277,7 @@ async def geocode_missing(
db.execute(
text(
f"""
SELECT id, address
SELECT id, address, city
FROM {target}
WHERE lat IS NULL
AND COALESCE(address, '') != ''
@ -310,7 +311,13 @@ async def geocode_missing(
)
break
clean = _clean_address_for_geocode(row["address"])
result = await geocode(clean, db)
# city (#2594 шаг 2/3) — известен вызывающему коду через listings.city
# (миграция 196) / deals.city (миграция 177), проставляется из контекста
# развёртки/импорта. Прокидываем как city_hint, а не полагаемся на то, что
# геокодер угадает город по тексту address (голый "ул. Победы, 30" без
# города в тексте иначе уходит в Екатеринбург).
city = row.get("city")
result = await geocode(clean, db, city_hint=city)
if result is None:
# Помечаем что пробовали — иначе ретрай на каждом cron.
db.execute(
@ -2883,3 +2890,44 @@ def patch_proxy(
created_at=_iso(row["created_at"]),
updated_at=_iso(row["updated_at"]),
)
# ── Proxy pool: ручная ротация exit-IP по proxy_id (#2600 п.5) ───────────────
#
# ОТДЕЛЬНО от /scraper/{source}/rotate-ip (выше) — тот работает по env-прокси
# mobileproxy для avito/cian/yandex (changeip-ссылка, ротация "на лету" без
# лимитов), не трогается. Этот эндпоинт — по proxy_id из пула scrape_proxies
# (сейчас это ASocks-порты с суточным лимитом 3/сутки), см.
# app.services.proxy_rotation.rotate_proxy.
class ProxyRotateResponse(BaseModel):
ok: bool
reason: str | None = None
new_ip: str | None = None
rotations_remaining_today: int
@router.post("/proxies/{proxy_id}/rotate", response_model=ProxyRotateResponse)
async def rotate_pool_proxy(
proxy_id: int,
db: Annotated[Session, Depends(get_db)],
) -> ProxyRotateResponse:
"""Ручная ротация exit-IP одного прокси пула (#2600 п.5).
Делегирует в app.services.proxy_rotation.rotate_proxy читает rotate_url
прокси из scrape_proxies, требует ASOCKS_API_TOKEN (settings.asocks_api_token),
проверяет суточный лимит (3/сутки, scrape_proxy_rotations) ДО обращения к API.
ok=False ожидаемая бизнес-ситуация (нет rotate_url / нет токена / лимит /
провайдер отказал), НЕ HTTPException; reason ВСЕГДА нейтральный, без токена.
ПОКА без автотриггера по бану (issue #2600 п.2: сигнал бана до пула не
доходит страница-заглушка отдаёт 200) только этот ручной вызов.
"""
result = await proxy_rotation_svc.rotate_proxy(db, proxy_id)
return ProxyRotateResponse(
ok=result.ok,
reason=result.reason,
new_ip=result.new_ip,
rotations_remaining_today=result.rotations_remaining_today,
)

View file

@ -6,9 +6,14 @@
это `/trade-in/api/v1/auth/*` снаружи.
Security:
- Неверные creds (неизвестный username / неактивен / password_hash NULL /
- Неверные creds (неизвестный username / доступ закрыт / password_hash NULL /
неверный пароль) ОДИНАКОВЫЙ 401 с generic сообщением не раскрываем,
существует ли username (user-enumeration защита).
- Состояние доступа проверяется ТОЛЬКО ПОСЛЕ проверки пароля, и осмысленный
ответ (403 «пробный доступ закончился») получает исключительно тот, кто
пароль уже доказал. Ветвление ДО пароля превратило бы отдельный статус в
оракул существования логина: перебором можно было бы перечислить аккаунты,
не зная ни одного пароля (миграция data/sql/auth/004, WHY-2).
- #2552 post-review Medium 2: `verify_password` ВСЕГДА вызывается ровно
один раз для несуществующего username / NULL password_hash сверяем
против статичного dummy-хеша (`_DUMMY_PASSWORD_HASH`, сгенерирован один
@ -38,10 +43,10 @@ from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.db import get_db
from app.core.password import hash_password, verify_password
from app.core.ratelimit import SlidingWindowLimiter, _client_ip
from app.services.auth_session import create_session, get_user_by_username, revoke_session
from app.services.identity_store import AccessState, get_identity_db
from app.services.user_events import schedule_event
logger = logging.getLogger(__name__)
@ -67,6 +72,16 @@ _DUMMY_PASSWORD_HASH = hash_password(secrets.token_urlsafe(16))
_INVALID_CREDENTIALS_DETAIL = "неверный логин или пароль"
# Единственный ответ логина, который НЕ generic 401: пароль верный, но пробный
# период истёк. `code` — машиночитаемый контракт для фронта (текст можно менять,
# ветку по нему — нет). Потребитель: `loginErrorMessage` в
# tradein-mvp/frontend/src/app/login/page.tsx — читает `detail.code` из
# `HTTPError.body` (frontend/src/lib/api.ts отдаёт тело ответа как есть) и
# показывает экран про пробный период вместо generic «Проверьте подключение».
# Меняешь значение здесь — меняй и там.
_ACCESS_EXPIRED_CODE = "access_expired"
_ACCESS_EXPIRED_MESSAGE = "Пробный доступ закончился"
class LoginRequest(BaseModel):
username: str
@ -82,7 +97,7 @@ async def login(
body: LoginRequest,
request: Request,
response: Response,
db: Annotated[Session, Depends(get_db)],
db: Annotated[Session, Depends(get_identity_db)],
) -> LoginResponse:
ip = _client_ip(request)
user_agent = request.headers.get("user-agent")
@ -105,9 +120,44 @@ async def login(
# ВСЕГДА вызывается — dummy-хеш при отсутствующем юзере/NULL password_hash
# держит время ответа одинаковым независимо от существования аккаунта.
password_ok = verify_password(body.password, hash_to_check)
credentials_ok = user is not None and user["is_active"] and password_ok
if not credentials_ok:
# Пароль проверен ВЫШЕ и безусловно — только теперь смотрим на состояние
# доступа. Порядок несущий, а не стилистический: см. модульный docstring.
if user is None or not password_ok:
schedule_event(
event_type="login_failed",
username=body.username,
ip=ip,
user_agent=user_agent,
path="/api/v1/auth/login",
method="POST",
)
raise HTTPException(status_code=401, detail=_INVALID_CREDENTIALS_DETAIL)
access_state = user["access_state"]
if access_state is AccessState.TRIAL_EXPIRED:
# Пароль верный, сессия НЕ создаётся. Единственный не-generic ответ:
# аккаунт существует и владелец это уже доказал паролем, так что
# осмысленный текст ничего не раскрывает постороннему.
# В режиме identity_store="tradein" эта ветка недостижима: булев
# is_active даёт только active/disabled (identity_store.to_access_state).
schedule_event(
event_type="login_blocked_expired",
username=user["username"],
ip=ip,
user_agent=user_agent,
path="/api/v1/auth/login",
method="POST",
)
raise HTTPException(
status_code=403,
detail={"code": _ACCESS_EXPIRED_CODE, "message": _ACCESS_EXPIRED_MESSAGE},
)
if not access_state.can_sign_in:
# disabled (и любое нераспознанное состояние — to_access_state fail-closed)
# → ТОТ ЖЕ generic 401 и то же событие, что при неверном пароле:
# заблокированный аккаунт неотличим от несуществующего.
schedule_event(
event_type="login_failed",
username=body.username,
@ -118,7 +168,6 @@ async def login(
)
raise HTTPException(status_code=401, detail=_INVALID_CREDENTIALS_DETAIL)
assert user is not None # narrowed by credentials_ok above
token = create_session(db, user_id=user["user_id"], ip=ip, user_agent=user_agent)
response.set_cookie(
@ -147,7 +196,7 @@ async def login(
async def logout(
request: Request,
response: Response,
db: Annotated[Session, Depends(get_db)],
db: Annotated[Session, Depends(get_identity_db)],
) -> dict[str, bool]:
token = request.cookies.get(settings.session_cookie_name)
if token:

View file

@ -9,10 +9,15 @@ Caddy basic_auth пропускает `X-Authenticated-User: <username>` чер
кому что показывать.
#2552: session-first. Валидная DB-session cookie (см. app.services.auth_session)
отдаёт scope из tradein_users (role/display_name/org/email) БЕЗ похода в
отдаёт scope из реестра людей (role/display_name/org/email) БЕЗ похода в
roles.yaml. Без cookie (или невалидная/истёкшая) legacy X-Authenticated-User
путь, БЕЗ ИЗМЕНЕНИЙ (regression недопустим существующие тесты держат его
бит-в-бит).
Сессия БД берётся у `identity_store.get_identity_db` (реестр), а не у
`app.core.db.get_db` (продуктовая БД): при `IDENTITY_STORE=auth` люди и сессии
живут в другой БД. В дефолтном режиме это ТОТ ЖЕ объект `Session`, что отдал бы
`get_db`, поведение прода не меняется.
"""
from __future__ import annotations
@ -25,8 +30,8 @@ from sqlalchemy.orm import Session
from app.core.auth import UserScope, get_user_scope
from app.core.config import settings
from app.core.db import get_db
from app.services.auth_session import get_db_role_scope, get_session_user
from app.services.identity_store import get_identity_db
logger = logging.getLogger(__name__)
@ -36,14 +41,15 @@ router = APIRouter()
@router.get("/me")
async def me(
request: Request,
db: Annotated[Session, Depends(get_db)],
db: Annotated[Session, Depends(get_identity_db)],
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
) -> UserScope | dict[str, Any]:
"""Return the current user's RBAC scope (role + allowed/deny paths).
Return type is a union (не только `UserScope`) `UserScope.role` это
`Literal["admin","pilot","analyst","expired"]` (legacy roles.yaml names),
а DB-роли (tradein_users.role) `"admin"/"manager"/"employee"`. FastAPI
а DB-роли (реестр: tradein_users.role / auth.users.role)
`"admin"/"manager"/"employee"`. FastAPI
строит response-схему из return-аннотации; жёсткий `UserScope` завернул бы
"employee"/"manager" в ResponseValidationError. Итоговая JSON-форма
ОДИНАКОВАЯ (те же 8 ключей) для обеих веток.

View file

@ -15,10 +15,35 @@ Mounted at `/api/v1/team`; через Caddy `uri strip_prefix /trade-in` это
- Роль должна быть `admin` или `manager` иначе 403.
Org-изоляция (главный инвариант фичи): manager видит/меняет ТОЛЬКО своих
employee (`tradein_users.manager_id = actor.user_id`). Чужой/несуществующий
employee (`<реестр>.manager_id = actor.user_id`). Чужой/несуществующий
employee_id 404 (НЕ 403) не подтверждаем/не опровергаем существование
чужого сотрудника перед manager'ом. См. `_authorize_employee`.
ДВЕ СЕССИИ БД, и это не дублирование:
- `identity_db` (`Depends(get_identity_db)`) реестр людей: строка сотрудника
и его сессии. При `IDENTITY_STORE=auth` это ДРУГАЯ БД (`auth`).
- `db` (`Depends(get_db)`) продуктовые таблицы «Меры», которые в общий
реестр не переезжают: `account_quota_overrides`, `account_estimate_usage`,
`user_events`, `trade_in_estimates`.
В дефолтном режиме (`IDENTITY_STORE=tradein`) это ОДИН И ТОТ ЖЕ объект `Session`
(см. `identity_store.get_identity_db`), поэтому всё по-прежнему коммитится одной
транзакцией прод не меняется. В режиме `auth` транзакции физически две:
порядок коммитов выбран так, чтобы при сбое второго коммита оставалось менее
вредное состояние (см. комментарии у `db.commit()`), а `db is not identity_db`
рантайм-признак «БД разные».
Гранты роли `auth_app` (data/sql/auth/004, Часть 4) этот роутер соблюдает без
обходов: он ПИШЕТ только `password_hash, display_name, org_name, email,
access_state, updated_at` (ровно column-level GRANT UPDATE), вставляет строку
целиком (табличный GRANT INSERT) и НИКОГДА не пишет `role`/`manager_id`
UPDATE'ом и не делает DELETE по `users`.
DELETE по `sessions` реестра штатный и грантом предусмотрен (data/sql/auth/002,
GRANT DELETE на sessions): блокировка и смена пароля обязаны рвать живые сессии
немедленно, это `revoke_user_sessions` из `app.services.auth_session`, вызываемый
из `update_employee`. То есть периметр DELETE у этого роутера ровно `sessions`
и ничего больше; грант DELETE на sessions не лишний.
Кого именно можно менять через этот роутер (`_MANAGEABLE_ROLES_BY_ACTOR`):
- actor manager только `role='employee'` И только своих (как было).
- actor admin `role IN ('employee','manager')`.
@ -51,6 +76,7 @@ from sqlalchemy import text
from sqlalchemy.engine import RowMapping
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from sqlalchemy.sql.elements import TextClause
from app.core.auth import get_role
from app.core.config import settings
@ -65,6 +91,14 @@ from app.schemas.team import (
)
from app.services import account_quota
from app.services.auth_session import get_session_user, revoke_user_sessions
from app.services.identity_store import (
AccessState,
IdentitySchema,
access_state_param,
get_identity_db,
identity_schema,
to_access_state,
)
from app.services.user_events import schedule_event
logger = logging.getLogger(__name__)
@ -83,18 +117,19 @@ class TeamActor:
async def current_team_actor(
request: Request,
db: Annotated[Session, Depends(get_db)],
identity_db: Annotated[Session, Depends(get_identity_db)],
) -> TeamActor:
"""Dependency: session-only identity, роль admin|manager, иначе 401/403.
Намеренно НЕ читает `X-Authenticated-User` см. модульный docstring.
Сессия резолвится в БД РЕЕСТРА (см. про две сессии в модульном docstring).
"""
token = request.cookies.get(settings.session_cookie_name)
if not token:
raise HTTPException(status_code=401, detail="valid session required")
try:
session_user = get_session_user(db, token)
session_user = get_session_user(identity_db, token)
except Exception:
logger.exception("team: session lookup failed")
raise HTTPException(status_code=401, detail="valid session required") from None
@ -159,30 +194,40 @@ def _require_same_origin(request: Request) -> None:
# ---------------------------------------------------------------------------
# Имена таблицы и колонки состояния доступа приходят из `identity_schema()` —
# фиксированный словарь в `app.services.identity_store`, единственный источник
# этих имён (в SQL-строку не попадает ничего пришедшего снаружи; значения
# по-прежнему биндятся параметрами).
#
# `AS access_state` в КАЖДОМ SELECT'е — не косметика: колонка называется
# по-разному в двух схемах, и без алиаса вызывающий код читал бы то `is_active`,
# то `access_state`, то есть завёл бы то самое второе представление состояния,
# которого быть не должно. Дальше значение всегда идёт через `to_access_state()`.
def _employee_columns(schema: IdentitySchema) -> str:
return (
"id, username, role, display_name, org_name, email, "
f"{schema.access_state_column} AS access_state, manager_id, created_at"
)
# Два статических варианта — НЕ динамическая сборка WHERE (та же мотивация, что
# у `_LIST_EMPLOYEES_*_SQL` ниже: значения и так биндятся параметрами, но
# у `_list_employees_sql` ниже: значения и так биндятся параметрами, но
# статические ветки не провоцируют будущие правки в сторону конкатенации SQL).
# Роль 'admin' не встречается ни в одной ветке — см. модульный docstring.
_FETCH_MANAGED_EMPLOYEE_SQL = text(
"""
SELECT id, username, role, display_name, org_name, email, is_active,
manager_id, created_at
FROM tradein_users
WHERE id = :id AND role = 'employee'
"""
)
_FETCH_MANAGED_ANY_SQL = text(
"""
SELECT id, username, role, display_name, org_name, email, is_active,
manager_id, created_at
FROM tradein_users
WHERE id = :id AND role IN ('employee', 'manager')
"""
)
def _fetch_employee_sql(actor_role: str) -> TextClause:
schema = identity_schema()
cols = _employee_columns(schema)
if actor_role == "admin":
return text(
f"SELECT {cols} FROM {schema.users_table} "
"WHERE id = :id AND role IN ('employee', 'manager')"
)
return text(f"SELECT {cols} FROM {schema.users_table} WHERE id = :id AND role = 'employee'")
def _fetch_employee_row(db: Session, employee_id: int, actor: TeamActor) -> RowMapping | None:
def _fetch_employee_row(
identity_db: Session, employee_id: int, actor: TeamActor
) -> RowMapping | None:
"""Строка управляемого юзера в пределах прав *actor* — иначе None (→ 404).
Фильтр по роли делается ЗДЕСЬ, в SQL, а не в `_authorize_employee` ниже:
@ -191,8 +236,8 @@ def _fetch_employee_row(db: Session, employee_id: int, actor: TeamActor) -> RowM
тебе не по зубам») тот же принцип, что и 404-вместо-403 в
`_authorize_employee`: не палим существование чужой строки.
"""
sql = _FETCH_MANAGED_ANY_SQL if actor.role == "admin" else _FETCH_MANAGED_EMPLOYEE_SQL
return db.execute(sql, {"id": employee_id}).mappings().fetchone()
sql = _fetch_employee_sql(actor.role)
return identity_db.execute(sql, {"id": employee_id}).mappings().fetchone()
def _authorize_employee(actor: TeamActor, row: RowMapping | None) -> RowMapping:
@ -343,6 +388,13 @@ def _batch_quota_status(db: Session, usernames: list[str]) -> dict[str, dict[str
def _employee_out(row: RowMapping, quota: dict[str, Any]) -> EmployeeOut:
"""Строка реестра → ответ API.
`is_active` в контракте API остаётся булевым (форма ответа не меняется
фронт «Команды» не трогаем этим PR), и считается он ровно как «пустят ли
входить»: `trial_expired` показывается как заблокированный. Отдельное
отображение пробного периода в «Команде» вопрос UI-PR'а, не этого.
"""
return EmployeeOut(
id=row["id"],
username=row["username"],
@ -350,7 +402,7 @@ def _employee_out(row: RowMapping, quota: dict[str, Any]) -> EmployeeOut:
display_name=row["display_name"],
org_name=row["org_name"],
email=row["email"],
is_active=row["is_active"],
is_active=to_access_state(row["access_state"]).can_sign_in,
manager_id=row["manager_id"],
created_at=row["created_at"],
quota=QuotaStatusOut(**quota),
@ -367,6 +419,7 @@ async def create_employee(
body: EmployeeCreateRequest,
actor: Annotated[TeamActor, Depends(current_team_actor)],
db: Annotated[Session, Depends(get_db)],
identity_db: Annotated[Session, Depends(get_identity_db)],
_origin_check: Annotated[None, Depends(_require_same_origin)],
) -> EmployeeOut:
"""Создать сотрудника. Роль всегда `employee`.
@ -375,9 +428,13 @@ async def create_employee(
значение из тела ИГНОРИРУЕТСЯ, org-изоляция инвариант #2554). Для
actor.role == admin опционально из тела, валидируется что указанный id
существует и role='manager' (иначе 422).
`identity_db` реестр (строка сотрудника), `db` продуктовая квота;
в дефолтном режиме это одна и та же сессия и одна транзакция.
"""
existing = db.execute(
text("SELECT id FROM tradein_users WHERE username = :u"),
schema = identity_schema()
existing = identity_db.execute(
text(f"SELECT id FROM {schema.users_table} WHERE username = :u"),
{"u": body.username},
).fetchone()
if existing is not None:
@ -396,8 +453,8 @@ async def create_employee(
else:
manager_id = body.manager_id
if manager_id is not None:
mgr = db.execute(
text("SELECT id FROM tradein_users WHERE id = :id AND role = 'manager'"),
mgr = identity_db.execute(
text(f"SELECT id FROM {schema.users_table} WHERE id = :id AND role = 'manager'"),
{"id": manager_id},
).fetchone()
if mgr is None:
@ -408,17 +465,16 @@ async def create_employee(
try:
row = (
db.execute(
identity_db.execute(
text(
"""
INSERT INTO tradein_users
f"""
INSERT INTO {schema.users_table}
(username, password_hash, role, manager_id, display_name, org_name,
email, is_active)
email, {schema.access_state_column})
VALUES
(:username, :password_hash, 'employee', :manager_id, :display_name,
:org_name, :email, true)
RETURNING id, username, role, display_name, org_name, email, is_active,
manager_id, created_at
:org_name, :email, :access_state)
RETURNING {_employee_columns(schema)}
"""
),
{
@ -428,6 +484,10 @@ async def create_employee(
"display_name": body.display_name,
"org_name": body.org_name,
"email": body.email,
# Новый сотрудник заводится с открытым доступом — как и
# раньше (`is_active = true` литералом). Литерала здесь
# больше нет: тип колонки разный, знает о нём identity_store.
"access_state": access_state_param(AccessState.ACTIVE),
},
)
.mappings()
@ -435,8 +495,8 @@ async def create_employee(
)
except IntegrityError:
# TOCTOU: два конкурентных POST с одинаковым username между pre-check
# выше и этим INSERT — UNIQUE-констрейнт на tradein_users.username ловит.
db.rollback()
# выше и этим INSERT — UNIQUE-констрейнт на username в реестре ловит.
identity_db.rollback()
raise HTTPException(status_code=409, detail="username already exists") from None
assert row is not None # RETURNING на успешный INSERT всегда отдаёт строку
@ -444,7 +504,15 @@ async def create_employee(
if body.monthly_limit is not None:
_upsert_quota_override(db, body.username, body.monthly_limit, actor.username)
db.commit()
# Реестр коммитится ПЕРВЫМ. В дефолтном режиме это один коммит на одну
# транзакцию (identity_db is db) — ровно как было. В режиме `auth` БД две,
# и порядок выбран по цене сбоя: не доехавшая квота — это сотрудник с
# глобальным лимитом (чинится повторным PATCH), тогда как не доехавшая
# строка сотрудника при уже сохранённой квоте — висящий override на
# несуществующего человека.
identity_db.commit()
if db is not identity_db:
db.commit()
schedule_event(
event_type="employee_created",
@ -471,6 +539,7 @@ async def update_employee(
body: EmployeeUpdateRequest,
actor: Annotated[TeamActor, Depends(current_team_actor)],
db: Annotated[Session, Depends(get_db)],
identity_db: Annotated[Session, Depends(get_identity_db)],
_origin_check: Annotated[None, Depends(_require_same_origin)],
) -> EmployeeOut:
"""Частичное обновление сотрудника — block/unblock, лимит, профиль, пароль.
@ -483,8 +552,14 @@ async def update_employee(
КАЖДОМ запросе, так что скомпрометированная/чужая сессия живёт неограниченно
долго, а не «до TTL». `revoke_user_sessions` сам называет смену пароля своим
use-case см. его докстринг.
`is_active` в теле остаётся булевым (контракт API не меняется): true
`active`, false `disabled`. Перевести аккаунт В `trial_expired` этим
роутом нельзя это состояние проставляется миграцией/владельцем, а
выразить его булевым полем нечем; is_active=true на таком аккаунте открывает
доступ (снимает пробное ограничение), is_active=false закрывает жёстко.
"""
row = _fetch_employee_row(db, employee_id, actor)
row = _fetch_employee_row(identity_db, employee_id, actor)
row = _authorize_employee(actor, row)
new_password_hash: str | None = None
@ -494,14 +569,27 @@ async def update_employee(
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e)) from None
db.execute(
schema = identity_schema()
# Пишутся РОВНО те колонки, на которые у auth_app есть column-level GRANT
# UPDATE (data/sql/auth/004, Часть 4): password_hash, display_name, org_name,
# email, access_state, updated_at. role и manager_id этим роутом не
# обновляются — не «пока не понадобилось», а сознательно: право на их запись
# роли приложения не выдано, и добавлять его в обход миграции нельзя.
#
# CAST обязателен из-за NULL-параметра (поле не пришло в PATCH → COALESCE
# оставляет текущее значение): у нетипизированного NULL Postgres не может
# вывести тип. Имя SQL-типа — из фиксированного словаря identity_store.
identity_db.execute(
text(
"""
UPDATE tradein_users
f"""
UPDATE {schema.users_table}
SET display_name = COALESCE(:display_name, display_name),
org_name = COALESCE(:org_name, org_name),
email = COALESCE(:email, email),
is_active = COALESCE(CAST(:is_active AS boolean), is_active),
{schema.access_state_column} = COALESCE(
CAST(:access_state AS {schema.access_state_sql_type}),
{schema.access_state_column}
),
password_hash = COALESCE(:password_hash, password_hash),
updated_at = now()
WHERE id = :id
@ -511,7 +599,13 @@ async def update_employee(
"display_name": body.display_name,
"org_name": body.org_name,
"email": body.email,
"is_active": body.is_active,
"access_state": (
None
if body.is_active is None
else access_state_param(
AccessState.ACTIVE if body.is_active else AccessState.DISABLED
)
),
"password_hash": new_password_hash,
"id": employee_id,
},
@ -523,13 +617,20 @@ async def update_employee(
if body.is_active is False or body.new_password is not None:
# Обязательно ПОСЛЕ UPDATE, ДО финального commit — revoke_user_sessions
# коммитит сам (см. app.services.auth_session), это флашит и наш
# предшествующий UPDATE/quota-upsert в той же сессии. Self-lockout
# предшествующий UPDATE (а в дефолтном режиме, где сессия одна, — и
# quota-upsert). Сессии живут в БД реестра, вместе с пользователем,
# поэтому рвём их через `identity_db`: с чужой сессией здесь блокировка
# и смена пароля перестали бы действовать немедленно. Self-lockout
# невозможен: _fetch_employee_row не отдаёт строки с role='admin'
# НИКОМУ, а manager'у — ещё и только role='employee'; т.е. actor
# (admin|manager) никогда не может патчить сам себя через этот роут.
revoke_user_sessions(db, employee_id)
revoke_user_sessions(identity_db, employee_id)
db.commit()
# Порядок и смысл — как в create_employee: реестр первым, продуктовая БД
# отдельным коммитом только если она физически другая.
identity_db.commit()
if db is not identity_db:
db.commit()
changed_profile_fields = [
f
@ -573,7 +674,7 @@ async def update_employee(
},
)
updated_row = _fetch_employee_row(db, employee_id, actor)
updated_row = _fetch_employee_row(identity_db, employee_id, actor)
assert updated_row is not None # только что успешно обновили эту же строку
quota = account_quota.get_status(db, updated_row["username"])
return _employee_out(updated_row, quota)
@ -596,50 +697,48 @@ async def update_employee(
# постраничном листании. `id` монотонно растёт (BIGINT IDENTITY) — детерминированный
# tie-break без доп. индекса (созданные позже = бОльший id, тот же порядок что и
# намерение DESC-сортировки по времени).
_LIST_EMPLOYEES_BY_MANAGER_SQL = text(
"""
SELECT id, username, role, display_name, org_name, email, is_active, manager_id, created_at
FROM tradein_users
WHERE role = 'employee' AND manager_id = :manager_id
ORDER BY created_at DESC, id DESC
LIMIT :limit OFFSET :offset
"""
)
# Admin-ветка: сюда попадают И менеджеры (см. модульный docstring — иначе admin
# не видит в UI строку, которой должен уметь сбросить пароль). `role='admin'`
# по-прежнему невидим и неуправляем. Сортировка по (created_at, id) общая для
# обеих ролей — намеренно: seed (#2557) вставил всех одной транзакцией, так что
# группировка «сначала менеджеры» дала бы ложное ощущение иерархии там, где её
# в данных нет; роль показывается колонкой (`EmployeeOut.role`).
_LIST_EMPLOYEES_ALL_SQL = text(
"""
SELECT id, username, role, display_name, org_name, email, is_active, manager_id, created_at
FROM tradein_users
WHERE role IN ('employee', 'manager')
ORDER BY created_at DESC, id DESC
LIMIT :limit OFFSET :offset
"""
)
#
# Admin-ветка (`by_manager=False`): сюда попадают И менеджеры (см. модульный
# docstring — иначе admin не видит в UI строку, которой должен уметь сбросить
# пароль). `role='admin'` по-прежнему невидим и неуправляем. Сортировка по
# (created_at, id) общая для обеих веток — намеренно: seed (#2557) вставил всех
# одной транзакцией, так что группировка «сначала менеджеры» дала бы ложное
# ощущение иерархии там, где её в данных нет; роль показывается колонкой
# (`EmployeeOut.role`).
def _list_employees_sql(*, by_manager: bool) -> TextClause:
schema = identity_schema()
cols = _employee_columns(schema)
tail = "ORDER BY created_at DESC, id DESC LIMIT :limit OFFSET :offset"
if by_manager:
return text(
f"SELECT {cols} FROM {schema.users_table} "
f"WHERE role = 'employee' AND manager_id = :manager_id {tail}"
)
return text(
f"SELECT {cols} FROM {schema.users_table} WHERE role IN ('employee', 'manager') {tail}"
)
@router.get("/employees", response_model=list[EmployeeOut])
async def list_employees(
actor: Annotated[TeamActor, Depends(current_team_actor)],
db: Annotated[Session, Depends(get_db)],
identity_db: Annotated[Session, Depends(get_identity_db)],
manager_id: Annotated[int | None, Query()] = None,
limit: Annotated[int, Query(ge=1, le=200)] = 50,
offset: Annotated[int, Query(ge=0)] = 0,
) -> list[EmployeeOut]:
"""Список сотрудников. manager видит только своих; admin — всех, опц. ?manager_id=.
Квота ОДИН батч-запрос на всю страницу (`_batch_quota_status`), не N+1
(Medium2, review PR #2563: было 2N+3 SQL-запросов на N сотрудников).
Сотрудники читаются из реестра (`identity_db`), квоты из продуктовой БД
(`db`): `account_quota_overrides`/`account_estimate_usage` в общий реестр не
переезжают. Квота ОДИН батч-запрос на всю страницу (`_batch_quota_status`),
не N+1 (Medium2, review PR #2563: было 2N+3 SQL-запросов на N сотрудников).
"""
if actor.role == "manager":
rows = (
db.execute(
_LIST_EMPLOYEES_BY_MANAGER_SQL,
identity_db.execute(
_list_employees_sql(by_manager=True),
{"manager_id": actor.user_id, "limit": limit, "offset": offset},
)
.mappings()
@ -647,8 +746,8 @@ async def list_employees(
)
elif manager_id is not None:
rows = (
db.execute(
_LIST_EMPLOYEES_BY_MANAGER_SQL,
identity_db.execute(
_list_employees_sql(by_manager=True),
{"manager_id": manager_id, "limit": limit, "offset": offset},
)
.mappings()
@ -656,7 +755,11 @@ async def list_employees(
)
else:
rows = (
db.execute(_LIST_EMPLOYEES_ALL_SQL, {"limit": limit, "offset": offset}).mappings().all()
identity_db.execute(
_list_employees_sql(by_manager=False), {"limit": limit, "offset": offset}
)
.mappings()
.all()
)
quota_by_username = _batch_quota_status(db, [row["username"] for row in rows])
@ -673,15 +776,17 @@ async def employee_history(
employee_id: int,
actor: Annotated[TeamActor, Depends(current_team_actor)],
db: Annotated[Session, Depends(get_db)],
identity_db: Annotated[Session, Depends(get_identity_db)],
limit: Annotated[int, Query(ge=1, le=200)] = 50,
offset: Annotated[int, Query(ge=0)] = 0,
) -> list[EmployeeHistoryEntry]:
"""История оценок сотрудника (адрес/дата/результат) — из `user_events`,
LEFT JOIN `trade_in_estimates` за фактическим результатом.
Та же org-проверка что и в PATCH: чужой employee_id 404.
Та же org-проверка что и в PATCH: чужой employee_id 404. Проверка идёт по
реестру (`identity_db`), сама история продуктовые таблицы (`db`).
"""
row = _fetch_employee_row(db, employee_id, actor)
row = _fetch_employee_row(identity_db, employee_id, actor)
row = _authorize_employee(actor, row)
rows = (

View file

@ -1831,7 +1831,7 @@ def get_sales_vs_listings(
Per-street view: Росреестр open dataset агрегирует адреса до улицы.
"""
from app.services.estimator import _percentile, extract_street_name
from app.services.estimator import _percentile, _resolve_target_city, extract_street_name
def _empty(reason_street: str | None = None) -> SalesVsListingsResponse:
return SalesVsListingsResponse(
@ -1852,6 +1852,15 @@ def get_sales_vs_listings(
logger.warning("sales-vs-listings: cannot extract street from %r", address)
return _empty()
# #2583 H4 city-scope (зеркало /street-deals #C1, trade_in.py:1717): без него
# street_pattern матчит одноимённые улицы ЛЮБОГО города обл.66 на ОБЕИХ сторонах
# JOIN (deals.address / listings.address хранят "<Город>, <Улица>") — прод-аудит
# показал 49% явно чужого города + 50% NULL-city listings для проверенных стритов,
# медианный discount_pct уезжал в -60%+ на смеси рынков. target_city резолвится тем
# же словарём (~30 городов обл.66), что и street-deals; None (адрес вне словаря,
# известная H1) → фильтр не применяется на TVF-стороне (см. миграцию 205).
target_city = _resolve_target_city(address)
rows = (
db.execute(
text(
@ -1868,7 +1877,8 @@ def get_sales_vs_listings(
CAST(:rooms AS integer),
CAST(:window_days AS integer),
CAST(:area_tolerance AS numeric),
CAST(:period_months AS integer)
CAST(:period_months AS integer),
CAST(:target_city AS text)
)
"""
),
@ -1879,6 +1889,7 @@ def get_sales_vs_listings(
"window_days": window_days,
"area_tolerance": area_tolerance,
"period_months": period_months,
"target_city": target_city,
},
)
.mappings()

View file

@ -0,0 +1,162 @@
"""Engine + session-factory для БД `auth` — общего реестра людей (эпик «единый вход»).
Отдельный модуль, а не ещё пара строк в `app.core.db`, ровно по одной причине:
`app.core.db` создаёт engine НА ИМПОРТЕ (`create_engine(settings.database_url)` в
теле модуля). Сделай мы так же для БД `auth` приложение начало бы падать на
старте везде, где реестр не сконфигурирован, а не сконфигурирован он сейчас
ВЕЗДЕ: на проде роль `auth_app` ещё без пароля, в тестах этой БД нет вовсе.
Здесь engine создаётся ЛЕНИВО, при первом реальном обращении.
Контракт ( после мержа прод обязан работать ТОЧНО как сейчас):
* `settings.identity_store == "tradein"` (дефолт) в этот модуль не заходит
никто: `app.services.identity_store` берёт сессию из `app.core.db`. Пустая
конфигурация БД `auth` при этом не ошибка ни на импорте, ни в рантайме; ни
одно соединение с БД `auth` не открывается.
* `settings.identity_store == "auth"` + не сконфигурированный реестр первое
же обращение поднимает `AuthDatabaseNotConfiguredError` с внятным текстом.
Именно исключение, а НЕ тихий откат на tradein-таблицы и не пустой результат:
молчаливая деградация auth-пути означала бы «пользователь не найден» вместо
«конфигурация сломана», то есть массовый отказ входа под видом неверных
паролей либо, в обратную сторону, анонимный доступ.
Сам DSN этот модуль НЕ выбирает и НЕ склеивает берёт готовый у
`settings.resolved_auth_database_url` (явный `AUTH_DATABASE_URL`, иначе сборка из
`AUTH_DB_PASSWORD` + частей хоста/порта/базы/пользователя, иначе пусто).
В DSN пароль роли `auth_app`. Он не логируется и не попадает в текст
исключений НИ В ОДНОЙ ветке этого модуля: сообщения ниже константы, а ошибку
разбора URL от SQLAlchemy (её текст содержит исходную строку) мы перехватываем и
заменяем своей, обрывая цепочку `from None`, чтобы исходник не всплыл в
traceback. Добавляешь сюда `logger`/`raise ... {dsn}` не добавляй.
`create_engine` сам по себе к серверу не ходит (connection pool ленивый), так что
даже после первого обращения реальный коннект открывается только на первом
запросе но ошибку конфигурации мы обязаны отдать раньше, чем это станет
похоже на сетевую проблему.
"""
from __future__ import annotations
import threading
from collections.abc import Iterator
from contextlib import contextmanager
from sqlalchemy import Engine, create_engine
from sqlalchemy.exc import ArgumentError
from sqlalchemy.orm import Session, sessionmaker
from app.core.config import settings
class AuthDatabaseNotConfiguredError(RuntimeError):
"""`IDENTITY_STORE=auth`, а DSN БД `auth` не задан/не разобрался."""
_NOT_CONFIGURED_MSG = (
"IDENTITY_STORE=auth, но реестр людей (БД `auth`) не сконфигурирован: пусты и "
"AUTH_DB_PASSWORD, и AUTH_DATABASE_URL — подключаться не к чему. Задай в "
".env.runtime AUTH_DB_PASSWORD (пароль роли auth_app; остальные части DSN — "
"AUTH_DB_HOST/AUTH_DB_PORT/AUTH_DB_NAME/AUTH_DB_USER — имеют прод-дефолты), "
"либо целиком AUTH_DATABASE_URL, либо верни IDENTITY_STORE=tradein (старое "
"поведение на tradein_users/tradein_sessions)."
)
# Текст для нечитаемого DSN. БЕЗ подстановки самого DSN — там пароль; исходную
# ошибку SQLAlchemy (она цитирует строку целиком) гасим `from None`.
_MALFORMED_DSN_MSG = (
"DSN БД `auth` не разобрался SQLAlchemy. Проверь AUTH_DATABASE_URL (если задан "
"явно) либо части AUTH_DB_HOST/AUTH_DB_PORT/AUTH_DB_NAME/AUTH_DB_USER. Схема "
"обязана быть postgresql+psycopg:// (psycopg v3). Сам DSN сюда намеренно НЕ "
"подставлен: в нём пароль роли auth_app."
)
# Кеш engine/factory + защита от гонки: rbac_guard резолвит сессию на каждом
# non-public запросе, а uvicorn обслуживает их из нескольких потоков (sync-роуты
# уходят в threadpool). Без лока два одновременных первых запроса создали бы два
# engine — то есть два независимых пула коннектов, один из которых потеряется.
_LOCK = threading.Lock()
_engine: Engine | None = None
_session_factory: sessionmaker[Session] | None = None
def _build() -> tuple[Engine, sessionmaker[Session]]:
"""Создаёт engine + session-factory по текущему DSN. Нет DSN → явная ошибка.
DSN резолвит `settings` (явный AUTH_DATABASE_URL или сборка из AUTH_DB_*)
здесь только «пусто или нет» и создание engine.
"""
dsn = settings.resolved_auth_database_url
if not dsn:
raise AuthDatabaseNotConfiguredError(_NOT_CONFIGURED_MSG)
try:
engine = create_engine(dsn, pool_pre_ping=True, future=True)
except (ArgumentError, ValueError):
# ValueError — не паранойя: на «почти URL» разбор SQLAlchemy доходит до
# `int(port)` и падает с `invalid literal for int() with base 10: 'w'`,
# где 'w' — КУСОК ПАРОЛЯ, съехавший на позицию порта. `from None`
# обязателен: он гасит цепочку, иначе исходная ошибка (а с ней и этот
# кусок) печатается в traceback как «During handling of...».
raise AuthDatabaseNotConfiguredError(_MALFORMED_DSN_MSG) from None
factory = sessionmaker(autocommit=False, autoflush=False, bind=engine, expire_on_commit=False)
return engine, factory
def _ensure_built() -> tuple[Engine, sessionmaker[Session]]:
global _engine, _session_factory
if _engine is not None and _session_factory is not None:
return _engine, _session_factory
with _LOCK:
if _engine is None or _session_factory is None:
_engine, _session_factory = _build()
return _engine, _session_factory
def get_auth_engine() -> Engine:
"""Engine БД `auth` (создаётся при первом вызове).
Raises:
AuthDatabaseNotConfiguredError: реестр не сконфигурирован (нет ни
AUTH_DATABASE_URL, ни AUTH_DB_PASSWORD) либо DSN не разобрался.
"""
engine, _ = _ensure_built()
return engine
def get_auth_session_factory() -> sessionmaker[Session]:
"""Session-factory БД `auth` (создаётся при первом вызове).
Raises:
AuthDatabaseNotConfiguredError: реестр не сконфигурирован (нет ни
AUTH_DATABASE_URL, ни AUTH_DB_PASSWORD) либо DSN не разобрался.
"""
_, factory = _ensure_built()
return factory
@contextmanager
def auth_session() -> Iterator[Session]:
"""Сессия к БД `auth`, закрывается на выходе из блока.
Прямой вызов из роутов/сервисов НЕ предполагается ходи через
`app.services.identity_store.identity_session()`, он один знает, какая БД
сейчас является реестром.
"""
factory = get_auth_session_factory()
with factory() as db:
yield db
def reset_auth_db() -> None:
"""Сбрасывает закешированные engine/factory (смена DSN в рантайме, тесты).
Старый engine `dispose()`-ится вне лока: закрытие пула может блокировать, а
держать в это время лок незачем ссылки на него уже сняты.
"""
global _engine, _session_factory
with _LOCK:
stale = _engine
_engine = None
_session_factory = None
if stale is not None:
stale.dispose()

View file

@ -1,10 +1,35 @@
"""Минимальный settings для standalone trade-in MVP."""
from typing import Literal
from urllib.parse import quote
from pydantic import Field
from pydantic import Field, SecretStr, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
# ── Дефолтные части DSN БД `auth` (общий реестр людей, эпик «единый вход») ──────
# Вынесены константами, потому что используются ДВАЖДЫ: как `Field(default=...)`
# и как запасное значение, если переменная окружения задана пустой строкой
# (`AUTH_DB_HOST=` в .env.runtime не должен давать DSN вида `...@:5432/auth`).
#
# ⚠️ ХОСТ — главная ловушка. Внутри стека «Меры» имя `postgres` резолвится в ЕЁ
# СОБСТВЕННЫЙ контейнер: tradein-mvp/docker-compose.prod.yml объявляет сервис
# `postgres` (container_name `tradein-postgres`, сети `tradein-net` +
# `gendesign_shared`) и собирает им продуктовый DATABASE_URL —
# `postgresql+psycopg://...@postgres:5432/tradein`. БД `auth` живёт НЕ там, а на
# постгресе главного стека: корневой docker-compose.prod.yml вешает своему
# сервису `postgres` в сети `shared` (external, name `gendesign_shared`) алиас
# `gendesign-postgres`. tradein-backend к `gendesign_shared` подписан, поэтому
# `gendesign-postgres:5432` из него резолвится, а `postgres:5432` увело бы в
# чужую (свою же продуктовую) БД — там ни роли auth_app, ни таблиц реестра.
# Порт 5432 — ВНУТРИСЕТЕВОЙ порт контейнера; публикация `127.0.0.1:5432:5432` в
# корневом compose существует только ради SSH-туннеля с хоста и к этому пути
# отношения не имеет.
_AUTH_DB_DEFAULT_HOST = "gendesign-postgres"
_AUTH_DB_DEFAULT_PORT = 5432
_AUTH_DB_DEFAULT_NAME = "auth"
# Роль приложения из data/sql/auth/002_auth_app_role.sql (least privilege).
_AUTH_DB_DEFAULT_USER = "auth_app"
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
@ -71,6 +96,149 @@ class Settings(BaseSettings):
default=300, validation_alias="LOGIN_RATE_LIMIT_WINDOW_S"
)
# ── Эпик «единый вход»: общий реестр людей в БД `auth` ─────────────────────
# DSN БД `auth` (роль auth_app) — единый реестр людей «Меры» (trade-in) и
# «Птицы» (Site Finder); схема — data/sql/auth/001-004.
#
# ПУСТО ПО УМОЛЧАНИЮ, И ЭТО НЕ ОШИБКА. На проде пароль роли auth_app ещё не
# заведён (переменной AUTH_DATABASE_URL там нет), данные (хеши/роли/живые
# сессии) в `auth` ещё не скопированы. Пока identity_store="tradein" (дефолт)
# к этой БД не обращается ни одна строка кода: engine не создаётся,
# соединение не открывается, пустой DSN на старте ничего не роняет — см.
# app.core.auth_db (ленивое создание engine). ENV: AUTH_DATABASE_URL.
#
# Задавать его РУКАМИ больше не обязательно — см. `resolved_auth_database_url`
# ниже: при пустом AUTH_DATABASE_URL и заданном AUTH_DB_PASSWORD DSN собирается
# из частей. Явное значение, если оно есть, по-прежнему выигрывает.
auth_database_url: str = Field(default="", validation_alias="AUTH_DATABASE_URL")
# ── Части DSN БД `auth` — чтобы пароль жил в ОДНОМ месте ────────────────────
# Пароль роли auth_app уже лежит в .env.runtime отдельной переменной
# AUTH_DB_PASSWORD: её читает .forgejo/workflows/deploy.yml, чтобы выполнить
# ALTER ROLE (ops/db-bootstrap/set_auth_app_password.sql). Требовать вдобавок
# целиковый AUTH_DATABASE_URL значило бы держать ОДИН секрет в ДВУХ местах:
# сменили пароль роли, забыли переписать DSN — и вход ложится молча и целиком
# (аутентификация к БД `auth` отваливается для всех сразу).
#
# ⚠️ ops-нюанс: deploy.yml делает ALTER ROLE, читая AUTH_DB_PASSWORD из
# backend/.env.runtime ГЛАВНОГО стека, а этот контейнер читает
# tradein-mvp/backend/.env.runtime (env_file в tradein-mvp/docker-compose.prod.yml).
# Файлы разные — переменная должна быть в обоих. Зато их значение сравнимо
# глазами, чего нельзя сказать про пароль, замурованный внутрь DSN.
#
# Пусто по умолчанию — как и AUTH_DATABASE_URL: в дефолтном режиме
# IDENTITY_STORE=tradein ничего из этого не читается. ENV: AUTH_DB_PASSWORD.
#
# SecretStr, а не str: это единственное поле-секрет, добавленное здесь, и
# обёртка бесплатно закрывает канал утечки, которого не видно глазами —
# `repr(settings)` и `settings.model_dump()` печатают обычные str-поля
# ДОСЛОВНО. Сегодня их никто не рендерит (grep по app: ни дампа env, ни
# `/debug`; sentry_sdk в app/main.py идёт с include_local_variables=False),
# но появиться такой рендер может в любой момент и тихо — с SecretStr он
# напечатает `SecretStr('**********')`. Значение достаётся ровно в одном
# месте — `.get_secret_value()` в резолвере ниже.
# ⚠️ Соседние секреты (database_url, telegram_bot_token, …) остались str —
# это предсуществующее положение, а не «здесь безопасно, а там нет».
auth_db_password: SecretStr = Field(default=SecretStr(""), validation_alias="AUTH_DB_PASSWORD")
# Остальные части — с дефолтами, верными для прод-стека (см. константы выше).
# Переопределяются через ENV для dev/локального запуска (напр. AUTH_DB_HOST=
# localhost + AUTH_DB_PORT=15432 поверх SSH-туннеля).
# ENV: AUTH_DB_HOST, AUTH_DB_PORT, AUTH_DB_NAME, AUTH_DB_USER.
auth_db_host: str = Field(default=_AUTH_DB_DEFAULT_HOST, validation_alias="AUTH_DB_HOST")
auth_db_port: int = Field(default=_AUTH_DB_DEFAULT_PORT, validation_alias="AUTH_DB_PORT")
auth_db_name: str = Field(default=_AUTH_DB_DEFAULT_NAME, validation_alias="AUTH_DB_NAME")
auth_db_user: str = Field(default=_AUTH_DB_DEFAULT_USER, validation_alias="AUTH_DB_USER")
@field_validator("auth_db_port", mode="before")
@classmethod
def _blank_port_means_default(cls, value: object) -> object:
"""`AUTH_DB_PORT=` (пустая строка) → прод-дефолт, а не падение на импорте.
Симметрия с host/name/user, у которых пустое значение переменной падает
обратно на дефолт в резолвере. Для порта того же добиться нельзя: он
типизирован `int` и валидируется pydantic'ом ДО всякой нашей логики, а
`settings = Settings()` выполняется на уровне модуля то есть
`AUTH_DB_PORT=` в .env.runtime роняло бы ValidationError на импорте
конфига и уводило контейнер в restart-loop. Причём В ЛЮБОМ режиме,
включая дефолтный IDENTITY_STORE=tradein, где к БД `auth` не идёт ни
одного обращения ровно тот инвариант «дефолт не трогаем», который
держит остальной код.
Сценарий не гипотетический: ops копирует блок AUTH_DB_* в .env.runtime и
заполняет только пароль остальные строки остаются пустыми намеренно.
`mode="before"` потому что вмешаться надо ДО приведения к int.
Непустой мусор (`AUTH_DB_PORT=abc`) по-прежнему валится, и правильно:
это опечатка со смыслом, а не «оставил пустым».
"""
if isinstance(value, str) and not value.strip():
return _AUTH_DB_DEFAULT_PORT
return value
@property
def resolved_auth_database_url(self) -> str:
"""DSN БД `auth` — единственный источник правды для `app.core.auth_db`.
Приоритет:
1. `AUTH_DATABASE_URL`, если задан выигрывает всегда. Обратная
совместимость (так настроено «до») плюс аварийный обход: если DSN
понадобился нестандартный (другой хост, sslmode, пул-байпас), его
можно вписать целиком, не трогая код.
2. Иначе, если задан `AUTH_DB_PASSWORD` DSN собирается из частей.
3. Иначе пустая строка, то есть «не сконфигурировано». Это НЕ ошибка
сама по себе: при `IDENTITY_STORE=tradein` (дефолт) сюда не заходит
никто. Ошибку явную, а не тихий фолбэк поднимает `app.core.auth_db`
и только когда реестр реально понадобился.
Возвращаемое значение СОДЕРЖИТ ПАРОЛЬ: не логировать, не класть в текст
исключений, не отдавать наружу (`/health`, `/debug`, метрики).
Пароль экранируется `quote(..., safe="")`: спецсимвол (`@`, `:`, `/`, `?`,
`#`, `%`) внутри пароля иначе порвал бы URL по своей грамматике — `@`
сдвинул бы границу host, `/` открыл бы path. Разбор дал бы либо ошибку,
либо, что хуже, МОЛЧА другой хост/базу. По той же причине экранируется
имя пользователя.
А вот имя БД и хост НЕ экранируются, и это не забывчивость: SQLAlchemy
раскодирует обратно только userinfo (user/password), а path отдаёт как
есть. Прогони мы имя БД через `quote`, в сервер уехало бы литеральное
`c%2Fd` вместо `c/d` (проверено round-trip'ом в тестах). Хосту
%-кодирование тоже только мешает оно поломало бы IPv6-скобки.
"""
explicit = self.auth_database_url.strip()
if explicit:
return explicit
# `.strip()` только для ПРОВЕРКИ «задан ли»: пробельная строка в .env — это
# опечатка, а не пароль. В сам DSN идёт значение КАК ЕСТЬ (не стриппится):
# ведущий/хвостовой пробел может быть частью настоящего пароля.
# Единственная точка распаковки SecretStr во всём коде — см. поле выше.
password = self.auth_db_password.get_secret_value()
if not password.strip():
return ""
user = quote(self.auth_db_user.strip() or _AUTH_DB_DEFAULT_USER, safe="")
secret = quote(password, safe="")
host = self.auth_db_host.strip() or _AUTH_DB_DEFAULT_HOST
port = self.auth_db_port
name = self.auth_db_name.strip() or _AUTH_DB_DEFAULT_NAME
# Схема — ровно та же, что у продуктового DATABASE_URL (psycopg v3;
# `postgresql://` без суффикса увёл бы SQLAlchemy на psycopg2, которого в
# зависимостях нет).
return f"postgresql+psycopg://{user}:{secret}@{host}:{port}/{name}"
# Где живут identity (люди + сессии):
# "tradein" (ДЕФОЛТ) — БД tradein, таблицы tradein_users/tradein_sessions
# (ровно сегодняшний прод, поведение не меняется);
# "auth" — БД auth, таблицы users/sessions (единый реестр).
# Переключать ТОЛЬКО после того, как на проде заведён пароль auth_app и
# перенесены данные. Дефолт = старое поведение: включить новый путь можно
# исключительно явной сменой этого флага. Единственный потребитель —
# app.services.identity_store. ENV: IDENTITY_STORE.
identity_store: Literal["tradein", "auth"] = Field(
default="tradein", validation_alias="IDENTITY_STORE"
)
# для User-Agent в Nominatim (Nominatim Usage Policy)
contact_email: str = "erginrajpopxbe@outlook.com"
@ -525,6 +693,18 @@ class Settings(BaseSettings):
proxy_rotate_attempt_timeout_s: float = 8.0
proxy_rotate_attempts: int = 3
# ── ASocks pool-proxy rotation (#2600) ───────────────────────────────────
# Bearer-токен веб-кабинета ASocks для POST .../unlimited-proxy/{portId}/refresh-ip
# (app.services.proxy_rotation). Документированный публичный API (GET
# /v2/proxy/refresh/{portId}?apiKey=) для безлимитных портов не работает —
# подтверждено владельцем аккаунта; единственный рабочий путь — эта ручка
# веб-кабинета с сессионным токеном. Токен разово протухнет (осознанное
# решение владельца) — тогда provider вернёт 401, proxy_rotation.rotate_proxy
# логирует error + шлёт Sentry/GlitchTip alert. Пусто = ротация для всех
# прокси недоступна (rotate_proxy возвращает внятный отказ, не падает).
# ENV: ASOCKS_API_TOKEN. НИКОГДА не логировать / не возвращать в HTTP-ответе.
asocks_api_token: str = Field(default="", validation_alias="ASOCKS_API_TOKEN")
# #1950: если SERP уже сохранил лоты (ins+upd > 0) и упали только detail/houses,
# ставим 'done' а не 'banned' — partial intake сохранён, 'banned' лишний.
# False = старое поведение. ENV: AVITO_SERP_OK_NOT_BANNED.

View file

@ -10,13 +10,19 @@ so a regression in that check would NOT have failed CI.
This module holds the real guard. Historically it had "no DB/lifespan/scheduler
side effects" beyond ``app.core.auth``/``app.core.config`` (both side-effect-free
at import time). #2552 (dual-mode DB-session auth) adds a conditional per-request
DB round trip via ``app.core.db.SessionLocal`` но ТОЛЬКО когда запрос реально
несёт session-cookie (``request.cookies.get(settings.session_cookie_name)``);
без cookie (весь существующий тестовый трафик, legacy Caddy trusted-header
запросы) ветка не выполняется ноль новых DB-побочных эффектов для старых
путей. ``app/main.py`` and the test apps both import THIS module, so tests
exercise the exact production code path instead of a copy that can silently
fall out of sync.
DB round trip via ``app.services.identity_store.identity_session`` но ТОЛЬКО
когда запрос реально несёт session-cookie
(``request.cookies.get(settings.session_cookie_name)``); без cookie (весь
существующий тестовый трафик, legacy Caddy trusted-header запросы) ветка не
выполняется ноль новых DB-побочных эффектов для старых путей. ``app/main.py``
and the test apps both import THIS module, so tests exercise the exact
production code path instead of a copy that can silently fall out of sync.
Сессия открывается через ``identity_session()``, а не через
``app.core.db.SessionLocal`` напрямую: guard middleware, FastAPI-DI здесь нет,
а реестр людей при ``IDENTITY_STORE=auth`` лежит в другой БД. В дефолтном режиме
``identity_session()`` открывает ровно ``app.core.db.SessionLocal()`` тот же
коннект-пул и то же поведение, что до эпика «единый вход».
"""
from __future__ import annotations
@ -32,8 +38,8 @@ from fastapi.responses import JSONResponse, Response
from app.core.auth import get_role, is_path_allowed
from app.core.config import settings
from app.core.db import SessionLocal
from app.services.auth_session import get_db_role_scope, get_session_user
from app.services.identity_store import identity_session
logger = logging.getLogger(__name__)
@ -178,9 +184,23 @@ async def rbac_guard(
if token:
session_user: dict[str, Any] | None = None
try:
with SessionLocal() as db:
with identity_session() as db:
session_user = get_session_user(db, token)
except Exception:
# Сюда попадает и AuthDatabaseNotConfiguredError (IDENTITY_STORE=auth
# без AUTH_DATABASE_URL): резолв сессии не состоялся, дальше работает
# тот же путь, что и при любом сбое БД, — auth_mode решает, пускать ли
# legacy trusted-header.
#
# ⚠️ Этот except НЕ должен быть тем, что ловит сломанный DSN: молча
# деградировать в legacy trusted-header означало бы раздавать права
# из roles.yaml в обход реестра (включая аккаунты с access_state
# 'disabled'/'trial_expired'), причём сутками — продуктовая БД жива,
# приложение работоспособно, сигнал только в логах. Поэтому
# конфигурацию проверяет lifespan (app/main.py): при
# IDENTITY_STORE=auth пустой DSN роняет СТАРТ. Здесь остаётся второй
# рубеж — реестр, отвалившийся уже после успешного старта, не имеет
# права отдавать 500.
logger.exception("RBAC: session lookup failed for %s", path)
if session_user is not None:
username = session_user["username"]

View file

@ -34,6 +34,7 @@ from app.api.v1 import (
team,
trade_in,
)
from app.core.auth_db import get_auth_engine
from app.core.config import settings
from app.core.db import SessionLocal
from app.core.fdw import ensure_fdw_user_mapping
@ -121,6 +122,27 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"которым подпись реально нужна"
)
# Эпик «единый вход»: при IDENTITY_STORE=auth реестр людей обязан быть
# СКОНФИГУРИРОВАН — иначе стартуем сломанными. Ошибка DSN не похожа на «БД
# недоступна»: продуктовая БД жива, приложение полностью работоспособно и
# может так работать сутками, а rbac_guard ловит AuthDatabaseNotConfiguredError
# вместе с любым другим сбоем резолва сессии и падает в legacy
# trusted-header ветку (auth_mode='dual'). То есть любой, кого пропустил
# Caddy basic_auth, молча получал бы права из roles.yaml — даже аккаунт с
# access_state='disabled'/'trial_expired' в реестре. Пусть лучше сломанный
# деплой не поднимется вообще, чем сутки раздаёт доступ мимо реестра.
#
# На ДЕФОЛТНЫЙ режим не влияет: при identity_store="tradein" (прод сегодня)
# ветка не выполняется, engine БД `auth` не создаётся, пустой
# AUTH_DATABASE_URL по-прежнему не ошибка.
if settings.identity_store == "auth":
# Наружу летит AuthDatabaseNotConfiguredError с внятным текстом
# (app.core.auth_db); create_engine к серверу не ходит, так что это
# проверка КОНФИГУРАЦИИ, а не доступности БД — недоступный сервер
# по-прежнему не мешает старту.
get_auth_engine()
logger.info("identity_store=auth: DSN общего реестра людей (БД `auth`) сконфигурирован")
# FDW bootstrap: create/refresh USER MAPPING for gendesign_remote postgres_fdw server.
# Best-effort: failure does not abort startup, just logs.
try:

View file

@ -1,15 +1,26 @@
"""Session-сервис для DB-backed auth (#2552, эпик #2549 — auth-core).
Схема: `tradein_users` + `tradein_sessions` (migration `192_tradein_users_auth.sql`).
Схема НЕ зашита: имена таблиц и имя колонки состояния доступа берутся из
`app.services.identity_store.identity_schema()` эпик «единый вход» переводит
реестр людей с `tradein_users`/`tradein_sessions` (migration
`192_tradein_users_auth.sql`, БД tradein) на `users`/`sessions` (БД `auth`,
миграции data/sql/auth/001-004) флагом `IDENTITY_STORE`, дефолт которого =
сегодняшнее прод-поведение. Никаких других отличий между режимами у этого
модуля нет: SQL один и тот же, подставляются только имена из фиксированного
словаря `identity_store._SCHEMAS`.
Опаковые (`secrets.token_urlsafe`) токены-сессии не JWT, не подписаны: валидность
проверяется исключительно наличием + `expires_at`/`is_active` строкой в БД, поэтому
`SESSION_SECRET` НЕ обязателен для работы этого модуля (зарезервирован на будущее,
см. `app.core.config.Settings.session_secret` docstring).
проверяется исключительно наличием строки + `expires_at` + состоянием доступа
юзера в БД, поэтому `SESSION_SECRET` НЕ обязателен для работы этого модуля
(зарезервирован на будущее, см. `app.core.config.Settings.session_secret` docstring).
Все функции здесь принимают уже открытую `db: Session` сами НЕ открывают
`SessionLocal()` (вызывающая сторона решает время жизни транзакции: `rbac_guard`
и `app.core.db.get_db()`-роуты открывают её по-разному). Это делает модуль
тривиально unit-тестируемым без патчинга `SessionLocal` тесты просто передают
сессию (вызывающая сторона решает время жизни транзакции: `rbac_guard` и
роуты открывают её по-разному). Это ОБЯЗАНА быть сессия РЕЕСТРА
(`identity_store.identity_session()` / `Depends(get_identity_db)`), а не
`app.core.db.get_db`: при `IDENTITY_STORE=auth` запрос уйдёт в БД tradein,
где таблиц `users`/`sessions` нет. В дефолтном режиме это один и тот же объект.
Модуль остаётся тривиально unit-тестируемым тесты просто передают
fake/real `Session`.
Ни одна функция не должна ронять вызывающий HTTP-запрос: DB-ошибки логируются
@ -30,6 +41,7 @@ from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings
from app.services.identity_store import identity_schema, to_access_state
logger = logging.getLogger(__name__)
@ -52,11 +64,13 @@ def create_session(
`expires_at = now() + settings.session_ttl_hours`. Коммитит сам (self-contained,
как `app.services.user_events.record_event`).
"""
schema = identity_schema()
token = secrets.token_urlsafe(_TOKEN_BYTES)
db.execute(
text(
"""
INSERT INTO tradein_sessions (token, user_id, expires_at, ip_address, user_agent)
f"""
INSERT INTO {schema.sessions_table}
(token, user_id, expires_at, ip_address, user_agent)
VALUES (
:token, :user_id,
now() + make_interval(hours => CAST(:ttl_hours AS integer)),
@ -78,7 +92,16 @@ def create_session(
def get_session_user(db: Session, token: str) -> dict[str, Any] | None:
"""Резолвит сессионный токен в данные юзера, или None если сессия
невалидна (не найдена / истекла / юзер деактивирован).
невалидна (не найдена / истекла / доступ юзера не `active`).
Состояние доступа: пропускает ТОЛЬКО `AccessState.ACTIVE`. Любое другое
(`disabled`, `trial_expired`, а также нераспознанное `to_access_state`
fail-closed'ит его в `disabled`) делает уже выданную сессию недействительной
немедленно, без ожидания TTL. Это то же решение, что и в булевой схеме
(`is_active = false` None), просто теперь состояний больше одного:
«пробный период истёк» гасит живую сессию так же, как блокировка иначе
сотрудник, залогиненный до истечения пробного доступа, продолжал бы
работать, а sliding-refresh продлевал бы ему сессию бесконечно.
Sliding refresh: если с последнего `last_seen_at` прошло >=5 минут
продлевает `expires_at`/`last_seen_at` ОДНИМ UPDATE. Сбой refresh
@ -88,13 +111,15 @@ def get_session_user(db: Session, token: str) -> dict[str, Any] | None:
if not token:
return None
schema = identity_schema()
row = db.execute(
text(
"""
f"""
SELECT s.user_id, s.expires_at, s.last_seen_at,
u.username, u.role, u.display_name, u.org_name, u.email, u.is_active
FROM tradein_sessions s
JOIN tradein_users u ON u.id = s.user_id
u.username, u.role, u.display_name, u.org_name, u.email,
u.{schema.access_state_column} AS access_state
FROM {schema.sessions_table} s
JOIN {schema.users_table} u ON u.id = s.user_id
WHERE s.token = :token
"""
),
@ -107,15 +132,16 @@ def get_session_user(db: Session, token: str) -> dict[str, Any] | None:
now = datetime.now(UTC)
if row.expires_at is None or row.expires_at <= now:
return None
if not row.is_active:
access_state = to_access_state(row.access_state)
if not access_state.can_sign_in:
return None
if row.last_seen_at is None or (now - row.last_seen_at) >= _SLIDING_REFRESH_INTERVAL:
try:
db.execute(
text(
"""
UPDATE tradein_sessions
f"""
UPDATE {schema.sessions_table}
SET last_seen_at = now(),
expires_at = now() + make_interval(hours => CAST(:ttl_hours AS integer))
WHERE token = :token
@ -137,23 +163,35 @@ def get_session_user(db: Session, token: str) -> dict[str, Any] | None:
"display_name": row.display_name,
"org_name": row.org_name,
"email": row.email,
"is_active": row.is_active,
# Всегда AccessState.ACTIVE — не-active сюда не доходит (см. выше).
# Ключ оставлен вместо прежнего `is_active`, чтобы состояние доступа во
# ВСЁМ коде называлось и выражалось одинаково.
"access_state": access_state,
}
def get_user_by_username(db: Session, username: str) -> dict[str, Any] | None:
"""Возвращает строку `tradein_users` по username, или None если не найден.
"""Возвращает строку реестра по username, или None если не найден.
Используется login-флоу (`app.api.v1.auth.login`) для password-проверки.
Отдаёт `password_hash` как есть (может быть NULL переходный период,
см. migration 192 docstring) вызывающая сторона решает, что с ним делать.
`access_state` уже `AccessState` (не сырое значение колонки): решение
«пускать / не пускать / показать экран пробного периода» принимает login,
и принимать его он обязан по ОДНОМУ понятию, а не по boolean в одном режиме
и строке в другом. Отсутствие юзера состоянием НЕ выражается (None остаётся
None) иначе login потерял бы разницу между «нет такого логина» и
«заблокирован», а она нужна ему для выбора события аудита.
"""
schema = identity_schema()
row = db.execute(
text(
"""
SELECT id, username, password_hash, role, is_active,
f"""
SELECT id, username, password_hash, role,
{schema.access_state_column} AS access_state,
display_name, org_name, email
FROM tradein_users
FROM {schema.users_table}
WHERE username = :username
"""
),
@ -168,7 +206,7 @@ def get_user_by_username(db: Session, username: str) -> dict[str, Any] | None:
"username": row.username,
"password_hash": row.password_hash,
"role": row.role,
"is_active": row.is_active,
"access_state": to_access_state(row.access_state),
"display_name": row.display_name,
"org_name": row.org_name,
"email": row.email,
@ -177,14 +215,19 @@ def get_user_by_username(db: Session, username: str) -> dict[str, Any] | None:
def revoke_session(db: Session, token: str) -> None:
"""Удаляет одну сессию по токену (logout). No-op если токен не найден."""
db.execute(text("DELETE FROM tradein_sessions WHERE token = :token"), {"token": token})
schema = identity_schema()
db.execute(text(f"DELETE FROM {schema.sessions_table} WHERE token = :token"), {"token": token})
db.commit()
def revoke_user_sessions(db: Session, user_id: int) -> None:
"""Удаляет ВСЕ сессии юзера (напр. смена пароля / принудительный logout всех
устройств не используется этим PR напрямую, задел для будущих admin-действий)."""
db.execute(text("DELETE FROM tradein_sessions WHERE user_id = :user_id"), {"user_id": user_id})
"""Удаляет ВСЕ сессии юзера — смена пароля и блокировка обязаны рвать
активные сессии немедленно (см. `app.api.v1.team.update_employee`)."""
schema = identity_schema()
db.execute(
text(f"DELETE FROM {schema.sessions_table} WHERE user_id = :user_id"),
{"user_id": user_id},
)
db.commit()
@ -192,7 +235,9 @@ def revoke_user_sessions(db: Session, user_id: int) -> None:
# DB-role → RBAC scope (paths/deny) — #2552 dual-mode.
# ---------------------------------------------------------------------------
#
# tradein_users.role ('admin'|'manager'|'employee', CHECK-констрейнт migration 192)
# Роли реестра ('admin'|'manager'|'employee' — CHECK-констрейнт: tradein м.192 для
# tradein_users.role, auth м.004 для auth.users.role; наборы значений совпадают
# намеренно, чтобы код «Меры» переехал на общий реестр без правок в проверках роли)
# НЕ являются ключами auth/roles.yaml (тот файл — legacy Caddy trusted-header путь,
# который этот эпик намеренно не трогает). Маппинг ниже даёт DB-ролям тот же
# paths/deny-смысл, что и legacy-ролям, БЕЗ правки roles.yaml:

View file

@ -343,6 +343,10 @@ async def suggest_addresses(
жёсткий фильтр (не boost) на уровне указанного admin-поля доп.
параметров не требуется. По умолчанию не задан поведение (и body
запроса) для существующих вызовов не меняется.
ВАЖНО: значение сравнивается с полем DaData `region`, где имя лежит
БЕЗ типа («Свердловская», а тип отдельно в `region_type`="обл").
Передашь «Свердловская область» совпадений не будет, и запрос
вернёт ПУСТО без всякой ошибки (hard-filter, не boost).
Returns:
list[DadataSuggestion] пустой список если:

View file

@ -200,6 +200,55 @@ def _load_city_price_bands(db: Session) -> dict[str, tuple[int, int]]:
return bands
# Правдоподобный диапазон года постройки МКД (guard на входе — Mera-audit 2026-08-02).
# Нижняя граница 1917: массовая многоквартирная застройка в РФ/СССР началась
# после революции — год раньше почти гарантированно ошибка источника
# (house_metadata OSM/кадастр смешивают год постройки дома с годом основания
# места/памятника на тех же координатах — прод-инцидент 2026-08: house_metadata
# отдал year_built=1829 для обычной вторички, см. vault fixes). Верхняя граница
# — текущий год + 3: допуск на цели trade-in со строящимся домом (год сдачи по
# ДДУ известен заранее, но не более чем на несколько лет вперёд).
# Год вне диапазона трактуем как ОТСУТСТВУЮЩИЙ (None), а НЕ клампим к границе —
# хедонический фактор (_price_from_inputs, #2002) экстраполирует regression fit
# far вне обучающей выборки (COHORTS ниже даже не определяет когорту раньше
# 1955 — модель никогда не видела осмысленного объёма домов старше этого), и
# estimate_hedonic_factor_min=0.75 в таком случае не защита, а маскировка
# выхода за диапазон под видом уверенной 25% поправки. «Не знаем год» —
# честный сигнал, который просто отключает year-term фактора (нейтрален).
MIN_PLAUSIBLE_BUILD_YEAR = 1917
MAX_PLAUSIBLE_BUILD_YEAR_LEAD = 3 # текущий год + N — допуск на стройки
def _sanitize_build_year(
year: int | None, *, house_id: int | None = None, address: str | None = None
) -> int | None:
"""Отбрасывает неправдоподобный год постройки, трактуя его как «неизвестен».
Валидный диапазон [MIN_PLAUSIBLE_BUILD_YEAR, текущий год + LEAD]. Год вне
диапазона логируется на WARNING (с идентификатором дома house_id либо
адрес) и заменяется на None, а не клампится к границе: клампинг превращает
заведомый мусор источника (house_metadata OSM/кадастр, либо year_built из
payload ge=1800 в схеме пропускает подобные значения) в уверенный вход
для хедонической поправки (_price_from_inputs), хотя физического смысла
у результата нет.
"""
if year is None:
return None
max_year = datetime.now(UTC).year + MAX_PLAUSIBLE_BUILD_YEAR_LEAD
if year < MIN_PLAUSIBLE_BUILD_YEAR or year > max_year:
logger.warning(
"estimate: implausible year_built=%s dropped (house_id=%s, address=%s) — "
"valid range [%s, %s]",
year,
house_id,
address,
MIN_PLAUSIBLE_BUILD_YEAR,
max_year,
)
return None
return year
# Когорта по году постройки — типизация массовой застройки РФ.
# Используется как hard-filter в Tier 0 _fetch_analogs (PR 9, 2026-05-24).
# Если target_year не задан — cohort = None → фильтр отключён, Tier 0 пропускается.
@ -3335,6 +3384,16 @@ async def estimate_quality(
if target_house_type is None:
target_house_type = house_meta.house_type
# 2b. Mera-audit 2026-08-02: неправдоподобный год (payload user-input ge=1800/le=2100 в схеме,
# либо house_metadata OSM/кадастр — прод-инцидент year_built=1829) — на
# «неизвестен» ДО того как target_year уйдёт в cohort-фильтр (ниже),
# _fetch_analogs house-match scoring и хедонический фактор
# (_price_from_inputs, #2002). Единая точка входа — все три места ниже
# используют этот же target_year.
target_year = _sanitize_build_year(
target_year, house_id=target_house_id, address=payload.address
)
# 3. Four-tier fallback (PR 9 — added Tier 0 with cohort filter):
# 0) 1km + ±15% area + cohort match (year_built — если задан)
# a) 1km + ±15% area (без cohort — drop fallback)

View file

@ -156,7 +156,15 @@ SVERDLOVSK_OBLAST_CITIES = frozenset(
# без district-префикса ложно ушёл бы в non-EKB gate.
}
)
SVERDLOVSK_OBLAST_REGION = "Свердловская область"
# Значение для DaData-констрейнта `locations: [{"region": ...}]`.
# ВАЖНО: DaData хранит имя региона БЕЗ типа — `region="Свердловская"`,
# `region_type="обл"` (тип лежит в отдельных полях `region_type` /
# `region_with_type`). `locations` сравнивает именно с `region`, поэтому
# «Свердловская область» не совпадает НИ С ЧЕМ и hard-фильтр молча схлопывал
# выдачу в 0 подсказок (замер на проде: «Свердловская область» → 0 хитов,
# «Свердловская» → 5 хитов, первый — искомый «д 13б» с fias_id).
# Тип региона сюда дописывать нельзя — см. `test_dadata_region_constant_*`.
SVERDLOVSK_OBLAST_REGION = "Свердловская"
# Word/phrase-boundary regex — НЕ substring — чтобы «Серова 27» не матчил город
# «Серов», «Ирбитская 5» — «Ирбит», «Асбестовский пер.» — «Асбест», «Невьянский
@ -637,13 +645,24 @@ async def _dadata_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]
типа город/район, для autocomplete с привязкой к карте они бесполезны).
Label собирается из DaData `value` (короткая форма «ул Малышева, д 30»).
Constraint вся область (region='Свердловская область', hard-filter внутри
`suggest_addresses`), а не один город ЕКБ иначе Нижний Тагил/Серов/etc
никогда не появились бы в подсказках.
Constraint вся область (region=`SVERDLOVSK_OBLAST_REGION`, hard-filter
внутри `suggest_addresses`), а не один город ЕКБ иначе Нижний Тагил/
Серов/etc никогда не появились бы в подсказках.
"""
raw = await dadata.suggest_addresses(
query, limit=limit, city=None, region=SVERDLOVSK_OBLAST_REGION
)
if not raw:
# Region-констрейнт — hard-filter: неверное значение схлопывает выдачу в
# 0 БЕЗ ошибки (так и жил баг «Свердловская область» → 0 подсказок).
# Отдельный warning, чтобы следующая такая регрессия была видна в логах,
# а не выглядела как «DaData ничего не знает про этот адрес».
logger.warning(
"dadata suggest: 0 кандидатов для %r при region=%r"
"проверь, что констрейнт совпадает с полем DaData `region` (без типа)",
query[:60],
SVERDLOVSK_OBLAST_REGION,
)
out: list[GeocodeSuggestion] = []
for s in raw:
if s.lat is None or s.lon is None:
@ -946,23 +965,85 @@ def _parse_street_house(address: str) -> tuple[str, str] | None:
return (street, house)
# Извлечение номера дома из `readable_address` реестра. Реальные формы в
# gendesign_cad_buildings (47k строк, замер 2026-08-02):
# «д. 13» / «дом 13» / «сооружение 30» — 21k
# «д. 13б» — 2.6k
# «д. 13-б» — 2.1k
# «д. 13 б» — 125
# «д. 58/3», «д. 64-2» — 0.9k (угловые/корпусные номера)
# «д. 11 (кв. 1-150)», «д. 102 корпус 1» — хвост, литерой НЕ является
# Разбор:
# \m… — маркер только с НАЧАЛА слова, иначе «проезд 8
# Марта, д 5» дало бы дом «8» (старый `д\.?` без
# границы слова ловил «д» внутри «проезд»)
# [0-9]+ — номер
# (\s*[-/]\s*[0-9]+)? — «58/3» / «64-2»: часть номера, а не мусор —
# иначе «58» ложно совпало бы с «58/3»
# (\s*-?\s*[а-яё](?![а-яё]))? — литера; lookahead отсекает начало слова
# («102 корпус 1» → «102», не «102к»)
_SQL_HOUSE_TOKEN_RE = (
r"\m(?:дом|д\.?|строение|стр\.?|сооружение|соор\.?)\s*"
r"([0-9]+(?:\s*[-/]\s*[0-9]+)?(?:\s*-?\s*[а-яё](?![а-яё]))?)"
)
# Нормализация извлечённого токена к канону `_norm_house`: убираем пробелы,
# затем дефис ТОЛЬКО перед литерой («23-б» → «23б», но «64-2» остаётся «64-2»,
# иначе он схлопнулся бы в реальный дом «642»).
_SQL_HOUSE_TOKEN_NORM = (
r"regexp_replace("
r" regexp_replace("
r" lower(COALESCE((regexp_match(readable_address, :house_token_re, 'i'))[1], '')),"
r" '\s', '', 'g'),"
r" '-([а-яё])', '\1', 'g')"
)
def _cadastral_house_match(db: Session, street: str, house: str) -> GeocodeSuggestion | None:
"""Anchored cadastral match: ILIKE по улице + regex-anchor на дом-маркер.
"""Anchored cadastral match: ILIKE по улице + СТРОГОЕ равенство номера дома.
SQL validated на проде (11/16 hits, 0 false positives). Anchor на
«д./дом/строение» убивает ложный матч номера внутри «(1-83)»-диапазона.
Литера часть идентичности дома, а не украшение: «Новгородцевой 13б» и
«Новгородцевой 13» РАЗНЫЕ здания. Поэтому номер сравнивается равенством
нормализованных форм (обе стороны канон «13б»), а не «совпали цифры,
литера опциональна».
`street` идёт ТОЛЬКО в bound-param ILIKE (безопасно). Для regex берём
только ЦИФРЫ дома (regex-safe) конкатенируем bound-param внутри SQL.
Литеру (если есть) используем лишь для tie-break сортировки.
Раньше в regex шли только ЦИФРЫ дома, литера была опциональна в WHERE и
участвовала лишь как tie-break в ORDER BY из-за чего запрос с литерой
молча получал соседний дом БЕЗ неё (и наоборот: «Малышева 30» «д. 30-б»),
причём с `confidence="exact"` и записью в `geocode_cache` на 90 дней.
Regex-anchor на «д./дом/строение» (prefilter) сохранён: он дёшев, пушится
в FDW и убивает ложный матч номера внутри «(1-83)»-диапазона. Точность
даёт равенство токенов ниже.
`street` идёт ТОЛЬКО в bound-param ILIKE, номер дома в regex больше НЕ
конкатенируется (сравнивается как текст) regex-injection поверхность
сузилась до цифр prefilter'а.
Нет дома с нужной литерой возвращаем None, а НЕ «похожий» дом: пусть
отработают следующие тиры. Тихо подставленный соседний дом здесь
необратимо помечался бы `exact`.
ВНИМАНИЕ, цепочки различаются не путать:
* `geocode()` : geoportal cadastral `_cadastral_forward_sync`
Nominatim None. Тира DaData тут НЕТ.
* `suggest()` : cadastral DaData Nominatim (единственный вызов
`_dadata_suggest`).
То есть на прямом вызове `geocode()` (API/PDF/восстановление по `?id=`)
адрес с литерой, неизвестный ни геопорталу, ни Nominatim, даёт None
оценка не строится. Это сознательный выбор: честный отказ вместо
уверенно-неверной оценки чужого дома. Основной UI-путь этим не задет
координаты приходят из выбранной подсказки (`ParamsPanel.tsx:776`
`api/v1/trade_in.py:128` использует lat/lon напрямую, минуя `geocode()`).
"""
house_digits_m = re.match(r"\d+", house)
house_norm = _norm_house(house)
house_digits_m = re.match(r"\d+", house_norm)
if not house_digits_m:
return None
house_digits = house_digits_m.group(0)
try:
row = db.execute(
text(r"""
text(
r"""
SELECT readable_address, lat, lon
FROM gendesign_cad_buildings
WHERE readable_address ILIKE CAST('%' || :street || '%' AS text)
@ -976,14 +1057,19 @@ def _cadastral_house_match(db: Session, street: str, house: str) -> GeocodeSugge
'(п\.\s|пос[. ]|посёлок|поселок|северка|шабровский'
|| '| км|снт|гараж|коллективный сад)'
)
ORDER BY
(CASE WHEN CAST(:house_full AS text) ~ '[а-яё]'
AND readable_address ~* (CAST(:house_full AS text) || '(\D|$)')
THEN 0 ELSE 1 END),
length(readable_address) ASC
AND """
+ _SQL_HOUSE_TOKEN_NORM
+ r""" = CAST(:house_norm AS text)
ORDER BY length(readable_address) ASC
LIMIT 1
"""),
{"street": street, "house_digits": house_digits, "house_full": house},
"""
),
{
"street": street,
"house_digits": house_digits,
"house_norm": house_norm,
"house_token_re": _SQL_HOUSE_TOKEN_RE,
},
).first()
except Exception:
logger.warning(

View file

@ -0,0 +1,291 @@
"""Единственное место, знающее, В КАКОЙ БД и В КАКИХ ТАБЛИЦАХ живёт identity.
Эпик «единый вход»: люди «Меры» (trade-in) и «Птицы» (Site Finder) переезжают в
общую БД `auth` (`users` / `sessions`, миграции data/sql/auth/001-004), а
`tradein_users` в итоге удаляется. Переезд идёт под флагом
`settings.identity_store`, дефолт которого = СТАРОЕ поведение:
"tradein" (ДЕФОЛТ) БД tradein, tradein_users / tradein_sessions;
"auth" БД auth, users / sessions.
Смысл модуля: во всём остальном коде не должно быть ни одного упоминания
конкретной БД, конкретных имён таблиц и того, каким столбцом выражено состояние
доступа. Кто хочет читать/писать людей и сессии спрашивает здесь.
Что модуль отдаёт вызывающему:
* `identity_session()` / `get_identity_db()` сессия ТОЙ БД, которая сейчас
является реестром (для "tradein" это ровно `app.core.db.SessionLocal`, то
есть сегодняшний прод-путь без единого лишнего коннекта);
* `identity_schema()` имена таблиц users/sessions и имя колонки состояния
доступа;
* `AccessState` + `to_access_state()` ОДНО понятие «состояние доступа» для
обеих схем.
Схемы `tradein_users` и `auth.users` совпадают, кроме состояния доступа:
`tradein_users.is_active` boolean, `auth.users.access_state` text из трёх
значений (`active` / `trial_expired` / `disabled`, семантика в COMMENT'е
миграции 004). Вызывающий код обязан работать с ОДНИМ понятием: он читает
колонку `schema.access_state_column` и прогоняет значение через
`to_access_state()`. Второго представления состояния в коде быть не должно
`if row.is_active` вне этого модуля больше не пишем.
Как СПРАШИВАТЬ состояние доступа (канонический вызов):
schema = identity_schema()
with identity_session() as db:
row = db.execute(
text(
f"SELECT u.id, u.username, u.role, "
f" u.{schema.access_state_column} AS access_state "
f" FROM {schema.users_table} u "
f" WHERE u.username = :username"
),
{"username": username},
).fetchone()
state = to_access_state(row.access_state)
if not state.can_sign_in:
... # 401 для disabled, отдельный 403 для AccessState.TRIAL_EXPIRED
Значение подставляется bind-параметром (`:username`), имя таблицы и имя колонки
из `schema`, то есть из фиксированного словаря; в SQL-строку не попадает ничего,
пришедшего снаружи.
Как ПИСАТЬ состояние доступа (обратное направление, `access_state_param()`):
db.execute(
text(
f"UPDATE {schema.users_table} "
f" SET {schema.access_state_column} = :access_state "
f" WHERE id = :id"
),
{"access_state": access_state_param(AccessState.DISABLED), "id": user_id},
)
Литералов `True` / `'active'` по месту быть не должно: тип колонки разный, и
единственное место, знающее какой, этот модуль.
SQL-инъекция по имени таблицы: имена таблиц/колонок в SQL нельзя передать
bind-параметром, поэтому они подставляются в строку запроса. Единственный
допустимый источник фиксированный словарь `_SCHEMAS` НИЖЕ. Никакой
конкатенации с внешним вводом (заголовок, тело запроса, переменная окружения,
имя роли) значение `settings.identity_store` ограничено `Literal` в pydantic,
и лукап по нему делается только здесь.
"""
from __future__ import annotations
import logging
from collections.abc import Generator, Iterator
from contextlib import contextmanager
from dataclasses import dataclass
from enum import StrEnum
from typing import Annotated
from fastapi import Depends
from sqlalchemy.orm import Session
from app.core import auth_db
from app.core.config import settings
from app.core.db import SessionLocal, get_db
logger = logging.getLogger(__name__)
class AccessState(StrEnum):
"""Состояние доступа аккаунта — ЕДИНОЕ понятие для обеих схем.
Значения дословно совпадают с `auth.users.access_state` (CHECK-констрейнт
`users_access_state_ck`, миграция 004); булев `tradein_users.is_active`
приводится сюда в `to_access_state()`.
Семантика (COMMENT миграции 004, решение владельца от 2026-07-31):
active вход разрешён;
trial_expired пароль ВЕРНЫЙ, но пробный период истёк: отдельный 403 и
экран «пробный доступ закончился», сессия не выдаётся;
disabled доступ закрыт: generic 401, для пользователя неотличимо от
неверного пароля.
Неверный пароль в ЛЮБОМ состоянии generic 401, иначе отдельный ответ для
trial_expired превращается в оракул существования логина.
"""
ACTIVE = "active"
TRIAL_EXPIRED = "trial_expired"
DISABLED = "disabled"
@property
def can_sign_in(self) -> bool:
"""True только для `active` — единственная проверка «пускать ли».
Вынесена в свойство, чтобы вызывающий не писал `state == "active"`:
добавится четвёртое состояние оно по умолчанию окажется «не пускать»,
а не «пускать, потому что не disabled».
"""
return self is AccessState.ACTIVE
@dataclass(frozen=True, slots=True)
class IdentitySchema:
"""Где физически лежит identity при текущем значении флага.
Attributes:
store: значение `settings.identity_store`, которому соответствует схема.
users_table: имя таблицы людей.
sessions_table: имя таблицы сессий.
access_state_column: имя колонки состояния доступа. Значение из неё
ОБЯЗАНО пройти через `to_access_state()` тип отличается между
схемами (boolean против text).
access_state_sql_type: SQL-тип этой колонки для `CAST(:param AS ...)`.
Нужен там, где параметр может быть NULL (`COALESCE(CAST(:x AS T), col)`
в PATCH «Команды»): без явного типа Postgres не может вывести тип
NULL-параметра. Значение литерал из `_SCHEMAS`, в SQL-строку
снаружи ничего не попадает.
"""
store: str
users_table: str
sessions_table: str
access_state_column: str
access_state_sql_type: str
# Фиксированный словарь — ЕДИНСТВЕННЫЙ источник имён таблиц/колонок для SQL.
# Ключи = допустимые значения settings.identity_store (Literal в pydantic).
_SCHEMAS: dict[str, IdentitySchema] = {
"tradein": IdentitySchema(
store="tradein",
users_table="tradein_users",
sessions_table="tradein_sessions",
access_state_column="is_active",
access_state_sql_type="boolean",
),
"auth": IdentitySchema(
store="auth",
# В БД `auth` таблицы лежат без префикса продукта — реестр общий
# (data/sql/auth/001_identity_schema.sql).
users_table="users",
sessions_table="sessions",
access_state_column="access_state",
access_state_sql_type="text",
),
}
def identity_schema() -> IdentitySchema:
"""Схема реестра для текущего значения `settings.identity_store`.
Читается на КАЖДОМ вызове, а не кешируется на импорте: тесты и
переключение флага не должны требовать перезагрузки модулей.
"""
schema = _SCHEMAS.get(settings.identity_store)
if schema is None:
# Недостижимо через настройки (Literal валидируется pydantic), но
# молчаливый fallback здесь означал бы поход не в ту БД.
raise ValueError(f"неизвестный identity_store={settings.identity_store!r}")
return schema
@contextmanager
def identity_session() -> Iterator[Session]:
"""Сессия БД, в которой сейчас живёт identity.
"tradein" `app.core.db.SessionLocal` (та же БД и тот же пул, что у всего
остального приложения сегодняшнее поведение прода без изменений).
"auth" ленивый engine `app.core.auth_db`; пустой `AUTH_DATABASE_URL`
здесь поднимет `AuthDatabaseNotConfiguredError`, а не отдаст пустой
результат.
"""
if settings.identity_store == "auth":
with auth_db.auth_session() as db:
yield db
else:
with SessionLocal() as db:
yield db
def get_identity_db(
db: Annotated[Session, Depends(get_db)],
) -> Generator[Session, None, None]:
"""FastAPI-зависимость: `db: Annotated[Session, Depends(get_identity_db)]`.
Аналог `app.core.db.get_db`, но для реестра людей. Роуты, работающие с
identity, обязаны брать сессию отсюда иначе при `identity_store="auth"`
они уйдут запросом в БД tradein, где нужных таблиц уже не будет.
При `identity_store="tradein"` отдаётся РОВНО ТОТ ЖЕ объект `Session`,
что и у `Depends(get_db)` не новая сессия к той же БД. Это не экономия
коннекта, а требование «прод обязан работать точно как сейчас»: роуты
«Команды» пишут в ОДНОЙ транзакции строку сотрудника (реестр) и его квоту
(`account_quota_overrides`, продуктовая таблица). Две сессии = две
транзакции = состояние «сотрудник создан, квота нет» на ровном месте.
FastAPI кеширует результат `Depends(get_db)` в пределах запроса, поэтому
роут, объявивший ОБЕ зависимости, в этом режиме получает один и тот же
объект, и `db is identity_db` честный рантайм-признак «одна БД».
При `identity_store="auth"` это разные БД физически, и одной транзакции
быть не может (двухфазный коммит здесь не заводим): вызывающий код обязан
коммитить обе сессии и понимать порядок см. `app.api.v1.team`.
Зависимость `get_db` при этом всё равно резолвится, но `Session` ленив
без единого запроса он коннект не открывает, так что лишнего соединения с
БД tradein не появляется.
"""
if settings.identity_store != "auth":
yield db
return
with auth_db.auth_session() as identity_db:
yield identity_db
def to_access_state(value: object) -> AccessState:
"""Приводит значение колонки состояния доступа к `AccessState`.
ЕДИНСТВЕННОЕ место, где булев `tradein_users.is_active` превращается в
трёхзначное состояние: True `active`, False `disabled` (жёсткая
блокировка, generic 401 ровно то, что булева схема и означала).
`trial_expired` в булевой схеме выразить нечем: состояния там не
существовало, и на tradein-пути оно не появится.
Fail-closed: неизвестная строка, NULL и любой неожиданный тип `disabled` +
WARNING. Обратный выбор (пускать всё, что не `disabled`) означал бы, что
новое состояние, добавленное миграцией раньше кода, молча раздаёт доступ.
"""
if isinstance(value, bool):
return AccessState.ACTIVE if value else AccessState.DISABLED
if isinstance(value, str):
try:
return AccessState(value)
except ValueError:
logger.warning(
"identity_store: неизвестное состояние доступа %r → трактую как disabled", value
)
return AccessState.DISABLED
logger.warning(
"identity_store: состояние доступа %r неожиданного типа %s → трактую как disabled",
value,
type(value).__name__,
)
return AccessState.DISABLED
def access_state_param(state: AccessState) -> bool | str:
"""Значение для ЗАПИСИ в `schema.access_state_column` — обратная к `to_access_state()`.
Тип колонки разный (boolean против text), поэтому конверсию нельзя оставить
вызывающему: он бы неизбежно писал `True`/`'active'` по месту, и это ровно
то второе представление состояния, которого в коде быть не должно.
Для булевой схемы `trial_expired` невыразим там существуют только «пустят»
и «не пустят», и попытка записать промежуточное состояние молча стала бы
жёсткой блокировкой (клиент увидел бы «неверный пароль» вместо экрана
пробного периода). Поэтому это ошибка вызывающего, а не тихое приведение:
писать `trial_expired` можно только при `identity_store="auth"`.
"""
schema = identity_schema()
if schema.access_state_sql_type == "boolean":
if state is AccessState.TRIAL_EXPIRED:
raise ValueError(
f"состояние {state.value!r} невыразимо в схеме {schema.store!r} "
f"(колонка {schema.access_state_column} — boolean): доступны только "
f"{AccessState.ACTIVE.value!r} и {AccessState.DISABLED.value!r}"
)
return state.can_sign_in
return state.value

View file

@ -94,13 +94,15 @@ async def _job_rosreestr_dkp(
# ── listing_source_snapshot — sync DB-snapshot в executor ────────────────────
# params прокинуты (#2607) — snapshot_listing_sources теперь читает budget_sec из
# default_params (SET LOCAL statement_timeout, см. app/tasks/listing_source_snapshot.py).
async def _job_listing_source_snapshot(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
from app.tasks.listing_source_snapshot import snapshot_listing_sources
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, snapshot_listing_sources, db, run_id)
await loop.run_in_executor(None, snapshot_listing_sources, db, run_id, params)
# ── asking_to_sold_ratio_refresh — sync re-derive в executor ─────────────────

View file

@ -15,11 +15,25 @@ ipify-пробу через каждый прокси и обновляет heal
за одну строку второй параллельный вызов пропустит залоченную и возьмёт следующую).
Health:
- mark_health(ok=True) consecutive_fails=0, last_ok_at/last_check_at, exit_ip, latency.
- mark_health(ok=True) consecutive_fails=0, enabled=true, last_ok_at/last_check_at,
exit_ip, latency. enabled=true реанимация: узел, выключенный
ранее авто-disable'ом, возвращается в строй первой же успешной
пробой (см. run_proxy_healthcheck).
- mark_health(ok=False) consecutive_fails += 1; при достижении DISABLE_THRESHOLD прокси
авто-disable (enabled=false), чтобы битый узел выпал из пула.
- acquire отфильтровывает enabled=false И consecutive_fails >= MAX_FAILS.
Self-healing (#2600):
- run_proxy_healthcheck проверяет не только enabled-узлы, но и disabled реже, раз в
DISABLED_RECHECK_MINUTES (или если ни разу не проверялся). Успешная проба выключенного
узла реанимирует его (enabled=true), инкрементит счётчик `revived` и пишет INFO-лог.
Без этого auto-disable необратим: транзиентный сбой = вечный приговор узлу.
- acquire, не найдя свободного здорового узла нужной provider_affinity, вторым заходом
берёт любой свободный здоровый узел ЛЮБОЙ affinity (WARNING-лог) иначе источник
голодает при живых свободных узлах чужой affinity. Fallback НЕ забирает последний
enabled-узел выделенной affinity (пример domclick, один узел на всё, см. acquire
docstring) иначе чинили бы один источник ценой полной поломки другого.
psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type.
"""
@ -36,6 +50,7 @@ from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
__all__ = [
"DISABLED_RECHECK_MINUTES",
"DISABLE_THRESHOLD",
"MAX_CONSECUTIVE_FAILS",
"NON_RUN_LEASE_MARKER",
@ -62,6 +77,12 @@ DISABLE_THRESHOLD = 5
# освобождается reap_stale_leases — иначе прокси навсегда «занят» мёртвым run'ом.
STALE_LEASE_MINUTES = 30
# Disabled-узлы перепроверяются не каждый прогон (это долбёж по мёртвому/дорогому
# провайдеру), а раз в это число минут — либо если ни разу не проверялся. Успешная
# проба реанимирует узел (см. run_proxy_healthcheck). Без recheck'а auto-disable
# необратим: транзиентный сбой = вечный приговор (#2600).
DISABLED_RECHECK_MINUTES = 60
# Маркер lease для не-run вызовов (leased_by NOT NULL = занят, но это не id из scrape_runs).
NON_RUN_LEASE_MARKER = -1
@ -90,10 +111,25 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
(last_ok_at NULLS LAST). Затем помечает строку leased_by=run_id (или
NON_RUN_LEASE_MARKER если run_id не задан) и коммитит.
Если свободных здоровых узлов нужной affinity (provider/'any') нет вторым заходом
берётся любой свободный здоровый узел ЛЮБОЙ affinity (тот же ORDER BY/FOR UPDATE SKIP
LOCKED), с WARNING-логом. Приоритет не меняется: своя affinity всегда предпочтительнее,
чужая только запасной вариант, чтобы источник не голодал при живых свободных узлах
чужой affinity (#2600).
Fallback НЕ трогает последний enabled-узел выделенной (не-'any') affinity см.
173_scrape_proxies_add_domclick_affinity.sql: у domclick ровно один узел (id=1),
намеренно вырезанный из общего пула, потому что QRATOR банит все прокси кроме этого
одного чистого residential-адреса. Если fallback заберёт его под avito/cian/yandex,
domclick останется без прокси вообще хуже, чем голодание исходного источника,
которое фикс призван устранить. Кандидат участвует в fallback, только если его
affinity='any' ИЛИ у этой affinity есть ДРУГОЙ enabled-узел (EXISTS-подзапрос)
т.е. выдача не обнулит доступность выделенной affinity целиком.
Конкурентные acquire не дерутся за одну строку: SKIP LOCKED пропускает залоченную
другим вызовом строку, второй параллельный acquire берёт следующую свободную.
Returns ProxyLease или None если свободных здоровых прокси нет.
Returns ProxyLease или None если свободных здоровых прокси нет вообще.
"""
lease_marker = run_id if run_id is not None else NON_RUN_LEASE_MARKER
@ -117,6 +153,44 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
.mappings()
.fetchone()
)
fallback_used = False
if row is None:
# Нет своих (provider/'any') — запасной заход: любой свободный здоровый узел
# ЛЮБОЙ affinity, кроме последнего enabled-узла выделенной affinity (domclick и
# т.п.) — EXISTS-подзапрос требует хотя бы ОДИН ДРУГОЙ enabled-узел той же
# affinity, иначе affinity='any' достаточно.
row = (
db.execute(
text(
"""
SELECT sp.id, sp.url, sp.kind, sp.rotate_url
FROM scrape_proxies AS sp
WHERE sp.enabled
AND sp.consecutive_fails < CAST(:max_fails AS integer)
AND sp.leased_by IS NULL
AND (
sp.provider_affinity = 'any'
OR EXISTS (
SELECT 1
FROM scrape_proxies AS other
WHERE other.provider_affinity = sp.provider_affinity
AND other.enabled
AND other.id <> sp.id
)
)
ORDER BY sp.last_ok_at NULLS LAST, sp.id
FOR UPDATE SKIP LOCKED
LIMIT 1
"""
),
{"max_fails": MAX_CONSECUTIVE_FAILS},
)
.mappings()
.fetchone()
)
fallback_used = row is not None
if row is None:
db.rollback() # снять FOR UPDATE-транзакцию (ничего не залочено, но чисто)
return None
@ -133,9 +207,18 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
{"run_id": lease_marker, "id": proxy_id},
)
db.commit()
logger.info(
"proxy_pool: leased proxy id=%d provider=%s by=%s", proxy_id, provider, lease_marker
)
if fallback_used:
logger.warning(
"proxy_pool: leased proxy id=%d provider=%s by=%s — FALLBACK affinity "
"(no free healthy proxy of matching affinity, issuing proxy of other affinity)",
proxy_id,
provider,
lease_marker,
)
else:
logger.info(
"proxy_pool: leased proxy id=%d provider=%s by=%s", proxy_id, provider, lease_marker
)
return ProxyLease(
id=proxy_id,
url=str(row["url"]),
@ -167,13 +250,24 @@ def mark_health(
*,
exit_ip: str | None = None,
latency_ms: int | None = None,
fail_kind: str | None = None,
) -> None:
"""Записать результат health-check'а прокси.
ok=True consecutive_fails обнуляется, обновляются last_ok_at/last_check_at/
exit_ip/latency_ms.
ok=True consecutive_fails обнуляется, enabled=true, обновляются last_ok_at/
last_check_at/exit_ip/latency_ms. enabled=true безусловно это реанимация:
узел, ранее выключенный auto-disable'ом, возвращается в строй первой же
успешной пробой (см. run_proxy_healthcheck, #2600 п.1).
ok=False consecutive_fails += 1; при достижении DISABLE_THRESHOLD прокси
авто-disable (enabled=false). last_check_at обновляется в любом случае.
fail_kind необязательная классификация неуспеха ("timeout" / "connect_error" /
"http_error" / "other", см. _probe_proxy), используется ТОЛЬКО для логирования.
Счётчик consecutive_fails/порог disable инкрементится одинаково для любого fail_kind
аккуратное разделение "транзиентный сбой vs перманентный бан" (разные пороги/скорость
инкремента по типу ошибки) требует более глубокой переработки модуля (отдельный
трекинг по типам ошибок, вероятно per-fail_kind счётчики) и намеренно НЕ сделано в
рамках #2600 п.2 — см. обоснование в PR. fail_kind — задел под это на будущее.
"""
if ok:
db.execute(
@ -185,6 +279,7 @@ def mark_health(
last_check_at = now(),
exit_ip = CAST(:exit_ip AS text),
latency_ms = CAST(:latency_ms AS integer),
enabled = true,
updated_at = now()
WHERE id = CAST(:id AS bigint)
"""
@ -210,7 +305,13 @@ def mark_health(
{"disable_threshold": DISABLE_THRESHOLD, "id": proxy_id},
)
db.commit()
logger.info("proxy_pool: mark_health id=%d ok=%s exit_ip=%s", proxy_id, ok, exit_ip)
logger.info(
"proxy_pool: mark_health id=%d ok=%s exit_ip=%s fail_kind=%s",
proxy_id,
ok,
exit_ip,
fail_kind,
)
def reap_stale_leases(db: Session, older_than_minutes: int = STALE_LEASE_MINUTES) -> int:
@ -237,10 +338,17 @@ def reap_stale_leases(db: Session, older_than_minutes: int = STALE_LEASE_MINUTES
return len(rows)
async def _probe_proxy(url: str) -> tuple[bool, str | None, int | None]:
async def _probe_proxy(url: str) -> tuple[bool, str | None, int | None, str | None]:
"""GET ipify через прокси (timeout _HEALTH_PROBE_TIMEOUT_S).
Returns (ok, exit_ip, latency_ms). ok=False + (None, None) при любой ошибке.
Returns (ok, exit_ip, latency_ms, fail_kind). При успехе fail_kind=None. При неуспехе
exit_ip/latency_ms=None, а fail_kind классифицирует что случилось (#2600 п.2 —
транзиентный сбой узла перманентный бан, используется пока только для логов):
- "timeout" сеть недоступна/медленная (httpx.TimeoutException)
- "connect_error" прокси не поднят/не слушает/DNS (httpx.ConnectError)
- "http_error" ipify ответил ошибкой через прокси (auth/upstream)
- "other" прочее
url несёт схему (http:// / socks5://) httpx[socks] обрабатывает оба.
"""
started = time.monotonic()
@ -250,10 +358,23 @@ async def _probe_proxy(url: str) -> tuple[bool, str | None, int | None]:
resp.raise_for_status()
ip = resp.json().get("ip")
latency_ms = int((time.monotonic() - started) * 1000)
return True, (str(ip) if ip else None), latency_ms
return True, (str(ip) if ip else None), latency_ms, None
except httpx.TimeoutException:
logger.warning("proxy_pool: health probe timeout proxy=%s", _mask(url))
return False, None, None, "timeout"
except httpx.ConnectError:
logger.warning("proxy_pool: health probe connect_error proxy=%s", _mask(url))
return False, None, None, "connect_error"
except httpx.HTTPStatusError as exc:
logger.warning(
"proxy_pool: health probe http_error proxy=%s status=%s",
_mask(url),
exc.response.status_code,
)
return False, None, None, "http_error"
except Exception:
logger.warning("proxy_pool: health probe failed proxy=%s", _mask(url), exc_info=True)
return False, None, None
return False, None, None, "other"
def _mask(url: str) -> str:
@ -269,16 +390,23 @@ def _mask(url: str) -> str:
async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
"""Периодический health-check всех enabled-прокси пула (#2162).
"""Периодический health-check прокси пула — enabled каждый прогон, disabled реже (#2162, #2600).
Сначала reap_stale_leases (освобождает протухшие lease'ы), затем для каждого
enabled-прокси гоняет ipify-пробу через сам прокси и пишет результат через
mark_health (успех сброс fails + свежий exit_ip/latency; фейл инкремент,
авто-disable при DISABLE_THRESHOLD).
Сначала reap_stale_leases (освобождает протухшие lease'ы), затем гоняет ipify-пробу
через каждый кандидат и пишет результат через mark_health (успех сброс fails +
enabled=true + свежий exit_ip/latency; фейл инкремент, авто-disable при
DISABLE_THRESHOLD).
Кандидаты: ВСЕ enabled-узлы (как раньше) + disabled-узлы, которые ни разу не
проверялись (last_check_at IS NULL) или проверялись давнее DISABLED_RECHECK_MINUTES
назад. Без этого auto-disable необратим узел, ушедший в disable из-за транзиентного
сбоя, никогда больше не проверяется и не может вернуться (#2600 п.1). Успешная проба
disabled-узла реанимирует его (enabled=true через mark_health) инкрементит `revived`
и пишет отдельный INFO-лог.
Пробы идут последовательно пул небольшой (десятки узлов), а параллельный залп на
один и тот же upstream-endpoint (ipify) не нужен. Returns counters
{reaped, checked, ok, failed}.
{reaped, checked, ok, failed, revived}.
"""
reaped = reap_stale_leases(db)
@ -286,12 +414,17 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
db.execute(
text(
"""
SELECT id, url, kind
SELECT id, url, kind, enabled
FROM scrape_proxies
WHERE enabled
OR last_check_at IS NULL
OR last_check_at < now() - make_interval(
mins => CAST(:disabled_recheck_minutes AS integer)
)
ORDER BY id
"""
)
),
{"disabled_recheck_minutes": DISABLED_RECHECK_MINUTES},
)
.mappings()
.all()
@ -300,22 +433,38 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
checked = 0
ok_count = 0
failed = 0
revived = 0
for row in proxies:
proxy_id = int(row["id"])
url = str(row["url"])
ok, exit_ip, latency_ms = await _probe_proxy(url)
mark_health(db, proxy_id, ok, exit_ip=exit_ip, latency_ms=latency_ms)
was_disabled = not bool(row["enabled"])
ok, exit_ip, latency_ms, fail_kind = await _probe_proxy(url)
mark_health(db, proxy_id, ok, exit_ip=exit_ip, latency_ms=latency_ms, fail_kind=fail_kind)
checked += 1
if ok:
ok_count += 1
if was_disabled:
revived += 1
logger.info(
"proxy_pool: REVIVED proxy id=%d — successful probe of a disabled node, "
"returned to service (enabled=true, consecutive_fails=0)",
proxy_id,
)
else:
failed += 1
logger.info(
"proxy_pool: healthcheck done — reaped=%d checked=%d ok=%d failed=%d",
"proxy_pool: healthcheck done — reaped=%d checked=%d ok=%d failed=%d revived=%d",
reaped,
checked,
ok_count,
failed,
revived,
)
return {"reaped": reaped, "checked": checked, "ok": ok_count, "failed": failed}
return {
"reaped": reaped,
"checked": checked,
"ok": ok_count,
"failed": failed,
"revived": revived,
}

View file

@ -0,0 +1,370 @@
"""Ротация exit-IP прокси ASocks по требованию, со счётчиком и громким отказом (#2600 п.5).
АДДИТИВНО. НЕ трогает app.services.proxy_pool (pick/lease/health параллельный
PR #2609, конфликт исключён: вся новая логика тут, в новом модуле).
Контекст (эмпирика, issue #2600 п.5 — проверено владельцем аккаунта/пробой):
- Документированный публичный API ASocks (GET /v2/proxy/refresh/{portId}?apiKey=)
для безлимитных портов НЕ работает.
- Ротация сменой session-суффикса логина (-session-N) НЕ работает exit-IP
не меняется (три варианта дали один и тот же IP).
- Единственный рабочий путь ручка веб-кабинета:
POST https://api.asocks.com/unlimited-proxy/{portId}/refresh-ip
Authorization: Bearer <токен>
Без заголовка провайдер отдаёт 401 {"success": false, "message": "Unauthenticated"}.
scrape_proxies.rotate_url уже несёт этот URL (миграция 199) токен НЕ в URL,
он только в ASOCKS_API_TOKEN (env, app.core.config.settings.asocks_api_token).
- Лимит провайдера: 3 ротации в сутки на порт.
- Токен сессионный, однажды протухнет (осознанное решение владельца аккаунта).
Когда это случится, провайдер ответит 401 это ГРОМКИЙ отказ ниже
(logger.error + Sentry/GlitchTip capture_message), а не молчаливая остановка.
Суточный лимит и таблица истории (scrape_proxy_rotations, миграция 198):
Против лимита 3/сутки считаются ТОЛЬКО попытки, реально дошедшие до провайдера
и обработанные им т.е. любой HTTP-ответ провайдера, КРОМЕ 401. Обоснование:
401 это буквально описание провайдера "Unauthenticated": запрос отсеян на
уровне аутентификации ДО обращения к самой логике ротации порта, провайдер не
мог засчитать использование ротации тому, кого даже не подтвердил. Сетевые
ошибки (таймаут / разрыв соединения ответа вообще нет) по той же логике не
считаются: нет подтверждения, что запрос вообще дошёл до провайдера. Локальные
отказы (нет rotate_url / нет токена / лимит уже исчерпан) до HTTP-вызова не
доходят вовсе в таблицу не пишутся и лимит не трогают.
quota-consuming := http_status IS NOT NULL AND http_status != 401
(успех 200 И любой не-401 ответ провайдера, включая его собственные 4xx/5xx
если провайдер прошёл auth и ответил бизнес-ошибкой, запрос точно дошёл до
реальной rotate-логики и мог быть учтён в лимите на его стороне).
Токен никогда не должен появиться в возвращаемом клиенту reason, в тексте
исключения, ни в одной записи scrape_proxy_rotations. Прецедент утечки через
str(exc) см. комментарий в app.api.v1.admin.rotate_proxy_ip (~line 2400):
httpx-исключения несут полный request URL/детали, поэтому наружу только
нейтральный reason, полные детали в лог с exc_info=True.
Хост-пиннинг (security review PR #2611): scrape_proxies.rotate_url колонка
НЕОДНОРОДНА часть строк пула (id 3/4/5 на проде) несёт mobileproxy changeip-
ссылки (`https://changeip.mobileproxy.space/?proxy_key=<секрет mobileproxy>`,
см. app.api.v1.admin._provider_rotate_url / avito_proxy_rotate_url), не ASocks.
Без явной проверки хоста наш `Authorization: Bearer <ASOCKS_API_TOKEN>` ушёл бы
на ЧУЖОЙ провайдер (mobileproxy) плюс сам GET/POST по их changeip, вероятно,
реально ротирует ИХ IP и тратит ИХ суточный лимит, а мы бы записали это как
успех ASocks. rotate_proxy ПЕРЕД любым HTTP-вызовом проверяет
urlparse(rotate_url).hostname == ALLOWED_ROTATE_HOST (https-only) несовпадение
это ОТКАЗ (ok=False, нейтральный reason), а НЕ попытка безголового запроса без
Authorization: смысл ручной ротации конкретный провайдер (ASocks), молчаливый
вызов чужой ручки без авторизации это сюрприз оператору (он думает "ASocks
ротировал", а фактически задел mobileproxy), которого проще не допустить, чем
потом объяснять админу расхождение счётчиков.
psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any
from urllib.parse import urlparse
import httpx
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings
logger = logging.getLogger(__name__)
__all__ = [
"ALLOWED_ROTATE_HOST",
"DAILY_ROTATION_LIMIT",
"RotationResult",
"rotate_proxy",
]
# Лимит провайдера (ASocks, безлимитные порты): 3 ротации в сутки на порт (эмпирика).
DAILY_ROTATION_LIMIT = 3
# Таймаут POST refresh-ip. Пункт задачи требует "~30с".
_ROTATE_TIMEOUT_S = 30.0
# Единственный хост, на который разрешено уходить с ASOCKS_API_TOKEN в заголовке
# (см. "⛔ Хост-пиннинг" в docstring модуля). scrape_proxies.rotate_url может
# нести ЧУЖИЕ changeip-ссылки (mobileproxy и т.п.) — сравнение ДО HTTP-вызова.
ALLOWED_ROTATE_HOST = "api.asocks.com"
def _is_allowed_rotate_url(url: str) -> bool:
"""https-only + hostname точно ALLOWED_ROTATE_HOST (регистронезависимо —
urlparse().hostname уже лоуеркейзит). Не бросает исключений на кривом url."""
try:
parsed = urlparse(url)
except ValueError:
return False
return parsed.scheme == "https" and parsed.hostname == ALLOWED_ROTATE_HOST
@dataclass
class RotationResult:
"""Результат попытки ротации exit-IP одного прокси. reason — ВСЕГДА нейтральный
(безопасен для HTTP-ответа клиенту), никогда не несёт токен/секреты."""
ok: bool
reason: str | None
new_ip: str | None = None
# Сколько quota-consuming попыток остаётся сегодня ПОСЛЕ этой попытки (см. модуль
# docstring за определением quota-consuming). Для локально отклонённых попыток
# (no rotate_url/no token) не относится к текущему прокси — просто текущий остаток.
rotations_remaining_today: int = DAILY_ROTATION_LIMIT
def _quota_used_today(db: Session, proxy_id: int) -> int:
"""Число quota-consuming попыток за последние 24ч (см. docstring модуля).
http_status IS NOT NULL AND != 401 успех И любой не-401 ответ провайдера.
401 (auth-отсев) и сетевые ошибки (http_status IS NULL) не считаются.
"""
row = (
db.execute(
text(
"""
SELECT count(*) AS n
FROM scrape_proxy_rotations
WHERE proxy_id = CAST(:proxy_id AS bigint)
AND rotated_at > now() - interval '24 hours'
AND http_status IS NOT NULL
AND http_status != 401
"""
),
{"proxy_id": proxy_id},
)
.mappings()
.fetchone()
)
return int(row["n"]) if row is not None else 0
def _record_attempt(
db: Session,
proxy_id: int,
*,
success: bool,
http_status: int | None,
note: str | None,
) -> None:
"""Записать попытку ротации в аудит-таблицу. Вызывается ТОЛЬКО когда HTTP-запрос
к провайдеру реально был сделан (локально отклонённые попытки не пишутся
см. модуль docstring)."""
db.execute(
text(
"""
INSERT INTO scrape_proxy_rotations (proxy_id, success, http_status, note)
VALUES (
CAST(:proxy_id AS bigint),
CAST(:success AS boolean),
CAST(:http_status AS integer),
CAST(:note AS text)
)
"""
),
{"proxy_id": proxy_id, "success": success, "http_status": http_status, "note": note},
)
db.commit()
def _alert_stale_token(proxy_id: int) -> None:
"""Громкий отказ на 401: logger.error + событие в Sentry/GlitchTip (best-effort).
401 значит, что провайдер отверг Authorization-заголовок токен протух (issue
#2600 п.5: "Токен — сессионный, однажды протухнет. Это осознанное решение
владельца"). Молчаливая остановка ротации недопустима — операторы должны узнать
об этом сразу, а не когда прокси уже забанены неделю.
"""
logger.error(
"proxy_rotation: ASocks REJECTED Authorization (401) for proxy_id=%d"
"ASOCKS_API_TOKEN likely EXPIRED, IP rotation is now BLOCKED for this proxy "
"until the token is refreshed in web-cabinet + env",
proxy_id,
)
try:
import sentry_sdk
sentry_sdk.capture_message(
f"ASocks rotation token rejected (401) for proxy_id={proxy_id}"
"ASOCKS_API_TOKEN expired, IP rotation blocked until refreshed",
level="error",
)
except Exception:
pass # sentry_sdk not initialised in dev — best-effort only
def _extract_new_ip(resp: httpx.Response) -> str | None:
"""Best-effort вытащить новый exit-IP из ответа провайдера. Формат ответа
refresh-ip для безлимитных портов ASocks не документирован (issue #2600 п.5) —
парсинг заведомо defensive, неудача не является ошибкой ротации."""
try:
data: Any = resp.json()
except Exception:
return None
if not isinstance(data, dict):
return None
for key in ("new_ip", "ip", "exit_ip"):
val = data.get(key)
if val:
return str(val)
nested = data.get("data")
if isinstance(nested, dict):
for key in ("new_ip", "ip", "exit_ip"):
val = nested.get(key)
if val:
return str(val)
return None
async def rotate_proxy(db: Session, proxy_id: int) -> RotationResult:
"""Сменить exit-IP одного прокси пула через ASocks refresh-ip (#2600 п.5).
Порядок:
1. proxy_id не найден в scrape_proxies ok=False, reason нейтральный.
2. rotate_url пусто ok=False, "ротация не поддерживается" (НЕ ошибка).
3. rotate_url хост != ALLOWED_ROTATE_HOST (https://api.asocks.com) ok=False
ДО HTTP-вызова токен не должен уйти на чужой провайдер (mobileproxy
changeip и т.п. в этой же колонке пула, см. "⛔ Хост-пиннинг" в модуле).
4. ASOCKS_API_TOKEN не задан (settings.asocks_api_token) ok=False,
внятный отказ, ничего не ломается.
5. Суточный лимит (см. _quota_used_today) исчерпан ok=False, отказ БЕЗ
обращения к API.
6. POST rotate_url с Authorization: Bearer <token>, timeout ~30с.
- Сетевая ошибка (нет ответа) ok=False, аудит-запись http_status=NULL
(НЕ считается в лимите), нейтральный reason, детали в лог exc_info=True.
- 401 громкий отказ (_alert_stale_token) + аудит-запись (НЕ считается
в лимите), нейтральный reason.
- Другой 4xx/5xx аудит-запись (считается в лимите провайдер прошёл
auth и ответил своей бизнес-логикой), нейтральный reason.
- 2xx аудит-запись success=True (считается в лимите), new_ip best-effort.
Ни в одном из reason/логов НЕ появляется токен.
"""
row = (
db.execute(
text("SELECT id, rotate_url FROM scrape_proxies WHERE id = CAST(:id AS bigint)"),
{"id": proxy_id},
)
.mappings()
.fetchone()
)
if row is None:
return RotationResult(ok=False, reason="proxy not found")
rotate_url = row["rotate_url"]
if not rotate_url:
logger.info(
"proxy_rotation: proxy_id=%d has no rotate_url — rotation not supported", proxy_id
)
return RotationResult(
ok=False, reason="rotation not supported for this proxy (no rotate_url configured)"
)
if not _is_allowed_rotate_url(rotate_url):
# scrape_proxies.rotate_url колонка неоднородна (другие строки пула несут
# mobileproxy changeip-ссылки с ИХ секретом) — отправлять наш
# Authorization: Bearer <ASOCKS_API_TOKEN> на непроверенный хост нельзя.
# Логируем ТОЛЬКО hostname (не полный url — на других провайдерах он
# несёт их собственный секрет в query-string, тот же класс утечки, что
# и в rotate_proxy_ip, см. модуль docstring).
logger.warning(
"proxy_rotation: proxy_id=%d rotate_url host=%r is not the allowed ASocks host "
"(%s) — refusing before any HTTP call to avoid leaking the token to it",
proxy_id,
urlparse(rotate_url).hostname,
ALLOWED_ROTATE_HOST,
)
return RotationResult(
ok=False, reason="rotation not supported for this proxy (unexpected rotate host)"
)
token = settings.asocks_api_token
if not token:
logger.warning(
"proxy_rotation: ASOCKS_API_TOKEN not configured — proxy_id=%d rotation skipped",
proxy_id,
)
return RotationResult(ok=False, reason="rotation not configured (missing API token)")
used = _quota_used_today(db, proxy_id)
if used >= DAILY_ROTATION_LIMIT:
logger.warning(
"proxy_rotation: daily limit reached proxy_id=%d used=%d/%d — skipping API call",
proxy_id,
used,
DAILY_ROTATION_LIMIT,
)
return RotationResult(
ok=False,
reason=f"daily rotation limit reached ({DAILY_ROTATION_LIMIT}/day)",
rotations_remaining_today=0,
)
try:
async with httpx.AsyncClient(timeout=_ROTATE_TIMEOUT_S) as client:
resp = await client.post(rotate_url, headers={"Authorization": f"Bearer {token}"})
except Exception as exc:
# Ответа не было вообще — не подтверждено, что запрос дошёл до провайдера,
# значит квота НЕ тратится. str(exc) НИКОГДА не идёт наружу (может нести
# служебные детали соединения) — только exc_info=True в лог. type(exc).__name__
# секрета не несёт (это имя класса — ConnectError/ReadTimeout/…) и в note
# ПОЛЕЗЕН оператору: отличить "не дозвонились" от "дозвонились, зависли".
logger.warning(
"proxy_rotation: request failed (no response) proxy_id=%d", proxy_id, exc_info=True
)
_record_attempt(
db,
proxy_id,
success=False,
http_status=None,
note=f"request failed: {type(exc).__name__}",
)
return RotationResult(
ok=False,
reason="rotation request failed (network error)",
rotations_remaining_today=max(0, DAILY_ROTATION_LIMIT - used),
)
status = resp.status_code
if status == 401:
_alert_stale_token(proxy_id)
_record_attempt(
db,
proxy_id,
success=False,
http_status=401,
note="unauthenticated — token expired/invalid (excluded from daily quota)",
)
return RotationResult(
ok=False,
reason="rotation service rejected credentials — alerted, contact operator",
rotations_remaining_today=max(0, DAILY_ROTATION_LIMIT - used),
)
if status >= 400:
logger.warning(
"proxy_rotation: provider returned error proxy_id=%d status=%d", proxy_id, status
)
_record_attempt(
db, proxy_id, success=False, http_status=status, note="provider returned error"
)
return RotationResult(
ok=False,
reason=f"rotation request failed (provider status {status})",
rotations_remaining_today=max(0, DAILY_ROTATION_LIMIT - (used + 1)),
)
new_ip = _extract_new_ip(resp)
logger.info("proxy_rotation: rotated proxy_id=%d status=%d new_ip=%s", proxy_id, status, new_ip)
_record_attempt(db, proxy_id, success=True, http_status=status, note=None)
return RotationResult(
ok=True,
reason=None,
new_ip=new_ip,
rotations_remaining_today=max(0, DAILY_ROTATION_LIMIT - (used + 1)),
)

View file

@ -41,12 +41,20 @@ from app.services import scrape_runs as runs_mod
# Нижняя граница ppm² — отсекает нежилые/технические сделки; не меняется.
_PPM2_MIN: int = 30_000
# #C2 — asking-сторона (listings) покрыта скрейпом ТОЛЬКО по ЕКБ (per-city scrape B1/B2
# ещё нет; в listings даже нет колонки city). Миграция 177 залила ДКП-сделки по всей
# обл.66 (368 городов) → sold-медиана смешивала дешёвую область с ЕКБ-asking и обваливала
# ratio (0.877→0.62, «выкупная» 29% системно). Скоупим SOLD-сторону (deal_side/deal_global)
# на ЕКБ, чтобы sold и asking считались по ОДНОМУ рынку. Когда появятся oblast-листинги —
# заменить на per-city ratio через зарезервированный столбец `district` (#647).
# #C2 — исторически asking-сторона (listings) была покрыта скрейпом ТОЛЬКО по ЕКБ, а
# миграция 177 залила ДКП-сделки по всей обл.66 (368 городов) → sold-медиана смешивала
# дешёвую область с ЕКБ-asking и обваливала ratio (0.877→0.62, «выкупная» 29% системно).
# Скоупили SOLD-сторону (deal_side/deal_global) на ЕКБ, чтобы sold и asking считались по
# ОДНОМУ рынку.
#
# #2583 H2 (аудит, 2026-08): oblast-развёртки заработали 12 июля — областные объявления
# попали в знаменатель (ask_side/ask_global) без городского скоупа, а sold-сторона
# осталась скоуплена на ЕКБ → асимметрия вернулась с другой стороны (дешёвая область
# занижает ask-медиану → ratio завышен на 2.5-5.3% по всем бакетам, выкупные цены
# системно переплачены). Теперь ask_side/ask_global ТОЖЕ скоупятся этим паттерном
# (предикат `city IS NULL OR city ILIKE :asking_city` — см. комментарий на месте в CTE
# ниже) — симметрично deal-стороне. Когда появится per-city ratio через зарезервированный
# столбец `district` (#647), эта константа станет per-city параметром для обеих сторон.
_ASKING_CITY_PATTERN: str = "%Екатеринбург%"
# Верхняя граница берётся из settings.asking_ratio_ppm2_max (default 1_200_000).
# QA-note: точное значение сверить с `SELECT max(price_per_m2) FROM deals
@ -71,7 +79,8 @@ _DELETE_SQL = text(
# ppm² ∈ [_PPM2_MIN, settings.asking_ratio_ppm2_max], deal_date >= CURRENT_DATE 12 months),
# бакет LEAST(GREATEST(rooms,0),4).
# ask_median = percentile_cont(0.5) по listings.price_per_m2
# (is_active, та же ppm²-полоса [_PPM2_MIN, asking_ratio_ppm2_max]).
# (is_active, та же ppm²-полоса [_PPM2_MIN, asking_ratio_ppm2_max], тот же город что
# SOLD-сторона — city IS NULL OR city ILIKE :asking_city, #2583 H2).
# per_rooms строки — только при n_deals>=30 AND n_listings>=30 AND ask>0 AND sold>0.
# global -1 строка (basis='global_fallback') — всегда (если ask>0 AND sold>0). window_months=12.
# Порог/окно — литералы; ppm²-полоса передаётся bind-параметрами :ppm2_min/:ppm2_max
@ -105,6 +114,15 @@ _REDERIVE_SQL = text(
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
-- novostroyki guard (#1186): NULL = legacy вторичка до м.011
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
-- #2583 H2: скоупим ASKING-сторону на тот же город, что и SOLD-сторона
-- (симметрично deal_side выше) иначе дешёвые oblast-объявления (развёртки
-- с 12 июля) занижают ask-медиану и завышают ratio. city IS NULL считается
-- "своим" (не отбрасывается) НАМЕРЕННО: listings.city заполнена пока только у
-- Авито (Циан/Домклик/Яндекс NULL, #2598/#2606), симметричный
-- `city ILIKE :asking_city` без IS NULL выбросил бы ~70% выборки. По мере
-- роста покрытия колонки этот предикат сам ужесточается без правок кода; когда
-- покрытие станет полным заменить на строго симметричный `city ILIKE :asking_city`.
AND (city IS NULL OR city ILIKE :asking_city)
GROUP BY LEAST(GREATEST(rooms, 0), 4)
),
-- Per-rooms строки: только бакеты с обеими сторонами, прошедшие порог 30/30 и ask>0.
@ -152,6 +170,9 @@ _REDERIVE_SQL = text(
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
-- novostroyki guard (#1186): NULL = legacy вторичка до м.011
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
-- #2583 H2: тот же городской скоуп, что и ask_side выше (см. комментарий там
-- про причину city IS NULL == "свой" и #2598/#2606).
AND (city IS NULL OR city ILIKE :asking_city)
),
-- Global fallback строка rooms_bucket=-1 (пишется всегда, если ask>0).
global_row AS (

View file

@ -10,15 +10,21 @@
Парсинг адреса _parse_street_house из app.services.geocoder (готовый парсер),
работающий с формами «г. Екатеринбург, ул. Малышева, 30, кв. 28».
Городской гейт (#2583, находка H3): в `listings` НЕТ отдельной колонки города — город
известен только из текста адреса. `ekb_geoportal_buildings` EKB-only реестр: улица+дом
могут буквально совпасть между Екатеринбургом и другим городом области (например,
«проспект Ленина 1» есть и в ЕКБ, и в Нижнем Тагиле). Без проверки города такой листинг
получает екатеринбургские координаты, хотя находится в другом городе. Перед вызовом
_geoportal_house_match каждый адрес проверяется через _names_non_ekb_city (та же функция,
что гейтит EKB-only тиры внутри geocoder.geocode()) адрес, явно называющий другой город
региона, пропускается (counted как skipped_non_ekb) и остаётся lat IS NULL для
geocode_missing_listings (oblast-aware Nominatim/Yandex, окно 06:00-09:00 UTC).
Городской гейт (#2583, находка H3; расширен #2594 шаг 2/3): `ekb_geoportal_buildings` —
EKB-only реестр: улица+дом могут буквально совпасть между Екатеринбургом и другим городом
области (например, «проспект Ленина 1» есть и в ЕКБ, и в Нижнем Тагиле). Без проверки
города такой листинг получает екатеринбургские координаты, хотя находится в другом городе.
Гейт ДВЕ проверки перед вызовом _geoportal_house_match:
1. Колонка `listings.city` (#2594, миграция 196) — если проставлена НЕ-Екатеринбургом,
листинг пропускается сразу, без обращения к тексту адреса. Это надёжный сигнал из
контекста развёртки (скрапер знает город явно), тогда как текстовый гейт полагается
на то, что город явно упомянут в самом тексте адреса.
2. _names_non_ekb_city(address) (та же функция, что гейтит EKB-only тиры внутри
geocoder.geocode()) СОХРАНЕНА как fallback для листингов, у которых city IS NULL
(записаны до миграции 196 или путём, ещё не проставляющим город, например admin
ad-hoc /admin/scrape) там единственный сигнал о городе текст адреса.
Оба пути пропуска считаются в skipped_non_ekb (адрес остаётся lat IS NULL для
geocode_missing_listings, oblast-aware Nominatim/Yandex, окно 06:00-09:00 UTC).
Прямой вызов _geoportal_house_match (а не полноценный geocode()) оставлен намеренно
это pure local-DB матч без единого внешнего HTTP-запроса; полноценный geocode() на каждый
non-EKB адрес добавил бы Nominatim/Yandex вызов на весь backlog (сотни-тысячи строк за
@ -79,7 +85,7 @@ class BackfillCoordsResult:
updated: int = 0 # реально обновлено (UPDATE rowcount)
no_address: int = 0 # listing.address IS NULL / не распарсился
no_match: int = 0 # адрес распарсился, но в реестре здания нет
skipped_non_ekb: int = 0 # адрес явно называет другой город области (#2583 гейт)
skipped_non_ekb: int = 0 # non-ЕКБ гейт: колонка city (#2594) ИЛИ текст адреса (#2583)
errors: int = 0 # исключения при обработке отдельной записи
duration_sec: float = field(default=0.0)
@ -179,7 +185,7 @@ def backfill_coords_from_geoportal(
rows = (
db.execute(
text(f"""
SELECT id, address
SELECT id, address, city
FROM listings
WHERE lat IS NULL
AND geom IS NULL
@ -210,9 +216,23 @@ def backfill_coords_from_geoportal(
res.no_address += 1
continue
# Городской гейт (#2583, H3) — ekb_geoportal_buildings EKB-only,
# улица+дом могут совпасть с другим городом области. Адрес, явно
# называющий другой город региона, пропускаем — остаётся
# Городской гейт по колонке (#2594 шаг 2/3) — ПЕРЕД матчем и ПЕРЕД
# текстовым гейтом. listings.city (миграция 196) проставляется из
# контекста развёртки скрапером — надёжнее текста адреса. Голый
# тагильский адрес без города в тексте ("ул. Победы, 30") раньше
# проходил только текстовый гейт и мог ложно сматчиться с
# одноимённым екатеринбургским домом в EKB-only реестре. Это окно
# идёт ПЕРЕД geocode_missing_listings — без гейта по колонке оно
# успевает испортить координаты первым.
city: str | None = row.get("city")
if city is not None and city != "Екатеринбург":
res.skipped_non_ekb += 1
continue
# Текстовый гейт (#2583, H3) — fallback для листингов, у которых
# колонка city пуста (записаны до миграции 196 либо путём, ещё не
# проставляющим город, напр. admin ad-hoc /admin/scrape). Адрес,
# явно называющий другой город региона, пропускаем — остаётся
# lat IS NULL для oblast-aware geocode_missing_listings.
if _names_non_ekb_city(address):
res.skipped_non_ekb += 1

View file

@ -5,15 +5,29 @@
- Scheduled: nightly via scrape_schedules (source='geocode_missing_listings', migration 110)
wired into in-app scheduler, window 06:00-09:00 UTC.
Pattern: dedup по address (1 unique address 1 geocode call UPDATE all listings).
Pattern: dedup по паре (address, city) 1 уникальная пара 1 geocode call UPDATE
всех listings с этим address+city (#2594 шаг 2/3: listings.city теперь заполняется
скрапером из контекста развёртки один и тот же текст адреса в разных городах
(«ул. Победы, 30» в ЕКБ и в Нижнем Тагиле) должен получать РАЗНЫЕ координаты, а
не схлопываться в один geocode-вызов и один UPDATE по тексту адреса).
Rate limit: Nominatim 1 req/sec (#2593: Yandex Geocoder tier удалён из geocoder).
SELECT фильтрует `is_active` (#2604 п.1): на проде очередь была на 98.5% забита
мёртвыми объявлениями чужих регионов (Новосибирск/Казань/Челябинск/) без is_active
`ORDER BY listings_count DESC` ставил их В НАЧАЛО (у мусорного адреса вида
«Новосибирская обл.,Новосибирск» сотни listings, у реального адреса 1-2), поэтому
весь batch-бюджет (Nominatim 1 req/sec) съедался мусором и до настоящих адресов дело
не доходило (8 ночных прогонов подряд: saved=0). UPDATE после успешного/неуспешного
geocode НЕ фильтрует is_active см. комментарии у соответствующих UPDATE ниже.
Отличие от /admin/geocode-missing (per-ID):
- Этот модуль группирует по address меньше API calls (dedup).
- Этот модуль группирует по (address, city) меньше API calls (dedup), но не
схлопывает разные города с одинаковым текстом адреса.
- Поддерживает all sources включая Avito (после PR #487 убрали jitter).
- Возвращает GeocodeBackfillResult с детальными counters.
- Loop-safe: SELECT фильтрует geocode_tried_at IS NULL OR tried_at < 7 days;
при geocode failure помечает tried_at=NOW() адрес не переотбирается в этом же run.
при geocode failure помечает tried_at=NOW() пара (address, city) не
переотбирается в этом же run.
"""
from __future__ import annotations
@ -53,13 +67,23 @@ async def geocode_missing_listings(
"""Geocode listings с NULL coords (любой source).
Steps:
1. SELECT DISTINCT address FROM listings WHERE lat IS NULL AND address IS NOT NULL
GROUP BY address ORDER BY COUNT(*) DESC LIMIT batch_size
(приоритет адресам с большим числом listings больший ROI per geocode call)
1. SELECT address, city FROM listings WHERE lat IS NULL AND is_active
AND address IS NOT NULL GROUP BY address, city ORDER BY COUNT(*) DESC
LIMIT batch_size
(приоритет парам address+city с большим числом listings больший ROI per
geocode call; группировка по паре, НЕ только по address #2594 шаг 2/3:
один и тот же текст адреса в разных городах разные записи. `is_active`
#2604 п.1: не тратим Nominatim-бюджет на мёртвые объявления, которые никогда
не попадут в выдачу пользователю)
2. Для каждого address:
- geocode(address, db) auto-cache (hit или miss)
- Если есть результат: UPDATE listings SET lat, lon WHERE address = :addr AND lat IS NULL
2. Для каждой пары (address, city):
- geocode(address, db, city_hint=city) auto-cache (hit или miss)
- Если есть результат: UPDATE listings SET lat, lon
WHERE address = :addr AND city IS NOT DISTINCT FROM :city AND lat IS NULL
(IS NOT DISTINCT FROM, а не `=` стандартная SQL NULL-семантика: `city = NULL`
никогда не true, поэтому обычным `=` группа с city IS NULL не обновилась бы
вообще ни для одной строки; `IS NOT DISTINCT FROM` трактует NULL=NULL как
совпадение, оставаясь строгим при непустом city нужная нам симметрия)
- PostGIS trigger (listings_set_geom_trg) автоматически обновит geom
3. Log progress каждые 50 addresses.
@ -74,25 +98,39 @@ async def geocode_missing_listings(
start = time.monotonic()
result = GeocodeBackfillResult()
# 1. Найти top-N адресов с NULL coords (DESC by occurrence count).
# Фильтруем адреса, по которым геокодер уже пробовал и не нашёл — они помечены
# 1. Найти top-N пар (address, city) с NULL coords (DESC by occurrence count).
# Группировка по паре, а не только по address (#2594 шаг 2/3) — один и тот же
# текст адреса в разных городах (напр. «ул. Победы, 30» в ЕКБ и в Нижнем Тагиле)
# это разные записи с разными координатами, их нельзя схлопывать в один
# geocode-вызов. GROUP BY address, city трактует NULL city как отдельную
# группу (стандартная SQL-семантика группировки NULL как равных друг другу).
# Фильтруем пары, по которым геокодер уже пробовал и не нашёл — они помечены
# geocode_tried_at. Повторяем попытку только если tried_at старше 7 дней (возможен
# переезд адреса в кэше или смена провайдера), либо tried_at IS NULL (ещё не пробовали).
# Это делает функцию loop-safe: при вызове несколько раз в одном прогоне
# failed-адреса не переотбираются бесконечно.
# failed-пары не переотбираются бесконечно.
#
# AND is_active (#2604 п.1) — очередь без этого фильтра на 98.5% состояла из
# is_active=false объявлений чужих регионов (Новосибирск/Казань/Челябинск/…),
# а ORDER BY listings_count DESC ставил самый мусорный адрес («Новосибирская
# обл.,Новосибирск», сотни listings) В НАЧАЛО — весь batch съедался мусором,
# который пользователь никогда не увидит (is_active=false), 8 ночных прогонов
# подряд saved=0. Активные объявления с валидным адресом почти всегда попадают
# в topN только теперь, когда мусор не конкурирует за место в LIMIT.
rows = (
db.execute(
text(
"""
SELECT address, COUNT(*) AS listings_count
SELECT address, city, COUNT(*) AS listings_count
FROM listings
WHERE lat IS NULL
AND is_active
AND address IS NOT NULL
AND length(trim(address)) >= 5
AND (geocode_tried_at IS NULL
OR geocode_tried_at < NOW() - INTERVAL '7 days')
GROUP BY address
ORDER BY listings_count DESC, address ASC
GROUP BY address, city
ORDER BY listings_count DESC, address ASC, city ASC NULLS FIRST
LIMIT :limit
"""
),
@ -117,23 +155,36 @@ async def geocode_missing_listings(
for idx, row in enumerate(rows):
address: str = row["address"]
city: str | None = row.get("city")
listings_count: int = row["listings_count"]
result.addresses_processed += 1
try:
geo = await geocode(address, db)
geo = await geocode(address, db, city_hint=city)
except Exception as exc:
logger.warning("geocode_missing: geocode raised for '%s': %s", address[:60], exc)
result.addresses_failed += 1
if not dry_run:
# Пометить tried_at чтобы адрес не переотбирался в следующих batch'ах
# этого же прогона (loop-safe backoff 7 дней).
# Пометить tried_at чтобы пара (address, city) не переотбиралась
# в следующих batch'ах этого же прогона (loop-safe backoff 7 дней).
# IS NOT DISTINCT FROM — city=NULL это отдельная группа, обычное
# `=` не поймает NULL-город и не должно задеть другой город с тем
# же текстом адреса.
# Намеренно БЕЗ `AND is_active` (#2604 п.2): tried_at — backoff-метка
# для (address, city) КАК ТЕКСТА, а не для конкретного listing.
# is_active=false дубликат этой пары и так никогда не будет выбран
# SELECT'ом заново (is_active=false исключён там навсегда) — фильтр
# здесь был бы no-op для неактивных строк. Единственный случай когда
# это имеет значение — если строка позже реактивируется (is_active
# → true): тогда tried_at уже стоит и backoff корректно защищает от
# немедленного повторного запроса того же заведомо неудачного адреса.
db.execute(
text(
"UPDATE listings SET geocode_tried_at = NOW()"
" WHERE address = :addr AND lat IS NULL"
" WHERE address = :addr AND city IS NOT DISTINCT FROM :city"
" AND lat IS NULL"
),
{"addr": address},
{"addr": address, "city": city},
)
db.commit()
continue
@ -141,18 +192,25 @@ async def geocode_missing_listings(
if geo is None:
result.addresses_failed += 1
logger.info(
"geocode_missing: NOT FOUND '%s' (used in %d listings)",
"geocode_missing: NOT FOUND '%s' city=%r (used in %d listings)",
address[:60],
city,
listings_count,
)
if not dry_run:
# Пометить tried_at — geocoder не нашёл адрес, backoff 7 дней.
# Намеренно БЕЗ `AND is_active` (#2604 п.2) — то же обоснование, что
# и в except-ветке выше: backoff привязан к тексту (address, city),
# не к конкретному listing, is_active=false строка и так не выбирается
# SELECT'ом заново; при реактивации backoff корректно защитит от
# немедленного повтора заведомо неудачного запроса.
db.execute(
text(
"UPDATE listings SET geocode_tried_at = NOW()"
" WHERE address = :addr AND lat IS NULL"
" WHERE address = :addr AND city IS NOT DISTINCT FROM :city"
" AND lat IS NULL"
),
{"addr": address},
{"addr": address, "city": city},
)
db.commit()
continue
@ -183,16 +241,37 @@ async def geocode_missing_listings(
# UPDATE listings — PostGIS trigger (listings_set_geom_trg) обновит geom автоматически.
# geo_precision и geocode_tried_at проставляются одновременно с координатами.
# city IS NOT DISTINCT FROM :city — обновляем ТОЛЬКО пару (address, city), из
# которой был geocode-запрос; иначе тот же текст адреса в другом городе
# (city IS NULL или другой явный город) перезаписался бы чужими координатами.
#
# Намеренно БЕЗ `AND is_active` (#2604 п.1): координаты — свойство физического
# адреса, а не свойство конкретного объявления. Если у этой же пары
# (address, city) есть is_active=false дубликат с lat IS NULL, он получит те же
# координаты бесплатно — Nominatim-вызов уже оплачен геокодом активного
# листинга, доп. запроса не будет. SELECT выше и так навсегда исключает
# is_active=false строки из очереди — без этого UPDATE такой дубликат остался
# бы с NULL lat/lon НАВСЕГДА (переезд в EKB-only локальные реестры/analytics по
# координатам сломан для него), хотя ответ уже есть в руках. Единственный
# довод «за» фильтр — консистентность с SELECT — не перевешивает: это не
# ошибка данных (координаты адреса объективны и не зависят от активности),
# а чистый выигрыш (та же строка при реактивации уже готова, доп. cost = 0).
update_result = db.execute(
text(
"""
UPDATE listings
SET lat = :lat, lon = :lon, geo_precision = :precision,
geocode_tried_at = NOW()
WHERE address = :addr AND lat IS NULL
WHERE address = :addr AND city IS NOT DISTINCT FROM :city AND lat IS NULL
"""
),
{"lat": geo.lat, "lon": geo.lon, "precision": precision, "addr": address},
{
"lat": geo.lat,
"lon": geo.lon,
"precision": precision,
"addr": address,
"city": city,
},
)
db.commit()
result.listings_updated += update_result.rowcount
@ -293,6 +372,13 @@ async def run_geocode_missing_listings(
)
break
if res.addresses_total < batch_size:
# #2604 п.3: с is_active-фильтром в SELECT очередь резко уже (была
# 14294 строк/98.5% мёртвых, стало ~220 активных → десятки уникальных
# пар address+city после GROUP BY) — этот дренаж почти всегда сработает
# уже на первой итерации (addresses_total < default batch_size=200), и
# это ПРАВИЛЬНОЕ поведение: разгребли всё что было, ждём следующего
# прогона. Никакого деления тут нет (только сравнение int), пустая
# очередь (addresses_total=0) ловится веткой выше, а не этой.
logger.info(
"run_geocode_missing_listings: run_id=%d — дренаж "
"(addresses_total=%d < batch_size=%d), завершаем",

View file

@ -9,13 +9,34 @@ listing_source_events. Так история per-source цены копится
через product_handlers._job_listing_source_snapshot,
по образцу import_rosreestr_dkp (sync task в run_in_executor).
Вся работа два set-based SQL statement'а (snapshot upsert + event-diff CTE),
никакого row-by-row Python: 18 355 строк обслуживаются одним INSERT SELECT каждый.
Вся работа два set-based SQL statement'а (snapshot upsert + event-diff), никакого
row-by-row Python.
#2607 — root cause висящих прогонов (ежедневный zombie с минимум 19 июля, всегда ровно 6h
до zombie-порога): event-diff раньше писал "prior" как CTE `DISTINCT ON (listing_source_id)
... ORDER BY listing_source_id, snapshot_date DESC` по ВСЕЙ listing_source_snapshots (~2.6-2.8M
строк) и джойнил её с "today" через обычный JOIN. Планировщик оценивает `snapshot_date =
CURRENT_DATE` в 1 строку (статистика ANALYZE ещё не видела свежевставленные в этой же
транзакции строки today CURRENT_DATE всегда за пределами гистограммы), выбирает Nested
Loop БЕЗ Materialize на внутренней стороне и на КАЖДУЮ реальную строку today (~80-140k)
заново пересчитывает DISTINCT ON по всей таблице (Unique + Index Scan ~2.7M строк)
EXPLAIN на проде показал cost300k именно на этом шаге. Реально это никогда не завершалось
за 6h, оставляя backend 'active' на сутки после того как zombie-детектор помечал
scrape_runs.status='zombie' (детектор НЕ убивает backend, см. reap_zombies) держало
backend_xmin, блокируя autovacuum на listings/listing_sources.
Fix: `prior` переписан через `JOIN LATERAL (... ORDER BY snapshot_date DESC LIMIT 1) ON true`
форсирует per-row индексный point-lookup по idx_lss_source_date (listing_source_id,
snapshot_date DESC) вместо полного DISTINCT ON по таблице; EXPLAIN на проде: cost внутреннего
подзапроса упал с ~298 627 до ~4.4 за строку today. Плюс defense-in-depth: budget_sec
SET LOCAL statement_timeout (см. snapshot_listing_sources) если что-то опять разрегрессирует
план, прогон честно падает в mark_failed вместо того чтобы висеть сутками.
"""
from __future__ import annotations
import logging
from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
@ -27,6 +48,30 @@ logger = logging.getLogger(__name__)
# Окно свежести: источник считается активным, если last_seen_at не старше N дней.
FRESHNESS_WINDOW_DAYS = 7
# ── Wall-clock budget (#2607 п.4) ─────────────────────────────────────────────
# Задача не батчится Python-циклом (два set-based statement'а) — единственный способ
# гарантированно оборвать зависший statement это Postgres-нативный statement_timeout,
# выставленный SET LOCAL (per-transaction scope, НЕ трогает server/role-level timeout —
# это issue #2607 п.2, отдельное решение с согласованием). По образцу budget_sec из
# app/tasks/geocode_missing.py (run_geocode_missing_listings), только здесь это не Python
# loop-budget, а SQL statement_timeout.
# Default/clamp: см. data/sql/202_listing_source_snapshot_budget_sec.sql (default_params
# budget_sec=900 — 15 мин, с большим запасом над ожидаемым временем выполнения после
# LATERAL-фикса (секунды) и далеко от 6h zombie-порога).
DEFAULT_BUDGET_SEC = 900.0
_MIN_BUDGET_SEC = 30.0
_MAX_BUDGET_SEC = 3600.0 # hard ceiling — не даём budget_sec случайно воссоздать "висит вечно"
def _clamp_budget_sec(raw: Any) -> float:
"""Валидировать/зажать budget_sec из default_params — защита от 0/отрицательного/мусора."""
try:
val = float(raw)
except (TypeError, ValueError):
val = DEFAULT_BUDGET_SEC
return max(_MIN_BUDGET_SEC, min(val, _MAX_BUDGET_SEC))
# ── Daily snapshot upsert ─────────────────────────────────────────────────────
# Снимок на (listing_source_id, CURRENT_DATE). ON CONFLICT → last-write-wins за день
# (повторный прогон в те же сутки перезаписывает снимок свежими значениями).
@ -63,9 +108,25 @@ _SNAPSHOT_SQL = text(
# Для каждого источника сравниваем сегодняшнюю цену (snapshot_date = CURRENT_DATE) с
# самым свежим ПРЕДЫДУЩИМ снимком (snapshot_date < CURRENT_DATE). Если цена изменилась
# (обе NOT NULL, old <> 0) — пишем price_change.
# today — снимок за сегодня (только что записан _SNAPSHOT_SQL).
# prior — последний снимок строго ДО сегодня (DISTINCT ON … ORDER BY date DESC).
# Полностью set-based: один INSERT … SELECT по всем источникам, без Python-цикла.
# today — снимок за сегодня (только что записан _SNAPSHOT_SQL, в той же транзакции).
# p — последний снимок строго ДО сегодня, per-row LATERAL point-lookup (#2607).
#
# #2607: раньше `p` был отдельным CTE `DISTINCT ON (listing_source_id) ... FROM
# listing_source_snapshots WHERE snapshot_date < CURRENT_DATE` и джойнился обычным JOIN.
# Планировщик оценивает `today` в 1 строку (свежевставленные в этой же транзакции строки
# ANALYZE ещё не видел) → Nested Loop БЕЗ Materialize на внутренней стороне → DISTINCT ON
# по ВСЕЙ таблице (~2.6-2.8M строк, Index Scan + Unique) пересчитывался ЗАНОВО на каждую
# из ~80-140k реальных строк today — на проде EXPLAIN показал cost≈300k на этом шаге,
# запрос не укладывался ни в 6h zombie-порог, ни в сутки. LATERAL форсирует per-row
# индексный lookup через idx_lss_source_date (listing_source_id, snapshot_date DESC) —
# `ORDER BY s.snapshot_date DESC LIMIT 1` даёт тот же единственный "последний снимок до
# сегодня" на listing_source_id, что и старый DISTINCT ON (PK (listing_source_id,
# snapshot_date) исключает дубликаты snapshot_date на одном источнике — семантика
# идентична), но за O(log n) на строку вместо полного скана таблицы. EXPLAIN на проде:
# cost внутреннего подзапроса упал с ~298 627 до ~4.4 за строку today.
#
# Полностью set-based: один INSERT … SELECT по всем источникам, без Python-цикла (LATERAL
# — это внутренний план Postgres, не Python-итерация).
# change_time = now() детерминирует UNIQUE(listing_source_id, change_time, event_type)
# в пределах прогона → ON CONFLICT DO NOTHING делает писатель идемпотентным.
_EVENT_DIFF_SQL = text(
@ -74,13 +135,6 @@ _EVENT_DIFF_SQL = text(
SELECT listing_source_id, price_rub
FROM listing_source_snapshots
WHERE snapshot_date = CURRENT_DATE
),
prior AS (
SELECT DISTINCT ON (listing_source_id)
listing_source_id, price_rub
FROM listing_source_snapshots
WHERE snapshot_date < CURRENT_DATE
ORDER BY listing_source_id, snapshot_date DESC
)
INSERT INTO listing_source_events (
listing_source_id, change_time, event_type, price_rub, diff_percent
@ -92,7 +146,14 @@ _EVENT_DIFF_SQL = text(
t.price_rub,
round((t.price_rub - p.price_rub)::numeric / p.price_rub * 100, 4)
FROM today t
JOIN prior p ON p.listing_source_id = t.listing_source_id
JOIN LATERAL (
SELECT s.price_rub
FROM listing_source_snapshots s
WHERE s.listing_source_id = t.listing_source_id
AND s.snapshot_date < CURRENT_DATE
ORDER BY s.snapshot_date DESC
LIMIT 1
) p ON true
WHERE t.price_rub IS NOT NULL
AND p.price_rub IS NOT NULL
AND p.price_rub <> 0
@ -102,7 +163,9 @@ _EVENT_DIFF_SQL = text(
)
def snapshot_listing_sources(db: Session, run_id: int) -> dict[str, int]:
def snapshot_listing_sources(
db: Session, run_id: int, params: dict[str, Any] | None = None
) -> dict[str, int]:
"""Записать дневной снимок listing_sources + price_change-события.
Sync (вызывается scheduler-триггером в executor, как import_rosreestr_dkp).
@ -110,12 +173,32 @@ def snapshot_listing_sources(db: Session, run_id: int) -> dict[str, int]:
1. upsert снимка на (listing_source_id, CURRENT_DATE) last-write-wins.
2. diff сегодняшней цены против последнего предыдущего снимка price_change-события.
Params (из default_params jsonb в scrape_schedules, #2607):
budget_sec: float SET LOCAL statement_timeout на транзакцию (default 900,
clamp [30, 3600]). Единственный способ гарантированно оборвать зависший
statement у не-батчащейся (два statement'а, не Python-цикл) задачи — если
план снова разрегрессирует, прогон честно упадёт в mark_failed вместо того
чтобы висеть часами/сутками (root cause #2607 — см. шапку файла и
_EVENT_DIFF_SQL).
Финализирует scrape_runs (mark_done / mark_failed) и пишет counters.
Returns {"snapshotted": N, "price_change_events": M}.
"""
params = params or {}
budget_sec = _clamp_budget_sec(params.get("budget_sec", DEFAULT_BUDGET_SEC))
counters: dict[str, int] = {"snapshotted": 0, "price_change_events": 0}
try:
# statement_timeout НЕ принимает bind-параметр ($1/:name) — синтаксис Postgres SET
# запрещает placeholder на этом месте (проверено вживую на проде: "syntax error at
# or near \"$1\""). budget_sec провалидирован/clamp'нут в _clamp_budget_sec выше
# (источник — scrape_schedules.default_params, не user input) — f-string здесь
# безопасен (единственный практический способ выставить эту GUC динамически).
# SET LOCAL — per-transaction scope, сбрасывается на COMMIT/ROLLBACK, НЕ трогает
# server/role-level statement_timeout (issue #2607 п.2 — отдельное решение).
timeout_ms = int(budget_sec * 1000)
db.execute(text(f"SET LOCAL statement_timeout = {timeout_ms}"))
snap_result = db.execute(
_SNAPSHOT_SQL,
{"freshness_days": FRESHNESS_WINDOW_DAYS, "run_id": run_id},
@ -135,7 +218,9 @@ def snapshot_listing_sources(db: Session, run_id: int) -> dict[str, int]:
)
return counters
except Exception as exc:
logger.exception("snapshot_listing_sources run_id=%d failed", run_id)
logger.exception(
"snapshot_listing_sources run_id=%d failed (budget_sec=%.0f)", run_id, budget_sec
)
db.rollback()
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
raise

View file

@ -0,0 +1,92 @@
-- 197_backfill_listings_city_from_url.sql
-- Issue #2594 шаг 3 — бэкфилл listings.city (миграция 196) для УЖЕ накопленных
-- Avito-объявлений из слага города в source_url.
--
-- ПРОБЛЕМА: 196 добавила колонку listings.city и write-path проставляет её
-- ТОЛЬКО для новых листингов (см. заголовок 196). Накопленные ранее строки
-- остались с city IS NULL. Для Avito-объявлений вне ЕКБ (city-sweep областных
-- городов) адрес в тексте часто без города («пр-т Вагоностроителей,18» вместо
-- «Нижний Тагил, пр-т Вагоностроителей,18»), а у части улиц есть тёзки в
-- Екатеринбурге (Хохрякова, Калинина — центральные ЕКБ-улицы). Без явного
-- city такой адрес при геокодировании (app/tasks/geocode_missing.py,
-- app/services/geocoder.py city_hint) считается «город не назван» → рискует
-- получить координаты Екатеринбурга (тот же баг-класс, что и #2594 основной).
-- Ночной прогон geocode_missing_listings 2026-08-01 заберёт в очередь 148
-- активных объявлений Нижнего Тагила без city — этот бэкфилл проставляет им
-- city ДО того, как очередь начнёт их обрабатывать.
--
-- ИСТОЧНИК: первый сегмент пути URL после хоста —
-- https://www.avito.ru/nizhniy_tagil/kvartiry/... -> 'nizhniy_tagil'
-- извлекается regex `substring(source_url from 'avito\.ru/([^/]+)/')`.
-- Маппинг ТОЛЬКО наших шести городов Свердловской обл. (region 66); слаги и
-- человекочитаемые названия сверены с CITY_DISPLAY_NAMES/CITY_LOCATIONS
-- (tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py)
-- — значения побайтно совпадают с тем, что теперь пишет скрапер (go-forward
-- write-path 196), чтобы не расщепить один город на две разные метки.
--
-- Проверено на проде (SELECT, read-only) перед миграцией:
-- avito_slug наш город city IS NULL (Avito)
-- 'ekaterinburg' -> 'Екатеринбург' 26770
-- 'nizhniy_tagil' -> 'Нижний Тагил' 551 (148 сегодня в очереди геокода)
-- 'kamensk-uralskiy' -> 'Каменск-Уральский' 244
-- 'pervouralsk' -> 'Первоуральск' 95
-- 'verhnyaya_pyshma' -> 'Верхняя Пышма' 21
-- 'serov' -> 'Серов' 25
-- ИТОГО 27706
-- ⚠️ avito_slug у Каменска-Уральского — ЧЕРЕЗ ДЕФИС ('kamensk-uralskiy'), не
-- через подчёркивание, в отличие от нашего внутреннего city_slug
-- 'kamensk_uralskiy' (CITY_LOCATIONS ключ). У Верхней Пышмы наоборот —
-- у Avito 'verhnyaya_pyshma' (kh -> h, БЕЗ 'k'), совпадает с
-- CityLocation("verhnyaya_pyshma", ...).avito_slug в pipeline.py, но
-- отличается от нашего внутреннего ключа 'verkhnyaya_pyshma' (с 'k').
-- В фактических данных встретился ТОЛЬКО вариант 'verhnyaya_pyshma' — второй
-- вариант написания в WHERE не нужен (дал бы 0 доп. строк).
--
-- ВНЕ SCOPE (сознательно не трогаем, обоснование):
-- - Cian: хост НЕ индикатор города (ekb.cian.ru отдаёт областные объявления,
-- включая тагильские, через тот же хост с параметром региона) — бэкфилл
-- по хосту дал бы неверный результат.
-- - Domclick: у объявлений без координат город не критичен (0 rows без
-- lat), 13 строк на голом domclick.ru — отдельный разбор, не эта миграция.
-- - Yandex: в URL (realty.yandex.ru/offer/<id>) города нет вовсе.
-- - listings.region_code: у 16912 чужих-региона строк он неверный (стоит
-- 66) — отдельный пункт issue #2604, ждёт решения владельца, здесь НЕ
-- трогаем.
-- - Слаги вне наших шести городов (1644 distinct на Avito, 16930 строк
-- city IS NULL) остаются NULL — по ним отдельное решение владельца.
--
-- Idempotency:
-- `WHERE city IS NULL` — не перетирает то, что уже проставил скрапер
-- (write-path 196) или предыдущий прогон этой же миграции. Повторный
-- прогон обновляет 0 строк (все затронутые строки уже НЕ city IS NULL).
-- CASE ветки строго совпадают со списком в WHERE ... IN (...), поэтому
-- для любой строки, прошедшей WHERE, CASE НЕ может вернуть NULL.
--
-- НЕ DDL — только UPDATE данных (колонка listings.city уже существует,
-- миграция 196). Ни одна строка не удаляется и не деактивируется.
--
-- Dependencies: 196_listings_city.sql (колонка listings.city).
BEGIN;
UPDATE listings
SET city = CASE substring(source_url from 'avito\.ru/([^/]+)/')
WHEN 'ekaterinburg' THEN 'Екатеринбург'
WHEN 'nizhniy_tagil' THEN 'Нижний Тагил'
WHEN 'kamensk-uralskiy' THEN 'Каменск-Уральский'
WHEN 'pervouralsk' THEN 'Первоуральск'
WHEN 'verhnyaya_pyshma' THEN 'Верхняя Пышма'
WHEN 'serov' THEN 'Серов'
END
WHERE source = 'avito'
AND city IS NULL
AND substring(source_url from 'avito\.ru/([^/]+)/') IN (
'ekaterinburg',
'nizhniy_tagil',
'kamensk-uralskiy',
'pervouralsk',
'verhnyaya_pyshma',
'serov'
);
COMMIT;

View file

@ -0,0 +1,49 @@
-- 198_scrape_proxy_rotations.sql
-- Issue #2600 п.5 — ротация exit-IP прокси ASocks по бану, со счётчиком и громким
-- отказом. АДДИТИВНО, не трогает scrape_proxies (157_scrape_proxies.sql) кроме
-- FK-ссылки; не трогает proxy_pool.py (параллельный PR #2609).
--
-- WHY:
-- Провайдер (ASocks, безлимитные порты) ограничивает ручную ротацию exit-IP тремя
-- вызовами в сутки на порт (эмпирика, владелец аккаунта). app.services.proxy_rotation
-- должен и проверять этот лимит ПЕРЕД обращением к API, и вести аудит попыток —
-- без отдельной таблицы истории лимит негде считать (scrape_proxies хранит только
-- текущее состояние, не историю).
--
-- Semantics:
-- Одна строка = одна попытка ротации (успешная ИЛИ неуспешная), но НЕ каждый
-- вызов rotate_proxy() пишет строку — локально отклонённые попытки (нет
-- rotate_url / нет ASOCKS_API_TOKEN / лимит уже исчерпан) вообще не доходят до
-- HTTP-вызова и в таблицу не пишутся (см. app.services.proxy_rotation docstring
-- за полным обоснованием "какие попытки считать против лимита").
-- http_status NULL = сетевая ошибка (ответа от провайдера не было вообще).
--
-- Idempotency:
-- CREATE TABLE IF NOT EXISTS + CREATE INDEX IF NOT EXISTS → повторный прогон
-- no-op (auto-apply strict на деплое это требует). Весь файл в BEGIN/COMMIT.
--
-- Dependencies:
-- 157_scrape_proxies.sql (scrape_proxies.id — FK-таргет).
BEGIN;
CREATE TABLE IF NOT EXISTS scrape_proxy_rotations (
id bigserial PRIMARY KEY,
proxy_id bigint NOT NULL REFERENCES scrape_proxies (id),
rotated_at timestamptz NOT NULL DEFAULT now(),
success boolean NOT NULL,
http_status integer,
note text
);
COMMENT ON TABLE scrape_proxy_rotations IS
'Аудит + суточный лимит (#2600 п.5) ручных ротаций exit-IP через ASocks '
'refresh-ip. Лимит провайдера — 3 попытки/сутки на порт; app.services.'
'proxy_rotation._quota_used_today считает только строки с http_status '
'IS NOT NULL AND != 401 (реально дошедшие до провайдера) за последние 24ч.';
-- Проверка суточного лимита + выборка истории по прокси: (proxy_id, rotated_at).
CREATE INDEX IF NOT EXISTS idx_scrape_proxy_rotations_proxy_time
ON scrape_proxy_rotations (proxy_id, rotated_at);
COMMIT;

View file

@ -0,0 +1,58 @@
-- 199_scrape_proxies_asocks_rotate_url.sql
-- Issue #2600 п.5 — проставить rotate_url для четырёх ASocks unlimited-портов пула,
-- чтобы app.services.proxy_rotation.rotate_proxy имел куда стучаться.
--
-- WHY:
-- scrape_proxies.rotate_url для этих 4 строк сейчас NULL (загружены через
-- POST /proxies/bulk без rotate_url). Единственный рабочий способ ротации exit-IP
-- для ASocks-безлимитных портов — ручка веб-кабинета
-- POST https://api.asocks.com/unlimited-proxy/{portId}/refresh-ip с заголовком
-- Authorization: Bearer <ASOCKS_API_TOKEN> (env, НЕ в URL — секретов в миграции
-- нет). Документированный публичный GET /v2/proxy/refresh/{portId}?apiKey= для
-- безлимитных портов не работает (подтверждено владельцем аккаунта); ротация
-- session-суффиксом логина тоже не работает (проверено пробой, три варианта —
-- один и тот же exit-IP).
--
-- Matching (важно — НЕ по id):
-- scrape_proxies.id может разъехаться между средами (dev/stage/prod грузятся
-- bulk-ручкой независимо) — сопоставляем по адресу host:port, зашитому в конец
-- url (scrape_proxies.url — всегда 'scheme://[user:pass@]host:port' БЕЗ пути,
-- см. admin.py _mask_proxy_url/urlparse-логику и 157_scrape_proxies.sql) через
-- right(url, length(hostport)) = hostport. portId → host:port (проверено
-- владельцем аккаунта, issue #2600 п.5):
-- 223610715 → 212.8.249.134:10423
-- 225031312 → 190.2.145.131:10313
-- 231878029 → 175.110.115.153:10492
-- 231878030 → 109.236.82.42:11048
--
-- Idempotency:
-- Обычный UPDATE ... WHERE — повторный прогон пишет то же значение, no-op по
-- результату. Прокси, которых нет в пуле текущей среды (host:port не найден) —
-- 0 строк обновлено, не ошибка. Весь файл в BEGIN/COMMIT.
--
-- Dependencies:
-- 157_scrape_proxies.sql (scrape_proxies.rotate_url).
BEGIN;
UPDATE scrape_proxies
SET rotate_url = 'https://api.asocks.com/unlimited-proxy/223610715/refresh-ip',
updated_at = now()
WHERE right(url, length(CAST('212.8.249.134:10423' AS text))) = '212.8.249.134:10423';
UPDATE scrape_proxies
SET rotate_url = 'https://api.asocks.com/unlimited-proxy/225031312/refresh-ip',
updated_at = now()
WHERE right(url, length(CAST('190.2.145.131:10313' AS text))) = '190.2.145.131:10313';
UPDATE scrape_proxies
SET rotate_url = 'https://api.asocks.com/unlimited-proxy/231878029/refresh-ip',
updated_at = now()
WHERE right(url, length(CAST('175.110.115.153:10492' AS text))) = '175.110.115.153:10492';
UPDATE scrape_proxies
SET rotate_url = 'https://api.asocks.com/unlimited-proxy/231878030/refresh-ip',
updated_at = now()
WHERE right(url, length(CAST('109.236.82.42:11048' AS text))) = '109.236.82.42:11048';
COMMIT;

View file

@ -0,0 +1,100 @@
-- 200_region_code_foreign_cities.sql
-- Issue #2604 п.2 — убрать ложную метку региона у объявлений Avito из чужих
-- городов (Новосибирск, Казань, Челябинск, Тюмень и ещё ~1600 слагов).
--
-- ПРОБЛЕМА: 16930 строк listings (source='avito') несут region_code = 66
-- (Свердловская обл.), хотя source_url указывает на город ВНЕ наших шести —
-- это неправда. Строки — наследие массового заброса 18 июня (сплошной
-- multi-city SERP-краул до появления гео-фильтра карточек, коммит
-- f0264237, 20 июня), который с тех пор не проставлял target_city_slug на
-- SERP-запрос и не отсеивал карточки чужих городов на этапе сбора. Канал
-- давно закрыт (тот же класс проблемы, что чинили 196/197 для listings.city),
-- новых таких строк не поступает — все 16930 сейчас is_active = false.
--
-- ПОЧЕМУ NULL, А НЕ НАСТОЯЩИЙ РЕГИОН: вывести реальный регион из текста
-- адреса/URL можно было бы (slug города в source_url), но это требовало бы
-- поддерживать растущий справочник ~1600 чужих региональных кодов ради
-- колонки, которую сегодня не читает НИ ОДНА живая выборка (проверено grep:
-- только исторические миграции 077_*/091_* и один комментарий). Честное
-- «неизвестно» (NULL) дешевле и не создаёт вторую ложь взамен первой.
--
-- ПОЧЕМУ ТОЛЬКО AVITO: у cian/domklik/yandex region_code=66 определяется не
-- заброс-механизмом чужого города (там его и не было), а параметром region=
-- самого запроса (cian) / отсутствием городской привязки в URL вовсе
-- (domklik/yandex) — то есть в подавляющем большинстве region_code=66 у них
-- ВЕРНЫЙ. Среди них нашлось лишь 27 строк с адресом, похожим на чужой город
-- (текстовый разбор, ненадёжный сигнал) — сознательно НЕ трогаем, отдельная
-- задача при желании её довести.
--
-- ИСТОЧНИК СЛАГА: первый сегмент пути после хоста —
-- https://www.avito.ru/nizhniy_tagil/kvartiry/... -> 'nizhniy_tagil'
-- извлекается regex `substring(source_url from 'avito\.ru/([^/]+)/')` —
-- тот же идиом, что и в 197 (проверено: 'www.' перед 'avito.ru' в общий
-- матч не проваливается, слаг 'www' ни разу не извлёкся — все 45472
-- source_url на проде имеют форму 'https://www.avito.ru/...'). Точный
-- сегмент пути, НЕ `LIKE '%slug%'` — среди наших шести слагов нет
-- подстрочных коллизий друг с другом (ekaterinburg, nizhniy_tagil,
-- kamensk-uralskiy, pervouralsk, verhnyaya_pyshma, serov — все взаимно
-- не substring), поэтому точное сравнение через WHERE ... NOT IN (...) над
-- извлечённым сегментом безопасно.
--
-- Наши шесть слагов — АВИТОВСКОЕ написание (см. CityLocation(...).avito_slug
-- в packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py,
-- CITY_LOCATIONS ~ строки 330-336 + EKB default для 'ekaterinburg'):
-- kamensk-uralskiy — ЧЕРЕЗ ДЕФИС (не 'kamensk_uralskiy', наш внутренний
-- city_slug/CITY_LOCATIONS-ключ — через подчёркивание)
-- verhnyaya_pyshma — БЕЗ 'k' (не 'verkhnyaya_pyshma', наш внутренний ключ)
-- Побайтно сверено с 197_backfill_listings_city_from_url.sql, который решает
-- ту же задачу маппинга avito_slug -> наши города.
--
-- ЗАМЕРЫ (SELECT, read-only, прод, перед миграцией):
-- Наши шесть городов (НЕ должны попасть под UPDATE): 28542 строк
-- Кандидаты на UPDATE (source='avito', НЕ наши 6, region_code=66):
-- 16930 строк
-- из них is_active = false: 16930 (100%)
-- из них region_code = 66 (единственное текущее значение): 16930 (100%)
-- Avito-строк с region_code уже NULL среди кандидатов: 0
-- (UPDATE их не задевает по построению — WHERE region_code IS NOT NULL)
-- Avito-строк с нераспознаваемым source_url (слаг не извлёкся): 0
-- total avito = 45472 = 28542 (наши 6) + 16930 (кандидаты) — сходится.
--
-- ПРОИЗВОДИТЕЛЬНОСТЬ: триггеры на listings — column-scoped
-- (`listings_price_change_trg` на UPDATE OF price_rub,
-- `listings_set_geom_trg` на UPDATE OF lat, lon) — UPDATE только по
-- region_code их не пробуждает. Но `tsv` (GENERATED ALWAYS ... STORED над
-- description+address) пересчитывается на КАЖДОМ UPDATE независимо от того,
-- какие колонки менялись. EXPLAIN (без ANALYZE, план не исполняется) на
-- проде показывает Bitmap Heap Scan по listings_source_idx (source='avito')
-- — тот же путь доступа, что и в 197. 197 обновила 27706 строк с тем же tsv
-- recalculation за 4.1с; здесь строк меньше (16930, ~61% от 27706) —
-- ожидаемая длительность ~2.5-3с. Никакого DDL, GIST/geom не затронуты.
--
-- Idempotency: `AND region_code IS NOT NULL` — повторный прогон находит 0
-- строк (все затронутые строки уже NULL после первого прогона), UPDATE
-- становится no-op. WHERE ограничен ровно source='avito' и slug вне наших
-- шести — наши города и другие источники никогда не попадают в scope.
--
-- ГРАНИЦЫ: НЕ трогает region_code наших шести городов, НЕ трогает
-- cian/domklik/yandex/n1, НЕ трогает city/is_active/скраперы/
-- DEFAULT_REGION_CODE. Ничего не удаляет, ничего не деактивирует. Только
-- UPDATE одной колонки одной таблицы.
--
-- Dependencies: 002_core_tables.sql (listings.region_code — nullable int,
-- без DEFAULT на уровне таблицы).
BEGIN;
UPDATE listings
SET region_code = NULL
WHERE source = 'avito'
AND region_code IS NOT NULL
AND substring(source_url from 'avito\.ru/([^/]+)/') NOT IN (
'ekaterinburg',
'nizhniy_tagil',
'kamensk-uralskiy',
'pervouralsk',
'verhnyaya_pyshma',
'serov'
);
COMMIT;

View file

@ -0,0 +1,83 @@
-- 201_purge_dead_mobileproxy_proxies.sql
-- Issue #2613 — выпилить мёртвые узлы mobileproxy из пула scrape_proxies
-- вместе с чужим API-ключом, который лежал у них в rotate_url.
--
-- WHY:
-- Владелец подтвердил: подписка mobileproxy закрыта, продлевать не будут.
-- Прямая проба каждого узла из контейнера tradein-scraper (2026-08-01)
-- подтверждает смерть: id 2 — connection refused, id 3/4/5 — 407 Proxy
-- Authentication Required. Последняя успешная проверка (last_check_at) у
-- всех четырёх — 4-9 июля, все четыре enabled=false, consecutive_fails=5.
--
-- Две причины удалить, вторая важнее:
-- 1. Мёртвые узлы засоряют пул и его health-метрики.
-- 2. rotate_url у трёх из четырёх строк (id 3, 4, 5) хранит открытым
-- текстом чужой ключ провайдера в query-параметре ссылки ротации
-- (https://changeip.mobileproxy.space/?proxy_key=...). Именно из-за
-- неоднородности этой колонки (вперемешку с ASocks-строками, где
-- rotate_url — наш собственный API-эндпоинт БЕЗ секрета в URL,
-- авторизация Bearer-заголовком) глубокое ревью PR #2611 нашло
-- блокер: вызов ротации для такой строки отправил бы НАШ токен
-- ASocks на changeip.mobileproxy.space. Пин хоста в #2611 уже
-- закрывает саму уязвимость, но чужой секрет в базе держать незачем.
--
-- ПОЧЕМУ DELETE, А НЕ UPDATE (очистка полей + enabled=false):
-- Единственный FK, ссылающийся на scrape_proxies — scrape_proxy_rotations
-- .proxy_id (заведён 198_scrape_proxy_rotations.sql), delete_rule NO ACTION.
-- На момент миграции (замер ниже) в scrape_proxy_rotations нет НИ ОДНОЙ
-- строки вообще — таблица введена в этом же цикле работ (#2600 п.5) и
-- ручная ротация ни разу не запускалась. DELETE четырёх строк scrape_proxies
-- ничего не упирает. Если бы к строкам 2-5 успела прилипнуть история ротаций
-- к моменту применения — DELETE упадёт по FK-violation ВНУТРИ этой же
-- транзакции (BEGIN/COMMIT ниже), миграция целиком откатится, deploy
-- завершится ошибкой (auto-apply strict, exit 1) без частичного эффекта и
-- без порчи данных; отдельного ON DELETE-обработчика не требуется — узлы
-- disabled=false уже сейчас, acquire() их не выдаёт (idx_scrape_proxies_pick
-- фильтрует по enabled), новых ротаций на них взяться неоткуда до deploy.
-- Строки — исторический мусор без ссылок, полное удаление честнее частичной
-- очистки (не оставляет призрачную запись мёртвого узла в пуле) и убирает
-- секрет из базы целиком, а не только из одной колонки.
--
-- Matching (по домену url, НЕ по id):
-- id в scrape_proxies разъезжается между средами (bulk-загрузка независима
-- per-среда, тот же класс проблемы решён в 199 через host:port-matching).
-- Условие — WHERE url LIKE '%mobileproxy.space%' — ловит все четыре узла
-- независимо от порта/поддомена (ha./gi./auv./aup.mobileproxy.space) и не
-- заденет ASocks-строки (212.8.249.134 / 190.2.145.131 / 175.110.115.153 /
-- 109.236.82.42 — IP-адреса, без mobileproxy.space в url вовсе).
--
-- ЗАМЕРЫ (SELECT, read-only, прод, перед миграцией, 2026-08-01):
-- Строк под условие (url LIKE '%mobileproxy.space%'): 4 (id 2, 3, 4, 5)
-- Остаток пула после удаления (url NOT LIKE '%mobileproxy.space%'):
-- 4 (id 1, 9, 10, 11) — все ASocks
-- Строк в scrape_proxy_rotations на id 2/3/4/5: 0
-- Строк в scrape_proxy_rotations всего (таблица пуста): 0
-- Секрет-паттерн (token|bearer|secret|key=|password, regex
-- case-insensitive) в rotate_url ОСТАЮЩИХСЯ 4 строк: 0 совпадений
-- (rotate_url остающихся — https://api.asocks.com/unlimited-proxy/
-- <portId>/refresh-ip, без query-параметров вообще, авторизация Bearer
-- заголовком вне URL, см. 199_scrape_proxies_asocks_rotate_url.sql)
-- FK, ссылающиеся на scrape_proxies: ровно один —
-- scrape_proxy_rotations.proxy_id -> scrape_proxies.id, delete_rule NO ACTION.
--
-- Idempotency:
-- Обычный DELETE ... WHERE — повторный прогон находит 0 строк (уже
-- удалены), no-op. Весь файл в BEGIN/COMMIT.
--
-- ГРАНИЦЫ: НЕ трогает ASocks-строки (id 1, 9, 10, 11) и их rotate_url. НЕ
-- трогает переменные окружения (*_PROXY_URL, BROWSER_PROXY_*,
-- *_PROXY_ROTATE_URL) — их снятие отдельная задача и НЕ раньше неё, иначе
-- при пустом прокси curl_proxy_url отдаёт None = скрапер идёт напрямую с IP
-- сервера. НЕ трогает app/services/proxy_pool.py, proxy_rotation.py,
-- скраперы. Никакого DDL.
--
-- Dependencies:
-- 157_scrape_proxies.sql (scrape_proxies.url/rotate_url/enabled).
-- 198_scrape_proxy_rotations.sql (FK proxy_id -> scrape_proxies.id, NO ACTION).
BEGIN;
DELETE FROM scrape_proxies
WHERE url LIKE '%mobileproxy.space%';
COMMIT;

View file

@ -0,0 +1,38 @@
-- 202_listing_source_snapshot_budget_sec.sql
-- #2607 — listing_source_snapshot зависал каждую ночь (минимум с 19 июля): scrape_runs
-- всегда добирал до 'zombie' ровно за 6h (порог zombie-детектора), но backend в Postgres
-- продолжал жечь CPU СУТКАМИ после этого (zombie-детектор в scraper_kit.orchestration.
-- scheduler.reap_zombies только помечает строку scrape_runs — не убивает backend), держа
-- backend_xmin и блокируя autovacuum на listings/listing_sources.
--
-- ROOT CAUSE (тот же PR, app/tasks/listing_source_snapshot.py): event-diff CTE джойнил
-- "today" (снимок за CURRENT_DATE) с "prior" — DISTINCT ON по ВСЕЙ listing_source_snapshots
-- (~2.6-2.8M строк) обычным JOIN. Планировщик оценивал "today" в 1 строку (свежевставленные
-- в той же транзакции строки ANALYZE ещё не видел) → выбирал Nested Loop БЕЗ Materialize на
-- внутренней стороне → DISTINCT ON пересчитывался заново на КАЖДУЮ из ~80-140k реальных
-- строк today. EXPLAIN на проде: cost внутреннего подзапроса ~298 627. Запрос переписан на
-- JOIN LATERAL (per-row indexed point-lookup, cost ~4.4/строку) — устраняет корневую причину.
--
-- ЭТА миграция — ДОПОЛНИТЕЛЬНЫЙ предохранитель (issue #2607 п.4): budget_sec в default_params
-- теперь читается snapshot_listing_sources() и выставляется как SET LOCAL statement_timeout
-- (per-transaction, НЕ server/role-level — тот отдельный вопрос issue #2607 п.2, требует
-- согласования, здесь намеренно не трогается). Если план когда-нибудь снова разрегрессирует,
-- прогон честно упадёт в mark_failed вместо того чтобы висеть сутками.
--
-- 900 сек (15 мин) — по образцу migration 110 (geocode_missing_listings budget_sec=1800),
-- с большим запасом над ожидаемым временем выполнения после LATERAL-фикса (секунды) и
-- далеко от 6h zombie-порога и от окна 01:00-02:00 UTC (052/079).
--
-- ЗАВИСИМОСТИ: 079_listing_source_history.sql (создаёт scrape_schedules row, source=
-- 'listing_source_snapshot', default_params='{}'::jsonb).
-- Idempotent: UPDATE ... || jsonb-merge — безопасно перезапускать (всегда приводит
-- default_params.budget_sec к 900 независимо от предыдущего состояния).
-- Apply after: 201_purge_dead_mobileproxy_proxies.sql
BEGIN;
UPDATE scrape_schedules
SET default_params = COALESCE(default_params, '{}'::jsonb) || '{"budget_sec": 900}'::jsonb
WHERE source = 'listing_source_snapshot';
COMMIT;

View file

@ -0,0 +1,52 @@
-- Инвалидация записей geocode_cache, отравленных багом матчинга литеры дома.
--
-- Контекст: `_cadastral_house_match` (app/services/geocoder.py) сравнивал дом
-- только по ЦИФРАМ — литера была опциональна в WHERE и участвовала лишь как
-- tie-break в ORDER BY. Итог, двусторонний:
-- • «Новгородцевой 13б» → «дом 13» (запрос с литерой → дом без неё)
-- • «Малышева 30» → «д. 30-б» (запрос без литеры → дом с литерой)
-- Оба результата писались с provider-тиром локального реестра и
-- `confidence='exact'`, TTL 90 дней → пользователь получал оценку ЧУЖОГО
-- здания, помеченную как точная, и она залипала в кэше.
--
-- Здесь удаляем только ПОДОЗРИТЕЛЬНЫЕ строки, а не весь кэш: полная очистка
-- сожгла бы квоту внешних геокодеров (DaData 10k/день) на ре-резолв заведомо
-- корректных адресов. Удалённое будет пересчитано лениво, при следующем
-- запросе, уже исправленным матчером.
--
-- Идемпотентность: чистый DELETE по предикату. Повторный прогон удалит 0 строк
-- (первый уже вычистил всё подходящее), новых строк с такой же патологией
-- исправленный код не создаёт. Безопасно для strict exit-1 авто-применения.
BEGIN;
DELETE FROM geocode_cache
WHERE
-- (a) В самом запросе была литера дома: под старым матчером такой адрес мог
-- уехать в дом без литеры / с чужой литерой. Смотрим на ХВОСТ адреса
-- (дом пишется последним) — иначе порядковые части улиц («1-я
-- Пятилетки», «4-й Кианитовый») ложно читались бы как литера.
-- `|city=` — суффикс ключа кэша (см. geocoder._cache_key), отрезаем.
split_part(address_normalized, '|city=', 1) ~* '[0-9]+\s*-?\s*[а-яё]\s*$'
-- (b) Обратное направление: в запросе литеры НЕ было, а закэширован адрес
-- реестра, у которого номер дома С литерой («Малышева 30» → «д. 30-б»).
-- Извлечение номера — то же выражение, что и в исправленном матчере
-- (geocoder._SQL_HOUSE_TOKEN_RE): маркер только с начала слова, литера
-- — одиночная кириллическая буква, «58/3»/«64-2» литерой не считаются.
OR (
split_part(address_normalized, '|city=', 1) !~* '[0-9]+\s*-?\s*[а-яё]\s*$'
AND full_address IS NOT NULL
AND regexp_replace(
regexp_replace(
lower(COALESCE((regexp_match(
full_address,
'\m(?:дом|д\.?|строение|стр\.?|сооружение|соор\.?)\s*'
|| '([0-9]+(?:\s*[-/]\s*[0-9]+)?(?:\s*-?\s*[а-яё](?![а-яё]))?)',
'i'))[1], '')),
'\s', '', 'g'),
'-([а-яё])', '\1', 'g'
) ~ '[а-яё]$'
);
COMMIT;

View file

@ -0,0 +1,87 @@
-- 204_cian_oblast_sweeps_secondary.sql
-- Включить сбор вторички Циана по 4 областным city-sweep'ам (Свердловская обл.,
-- миграция 179 — nizhniy_tagil/kamensk_uralskiy/pervouralsk/serov).
--
-- ПРОБЛЕМА: _job_cian_city_sweep (scraper_kit.orchestration.scheduler:570) читает
-- newbuilding_only = bool(default_params.get("newbuilding_only", True)) — дефолт True.
-- run_cian_city_sweep (pipeline.py:2436) фильтрует SERP-результат на
-- listing_segment == "novostroyki" ДО save_listings, вторичку отбрасывает
-- (counters.lots_dropped_secondary).
--
-- Дефолт осмыслен для ЕКБ: docstring run_cian_city_sweep прямо говорит, что
-- вторичку авторитетно собирает run_cian_full_load (exhaustive региональный сбор).
-- НО run_cian_full_load (pipeline.py:2790) хардкодит city=EKATERINBURG_CITY_NAME —
-- параметра города там нет вообще, область не покрывает. Итог: областную вторичку
-- Циана не собирает НИКТО (городская развёртка её выбрасывает, full_load туда не
-- ходит) — областные schedule'ы склонированы с ЕКБ (миграция 179) и унаследовали
-- предположение, которое для них неверно.
--
-- Прод-счётчики (scrape_runs.counters, последние runs на 2026-08-02) подтверждают:
-- pervouralsk 55 увидено, 53 выброшено (сохранено 2)
-- kamensk_uralskiy 113 увидено, 108 выброшено (сохранено 5)
-- nizhniy_tagil 184 увидено, 176 выброшено (сохранено 3)
-- verkhnyaya_pyshma 38 увидено, 16 выброшено (сохранено 9) -- см. EXCLUSION ниже
--
-- FIX: newbuilding_only: false для ЧЕТЫРЁХ областных source'ов. cian_city_sweep (ЕКБ,
-- БЕЗ суффикса города) НЕ трогаем — для него дефолт корректен (вторичку ЕКБ
-- собирает cian_full_load), включение дало бы дублирующую нагрузку на источник.
--
-- !!! EXCLUSION: cian_city_sweep_verkhnyaya_pyshma НЕ включён в эту миграцию !!!
-- Верхняя Пышма физически ~15 км от центра Екатеринбурга — geo-проверка по
-- ST_DWithin (координаты listings vs центр города) показала, что 5 из 22 (23%)
-- текущих cian-строк с меткой city="Верхняя Пышма" физически лежат в 15 км от
-- центра ЕКБ, т.е. это загрязнённая городская разметка (sweep по anchor'у В.Пышмы
-- зацепляет краевые екатеринбургские объявления и подписывает их не тем городом).
-- Колонка listings.city — money-critical: её читает asking_to_sold_ratio.py
-- (city-скоуп ASKING vs SOLD стороны, #2583 H2) — неверная метка двигает выкупные
-- цены. При newbuilding_only=false объём cian-строк под меткой В.Пышма вырастет с
-- 22 до нескольких сотен (те же ~38 увидено/16 выброшено за один run, помноженные
-- на число прогонов) — 23%-загрязнение умножилось бы пропорционально.
-- nizhniy_tagil/kamensk_uralskiy/pervouralsk/serov — загрязнение по той же
-- geo-проверке НУЛЕВОЕ (0 из 8/5/2 соответственно физически в ЕКБ) — включать
-- безопасно. cian_city_sweep_verkhnyaya_pyshma будет включён ОТДЕЛЬНОЙ миграцией
-- после починки городской разметки sweep'а (правится параллельно) — НЕ забыт.
--
-- Нагрузка на источник (см. PR description / vault fix-запись для полного разбора):
-- fetch_around_multi_room (providers/cian/serp.py:209) НЕ принимает newbuilding_only/
-- secondary_only — SERP-фаза (все rooms×pages) выполняется ОДИНАКОВО независимо от
-- этого флага. Фильтр в pipeline.py:2436 применяется ПОСЛЕ фетча, ДО save — чисто
-- in-memory отсечение уже оплаченных запросов. HTTP-нагрузка на cian.ru НЕ меняется;
-- меняется только объём save_listings (DB-writes) — на порядок больше СОХРАНЯЕМЫХ
-- строк, не больше запросов к источнику. detail_top_n=10 detail-фетчей тоже не растёт
-- (LIMIT :lim константен, лишь конкурирующий пул кандидатов расширяется).
--
-- Дубли: run_cian_full_load всегда region_code=EKB (city_region_id=4743 через
-- CianScraper() без city_slug), областные sweeps используют CITY_LOCATIONS[<slug>]
-- .cian_region_id (4886/4781/4925/4982 — все != 4743) — SERP-запросы физически
-- разных региональных выдач. dedup_hash = sha256(source|source_id) — глобальный
-- Cian offer_id, ON CONFLICT (dedup_hash) DO UPDATE — даже в теоретическом edge-case
-- совпадения upsert НЕ создаёт дубль-строку.
--
-- listing_segment: providers/cian/serp.py:892 — вторичка получает
-- listing_segment = "vtorichka" (НЕ NULL) → проходит фильтр
-- "listing_segment IS NULL OR listing_segment = 'vtorichka'" в asking_to_sold_ratio.py
-- и buildings_query.py — новые лоты попадут в оценку без доп. кода.
--
-- Мердж jsonb (COALESCE || ...), НЕ перезапись — сохраняет city/radius_m/detail_top_n/
-- enrich_houses/pages_per_anchor/request_delay_sec (см. 179_scrape_schedules_seed_oblast_city_sweeps.sql
-- за текущими прод-значениями). Idempotent: повторный прогон ставит то же значение.
--
-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица), 179 (seed этих source'ов).
-- deploy order: только миграция — код scheduler.py/pipeline.py НЕ меняется в этом PR,
-- дефолт newbuilding_only=True в коде остаётся (правильный fallback для будущих
-- source'ов без явного default_params override).
BEGIN;
UPDATE scrape_schedules
SET default_params = COALESCE(default_params, '{}'::jsonb)
|| '{"newbuilding_only": false}'::jsonb
WHERE source IN (
'cian_city_sweep_nizhniy_tagil',
'cian_city_sweep_kamensk_uralskiy',
'cian_city_sweep_pervouralsk',
'cian_city_sweep_serov'
);
COMMIT;

View file

@ -0,0 +1,222 @@
-- 205_sales_vs_listings_city_filter.sql
-- Purpose: #2583 H4 — street_sales_vs_listings() (067) строит пары «ДКП-сделка ↔
-- listing» через LEFT JOIN, где условие матчинга — ТОЛЬКО street_pattern (ILIKE) +
-- rooms + area ±tolerance + дата. Городской корреляции нет вообще: deals.address /
-- listings.address хранят "<Город>, <Улица>" (Росреестр агрегирует до улицы, без
-- дома), а street_pattern = голое имя улицы («Ленина», «Красноармейская»,
-- «Советская» — десятки одноимённых улиц в разных городах обл.66). ILIKE
-- '%Ленина%' матчит "Нижний Тагил, Ленина" И "Екатеринбург, Ленина" одинаково —
-- пара выбирается ближайшей по дате, город игнорируется.
--
-- Прод-репро (см. PR-описание): street='Ленина', rooms=2, area≈44.3м², defaults —
-- 352 total pairs по всем городам, 244 с listing-match, из них 119 (49%) явно
-- чужого города (deal.city <> listing.city, обе стороны известны) + 122 (50%) с
-- listing.city IS NULL (Циан/Домклик/Яндекс, город неизвестен — потенциально тоже
-- чужой). Для Нижнего Тагила конкретно: 8 сделок получили match, 4 — явно чужой
-- город (ЕКБ и др.). median_discount_pct на смеси городов уезжает в -63.6%
-- (в audit-заходе см. #2583 -59%) — «медианный торг» на витрине читается как
-- реальная рыночная скидка по улице пользователя, а на деле мешает рынки разной
-- ценовой полки.
--
-- Соседний эндпоинт /street-deals (trade_in.py:1654) городской скоуп уже получил
-- (комментарий #C1 там же) — тот же паттерн переносим сюда: город резолвится
-- ОДИН раз в Python через _resolve_target_city(address) (estimator.py:1350,
-- словарь ~30 городов обл.66 вкл. ЕКБ + sweep-города) и передаётся как ОДИН
-- bind-параметр в TVF, который применяет его к ОБЕИМ сторонам JOIN:
-- - deals.city заполнена на 100% (проверено на проде) → строгое равенство
-- LOWER(d.city) = LOWER(p_target_city).
-- - listings.city заполнена ЧАСТИЧНО (прод-замер: avito 63%, yandex 19%,
-- cian 4.6%, domklik 0.6%, n1 0%) → предикат терпим к NULL, симметрично
-- паттерну asking_to_sold_ratio.py (#2583 H2, PR #2617):
-- (l.city IS NULL OR LOWER(l.city) = LOWER(p_target_city)).
-- Строгий `l.city = p_target_city` без IS NULL выбросил бы ~80-95% listings
-- для источников кроме avito — по мере роста покрытия колонки предикат сам
-- ужесточается без правок кода.
-- - p_target_city IS NULL (адрес вне словаря SVERDLOVSK_OBLAST_CITIES, редкий
-- мелкий н.п. области — тот же неполный список, что в известной находке H1)
-- → фильтр не применяется НИ на одной стороне, текущее (pre-fix) поведение
-- сохраняется как fallback. Осознанно, не побочный эффект: /street-deals уже
-- принял этот компромисс для того же словаря городов — расхождение в
-- поведении между двумя виджетами на одной странице (для одного и того же
-- адреса) было бы хуже, чем редкий edge-case без фильтра. H1 — известная
-- отдельная находка (fix отдельным PR), здесь её не трогаем.
--
-- Signature change: p_target_city добавлен СЕДЬМЫМ параметром с DEFAULT NULL —
-- обратная совместимость с любым caller'ом, который вызывает функцию 6
-- позиционными аргументами (сейчас единственный caller — trade_in.py:1865,
-- обновляется в этом же PR). CREATE OR REPLACE FUNCTION с ДОБАВЛЕННЫМ параметром
-- технически создаёт НОВУЮ перегрузку (Postgres матчит функции по списку типов
-- аргументов) — поэтому старую 6-параметровую сигнатуру дропаем явно ПЕРЕД
-- CREATE OR REPLACE, чтобы не остались висеть два оверлоада одной функции.
-- DROP FUNCTION IF EXISTS с 6-арг сигнатурой идемпотентен: при повторном
-- прогоне (когда функция уже 7-арг) просто no-op, ошибки не будет.
--
-- Grep-проверка вызывающих (2026-08): единственный caller —
-- app/api/v1/trade_in.py:1865 (/sales-vs-listings). Convenience view
-- v_street_sales_vs_listings из 067 уже дропнута в 068 (была без street-match,
-- генерила 50k spurious pairs) — фиксить нечего, объекта не существует.
--
-- Deploy order: после 204. Второй caller (Python) обновляется в том же PR —
-- миграция должна применяться ДО деплоя backend-кода (стандартный SQL-first
-- порядок), но т.к. новый параметр DEFAULT NULL — старый код (без city) продолжит
-- работать без ошибок между миграцией и деплоем кода (не критичный порядок, но
-- соблюдаем канон).
BEGIN;
DROP FUNCTION IF EXISTS street_sales_vs_listings(text, numeric, integer, integer, numeric, integer);
CREATE OR REPLACE FUNCTION street_sales_vs_listings(
p_street_pattern text,
p_area_m2 numeric,
p_rooms integer,
p_window_days integer DEFAULT 180,
p_area_tolerance numeric DEFAULT 0.15,
p_period_months integer DEFAULT 24,
p_target_city text DEFAULT NULL
)
RETURNS TABLE (
deal_id bigint,
deal_date date,
deal_price_rub bigint,
deal_price_per_m2 integer,
deal_area_m2 numeric,
deal_rooms integer,
deal_floor integer,
deal_address text,
listing_id bigint,
listing_source text,
listing_source_url text,
listing_date date,
listing_price_rub bigint,
listing_price_per_m2 integer,
listing_area_m2 numeric,
days_listing_to_deal integer,
discount_pct numeric
)
LANGUAGE sql
STABLE
AS $$
WITH window_deals AS (
-- Сделки в улице + период. Фильтр по rooms + area + (#2583 H4) city.
SELECT
d.id AS deal_id,
d.deal_date AS deal_date,
d.price_rub AS deal_price_rub,
d.price_per_m2 AS deal_price_per_m2,
d.area_m2 AS deal_area_m2,
d.rooms AS deal_rooms,
d.floor AS deal_floor,
d.address AS deal_address
FROM deals d
WHERE d.source = 'rosreestr'
AND d.address ILIKE p_street_pattern
AND d.rooms = p_rooms
AND d.area_m2 BETWEEN p_area_m2 * (1.0 - p_area_tolerance)
AND p_area_m2 * (1.0 + p_area_tolerance)
AND d.deal_date > NOW() - (p_period_months || ' months')::interval
AND d.price_rub > 0
-- #2583 H4: deals.city заполнена на 100% — строгое равенство.
-- NULL p_target_city (город вне словаря) → фильтр не применяется.
AND (p_target_city IS NULL OR LOWER(d.city) = LOWER(p_target_city))
),
window_listings AS (
-- Кандидаты-listings на той же улице, rooms exact, area ±tolerance,
-- (#2583 H4) тот же город что deals-сторона.
SELECT
l.id AS listing_id,
l.source AS listing_source,
l.source_url AS listing_source_url,
l.listing_date AS listing_date,
l.price_rub AS listing_price_rub,
l.price_per_m2 AS listing_price_per_m2,
l.area_m2 AS listing_area_m2,
l.rooms AS listing_rooms,
COALESCE(l.listing_date, l.scraped_at::date) AS listing_event_date
FROM listings l
WHERE l.address ILIKE p_street_pattern
AND l.rooms = p_rooms
AND l.area_m2 BETWEEN p_area_m2 * (1.0 - p_area_tolerance)
AND p_area_m2 * (1.0 + p_area_tolerance)
AND l.price_rub > 0
AND COALESCE(l.listing_date, l.scraped_at::date)
> NOW() - ((p_period_months + 6) || ' months')::interval
-- #2583 H4: listings.city заполнена ЧАСТИЧНО (прод: avito 63%,
-- yandex 19%, cian 4.6%, domklik 0.6%, n1 0%) — NULL считается "своим"
-- (симметрично asking_to_sold_ratio.py #2583 H2), иначе строгий
-- фильтр выбросил бы почти все listings кроме avito.
AND (p_target_city IS NULL OR l.city IS NULL OR LOWER(l.city) = LOWER(p_target_city))
),
paired AS (
-- LEFT JOIN: сохраняем все сделки даже если нет listing match.
-- Для каждой сделки выбираем listing с listing_date ближайший
-- к deal_date (предпочтительно перед сделкой).
SELECT DISTINCT ON (wd.deal_id)
wd.deal_id,
wd.deal_date,
wd.deal_price_rub,
wd.deal_price_per_m2,
wd.deal_area_m2,
wd.deal_rooms,
wd.deal_floor,
wd.deal_address,
wl.listing_id,
wl.listing_source,
wl.listing_source_url,
wl.listing_date,
wl.listing_price_rub,
wl.listing_price_per_m2,
wl.listing_area_m2,
(wd.deal_date - wl.listing_event_date)::integer AS days_listing_to_deal,
CASE
WHEN wl.listing_price_rub IS NOT NULL AND wl.listing_price_rub > 0
THEN ROUND(
(wd.deal_price_rub - wl.listing_price_rub)::numeric
/ wl.listing_price_rub * 100,
2
)
ELSE NULL
END AS discount_pct
FROM window_deals wd
LEFT JOIN window_listings wl
ON wl.listing_event_date
BETWEEN (wd.deal_date - (p_window_days || ' days')::interval)::date
AND (wd.deal_date + interval '30 days')::date
ORDER BY
wd.deal_id,
-- prefer listing event дата перед сделкой и ближе к ней
CASE WHEN wl.listing_event_date IS NULL THEN 1 ELSE 0 END,
CASE WHEN wl.listing_event_date <= wd.deal_date THEN 0 ELSE 1 END,
ABS((wd.deal_date - wl.listing_event_date))
)
SELECT
deal_id,
deal_date,
deal_price_rub,
deal_price_per_m2,
deal_area_m2,
deal_rooms,
deal_floor,
deal_address,
listing_id,
listing_source,
listing_source_url,
listing_date,
listing_price_rub,
listing_price_per_m2,
listing_area_m2,
days_listing_to_deal,
discount_pct
FROM paired
ORDER BY deal_date DESC;
$$;
COMMENT ON FUNCTION street_sales_vs_listings(text, numeric, integer, integer, numeric, integer, text) IS
'Pairs (ДКП-сделка, listing) для улицы. PR K / issue #564 Foundation Phase 1, '
'city-filter #2583 H4 (миграция 205). Per-street matching: address ILIKE, area '
'±tolerance, rooms exact, window_days до даты сделки (+30д grace), city-scope '
'(p_target_city, deals строго / listings терпимо к NULL). Возвращает LEFT '
'JOIN — сделки без listing match имеют listing_* = NULL. discount_pct = '
'(deal - listing) / listing * 100.';
COMMIT;

View file

@ -10,144 +10,20 @@ each row is wrapped in a SAVEPOINT (`db.begin_nested()`) per `.claude/rules/back
---
## Production usage (canonical)
## Address audit + backfill (issue #582) — REMOVED (#2593)
Scripts ship inside the `tradein-backend` image (PR F — `COPY scripts ./scripts`
в `backend/Dockerfile`). На VPS они уже в `/app/scripts/` — никаких manual
`docker cp` не нужно.
`YANDEX_GEOCODER_API_KEY` подтягивается из `/opt/gendesign/tradein-mvp/backend/
.env.runtime` через `env_file:` в `docker-compose.prod.yml` — никакого `-e` в
`docker exec` не нужно.
```bash
# Backfill (forward geocode 4170 houses без coords)
ssh gendesign 'docker exec tradein-backend python -m scripts.backfill_house_coords --batch 2026-05-27_backfill'
# Audit-only (reverse geocode проверка для уже geocoded houses)
ssh gendesign 'docker exec tradein-backend python -m scripts.backfill_house_coords --audit-only --batch 2026-05-27_audit'
# Canary first
ssh gendesign 'docker exec tradein-backend python -m scripts.backfill_house_coords --limit 100 --batch canary_$(date +%F)'
```
После изменения `backend/.env.runtime` нужен `--force-recreate` контейнера
(см. `.claude/rules/deploy.md`):
```bash
ssh gendesign 'cd /opt/gendesign/tradein-mvp && docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --force-recreate --no-deps backend'
```
---
## Address audit + backfill (issue #582)
End-to-end address quality pipeline. Three scripts, two helpers, two SQL files.
> Локальные примеры ниже — для dev-машины с `uv run` и переменными в shell.
> На prod используй canonical `docker exec` команды из секции выше — там
> `YANDEX_GEOCODER_API_KEY` уже подгружен из `backend/.env.runtime`.
### `audit_address_mismatch.py` — Phase 1 baseline (PR #583)
Stratified-sample audit (200 EKB houses) comparing `houses.address` vs
Yandex Geocoder reverse lookup. Writes one row per house into
`address_mismatch_audit` with the snapped point + canonical address + distance.
```bash
DATABASE_URL=postgresql+psycopg://... \
YANDEX_GEOCODER_API_KEY=... \
uv run python -m scripts.audit_address_mismatch \
--batch 2026-05-25_run1 \
--limit-per-district 25
```
Mode `auto` picks API if the key is set, otherwise Playwright (CAPTCHA-aware,
4-7s sleep between calls). API tier free is 25k req/day → 200-row sample
takes ~10s with no quota concern.
Report:
```bash
psql "$DATABASE_URL" -v batch='2026-05-25_run1' \
-f scripts/address_audit_report.sql
```
### `backfill_house_coords.py` — Phase 2-3 (PR for #582)
Two modes (`--audit-only` flag switches between them):
**Backfill (default)** — forward-geocode `houses.address` for the ~4141 rows
WHERE `lat IS NULL OR lon IS NULL`. Only writes back if Yandex returns
`precision='exact'` or `'number'` (skips street-only / locality matches).
Each processed row gets an `address_mismatch_audit` entry with status
`backfill` / `imprecise` / `no_match` / `error`.
```bash
DATABASE_URL=postgresql+psycopg://... \
YANDEX_GEOCODER_API_KEY=... \
uv run python -m scripts.backfill_house_coords \
--batch 2026-05-27_backfill
```
Expected duration (~4141 rows, 50ms between calls, ~250ms RTT per request):
20-25 min. Expected output split (rough baseline from Phase 1 numbers):
| Status | Approx rows | What it means |
|-------------|-------------|-----------------------------------------------------|
| `backfill` | ~3.3k3.7k | UPDATE landed, lat/lon now populated |
| `imprecise` | ~300500 | Match returned but precision too low — needs review |
| `no_match` | ~100300 | Yandex couldn't resolve; address probably mangled |
| `error` | <50 | HTTP errors / timeouts re-run picks them up |
**Audit-only** — reverse-geocode the ~4452 houses WITH coords, write
audit rows with status `ok` (≤50m) / `mismatch` (>50m) / `no_match` / `error`.
Does NOT modify the `houses` table.
```bash
uv run python -m scripts.backfill_house_coords \
--batch 2026-05-27_audit --audit-only
```
Combined budget for both phases (~8.6k requests) is well under the 25k/day
Geocoder free tier.
### Common ops
Canary first — run with `--limit 100` and inspect the audit table before
letting the full job loose:
```bash
uv run python -m scripts.backfill_house_coords \
--batch canary_$(date +%F) --limit 100
psql "$DATABASE_URL" -c "
SELECT audit_status, COUNT(*)
FROM address_mismatch_audit
WHERE audit_batch = 'canary_$(date +%F)'
GROUP BY audit_status;
"
```
Resume after crash / quota hit — same `--batch` label, the UNIQUE
`(house_id, audit_batch)` index skips finished rows:
```bash
uv run python -m scripts.backfill_house_coords --batch 2026-05-27_backfill
# ... interruption ...
uv run python -m scripts.backfill_house_coords --batch 2026-05-27_backfill
# logs: "resuming batch 2026-05-27_backfill: N rows already processed"
```
### Helpers (not entry points)
- `_yandex_reverse.py``forward_via_api()`, `reverse_via_api()`,
`reverse_via_playwright()`, `YandexReverseResult` dataclass. Both API
paths share `_parse_api_payload` because Yandex's forward/reverse
envelopes have the same shape.
- `audit_address_sample.sql` — random sample for the Phase 1 audit (used
by `audit_address_mismatch.py`).
- `address_audit_report.sql` — psql-driven post-run summary (p50/p75/p95
distance, top-20 outliers, per-district breakdown).
`audit_address_mismatch.py`, `backfill_house_coords.py`, `_yandex_reverse.py`
и их SQL-хелперы (`audit_address_sample.sql`, `address_audit_report.sql`)
удалены — весь pipeline опирался на Yandex Geocoder API, который выпилен
из проекта (#2593, части 1-3). `houses.address`→lat/lon geocoding теперь
идёт через `app/services/geocoder.py` (кадастр/геопортал ЕКБ-тиры + Nominatim
fallback, единственный живой внешний провайдер) на обычном write-path
(`/api/v1/trade-in/estimate`, listing ingest). Разовый forward-backfill
недостающих `houses` координат — `scripts/geocode_deals_nominatim.py`
(живой, работает с `rosreestr_deals`, не с `houses` — читай его docstring
перед использованием на других таблицах). Таблица `address_mismatch_audit`
осталась в схеме (используется `house_dedup_merge.py` при слиянии дублей
домов, независимо от Yandex-аудита).
---

View file

@ -1,380 +0,0 @@
"""Yandex Geocoder helpers for the address-mismatch audit + backfill (issue #582).
Three geocoding paths exposed:
- `reverse_via_api()` Yandex Geocoder HTTP API, lon/lat address. Fast,
structured response, needs a valid API key (env `YANDEX_GEOCODER_API_KEY`).
Free tier is 25k req/day, fine for ~8.5k houses + audit (~17k total).
- `reverse_via_playwright()` fallback when no API key is available. Drives
a real browser session at https://yandex.ru/maps/?&mode=whatshere. Slower
and CAPTCHA-prone, so the driver inserts 4-7s sleeps between calls and we
raise a dedicated exception on CAPTCHA so the batch can pause-and-resume.
- `forward_via_api()` address lon/lat + canonical address (Phase 2 of
issue #582). Used by `backfill_house_coords.py` to fill `houses.lat/lon`
for the 4141 houses scraped from sources that didn't include coords (esp.
yandex_valuation, which only returns an address string).
All three return a `YandexReverseResult` dataclass same shape regardless
of direction so the driver code stays implementation-agnostic. The `raw`
field always carries the full source payload for post-hoc diagnostics, and
`precision` / `kind` are filled in by the API paths so the caller can skip
imprecise matches (e.g. only-street-level results during backfill).
Why three paths:
The user (issue #582 discussion) wants the audit to run on dev machines
that may not have an API key, but on prod we already provision the key for
estimator.py. Forward geocode is API-only Playwright forward geocoding
through Yandex Maps search is too fragile (relevance ranking, suggest
dropdown). For dev without a key, backfill simply doesn't run.
"""
from __future__ import annotations
import asyncio
import logging
import random
from dataclasses import dataclass, field
from typing import Any
import httpx
logger = logging.getLogger(__name__)
# Yandex Maps "what's here" URL — wraps a reverse-geocode in browser-driven UI.
# `whatshere[point]` accepts "<lon>,<lat>" (note: lon first, Yandex convention).
_YANDEX_MAPS_WHATSHERE = (
"https://yandex.ru/maps/?ll={lon:.6f}%2C{lat:.6f}&z=18&mode=whatshere"
"&whatshere%5Bpoint%5D={lon:.6f}%2C{lat:.6f}&whatshere%5Bzoom%5D=18"
)
# Geocoder HTTP API. `kind=house` narrows the result to a building if possible,
# which is what we want for cadastr-style addresses (улица + дом).
_YANDEX_GEOCODE_API = "https://geocode-maps.yandex.ru/1.x/"
# Reasonable timeouts: API call should be sub-second; we give it generous
# headroom for slow networks but not so much that a hang stalls the batch.
_API_TIMEOUT = httpx.Timeout(connect=5.0, read=10.0, write=5.0, pool=5.0)
# ---------------------------------------------------------------------------
# Dataclasses + exceptions
# ---------------------------------------------------------------------------
@dataclass
class YandexReverseResult:
"""Normalized result of a geocode call (forward, reverse-API, or browser).
Attributes:
address: Human-readable canonical address Yandex returned. For
reverse, this is the snapped address at the queried point. For
forward, this is the canonical form of the input address. None
if Yandex returned no match.
snapped_lat: Latitude of the matched object's geometric centre.
snapped_lon: Longitude of the matched object's geometric centre.
precision: For forward calls Yandex match precision tag (`exact`,
`number`, `near`, `range`, `street`, `other`). For reverse
same field is filled when present (usually `house` / `street`).
None for the playwright path. Used by the backfill driver to
skip imprecise matches.
kind: Object kind from Yandex (`house`, `street`, `locality`, ...).
Same source as `precision` see metaDataProperty.GeocoderMetaData.
raw: Raw response payload retained for forensics (JSON dict from API,
or snapshot dict from playwright). Used to populate
`address_mismatch_audit.raw_payload` and
`houses.raw_payload.yandex_geocode`.
"""
address: str | None
snapped_lat: float | None
snapped_lon: float | None
raw: dict[str, Any] = field(default_factory=dict)
precision: str | None = None
kind: str | None = None
class YandexBlockedError(RuntimeError):
"""Raised when Yandex returns a CAPTCHA / anti-bot challenge.
The driver catches this, marks the row `audit_status='blocked'`, logs the
current batch position, then exits cleanly so a human can intervene.
"""
# ---------------------------------------------------------------------------
# Path A — HTTP Geocoder API
# ---------------------------------------------------------------------------
async def reverse_via_api(
lat: float,
lon: float,
api_key: str,
*,
client: httpx.AsyncClient | None = None,
) -> YandexReverseResult:
"""Reverse-geocode (lat, lon) via the Yandex Geocoder HTTP API.
Why a separate `client` parameter: lets the driver reuse one
`AsyncClient` across all 200 calls (TCP keep-alive + connection pool),
and lets the tests inject a `MockTransport` to assert request shape.
Args:
lat: latitude in WGS84.
lon: longitude in WGS84.
api_key: Yandex Geocoder API key.
client: optional pre-built async client. If None, a one-shot client
is created.
Returns:
`YandexReverseResult` with the first `featureMember[0].GeoObject`
result, or all-None if Yandex returned no match (still includes
`raw` payload so we can later inspect why).
"""
params = {
"apikey": api_key,
# Yandex expects "lon,lat" (longitude first) per docs — same
# convention as the "whatshere" map URL above.
"geocode": f"{lon},{lat}",
"format": "json",
"kind": "house",
"results": "1",
}
own_client = client is None
if client is None:
client = httpx.AsyncClient(timeout=_API_TIMEOUT)
try:
resp = await client.get(_YANDEX_GEOCODE_API, params=params)
resp.raise_for_status()
data = resp.json()
finally:
if own_client:
await client.aclose()
return _parse_api_payload(data)
def _parse_api_payload(data: dict[str, Any]) -> YandexReverseResult:
"""Extract address + snapped point from a Yandex Geocoder API JSON response.
Split out so unit tests can feed a fixture file directly without spinning
up an HTTP mock. Same payload shape for forward and reverse calls
Yandex's response envelope is symmetric.
"""
try:
members = data.get("response", {}).get("GeoObjectCollection", {}).get("featureMember", [])
if not members:
return YandexReverseResult(address=None, snapped_lat=None, snapped_lon=None, raw=data)
geo_obj = members[0].get("GeoObject", {})
# Address: prefer the long `metaDataProperty.GeocoderMetaData.text`
# (full canonical) and fall back to `name` (street + house number).
meta = geo_obj.get("metaDataProperty", {}).get("GeocoderMetaData", {})
address = meta.get("text") or geo_obj.get("name")
precision = meta.get("precision")
kind = meta.get("kind")
# Point format: "<lon> <lat>" — space-separated string.
point_str = geo_obj.get("Point", {}).get("pos", "")
snapped_lon: float | None
snapped_lat: float | None
if point_str:
try:
lon_s, lat_s = point_str.split()
snapped_lon = float(lon_s)
snapped_lat = float(lat_s)
except (ValueError, TypeError):
snapped_lon = None
snapped_lat = None
else:
snapped_lon = None
snapped_lat = None
return YandexReverseResult(
address=address,
snapped_lat=snapped_lat,
snapped_lon=snapped_lon,
raw=data,
precision=precision,
kind=kind,
)
except Exception as e: # pragma: no cover — defensive; tests cover happy paths
logger.warning("yandex API payload parse failed: %s", e)
return YandexReverseResult(address=None, snapped_lat=None, snapped_lon=None, raw=data)
# ---------------------------------------------------------------------------
# Path A.2 — Forward geocode (address → lon/lat) via HTTP API
# ---------------------------------------------------------------------------
async def forward_via_api(
address: str,
api_key: str,
*,
client: httpx.AsyncClient | None = None,
) -> YandexReverseResult:
"""Forward-geocode an address string via the Yandex Geocoder HTTP API.
Phase 2 of issue #582 — used by `backfill_house_coords.py` to populate
`houses.lat/lon` for houses that were scraped without coords (esp.
yandex_valuation rows, which only carry an address).
Args:
address: free-form address ("ул Малышева 51", "Екатеринбург, Ленина 5",
etc.). Yandex's NLU is forgiving — no need to pre-normalize.
api_key: Yandex Geocoder API key.
client: optional pre-built async client. If None, a one-shot client
is created (matches `reverse_via_api` ergonomics).
Returns:
`YandexReverseResult` with the canonical address + snapped point of
the first matching feature. `precision` and `kind` are populated so
the backfill driver can skip imprecise hits (e.g. precision='street'
means we landed on the road, not the building too vague for
comparable-listings spatial queries).
Same envelope as `reverse_via_api` `_parse_api_payload` handles both.
"""
params = {
"apikey": api_key,
"geocode": address,
"format": "json",
# `kind=house` filters out street-only / locality-only matches at
# the API level when possible. Yandex still returns lower-precision
# results when no building matches, so the caller must double-check
# `precision` before writing to houses.
"kind": "house",
"results": "1",
# Locality bias for EKB — improves recall when the input address
# omits the city. The audit population is 99% EKB houses, so this
# is safe; non-EKB inputs (rare) still resolve, just with the bias.
"ll": "60.6122,56.8389",
"spn": "0.6,0.4",
}
own_client = client is None
if client is None:
client = httpx.AsyncClient(timeout=_API_TIMEOUT)
try:
resp = await client.get(_YANDEX_GEOCODE_API, params=params)
resp.raise_for_status()
data = resp.json()
finally:
if own_client:
await client.aclose()
return _parse_api_payload(data)
# ---------------------------------------------------------------------------
# Path B — Playwright fallback
# ---------------------------------------------------------------------------
async def reverse_via_playwright(
lat: float,
lon: float,
page: Any,
) -> YandexReverseResult:
"""Reverse-geocode (lat, lon) by driving yandex.ru/maps with Playwright.
Why this exists:
The Yandex Geocoder API requires a key with paid quota for >25k/day. The
audit only needs 200 rows but a dev without a key still needs a way to
run the script, so we ship a browser-driven fallback.
Implementation:
1. Navigate to the `whatshere` URL Yandex Maps responds by opening a
toponym card at the requested coordinates and rendering the resolved
address in the side panel.
2. Wait for client hydration (`networkidle`).
3. First try to read `window.__INITIAL_STATE__` Yandex stores the
toponym address inside the hydrated Redux tree, which is more
stable across UI redesigns than DOM selectors.
4. Fall back to DOM selectors (`.toponym-card-title-view__title` +
`__subtitle`) if the state walk doesn't find an address.
5. Detect CAPTCHA (`.CheckboxCaptcha`) early and raise `YandexBlockedError`
so the batch can pause-and-resume without spamming Yandex.
`page` is typed as `Any` to keep playwright a dev-only dep runtime
importers don't need playwright installed if they only use the API path.
"""
url = _YANDEX_MAPS_WHATSHERE.format(lat=lat, lon=lon)
await page.goto(url, wait_until="domcontentloaded")
# Light wait for client-side hydration. Yandex Maps fires lots of
# background XHRs so `networkidle` is too aggressive; this small wait is
# enough for the toponym card to render.
try:
await page.wait_for_load_state("networkidle", timeout=8000)
except Exception as e:
# Slow networks: continue — selectors will retry with their own waits.
logger.debug("networkidle wait timed out, continuing: %s", e)
await asyncio.sleep(random.uniform(0.5, 1.2))
# CAPTCHA gate — Yandex shows a `.CheckboxCaptcha` form when it suspects
# automation. Once we see it, every subsequent reverse call will also be
# blocked, so we raise immediately and let the driver stop the batch.
captcha = await page.query_selector(".CheckboxCaptcha")
if captcha is not None:
raise YandexBlockedError("Yandex CAPTCHA detected on maps page")
# Attempt 1 — initial state walk.
state_addr: str | None = None
state_pos: tuple[float, float] | None = None
try:
state_addr, state_pos = await page.evaluate(
"() => {\n"
" const s = window.__INITIAL_STATE__ || {};\n"
" const card = (s.cards && s.cards.toponym) || (s.card && s.card.toponym) || null;\n"
" if (!card) return [null, null];\n"
" const addr = card.title || card.address || null;\n"
" const pos = card.coords || card.point || null;\n"
" if (pos && pos.length === 2) return [addr, [pos[0], pos[1]]];\n"
" return [addr, null];\n"
"}"
)
except Exception as e:
logger.debug("playwright state walk failed (will fall back to DOM): %s", e)
address = state_addr
# Attempt 2 — DOM fallback.
if not address:
title_el = await page.query_selector(".toponym-card-title-view__title")
subtitle_el = await page.query_selector(".toponym-card-title-view__subtitle")
title = (await title_el.inner_text()).strip() if title_el else ""
subtitle = (await subtitle_el.inner_text()).strip() if subtitle_el else ""
# subtitle often holds "Екатеринбург, район", title the street + house
address = ", ".join([p for p in (subtitle, title) if p]) or None
snapped_lat: float | None
snapped_lon: float | None
if state_pos:
# State stored as [lon, lat] in Yandex's coordinate convention.
snapped_lon = float(state_pos[0])
snapped_lat = float(state_pos[1])
else:
snapped_lon = None
snapped_lat = None
raw = {
"url": url,
"state_addr": state_addr,
"state_pos": list(state_pos) if state_pos else None,
"dom_address": address if not state_addr else None,
}
return YandexReverseResult(
address=address,
snapped_lat=snapped_lat,
snapped_lon=snapped_lon,
raw=raw,
)

View file

@ -1,91 +0,0 @@
-- address_audit_report.sql
-- Post-run report for the address-mismatch audit (issue #582 Phase 1).
--
-- Sections:
-- 1. Summary — count, p50/p75/p95/mean distance, % street_differs,
-- % over 50m / 200m thresholds.
-- 2. Top-20 outliers by distance (manual triage list).
-- 3. Per-district breakdown — same metrics grouped by district column.
--
-- Run via psql:
-- psql "$DATABASE_URL" -v batch='2026-05-25_run1' -f scripts/address_audit_report.sql
--
-- :batch is a psql client variable substituted via -v.
\set ON_ERROR_STOP on
\echo '=============================================='
\echo ' Address mismatch audit — batch:' :batch
\echo '=============================================='
-- ---------------------------------------------------------------------------
-- 1) Top-level summary
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- Summary (status=ok rows only) ---'
SELECT
COUNT(*) AS n_total,
COUNT(*) FILTER (WHERE audit_status = 'ok') AS n_ok,
COUNT(*) FILTER (WHERE audit_status = 'no_match') AS n_no_match,
COUNT(*) FILTER (WHERE audit_status = 'error') AS n_error,
COUNT(*) FILTER (WHERE audit_status = 'blocked') AS n_blocked,
ROUND(percentile_cont(0.50)
WITHIN GROUP (ORDER BY distance_m)::numeric, 1) AS p50_distance_m,
ROUND(percentile_cont(0.75)
WITHIN GROUP (ORDER BY distance_m)::numeric, 1) AS p75_distance_m,
ROUND(percentile_cont(0.95)
WITHIN GROUP (ORDER BY distance_m)::numeric, 1) AS p95_distance_m,
ROUND(AVG(distance_m)::numeric, 1) AS mean_distance_m,
ROUND(100.0 * AVG(CASE WHEN street_differs THEN 1.0 ELSE 0.0 END), 1)
AS pct_street_differs,
ROUND(100.0 * AVG(CASE WHEN distance_m > 50 THEN 1.0 ELSE 0.0 END), 1)
AS pct_over_50m,
ROUND(100.0 * AVG(CASE WHEN distance_m > 200 THEN 1.0 ELSE 0.0 END), 1)
AS pct_over_200m
FROM address_mismatch_audit
WHERE audit_batch = :'batch'
AND audit_status = 'ok';
-- ---------------------------------------------------------------------------
-- 2) Top-20 outliers
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- Top-20 outliers by distance ---'
SELECT
house_id,
district,
ROUND(distance_m::numeric, 1) AS distance_m,
street_differs,
LEFT(original_address, 60) AS original_address,
LEFT(snapped_address, 60) AS snapped_address
FROM address_mismatch_audit
WHERE audit_batch = :'batch'
AND audit_status = 'ok'
AND distance_m IS NOT NULL
ORDER BY distance_m DESC NULLS LAST
LIMIT 20;
-- ---------------------------------------------------------------------------
-- 3) Per-district breakdown
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- Per-district breakdown (status=ok only) ---'
SELECT
COALESCE(district, '(no district)') AS district,
COUNT(*) AS n,
ROUND(percentile_cont(0.50)
WITHIN GROUP (ORDER BY distance_m)::numeric, 1) AS p50_distance_m,
ROUND(percentile_cont(0.95)
WITHIN GROUP (ORDER BY distance_m)::numeric, 1) AS p95_distance_m,
ROUND(AVG(distance_m)::numeric, 1) AS mean_distance_m,
ROUND(100.0 * AVG(CASE WHEN street_differs THEN 1.0 ELSE 0.0 END), 1)
AS pct_street_differs,
ROUND(100.0 * AVG(CASE WHEN distance_m > 50 THEN 1.0 ELSE 0.0 END), 1)
AS pct_over_50m,
ROUND(100.0 * AVG(CASE WHEN distance_m > 200 THEN 1.0 ELSE 0.0 END), 1)
AS pct_over_200m
FROM address_mismatch_audit
WHERE audit_batch = :'batch'
AND audit_status = 'ok'
GROUP BY COALESCE(district, '(no district)')
ORDER BY n DESC, district;

View file

@ -1,595 +0,0 @@
"""Audit driver — compares houses.address vs Yandex reverse geocode.
Phase 1 of Forgejo issue #582. Pulls a stratified sample of EKB houses (25
per admin district = 200 total), reverse-geocodes each via Yandex, computes
the distance between the stored coordinates and the snapped Yandex point,
and writes the result into `address_mismatch_audit`.
Design choices:
- **Resumable**: the audit table has UNIQUE (house_id, audit_batch). Re-run
with the same `--batch` skips rows already inserted, so a partial run can
be picked up after CAPTCHA / network blip.
- **Mode auto**: prefer API when `YANDEX_GEOCODER_API_KEY` is set, fall back
to Playwright otherwise. Explicit override via `--mode {api,playwright}`.
- **No prod side effects**: the script only writes to one new audit table;
it never touches `houses`, `house_sources`, or any matching/listing row.
- **Per-row SAVEPOINT**: a single Yandex error must not nuke the entire
batch wrap each INSERT in `db.begin_nested()` per backend.md.
How to run:
DATABASE_URL=postgresql+psycopg://... \
YANDEX_GEOCODER_API_KEY=... \
python -m scripts.audit_address_mismatch --batch 2026-05-25_run1
Outputs (post-run):
- New rows in `address_mismatch_audit` with batch label.
- `scripts/address_audit_report.sql :batch=<id>` for summary.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import logging
import os
import random
from dataclasses import dataclass
from datetime import date
from pathlib import Path
from typing import Any
import httpx
from sqlalchemy import text
from sqlalchemy.orm import Session
# Allow running both as `python -m scripts.audit_address_mismatch` (preferred)
# and as a stand-alone file (`python scripts/audit_address_mismatch.py`)
# without requiring package install.
try:
from app.core.db import SessionLocal # type: ignore[import-not-found]
from app.services.matching.normalize import normalize_address # type: ignore[import-not-found]
except ImportError: # pragma: no cover — fallback for adhoc invocation
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.core.db import SessionLocal
from app.services.matching.normalize import normalize_address
# `from .` works when run via -m; the absolute import works under pytest.
try:
from scripts._yandex_reverse import ( # type: ignore[import-not-found]
YandexBlockedError,
YandexReverseResult,
reverse_via_api,
reverse_via_playwright,
)
except ImportError:
from _yandex_reverse import ( # type: ignore[no-redef]
YandexBlockedError,
YandexReverseResult,
reverse_via_api,
reverse_via_playwright,
)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
logger = logging.getLogger("audit_address_mismatch")
# Playwright persistent context location — keeps cookies/local storage between
# runs so we look like a returning user, reducing CAPTCHA frequency.
_PLAYWRIGHT_USER_DATA = Path.home() / ".cache" / "tradein-audit-playwright"
_PLAYWRIGHT_UA = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
)
_SAMPLE_SQL_PATH = Path(__file__).parent / "audit_address_sample.sql"
# ---------------------------------------------------------------------------
# Domain helpers
# ---------------------------------------------------------------------------
@dataclass
class SampleRow:
"""One house from the stratified sampling query."""
id: int
address: str
lat: float
lon: float
district: str | None
# Words that introduce a street rather than identify it. We skip these so the
# comparison lands on the actual street name ('малышева' / 'ленина'). Mirrors
# the canonical forms produced by `normalize_address` (which expands all known
# abbreviations to these full words).
_STREET_TYPE_WORDS = frozenset(
{
"улица",
"проспект",
"переулок",
"бульвар",
"проезд",
"шоссе",
"площадь",
"набережная",
"тупик",
"строение",
"корпус",
"дом",
}
)
# Geographic prefix words that addresses sometimes carry before the street
# (e.g. 'россия екатеринбург улица малышева 51'). We skip them too so we
# converge on the same identifying token regardless of how verbose the
# source representation is.
_GEO_PREFIX_WORDS = frozenset(
{
"россия",
"свердловская",
"область",
"екатеринбург",
"город",
"г",
}
)
def _first_street_token(address: str | None) -> str | None:
"""Extract the first street-name token of a normalized address.
Phase-1 heuristic for "do the streets agree": skip numeric tokens (house
numbers), street-type words ('улица', 'проспект', ), and geographic
prefixes ('россия', 'екатеринбург', ) the next token is the street
name itself, which is the identifying part we want to compare.
Returns None for an empty address or when no candidate token remains.
"""
norm = normalize_address(address or "")
if not norm:
return None
for tok in norm.split():
if not tok:
continue
# Skip purely numeric tokens (e.g. '5', '17а' if it starts with digit).
if tok[0].isdigit():
continue
# Skip street type words and geographic prefixes.
if tok in _STREET_TYPE_WORDS or tok in _GEO_PREFIX_WORDS:
continue
return tok
return None
def _street_differs(original: str | None, snapped: str | None) -> bool | None:
"""True iff first non-numeric token differs between the two addresses.
Returns None when either side is empty we cannot compute a meaningful
diff (caller writes NULL into the audit row).
"""
a = _first_street_token(original)
b = _first_street_token(snapped)
if a is None or b is None:
return None
return a != b
def _distance_meters(
db: Session,
olat: float,
olon: float,
slat: float,
slon: float,
) -> float | None:
"""Compute great-circle distance via PostGIS geography type.
We could do this in Python with a haversine formula, but the audit table
uses ST_Distance results elsewhere so we use the same authority to avoid
drift. ST_MakePoint(lon, lat) PostGIS convention is lon first.
"""
row = db.execute(
text(
"SELECT ST_Distance("
" ST_SetSRID(ST_MakePoint(CAST(:olon AS double precision), "
" CAST(:olat AS double precision)), 4326)::geography, "
" ST_SetSRID(ST_MakePoint(CAST(:slon AS double precision), "
" CAST(:slat AS double precision)), 4326)::geography"
") AS m"
),
{"olat": olat, "olon": olon, "slat": slat, "slon": slon},
).first()
if row is None or row[0] is None:
return None
return float(row[0])
# ---------------------------------------------------------------------------
# Sampling + resumption queries
# ---------------------------------------------------------------------------
def _load_sample(db: Session, limit_per_district: int) -> list[SampleRow]:
"""Run the stratified sampling SQL → list of SampleRow."""
sql = _SAMPLE_SQL_PATH.read_text(encoding="utf-8")
rows = db.execute(text(sql), {"limit_per_district": limit_per_district}).mappings().all()
return [
SampleRow(
id=r["id"],
address=r["address"],
lat=float(r["lat"]),
lon=float(r["lon"]),
district=r["district"],
)
for r in rows
]
def _already_processed_ids(db: Session, batch: str) -> set[int]:
"""Return the set of house_id already in the audit table for this batch.
Drives resumability: drop these from the sample before geocoding.
"""
rows = db.execute(
text("SELECT house_id FROM address_mismatch_audit WHERE audit_batch = CAST(:b AS text)"),
{"b": batch},
).all()
return {r[0] for r in rows}
# ---------------------------------------------------------------------------
# Insert helper
# ---------------------------------------------------------------------------
def _insert_audit_row(
db: Session,
*,
house_id: int,
batch: str,
district: str | None,
original_address: str | None,
original_lat: float | None,
original_lon: float | None,
snapped_address: str | None,
snapped_lat: float | None,
snapped_lon: float | None,
distance_m: float | None,
street_differs: bool | None,
audit_status: str,
error_message: str | None,
raw_payload: dict[str, Any] | None,
) -> None:
"""INSERT … ON CONFLICT DO NOTHING into address_mismatch_audit.
Wrapped in begin_nested by the caller per backend.md SAVEPOINT pattern.
"""
db.execute(
text(
"INSERT INTO address_mismatch_audit ("
" house_id, audit_batch, district,"
" original_address, original_lat, original_lon,"
" snapped_address, snapped_lat, snapped_lon,"
" distance_m, street_differs,"
" audit_status, error_message, raw_payload"
") VALUES ("
" CAST(:house_id AS bigint), CAST(:batch AS text), :district,"
" :original_address, :original_lat, :original_lon,"
" :snapped_address, :snapped_lat, :snapped_lon,"
" :distance_m, :street_differs,"
" CAST(:audit_status AS text), :error_message,"
" CAST(:raw_payload AS jsonb)"
") ON CONFLICT (house_id, audit_batch) DO NOTHING"
),
{
"house_id": house_id,
"batch": batch,
"district": district,
"original_address": original_address,
"original_lat": original_lat,
"original_lon": original_lon,
"snapped_address": snapped_address,
"snapped_lat": snapped_lat,
"snapped_lon": snapped_lon,
"distance_m": distance_m,
"street_differs": street_differs,
"audit_status": audit_status,
"error_message": error_message,
"raw_payload": json.dumps(raw_payload) if raw_payload is not None else None,
},
)
# ---------------------------------------------------------------------------
# Mode dispatcher
# ---------------------------------------------------------------------------
def _resolve_mode(mode: str, api_key: str | None) -> str:
"""Translate `--mode auto` → concrete 'api' / 'playwright' choice.
Explicit modes are passed through unchanged; auto chooses api iff a key
is configured (fail-fast: we don't want a "should have used the API but
silently fell back to slow scraping" surprise).
"""
if mode == "auto":
return "api" if api_key else "playwright"
return mode
# ---------------------------------------------------------------------------
# Main loop
# ---------------------------------------------------------------------------
async def _run_api_mode(
db: Session,
sample: list[SampleRow],
batch: str,
api_key: str,
) -> int:
"""Geocode the sample using the HTTP Geocoder API."""
processed = 0
last_distance: float | None = None
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0)) as client:
for i, row in enumerate(sample, start=1):
status = "ok"
err: str | None = None
res: YandexReverseResult | None = None
try:
res = await reverse_via_api(row.lat, row.lon, api_key, client=client)
except httpx.HTTPError as e:
status = "error"
err = f"http_error: {e!s}"
except Exception as e: # pragma: no cover — defensive
status = "error"
err = f"unhandled: {e!s}"
distance = None
street_diff: bool | None = None
if res is not None and status == "ok":
if res.address is None:
status = "no_match"
else:
if res.snapped_lat is not None and res.snapped_lon is not None:
distance = _distance_meters(
db, row.lat, row.lon, res.snapped_lat, res.snapped_lon
)
last_distance = distance
street_diff = _street_differs(row.address, res.address)
try:
with db.begin_nested():
_insert_audit_row(
db,
house_id=row.id,
batch=batch,
district=row.district,
original_address=row.address,
original_lat=row.lat,
original_lon=row.lon,
snapped_address=res.address if res else None,
snapped_lat=res.snapped_lat if res else None,
snapped_lon=res.snapped_lon if res else None,
distance_m=distance,
street_differs=street_diff,
audit_status=status,
error_message=err,
raw_payload=res.raw if res else None,
)
# Per-row commit: each row is durable on disk before the next
# Yandex call; --batch resume picks up exactly where we crashed.
db.commit()
processed += 1
except Exception as e:
db.rollback()
logger.warning("insert failed for house_id=%s: %s", row.id, e)
if i % 10 == 0:
logger.info(
"progress %d/%d, mode=api, last_distance=%s",
i,
len(sample),
f"{last_distance:.1f}m" if last_distance is not None else "n/a",
)
return processed
async def _run_playwright_mode(
db: Session,
sample: list[SampleRow],
batch: str,
) -> int:
"""Geocode via a persistent Playwright context (CAPTCHA-aware)."""
try:
from playwright.async_api import async_playwright # type: ignore[import-not-found]
except ImportError as e:
raise RuntimeError(
"Playwright is required for --mode playwright. "
"Install with `uv sync --group dev` and `playwright install chromium`."
) from e
_PLAYWRIGHT_USER_DATA.mkdir(parents=True, exist_ok=True)
processed = 0
last_distance: float | None = None
async with async_playwright() as p:
context = await p.chromium.launch_persistent_context(
user_data_dir=str(_PLAYWRIGHT_USER_DATA),
headless=False,
user_agent=_PLAYWRIGHT_UA,
locale="ru-RU",
timezone_id="Asia/Yekaterinburg",
)
page = await context.new_page()
try:
for i, row in enumerate(sample, start=1):
status = "ok"
err: str | None = None
res: YandexReverseResult | None = None
stop_batch = False
try:
res = await reverse_via_playwright(row.lat, row.lon, page)
except YandexBlockedError as e:
status = "blocked"
err = str(e)
stop_batch = True
except Exception as e:
status = "error"
err = f"playwright: {e!s}"
distance = None
street_diff: bool | None = None
if res is not None and status == "ok":
if res.address is None:
status = "no_match"
else:
if res.snapped_lat is not None and res.snapped_lon is not None:
distance = _distance_meters(
db, row.lat, row.lon, res.snapped_lat, res.snapped_lon
)
last_distance = distance
street_diff = _street_differs(row.address, res.address)
try:
with db.begin_nested():
_insert_audit_row(
db,
house_id=row.id,
batch=batch,
district=row.district,
original_address=row.address,
original_lat=row.lat,
original_lon=row.lon,
snapped_address=res.address if res else None,
snapped_lat=res.snapped_lat if res else None,
snapped_lon=res.snapped_lon if res else None,
distance_m=distance,
street_differs=street_diff,
audit_status=status,
error_message=err,
raw_payload=res.raw if res else None,
)
db.commit()
processed += 1
except Exception as e:
db.rollback()
logger.warning("insert failed for house_id=%s: %s", row.id, e)
if stop_batch:
logger.error(
"Yandex CAPTCHA detected at position %d/%d (house_id=%s). "
"Stopping batch — re-run with same --batch to resume.",
i,
len(sample),
row.id,
)
break
if i % 10 == 0:
logger.info(
"progress %d/%d, mode=playwright, last_distance=%s",
i,
len(sample),
f"{last_distance:.1f}m" if last_distance is not None else "n/a",
)
# Random delay 4-7s between requests — keeps us under Yandex's
# heuristic rate limit while still finishing 200 rows in <30min.
# Skip the wait on the last iteration (no next request to space).
if i < len(sample):
await asyncio.sleep(random.uniform(4.0, 7.0))
finally:
await context.close()
return processed
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
"""argparse setup, factored out for testability."""
p = argparse.ArgumentParser(
description="Phase 1 audit — houses.address vs Yandex reverse geocode.",
)
p.add_argument(
"--batch",
default=f"{date.today().isoformat()}_run1",
help="Audit batch label. Same batch re-run skips already-processed houses.",
)
p.add_argument(
"--limit-per-district",
type=int,
default=25,
help="Houses to sample per district (default 25 → ~200 total for EKB).",
)
p.add_argument(
"--mode",
choices=("auto", "api", "playwright"),
default="auto",
help="auto = API if YANDEX_GEOCODER_API_KEY set, else playwright.",
)
return p.parse_args(argv)
async def main(argv: list[str] | None = None) -> int:
"""CLI entry point. Returns the number of rows processed this run."""
args = _parse_args(argv)
api_key = os.environ.get("YANDEX_GEOCODER_API_KEY")
mode = _resolve_mode(args.mode, api_key)
if mode == "api" and not api_key:
raise SystemExit("mode=api requested but YANDEX_GEOCODER_API_KEY is not set")
logger.info(
"starting audit batch=%s mode=%s limit_per_district=%d",
args.batch,
mode,
args.limit_per_district,
)
db = SessionLocal()
try:
sample = _load_sample(db, args.limit_per_district)
logger.info("loaded sample: %d houses", len(sample))
# Resume support — drop already-processed house_ids.
done = _already_processed_ids(db, args.batch)
if done:
logger.info(
"resuming batch %s: %d rows already processed, %d remaining",
args.batch,
len(done),
len(sample) - sum(1 for s in sample if s.id in done),
)
remaining = [s for s in sample if s.id not in done]
if not remaining:
logger.info("nothing to do — batch %s is complete", args.batch)
return 0
if mode == "api":
n = await _run_api_mode(db, remaining, args.batch, api_key or "")
else:
n = await _run_playwright_mode(db, remaining, args.batch)
logger.info("done: processed=%d batch=%s mode=%s", n, args.batch, mode)
return n
finally:
db.close()
if __name__ == "__main__": # pragma: no cover
asyncio.run(main())

View file

@ -1,47 +0,0 @@
-- audit_address_sample.sql
-- Random sample of EKB houses for the address-mismatch audit (issue #582).
--
-- Strategy:
-- 1. Filter to houses with non-null lat/lon and non-empty address.
-- 2. Random shuffle via `ORDER BY random()` — repeatable enough for spot
-- sampling without needing a stable PRNG seed (the audit table dedupes
-- via UNIQUE (house_id, audit_batch), so re-running gives idempotent
-- results regardless of which rows land in the sample first).
-- 3. Cap the result at :limit_per_district * 8 rows — keeps the bind-param
-- contract compatible with the old stratified sampler (`:limit_per_district`
-- is still honored, just multiplied by the assumed 8-district count).
--
-- Why no spatial stratification anymore:
-- The previous version JOINed to `gendesign_ekb_districts_geom` (FDW
-- polygon table) to bucket houses by admin district. That join is fine on
-- prod where FDW is wired, but it adds a dependency we don't need for
-- Phase 2-3 (backfill + canonical reverse). Aggregation by district at
-- report time still works — we re-derive district during the audit via
-- spatial containment in the report SQL when needed.
--
-- Bind param:
-- :limit_per_district — kept for back-compat with the audit driver.
-- Effective sample size = :limit_per_district * 8 (e.g. 25 → 200).
--
-- Columns returned:
-- id, address, lat, lon, district
-- `district` is always NULL here — the audit driver will reverse-derive it
-- from Yandex Geocoder response (Yandex returns admin component) or leave
-- it NULL if not present in the response.
--
-- NB: uses CAST(:x AS int) per project sql.md rule (psycopg v3 ignores ::type
-- after bind params).
SELECT
h.id,
h.address,
h.lat,
h.lon,
NULL::text AS district
FROM houses h
WHERE h.lat IS NOT NULL
AND h.lon IS NOT NULL
AND h.address IS NOT NULL
AND length(trim(h.address)) > 0
ORDER BY random()
LIMIT CAST(:limit_per_district AS int) * 8;

View file

@ -1,619 +0,0 @@
"""Forward-geocode houses through Yandex Geocoder API to backfill lat/lon
and canonical address, plus optional reverse audit of already-geocoded houses.
Phase 2-3 of Forgejo issue #582. Two modes (mutually exclusive):
1. Backfill (default) for the ~4141 rows WHERE lat IS NULL OR lon IS NULL:
forward-geocode `houses.address` snap to a Yandex `house`-precision
point, UPDATE houses with the new lat/lon + canonical address payload,
and write an `address_mismatch_audit` row with `audit_status='backfill'`.
2. Audit-only (--audit-only) for the ~4452 rows that already have coords:
reverse-geocode (lat, lon) snapped point + canonical address, compute
ST_Distance vs stored coords, write an `address_mismatch_audit` row with
status 'ok' (50m) or 'mismatch' (>50m). Does NOT touch houses.
Design choices:
- **Per-row SAVEPOINT** (`db.begin_nested()`): a single Yandex/PostGIS error
must not nuke the entire batch. Per backend.md, never use bare rollback
inside a loop.
- **Resumable** via UNIQUE (house_id, audit_batch). Re-running the same
--batch label skips already-processed houses, so a partial run can be
picked up after CAPTCHA / network blip / 25k/day quota hit.
- **Precision filter**: backfill skips matches with precision in
('street', 'other', 'range', 'near', None) those are too imprecise for
comparable-listing spatial queries and would silently degrade matching
recall. The audit row still records what Yandex returned for forensics.
- **Rate limit**: 50ms between calls (~20 req/sec, well under Yandex's
25 req/sec service limit). Backfill mode runs single-threaded.
- **Daily quota**: 4141 backfill + 4452 audit 8.6k requests. Free Geocoder
tier is 25k/day comfortable buffer for retries.
Usage:
YANDEX_GEOCODER_API_KEY=xxx \\
DATABASE_URL=postgresql+psycopg://... \\
python -m scripts.backfill_house_coords --batch 2026-05-27_backfill
# Audit-only on the 4452 already-geocoded houses
python -m scripts.backfill_house_coords --batch 2026-05-27_audit \\
--audit-only --limit 500
Outputs:
- Backfill mode: UPDATE rows in `houses`, INSERT rows in
`address_mismatch_audit` with status 'backfill' / 'no_match' / 'imprecise'.
- Audit mode: INSERT rows in `address_mismatch_audit` with status 'ok' /
'mismatch' / 'no_match' / 'error'.
- Per-batch progress is logged every 25 rows.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import logging
import os
from dataclasses import dataclass
from datetime import date
from pathlib import Path
from typing import Any
import httpx
from sqlalchemy import text
from sqlalchemy.orm import Session
# Allow running both as `python -m scripts.backfill_house_coords` (preferred)
# and as a stand-alone file. Mirrors the audit_address_mismatch import dance.
try:
from app.core.db import SessionLocal # type: ignore[import-not-found]
except ImportError: # pragma: no cover — fallback for adhoc invocation
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.core.db import SessionLocal
try:
from scripts._yandex_reverse import ( # type: ignore[import-not-found]
YandexReverseResult,
forward_via_api,
reverse_via_api,
)
except ImportError:
from _yandex_reverse import ( # type: ignore[no-redef]
YandexReverseResult,
forward_via_api,
reverse_via_api,
)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
logger = logging.getLogger("backfill_house_coords")
# Yandex Geocoder service limits per docs (as of 2026-05):
# - 25k requests/day free tier
# - 25 requests/sec sustained
# 50ms between calls = ~20 req/sec, leaving headroom for connection ramp-up.
_REQUEST_DELAY_S = 0.05
# Precision values we ACCEPT for backfill — anything else means Yandex didn't
# resolve to a specific building, and writing the result back into houses
# would degrade matching recall.
# `exact` → match found at the exact address (best case)
# `number` → house number matched, but unit/entrance unspecified (acceptable)
# `near` / `range` / `street` / `other` / None → skipped (logged for analysis).
_BACKFILL_OK_PRECISION = frozenset({"exact", "number"})
# Audit threshold per issue #582 — distances above this flag a "mismatch"
# (the row still goes in the audit table, just with status='mismatch' for
# the report SQL to bucket separately).
_MISMATCH_DISTANCE_M = 50.0
# ---------------------------------------------------------------------------
# Domain types
# ---------------------------------------------------------------------------
@dataclass
class HouseRow:
"""One house from the source query — minimal fields needed for geocode."""
id: int
address: str
lat: float | None
lon: float | None
# ---------------------------------------------------------------------------
# Source-row queries
# ---------------------------------------------------------------------------
def _select_houses_without_coords(db: Session, limit: int | None) -> list[HouseRow]:
"""Pull houses needing forward geocode (lat IS NULL OR lon IS NULL).
Skips rows with empty address there's nothing to geocode there, they
need a separate cleanup pass.
"""
sql = (
"SELECT id, address, lat, lon "
"FROM houses "
"WHERE (lat IS NULL OR lon IS NULL) "
" AND address IS NOT NULL "
" AND length(trim(address)) > 0 "
"ORDER BY id"
)
if limit is not None:
sql += " LIMIT CAST(:limit AS int)"
rows = db.execute(text(sql), {"limit": limit}).mappings().all()
else:
rows = db.execute(text(sql)).mappings().all()
return [
HouseRow(id=r["id"], address=r["address"], lat=r["lat"], lon=r["lon"]) for r in rows
]
def _select_houses_with_coords(db: Session, limit: int | None) -> list[HouseRow]:
"""Pull houses needing reverse audit (both lat AND lon present)."""
sql = (
"SELECT id, address, lat, lon "
"FROM houses "
"WHERE lat IS NOT NULL "
" AND lon IS NOT NULL "
" AND address IS NOT NULL "
" AND length(trim(address)) > 0 "
"ORDER BY id"
)
if limit is not None:
sql += " LIMIT CAST(:limit AS int)"
rows = db.execute(text(sql), {"limit": limit}).mappings().all()
else:
rows = db.execute(text(sql)).mappings().all()
return [
HouseRow(id=r["id"], address=r["address"], lat=r["lat"], lon=r["lon"]) for r in rows
]
def _already_processed_ids(db: Session, batch: str) -> set[int]:
"""house_ids already in address_mismatch_audit for this batch → skip set."""
rows = db.execute(
text("SELECT house_id FROM address_mismatch_audit WHERE audit_batch = CAST(:b AS text)"),
{"b": batch},
).all()
return {r[0] for r in rows}
# ---------------------------------------------------------------------------
# Distance helper — PostGIS, lon/lat order
# ---------------------------------------------------------------------------
def _distance_meters(
db: Session, olat: float, olon: float, slat: float, slon: float
) -> float | None:
"""Great-circle distance (meters) via PostGIS geography type.
Lifted from `audit_address_mismatch.py` to keep the two scripts using
the same authority for distance computation. ST_MakePoint takes lon
first per PostGIS convention.
"""
row = db.execute(
text(
"SELECT ST_Distance("
" ST_SetSRID(ST_MakePoint(CAST(:olon AS double precision), "
" CAST(:olat AS double precision)), 4326)::geography, "
" ST_SetSRID(ST_MakePoint(CAST(:slon AS double precision), "
" CAST(:slat AS double precision)), 4326)::geography"
") AS m"
),
{"olat": olat, "olon": olon, "slat": slat, "slon": slon},
).first()
if row is None or row[0] is None:
return None
return float(row[0])
# ---------------------------------------------------------------------------
# DB writers
# ---------------------------------------------------------------------------
def _update_house_coords(
db: Session,
*,
house_id: int,
lat: float,
lon: float,
payload: dict[str, Any],
) -> None:
"""UPDATE houses SET lat/lon + merge yandex_geocode into raw_payload.
The `houses_set_geom_trg` BEFORE UPDATE trigger (009_houses.sql) maintains
`geom` automatically when lat/lon change, so we don't need to set geom
explicitly here. `raw_payload || jsonb_build_object(...)` is the idiomatic
psycopg-safe way to merge single ALTER, no read-modify-write race.
"""
db.execute(
text(
"UPDATE houses "
" SET lat = CAST(:lat AS double precision), "
" lon = CAST(:lon AS double precision), "
" raw_payload = COALESCE(raw_payload, '{}'::jsonb) "
" || jsonb_build_object('yandex_geocode', "
" CAST(:payload AS jsonb)) "
" WHERE id = CAST(:id AS bigint)"
),
{"id": house_id, "lat": lat, "lon": lon, "payload": json.dumps(payload)},
)
def _insert_audit_row(
db: Session,
*,
house_id: int,
batch: str,
original_address: str | None,
original_lat: float | None,
original_lon: float | None,
snapped_address: str | None,
snapped_lat: float | None,
snapped_lon: float | None,
distance_m: float | None,
audit_status: str,
error_message: str | None,
raw_payload: dict[str, Any] | None,
) -> None:
"""INSERT … ON CONFLICT DO NOTHING into address_mismatch_audit.
Same column shape as `audit_address_mismatch._insert_audit_row` but the
`district` and `street_differs` fields are left NULL backfill/audit
here doesn't have a stratification basis and we let the report SQL
derive district at query time if needed (via Yandex address parse).
Caller wraps in `begin_nested()` per backend.md SAVEPOINT pattern.
"""
db.execute(
text(
"INSERT INTO address_mismatch_audit ("
" house_id, audit_batch, district,"
" original_address, original_lat, original_lon,"
" snapped_address, snapped_lat, snapped_lon,"
" distance_m, street_differs,"
" audit_status, error_message, raw_payload"
") VALUES ("
" CAST(:house_id AS bigint), CAST(:batch AS text), NULL,"
" :original_address, :original_lat, :original_lon,"
" :snapped_address, :snapped_lat, :snapped_lon,"
" :distance_m, NULL,"
" CAST(:audit_status AS text), :error_message,"
" CAST(:raw_payload AS jsonb)"
") ON CONFLICT (house_id, audit_batch) DO NOTHING"
),
{
"house_id": house_id,
"batch": batch,
"original_address": original_address,
"original_lat": original_lat,
"original_lon": original_lon,
"snapped_address": snapped_address,
"snapped_lat": snapped_lat,
"snapped_lon": snapped_lon,
"distance_m": distance_m,
"audit_status": audit_status,
"error_message": error_message,
"raw_payload": json.dumps(raw_payload) if raw_payload is not None else None,
},
)
# ---------------------------------------------------------------------------
# Backfill loop (forward geocode, lat IS NULL houses)
# ---------------------------------------------------------------------------
def _classify_backfill_status(res: YandexReverseResult | None) -> str:
"""Translate a forward-geocode result into an audit_status value.
'backfill' Yandex returned a precise hit, lat/lon will be written.
'imprecise' match returned but precision is too low (street/other/...).
'no_match' Yandex returned an empty featureMember.
'error' handled by the caller's exception branch.
"""
if res is None or res.address is None:
return "no_match"
if res.precision not in _BACKFILL_OK_PRECISION:
return "imprecise"
if res.snapped_lat is None or res.snapped_lon is None:
return "no_match"
return "backfill"
async def _run_backfill_mode(
db: Session, sample: list[HouseRow], batch: str, api_key: str
) -> int:
"""Forward-geocode each house, UPDATE coords on precise hits, audit-log all."""
processed = 0
updated = 0
n_imprecise = 0
n_no_match = 0
n_error = 0
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0)) as client:
for i, row in enumerate(sample, start=1):
status = "backfill"
err: str | None = None
res: YandexReverseResult | None = None
try:
res = await forward_via_api(row.address, api_key, client=client)
except httpx.HTTPError as e:
status = "error"
err = f"http_error: {e!s}"
n_error += 1
except Exception as e: # pragma: no cover — defensive
status = "error"
err = f"unhandled: {e!s}"
n_error += 1
if status != "error":
status = _classify_backfill_status(res)
if status == "imprecise":
n_imprecise += 1
elif status == "no_match":
n_no_match += 1
try:
with db.begin_nested():
if status == "backfill" and res is not None and res.snapped_lat is not None:
# safe: status='backfill' guarantees snapped_lat/lon non-None.
assert res.snapped_lon is not None
_update_house_coords(
db,
house_id=row.id,
lat=res.snapped_lat,
lon=res.snapped_lon,
payload={
"address": res.address,
"precision": res.precision,
"kind": res.kind,
"batch": batch,
"source": "yandex_geocoder_api",
},
)
updated += 1
_insert_audit_row(
db,
house_id=row.id,
batch=batch,
original_address=row.address,
original_lat=row.lat,
original_lon=row.lon,
snapped_address=res.address if res else None,
snapped_lat=res.snapped_lat if res else None,
snapped_lon=res.snapped_lon if res else None,
distance_m=None,
audit_status=status,
error_message=err,
raw_payload=res.raw if res else None,
)
# Per-row commit so resume picks up exactly where we crashed.
db.commit()
processed += 1
except Exception as e:
db.rollback()
logger.warning("backfill insert failed for house_id=%s: %s", row.id, e)
if i % 25 == 0:
logger.info(
"backfill progress %d/%d updated=%d imprecise=%d no_match=%d error=%d",
i,
len(sample),
updated,
n_imprecise,
n_no_match,
n_error,
)
# Yandex 25 req/sec → 50ms between calls is plenty of headroom.
if i < len(sample):
await asyncio.sleep(_REQUEST_DELAY_S)
logger.info(
"backfill done: processed=%d updated=%d imprecise=%d no_match=%d error=%d",
processed,
updated,
n_imprecise,
n_no_match,
n_error,
)
return processed
# ---------------------------------------------------------------------------
# Audit-only loop (reverse geocode, lat IS NOT NULL houses)
# ---------------------------------------------------------------------------
async def _run_audit_mode(
db: Session, sample: list[HouseRow], batch: str, api_key: str
) -> int:
"""Reverse-geocode each house, compute distance, audit-log status/mismatch."""
processed = 0
n_ok = 0
n_mismatch = 0
n_no_match = 0
n_error = 0
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0)) as client:
for i, row in enumerate(sample, start=1):
# Type-narrow: audit mode only feeds rows with non-null coords.
assert row.lat is not None and row.lon is not None
status = "ok"
err: str | None = None
res: YandexReverseResult | None = None
try:
res = await reverse_via_api(row.lat, row.lon, api_key, client=client)
except httpx.HTTPError as e:
status = "error"
err = f"http_error: {e!s}"
n_error += 1
except Exception as e: # pragma: no cover — defensive
status = "error"
err = f"unhandled: {e!s}"
n_error += 1
distance = None
if res is not None and status == "ok":
if res.address is None:
status = "no_match"
n_no_match += 1
else:
if res.snapped_lat is not None and res.snapped_lon is not None:
distance = _distance_meters(
db, row.lat, row.lon, res.snapped_lat, res.snapped_lon
)
if distance is not None and distance > _MISMATCH_DISTANCE_M:
status = "mismatch"
n_mismatch += 1
else:
n_ok += 1
else:
n_ok += 1
try:
with db.begin_nested():
_insert_audit_row(
db,
house_id=row.id,
batch=batch,
original_address=row.address,
original_lat=row.lat,
original_lon=row.lon,
snapped_address=res.address if res else None,
snapped_lat=res.snapped_lat if res else None,
snapped_lon=res.snapped_lon if res else None,
distance_m=distance,
audit_status=status,
error_message=err,
raw_payload=res.raw if res else None,
)
db.commit()
processed += 1
except Exception as e:
db.rollback()
logger.warning("audit insert failed for house_id=%s: %s", row.id, e)
if i % 25 == 0:
logger.info(
"audit progress %d/%d ok=%d mismatch=%d no_match=%d error=%d",
i,
len(sample),
n_ok,
n_mismatch,
n_no_match,
n_error,
)
if i < len(sample):
await asyncio.sleep(_REQUEST_DELAY_S)
logger.info(
"audit done: processed=%d ok=%d mismatch=%d no_match=%d error=%d",
processed,
n_ok,
n_mismatch,
n_no_match,
n_error,
)
return processed
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
"""argparse setup, factored out for testability."""
p = argparse.ArgumentParser(
description=(
"Phase 2-3 of issue #582 — backfill houses.lat/lon via Yandex forward "
"geocode, or audit already-geocoded houses via reverse geocode."
),
)
p.add_argument(
"--batch",
default=f"{date.today().isoformat()}_backfill",
help="Audit batch label. Same batch re-run skips already-processed houses.",
)
p.add_argument(
"--audit-only",
action="store_true",
help=(
"Run reverse-geocode audit on houses WITH coords instead of forward "
"backfill on houses WITHOUT coords. Does not modify the houses table."
),
)
p.add_argument(
"--limit",
type=int,
default=None,
help=(
"Optional cap on source-row count. Useful for canary runs "
"(e.g. --limit 100 before letting the full 4k loose)."
),
)
return p.parse_args(argv)
async def main(argv: list[str] | None = None) -> int:
"""CLI entry point. Returns the number of rows processed this run."""
args = _parse_args(argv)
api_key = os.environ.get("YANDEX_GEOCODER_API_KEY")
if not api_key:
raise SystemExit(
"YANDEX_GEOCODER_API_KEY is required — forward geocode is API-only."
)
mode = "audit" if args.audit_only else "backfill"
logger.info(
"starting batch=%s mode=%s limit=%s",
args.batch,
mode,
args.limit if args.limit is not None else "all",
)
db = SessionLocal()
try:
if args.audit_only:
sample = _select_houses_with_coords(db, args.limit)
else:
sample = _select_houses_without_coords(db, args.limit)
logger.info("loaded source rows: %d", len(sample))
done = _already_processed_ids(db, args.batch)
if done:
logger.info(
"resuming batch %s: %d rows already processed",
args.batch,
len(done),
)
remaining = [s for s in sample if s.id not in done]
if not remaining:
logger.info("nothing to do — batch %s is complete for the loaded sample", args.batch)
return 0
if args.audit_only:
n = await _run_audit_mode(db, remaining, args.batch, api_key)
else:
n = await _run_backfill_mode(db, remaining, args.batch, api_key)
logger.info("done: processed=%d batch=%s mode=%s", n, args.batch, mode)
return n
finally:
db.close()
if __name__ == "__main__": # pragma: no cover
asyncio.run(main())

View file

@ -1,74 +0,0 @@
{
"response": {
"GeoObjectCollection": {
"metaDataProperty": {
"GeocoderResponseMetaData": {
"request": "60.586,56.838",
"results": "1",
"found": "1"
}
},
"featureMember": [
{
"GeoObject": {
"metaDataProperty": {
"GeocoderMetaData": {
"precision": "exact",
"text": "Россия, Свердловская область, Екатеринбург, улица Малышева, 51",
"kind": "house",
"Address": {
"country_code": "RU",
"formatted": "Россия, Свердловская область, Екатеринбург, улица Малышева, 51",
"postal_code": "620075",
"Components": [
{"kind": "country", "name": "Россия"},
{"kind": "province", "name": "Уральский федеральный округ"},
{"kind": "province", "name": "Свердловская область"},
{"kind": "area", "name": "городской округ Екатеринбург"},
{"kind": "locality", "name": "Екатеринбург"},
{"kind": "street", "name": "улица Малышева"},
{"kind": "house", "name": "51"}
]
},
"AddressDetails": {
"Country": {
"AddressLine": "Россия, Свердловская область, Екатеринбург, улица Малышева, 51",
"CountryNameCode": "RU",
"CountryName": "Россия",
"AdministrativeArea": {
"AdministrativeAreaName": "Свердловская область",
"SubAdministrativeArea": {
"SubAdministrativeAreaName": "городской округ Екатеринбург",
"Locality": {
"LocalityName": "Екатеринбург",
"Thoroughfare": {
"ThoroughfareName": "улица Малышева",
"Premise": {
"PremiseNumber": "51",
"PostalCode": {"PostalCodeNumber": "620075"}
}
}
}
}
}
}
}
}
},
"name": "улица Малышева, 51",
"description": "Екатеринбург, Россия",
"boundedBy": {
"Envelope": {
"lowerCorner": "60.585217 56.837461",
"upperCorner": "60.587094 56.838547"
}
},
"Point": {
"pos": "60.586155 56.838004"
}
}
}
]
}
}
}

View file

@ -1,4 +1,4 @@
"""Offline-тесты пула прокси (#2162).
"""Offline-тесты пула прокси (#2162, #2600).
Покрытие БЕЗ live-сети/БД: stateful FakeSession эмулирует таблицу scrape_proxies и
интерпретирует SQL по ключевым фрагментам, так что acquire/release/mark_health/
@ -8,11 +8,15 @@ reap_stale_leases проверяются по фактическому изме
- два acquire подряд РАЗНЫЕ прокси (первый лизнут выпал из выборки второго).
- release освобождает (leased_by NULL), прокси снова acquire-абелен.
- mark_health fail инкремент consecutive_fails, авто-disable при DISABLE_THRESHOLD.
- mark_health ok сброс fails + exit_ip/latency.
- mark_health ok сброс fails + exit_ip/latency + enabled=true (реанимация).
- reap_stale_leases освобождает старый lease, свежий не трогает.
- affinity-фильтр: acquire('avito') не берёт cian-only прокси.
- acquire пропускает disabled и «нездоровые» (fails >= MAX_CONSECUTIVE_FAILS).
- acquire без своих/any свободных берёт свободный чужой affinity (fallback, #2600 п.3).
- run_proxy_healthcheck: reap + проба каждого enabled + mark_health (проба замокана).
- run_proxy_healthcheck: disabled-узлы самовосстановление (#2600 п.1):
* успешная проба выключенного узла возвращает его в строй + revived++;
* недавно проверенный выключенный узел повторно не проверяется (не долбим провайдера).
"""
from __future__ import annotations
@ -29,6 +33,7 @@ import pytest
from app.services import proxy_pool
from app.services.proxy_pool import (
DISABLE_THRESHOLD,
DISABLED_RECHECK_MINUTES,
MAX_CONSECUTIVE_FAILS,
acquire,
mark_health,
@ -71,17 +76,44 @@ class FakeSession:
sql = str(stmt)
p = params or {}
if "FOR UPDATE SKIP LOCKED" in sql: # acquire SELECT
provider = p["provider"]
if "FOR UPDATE SKIP LOCKED" in sql: # acquire SELECT (primary affinity-scoped or fallback)
max_fails = p["max_fails"]
cands = [
r
for r in self.rows
if r["enabled"]
and r["consecutive_fails"] < max_fails
and r["provider_affinity"] in (provider, "any")
and r["leased_by"] is None
]
if "provider_affinity IN" in sql: # primary: своя affinity ИЛИ 'any'
provider = p["provider"]
cands = [
r
for r in self.rows
if r["enabled"]
and r["consecutive_fails"] < max_fails
and r["provider_affinity"] in (provider, "any")
and r["leased_by"] is None
]
else: # fallback: любая affinity, но не последний узел выделенной affinity
# (domclick и т.п. — #2600 review). ВАЖНО: применяем эту фильтрацию,
# только если сама SQL реально содержит защиту (EXISTS-подзапрос) —
# иначе мок реализовывал бы бизнес-логику независимо от проверяемого
# кода и не смог бы отличить старый (незащищённый) fallback-запрос от
# нового. Тот же класс бага, что был с "enabled" в mark_health-моке.
protects_last_node = "EXISTS" in sql
def _has_backup(row: dict[str, Any]) -> bool:
if row["provider_affinity"] == "any":
return True
return any(
other["provider_affinity"] == row["provider_affinity"]
and other["enabled"]
and other["id"] != row["id"]
for other in self.rows
)
cands = [
r
for r in self.rows
if r["enabled"]
and r["consecutive_fails"] < max_fails
and r["leased_by"] is None
and (not protects_last_node or _has_backup(r))
]
# ORDER BY last_ok_at NULLS LAST, id
cands.sort(
key=lambda r: (
@ -127,18 +159,33 @@ class FakeSession:
row["exit_ip"] = p["exit_ip"]
row["latency_ms"] = p["latency_ms"]
row["last_ok_at"] = datetime.now(UTC)
row["last_check_at"] = datetime.now(UTC)
# "SET consecutive_fails = 0" — общая подстрока старого И нового SQL,
# НЕ различает их сама по себе. Реанимация (enabled=true) — только если
# в тексте запроса реально есть присвоение enabled (#2600 review: старый
# мок ставил enabled=True безусловно и не ловил регресс).
if "enabled" in sql:
row["enabled"] = True
return _FakeResult([])
if "consecutive_fails = consecutive_fails + 1" in sql: # mark_health fail
row = self._by_id(p["id"])
if row is not None:
row["consecutive_fails"] += 1
row["last_check_at"] = datetime.now(UTC)
if row["consecutive_fails"] >= p["disable_threshold"]:
row["enabled"] = False
return _FakeResult([])
if "WHERE enabled" in sql and "ORDER BY id" in sql: # healthcheck SELECT
rows = sorted((r for r in self.rows if r["enabled"]), key=lambda r: r["id"])
if "WHERE enabled" in sql and "ORDER BY id" in sql: # healthcheck SELECT (#2600 п.1)
recheck_minutes = p["disabled_recheck_minutes"]
cutoff = datetime.now(UTC) - timedelta(minutes=recheck_minutes)
cands = [
r
for r in self.rows
if r["enabled"] or r.get("last_check_at") is None or r["last_check_at"] < cutoff
]
rows = sorted(cands, key=lambda r: r["id"])
return _FakeResult([dict(r) for r in rows])
raise AssertionError(f"unhandled SQL: {sql}")
@ -159,6 +206,7 @@ def _proxy(
leased_by: int | None = None,
leased_at: datetime | None = None,
last_ok_at: datetime | None = None,
last_check_at: datetime | None = None,
kind: str = "http",
rotate_url: str | None = None,
) -> dict[str, Any]:
@ -173,6 +221,7 @@ def _proxy(
"leased_by": leased_by,
"leased_at": leased_at,
"last_ok_at": last_ok_at,
"last_check_at": last_check_at,
"exit_ip": None,
"latency_ms": None,
}
@ -209,11 +258,6 @@ def test_acquire_empty_pool_returns_none() -> None:
assert acquire(db, "avito", run_id=1) is None # type: ignore[arg-type]
def test_acquire_affinity_filter_excludes_other_provider() -> None:
db = FakeSession([_proxy(1, affinity="cian")])
assert acquire(db, "avito", run_id=1) is None # type: ignore[arg-type]
def test_acquire_skips_disabled() -> None:
db = FakeSession([_proxy(1, affinity="avito", enabled=False)])
assert acquire(db, "avito", run_id=1) is None # type: ignore[arg-type]
@ -231,6 +275,62 @@ def test_acquire_without_run_id_uses_marker() -> None:
assert db._by_id(1)["leased_by"] == proxy_pool.NON_RUN_LEASE_MARKER
# ── acquire: fallback affinity (#2600 п.3 — не морить источник голодом) ────────
def test_acquire_prefers_own_affinity_when_available() -> None:
"""Своих (affinity=avito) хватает — приоритет не сломан, чужой (cian) не берём."""
db = FakeSession([_proxy(1, affinity="avito"), _proxy(2, affinity="cian")])
lease = acquire(db, "avito", run_id=1) # type: ignore[arg-type]
assert lease is not None
assert lease.id == 1
def test_acquire_falls_back_to_other_affinity_when_no_own_free() -> None:
"""Свободных avito/any нет, но есть свободный здоровый cian с бэкапом → fallback, а не None.
Два cian-узла забрать один через fallback безопасно: у cian остаётся другой
enabled-узел (protection на "последний узел affinity" не срабатывает).
"""
db = FakeSession([_proxy(1, affinity="cian"), _proxy(2, affinity="cian")])
lease = acquire(db, "avito", run_id=1) # type: ignore[arg-type]
assert lease is not None
assert lease.id == 1
assert db._by_id(1)["leased_by"] == 1
def test_acquire_no_fallback_when_nothing_free_at_all() -> None:
"""Fallback не выдумывает прокси из воздуха — если свободных нет вообще, None."""
db = FakeSession([_proxy(1, affinity="cian", leased_by=99)]) # занят
assert acquire(db, "avito", run_id=1) is None # type: ignore[arg-type]
# ── acquire: fallback НЕ забирает последний узел выделенной affinity (review #2609) ──
#
# domclick — ровно один узел (прод scrape_proxies.id=1), намеренно вырезанный из общего
# пула через provider_affinity='domclick': QRATOR банит всё, кроме этого одного чистого
# residential-адреса (см. 173_scrape_proxies_add_domclick_affinity.sql). Если fallback
# заберёт его под avito/cian/yandex — domclick (сейчас исправно собирает: 6501 активных
# объявлений, 368/сутки) останется без прокси вообще. Починка одного источника ценой
# полной поломки другого недопустима.
def test_acquire_fallback_protects_last_node_of_dedicated_affinity() -> None:
"""Единственный enabled-узел domclick НЕ отдаётся avito через fallback — None."""
db = FakeSession([_proxy(1, affinity="domclick")])
assert acquire(db, "avito", run_id=1) is None # type: ignore[arg-type]
assert db._by_id(1)["leased_by"] is None # узел не тронут
def test_acquire_fallback_allows_when_dedicated_affinity_has_backup() -> None:
"""Второй enabled-узел domclick есть → fallback как и раньше отдаёт свободный."""
db = FakeSession([_proxy(1, affinity="domclick"), _proxy(2, affinity="domclick")])
lease = acquire(db, "avito", run_id=1) # type: ignore[arg-type]
assert lease is not None
assert lease.id == 1
assert db._by_id(2)["leased_by"] is None # у domclick остался живой запасной узел
# ── release ──────────────────────────────────────────────────────────────────
@ -271,6 +371,15 @@ def test_mark_health_ok_resets_and_records() -> None:
assert row["last_ok_at"] is not None
def test_mark_health_ok_revives_disabled_proxy() -> None:
"""Успешная проба реанимирует выключенный узел (#2600 п.1) — enabled=true, fails=0."""
db = FakeSession([_proxy(1, enabled=False, fails=DISABLE_THRESHOLD)])
mark_health(db, 1, ok=True) # type: ignore[arg-type]
row = db._by_id(1)
assert row["enabled"] is True
assert row["consecutive_fails"] == 0
# ── reap_stale_leases ────────────────────────────────────────────────────────
@ -295,27 +404,95 @@ def test_reap_frees_stale_lease_keeps_fresh() -> None:
async def test_healthcheck_probes_enabled_and_marks_health(
monkeypatch: pytest.MonkeyPatch,
) -> None:
recently_checked = datetime.now(UTC) - timedelta(minutes=5) # < DISABLED_RECHECK_MINUTES
db = FakeSession(
[
_proxy(1, fails=2),
_proxy(2, enabled=False), # disabled — не проверяется
# disabled, recheck ещё не наступил (недавно проверен) — не проверяется в этот прогон
_proxy(2, enabled=False, last_check_at=recently_checked),
_proxy(3, fails=0),
]
)
async def _fake_probe(url: str) -> tuple[bool, str | None, int | None]:
async def _fake_probe(url: str) -> tuple[bool, str | None, int | None, str | None]:
# прокси 1 «жив», прокси 3 «мёртв»
if "h1:" in url:
return True, "9.9.9.9", 42
return False, None, None
return True, "9.9.9.9", 42, None
return False, None, None, "other"
monkeypatch.setattr(proxy_pool, "_probe_proxy", _fake_probe)
counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
assert counters["checked"] == 2 # только enabled (1 и 3)
assert counters["checked"] == 2 # только enabled (1 и 3), disabled recheck не наступил
assert counters["ok"] == 1
assert counters["failed"] == 1
assert counters["revived"] == 0
assert db._by_id(1)["consecutive_fails"] == 0 # ok → сброс
assert db._by_id(1)["exit_ip"] == "9.9.9.9"
assert db._by_id(3)["consecutive_fails"] == 1 # fail → инкремент
# ── run_proxy_healthcheck: self-healing disabled-узлов (#2600 п.1) ─────────────
async def test_healthcheck_revives_disabled_proxy_on_success(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Выключенный узел с успешной пробой возвращается в строй, revived++."""
stale_check = datetime.now(UTC) - timedelta(minutes=DISABLED_RECHECK_MINUTES + 5)
db = FakeSession([_proxy(1, enabled=False, fails=DISABLE_THRESHOLD, last_check_at=stale_check)])
async def _fake_probe(url: str) -> tuple[bool, str | None, int | None, str | None]:
return True, "5.5.5.5", 30, None
monkeypatch.setattr(proxy_pool, "_probe_proxy", _fake_probe)
counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
assert counters["checked"] == 1
assert counters["ok"] == 1
assert counters["revived"] == 1
row = db._by_id(1)
assert row["enabled"] is True
assert row["consecutive_fails"] == 0
async def test_healthcheck_skips_recently_checked_disabled_proxy(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Выключенный узел, проверенный недавно, повторно не проверяется в этот прогон."""
fresh_check = datetime.now(UTC) - timedelta(minutes=5) # < DISABLED_RECHECK_MINUTES
db = FakeSession([_proxy(1, enabled=False, fails=DISABLE_THRESHOLD, last_check_at=fresh_check)])
probed: list[str] = []
async def _fake_probe(url: str) -> tuple[bool, str | None, int | None, str | None]:
probed.append(url) # не должно вызваться
return True, "5.5.5.5", 30, None
monkeypatch.setattr(proxy_pool, "_probe_proxy", _fake_probe)
counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
assert counters["checked"] == 0
assert counters["revived"] == 0
assert probed == [] # провайдер не долбим каждый тик
assert db._by_id(1)["enabled"] is False # остался выключенным
async def test_healthcheck_checks_disabled_proxy_never_checked_before(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Выключенный узел без last_check_at (никогда не проверялся) — проверяется сразу."""
db = FakeSession([_proxy(1, enabled=False, fails=DISABLE_THRESHOLD, last_check_at=None)])
async def _fake_probe(url: str) -> tuple[bool, str | None, int | None, str | None]:
return False, None, None, "timeout"
monkeypatch.setattr(proxy_pool, "_probe_proxy", _fake_probe)
counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
assert counters["checked"] == 1
assert counters["revived"] == 0 # неуспех — не реанимируем
assert db._by_id(1)["enabled"] is False

View file

@ -0,0 +1,509 @@
"""Offline-тесты ротации exit-IP ASocks (#2600 п.5).
Покрытие БЕЗ live-сети/БД: httpx.AsyncClient подменён предсказуемым фейком,
FakeSession эмулирует scrape_proxies (одна строка) + scrape_proxy_rotations
(append-only список), pytest-asyncio (asyncio_mode=auto, см. pyproject.toml).
- rotate_url пуст «не поддерживается», НЕ ошибка, HTTP не дёргается.
- ASOCKS_API_TOKEN не задан внятный отказ, HTTP не дёргается.
- 4-я попытка за сутки отклоняется БЕЗ обращения к API (лимит 3/сутки).
- Успешная ротация пишет запись в scrape_proxy_rotations (success=True).
- 401 logger.error (громкий отказ) + sentry_sdk.capture_message (мониторинг),
аудит-запись пишется, но НЕ считается против суточного лимита.
- Токен не появляется ни в RotationResult.reason, ни в note аудит-записи, ни в
тексте log-сообщений (caplog.getMessage()), ни в тексте, ушедшем в Sentry
ни в одном из сценариев (сеть-ошибка, 401, provider 5xx, success).
- rotate_url на ЧУЖОМ хосте (не ALLOWED_ROTATE_HOST) отказ ДО HTTP-вызова
scrape_proxies.rotate_url колонка неоднородна (несёт и mobileproxy changeip-
ссылки), наш ASOCKS_API_TOKEN не должен уйти на них (security review PR #2611).
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import logging
from datetime import UTC, datetime, timedelta
from typing import Any
import httpx
import pytest
from app.services import proxy_rotation
SECRET_TOKEN = "asocks-super-secret-token-must-never-leak-1a2b3c"
# ── stateful fakes ────────────────────────────────────────────────────────────
class _FakeResult:
def __init__(self, rows: list[dict[str, Any]]):
self._rows = rows
def mappings(self) -> _FakeResult:
return self
def fetchone(self) -> dict[str, Any] | None:
return self._rows[0] if self._rows else None
class FakeSession:
"""Эмуляция Session: одна строка scrape_proxies + append-only
scrape_proxy_rotations, интерпретирует SQL по ключевым фрагментам (тот же
паттерн, что tests/services/test_proxy_pool.py)."""
def __init__(
self,
proxy_row: dict[str, Any] | None,
rotations: list[dict[str, Any]] | None = None,
):
self.proxy_row = proxy_row
self.rotations: list[dict[str, Any]] = rotations or []
self.commits = 0
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult:
sql = str(stmt)
p = params or {}
if "SELECT id, rotate_url FROM scrape_proxies" in sql:
if self.proxy_row is None or self.proxy_row["id"] != p["id"]:
return _FakeResult([])
return _FakeResult([dict(self.proxy_row)])
if "SELECT count(*) AS n" in sql and "scrape_proxy_rotations" in sql:
cutoff = datetime.now(UTC) - timedelta(hours=24)
n = sum(
1
for r in self.rotations
if r["proxy_id"] == p["proxy_id"]
and r["rotated_at"] > cutoff
and r["http_status"] is not None
and r["http_status"] != 401
)
return _FakeResult([{"n": n}])
if "INSERT INTO scrape_proxy_rotations" in sql:
self.rotations.append(
{
"proxy_id": p["proxy_id"],
"success": p["success"],
"http_status": p["http_status"],
"note": p["note"],
"rotated_at": datetime.now(UTC),
}
)
return _FakeResult([])
raise AssertionError(f"unhandled SQL: {sql}")
def commit(self) -> None:
self.commits += 1
def rollback(self) -> None:
pass
class _FakeResponse:
def __init__(self, status_code: int, json_data: dict[str, Any] | None):
self.status_code = status_code
self._json_data = json_data
def json(self) -> dict[str, Any]:
if self._json_data is None:
raise ValueError("no json body")
return self._json_data
def _fake_async_client(
*,
response: tuple[int, dict[str, Any] | None] | None,
exception: Exception | None,
):
"""Строит замену httpx.AsyncClient, никогда не бьющую в реальную сеть.
Ровно один из (response, exception) задан. calls накапливает (url, headers)
каждого post() тест проверяет по ним, был ли вообще HTTP-вызов.
"""
calls: list[dict[str, Any]] = []
class _FakeClientImpl:
def __init__(self, timeout: float | None = None) -> None:
self.timeout = timeout
async def __aenter__(self) -> _FakeClientImpl:
return self
async def __aexit__(self, *exc: object) -> bool:
return False
async def post(self, url: str, headers: dict[str, str] | None = None) -> _FakeResponse:
calls.append({"url": url, "headers": headers or {}})
if exception is not None:
raise exception
assert response is not None
status, body = response
return _FakeResponse(status, body)
return _FakeClientImpl, calls
def _no_http_allowed():
"""httpx.AsyncClient-заглушка, падающая AssertionError при любом post() —
для сценариев, где HTTP до провайдера дойти НЕ должно."""
class _ForbiddenClient:
def __init__(self, timeout: float | None = None) -> None:
pass
async def __aenter__(self) -> _ForbiddenClient:
return self
async def __aexit__(self, *exc: object) -> bool:
return False
async def post(self, *a: object, **kw: object) -> None:
raise AssertionError("HTTP call must NOT happen for this scenario")
return _ForbiddenClient
_DEFAULT_ROTATE_URL = "https://api.asocks.com/unlimited-proxy/1/refresh-ip"
# (name, rotate_url, response=(status, json_body)|None, exception|None) — ровно один
# из response/exception задан, либо оба None (локальный отказ, HTTP не идёт).
_LogScenario = tuple[str, str | None, tuple[int, dict[str, Any] | None] | None, Exception | None]
def _proxy_row(rotate_url: str | None = _DEFAULT_ROTATE_URL) -> dict[str, Any]:
return {"id": 1, "rotate_url": rotate_url}
def _quota_rows(proxy_id: int, n: int, *, http_status: int = 200) -> list[dict[str, Any]]:
now = datetime.now(UTC)
return [
{
"proxy_id": proxy_id,
"success": http_status < 400,
"http_status": http_status,
"note": None,
"rotated_at": now - timedelta(minutes=i),
}
for i in range(n)
]
# ── no rotate_url → not an error ────────────────────────────────────────────
async def test_no_rotate_url_is_not_an_error(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed())
db = FakeSession(_proxy_row(rotate_url=None))
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
assert result.ok is False
assert result.reason is not None
assert "rotat" in result.reason.lower() # human-readable, not a crash
assert db.rotations == [] # ничего не писалось — попытки не было
# ── host pinning (security review PR #2611) ─────────────────────────────────
#
# scrape_proxies.rotate_url колонка неоднородна: прод сейчас несёт mobileproxy
# changeip-ссылки (id 3/4/5) БОК О БОК с ASocks-ссылками (id 1/9/10/11, миграция
# 199). Без host-пиннинга наш Authorization: Bearer <ASOCKS_API_TOKEN> ушёл бы
# на чужой провайдер.
async def test_rotate_url_on_foreign_host_refused_before_http_call(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed())
foreign_url = "https://changeip.mobileproxy.space/?proxy_key=mobileproxy-own-secret"
db = FakeSession(_proxy_row(rotate_url=foreign_url))
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
assert result.ok is False
assert result.reason is not None
# _no_http_allowed() would have raised AssertionError from within rotate_proxy
# if the code had tried an HTTP call (i.e. sent our token) — reaching this
# line means it refused first. Belt-and-suspenders: no audit row either
# (this is a local rejection, same as no-rotate_url/no-token/limit).
assert db.rotations == []
assert SECRET_TOKEN not in result.reason
async def test_allowed_host_case_insensitive_still_proceeds(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Хост сверяется без учёта регистра (urlparse().hostname лоуеркейзит) — тот
же ALLOWED_ROTATE_HOST в другом регистре ДОЛЖЕН проходить, иначе пиннинг
превратился бы в ложный отказ на легитимном rotate_url."""
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
fake_client, calls = _fake_async_client(response=(200, {"ip": "1.2.3.4"}), exception=None)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
db = FakeSession(_proxy_row(rotate_url="https://API.ASOCKS.COM/unlimited-proxy/1/refresh-ip"))
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
assert result.ok is True
assert len(calls) == 1
async def test_allowed_host_over_plain_http_is_refused(monkeypatch: pytest.MonkeyPatch) -> None:
"""http:// (не https://) на тот же хост — отказ (защита от даунгрейда
транспорта, которым Authorization ушёл бы в открытом виде)."""
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed())
db = FakeSession(_proxy_row(rotate_url="http://api.asocks.com/unlimited-proxy/1/refresh-ip"))
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
assert result.ok is False
assert db.rotations == []
# ── missing token → neutral refusal, no crash ───────────────────────────────
async def test_missing_token_is_neutral_refusal(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", "")
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed())
db = FakeSession(_proxy_row())
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
assert result.ok is False
assert result.reason is not None
assert db.rotations == []
# ── daily limit ──────────────────────────────────────────────────────────────
async def test_fourth_attempt_today_rejected_without_api_call(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed())
# 3 quota-consuming попытки уже сегодня (успешные 200 — засчитываются).
db = FakeSession(_proxy_row(), _quota_rows(1, proxy_rotation.DAILY_ROTATION_LIMIT))
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
assert result.ok is False
assert "limit" in (result.reason or "").lower() or "лимит" in (result.reason or "").lower()
assert result.rotations_remaining_today == 0
# _no_http_allowed() would have raised AssertionError from within rotate_proxy
# if the code had tried an HTTP call — reaching here means it didn't.
assert len(db.rotations) == proxy_rotation.DAILY_ROTATION_LIMIT # ничего нового не дописано
# ── success writes history ──────────────────────────────────────────────────
async def test_successful_rotation_writes_history_row(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
fake_client, calls = _fake_async_client(response=(200, {"ip": "9.9.9.9"}), exception=None)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
db = FakeSession(_proxy_row())
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
assert result.ok is True
assert result.new_ip == "9.9.9.9"
assert result.rotations_remaining_today == proxy_rotation.DAILY_ROTATION_LIMIT - 1
assert len(calls) == 1
assert calls[0]["headers"]["Authorization"] == f"Bearer {SECRET_TOKEN}"
assert len(db.rotations) == 1
row = db.rotations[0]
assert row["success"] is True
assert row["http_status"] == 200
assert db.commits >= 1
# ── 401 → loud failure ───────────────────────────────────────────────────────
async def test_401_logs_error_and_alerts_monitoring_excluded_from_quota(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
fake_client, calls = _fake_async_client(
response=(401, {"success": False, "message": "Unauthenticated"}), exception=None
)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
sentry_calls: list[tuple[str, str | None]] = []
monkeypatch.setattr(
"sentry_sdk.capture_message",
lambda msg, level=None: sentry_calls.append((msg, level)),
)
db = FakeSession(_proxy_row())
with caplog.at_level(logging.ERROR):
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
assert result.ok is False
assert len(calls) == 1 # запрос реально ушёл
# громкий отказ: и лог, и мониторинг — не молчаливая остановка
error_records = [r for r in caplog.records if r.levelno == logging.ERROR]
assert any("401" in r.getMessage() for r in error_records)
assert len(sentry_calls) == 1
assert sentry_calls[0][1] == "error"
# аудит записан, но 401 НЕ считается против суточного лимита (см. модуль
# docstring: auth-отсев до провайдера, лимит на его стороне не тратится).
assert len(db.rotations) == 1
assert db.rotations[0]["http_status"] == 401
assert db.rotations[0]["success"] is False
assert proxy_rotation._quota_used_today(db, 1) == 0 # type: ignore[arg-type]
second = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
# 401 не съел лимит — снова полный DAILY_ROTATION_LIMIT доступен
assert second.rotations_remaining_today == proxy_rotation.DAILY_ROTATION_LIMIT
# ── token never leaks ────────────────────────────────────────────────────────
async def test_token_never_appears_in_reason_success(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
fake_client, _ = _fake_async_client(response=(200, {"ip": "1.1.1.1"}), exception=None)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
db = FakeSession(_proxy_row())
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
assert SECRET_TOKEN not in (result.reason or "")
assert SECRET_TOKEN not in (result.new_ip or "")
async def test_token_never_appears_in_reason_on_401(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
monkeypatch.setattr("sentry_sdk.capture_message", lambda *a, **kw: None)
fake_client, _ = _fake_async_client(
response=(401, {"message": "Unauthenticated"}), exception=None
)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
db = FakeSession(_proxy_row())
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
assert SECRET_TOKEN not in (result.reason or "")
assert all(SECRET_TOKEN not in (r["note"] or "") for r in db.rotations)
async def test_token_never_appears_in_reason_on_network_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""httpx-исключения могут нести полный request-контекст (URL/детали) —
прецедент утечки: app.api.v1.admin.rotate_proxy_ip (~line 2400). Здесь токен
живёт только в headers (не в URL), но проверяем end-to-end: даже если
exception-текст содержит секрет (симулируем это явно), наружу он не идёт."""
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
boom = httpx.ConnectError(f"connection failed while POSTing token={SECRET_TOKEN}")
fake_client, _ = _fake_async_client(response=None, exception=boom)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
db = FakeSession(_proxy_row())
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
assert result.ok is False
assert SECRET_TOKEN not in (result.reason or "")
assert all(SECRET_TOKEN not in (r["note"] or "") for r in db.rotations)
# сетевая ошибка не подтверждает, что провайдер обработал попытку → квота не тратится
assert db.rotations[0]["http_status"] is None
assert proxy_rotation._quota_used_today(db, 1) == 0 # type: ignore[arg-type]
# exception class name (не секрет) в note — оператор отличит "не дозвонились"
# (ConnectError) от "дозвонились, зависли" (ReadTimeout).
assert "ConnectError" in (db.rotations[0]["note"] or "")
async def test_token_never_appears_on_provider_error_status(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
fake_client, _ = _fake_async_client(
response=(500, {"message": "internal error"}), exception=None
)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
db = FakeSession(_proxy_row())
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
assert result.ok is False
assert SECRET_TOKEN not in (result.reason or "")
# провайдер прошёл auth и ответил своей ошибкой (500) — засчитывается в квоту
assert db.rotations[0]["http_status"] == 500
assert proxy_rotation._quota_used_today(db, 1) == 1 # type: ignore[arg-type]
async def test_token_never_appears_in_log_messages_or_sentry_text(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""Расширенное leak-покрытие (security review PR #2611): предыдущие тесты
проверяли только reason/note. Здесь текст, реально уходящий в logging и в
Sentry (не exc_info-traceback, который по дизайну МОЖЕТ нести детали
исключения см. модуль docstring; это осознанно разрешённое место).
caplog.records[i].getMessage() возвращает форматированный msg %% args, БЕЗ
exc_text то есть эта проверка ловит именно "секрет попал в аргумент
logger.*()", а не в traceback.
"""
sentry_texts: list[str] = []
monkeypatch.setattr(
"sentry_sdk.capture_message",
lambda msg, level=None: sentry_texts.append(msg),
)
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
scenarios: list[_LogScenario] = [
("success", _DEFAULT_ROTATE_URL, (200, {"ip": "1.1.1.1"}), None),
("401", _DEFAULT_ROTATE_URL, (401, {"message": "Unauthenticated"}), None),
("provider_500", _DEFAULT_ROTATE_URL, (500, {"message": "err"}), None),
(
"network_error",
_DEFAULT_ROTATE_URL,
None,
httpx.ConnectError(f"boom token={SECRET_TOKEN}"),
),
("foreign_host", "https://changeip.mobileproxy.space/?proxy_key=x", None, None),
]
for name, rotate_url, response, exception in scenarios:
if response is not None or exception is not None:
fake_client, _ = _fake_async_client(response=response, exception=exception)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
else:
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed())
db = FakeSession(_proxy_row(rotate_url=rotate_url))
with caplog.at_level(logging.DEBUG):
caplog.clear()
await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
for record in caplog.records:
assert (
SECRET_TOKEN not in record.getMessage()
), f"scenario={name}: token leaked into log message args"
assert sentry_texts, "expected at least one Sentry capture (401 scenario)"
assert all(SECRET_TOKEN not in text for text in sentry_texts)
# ── proxy not found ──────────────────────────────────────────────────────────
async def test_unknown_proxy_id_returns_neutral_not_found(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed())
db = FakeSession(None)
result = await proxy_rotation.rotate_proxy(db, 999) # type: ignore[arg-type]
assert result.ok is False
assert result.reason is not None

View file

@ -0,0 +1,200 @@
"""Помощники для тестов, зависящих от того, В КАКОМ РЕЕСТРЕ живут люди.
Эпик «единый вход»: `settings.identity_store` переключает код «Меры» между
`tradein_users`/`tradein_sessions` (БД tradein ДЕФОЛТ, сегодняшнее поведение
прода) и `users`/`sessions` (БД `auth`). Различаются имена таблиц И тип колонки
состояния доступа (`is_active boolean` против `access_state text`).
ЗАЧЕМ ЭТОТ МОДУЛЬ (главная ловушка этих тестов). Интеграционные тесты
`test_auth_api.py` / `test_team_api.py` используют fake-DB, который диспатчит по
ТЕКСТУ SQL. Если ветка такого fake'а сравнивает с литералом «tradein_users», то
при `identity_store="auth"` она просто перестаёт матчиться fake вернёт пустой
результат вместо строки, а тест останется ЗЕЛЁНЫМ на сломанном коде. Поэтому:
* имена для матчинга берутся из `identity_schema()` (`sql_names()` ниже)
ровно оттуда же, откуда их берёт продакшн-код;
* непонятый SQL в fake'ах ОБЯЗАН падать `AssertionError`, а не возвращать
пустоту (см. `raise AssertionError(f"unhandled fake SQL ...")` в обоих
файлах) это то, что превращает «ветка отвалилась» в красный тест.
`column_value()` намеренно ЛИТЕРАЛЬНАЯ таблица «состояние значение
колонки», а НЕ вызов `identity_store.access_state_param()`. Fake обязан хранить
то, что реально лежало бы в Postgres; если бы он звал ту же production-функцию,
что и проверяемый код, её инверсия (`active` `disabled`) прошла бы round-trip
через fake незамеченной, и тест бы не покраснел.
"""
from __future__ import annotations
import re
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from dataclasses import dataclass
from typing import Any
import pytest
from app.core import auth_db, config
from app.services import identity_store
from app.services.identity_store import AccessState, identity_schema
# Оба допустимых значения `IDENTITY_STORE` (Literal в pydantic-настройках).
# "tradein" ПЕРВЫЙ — это дефолт и путь прода; при чтении вывода pytest'а первый
# параметр всегда «как сейчас», второй — «после переезда».
IDENTITY_MODES = ("tradein", "auth")
# Состояние доступа → значение, которое реально лежит в колонке реестра.
# Литералы, независимые от production-кода (см. модульный docstring).
# `trial_expired` в булевой схеме ОТСУТСТВУЕТ: состояния «пробный период истёк»
# там не существовало, выразить его нечем — тесты про него имеют смысл только в
# режиме `auth`, поэтому здесь явная ошибка вместо тихого приведения к False.
_COLUMN_VALUE: dict[tuple[str, AccessState], bool | str] = {
("tradein", AccessState.ACTIVE): True,
("tradein", AccessState.DISABLED): False,
("auth", AccessState.ACTIVE): "active",
("auth", AccessState.TRIAL_EXPIRED): "trial_expired",
("auth", AccessState.DISABLED): "disabled",
}
def column_value(state: AccessState) -> bool | str:
"""Значение состояния *state* в колонке реестра для ТЕКУЩЕГО режима."""
store = config.settings.identity_store
try:
return _COLUMN_VALUE[(store, state)]
except KeyError:
raise AssertionError(
f"состояние {state.value!r} не существует в схеме {store!r}"
f"такой тест имеет смысл только при identity_store='auth'"
) from None
@dataclass(frozen=True, slots=True)
class SqlNames:
"""Имена, по которым fake-DB узнаёт запрос в ТЕКУЩЕМ режиме."""
users: str
sessions: str
access_state_column: str
access_state_sql_type: str
def sql_names() -> SqlNames:
"""Имена таблиц/колонки из `identity_schema()` — источник тот же, что у кода."""
schema = identity_schema()
return SqlNames(
users=schema.users_table,
sessions=schema.sessions_table,
access_state_column=schema.access_state_column,
access_state_sql_type=schema.access_state_sql_type,
)
def assert_reads_access_state(sql: str, names: SqlNames) -> None:
"""Запрос, читающий состояние доступа, ОБЯЗАН брать колонку ТЕКУЩЕГО режима.
Ставится в те ветки fake-DB, которые отдают строку человека. Без неё fake
остаётся ЗЕЛЁНЫМ на захардкоженном `is_active AS access_state`: строку он
собирает из `_Store`, где ключ УЖЕ называется `access_state`, и про имя
колонки в SELECT'е ничего не знает — то есть запрос, невозможный на реальном
Postgres (`column "is_active" does not exist` в БД `auth`), проехал бы молча.
Измерено мутацией: захардкодить колонку в `team._employee_columns` без
этой проверки все 48 тестов «Команды» остаются зелёными; с ней ветка
перестаёт матчиться, SQL доезжает до `raise AssertionError` в конце
`execute` и тесты краснеют.
Алиас проверяется отдельно от имени колонки: без `AS access_state`
вызывающий код читал бы то `is_active`, то `access_state`, то есть завёл бы
второе представление состояния ровно то, чего эпик не допускает.
"""
expected = f"{names.access_state_column} AS access_state"
if expected not in sql:
raise AssertionError(
f"запрос к реестру не читает колонку состояния текущего режима "
f"({expected!r}): {sql!r}"
)
def assert_insert_writes_access_state(sql: str, names: SqlNames) -> None:
"""INSERT в реестр обязан перечислять колонку состояния ТЕКУЩЕГО режима.
Проверяется именно СПИСОК КОЛОНОК, а не наличие подстроки: bind-параметр
называется `:access_state` в обоих режимах, поэтому `... , :access_state)`
в VALUES матчился бы всегда и `INSERT INTO users (..., is_active)`
(невозможный в БД `auth`) проехал бы молча. Измерено мутацией.
"""
match = re.search(rf"INSERT INTO\s+{re.escape(names.users)}\s*\(([^)]*)\)", sql)
if match is None:
raise AssertionError(f"не разобрал список колонок INSERT'а в реестр: {sql!r}")
columns = {c.strip() for c in match.group(1).split(",")}
if names.access_state_column not in columns:
raise AssertionError(
f"INSERT в реестр не пишет колонку состояния текущего режима "
f"({names.access_state_column!r}); в списке: {sorted(columns)}"
)
def assert_update_writes_access_state(sql: str, names: SqlNames) -> None:
"""UPDATE реестра обязан присваивать колонку состояния ТЕКУЩЕГО режима — и
кастовать параметр в ЕЁ тип.
CAST здесь несущий: параметр может быть NULL («поле не пришло в PATCH»
`COALESCE(CAST(:x AS T), col)`), и без явного типа Postgres тип NULL-параметра
не выведет. Захардкоженный `boolean` в текстовой схеме ошибка уровня БД,
которую fake иначе не увидел бы.
"""
assignment = f"{names.access_state_column} = COALESCE("
if assignment not in sql:
raise AssertionError(
f"UPDATE реестра не присваивает колонку состояния текущего режима "
f"({assignment!r}): {sql!r}"
)
cast = f"CAST(:access_state AS {names.access_state_sql_type})"
if cast not in sql:
raise AssertionError(
f"UPDATE реестра кастует состояние не в тип текущей схемы ({cast!r}): {sql!r}"
)
def use_identity_mode(monkeypatch: pytest.MonkeyPatch, mode: str) -> str:
"""Переключает реестр на *mode* на время теста.
`reset_auth_db()` на случай, если предыдущий тест успел построить engine
БД `auth`: закешированный engine пережил бы monkeypatch настроек (он живёт в
module-global, а не в `settings`) и утёк бы сюда.
"""
auth_db.reset_auth_db()
monkeypatch.setattr(config.settings, "identity_store", mode)
return mode
def patch_identity_sessions(monkeypatch: pytest.MonkeyPatch, make_db: Callable[[], Any]) -> None:
"""Подменяет ОБА источника сессии реестра так, чтобы работал РЕАЛЬНЫЙ
`identity_store.identity_session()` / `get_identity_db()`, а не их копия
в тесте.
Точки подмены выбраны настолько «низко», насколько возможно:
* `identity_store.SessionLocal` то, что открывает `identity_session()`
в режиме "tradein" (импортирован по имени, поэтому патчим в
`identity_store`, а не в `app.core.db`);
* `auth_db.auth_session` то, что открывают `identity_session()` и
`get_identity_db()` в режиме "auth" (`identity_store` держит ссылку на
МОДУЛЬ `auth_db`, поэтому подмена атрибута модуля видна ему сразу).
Благодаря этому ветвление по режиму остаётся на production-коде: тест не
повторяет его у себя, и регрессия в `get_identity_db` (например, если он
перестанет отдавать в режиме "tradein" тот же объект `Session`, что и
`get_db`) не сможет спрятаться за тестовым дублёром.
*make_db* вызывается БЕЗ аргументов и обязан отдавать новый fake-Session,
поддерживающий `with ... as db` (как настоящая `Session`).
"""
@contextmanager
def _fake_auth_session() -> Iterator[Any]:
with make_db() as db:
yield db
monkeypatch.setattr(identity_store, "SessionLocal", make_db)
monkeypatch.setattr(auth_db, "auth_session", _fake_auth_session)

View file

@ -273,6 +273,92 @@ def test_ekb_address_still_matched_with_real_gate() -> None:
mock_geo.assert_called_once_with(db, "проспект ленина", "1")
# ── городской гейт по колонке listings.city (#2594, миграция 196, шаг 2/3) ──────
def test_city_column_non_ekb_skips_before_text_gate_and_match() -> None:
"""listings.city='Нижний Тагил', но address НЕ называет город в тексте
("ул. Победы, 30" bare form). Текстовый гейт (_names_non_ekb_city) пропустил
бы этот адрес дальше (город нигде явно не назван в тексте), но колонка city
надёжный сигнал из контекста развёртки скрапера должна перехватить его
раньше матча против EKB-only ekb_geoportal_buildings, иначе адрес получил бы
ложные екатеринбургские координаты (issue #2594)."""
rows = [{"id": 200, "address": "ул. Победы, 30", "city": "Нижний Тагил"}]
db = _make_db([rows, []])
# Sanity: текстовый гейт САМ ПО СЕБЕ не поймал бы этот bare-адрес.
assert _names_non_ekb_city("ул. Победы, 30") is False
with (
patch(
"app.tasks.backfill_listings_coords_geoportal._geoportal_house_match",
return_value=_HIT, # ложный ЕКБ-матч, если бы гейт по колонке не сработал
) as mock_geo,
patch(
"app.tasks.backfill_listings_coords_geoportal._parse_street_house",
return_value=("победы", "30"),
) as mock_parse,
):
res = backfill_coords_from_geoportal(db, batch_size=500)
assert res.candidates == 1
assert res.skipped_non_ekb == 1
assert res.matched == 0
assert res.updated == 0
# Ни парсер, ни geoportal-матчер не должны были вызываться — гейт по колонке
# стоит раньше текстового гейта и раньше парсинга/матча.
mock_parse.assert_not_called()
mock_geo.assert_not_called()
update_calls = [c for c in db.execute.call_args_list if "UPDATE" in str(c.args[0])]
assert len(update_calls) == 0
def test_city_column_ekb_still_matched_not_a_regression() -> None:
"""listings.city='Екатеринбург' — гейт по колонке пропускает дальше, как раньше."""
rows = [{"id": 201, "address": "ул. Победы, 30", "city": "Екатеринбург"}]
db = _make_db([rows, []])
with (
patch(
"app.tasks.backfill_listings_coords_geoportal._geoportal_house_match",
return_value=_HIT,
) as mock_geo,
patch(
"app.tasks.backfill_listings_coords_geoportal._parse_street_house",
return_value=("победы", "30"),
),
):
res = backfill_coords_from_geoportal(db, batch_size=500)
assert res.candidates == 1
assert res.skipped_non_ekb == 0
assert res.matched == 1
assert res.updated == 1
mock_geo.assert_called_once_with(db, "победы", "30")
def test_city_column_null_falls_back_to_text_gate() -> None:
"""listings.city IS NULL (записан до миграции 196) — гейт по колонке молчит,
решение остаётся за текстовым гейтом _names_non_ekb_city (не деградация #2583)."""
rows = [{"id": 202, "address": "г. Нижний Тагил, проспект Ленина, 1", "city": None}]
db = _make_db([rows, []])
with (
patch(
"app.tasks.backfill_listings_coords_geoportal._geoportal_house_match",
return_value=_HIT,
) as mock_geo,
patch("app.tasks.backfill_listings_coords_geoportal._parse_street_house") as mock_parse,
):
res = backfill_coords_from_geoportal(db, batch_size=500)
assert res.candidates == 1
assert res.skipped_non_ekb == 1 # словил текстовый гейт (город назван в тексте)
assert res.matched == 0
mock_parse.assert_not_called()
mock_geo.assert_not_called()
# ── idempotency ───────────────────────────────────────────────────────────────

View file

@ -342,6 +342,31 @@ async def test_geocode_missing_recent_tried_at_excluded_via_where() -> None:
assert "7 days" in sql_text
@pytest.mark.asyncio
async def test_geocode_missing_select_filters_is_active() -> None:
"""SELECT содержит `AND is_active` (#2604 п.1).
На проде очередь без этого фильтра была на 98.5% забита is_active=false
объявлениями чужих регионов (Новосибирск/Казань/Челябинск/) без улицы и дома;
`ORDER BY listings_count DESC` ставил самый мусорный адрес («Новосибирская
обл.,Новосибирск», 214 listings) В НАЧАЛО очереди весь Nominatim-бюджет
(1 req/sec) съедался мусором, до реальных активных адресов дело не доходило
(8 ночных прогонов подряд: saved=0). Falsification-проба: на коде ДО фикса
`"AND is_active" in sql_text` ложно, тест падает; после фикса проходит.
"""
db = MagicMock()
select_result = MagicMock()
select_result.mappings.return_value.all.return_value = []
db.execute.return_value = select_result
with patch("app.tasks.geocode_missing.geocode", new_callable=AsyncMock):
await geocode_missing_listings(db, batch_size=10)
first_call = db.execute.call_args_list[0]
sql_text = str(first_call[0][0])
assert "AND is_active" in sql_text
@pytest.mark.asyncio
async def test_run_geocode_missing_listings_terminates_on_drained() -> None:
"""run_geocode_missing_listings завершается когда addresses_total == 0 (ничего pending)."""
@ -409,6 +434,242 @@ async def test_run_geocode_missing_listings_mark_failed_on_exception() -> None:
mock_runs.mark_done.assert_not_called()
# ── (address, city) pair grouping / city_hint (#2594 шаг 2/3) ────────────────
@pytest.mark.asyncio
async def test_geocode_missing_same_address_different_city_independent_calls_and_updates() -> None:
"""Ключевой сценарий #2594: два ряда с ОДИНАКОВЫМ текстом address, но РАЗНЫМИ
city каждый должен получить свой geocode-вызов (city_hint) и свой UPDATE, не
затрагивающий другую пару. Раньше группировка была только по address: SELECT
группировал по тексту, а UPDATE бил по WHERE address = :addr без city второй
UPDATE (для Нижнего Тагила) перезаписал бы координаты, уже проставленные первым
(для Екатеринбурга), и наоборот.
"""
rows = [
{"address": "ул. Победы, 30", "city": "Екатеринбург", "listings_count": 1},
{"address": "ул. Победы, 30", "city": "Нижний Тагил", "listings_count": 1},
]
ekb_geo = GeocodeResult(
lat=56.838,
lon=60.605,
full_address="Екатеринбург, ул. Победы, 30",
provider="nominatim", # type: ignore[arg-type]
confidence="exact",
)
tagil_geo = GeocodeResult(
lat=57.910,
lon=59.985,
full_address="Нижний Тагил, ул. Победы, 30",
provider="nominatim", # type: ignore[arg-type]
confidence="exact",
)
db = MagicMock()
select_result = MagicMock()
select_result.mappings.return_value.all.return_value = rows
update_ekb = MagicMock()
update_ekb.rowcount = 1
update_tagil = MagicMock()
update_tagil.rowcount = 1
db.execute.side_effect = [select_result, update_ekb, update_tagil]
with patch(
"app.tasks.geocode_missing.geocode",
new_callable=AsyncMock,
side_effect=[ekb_geo, tagil_geo],
) as mock_geo:
result = await geocode_missing_listings(db, batch_size=200)
# 2 отдельных geocode-вызова — по одному на пару (address, city), не 1 на address.
assert mock_geo.call_count == 2
ekb_call = mock_geo.call_args_list[0]
tagil_call = mock_geo.call_args_list[1]
assert ekb_call.args[0] == "ул. Победы, 30"
assert ekb_call.kwargs["city_hint"] == "Екатеринбург"
assert tagil_call.args[0] == "ул. Победы, 30"
assert tagil_call.kwargs["city_hint"] == "Нижний Тагил"
# 2 отдельных UPDATE, каждый со своим city в WHERE — не задевает другую пару.
update_calls = db.execute.call_args_list[1:]
assert len(update_calls) == 2
expected = [("Екатеринбург", 56.838), ("Нижний Тагил", 57.910)]
for call, (expected_city, expected_lat) in zip(update_calls, expected, strict=True):
sql = str(call.args[0])
params = call.args[1]
assert "IS NOT DISTINCT FROM" in sql
assert params["addr"] == "ул. Победы, 30"
assert params["city"] == expected_city
assert params["lat"] == pytest.approx(expected_lat)
assert result.addresses_processed == 2
assert result.addresses_geocoded == 2
assert result.listings_updated == 2
@pytest.mark.asyncio
async def test_geocode_missing_null_city_group_uses_is_not_distinct_from() -> None:
"""city IS NULL — своя группа. city_hint=None передаётся геокодеру, UPDATE
использует IS NOT DISTINCT FROM (обычный `=` никогда не совпал бы с NULL
группа NULL-city вообще не обновилась бы обычным equality-сравнением)."""
rows = [{"address": "ул. Дружинина, 33", "city": None, "listings_count": 2}]
db = MagicMock()
select_result = MagicMock()
select_result.mappings.return_value.all.return_value = rows
update_result = MagicMock()
update_result.rowcount = 2
db.execute.side_effect = [select_result, update_result]
with patch(
"app.tasks.geocode_missing.geocode",
new_callable=AsyncMock,
return_value=_make_geocode_result("nominatim"),
) as mock_geo:
result = await geocode_missing_listings(db, batch_size=200)
mock_geo.assert_called_once_with("ул. Дружинина, 33", db, city_hint=None)
update_call = db.execute.call_args_list[1]
sql = str(update_call.args[0])
params = update_call.args[1]
assert "IS NOT DISTINCT FROM" in sql
assert params["city"] is None
assert result.listings_updated == 2
@pytest.mark.asyncio
async def test_geocode_missing_select_groups_by_address_and_city() -> None:
"""SELECT содержит GROUP BY address, city — НЕ только по address (#2594)."""
db = MagicMock()
select_result = MagicMock()
select_result.mappings.return_value.all.return_value = []
db.execute.return_value = select_result
with patch("app.tasks.geocode_missing.geocode", new_callable=AsyncMock):
await geocode_missing_listings(db, batch_size=10)
first_call = db.execute.call_args_list[0]
sql_text = str(first_call[0][0])
assert "GROUP BY address, city" in sql_text
assert "SELECT address, city, COUNT(*)" in sql_text
@pytest.mark.asyncio
async def test_geocode_missing_failed_pair_tried_at_update_scoped_to_city() -> None:
"""Failed geocode (geo=None) для (address, city) → UPDATE tried_at ограничен
ЭТОЙ парой (IS NOT DISTINCT FROM city), не всеми строками с тем же address."""
rows = [{"address": "несуществующий адрес", "city": "Нижний Тагил", "listings_count": 1}]
db = MagicMock()
select_result = MagicMock()
select_result.mappings.return_value.all.return_value = rows
tried_at_result = MagicMock()
db.execute.side_effect = [select_result, tried_at_result]
with patch(
"app.tasks.geocode_missing.geocode",
new_callable=AsyncMock,
return_value=None,
) as mock_geo:
result = await geocode_missing_listings(db, batch_size=200)
mock_geo.assert_called_once_with("несуществующий адрес", db, city_hint="Нижний Тагил")
assert result.addresses_failed == 1
update_call = db.execute.call_args_list[1]
sql = str(update_call.args[0])
params = update_call.args[1]
assert "IS NOT DISTINCT FROM" in sql
assert params["city"] == "Нижний Тагил"
# ── #2604 п.1/п.2: UPDATE decisions — locked in by test, not just comment ────
@pytest.mark.asyncio
async def test_geocode_missing_success_update_not_filtered_by_is_active() -> None:
"""Decision #2604 п.1 (UPDATE lat/lon): намеренно БЕЗ `is_active` в WHERE.
Координаты свойство физического адреса (address, city), не свойство
конкретного listing. is_active=false дубликат ЭТОЙ ЖЕ пары никогда не будет
независимо отобран SELECT'ом (он навсегда исключён оттуда) — без unfiltered
UPDATE такой дубликат остался бы с NULL lat/lon навсегда, хотя ответ уже
получен и оплачен Nominatim-вызовом активного листинга.
"""
rows = [{"address": "ул. Тестовая, 1", "city": "Екатеринбург", "listings_count": 2}]
db = MagicMock()
select_result = MagicMock()
select_result.mappings.return_value.all.return_value = rows
update_result = MagicMock()
update_result.rowcount = 2
db.execute.side_effect = [select_result, update_result]
with patch(
"app.tasks.geocode_missing.geocode",
new_callable=AsyncMock,
return_value=_make_geocode_result("nominatim"),
):
await geocode_missing_listings(db, batch_size=200)
update_call = db.execute.call_args_list[1]
sql = str(update_call.args[0])
assert "is_active" not in sql
@pytest.mark.asyncio
async def test_geocode_missing_notfound_tried_at_update_not_filtered_by_is_active() -> None:
"""Decision #2604 п.2 (geo is None → tried_at UPDATE): намеренно БЕЗ `is_active`.
tried_at backoff-метка для (address, city) КАК ТЕКСТА, не для конкретного
listing; is_active=false дубликат и так никогда не переотбирается SELECT'ом.
Единственный сценарий где это важно реактивация (is_active true) той же
строки: backoff уже стоит и корректно защищает от немедленного повтора
заведомо неудачного адреса.
"""
rows = [{"address": "несуществующий адрес", "city": None, "listings_count": 1}]
db = MagicMock()
select_result = MagicMock()
select_result.mappings.return_value.all.return_value = rows
tried_at_result = MagicMock()
db.execute.side_effect = [select_result, tried_at_result]
with patch(
"app.tasks.geocode_missing.geocode",
new_callable=AsyncMock,
return_value=None,
):
await geocode_missing_listings(db, batch_size=200)
update_call = db.execute.call_args_list[1]
sql = str(update_call.args[0])
assert "is_active" not in sql
@pytest.mark.asyncio
async def test_geocode_missing_exception_tried_at_update_not_filtered_by_is_active() -> None:
"""Decision #2604 п.2 (geocode() raises → tried_at UPDATE): та же логика, что и
в NOT-FOUND ветке выше намеренно БЕЗ `is_active`, зафиксировано тестом."""
rows = [{"address": "ул. Битая, 99", "city": None, "listings_count": 1}]
db = MagicMock()
select_result = MagicMock()
select_result.mappings.return_value.all.return_value = rows
tried_at_result = MagicMock()
db.execute.side_effect = [select_result, tried_at_result]
with patch(
"app.tasks.geocode_missing.geocode",
new_callable=AsyncMock,
side_effect=RuntimeError("timeout"),
):
await geocode_missing_listings(db, batch_size=200)
update_call = db.execute.call_args_list[1]
sql = str(update_call.args[0])
assert "is_active" not in sql
# ── Integration-style: estimator Avito exclusion removed ─────────────────────
@ -494,3 +755,65 @@ def test_admin_geocode_missing_post_dry_run_endpoint_exists() -> None:
data = resp.json()
assert "status" in data
assert "addresses_total" in data
# ── admin.geocode_missing (per-ID endpoint) city_hint (#2594 шаг 2/3) ────────
@pytest.mark.asyncio
async def test_admin_geocode_missing_passes_city_hint() -> None:
"""POST /admin/geocode-missing читает city из SELECT и передаёт как city_hint.
Раньше endpoint читал только row["address"] и звал geocode(clean, db) без
города голый тагильский адрес без города в тексте уходил в Екатеринбург.
"""
from app.api.v1 import admin as admin_module
rows = [{"id": 55, "address": "ул. Победы, 30", "city": "Нижний Тагил"}]
db = MagicMock()
select_result = MagicMock()
select_result.mappings.return_value.all.return_value = rows
update_result = MagicMock()
remaining_result = MagicMock()
remaining_result.scalar.return_value = 0
db.execute.side_effect = [select_result, update_result, remaining_result]
geo = GeocodeResult(
lat=57.910,
lon=59.985,
full_address="Нижний Тагил, ул. Победы, 30",
provider="nominatim", # type: ignore[arg-type]
confidence="exact",
)
with patch(
"app.api.v1.admin.geocode",
new_callable=AsyncMock,
return_value=geo,
) as mock_geo:
result = await admin_module.geocode_missing(db, limit=100, target="listings")
mock_geo.assert_called_once_with("ул. Победы, 30", db, city_hint="Нижний Тагил")
assert result["geocoded"] == 1
assert result["skipped"] == 0
@pytest.mark.asyncio
async def test_admin_geocode_missing_select_includes_city_column() -> None:
"""SELECT в admin.geocode_missing содержит колонку city (#2594)."""
from app.api.v1 import admin as admin_module
db = MagicMock()
select_result = MagicMock()
select_result.mappings.return_value.all.return_value = []
remaining_result = MagicMock()
remaining_result.scalar.return_value = 0
db.execute.side_effect = [select_result, remaining_result]
with patch("app.api.v1.admin.geocode", new_callable=AsyncMock):
await admin_module.geocode_missing(db, limit=100, target="listings")
first_call = db.execute.call_args_list[0]
sql_text = str(first_call[0][0])
assert "SELECT id, address, city" in sql_text

View file

@ -116,17 +116,59 @@ def test_rederivation_cte_blocks_match_080() -> None:
def test_rederivation_scopes_sold_side_to_asking_city() -> None:
"""#C2: SOLD-сторона (deal_side + deal_global) скоупится на город asking-стороны (ЕКБ).
Миграция 177 залила ДКП по всей обл.66, а asking (listings) только ЕКБ. Без скоупа
sold-медиана смешивала дешёвую область ratio 0.8770.62, «выкупная» 29%. Оба
deal-CTE (per-rooms + global) должны нести предикат; ask-стороны НЕ трогаем.
Миграция 177 залила ДКП по всей обл.66, а asking (listings) исторически только ЕКБ.
Без скоупа sold-медиана смешивала дешёвую область ratio 0.8770.62, «выкупная» 29%.
Оба deal-CTE (per-rooms + global) несут предикат unconditionally (deals.city не имеет
массовых NULL как listings.city #2598 их не касается).
"""
assert ratio_mod._ASKING_CITY_PATTERN == "%Екатеринбург%"
# Оба deal-CTE (deal_side + deal_global) скоупятся — ровно 2 вхождения.
# Оба deal-CTE (deal_side + deal_global) скоупятся unconditional-предикатом —
# ровно 2 вхождения формы БЕЗ city IS NULL (ask-сторона использует другую форму,
# см. test_ask_side_and_ask_global_scoped_to_asking_city).
assert _REDERIVE_SQL.count("AND city ILIKE :asking_city") == 2
# ask-стороны (listings) НЕ фильтруются по городу (в listings нет колонки city).
def test_ask_side_and_ask_global_scoped_to_asking_city() -> None:
"""#2583 H2: ask-сторона (ask_side + ask_global) ТЕПЕРЬ ТОЖЕ скоупится на asking_city.
Oblast-развёртки заработали 12 июля областные объявления (дешевле ЕКБ) попали в
знаменатель ask_median БЕЗ городского скоупа, а sold-сторона осталась скоуплена на
ЕКБ (см. предыдущий тест) асимметрия занижала ask_median и завышала ratio на
2.5-5.3% по бакетам комнат 1-4 (замер на проде, аудит #2583 H2). Falsifiable: этот
assert FALSE на непропатченном коде (ask_side/ask_global без city-предиката вообще)
и TRUE после того как предикат `(city IS NULL OR city ILIKE :asking_city)` добавлен
проверено `git stash` на строках реализации.
"""
_a = _REDERIVE_SQL.index("ask_side AS")
_b = _REDERIVE_SQL.index("per_bucket AS")
assert "asking_city" not in _REDERIVE_SQL[_a:_b]
ask_side_block = _REDERIVE_SQL[_a:_b]
assert "AND (city IS NULL OR city ILIKE :asking_city)" in ask_side_block
_c = _REDERIVE_SQL.index("ask_global AS")
_d = _REDERIVE_SQL.index("global_row AS")
ask_global_block = _REDERIVE_SQL[_c:_d]
assert "AND (city IS NULL OR city ILIKE :asking_city)" in ask_global_block
def test_ask_side_keeps_city_is_null_rows_not_naive_filter() -> None:
"""Guard against the naive (wrong) fix — a plain symmetric `city ILIKE :asking_city`.
listings.city заполнена пока только у Авито (#2598/#2606) — Циан/Домклик/Яндекс
строки несут city IS NULL. На проде (2026-08, аудит #2583 H2) это ~8200 из ~11500
строк, проходящих остальные WHERE-предикаты (~71%). Наивный симметричный
`city ILIKE :asking_city` (как у deal_side) молча выбросил бы все city IS NULL
строки, схлопнув ask_median c ~11500 до ~2100 ЕКБ-only объявлений именно та
over-correction, от которой предостерегает #2583 H2.
"""
cte_pairs = (("ask_side AS", "per_bucket AS"), ("ask_global AS", "global_row AS"))
for cte_name, next_cte in cte_pairs:
start = _REDERIVE_SQL.index(cte_name)
end = _REDERIVE_SQL.index(next_cte)
block = _REDERIVE_SQL[start:end]
assert "city IS NULL" in block, f"{cte_name}: missing IS NULL tolerance"
# The naive fix (deal_side-style, no NULL tolerance) must NOT appear standalone.
naive = re.search(r"AND\s+city\s+ILIKE\s+:asking_city(?!\))", block)
assert naive is None, f"{cte_name}: found naive filter without IS NULL tolerance"
def _strip_sql(s: str) -> str:
@ -152,6 +194,9 @@ def test_migration_080_derivation_is_subset_of_refresh_sql() -> None:
#1186: the refresh now adds the novostroyki guard predicate to each ask_* CTE;
it is normalised away here so the 080 seed (no guard) still matches.
#2583 H2: the refresh now also adds the NULL-tolerant city-scope predicate to each
ask_* CTE (symmetric to the #C2 SOLD-side guard) — normalised away the same way.
"""
seed_sql = _MIGRATION_080.read_text("utf-8")
# Extract the WITH … (up to the ON CONFLICT) from the seed.
@ -176,8 +221,15 @@ def test_migration_080_derivation_is_subset_of_refresh_sql() -> None:
)
def _drop_city_guard(s: str) -> str:
"""Remove the #C2 EKB city-scope predicate on the SOLD side (absent in the 080 seed)."""
return re.sub(r"AND\s+city\s+ILIKE\s+:asking_city", "", s)
"""Remove the #C2 SOLD-side + #2583 H2 ASK-side city-scope predicates.
Both are absent in the 080 seed: #C2 added the unconditional SOLD-side guard
(deal_side/deal_global), #2583 H2 later added the NULL-tolerant ASK-side guard
(ask_side/ask_global).
"""
s = re.sub(r"AND\s+city\s+ILIKE\s+:asking_city", "", s)
s = re.sub(r"AND\s*\(\s*city\s+IS\s+NULL\s+OR\s+city\s+ILIKE\s+:asking_city\s*\)", "", s)
return s
def _norm(s: str) -> str:
return _strip_sql(_normalise_ppm2(_drop_city_guard(_drop_segment_guard(s))))

View file

@ -1,373 +0,0 @@
"""Unit tests for the Phase-1 address-mismatch audit (issue #582).
Coverage:
- `_first_street_token` / `_street_differs` normalization-driven diff.
- `_distance_meters` verified against a MagicMock'd DB that returns a
canned distance, plus a Haversine cross-check on the bind values to
catch lat/lon swaps.
- `reverse_via_api` via httpx MockTransport with the fixture file.
- `main()` resumability call twice with the same batch, second call
inserts 0 (uses MagicMock DB session).
Why no real Postgres in unit tests:
The repo doesn't bundle pytest-postgresql / testcontainers and the existing
tests all use `MagicMock` for the DB. We follow that convention here. The
distance and SQL-level resumability are validated by:
- The Haversine cross-check (pure-Python expected PostGIS result for
same coords, see `test_distance_calc_matches_haversine`).
- Calling `main()` twice in `test_audit_script_resumable` first run
inserts N rows, second run sees the same set of house_ids in the
"already processed" query and processes 0.
"""
from __future__ import annotations
import json
import math
import os
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
# Settings requires DATABASE_URL at init time — set dummy DSN before any
# `app.*` import (same pattern as test_cian_valuation.py).
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
import httpx
import pytest
from scripts._yandex_reverse import (
YandexBlockedError,
YandexReverseResult,
_parse_api_payload,
reverse_via_api,
)
from scripts.audit_address_mismatch import (
SampleRow,
_distance_meters,
_first_street_token,
_resolve_mode,
_run_api_mode,
_street_differs,
main,
)
_FIXTURES = Path(__file__).parent / "fixtures"
# ---------------------------------------------------------------------------
# _first_street_token / _street_differs
# ---------------------------------------------------------------------------
def test_normalize_address_street_token_basic():
"""First identifying token of a normalized address — skips street type."""
assert _first_street_token("ул Малышева 51") == "малышева"
def test_normalize_address_street_token_skips_leading_numbers():
"""Numeric tokens are skipped — the street name carries identity."""
# No type prefix → first non-numeric token is the street name itself.
assert _first_street_token("123 Постовского") == "постовского"
def test_normalize_address_street_token_handles_none():
assert _first_street_token(None) is None
assert _first_street_token("") is None
def test_street_differs_true_when_streets_differ():
assert _street_differs("ул Малышева 51", "ул Ленина 51") is True
def test_street_differs_false_when_same_after_normalization():
# 'ул' expands to 'улица' on both sides → same first token.
assert _street_differs("ул Малышева 51", "улица Малышева, 51") is False
def test_street_differs_none_on_empty_side():
assert _street_differs(None, "ул Малышева 51") is None
assert _street_differs("ул Малышева 51", "") is None
# ---------------------------------------------------------------------------
# _distance_meters — MagicMock DB + Haversine cross-check
# ---------------------------------------------------------------------------
def _haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Reference implementation for sanity-checking the PostGIS call."""
r = 6_371_000.0
p1 = math.radians(lat1)
p2 = math.radians(lat2)
dp = math.radians(lat2 - lat1)
dl = math.radians(lon2 - lon1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * r * math.asin(math.sqrt(a))
def test_distance_calc_passes_correct_bindings():
"""Test the helper passes lat/lon in correct order to the SQL bind names."""
db = MagicMock()
# PostGIS would return one row, single column (distance in meters).
db.execute.return_value.first.return_value = (123.45,)
out = _distance_meters(db, 56.838, 60.586, 56.840, 60.590)
assert out == 123.45
# Verify the bind dict — guard against lat/lon swap regressions.
args, _kwargs = db.execute.call_args
bound = args[1]
assert bound == {
"olat": 56.838,
"olon": 60.586,
"slat": 56.840,
"slon": 60.590,
}
def test_distance_calc_returns_none_when_postgis_null():
"""ST_Distance can return NULL — caller must propagate None, not 0."""
db = MagicMock()
db.execute.return_value.first.return_value = (None,)
assert _distance_meters(db, 56.0, 60.0, 56.0, 60.0) is None
def test_distance_calc_matches_haversine_within_tolerance():
"""Sanity check: if PostGIS returned 555.7m for a known pair, that's
within ~1% of the Haversine reference (PostGIS uses Vincenty on
geography which is slightly more accurate)."""
expected = _haversine_m(56.838, 60.586, 56.843, 60.591)
# Just assert reference is in a sensible range — proves the test helper
# works; the actual call is mocked.
assert 500 < expected < 700
# ---------------------------------------------------------------------------
# Yandex API: payload parsing + reverse_via_api with MockTransport
# ---------------------------------------------------------------------------
def test_yandex_parse_api_fixture():
"""Sanity check: parse the bundled fixture into a YandexReverseResult."""
data = json.loads((_FIXTURES / "yandex_geocode_sample.json").read_text("utf-8"))
res = _parse_api_payload(data)
assert res.address is not None
assert "Малышева" in res.address
# Fixture Point.pos = "60.586155 56.838004" → lon then lat.
assert res.snapped_lon == pytest.approx(60.586155, abs=1e-6)
assert res.snapped_lat == pytest.approx(56.838004, abs=1e-6)
assert res.raw == data
def test_yandex_parse_api_no_match():
"""Empty featureMember → all-None result, raw still preserved."""
data = {"response": {"GeoObjectCollection": {"featureMember": []}}}
res = _parse_api_payload(data)
assert res.address is None
assert res.snapped_lat is None
assert res.snapped_lon is None
assert res.raw == data
async def test_yandex_reverse_api_mock():
"""End-to-end: reverse_via_api hits a MockTransport, returns parsed result."""
fixture = json.loads((_FIXTURES / "yandex_geocode_sample.json").read_text("utf-8"))
captured: dict[str, httpx.Request] = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["req"] = request
return httpx.Response(200, json=fixture)
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport) as client:
res = await reverse_via_api(56.838004, 60.586155, "DUMMY_KEY", client=client)
assert res.address is not None and "Малышева" in res.address
# Verify the request shape — lon,lat order + apikey + kind=house.
req = captured["req"]
qs = dict(httpx.QueryParams(req.url.query))
assert qs["apikey"] == "DUMMY_KEY"
assert qs["geocode"] == "60.586155,56.838004"
assert qs["format"] == "json"
assert qs["kind"] == "house"
async def test_yandex_blocked_error_raised_on_captcha():
"""`reverse_via_playwright` must raise YandexBlockedError on captcha.
We mock the page object so we don't need an actual browser.
"""
from scripts._yandex_reverse import reverse_via_playwright
page = MagicMock()
page.goto = AsyncMock()
page.wait_for_load_state = AsyncMock()
page.query_selector = AsyncMock(
side_effect=lambda sel: MagicMock() if sel == ".CheckboxCaptcha" else None
)
page.evaluate = AsyncMock(return_value=[None, None])
with pytest.raises(YandexBlockedError):
await reverse_via_playwright(56.838, 60.586, page)
# ---------------------------------------------------------------------------
# Mode resolver
# ---------------------------------------------------------------------------
def test_resolve_mode_auto_with_key():
assert _resolve_mode("auto", "abc") == "api"
def test_resolve_mode_auto_without_key():
assert _resolve_mode("auto", None) == "playwright"
assert _resolve_mode("auto", "") == "playwright"
def test_resolve_mode_explicit_passes_through():
assert _resolve_mode("api", None) == "api"
assert _resolve_mode("playwright", "abc") == "playwright"
# ---------------------------------------------------------------------------
# Resumability — main() twice with same batch
# ---------------------------------------------------------------------------
def _make_db_mock(initial_sample: list[dict], processed_ids: set[int]):
"""Build a MagicMock SQLAlchemy session that:
- returns `initial_sample` for the sampling SQL (text() with limit_per_district)
- returns `processed_ids` for the resume SQL (text() with batch only)
- records INSERTs so the test can count them
"""
inserted: list[dict] = []
db = MagicMock()
db.begin_nested.return_value.__enter__ = lambda self: self
db.begin_nested.return_value.__exit__ = lambda self, *a: False
def execute_side_effect(sql, params=None):
sql_str = str(sql)
result = MagicMock()
if "FROM houses h" in sql_str or "houses_in_districts" in sql_str:
result.mappings.return_value.all.return_value = initial_sample
elif "FROM address_mismatch_audit" in sql_str and "house_id" in sql_str:
# Resume query — returns list of (house_id,) tuples.
result.all.return_value = [(hid,) for hid in processed_ids]
elif "INSERT INTO address_mismatch_audit" in sql_str:
inserted.append(dict(params))
# Simulate ON CONFLICT DO NOTHING — track id locally for re-run.
processed_ids.add(params["house_id"])
result = MagicMock()
elif "ST_Distance" in sql_str:
result.first.return_value = (42.0,)
else:
result = MagicMock()
return result
db.execute.side_effect = execute_side_effect
db.commit = MagicMock()
db.rollback = MagicMock()
db.close = MagicMock()
return db, inserted
async def test_audit_script_resumable(monkeypatch):
"""Run main() twice with the same batch — second pass inserts 0."""
sample = [
{
"id": 1,
"address": "ул Малышева 51",
"lat": 56.838,
"lon": 60.586,
"district": "Кировский",
},
{"id": 2, "address": "ул Ленина 5", "lat": 56.840, "lon": 60.600, "district": "Ленинский"},
]
processed_ids: set[int] = set()
db, inserted = _make_db_mock(sample, processed_ids)
# Force API mode without needing a real key.
monkeypatch.setenv("YANDEX_GEOCODER_API_KEY", "TEST_KEY")
fake_result = YandexReverseResult(
address="Россия, Екатеринбург, улица Малышева, 51",
snapped_lat=56.838004,
snapped_lon=60.586155,
raw={"ok": True},
)
with (
patch("scripts.audit_address_mismatch.SessionLocal", return_value=db),
patch(
"scripts.audit_address_mismatch.reverse_via_api",
new=AsyncMock(return_value=fake_result),
),
):
# First run — both rows processed.
n1 = await main(["--batch", "test_batch_1", "--mode", "api"])
assert n1 == 2
assert len(inserted) == 2
# Second run with same batch — nothing left to do.
inserted.clear()
n2 = await main(["--batch", "test_batch_1", "--mode", "api"])
assert n2 == 0
assert inserted == []
async def test_audit_script_api_mode_marks_error(monkeypatch):
"""When the reverse call raises, the row is still inserted with status=error."""
sample = [
{
"id": 99,
"address": "ул Малышева 51",
"lat": 56.838,
"lon": 60.586,
"district": "Кировский",
},
]
processed_ids: set[int] = set()
db, inserted = _make_db_mock(sample, processed_ids)
monkeypatch.setenv("YANDEX_GEOCODER_API_KEY", "TEST_KEY")
with (
patch("scripts.audit_address_mismatch.SessionLocal", return_value=db),
patch(
"scripts.audit_address_mismatch.reverse_via_api",
new=AsyncMock(side_effect=httpx.HTTPError("boom")),
),
):
n = await main(["--batch", "err_batch", "--mode", "api"])
assert n == 1
assert len(inserted) == 1
assert inserted[0]["audit_status"] == "error"
assert "boom" in (inserted[0]["error_message"] or "")
# ---------------------------------------------------------------------------
# Internal _run_api_mode no-match path
# ---------------------------------------------------------------------------
async def test_api_mode_no_match_path():
"""If Yandex returns address=None, row goes in with status=no_match."""
sample = [SampleRow(id=7, address="ул X 1", lat=56.0, lon=60.0, district="Кировский")]
processed_ids: set[int] = set()
db, inserted = _make_db_mock([], processed_ids)
res = YandexReverseResult(address=None, snapped_lat=None, snapped_lon=None, raw={"empty": True})
with patch(
"scripts.audit_address_mismatch.reverse_via_api",
new=AsyncMock(return_value=res),
):
n = await _run_api_mode(db, sample, "b1", "key")
assert n == 1
assert inserted[0]["audit_status"] == "no_match"
assert inserted[0]["snapped_address"] is None

View file

@ -3,19 +3,36 @@ and rbac_guard session-cookie resolution.
Uses the REAL `rbac_guard` (app.core.rbac) + REAL `auth.router` / `me.router` wired
into an isolated FastAPI test app (same pattern as tests/test_rbac.py), with an
in-memory fake DB standing in for `tradein_users`/`tradein_sessions`:
- `app.core.rbac.SessionLocal` is monkeypatched (rbac_guard opens its own session,
it's middleware — no FastAPI DI available there).
- `app.core.db.get_db` is overridden via `app.dependency_overrides` (auth.py /
me.py use `Depends(get_db)`, the idiomatic FastAPI-testable path).
in-memory fake DB standing in for the identity registry:
- сессия РЕЕСТРА подменяется на самом низком уровне `identity_store.SessionLocal`
и `auth_db.auth_session` (см. `tests.support.identity_modes.patch_identity_sessions`),
так что и `identity_session()` (rbac_guard middleware, FastAPI-DI там нет), и
`Depends(get_identity_db)` (auth.py / me.py) выполняются РЕАЛЬНЫЕ, вместе со своим
ветвлением по `settings.identity_store`;
- `app.core.db.get_db` переопределён через `app.dependency_overrides` это
продуктовая БД (в дефолтном режиме она же и реестр).
Both point at the SAME `_Store` instance per test, so a session created by POST
/login is immediately visible to rbac_guard's own DB round trip on the next request.
Все они смотрят в ОДИН `_Store` на тест, поэтому сессия, созданная POST /login,
сразу видна собственному DB-раунд-трипу rbac_guard'а на следующем запросе.
ДВА РЕЖИМА РЕЕСТРА И ЛОВУШКА FAKE-DB. `_FakeDB` диспатчит по ТЕКСТУ SQL, а
эпик «единый вход» переименовывает таблицы (`tradein_users`/`tradein_sessions`
`users`/`sessions`) и меняет тип колонки состояния доступа. Литерал
«tradein_users» в диспатчере означал бы, что при `IDENTITY_STORE=auth` ветка
молча перестаёт матчиться, fake отдаёт пустоту, а тест остаётся ЗЕЛЁНЫМ на
сломанном коде. Поэтому имена берутся из `sql_names()` (= `identity_schema()`,
тот же словарь, что у продакшн-кода), а непонятый SQL падает `AssertionError`,
а не возвращает пустой результат.
Дефолт (`identity_store="tradein"`) сегодняшний прод; тесты без фикстуры
`auth_store` идут именно в нём. Тесты про режим `auth` (в т.ч. про состояние
`trial_expired`, невыразимое булевым `is_active`) в конце файла.
"""
from __future__ import annotations
import os
import re
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from typing import Annotated, Any
@ -29,13 +46,21 @@ from fastapi.testclient import TestClient
from app.api.v1 import auth as auth_router
from app.api.v1 import me as me_router
from app.core import auth as auth_mod
from app.core import config
from app.core import auth_db, config
from app.core.db import get_db
from app.core.password import hash_password
from app.core.rbac import rbac_guard
from app.services.identity_store import AccessState
from tests.support.identity_modes import (
assert_reads_access_state,
column_value,
patch_identity_sessions,
sql_names,
use_identity_mode,
)
# ---------------------------------------------------------------------------
# Fake DB backing tradein_users / tradein_sessions
# Fake DB backing the identity registry (users/sessions таблицы текущего режима)
# ---------------------------------------------------------------------------
@ -43,6 +68,7 @@ class _Store:
def __init__(self) -> None:
self.users: dict[str, dict[str, Any]] = {}
self.sessions: dict[str, dict[str, Any]] = {}
self.sql_log: list[str] = [] # весь SQL, доехавший до «БД» — см. тесты режимов
self._next_id = 1
def add_user(
@ -51,7 +77,7 @@ class _Store:
password_hash: str | None,
*,
role: str = "employee",
is_active: bool = True,
access_state: AccessState = AccessState.ACTIVE,
display_name: str | None = "Alice A.",
org_name: str | None = "Org LLC",
email: str | None = "alice@example.com",
@ -63,13 +89,19 @@ class _Store:
"username": username,
"password_hash": password_hash,
"role": role,
"is_active": is_active,
# СЫРОЕ значение колонки текущего режима (boolean либо text) — ровно
# то, что вернул бы драйвер; в AccessState его превращает код.
"access_state": column_value(access_state),
"display_name": display_name,
"org_name": org_name,
"email": email,
}
return uid
def set_access_state(self, username: str, state: AccessState) -> None:
"""Меняет состояние доступа уже заведённого юзера (как сделал бы админ/миграция)."""
self.users[username]["access_state"] = column_value(state)
def user_by_id(self, uid: int) -> dict[str, Any] | None:
for u in self.users.values():
if u["id"] == uid:
@ -109,8 +141,12 @@ class _FakeDB:
def execute(self, stmt: object, params: dict[str, Any] | None = None) -> SimpleNamespace:
sql = str(stmt)
p = params or {}
# Имена таблиц берутся ИЗ КОДА (identity_schema), а не из литералов —
# см. «ЛОВУШКА FAKE-DB» в модульном docstring.
names = sql_names()
self.store.sql_log.append(sql)
if "INSERT INTO tradein_sessions" in sql:
if f"INSERT INTO {names.sessions}" in sql:
now = datetime.now(UTC)
self.store.sessions[p["token"]] = {
"user_id": p["user_id"],
@ -119,7 +155,7 @@ class _FakeDB:
}
return SimpleNamespace(fetchone=lambda: None)
if "UPDATE tradein_sessions" in sql and "SET last_seen_at" in sql:
if f"UPDATE {names.sessions}" in sql and "SET last_seen_at" in sql:
sess = self.store.sessions.get(p["token"])
if sess is not None:
now = datetime.now(UTC)
@ -127,23 +163,27 @@ class _FakeDB:
sess["expires_at"] = now + timedelta(hours=p["ttl_hours"])
return SimpleNamespace(fetchone=lambda: None)
if "DELETE FROM tradein_sessions WHERE token" in sql:
if f"DELETE FROM {names.sessions} WHERE token" in sql:
self.store.sessions.pop(p["token"], None)
return SimpleNamespace(fetchone=lambda: None)
if "DELETE FROM tradein_sessions WHERE user_id" in sql:
if f"DELETE FROM {names.sessions} WHERE user_id" in sql:
uid = p["user_id"]
for tok in [t for t, s in self.store.sessions.items() if s["user_id"] == uid]:
del self.store.sessions[tok]
return SimpleNamespace(fetchone=lambda: None)
if "FROM tradein_sessions s" in sql and "JOIN tradein_users u" in sql:
if f"FROM {names.sessions} s" in sql and f"JOIN {names.users} u" in sql:
assert_reads_access_state(sql, names)
sess = self.store.sessions.get(p["token"])
if sess is None:
return SimpleNamespace(fetchone=lambda: None)
user = self.store.user_by_id(sess["user_id"])
if user is None:
return SimpleNamespace(fetchone=lambda: None)
# Колонка состояния приезжает под алиасом `access_state` в ОБОИХ
# режимах (`u.<колонка> AS access_state` в реальном SELECT'е);
# значение — сырое, типа своей схемы.
row = SimpleNamespace(
user_id=sess["user_id"],
expires_at=sess["expires_at"],
@ -153,11 +193,12 @@ class _FakeDB:
display_name=user["display_name"],
org_name=user["org_name"],
email=user["email"],
is_active=user["is_active"],
access_state=user["access_state"],
)
return SimpleNamespace(fetchone=lambda: row)
if "FROM tradein_users" in sql:
if f"FROM {names.users}" in sql and "WHERE username = :username" in sql:
assert_reads_access_state(sql, names)
user = self.store.users.get(p["username"])
if user is None:
return SimpleNamespace(fetchone=lambda: None)
@ -214,6 +255,9 @@ def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
auth_mod.reset_cache_for_tests()
auth_router._LOGIN_LIMITER._hits.clear()
monkeypatch.setattr(config.settings, "auth_mode", "dual")
# Каждый тест стартует в ДЕФОЛТНОМ режиме реестра (сегодняшний прод), даже
# если предыдущий переключался на `auth`.
use_identity_mode(monkeypatch, "tradein")
@pytest.fixture
@ -221,9 +265,23 @@ def store() -> _Store:
return _Store()
@pytest.fixture
def auth_store(store: _Store, monkeypatch: pytest.MonkeyPatch) -> _Store:
"""Тот же `store`, но реестр — БД `auth` (`users`/`sessions`, text-состояние).
Запрашивай ПЕРЕД `client` в списке аргументов теста: `client` строится уже с
учётом режима (`_build_test_app` читает его лениво, но `store.add_user`
сохраняет значение колонки по режиму НА МОМЕНТ ВЫЗОВА).
"""
use_identity_mode(monkeypatch, "auth")
return store
@pytest.fixture
def client(store: _Store, monkeypatch: pytest.MonkeyPatch) -> TestClient:
monkeypatch.setattr("app.core.rbac.SessionLocal", lambda: _FakeDB(store))
# Подменяем сессию РЕЕСТРА на обоих её источниках сразу, а не ветвление по
# режиму: `identity_session()` / `get_identity_db()` остаются настоящими.
patch_identity_sessions(monkeypatch, lambda: _FakeDB(store))
# base_url=https:// — login sets the session cookie with Secure=True (real prod
# behaviour, not weakened for tests); httpx's cookie jar silently drops Secure
# cookies on a plain-http connection, so a plain http://testserver client would
@ -280,7 +338,9 @@ def test_login_unknown_username_401_generic_message(client: TestClient) -> None:
def test_login_inactive_user_401(client: TestClient, store: _Store) -> None:
store.add_user("bob", hash_password("Secret123!"), role="employee", is_active=False)
store.add_user(
"bob", hash_password("Secret123!"), role="employee", access_state=AccessState.DISABLED
)
resp = client.post("/api/v1/auth/login", json={"username": "bob", "password": "Secret123!"})
assert resp.status_code == 401
@ -611,3 +671,193 @@ def test_cyrillic_username_session_propagation_does_not_500(
# latin-1 "replace" гарантированно не крашит — точное значение (что именно
# получится из non-latin1 байт) не является контрактом, важно отсутствие 500.
assert resp.json()["user"] is not None
# ---------------------------------------------------------------------------
# Эпик «единый вход»: режим IDENTITY_STORE=auth (общий реестр в БД `auth`).
#
# Всё выше идёт в ДЕФОЛТНОМ режиме — он же прод — и служит регрессионным
# доказательством «после мержа работает точно как сейчас». Ниже — поведение,
# которое появляется ТОЛЬКО после переезда: трёхзначное состояние доступа
# (`active` / `trial_expired` / `disabled`) вместо булева `is_active`.
# ---------------------------------------------------------------------------
def test_default_mode_talks_to_tradein_tables_only(client: TestClient, store: _Store) -> None:
"""Дефолт трогает РОВНО сегодняшние таблицы — и ни одной таблицы реестра `auth`.
Пин на случай, если флаг когда-нибудь начнёт «протекать» (например, дефолт
поменяют или ветвление уедет не туда): расхождение здесь означало бы, что
прод после мержа пошёл в другую БД.
"""
store.add_user("alice", hash_password("Secret123!"), role="employee")
client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"})
assert client.get("/api/v1/me").status_code == 200
joined = "\n".join(store.sql_log)
assert "tradein_users" in joined
assert "tradein_sessions" in joined
# Ни один запрос не адресован таблицам общего реестра.
assert not re.search(r"\b(FROM|INTO|UPDATE|JOIN)\s+users\b", joined)
assert not re.search(r"\b(FROM|INTO|UPDATE|JOIN)\s+sessions\b", joined)
# И engine БД `auth` даже не создавался (AUTH_DATABASE_URL на проде пуст —
# ленивое построение обязано не случиться, иначе запрос упал бы).
assert auth_db._engine is None
def test_auth_mode_talks_to_shared_registry_tables(auth_store: _Store, client: TestClient) -> None:
"""Зеркало предыдущего: при IDENTITY_STORE=auth запросы уходят в users/sessions."""
auth_store.add_user("alice", hash_password("Secret123!"), role="employee")
resp = client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"})
assert resp.status_code == 200, resp.text
assert client.get("/api/v1/me").status_code == 200
joined = "\n".join(auth_store.sql_log)
assert "tradein_users" not in joined
assert "tradein_sessions" not in joined
assert re.search(r"FROM\s+users\b", joined)
assert re.search(r"INSERT INTO\s+sessions\b", joined)
def test_login_trial_expired_403_with_code_and_no_session(
auth_store: _Store, client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""ВЕРНЫЙ пароль + `trial_expired` → 403 с машиночитаемым кодом, сессии НЕТ.
Единственный не-generic ответ логина: аккаунт существует и владелец это уже
доказал паролем, так что осмысленный текст постороннему ничего не выдаёт.
"""
auth_store.add_user(
"trialguy",
hash_password("Secret123!"),
role="employee",
access_state=AccessState.TRIAL_EXPIRED,
)
events: list[dict[str, Any]] = []
monkeypatch.setattr(auth_router, "schedule_event", lambda **kw: events.append(kw))
resp = client.post(
"/api/v1/auth/login", json={"username": "trialguy", "password": "Secret123!"}
)
assert resp.status_code == 403, resp.text
detail = resp.json()["detail"]
# Контракт для фронта — `code`, а не текст сообщения.
assert detail["code"] == "access_expired"
assert detail["message"]
# Сессия не выдана: ни куки, ни строки в реестре.
assert config.settings.session_cookie_name not in resp.cookies
assert auth_store.sessions == {}
assert [e["event_type"] for e in events] == ["login_blocked_expired"]
def test_login_wrong_password_on_trial_expired_is_generic_401(
auth_store: _Store, client: TestClient
) -> None:
"""НЕверный пароль на `trial_expired` → тот же generic 401, что у чужого логина.
Иначе отдельный 403 превращается в оракул существования аккаунта: перебором
можно было бы перечислить логины, не зная ни одного пароля.
"""
auth_store.add_user(
"trialguy",
hash_password("Secret123!"),
role="employee",
access_state=AccessState.TRIAL_EXPIRED,
)
wrong_pw = client.post("/api/v1/auth/login", json={"username": "trialguy", "password": "nope"})
ghost = client.post("/api/v1/auth/login", json={"username": "ghost", "password": "nope"})
assert wrong_pw.status_code == 401
# Побайтово тот же ответ, что и на несуществующий логин.
assert wrong_pw.json() == ghost.json()
assert auth_store.sessions == {}
def test_login_disabled_is_generic_401_not_403(auth_store: _Store, client: TestClient) -> None:
"""`disabled` + верный пароль → generic 401, НЕ 403: заблокированный аккаунт
для пользователя неотличим от несуществующего (в отличие от `trial_expired`,
у которого есть свой экран)."""
auth_store.add_user(
"blocked",
hash_password("Secret123!"),
role="employee",
access_state=AccessState.DISABLED,
)
blocked = client.post(
"/api/v1/auth/login", json={"username": "blocked", "password": "Secret123!"}
)
ghost = client.post("/api/v1/auth/login", json={"username": "ghost", "password": "x"})
assert blocked.status_code == 401
assert blocked.json() == ghost.json()
assert auth_store.sessions == {}
def test_unknown_access_state_is_fail_closed_401(auth_store: _Store, client: TestClient) -> None:
"""Состояние, которого код не знает (миграция уехала вперёд кода), НЕ пускает."""
auth_store.add_user("newbie", hash_password("Secret123!"), role="employee")
auth_store.users["newbie"]["access_state"] = "pending_review"
resp = client.post("/api/v1/auth/login", json={"username": "newbie", "password": "Secret123!"})
assert resp.status_code == 401
assert auth_store.sessions == {}
@pytest.mark.parametrize("state", [AccessState.TRIAL_EXPIRED, AccessState.DISABLED])
def test_live_session_dies_when_access_state_leaves_active(
auth_store: _Store, client: TestClient, state: AccessState
) -> None:
"""Уже выданная сессия перестаёт работать СРАЗУ, как только состояние != active.
Без этого sliding-refresh (`get_session_user` продлевает expires_at на каждом
запросе) держал бы сессию истёкшего/заблокированного бесконечно долго.
"""
auth_store.add_user("alice", hash_password("Secret123!"), role="employee")
login = client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"})
assert login.status_code == 200
assert client.get("/api/v1/trade-in/dummy").status_code == 200
auth_store.set_access_state("alice", state)
# auth_mode=dual, но legacy-заголовка нет → сессия больше не резолвится → 401.
assert client.get("/api/v1/trade-in/dummy").status_code == 401
assert client.get("/api/v1/me").status_code == 401
def test_session_identity_wins_over_spoofed_header_auth_store(
auth_store: _Store, client: TestClient
) -> None:
"""Перезапись X-Authenticated-User в ASGI-scope работает и на общем реестре.
Тот же CRITICAL, что и в дефолтном режиме (см. выше): подделанный клиентом
заголовок не должен выигрывать у резолвленной сессии ни в одном режиме эти
~15 downstream-хендлеров читают сырой заголовок и про режим ничего не знают.
"""
auth_store.add_user("alice", hash_password("Secret123!"), role="employee")
auth_store.add_user("victim", hash_password("Secret123!"), role="employee")
client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"})
resp = client.get("/api/v1/trade-in/whoami", headers={"X-Authenticated-User": "victim"})
assert resp.status_code == 200
assert resp.json()["user"] == "alice"
def test_auth_mode_role_scope_and_logout(auth_store: _Store, client: TestClient) -> None:
"""Роль/скоуп и logout на общем реестре ведут себя как в дефолтном режиме."""
auth_store.add_user("mgr", hash_password("Secret123!"), role="manager")
login = client.post("/api/v1/auth/login", json={"username": "mgr", "password": "Secret123!"})
token = login.cookies[config.settings.session_cookie_name]
assert token in auth_store.sessions
body = client.get("/api/v1/me").json()
assert body["role"] == "manager"
assert "/api/v1/team/**" in body["allowed_paths"]
assert "/trade-in/sale-share/**" in body["deny_paths"]
assert client.post("/api/v1/auth/logout").status_code == 200
assert token not in auth_store.sessions

View file

@ -0,0 +1,469 @@
"""DSN БД `auth` из частей: один секрет — одно место (эпик «единый вход»).
Зачем это вообще. Пароль роли `auth_app` уже лежит в `.env.runtime` отдельной
переменной `AUTH_DB_PASSWORD` её читает `.forgejo/workflows/deploy.yml`, чтобы
сделать `ALTER ROLE`. Требовать вдобавок целиковый `AUTH_DATABASE_URL` с тем же
паролем внутри значило бы держать ОДИН секрет в ДВУХ местах: ротировали пароль
роли, забыли переписать DSN и вход ложится молча и целиком, у всех сразу.
Поэтому DSN собирается из частей, а явный `AUTH_DATABASE_URL` остаётся
приоритетным аварийным обходом.
Что пинят тесты ниже:
1. Дефолтный режим (`IDENTITY_STORE=tradein`) НЕ требует ни одной новой
переменной прод после мержа работает ровно как сейчас.
2. Хост по умолчанию `gendesign-postgres`, НЕ `postgres`. Внутри стека
«Меры» имя `postgres` резолвится в её собственный контейнер (см. коммент у
констант в `app/core/config.py`), и дефолт `postgres` увёл бы аутентификацию
в продуктовую БД, где нет ни роли, ни таблиц реестра.
3. Явный `AUTH_DATABASE_URL` бьёт сборку из частей.
4. Пароль экранируется: спецсимвол внутри него не имеет права порвать URL
иначе разбор молча уедет на другой хост/базу.
5. Пароль НЕ попадает ни в текст исключения, ни в traceback и ни в
`repr(settings)` / `model_dump()` (поле `SecretStr`).
6. Ни одна новая переменная не способна уронить СТАРТ процесса: пустое
значение любой части (включая `int`-порт, который валидируется на импорте)
падает обратно на дефолт, а не в ValidationError.
Все «пароли» в этом файле синтетические строки для проверки экранирования,
не секреты (настоящий живёт только в `.env.runtime` на VPS).
"""
from __future__ import annotations
import os
import traceback
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
from pydantic import SecretStr
from sqlalchemy.engine.url import make_url
from app.core import auth_db, config
from tests.support.identity_modes import use_identity_mode
# Набор символов, каждый из которых имеет СВОЙ смысл в грамматике URL:
# `@` — граница userinfo/host, `:` — граница user/password и host/port,
# `/` — начало пути (имени БД), `?` — начало query, `#` — начало фрагмента,
# `%` — начало процентной последовательности.
_SPECIALS_PASSWORD = "p@ss:w/o?rd#1%"
# Все переменные, которые новый код читает из окружения: в тестах, пинящих
# ДЕФОЛТЫ КОДА, их нужно снести — на дев-машине/CI они могут быть заданы.
_AUTH_ENV_VARS = (
"AUTH_DATABASE_URL",
"AUTH_DB_PASSWORD",
"AUTH_DB_HOST",
"AUTH_DB_PORT",
"AUTH_DB_NAME",
"AUTH_DB_USER",
"IDENTITY_STORE",
)
@pytest.fixture(autouse=True)
def _clean_auth_config(monkeypatch: pytest.MonkeyPatch):
"""Чистая конфигурация реестра до и после каждого теста.
Engine БД `auth` живёт в module-global, а не в `settings`, поэтому
monkeypatch его не откатывает сбрасываем явно с обеих сторон, иначе
построенный здесь engine утёк бы в соседние тесты сьюта.
"""
auth_db.reset_auth_db()
monkeypatch.setattr(config.settings, "identity_store", "tradein")
monkeypatch.setattr(config.settings, "auth_database_url", "")
monkeypatch.setattr(config.settings, "auth_db_password", SecretStr(""))
yield
auth_db.reset_auth_db()
def _fresh_settings(monkeypatch: pytest.MonkeyPatch, **env: str) -> config.Settings:
"""Настройки, собранные ЗАНОВО из чистого окружения + *env*.
`_env_file=None` не читать локальный `.env` (дев-машина и CI держат там
своё): пиним то, что записано литералом в `Settings`, а не окружение.
"""
for name in _AUTH_ENV_VARS:
monkeypatch.delenv(name, raising=False)
for name, value in env.items():
monkeypatch.setenv(name, value)
return config.Settings(_env_file=None) # type: ignore[call-arg]
def _set_password(monkeypatch: pytest.MonkeyPatch, value: str) -> None:
"""Подменить пароль на ЖИВОМ `settings` (для тестов, идущих через auth_db).
Обязательно через `SecretStr`: поле объявлено секретным, а `validate_assignment`
у `Settings` выключен `monkeypatch.setattr` кладёт объект КАК ЕСТЬ, без
приведения типа. Голая строка тихо прошла бы присваивание и упала бы уже в
резолвере на `.get_secret_value()`.
"""
monkeypatch.setattr(config.settings, "auth_db_password", SecretStr(value))
# ---------------------------------------------------------------------------
# Дефолт: новая механика ничего не требует
# ---------------------------------------------------------------------------
def test_nothing_configured_means_empty_dsn(monkeypatch: pytest.MonkeyPatch) -> None:
"""Ни одной переменной — DSN пуст, и это не ошибка.
Главный инвариант обратной совместимости: прод сегодня живёт с
`IDENTITY_STORE=tradein` и без всяких AUTH_*-переменных. Появление сборки из
частей не имеет права ни сделать что-то обязательным, ни начать угадывать
пароль.
"""
fresh = _fresh_settings(monkeypatch)
assert fresh.identity_store == "tradein"
assert fresh.auth_db_password.get_secret_value() == ""
assert fresh.resolved_auth_database_url == "", (
"без AUTH_DATABASE_URL и без AUTH_DB_PASSWORD реестр обязан считаться "
"несконфигурированным — иначе дефолтный режим полез бы в БД `auth`"
)
def test_default_mode_never_builds_engine_even_with_password(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""`AUTH_DB_PASSWORD` сам по себе НЕ включает новый реестр.
Переменная может приехать в `.env.runtime` заранее (deploy.yml ставит ею
пароль роли задолго до переключения) это не должно ничего активировать.
Переключатель ровно один: `IDENTITY_STORE`.
"""
_set_password(monkeypatch, _SPECIALS_PASSWORD)
assert config.settings.identity_store == "tradein"
assert auth_db._engine is None
assert auth_db._session_factory is None
# ---------------------------------------------------------------------------
# Сборка из частей
# ---------------------------------------------------------------------------
def test_dsn_assembled_from_password_and_prod_defaults(monkeypatch: pytest.MonkeyPatch) -> None:
"""Задан только пароль — остальное берётся из прод-дефолтов."""
fresh = _fresh_settings(monkeypatch, AUTH_DB_PASSWORD="parts-only")
assert (
fresh.resolved_auth_database_url
== "postgresql+psycopg://auth_app:parts-only@gendesign-postgres:5432/auth"
)
def test_default_host_is_shared_alias_not_own_postgres(monkeypatch: pytest.MonkeyPatch) -> None:
"""Хост по умолчанию — алиас чужого стека, а НЕ `postgres`.
Самая дорогая из возможных ошибок дефолта. `postgres` внутри «Меры»
это её собственный контейнер (`tradein-mvp/docker-compose.prod.yml` собирает
им продуктовый DATABASE_URL), а БД `auth` живёт на постгресе главного стека,
видном по алиасу `gendesign-postgres` в сети `gendesign_shared` (корневой
`docker-compose.prod.yml`). Подключение по `postgres` не упало бы «не тем»
хостом оно бы ушло в живую БД, где просто нет ни роли, ни таблиц реестра.
"""
url = make_url(_fresh_settings(monkeypatch, AUTH_DB_PASSWORD="x").resolved_auth_database_url)
assert url.host == "gendesign-postgres"
assert url.host != "postgres"
assert url.port == 5432
assert url.database == "auth"
assert url.username == "auth_app"
def test_scheme_matches_product_dsn_psycopg_v3(monkeypatch: pytest.MonkeyPatch) -> None:
"""Схема — та же, что у основного DATABASE_URL: psycopg v3.
`postgresql://` без суффикса увёл бы SQLAlchemy на psycopg2, которого нет в
зависимостях (`ModuleNotFoundError` на первом же обращении к реестру).
"""
dsn = _fresh_settings(monkeypatch, AUTH_DB_PASSWORD="x").resolved_auth_database_url
assert dsn.startswith("postgresql+psycopg://")
assert make_url(dsn).drivername == make_url(config.settings.database_url).drivername
def test_parts_are_overridable_via_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Каждая часть переопределяется своей переменной (dev / SSH-туннель)."""
fresh = _fresh_settings(
monkeypatch,
AUTH_DB_PASSWORD="tunnel",
AUTH_DB_HOST="localhost",
AUTH_DB_PORT="15432",
AUTH_DB_NAME="auth_copy",
AUTH_DB_USER="reader",
)
assert (
fresh.resolved_auth_database_url
== "postgresql+psycopg://reader:tunnel@localhost:15432/auth_copy"
)
def test_blank_part_falls_back_to_default(monkeypatch: pytest.MonkeyPatch) -> None:
"""`AUTH_DB_HOST=` (пустая строка в .env) — опечатка, а не «хост пустой».
Без этого получился бы DSN `...@:5432/auth`, который разберётся и уедет
коннектиться в непредсказуемое место вместо внятной ошибки.
ПОРТ здесь же и намеренно: он единственный из частей типизирован `int`, и
правило «пусто дефолт» держится для него отдельным валидатором. Читатель
обоснованно распространяет правило на всю семью AUTH_DB_* пусть тест это и
подтверждает, а не только host/user.
"""
fresh = _fresh_settings(
monkeypatch,
AUTH_DB_PASSWORD="x",
AUTH_DB_HOST=" ",
AUTH_DB_USER="",
AUTH_DB_PORT="",
AUTH_DB_NAME=" ",
)
url = make_url(fresh.resolved_auth_database_url)
assert url.host == "gendesign-postgres"
assert url.username == "auth_app"
assert url.port == 5432
assert url.database == "auth"
def test_blank_port_does_not_break_default_mode(monkeypatch: pytest.MonkeyPatch) -> None:
"""`AUTH_DB_PORT=` не имеет права ронять КОНФИГ — тем более в режиме tradein.
Тут пинится не DSN, а старт процесса. `settings = Settings()` выполняется
на уровне модуля `app/core/config.py`, а `int`-поле валидируется pydantic'ом
ДО всякой логики резолвера: без `_blank_port_means_default` пустая строка
давала бы ValidationError НА ИМПОРТЕ то есть не отказ auth-пути, а
restart-loop контейнера. И это при `IDENTITY_STORE=tradein`, где новая
механика не должна читаться вообще.
Сценарий ровно тот, ради которого дефолты и заводились: ops кладёт в
.env.runtime шаблон блока AUTH_DB_*, заполняя только пароль.
"""
fresh = _fresh_settings(monkeypatch, AUTH_DB_PORT="")
assert fresh.auth_db_port == 5432
assert fresh.identity_store == "tradein"
assert fresh.resolved_auth_database_url == ""
def test_garbage_port_still_fails_loudly(monkeypatch: pytest.MonkeyPatch) -> None:
"""`AUTH_DB_PORT=abc` обязан падать: это опечатка со смыслом, не «пусто».
Граница послабления: пустую строку мы прощаем (её оставляют намеренно),
непустой мусор нет, иначе тихо уехали бы на 5432 мимо того порта, который
человек имел в виду.
"""
with pytest.raises(ValueError):
_fresh_settings(monkeypatch, AUTH_DB_PORT="abc")
def test_whitespace_only_password_is_not_configured(monkeypatch: pytest.MonkeyPatch) -> None:
"""Пароль из одних пробелов = не задан (симметрично пустому DSN)."""
assert _fresh_settings(monkeypatch, AUTH_DB_PASSWORD=" ").resolved_auth_database_url == ""
# ---------------------------------------------------------------------------
# Приоритет явного DSN
# ---------------------------------------------------------------------------
def test_explicit_dsn_wins_over_parts(monkeypatch: pytest.MonkeyPatch) -> None:
"""Явный `AUTH_DATABASE_URL` выигрывает — обратная совместимость + обход.
Кто уже настроил стек по-старому, не должен ничего менять; и остаётся
аварийный путь вписать нестандартный DSN (другой хост, `sslmode`, пул-байпас)
без правки кода.
"""
explicit = "postgresql+psycopg://other:whole-dsn@elsewhere:6432/auth?sslmode=require"
fresh = _fresh_settings(
monkeypatch,
AUTH_DATABASE_URL=explicit,
AUTH_DB_PASSWORD="parts-must-lose",
AUTH_DB_HOST="ignored-host",
)
assert fresh.resolved_auth_database_url == explicit
def test_explicit_dsn_is_stripped_and_blank_falls_through_to_parts(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""DSN из одних пробелов = не задан → сборка из частей, а не пустота.
Иначе `AUTH_DATABASE_URL=` (оставленная в файле пустая строка) заблокировала
бы работающий пароль и дала бы отказ входа на пустом месте.
"""
fresh = _fresh_settings(monkeypatch, AUTH_DATABASE_URL=" ", AUTH_DB_PASSWORD="fallback")
assert make_url(fresh.resolved_auth_database_url).password == "fallback"
# ---------------------------------------------------------------------------
# Экранирование
# ---------------------------------------------------------------------------
def test_special_chars_in_password_survive_roundtrip(monkeypatch: pytest.MonkeyPatch) -> None:
"""`@ : / ? # %` в пароле не рвут URL — разбор возвращает его дословно.
Каждый из этих символов разделитель в грамматике URL. Без экранирования
`@` сдвинул бы границу хоста, `/` открыл бы имя БД, `%` дал бы битую
процентную последовательность. Проверка round-trip через тот же парсер,
которым пользуется SQLAlchemy при создании engine.
"""
fresh = _fresh_settings(monkeypatch, AUTH_DB_PASSWORD=_SPECIALS_PASSWORD)
dsn = fresh.resolved_auth_database_url
assert "p%40ss%3Aw%2Fo%3Frd%231%25" in dsn, "пароль обязан быть percent-encoded"
assert _SPECIALS_PASSWORD not in dsn, "сырой пароль в DSN = незаэкранированные разделители"
url = make_url(dsn)
assert url.password == _SPECIALS_PASSWORD
# И, главное, разделители из пароля не увели разбор в другое место:
assert (url.username, url.host, url.port, url.database) == (
"auth_app",
"gendesign-postgres",
5432,
"auth",
)
def test_special_chars_in_user_are_escaped(monkeypatch: pytest.MonkeyPatch) -> None:
"""Имя пользователя экранируется по той же причине, что и пароль.
`@` в имени роли иначе сдвинул бы границу userinfo/host и коннект молча
пошёл бы не туда.
"""
fresh = _fresh_settings(monkeypatch, AUTH_DB_PASSWORD="x", AUTH_DB_USER="a@b")
url = make_url(fresh.resolved_auth_database_url)
assert url.username == "a@b"
assert url.host == "gendesign-postgres"
def test_dbname_is_passed_through_unescaped(monkeypatch: pytest.MonkeyPatch) -> None:
"""Имя БД НЕ percent-энкодится — иначе в сервер уедет литеральное `%2F`.
Асимметрия не случайна и легко читается как баг: SQLAlchemy раскодирует
обратно только userinfo (user/password), а path отдаёт как есть. Пропусти мы
имя БД через `quote`, `c/d` превратилось бы в `c%2Fd` уже НА СТОРОНЕ
ПОСТГРЕСА (`database "c%2Fd" does not exist`). Тест пинит именно round-trip.
"""
fresh = _fresh_settings(monkeypatch, AUTH_DB_PASSWORD="x", AUTH_DB_NAME="c/d")
assert make_url(fresh.resolved_auth_database_url).database == "c/d"
def test_engine_from_parts_carries_exact_password(monkeypatch: pytest.MonkeyPatch) -> None:
"""Сквозная проверка: engine строится из частей и несёт ИМЕННО тот пароль.
`create_engine` к серверу не ходит (пул ленивый), поэтому живая БД не нужна
но URL внутри engine уже разобран SQLAlchemy, то есть это проверка всей
цепочки «части экранирование разбор», а не только строки.
"""
use_identity_mode(monkeypatch, "auth")
_set_password(monkeypatch, _SPECIALS_PASSWORD)
engine = auth_db.get_auth_engine()
assert engine.url.password == _SPECIALS_PASSWORD
assert engine.url.host == "gendesign-postgres"
assert engine.url.database == "auth"
# repr URL маскирует пароль — на этом держится безопасность чужих логов.
assert _SPECIALS_PASSWORD not in repr(engine.url)
# ---------------------------------------------------------------------------
# Ошибки: явные, но без секрета внутри
# ---------------------------------------------------------------------------
def test_auth_mode_without_password_and_dsn_raises(monkeypatch: pytest.MonkeyPatch) -> None:
"""Режим `auth` без конфигурации — явная ошибка, как и до появления частей.
Тихий фолбэк был бы худшим исходом: вход «работал» бы по неактуальному
реестру либо молча отказывал бы всем под видом неверных паролей.
"""
use_identity_mode(monkeypatch, "auth")
with pytest.raises(auth_db.AuthDatabaseNotConfiguredError) as excinfo:
auth_db.get_auth_engine()
message = str(excinfo.value)
# Текст обязан называть ОБА пути конфигурации — иначе дежурный будет искать
# переменную, которую мы же и перестали требовать.
assert "AUTH_DB_PASSWORD" in message
assert "AUTH_DATABASE_URL" in message
assert "IDENTITY_STORE=tradein" in message
def test_malformed_explicit_dsn_never_leaks_password(monkeypatch: pytest.MonkeyPatch) -> None:
"""Нечитаемый DSN → своя ошибка; ни пароля, ни его обломков нигде.
Ловушка, ради которой существует `from None`: на «почти URL» разбор
SQLAlchemy доходит до `int(port)` и падает с `invalid literal for int() with
base 10: 'w'`, где `'w'` символ ПАРОЛЯ, съехавший на позицию порта. Без
обрыва цепочки исключений он всплыл бы в traceback («During handling of the
above exception...») то есть в логи и в GlitchTip.
"""
use_identity_mode(monkeypatch, "auth")
monkeypatch.setattr(
config.settings,
"auth_database_url",
f"garbage://auth_app:{_SPECIALS_PASSWORD}@gendesign-postgres/auth",
)
with pytest.raises(auth_db.AuthDatabaseNotConfiguredError) as excinfo:
auth_db.get_auth_engine()
exc = excinfo.value
rendered = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
assert _SPECIALS_PASSWORD not in rendered
# Обломки пароля тоже не должны просочиться: пиним, что цепочка оборвана и
# рендерится ровно наше сообщение-константа.
assert "invalid literal for int" not in rendered
assert exc.__cause__ is None
assert exc.__suppress_context__ is True
assert str(exc) == auth_db._MALFORMED_DSN_MSG
def test_assembled_dsn_is_never_malformed(monkeypatch: pytest.MonkeyPatch) -> None:
"""Сборка из частей не может дать нечитаемый DSN даже на злом пароле.
Обратная сторона экранирования: путь «из частей» не должен уметь попадать в
ветку `_MALFORMED_DSN_MSG` вообще иначе ротация пароля с неудачным
символом положила бы вход.
"""
use_identity_mode(monkeypatch, "auth")
_set_password(monkeypatch, "://@:/?#%" + _SPECIALS_PASSWORD)
engine = auth_db.get_auth_engine()
assert engine.url.password == "://@:/?#%" + _SPECIALS_PASSWORD
assert engine.url.host == "gendesign-postgres"
def test_password_is_not_rendered_by_settings_repr_or_dump(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Пароль не печатается ни в `repr(settings)`, ни в `model_dump()`.
Канал утечки, которого не видно глазами: обычное `str`-поле pydantic
выводит ДОСЛОВНО, поэтому любой будущий дамп настроек (диагностический
роут, `logger.debug("%s", settings)`, чужой обработчик ошибок) утащил бы
пароль роли `auth_app` в логи целиком. Поле объявлено `SecretStr` здесь
пинится именно это свойство, а не факт наличия обёртки, чтобы откат к
голому `str` красил тест.
"""
fresh = _fresh_settings(monkeypatch, AUTH_DB_PASSWORD=_SPECIALS_PASSWORD)
assert _SPECIALS_PASSWORD not in repr(fresh)
assert _SPECIALS_PASSWORD not in str(fresh.model_dump())
# …и при этом значение достаётся: маскировка не должна ломать работу.
assert fresh.auth_db_password.get_secret_value() == _SPECIALS_PASSWORD
assert make_url(fresh.resolved_auth_database_url).password == _SPECIALS_PASSWORD

View file

@ -2,15 +2,28 @@
Coverage:
- create_session: INSERT with CAST(...) (never `:x::type`), commit, unique tokens.
- get_session_user: valid/expired/inactive/missing-row + sliding refresh (only when
- get_session_user: valid/expired/не-active/missing-row + sliding refresh (only when
last_seen_at is stale, best-effort a refresh failure still returns the user).
- get_user_by_username: found/not-found.
- get_user_by_username: found/not-found + состояние доступа как `AccessState`.
- revoke_session / revoke_user_sessions: DELETE + commit.
- get_db_role_scope: employee/manager/admin/unknown mapping.
All functions here take `db: Session` as a plain argument (no SessionLocal() opened
internally) unit tests just pass a hand-rolled fake, mirroring the `_FakeSession`
pattern from tests/test_user_events.py but adapted for `.fetchone()`-based reads.
ОБА РЕЖИМА РЕЕСТРА. Эпик «единый вход» вынес имена таблиц и имя/тип колонки
состояния доступа в `identity_store.identity_schema()`. Тесты, которые вообще
трогают SQL, прогоняются в ОБОИХ режимах (фикстура `identity_mode`): "tradein"
(дефолт, сегодняшний прод `tradein_users`/`tradein_sessions`, boolean
`is_active`) и "auth" (`users`/`sessions`, text `access_state`). Ожидаемые имена
в ассертах берутся из `identity_schema()` из того же словаря, что и у кода,
поэтому переименование таблиц не «разъезжает» тест с реальностью тихо;
поломка запроса ловится тем, что fake отдаёт строку ТОЛЬКО на ожидаемый SQL,
а сам SQL проверяется явными ассертами ниже.
Тесты БЕЗ фикстуры `identity_mode` намеренно идут в дефолтном режиме
(`_default_identity_mode` autouse) это чистая логика без SQL.
"""
from __future__ import annotations
@ -23,7 +36,34 @@ from typing import Any
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
from app.services import auth_session as svc
from app.services.identity_store import AccessState, identity_schema
from tests.support.identity_modes import IDENTITY_MODES, column_value, use_identity_mode
# ---------------------------------------------------------------------------
# Режим реестра
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _default_identity_mode(monkeypatch: pytest.MonkeyPatch) -> None:
"""Каждый тест стартует в ДЕФОЛТНОМ режиме, даже если предыдущий его менял."""
use_identity_mode(monkeypatch, "tradein")
@pytest.fixture(params=IDENTITY_MODES)
def identity_mode(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch) -> str:
"""Тест прогоняется дважды: "tradein" (прод) и "auth" (после переезда)."""
return use_identity_mode(monkeypatch, request.param)
@pytest.fixture
def auth_mode(monkeypatch: pytest.MonkeyPatch) -> str:
"""Только режим "auth" — для состояний, невыразимых булевой колонкой."""
return use_identity_mode(monkeypatch, "auth")
# ---------------------------------------------------------------------------
# Fake DB session
@ -58,6 +98,11 @@ class _FakeDB:
self.rolled_back += 1
# Часовой «аргумент не передан» — None здесь занят (это валидное сырое значение
# колонки: NULL, который to_access_state обязан трактовать как disabled).
_MISSING = object()
def _session_row(
*,
user_id: int = 1,
@ -65,8 +110,17 @@ def _session_row(
last_seen_at: datetime | None = None,
username: str = "alice",
role: str = "employee",
is_active: bool = True,
access_state: AccessState = AccessState.ACTIVE,
raw_access_state: object = _MISSING,
) -> SimpleNamespace:
"""Строка JOIN'а sessions×users, как её отдал бы драйвер.
Колонка состояния всегда приезжает под алиасом `access_state` (`AS access_state`
в реальном SELECT'е), а ЗНАЧЕНИЕ в ней — то, что лежит в БД текущего режима:
boolean для `tradein_users.is_active`, text для `auth.users.access_state`.
*raw_access_state* обход таблицы состояний для проверки fail-closed на
значении, которого код не знает.
"""
now = datetime.now(UTC)
return SimpleNamespace(
user_id=user_id,
@ -77,7 +131,9 @@ def _session_row(
display_name="Alice A.",
org_name="Org LLC",
email="alice@example.com",
is_active=is_active,
access_state=(
column_value(access_state) if raw_access_state is _MISSING else raw_access_state
),
)
@ -87,14 +143,14 @@ def _user_row(
username: str = "alice",
password_hash: str | None = "hash",
role: str = "employee",
is_active: bool = True,
access_state: AccessState = AccessState.ACTIVE,
) -> SimpleNamespace:
return SimpleNamespace(
id=user_id,
username=username,
password_hash=password_hash,
role=role,
is_active=is_active,
access_state=column_value(access_state),
display_name="Alice A.",
org_name="Org LLC",
email="alice@example.com",
@ -106,14 +162,14 @@ def _user_row(
# ---------------------------------------------------------------------------
def test_create_session_inserts_and_commits() -> None:
def test_create_session_inserts_and_commits(identity_mode: str) -> None:
db = _FakeDB()
token = svc.create_session(db, user_id=42, ip="1.2.3.4", user_agent="pytest")
assert db.committed == 1
assert len(db.executed) == 1
sql, params = db.executed[0]
assert "INSERT INTO tradein_sessions" in sql
assert f"INSERT INTO {identity_schema().sessions_table}" in sql
assert params is not None
assert params["user_id"] == 42
assert params["ip"] == "1.2.3.4"
@ -123,7 +179,7 @@ def test_create_session_inserts_and_commits() -> None:
assert len(token) >= 32
def test_create_session_cast_not_doublecolon() -> None:
def test_create_session_cast_not_doublecolon(identity_mode: str) -> None:
db = _FakeDB()
svc.create_session(db, user_id=1)
sql, _ = db.executed[0]
@ -150,16 +206,20 @@ def test_get_session_user_no_token_returns_none() -> None:
assert db.executed == []
def test_get_session_user_missing_row_returns_none() -> None:
def test_get_session_user_missing_row_returns_none(identity_mode: str) -> None:
schema = identity_schema()
db = _FakeDB(rows=[None])
assert svc.get_session_user(db, "tok") is None
sql, params = db.executed[0]
assert "FROM tradein_sessions s" in sql
assert "JOIN tradein_users u" in sql
assert f"FROM {schema.sessions_table} s" in sql
assert f"JOIN {schema.users_table} u" in sql
# Колонка состояния — под именем текущей схемы и обязательно с алиасом:
# без него вызывающий код читал бы то `is_active`, то `access_state`.
assert f"u.{schema.access_state_column} AS access_state" in sql
assert params == {"token": "tok"}
def test_get_session_user_expired_returns_none() -> None:
def test_get_session_user_expired_returns_none(identity_mode: str) -> None:
now = datetime.now(UTC)
db = _FakeDB(rows=[_session_row(expires_at=now - timedelta(minutes=1))])
assert svc.get_session_user(db, "tok") is None
@ -167,13 +227,36 @@ def test_get_session_user_expired_returns_none() -> None:
assert len(db.executed) == 1
def test_get_session_user_inactive_returns_none() -> None:
db = _FakeDB(rows=[_session_row(is_active=False)])
def test_get_session_user_disabled_returns_none(identity_mode: str) -> None:
"""Жёстко заблокированный аккаунт — сессия недействительна в обеих схемах."""
db = _FakeDB(rows=[_session_row(access_state=AccessState.DISABLED)])
assert svc.get_session_user(db, "tok") is None
assert len(db.executed) == 1
def test_get_session_user_valid_recent_no_refresh() -> None:
def test_get_session_user_trial_expired_returns_none(auth_mode: str) -> None:
"""Пробный период истёк — УЖЕ ВЫДАННАЯ сессия гасится немедленно.
Иначе сотрудник, залогиненный до истечения пробного доступа, продолжал бы
работать, а sliding-refresh продлевал бы ему `expires_at` бесконечно
состояние `trial_expired` не наступило бы для него никогда.
"""
db = _FakeDB(rows=[_session_row(access_state=AccessState.TRIAL_EXPIRED)])
assert svc.get_session_user(db, "tok") is None
# Ни UPDATE (sliding refresh), ни commit — сессия не продлевается.
assert len(db.executed) == 1
assert db.committed == 0
def test_get_session_user_unknown_state_returns_none(auth_mode: str) -> None:
"""Fail-closed: состояние, которого код не знает (миграция впереди кода),
НЕ пускает. Обратный выбор молча раздавал бы доступ по новому значению."""
db = _FakeDB(rows=[_session_row(raw_access_state="pending_review")])
assert svc.get_session_user(db, "tok") is None
assert len(db.executed) == 1
def test_get_session_user_valid_recent_no_refresh(identity_mode: str) -> None:
"""last_seen_at свежий (<5 мин) — sliding refresh НЕ триггерится."""
now = datetime.now(UTC)
db = _FakeDB(rows=[_session_row(last_seen_at=now - timedelta(minutes=1))])
@ -186,12 +269,15 @@ def test_get_session_user_valid_recent_no_refresh() -> None:
assert result["org_name"] == "Org LLC"
assert result["email"] == "alice@example.com"
assert result["user_id"] == 1
# Состояние доступа приезжает ЕДИНЫМ понятием, а не boolean/str по режимам;
# сюда доходит только ACTIVE (не-active отсеян выше).
assert result["access_state"] is AccessState.ACTIVE
# Только 1 execute (SELECT) — никакого UPDATE.
assert len(db.executed) == 1
assert db.committed == 0
def test_get_session_user_stale_last_seen_triggers_refresh() -> None:
def test_get_session_user_stale_last_seen_triggers_refresh(identity_mode: str) -> None:
"""last_seen_at старше 5 минут — один UPDATE (sliding refresh) + commit."""
now = datetime.now(UTC)
db = _FakeDB(rows=[_session_row(last_seen_at=now - timedelta(minutes=10))])
@ -200,7 +286,7 @@ def test_get_session_user_stale_last_seen_triggers_refresh() -> None:
assert result is not None
assert len(db.executed) == 2
update_sql, update_params = db.executed[1]
assert "UPDATE tradein_sessions" in update_sql
assert f"UPDATE {identity_schema().sessions_table}" in update_sql
assert "SET last_seen_at" in update_sql
assert not re.search(r":\w+::\w", update_sql)
assert "CAST(:ttl_hours AS integer)" in update_sql
@ -208,7 +294,7 @@ def test_get_session_user_stale_last_seen_triggers_refresh() -> None:
assert db.committed == 1
def test_get_session_user_refresh_failure_is_swallowed() -> None:
def test_get_session_user_refresh_failure_is_swallowed(identity_mode: str) -> None:
"""Sliding-refresh UPDATE падает — всё равно возвращаем валидного юзера
(best-effort refresh, не часть решения "валидна ли сессия")."""
now = datetime.now(UTC)
@ -228,7 +314,8 @@ def test_get_session_user_refresh_failure_is_swallowed() -> None:
# ---------------------------------------------------------------------------
def test_get_user_by_username_found() -> None:
def test_get_user_by_username_found(identity_mode: str) -> None:
schema = identity_schema()
db = _FakeDB(rows=[_user_row()])
user = svc.get_user_by_username(db, "alice")
@ -236,13 +323,38 @@ def test_get_user_by_username_found() -> None:
assert user["username"] == "alice"
assert user["password_hash"] == "hash"
assert user["role"] == "employee"
assert user["is_active"] is True
assert user["access_state"] is AccessState.ACTIVE
sql, params = db.executed[0]
assert "FROM tradein_users" in sql
assert f"FROM {schema.users_table}" in sql
assert f"{schema.access_state_column} AS access_state" in sql
assert params == {"username": "alice"}
def test_get_user_by_username_not_found() -> None:
def test_get_user_by_username_disabled_state_is_reported_not_hidden(identity_mode: str) -> None:
"""Строка отдаётся ВСЕГДА, состояние — отдельным полем.
Login обязан отличать «нет такого логина» (None) от «есть, но доступ закрыт»
(строка + не-ACTIVE): от этого зависит выбор события аудита, а прятать
заблокированного за None означало бы потерять эту разницу.
"""
db = _FakeDB(rows=[_user_row(access_state=AccessState.DISABLED)])
user = svc.get_user_by_username(db, "alice")
assert user is not None
assert user["access_state"] is AccessState.DISABLED
assert user["access_state"].can_sign_in is False
def test_get_user_by_username_trial_expired_state(auth_mode: str) -> None:
db = _FakeDB(rows=[_user_row(access_state=AccessState.TRIAL_EXPIRED)])
user = svc.get_user_by_username(db, "alice")
assert user is not None
assert user["access_state"] is AccessState.TRIAL_EXPIRED
assert user["access_state"].can_sign_in is False
def test_get_user_by_username_not_found(identity_mode: str) -> None:
db = _FakeDB(rows=[None])
assert svc.get_user_by_username(db, "ghost") is None
@ -252,24 +364,24 @@ def test_get_user_by_username_not_found() -> None:
# ---------------------------------------------------------------------------
def test_revoke_session_deletes_and_commits() -> None:
def test_revoke_session_deletes_and_commits(identity_mode: str) -> None:
db = _FakeDB()
svc.revoke_session(db, "tok")
assert db.committed == 1
sql, params = db.executed[0]
assert "DELETE FROM tradein_sessions" in sql
assert f"DELETE FROM {identity_schema().sessions_table}" in sql
assert "token" in sql
assert params == {"token": "tok"}
def test_revoke_user_sessions_deletes_and_commits() -> None:
def test_revoke_user_sessions_deletes_and_commits(identity_mode: str) -> None:
db = _FakeDB()
svc.revoke_user_sessions(db, 7)
assert db.committed == 1
sql, params = db.executed[0]
assert "DELETE FROM tradein_sessions" in sql
assert f"DELETE FROM {identity_schema().sessions_table}" in sql
assert "user_id" in sql
assert params == {"user_id": 7}

View file

@ -1,510 +0,0 @@
"""Unit tests for the Phase 2-3 backfill/audit script (issue #582).
Coverage:
- `forward_via_api` request shape verifies geocode/format/locality bias.
- `_parse_api_payload` precision + kind extraction.
- `_classify_backfill_status` precision filter rules.
- `_update_house_coords` UPDATE shape + raw_payload merge.
- `_run_backfill_mode` happy path UPDATE + audit row, plus imprecise-skip.
- `_run_audit_mode` ok / mismatch / no_match distinction.
- `main()` resumability second pass on same batch inserts 0.
No real Postgres in unit tests (same convention as test_audit_address_mismatch).
DB is a MagicMock that records INSERT/UPDATE calls and routes SELECT side-effects.
"""
from __future__ import annotations
import json
import os
from unittest.mock import AsyncMock, MagicMock, patch
# Same dance as test_audit_address_mismatch — settings needs a DSN at import.
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
import httpx
import pytest
from scripts._yandex_reverse import (
YandexReverseResult,
_parse_api_payload,
forward_via_api,
)
from scripts.backfill_house_coords import (
HouseRow,
_classify_backfill_status,
_run_audit_mode,
_run_backfill_mode,
_update_house_coords,
main,
)
# ---------------------------------------------------------------------------
# forward_via_api — request shape
# ---------------------------------------------------------------------------
async def test_forward_api_request_shape():
"""Verify the GET param dict — address as `geocode`, kind=house, EKB bias."""
fixture = {
"response": {
"GeoObjectCollection": {
"featureMember": [
{
"GeoObject": {
"metaDataProperty": {
"GeocoderMetaData": {
"text": "Россия, Свердловская область, Екатеринбург, "
"улица Малышева, 51",
"precision": "exact",
"kind": "house",
}
},
"name": "улица Малышева, 51",
"Point": {"pos": "60.586155 56.838004"},
}
}
]
}
}
}
captured: dict[str, httpx.Request] = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["req"] = request
return httpx.Response(200, json=fixture)
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport) as client:
res = await forward_via_api("ул Малышева 51", "DUMMY_KEY", client=client)
assert res.address is not None and "Малышева" in res.address
assert res.precision == "exact"
assert res.kind == "house"
assert res.snapped_lon == pytest.approx(60.586155, abs=1e-6)
assert res.snapped_lat == pytest.approx(56.838004, abs=1e-6)
qs = dict(httpx.QueryParams(captured["req"].url.query))
assert qs["apikey"] == "DUMMY_KEY"
assert qs["geocode"] == "ул Малышева 51"
assert qs["format"] == "json"
assert qs["kind"] == "house"
# EKB locality bias for forward geocode — important so addresses without
# the city resolve to the correct Малышева (there's one in Moscow too).
assert "ll" in qs
assert "spn" in qs
# ---------------------------------------------------------------------------
# Precision / kind passthrough in _parse_api_payload
# ---------------------------------------------------------------------------
def test_parse_api_payload_propagates_precision_and_kind():
data = {
"response": {
"GeoObjectCollection": {
"featureMember": [
{
"GeoObject": {
"metaDataProperty": {
"GeocoderMetaData": {
"text": "ул Ленина 5",
"precision": "exact",
"kind": "house",
}
},
"name": "ул Ленина 5",
"Point": {"pos": "60.6 56.8"},
}
}
]
}
}
}
res = _parse_api_payload(data)
assert res.precision == "exact"
assert res.kind == "house"
# ---------------------------------------------------------------------------
# _classify_backfill_status — precision filter rules
# ---------------------------------------------------------------------------
def test_classify_backfill_status_exact_match():
res = YandexReverseResult(
address="ул Малышева 51",
snapped_lat=56.838,
snapped_lon=60.586,
precision="exact",
kind="house",
)
assert _classify_backfill_status(res) == "backfill"
def test_classify_backfill_status_number_match():
res = YandexReverseResult(
address="ул Ленина 5",
snapped_lat=56.840,
snapped_lon=60.600,
precision="number",
kind="house",
)
assert _classify_backfill_status(res) == "backfill"
def test_classify_backfill_status_street_is_imprecise():
res = YandexReverseResult(
address="ул Ленина",
snapped_lat=56.840,
snapped_lon=60.600,
precision="street",
kind="street",
)
assert _classify_backfill_status(res) == "imprecise"
def test_classify_backfill_status_other_is_imprecise():
res = YandexReverseResult(
address="Свердловская область",
snapped_lat=56.8,
snapped_lon=60.6,
precision="other",
kind="locality",
)
assert _classify_backfill_status(res) == "imprecise"
def test_classify_backfill_status_no_match():
res = YandexReverseResult(address=None, snapped_lat=None, snapped_lon=None)
assert _classify_backfill_status(res) == "no_match"
def test_classify_backfill_status_none():
assert _classify_backfill_status(None) == "no_match"
def test_classify_backfill_status_precision_ok_but_no_coords():
"""Defensive: precision=exact but snapped point missing → no_match, not backfill."""
res = YandexReverseResult(
address="ул Малышева 51",
snapped_lat=None,
snapped_lon=None,
precision="exact",
kind="house",
)
assert _classify_backfill_status(res) == "no_match"
# ---------------------------------------------------------------------------
# _update_house_coords — UPDATE shape verification
# ---------------------------------------------------------------------------
def test_update_house_coords_passes_bindings():
db = MagicMock()
_update_house_coords(
db,
house_id=42,
lat=56.838,
lon=60.586,
payload={"address": "ул Малышева 51", "precision": "exact"},
)
args, _kw = db.execute.call_args
sql_str = str(args[0])
binds = args[1]
assert "UPDATE houses" in sql_str
assert "raw_payload" in sql_str
assert "yandex_geocode" in sql_str
assert binds["id"] == 42
assert binds["lat"] == 56.838
assert binds["lon"] == 60.586
# payload bound as JSON string for CAST(:payload AS jsonb)
decoded = json.loads(binds["payload"])
assert decoded["address"] == "ул Малышева 51"
# ---------------------------------------------------------------------------
# DB mock helper — same approach as test_audit_address_mismatch
# ---------------------------------------------------------------------------
def _make_db_mock(
backfill_sample: list[dict] | None = None,
audit_sample: list[dict] | None = None,
processed_ids: set[int] | None = None,
distance_value: float = 12.5,
):
"""MagicMock DB that:
- returns `backfill_sample` for `lat IS NULL OR lon IS NULL` SELECT
- returns `audit_sample` for `lat IS NOT NULL` SELECT
- returns `processed_ids` for the resume SELECT
- records INSERTs and UPDATEs
- returns `distance_value` for ST_Distance calls
"""
backfill_sample = backfill_sample or []
audit_sample = audit_sample or []
processed_ids = processed_ids if processed_ids is not None else set()
inserted: list[dict] = []
updated: list[dict] = []
db = MagicMock()
db.begin_nested.return_value.__enter__ = lambda self: self
db.begin_nested.return_value.__exit__ = lambda self, *a: False
def execute_side_effect(sql, params=None):
sql_str = str(sql)
result = MagicMock()
if "FROM houses" in sql_str and "lat IS NULL OR lon IS NULL" in sql_str:
result.mappings.return_value.all.return_value = backfill_sample
elif "FROM houses" in sql_str and "lat IS NOT NULL" in sql_str:
result.mappings.return_value.all.return_value = audit_sample
elif "FROM address_mismatch_audit" in sql_str and "house_id" in sql_str:
result.all.return_value = [(hid,) for hid in processed_ids]
elif "INSERT INTO address_mismatch_audit" in sql_str:
inserted.append(dict(params))
processed_ids.add(params["house_id"])
elif "UPDATE houses" in sql_str:
updated.append(dict(params))
elif "ST_Distance" in sql_str:
result.first.return_value = (distance_value,)
return result
db.execute.side_effect = execute_side_effect
db.commit = MagicMock()
db.rollback = MagicMock()
db.close = MagicMock()
return db, inserted, updated
# ---------------------------------------------------------------------------
# _run_backfill_mode — happy path + imprecise-skip
# ---------------------------------------------------------------------------
async def test_run_backfill_mode_writes_update_and_audit():
sample = [
HouseRow(id=1, address="ул Малышева 51", lat=None, lon=None),
]
db, inserted, updated = _make_db_mock()
res = YandexReverseResult(
address="Россия, Екатеринбург, улица Малышева, 51",
snapped_lat=56.838,
snapped_lon=60.586,
precision="exact",
kind="house",
raw={"ok": True},
)
with patch(
"scripts.backfill_house_coords.forward_via_api",
new=AsyncMock(return_value=res),
):
n = await _run_backfill_mode(db, sample, "b1", "KEY")
assert n == 1
assert len(updated) == 1
assert updated[0]["id"] == 1
assert updated[0]["lat"] == 56.838
assert updated[0]["lon"] == 60.586
assert len(inserted) == 1
assert inserted[0]["audit_status"] == "backfill"
assert inserted[0]["snapped_address"] == "Россия, Екатеринбург, улица Малышева, 51"
async def test_run_backfill_mode_imprecise_skips_update():
"""precision='street' → audit row written with status=imprecise, no UPDATE."""
sample = [HouseRow(id=2, address="ул Ленина", lat=None, lon=None)]
db, inserted, updated = _make_db_mock()
res = YandexReverseResult(
address="ул Ленина",
snapped_lat=56.840,
snapped_lon=60.600,
precision="street",
kind="street",
raw={"oh_well": True},
)
with patch(
"scripts.backfill_house_coords.forward_via_api",
new=AsyncMock(return_value=res),
):
n = await _run_backfill_mode(db, sample, "b2", "KEY")
assert n == 1
assert updated == []
assert len(inserted) == 1
assert inserted[0]["audit_status"] == "imprecise"
async def test_run_backfill_mode_no_match():
"""Yandex returns empty result → status=no_match, no UPDATE."""
sample = [HouseRow(id=3, address="несуществующая улица 99", lat=None, lon=None)]
db, inserted, updated = _make_db_mock()
res = YandexReverseResult(
address=None, snapped_lat=None, snapped_lon=None, raw={"empty": True}
)
with patch(
"scripts.backfill_house_coords.forward_via_api",
new=AsyncMock(return_value=res),
):
n = await _run_backfill_mode(db, sample, "b3", "KEY")
assert n == 1
assert updated == []
assert inserted[0]["audit_status"] == "no_match"
async def test_run_backfill_mode_http_error_marks_error():
sample = [HouseRow(id=4, address="ул X 1", lat=None, lon=None)]
db, inserted, updated = _make_db_mock()
with patch(
"scripts.backfill_house_coords.forward_via_api",
new=AsyncMock(side_effect=httpx.HTTPError("boom")),
):
n = await _run_backfill_mode(db, sample, "b4", "KEY")
assert n == 1
assert updated == []
assert inserted[0]["audit_status"] == "error"
assert "boom" in (inserted[0]["error_message"] or "")
# ---------------------------------------------------------------------------
# _run_audit_mode — ok / mismatch / no_match
# ---------------------------------------------------------------------------
async def test_run_audit_mode_ok_within_50m():
sample = [HouseRow(id=10, address="ул Малышева 51", lat=56.838, lon=60.586)]
db, inserted, _updated = _make_db_mock(distance_value=12.5)
res = YandexReverseResult(
address="Россия, Екатеринбург, улица Малышева, 51",
snapped_lat=56.838004,
snapped_lon=60.586155,
precision="exact",
kind="house",
raw={"r": 1},
)
with patch(
"scripts.backfill_house_coords.reverse_via_api",
new=AsyncMock(return_value=res),
):
n = await _run_audit_mode(db, sample, "ba1", "KEY")
assert n == 1
assert inserted[0]["audit_status"] == "ok"
assert inserted[0]["distance_m"] == 12.5
async def test_run_audit_mode_mismatch_above_50m():
sample = [HouseRow(id=11, address="ул Ленина 5", lat=56.840, lon=60.600)]
db, inserted, _updated = _make_db_mock(distance_value=312.0)
res = YandexReverseResult(
address="Россия, Екатеринбург, улица Ленина, 7",
snapped_lat=56.841,
snapped_lon=60.601,
precision="exact",
kind="house",
raw={"r": 2},
)
with patch(
"scripts.backfill_house_coords.reverse_via_api",
new=AsyncMock(return_value=res),
):
n = await _run_audit_mode(db, sample, "ba2", "KEY")
assert n == 1
assert inserted[0]["audit_status"] == "mismatch"
assert inserted[0]["distance_m"] == 312.0
async def test_run_audit_mode_no_match():
sample = [HouseRow(id=12, address="ул X 99", lat=56.0, lon=60.0)]
db, inserted, _updated = _make_db_mock()
res = YandexReverseResult(
address=None, snapped_lat=None, snapped_lon=None, raw={"empty": True}
)
with patch(
"scripts.backfill_house_coords.reverse_via_api",
new=AsyncMock(return_value=res),
):
n = await _run_audit_mode(db, sample, "ba3", "KEY")
assert n == 1
assert inserted[0]["audit_status"] == "no_match"
# ---------------------------------------------------------------------------
# Resumability — second pass on same batch inserts 0
# ---------------------------------------------------------------------------
async def test_main_resumable_skips_processed(monkeypatch):
"""Run main() twice with same batch — second pass processes nothing."""
backfill_sample = [
{"id": 1, "address": "ул Малышева 51", "lat": None, "lon": None},
{"id": 2, "address": "ул Ленина 5", "lat": None, "lon": None},
]
processed_ids: set[int] = set()
db, inserted, updated = _make_db_mock(
backfill_sample=backfill_sample, processed_ids=processed_ids
)
monkeypatch.setenv("YANDEX_GEOCODER_API_KEY", "TEST_KEY")
fake = YandexReverseResult(
address="ул Малышева 51",
snapped_lat=56.838,
snapped_lon=60.586,
precision="exact",
kind="house",
raw={"ok": True},
)
with (
patch("scripts.backfill_house_coords.SessionLocal", return_value=db),
patch(
"scripts.backfill_house_coords.forward_via_api",
new=AsyncMock(return_value=fake),
),
):
n1 = await main(["--batch", "resume_test"])
assert n1 == 2
assert len(inserted) == 2
assert len(updated) == 2
inserted.clear()
updated.clear()
n2 = await main(["--batch", "resume_test"])
assert n2 == 0
assert inserted == []
assert updated == []
async def test_main_requires_api_key(monkeypatch):
"""Without YANDEX_GEOCODER_API_KEY the script exits cleanly."""
monkeypatch.delenv("YANDEX_GEOCODER_API_KEY", raising=False)
with pytest.raises(SystemExit):
await main(["--batch", "no_key"])
async def test_main_audit_only_flag_routes_to_audit_loop(monkeypatch):
"""--audit-only switches sample query + loop, no UPDATE expected."""
audit_sample = [
{"id": 50, "address": "ул Малышева 51", "lat": 56.838, "lon": 60.586},
]
db, inserted, updated = _make_db_mock(audit_sample=audit_sample, distance_value=8.0)
monkeypatch.setenv("YANDEX_GEOCODER_API_KEY", "TEST_KEY")
fake = YandexReverseResult(
address="Россия, Екатеринбург, улица Малышева, 51",
snapped_lat=56.838004,
snapped_lon=60.586155,
precision="exact",
kind="house",
raw={"r": 1},
)
with (
patch("scripts.backfill_house_coords.SessionLocal", return_value=db),
patch(
"scripts.backfill_house_coords.reverse_via_api",
new=AsyncMock(return_value=fake),
),
):
n = await main(["--batch", "audit_run", "--audit-only"])
assert n == 1
assert updated == [] # audit mode never updates houses
assert inserted[0]["audit_status"] == "ok"
assert inserted[0]["distance_m"] == 8.0

View file

@ -0,0 +1,187 @@
"""Guard against implausible year_built poisoning the hedonic correction (Mera-audit 2026-08-02).
house_metadata (OSM/кадастр, best-effort enrichment) и
TradeInEstimateInput.year_built (payload, схема допускает ge=1800) могут
отдать явно ошибочный год постройки МКД прод-инцидент: house_metadata
year_built=1829 для обычной вторички (см. vault fixes).
Без валидации этот год уходит в хедонический year+area фактор
(_price_from_inputs, #2002), который экстраполирует regression fit далеко за
пределы обучающей выборки (COHORTS не определяет когорту раньше 1955 см.
estimator.py) и упирается в нижний кламп estimate_hedonic_factor_min=0.75
выкупная цена режется на фиксированные 25% без физического смысла.
_sanitize_build_year() отсекает год вне
[MIN_PLAUSIBLE_BUILD_YEAR, текущий год + MAX_PLAUSIBLE_BUILD_YEAR_LEAD] на
входе, трактуя его как «неизвестен» (None) хедонический year-term
становится нейтральным (эквивалент year=2000, см. test_estimator_hedonic.py
::test_target_year_none_is_neutral), а не клампится к произвольной границе.
NOTE: importing app.services.estimator pulls app.core.config.Settings which
requires DATABASE_URL. Set it BEFORE importing app modules (см. паттерн
test_estimator_hedonic.py).
"""
from __future__ import annotations
import os
from datetime import UTC, datetime
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services import estimator
from app.services.geocoder import GeocodeResult
# ── _sanitize_build_year: unit-level ────────────────────────────────────────
def test_implausible_low_year_dropped_to_none(caplog: pytest.LogCaptureFixture) -> None:
"""год=1829 (прод-инцидент house_metadata) → трактуется как отсутствующий."""
with caplog.at_level("WARNING"):
result = estimator._sanitize_build_year(1829, house_id=42, address="ул. Тестовая, 1")
assert result is None
assert any("1829" in r.message for r in caplog.records)
assert any("42" in r.message for r in caplog.records)
def test_plausible_year_unchanged() -> None:
"""год=1960 (хрущёвка) — валиден, работает как раньше (без изменений)."""
assert estimator._sanitize_build_year(1960) == 1960
def test_future_year_beyond_lead_dropped() -> None:
"""год = текущий+10 (далеко за допуском для строек) → отбрасывается."""
future_year = datetime.now(UTC).year + 10
assert estimator._sanitize_build_year(future_year) is None
def test_near_future_year_within_lead_kept() -> None:
"""год = текущий + LEAD (граница допуска для строек) — остаётся валидным."""
near_future = datetime.now(UTC).year + estimator.MAX_PLAUSIBLE_BUILD_YEAR_LEAD
assert estimator._sanitize_build_year(near_future) == near_future
def test_none_year_unchanged() -> None:
"""Отсутствие года — поведение НЕ меняется (уже было честным «не знаем»)."""
assert estimator._sanitize_build_year(None) is None
def test_boundary_year_min_plausible_kept() -> None:
"""MIN_PLAUSIBLE_BUILD_YEAR сам — валиден (inclusive)."""
year = estimator.MIN_PLAUSIBLE_BUILD_YEAR
assert estimator._sanitize_build_year(year) == year
def test_boundary_year_below_min_dropped() -> None:
"""MIN_PLAUSIBLE_BUILD_YEAR - 1 — уже невалиден."""
assert estimator._sanitize_build_year(estimator.MIN_PLAUSIBLE_BUILD_YEAR - 1) is None
# ── price impact via _price_from_inputs (hermetic, no DB) — #1966-стиль ────
def _geo() -> GeocodeResult:
return GeocodeResult(
lat=56.838,
lon=60.597,
full_address="ул. Тестовая, 1",
provider="nominatim",
confidence="approximate",
)
def _lots(ppm2: float, n: int = 7) -> list[dict]:
"""n unique-address lots all at the same ₽/m² → median_ppm2 == ppm2."""
return [
{"price_per_m2": ppm2, "address": f"ул. Тестовая, {i + 1}", "source": "avito"}
for i in range(n)
]
def _price(*, target_year: int | None, area_m2: float = 50.0) -> estimator.PricingResult:
"""Pure radius-only spine call (no anchor / dkp / imv) with a forced ratio.
Зеркалит helper из test_estimator_hedonic.py прогоняет ровно тот же
вызов _price_from_inputs, который estimate_quality делает после
_sanitize_build_year(target_year, ...).
"""
def ratio_resolver(appm2: float | None) -> tuple[float | None, str | None]:
return 0.85, "per_rooms"
return estimator._price_from_inputs(
listings=_lots(100_000.0),
area_m2=area_m2,
rooms=2,
repair_state=None,
floor=5,
total_floors=10,
target_year=target_year,
analog_tier="W",
fallback_used=False,
area_widened=False,
anchor_comps=[],
anchor_tier_fetched=None,
dkp_raw=None,
imv_anchor=None,
imv_eval=None,
yandex_val_present=False,
cian_val_present=False,
ratio_resolver=ratio_resolver,
quarter_index_lookup=lambda q: None,
quarter_indexes_lookup=lambda qs: {},
target_house_cadnum=None,
dadata_coarse=False,
geo=_geo(),
dadata_qc_geo=None,
)
def test_price_not_cut_after_sanitizing_1829(monkeypatch: pytest.MonkeyPatch) -> None:
"""Прод-репро (Mera-audit 2026-08-02): год=1829 без guard'а клампит хедонический фактор в
пол estimate_hedonic_factor_min=0.75 (25% к цене). После sanitize
(estimate_quality прогоняет target_year через _sanitize_build_year ДО
_price_from_inputs) год трактуется как отсутствующий фактор нейтрален,
цена НЕ порезана.
"""
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True)
# "Было бы" без фикса: 1829 идёт в хедонику напрямую.
unfixed = _price(target_year=1829)
# "Стало" с фиксом: estimate_quality сначала санитайзит год.
sanitized_year = estimator._sanitize_build_year(1829)
assert sanitized_year is None
fixed = _price(target_year=sanitized_year)
assert unfixed.expected_sold_price is not None
assert fixed.expected_sold_price is not None
ratio_only = round(unfixed.median_price * 0.85)
factor_before = unfixed.expected_sold_price / ratio_only
factor_after = fixed.expected_sold_price / ratio_only
# До фикса — кламп ровно в пол (фиксированная 25% недоплата).
assert factor_before == pytest.approx(estimator.settings.estimate_hedonic_factor_min, abs=1e-3)
# После фикса — год «неизвестен», фактор около нейтрали (НЕ 0.75).
assert factor_after > 0.95
assert fixed.expected_sold_price > unfixed.expected_sold_price
# Совпадает байт-в-байт с явным "год не указан" (test_target_year_none_is_neutral).
none_year = _price(target_year=None)
assert fixed.expected_sold_price == none_year.expected_sold_price
assert fixed.expected_sold_per_m2 == none_year.expected_sold_per_m2
def test_year_1960_hedonic_unaffected_by_guard(monkeypatch: pytest.MonkeyPatch) -> None:
"""год=1960 (валидная хрущёвка) — guard не меняет хедоническую поправку."""
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True)
sanitized_year = estimator._sanitize_build_year(1960)
assert sanitized_year == 1960
before = _price(target_year=1960)
after = _price(target_year=sanitized_year)
assert before.expected_sold_price == after.expected_sold_price
assert before.expected_sold_per_m2 == after.expected_sold_per_m2

View file

@ -12,6 +12,7 @@ Covers:
from __future__ import annotations
import os
import re
import sys
from unittest.mock import AsyncMock, MagicMock, patch
@ -26,9 +27,13 @@ sys.modules.setdefault("weasyprint", _wp_mock)
from app.services.geocoder import ( # noqa: E402
_SQL_HOUSE_TOKEN_RE,
SVERDLOVSK_OBLAST_REGION,
GeocodeResult,
GeocodeSuggestion,
_cadastral_house_match,
_dadata_suggest,
_norm_house,
_parse_street_house,
geocode,
suggest,
@ -143,8 +148,8 @@ def test_house_match_returns_none_for_non_numeric_house() -> None:
db.execute.assert_not_called()
def test_house_match_passes_only_digits_as_regex_param() -> None:
"""house='26а' → :house_digits bound param is '26' (letter stripped for regex)."""
def _house_match_params(house: str, street: str = "космонавтов") -> dict:
"""Вызывает матчер с mock-сессией и возвращает bound-params запроса."""
row = MagicMock()
row.readable_address = "г. Екатеринбург, пр-кт Космонавтов, д. 26а"
row.lat = 56.9
@ -154,13 +159,58 @@ def test_house_match_passes_only_digits_as_regex_param() -> None:
result.first.return_value = row
db.execute.return_value = result
_cadastral_house_match(db, "космонавтов", "26а")
_cadastral_house_match(db, street, house)
# second positional arg to execute() is the bound-params dict
params = db.execute.call_args.args[1]
assert params["house_digits"] == "26"
assert params["house_full"] == "26а"
return db.execute.call_args.args[1]
def test_house_match_passes_full_normalized_house_not_just_digits() -> None:
"""house='26а' → в запрос уходит ПОЛНЫЙ номер '26а', а не только цифры '26'.
Регрессия-гард на исходный баг: раньше литера отрезалась (`house_digits`
= '26') и в WHERE была опциональна, поэтому «26а» матчился на дом «26».
Цифры остаются отдельным параметром но только как дешёвый prefilter.
"""
params = _house_match_params("26а")
assert params["house_norm"] == "26а"
assert params["house_digits"] == "26" # prefilter only
assert params["street"] == "космонавтов"
# Литера больше не «подсказка для сортировки» — старый параметр ушёл.
assert "house_full" not in params
@pytest.mark.parametrize(
("raw_house", "expected_norm"),
[
("13б", "13б"),
("13 б", "13б"),
("13-б", "13б"),
("13Б", "13б"),
("13 Б", "13б"),
("13", "13"),
],
)
def test_house_match_normalizes_letter_spellings(raw_house: str, expected_norm: str) -> None:
"""«13б» / «13 б» / «13-б» / «13Б» — одна и та же литера, один канон."""
assert _house_match_params(raw_house, street="новгородцевой")["house_norm"] == expected_norm
def test_house_match_sql_compares_house_by_equality() -> None:
"""SQL сравнивает нормализованный номер РАВЕНСТВОМ, а не «литера опциональна».
Структурный гард: если кто-то вернёт матч по цифрам с опциональной литерой
(`[а-яё]?` в WHERE как единственная проверка дома), тест упадёт.
"""
db = MagicMock()
db.execute.return_value.first.return_value = None
_cadastral_house_match(db, "новгородцевой", "13б")
sql = str(db.execute.call_args.args[0])
assert "= CAST(:house_norm AS text)" in sql
# tie-break по литере в ORDER BY больше не решает корректность
assert "house_full" not in sql
# ── geocode() wiring ─────────────────────────────────────────────────────────
@ -604,3 +654,202 @@ async def test_suggest_skips_ekb_local_tier_for_unrecognized_locality(
mock_house.assert_not_called()
mock_forward.assert_not_called()
mock_nominatim.assert_called_once()
# ── House-letter matching semantics ─────────────────────────────────────────
# Само сравнение дома выполняет Postgres, поэтому здесь — зеркало SQL-выражения
# на Python. Паттерн НЕ дублируется: он выводится из той же константы
# `_SQL_HOUSE_TOKEN_RE`, что уходит в запрос (Postgres `\m` = «начало слова»
# ≡ Python `\b` перед словесным символом). Правка SQL-регекспа автоматически
# меняет и эти проверки — рассинхрон невозможен.
# Строки-адреса — реальные формы `readable_address` из gendesign_cad_buildings.
def _sql_house_token(readable_address: str) -> str:
"""Зеркало `_SQL_HOUSE_TOKEN_NORM`: извлечь номер дома и привести к канону."""
py_pattern = _SQL_HOUSE_TOKEN_RE.replace("\\m", "\\b")
m = re.search(py_pattern, readable_address, re.IGNORECASE)
token = (m.group(1) if m else "").lower()
token = re.sub(r"\s", "", token)
return re.sub(r"-([а-яё])", r"\1", token)
@pytest.mark.parametrize(
("readable_address", "expected"),
[
# Литера в трёх написаниях + регистр → один канон
("Свердловская область, г. Екатеринбург, ул. Новгородцевой, д. 7б", "7б"),
("Свердловская область, г. Екатеринбург, ул. Новгородцевой, д. 23-б", "23б"),
("Свердловская область, г. Екатеринбург, ул. X, д. 18 б", "18б"),
("Свердловская область, г. Екатеринбург, ул. X, д. 13Б", "13б"),
# Без литеры
("Свердловская область, г. Екатеринбург, ул. Новгородцевой, д. 13", "13"),
("Российская Федерация, город Екатеринбург, улица Новгородцевой, дом 13", "13"),
("Российская Федерация, город Екатеринбург, улица Малышева, сооружение 30", "30"),
("Российская Федерация, город Екатеринбург, улица Малышева, строение 30 в", "30в"),
# Хвосты, которые литерой НЕ являются
("Свердловская область, г. Екатеринбург, ул. X, д. 11 (кв. 1-150)", "11"),
("Свердловская область, г. Екатеринбург, ул. X, д. 25, корп. 1", "25"),
("Российская Федерация, город Екатеринбург, улица X, дом 102 корпус 1", "102"),
("Свердловская область, г. Екатеринбург, ул. X, д. 16 угол улица Титова", "16"),
# Угловые/корпусные номера — ЧАСТЬ номера, не отбрасываются
("Свердловская область, г. Екатеринбург, ул. X, д. 58/3", "58/3"),
("Свердловская область, г. Екатеринбург, ул. X, д. 36/24а", "36/24а"),
# Дефис перед ЦИФРОЙ не схлопывается (иначе «64-2» стало бы домом «642»)
("Свердловская область, г. Екатеринбург, ул. X, д. 64-2", "64-2"),
("Свердловская область, г. Екатеринбург, ул. X, д. 642", "642"),
# Маркер только с начала слова: «проезд» не даёт дом «8»
("Свердловская область, г Екатеринбург, проезд 8 Марта, д 5", "5"),
("Свердловская область, г Екатеринбург, ул Привокзальная, д 22", "22"),
# Нет дом-маркера → номер не извлекаем (адрес недостижим этим тиром)
("Свердловская область, город Екатеринбург, проезд 4-й ЕКАД Южный", ""),
],
)
def test_sql_house_token_extraction(readable_address: str, expected: str) -> None:
assert _sql_house_token(readable_address) == expected
@pytest.mark.parametrize(
("query_house", "readable_address", "should_match", "label"),
[
# ── Прод-баг #1: «Новгородцевой 13б» отдавал дом 13 как exact ──────
(
"13б",
"Российская Федерация, город Екатеринбург, улица Новгородцевой, дом 13",
False,
"запрос С литерой не берёт дом БЕЗ литеры",
),
# ── Прод-баг #2 (обратный): «Малышева 30» отдавал «д. 30-б» ────────
(
"30",
"Свердловская область, г. Екатеринбург, ул. Малышева, д. 30-б",
False,
"запрос БЕЗ литеры не берёт дом С литерой",
),
(
"7б",
"Свердловская область, г. Екатеринбург, ул. Новгородцевой, д. 7в",
False,
"другая литера не матчится",
),
# ── Позитив: литера совпала во всех написаниях реестра ─────────────
("7б", "Свердловская область, г. Екатеринбург, ул. Новгородцевой, д. 7б", True, "«13б»"),
(
"23б",
"Свердловская область, г. Екатеринбург, ул. Новгородцевой, д. 23-б",
True,
"«13-б»",
),
("18б", "Свердловская область, г. Екатеринбург, ул. X, д. 18 б", True, "«13 б»"),
("13б", "Свердловская область, г. Екатеринбург, ул. X, д. 13Б", True, "регистр"),
# ── Позитив: без литеры ────────────────────────────────────────────
(
"13",
"Российская Федерация, город Екатеринбург, улица Новгородцевой, дом 13",
True,
"«дом N»",
),
(
"30",
"Российская Федерация, город Екатеринбург, улица Малышева, сооружение 30",
True,
"«сооружение N»",
),
# Префикс числа не считается совпадением
("13", "Свердловская область, г. Екатеринбург, ул. X, д. 130", False, "13 ≠ 130"),
# Угловой номер не подменяет простой
("58", "Свердловская область, г. Екатеринбург, ул. X, д. 58/3", False, "58 ≠ 58/3"),
],
)
def test_house_letter_match_semantics(
query_house: str, readable_address: str, should_match: bool, label: str
) -> None:
"""Равенство нормализованных номеров — обе стороны приводятся к одному канону."""
matched = _sql_house_token(readable_address) == _norm_house(query_house)
assert matched is should_match, label
def test_query_letter_house_falls_through_instead_of_returning_neighbour() -> None:
"""Нет дома с литерой → None (не «похожий» дом) → работают следующие тиры.
Ключевое свойство фикса: молчаливая подмена соседнего здания здесь
помечалась бы `confidence="exact"` и кэшировалась на 90 дней.
"""
db = MagicMock()
db.execute.return_value.first.return_value = None # дома «13б» в реестре нет
assert _cadastral_house_match(db, "новгородцевой", "13б") is None
assert db.execute.call_args.args[1]["house_norm"] == "13б"
async def test_geocode_letter_house_miss_reaches_nominatim() -> None:
"""«Новгородцевой 13б» без хита в реестре доходит до Nominatim, а не
возвращает дом 13 с `confidence="exact"`."""
db = MagicMock()
nominatim_result = GeocodeResult(
lat=56.82,
lon=60.68,
full_address="ул. Новгородцевой, 13б, Екатеринбург",
provider="nominatim",
confidence="approximate",
)
with (
patch("app.services.geocoder._cache_get", return_value=None),
patch("app.services.geocoder._geoportal_house_match", return_value=None),
patch("app.services.geocoder._cadastral_house_match", return_value=None) as mock_house,
patch("app.services.geocoder._cadastral_forward_sync", return_value=[]),
patch("app.services.geocoder._cache_put"),
patch(
"app.services.geocoder._nominatim_lookup",
new_callable=AsyncMock,
return_value=nominatim_result,
) as mock_nominatim,
):
result = await geocode("Екатеринбург, Новгородцевой 13б", db)
assert result is not None
assert result.confidence == "approximate"
assert result.lat == pytest.approx(56.82)
mock_house.assert_called_once()
# в матчер ушёл ПОЛНЫЙ номер с литерой
assert mock_house.call_args.args[2] == "13б"
mock_nominatim.assert_called_once()
def test_parse_street_house_keeps_letter_in_all_spellings() -> None:
"""Парсер отдаёт литеру матчеру в каноне — иначе строгий матч бесполезен."""
assert _parse_street_house("Новгородцевой 13б") == ("новгородцевой", "13б")
assert _parse_street_house("Новгородцевой 13-б") == ("новгородцевой", "13б")
assert _parse_street_house("Новгородцевой 13 Б") == ("новгородцевой", "13б")
assert _parse_street_house("ул. Новгородцевой, д. 13б") == ("новгородцевой", "13б")
assert _parse_street_house("Новгородцевой 13") == ("новгородцевой", "13")
# ── DaData region constraint ────────────────────────────────────────────────
def test_dadata_region_constant_has_no_region_type() -> None:
"""DaData `locations.region` сравнивается с именем БЕЗ типа.
«Свердловская область» hard-filter, который не совпадает ни с чем и молча
даёт 0 подсказок (прод-баг). Тип живёт в `region_type`/`region_with_type`.
"""
assert SVERDLOVSK_OBLAST_REGION == "Свердловская"
lowered = SVERDLOVSK_OBLAST_REGION.lower()
for type_word in ("область", "обл", "край", "респ"):
assert type_word not in lowered, f"тип региона {type_word!r} ломает locations-фильтр"
async def test_dadata_suggest_passes_region_without_type() -> None:
"""`_dadata_suggest` отдаёт в DaData именно region-константу (не город)."""
with patch(
"app.services.geocoder.dadata.suggest_addresses",
new_callable=AsyncMock,
return_value=[],
) as mock_suggest:
assert await _dadata_suggest("Новгородцевой 13б", limit=5) == []
kwargs = mock_suggest.call_args.kwargs
assert kwargs["region"] == "Свердловская"
assert kwargs["city"] is None

View file

@ -0,0 +1,91 @@
"""Тесты `_nominatim_lookup` — city реально доходит до исходящего HTTP-запроса.
#2593 (часть 3): Yandex Geocoder полностью удалён из проекта, вместе с ним ушли
`_yandex_reverse.py` + `tests/test_audit_address_mismatch.py` +
`tests/test_backfill_house_coords.py` они были единственной проверкой, что
`city`/`city_hint` реально передаётся во внешний геокодер, а не только влияет на
cache-ключ (см. `tests/test_geocoder_city_hint.py`, который мокает
`_nominatim_lookup`/`_nominatim_suggest` целиком и потому не видит их внутренности).
Nominatim теперь единственный живой внешний провайдер (`_nominatim_lookup`
docstring, `app/services/geocoder.py`) этот файл закрывает получившуюся дыру:
мокает HTTP-транспорт (`httpx.MockTransport`, паттерн из `test_geocoder_bbox.py` /
`tests/services/test_dadata.py`) и проверяет параметр `q` реального исходящего
GET-запроса к `nominatim.openstreetmap.org/search`.
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from unittest.mock import patch
import httpx
from app.services.geocoder import _nominatim_lookup
# EKB-центр (Плотинка) — внутри tight EKB bbox, `_nominatim_query` его примет
# без похода во второй (typo-variant) тир.
_SAMPLE_ITEM = {
"lat": "56.838",
"lon": "60.605",
"class": "building",
"display_name": "ул. Малышева, 30, Екатеринбург",
"address": {"state": "Свердловская область"},
}
# Snapshot реального httpx.AsyncClient ДО patch'а — фабрика ниже использует именно
# его с подменённым transport (паттерн tests/services/test_dadata.py: избегает
# recursion, если бы `httpx.AsyncClient` патчился поверх самого себя).
_REAL_ASYNC_CLIENT = httpx.AsyncClient
def _async_client_factory(transport: httpx.MockTransport):
def factory(*_: object, **__: object) -> httpx.AsyncClient:
return _REAL_ASYNC_CLIENT(transport=transport)
return factory
def _capturing_transport(captured_q: list[str]) -> httpx.MockTransport:
def handler(request: httpx.Request) -> httpx.Response:
captured_q.append(request.url.params.get("q", ""))
return httpx.Response(200, json=[_SAMPLE_ITEM])
return httpx.MockTransport(handler)
async def test_nominatim_lookup_sends_city_hint_in_query_param() -> None:
"""city_hint="Нижний Тагил" должен попасть в q= реального GET-запроса.
Регрессия, о которой явно предупреждает docstring `_nominatim_lookup` (#2580 C):
city_hint обязан влиять на сам запрос к провайдеру, не только на cache-ключ.
"""
captured_q: list[str] = []
transport = _capturing_transport(captured_q)
with patch("app.services.geocoder.httpx.AsyncClient", _async_client_factory(transport)):
result = await _nominatim_lookup("Ленина, 1", city_hint="Нижний Тагил")
assert captured_q, "запрос к Nominatim не был отправлен"
assert captured_q[0] == "Нижний Тагил, Ленина, 1"
assert result is not None
assert result.provider == "nominatim"
async def test_nominatim_lookup_no_city_sends_bare_address() -> None:
"""Без city_hint и без маркера города в тексте — q= остаётся bare-адресом.
Guard против регрессии в молчаливый дефолт на конкретный город (#2576/#2593)
до фикса #2576 сюда молча подставлялся "Екатеринбург".
"""
captured_q: list[str] = []
transport = _capturing_transport(captured_q)
with patch("app.services.geocoder.httpx.AsyncClient", _async_client_factory(transport)):
result = await _nominatim_lookup("Малышева, 30")
assert captured_q == ["Малышева, 30"]
assert result is not None

View file

@ -0,0 +1,491 @@
"""Tests for app.services.identity_store + app.core.auth_db — эпик «единый вход».
`identity_store` единственное место, знающее, В КАКОЙ БД и В КАКИХ ТАБЛИЦАХ
живёт identity. Всё остальное (auth_session, rbac, роуты) спрашивает у него, и
поэтому ошибка ЗДЕСЬ это ошибка сразу везде.
Главное, что пинят эти тесты ( ограничение PR: после мержа прод обязан
работать ТОЧНО как сейчас):
1. ДЕФОЛТ = старое поведение. `IDENTITY_STORE` не задан `tradein_users` /
`tradein_sessions`, boolean-колонка, сессия из `app.core.db.SessionLocal`.
2. При дефолте код НЕ ТРОГАЕТ БД `auth` вообще: engine не строится, пустой
`AUTH_DATABASE_URL` не ошибка. На проде роль `auth_app` ещё без пароля и
DSN не заведён любое обращение туда было бы отказом входа.
3. `IDENTITY_STORE=auth` + пустой DSN ЯВНАЯ `AuthDatabaseNotConfiguredError`,
а не тихий фолбэк на tradein-таблицы и не пустой результат. Молчаливая
деградация auth-пути читалась бы как «неверный пароль» у всех сразу.
4. `get_identity_db` в дефолтном режиме отдаёт ТОТ ЖЕ объект `Session`, что и
`get_db` «Команда» пишет строку сотрудника и его квоту одной транзакцией.
Регрессия здесь дала бы состояние «сотрудник создан, квота нет».
5. Литералы значений состояния (`True`/`'active'`/...) пин по таблице
значений, а не round-trip через `to_access_state`: инверсия
`access_state_param` обязана быть видна.
"""
from __future__ import annotations
import os
from typing import Annotated, Any
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
from pydantic import SecretStr
from sqlalchemy import Engine
from app.core import auth_db, config
from app.core.db import get_db
from app.core.rbac import rbac_guard
from app.services import identity_store
from app.services.identity_store import (
AccessState,
access_state_param,
get_identity_db,
identity_schema,
identity_session,
to_access_state,
)
from tests.support.identity_modes import IDENTITY_MODES, use_identity_mode
_FAKE_AUTH_DSN = "postgresql+psycopg://auth_app:secret@localhost:5432/auth"
@pytest.fixture(autouse=True)
def _clean_identity_state(monkeypatch: pytest.MonkeyPatch):
"""Дефолтный режим + пустой DSN + сброшенный engine до И после теста.
Engine БД `auth` живёт в module-global, а не в `settings`, поэтому
monkeypatch его не откатывает держим сброс явно с обеих сторон, иначе
построенный здесь engine утёк бы в любой следующий тест сьюта.
"""
auth_db.reset_auth_db()
monkeypatch.setattr(config.settings, "identity_store", "tradein")
monkeypatch.setattr(config.settings, "auth_database_url", "")
# Второй источник DSN: при пустом AUTH_DATABASE_URL он собирается из
# AUTH_DB_PASSWORD + частей (см. Settings.resolved_auth_database_url). Не
# обнули его здесь — и заданная в окружении переменная сделала бы реестр
# «сконфигурированным»: тесты про «пустой DSN → явная ошибка» позеленели бы
# мимо проверяемого поведения.
# SecretStr, а не "": поле объявлено `SecretStr`, а `validate_assignment` у
# Settings выключен — monkeypatch кладёт значение КАК ЕСТЬ, без приведения
# типа, и голая строка уронила бы резолвер на `.get_secret_value()`.
monkeypatch.setattr(config.settings, "auth_db_password", SecretStr(""))
yield
auth_db.reset_auth_db()
class _FakeSession:
"""Session-заглушка: тестам здесь важна ИДЕНТИЧНОСТЬ объекта, не поведение."""
def __enter__(self) -> _FakeSession:
return self
def __exit__(self, *exc: object) -> bool:
return False
def close(self) -> None:
pass
# ---------------------------------------------------------------------------
# identity_schema — имена, попадающие прямо в SQL
# ---------------------------------------------------------------------------
def test_settings_defaults_are_legacy_mode(monkeypatch: pytest.MonkeyPatch) -> None:
"""⚠️ ГЛАВНЫЙ ИНВАРИАНТ PR, пин НАПРЯМУЮ по классу настроек.
Все остальные identity-тесты работают под autouse-фикстурой, которая
ПРИНУДИТЕЛЬНО выставляет `identity_store="tradein"` то есть проверяют
поведение при уже выбранном режиме, а не сам дефолт. Перевернись
`Field(default=...)` в config.py они бы этого не заметили, и прод молча
ушёл бы в БД `auth`, где ещё нет ни пароля роли `auth_app`, ни данных.
Поэтому здесь настройки конструируются заново, минуя `config.settings`:
* `_env_file=None` не читать локальный `.env` (дев-машина или CI могут
держать там свои значения; пиним ДЕФОЛТ КОДА, а не окружение);
* `delenv` обеих переменных то же самое для переменных процесса.
Останется ровно то, что записано литералом в `Settings`.
"""
monkeypatch.delenv("IDENTITY_STORE", raising=False)
monkeypatch.delenv("AUTH_DATABASE_URL", raising=False)
fresh = config.Settings(_env_file=None) # type: ignore[call-arg]
assert fresh.identity_store == "tradein", (
"дефолт IDENTITY_STORE обязан остаться 'tradein': прод после мержа должен "
"работать ТОЧНО как сейчас, на tradein_users/tradein_sessions"
)
assert fresh.auth_database_url == "", (
"AUTH_DATABASE_URL обязан быть пуст по умолчанию: на проде DSN роли "
"auth_app ещё не заведён, и пустое значение не должно ронять старт"
)
def test_default_schema_is_todays_production(monkeypatch: pytest.MonkeyPatch) -> None:
"""Без переменной окружения — ровно сегодняшние таблицы «Меры»."""
schema = identity_schema()
assert schema.store == "tradein"
assert schema.users_table == "tradein_users"
assert schema.sessions_table == "tradein_sessions"
assert schema.access_state_column == "is_active"
assert schema.access_state_sql_type == "boolean"
def test_auth_schema_points_at_shared_registry(monkeypatch: pytest.MonkeyPatch) -> None:
"""В БД `auth` таблицы без префикса продукта — реестр общий на «Меру» и «Птицу»."""
use_identity_mode(monkeypatch, "auth")
schema = identity_schema()
assert schema.store == "auth"
assert schema.users_table == "users"
assert schema.sessions_table == "sessions"
assert schema.access_state_column == "access_state"
assert schema.access_state_sql_type == "text"
def test_schema_is_read_per_call_not_cached_at_import(monkeypatch: pytest.MonkeyPatch) -> None:
"""Флаг читается на КАЖДОМ вызове: переключение не требует перезагрузки модулей."""
assert identity_schema().users_table == "tradein_users"
use_identity_mode(monkeypatch, "auth")
assert identity_schema().users_table == "users"
def test_unknown_store_raises_instead_of_silent_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
"""Значение вне словаря — ошибка, а не «ну возьмём tradein».
Недостижимо через настройки (`Literal` валидируется pydantic), но молчаливый
фолбэк здесь означал бы поход не в ту БД.
"""
monkeypatch.setattr(config.settings, "identity_store", "elsewhere")
with pytest.raises(ValueError, match="elsewhere"):
identity_schema()
@pytest.mark.parametrize("mode", IDENTITY_MODES)
def test_table_names_never_come_from_outside(monkeypatch: pytest.MonkeyPatch, mode: str) -> None:
"""Имена таблиц — только из фиксированного словаря (защита от SQL-инъекции по имени).
Имя таблицы нельзя передать bind-параметром, оно склеивается в строку запроса,
поэтому единственный допустимый источник `_SCHEMAS`. Тест пинит, что весь
набор значений конечен и не содержит ничего, кроме идентификаторов.
"""
use_identity_mode(monkeypatch, mode)
schema = identity_schema()
for name in (schema.users_table, schema.sessions_table, schema.access_state_column):
assert name.replace("_", "").isalnum(), name
assert schema.access_state_sql_type in ("boolean", "text")
# ---------------------------------------------------------------------------
# AccessState / to_access_state — ОДНО понятие состояния на обе схемы
# ---------------------------------------------------------------------------
def test_only_active_can_sign_in() -> None:
assert AccessState.ACTIVE.can_sign_in is True
assert AccessState.TRIAL_EXPIRED.can_sign_in is False
assert AccessState.DISABLED.can_sign_in is False
def test_boolean_column_maps_to_active_disabled() -> None:
"""Булев `tradein_users.is_active` — ровно два состояния, `trial_expired` там нет."""
assert to_access_state(True) is AccessState.ACTIVE
assert to_access_state(False) is AccessState.DISABLED
def test_text_column_maps_by_value() -> None:
assert to_access_state("active") is AccessState.ACTIVE
assert to_access_state("trial_expired") is AccessState.TRIAL_EXPIRED
assert to_access_state("disabled") is AccessState.DISABLED
@pytest.mark.parametrize("value", ["", "ACTIVE", "pending_review", None, 1, 0, object()])
def test_unrecognized_state_is_fail_closed(value: object) -> None:
"""Неизвестное значение / NULL / неожиданный тип → `disabled`.
Обратный выбор («пускать всё, что не disabled») означал бы, что состояние,
добавленное миграцией РАНЬШЕ кода, молча раздаёт доступ. NB: `1`/`0` это
int, а не bool, и в булевой схеме они не появляются; сюда они попадают как
«неожиданный тип» и тоже блокируются.
"""
assert to_access_state(value) is AccessState.DISABLED
# ---------------------------------------------------------------------------
# access_state_param — обратное направление (ЗАПИСЬ)
# ---------------------------------------------------------------------------
def test_write_value_in_boolean_schema() -> None:
"""Литералы, а не round-trip: инверсия функции обязана быть видна прямо здесь."""
assert access_state_param(AccessState.ACTIVE) is True
assert access_state_param(AccessState.DISABLED) is False
def test_write_value_in_text_schema(monkeypatch: pytest.MonkeyPatch) -> None:
use_identity_mode(monkeypatch, "auth")
assert access_state_param(AccessState.ACTIVE) == "active"
assert access_state_param(AccessState.TRIAL_EXPIRED) == "trial_expired"
assert access_state_param(AccessState.DISABLED) == "disabled"
def test_trial_expired_is_not_silently_downgraded_in_boolean_schema() -> None:
"""`trial_expired` в булевой схеме — ошибка вызывающего, НЕ тихий `False`.
Тихое приведение превратило бы «пробный период истёк» в жёсткую блокировку:
клиент увидел бы «неверный логин или пароль» вместо экрана пробного периода.
"""
with pytest.raises(ValueError, match="trial_expired"):
access_state_param(AccessState.TRIAL_EXPIRED)
# ---------------------------------------------------------------------------
# Где физически берётся сессия реестра
# ---------------------------------------------------------------------------
def test_default_mode_uses_product_session_and_never_builds_auth_engine(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Дефолт: та же `SessionLocal`, что у всего приложения; БД `auth` не трогается.
Это буквально «прод после мержа работает как сейчас»: `AUTH_DATABASE_URL` на
проде пуст, и его отсутствие не должно ни ронять старт, ни всплывать в
рантайме.
"""
opened: list[_FakeSession] = []
def _session_local() -> _FakeSession:
s = _FakeSession()
opened.append(s)
return s
monkeypatch.setattr(identity_store, "SessionLocal", _session_local)
with identity_session() as db:
assert db is opened[0]
assert len(opened) == 1
assert auth_db._engine is None
assert auth_db._session_factory is None
def test_auth_mode_without_dsn_raises_instead_of_silent_fallback(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""`IDENTITY_STORE=auth` + пустой DSN → явная ошибка, и НИ ОДНОГО запроса в tradein.
Тихий фолбэк на `tradein_users` был бы худшим исходом: вход бы «работал», но
в реестре, который к тому моменту считается неактуальным.
"""
use_identity_mode(monkeypatch, "auth")
def _must_not_be_called() -> _FakeSession:
raise AssertionError("режим auth не имеет права открывать сессию БД tradein")
monkeypatch.setattr(identity_store, "SessionLocal", _must_not_be_called)
with pytest.raises(auth_db.AuthDatabaseNotConfiguredError, match="AUTH_DATABASE_URL"):
with identity_session():
pass
def test_auth_engine_is_lazy_cached_and_resettable(monkeypatch: pytest.MonkeyPatch) -> None:
"""Engine строится при ПЕРВОМ обращении, кешируется, сбрасывается `reset_auth_db`.
`create_engine` к серверу не ходит (пул ленивый), поэтому тест не требует
живой БД проверяется именно кеширование, из-за которого два одновременных
первых запроса иначе создали бы два независимых пула.
"""
use_identity_mode(monkeypatch, "auth")
monkeypatch.setattr(config.settings, "auth_database_url", _FAKE_AUTH_DSN)
assert auth_db._engine is None # ленивость: до первого обращения ничего нет
engine = auth_db.get_auth_engine()
assert isinstance(engine, Engine)
assert auth_db.get_auth_engine() is engine
assert auth_db.get_auth_session_factory() is auth_db.get_auth_session_factory()
auth_db.reset_auth_db()
assert auth_db._engine is None
assert auth_db.get_auth_engine() is not engine
def test_blank_dsn_is_not_configured(monkeypatch: pytest.MonkeyPatch) -> None:
"""DSN из одних пробелов = не задан (иначе `create_engine('')` дал бы мутную ошибку)."""
use_identity_mode(monkeypatch, "auth")
monkeypatch.setattr(config.settings, "auth_database_url", " ")
with pytest.raises(auth_db.AuthDatabaseNotConfiguredError):
auth_db.get_auth_engine()
# ---------------------------------------------------------------------------
# get_identity_db — FastAPI-зависимость: ОДНА транзакция в дефолте, две в auth
# ---------------------------------------------------------------------------
def _probe_app() -> FastAPI:
"""Мини-приложение с обеими зависимостями сразу — как у роутов «Команды»."""
app = FastAPI()
@app.get("/probe")
async def probe(
db: Annotated[Any, Depends(get_db)],
identity_db: Annotated[Any, Depends(get_identity_db)],
) -> dict[str, bool]:
return {"same_session": db is identity_db}
return app
def test_default_mode_shares_one_session_with_get_db(monkeypatch: pytest.MonkeyPatch) -> None:
"""`db is identity_db` в дефолте — не экономия коннекта, а требование прода.
«Команда» пишет строку сотрудника (реестр) и его квоту (`account_quota_overrides`,
продуктовая таблица) В ОДНОЙ транзакции. Две сессии = две транзакции =
состояние «сотрудник создан, квота нет» на ровном месте.
"""
app = _probe_app()
app.dependency_overrides[get_db] = lambda: iter([_FakeSession()])
resp = TestClient(app).get("/probe")
assert resp.status_code == 200, resp.text
assert resp.json() == {"same_session": True}
def test_auth_mode_yields_separate_registry_session(monkeypatch: pytest.MonkeyPatch) -> None:
"""В режиме `auth` БД физически разные → и сессии обязаны быть разными объектами.
`db is not identity_db` рантайм-признак «БД разные», по которому `team.py`
решает, коммитить ли вторую транзакцию.
"""
use_identity_mode(monkeypatch, "auth")
registry_session = _FakeSession()
from contextlib import contextmanager
@contextmanager
def _fake_auth_session():
yield registry_session
monkeypatch.setattr(auth_db, "auth_session", _fake_auth_session)
app = _probe_app()
app.dependency_overrides[get_db] = lambda: iter([_FakeSession()])
resp = TestClient(app).get("/probe")
assert resp.status_code == 200, resp.text
assert resp.json() == {"same_session": False}
# ---------------------------------------------------------------------------
# Сломанная конфигурация не роняет запрос (rbac_guard)
# ---------------------------------------------------------------------------
def _guarded_app() -> FastAPI:
app = FastAPI()
app.middleware("http")(rbac_guard)
@app.get("/api/v1/trade-in/dummy")
async def dummy() -> dict[str, bool]:
return {"ok": True}
return app
def test_misconfigured_auth_store_degrades_to_401_not_500(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""`IDENTITY_STORE=auth` без DSN + запрос С КУКОЙ → 401, а не 500.
`AuthDatabaseNotConfiguredError` обрабатывается тем же путём, что и любой
сбой БД: резолв сессии не состоялся, дальше решает `auth_mode`. Сознательно
не отличается от «БД недоступна» обе ситуации это сломанная конфигурация
реестра, и ни одна не имеет права отдавать 500 (или, тем более, пускать).
"""
use_identity_mode(monkeypatch, "auth")
client = TestClient(_guarded_app(), base_url="https://testserver")
client.cookies.set(config.settings.session_cookie_name, "some-token")
resp = client.get("/api/v1/trade-in/dummy")
assert resp.status_code == 401
# Legacy trusted-header путь (dual-mode) при этом продолжает работать —
# сломанный реестр не отрезает существующих пользователей Caddy.
#
# ⚠️ Это поведение УЖЕ НЕДОСТИЖИМО в реальном процессе: до такого состояния
# приложение не доживает, потому что lifespan падает на старте (см.
# `test_lifespan_fails_fast_when_auth_store_has_no_dsn` ниже). Тест держит
# guard'а от 500-ки/анонимного доступа как второй рубеж — на случай, если
# DSN сломается уже ПОСЛЕ успешного старта.
fallback = client.get("/api/v1/trade-in/dummy", headers={"X-Authenticated-User": "kopylov"})
assert fallback.status_code == 200, fallback.text
# ---------------------------------------------------------------------------
# Boot-time guard: сломанный реестр не должен ЖИТЬ на legacy-пути
# ---------------------------------------------------------------------------
def _run_lifespan(monkeypatch: pytest.MonkeyPatch) -> None:
"""Прогоняет lifespan приложения до `yield` и обратно.
FDW-bootstrap выключен: он ходит в продуктовую БД, которой в юнит-тестах
нет. К проверяемому здесь он отношения не имеет (и в самом lifespan обёрнут
в try/except), а без заглушки тест ждал бы таймаута коннекта.
"""
import asyncio
from app import main as app_main
monkeypatch.setattr(app_main, "ensure_fdw_user_mapping", lambda db: None)
async def _cycle() -> None:
async with app_main.lifespan(app_main.app):
pass
asyncio.run(_cycle())
def test_lifespan_fails_fast_when_auth_store_has_no_dsn(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""`IDENTITY_STORE=auth` + пустой DSN → контейнер НЕ поднимается.
Почему не «работает как-нибудь»: `rbac_guard` ловит
`AuthDatabaseNotConfiguredError` вместе с любым другим сбоем резолва сессии
и уходит в legacy trusted-header ветку. Продуктовая БД при этом жива, и
такой деплой способен работать сутками, раздавая права из roles.yaml всем,
кого пропустил Caddy basic_auth, включая аккаунты, у которых в реестре
`access_state='disabled'`/`'trial_expired'`. Ошибка КОНФИГУРАЦИИ обязана
убивать старт, а не деградировать в тихий обход реестра.
"""
use_identity_mode(monkeypatch, "auth")
monkeypatch.setattr(config.settings, "auth_database_url", "")
with pytest.raises(auth_db.AuthDatabaseNotConfiguredError):
_run_lifespan(monkeypatch)
def test_lifespan_does_not_touch_auth_db_in_default_mode(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Дефолтный режим: старт НЕ обращается к БД `auth` и пустой DSN не мешает.
Ровно ограничение PR сегодняшний прод (`IDENTITY_STORE` не задан,
`AUTH_DATABASE_URL` нет вовсе) обязан подниматься как раньше.
"""
from app import main as app_main
calls: list[str] = []
monkeypatch.setattr(app_main, "get_auth_engine", lambda: calls.append("built"))
_run_lifespan(monkeypatch)
assert calls == [], "в дефолтном режиме engine БД `auth` не должен строиться на старте"

View file

@ -75,17 +75,29 @@ def test_snapshot_derives_is_active_and_payload_hash() -> None:
# ── Event-diff CTE SQL ────────────────────────────────────────────────────────
def test_event_diff_is_set_based_cte_not_python_loop() -> None:
"""Event diff is one set-based INSERT … SELECT over a CTE — never a row-by-row loop."""
def test_event_diff_is_set_based_lateral_not_python_loop() -> None:
"""Event diff is one set-based INSERT … SELECT with a LATERAL join — no Python loop.
#2607: prior used to be a `DISTINCT ON (listing_source_id) ... FROM
listing_source_snapshots` CTE joined via plain JOIN the planner's Nested Loop
(no Materialize, misestimated `today` row count) re-executed the DISTINCT ON over
the whole table once per today-row, hanging for days. Rewritten as `JOIN LATERAL
(... ORDER BY snapshot_date DESC LIMIT 1) ON true` forces a per-row indexed
point-lookup via idx_lss_source_date instead of a full-table DISTINCT ON.
"""
assert "WITH today AS" in _EVENT_DIFF_SQL
assert "prior AS" in _EVENT_DIFF_SQL
assert "DISTINCT ON (listing_source_id)" in _EVENT_DIFF_SQL
assert "JOIN LATERAL" in _EVENT_DIFF_SQL
assert "prior AS" not in _EVENT_DIFF_SQL, "prior CTE removed — replaced by LATERAL (#2607)"
assert "DISTINCT ON" not in _EVENT_DIFF_SQL, "DISTINCT ON over full table removed (#2607)"
assert "INSERT INTO listing_source_events" in _EVENT_DIFF_SQL
# Prior = most-recent snapshot strictly before today.
assert "snapshot_date < CURRENT_DATE" in _EVENT_DIFF_SQL
# LATERAL subquery: most-recent snapshot strictly before today, per listing_source_id.
assert "s.listing_source_id = t.listing_source_id" in _EVENT_DIFF_SQL
assert "s.snapshot_date < CURRENT_DATE" in _EVENT_DIFF_SQL
assert "snapshot_date = CURRENT_DATE" in _EVENT_DIFF_SQL
assert "ORDER BY listing_source_id, snapshot_date DESC" in _EVENT_DIFF_SQL
# No Python iteration over rows in the writer body (set-based only).
assert "ORDER BY s.snapshot_date DESC" in _EVENT_DIFF_SQL
assert "LIMIT 1" in _EVENT_DIFF_SQL
# No Python iteration over rows in the writer body (set-based only — LATERAL is a
# Postgres execution-plan construct, not a Python loop).
body = _WRITER_SRC.split('"""', 2)[-1]
assert "for " not in body, "writer must be set-based — no Python row loop"
@ -222,20 +234,69 @@ def test_counter_logic_with_fake_db(monkeypatch: pytest.MonkeyPatch) -> None:
)
monkeypatch.setattr(snap_mod.runs_mod, "mark_failed", lambda *a, **k: None)
db = _FakeDB(rowcounts=[18355, 42]) # snapshot rowcount, then event rowcount
# rowcounts: SET LOCAL statement_timeout (ignored), snapshot upsert, event-diff insert.
db = _FakeDB(rowcounts=[0, 18355, 42])
out = snap_mod.snapshot_listing_sources(db, run_id=99) # type: ignore[arg-type]
assert out == {"snapshotted": 18355, "price_change_events": 42}
assert db.committed is True
assert len(db.executed) == 2
# run_id threaded into the snapshot statement's bind params.
_stmt, params = db.executed[0]
assert len(db.executed) == 3
# First statement sets the per-transaction wall-clock budget (#2607).
stmt0, _params0 = db.executed[0]
assert "SET LOCAL statement_timeout" in str(stmt0)
# run_id threaded into the snapshot statement's bind params (now executed[1]).
_stmt, params = db.executed[1]
assert params is not None and params["run_id"] == 99
# Run finalised via mark_done with the same counters.
assert marked["run_id"] == 99
assert marked["counters"] == {"snapshotted": 18355, "price_change_events": 42}
# ── budget_sec / statement_timeout (#2607) ─────────────────────────────────────
def test_clamp_budget_sec_defaults_and_bounds() -> None:
assert snap_mod._clamp_budget_sec(snap_mod.DEFAULT_BUDGET_SEC) == snap_mod.DEFAULT_BUDGET_SEC
# Below floor / garbage / zero (the historical bug: 0 == "no timeout") clamp to the floor.
assert snap_mod._clamp_budget_sec(0) == snap_mod._MIN_BUDGET_SEC
assert snap_mod._clamp_budget_sec(-5) == snap_mod._MIN_BUDGET_SEC
assert snap_mod._clamp_budget_sec(None) == snap_mod.DEFAULT_BUDGET_SEC
assert snap_mod._clamp_budget_sec("garbage") == snap_mod.DEFAULT_BUDGET_SEC
# Above ceiling clamps down — never lets a fat-fingered value re-create "hangs forever".
assert snap_mod._clamp_budget_sec(999_999) == snap_mod._MAX_BUDGET_SEC
# Sane custom value passes through unclamped.
assert snap_mod._clamp_budget_sec(120) == 120.0
def test_snapshot_listing_sources_sets_statement_timeout_from_params(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""budget_sec from default_params is applied via SET LOCAL statement_timeout (ms)."""
monkeypatch.setattr(snap_mod.runs_mod, "mark_done", lambda *a, **k: None)
monkeypatch.setattr(snap_mod.runs_mod, "mark_failed", lambda *a, **k: None)
db = _FakeDB(rowcounts=[0, 10, 1])
snap_mod.snapshot_listing_sources(db, run_id=1, params={"budget_sec": 120}) # type: ignore[arg-type]
stmt0, _params0 = db.executed[0]
assert "SET LOCAL statement_timeout = 120000" in str(stmt0)
def test_snapshot_listing_sources_defaults_budget_sec_when_params_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""No params / no budget_sec key → DEFAULT_BUDGET_SEC applied (never unlimited/0)."""
monkeypatch.setattr(snap_mod.runs_mod, "mark_done", lambda *a, **k: None)
monkeypatch.setattr(snap_mod.runs_mod, "mark_failed", lambda *a, **k: None)
db = _FakeDB(rowcounts=[0, 10, 1])
snap_mod.snapshot_listing_sources(db, run_id=1) # type: ignore[arg-type]
stmt0, _params0 = db.executed[0]
expected_ms = int(snap_mod.DEFAULT_BUDGET_SEC * 1000)
assert f"SET LOCAL statement_timeout = {expected_ms}" in str(stmt0)
def test_counter_logic_failure_path_marks_failed(monkeypatch: pytest.MonkeyPatch) -> None:
"""On execute error: rollback + mark_failed + re-raise (no silent swallow)."""
failed: dict[str, Any] = {}

View file

@ -80,12 +80,20 @@ def _kit_matcher() -> MagicMock:
return matcher
def _lot(source: str = "avito", source_id: str = "1", address: str | None = None) -> KitLot:
def _lot(
source: str = "avito",
source_id: str = "1",
address: str | None = None,
lat: float | None = None,
lon: float | None = None,
) -> KitLot:
return KitLot(
source=source,
source_url=f"https://www.{source}.ru/item/{source_id}",
source_id=source_id,
address=address,
lat=lat,
lon=lon,
price_rub=3_000_000,
)
@ -197,6 +205,103 @@ def test_save_listings_reconcile_update_coalesces_city() -> None:
assert params["city"] == "Серов"
# ── Geo-guard: соседний-город-в-развёртке ──────────────────────────────────────
#
# Замер на проде (см. PR): yandex-развёртка city_slug="verkhnyaya_pyshma"
# (radius_m=25000 вокруг anchor'а В.Пышмы, ~15.3км от центра ЕКБ) проставляла
# "Верхняя Пышма" 97% найденного — большинство физически лежит в Екатеринбурге.
# save_listings(..., city_anchor=..., city_radius_km=...) режет city per-lot, если
# у лота ЕСТЬ координаты и они дальше city_radius_km от city_anchor.
_VP_ANCHOR = (56.976, 60.578) # CITY_ANCHORS["verkhnyaya_pyshma"][0][:2]
_VP_RADIUS_KM = 8.0 # get_city_stamp_radius_km("verkhnyaya_pyshma")
_EKB_CENTER = (56.8389, 60.6057) # ~15.34км от _VP_ANCHOR — вне guard-радиуса
def test_save_listings_geo_guard_drops_city_for_lot_outside_radius() -> None:
"""Лот с координатами ЕКБ в развёртке 'Верхняя Пышма' (guard 8км) — city НЕ
проставляется 'Верхняя Пышма' (дистанция ~15.3км > 8км)."""
db = _mock_db_insert_path()
lot = _lot(address="ул. Победы, 30", lat=_EKB_CENTER[0], lon=_EKB_CENTER[1])
with patch("scraper_kit.base.upsert_listing_snapshot", return_value=None):
kit_save_listings(
db,
[lot],
matcher=_kit_matcher(),
region_code=66,
city="Верхняя Пышма",
city_anchor=_VP_ANCHOR,
city_radius_km=_VP_RADIUS_KM,
)
_sql, params = _find_call(db, "INSERT INTO listings (")
assert params["city"] is None, "лот физически в ЕКБ НЕ должен получить 'Верхняя Пышма'"
def test_save_listings_geo_guard_keeps_city_for_lot_inside_radius() -> None:
"""Лот с координатами самой В.Пышмы (anchor, dist=0) — city проставлен как обычно."""
db = _mock_db_insert_path()
lot = _lot(address="ул. Кривоусова, 5", lat=_VP_ANCHOR[0], lon=_VP_ANCHOR[1])
with patch("scraper_kit.base.upsert_listing_snapshot", return_value=None):
kit_save_listings(
db,
[lot],
matcher=_kit_matcher(),
region_code=66,
city="Верхняя Пышма",
city_anchor=_VP_ANCHOR,
city_radius_km=_VP_RADIUS_KM,
)
_sql, params = _find_call(db, "INSERT INTO listings (")
assert params["city"] == "Верхняя Пышма"
def test_save_listings_geo_guard_stamps_city_for_lot_without_coords() -> None:
"""Лот БЕЗ координат (avito — большинство, напр. Серов 142/150) — нечем сверить
против anchor'а, поэтому city ВСЁ РАВНО проставляется (решение #2620): провайдер
уже скоупил SERP/API-запрос на этот город (city_slug/rgid/region_id), а без city
колонка теряет главную ценность именно для адресов без города в тексте."""
db = _mock_db_insert_path()
lot = _lot(address="ул. Ленина, 1", lat=None, lon=None)
with patch("scraper_kit.base.upsert_listing_snapshot", return_value=None):
kit_save_listings(
db,
[lot],
matcher=_kit_matcher(),
region_code=66,
city="Верхняя Пышма",
city_anchor=_VP_ANCHOR,
city_radius_km=_VP_RADIUS_KM,
)
_sql, params = _find_call(db, "INSERT INTO listings (")
assert params["city"] == "Верхняя Пышма"
def test_save_listings_geo_guard_inactive_ekaterinburg_sweep_unaffected() -> None:
"""ЕКБ-развёртка (city_anchor/city_radius_km не переданы, как в run_*_city_sweep
при city_slug=None) guard выключен, city проставляется независимо от координат
лота (даже координаты далёкого Серова не режутся старое поведение сохранено)."""
db = _mock_db_insert_path()
serov_coords = (59.604, 60.578)
lot = _lot(address="ул. Ленина, 1", lat=serov_coords[0], lon=serov_coords[1])
with patch("scraper_kit.base.upsert_listing_snapshot", return_value=None):
kit_save_listings(
db,
[lot],
matcher=_kit_matcher(),
region_code=66,
city="Екатеринбург",
)
_sql, params = _find_call(db, "INSERT INTO listings (")
assert params["city"] == "Екатеринбург"
# ── Migration 196: listings.city column ────────────────────────────────────────
_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"

View file

@ -0,0 +1,158 @@
"""Static guards for migration 197 (issue #2594 шаг 3 — бэкфилл listings.city
из слага города в Avito source_url для накопленных объявлений).
Прод применяет data/sql построчно строго (ON_ERROR_STOP). Полный DB-прогон
требует живой БД; здесь фиксируем структурные инварианты, которые ГАРАНТИРУЮТ
идемпотентность, скоуп (только Avito, только city IS NULL, только 6 наших
городов) и НЕдеструктивность к самим listings-строкам по построению.
"""
from __future__ import annotations
import re
from pathlib import Path
_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
_MIGRATION_197 = _SQL_DIR / "197_backfill_listings_city_from_url.sql"
def _sql() -> str:
return _MIGRATION_197.read_text(encoding="utf-8")
def _executable_sql() -> str:
"""SQL без построчных `--`-комментариев — только исполняемый код."""
lines = []
for raw in _sql().splitlines():
code = raw.split("--", 1)[0]
if code.strip():
lines.append(code)
return "\n".join(lines)
def _flat(text: str) -> str:
return re.sub(r"\s+", " ", text).strip().lower()
def test_migration_197_exists() -> None:
assert _MIGRATION_197.exists(), f"missing migration: {_MIGRATION_197}"
def test_migration_197_is_transactional() -> None:
sql = _sql()
assert "BEGIN;" in sql
assert "COMMIT;" in sql
def test_migration_197_only_avito_city_null() -> None:
"""WHERE ограничен source='avito' AND city IS NULL — не перетирает то, что
уже проставил скрапер (196), не трогает Cian/Domclick/Yandex."""
flat = _flat(_executable_sql())
assert "where source = 'avito'" in flat
assert "and city is null" in flat
def test_migration_197_covers_exactly_six_cities() -> None:
"""CASE и WHERE ... IN покрывают ровно наши шесть городов Свердловской
обл. ни больше (не расползаемся на чужие регионы), ни меньше."""
flat = _flat(_executable_sql())
expected_pairs = {
"'ekaterinburg'": "екатеринбург",
"'nizhniy_tagil'": "нижний тагил",
"'kamensk-uralskiy'": "каменск-уральский",
"'pervouralsk'": "первоуральск",
"'verhnyaya_pyshma'": "верхняя пышма",
"'serov'": "серов",
}
for slug, _city_lower in expected_pairs.items():
assert slug in flat, f"missing avito slug branch: {slug}"
# Ровно 6 веток WHEN в CASE (по числу городов).
assert flat.count(" when ") == len(expected_pairs)
def test_migration_197_kamensk_slug_uses_dash_not_underscore() -> None:
"""Avito отдаёт 'kamensk-uralskiy' (дефис) — НЕ наш внутренний city_slug
'kamensk_uralskiy' (подчёркивание, CITY_LOCATIONS ключ в pipeline.py).
Регресс на подчёркивание означало бы 0 подхваченных строк на проде."""
flat = _flat(_executable_sql())
assert "'kamensk-uralskiy'" in flat
assert "'kamensk_uralskiy'" not in flat
def test_migration_197_pyshma_slug_matches_avito_not_internal_key() -> None:
"""Avito слаг — 'verhnyaya_pyshma' (без 'k'), а не наш внутренний ключ
'verkhnyaya_pyshma' (с 'k', CITY_DISPLAY_NAMES/CITY_LOCATIONS в
pipeline.py). На проде встретился только вариант без 'k' второй сюда
сознательно не добавлен (см. заголовок миграции)."""
flat = _flat(_executable_sql())
assert "'verhnyaya_pyshma'" in flat
assert "'verkhnyaya_pyshma'" not in flat
def test_migration_197_city_names_match_pipeline_display_names() -> None:
"""Человекочитаемые названия городов побайтно совпадают с
CITY_DISPLAY_NAMES / EKATERINBURG_CITY_NAME в scraper_kit.orchestration
.pipeline иначе один и тот же город расщепится на две разные метки
(старые backfilled-строки vs новые, проставленные скрапером)."""
pipeline_path = (
Path(__file__).resolve().parents[2]
/ "packages"
/ "scraper-kit"
/ "src"
/ "scraper_kit"
/ "orchestration"
/ "pipeline.py"
)
pipeline_src = pipeline_path.read_text(encoding="utf-8")
sql = _sql()
expected_names = [
"Екатеринбург",
"Нижний Тагил",
"Каменск-Уральский",
"Первоуральск",
"Верхняя Пышма",
"Серов",
]
for name in expected_names:
assert name in sql, f"missing display name in migration: {name}"
assert name in pipeline_src, (
f"display name {name!r} in migration 197 не найден в pipeline.py "
"CITY_DISPLAY_NAMES/EKATERINBURG_CITY_NAME — риск расщепления "
"одного города на две метки"
)
def test_migration_197_no_ddl() -> None:
"""Только UPDATE данных — колонка listings.city уже существует (196),
никакого ALTER/CREATE/DROP здесь быть не должно."""
flat = _flat(_executable_sql())
assert "alter table" not in flat
assert "create table" not in flat
assert "drop table" not in flat
assert flat.count("update listings") == 1
def test_migration_197_no_destructive_ddl() -> None:
"""Миграция не должна содержать DROP TABLE / TRUNCATE / DELETE."""
flat = _flat(_executable_sql())
assert "drop table" not in flat
assert "truncate" not in flat
assert "delete from" not in flat
def test_migration_197_does_not_touch_other_sources_or_region_code() -> None:
"""Явно вне scope (#2601/#2604): cian/yandex/domclick и region_code не
упоминаются в исполняемом SQL этой миграции."""
flat = _flat(_executable_sql())
assert "cian" not in flat
assert "yandex" not in flat
assert "domclick" not in flat
assert "region_code" not in flat
def test_migration_197_no_psycopg_trap() -> None:
"""Никаких :param::type — psycopg v3 требует CAST(... AS type) (не
применимо в чистом .sql без bind params, но проверяем на регресс
copy-paste из Python-кода)."""
assert not re.search(r":\w+::", _sql())

View file

@ -0,0 +1,192 @@
"""Static guards for migration 200 (issue #2604 п.2 — убрать ложный
region_code=66 у объявлений Avito из чужих городов).
Прод применяет data/sql построчно строго (ON_ERROR_STOP). Полный DB-прогон
требует живой БД; здесь фиксируем структурные инварианты, которые ГАРАНТИРУЮТ
идемпотентность, скоуп (только Avito, только чужие города, не наши шесть) и
НЕдеструктивность к самим listings-строкам по построению.
"""
from __future__ import annotations
import re
from pathlib import Path
_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
_MIGRATION_200 = _SQL_DIR / "200_region_code_foreign_cities.sql"
_OUR_SIX_SLUGS = (
"ekaterinburg",
"nizhniy_tagil",
"kamensk-uralskiy",
"pervouralsk",
"verhnyaya_pyshma",
"serov",
)
def _sql() -> str:
return _MIGRATION_200.read_text(encoding="utf-8")
def _executable_sql() -> str:
"""SQL без построчных `--`-комментариев — только исполняемый код."""
lines = []
for raw in _sql().splitlines():
code = raw.split("--", 1)[0]
if code.strip():
lines.append(code)
return "\n".join(lines)
def _flat(text: str) -> str:
return re.sub(r"\s+", " ", text).strip().lower()
def test_migration_200_exists() -> None:
assert _MIGRATION_200.exists(), f"missing migration: {_MIGRATION_200}"
def test_migration_200_is_transactional() -> None:
sql = _sql()
assert "BEGIN;" in sql
assert "COMMIT;" in sql
def test_migration_200_only_avito() -> None:
"""WHERE ограничен source='avito' — cian/domklik/yandex/n1 не трогаются
(у них region_code=66 в основном верен; 27 подозрительных строк там
сознательно вне scope этой миграции, ненадёжный сигнал)."""
flat = _flat(_executable_sql())
assert "where source = 'avito'" in flat
def test_migration_200_idempotent_guard_present() -> None:
"""`AND region_code IS NOT NULL` — повторный прогон находит 0 строк
(уже NULL после первого прогона), UPDATE становится no-op."""
flat = _flat(_executable_sql())
assert "and region_code is not null" in flat
def test_migration_200_sets_null_not_a_guessed_region() -> None:
"""SET region_code = NULL — честное «неизвестно», не подставной код
другого региона (мы не выводим регион из текста адреса)."""
flat = _flat(_executable_sql())
assert "set region_code = null" in flat
def test_migration_200_excludes_exactly_our_six_cities() -> None:
"""WHERE ... NOT IN покрывает ровно наши шесть слагов — не больше (не
расширяем защищённый список произвольно), не меньше (иначе один из наших
городов ложно попадёт под обнуление)."""
flat = _flat(_executable_sql())
for slug in _OUR_SIX_SLUGS:
assert f"'{slug}'" in flat, f"missing protected avito slug: {slug}"
def test_migration_200_kamensk_slug_uses_dash_not_underscore() -> None:
"""Avito отдаёт 'kamensk-uralskiy' (дефис) — НЕ наш внутренний city_slug
'kamensk_uralskiy' (подчёркивание, CITY_LOCATIONS ключ в pipeline.py).
Регресс на подчёркивание означал бы, что реальный Каменск-Уральский
ложно обнуляется этой миграцией."""
flat = _flat(_executable_sql())
assert "'kamensk-uralskiy'" in flat
assert "'kamensk_uralskiy'" not in flat
def test_migration_200_pyshma_slug_matches_avito_not_internal_key() -> None:
"""Avito слаг — 'verhnyaya_pyshma' (без 'k'), а не наш внутренний ключ
'verkhnyaya_pyshma' (с 'k', CITY_LOCATIONS в pipeline.py)."""
flat = _flat(_executable_sql())
assert "'verhnyaya_pyshma'" in flat
assert "'verkhnyaya_pyshma'" not in flat
def test_migration_200_slugs_match_pipeline_source_of_truth() -> None:
"""Шесть защищённых слагов побайтно совпадают с CityLocation(...)
.avito_slug в scraper_kit.orchestration.pipeline (CITY_LOCATIONS +
'ekaterinburg' EKB-дефолт) иначе список разойдётся с источником
истины и миграция начнёт либо обнулять свои города, либо пропускать
чужие."""
pipeline_path = (
Path(__file__).resolve().parents[2]
/ "packages"
/ "scraper-kit"
/ "src"
/ "scraper_kit"
/ "orchestration"
/ "pipeline.py"
)
pipeline_src = pipeline_path.read_text(encoding="utf-8")
sql = _sql()
for slug in _OUR_SIX_SLUGS:
assert slug in sql, f"missing avito slug in migration: {slug}"
# 'ekaterinburg' — EKB-дефолт, в pipeline.py не встречается как
# avito_slug строкой (нет явного CityLocation для ЕКБ, city_slug=None
# -> _avito_slug fallback на city_slug), остальные пять — явные
# CityLocation(...).avito_slug значения в CITY_LOCATIONS.
if slug != "ekaterinburg":
assert slug in pipeline_src, (
f"avito_slug {slug!r} в миграции 200 не найден в pipeline.py "
"CITY_LOCATIONS — риск расхождения защищённого списка с "
"источником истины"
)
def test_migration_200_no_substring_collision_between_slugs() -> None:
"""Ни один из шести слагов не является подстрокой другого — точное
сравнение сегмента пути через NOT IN (...) безопасно, LIKE '%slug%' не
нужен и не используется."""
for a in _OUR_SIX_SLUGS:
for b in _OUR_SIX_SLUGS:
if a == b:
continue
assert a not in b, f"{a!r} is a substring of {b!r} — collision risk"
flat = _flat(_executable_sql())
assert "like '%" not in flat
def test_migration_200_extracts_exact_path_segment() -> None:
"""Слаг извлекается точным сегментом пути через substring(...) regex
(тот же идиом, что 197), не LIKE-паттерном."""
flat = _flat(_executable_sql())
assert "substring(source_url from 'avito" in flat
def test_migration_200_no_ddl() -> None:
"""Только UPDATE данных — никакого ALTER/CREATE/DROP."""
flat = _flat(_executable_sql())
assert "alter table" not in flat
assert "create table" not in flat
assert "drop table" not in flat
assert flat.count("update listings") == 1
def test_migration_200_no_destructive_ddl() -> None:
"""Миграция не должна содержать DROP TABLE / TRUNCATE / DELETE — ничего
не удаляется, ничего не деактивируется."""
flat = _flat(_executable_sql())
assert "drop table" not in flat
assert "truncate" not in flat
assert "delete from" not in flat
assert "is_active" not in flat
def test_migration_200_does_not_touch_other_sources_or_city() -> None:
"""Явно вне scope: cian/domklik/yandex/n1 и listings.city не
упоминаются в исполняемом SQL этой миграции."""
flat = _flat(_executable_sql())
assert "cian" not in flat
assert "domklik" not in flat
assert "yandex" not in flat
assert " n1 " not in flat
assert "set city" not in flat
def test_migration_200_no_psycopg_trap() -> None:
"""Никаких :param::type — psycopg v3 требует CAST(... AS type) (не
применимо в чистом .sql без bind params, но проверяем на регресс
copy-paste из Python-кода)."""
assert not re.search(r":\w+::", _sql())

View file

@ -0,0 +1,106 @@
"""Static guards for migration 201 (issue #2613 — выпилить мёртвые узлы
mobileproxy из scrape_proxies вместе с чужим API-ключом в rotate_url).
Прод применяет data/sql построчно строго (ON_ERROR_STOP). Полный DB-прогон
требует живой БД; здесь фиксируем структурные инварианты, которые ГАРАНТИРУЮТ
идемпотентность, domain-based scope (НЕ по id они разъезжаются между
средами) и то, что ASocks-строки миграция не задевает.
"""
from __future__ import annotations
import re
from pathlib import Path
_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
_MIGRATION_201 = _SQL_DIR / "201_purge_dead_mobileproxy_proxies.sql"
def _sql() -> str:
return _MIGRATION_201.read_text(encoding="utf-8")
def _executable_sql() -> str:
"""SQL без построчных `--`-комментариев — только исполняемый код."""
lines = []
for raw in _sql().splitlines():
code = raw.split("--", 1)[0]
if code.strip():
lines.append(code)
return "\n".join(lines)
def _flat(text: str) -> str:
return re.sub(r"\s+", " ", text).strip().lower()
def test_migration_201_exists() -> None:
assert _MIGRATION_201.exists(), f"missing migration: {_MIGRATION_201}"
def test_migration_201_is_transactional() -> None:
sql = _sql()
assert "BEGIN;" in sql
assert "COMMIT;" in sql
def test_migration_201_deletes_by_domain_not_id() -> None:
"""Условие удаления — по домену mobileproxy.space в url, НЕ по id (id
разъезжается между средами, тот же класс проблемы решён в 199 через
host:port-matching)."""
flat = _flat(_executable_sql())
assert "delete from scrape_proxies" in flat
assert "where url like '%mobileproxy.space%'" in flat
assert (
re.search(r"where\s+id\s*(=|in)", flat) is None
), "миграция не должна фильтровать по id — id разъезжается между средами"
def test_migration_201_is_idempotent_by_construction() -> None:
"""DELETE ... WHERE без вспомогательного флага — повторный прогон
находит 0 строк (уже удалены в первом прогоне), сам DELETE идемпотентен
по построению, отдельного guard-условия не требуется."""
flat = _flat(_executable_sql())
assert flat.count("delete from") == 1
assert "delete from scrape_proxies" in flat
def test_migration_201_does_not_touch_asocks_rows() -> None:
"""ASocks-строки (id 1, 9, 10, 11) адресуются IP-хостами
(212.8.249.134 / 190.2.145.131 / 175.110.115.153 / 109.236.82.42) без
mobileproxy.space в url WHERE их не задевает. Явно запрещаем regression
в сторону id-based или asocks-упоминающего условия."""
flat = _flat(_executable_sql())
assert "asocks" not in flat
for asocks_ip in (
"212.8.249.134",
"190.2.145.131",
"175.110.115.153",
"109.236.82.42",
):
assert asocks_ip not in flat
def test_migration_201_no_ddl() -> None:
"""Только DELETE данных — никакого ALTER/CREATE/DROP TABLE/TRUNCATE."""
flat = _flat(_executable_sql())
assert "alter table" not in flat
assert "create table" not in flat
assert "drop table" not in flat
assert "truncate" not in flat
def test_migration_201_no_secret_literal_in_file() -> None:
"""Файл миграции не должен содержать сам секрет (query-параметр
proxy_key mobileproxy.space) только описание проблемы текстом."""
sql = _sql()
assert "proxy_key=" not in sql or "proxy_key=..." in sql or "<секрет>" in sql
# Явный запрет на длинные alnum-токены рядом с 'proxy_key=' (сам секрет).
assert not re.search(r"proxy_key=[A-Za-z0-9_-]{10,}", sql)
def test_migration_201_no_psycopg_trap() -> None:
"""Никаких :param::type — psycopg v3 требует CAST(... AS type) (не
применимо в чистом .sql без bind params, но проверяем на регресс
copy-paste из Python-кода)."""
assert not re.search(r":\w+::", _sql())

View file

@ -0,0 +1,136 @@
"""Static guards for migration 204 (включить сбор вторички Циана по 4 областным
city-sweep'ам — Свердловская обл., см. миграцию 179).
Прод применяет data/sql построчно строго (ON_ERROR_STOP). Полный DB-прогон требует
живой БД; здесь фиксируем структурные инварианты: транзакционность, отсутствие DDL,
отсутствие psycopg CAST-ловушки, jsonb-мердж (не перезапись), ровно 4 таргетных
source'а — и, главное, ДВА regression-guard'а:
1. 'cian_city_sweep' (ЕКБ, без суффикса города) НЕ фигурирует для него текущий
дефолт newbuilding_only=True в коде корректен (вторичку ЕКБ авторитетно собирает
run_cian_full_load; включение дало бы дублирующую нагрузку на источник).
2. 'cian_city_sweep_verkhnyaya_pyshma' НЕ фигурирует geo-проверка (ST_DWithin от
центра ЕКБ) показала 23% (5 из 22) загрязнение городской метки cian-строк В.Пышмы
екатеринбургскими объявлениями; listings.city money-critical (читает
asking_to_sold_ratio.py). Включат отдельной миграцией после починки разметки.
"""
from __future__ import annotations
import re
from pathlib import Path
_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
_MIGRATION_204 = _SQL_DIR / "204_cian_oblast_sweeps_secondary.sql"
_OBLAST_SOURCES = (
"cian_city_sweep_nizhniy_tagil",
"cian_city_sweep_kamensk_uralskiy",
"cian_city_sweep_pervouralsk",
"cian_city_sweep_serov",
)
def _sql() -> str:
return _MIGRATION_204.read_text(encoding="utf-8")
def _executable_sql() -> str:
"""SQL без построчных `--`-комментариев — только исполняемый код."""
lines = []
for raw in _sql().splitlines():
code = raw.split("--", 1)[0]
if code.strip():
lines.append(code)
return "\n".join(lines)
def _flat(text: str) -> str:
return re.sub(r"\s+", " ", text).strip().lower()
def test_migration_204_exists() -> None:
assert _MIGRATION_204.exists(), f"missing migration: {_MIGRATION_204}"
def test_migration_204_is_transactional() -> None:
sql = _sql()
assert "BEGIN;" in sql
assert "COMMIT;" in sql
def test_migration_204_no_ddl() -> None:
"""Только UPDATE данных (default_params) — никакого ALTER/CREATE/DROP TABLE/TRUNCATE."""
flat = _flat(_executable_sql())
assert "alter table" not in flat
assert "create table" not in flat
assert "drop table" not in flat
assert "truncate" not in flat
def test_migration_204_updates_default_params_via_jsonb_merge() -> None:
"""COALESCE(default_params, '{}'::jsonb) || '{...}'::jsonb — мердж, НЕ перезапись
(соседние ключи city/radius_m/detail_top_n/enrich_houses/pages_per_anchor/
request_delay_sec должны сохраниться)."""
flat = _flat(_executable_sql())
assert "update scrape_schedules" in flat
assert "set default_params = coalesce(default_params, '{}'::jsonb)" in flat
assert "|| '{\"newbuilding_only\": false}'::jsonb" in flat
# Regression guard: перезапись без COALESCE/|| стёрла бы соседние ключи.
assert "set default_params = '{" not in flat
def test_migration_204_targets_exactly_four_oblast_sources() -> None:
flat = _flat(_executable_sql())
for source in _OBLAST_SOURCES:
assert f"'{source}'" in flat, f"missing target source: {source}"
# Ровно 4 закавыченных source-литерала в WHERE ... IN (...) — не больше, не меньше.
quoted = re.findall(r"'(cian_city_sweep_[a-z_]+)'", flat)
assert sorted(set(quoted)) == sorted(_OBLAST_SOURCES)
assert len(quoted) == 4
def test_migration_204_does_not_touch_ekaterinburg_schedule() -> None:
"""Regression-guard против «включили всем»: 'cian_city_sweep' (ЕКБ, БЕЗ суффикса
города) НЕ должен фигурировать в списке таргетов миграции. Для него текущий
дефолт newbuilding_only=True (код) корректен вторичку ЕКБ авторитетно собирает
run_cian_full_load; включение дало бы дублирующий сбор той же вторички."""
sql = _sql()
# Каждое вхождение 'cian_city_sweep' в исполняемом SQL обязано иметь city-суффикс —
# ищем токен 'cian_city_sweep' НЕ followed immediately by "_<city>" внутри кавычек.
for match in re.finditer(r"'cian_city_sweep([a-z_]*)'", sql):
suffix = match.group(1)
assert suffix.startswith("_"), (
"нашли bare 'cian_city_sweep' (ЕКБ-расписание) среди таргетов миграции — "
"это регресс: ЕКБ-вторичку собирает run_cian_full_load, включать её здесь нельзя"
)
assert suffix[1:] in {
"nizhniy_tagil",
"kamensk_uralskiy",
"pervouralsk",
"serov",
}
def test_migration_204_does_not_touch_verkhnyaya_pyshma() -> None:
"""Regression-guard против «Пышму забыли обратно включить»: geo-проверка
(ST_DWithin от центра ЕКБ) показала 23% (5 из 22) cian-строк с меткой
city="Верхняя Пышма" физически лежат в 15 км от центра Екатеринбурга загрязнённая
городская разметка. listings.city money-critical (asking_to_sold_ratio.py читает
его для city-скоупа ASKING vs SOLD стороны). Включение вторички умножило бы это
загрязнение (22 несколько сотен строк). Пышму включат отдельной миграцией
ПОСЛЕ починки городской разметки sweep'а — сейчас её НЕ должно быть в WHERE."""
flat = _flat(_executable_sql())
assert "cian_city_sweep_verkhnyaya_pyshma" not in flat
def test_migration_204_no_psycopg_cast_trap() -> None:
"""Никаких :param::type — psycopg v3 требует CAST(... AS type) (не применимо
в чистом .sql без bind params, но проверяем на регресс copy-paste из Python)."""
assert not re.search(r":\w+::", _sql())
def test_migration_204_idempotent_by_construction() -> None:
"""UPDATE ... SET x = merge(x, const) — повторный прогон ставит то же значение,
отдельного guard-условия (IF NOT EXISTS/ON CONFLICT) не требуется."""
flat = _flat(_executable_sql())
assert flat.count("update scrape_schedules") == 1

View file

@ -0,0 +1,159 @@
"""Static guards for migration 205 (city-scope в street_sales_vs_listings(), #2583 H4).
Прод применяет data/sql построчно строго (ON_ERROR_STOP). Полный DB-прогон требует
живой БД; здесь фиксируем структурные инварианты: транзакционность, идемпотентность
DROP FUNCTION (старая 6-арг сигнатура), наличие НОВОЙ 7-арг сигнатуры с
p_target_city DEFAULT NULL, city-предикаты на ОБЕИХ сторонах JOIN (deals строго,
listings терпимо к NULL зеркало asking_to_sold_ratio.py #2583 H2), отсутствие
psycopg CAST-ловушки, отсутствие DROP TABLE/TRUNCATE.
"""
from __future__ import annotations
import re
from pathlib import Path
_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
_MIGRATION_205 = _SQL_DIR / "205_sales_vs_listings_city_filter.sql"
_OLD_SIGNATURE = "street_sales_vs_listings(text, numeric, integer, integer, numeric, integer)"
_NEW_SIGNATURE_PARAMS = (
"text",
"numeric",
"integer",
"integer",
"numeric",
"integer",
"text",
)
def _sql() -> str:
return _MIGRATION_205.read_text(encoding="utf-8")
def _executable_sql() -> str:
"""SQL без построчных `--`-комментариев — только исполняемый код."""
lines = []
for raw in _sql().splitlines():
code = raw.split("--", 1)[0]
if code.strip():
lines.append(code)
return "\n".join(lines)
def _flat(text: str) -> str:
return re.sub(r"\s+", " ", text).strip().lower()
def test_migration_205_exists() -> None:
assert _MIGRATION_205.exists(), f"missing migration: {_MIGRATION_205}"
def test_migration_205_is_transactional() -> None:
sql = _sql()
assert "BEGIN;" in sql
assert "COMMIT;" in sql
def test_migration_205_no_destructive_ddl() -> None:
"""Только DROP FUNCTION (сигнатура меняется) + CREATE OR REPLACE FUNCTION —
никакого DROP/ALTER TABLE, TRUNCATE (таблицы deals/listings не трогаются)."""
flat = _flat(_executable_sql())
assert "drop table" not in flat
assert "alter table" not in flat
assert "truncate" not in flat
def test_migration_205_drops_old_signature_before_replace() -> None:
"""CREATE OR REPLACE FUNCTION с добавленным параметром создаёт НОВУЮ
перегрузку (Postgres матчит по списку типов аргументов) старую 6-арг
сигнатуру нужно дропнуть явно, иначе останутся два оверлоада одной функции.
DROP FUNCTION IF EXISTS идемпотентен: на повторном прогоне (функция уже
7-арг) no-op, ошибки нет."""
flat = _flat(_executable_sql())
assert f"drop function if exists {_OLD_SIGNATURE.lower()}" in flat
def test_migration_205_creates_new_signature_with_target_city_default_null() -> None:
"""Новый параметр p_target_city — СЕДЬМОЙ, DEFAULT NULL (обратная
совместимость с любым caller'ом на 6 позиционных аргументах)."""
sql = _sql()
assert "CREATE OR REPLACE FUNCTION street_sales_vs_listings(" in sql
assert "p_target_city text DEFAULT NULL" in sql
# Порядок параметров ВНУТРИ сигнатуры (не в header-комментариях, которые
# упоминают p_target_city раньше по тексту файла): p_target_city должен
# идти ПОСЛЕ p_period_months (седьмым, не разрывая позиционную сигнатуру).
sig_start = sql.index("CREATE OR REPLACE FUNCTION street_sales_vs_listings(")
sig_body = sql[sig_start:]
period_pos = sig_body.index("p_period_months")
city_pos = sig_body.index("p_target_city")
assert period_pos < city_pos
def test_migration_205_comment_on_function_matches_new_signature() -> None:
"""COMMENT ON FUNCTION должен ссылаться на НОВУЮ (7-арг) сигнатуру —
иначе COMMENT молча создаст comment на несуществующий оверлоад / упадёт."""
flat = _flat(_executable_sql())
new_sig = "street_sales_vs_listings(" + ", ".join(_NEW_SIGNATURE_PARAMS) + ")"
assert f"comment on function {new_sig.lower()}" in flat
def test_migration_205_deals_side_city_predicate_strict_with_null_fallback() -> None:
"""deals.city заполнена на 100% (прод-замер) → строгое равенство при
p_target_city заданном; p_target_city IS NULL (город вне словаря, H1)
фильтр не применяется тот же fallback, что и /street-deals."""
flat = _flat(_executable_sql())
assert "(p_target_city is null or lower(d.city) = lower(p_target_city))" in flat
def test_migration_205_listings_side_city_predicate_tolerant_to_null() -> None:
"""listings.city заполнена частично (avito ~63%, yandex ~19%, cian ~4.6%,
domklik ~0.6%, n1 ~0%) NULL считается "своим" (симметрично
asking_to_sold_ratio.py #2583 H2), иначе строгий фильтр выбросил бы
почти все listings кроме avito."""
flat = _flat(_executable_sql())
assert (
"(p_target_city is null or l.city is null or lower(l.city) = lower(p_target_city))" in flat
)
def test_migration_205_no_psycopg_cast_trap() -> None:
"""Никаких :param::type — psycopg v3 требует CAST(... AS type) (не применимо
в чистом .sql без bind params здесь, но проверяем на регресс copy-paste)."""
assert not re.search(r":\w+::", _sql())
def test_migration_205_return_table_shape_unchanged() -> None:
"""RETURNS TABLE(...) columns остаются теми же, что в 067 — endpoint
(trade_in.py) читает их по имени через .mappings(), любое переименование/
удаление сломало бы response mapping без явного сигнала."""
sql = _sql()
for col in (
"deal_id",
"deal_date",
"deal_price_rub",
"deal_price_per_m2",
"deal_area_m2",
"deal_rooms",
"deal_floor",
"deal_address",
"listing_id",
"listing_source",
"listing_source_url",
"listing_date",
"listing_price_rub",
"listing_price_per_m2",
"listing_area_m2",
"days_listing_to_deal",
"discount_pct",
):
assert col in sql, f"missing column in RETURNS TABLE: {col}"
def test_migration_205_idempotent_by_construction() -> None:
"""DROP FUNCTION IF EXISTS (старая сигнатура) + CREATE OR REPLACE (новая) —
оба идемпотентны по конструкции, отдельного guard-условия не требуется."""
flat = _flat(_executable_sql())
assert flat.count("drop function if exists") == 1
assert flat.count("create or replace function street_sales_vs_listings") == 1

View file

@ -335,6 +335,56 @@ def test_sales_vs_listings_passes_proper_params(trade_in_app: FastAPI) -> None:
assert params["period_months"] == 12
# ── Test: city-scope propagation (#2583 H4) ───────────────────────────────────
def test_sales_vs_listings_passes_resolved_target_city(trade_in_app: FastAPI) -> None:
"""#2583 H4: адрес с распознаваемым городом (словарь SVERDLOVSK_OBLAST_CITIES)
должен прокидывать target_city в street_sales_vs_listings() иначе пары
склеиваются с другими городами (зеркало /street-deals #C1)."""
db_mock = _make_db_mock([])
_override_db(trade_in_app, db_mock)
client = TestClient(trade_in_app)
resp = client.get(
"/api/v1/trade-in/sales-vs-listings",
params={
"address": "Нижний Тагил, ул. Ленина, 5",
"area_m2": 44.3,
"rooms": 2,
},
)
assert resp.status_code == 200
assert db_mock.execute.called
args, kwargs = db_mock.execute.call_args
params = args[1] if len(args) > 1 else kwargs.get("parameters", {})
assert params["target_city"] == "нижний тагил"
def test_sales_vs_listings_target_city_none_when_city_unresolved(
trade_in_app: FastAPI,
) -> None:
"""Адрес вне словаря SVERDLOVSK_OBLAST_CITIES (известная H1) → target_city=None,
TVF-сторона не фильтрует по городу тот же fallback, что и /street-deals."""
db_mock = _make_db_mock([])
_override_db(trade_in_app, db_mock)
client = TestClient(trade_in_app)
resp = client.get(
"/api/v1/trade-in/sales-vs-listings",
params={
"address": "Верхняя Синячиха, ул. Ленина, 5",
"area_m2": 44.3,
"rooms": 2,
},
)
assert resp.status_code == 200
assert db_mock.execute.called
args, kwargs = db_mock.execute.call_args
params = args[1] if len(args) > 1 else kwargs.get("parameters", {})
assert params["target_city"] is None
# ── Test: response shape (Pydantic validation) ───────────────────────────────

View file

@ -357,3 +357,51 @@ async def test_city_defaults_to_ekaterinburg_when_no_city_slug() -> None:
await _drive(scenario, capture=capture)
save_mock = capture["save_mock"]
assert save_mock.call_args.kwargs["city"] == "Екатеринбург"
# ── Гео-guard: соседний-город-в-развёртке — save_listings получает anchor+radius ──
#
# Замер на проде (см. PR): city_slug="verkhnyaya_pyshma" развёртка стамповала
# 'Верхняя Пышма' на лоты, физически лежащие в ЕКБ. save_listings режет city
# per-lot, если получит city_anchor/city_radius_km — оркестратор обязан их передать
# для oblast-города и НЕ передавать (None/None) для ЕКБ (нет большего соседа).
@pytest.mark.asyncio
async def test_avito_city_sweep_passes_geo_guard_anchor_for_oblast_city() -> None:
"""city_slug='verkhnyaya_pyshma' → save_listings получает city_anchor/city_radius_km
из pipeline.get_city_anchor_point/get_city_stamp_radius_km (НЕ None/None)."""
from scraper_kit.orchestration.pipeline import (
get_city_anchor_point,
get_city_stamp_radius_km,
)
scenario = _Scenario(
anchors=[(56.976, 60.578, "В.Пышма центр")],
per_anchor=[("lots", 3, 3, 0)],
city_slug="verkhnyaya_pyshma",
)
capture: dict[str, Any] = {}
await _drive(scenario, capture=capture)
save_mock = capture["save_mock"]
assert save_mock.call_args.kwargs["city_anchor"] == get_city_anchor_point("verkhnyaya_pyshma")
assert save_mock.call_args.kwargs["city_radius_km"] == get_city_stamp_radius_km(
"verkhnyaya_pyshma"
)
@pytest.mark.asyncio
async def test_avito_city_sweep_no_geo_guard_anchor_for_ekaterinburg() -> None:
"""city_slug=None (ЕКБ) → save_listings получает city_anchor=None/city_radius_km=None
guard остаётся выключенным (нет города крупнее ЕКБ, ЕКБ-развёртка не должна
ломаться геопроверкой)."""
scenario = _Scenario(
anchors=[(56.84, 60.60, "A1")],
per_anchor=[("lots", 3, 3, 0)],
city_slug=None,
)
capture: dict[str, Any] = {}
await _drive(scenario, capture=capture)
save_mock = capture["save_mock"]
assert save_mock.call_args.kwargs["city_anchor"] is None
assert save_mock.call_args.kwargs["city_radius_km"] is None

View file

@ -462,3 +462,68 @@ async def test_full_load_stamps_ekaterinburg(source: str) -> None:
assert save_mock.call_count > 0
for call in save_mock.call_args_list:
assert call.kwargs["city"] == "Екатеринбург"
# ── Гео-guard: соседний-город-в-развёртке — save_listings получает anchor+radius ──
#
# Замер на проде (см. PR): oblast city-sweep (yandex/cian) стамповал город-цель на
# лоты, физически лежащие в куда более крупном ЕКБ (yandex radius_m=25000 вокруг
# anchor'а В.Пышмы, ~15.3км от центра ЕКБ — захватывает почти весь город). Оркестратор
# обязан передать city_anchor/city_radius_km для oblast-города и НЕ передавать
# (None/None) для ЕКБ (нет большего соседа — guard там не нужен).
@pytest.mark.asyncio
async def test_yandex_city_sweep_passes_geo_guard_anchor_for_oblast_city() -> None:
"""city_slug='verkhnyaya_pyshma' → save_listings получает city_anchor/city_radius_km
из pipeline.get_city_anchor_point/get_city_stamp_radius_km."""
from scraper_kit.orchestration.pipeline import (
get_city_anchor_point,
get_city_stamp_radius_km,
)
capture: dict[str, Any] = {}
await _drive_yandex_city(city_slug="verkhnyaya_pyshma", capture=capture)
save_mock = capture["save_mock"]
call = save_mock.call_args_list[-1]
assert call.kwargs["city_anchor"] == get_city_anchor_point("verkhnyaya_pyshma")
assert call.kwargs["city_radius_km"] == get_city_stamp_radius_km("verkhnyaya_pyshma")
@pytest.mark.asyncio
async def test_yandex_city_sweep_no_geo_guard_anchor_for_ekaterinburg() -> None:
"""city_slug=None (ЕКБ) → save_listings получает city_anchor=None/city_radius_km=None —
ЕКБ-развёртка не ломается геопроверкой (нет города крупнее ЕКБ в регионе)."""
capture: dict[str, Any] = {}
await _drive_yandex_city(city_slug=None, capture=capture)
save_mock = capture["save_mock"]
call = save_mock.call_args_list[-1]
assert call.kwargs["city_anchor"] is None
assert call.kwargs["city_radius_km"] is None
@pytest.mark.asyncio
async def test_cian_city_sweep_passes_geo_guard_anchor_for_oblast_city() -> None:
"""city_slug='verkhnyaya_pyshma' → save_listings получает city_anchor/city_radius_km."""
from scraper_kit.orchestration.pipeline import (
get_city_anchor_point,
get_city_stamp_radius_km,
)
capture: dict[str, Any] = {}
await _drive_cian_city(city_slug="verkhnyaya_pyshma", capture=capture)
save_mock = capture["save_mock"]
assert save_mock.call_args.kwargs["city_anchor"] == get_city_anchor_point("verkhnyaya_pyshma")
assert save_mock.call_args.kwargs["city_radius_km"] == get_city_stamp_radius_km(
"verkhnyaya_pyshma"
)
@pytest.mark.asyncio
async def test_cian_city_sweep_no_geo_guard_anchor_for_ekaterinburg() -> None:
"""city_slug=None (ЕКБ) → save_listings получает city_anchor=None/city_radius_km=None."""
capture: dict[str, Any] = {}
await _drive_cian_city(city_slug=None, capture=capture)
save_mock = capture["save_mock"]
assert save_mock.call_args.kwargs["city_anchor"] is None
assert save_mock.call_args.kwargs["city_radius_km"] is None

View file

@ -2,19 +2,44 @@
Same pattern as `tests/test_auth_api.py`: real `rbac_guard` + real `auth.router` /
`team.router` wired into an isolated FastAPI test app, with an in-memory fake DB
(`_Store`/`_FakeDB`) dispatching on SQL text standing in for `tradein_users` /
`tradein_sessions` / `account_quota_overrides` / `account_estimate_usage` /
`user_events` / `trade_in_estimates`.
(`_Store`/`_FakeDB`) dispatching on SQL text standing in for реестра людей /
`account_quota_overrides` / `account_estimate_usage` / `user_events` /
`trade_in_estimates`.
`app.core.rbac.SessionLocal` (middleware, no FastAPI DI) and `app.core.db.get_db`
(auth.router / team.router `Depends(get_db)`) both point at the SAME `_Store`
instance per test a session created via POST /login is immediately visible to
rbac_guard's own DB round trip AND to `current_team_actor`.
Сессия РЕЕСТРА подменяется на самом низком уровне (`identity_store.SessionLocal`
+ `auth_db.auth_session`, см. `tests.support.identity_modes.patch_identity_sessions`),
а `app.core.db.get_db` через `app.dependency_overrides`. Поэтому и
`identity_session()` (rbac_guard middleware, FastAPI-DI там нет), и
`Depends(get_identity_db)` (`current_team_actor`, все team-роуты) выполняются
НАСТОЯЩИЕ, вместе со своим ветвлением по `settings.identity_store`. Все они
смотрят в ОДИН `_Store` на тест сессия из POST /login сразу видна и
rbac_guard'у, и `current_team_actor`.
ДВЕ СЕССИИ. В дефолтном режиме `get_identity_db` отдаёт ТОТ ЖЕ объект, что
`get_db` (одна БД, одна транзакция сегодняшний прод). В режиме `auth` это
физически разные сессии, и `team.py` коммитит их отдельно (`if db is not
identity_db`). Здесь это воспроизводится честно: в режиме `auth` реестр и
продуктовые таблицы получают РАЗНЫЕ `_FakeDB` (общий `_Store` как общий
«кластер», но разные соединения).
ЛОВУШКА FAKE-DB. `_FakeDB` диспатчит по ТЕКСТУ SQL, а эпик «единый вход»
переименовывает таблицы (`tradein_users`/`tradein_sessions` `users`/`sessions`)
и меняет тип колонки состояния доступа (`is_active boolean` `access_state
text`). Литерал «tradein_users» в диспатчере означал бы, что при
`IDENTITY_STORE=auth` ветка молча перестаёт матчиться, fake отдаёт пустоту, а
тест остаётся ЗЕЛЁНЫМ на сломанном коде. Поэтому имена берутся из `sql_names()`
(= `identity_schema()`, тот же словарь, что у продакшн-кода), а непонятый SQL
падает `AssertionError`, а не возвращает пустой результат.
Значение состояния доступа fake хранит СЫРЫМ (то, что реально лежало бы в
колонке) и НЕ прогоняет через `identity_store.access_state_param()` иначе
инверсия этой функции прошла бы round-trip через fake незамеченной.
"""
from __future__ import annotations
import os
import re
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from typing import Any
@ -33,9 +58,19 @@ from app.core import config
from app.core.db import get_db
from app.core.password import hash_password
from app.core.rbac import rbac_guard
from app.services.identity_store import AccessState
from tests.support.identity_modes import (
assert_insert_writes_access_state,
assert_reads_access_state,
assert_update_writes_access_state,
column_value,
patch_identity_sessions,
sql_names,
use_identity_mode,
)
# ---------------------------------------------------------------------------
# Fake DB backing tradein_users / tradein_sessions / quota / user_events
# Fake DB backing реестр людей / sessions / quota / user_events
# ---------------------------------------------------------------------------
@ -47,6 +82,8 @@ class _Store:
self.usage: dict[tuple[str, str], int] = {}
self.estimates: dict[str, dict[str, Any]] = {} # estimate_id -> result fields
self.events: list[dict[str, Any]] = [] # user_events rows (history source)
self.sql_log: list[str] = [] # весь SQL, доехавший до «БД» — см. тесты режимов
self.commits: list[int] = [] # id() сессий, на которых вызывали commit()
self._next_id = 1
self.query_count = 0 # db.execute() calls — N+1 regression guard (review PR #2563)
@ -57,7 +94,7 @@ class _Store:
*,
role: str = "employee",
manager_id: int | None = None,
is_active: bool = True,
access_state: AccessState = AccessState.ACTIVE,
display_name: str | None = None,
org_name: str | None = None,
email: str | None = None,
@ -74,7 +111,8 @@ class _Store:
"display_name": display_name,
"org_name": org_name,
"email": email,
"is_active": is_active,
# СЫРОЕ значение колонки текущего режима (boolean либо text).
"access_state": column_value(access_state),
"created_at": created_at or datetime.now(UTC),
}
return uid
@ -162,7 +200,7 @@ class _FakeDB:
pass
def commit(self) -> None:
pass
self.store.commits.append(id(self))
def rollback(self) -> None:
pass
@ -172,9 +210,13 @@ class _FakeDB:
p = params or {}
s = self.store
s.query_count += 1
s.sql_log.append(sql)
# Имена таблиц/колонки берутся ИЗ КОДА (identity_schema), а не из
# литералов — см. «ЛОВУШКА FAKE-DB» в модульном docstring.
names = sql_names()
# ---- tradein_sessions ----
if "INSERT INTO tradein_sessions" in sql:
# ---- сессии реестра ----
if f"INSERT INTO {names.sessions}" in sql:
now = datetime.now(UTC)
s.sessions[p["token"]] = {
"user_id": p["user_id"],
@ -183,7 +225,7 @@ class _FakeDB:
}
return _Result([])
if "UPDATE tradein_sessions" in sql and "SET last_seen_at" in sql:
if f"UPDATE {names.sessions}" in sql and "SET last_seen_at" in sql:
sess = s.sessions.get(p["token"])
if sess is not None:
now = datetime.now(UTC)
@ -191,23 +233,25 @@ class _FakeDB:
sess["expires_at"] = now + timedelta(hours=p["ttl_hours"])
return _Result([])
if "DELETE FROM tradein_sessions WHERE token" in sql:
if f"DELETE FROM {names.sessions} WHERE token" in sql:
s.sessions.pop(p["token"], None)
return _Result([])
if "DELETE FROM tradein_sessions WHERE user_id" in sql:
if f"DELETE FROM {names.sessions} WHERE user_id" in sql:
uid = p["user_id"]
for tok in [t for t, sess in s.sessions.items() if sess["user_id"] == uid]:
del s.sessions[tok]
return _Result([])
if "FROM tradein_sessions s" in sql and "JOIN tradein_users u" in sql:
if f"FROM {names.sessions} s" in sql and f"JOIN {names.users} u" in sql:
sess = s.sessions.get(p["token"])
if sess is None:
return _Result([])
user = s.user_by_id(sess["user_id"])
if user is None:
return _Result([])
# Колонка состояния приезжает под алиасом `access_state` в обоих
# режимах (`u.<колонка> AS access_state`), значение — сырое.
return _Result(
[
{
@ -219,18 +263,23 @@ class _FakeDB:
"display_name": user["display_name"],
"org_name": user["org_name"],
"email": user["email"],
"is_active": user["is_active"],
"access_state": user["access_state"],
}
]
)
# ---- tradein_users: login lookup (get_user_by_username) ----
if "password_hash, role, is_active" in sql and "FROM tradein_users" in sql:
# ---- реестр: login lookup (get_user_by_username) ----
# Дискриминатор — bind-параметр `:username` (у pre-check'а уникальности
# ниже он называется `:u`), поэтому ветки не пересекаются ни в одном режиме.
if f"FROM {names.users}" in sql and "WHERE username = :username" in sql:
assert_reads_access_state(sql, names)
user = s.users.get(p["username"])
return _Result([user] if user is not None else [])
# ---- tradein_users: create ----
if "INSERT INTO tradein_users" in sql:
# ---- реестр: create ----
if f"INSERT INTO {names.users}" in sql:
assert_insert_writes_access_state(sql, names)
assert_reads_access_state(sql, names) # RETURNING отдаёт её же
uid = s._next_id
s._next_id += 1
created_at = datetime.now(UTC)
@ -243,25 +292,31 @@ class _FakeDB:
"display_name": p["display_name"],
"org_name": p["org_name"],
"email": p["email"],
"is_active": True,
# Ровно то, что код прислал параметром — БЕЗ нормализации.
# Инверсия `access_state_param()` обязана доехать до ответа API
# (`is_active`), а не раствориться в дублёре.
"access_state": p["access_state"],
"created_at": created_at,
}
s.users[p["username"]] = row
return _Result([dict(row)])
# ---- tradein_users: manager_id validation ----
if "role = 'manager'" in sql:
# ---- реестр: manager_id validation ----
if f"FROM {names.users}" in sql and "role = 'manager'" in sql:
user = s.user_by_id(p["id"])
match = user is not None and user["role"] == "manager"
return _Result([{"id": user["id"]}] if match else [])
# ---- tradein_users: list managed rows (has explicit ORDER BY) ----
# ---- реестр: list managed rows (has explicit ORDER BY) ----
# Две ветки реального кода: `role = 'employee'` (manager, либо admin с
# ?manager_id=) и `role IN ('employee','manager')` (admin без фильтра —
# ему нужны и менеджеры, иначе некому сбросить пароль, см. team.py).
if ("role = 'employee'" in sql or "role IN ('employee', 'manager')" in sql) and (
"ORDER BY created_at DESC" in sql
if (
f"FROM {names.users}" in sql
and ("role = 'employee'" in sql or "role IN ('employee', 'manager')" in sql)
and "ORDER BY created_at DESC" in sql
):
assert_reads_access_state(sql, names)
managed = (
("employee", "manager")
if "role IN ('employee', 'manager')" in sql
@ -285,7 +340,7 @@ class _FakeDB:
"display_name": u["display_name"],
"org_name": u["org_name"],
"email": u["email"],
"is_active": u["is_active"],
"access_state": u["access_state"],
"manager_id": u["manager_id"],
"created_at": u["created_at"],
}
@ -293,8 +348,11 @@ class _FakeDB:
]
)
# ---- tradein_users: fetch single managed row by id ----
if "role = 'employee'" in sql or "role IN ('employee', 'manager')" in sql:
# ---- реестр: fetch single managed row by id ----
if f"FROM {names.users}" in sql and (
"role = 'employee'" in sql or "role IN ('employee', 'manager')" in sql
):
assert_reads_access_state(sql, names)
managed = (
("employee", "manager")
if "role IN ('employee', 'manager')" in sql
@ -312,20 +370,21 @@ class _FakeDB:
"display_name": user["display_name"],
"org_name": user["org_name"],
"email": user["email"],
"is_active": user["is_active"],
"access_state": user["access_state"],
"manager_id": user["manager_id"],
"created_at": user["created_at"],
}
]
)
# ---- tradein_users: uniqueness pre-check ----
if sql.strip().startswith("SELECT id FROM tradein_users WHERE username"):
# ---- реестр: uniqueness pre-check ----
if sql.strip().startswith(f"SELECT id FROM {names.users} WHERE username"):
user = s.users.get(p["u"])
return _Result([{"id": user["id"]}] if user is not None else [])
# ---- tradein_users: update (PATCH) ----
if "UPDATE tradein_users" in sql and "SET display_name = COALESCE" in sql:
# ---- реестр: update (PATCH) ----
if f"UPDATE {names.users}" in sql and "SET display_name = COALESCE" in sql:
assert_update_writes_access_state(sql, names)
user = s.user_by_id(p["id"])
assert user is not None
if p.get("display_name") is not None:
@ -334,8 +393,11 @@ class _FakeDB:
user["org_name"] = p["org_name"]
if p.get("email") is not None:
user["email"] = p["email"]
if p.get("is_active") is not None:
user["is_active"] = p["is_active"]
# COALESCE(CAST(:access_state AS <тип>), <колонка>) — None означает
# «поле не пришло в PATCH», значение записывается КАК ЕСТЬ (см.
# комментарий про round-trip в INSERT выше).
if p.get("access_state") is not None:
user["access_state"] = p["access_state"]
if p.get("password_hash") is not None:
user["password_hash"] = p["password_hash"]
return _Result([])
@ -437,6 +499,8 @@ def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
auth_mod.reset_cache_for_tests()
auth_router._LOGIN_LIMITER._hits.clear()
monkeypatch.setattr(config.settings, "auth_mode", "dual")
# Каждый тест стартует в ДЕФОЛТНОМ режиме реестра (сегодняшний прод).
use_identity_mode(monkeypatch, "tradein")
# team.py / auth.py events go through schedule_event (own SessionLocal(), fire-
# and-forget) — captured into a list instead of hitting a real DB.
monkeypatch.setattr(team_router, "schedule_event", lambda **kw: _EVENTS.append(kw))
@ -452,9 +516,23 @@ def store() -> _Store:
return _Store()
@pytest.fixture
def auth_store(store: _Store, monkeypatch: pytest.MonkeyPatch) -> _Store:
"""Тот же `store`, но реестр — БД `auth` (`users`/`sessions`, text-состояние).
Запрашивать ПЕРЕД `client`: `store.add_user` фиксирует значение колонки по
режиму на момент вызова.
"""
use_identity_mode(monkeypatch, "auth")
return store
@pytest.fixture
def client(store: _Store, monkeypatch: pytest.MonkeyPatch) -> TestClient:
monkeypatch.setattr("app.core.rbac.SessionLocal", lambda: _FakeDB(store))
# Подменяем сессию РЕЕСТРА на обоих её источниках сразу, а не ветвление по
# режиму: `identity_session()` / `get_identity_db()` остаются настоящими,
# включая инвариант «в дефолтном режиме это тот же объект, что у get_db».
patch_identity_sessions(monkeypatch, lambda: _FakeDB(store))
# base_url=https:// — login sets a Secure cookie; see test_auth_api.py for why
# a plain-http TestClient would silently drop it.
return TestClient(_build_test_app(store), base_url="https://testserver")
@ -818,7 +896,11 @@ def test_reset_password_revokes_old_sessions(client: TestClient, store: _Store)
def test_unblock_employee_event(client: TestClient, store: _Store) -> None:
mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager")
emp_id = store.add_user(
"emp_a", hash_password("Secret123!"), role="employee", manager_id=mgr_id, is_active=False
"emp_a",
hash_password("Secret123!"),
role="employee",
manager_id=mgr_id,
access_state=AccessState.DISABLED,
)
_login(client, "mgr_a", "Secret123!")
@ -1157,3 +1239,178 @@ def test_employee_history_limit_max_200(client: TestClient, store: _Store) -> No
resp = client.get(f"/api/v1/team/employees/{emp_id}/history", params={"limit": 500})
assert resp.status_code == 422
# ---------------------------------------------------------------------------
# Эпик «единый вход»: режим IDENTITY_STORE=auth (общий реестр в БД `auth`).
#
# Всё выше идёт в ДЕФОЛТНОМ режиме — он же прод. Ниже — то, что появляется
# только после переезда: другая БД под реестром (две сессии вместо одной) и
# текстовое трёхзначное состояние доступа вместо булева `is_active`.
# ---------------------------------------------------------------------------
def test_default_mode_single_session_and_tradein_tables(client: TestClient, store: _Store) -> None:
"""Дефолт: реестр и продуктовые таблицы — ОДНА сессия, один commit, старые имена.
Это и есть «после мержа прод работает точно как сейчас» на уровне
транзакции: «сотрудник создан, квота нет» невозможно, потому что писать
обоих некуда, кроме одной транзакции.
"""
store.add_user("mgr_a", hash_password("Secret123!"), role="manager")
_login(client, "mgr_a", "Secret123!")
store.commits.clear()
resp = client.post(
"/api/v1/team/employees",
json={"username": "emp_x", "password": "Secret123!", "monthly_limit": 7},
)
assert resp.status_code == 201, resp.text
# Ровно один commit и ровно на одной сессии — `db is identity_db`.
assert len(set(store.commits)) == 1, store.commits
joined = "\n".join(store.sql_log)
assert "tradein_users" in joined
assert "tradein_sessions" in joined
assert not re.search(r"\b(FROM|INTO|UPDATE|JOIN)\s+users\b", joined)
assert not re.search(r"\b(FROM|INTO|UPDATE|JOIN)\s+sessions\b", joined)
# Новый сотрудник заводится открытым — булевым литералом, как и раньше.
assert store.users["emp_x"]["access_state"] is True
assert resp.json()["is_active"] is True
def test_auth_mode_commits_registry_and_product_db_separately(
auth_store: _Store, client: TestClient
) -> None:
"""Режим `auth`: БД физически две → две сессии и два отдельных коммита.
Порядок несущий (реестр первым): не доехавшая квота это сотрудник с
глобальным лимитом (чинится повторным PATCH), а обратный порядок оставил бы
висящий override на несуществующего человека.
"""
auth_store.add_user("mgr_a", hash_password("Secret123!"), role="manager")
_login(client, "mgr_a", "Secret123!")
auth_store.commits.clear()
resp = client.post(
"/api/v1/team/employees",
json={"username": "emp_x", "password": "Secret123!", "monthly_limit": 7},
)
assert resp.status_code == 201, resp.text
assert len(set(auth_store.commits)) == 2, auth_store.commits
joined = "\n".join(auth_store.sql_log)
assert "tradein_users" not in joined
assert "tradein_sessions" not in joined
assert re.search(r"INSERT INTO\s+users\b", joined)
# Квота осталась в ПРОДУКТОВОЙ таблице — она в общий реестр не переезжает.
assert "INSERT INTO account_quota_overrides" in joined
assert auth_store.quota_overrides["emp_x"]["monthly_limit"] == 7
def test_auth_mode_create_writes_text_active_literal(
auth_store: _Store, client: TestClient
) -> None:
"""INSERT кладёт в колонку 'active' (text), а не булев true.
Значение fake хранит как есть если бы `access_state_param()` инвертировался
или отдавал не тот тип, это доехало бы прямо сюда и до `is_active` в ответе.
"""
auth_store.add_user("mgr_a", hash_password("Secret123!"), role="manager")
_login(client, "mgr_a", "Secret123!")
resp = client.post(
"/api/v1/team/employees", json={"username": "emp_x", "password": "Secret123!"}
)
assert resp.status_code == 201, resp.text
assert auth_store.users["emp_x"]["access_state"] == "active"
assert resp.json()["is_active"] is True
def test_auth_mode_block_writes_disabled_and_revokes_sessions(
auth_store: _Store, client: TestClient
) -> None:
"""PATCH is_active=false → колонка 'disabled' + все сессии сотрудника порваны.
Сессии живут в БД РЕЕСТРА, поэтому рвать их надо через `identity_db`: с
продуктовой сессией DELETE ушёл бы не в ту БД, и блокировка не действовала бы
до истечения TTL (а sliding-refresh продлевал бы её бесконечно).
"""
mgr_id = auth_store.add_user("mgr_a", hash_password("Secret123!"), role="manager")
emp_id = auth_store.add_user(
"emp_a", hash_password("Secret123!"), role="employee", manager_id=mgr_id
)
auth_store.sessions["emp-token"] = {
"user_id": emp_id,
"expires_at": datetime.now(UTC) + timedelta(hours=1),
"last_seen_at": datetime.now(UTC),
}
_login(client, "mgr_a", "Secret123!")
resp = client.patch(f"/api/v1/team/employees/{emp_id}", json={"is_active": False})
assert resp.status_code == 200, resp.text
assert resp.json()["is_active"] is False
assert auth_store.users["emp_a"]["access_state"] == "disabled"
assert "emp-token" not in auth_store.sessions
def test_auth_mode_trial_expired_shows_as_blocked_and_unblock_activates(
auth_store: _Store, client: TestClient
) -> None:
"""`trial_expired` в «Команде» выглядит заблокированным, а is_active=true снимает
пробное ограничение (переводит в `active`).
Форма ответа API не меняется этим PR: `is_active` остаётся булевым и считается
как «пустят ли входить». Отдельное отображение пробного периода вопрос UI-PR'а.
"""
mgr_id = auth_store.add_user("mgr_a", hash_password("Secret123!"), role="manager")
emp_id = auth_store.add_user(
"emp_a",
hash_password("Secret123!"),
role="employee",
manager_id=mgr_id,
access_state=AccessState.TRIAL_EXPIRED,
)
_login(client, "mgr_a", "Secret123!")
listed = client.get("/api/v1/team/employees")
assert listed.status_code == 200, listed.text
assert [e["is_active"] for e in listed.json()] == [False]
resp = client.patch(f"/api/v1/team/employees/{emp_id}", json={"is_active": True})
assert resp.status_code == 200, resp.text
assert resp.json()["is_active"] is True
assert auth_store.users["emp_a"]["access_state"] == "active"
def test_auth_mode_org_isolation_still_404s_foreign_employee(
auth_store: _Store, client: TestClient
) -> None:
"""Главный инвариант «Команды» (чужой сотрудник → 404, не 403) переезд переживает."""
auth_store.add_user("mgr_a", hash_password("Secret123!"), role="manager")
mgr_b_id = auth_store.add_user("mgr_b", hash_password("Secret123!"), role="manager")
foreign_id = auth_store.add_user(
"emp_b", hash_password("Secret123!"), role="employee", manager_id=mgr_b_id
)
_login(client, "mgr_a", "Secret123!")
assert client.get("/api/v1/team/employees").json() == []
patched = client.patch(f"/api/v1/team/employees/{foreign_id}", json={"is_active": False})
assert patched.status_code == 404
assert client.get(f"/api/v1/team/employees/{foreign_id}/history").status_code == 404
# Чужая строка не тронута.
assert auth_store.users["emp_b"]["access_state"] == "active"
def test_auth_mode_employee_role_still_403_on_team_routes(
auth_store: _Store, client: TestClient
) -> None:
"""Роль резолвится из общего реестра — employee по-прежнему не админ «Команды»."""
auth_store.add_user("emp_only", hash_password("Secret123!"), role="employee")
_login(client, "emp_only", "Secret123!")
resp = client.get("/api/v1/team/employees")
assert resp.status_code == 403
assert "admin or manager" in resp.json()["detail"].lower()

View file

@ -71,12 +71,32 @@ function sanitizeNext(next: string | null): string {
return cleaned;
}
/**
* Единственный 403 логина «пробный доступ закончился» (пароль ВЕРНЫЙ,
* access_state='trial_expired' в реестре людей). Ветвимся по машиночитаемому
* `detail.code`, а не по тексту: текст сообщения бэк вправе менять, код нет
* (app/api/v1/auth.py, _ACCESS_EXPIRED_CODE).
*
* Достижимо только при IDENTITY_STORE=auth: в дефолтном режиме состояние
* доступа булево (active/disabled), и trial_expired там не существует.
*/
function accessExpiredCode(body: unknown): string | undefined {
if (typeof body !== "object" || body === null) return undefined;
const detail = (body as { detail?: unknown }).detail;
if (typeof detail !== "object" || detail === null) return undefined;
const code = (detail as { code?: unknown }).code;
return typeof code === "string" ? code : undefined;
}
function loginErrorMessage(error: unknown): string {
if (error instanceof HTTPError) {
if (error.status === 401) return "Неверный логин или пароль";
if (error.status === 429) {
return "Слишком много попыток. Попробуйте через несколько минут";
}
if (error.status === 403 && accessExpiredCode(error.body) === "access_expired") {
return "Пробный доступ закончился — обратитесь к менеджеру";
}
}
return "Не удалось войти. Проверьте подключение и попробуйте ещё раз";
}

View file

@ -0,0 +1,232 @@
"use client";
/**
* AddressForm поле адреса на первом экране.
*
* ЧТО ЭТА ФОРМА ДЕЛАЕТ СЕГОДНЯ И ПОЧЕМУ ИМЕННО ТАК
*
* Она не считает цену и не притворяется, что считает. Причина техническая и
* жёсткая: `rbac_guard` (backend/app/core/rbac.py) пропускает анонима только на
* пути из `_PUBLIC_PATHS`, а `/api/v1/geocode/suggest` и
* `/api/v1/trade-in/estimate` туда не входят любой запрос отсюда вернул бы
* 401. Открытие анонимного периметра отдельный backend-PR, вне границ этой
* задачи. Поэтому здесь честная валидация на клиенте + прямой ответ «публичный
* расчёт ещё не открыт» вместо фейкового спиннера.
*
* Что форма всё-таки делает по-настоящему:
* - проверяет, что адрес введён;
* - требует явно назвать город и не подставляет Екатеринбург молча. Это ровно
* тот баг, который чинил бэкенд в #2576: житель Нижнего Тагила вводил
* «Ленина, 1» и получал уверенную цену по одноимённой улице в ЕКБ. Правило
* из шапки `lib/city-registry.ts` город считается известным только если
* пользователь его выбрал ИЛИ `detectCityInText` нашёл его в тексте;
* - если названного города нет в покрытии мягко и честно говорит про
* Свердловскую область, не обещая «оценим любую квартиру в РФ».
*
* Осознанно НЕ переиспользован автокомплит из закрытого контура
* (ParamsPanel.tsx / AddressInput.tsx): он ходит в `/geocode/suggest` через
* `useGeocodeSuggest`, что для анонима = 401. Тянуть сюда хуки B2B-контура
* (useMe/useQuota/useHistory и соседей) запрещено публичный экран не должен
* иметь к ним доступа даже теоретически.
*
* Когда бэкенд откроет анонимные ручки: переключить `PUBLIC_ESTIMATE_ENABLED`
* в content.ts и заменить ветку `notLaunched` в `handleSubmit` на реальный
* переход/запрос (комбобокс подсказок по образцу ParamsPanel.tsx, вместе с
* его клавиатурной моделью и sr-live-регионом).
*/
import { useId, useRef, useState } from "react";
import type { FormEvent } from "react";
import { detectCityInText } from "@/lib/city-registry";
import {
COVERED_CITIES,
PRIMARY_CITY,
PUBLIC_ESTIMATE_ENABLED,
REGION_NAME,
SECONDARY_CITIES,
} from "../content";
import styles from "../landing.module.css";
/** Значение <option> «моего города нет в списке». */
const OTHER_CITY = "__other__";
type Feedback =
| { kind: "none" }
| { kind: "error"; field: "address" | "city"; text: string }
| { kind: "info"; title: string; lines: readonly string[] };
const NONE: Feedback = { kind: "none" };
export function AddressForm() {
const addressId = useId();
const cityId = useId();
const feedbackId = useId();
const [address, setAddress] = useState("");
const [city, setCity] = useState("");
const [feedback, setFeedback] = useState<Feedback>(NONE);
const addressRef = useRef<HTMLInputElement>(null);
const cityRef = useRef<HTMLSelectElement>(null);
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const trimmed = address.trim();
if (!trimmed) {
setFeedback({
kind: "error",
field: "address",
text: "Напишите улицу и номер дома — например, «Ленина, 5».",
});
addressRef.current?.focus();
return;
}
// Город известен, только если его выбрали руками или назвали в тексте
// адреса. Ничего не додумываем — см. шапку файла.
const detected = detectCityInText(trimmed);
const resolvedCity =
city === OTHER_CITY ? OTHER_CITY : city || detected || "";
if (!resolvedCity) {
setFeedback({
kind: "error",
field: "city",
text: "Выберите город: одинаковые названия улиц есть в разных городах области, и без города цена будет не про вашу квартиру.",
});
cityRef.current?.focus();
return;
}
if (resolvedCity === OTHER_CITY) {
setFeedback({
kind: "info",
title: `Пока мы считаем только по одному региону — ${REGION_NAME}`,
lines: [
`Данные мы собираем сами, город за городом: полностью — ${PRIMARY_CITY}, частично — ${SECONDARY_CITIES.join(", ")}. По остальным адресам оценка была бы догадкой, поэтому мы её не показываем.`,
],
});
return;
}
if (city === "" && detected) {
// Город распознали в тексте — синхронизируем селект, чтобы человек видел,
// что именно мы поняли, и мог поправить.
setCity(detected);
}
if (PUBLIC_ESTIMATE_ENABLED) {
// TODO(backend-периметр): здесь появится реальный расчёт. Отдельный PR.
return;
}
// Если город уже назван в самом тексте адреса — не дублируем его в эхо
// («Серов, Серов, Ленина 3»).
const echo =
detected === resolvedCity ? trimmed : `${resolvedCity}, ${trimmed}`;
setFeedback({
kind: "info",
title: "Расчёт по адресу мы ещё не открыли для всех",
lines: [
`Адрес выглядит как наш: ${echo}. Но публичная оценка пока выключена — сервис работает по доступу для партнёров, и мы не хотим показывать заглушку вместо цены.`,
"Оценка появится на этой же странице. Если вопрос срочный — напишите нам в поддержку, ссылка в подвале.",
],
});
}
const addressInvalid =
feedback.kind === "error" && feedback.field === "address";
const cityInvalid = feedback.kind === "error" && feedback.field === "city";
const describedBy = feedback.kind === "none" ? undefined : feedbackId;
return (
<form className={styles.form} onSubmit={handleSubmit} noValidate>
<div className={styles.formRow}>
<div className={`${styles.field} ${styles.fieldCity}`}>
<label className={styles.label} htmlFor={cityId}>
Город
</label>
<select
id={cityId}
ref={cityRef}
className={`${styles.select} ${cityInvalid ? styles.inputInvalid : ""}`}
value={city}
onChange={(event) => {
setCity(event.target.value);
setFeedback(NONE);
}}
aria-invalid={cityInvalid || undefined}
aria-describedby={cityInvalid ? describedBy : undefined}
>
<option value="">Выберите город</option>
{COVERED_CITIES.map((label) => (
<option key={label} value={label}>
{label}
</option>
))}
<option value={OTHER_CITY}>Другой город</option>
</select>
</div>
<div className={`${styles.field} ${styles.fieldAddress}`}>
<label className={styles.label} htmlFor={addressId}>
Улица и дом
</label>
<input
id={addressId}
ref={addressRef}
className={`${styles.input} ${addressInvalid ? styles.inputInvalid : ""}`}
type="text"
name="address"
autoComplete="street-address"
enterKeyHint="go"
placeholder="Например, Ленина, 5"
value={address}
onChange={(event) => {
setAddress(event.target.value);
setFeedback(NONE);
}}
aria-invalid={addressInvalid || undefined}
aria-describedby={addressInvalid ? describedBy : undefined}
/>
</div>
{/* Кнопка НИКОГДА не disabled по валидности: disabled-кнопка не
диспатчит submit, и невалидная попытка молча ничего бы не делала
вместо объяснения (тот же фикс, что в v2/LeadForm.tsx). */}
<button type="submit" className={styles.cta}>
Узнать цену
</button>
</div>
<p className={styles.formHint}>
Ничего не спишется и не позвонит: телефон мы спрашиваем, только если вы
сами оставите заявку.
</p>
{/* Живая область объявляется скринридеру при любой смене содержимого.
Держим её в DOM постоянно регион, добавленный в момент ошибки,
часть скринридеров не озвучивает. */}
<div id={feedbackId} role="status" aria-live="polite">
{feedback.kind === "error" && (
<div className={`${styles.formFeedback} ${styles.formFeedbackError}`}>
<p className={styles.formFeedbackText}>{feedback.text}</p>
</div>
)}
{feedback.kind === "info" && (
<div className={styles.formFeedback}>
<p className={styles.formFeedbackTitle}>{feedback.title}</p>
{feedback.lines.map((line) => (
<p key={line} className={styles.formFeedbackText}>
{line}
</p>
))}
</div>
)}
</div>
</form>
);
}

View file

@ -0,0 +1,55 @@
/**
* DataSources блок доверия «откуда мы берём цифры». Серверный компонент.
*
* Названия площадок НЕ вбиты строками: группы собираются в content.ts из
* `lib/source-registry.ts`, который и есть единственный источник правды по
* источникам (контракт честности #2211). Добавится площадка в реестр она
* появится здесь сама; исчезнет исчезнет и тут.
*
* Логотипов площадок нет намеренно: чужие товарные знаки на публичной странице
* отдельный юридический вопрос, а картинки пришлось бы тянуть с чужих
* доменов (запрещено). Текстовые чипы решают ту же задачу.
*/
import { SOURCE_GROUPS } from "../content";
import styles from "../landing.module.css";
export function DataSources() {
return (
<section className={styles.section} aria-labelledby="sources-title">
<div className={styles.container}>
<div className={styles.sectionHead}>
<h2 id="sources-title" className={styles.h2}>
Откуда мы берём данные
</h2>
{/* Без числа групп в тексте: группы выводятся из реестра источников,
и «два типа данных» уже однажды разъехалось с кодом оценочные
модели площадок участвуют в расчёте (estimator.py, IMV/Yandex
blend), но в тексте их не было. */}
<p className={styles.sectionLead}>
Мы не опрашиваем экспертов и не берём цифры из головы. Данные
разного происхождения отвечают на разные вопросы поэтому мы держим
их раздельно и показываем, что откуда.
</p>
</div>
<div className={styles.sourceGroups}>
{SOURCE_GROUPS.map((group) => (
<div key={group.title} className={styles.sourceGroup}>
<h3 className={styles.sourceGroupTitle}>{group.title}</h3>
<ul className={styles.sourceChips} role="list">
{group.items.map((item) => (
<li key={item} className={styles.sourceChip}>
<span className={styles.sourceChipDot} aria-hidden="true" />
{item}
</li>
))}
</ul>
<p className={styles.sourceGroupNote}>{group.note}</p>
</div>
))}
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,65 @@
/**
* Faq аккордеон на нативных <details>/<summary>. Серверный компонент.
*
* Почему без "use client": браузер уже умеет раскрывать details по Enter/Space,
* ставит фокус на summary и сам сообщает состояние скринридеру. Самописный
* аккордеон на useState потребовал бы вручную воспроизвести aria-expanded,
* управление фокусом и клавиатуру и работал бы хуже до гидратации. Клиентский
* JS здесь не нужен вообще.
*/
import { FAQ } from "../content";
import styles from "../landing.module.css";
export function Faq() {
return (
<section
className={`${styles.section} ${styles.sectionAlt}`}
aria-labelledby="faq-title"
>
<div className={styles.container}>
<div className={styles.sectionHead}>
<h2 id="faq-title" className={styles.h2}>
Частые вопросы
</h2>
</div>
<div className={styles.faqList}>
{FAQ.map((item) => (
<details key={item.id} className={styles.faqItem}>
<summary className={styles.faqSummary}>
<span>{item.q}</span>
<ChevronIcon />
</summary>
<div className={styles.faqAnswer}>
{item.a.map((paragraph) => (
<p key={paragraph}>{paragraph}</p>
))}
</div>
</details>
))}
</div>
</div>
</section>
);
}
function ChevronIcon() {
return (
<svg
className={styles.faqChevron}
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<polyline points="6 9 12 15 18 9" />
</svg>
);
}

View file

@ -0,0 +1,87 @@
/**
* Hero первый экран: что это, для кого, и сразу граница по географии.
*
* Плашка «Свердловская область» стоит ДО формы намеренно (решение владельца):
* человек должен узнать про ограничение раньше, чем потратит время на ввод
* адреса, а не после. Города берутся из реестра (`COVERED_CITIES`), не из
* строки, иначе разъедутся с реальным покрытием сбора.
*
* Серверный компонент; клиентская часть только `AddressForm`.
*/
import { PRIMARY_CITY, REGION_NAME, SECONDARY_CITIES } from "../content";
import styles from "../landing.module.css";
import { AddressForm } from "./AddressForm";
export function Hero() {
return (
<section className={styles.hero} aria-labelledby="hero-title">
{/* Два вложенных div'а, а не два класса на одном: `.container` задаёт
общую 1120px-сетку страницы и центрирует её, `.heroInner` узкую
колонку измерения (720px) ВНУТРИ неё, прижатую к левому краю. Пока оба
класса висели на одном элементе, побеждал max-width: 720px, и весь
первый экран уезжал вправо относительно всех секций ниже (на 1440px
на 200px). */}
<div className={styles.container}>
<div className={styles.heroInner}>
<p className={styles.eyebrow}>Мера · оценка квартиры</p>
<h1 id="hero-title" className={styles.h1}>
Сколько на самом деле стоит ваша квартира
</h1>
<p className={styles.heroLead}>
Введите адрес покажем, за сколько продаются похожие квартиры рядом
и, если по вашему дому или поблизости есть зарегистрированные
сделки, за сколько их реально покупают. Без звонка риелтора и без
визита оценщика.
</p>
{/* Покрытие подано неравномерно намеренно см. COVERED_CITIES в
content.ts: список городов в реестре карта покрытия. */}
<div className={styles.regionBadge}>
<p className={styles.regionBadgeTitle}>
<PinIcon />
{REGION_NAME}
</p>
<p className={styles.regionBadgeCities}>
Полное покрытие {PRIMARY_CITY}. По остальным городам области (
{SECONDARY_CITIES.join(", ")}) данных меньше, и оценка там может
быть ориентировочной. По другим регионам не считаем вовсе не
хотим гадать.
</p>
</div>
<AddressForm />
<p className={styles.heroNote}>
«Мера» сервис оценки вторичного жилья по рыночным данным. Мы не
покупаем квартиры и не берём их на продажу: наша работа показать
цифру и то, откуда она взялась.
</p>
</div>
</div>
</section>
);
}
function PinIcon() {
return (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0z" />
<circle cx="12" cy="10" r="3" />
</svg>
);
}

View file

@ -0,0 +1,46 @@
/**
* HowItWorks три шага. Серверный компонент.
*
* Разметка упорядоченный список <ol>: порядок шагов несёт смысл, и
* скринридер объявит «список из 3 элементов, элемент 1», без декоративных
* кружков с цифрами.
*
* `role="list"` не избыточность. WebKit СНИМАЕТ роли list/listitem со списка,
* у которого `list-style: none` (а он здесь есть, см. `.steps`), и в
* Safari/VoiceOver порядковый номер исчезал бы полностью: из семантики из-за
* этого quirk'а, из текста потому что видимая подпись «ШАГ N» помечена
* aria-hidden именно чтобы не дублировать семантику. Атрибут возвращает роли
* обратно. То же самое сделано у остальных списков лэндинга с list-style: none.
*/
import { STEPS } from "../content";
import styles from "../landing.module.css";
export function HowItWorks() {
return (
<section className={styles.section} aria-labelledby="how-title">
<div className={styles.container}>
<div className={styles.sectionHead}>
<h2 id="how-title" className={styles.h2}>
Как это работает
</h2>
<p className={styles.sectionLead}>
Три шага, никакой регистрации на входе.
</p>
</div>
<ol className={styles.steps} role="list">
{STEPS.map((step, index) => (
<li key={step.title} className={styles.step}>
<span className={styles.stepNum} aria-hidden="true">
ШАГ {index + 1}
</span>
<h3 className={styles.stepTitle}>{step.title}</h3>
<p className={styles.stepText}>{step.text}</p>
</li>
))}
</ol>
</div>
</section>
);
}

View file

@ -0,0 +1,97 @@
/**
* SiteFooter подвал. Серверный компонент.
*
* Что здесь честно ОТСУТСТВУЕТ:
* - Реквизиты юрлица/ИП. В репозитории их нет (поиск по коду, бэкенду и
* разметке не дал ни наименования, ни ИНН/ОГРН), а выдумывать реквизиты
* оператора персональных данных на публичной странице нельзя. Блок
* рендерится, как только `LEGAL_ENTITY` в content.ts перестанет быть null;
* заполнить обязательно до открытия домена наружу 152-ФЗ требует
* идентифицируемого оператора.
* - E-mail поддержки: реального адреса в коде тоже нет. Единственный
* проверяемый канал телеграм-бот из `v2/SupportChatContext.tsx`.
*
* Внешняя ссылка проверяется `safeUrl` (правило frontend.md: ничего в href без
* валидации схемы) и открывается в новой вкладке с rel="noreferrer".
*/
import Link from "next/link";
import { safeUrl } from "@/lib/safeUrl";
import {
LEGAL_ENTITY,
PRIVACY_PATH,
REGION_NAME,
SUPPORT_TELEGRAM_LABEL,
SUPPORT_TELEGRAM_URL,
} from "../content";
import styles from "../landing.module.css";
export function SiteFooter() {
const telegramHref = safeUrl(SUPPORT_TELEGRAM_URL);
const year = new Date().getFullYear();
return (
<footer className={styles.footer}>
<div className={styles.container}>
<div className={styles.footerGrid}>
<div>
<div className={styles.wordmark}>
<span className={styles.wordmarkDot} aria-hidden="true" />
МЕРА
</div>
<p className={styles.footerText} style={{ marginTop: 10 }}>
Оценка квартир на вторичном рынке по сделкам и объявлениям.{" "}
{REGION_NAME}.
</p>
</div>
<div>
<p className={styles.footerTitle}>Связаться</p>
{telegramHref ? (
<p className={styles.footerText}>
Поддержка в Telegram:{" "}
<a
className={styles.link}
href={telegramHref}
target="_blank"
rel="noreferrer"
>
{SUPPORT_TELEGRAM_LABEL}
</a>
</p>
) : (
<p className={styles.footerText}>Контакты появятся к запуску.</p>
)}
</div>
<div>
<p className={styles.footerTitle}>Документы</p>
<ul className={styles.footerLinks} role="list">
<li>
<Link className={styles.link} href={PRIVACY_PATH}>
Обработка персональных данных
</Link>
</li>
</ul>
</div>
</div>
<div className={styles.footerBottom}>
<span>© {year} МЕРА</span>
{LEGAL_ENTITY && (
<span>
{LEGAL_ENTITY.name}, ИНН {LEGAL_ENTITY.inn},{" "}
{LEGAL_ENTITY.address}
</span>
)}
<span>
Оценка носит информационный характер и не является офертой или
отчётом об оценке.
</span>
</div>
</div>
</footer>
);
}

View file

@ -0,0 +1,26 @@
/**
* SiteHeader шапка лэндинга. Серверный компонент: интерактивности нет.
*
* Логотип НЕ является ссылкой на "/": в next.config.ts стоит redirect "/" "/v2",
* то есть клик по нему выкинул бы публичного посетителя в закрытое B2B-приложение
* (и дальше на /login). Пока публичная страница одна, вордмарк просто текст.
*/
import { REGION_NAME } from "../content";
import styles from "../landing.module.css";
export function SiteHeader() {
return (
<header className={styles.header}>
<div className={`${styles.container} ${styles.headerInner}`}>
<div className={styles.wordmark}>
<span className={styles.wordmarkDot} aria-hidden="true" />
МЕРА
</div>
<p className={styles.headerTag}>
Оценка вторичного жилья · {REGION_NAME}
</p>
</div>
</header>
);
}

View file

@ -0,0 +1,93 @@
/**
* WhatYouGet что человек получает по итогу. Серверный компонент.
*
* Каждый пункт соответствует блоку, который результат оценки рендерит сегодня
* (`v2/types.ts` ResultCard.value/range/ppm/delta/ResultMeta.builtOn).
* Ничего «планируемого» и ничего недоступного анониму в списке нет правило и
* разбор по PDF в шапке content.ts.
*
* Дисклеймер про 135-ФЗ обязателен: «Мера» даёт рыночную оценку по
* сопоставимым объектам, а не отчёт аккредитованного оценщика.
*/
import { DELIVERABLES, DELIVERABLES_DISCLAIMER } from "../content";
import styles from "../landing.module.css";
export function WhatYouGet() {
return (
<section
className={`${styles.section} ${styles.sectionAlt}`}
aria-labelledby="get-title"
>
<div className={styles.container}>
<div className={styles.sectionHead}>
<h2 id="get-title" className={styles.h2}>
Что вы получите
</h2>
<p className={styles.sectionLead}>
Не «примерную стоимость», а расчёт, который видно насквозь.
</p>
</div>
<ul className={styles.deliverables} role="list">
{DELIVERABLES.map((item) => (
<li key={item.title} className={styles.deliverable}>
<CheckIcon />
<div>
<h3 className={styles.deliverableTitle}>{item.title}</h3>
<p className={styles.deliverableText}>{item.text}</p>
</div>
</li>
))}
</ul>
<p className={styles.disclaimer}>
<InfoIcon />
<span>{DELIVERABLES_DISCLAIMER}</span>
</p>
</div>
</section>
);
}
function CheckIcon() {
return (
<svg
className={styles.checkIcon}
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<polyline points="20 6 9 17 4 12" />
</svg>
);
}
function InfoIcon() {
return (
<svg
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
style={{ flex: "0 0 auto", marginTop: 2 }}
>
<circle cx="12" cy="12" r="9" />
<line x1="12" y1="11" x2="12" y2="16.5" />
<line x1="12" y1="7.6" x2="12" y2="7.7" />
</svg>
);
}

View file

@ -0,0 +1,295 @@
/**
* content весь текст и все внешние ссылки публичного лэндинга «МЕРА» в одном
* файле, чтобы обещания продукта можно было отревьюить одним чтением, не
* вылавливая строки по компонентам.
*
* ПРАВИЛО ЧЕСТНОСТИ (продолжение контракта из `lib/source-registry.ts` и
* `lib/city-registry.ts`): на этой странице не должно быть ни одного
* утверждения, которого не делает код.
* - Никаких цифр («N объявлений в базе», «точность 95%») таких величин в
* коде нет, посчитать их фронт не может.
* - Список городов и список источников НЕ хардкодятся здесь строками, а
* выводятся из реестров (`OBLAST_CITIES`, `SOURCES`) иначе они разъедутся
* с реальным покрытием при следующем расширении.
* - Обещания в «что вы получите» описывают ровно те блоки, которые сегодня
* рендерит результат оценки (`v2/types.ts` ResultCard/ResultMeta) И
* доступны тому, кому страница адресована. PDF-отчёта в списке НЕТ
* намеренно: `GET /api/v1/trade-in/estimate/{id}/pdf` защищён
* `_assert_estimate_access` (401 без `X-Authenticated-User`), в
* `rbac.py::_PUBLIC_PATHS` его нет и быть не может (ручка owner-scoped), а
* сама оценка живёт 24 часа (410 «estimate expired»). Анониму эта фича
* недоступна by design обещать её до появления anon-owner механизма
* нельзя.
*/
import { DEFAULT_CITY, OBLAST_CITIES } from "@/lib/city-registry";
import {
LIVE_LISTING_SOURCES,
SOURCES,
sourceLabel,
} from "@/lib/source-registry";
// ---------------------------------------------------------------------------
// Флаги состояния продукта
// ---------------------------------------------------------------------------
/**
* Включён ли публичный расчёт по адресу.
*
* Сегодня `false` и это не «недоделка фронта»: анонимный запрос к
* `/api/v1/geocode/suggest` и `/api/v1/trade-in/estimate` отбивается
* `rbac_guard` (backend/app/core/rbac.py::_PUBLIC_PATHS эти пути в белом
* списке отсутствуют), т.е. без отдельного backend-PR любая «живая» форма на
* лэндинге отдавала бы 401. Форма поэтому честно сообщает, что расчёт ещё не
* открыт, вместо имитации загрузки.
*
* Когда бэкенд откроет анонимный периметр переключить в `true` и подключить
* реальный сабмит в `_components/AddressForm.tsx` (там помечено TODO-местом).
*
* ГЕЙТ: это НЕ однострочник. Переключение в `true` делает ложными публичные
* утверждения, которые сегодня правдивы, поэтому вместе с флагом обязаны быть
* сделаны:
* 1. `privacy/page.tsx`, раздел «Что делает эта страница» он УЖЕ условный по
* этому флагу (ветка `true` описывает отправку и сохранение адреса);
* перечитать текст обеих веток перед включением.
* 2. Согласие на обработку ПДн должно фиксироваться ДО первого INSERT в
* `trade_in_estimates`: сегодня адрес физлица попадает в БД раньше любого
* согласия (`address` NOT NULL, `expires_at` применяется только на чтении).
* 3. Должен существовать реальный путь удаления данных в бэкенде нет ни
* DELETE-джоба в `app/tasks/**`, ни ручки erasure (проверено grep'ом);
* privacy-страница поэтому и не обещает удаление.
*/
export const PUBLIC_ESTIMATE_ENABLED: boolean = false;
// ---------------------------------------------------------------------------
// Контакты и юридическое
// ---------------------------------------------------------------------------
/**
* Телеграм-бот поддержки. Значение продублировано из
* `components/trade-in/v2/SupportChatContext.tsx::SUPPORT_BOT_URL` НАМЕРЕННО:
* тот модуль помечен "use client", и импорт константы из него в серверный
* компонент вернул бы client-reference, а не строку. Дублируется ровно так же,
* как `PHONE_PATTERN` в `v2/LeadForm.tsx` дублирует бэкендовый регэксп.
* При смене бота править оба места.
*/
export const SUPPORT_TELEGRAM_URL = "https://t.me/MERAsupport_bot";
export const SUPPORT_TELEGRAM_LABEL = "@MERAsupport_bot";
/**
* Реквизиты оператора персональных данных (наименование юрлица/ИП, ИНН, адрес).
* `null` потому что в репозитории их НЕТ: поиск по коду, бэкенду и разметке
* не дал ни ООО/ИП, ни ИНН/ОГРН. Выдумывать реквизиты на публичной странице
* нельзя, поэтому блок реквизитов просто не рендерится, пока значение null.
* Заполнить перед публичным запуском (обязательное требование 152-ФЗ).
*/
export const LEGAL_ENTITY: {
name: string;
inn: string;
address: string;
} | null = null;
/** Внутренний маршрут страницы про обработку персональных данных. */
export const PRIVACY_PATH = "/mera-public/privacy";
// ---------------------------------------------------------------------------
// География
// ---------------------------------------------------------------------------
export const REGION_NAME = "Свердловская область";
/**
* Города, которые сервис вообще умеет различать (это же список `city_hint` в
* форме). Берём из реестра, а не из строки иначе разъедется при расширении.
*
* ЭТО НЕ КАРТА ПОКРЫТИЯ. Реестр перечисляет опции city_hint, а не города с
* равным объёмом данных предупреждение стоит в шапке самого city-registry.ts.
* Фактическое положение дел (проверяемое по репозиторию):
* - `data/sql/179_scrape_schedules_seed_oblast_city_sweeps.sql` сеет ВСЕ 15
* областных city-sweep (avito/cian/yandex × 5 городов) с `enabled = false`
* и помечен «!!! DORMANT BY DESIGN !!! Оператор включает ВРУЧНУЮ по
* одному городу за раз»; ни одна последующая миграция их не включает.
* - Прод-замер, зафиксированный в шапке
* `data/sql/197_backfill_listings_city_from_url.sql` (read-only SELECT):
* Екатеринбург 26 770 объявлений, Нижний Тагил 551, Каменск-Уральский 244,
* Первоуральск 95, Серов 25, Верхняя Пышма 21.
* Поэтому публично мы НЕ подаём шесть городов как равнозначные: полное
* покрытие один город, остальные идут с честной оговоркой.
*/
export const COVERED_CITIES: readonly string[] = OBLAST_CITIES.map(
(c) => c.label,
);
/** Город с полным покрытием сбора (он же дефолт формы) — сегодня Екатеринбург. */
export const PRIMARY_CITY: string = DEFAULT_CITY.label;
/** Остальные города области: сбор заведён, но данных кратно меньше. */
export const SECONDARY_CITIES: readonly string[] = OBLAST_CITIES.filter(
(c) => c.id !== DEFAULT_CITY.id,
).map((c) => c.label);
// ---------------------------------------------------------------------------
// Источники данных
// ---------------------------------------------------------------------------
export interface SourceGroup {
readonly title: string;
readonly items: readonly string[];
readonly note: string;
}
/**
* Три группы источников. Листинговые выводятся из `LIVE_LISTING_SOURCES`
* (реестр помечает их как «источники, реально дающие аналоги»), сделки
* Росреестр, оценочные модели `kind: "valuation"` из того же реестра.
*
* ПОЧЕМУ ТРЕТЬЯ ГРУППА ЕСТЬ (а не «два типа данных», как было). Оценки площадок
* не украшение экрана: в `backend/app/services/estimator.py` (блок «#651: IMV
* / Yandex blend», Tier D — когда якоря по дому/500 м нет) медиана
* переписывается на `new_median` с весом `estimate_imv_blend_weight`,
* объяснение дополняется «Оценка скорректирована по», а `sources_used`
* пополняется `avito_imv`. Умолчать об этом значит утверждать на публичной
* странице то, чего код не делает.
*
* Осознанное сужение: в `SOURCES` у сделок есть ещё «Этажи» (kind: "deals"),
* но на публичной странице говорим только про Росреестр это продуктовое
* решение владельца («сделки Росреестра + объявления площадок»), а не
* недосмотр. Лейбл берём через `sourceLabel`, чтобы не разъехаться с реестром.
*/
export const SOURCE_GROUPS: readonly SourceGroup[] = [
{
title: "Зарегистрированные сделки",
items: [sourceLabel("rosreestr")],
note: "Цены, по которым квартиры действительно перешли к новым собственникам — по договорам купли-продажи. Сначала смотрим сделки по вашему дому, а если их мало — по ближайшему окружению.",
},
{
title: "Объявления о продаже",
items: LIVE_LISTING_SOURCES.map((s) => s.label),
note: "Что просят прямо сейчас за похожие квартиры: площадь, этаж, тип дома, состояние.",
},
{
title: "Оценочные модели площадок",
items: SOURCES.filter((s) => s.kind === "valuation").map((s) => s.label),
note: "Собственные оценки площадок мы не игнорируем, но и не выдаём за свои: они идут в дело как сверка, когда по дому не набралось ни сделок, ни близких аналогов. Если расчёт был скорректирован по такой оценке, это написано в самом отчёте.",
},
];
// ---------------------------------------------------------------------------
// Как это работает
// ---------------------------------------------------------------------------
export interface Step {
readonly title: string;
readonly text: string;
}
export const STEPS: readonly Step[] = [
{
title: "Указываете адрес",
text: "Город, улица и дом — это всё, что нужно на входе; площадь, этаж и число комнат уточняются на следующем шаге. Ничего про себя сообщать не нужно — телефон спрашиваем, только если вы сами захотите оставить заявку.",
},
{
title: "Мы собираем данные по дому и району",
text: "Сделки Росреестра и объявления с площадок — отбираем те, что сопоставимы с вашей квартирой по площади, этажу и типу дома.",
},
{
title: "Показываем цену и то, из чего она сложилась",
text: "Не одно число, а диапазон, цена за квадратный метр и то, на скольких сопоставимых объектах и сделках построен расчёт. Если данных по дому мало — это написано прямо в отчёте.",
},
];
// ---------------------------------------------------------------------------
// Что получает человек
// ---------------------------------------------------------------------------
export interface Deliverable {
readonly title: string;
readonly text: string;
}
export const DELIVERABLES: readonly Deliverable[] = [
{
title: "Диапазон цены",
text: "Нижняя, средняя и верхняя граница — вместо одного числа, которое всё равно не бывает точным.",
},
{
title: "Цена за квадратный метр",
text: "По вашей квартире и по сопоставимым объектам рядом — чтобы понимать, откуда взялась сумма.",
},
{
title: "Разница между объявлениями и сделками",
text: "Объявление — это запрашиваемая цена, сделка — та, по которой квартиру купили. Если по вашему дому и району есть зарегистрированные сделки, показываем оба числа и разрыв между ними; если их не нашлось — честно пишем, что данных нет, вместо прочерка.",
},
{
title: "На чём построен расчёт",
text: "Сколько нашлось сопоставимых квартир и сделок и насколько сильно они разошлись по цене. Если данных мало — так и написано, а не спрятано.",
},
];
/**
* Дисклеймер рядом со списком. Обязателен: «Мера» рыночная оценка по
* сопоставимым объектам, а не отчёт об оценке по 135-ФЗ.
*/
export const DELIVERABLES_DISCLAIMER =
"Это рыночная оценка по сопоставимым объектам, а не официальный отчёт оценщика: для банка, суда, опеки или нотариуса нужен отчёт аккредитованного оценщика.";
// ---------------------------------------------------------------------------
// Частые вопросы
// ---------------------------------------------------------------------------
export interface FaqItem {
readonly id: string;
readonly q: string;
readonly a: readonly string[];
}
export const FAQ: readonly FaqItem[] = [
{
id: "how-do-you-know",
q: "Откуда вы знаете, сколько стоит именно моя квартира?",
a: [
"По адресу мы находим ваш дом и смотрим, что происходило с похожими квартирами: какие сделки зарегистрированы по самому дому, а если их мало — по ближайшему окружению, и что сейчас продаётся рядом.",
"Сопоставимость считаем по понятным признакам — площадь, этаж, число комнат, тип дома. Итог — не мнение и не формула из воздуха: рядом с каждым числом видно, на скольких объектах и сделках оно построено и насколько они разошлись по цене.",
],
},
{
id: "how-accurate",
q: "Насколько это точно?",
a: [
"Мы намеренно показываем диапазон, а не одно число: реальная цена зависит от состояния квартиры, вида из окна и того, насколько срочно нужно продать.",
"Точность прямо зависит от того, сколько нашлось сопоставимых объектов. Поэтому мы всегда пишем, на скольких объектах построен расчёт, — и честно сообщаем, если данных по дому мало.",
],
},
{
id: "why-region",
q: "Почему только Свердловская область?",
a: [
`Мы собираем данные сами, город за городом, и по объёму эти города не равны: полнее всего покрыт ${PRIMARY_CITY}. По остальным городам области данных заметно меньше — там оценка скорее ориентировочная, и мы про это пишем, а не делаем вид, что разницы нет.`,
"Там, где сбора нет вовсе, оценка была бы догадкой с уверенным видом. Поэтому другие регионы мы не обещаем и добавляем их по мере появления реального покрытия, а не заранее.",
],
},
{
id: "vs-marketplace",
q: "Чем это отличается от калькулятора на сайте объявлений?",
a: [
"Калькулятор площадки считает по объявлениям этой же площадки — то есть по ценам, которые продавцы просят, а не получают.",
"Мы смотрим сразу несколько площадок и добавляем к объявлениям зарегистрированные сделки, чтобы была видна разница между запрашиваемой и реальной ценой. Оценки самих площадок при этом не выбрасываем — используем их как сверку, когда данных по дому не хватает.",
],
},
{
id: "personal-data",
q: "Что будет с моими данными?",
a: [
"Для расчёта нужны адрес и параметры квартиры. Имя, паспорт и документы на квартиру мы не спрашиваем.",
"Телефон появляется, только если вы сами решите оставить заявку, и всегда с отдельной галочкой согласия по 152-ФЗ. Заявка привязывается к вашему расчёту, чтобы с вами связались именно по нему. Подробнее — на странице про обработку персональных данных.",
],
},
{
id: "bank-report",
q: "Подойдёт для банка или суда?",
a: [
"Нет. Для ипотеки, суда, опеки и нотариуса нужен отчёт аккредитованного оценщика по 135-ФЗ — это отдельная платная процедура с выездом.",
"«Мера» отвечает на другой вопрос: за сколько эта квартира реально продаётся на рынке сегодня.",
],
},
];

View file

@ -0,0 +1,896 @@
/*
* landing.module.css вёрстка публичного лэндинга «МЕРА».
*
* Почему CSS-модуль, а не inline-стили (как в v2): лэндинг обязан быть
* адаптивным (B2C, основной трафик телефон), а медиа-запросы, :hover,
* :focus-visible, ::before и details[open] через `style={{}}` не выражаются.
* v2-компоненты порт фиксированного артборда 1536×1024 и переиспользованию
* на мобильной странице не подлежат; здесь собственная mobile-first сетка на
* тех же токенах.
*
* Все цвета/шрифты через var(--m-*), которые проставляет `theme.ts` на
* корневом <div> в layout.tsx. Хардкод hex здесь запрещён (шапка tokens.ts).
*
* Контраст: цвет текста берётся только из ink2/body/body2/muted на самом
* тёмном фоне страницы (--m-page-bg #dde6ef) у них 4.5:1 по замерам в
* tokens.ts. Акцент используется как заливка/обводка; белым по акценту
* набрана только кнопка (сплошной --m-accent-deep, 4.93:1) подробности в
* шапке theme.ts.
*
* Брейкпоинты: база 360px, затем 720px (планшет / 768) и 1080px (десктоп /
* 1280). Три штуки, больше не нужно.
*
* ТИПОГРАФИКА В px осознанное решение, а не недосмотр; не переписывать на rem
* «заодно». Перевод этого файла на rem САМ ПО СЕБЕ ничего бы не дал: корень
* задан жёстко в `app/globals.css` (`html, body { font-size: 14px }`), а rem
* считается от <html>. То есть пользовательская настройка размера шрифта в
* браузере гасится там, а не здесь, и rem-значения просто отмасштабировались бы
* от тех же 14px плюс лэндинг стал бы мельче (14px вместо 16px базы).
* Настоящий фикс снять хардкод с <html> в globals.css, но это перекраивает
* ВСЕ экраны закрытого контура (они порт фиксированного артборда 1536×1024) и
* делается отдельной задачей, не в PR про публичную страницу.
* Полноэкранный zoom работает и сейчас, поэтому WCAG 1.4.4 не нарушен.
*/
/* --------------------------------------------------------------------------
* Каркас
* -------------------------------------------------------------------------- */
.page {
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--m-page-bg);
color: var(--m-body);
/* Перебиваем глобальные body-стили из app/globals.css (Inter 14px, --bg-app):
лэндинг живёт в типографике «Меры», а не аналитической панели Site Finder. */
font-family: var(--m-font-sans);
font-size: 16px;
line-height: 1.55;
/* Табличные цифры глобально включены в globals.css ради выравнивания чисел в
таблицах; в продающем тексте они выглядят механически. */
font-variant-numeric: normal;
font-feature-settings: normal;
}
.container {
width: 100%;
max-width: 1120px;
margin-inline: auto;
padding-inline: 16px;
}
@media (min-width: 720px) {
.container {
padding-inline: 32px;
}
}
.main {
flex: 1 1 auto;
}
/* Ссылка «к содержимому» — видна только с клавиатуры. */
.skipLink {
position: absolute;
left: -9999px;
top: 0;
z-index: 10;
padding: 10px 16px;
border-radius: 0 0 8px 0;
background: var(--m-accent-deep);
color: var(--m-on-accent);
font-size: 14px;
font-weight: 600;
text-decoration: none;
}
.skipLink:focus {
left: 0;
}
/* Единое кольцо фокуса заметное на всех поверхностях лэндинга.
БЕЗ border-radius: свойства `outline-radius` в стандарте нет, и радиус здесь
применялся бы к самому элементу, а не к контуру. Специфичность (0,2,0) бьёт
.input/.cta/.faqItem (0,1,0), поэтому при табуляции углы поля, селекта и
кнопки скачком менялись с 8px на 4px, а у <summary> скругление появлялось из
ниоткуда. Современные браузеры и так рисуют outline по форме элемента. */
.page :focus-visible {
outline: 2px solid var(--m-accent-deep);
outline-offset: 2px;
}
/* --------------------------------------------------------------------------
* Шапка
* -------------------------------------------------------------------------- */
.header {
border-bottom: 1px solid var(--m-line-soft);
background: var(--m-surface-70);
backdrop-filter: blur(6px);
}
.headerInner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 56px;
padding-block: 10px;
}
.wordmark {
display: flex;
align-items: center;
gap: 9px;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.32em;
color: var(--m-ink2);
}
.wordmarkDot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--m-accent);
flex: 0 0 auto;
}
.headerTag {
font-family: var(--m-font-mono);
font-size: 11px;
letter-spacing: 0.06em;
color: var(--m-muted);
text-align: right;
}
/* --------------------------------------------------------------------------
* Первый экран
* -------------------------------------------------------------------------- */
.hero {
background: var(--m-gradient-bg);
border-bottom: 1px solid var(--m-line-soft);
padding-block: 36px 44px;
}
@media (min-width: 720px) {
.hero {
padding-block: 60px 64px;
}
}
@media (min-width: 1080px) {
.hero {
padding-block: 76px 80px;
}
}
/* Колонка измерения первого экрана ВЛОЖЕНА в `.container` (см. Hero.tsx), а
не навешена на него же. На одном элементе побеждал этот max-width, hero
центрировался по вьюпорту в своих 720px, и его левый край не совпадал с
левым краем всех секций ниже (на 1440px расхождение 200px).
`margin-inline-end: auto` фиксирует прижатие к левому краю контейнера явно,
не полагаясь на дефолт блочного элемента. */
.heroInner {
max-width: 720px;
margin-inline-end: auto;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
font-family: var(--m-font-mono);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--m-muted);
margin: 0 0 14px;
}
.h1 {
margin: 0 0 14px;
font-size: 27px;
line-height: 1.2;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--m-ink2);
text-wrap: balance;
}
@media (min-width: 720px) {
.h1 {
font-size: 38px;
}
}
@media (min-width: 1080px) {
.h1 {
font-size: 44px;
}
}
.heroLead {
margin: 0 0 20px;
font-size: 16px;
line-height: 1.6;
color: var(--m-body);
max-width: 34em;
}
@media (min-width: 720px) {
.heroLead {
font-size: 18px;
}
}
/* Плашка «работаем по области» — граница честно стоит на первом экране. */
.regionBadge {
display: flex;
flex-direction: column;
gap: 4px;
padding: 11px 14px;
margin-bottom: 22px;
border: 1px solid var(--m-info-border);
border-radius: 8px;
background: var(--m-badge-tint);
}
.regionBadgeTitle {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: var(--m-ink2);
}
.regionBadgeCities {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: var(--m-body2);
}
.heroNote {
margin: 18px 0 0;
font-size: 13px;
line-height: 1.55;
color: var(--m-muted);
max-width: 46em;
}
/* --------------------------------------------------------------------------
* Форма адреса
* -------------------------------------------------------------------------- */
.form {
position: relative;
padding: 16px;
border: 1px solid var(--m-line2);
border-radius: 10px;
background: var(--m-surface-85);
box-shadow: 0 1px 2px rgba(28, 44, 64, 0.04);
}
@media (min-width: 720px) {
.form {
padding: 20px;
}
}
.formRow {
display: flex;
flex-direction: column;
gap: 12px;
}
@media (min-width: 720px) {
.formRow {
flex-direction: row;
align-items: flex-end;
}
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.fieldAddress {
flex: 1 1 auto;
}
.fieldCity {
flex: 0 0 auto;
}
@media (min-width: 720px) {
.fieldCity {
width: 210px;
}
}
.label {
font-family: var(--m-font-mono);
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--m-muted);
}
.input,
.select {
width: 100%;
min-width: 0;
box-sizing: border-box;
/* 48px комфортная зона нажатия на телефоне; 16px шрифт не даёт iOS
зумить страницу при фокусе на поле. */
height: 48px;
padding: 0 13px;
border: 1px solid var(--m-line);
border-radius: 8px;
background: var(--m-surface-98);
font-family: inherit;
font-size: 16px;
color: var(--m-ink2);
}
.select {
/* Нативный select на мобиле = системный пикер, ничего лучше не изобретаем. */
appearance: none;
padding-right: 34px;
background-image:
linear-gradient(45deg, transparent 50%, var(--m-body2) 50%),
linear-gradient(135deg, var(--m-body2) 50%, transparent 50%);
background-position:
calc(100% - 18px) 21px,
calc(100% - 13px) 21px;
background-size:
5px 5px,
5px 5px;
background-repeat: no-repeat;
}
.input::placeholder {
color: var(--m-hint);
}
.input:hover,
.select:hover {
border-color: var(--m-bracket);
}
.inputInvalid {
border-color: var(--m-danger);
}
.cta {
height: 48px;
flex: 0 0 auto;
padding: 0 22px;
border: none;
border-radius: 8px;
/* Сплошная заливка, НЕ градиент: у светлого --m-accent контраст с белым
3.36:1 ниже AA. У --m-accent-deep 4.93:1. */
background: var(--m-accent-deep);
color: var(--m-on-accent);
font-family: inherit;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.02em;
cursor: pointer;
transition: filter 0.16s ease;
}
.cta:hover {
filter: brightness(0.92);
}
.cta:active {
transform: translateY(1px);
}
@media (prefers-reduced-motion: reduce) {
.cta {
transition: none;
}
.cta:active {
transform: none;
}
}
.formHint {
margin: 12px 0 0;
font-size: 12.5px;
line-height: 1.5;
color: var(--m-muted);
}
/* Ответ формы (ошибка / честное «расчёт ещё не открыт»). */
.formFeedback {
margin-top: 14px;
padding: 12px 14px;
border-radius: 8px;
border: 1px solid var(--m-info-border);
background: var(--m-info-bg);
font-size: 14px;
line-height: 1.55;
color: var(--m-body2);
}
.formFeedbackError {
border-color: var(--m-danger);
background: var(--m-surface-70);
}
.formFeedbackTitle {
margin: 0 0 4px;
font-size: 14px;
font-weight: 600;
color: var(--m-ink2);
}
.formFeedbackText {
margin: 0;
}
.formFeedbackText + .formFeedbackText {
margin-top: 8px;
}
/* --------------------------------------------------------------------------
* Секции
* -------------------------------------------------------------------------- */
.section {
padding-block: 40px;
border-bottom: 1px solid var(--m-line-soft2);
}
@media (min-width: 720px) {
.section {
padding-block: 60px;
}
}
.sectionAlt {
background: var(--m-surface-50);
}
.sectionHead {
max-width: 46em;
margin-bottom: 24px;
}
.h2 {
margin: 0 0 8px;
font-size: 22px;
line-height: 1.25;
font-weight: 700;
letter-spacing: -0.005em;
color: var(--m-ink2);
text-wrap: balance;
}
@media (min-width: 720px) {
.h2 {
font-size: 28px;
}
}
.sectionLead {
margin: 0;
font-size: 15px;
line-height: 1.6;
color: var(--m-body);
}
/* --------------------------------------------------------------------------
* Шаги
* -------------------------------------------------------------------------- */
.steps {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 14px;
grid-template-columns: 1fr;
}
@media (min-width: 1080px) {
.steps {
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
}
.step {
position: relative;
padding: 18px 18px 20px;
border: 1px solid var(--m-line2);
border-radius: 10px;
background: var(--m-surface-85);
}
/* Номер шага mono-подпись в теле карточки, а не декоративный кружок: сам
порядок несёт <ol>, поэтому подпись помечена aria-hidden и не дублирует
скринридеру «элемент 1 ШАГ 1». */
.stepNum {
display: block;
margin-bottom: 8px;
font-family: var(--m-font-mono);
font-size: 11px;
letter-spacing: 0.08em;
color: var(--m-muted);
}
.stepTitle {
margin: 0 0 6px;
font-size: 17px;
font-weight: 600;
line-height: 1.3;
color: var(--m-ink2);
}
.stepText {
margin: 0;
font-size: 14.5px;
line-height: 1.6;
color: var(--m-body);
}
/* --------------------------------------------------------------------------
* Что получает человек
* -------------------------------------------------------------------------- */
.deliverables {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 2px;
grid-template-columns: 1fr;
border: 1px solid var(--m-line2);
border-radius: 10px;
overflow: hidden;
background: var(--m-line-soft2);
}
@media (min-width: 720px) {
.deliverables {
grid-template-columns: repeat(2, 1fr);
}
/* Разделители здесь это фон контейнера, просвечивающий сквозь gap: 2px.
У приёма есть цена: при НЕЧЁТНОМ числе пунктов вторая ячейка последнего
ряда остаётся пустой, и сквозь неё видно сплошной серый прямоугольник
читается как сломанная карточка, а не как разделитель. Последний пункт в
таком случае растягиваем на всю строку. Правило страхует список от
будущих правок: сегодня пунктов чётное число, завтра может стать не так. */
.deliverable:last-child:nth-child(odd) {
grid-column: 1 / -1;
}
}
.deliverable {
display: flex;
gap: 12px;
padding: 16px 18px;
background: var(--m-surface-85);
}
.checkIcon {
flex: 0 0 auto;
margin-top: 3px;
color: var(--m-accent-deep);
}
.deliverableTitle {
margin: 0 0 4px;
font-size: 16px;
font-weight: 600;
line-height: 1.3;
color: var(--m-ink2);
}
.deliverableText {
margin: 0;
font-size: 14.5px;
line-height: 1.55;
color: var(--m-body);
}
.disclaimer {
display: flex;
gap: 10px;
margin: 16px 0 0;
padding: 13px 15px;
border: 1px solid var(--m-info-border);
border-radius: 8px;
background: var(--m-info-bg);
font-size: 14px;
line-height: 1.55;
color: var(--m-body2);
}
/* --------------------------------------------------------------------------
* Источники данных
* -------------------------------------------------------------------------- */
.sourceGroups {
display: grid;
gap: 14px;
grid-template-columns: 1fr;
}
@media (min-width: 720px) {
.sourceGroups {
grid-template-columns: repeat(2, 1fr);
gap: 20px;
}
}
.sourceGroup {
padding: 18px;
border: 1px solid var(--m-line2);
border-radius: 10px;
background: var(--m-surface-85);
}
.sourceGroupTitle {
margin: 0 0 12px;
font-size: 17px;
font-weight: 600;
color: var(--m-ink2);
}
.sourceChips {
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 0 0 12px;
padding: 0;
}
.sourceChip {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 5px 11px;
border: 1px solid var(--m-line);
border-radius: 999px;
background: var(--m-surface-98);
font-size: 13.5px;
color: var(--m-ink2);
}
.sourceChipDot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--m-accent);
flex: 0 0 auto;
}
.sourceGroupNote {
margin: 0;
font-size: 14px;
line-height: 1.55;
color: var(--m-body);
}
/* --------------------------------------------------------------------------
* FAQ (нативный details доступность из коробки, без клиентского JS)
* -------------------------------------------------------------------------- */
.faqList {
display: grid;
gap: 10px;
max-width: 60em;
}
.faqItem {
border: 1px solid var(--m-line2);
border-radius: 10px;
background: var(--m-surface-85);
overflow: hidden;
}
.faqSummary {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
padding: 15px 16px;
cursor: pointer;
font-size: 16px;
font-weight: 600;
line-height: 1.4;
color: var(--m-ink2);
list-style: none;
}
.faqSummary::-webkit-details-marker {
display: none;
}
.faqSummary:hover {
background: var(--m-surface-98);
}
.faqChevron {
flex: 0 0 auto;
margin-top: 3px;
color: var(--m-body2);
transition: transform 0.18s ease;
}
.faqItem[open] .faqChevron {
transform: rotate(180deg);
}
@media (prefers-reduced-motion: reduce) {
.faqChevron {
transition: none;
}
}
.faqAnswer {
padding: 0 16px 16px;
}
.faqAnswer p {
margin: 0;
font-size: 14.5px;
line-height: 1.62;
color: var(--m-body);
}
.faqAnswer p + p {
margin-top: 10px;
}
/* --------------------------------------------------------------------------
* Подвал
* -------------------------------------------------------------------------- */
.footer {
padding-block: 28px 34px;
background: var(--m-surface-60);
border-top: 1px solid var(--m-line-soft);
}
.footerGrid {
display: grid;
gap: 20px;
grid-template-columns: 1fr;
}
@media (min-width: 720px) {
.footerGrid {
grid-template-columns: 1.2fr 1fr 1fr;
gap: 32px;
}
}
.footerTitle {
margin: 0 0 8px;
font-family: var(--m-font-mono);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--m-muted);
}
.footerText {
margin: 0;
font-size: 14px;
line-height: 1.6;
color: var(--m-body2);
}
.footerText + .footerText {
margin-top: 6px;
}
/* gap: 0 разделение между пунктами теперь даёт вертикальный padding самих
ссылок (см. `.footerLinks .link`). Оставить и gap, и padding нельзя: цели
нажатия по 44px разъехались бы, а подвал вырос бы вдвое. */
.footerLinks {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0;
}
/* Ссылки набраны ink2, а не акцентом: --m-accent-deep на бледно-голубом фоне
даёт 3.90:1 ниже AA. Подчёркивание остаётся всегда, чтобы ссылка
опознавалась не только цветом. */
.link {
color: var(--m-ink2);
font-size: 14px;
text-decoration: underline;
text-underline-offset: 3px;
text-decoration-color: var(--m-bracket);
text-decoration-thickness: 1px;
}
.link:hover {
text-decoration-color: var(--m-accent-deep);
text-decoration-thickness: 2px;
}
/* Ссылки-ПУНКТЫ (подвал, « На главную») самостоятельные цели нажатия, а не
часть фразы: строка в 14px даёт высоту ~19px, то есть вдвое меньше ориентира
~44px, и в подвал на телефоне приходится целиться. Формальный минимум WCAG
2.5.8 они проходили и раньше, но «проходит формально» «удобно пальцем».
Инлайновую ссылку внутри предложения («Поддержка в Telegram: ») правило
намеренно НЕ трогает она под inline-исключением, и раздувать строку текста
было бы хуже, чем оставить как есть. */
.footerLinks .link,
.docBack {
display: inline-block;
min-height: 44px;
padding-block: 12px;
}
.footerBottom {
display: flex;
flex-wrap: wrap;
gap: 6px 18px;
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid var(--m-line-soft);
font-size: 12.5px;
color: var(--m-muted);
}
/* --------------------------------------------------------------------------
* Страница про персональные данные
* -------------------------------------------------------------------------- */
.doc {
padding-block: 32px 56px;
max-width: 46em;
}
/* margin-bottom скомпенсирован на padding-block из правила выше: 8 + 12 = те же
20px визуального отступа, что были до увеличения зоны нажатия. */
.docBack {
margin-bottom: 8px;
}
.doc h2 {
margin: 28px 0 8px;
font-size: 19px;
font-weight: 600;
line-height: 1.3;
color: var(--m-ink2);
}
.doc p,
.doc li {
font-size: 15px;
line-height: 1.65;
color: var(--m-body);
}
.doc p {
margin: 0 0 10px;
}
.doc ul {
margin: 0 0 10px;
padding-left: 20px;
display: grid;
gap: 6px;
}

View file

@ -1,31 +1,36 @@
import type { Metadata } from "next";
import { IBM_Plex_Mono, Manrope } from "next/font/google";
import { pageBg } from "@/components/trade-in/v2/tokens";
import { REGION_NAME } from "./content";
import styles from "./landing.module.css";
import { landingVars } from "./theme";
import { SiteFooter } from "./_components/SiteFooter";
import { SiteHeader } from "./_components/SiteHeader";
/**
* ЭТАП 1 плана B2C-запуска МЕРА: публичный периметр БЕЗ функционала.
* Публичный (B2C) периметр «МЕРЫ»: лэндинг для собственника квартиры.
*
* Обслуживается ОТДЕЛЬНЫМ доменом meraocenka.ru (см. корневой Caddyfile,
* site-блок `meraocenka.ru { ... }`) не напрямую по basePath. Caddy на
* этом домене rewrite'ит запрос корня "/" в "/trade-in/mera-public" (тот
* же образ tradein-frontend, что обслуживает и gendsgn.ru/trade-in/*, у него
* запечён NEXT_PUBLIC_BASE_PATH=/trade-in) пользователь префикс /trade-in
* никогда не видит, это внутренний Caddybackend hop.
* ИЗОЛЯЦИЯ ОТ ЗАКРЫТОГО КОНТУРА главное требование этого дерева. Ни один
* файл под `app/mera-public/**` не импортирует хуки и компоненты B2B-части:
* useMe / useQuota / useBrand / useLogout / useEstimateHistory, RouteGuard,
* Topnav/UserMenu, провайдер чата поддержки. Единственные общие модули
* заведомо безопасные, без сети и авторизации: `v2/tokens.ts` (палитра),
* `lib/city-registry.ts` (города области), `lib/source-registry.ts` (реестр
* источников), `lib/safeUrl.ts` (валидация href).
*
* Изоляция от B2B-дерева (намеренная, см. задачу ЭТАП 1):
* - НЕ импортирует SupportChatProvider (тот живёт только в app/v2/layout.tsx
* поддержка нужна оценщикам с доступом, не анонимным посетителям заглушки).
* - RouteGuard (components/auth/RouteGuard.tsx) содержит явный безусловный
* bypass именно для пути "/mera-public" страница рендерится сразу, без
* ожидания /api/v1/me и без экрана "нет доступа" (в отличие от /ui-preview,
* чей bypass живёт только под dev/CI-флагом ENABLE_PREVIEW, этот always-on,
* т.к. страница публична по продуктовому решению, а не временный QA-артефакт).
* - page.tsx server component без хуков (useMe/useQuota/useHistory не
* импортируются вообще); функционала на этом этапе нет.
* RouteGuard (`components/auth/RouteGuard.tsx`) отдаёт это поддерево ДО вызова
* `useMe()` на публичной странице запроса к `/api/v1/me` не происходит вовсе,
* см. `PublicRouteBypass` там же.
*
* robots: noindex прецедент app/ui-preview/layout.tsx. На ЭТАПЕ 1 страница
* ещё не должна попадать в поисковую индексацию.
* ШРИФТЫ next/font/google, то есть self-hosted: файлы скачиваются на этапе
* сборки и раздаются с нашего домена, запроса к fonts.googleapis.com в рантайме
* нет. Требование «никаких внешних CDN» соблюдено (в отличие от Leaflet в
* v2-картах, который тянется с unpkg на лэндинге карт нет).
*
* robots: noindex сохранён намеренно. Маршрут наружу ещё не открыт (домен и
* периметр Caddy делаются отдельным PR); до этого момента страница не должна
* попадать в индекс. Снять флаг вместе с открытием домена.
*/
const manrope = Manrope({
subsets: ["latin", "cyrillic"],
@ -42,9 +47,8 @@ const plexMono = IBM_Plex_Mono({
});
export const metadata: Metadata = {
title: "МЕРА — оценка вторичного жилья",
description:
"Онлайн-оценка стоимости квартиры на вторичном рынке. Сервис скоро откроется.",
title: "МЕРА — оценка квартиры на вторичном рынке",
description: `Узнайте рыночную стоимость квартиры по адресу: сделки Росреестра и объявления площадок. ${REGION_NAME}.`,
robots: { index: false, follow: false },
};
@ -55,16 +59,20 @@ export default function MeraPublicLayout({
}) {
return (
<div
className={`${manrope.variable} ${plexMono.variable}`}
style={{
minHeight: "100vh",
background: pageBg,
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
className={`${manrope.variable} ${plexMono.variable} ${styles.page}`}
style={landingVars}
>
{/* Цель ссылки <main id="content"> с tabIndex={-1} (см. page.tsx и
privacy/page.tsx). Без tabIndex Safari/VoiceOver не переносит фокус на
фрагмент: он остаётся на самой ссылке, и следующий Tab возвращает
пользователя в шапку то есть skip-link не работает ровно в том
браузере, где он нужнее всего. */}
<a className={styles.skipLink} href="#content">
Перейти к содержимому
</a>
<SiteHeader />
{children}
<SiteFooter />
</div>
);
}

View file

@ -1,48 +1,30 @@
import { tokens } from "@/components/trade-in/v2/tokens";
/**
* ЭТАП 1 B2C-плана публичная заглушка, БЕЗ функционала (никаких форм,
* запросов к API, авторизации). Server component намеренно: нет "use client",
* нет хуков useMe/useQuota/useHistory здесь не используются вообще.
* Изоляция от RouteGuard/B2B-дерева см. комментарий в layout.tsx.
* Публичный лэндинг «МЕРА» первое публичное лицо продукта.
*
* Серверный компонент: никаких хуков, никакого клиентского JS, кроме одного
* острова `AddressForm` (её "use client" оправдан состоянием поля и ответом
* на сабмит). FAQ-аккордеон клиентским НЕ является: он на нативных <details>.
*
* Порядок блоков продиктован тем, в каком порядке у собственника квартиры
* возникают вопросы: что это и работает ли у меня как это устроено что я
* получу можно ли этому верить а если у меня возражение как связаться.
*/
import { DataSources } from "./_components/DataSources";
import { Faq } from "./_components/Faq";
import { Hero } from "./_components/Hero";
import { HowItWorks } from "./_components/HowItWorks";
import { WhatYouGet } from "./_components/WhatYouGet";
import styles from "./landing.module.css";
export default function MeraPublicPage() {
return (
<main
style={{
fontFamily: tokens.font.sans,
color: tokens.ink,
textAlign: "center",
padding: "48px 24px",
maxWidth: 560,
}}
>
<div
style={{
fontFamily: tokens.font.mono,
fontSize: 13,
letterSpacing: "0.08em",
textTransform: "uppercase",
color: tokens.muted,
marginBottom: 16,
}}
>
МЕРА
</div>
<h1
style={{
fontSize: 28,
fontWeight: 600,
marginBottom: 12,
color: tokens.ink2,
}}
>
Оценка квартиры на вторичном рынке
</h1>
<p style={{ fontSize: 16, color: tokens.body, lineHeight: 1.5 }}>
Сервис скоро откроется для всех. Мы готовим публичный запуск загляните
позже.
</p>
<main id="content" className={styles.main} tabIndex={-1}>
<Hero />
<HowItWorks />
<WhatYouGet />
<DataSources />
<Faq />
</main>
);
}

View file

@ -0,0 +1,161 @@
import type { Metadata } from "next";
import Link from "next/link";
import {
LEGAL_ENTITY,
PUBLIC_ESTIMATE_ENABLED,
SUPPORT_TELEGRAM_LABEL,
SUPPORT_TELEGRAM_URL,
} from "../content";
import styles from "../landing.module.css";
import { safeUrl } from "@/lib/safeUrl";
/**
* Страница «Обработка персональных данных» для публичного лэндинга.
*
* ЧТО ЭТО ЗА ДОКУМЕНТ. Это НЕ утверждённая политика по ст. 18.1 152-ФЗ:
* полноценная политика обязана называть оператора (наименование, ИНН, адрес), а
* этих данных в проекте нет см. `LEGAL_ENTITY` в content.ts. Выдумывать
* реквизиты на публичной странице нельзя, поэтому здесь честное описание
* того, что сервис делает с данными СЕГОДНЯ по факту кода:
*
* - расчёт: адрес + параметры квартиры (`TradeInEstimateInput`);
* - заявка: телефон + явное согласие галочкой (`v2/LeadForm.tsx`
* `POST /api/v1/trade-in/lead`), факт согласия сохраняется отдельно;
* - сама эта страница не делает ни одного запроса к API и не подключает
* счётчики (проверено: RouteGuard отдаёт публичный путь до useMe,
* `app/providers.tsx` поднимает только пустой QueryClient, шрифты
* self-hosted через next/font).
*
* ЧЕГО ЗДЕСЬ СОЗНАТЕЛЬНО НЕ НАПИСАНО:
* - «адрес и параметры квартиры это не данные о вас». Правовая
* квалификация не наше дело: заявка привязывается к конкретному расчёту
* (`TradeInLeadInput.estimate_id`), то есть телефон связывается с ранее
* сохранённым адресом. Финальную формулировку даёт юрист.
* - «мы удалим ваш телефон и заявку». Механизма удаления в бэкенде НЕТ:
* ни `DELETE FROM trade_in_leads/trade_in_estimates` в коде, ни
* retention/erasure-джоба среди `app/tasks/**` (проверено grep'ом);
* `expires_at` применяется только на чтении. Обещать удаление до появления
* процедуры нельзя это самое дорогое из обещаний.
*
* Раздел «Что делает эта страница» УСЛОВЕН по `PUBLIC_ESTIMATE_ENABLED`: пока
* расчёт выключен, адрес действительно не покидает браузер; после включения это
* перестанет быть правдой, и текст должен смениться вместе с флагом, а не
* когда-нибудь потом.
*
* Перед открытием домена наружу текст обязан быть заменён на утверждённую
* политику с реквизитами оператора.
*/
export const metadata: Metadata = {
title: "Обработка персональных данных — МЕРА",
robots: { index: false, follow: false },
};
export default function MeraPublicPrivacyPage() {
const telegramHref = safeUrl(SUPPORT_TELEGRAM_URL);
return (
<main id="content" className={styles.main} tabIndex={-1}>
<div className={`${styles.container} ${styles.doc}`}>
<Link
className={`${styles.link} ${styles.docBack}`}
href="/mera-public"
>
На главную
</Link>
<h1 className={styles.h2}>Обработка персональных данных</h1>
<p>
Здесь по-человечески описано, какие данные нужны сервису «Мера», зачем
и что с ними происходит.
</p>
<h2>Что нужно для оценки</h2>
<p>
Адрес дома и параметры квартиры: площадь, этаж, число комнат, тип
дома, состояние. Имя, паспорт и документы на квартиру мы не
спрашиваем, и сами по себе эти сведения описывают объект недвижимости.
</p>
<p>
При этом мы не делаем вид, что связи с вами нет совсем: если вы потом
оставите заявку, ваш телефон будет привязан именно к этому расчёту
то есть к конкретному адресу. Поэтому телефон и обращаемся с ним как с
персональными данными, с отдельным согласием.
</p>
<h2>Когда появляется телефон</h2>
<p>
Только если вы сами решите оставить заявку и поставите отдельную
галочку согласия на обработку персональных данных в соответствии с
Федеральным законом 152-ФЗ. Без этой галочки заявка не отправляется.
</p>
<p>
Телефон используется, чтобы связаться с вами по вашей же заявке.
Вместе с ним сохраняется сам факт согласия когда именно и на каком
тексте оно было дано.
</p>
<h2>Что делает эта страница</h2>
{PUBLIC_ESTIMATE_ENABLED ? (
<p>
Введённый адрес и параметры квартиры уходят на наш сервер, чтобы по
ним посчитать оценку, и сохраняются вместе с результатом расчёта
иначе отчёт нельзя было бы открыть повторно. Счётчиков и рекламных
пикселей на странице нет, шрифты отдаются с нашего домена, а не со
сторонних сервисов.
</p>
) : (
<p>
Ничего не отправляет. Пока публичная оценка не открыта, форма адреса
работает только в браузере: введённый адрес никуда не уходит и нигде
не сохраняется. Счётчиков и рекламных пикселей на странице нет,
шрифты отдаются с нашего домена, а не со сторонних сервисов.
</p>
)}
<h2>Как отозвать согласие</h2>
<p>
Напишите нам в поддержку обращение об отзыве согласия мы принимаем и
разбираем вручную, после чего перестаём использовать ваш телефон для
связи по заявке.{" "}
{telegramHref ? (
<>
Канал связи:{" "}
<a
className={styles.link}
href={telegramHref}
target="_blank"
rel="noreferrer"
>
{SUPPORT_TELEGRAM_LABEL}
</a>
.
</>
) : null}
</p>
<p>
Автоматической кнопки «удалить мои данные» в сервисе пока нет, и мы не
обещаем то, чего не умеем: порядок и сроки удаления будут описаны в
утверждённой политике обработки, которая появится здесь до открытия
публичного доступа.
</p>
<h2>Оператор</h2>
{LEGAL_ENTITY ? (
<p>
{LEGAL_ENTITY.name}, ИНН {LEGAL_ENTITY.inn}, {LEGAL_ENTITY.address}.
</p>
) : (
<p>
Реквизиты оператора персональных данных и утверждённая политика
обработки будут опубликованы здесь до открытия публичного доступа к
сервису. До этого момента страница доступна не публично, а по прямой
ссылке.
</p>
)}
</div>
</main>
);
}

View file

@ -0,0 +1,65 @@
/**
* theme мост между TS-токенами «Меры» и CSS-модулем лэндинга.
*
* Зачем: вёрстка лэндинга живёт в `landing.module.css` (нужны медиа-запросы,
* :hover/:focus-visible, ::before inline-стилями это не выражается), а
* единственный источник правды по цветам/шрифтам `v2/tokens.ts`. Чтобы не
* дублировать hex-литералы в CSS (прямой запрет в шапке tokens.ts), токены
* пробрасываются в CSS как кастомные свойства на корневом <div> лэндинга, а
* CSS ссылается на них через var(--m-*).
*
* ВАЖНО про контраст (WCAG AA 4.5:1): худший фон лэндинга `pageBg`
* (#dde6ef). На нём `accentDeep` (#0d6fd6) даёт лишь 3.90:1, а `accent`
* (#2e8bff) 3.36:1 даже на чистом белом. Поэтому акцентные цвета
* используются ТОЛЬКО как заливка/обводка/декор, но НИКОГДА как цвет текста:
* весь текст берёт ink2/body/body2/muted (у них запас проверен в tokens.ts).
* Единственное исключение белый текст на сплошной заливке accentDeep
* (4.93:1), см. `.cta` в landing.module.css.
*/
import type { CSSProperties } from "react";
import { tokens } from "@/components/trade-in/v2/tokens";
/**
* Набор CSS-переменных лэндинга. Приводится к CSSProperties: TS не знает про
* произвольные `--*` ключи, но React их корректно проставляет в style.
*/
export const landingVars = {
"--m-accent": tokens.accent,
"--m-accent-deep": tokens.accentDeep,
"--m-on-accent": tokens.onAccent,
"--m-ink": tokens.ink,
"--m-ink2": tokens.ink2,
"--m-body": tokens.body,
"--m-body2": tokens.body2,
"--m-muted": tokens.muted,
"--m-muted3": tokens.muted3,
"--m-hint": tokens.hint,
"--m-line": tokens.line,
"--m-line2": tokens.line2,
"--m-line-soft": tokens.lineSoft,
"--m-line-soft2": tokens.lineSoft2,
"--m-bracket": tokens.bracket,
"--m-success": tokens.success,
"--m-danger": tokens.danger,
"--m-info-bg": tokens.infoSoftBg,
"--m-info-border": tokens.infoSoftBorder,
"--m-badge-tint": tokens.badgeTint,
"--m-surface-50": tokens.surface.w50,
"--m-surface-60": tokens.surface.w60,
"--m-surface-70": tokens.surface.w70,
"--m-surface-85": tokens.surface.w85,
"--m-surface-98": tokens.surface.w98,
"--m-page-bg": tokens.pageBg,
"--m-gradient-bg": tokens.gradientBg,
"--m-font-sans": tokens.font.sans,
"--m-font-mono": tokens.font.mono,
} as CSSProperties;

View file

@ -0,0 +1,142 @@
"use client";
/**
* GuardedRoute вся RBAC-механика закрытого контура: запрос `/api/v1/me`,
* редирект на логин по 401, экраны отказа. Раньше жила прямо в `RouteGuard.tsx`.
*
* ПОЧЕМУ ВЫНЕСЕНО В ОТДЕЛЬНЫЙ МОДУЛЬ (а не «так аккуратнее»). Root-layout
* (`app/layout.tsx`) оборачивает в `RouteGuard` ВСЁ дерево приложения, включая
* публичный лэндинг `/mera-public`. Пока этот код лежал в одном модуле с
* `RouteGuard`, webpack складывал в чанк root-layout'а весь статический граф:
* `useMe` `lib/api` `lib/sessionId`, `isPathAllowed`, а через
* `NoAccessScreen` `AnonSupportWidget` `SupportChatProvider` ещё и клиент
* чата поддержки. Анонимный посетитель публичной страницы физически скачивал
* этот JS и мог прочитать в нём имена внутренних ручек (`/api/v1/me`,
* `/api/v1/trade-in/support`, `/api/v1/trade-in/support/anon`) и модель полей
* RBAC (`allowed_paths` / `deny_paths`). Утечки пользовательских данных не было
* ни один запрос не уходил, ни один компонент не рендерился, но
* information disclosure о внутреннем периметре был, и «изоляция публичной
* страницы» соблюдалась только на уровне выполнения, а не поставки бандла.
*
* `RouteGuard` подключает этот модуль через `next/dynamic`, то есть точкой
* разрыва графа. На публичном пути компонент не рендерится чанк не
* запрашивается закрытый код до анонима не доезжает вообще.
*
* SSR намеренно НЕ отключён (`ssr: false` не ставим): на сервере этот компонент
* и раньше отдавал `null` (`useMe` там всегда в isLoading), так что поведение
* закрытых страниц не меняется меняется только момент загрузки чанка на
* клиенте.
*
* Особенность tradein-mvp: Next.js basePath=/trade-in (см. `next.config.ts`).
* `usePathname()` возвращает путь БЕЗ basePath например на странице
* `gendsgn.ru/trade-in/scrapers/avito` хук вернёт `/scrapers/avito`.
* RBAC config (`auth/roles.yaml`) использует абсолютные пути сайта
* (`/trade-in/**`, `/trade-in/api/v1/admin/**`), поэтому перед проверкой
* isPathAllowed мы префиксим pathname через NEXT_PUBLIC_BASE_PATH.
*
* #2555 login redirect: `router.push()` (как и `usePathname()`) работает в
* пространстве путей БЕЗ basePath Next сам префиксит basePath на навигации
* (см. `next.config.ts` комментарий `basePath`). Поэтому `next=` в query
* строится из `rawPath` (БЕЗ basePath), а не `absolutePath` иначе
* `/login/page.tsx` сделал бы `router.push("/trade-in/history")`, и Next
* задвоил бы префикс в `/trade-in/trade-in/history`.
*/
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { NoAccessScreen } from "@/components/auth/NoAccessScreen";
import { HTTPError } from "@/lib/api";
import { isPathAllowed } from "@/lib/isPathAllowed";
import { useMe } from "@/lib/useMe";
const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
// #801: dev/CI-only preview-маршрут (a11y/lighthouse) рендерится оффлайн без RBAC.
const ENABLE_PREVIEW = process.env.NEXT_PUBLIC_ENABLE_PREVIEW === "1";
export default function GuardedRoute({
rawPath,
children,
}: {
rawPath: string;
children: React.ReactNode;
}) {
const router = useRouter();
// Абсолютный путь сайта: BASE_PATH + rawPath. Аккуратно с двойным слэшем
// на `/`: `BASE_PATH = "/trade-in"` + `"/"` → `/trade-in/` (ок).
const absolutePath = BASE_PATH
? `${BASE_PATH}${rawPath === "/" ? "/" : rawPath}`
: rawPath;
const { data, isLoading, error } = useMe();
// #2555: /login сам себя не гейтит — иначе редирект-петля (401 на /me →
// редирект на /login → RouteGuard на /login опять видит 401 → редирект…).
const isLoginPage = rawPath === "/login";
// Prod-only: сессия истекла/отсутствует → уводим на логин вместо старого
// NoAccessScreen variant="session". Редирект — побочный эффект (нельзя
// router.push во время рендера), поэтому useEffect; пока он не сработал,
// рендерим null (см. return ниже), чтобы не мигал старый contents.
const shouldRedirectToLogin =
!isLoginPage &&
process.env.NODE_ENV === "production" &&
error instanceof HTTPError &&
error.status === 401;
useEffect(() => {
if (!shouldRedirectToLogin) return;
// PR #2562 review finding 3: deep-links carry их state в query (`/v2?id=
// <uuid>` — см. next.config.ts redirect comment про restore-by-id). Без
// `window.location.search` юзер, чья сессия истекла mid-session на такой
// ссылке, после логина попадал бы на голый `/v2` и терял отчёт. Effect
// — гарантированно client-side (useEffect тело никогда не бежит на SSR),
// поэтому `window` тут безопасен без typeof-guard.
const next = `${rawPath}${window.location.search}`;
router.push(`/login?next=${encodeURIComponent(next)}`);
}, [shouldRedirectToLogin, rawPath, router]);
// #801: preview-страница самодостаточна (свой QueryClient с фейковым me),
// RBAC к ней не применяем. Только под флагом — в проде по умолчанию выключено.
if (ENABLE_PREVIEW && rawPath.startsWith("/ui-preview")) {
return <>{children}</>;
}
if (isLoginPage) {
return <>{children}</>;
}
if (isLoading) return null;
if (error instanceof HTTPError && error.status === 401) {
// Dev without Caddy: 401 is normal, mount the app so local dev works.
if (process.env.NODE_ENV !== "production") return <>{children}</>;
// Prod: редирект уже запущен эффектом выше — ничего не рендерим, пока
// навигация не завершится (mounting children on 401 causes TanStack
// Query re-subscribe storm, см. историю до #2555 в git blame).
return null;
}
if (error instanceof HTTPError && error.status === 403) {
return <NoAccessScreen variant="user" />;
}
if (error) {
// Не 401/403 — 500 / сетевой сбой / таймаут. НЕ переиспользуем variant="user"
// (текст «аккаунт не привязан к роли» вводит в заблуждение при техническом
// сбое) — отдельный экран с честной формулировкой + reload CTA.
return <NoAccessScreen variant="error" />;
}
if (!data) return null;
// Пробный доступ закончился (#praktika): role=expired → спец-экран, а не generic path-deny.
if (data.role === "expired") {
return <NoAccessScreen variant="trial" />;
}
if (!isPathAllowed(data.allowed_paths, data.deny_paths, absolutePath)) {
return <NoAccessScreen variant="path" path={absolutePath} />;
}
return <>{children}</>;
}

View file

@ -1,132 +1,77 @@
"use client";
/**
* MIRROR of main frontend `frontend/src/components/auth/RouteGuard.tsx`
* keep in sync manually.
* РАСХОДИТСЯ с зеркалом `frontend/src/components/auth/RouteGuard.tsx` (Site
* Finder) намеренно и уже не является его копией: там нет ни публичного
* B2C-периметра, ни выноса гварда в отдельный чанк. Синхронизировать построчно
* больше нельзя при правках RBAC-логики править `GuardedRoute.tsx`, сверяясь
* с зеркалом по смыслу, а не по диффу.
*
* Особенность tradein-mvp: Next.js basePath=/trade-in (см. `next.config.ts`).
* `usePathname()` возвращает путь БЕЗ basePath например на странице
* `gendsgn.ru/trade-in/scrapers/avito` хук вернёт `/scrapers/avito`.
* RBAC config (`auth/roles.yaml`) использует абсолютные пути сайта
* (`/trade-in/**`, `/trade-in/api/v1/admin/**`), поэтому перед проверкой
* isPathAllowed мы префиксим pathname через NEXT_PUBLIC_BASE_PATH.
* Этот модуль намеренно ДЕРЖИТСЯ ПУСТЫМ по зависимостям: он импортирует только
* `usePathname` и `next/dynamic`. Причина `app/layout.tsx` оборачивает в него
* ВСЁ дерево, включая публичный лэндинг `/mera-public`, поэтому всё, что здесь
* импортировано статически, webpack кладёт в чанк root-layout'а и отдаёт
* анонимному посетителю публичной страницы. До выноса `GuardedRoute` в
* `next/dynamic` туда уезжали `useMe` `lib/api` `lib/sessionId`,
* `isPathAllowed` и через `NoAccessScreen` `AnonSupportWidget` клиент
* чата поддержки; в публичном JS читались имена внутренних ручек и модель
* RBAC-полей. Подробный разбор в шапке `GuardedRoute.tsx`.
*
* #2555 login redirect: `router.push()` (как и `usePathname()`) работает в
* пространстве путей БЕЗ basePath Next сам префиксит basePath на навигации
* (см. `next.config.ts` комментарий `basePath`). Поэтому `next=` в query
* строится из `rawPath` (БЕЗ basePath), а не `absolutePath` иначе
* `/login/page.tsx` сделал бы `router.push("/trade-in/history")`, и Next
* задвоил бы префикс в `/trade-in/trade-in/history`.
* НЕ добавляй сюда статических импортов из закрытого контура. Всё, что нужно
* гварду, живёт за `dynamic()`.
*/
import { useRouter, usePathname } from "next/navigation";
import { useEffect } from "react";
import dynamic from "next/dynamic";
import { usePathname } from "next/navigation";
import { NoAccessScreen } from "@/components/auth/NoAccessScreen";
import { HTTPError } from "@/lib/api";
import { isPathAllowed } from "@/lib/isPathAllowed";
import { useMe } from "@/lib/useMe";
// Точка разрыва графа модулей: отдельный чанк, который запрашивается ТОЛЬКО
// если компонент реально отрендерился, т.е. никогда — на публичном пути.
// ssr не отключаем: на сервере GuardedRoute и раньше отдавал null (useMe там
// всегда isLoading), поведение закрытых страниц не меняется.
const GuardedRoute = dynamic(() => import("@/components/auth/GuardedRoute"));
const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
// #801: dev/CI-only preview-маршрут (a11y/lighthouse) рендерится оффлайн без RBAC.
const ENABLE_PREVIEW = process.env.NEXT_PUBLIC_ENABLE_PREVIEW === "1";
// ЭТАП 1 B2C-плана МЕРА: публичный периметр (meraocenka.ru → rewrite на
// /trade-in/mera-public, см. Caddyfile). В отличие от ENABLE_PREVIEW выше —
// Публичный (B2C) периметр МЕРЫ. В отличие от NEXT_PUBLIC_ENABLE_PREVIEW —
// ЭТОТ bypass ВСЕГДА включён (в т.ч. в проде): страница публична по
// продуктовому решению, а не временный QA-артефакт.
// продуктовому решению, а не временный QA-артефакт. Домен/rewrite для неё
// настраиваются отдельным PR — на поведение гварда это не влияет, он смотрит
// на путь.
//
// ⚠️ Пути здесь — БЕЗ basePath. `usePathname()` в Next возвращает путь в
// пространстве без префикса: в проде (basePath=/trade-in) на странице
// `gendsgn.ru/trade-in/mera-public` хук вернёт именно `/mera-public`. Тот же
// инвариант подтверждается тем, что `GuardedRoute` вынужден вручную клеить
// BASE_PATH обратно (`absolutePath`), чтобы сматчить globs из roles.yaml.
// Если добавлять сюда `/trade-in/mera-public` — bypass молча перестанет
// срабатывать в проде, и аноним поедет на /login.
const PUBLIC_PATHS = ["/mera-public"];
function isPublicPath(rawPath: string): boolean {
return PUBLIC_PATHS.some((p) => rawPath === p || rawPath.startsWith(`${p}/`));
}
interface RouteGuardProps {
children: React.ReactNode;
}
/**
* Внешняя оболочка: решает ТОЛЬКО по пути и не вызывает ни одного хука данных.
*
* Почему разделено на два компонента, а не «ранний return внутри одного»:
* хуки выполняются всегда, до любых early-return. Пока `useMe()` и
* redirect-эффект жили в одном компоненте с bypass'ом, на публичной странице в
* проде происходило вот что: фоновый GET /api/v1/me 401 эффект
* `shouldRedirectToLogin` уводил анонимного посетителя на
* `/login?next=%2Fmera-public`, полностью аннулируя «безусловный bypass».
* Вынос useMe() в дочерний `GuardedRoute` единственный способ гарантировать,
* что на публичном пути запроса к /me не происходит ВООБЩЕ (заодно исчезает
* лишний 401 в консоли посетителя и подписка TanStack Query).
*/
export function RouteGuard({ children }: RouteGuardProps) {
const rawPath = usePathname() ?? "/";
const router = useRouter();
// Абсолютный путь сайта: BASE_PATH + rawPath. Аккуратно с двойным слэшем
// на `/`: `BASE_PATH = "/trade-in"` + `"/"` → `/trade-in/` (ок).
const absolutePath = BASE_PATH
? `${BASE_PATH}${rawPath === "/" ? "/" : rawPath}`
: rawPath;
const { data, isLoading, error } = useMe();
// #2555: /login сам себя не гейтит — иначе редирект-петля (401 на /me →
// редирект на /login → RouteGuard на /login опять видит 401 → редирект…).
const isLoginPage = rawPath === "/login";
// Prod-only: сессия истекла/отсутствует → уводим на логин вместо старого
// NoAccessScreen variant="session". Редирект — побочный эффект (нельзя
// router.push во время рендера), поэтому useEffect; пока он не сработал,
// рендерим null (см. return ниже), чтобы не мигал старый contents.
const shouldRedirectToLogin =
!isLoginPage &&
process.env.NODE_ENV === "production" &&
error instanceof HTTPError &&
error.status === 401;
useEffect(() => {
if (!shouldRedirectToLogin) return;
// PR #2562 review finding 3: deep-links carry их state в query (`/v2?id=
// <uuid>` — см. next.config.ts redirect comment про restore-by-id). Без
// `window.location.search` юзер, чья сессия истекла mid-session на такой
// ссылке, после логина попадал бы на голый `/v2` и терял отчёт. Effect
// — гарантированно client-side (useEffect тело никогда не бежит на SSR),
// поэтому `window` тут безопасен без typeof-guard.
const next = `${rawPath}${window.location.search}`;
router.push(`/login?next=${encodeURIComponent(next)}`);
}, [shouldRedirectToLogin, rawPath, router]);
// #801: preview-страница самодостаточна (свой QueryClient с фейковым me),
// RBAC к ней не применяем. Только под флагом — в проде по умолчанию выключено.
if (ENABLE_PREVIEW && rawPath.startsWith("/ui-preview")) {
if (isPublicPath(rawPath)) {
return <>{children}</>;
}
if (isLoginPage) {
return <>{children}</>;
}
// ЭТАП 1 B2C: публичная заглушка МЕРА не ждёт /api/v1/me и не проверяет
// RBAC — рендерится сразу для анонимного посетителя. useMe() выше уже
// вызван (Rules of Hooks — нельзя условно), но его результат здесь
// игнорируется: фоновый запрос к /me (скорее всего 401 без сессии) ни на
// что не влияет, страница не зависит от него — как и /ui-preview выше.
if (PUBLIC_PATHS.some((p) => rawPath === p || rawPath.startsWith(`${p}/`))) {
return <>{children}</>;
}
if (isLoading) return null;
if (error instanceof HTTPError && error.status === 401) {
// Dev without Caddy: 401 is normal, mount the app so local dev works.
if (process.env.NODE_ENV !== "production") return <>{children}</>;
// Prod: редирект уже запущен эффектом выше — ничего не рендерим, пока
// навигация не завершится (mounting children on 401 causes TanStack
// Query re-subscribe storm, см. историю до #2555 в git blame).
return null;
}
if (error instanceof HTTPError && error.status === 403) {
return <NoAccessScreen variant="user" />;
}
if (error) {
// Не 401/403 — 500 / сетевой сбой / таймаут. НЕ переиспользуем variant="user"
// (текст «аккаунт не привязан к роли» вводит в заблуждение при техническом
// сбое) — отдельный экран с честной формулировкой + reload CTA.
return <NoAccessScreen variant="error" />;
}
if (!data) return null;
// Пробный доступ закончился (#praktika): role=expired → спец-экран, а не generic path-deny.
if (data.role === "expired") {
return <NoAccessScreen variant="trial" />;
}
if (!isPathAllowed(data.allowed_paths, data.deny_paths, absolutePath)) {
return <NoAccessScreen variant="path" path={absolutePath} />;
}
return <>{children}</>;
return <GuardedRoute rawPath={rawPath}>{children}</GuardedRoute>;
}

View file

@ -25,6 +25,7 @@ from __future__ import annotations
import asyncio
import hashlib
import logging
import math
import random
from abc import ABC, abstractmethod
from datetime import date, datetime, timedelta
@ -294,6 +295,22 @@ class BaseScraper(ABC):
...
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Ортодромическое расстояние (км) между двумя точками (сферическая Земля).
Используется гео-guard'ом `save_listings(..., city_anchor=..., city_radius_km=...)`
(соседний-город-в-развёртке): сверяет физические координаты лота с anchor'ом
города-цели без ST_DWithin/PostGIS round-trip чистая математика, лот уже в
памяти (lat/lon Python float на ScrapedLot).
"""
r_earth_km = 6371.0
p1, p2 = math.radians(lat1), math.radians(lat2)
d_phi = math.radians(lat2 - lat1)
d_lmb = math.radians(lon2 - lon1)
a = math.sin(d_phi / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(d_lmb / 2) ** 2
return 2 * r_earth_km * math.asin(math.sqrt(a))
# ── Запись пачки результатов в Postgres ─────────────────────────────────────
def save_listings(
db: Session,
@ -304,6 +321,8 @@ def save_listings(
run_id: int | None = None,
skip_seen_today: bool = False,
city: str | None = None,
city_anchor: tuple[float, float] | None = None,
city_radius_km: float | None = None,
) -> tuple[int, int]:
"""Пишем list[ScrapedLot] в `listings` с upsert по dedup_hash.
@ -333,6 +352,21 @@ def save_listings(
admin/manual пути) колонка остаётся NULL, backward-compatible.
ON CONFLICT COALESCE (новое значение НЕ затирает уже известный город
NULL'ом, если какой-то caller ещё не передаёт city).
city_anchor: (lat, lon) референсной точки города-цели этого batch'агео-guard
(соседний-город-в-развёртке, замер на проде: 97% лотов, помеченных
"Верхняя Пышма" из yandex-развёртки radius_m=25000, физически лежат в
Екатеринбурге anchor города-цели всего ~15км от центра ЕКБ, широкий
radius_m захватывает весь ЕКБ). Если задан ВМЕСТЕ с `city_radius_km` для
каждого лота С координатами (lot.lat/lot.lon НЕ None) считаем haversine-
расстояние до `city_anchor`; лот ДАЛЬШЕ `city_radius_km` НЕ получает `city`
этого batch'а (пишется NULL, а не угадывается чужой город). Лоты БЕЗ
координат city проставляется как обычно (нечем сверить; провайдер уже
скоупил SERP/API-запрос на этот город через city_slug/rgid/region_id см.
resolve_city_name в orchestration/pipeline.py). None/None (default,
backward-compatible) guard выключен, старое поведение (ОДИН city на
весь batch без проверки координат) так вызывается EKB-развёртка (нет
в регионе города КРУПНЕЕ ЕКБ, чей SERP мог бы её "поглотить").
city_radius_km: см. `city_anchor` оба параметра включают guard ТОЛЬКО вместе.
Returns:
(inserted, updated) counters для логов.
@ -346,14 +380,38 @@ def save_listings(
reconciled = 0 # UPDATE by (source,source_id) при dedup_hash-дрейфе
matched = 0
match_failures = 0
geo_guard_dropped = 0 # city NULL'ен из-за geo-guard (лот вне city_radius_km от anchor'а)
today_msk = datetime.now(_MSK).date()
_geo_guard_active = city is not None and city_anchor is not None and city_radius_km is not None
for lot in lots:
ppm2 = lot.price_per_m2 or lot.compute_price_per_m2()
dedup = lot.compute_dedup_hash()
card_hash = lot.compute_card_hash()
# ── Гео-guard: соседний-город-в-развёртке (см. docstring city_anchor) ──
# Лот С координатами дальше city_radius_km от anchor'аНЕ доверяем city
# этого batch'а (пишем NULL, не текущий-но-неверный город). Лоты БЕЗ координат
# проверить нечем — city проставляется как обычно (см. docstring).
lot_city = city
if _geo_guard_active and lot.lat is not None and lot.lon is not None:
assert city_anchor is not None and city_radius_km is not None # narrow for mypy
dist_km = _haversine_km(lot.lat, lot.lon, city_anchor[0], city_anchor[1])
if dist_km > city_radius_km:
lot_city = None
geo_guard_dropped += 1
logger.debug(
"save_listings:geo_guard_dropped source=%s dedup=%s dist_km=%.1f "
"> city_radius_km=%.1f (target_city=%s anchor=%s)",
lot.source,
dedup,
dist_km,
city_radius_km,
city,
city_anchor,
)
# Pre-read the existing row's card_hash and last_seen_at (keyed by
# dedup_hash) BEFORE the upsert — needed to know the *prior* card
# content and to implement skip_seen_today logic.
@ -389,7 +447,7 @@ def save_listings(
"dedup": dedup,
"region_code": region_code,
"address": lot.address,
"city": city,
"city": lot_city,
"lat": lot.lat,
"lon": lot.lon,
"rooms": lot.rooms,
@ -777,7 +835,7 @@ def save_listings(
db.commit()
logger.info(
"save_listings: source=%s inserted=%d updated=%d reconciled=%d "
"skipped_seen_today=%d matched=%d match_failures=%d (total %d)",
"skipped_seen_today=%d matched=%d match_failures=%d geo_guard_dropped=%d (total %d)",
lots[0].source if lots else "?",
inserted,
updated,
@ -785,6 +843,7 @@ def save_listings(
skipped,
matched,
match_failures,
geo_guard_dropped,
len(lots),
)
return inserted, updated

View file

@ -310,6 +310,29 @@ def get_city_anchors(city_slug: str | None) -> list[tuple[float, float, str]] |
return CITY_ANCHORS.get(city_slug)
def get_city_anchor_point(city_slug: str | None) -> tuple[float, float] | None:
"""(lat, lon) референсной точки города-цели — для гео-guard'а `save_listings`.
Соседний-город-в-развёртке (замер на проде): oblast city-sweep стамповал СВОЙ
город-цель на 100% найденного, включая лоты, физически лежащие в куда более
крупном соседнем городе, случайно захваченные широким radius_m/loose SERP-city-
фильтром провайдера (напр. Верхняя Пышма ~15км от ЕКБ, yandex radius_m=25000
97% "verkhnyaya_pyshma"-развёртки на проде физически в ЕКБ). Берём ПЕРВЫЙ (и пока
единственный) anchor CITY_ANCHORS[city_slug] та же точка, что реально ходит в
scraper (fetch_around/fetch_around_multi_room), поэтому guard сверяется с
РЕАЛЬНЫМ центром запроса, а не с отдельно захардкоженными координатами.
None (EKB/неизвестный slug) гео-guard НЕ применяется у вызывающей стороны (см.
run_*_city_sweep): в регионе нет города КРУПНЕЕ ЕКБ, чей SERP мог бы её
"поглотить" симметричный риск для ЕКБ-развёртки отсутствует.
"""
anchors = get_city_anchors(city_slug)
if not anchors:
return None
lat, lon, _name = anchors[0]
return (lat, lon)
@dataclass(frozen=True)
class CityLocation:
"""Per-provider гео-идентификаторы города-цели SERP-запроса (oblast rollout).
@ -378,6 +401,43 @@ def resolve_city_name(city_slug: str | None) -> str:
return CITY_DISPLAY_NAMES.get(city_slug, EKATERINBURG_CITY_NAME)
# Гео-guard радиус (км) от anchor'а города-цели (get_city_anchor_point), за пределами
# которого save_listings НЕ доверяет city этого batch'а — см. save_listings docstring
# (scraper_kit.base) и замер на проде в PR. НЕ путать с radius_m поисковых запросов
# scraper'а (avito/cian 1500м, yandex 25000м вокруг того же anchor'а — ЭТО определяет
# ЧТО скачано; guard-радиус — какому city-batch'у из скачанного верить).
#
# Дефолт 15км — с запасом покрывает застройку города + ближние пригороды и остаётся
# НАМНОГО меньше дистанции до ЕКБ у 4 из 5 oblast-городов (Первоуральск ~41км,
# Каменск-Уральский ~93км, Нижний Тагил ~125км, Серов ~307км от центра ЕКБ) — guard
# у них никогда ложно не режет настоящие лоты, но всё ещё ловит редкие выбросы
# (напр. геокод-артефакты, см. PR: 3 avito-лота "Серов" физически в ЕКБ).
#
# Верхняя Пышма — особый случай: САМ anchor'а города лишь ~15.3км от центра ЕКБ
# (агломерации почти смыкаются) — дефолтный 15км-порог никогда бы не сработал (лот
# из ЕКБ остался бы формально "в радиусе" В.Пышмы). Уменьшенный порог 8км (~половина
# дистанции до ЕКБ, safety margin ~7.3км) всё ещё покрывает застройку самой
# В.Пышмы (компактный город, ~5км в поперечнике) и реально режет EKB-заброс (на
# проде — 97% "verkhnyaya_pyshma"-лотов из yandex-развёртки).
_DEFAULT_CITY_STAMP_RADIUS_KM: float = 15.0
_CITY_STAMP_RADIUS_KM: dict[str, float] = {
"verkhnyaya_pyshma": 8.0,
}
def get_city_stamp_radius_km(city_slug: str | None) -> float:
"""Гео-guard радиус (км) для city_slug — см. `_CITY_STAMP_RADIUS_KM` выше.
None/неизвестный slug `_DEFAULT_CITY_STAMP_RADIUS_KM`. Вызывающая сторона
передаёт результат в `save_listings(..., city_radius_km=...)` ТОЛЬКО вместе с
`get_city_anchor_point(city_slug)` (при anchor=None guard всё равно выключен
см. save_listings docstring).
"""
if city_slug is None:
return _DEFAULT_CITY_STAMP_RADIUS_KM
return _CITY_STAMP_RADIUS_KM.get(city_slug, _DEFAULT_CITY_STAMP_RADIUS_KM)
_CHROME_HEADERS = {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
@ -934,6 +994,13 @@ async def run_avito_city_sweep(
# #2594: город для save_listings(..., city=...) — один на весь sweep (все anchor'ы
# одного run'а бьют по одному city_slug), вычисляем один раз до цикла.
_city_name = resolve_city_name(city_slug)
# Гео-guard (соседний-город-в-развёртке): anchor=None у ЕКБ (city_slug=None) — guard
# выключен у save_listings (оба параметра обязаны быть not-None вместе), старое
# поведение. См. get_city_anchor_point docstring.
_city_anchor_point = get_city_anchor_point(city_slug)
_city_radius_km = (
get_city_stamp_radius_km(city_slug) if _city_anchor_point is not None else None
)
counters = CitySweepCounters(anchors_total=len(_anchors))
all_touched_house_ids: set[int] = set()
@ -1078,6 +1145,8 @@ async def run_avito_city_sweep(
matcher=matcher,
region_code=region_code,
city=_city_name,
city_anchor=_city_anchor_point,
city_radius_km=_city_radius_km,
)
counters.lots_inserted += ins
counters.lots_updated += upd
@ -1833,6 +1902,13 @@ async def run_yandex_city_sweep(
_loc = get_city_location(city_slug)
# #2594: город для save_listings(..., city=...) — один на весь sweep.
_city_name = resolve_city_name(city_slug)
# Гео-guard (соседний-город-в-развёртке): anchor=None у ЕКБ (city_slug=None) — guard
# выключен у save_listings (оба параметра обязаны быть not-None вместе), старое
# поведение. См. get_city_anchor_point docstring.
_city_anchor_point = get_city_anchor_point(city_slug)
_city_radius_km = (
get_city_stamp_radius_km(city_slug) if _city_anchor_point is not None else None
)
_rooms_list = rooms_list or list(ROOM_PATH.keys())
_price_ranges = price_ranges or DEFAULT_PRICE_RANGES
@ -1937,6 +2013,8 @@ async def run_yandex_city_sweep(
region_code=region_code,
run_id=run_id,
city=_city_name,
city_anchor=_city_anchor_point,
city_radius_km=_city_radius_km,
)
counters.lots_inserted += ins
counters.lots_updated += upd
@ -2346,6 +2424,13 @@ async def run_cian_city_sweep(
_loc = get_city_location(city_slug)
# #2594: город для save_listings(..., city=...) — один на весь sweep.
_city_name = resolve_city_name(city_slug)
# Гео-guard (соседний-город-в-развёртке): anchor=None у ЕКБ (city_slug=None) — guard
# выключен у save_listings (оба параметра обязаны быть not-None вместе), старое
# поведение. См. get_city_anchor_point docstring.
_city_anchor_point = get_city_anchor_point(city_slug)
_city_radius_km = (
get_city_stamp_radius_km(city_slug) if _city_anchor_point is not None else None
)
counters = CianCitySweepCounters(anchors_total=len(_anchors))
consecutive_failures = 0
cian_rotations_done = 0 # #1848: бюджет IP-ротаций на весь sweep
@ -2447,6 +2532,8 @@ async def run_cian_city_sweep(
region_code=region_code,
run_id=run_id,
city=_city_name,
city_anchor=_city_anchor_point,
city_radius_km=_city_radius_km,
)
counters.lots_inserted += inserted
counters.lots_updated += updated