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
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:
commit
537b0ac06e
89 changed files with 10542 additions and 3409 deletions
19
Caddyfile
19
Caddyfile
|
|
@ -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-данных, ни секретов.
|
||||
|
|
|
|||
|
|
@ -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-хешей.
|
||||
|
||||
|
|
|
|||
408
data/sql/auth/004_users_roles_and_access_state.sql
Normal file
408
data/sql/auth/004_users_roles_and_access_state.sql
Normal 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;
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 ключей) для обеих веток.
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
162
tradein-mvp/backend/app/core/auth_db.py
Normal file
162
tradein-mvp/backend/app/core/auth_db.py
Normal 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()
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -343,6 +343,10 @@ async def suggest_addresses(
|
|||
жёсткий фильтр (не boost) на уровне указанного admin-поля — доп.
|
||||
параметров не требуется. По умолчанию не задан — поведение (и body
|
||||
запроса) для существующих вызовов не меняется.
|
||||
ВАЖНО: значение сравнивается с полем DaData `region`, где имя лежит
|
||||
БЕЗ типа («Свердловская», а тип — отдельно в `region_type`="обл").
|
||||
Передашь «Свердловская область» — совпадений не будет, и запрос
|
||||
вернёт ПУСТО без всякой ошибки (hard-filter, не boost).
|
||||
|
||||
Returns:
|
||||
list[DadataSuggestion] — пустой список если:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
291
tradein-mvp/backend/app/services/identity_store.py
Normal file
291
tradein-mvp/backend/app/services/identity_store.py
Normal 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
|
||||
|
|
@ -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 ─────────────────
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
370
tradein-mvp/backend/app/services/proxy_rotation.py
Normal file
370
tradein-mvp/backend/app/services/proxy_rotation.py
Normal 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)),
|
||||
)
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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), завершаем",
|
||||
|
|
|
|||
|
|
@ -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 на проде показал cost≈300k именно на этом шаге. Реально это никогда не завершалось
|
||||
за 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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
49
tradein-mvp/backend/data/sql/198_scrape_proxy_rotations.sql
Normal file
49
tradein-mvp/backend/data/sql/198_scrape_proxy_rotations.sql
Normal 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;
|
||||
|
|
@ -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;
|
||||
100
tradein-mvp/backend/data/sql/200_region_code_foreign_cities.sql
Normal file
100
tradein-mvp/backend/data/sql/200_region_code_foreign_cities.sql
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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.3k–3.7k | UPDATE landed, lat/lon now populated |
|
||||
| `imprecise` | ~300–500 | Match returned but precision too low — needs review |
|
||||
| `no_match` | ~100–300 | 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-аудита).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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;
|
||||
|
|
@ -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())
|
||||
|
|
@ -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;
|
||||
|
|
@ -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())
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
509
tradein-mvp/backend/tests/services/test_proxy_rotation.py
Normal file
509
tradein-mvp/backend/tests/services/test_proxy_rotation.py
Normal 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
|
||||
200
tradein-mvp/backend/tests/support/identity_modes.py
Normal file
200
tradein-mvp/backend/tests/support/identity_modes.py
Normal 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)
|
||||
|
|
@ -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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.877→0.62, «выкупная» −29%. Оба
|
||||
deal-CTE (per-rooms + global) должны нести предикат; ask-стороны — НЕ трогаем.
|
||||
Миграция 177 залила ДКП по всей обл.66, а asking (listings) исторически — только ЕКБ.
|
||||
Без скоупа sold-медиана смешивала дешёвую область → ratio 0.877→0.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))))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
469
tradein-mvp/backend/tests/test_auth_dsn_from_parts.py
Normal file
469
tradein-mvp/backend/tests/test_auth_dsn_from_parts.py
Normal 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
|
||||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
187
tradein-mvp/backend/tests/test_estimator_implausible_year.py
Normal file
187
tradein-mvp/backend/tests/test_estimator_implausible_year.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
91
tradein-mvp/backend/tests/test_geocoder_nominatim_lookup.py
Normal file
91
tradein-mvp/backend/tests/test_geocoder_nominatim_lookup.py
Normal 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
|
||||
491
tradein-mvp/backend/tests/test_identity_store.py
Normal file
491
tradein-mvp/backend/tests/test_identity_store.py
Normal 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` не должен строиться на старте"
|
||||
|
|
@ -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] = {}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
@ -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())
|
||||
|
|
@ -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())
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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) ───────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 "Не удалось войти. Проверьте подключение и попробуйте ещё раз";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
65
tradein-mvp/frontend/src/app/mera-public/_components/Faq.tsx
Normal file
65
tradein-mvp/frontend/src/app/mera-public/_components/Faq.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
295
tradein-mvp/frontend/src/app/mera-public/content.ts
Normal file
295
tradein-mvp/frontend/src/app/mera-public/content.ts
Normal 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-ФЗ — это отдельная платная процедура с выездом.",
|
||||
"«Мера» отвечает на другой вопрос: за сколько эта квартира реально продаётся на рынке сегодня.",
|
||||
],
|
||||
},
|
||||
];
|
||||
896
tradein-mvp/frontend/src/app/mera-public/landing.module.css
Normal file
896
tradein-mvp/frontend/src/app/mera-public/landing.module.css
Normal 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
* никогда не видит, это внутренний Caddy→backend 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
161
tradein-mvp/frontend/src/app/mera-public/privacy/page.tsx
Normal file
161
tradein-mvp/frontend/src/app/mera-public/privacy/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
tradein-mvp/frontend/src/app/mera-public/theme.ts
Normal file
65
tradein-mvp/frontend/src/app/mera-public/theme.ts
Normal 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;
|
||||
142
tradein-mvp/frontend/src/components/auth/GuardedRoute.tsx
Normal file
142
tradein-mvp/frontend/src/components/auth/GuardedRoute.tsx
Normal 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}</>;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue