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
|
output file /var/log/caddy/meraocenka.ru.log
|
||||||
}
|
}
|
||||||
|
|
||||||
# Единственная публичная страница этого этапа — заглушка "скоро".
|
# Корень домена → лэндинг МЕРЫ (#2615 заменил заглушку этого этапа на
|
||||||
# rewrite добавляет basePath-префикс только для Caddy→backend хопа.
|
# полноценную страницу). rewrite добавляет basePath-префикс только для
|
||||||
|
# Caddy→backend хопа, пользователь /trade-in никогда не видит.
|
||||||
handle / {
|
handle / {
|
||||||
rewrite * /trade-in/mera-public
|
rewrite * /trade-in/mera-public
|
||||||
reverse_proxy tradein-frontend:3000
|
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-префиксом (тот же
|
# Next.js уже эмитит ссылки на статику с /trade-in-префиксом (тот же
|
||||||
# basePath) — passthrough без rewrite. Нужны для рендера страницы (JS/CSS
|
# basePath) — passthrough без rewrite. Нужны для рендера страницы (JS/CSS
|
||||||
# чанки), сами по себе не содержат ни B2B-данных, ни секретов.
|
# чанки), сами по себе не содержат ни B2B-данных, ни секретов.
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,28 @@ def test_migrations_are_transactional() -> None:
|
||||||
), f"Миграции без обёртки BEGIN;/COMMIT;: {broken} (.claude/rules/sql.md → Structure)."
|
), 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:
|
def test_no_password_material_in_auth_sql() -> None:
|
||||||
"""Ни в data/sql/auth, ни в ops/db-bootstrap нет plaintext-паролей и bcrypt-хешей.
|
"""Ни в 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_POSTGRES_PASSWORD` / `TRADEIN_POSTGRES_USER` | Пароль/юзер БД `tradein` | **E** |
|
||||||
| `TRADEIN_READER_PASSWORD` | Пароль роли `gendesign_reader` (ETL #976, `ops/db-bootstrap/set_gendesign_reader_password.sql`) | **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** |
|
| `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) |
|
| `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** |
|
| `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/**`.
|
3. Frontend: обновить `GLITCHTIP_FRONTEND_DSN` (build-arg `NEXT_PUBLIC_GLITCHTIP_DSN`) → требует **rebuild frontend образа** (запекается на build-time) → `workflow_dispatch` или push в `frontend/**`.
|
||||||
4. Vault entry.
|
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-комментарии).
|
**Downtime:** нет (фичи gracefully degrade при пустом ключе — см. config-комментарии).
|
||||||
|
|
||||||
1. Перевыпустить/ротировать ключ в кабинете провайдера (Объектив / OpenAI / Yandex Cloud / DaData / Cian-аккаунт).
|
1. Перевыпустить/ротировать ключ в кабинете провайдера (Объектив / OpenAI / DaData / Cian-аккаунт).
|
||||||
2. Где живёт:
|
2. Где живёт:
|
||||||
- `OBJECTIVE_API_KEY`, `OPENAI_API_KEY` — Forgejo secret → deploy пишет в main `.env.runtime`.
|
- `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).
|
3. Обновить значение `sed`-ом (НЕ перезапись файла) и `up -d --force-recreate --no-deps backend worker beat` (main) / `... backend scraper` (tradein).
|
||||||
4. Vault entry.
|
4. Vault entry.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,12 @@
|
||||||
# Регресс-тест публичного B2C-периметра МЕРА (ЭТАП 1 плана B2C-запуска).
|
# Регресс-тест публичного B2C-периметра МЕРА (ЭТАП 1 плана B2C-запуска).
|
||||||
#
|
#
|
||||||
# Проверяет инварианты периметра (см. корневой Caddyfile):
|
# Проверяет инварианты периметра (см. корневой Caddyfile):
|
||||||
# 1. meraocenka.ru отдаёт 200 анонимно (публичная заглушка).
|
# 1. meraocenka.ru отдаёт 200 анонимно (публичный лэндинг).
|
||||||
# 2. meraocenka.ru/v2 (B2B-путь) отдаёт 404 — allowlist-by-default,
|
# 1b. Подстраница лэндинга /trade-in/mera-public/privacy отдаёт 200 —
|
||||||
# НЕ был случайно проброшен на B2B-дерево tradein-frontend.
|
# политика ПДн, на которую ссылается футер.
|
||||||
|
# 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
|
# 3. trade-in API (/me, /history, /admin/*) отдаёт 401 анониму — данные B2B
|
||||||
# закрыты. Именно API, а не страница: см. комментарий у проверки ниже.
|
# закрыты. Именно API, а не страница: см. комментарий у проверки ниже.
|
||||||
# 4. gendsgn.ru/api/v1/admin/* отдаёт 401 анониму (gate Site Finder).
|
# 4. gendsgn.ru/api/v1/admin/* отдаёт 401 анониму (gate Site Finder).
|
||||||
|
|
@ -43,9 +46,22 @@ echo "== МЕРА B2C perimeter smoke (ЭТАП 1) =="
|
||||||
# 1. Публичный домен отдаёт 200 анонимно.
|
# 1. Публичный домен отдаёт 200 анонимно.
|
||||||
check "meraocenka.ru root — public 200" "$BASE_MERA/" 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.
|
# 2. B2B-путь на публичном домене — 404 (allowlist-by-default), не 200/401.
|
||||||
check "meraocenka.ru/v2 — B2B path must 404" "$BASE_MERA/v2" 404
|
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 по-прежнему закрыты анониму.
|
# 3. B2B-данные trade-in по-прежнему закрыты анониму.
|
||||||
#
|
#
|
||||||
# ВНИМАНИЕ: проверять СТРАНИЦУ (/trade-in/v2) больше нельзя — она отдаёт 200.
|
# ВНИМАНИЕ: проверять СТРАНИЦУ (/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"]
|
CORS_ORIGINS=["http://localhost:8080","http://localhost:3000"]
|
||||||
ENVIRONMENT=dev
|
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).
|
# DaData /clean/address — обогащение target адреса в estimate flow (PR Q1).
|
||||||
# Возвращает canonical-форму, kadastr_num, ФИАС, координаты, ближайшее метро.
|
# Возвращает canonical-форму, kadastr_num, ФИАС, координаты, ближайшее метро.
|
||||||
# Demo tier: 100 req/день — хватит для тестов и low-traffic prod.
|
# 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`.
|
shell-скриптом deploy через `source .env.runtime` перед `compose up`.
|
||||||
2. `/opt/gendesign/tradein-mvp/backend/.env.runtime` — переменные внутри
|
2. `/opt/gendesign/tradein-mvp/backend/.env.runtime` — переменные внутри
|
||||||
контейнера `tradein-backend` (читаются через `env_file:` в compose). Сюда
|
контейнера `tradein-backend` (читаются через `env_file:` в compose). Сюда
|
||||||
попадают `YANDEX_GEOCODER_API_KEY`, `COOKIE_ENCRYPTION_KEY` —
|
попадают `COOKIE_ENCRYPTION_KEY` и остальные application-секреты внутри
|
||||||
всё, что нужно scripts/backfill_house_coords.py и application code внутри
|
|
||||||
контейнера.
|
контейнера.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -66,7 +65,6 @@ import /opt/gendesign/tradein-mvp/deploy/Caddyfile.tradein-fragment
|
||||||
TRADEIN_POSTGRES_USER=tradein
|
TRADEIN_POSTGRES_USER=tradein
|
||||||
TRADEIN_POSTGRES_PASSWORD=<сгенерировать openssl rand -hex 32>
|
TRADEIN_POSTGRES_PASSWORD=<сгенерировать openssl rand -hex 32>
|
||||||
TRADEIN_CONTACT_EMAIL=tradein@gendsgn.ru
|
TRADEIN_CONTACT_EMAIL=tradein@gendsgn.ru
|
||||||
YANDEX_GEOCODER_API_KEY= # пусто пока, Nominatim fallback работает
|
|
||||||
|
|
||||||
# Encryption key for Cian session cookies (pgp_sym_encrypt / Stage 9 Calculator).
|
# Encryption key for Cian session cookies (pgp_sym_encrypt / Stage 9 Calculator).
|
||||||
# Empty = Valuation Calculator scraper disabled + /api/v1/cookies/upload returns 503.
|
# Empty = Valuation Calculator scraper disabled + /api/v1/cookies/upload returns 503.
|
||||||
|
|
@ -77,10 +75,9 @@ COOKIE_ENCRYPTION_KEY=<64-char hex>
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# /opt/gendesign/tradein-mvp/backend/.env.runtime — те же ключи которые
|
# /opt/gendesign/tradein-mvp/backend/.env.runtime — те же ключи которые
|
||||||
# читаются ВНУТРИ container'а (scripts/backfill_house_coords.py, app/*).
|
# читаются ВНУТРИ container'а (app/*, scripts/*.py).
|
||||||
# Может быть симлинком на ../.env.runtime если переменные совпадают:
|
# Может быть симлинком на ../.env.runtime если переменные совпадают:
|
||||||
# ln -s ../.env.runtime /opt/gendesign/tradein-mvp/backend/.env.runtime
|
# ln -s ../.env.runtime /opt/gendesign/tradein-mvp/backend/.env.runtime
|
||||||
YANDEX_GEOCODER_API_KEY=<key или пусто>
|
|
||||||
COOKIE_ENCRYPTION_KEY=<64-char hex>
|
COOKIE_ENCRYPTION_KEY=<64-char hex>
|
||||||
GENDESIGN_FDW_PASSWORD=<password или пусто>
|
GENDESIGN_FDW_PASSWORD=<password или пусто>
|
||||||
GLITCHTIP_DSN=<dsn или пусто>
|
GLITCHTIP_DSN=<dsn или пусто>
|
||||||
|
|
@ -200,7 +197,6 @@ cat > tradein-mvp/.env.runtime <<EOF
|
||||||
TRADEIN_POSTGRES_USER=tradein
|
TRADEIN_POSTGRES_USER=tradein
|
||||||
TRADEIN_POSTGRES_PASSWORD=$(openssl rand -hex 32)
|
TRADEIN_POSTGRES_PASSWORD=$(openssl rand -hex 32)
|
||||||
TRADEIN_CONTACT_EMAIL=tradein@gendsgn.ru
|
TRADEIN_CONTACT_EMAIL=tradein@gendsgn.ru
|
||||||
YANDEX_GEOCODER_API_KEY=
|
|
||||||
EOF
|
EOF
|
||||||
chmod 600 tradein-mvp/.env.runtime
|
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.schemas.trade_in import ScheduleConfig, ScheduleConfigUpdate
|
||||||
from app.services import cian_session as cian_session_svc
|
from app.services import cian_session as cian_session_svc
|
||||||
from app.services import domclick_session as domclick_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 import scrape_runs as runs_mod
|
||||||
from app.services.geocoder import geocode
|
from app.services.geocoder import geocode
|
||||||
from app.services.scheduler import has_running_run
|
from app.services.scheduler import has_running_run
|
||||||
|
|
@ -276,7 +277,7 @@ async def geocode_missing(
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
f"""
|
f"""
|
||||||
SELECT id, address
|
SELECT id, address, city
|
||||||
FROM {target}
|
FROM {target}
|
||||||
WHERE lat IS NULL
|
WHERE lat IS NULL
|
||||||
AND COALESCE(address, '') != ''
|
AND COALESCE(address, '') != ''
|
||||||
|
|
@ -310,7 +311,13 @@ async def geocode_missing(
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
clean = _clean_address_for_geocode(row["address"])
|
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:
|
if result is None:
|
||||||
# Помечаем что пробовали — иначе ретрай на каждом cron.
|
# Помечаем что пробовали — иначе ретрай на каждом cron.
|
||||||
db.execute(
|
db.execute(
|
||||||
|
|
@ -2883,3 +2890,44 @@ def patch_proxy(
|
||||||
created_at=_iso(row["created_at"]),
|
created_at=_iso(row["created_at"]),
|
||||||
updated_at=_iso(row["updated_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/*` снаружи.
|
это `/trade-in/api/v1/auth/*` снаружи.
|
||||||
|
|
||||||
Security:
|
Security:
|
||||||
- Неверные creds (неизвестный username / неактивен / password_hash NULL /
|
- Неверные creds (неизвестный username / доступ закрыт / password_hash NULL /
|
||||||
неверный пароль) → ОДИНАКОВЫЙ 401 с generic сообщением — не раскрываем,
|
неверный пароль) → ОДИНАКОВЫЙ 401 с generic сообщением — не раскрываем,
|
||||||
существует ли username (user-enumeration защита).
|
существует ли username (user-enumeration защита).
|
||||||
|
- Состояние доступа проверяется ТОЛЬКО ПОСЛЕ проверки пароля, и осмысленный
|
||||||
|
ответ (403 «пробный доступ закончился») получает исключительно тот, кто
|
||||||
|
пароль уже доказал. Ветвление ДО пароля превратило бы отдельный статус в
|
||||||
|
оракул существования логина: перебором можно было бы перечислить аккаунты,
|
||||||
|
не зная ни одного пароля (миграция data/sql/auth/004, WHY-2).
|
||||||
- #2552 post-review Medium 2: `verify_password` ВСЕГДА вызывается ровно
|
- #2552 post-review Medium 2: `verify_password` ВСЕГДА вызывается ровно
|
||||||
один раз — для несуществующего username / NULL password_hash сверяем
|
один раз — для несуществующего username / NULL password_hash сверяем
|
||||||
против статичного dummy-хеша (`_DUMMY_PASSWORD_HASH`, сгенерирован один
|
против статичного dummy-хеша (`_DUMMY_PASSWORD_HASH`, сгенерирован один
|
||||||
|
|
@ -38,10 +43,10 @@ from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.config import settings
|
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.password import hash_password, verify_password
|
||||||
from app.core.ratelimit import SlidingWindowLimiter, _client_ip
|
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.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
|
from app.services.user_events import schedule_event
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -67,6 +72,16 @@ _DUMMY_PASSWORD_HASH = hash_password(secrets.token_urlsafe(16))
|
||||||
|
|
||||||
_INVALID_CREDENTIALS_DETAIL = "неверный логин или пароль"
|
_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):
|
class LoginRequest(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
|
|
@ -82,7 +97,7 @@ async def login(
|
||||||
body: LoginRequest,
|
body: LoginRequest,
|
||||||
request: Request,
|
request: Request,
|
||||||
response: Response,
|
response: Response,
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_identity_db)],
|
||||||
) -> LoginResponse:
|
) -> LoginResponse:
|
||||||
ip = _client_ip(request)
|
ip = _client_ip(request)
|
||||||
user_agent = request.headers.get("user-agent")
|
user_agent = request.headers.get("user-agent")
|
||||||
|
|
@ -105,9 +120,44 @@ async def login(
|
||||||
# ВСЕГДА вызывается — dummy-хеш при отсутствующем юзере/NULL password_hash
|
# ВСЕГДА вызывается — dummy-хеш при отсутствующем юзере/NULL password_hash
|
||||||
# держит время ответа одинаковым независимо от существования аккаунта.
|
# держит время ответа одинаковым независимо от существования аккаунта.
|
||||||
password_ok = verify_password(body.password, hash_to_check)
|
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(
|
schedule_event(
|
||||||
event_type="login_failed",
|
event_type="login_failed",
|
||||||
username=body.username,
|
username=body.username,
|
||||||
|
|
@ -118,7 +168,6 @@ async def login(
|
||||||
)
|
)
|
||||||
raise HTTPException(status_code=401, detail=_INVALID_CREDENTIALS_DETAIL)
|
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)
|
token = create_session(db, user_id=user["user_id"], ip=ip, user_agent=user_agent)
|
||||||
|
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
|
|
@ -147,7 +196,7 @@ async def login(
|
||||||
async def logout(
|
async def logout(
|
||||||
request: Request,
|
request: Request,
|
||||||
response: Response,
|
response: Response,
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_identity_db)],
|
||||||
) -> dict[str, bool]:
|
) -> dict[str, bool]:
|
||||||
token = request.cookies.get(settings.session_cookie_name)
|
token = request.cookies.get(settings.session_cookie_name)
|
||||||
if token:
|
if token:
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,15 @@ Caddy basic_auth пропускает `X-Authenticated-User: <username>` чер
|
||||||
кому что показывать.
|
кому что показывать.
|
||||||
|
|
||||||
#2552: session-first. Валидная DB-session cookie (см. app.services.auth_session)
|
#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
|
roles.yaml. Без cookie (или невалидная/истёкшая) — legacy X-Authenticated-User
|
||||||
путь, БЕЗ ИЗМЕНЕНИЙ (regression недопустим — существующие тесты держат его
|
путь, БЕЗ ИЗМЕНЕНИЙ (regression недопустим — существующие тесты держат его
|
||||||
бит-в-бит).
|
бит-в-бит).
|
||||||
|
|
||||||
|
Сессия БД берётся у `identity_store.get_identity_db` (реестр), а не у
|
||||||
|
`app.core.db.get_db` (продуктовая БД): при `IDENTITY_STORE=auth` люди и сессии
|
||||||
|
живут в другой БД. В дефолтном режиме это ТОТ ЖЕ объект `Session`, что отдал бы
|
||||||
|
`get_db`, — поведение прода не меняется.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
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.auth import UserScope, get_user_scope
|
||||||
from app.core.config import settings
|
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.auth_session import get_db_role_scope, get_session_user
|
||||||
|
from app.services.identity_store import get_identity_db
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -36,14 +41,15 @@ router = APIRouter()
|
||||||
@router.get("/me")
|
@router.get("/me")
|
||||||
async def me(
|
async def me(
|
||||||
request: Request,
|
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,
|
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
|
||||||
) -> UserScope | dict[str, Any]:
|
) -> UserScope | dict[str, Any]:
|
||||||
"""Return the current user's RBAC scope (role + allowed/deny paths).
|
"""Return the current user's RBAC scope (role + allowed/deny paths).
|
||||||
|
|
||||||
Return type is a union (не только `UserScope`) — `UserScope.role` — это
|
Return type is a union (не только `UserScope`) — `UserScope.role` — это
|
||||||
`Literal["admin","pilot","analyst","expired"]` (legacy roles.yaml names),
|
`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` завернул бы
|
строит response-схему из return-аннотации; жёсткий `UserScope` завернул бы
|
||||||
"employee"/"manager" в ResponseValidationError. Итоговая JSON-форма
|
"employee"/"manager" в ResponseValidationError. Итоговая JSON-форма
|
||||||
ОДИНАКОВАЯ (те же 8 ключей) для обеих веток.
|
ОДИНАКОВАЯ (те же 8 ключей) для обеих веток.
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,35 @@ Mounted at `/api/v1/team`; через Caddy `uri strip_prefix /trade-in` это
|
||||||
- Роль должна быть `admin` или `manager` — иначе 403.
|
- Роль должна быть `admin` или `manager` — иначе 403.
|
||||||
|
|
||||||
Org-изоляция (главный инвариант фичи): manager видит/меняет ТОЛЬКО своих
|
Org-изоляция (главный инвариант фичи): manager видит/меняет ТОЛЬКО своих
|
||||||
employee (`tradein_users.manager_id = actor.user_id`). Чужой/несуществующий
|
employee (`<реестр>.manager_id = actor.user_id`). Чужой/несуществующий
|
||||||
employee_id → 404 (НЕ 403) — не подтверждаем/не опровергаем существование
|
employee_id → 404 (НЕ 403) — не подтверждаем/не опровергаем существование
|
||||||
чужого сотрудника перед manager'ом. См. `_authorize_employee`.
|
чужого сотрудника перед 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`):
|
Кого именно можно менять через этот роутер (`_MANAGEABLE_ROLES_BY_ACTOR`):
|
||||||
- actor manager → только `role='employee'` И только своих (как было).
|
- actor manager → только `role='employee'` И только своих (как было).
|
||||||
- actor admin → `role IN ('employee','manager')`.
|
- actor admin → `role IN ('employee','manager')`.
|
||||||
|
|
@ -51,6 +76,7 @@ from sqlalchemy import text
|
||||||
from sqlalchemy.engine import RowMapping
|
from sqlalchemy.engine import RowMapping
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy.sql.elements import TextClause
|
||||||
|
|
||||||
from app.core.auth import get_role
|
from app.core.auth import get_role
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
@ -65,6 +91,14 @@ from app.schemas.team import (
|
||||||
)
|
)
|
||||||
from app.services import account_quota
|
from app.services import account_quota
|
||||||
from app.services.auth_session import get_session_user, revoke_user_sessions
|
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
|
from app.services.user_events import schedule_event
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -83,18 +117,19 @@ class TeamActor:
|
||||||
|
|
||||||
async def current_team_actor(
|
async def current_team_actor(
|
||||||
request: Request,
|
request: Request,
|
||||||
db: Annotated[Session, Depends(get_db)],
|
identity_db: Annotated[Session, Depends(get_identity_db)],
|
||||||
) -> TeamActor:
|
) -> TeamActor:
|
||||||
"""Dependency: session-only identity, роль admin|manager, иначе 401/403.
|
"""Dependency: session-only identity, роль admin|manager, иначе 401/403.
|
||||||
|
|
||||||
Намеренно НЕ читает `X-Authenticated-User` — см. модульный docstring.
|
Намеренно НЕ читает `X-Authenticated-User` — см. модульный docstring.
|
||||||
|
Сессия резолвится в БД РЕЕСТРА (см. про две сессии в модульном docstring).
|
||||||
"""
|
"""
|
||||||
token = request.cookies.get(settings.session_cookie_name)
|
token = request.cookies.get(settings.session_cookie_name)
|
||||||
if not token:
|
if not token:
|
||||||
raise HTTPException(status_code=401, detail="valid session required")
|
raise HTTPException(status_code=401, detail="valid session required")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
session_user = get_session_user(db, token)
|
session_user = get_session_user(identity_db, token)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("team: session lookup failed")
|
logger.exception("team: session lookup failed")
|
||||||
raise HTTPException(status_code=401, detail="valid session required") from None
|
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 (та же мотивация, что
|
# Два статических варианта — НЕ динамическая сборка WHERE (та же мотивация, что
|
||||||
# у `_LIST_EMPLOYEES_*_SQL` ниже: значения и так биндятся параметрами, но
|
# у `_list_employees_sql` ниже: значения и так биндятся параметрами, но
|
||||||
# статические ветки не провоцируют будущие правки в сторону конкатенации SQL).
|
# статические ветки не провоцируют будущие правки в сторону конкатенации SQL).
|
||||||
# Роль 'admin' не встречается ни в одной ветке — см. модульный docstring.
|
# Роль 'admin' не встречается ни в одной ветке — см. модульный docstring.
|
||||||
_FETCH_MANAGED_EMPLOYEE_SQL = text(
|
def _fetch_employee_sql(actor_role: str) -> TextClause:
|
||||||
"""
|
schema = identity_schema()
|
||||||
SELECT id, username, role, display_name, org_name, email, is_active,
|
cols = _employee_columns(schema)
|
||||||
manager_id, created_at
|
if actor_role == "admin":
|
||||||
FROM tradein_users
|
return text(
|
||||||
WHERE id = :id AND role = 'employee'
|
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'")
|
||||||
_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_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).
|
"""Строка управляемого юзера в пределах прав *actor* — иначе None (→ 404).
|
||||||
|
|
||||||
Фильтр по роли делается ЗДЕСЬ, в SQL, а не в `_authorize_employee` ниже:
|
Фильтр по роли делается ЗДЕСЬ, в SQL, а не в `_authorize_employee` ниже:
|
||||||
|
|
@ -191,8 +236,8 @@ def _fetch_employee_row(db: Session, employee_id: int, actor: TeamActor) -> RowM
|
||||||
тебе не по зубам») — тот же принцип, что и 404-вместо-403 в
|
тебе не по зубам») — тот же принцип, что и 404-вместо-403 в
|
||||||
`_authorize_employee`: не палим существование чужой строки.
|
`_authorize_employee`: не палим существование чужой строки.
|
||||||
"""
|
"""
|
||||||
sql = _FETCH_MANAGED_ANY_SQL if actor.role == "admin" else _FETCH_MANAGED_EMPLOYEE_SQL
|
sql = _fetch_employee_sql(actor.role)
|
||||||
return db.execute(sql, {"id": employee_id}).mappings().fetchone()
|
return identity_db.execute(sql, {"id": employee_id}).mappings().fetchone()
|
||||||
|
|
||||||
|
|
||||||
def _authorize_employee(actor: TeamActor, row: RowMapping | None) -> RowMapping:
|
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:
|
def _employee_out(row: RowMapping, quota: dict[str, Any]) -> EmployeeOut:
|
||||||
|
"""Строка реестра → ответ API.
|
||||||
|
|
||||||
|
`is_active` в контракте API остаётся булевым (форма ответа не меняется —
|
||||||
|
фронт «Команды» не трогаем этим PR), и считается он ровно как «пустят ли
|
||||||
|
входить»: `trial_expired` показывается как заблокированный. Отдельное
|
||||||
|
отображение пробного периода в «Команде» — вопрос UI-PR'а, не этого.
|
||||||
|
"""
|
||||||
return EmployeeOut(
|
return EmployeeOut(
|
||||||
id=row["id"],
|
id=row["id"],
|
||||||
username=row["username"],
|
username=row["username"],
|
||||||
|
|
@ -350,7 +402,7 @@ def _employee_out(row: RowMapping, quota: dict[str, Any]) -> EmployeeOut:
|
||||||
display_name=row["display_name"],
|
display_name=row["display_name"],
|
||||||
org_name=row["org_name"],
|
org_name=row["org_name"],
|
||||||
email=row["email"],
|
email=row["email"],
|
||||||
is_active=row["is_active"],
|
is_active=to_access_state(row["access_state"]).can_sign_in,
|
||||||
manager_id=row["manager_id"],
|
manager_id=row["manager_id"],
|
||||||
created_at=row["created_at"],
|
created_at=row["created_at"],
|
||||||
quota=QuotaStatusOut(**quota),
|
quota=QuotaStatusOut(**quota),
|
||||||
|
|
@ -367,6 +419,7 @@ async def create_employee(
|
||||||
body: EmployeeCreateRequest,
|
body: EmployeeCreateRequest,
|
||||||
actor: Annotated[TeamActor, Depends(current_team_actor)],
|
actor: Annotated[TeamActor, Depends(current_team_actor)],
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
identity_db: Annotated[Session, Depends(get_identity_db)],
|
||||||
_origin_check: Annotated[None, Depends(_require_same_origin)],
|
_origin_check: Annotated[None, Depends(_require_same_origin)],
|
||||||
) -> EmployeeOut:
|
) -> EmployeeOut:
|
||||||
"""Создать сотрудника. Роль всегда `employee`.
|
"""Создать сотрудника. Роль всегда `employee`.
|
||||||
|
|
@ -375,9 +428,13 @@ async def create_employee(
|
||||||
значение из тела ИГНОРИРУЕТСЯ, org-изоляция инвариант #2554). Для
|
значение из тела ИГНОРИРУЕТСЯ, org-изоляция инвариант #2554). Для
|
||||||
actor.role == admin — опционально из тела, валидируется что указанный id
|
actor.role == admin — опционально из тела, валидируется что указанный id
|
||||||
существует и role='manager' (иначе 422).
|
существует и role='manager' (иначе 422).
|
||||||
|
|
||||||
|
`identity_db` — реестр (строка сотрудника), `db` — продуктовая квота;
|
||||||
|
в дефолтном режиме это одна и та же сессия и одна транзакция.
|
||||||
"""
|
"""
|
||||||
existing = db.execute(
|
schema = identity_schema()
|
||||||
text("SELECT id FROM tradein_users WHERE username = :u"),
|
existing = identity_db.execute(
|
||||||
|
text(f"SELECT id FROM {schema.users_table} WHERE username = :u"),
|
||||||
{"u": body.username},
|
{"u": body.username},
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
|
|
@ -396,8 +453,8 @@ async def create_employee(
|
||||||
else:
|
else:
|
||||||
manager_id = body.manager_id
|
manager_id = body.manager_id
|
||||||
if manager_id is not None:
|
if manager_id is not None:
|
||||||
mgr = db.execute(
|
mgr = identity_db.execute(
|
||||||
text("SELECT id FROM tradein_users WHERE id = :id AND role = 'manager'"),
|
text(f"SELECT id FROM {schema.users_table} WHERE id = :id AND role = 'manager'"),
|
||||||
{"id": manager_id},
|
{"id": manager_id},
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if mgr is None:
|
if mgr is None:
|
||||||
|
|
@ -408,17 +465,16 @@ async def create_employee(
|
||||||
|
|
||||||
try:
|
try:
|
||||||
row = (
|
row = (
|
||||||
db.execute(
|
identity_db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
f"""
|
||||||
INSERT INTO tradein_users
|
INSERT INTO {schema.users_table}
|
||||||
(username, password_hash, role, manager_id, display_name, org_name,
|
(username, password_hash, role, manager_id, display_name, org_name,
|
||||||
email, is_active)
|
email, {schema.access_state_column})
|
||||||
VALUES
|
VALUES
|
||||||
(:username, :password_hash, 'employee', :manager_id, :display_name,
|
(:username, :password_hash, 'employee', :manager_id, :display_name,
|
||||||
:org_name, :email, true)
|
:org_name, :email, :access_state)
|
||||||
RETURNING id, username, role, display_name, org_name, email, is_active,
|
RETURNING {_employee_columns(schema)}
|
||||||
manager_id, created_at
|
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
|
|
@ -428,6 +484,10 @@ async def create_employee(
|
||||||
"display_name": body.display_name,
|
"display_name": body.display_name,
|
||||||
"org_name": body.org_name,
|
"org_name": body.org_name,
|
||||||
"email": body.email,
|
"email": body.email,
|
||||||
|
# Новый сотрудник заводится с открытым доступом — как и
|
||||||
|
# раньше (`is_active = true` литералом). Литерала здесь
|
||||||
|
# больше нет: тип колонки разный, знает о нём identity_store.
|
||||||
|
"access_state": access_state_param(AccessState.ACTIVE),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.mappings()
|
.mappings()
|
||||||
|
|
@ -435,8 +495,8 @@ async def create_employee(
|
||||||
)
|
)
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
# TOCTOU: два конкурентных POST с одинаковым username между pre-check
|
# TOCTOU: два конкурентных POST с одинаковым username между pre-check
|
||||||
# выше и этим INSERT — UNIQUE-констрейнт на tradein_users.username ловит.
|
# выше и этим INSERT — UNIQUE-констрейнт на username в реестре ловит.
|
||||||
db.rollback()
|
identity_db.rollback()
|
||||||
raise HTTPException(status_code=409, detail="username already exists") from None
|
raise HTTPException(status_code=409, detail="username already exists") from None
|
||||||
|
|
||||||
assert row is not None # RETURNING на успешный INSERT всегда отдаёт строку
|
assert row is not None # RETURNING на успешный INSERT всегда отдаёт строку
|
||||||
|
|
@ -444,7 +504,15 @@ async def create_employee(
|
||||||
if body.monthly_limit is not None:
|
if body.monthly_limit is not None:
|
||||||
_upsert_quota_override(db, body.username, body.monthly_limit, actor.username)
|
_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(
|
schedule_event(
|
||||||
event_type="employee_created",
|
event_type="employee_created",
|
||||||
|
|
@ -471,6 +539,7 @@ async def update_employee(
|
||||||
body: EmployeeUpdateRequest,
|
body: EmployeeUpdateRequest,
|
||||||
actor: Annotated[TeamActor, Depends(current_team_actor)],
|
actor: Annotated[TeamActor, Depends(current_team_actor)],
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
identity_db: Annotated[Session, Depends(get_identity_db)],
|
||||||
_origin_check: Annotated[None, Depends(_require_same_origin)],
|
_origin_check: Annotated[None, Depends(_require_same_origin)],
|
||||||
) -> EmployeeOut:
|
) -> EmployeeOut:
|
||||||
"""Частичное обновление сотрудника — block/unblock, лимит, профиль, пароль.
|
"""Частичное обновление сотрудника — block/unblock, лимит, профиль, пароль.
|
||||||
|
|
@ -483,8 +552,14 @@ async def update_employee(
|
||||||
КАЖДОМ запросе, так что скомпрометированная/чужая сессия живёт неограниченно
|
КАЖДОМ запросе, так что скомпрометированная/чужая сессия живёт неограниченно
|
||||||
долго, а не «до TTL». `revoke_user_sessions` сам называет смену пароля своим
|
долго, а не «до TTL». `revoke_user_sessions` сам называет смену пароля своим
|
||||||
use-case — см. его докстринг.
|
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)
|
row = _authorize_employee(actor, row)
|
||||||
|
|
||||||
new_password_hash: str | None = None
|
new_password_hash: str | None = None
|
||||||
|
|
@ -494,14 +569,27 @@ async def update_employee(
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=422, detail=str(e)) from None
|
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(
|
text(
|
||||||
"""
|
f"""
|
||||||
UPDATE tradein_users
|
UPDATE {schema.users_table}
|
||||||
SET display_name = COALESCE(:display_name, display_name),
|
SET display_name = COALESCE(:display_name, display_name),
|
||||||
org_name = COALESCE(:org_name, org_name),
|
org_name = COALESCE(:org_name, org_name),
|
||||||
email = COALESCE(:email, email),
|
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),
|
password_hash = COALESCE(:password_hash, password_hash),
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = :id
|
WHERE id = :id
|
||||||
|
|
@ -511,7 +599,13 @@ async def update_employee(
|
||||||
"display_name": body.display_name,
|
"display_name": body.display_name,
|
||||||
"org_name": body.org_name,
|
"org_name": body.org_name,
|
||||||
"email": body.email,
|
"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,
|
"password_hash": new_password_hash,
|
||||||
"id": employee_id,
|
"id": employee_id,
|
||||||
},
|
},
|
||||||
|
|
@ -523,13 +617,20 @@ async def update_employee(
|
||||||
if body.is_active is False or body.new_password is not None:
|
if body.is_active is False or body.new_password is not None:
|
||||||
# Обязательно ПОСЛЕ UPDATE, ДО финального commit — revoke_user_sessions
|
# Обязательно ПОСЛЕ UPDATE, ДО финального commit — revoke_user_sessions
|
||||||
# коммитит сам (см. app.services.auth_session), это флашит и наш
|
# коммитит сам (см. app.services.auth_session), это флашит и наш
|
||||||
# предшествующий UPDATE/quota-upsert в той же сессии. Self-lockout
|
# предшествующий UPDATE (а в дефолтном режиме, где сессия одна, — и
|
||||||
|
# quota-upsert). Сессии живут в БД реестра, вместе с пользователем,
|
||||||
|
# поэтому рвём их через `identity_db`: с чужой сессией здесь блокировка
|
||||||
|
# и смена пароля перестали бы действовать немедленно. Self-lockout
|
||||||
# невозможен: _fetch_employee_row не отдаёт строки с role='admin'
|
# невозможен: _fetch_employee_row не отдаёт строки с role='admin'
|
||||||
# НИКОМУ, а manager'у — ещё и только role='employee'; т.е. actor
|
# НИКОМУ, а manager'у — ещё и только role='employee'; т.е. actor
|
||||||
# (admin|manager) никогда не может патчить сам себя через этот роут.
|
# (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 = [
|
changed_profile_fields = [
|
||||||
f
|
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 # только что успешно обновили эту же строку
|
assert updated_row is not None # только что успешно обновили эту же строку
|
||||||
quota = account_quota.get_status(db, updated_row["username"])
|
quota = account_quota.get_status(db, updated_row["username"])
|
||||||
return _employee_out(updated_row, quota)
|
return _employee_out(updated_row, quota)
|
||||||
|
|
@ -596,50 +697,48 @@ async def update_employee(
|
||||||
# постраничном листании. `id` монотонно растёт (BIGINT IDENTITY) — детерминированный
|
# постраничном листании. `id` монотонно растёт (BIGINT IDENTITY) — детерминированный
|
||||||
# tie-break без доп. индекса (созданные позже = бОльший id, тот же порядок что и
|
# tie-break без доп. индекса (созданные позже = бОльший id, тот же порядок что и
|
||||||
# намерение DESC-сортировки по времени).
|
# намерение DESC-сортировки по времени).
|
||||||
_LIST_EMPLOYEES_BY_MANAGER_SQL = text(
|
#
|
||||||
"""
|
# Admin-ветка (`by_manager=False`): сюда попадают И менеджеры (см. модульный
|
||||||
SELECT id, username, role, display_name, org_name, email, is_active, manager_id, created_at
|
# docstring — иначе admin не видит в UI строку, которой должен уметь сбросить
|
||||||
FROM tradein_users
|
# пароль). `role='admin'` по-прежнему невидим и неуправляем. Сортировка по
|
||||||
WHERE role = 'employee' AND manager_id = :manager_id
|
# (created_at, id) общая для обеих веток — намеренно: seed (#2557) вставил всех
|
||||||
ORDER BY created_at DESC, id DESC
|
# одной транзакцией, так что группировка «сначала менеджеры» дала бы ложное
|
||||||
LIMIT :limit OFFSET :offset
|
# ощущение иерархии там, где её в данных нет; роль показывается колонкой
|
||||||
"""
|
# (`EmployeeOut.role`).
|
||||||
)
|
def _list_employees_sql(*, by_manager: bool) -> TextClause:
|
||||||
|
schema = identity_schema()
|
||||||
# Admin-ветка: сюда попадают И менеджеры (см. модульный docstring — иначе admin
|
cols = _employee_columns(schema)
|
||||||
# не видит в UI строку, которой должен уметь сбросить пароль). `role='admin'`
|
tail = "ORDER BY created_at DESC, id DESC LIMIT :limit OFFSET :offset"
|
||||||
# по-прежнему невидим и неуправляем. Сортировка по (created_at, id) общая для
|
if by_manager:
|
||||||
# обеих ролей — намеренно: seed (#2557) вставил всех одной транзакцией, так что
|
return text(
|
||||||
# группировка «сначала менеджеры» дала бы ложное ощущение иерархии там, где её
|
f"SELECT {cols} FROM {schema.users_table} "
|
||||||
# в данных нет; роль показывается колонкой (`EmployeeOut.role`).
|
f"WHERE role = 'employee' AND manager_id = :manager_id {tail}"
|
||||||
_LIST_EMPLOYEES_ALL_SQL = text(
|
)
|
||||||
"""
|
return text(
|
||||||
SELECT id, username, role, display_name, org_name, email, is_active, manager_id, created_at
|
f"SELECT {cols} FROM {schema.users_table} WHERE role IN ('employee', 'manager') {tail}"
|
||||||
FROM tradein_users
|
)
|
||||||
WHERE role IN ('employee', 'manager')
|
|
||||||
ORDER BY created_at DESC, id DESC
|
|
||||||
LIMIT :limit OFFSET :offset
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/employees", response_model=list[EmployeeOut])
|
@router.get("/employees", response_model=list[EmployeeOut])
|
||||||
async def list_employees(
|
async def list_employees(
|
||||||
actor: Annotated[TeamActor, Depends(current_team_actor)],
|
actor: Annotated[TeamActor, Depends(current_team_actor)],
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
identity_db: Annotated[Session, Depends(get_identity_db)],
|
||||||
manager_id: Annotated[int | None, Query()] = None,
|
manager_id: Annotated[int | None, Query()] = None,
|
||||||
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
||||||
offset: Annotated[int, Query(ge=0)] = 0,
|
offset: Annotated[int, Query(ge=0)] = 0,
|
||||||
) -> list[EmployeeOut]:
|
) -> list[EmployeeOut]:
|
||||||
"""Список сотрудников. manager видит только своих; admin — всех, опц. ?manager_id=.
|
"""Список сотрудников. manager видит только своих; admin — всех, опц. ?manager_id=.
|
||||||
|
|
||||||
Квота — ОДИН батч-запрос на всю страницу (`_batch_quota_status`), не N+1
|
Сотрудники читаются из реестра (`identity_db`), квоты — из продуктовой БД
|
||||||
(Medium2, review PR #2563: было 2N+3 SQL-запросов на N сотрудников).
|
(`db`): `account_quota_overrides`/`account_estimate_usage` в общий реестр не
|
||||||
|
переезжают. Квота — ОДИН батч-запрос на всю страницу (`_batch_quota_status`),
|
||||||
|
не N+1 (Medium2, review PR #2563: было 2N+3 SQL-запросов на N сотрудников).
|
||||||
"""
|
"""
|
||||||
if actor.role == "manager":
|
if actor.role == "manager":
|
||||||
rows = (
|
rows = (
|
||||||
db.execute(
|
identity_db.execute(
|
||||||
_LIST_EMPLOYEES_BY_MANAGER_SQL,
|
_list_employees_sql(by_manager=True),
|
||||||
{"manager_id": actor.user_id, "limit": limit, "offset": offset},
|
{"manager_id": actor.user_id, "limit": limit, "offset": offset},
|
||||||
)
|
)
|
||||||
.mappings()
|
.mappings()
|
||||||
|
|
@ -647,8 +746,8 @@ async def list_employees(
|
||||||
)
|
)
|
||||||
elif manager_id is not None:
|
elif manager_id is not None:
|
||||||
rows = (
|
rows = (
|
||||||
db.execute(
|
identity_db.execute(
|
||||||
_LIST_EMPLOYEES_BY_MANAGER_SQL,
|
_list_employees_sql(by_manager=True),
|
||||||
{"manager_id": manager_id, "limit": limit, "offset": offset},
|
{"manager_id": manager_id, "limit": limit, "offset": offset},
|
||||||
)
|
)
|
||||||
.mappings()
|
.mappings()
|
||||||
|
|
@ -656,7 +755,11 @@ async def list_employees(
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
rows = (
|
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])
|
quota_by_username = _batch_quota_status(db, [row["username"] for row in rows])
|
||||||
|
|
@ -673,15 +776,17 @@ async def employee_history(
|
||||||
employee_id: int,
|
employee_id: int,
|
||||||
actor: Annotated[TeamActor, Depends(current_team_actor)],
|
actor: Annotated[TeamActor, Depends(current_team_actor)],
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
identity_db: Annotated[Session, Depends(get_identity_db)],
|
||||||
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
||||||
offset: Annotated[int, Query(ge=0)] = 0,
|
offset: Annotated[int, Query(ge=0)] = 0,
|
||||||
) -> list[EmployeeHistoryEntry]:
|
) -> list[EmployeeHistoryEntry]:
|
||||||
"""История оценок сотрудника (адрес/дата/результат) — из `user_events`,
|
"""История оценок сотрудника (адрес/дата/результат) — из `user_events`,
|
||||||
LEFT JOIN `trade_in_estimates` за фактическим результатом.
|
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)
|
row = _authorize_employee(actor, row)
|
||||||
|
|
||||||
rows = (
|
rows = (
|
||||||
|
|
|
||||||
|
|
@ -1831,7 +1831,7 @@ def get_sales_vs_listings(
|
||||||
|
|
||||||
Per-street view: Росреестр open dataset агрегирует адреса до улицы.
|
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:
|
def _empty(reason_street: str | None = None) -> SalesVsListingsResponse:
|
||||||
return SalesVsListingsResponse(
|
return SalesVsListingsResponse(
|
||||||
|
|
@ -1852,6 +1852,15 @@ def get_sales_vs_listings(
|
||||||
logger.warning("sales-vs-listings: cannot extract street from %r", address)
|
logger.warning("sales-vs-listings: cannot extract street from %r", address)
|
||||||
return _empty()
|
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 = (
|
rows = (
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
|
|
@ -1868,7 +1877,8 @@ def get_sales_vs_listings(
|
||||||
CAST(:rooms AS integer),
|
CAST(:rooms AS integer),
|
||||||
CAST(:window_days AS integer),
|
CAST(:window_days AS integer),
|
||||||
CAST(:area_tolerance AS numeric),
|
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,
|
"window_days": window_days,
|
||||||
"area_tolerance": area_tolerance,
|
"area_tolerance": area_tolerance,
|
||||||
"period_months": period_months,
|
"period_months": period_months,
|
||||||
|
"target_city": target_city,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.mappings()
|
.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."""
|
"""Минимальный settings для standalone trade-in MVP."""
|
||||||
|
|
||||||
from typing import Literal
|
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
|
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):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
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"
|
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)
|
# для User-Agent в Nominatim (Nominatim Usage Policy)
|
||||||
contact_email: str = "erginrajpopxbe@outlook.com"
|
contact_email: str = "erginrajpopxbe@outlook.com"
|
||||||
|
|
||||||
|
|
@ -525,6 +693,18 @@ class Settings(BaseSettings):
|
||||||
proxy_rotate_attempt_timeout_s: float = 8.0
|
proxy_rotate_attempt_timeout_s: float = 8.0
|
||||||
proxy_rotate_attempts: int = 3
|
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,
|
# #1950: если SERP уже сохранил лоты (ins+upd > 0) и упали только detail/houses,
|
||||||
# ставим 'done' а не 'banned' — partial intake сохранён, 'banned' лишний.
|
# ставим 'done' а не 'banned' — partial intake сохранён, 'banned' лишний.
|
||||||
# False = старое поведение. ENV: AVITO_SERP_OK_NOT_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
|
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
|
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
|
at import time). #2552 (dual-mode DB-session auth) adds a conditional per-request
|
||||||
DB round trip via ``app.core.db.SessionLocal`` — но ТОЛЬКО когда запрос реально
|
DB round trip via ``app.services.identity_store.identity_session`` — но ТОЛЬКО
|
||||||
несёт session-cookie (``request.cookies.get(settings.session_cookie_name)``);
|
когда запрос реально несёт session-cookie
|
||||||
без cookie (весь существующий тестовый трафик, legacy Caddy trusted-header
|
(``request.cookies.get(settings.session_cookie_name)``); без cookie (весь
|
||||||
запросы) ветка не выполняется — ноль новых DB-побочных эффектов для старых
|
существующий тестовый трафик, legacy Caddy trusted-header запросы) ветка не
|
||||||
путей. ``app/main.py`` and the test apps both import THIS module, so tests
|
выполняется — ноль новых DB-побочных эффектов для старых путей. ``app/main.py``
|
||||||
exercise the exact production code path instead of a copy that can silently
|
and the test apps both import THIS module, so tests exercise the exact
|
||||||
fall out of sync.
|
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
|
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.auth import get_role, is_path_allowed
|
||||||
from app.core.config import settings
|
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.auth_session import get_db_role_scope, get_session_user
|
||||||
|
from app.services.identity_store import identity_session
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -178,9 +184,23 @@ async def rbac_guard(
|
||||||
if token:
|
if token:
|
||||||
session_user: dict[str, Any] | None = None
|
session_user: dict[str, Any] | None = None
|
||||||
try:
|
try:
|
||||||
with SessionLocal() as db:
|
with identity_session() as db:
|
||||||
session_user = get_session_user(db, token)
|
session_user = get_session_user(db, token)
|
||||||
except Exception:
|
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)
|
logger.exception("RBAC: session lookup failed for %s", path)
|
||||||
if session_user is not None:
|
if session_user is not None:
|
||||||
username = session_user["username"]
|
username = session_user["username"]
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ from app.api.v1 import (
|
||||||
team,
|
team,
|
||||||
trade_in,
|
trade_in,
|
||||||
)
|
)
|
||||||
|
from app.core.auth_db import get_auth_engine
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.db import SessionLocal
|
from app.core.db import SessionLocal
|
||||||
from app.core.fdw import ensure_fdw_user_mapping
|
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.
|
# FDW bootstrap: create/refresh USER MAPPING for gendesign_remote postgres_fdw server.
|
||||||
# Best-effort: failure does not abort startup, just logs.
|
# Best-effort: failure does not abort startup, just logs.
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,26 @@
|
||||||
"""Session-сервис для DB-backed auth (#2552, эпик #2549 — auth-core).
|
"""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, не подписаны: валидность
|
Опаковые (`secrets.token_urlsafe`) токены-сессии — не JWT, не подписаны: валидность
|
||||||
проверяется исключительно наличием + `expires_at`/`is_active` строкой в БД, поэтому
|
проверяется исключительно наличием строки + `expires_at` + состоянием доступа
|
||||||
`SESSION_SECRET` НЕ обязателен для работы этого модуля (зарезервирован на будущее,
|
юзера в БД, поэтому `SESSION_SECRET` НЕ обязателен для работы этого модуля
|
||||||
см. `app.core.config.Settings.session_secret` docstring).
|
(зарезервирован на будущее, см. `app.core.config.Settings.session_secret` docstring).
|
||||||
|
|
||||||
Все функции здесь принимают уже открытую `db: Session` — сами НЕ открывают
|
Все функции здесь принимают уже открытую `db: Session` — сами НЕ открывают
|
||||||
`SessionLocal()` (вызывающая сторона решает время жизни транзакции: `rbac_guard`
|
сессию (вызывающая сторона решает время жизни транзакции: `rbac_guard` и
|
||||||
и `app.core.db.get_db()`-роуты открывают её по-разному). Это делает модуль
|
роуты открывают её по-разному). ⚠️ Это ОБЯЗАНА быть сессия РЕЕСТРА
|
||||||
тривиально unit-тестируемым без патчинга `SessionLocal` — тесты просто передают
|
(`identity_store.identity_session()` / `Depends(get_identity_db)`), а не
|
||||||
|
`app.core.db.get_db`: при `IDENTITY_STORE=auth` запрос уйдёт в БД tradein,
|
||||||
|
где таблиц `users`/`sessions` нет. В дефолтном режиме это один и тот же объект.
|
||||||
|
Модуль остаётся тривиально unit-тестируемым — тесты просто передают
|
||||||
fake/real `Session`.
|
fake/real `Session`.
|
||||||
|
|
||||||
Ни одна функция не должна ронять вызывающий HTTP-запрос: DB-ошибки логируются
|
Ни одна функция не должна ронять вызывающий HTTP-запрос: DB-ошибки логируются
|
||||||
|
|
@ -30,6 +41,7 @@ from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.services.identity_store import identity_schema, to_access_state
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -52,11 +64,13 @@ def create_session(
|
||||||
`expires_at = now() + settings.session_ttl_hours`. Коммитит сам (self-contained,
|
`expires_at = now() + settings.session_ttl_hours`. Коммитит сам (self-contained,
|
||||||
как `app.services.user_events.record_event`).
|
как `app.services.user_events.record_event`).
|
||||||
"""
|
"""
|
||||||
|
schema = identity_schema()
|
||||||
token = secrets.token_urlsafe(_TOKEN_BYTES)
|
token = secrets.token_urlsafe(_TOKEN_BYTES)
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
f"""
|
||||||
INSERT INTO tradein_sessions (token, user_id, expires_at, ip_address, user_agent)
|
INSERT INTO {schema.sessions_table}
|
||||||
|
(token, user_id, expires_at, ip_address, user_agent)
|
||||||
VALUES (
|
VALUES (
|
||||||
:token, :user_id,
|
:token, :user_id,
|
||||||
now() + make_interval(hours => CAST(:ttl_hours AS integer)),
|
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:
|
def get_session_user(db: Session, token: str) -> dict[str, Any] | None:
|
||||||
"""Резолвит сессионный токен в данные юзера, или 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 минут —
|
Sliding refresh: если с последнего `last_seen_at` прошло >=5 минут —
|
||||||
продлевает `expires_at`/`last_seen_at` ОДНИМ UPDATE. Сбой refresh
|
продлевает `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:
|
if not token:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
schema = identity_schema()
|
||||||
row = db.execute(
|
row = db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
f"""
|
||||||
SELECT s.user_id, s.expires_at, s.last_seen_at,
|
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
|
u.username, u.role, u.display_name, u.org_name, u.email,
|
||||||
FROM tradein_sessions s
|
u.{schema.access_state_column} AS access_state
|
||||||
JOIN tradein_users u ON u.id = s.user_id
|
FROM {schema.sessions_table} s
|
||||||
|
JOIN {schema.users_table} u ON u.id = s.user_id
|
||||||
WHERE s.token = :token
|
WHERE s.token = :token
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
|
|
@ -107,15 +132,16 @@ def get_session_user(db: Session, token: str) -> dict[str, Any] | None:
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
if row.expires_at is None or row.expires_at <= now:
|
if row.expires_at is None or row.expires_at <= now:
|
||||||
return None
|
return None
|
||||||
if not row.is_active:
|
access_state = to_access_state(row.access_state)
|
||||||
|
if not access_state.can_sign_in:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if row.last_seen_at is None or (now - row.last_seen_at) >= _SLIDING_REFRESH_INTERVAL:
|
if row.last_seen_at is None or (now - row.last_seen_at) >= _SLIDING_REFRESH_INTERVAL:
|
||||||
try:
|
try:
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
f"""
|
||||||
UPDATE tradein_sessions
|
UPDATE {schema.sessions_table}
|
||||||
SET last_seen_at = now(),
|
SET last_seen_at = now(),
|
||||||
expires_at = now() + make_interval(hours => CAST(:ttl_hours AS integer))
|
expires_at = now() + make_interval(hours => CAST(:ttl_hours AS integer))
|
||||||
WHERE token = :token
|
WHERE token = :token
|
||||||
|
|
@ -137,23 +163,35 @@ def get_session_user(db: Session, token: str) -> dict[str, Any] | None:
|
||||||
"display_name": row.display_name,
|
"display_name": row.display_name,
|
||||||
"org_name": row.org_name,
|
"org_name": row.org_name,
|
||||||
"email": row.email,
|
"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:
|
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-проверки.
|
Используется login-флоу (`app.api.v1.auth.login`) для password-проверки.
|
||||||
Отдаёт `password_hash` как есть (может быть NULL — переходный период,
|
Отдаёт `password_hash` как есть (может быть NULL — переходный период,
|
||||||
см. migration 192 docstring) — вызывающая сторона решает, что с ним делать.
|
см. migration 192 docstring) — вызывающая сторона решает, что с ним делать.
|
||||||
|
|
||||||
|
`access_state` — уже `AccessState` (не сырое значение колонки): решение
|
||||||
|
«пускать / не пускать / показать экран пробного периода» принимает login,
|
||||||
|
и принимать его он обязан по ОДНОМУ понятию, а не по boolean в одном режиме
|
||||||
|
и строке в другом. Отсутствие юзера состоянием НЕ выражается (None остаётся
|
||||||
|
None) — иначе login потерял бы разницу между «нет такого логина» и
|
||||||
|
«заблокирован», а она нужна ему для выбора события аудита.
|
||||||
"""
|
"""
|
||||||
|
schema = identity_schema()
|
||||||
row = db.execute(
|
row = db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
f"""
|
||||||
SELECT id, username, password_hash, role, is_active,
|
SELECT id, username, password_hash, role,
|
||||||
|
{schema.access_state_column} AS access_state,
|
||||||
display_name, org_name, email
|
display_name, org_name, email
|
||||||
FROM tradein_users
|
FROM {schema.users_table}
|
||||||
WHERE username = :username
|
WHERE username = :username
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
|
|
@ -168,7 +206,7 @@ def get_user_by_username(db: Session, username: str) -> dict[str, Any] | None:
|
||||||
"username": row.username,
|
"username": row.username,
|
||||||
"password_hash": row.password_hash,
|
"password_hash": row.password_hash,
|
||||||
"role": row.role,
|
"role": row.role,
|
||||||
"is_active": row.is_active,
|
"access_state": to_access_state(row.access_state),
|
||||||
"display_name": row.display_name,
|
"display_name": row.display_name,
|
||||||
"org_name": row.org_name,
|
"org_name": row.org_name,
|
||||||
"email": row.email,
|
"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:
|
def revoke_session(db: Session, token: str) -> None:
|
||||||
"""Удаляет одну сессию по токену (logout). No-op если токен не найден."""
|
"""Удаляет одну сессию по токену (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()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
def revoke_user_sessions(db: Session, user_id: int) -> None:
|
def revoke_user_sessions(db: Session, user_id: int) -> None:
|
||||||
"""Удаляет ВСЕ сессии юзера (напр. смена пароля / принудительный logout всех
|
"""Удаляет ВСЕ сессии юзера — смена пароля и блокировка обязаны рвать
|
||||||
устройств — не используется этим PR напрямую, задел для будущих admin-действий)."""
|
активные сессии немедленно (см. `app.api.v1.team.update_employee`)."""
|
||||||
db.execute(text("DELETE FROM tradein_sessions WHERE user_id = :user_id"), {"user_id": user_id})
|
schema = identity_schema()
|
||||||
|
db.execute(
|
||||||
|
text(f"DELETE FROM {schema.sessions_table} WHERE user_id = :user_id"),
|
||||||
|
{"user_id": user_id},
|
||||||
|
)
|
||||||
db.commit()
|
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.
|
# 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 путь,
|
# НЕ являются ключами auth/roles.yaml (тот файл — legacy Caddy trusted-header путь,
|
||||||
# который этот эпик намеренно не трогает). Маппинг ниже даёт DB-ролям тот же
|
# который этот эпик намеренно не трогает). Маппинг ниже даёт DB-ролям тот же
|
||||||
# paths/deny-смысл, что и legacy-ролям, БЕЗ правки roles.yaml:
|
# paths/deny-смысл, что и legacy-ролям, БЕЗ правки roles.yaml:
|
||||||
|
|
|
||||||
|
|
@ -343,6 +343,10 @@ async def suggest_addresses(
|
||||||
жёсткий фильтр (не boost) на уровне указанного admin-поля — доп.
|
жёсткий фильтр (не boost) на уровне указанного admin-поля — доп.
|
||||||
параметров не требуется. По умолчанию не задан — поведение (и body
|
параметров не требуется. По умолчанию не задан — поведение (и body
|
||||||
запроса) для существующих вызовов не меняется.
|
запроса) для существующих вызовов не меняется.
|
||||||
|
ВАЖНО: значение сравнивается с полем DaData `region`, где имя лежит
|
||||||
|
БЕЗ типа («Свердловская», а тип — отдельно в `region_type`="обл").
|
||||||
|
Передашь «Свердловская область» — совпадений не будет, и запрос
|
||||||
|
вернёт ПУСТО без всякой ошибки (hard-filter, не boost).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
list[DadataSuggestion] — пустой список если:
|
list[DadataSuggestion] — пустой список если:
|
||||||
|
|
|
||||||
|
|
@ -200,6 +200,55 @@ def _load_city_price_bands(db: Session) -> dict[str, tuple[int, int]]:
|
||||||
return bands
|
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).
|
# Используется как hard-filter в Tier 0 _fetch_analogs (PR 9, 2026-05-24).
|
||||||
# Если target_year не задан — cohort = None → фильтр отключён, Tier 0 пропускается.
|
# Если target_year не задан — cohort = None → фильтр отключён, Tier 0 пропускается.
|
||||||
|
|
@ -3335,6 +3384,16 @@ async def estimate_quality(
|
||||||
if target_house_type is None:
|
if target_house_type is None:
|
||||||
target_house_type = house_meta.house_type
|
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):
|
# 3. Four-tier fallback (PR 9 — added Tier 0 with cohort filter):
|
||||||
# 0) 1km + ±15% area + cohort match (year_built — если задан)
|
# 0) 1km + ±15% area + cohort match (year_built — если задан)
|
||||||
# a) 1km + ±15% area (без cohort — drop fallback)
|
# a) 1km + ±15% area (без cohort — drop fallback)
|
||||||
|
|
|
||||||
|
|
@ -156,7 +156,15 @@ SVERDLOVSK_OBLAST_CITIES = frozenset(
|
||||||
# без district-префикса ложно ушёл бы в non-EKB gate.
|
# без 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» не матчил город
|
# Word/phrase-boundary regex — НЕ substring — чтобы «Серова 27» не матчил город
|
||||||
# «Серов», «Ирбитская 5» — «Ирбит», «Асбестовский пер.» — «Асбест», «Невьянский
|
# «Серов», «Ирбитская 5» — «Ирбит», «Асбестовский пер.» — «Асбест», «Невьянский
|
||||||
|
|
@ -637,13 +645,24 @@ async def _dadata_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]
|
||||||
типа город/район, для autocomplete с привязкой к карте они бесполезны).
|
типа город/район, для autocomplete с привязкой к карте они бесполезны).
|
||||||
|
|
||||||
Label собирается из DaData `value` (короткая форма «ул Малышева, д 30»).
|
Label собирается из DaData `value` (короткая форма «ул Малышева, д 30»).
|
||||||
Constraint — вся область (region='Свердловская область', hard-filter внутри
|
Constraint — вся область (region=`SVERDLOVSK_OBLAST_REGION`, hard-filter
|
||||||
`suggest_addresses`), а не один город ЕКБ — иначе Нижний Тагил/Серов/etc
|
внутри `suggest_addresses`), а не один город ЕКБ — иначе Нижний Тагил/
|
||||||
никогда не появились бы в подсказках.
|
Серов/etc никогда не появились бы в подсказках.
|
||||||
"""
|
"""
|
||||||
raw = await dadata.suggest_addresses(
|
raw = await dadata.suggest_addresses(
|
||||||
query, limit=limit, city=None, region=SVERDLOVSK_OBLAST_REGION
|
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] = []
|
out: list[GeocodeSuggestion] = []
|
||||||
for s in raw:
|
for s in raw:
|
||||||
if s.lat is None or s.lon is None:
|
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)
|
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:
|
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 на
|
Литера — часть идентичности дома, а не украшение: «Новгородцевой 13б» и
|
||||||
«д./дом/строение» убивает ложный матч номера внутри «(1-83)»-диапазона.
|
«Новгородцевой 13» — РАЗНЫЕ здания. Поэтому номер сравнивается равенством
|
||||||
|
нормализованных форм (обе стороны → канон «13б»), а не «совпали цифры,
|
||||||
|
литера опциональна».
|
||||||
|
|
||||||
`street` идёт ТОЛЬКО в bound-param ILIKE (безопасно). Для regex берём
|
Раньше в regex шли только ЦИФРЫ дома, литера была опциональна в WHERE и
|
||||||
только ЦИФРЫ дома (regex-safe) — конкатенируем bound-param внутри SQL.
|
участвовала лишь как tie-break в ORDER BY — из-за чего запрос с литерой
|
||||||
Литеру (если есть) используем лишь для tie-break сортировки.
|
молча получал соседний дом БЕЗ неё (и наоборот: «Малышева 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:
|
if not house_digits_m:
|
||||||
return None
|
return None
|
||||||
house_digits = house_digits_m.group(0)
|
house_digits = house_digits_m.group(0)
|
||||||
try:
|
try:
|
||||||
row = db.execute(
|
row = db.execute(
|
||||||
text(r"""
|
text(
|
||||||
|
r"""
|
||||||
SELECT readable_address, lat, lon
|
SELECT readable_address, lat, lon
|
||||||
FROM gendesign_cad_buildings
|
FROM gendesign_cad_buildings
|
||||||
WHERE readable_address ILIKE CAST('%' || :street || '%' AS text)
|
WHERE readable_address ILIKE CAST('%' || :street || '%' AS text)
|
||||||
|
|
@ -976,14 +1057,19 @@ def _cadastral_house_match(db: Session, street: str, house: str) -> GeocodeSugge
|
||||||
'(п\.\s|пос[. ]|посёлок|поселок|северка|шабровский'
|
'(п\.\s|пос[. ]|посёлок|поселок|северка|шабровский'
|
||||||
|| '| км|снт|гараж|коллективный сад)'
|
|| '| км|снт|гараж|коллективный сад)'
|
||||||
)
|
)
|
||||||
ORDER BY
|
AND """
|
||||||
(CASE WHEN CAST(:house_full AS text) ~ '[а-яё]'
|
+ _SQL_HOUSE_TOKEN_NORM
|
||||||
AND readable_address ~* (CAST(:house_full AS text) || '(\D|$)')
|
+ r""" = CAST(:house_norm AS text)
|
||||||
THEN 0 ELSE 1 END),
|
ORDER BY length(readable_address) ASC
|
||||||
length(readable_address) ASC
|
|
||||||
LIMIT 1
|
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()
|
).first()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning(
|
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 ────────────────────
|
# ── 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(
|
async def _job_listing_source_snapshot(
|
||||||
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||||
) -> None:
|
) -> None:
|
||||||
from app.tasks.listing_source_snapshot import snapshot_listing_sources
|
from app.tasks.listing_source_snapshot import snapshot_listing_sources
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
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 ─────────────────
|
# ── asking_to_sold_ratio_refresh — sync re-derive в executor ─────────────────
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,25 @@ ipify-пробу через каждый прокси и обновляет heal
|
||||||
за одну строку — второй параллельный вызов пропустит залоченную и возьмёт следующую).
|
за одну строку — второй параллельный вызов пропустит залоченную и возьмёт следующую).
|
||||||
|
|
||||||
Health:
|
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 прокси
|
- mark_health(ok=False) → consecutive_fails += 1; при достижении DISABLE_THRESHOLD прокси
|
||||||
авто-disable (enabled=false), чтобы битый узел выпал из пула.
|
авто-disable (enabled=false), чтобы битый узел выпал из пула.
|
||||||
- acquire отфильтровывает enabled=false И consecutive_fails >= MAX_FAILS.
|
- 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.
|
psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -36,6 +50,7 @@ from sqlalchemy.orm import Session
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"DISABLED_RECHECK_MINUTES",
|
||||||
"DISABLE_THRESHOLD",
|
"DISABLE_THRESHOLD",
|
||||||
"MAX_CONSECUTIVE_FAILS",
|
"MAX_CONSECUTIVE_FAILS",
|
||||||
"NON_RUN_LEASE_MARKER",
|
"NON_RUN_LEASE_MARKER",
|
||||||
|
|
@ -62,6 +77,12 @@ DISABLE_THRESHOLD = 5
|
||||||
# освобождается reap_stale_leases — иначе прокси навсегда «занят» мёртвым run'ом.
|
# освобождается reap_stale_leases — иначе прокси навсегда «занят» мёртвым run'ом.
|
||||||
STALE_LEASE_MINUTES = 30
|
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).
|
# Маркер lease для не-run вызовов (leased_by NOT NULL = занят, но это не id из scrape_runs).
|
||||||
NON_RUN_LEASE_MARKER = -1
|
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 (или
|
(last_ok_at NULLS LAST). Затем помечает строку leased_by=run_id (или
|
||||||
NON_RUN_LEASE_MARKER если 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 не дерутся за одну строку: SKIP LOCKED пропускает залоченную
|
||||||
другим вызовом строку, второй параллельный acquire берёт следующую свободную.
|
другим вызовом строку, второй параллельный acquire берёт следующую свободную.
|
||||||
|
|
||||||
Returns ProxyLease или None если свободных здоровых прокси нет.
|
Returns ProxyLease или None если свободных здоровых прокси нет вообще.
|
||||||
"""
|
"""
|
||||||
lease_marker = run_id if run_id is not None else NON_RUN_LEASE_MARKER
|
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()
|
.mappings()
|
||||||
.fetchone()
|
.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:
|
if row is None:
|
||||||
db.rollback() # снять FOR UPDATE-транзакцию (ничего не залочено, но чисто)
|
db.rollback() # снять FOR UPDATE-транзакцию (ничего не залочено, но чисто)
|
||||||
return None
|
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},
|
{"run_id": lease_marker, "id": proxy_id},
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
logger.info(
|
if fallback_used:
|
||||||
"proxy_pool: leased proxy id=%d provider=%s by=%s", proxy_id, provider, lease_marker
|
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(
|
return ProxyLease(
|
||||||
id=proxy_id,
|
id=proxy_id,
|
||||||
url=str(row["url"]),
|
url=str(row["url"]),
|
||||||
|
|
@ -167,13 +250,24 @@ def mark_health(
|
||||||
*,
|
*,
|
||||||
exit_ip: str | None = None,
|
exit_ip: str | None = None,
|
||||||
latency_ms: int | None = None,
|
latency_ms: int | None = None,
|
||||||
|
fail_kind: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Записать результат health-check'а прокси.
|
"""Записать результат health-check'а прокси.
|
||||||
|
|
||||||
ok=True → consecutive_fails обнуляется, обновляются last_ok_at/last_check_at/
|
ok=True → consecutive_fails обнуляется, enabled=true, обновляются last_ok_at/
|
||||||
exit_ip/latency_ms.
|
last_check_at/exit_ip/latency_ms. enabled=true безусловно — это реанимация:
|
||||||
|
узел, ранее выключенный auto-disable'ом, возвращается в строй первой же
|
||||||
|
успешной пробой (см. run_proxy_healthcheck, #2600 п.1).
|
||||||
ok=False → consecutive_fails += 1; при достижении DISABLE_THRESHOLD прокси
|
ok=False → consecutive_fails += 1; при достижении DISABLE_THRESHOLD прокси
|
||||||
авто-disable (enabled=false). last_check_at обновляется в любом случае.
|
авто-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:
|
if ok:
|
||||||
db.execute(
|
db.execute(
|
||||||
|
|
@ -185,6 +279,7 @@ def mark_health(
|
||||||
last_check_at = now(),
|
last_check_at = now(),
|
||||||
exit_ip = CAST(:exit_ip AS text),
|
exit_ip = CAST(:exit_ip AS text),
|
||||||
latency_ms = CAST(:latency_ms AS integer),
|
latency_ms = CAST(:latency_ms AS integer),
|
||||||
|
enabled = true,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = CAST(:id AS bigint)
|
WHERE id = CAST(:id AS bigint)
|
||||||
"""
|
"""
|
||||||
|
|
@ -210,7 +305,13 @@ def mark_health(
|
||||||
{"disable_threshold": DISABLE_THRESHOLD, "id": proxy_id},
|
{"disable_threshold": DISABLE_THRESHOLD, "id": proxy_id},
|
||||||
)
|
)
|
||||||
db.commit()
|
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:
|
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)
|
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).
|
"""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] обрабатывает оба.
|
url несёт схему (http:// / socks5://) — httpx[socks] обрабатывает оба.
|
||||||
"""
|
"""
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
|
|
@ -250,10 +358,23 @@ async def _probe_proxy(url: str) -> tuple[bool, str | None, int | None]:
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
ip = resp.json().get("ip")
|
ip = resp.json().get("ip")
|
||||||
latency_ms = int((time.monotonic() - started) * 1000)
|
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:
|
except Exception:
|
||||||
logger.warning("proxy_pool: health probe failed proxy=%s", _mask(url), exc_info=True)
|
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:
|
def _mask(url: str) -> str:
|
||||||
|
|
@ -269,16 +390,23 @@ def _mask(url: str) -> str:
|
||||||
|
|
||||||
|
|
||||||
async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
||||||
"""Периодический health-check всех enabled-прокси пула (#2162).
|
"""Периодический health-check прокси пула — enabled каждый прогон, disabled реже (#2162, #2600).
|
||||||
|
|
||||||
Сначала reap_stale_leases (освобождает протухшие lease'ы), затем для каждого
|
Сначала reap_stale_leases (освобождает протухшие lease'ы), затем гоняет ipify-пробу
|
||||||
enabled-прокси гоняет ipify-пробу через сам прокси и пишет результат через
|
через каждый кандидат и пишет результат через mark_health (успех → сброс fails +
|
||||||
mark_health (успех → сброс fails + свежий exit_ip/latency; фейл → инкремент,
|
enabled=true + свежий exit_ip/latency; фейл → инкремент, авто-disable при
|
||||||
авто-disable при DISABLE_THRESHOLD).
|
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
|
один и тот же upstream-endpoint (ipify) не нужен. Returns counters
|
||||||
{reaped, checked, ok, failed}.
|
{reaped, checked, ok, failed, revived}.
|
||||||
"""
|
"""
|
||||||
reaped = reap_stale_leases(db)
|
reaped = reap_stale_leases(db)
|
||||||
|
|
||||||
|
|
@ -286,12 +414,17 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
SELECT id, url, kind
|
SELECT id, url, kind, enabled
|
||||||
FROM scrape_proxies
|
FROM scrape_proxies
|
||||||
WHERE enabled
|
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
|
ORDER BY id
|
||||||
"""
|
"""
|
||||||
)
|
),
|
||||||
|
{"disabled_recheck_minutes": DISABLED_RECHECK_MINUTES},
|
||||||
)
|
)
|
||||||
.mappings()
|
.mappings()
|
||||||
.all()
|
.all()
|
||||||
|
|
@ -300,22 +433,38 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
||||||
checked = 0
|
checked = 0
|
||||||
ok_count = 0
|
ok_count = 0
|
||||||
failed = 0
|
failed = 0
|
||||||
|
revived = 0
|
||||||
for row in proxies:
|
for row in proxies:
|
||||||
proxy_id = int(row["id"])
|
proxy_id = int(row["id"])
|
||||||
url = str(row["url"])
|
url = str(row["url"])
|
||||||
ok, exit_ip, latency_ms = await _probe_proxy(url)
|
was_disabled = not bool(row["enabled"])
|
||||||
mark_health(db, proxy_id, ok, exit_ip=exit_ip, latency_ms=latency_ms)
|
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
|
checked += 1
|
||||||
if ok:
|
if ok:
|
||||||
ok_count += 1
|
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:
|
else:
|
||||||
failed += 1
|
failed += 1
|
||||||
|
|
||||||
logger.info(
|
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,
|
reaped,
|
||||||
checked,
|
checked,
|
||||||
ok_count,
|
ok_count,
|
||||||
failed,
|
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² — отсекает нежилые/технические сделки; не меняется.
|
# Нижняя граница ppm² — отсекает нежилые/технические сделки; не меняется.
|
||||||
_PPM2_MIN: int = 30_000
|
_PPM2_MIN: int = 30_000
|
||||||
|
|
||||||
# #C2 — asking-сторона (listings) покрыта скрейпом ТОЛЬКО по ЕКБ (per-city scrape B1/B2
|
# #C2 — исторически asking-сторона (listings) была покрыта скрейпом ТОЛЬКО по ЕКБ, а
|
||||||
# ещё нет; в listings даже нет колонки city). Миграция 177 залила ДКП-сделки по всей
|
# миграция 177 залила ДКП-сделки по всей обл.66 (368 городов) → sold-медиана смешивала
|
||||||
# обл.66 (368 городов) → sold-медиана смешивала дешёвую область с ЕКБ-asking и обваливала
|
# дешёвую область с ЕКБ-asking и обваливала ratio (0.877→0.62, «выкупная» −29% системно).
|
||||||
# ratio (0.877→0.62, «выкупная» −29% системно). Скоупим SOLD-сторону (deal_side/deal_global)
|
# Скоупили SOLD-сторону (deal_side/deal_global) на ЕКБ, чтобы sold и asking считались по
|
||||||
# на ЕКБ, чтобы sold и asking считались по ОДНОМУ рынку. Когда появятся oblast-листинги —
|
# ОДНОМУ рынку.
|
||||||
# заменить на per-city ratio через зарезервированный столбец `district` (#647).
|
#
|
||||||
|
# #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 = "%Екатеринбург%"
|
_ASKING_CITY_PATTERN: str = "%Екатеринбург%"
|
||||||
# Верхняя граница берётся из settings.asking_ratio_ppm2_max (default 1_200_000).
|
# Верхняя граница берётся из settings.asking_ratio_ppm2_max (default 1_200_000).
|
||||||
# QA-note: точное значение сверить с `SELECT max(price_per_m2) FROM deals
|
# 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),
|
# ppm² ∈ [_PPM2_MIN, settings.asking_ratio_ppm2_max], deal_date >= CURRENT_DATE − 12 months),
|
||||||
# бакет LEAST(GREATEST(rooms,0),4).
|
# бакет LEAST(GREATEST(rooms,0),4).
|
||||||
# ask_median = percentile_cont(0.5) по listings.price_per_m2
|
# 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.
|
# 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.
|
# global -1 строка (basis='global_fallback') — всегда (если ask>0 AND sold>0). window_months=12.
|
||||||
# Порог/окно — литералы; ppm²-полоса передаётся bind-параметрами :ppm2_min/:ppm2_max
|
# Порог/окно — литералы; ppm²-полоса передаётся bind-параметрами :ppm2_min/:ppm2_max
|
||||||
|
|
@ -105,6 +114,15 @@ _REDERIVE_SQL = text(
|
||||||
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
|
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
|
||||||
-- novostroyki guard (#1186): NULL = legacy вторичка до м.011
|
-- novostroyki guard (#1186): NULL = legacy вторичка до м.011
|
||||||
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
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)
|
GROUP BY LEAST(GREATEST(rooms, 0), 4)
|
||||||
),
|
),
|
||||||
-- Per-rooms строки: только бакеты с обеими сторонами, прошедшие порог 30/30 и ask>0.
|
-- Per-rooms строки: только бакеты с обеими сторонами, прошедшие порог 30/30 и ask>0.
|
||||||
|
|
@ -152,6 +170,9 @@ _REDERIVE_SQL = text(
|
||||||
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
|
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
|
||||||
-- novostroyki guard (#1186): NULL = legacy вторичка до м.011
|
-- novostroyki guard (#1186): NULL = legacy вторичка до м.011
|
||||||
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
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 fallback строка rooms_bucket=-1 (пишется всегда, если ask>0).
|
||||||
global_row AS (
|
global_row AS (
|
||||||
|
|
|
||||||
|
|
@ -10,15 +10,21 @@
|
||||||
Парсинг адреса — _parse_street_house из app.services.geocoder (готовый парсер),
|
Парсинг адреса — _parse_street_house из app.services.geocoder (готовый парсер),
|
||||||
работающий с формами «г. Екатеринбург, ул. Малышева, 30, кв. 28».
|
работающий с формами «г. Екатеринбург, ул. Малышева, 30, кв. 28».
|
||||||
|
|
||||||
Городской гейт (#2583, находка H3): в `listings` НЕТ отдельной колонки города — город
|
Городской гейт (#2583, находка H3; расширен #2594 шаг 2/3): `ekb_geoportal_buildings` —
|
||||||
известен только из текста адреса. `ekb_geoportal_buildings` — EKB-only реестр: улица+дом
|
EKB-only реестр: улица+дом могут буквально совпасть между Екатеринбургом и другим городом
|
||||||
могут буквально совпасть между Екатеринбургом и другим городом области (например,
|
области (например, «проспект Ленина 1» есть и в ЕКБ, и в Нижнем Тагиле). Без проверки
|
||||||
«проспект Ленина 1» есть и в ЕКБ, и в Нижнем Тагиле). Без проверки города такой листинг
|
города такой листинг получает екатеринбургские координаты, хотя находится в другом городе.
|
||||||
получает екатеринбургские координаты, хотя находится в другом городе. Перед вызовом
|
Гейт — ДВЕ проверки перед вызовом _geoportal_house_match:
|
||||||
_geoportal_house_match каждый адрес проверяется через _names_non_ekb_city (та же функция,
|
1. Колонка `listings.city` (#2594, миграция 196) — если проставлена НЕ-Екатеринбургом,
|
||||||
что гейтит EKB-only тиры внутри geocoder.geocode()) — адрес, явно называющий другой город
|
листинг пропускается сразу, без обращения к тексту адреса. Это надёжный сигнал из
|
||||||
региона, пропускается (counted как skipped_non_ekb) и остаётся lat IS NULL для
|
контекста развёртки (скрапер знает город явно), тогда как текстовый гейт полагается
|
||||||
geocode_missing_listings (oblast-aware Nominatim/Yandex, окно 06:00-09:00 UTC).
|
на то, что город явно упомянут в самом тексте адреса.
|
||||||
|
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()) оставлен намеренно —
|
Прямой вызов _geoportal_house_match (а не полноценный geocode()) оставлен намеренно —
|
||||||
это pure local-DB матч без единого внешнего HTTP-запроса; полноценный geocode() на каждый
|
это pure local-DB матч без единого внешнего HTTP-запроса; полноценный geocode() на каждый
|
||||||
non-EKB адрес добавил бы Nominatim/Yandex вызов на весь backlog (сотни-тысячи строк за
|
non-EKB адрес добавил бы Nominatim/Yandex вызов на весь backlog (сотни-тысячи строк за
|
||||||
|
|
@ -79,7 +85,7 @@ class BackfillCoordsResult:
|
||||||
updated: int = 0 # реально обновлено (UPDATE rowcount)
|
updated: int = 0 # реально обновлено (UPDATE rowcount)
|
||||||
no_address: int = 0 # listing.address IS NULL / не распарсился
|
no_address: int = 0 # listing.address IS NULL / не распарсился
|
||||||
no_match: int = 0 # адрес распарсился, но в реестре здания нет
|
no_match: int = 0 # адрес распарсился, но в реестре здания нет
|
||||||
skipped_non_ekb: int = 0 # адрес явно называет другой город области (#2583 гейт)
|
skipped_non_ekb: int = 0 # non-ЕКБ гейт: колонка city (#2594) ИЛИ текст адреса (#2583)
|
||||||
errors: int = 0 # исключения при обработке отдельной записи
|
errors: int = 0 # исключения при обработке отдельной записи
|
||||||
duration_sec: float = field(default=0.0)
|
duration_sec: float = field(default=0.0)
|
||||||
|
|
||||||
|
|
@ -179,7 +185,7 @@ def backfill_coords_from_geoportal(
|
||||||
rows = (
|
rows = (
|
||||||
db.execute(
|
db.execute(
|
||||||
text(f"""
|
text(f"""
|
||||||
SELECT id, address
|
SELECT id, address, city
|
||||||
FROM listings
|
FROM listings
|
||||||
WHERE lat IS NULL
|
WHERE lat IS NULL
|
||||||
AND geom IS NULL
|
AND geom IS NULL
|
||||||
|
|
@ -210,9 +216,23 @@ def backfill_coords_from_geoportal(
|
||||||
res.no_address += 1
|
res.no_address += 1
|
||||||
continue
|
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.
|
# lat IS NULL для oblast-aware geocode_missing_listings.
|
||||||
if _names_non_ekb_city(address):
|
if _names_non_ekb_city(address):
|
||||||
res.skipped_non_ekb += 1
|
res.skipped_non_ekb += 1
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,29 @@
|
||||||
- Scheduled: nightly via scrape_schedules (source='geocode_missing_listings', migration 110)
|
- Scheduled: nightly via scrape_schedules (source='geocode_missing_listings', migration 110)
|
||||||
— wired into in-app scheduler, window 06:00-09:00 UTC.
|
— 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).
|
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):
|
Отличие от /admin/geocode-missing (per-ID):
|
||||||
- Этот модуль группирует по address → меньше API calls (dedup).
|
- Этот модуль группирует по (address, city) → меньше API calls (dedup), но не
|
||||||
|
схлопывает разные города с одинаковым текстом адреса.
|
||||||
- Поддерживает all sources включая Avito (после PR #487 убрали jitter).
|
- Поддерживает all sources включая Avito (после PR #487 убрали jitter).
|
||||||
- Возвращает GeocodeBackfillResult с детальными counters.
|
- Возвращает GeocodeBackfillResult с детальными counters.
|
||||||
- Loop-safe: SELECT фильтрует geocode_tried_at IS NULL OR tried_at < 7 days;
|
- 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
|
from __future__ import annotations
|
||||||
|
|
@ -53,13 +67,23 @@ async def geocode_missing_listings(
|
||||||
"""Geocode listings с NULL coords (любой source).
|
"""Geocode listings с NULL coords (любой source).
|
||||||
|
|
||||||
Steps:
|
Steps:
|
||||||
1. SELECT DISTINCT address FROM listings WHERE lat IS NULL AND address IS NOT NULL
|
1. SELECT address, city FROM listings WHERE lat IS NULL AND is_active
|
||||||
GROUP BY address ORDER BY COUNT(*) DESC LIMIT batch_size
|
AND address IS NOT NULL GROUP BY address, city ORDER BY COUNT(*) DESC
|
||||||
(приоритет адресам с большим числом listings — больший ROI per geocode call)
|
LIMIT batch_size
|
||||||
|
(приоритет парам address+city с большим числом listings — больший ROI per
|
||||||
|
geocode call; группировка по паре, НЕ только по address — #2594 шаг 2/3:
|
||||||
|
один и тот же текст адреса в разных городах — разные записи. `is_active` —
|
||||||
|
#2604 п.1: не тратим Nominatim-бюджет на мёртвые объявления, которые никогда
|
||||||
|
не попадут в выдачу пользователю)
|
||||||
|
|
||||||
2. Для каждого address:
|
2. Для каждой пары (address, city):
|
||||||
- geocode(address, db) — auto-cache (hit или miss)
|
- geocode(address, db, city_hint=city) — auto-cache (hit или miss)
|
||||||
- Если есть результат: UPDATE listings SET lat, lon WHERE address = :addr AND lat IS NULL
|
- Если есть результат: 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
|
- PostGIS trigger (listings_set_geom_trg) автоматически обновит geom
|
||||||
|
|
||||||
3. Log progress каждые 50 addresses.
|
3. Log progress каждые 50 addresses.
|
||||||
|
|
@ -74,25 +98,39 @@ async def geocode_missing_listings(
|
||||||
start = time.monotonic()
|
start = time.monotonic()
|
||||||
result = GeocodeBackfillResult()
|
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 дней (возможен
|
# geocode_tried_at. Повторяем попытку только если tried_at старше 7 дней (возможен
|
||||||
# переезд адреса в кэше или смена провайдера), либо tried_at IS NULL (ещё не пробовали).
|
# переезд адреса в кэше или смена провайдера), либо tried_at IS NULL (ещё не пробовали).
|
||||||
# Это делает функцию loop-safe: при вызове несколько раз в одном прогоне
|
# Это делает функцию 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 = (
|
rows = (
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
SELECT address, COUNT(*) AS listings_count
|
SELECT address, city, COUNT(*) AS listings_count
|
||||||
FROM listings
|
FROM listings
|
||||||
WHERE lat IS NULL
|
WHERE lat IS NULL
|
||||||
|
AND is_active
|
||||||
AND address IS NOT NULL
|
AND address IS NOT NULL
|
||||||
AND length(trim(address)) >= 5
|
AND length(trim(address)) >= 5
|
||||||
AND (geocode_tried_at IS NULL
|
AND (geocode_tried_at IS NULL
|
||||||
OR geocode_tried_at < NOW() - INTERVAL '7 days')
|
OR geocode_tried_at < NOW() - INTERVAL '7 days')
|
||||||
GROUP BY address
|
GROUP BY address, city
|
||||||
ORDER BY listings_count DESC, address ASC
|
ORDER BY listings_count DESC, address ASC, city ASC NULLS FIRST
|
||||||
LIMIT :limit
|
LIMIT :limit
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
|
|
@ -117,23 +155,36 @@ async def geocode_missing_listings(
|
||||||
|
|
||||||
for idx, row in enumerate(rows):
|
for idx, row in enumerate(rows):
|
||||||
address: str = row["address"]
|
address: str = row["address"]
|
||||||
|
city: str | None = row.get("city")
|
||||||
listings_count: int = row["listings_count"]
|
listings_count: int = row["listings_count"]
|
||||||
result.addresses_processed += 1
|
result.addresses_processed += 1
|
||||||
|
|
||||||
try:
|
try:
|
||||||
geo = await geocode(address, db)
|
geo = await geocode(address, db, city_hint=city)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("geocode_missing: geocode raised for '%s': %s", address[:60], exc)
|
logger.warning("geocode_missing: geocode raised for '%s': %s", address[:60], exc)
|
||||||
result.addresses_failed += 1
|
result.addresses_failed += 1
|
||||||
if not dry_run:
|
if not dry_run:
|
||||||
# Пометить tried_at чтобы адрес не переотбирался в следующих batch'ах
|
# Пометить tried_at чтобы пара (address, city) не переотбиралась
|
||||||
# этого же прогона (loop-safe backoff 7 дней).
|
# в следующих 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(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
"UPDATE listings SET geocode_tried_at = NOW()"
|
"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()
|
db.commit()
|
||||||
continue
|
continue
|
||||||
|
|
@ -141,18 +192,25 @@ async def geocode_missing_listings(
|
||||||
if geo is None:
|
if geo is None:
|
||||||
result.addresses_failed += 1
|
result.addresses_failed += 1
|
||||||
logger.info(
|
logger.info(
|
||||||
"geocode_missing: NOT FOUND '%s' (used in %d listings)",
|
"geocode_missing: NOT FOUND '%s' city=%r (used in %d listings)",
|
||||||
address[:60],
|
address[:60],
|
||||||
|
city,
|
||||||
listings_count,
|
listings_count,
|
||||||
)
|
)
|
||||||
if not dry_run:
|
if not dry_run:
|
||||||
# Пометить tried_at — geocoder не нашёл адрес, backoff 7 дней.
|
# Пометить tried_at — geocoder не нашёл адрес, backoff 7 дней.
|
||||||
|
# Намеренно БЕЗ `AND is_active` (#2604 п.2) — то же обоснование, что
|
||||||
|
# и в except-ветке выше: backoff привязан к тексту (address, city),
|
||||||
|
# не к конкретному listing, is_active=false строка и так не выбирается
|
||||||
|
# SELECT'ом заново; при реактивации backoff корректно защитит от
|
||||||
|
# немедленного повтора заведомо неудачного запроса.
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
"UPDATE listings SET geocode_tried_at = NOW()"
|
"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()
|
db.commit()
|
||||||
continue
|
continue
|
||||||
|
|
@ -183,16 +241,37 @@ async def geocode_missing_listings(
|
||||||
|
|
||||||
# UPDATE listings — PostGIS trigger (listings_set_geom_trg) обновит geom автоматически.
|
# UPDATE listings — PostGIS trigger (listings_set_geom_trg) обновит geom автоматически.
|
||||||
# geo_precision и geocode_tried_at проставляются одновременно с координатами.
|
# 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(
|
update_result = db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
UPDATE listings
|
UPDATE listings
|
||||||
SET lat = :lat, lon = :lon, geo_precision = :precision,
|
SET lat = :lat, lon = :lon, geo_precision = :precision,
|
||||||
geocode_tried_at = NOW()
|
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()
|
db.commit()
|
||||||
result.listings_updated += update_result.rowcount
|
result.listings_updated += update_result.rowcount
|
||||||
|
|
@ -293,6 +372,13 @@ async def run_geocode_missing_listings(
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
if res.addresses_total < batch_size:
|
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(
|
logger.info(
|
||||||
"run_geocode_missing_listings: run_id=%d — дренаж "
|
"run_geocode_missing_listings: run_id=%d — дренаж "
|
||||||
"(addresses_total=%d < batch_size=%d), завершаем",
|
"(addresses_total=%d < batch_size=%d), завершаем",
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,34 @@ listing_source_events. Так история per-source цены копится
|
||||||
через product_handlers._job_listing_source_snapshot,
|
через product_handlers._job_listing_source_snapshot,
|
||||||
по образцу import_rosreestr_dkp (sync task в run_in_executor).
|
по образцу import_rosreestr_dkp (sync task в run_in_executor).
|
||||||
|
|
||||||
Вся работа — два set-based SQL statement'а (snapshot upsert + event-diff CTE),
|
Вся работа — два set-based SQL statement'а (snapshot upsert + event-diff), никакого
|
||||||
никакого row-by-row Python: 18 355 строк обслуживаются одним INSERT … SELECT каждый.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
@ -27,6 +48,30 @@ logger = logging.getLogger(__name__)
|
||||||
# Окно свежести: источник считается активным, если last_seen_at не старше N дней.
|
# Окно свежести: источник считается активным, если last_seen_at не старше N дней.
|
||||||
FRESHNESS_WINDOW_DAYS = 7
|
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 ─────────────────────────────────────────────────────
|
# ── Daily snapshot upsert ─────────────────────────────────────────────────────
|
||||||
# Снимок на (listing_source_id, CURRENT_DATE). ON CONFLICT → last-write-wins за день
|
# Снимок на (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) с
|
||||||
# самым свежим ПРЕДЫДУЩИМ снимком (snapshot_date < CURRENT_DATE). Если цена изменилась
|
# самым свежим ПРЕДЫДУЩИМ снимком (snapshot_date < CURRENT_DATE). Если цена изменилась
|
||||||
# (обе NOT NULL, old <> 0) — пишем price_change.
|
# (обе NOT NULL, old <> 0) — пишем price_change.
|
||||||
# today — снимок за сегодня (только что записан _SNAPSHOT_SQL).
|
# today — снимок за сегодня (только что записан _SNAPSHOT_SQL, в той же транзакции).
|
||||||
# prior — последний снимок строго ДО сегодня (DISTINCT ON … ORDER BY date DESC).
|
# p — последний снимок строго ДО сегодня, per-row LATERAL point-lookup (#2607).
|
||||||
# Полностью set-based: один INSERT … SELECT по всем источникам, без Python-цикла.
|
#
|
||||||
|
# #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)
|
# change_time = now() детерминирует UNIQUE(listing_source_id, change_time, event_type)
|
||||||
# в пределах прогона → ON CONFLICT DO NOTHING делает писатель идемпотентным.
|
# в пределах прогона → ON CONFLICT DO NOTHING делает писатель идемпотентным.
|
||||||
_EVENT_DIFF_SQL = text(
|
_EVENT_DIFF_SQL = text(
|
||||||
|
|
@ -74,13 +135,6 @@ _EVENT_DIFF_SQL = text(
|
||||||
SELECT listing_source_id, price_rub
|
SELECT listing_source_id, price_rub
|
||||||
FROM listing_source_snapshots
|
FROM listing_source_snapshots
|
||||||
WHERE snapshot_date = CURRENT_DATE
|
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 (
|
INSERT INTO listing_source_events (
|
||||||
listing_source_id, change_time, event_type, price_rub, diff_percent
|
listing_source_id, change_time, event_type, price_rub, diff_percent
|
||||||
|
|
@ -92,7 +146,14 @@ _EVENT_DIFF_SQL = text(
|
||||||
t.price_rub,
|
t.price_rub,
|
||||||
round((t.price_rub - p.price_rub)::numeric / p.price_rub * 100, 4)
|
round((t.price_rub - p.price_rub)::numeric / p.price_rub * 100, 4)
|
||||||
FROM today t
|
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
|
WHERE t.price_rub IS NOT NULL
|
||||||
AND p.price_rub IS NOT NULL
|
AND p.price_rub IS NOT NULL
|
||||||
AND p.price_rub <> 0
|
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-события.
|
"""Записать дневной снимок listing_sources + price_change-события.
|
||||||
|
|
||||||
Sync (вызывается scheduler-триггером в executor, как import_rosreestr_dkp).
|
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.
|
1. upsert снимка на (listing_source_id, CURRENT_DATE) — last-write-wins.
|
||||||
2. diff сегодняшней цены против последнего предыдущего снимка → price_change-события.
|
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.
|
Финализирует scrape_runs (mark_done / mark_failed) и пишет counters.
|
||||||
|
|
||||||
Returns {"snapshotted": N, "price_change_events": M}.
|
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}
|
counters: dict[str, int] = {"snapshotted": 0, "price_change_events": 0}
|
||||||
try:
|
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(
|
snap_result = db.execute(
|
||||||
_SNAPSHOT_SQL,
|
_SNAPSHOT_SQL,
|
||||||
{"freshness_days": FRESHNESS_WINDOW_DAYS, "run_id": run_id},
|
{"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
|
return counters
|
||||||
except Exception as exc:
|
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()
|
db.rollback()
|
||||||
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
|
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
|
||||||
raise
|
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`
|
`audit_address_mismatch.py`, `backfill_house_coords.py`, `_yandex_reverse.py`
|
||||||
в `backend/Dockerfile`). На VPS они уже в `/app/scripts/` — никаких manual
|
и их SQL-хелперы (`audit_address_sample.sql`, `address_audit_report.sql`)
|
||||||
`docker cp` не нужно.
|
удалены — весь pipeline опирался на Yandex Geocoder API, который выпилен
|
||||||
|
из проекта (#2593, части 1-3). `houses.address`→lat/lon geocoding теперь
|
||||||
`YANDEX_GEOCODER_API_KEY` подтягивается из `/opt/gendesign/tradein-mvp/backend/
|
идёт через `app/services/geocoder.py` (кадастр/геопортал ЕКБ-тиры + Nominatim
|
||||||
.env.runtime` через `env_file:` в `docker-compose.prod.yml` — никакого `-e` в
|
fallback, единственный живой внешний провайдер) на обычном write-path
|
||||||
`docker exec` не нужно.
|
(`/api/v1/trade-in/estimate`, listing ingest). Разовый forward-backfill
|
||||||
|
недостающих `houses` координат — `scripts/geocode_deals_nominatim.py`
|
||||||
```bash
|
(живой, работает с `rosreestr_deals`, не с `houses` — читай его docstring
|
||||||
# Backfill (forward geocode 4170 houses без coords)
|
перед использованием на других таблицах). Таблица `address_mismatch_audit`
|
||||||
ssh gendesign 'docker exec tradein-backend python -m scripts.backfill_house_coords --batch 2026-05-27_backfill'
|
осталась в схеме (используется `house_dedup_merge.py` при слиянии дублей
|
||||||
|
домов, независимо от Yandex-аудита).
|
||||||
# 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).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 и
|
Покрытие БЕЗ live-сети/БД: stateful FakeSession эмулирует таблицу scrape_proxies и
|
||||||
интерпретирует SQL по ключевым фрагментам, так что acquire/release/mark_health/
|
интерпретирует SQL по ключевым фрагментам, так что acquire/release/mark_health/
|
||||||
|
|
@ -8,11 +8,15 @@ reap_stale_leases проверяются по фактическому изме
|
||||||
- два acquire подряд → РАЗНЫЕ прокси (первый лизнут → выпал из выборки второго).
|
- два acquire подряд → РАЗНЫЕ прокси (первый лизнут → выпал из выборки второго).
|
||||||
- release освобождает (leased_by → NULL), прокси снова acquire-абелен.
|
- release освобождает (leased_by → NULL), прокси снова acquire-абелен.
|
||||||
- mark_health fail → инкремент consecutive_fails, авто-disable при DISABLE_THRESHOLD.
|
- 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, свежий не трогает.
|
- reap_stale_leases освобождает старый lease, свежий не трогает.
|
||||||
- affinity-фильтр: acquire('avito') не берёт cian-only прокси.
|
- affinity-фильтр: acquire('avito') не берёт cian-only прокси.
|
||||||
- acquire пропускает disabled и «нездоровые» (fails >= MAX_CONSECUTIVE_FAILS).
|
- acquire пропускает disabled и «нездоровые» (fails >= MAX_CONSECUTIVE_FAILS).
|
||||||
|
- acquire без своих/any свободных → берёт свободный чужой affinity (fallback, #2600 п.3).
|
||||||
- run_proxy_healthcheck: reap + проба каждого enabled + mark_health (проба замокана).
|
- run_proxy_healthcheck: reap + проба каждого enabled + mark_health (проба замокана).
|
||||||
|
- run_proxy_healthcheck: disabled-узлы — самовосстановление (#2600 п.1):
|
||||||
|
* успешная проба выключенного узла возвращает его в строй + revived++;
|
||||||
|
* недавно проверенный выключенный узел повторно не проверяется (не долбим провайдера).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -29,6 +33,7 @@ import pytest
|
||||||
from app.services import proxy_pool
|
from app.services import proxy_pool
|
||||||
from app.services.proxy_pool import (
|
from app.services.proxy_pool import (
|
||||||
DISABLE_THRESHOLD,
|
DISABLE_THRESHOLD,
|
||||||
|
DISABLED_RECHECK_MINUTES,
|
||||||
MAX_CONSECUTIVE_FAILS,
|
MAX_CONSECUTIVE_FAILS,
|
||||||
acquire,
|
acquire,
|
||||||
mark_health,
|
mark_health,
|
||||||
|
|
@ -71,17 +76,44 @@ class FakeSession:
|
||||||
sql = str(stmt)
|
sql = str(stmt)
|
||||||
p = params or {}
|
p = params or {}
|
||||||
|
|
||||||
if "FOR UPDATE SKIP LOCKED" in sql: # acquire SELECT
|
if "FOR UPDATE SKIP LOCKED" in sql: # acquire SELECT (primary affinity-scoped or fallback)
|
||||||
provider = p["provider"]
|
|
||||||
max_fails = p["max_fails"]
|
max_fails = p["max_fails"]
|
||||||
cands = [
|
if "provider_affinity IN" in sql: # primary: своя affinity ИЛИ 'any'
|
||||||
r
|
provider = p["provider"]
|
||||||
for r in self.rows
|
cands = [
|
||||||
if r["enabled"]
|
r
|
||||||
and r["consecutive_fails"] < max_fails
|
for r in self.rows
|
||||||
and r["provider_affinity"] in (provider, "any")
|
if r["enabled"]
|
||||||
and r["leased_by"] is None
|
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
|
# ORDER BY last_ok_at NULLS LAST, id
|
||||||
cands.sort(
|
cands.sort(
|
||||||
key=lambda r: (
|
key=lambda r: (
|
||||||
|
|
@ -127,18 +159,33 @@ class FakeSession:
|
||||||
row["exit_ip"] = p["exit_ip"]
|
row["exit_ip"] = p["exit_ip"]
|
||||||
row["latency_ms"] = p["latency_ms"]
|
row["latency_ms"] = p["latency_ms"]
|
||||||
row["last_ok_at"] = datetime.now(UTC)
|
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([])
|
return _FakeResult([])
|
||||||
|
|
||||||
if "consecutive_fails = consecutive_fails + 1" in sql: # mark_health fail
|
if "consecutive_fails = consecutive_fails + 1" in sql: # mark_health fail
|
||||||
row = self._by_id(p["id"])
|
row = self._by_id(p["id"])
|
||||||
if row is not None:
|
if row is not None:
|
||||||
row["consecutive_fails"] += 1
|
row["consecutive_fails"] += 1
|
||||||
|
row["last_check_at"] = datetime.now(UTC)
|
||||||
if row["consecutive_fails"] >= p["disable_threshold"]:
|
if row["consecutive_fails"] >= p["disable_threshold"]:
|
||||||
row["enabled"] = False
|
row["enabled"] = False
|
||||||
return _FakeResult([])
|
return _FakeResult([])
|
||||||
|
|
||||||
if "WHERE enabled" in sql and "ORDER BY id" in sql: # healthcheck SELECT
|
if "WHERE enabled" in sql and "ORDER BY id" in sql: # healthcheck SELECT (#2600 п.1)
|
||||||
rows = sorted((r for r in self.rows if r["enabled"]), key=lambda r: r["id"])
|
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])
|
return _FakeResult([dict(r) for r in rows])
|
||||||
|
|
||||||
raise AssertionError(f"unhandled SQL: {sql}")
|
raise AssertionError(f"unhandled SQL: {sql}")
|
||||||
|
|
@ -159,6 +206,7 @@ def _proxy(
|
||||||
leased_by: int | None = None,
|
leased_by: int | None = None,
|
||||||
leased_at: datetime | None = None,
|
leased_at: datetime | None = None,
|
||||||
last_ok_at: datetime | None = None,
|
last_ok_at: datetime | None = None,
|
||||||
|
last_check_at: datetime | None = None,
|
||||||
kind: str = "http",
|
kind: str = "http",
|
||||||
rotate_url: str | None = None,
|
rotate_url: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
|
@ -173,6 +221,7 @@ def _proxy(
|
||||||
"leased_by": leased_by,
|
"leased_by": leased_by,
|
||||||
"leased_at": leased_at,
|
"leased_at": leased_at,
|
||||||
"last_ok_at": last_ok_at,
|
"last_ok_at": last_ok_at,
|
||||||
|
"last_check_at": last_check_at,
|
||||||
"exit_ip": None,
|
"exit_ip": None,
|
||||||
"latency_ms": 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]
|
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:
|
def test_acquire_skips_disabled() -> None:
|
||||||
db = FakeSession([_proxy(1, affinity="avito", enabled=False)])
|
db = FakeSession([_proxy(1, affinity="avito", enabled=False)])
|
||||||
assert acquire(db, "avito", run_id=1) is None # type: ignore[arg-type]
|
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
|
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 ──────────────────────────────────────────────────────────────────
|
# ── release ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -271,6 +371,15 @@ def test_mark_health_ok_resets_and_records() -> None:
|
||||||
assert row["last_ok_at"] is not 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 ────────────────────────────────────────────────────────
|
# ── 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(
|
async def test_healthcheck_probes_enabled_and_marks_health(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
recently_checked = datetime.now(UTC) - timedelta(minutes=5) # < DISABLED_RECHECK_MINUTES
|
||||||
db = FakeSession(
|
db = FakeSession(
|
||||||
[
|
[
|
||||||
_proxy(1, fails=2),
|
_proxy(1, fails=2),
|
||||||
_proxy(2, enabled=False), # disabled — не проверяется
|
# disabled, recheck ещё не наступил (недавно проверен) — не проверяется в этот прогон
|
||||||
|
_proxy(2, enabled=False, last_check_at=recently_checked),
|
||||||
_proxy(3, fails=0),
|
_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 «мёртв»
|
# прокси 1 «жив», прокси 3 «мёртв»
|
||||||
if "h1:" in url:
|
if "h1:" in url:
|
||||||
return True, "9.9.9.9", 42
|
return True, "9.9.9.9", 42, None
|
||||||
return False, None, None
|
return False, None, None, "other"
|
||||||
|
|
||||||
monkeypatch.setattr(proxy_pool, "_probe_proxy", _fake_probe)
|
monkeypatch.setattr(proxy_pool, "_probe_proxy", _fake_probe)
|
||||||
|
|
||||||
counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
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["ok"] == 1
|
||||||
assert counters["failed"] == 1
|
assert counters["failed"] == 1
|
||||||
|
assert counters["revived"] == 0
|
||||||
assert db._by_id(1)["consecutive_fails"] == 0 # ok → сброс
|
assert db._by_id(1)["consecutive_fails"] == 0 # ok → сброс
|
||||||
assert db._by_id(1)["exit_ip"] == "9.9.9.9"
|
assert db._by_id(1)["exit_ip"] == "9.9.9.9"
|
||||||
assert db._by_id(3)["consecutive_fails"] == 1 # fail → инкремент
|
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")
|
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 ───────────────────────────────────────────────────────────────
|
# ── idempotency ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -342,6 +342,31 @@ async def test_geocode_missing_recent_tried_at_excluded_via_where() -> None:
|
||||||
assert "7 days" in sql_text
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_run_geocode_missing_listings_terminates_on_drained() -> None:
|
async def test_run_geocode_missing_listings_terminates_on_drained() -> None:
|
||||||
"""run_geocode_missing_listings завершается когда addresses_total == 0 (ничего pending)."""
|
"""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()
|
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 ─────────────────────
|
# ── Integration-style: estimator Avito exclusion removed ─────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -494,3 +755,65 @@ def test_admin_geocode_missing_post_dry_run_endpoint_exists() -> None:
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert "status" in data
|
assert "status" in data
|
||||||
assert "addresses_total" 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:
|
def test_rederivation_scopes_sold_side_to_asking_city() -> None:
|
||||||
"""#C2: SOLD-сторона (deal_side + deal_global) скоупится на город asking-стороны (ЕКБ).
|
"""#C2: SOLD-сторона (deal_side + deal_global) скоупится на город asking-стороны (ЕКБ).
|
||||||
|
|
||||||
Миграция 177 залила ДКП по всей обл.66, а asking (listings) — только ЕКБ. Без скоупа
|
Миграция 177 залила ДКП по всей обл.66, а asking (listings) исторически — только ЕКБ.
|
||||||
sold-медиана смешивала дешёвую область → ratio 0.877→0.62, «выкупная» −29%. Оба
|
Без скоупа sold-медиана смешивала дешёвую область → ratio 0.877→0.62, «выкупная» −29%.
|
||||||
deal-CTE (per-rooms + global) должны нести предикат; ask-стороны — НЕ трогаем.
|
Оба deal-CTE (per-rooms + global) несут предикат unconditionally (deals.city не имеет
|
||||||
|
массовых NULL как listings.city — #2598 их не касается).
|
||||||
"""
|
"""
|
||||||
assert ratio_mod._ASKING_CITY_PATTERN == "%Екатеринбург%"
|
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
|
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")
|
_a = _REDERIVE_SQL.index("ask_side AS")
|
||||||
_b = _REDERIVE_SQL.index("per_bucket 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:
|
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;
|
#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.
|
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")
|
seed_sql = _MIGRATION_080.read_text("utf-8")
|
||||||
# Extract the WITH … (up to the ON CONFLICT) from the seed.
|
# 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:
|
def _drop_city_guard(s: str) -> str:
|
||||||
"""Remove the #C2 EKB city-scope predicate on the SOLD side (absent in the 080 seed)."""
|
"""Remove the #C2 SOLD-side + #2583 H2 ASK-side city-scope predicates.
|
||||||
return re.sub(r"AND\s+city\s+ILIKE\s+:asking_city", "", s)
|
|
||||||
|
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:
|
def _norm(s: str) -> str:
|
||||||
return _strip_sql(_normalise_ppm2(_drop_city_guard(_drop_segment_guard(s))))
|
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
|
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
|
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`:
|
in-memory fake DB standing in for the identity registry:
|
||||||
- `app.core.rbac.SessionLocal` is monkeypatched (rbac_guard opens its own session,
|
- сессия РЕЕСТРА подменяется на самом низком уровне — `identity_store.SessionLocal`
|
||||||
it's middleware — no FastAPI DI available there).
|
и `auth_db.auth_session` (см. `tests.support.identity_modes.patch_identity_sessions`),
|
||||||
- `app.core.db.get_db` is overridden via `app.dependency_overrides` (auth.py /
|
так что и `identity_session()` (rbac_guard — middleware, FastAPI-DI там нет), и
|
||||||
me.py use `Depends(get_db)`, the idiomatic FastAPI-testable path).
|
`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
|
Все они смотрят в ОДИН `_Store` на тест, поэтому сессия, созданная POST /login,
|
||||||
/login is immediately visible to rbac_guard's own DB round trip on the next request.
|
сразу видна собственному 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
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import Annotated, Any
|
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 auth as auth_router
|
||||||
from app.api.v1 import me as me_router
|
from app.api.v1 import me as me_router
|
||||||
from app.core import auth as auth_mod
|
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.db import get_db
|
||||||
from app.core.password import hash_password
|
from app.core.password import hash_password
|
||||||
from app.core.rbac import rbac_guard
|
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:
|
def __init__(self) -> None:
|
||||||
self.users: dict[str, dict[str, Any]] = {}
|
self.users: dict[str, dict[str, Any]] = {}
|
||||||
self.sessions: dict[str, dict[str, Any]] = {}
|
self.sessions: dict[str, dict[str, Any]] = {}
|
||||||
|
self.sql_log: list[str] = [] # весь SQL, доехавший до «БД» — см. тесты режимов
|
||||||
self._next_id = 1
|
self._next_id = 1
|
||||||
|
|
||||||
def add_user(
|
def add_user(
|
||||||
|
|
@ -51,7 +77,7 @@ class _Store:
|
||||||
password_hash: str | None,
|
password_hash: str | None,
|
||||||
*,
|
*,
|
||||||
role: str = "employee",
|
role: str = "employee",
|
||||||
is_active: bool = True,
|
access_state: AccessState = AccessState.ACTIVE,
|
||||||
display_name: str | None = "Alice A.",
|
display_name: str | None = "Alice A.",
|
||||||
org_name: str | None = "Org LLC",
|
org_name: str | None = "Org LLC",
|
||||||
email: str | None = "alice@example.com",
|
email: str | None = "alice@example.com",
|
||||||
|
|
@ -63,13 +89,19 @@ class _Store:
|
||||||
"username": username,
|
"username": username,
|
||||||
"password_hash": password_hash,
|
"password_hash": password_hash,
|
||||||
"role": role,
|
"role": role,
|
||||||
"is_active": is_active,
|
# СЫРОЕ значение колонки текущего режима (boolean либо text) — ровно
|
||||||
|
# то, что вернул бы драйвер; в AccessState его превращает код.
|
||||||
|
"access_state": column_value(access_state),
|
||||||
"display_name": display_name,
|
"display_name": display_name,
|
||||||
"org_name": org_name,
|
"org_name": org_name,
|
||||||
"email": email,
|
"email": email,
|
||||||
}
|
}
|
||||||
return uid
|
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:
|
def user_by_id(self, uid: int) -> dict[str, Any] | None:
|
||||||
for u in self.users.values():
|
for u in self.users.values():
|
||||||
if u["id"] == uid:
|
if u["id"] == uid:
|
||||||
|
|
@ -109,8 +141,12 @@ class _FakeDB:
|
||||||
def execute(self, stmt: object, params: dict[str, Any] | None = None) -> SimpleNamespace:
|
def execute(self, stmt: object, params: dict[str, Any] | None = None) -> SimpleNamespace:
|
||||||
sql = str(stmt)
|
sql = str(stmt)
|
||||||
p = params or {}
|
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)
|
now = datetime.now(UTC)
|
||||||
self.store.sessions[p["token"]] = {
|
self.store.sessions[p["token"]] = {
|
||||||
"user_id": p["user_id"],
|
"user_id": p["user_id"],
|
||||||
|
|
@ -119,7 +155,7 @@ class _FakeDB:
|
||||||
}
|
}
|
||||||
return SimpleNamespace(fetchone=lambda: None)
|
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"])
|
sess = self.store.sessions.get(p["token"])
|
||||||
if sess is not None:
|
if sess is not None:
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
|
|
@ -127,23 +163,27 @@ class _FakeDB:
|
||||||
sess["expires_at"] = now + timedelta(hours=p["ttl_hours"])
|
sess["expires_at"] = now + timedelta(hours=p["ttl_hours"])
|
||||||
return SimpleNamespace(fetchone=lambda: None)
|
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)
|
self.store.sessions.pop(p["token"], None)
|
||||||
return SimpleNamespace(fetchone=lambda: 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"]
|
uid = p["user_id"]
|
||||||
for tok in [t for t, s in self.store.sessions.items() if s["user_id"] == uid]:
|
for tok in [t for t, s in self.store.sessions.items() if s["user_id"] == uid]:
|
||||||
del self.store.sessions[tok]
|
del self.store.sessions[tok]
|
||||||
return SimpleNamespace(fetchone=lambda: None)
|
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"])
|
sess = self.store.sessions.get(p["token"])
|
||||||
if sess is None:
|
if sess is None:
|
||||||
return SimpleNamespace(fetchone=lambda: None)
|
return SimpleNamespace(fetchone=lambda: None)
|
||||||
user = self.store.user_by_id(sess["user_id"])
|
user = self.store.user_by_id(sess["user_id"])
|
||||||
if user is None:
|
if user is None:
|
||||||
return SimpleNamespace(fetchone=lambda: None)
|
return SimpleNamespace(fetchone=lambda: None)
|
||||||
|
# Колонка состояния приезжает под алиасом `access_state` в ОБОИХ
|
||||||
|
# режимах (`u.<колонка> AS access_state` в реальном SELECT'е);
|
||||||
|
# значение — сырое, типа своей схемы.
|
||||||
row = SimpleNamespace(
|
row = SimpleNamespace(
|
||||||
user_id=sess["user_id"],
|
user_id=sess["user_id"],
|
||||||
expires_at=sess["expires_at"],
|
expires_at=sess["expires_at"],
|
||||||
|
|
@ -153,11 +193,12 @@ class _FakeDB:
|
||||||
display_name=user["display_name"],
|
display_name=user["display_name"],
|
||||||
org_name=user["org_name"],
|
org_name=user["org_name"],
|
||||||
email=user["email"],
|
email=user["email"],
|
||||||
is_active=user["is_active"],
|
access_state=user["access_state"],
|
||||||
)
|
)
|
||||||
return SimpleNamespace(fetchone=lambda: row)
|
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"])
|
user = self.store.users.get(p["username"])
|
||||||
if user is None:
|
if user is None:
|
||||||
return SimpleNamespace(fetchone=lambda: None)
|
return SimpleNamespace(fetchone=lambda: None)
|
||||||
|
|
@ -214,6 +255,9 @@ def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
auth_mod.reset_cache_for_tests()
|
auth_mod.reset_cache_for_tests()
|
||||||
auth_router._LOGIN_LIMITER._hits.clear()
|
auth_router._LOGIN_LIMITER._hits.clear()
|
||||||
monkeypatch.setattr(config.settings, "auth_mode", "dual")
|
monkeypatch.setattr(config.settings, "auth_mode", "dual")
|
||||||
|
# Каждый тест стартует в ДЕФОЛТНОМ режиме реестра (сегодняшний прод), даже
|
||||||
|
# если предыдущий переключался на `auth`.
|
||||||
|
use_identity_mode(monkeypatch, "tradein")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -221,9 +265,23 @@ def store() -> _Store:
|
||||||
return _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
|
@pytest.fixture
|
||||||
def client(store: _Store, monkeypatch: pytest.MonkeyPatch) -> TestClient:
|
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
|
# 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
|
# 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
|
# 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:
|
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!"})
|
resp = client.post("/api/v1/auth/login", json={"username": "bob", "password": "Secret123!"})
|
||||||
assert resp.status_code == 401
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
@ -611,3 +671,193 @@ def test_cyrillic_username_session_propagation_does_not_500(
|
||||||
# latin-1 "replace" гарантированно не крашит — точное значение (что именно
|
# latin-1 "replace" гарантированно не крашит — точное значение (что именно
|
||||||
# получится из non-latin1 байт) не является контрактом, важно отсутствие 500.
|
# получится из non-latin1 байт) не является контрактом, важно отсутствие 500.
|
||||||
assert resp.json()["user"] is not None
|
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:
|
Coverage:
|
||||||
- create_session: INSERT with CAST(...) (never `:x::type`), commit, unique tokens.
|
- 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).
|
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.
|
- revoke_session / revoke_user_sessions: DELETE + commit.
|
||||||
- get_db_role_scope: employee/manager/admin/unknown mapping.
|
- get_db_role_scope: employee/manager/admin/unknown mapping.
|
||||||
|
|
||||||
All functions here take `db: Session` as a plain argument (no SessionLocal() opened
|
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`
|
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.
|
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
|
from __future__ import annotations
|
||||||
|
|
@ -23,7 +36,34 @@ from typing import Any
|
||||||
|
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
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 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
|
# Fake DB session
|
||||||
|
|
@ -58,6 +98,11 @@ class _FakeDB:
|
||||||
self.rolled_back += 1
|
self.rolled_back += 1
|
||||||
|
|
||||||
|
|
||||||
|
# Часовой «аргумент не передан» — None здесь занят (это валидное сырое значение
|
||||||
|
# колонки: NULL, который to_access_state обязан трактовать как disabled).
|
||||||
|
_MISSING = object()
|
||||||
|
|
||||||
|
|
||||||
def _session_row(
|
def _session_row(
|
||||||
*,
|
*,
|
||||||
user_id: int = 1,
|
user_id: int = 1,
|
||||||
|
|
@ -65,8 +110,17 @@ def _session_row(
|
||||||
last_seen_at: datetime | None = None,
|
last_seen_at: datetime | None = None,
|
||||||
username: str = "alice",
|
username: str = "alice",
|
||||||
role: str = "employee",
|
role: str = "employee",
|
||||||
is_active: bool = True,
|
access_state: AccessState = AccessState.ACTIVE,
|
||||||
|
raw_access_state: object = _MISSING,
|
||||||
) -> SimpleNamespace:
|
) -> 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)
|
now = datetime.now(UTC)
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
|
|
@ -77,7 +131,9 @@ def _session_row(
|
||||||
display_name="Alice A.",
|
display_name="Alice A.",
|
||||||
org_name="Org LLC",
|
org_name="Org LLC",
|
||||||
email="alice@example.com",
|
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",
|
username: str = "alice",
|
||||||
password_hash: str | None = "hash",
|
password_hash: str | None = "hash",
|
||||||
role: str = "employee",
|
role: str = "employee",
|
||||||
is_active: bool = True,
|
access_state: AccessState = AccessState.ACTIVE,
|
||||||
) -> SimpleNamespace:
|
) -> SimpleNamespace:
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
id=user_id,
|
id=user_id,
|
||||||
username=username,
|
username=username,
|
||||||
password_hash=password_hash,
|
password_hash=password_hash,
|
||||||
role=role,
|
role=role,
|
||||||
is_active=is_active,
|
access_state=column_value(access_state),
|
||||||
display_name="Alice A.",
|
display_name="Alice A.",
|
||||||
org_name="Org LLC",
|
org_name="Org LLC",
|
||||||
email="alice@example.com",
|
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()
|
db = _FakeDB()
|
||||||
token = svc.create_session(db, user_id=42, ip="1.2.3.4", user_agent="pytest")
|
token = svc.create_session(db, user_id=42, ip="1.2.3.4", user_agent="pytest")
|
||||||
|
|
||||||
assert db.committed == 1
|
assert db.committed == 1
|
||||||
assert len(db.executed) == 1
|
assert len(db.executed) == 1
|
||||||
sql, params = db.executed[0]
|
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 is not None
|
||||||
assert params["user_id"] == 42
|
assert params["user_id"] == 42
|
||||||
assert params["ip"] == "1.2.3.4"
|
assert params["ip"] == "1.2.3.4"
|
||||||
|
|
@ -123,7 +179,7 @@ def test_create_session_inserts_and_commits() -> None:
|
||||||
assert len(token) >= 32
|
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()
|
db = _FakeDB()
|
||||||
svc.create_session(db, user_id=1)
|
svc.create_session(db, user_id=1)
|
||||||
sql, _ = db.executed[0]
|
sql, _ = db.executed[0]
|
||||||
|
|
@ -150,16 +206,20 @@ def test_get_session_user_no_token_returns_none() -> None:
|
||||||
assert db.executed == []
|
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])
|
db = _FakeDB(rows=[None])
|
||||||
assert svc.get_session_user(db, "tok") is None
|
assert svc.get_session_user(db, "tok") is None
|
||||||
sql, params = db.executed[0]
|
sql, params = db.executed[0]
|
||||||
assert "FROM tradein_sessions s" in sql
|
assert f"FROM {schema.sessions_table} s" in sql
|
||||||
assert "JOIN tradein_users u" 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"}
|
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)
|
now = datetime.now(UTC)
|
||||||
db = _FakeDB(rows=[_session_row(expires_at=now - timedelta(minutes=1))])
|
db = _FakeDB(rows=[_session_row(expires_at=now - timedelta(minutes=1))])
|
||||||
assert svc.get_session_user(db, "tok") is None
|
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
|
assert len(db.executed) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_get_session_user_inactive_returns_none() -> None:
|
def test_get_session_user_disabled_returns_none(identity_mode: str) -> None:
|
||||||
db = _FakeDB(rows=[_session_row(is_active=False)])
|
"""Жёстко заблокированный аккаунт — сессия недействительна в обеих схемах."""
|
||||||
|
db = _FakeDB(rows=[_session_row(access_state=AccessState.DISABLED)])
|
||||||
assert svc.get_session_user(db, "tok") is None
|
assert svc.get_session_user(db, "tok") is None
|
||||||
assert len(db.executed) == 1
|
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 НЕ триггерится."""
|
"""last_seen_at свежий (<5 мин) — sliding refresh НЕ триггерится."""
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
db = _FakeDB(rows=[_session_row(last_seen_at=now - timedelta(minutes=1))])
|
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["org_name"] == "Org LLC"
|
||||||
assert result["email"] == "alice@example.com"
|
assert result["email"] == "alice@example.com"
|
||||||
assert result["user_id"] == 1
|
assert result["user_id"] == 1
|
||||||
|
# Состояние доступа приезжает ЕДИНЫМ понятием, а не boolean/str по режимам;
|
||||||
|
# сюда доходит только ACTIVE (не-active отсеян выше).
|
||||||
|
assert result["access_state"] is AccessState.ACTIVE
|
||||||
# Только 1 execute (SELECT) — никакого UPDATE.
|
# Только 1 execute (SELECT) — никакого UPDATE.
|
||||||
assert len(db.executed) == 1
|
assert len(db.executed) == 1
|
||||||
assert db.committed == 0
|
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."""
|
"""last_seen_at старше 5 минут — один UPDATE (sliding refresh) + commit."""
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
db = _FakeDB(rows=[_session_row(last_seen_at=now - timedelta(minutes=10))])
|
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 result is not None
|
||||||
assert len(db.executed) == 2
|
assert len(db.executed) == 2
|
||||||
update_sql, update_params = db.executed[1]
|
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 "SET last_seen_at" in update_sql
|
||||||
assert not re.search(r":\w+::\w", update_sql)
|
assert not re.search(r":\w+::\w", update_sql)
|
||||||
assert "CAST(:ttl_hours AS integer)" in 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
|
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 падает — всё равно возвращаем валидного юзера
|
"""Sliding-refresh UPDATE падает — всё равно возвращаем валидного юзера
|
||||||
(best-effort refresh, не часть решения "валидна ли сессия")."""
|
(best-effort refresh, не часть решения "валидна ли сессия")."""
|
||||||
now = datetime.now(UTC)
|
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()])
|
db = _FakeDB(rows=[_user_row()])
|
||||||
user = svc.get_user_by_username(db, "alice")
|
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["username"] == "alice"
|
||||||
assert user["password_hash"] == "hash"
|
assert user["password_hash"] == "hash"
|
||||||
assert user["role"] == "employee"
|
assert user["role"] == "employee"
|
||||||
assert user["is_active"] is True
|
assert user["access_state"] is AccessState.ACTIVE
|
||||||
sql, params = db.executed[0]
|
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"}
|
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])
|
db = _FakeDB(rows=[None])
|
||||||
assert svc.get_user_by_username(db, "ghost") is 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()
|
db = _FakeDB()
|
||||||
svc.revoke_session(db, "tok")
|
svc.revoke_session(db, "tok")
|
||||||
|
|
||||||
assert db.committed == 1
|
assert db.committed == 1
|
||||||
sql, params = db.executed[0]
|
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 "token" in sql
|
||||||
assert params == {"token": "tok"}
|
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()
|
db = _FakeDB()
|
||||||
svc.revoke_user_sessions(db, 7)
|
svc.revoke_user_sessions(db, 7)
|
||||||
|
|
||||||
assert db.committed == 1
|
assert db.committed == 1
|
||||||
sql, params = db.executed[0]
|
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 "user_id" in sql
|
||||||
assert params == {"user_id": 7}
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
|
@ -26,9 +27,13 @@ sys.modules.setdefault("weasyprint", _wp_mock)
|
||||||
|
|
||||||
|
|
||||||
from app.services.geocoder import ( # noqa: E402
|
from app.services.geocoder import ( # noqa: E402
|
||||||
|
_SQL_HOUSE_TOKEN_RE,
|
||||||
|
SVERDLOVSK_OBLAST_REGION,
|
||||||
GeocodeResult,
|
GeocodeResult,
|
||||||
GeocodeSuggestion,
|
GeocodeSuggestion,
|
||||||
_cadastral_house_match,
|
_cadastral_house_match,
|
||||||
|
_dadata_suggest,
|
||||||
|
_norm_house,
|
||||||
_parse_street_house,
|
_parse_street_house,
|
||||||
geocode,
|
geocode,
|
||||||
suggest,
|
suggest,
|
||||||
|
|
@ -143,8 +148,8 @@ def test_house_match_returns_none_for_non_numeric_house() -> None:
|
||||||
db.execute.assert_not_called()
|
db.execute.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
def test_house_match_passes_only_digits_as_regex_param() -> None:
|
def _house_match_params(house: str, street: str = "космонавтов") -> dict:
|
||||||
"""house='26а' → :house_digits bound param is '26' (letter stripped for regex)."""
|
"""Вызывает матчер с mock-сессией и возвращает bound-params запроса."""
|
||||||
row = MagicMock()
|
row = MagicMock()
|
||||||
row.readable_address = "г. Екатеринбург, пр-кт Космонавтов, д. 26а"
|
row.readable_address = "г. Екатеринбург, пр-кт Космонавтов, д. 26а"
|
||||||
row.lat = 56.9
|
row.lat = 56.9
|
||||||
|
|
@ -154,13 +159,58 @@ def test_house_match_passes_only_digits_as_regex_param() -> None:
|
||||||
result.first.return_value = row
|
result.first.return_value = row
|
||||||
db.execute.return_value = result
|
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
|
# second positional arg to execute() is the bound-params dict
|
||||||
params = db.execute.call_args.args[1]
|
return db.execute.call_args.args[1]
|
||||||
assert params["house_digits"] == "26"
|
|
||||||
assert params["house_full"] == "26а"
|
|
||||||
|
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 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 ─────────────────────────────────────────────────────────
|
# ── geocode() wiring ─────────────────────────────────────────────────────────
|
||||||
|
|
@ -604,3 +654,202 @@ async def test_suggest_skips_ekb_local_tier_for_unrecognized_locality(
|
||||||
mock_house.assert_not_called()
|
mock_house.assert_not_called()
|
||||||
mock_forward.assert_not_called()
|
mock_forward.assert_not_called()
|
||||||
mock_nominatim.assert_called_once()
|
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 ────────────────────────────────────────────────────────
|
# ── Event-diff CTE SQL ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def test_event_diff_is_set_based_cte_not_python_loop() -> None:
|
def test_event_diff_is_set_based_lateral_not_python_loop() -> None:
|
||||||
"""Event diff is one set-based INSERT … SELECT over a CTE — never a row-by-row loop."""
|
"""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 "WITH today AS" in _EVENT_DIFF_SQL
|
||||||
assert "prior AS" in _EVENT_DIFF_SQL
|
assert "JOIN LATERAL" in _EVENT_DIFF_SQL
|
||||||
assert "DISTINCT ON (listing_source_id)" 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
|
assert "INSERT INTO listing_source_events" in _EVENT_DIFF_SQL
|
||||||
# Prior = most-recent snapshot strictly before today.
|
# LATERAL subquery: most-recent snapshot strictly before today, per listing_source_id.
|
||||||
assert "snapshot_date < CURRENT_DATE" in _EVENT_DIFF_SQL
|
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 "snapshot_date = CURRENT_DATE" in _EVENT_DIFF_SQL
|
||||||
assert "ORDER BY listing_source_id, snapshot_date DESC" in _EVENT_DIFF_SQL
|
assert "ORDER BY s.snapshot_date DESC" in _EVENT_DIFF_SQL
|
||||||
# No Python iteration over rows in the writer body (set-based only).
|
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]
|
body = _WRITER_SRC.split('"""', 2)[-1]
|
||||||
assert "for " not in body, "writer must be set-based — no Python row loop"
|
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)
|
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]
|
out = snap_mod.snapshot_listing_sources(db, run_id=99) # type: ignore[arg-type]
|
||||||
|
|
||||||
assert out == {"snapshotted": 18355, "price_change_events": 42}
|
assert out == {"snapshotted": 18355, "price_change_events": 42}
|
||||||
assert db.committed is True
|
assert db.committed is True
|
||||||
assert len(db.executed) == 2
|
assert len(db.executed) == 3
|
||||||
# run_id threaded into the snapshot statement's bind params.
|
# First statement sets the per-transaction wall-clock budget (#2607).
|
||||||
_stmt, params = db.executed[0]
|
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
|
assert params is not None and params["run_id"] == 99
|
||||||
# Run finalised via mark_done with the same counters.
|
# Run finalised via mark_done with the same counters.
|
||||||
assert marked["run_id"] == 99
|
assert marked["run_id"] == 99
|
||||||
assert marked["counters"] == {"snapshotted": 18355, "price_change_events": 42}
|
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:
|
def test_counter_logic_failure_path_marks_failed(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
"""On execute error: rollback + mark_failed + re-raise (no silent swallow)."""
|
"""On execute error: rollback + mark_failed + re-raise (no silent swallow)."""
|
||||||
failed: dict[str, Any] = {}
|
failed: dict[str, Any] = {}
|
||||||
|
|
|
||||||
|
|
@ -80,12 +80,20 @@ def _kit_matcher() -> MagicMock:
|
||||||
return matcher
|
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(
|
return KitLot(
|
||||||
source=source,
|
source=source,
|
||||||
source_url=f"https://www.{source}.ru/item/{source_id}",
|
source_url=f"https://www.{source}.ru/item/{source_id}",
|
||||||
source_id=source_id,
|
source_id=source_id,
|
||||||
address=address,
|
address=address,
|
||||||
|
lat=lat,
|
||||||
|
lon=lon,
|
||||||
price_rub=3_000_000,
|
price_rub=3_000_000,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -197,6 +205,103 @@ def test_save_listings_reconcile_update_coalesces_city() -> None:
|
||||||
assert params["city"] == "Серов"
|
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 ────────────────────────────────────────
|
# ── Migration 196: listings.city column ────────────────────────────────────────
|
||||||
|
|
||||||
_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
|
_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
|
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) ───────────────────────────────
|
# ── 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)
|
await _drive(scenario, capture=capture)
|
||||||
save_mock = capture["save_mock"]
|
save_mock = capture["save_mock"]
|
||||||
assert save_mock.call_args.kwargs["city"] == "Екатеринбург"
|
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
|
assert save_mock.call_count > 0
|
||||||
for call in save_mock.call_args_list:
|
for call in save_mock.call_args_list:
|
||||||
assert call.kwargs["city"] == "Екатеринбург"
|
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` /
|
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
|
`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` /
|
(`_Store`/`_FakeDB`) dispatching on SQL text standing in for реестра людей /
|
||||||
`tradein_sessions` / `account_quota_overrides` / `account_estimate_usage` /
|
`account_quota_overrides` / `account_estimate_usage` / `user_events` /
|
||||||
`user_events` / `trade_in_estimates`.
|
`trade_in_estimates`.
|
||||||
|
|
||||||
`app.core.rbac.SessionLocal` (middleware, no FastAPI DI) and `app.core.db.get_db`
|
Сессия РЕЕСТРА подменяется на самом низком уровне (`identity_store.SessionLocal`
|
||||||
(auth.router / team.router `Depends(get_db)`) both point at the SAME `_Store`
|
+ `auth_db.auth_session`, см. `tests.support.identity_modes.patch_identity_sessions`),
|
||||||
instance per test — a session created via POST /login is immediately visible to
|
а `app.core.db.get_db` — через `app.dependency_overrides`. Поэтому и
|
||||||
rbac_guard's own DB round trip AND to `current_team_actor`.
|
`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
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
@ -33,9 +58,19 @@ from app.core import config
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.core.password import hash_password
|
from app.core.password import hash_password
|
||||||
from app.core.rbac import rbac_guard
|
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.usage: dict[tuple[str, str], int] = {}
|
||||||
self.estimates: dict[str, dict[str, Any]] = {} # estimate_id -> result fields
|
self.estimates: dict[str, dict[str, Any]] = {} # estimate_id -> result fields
|
||||||
self.events: list[dict[str, Any]] = [] # user_events rows (history source)
|
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._next_id = 1
|
||||||
self.query_count = 0 # db.execute() calls — N+1 regression guard (review PR #2563)
|
self.query_count = 0 # db.execute() calls — N+1 regression guard (review PR #2563)
|
||||||
|
|
||||||
|
|
@ -57,7 +94,7 @@ class _Store:
|
||||||
*,
|
*,
|
||||||
role: str = "employee",
|
role: str = "employee",
|
||||||
manager_id: int | None = None,
|
manager_id: int | None = None,
|
||||||
is_active: bool = True,
|
access_state: AccessState = AccessState.ACTIVE,
|
||||||
display_name: str | None = None,
|
display_name: str | None = None,
|
||||||
org_name: str | None = None,
|
org_name: str | None = None,
|
||||||
email: str | None = None,
|
email: str | None = None,
|
||||||
|
|
@ -74,7 +111,8 @@ class _Store:
|
||||||
"display_name": display_name,
|
"display_name": display_name,
|
||||||
"org_name": org_name,
|
"org_name": org_name,
|
||||||
"email": email,
|
"email": email,
|
||||||
"is_active": is_active,
|
# СЫРОЕ значение колонки текущего режима (boolean либо text).
|
||||||
|
"access_state": column_value(access_state),
|
||||||
"created_at": created_at or datetime.now(UTC),
|
"created_at": created_at or datetime.now(UTC),
|
||||||
}
|
}
|
||||||
return uid
|
return uid
|
||||||
|
|
@ -162,7 +200,7 @@ class _FakeDB:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def commit(self) -> None:
|
def commit(self) -> None:
|
||||||
pass
|
self.store.commits.append(id(self))
|
||||||
|
|
||||||
def rollback(self) -> None:
|
def rollback(self) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
@ -172,9 +210,13 @@ class _FakeDB:
|
||||||
p = params or {}
|
p = params or {}
|
||||||
s = self.store
|
s = self.store
|
||||||
s.query_count += 1
|
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)
|
now = datetime.now(UTC)
|
||||||
s.sessions[p["token"]] = {
|
s.sessions[p["token"]] = {
|
||||||
"user_id": p["user_id"],
|
"user_id": p["user_id"],
|
||||||
|
|
@ -183,7 +225,7 @@ class _FakeDB:
|
||||||
}
|
}
|
||||||
return _Result([])
|
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"])
|
sess = s.sessions.get(p["token"])
|
||||||
if sess is not None:
|
if sess is not None:
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
|
|
@ -191,23 +233,25 @@ class _FakeDB:
|
||||||
sess["expires_at"] = now + timedelta(hours=p["ttl_hours"])
|
sess["expires_at"] = now + timedelta(hours=p["ttl_hours"])
|
||||||
return _Result([])
|
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)
|
s.sessions.pop(p["token"], None)
|
||||||
return _Result([])
|
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"]
|
uid = p["user_id"]
|
||||||
for tok in [t for t, sess in s.sessions.items() if sess["user_id"] == uid]:
|
for tok in [t for t, sess in s.sessions.items() if sess["user_id"] == uid]:
|
||||||
del s.sessions[tok]
|
del s.sessions[tok]
|
||||||
return _Result([])
|
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"])
|
sess = s.sessions.get(p["token"])
|
||||||
if sess is None:
|
if sess is None:
|
||||||
return _Result([])
|
return _Result([])
|
||||||
user = s.user_by_id(sess["user_id"])
|
user = s.user_by_id(sess["user_id"])
|
||||||
if user is None:
|
if user is None:
|
||||||
return _Result([])
|
return _Result([])
|
||||||
|
# Колонка состояния приезжает под алиасом `access_state` в обоих
|
||||||
|
# режимах (`u.<колонка> AS access_state`), значение — сырое.
|
||||||
return _Result(
|
return _Result(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
|
|
@ -219,18 +263,23 @@ class _FakeDB:
|
||||||
"display_name": user["display_name"],
|
"display_name": user["display_name"],
|
||||||
"org_name": user["org_name"],
|
"org_name": user["org_name"],
|
||||||
"email": user["email"],
|
"email": user["email"],
|
||||||
"is_active": user["is_active"],
|
"access_state": user["access_state"],
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---- tradein_users: login lookup (get_user_by_username) ----
|
# ---- реестр: login lookup (get_user_by_username) ----
|
||||||
if "password_hash, role, is_active" in sql and "FROM tradein_users" in sql:
|
# Дискриминатор — 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"])
|
user = s.users.get(p["username"])
|
||||||
return _Result([user] if user is not None else [])
|
return _Result([user] if user is not None else [])
|
||||||
|
|
||||||
# ---- tradein_users: create ----
|
# ---- реестр: create ----
|
||||||
if "INSERT INTO tradein_users" in sql:
|
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
|
uid = s._next_id
|
||||||
s._next_id += 1
|
s._next_id += 1
|
||||||
created_at = datetime.now(UTC)
|
created_at = datetime.now(UTC)
|
||||||
|
|
@ -243,25 +292,31 @@ class _FakeDB:
|
||||||
"display_name": p["display_name"],
|
"display_name": p["display_name"],
|
||||||
"org_name": p["org_name"],
|
"org_name": p["org_name"],
|
||||||
"email": p["email"],
|
"email": p["email"],
|
||||||
"is_active": True,
|
# Ровно то, что код прислал параметром — БЕЗ нормализации.
|
||||||
|
# Инверсия `access_state_param()` обязана доехать до ответа API
|
||||||
|
# (`is_active`), а не раствориться в дублёре.
|
||||||
|
"access_state": p["access_state"],
|
||||||
"created_at": created_at,
|
"created_at": created_at,
|
||||||
}
|
}
|
||||||
s.users[p["username"]] = row
|
s.users[p["username"]] = row
|
||||||
return _Result([dict(row)])
|
return _Result([dict(row)])
|
||||||
|
|
||||||
# ---- tradein_users: manager_id validation ----
|
# ---- реестр: manager_id validation ----
|
||||||
if "role = 'manager'" in sql:
|
if f"FROM {names.users}" in sql and "role = 'manager'" in sql:
|
||||||
user = s.user_by_id(p["id"])
|
user = s.user_by_id(p["id"])
|
||||||
match = user is not None and user["role"] == "manager"
|
match = user is not None and user["role"] == "manager"
|
||||||
return _Result([{"id": user["id"]}] if match else [])
|
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 с
|
# Две ветки реального кода: `role = 'employee'` (manager, либо admin с
|
||||||
# ?manager_id=) и `role IN ('employee','manager')` (admin без фильтра —
|
# ?manager_id=) и `role IN ('employee','manager')` (admin без фильтра —
|
||||||
# ему нужны и менеджеры, иначе некому сбросить пароль, см. team.py).
|
# ему нужны и менеджеры, иначе некому сбросить пароль, см. team.py).
|
||||||
if ("role = 'employee'" in sql or "role IN ('employee', 'manager')" in sql) and (
|
if (
|
||||||
"ORDER BY created_at DESC" in sql
|
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 = (
|
managed = (
|
||||||
("employee", "manager")
|
("employee", "manager")
|
||||||
if "role IN ('employee', 'manager')" in sql
|
if "role IN ('employee', 'manager')" in sql
|
||||||
|
|
@ -285,7 +340,7 @@ class _FakeDB:
|
||||||
"display_name": u["display_name"],
|
"display_name": u["display_name"],
|
||||||
"org_name": u["org_name"],
|
"org_name": u["org_name"],
|
||||||
"email": u["email"],
|
"email": u["email"],
|
||||||
"is_active": u["is_active"],
|
"access_state": u["access_state"],
|
||||||
"manager_id": u["manager_id"],
|
"manager_id": u["manager_id"],
|
||||||
"created_at": u["created_at"],
|
"created_at": u["created_at"],
|
||||||
}
|
}
|
||||||
|
|
@ -293,8 +348,11 @@ class _FakeDB:
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---- tradein_users: fetch single managed row by id ----
|
# ---- реестр: fetch single managed row by id ----
|
||||||
if "role = 'employee'" in sql or "role IN ('employee', 'manager')" in sql:
|
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 = (
|
managed = (
|
||||||
("employee", "manager")
|
("employee", "manager")
|
||||||
if "role IN ('employee', 'manager')" in sql
|
if "role IN ('employee', 'manager')" in sql
|
||||||
|
|
@ -312,20 +370,21 @@ class _FakeDB:
|
||||||
"display_name": user["display_name"],
|
"display_name": user["display_name"],
|
||||||
"org_name": user["org_name"],
|
"org_name": user["org_name"],
|
||||||
"email": user["email"],
|
"email": user["email"],
|
||||||
"is_active": user["is_active"],
|
"access_state": user["access_state"],
|
||||||
"manager_id": user["manager_id"],
|
"manager_id": user["manager_id"],
|
||||||
"created_at": user["created_at"],
|
"created_at": user["created_at"],
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---- tradein_users: uniqueness pre-check ----
|
# ---- реестр: uniqueness pre-check ----
|
||||||
if sql.strip().startswith("SELECT id FROM tradein_users WHERE username"):
|
if sql.strip().startswith(f"SELECT id FROM {names.users} WHERE username"):
|
||||||
user = s.users.get(p["u"])
|
user = s.users.get(p["u"])
|
||||||
return _Result([{"id": user["id"]}] if user is not None else [])
|
return _Result([{"id": user["id"]}] if user is not None else [])
|
||||||
|
|
||||||
# ---- tradein_users: update (PATCH) ----
|
# ---- реестр: update (PATCH) ----
|
||||||
if "UPDATE tradein_users" in sql and "SET display_name = COALESCE" in sql:
|
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"])
|
user = s.user_by_id(p["id"])
|
||||||
assert user is not None
|
assert user is not None
|
||||||
if p.get("display_name") is not None:
|
if p.get("display_name") is not None:
|
||||||
|
|
@ -334,8 +393,11 @@ class _FakeDB:
|
||||||
user["org_name"] = p["org_name"]
|
user["org_name"] = p["org_name"]
|
||||||
if p.get("email") is not None:
|
if p.get("email") is not None:
|
||||||
user["email"] = p["email"]
|
user["email"] = p["email"]
|
||||||
if p.get("is_active") is not None:
|
# COALESCE(CAST(:access_state AS <тип>), <колонка>) — None означает
|
||||||
user["is_active"] = p["is_active"]
|
# «поле не пришло в 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:
|
if p.get("password_hash") is not None:
|
||||||
user["password_hash"] = p["password_hash"]
|
user["password_hash"] = p["password_hash"]
|
||||||
return _Result([])
|
return _Result([])
|
||||||
|
|
@ -437,6 +499,8 @@ def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
auth_mod.reset_cache_for_tests()
|
auth_mod.reset_cache_for_tests()
|
||||||
auth_router._LOGIN_LIMITER._hits.clear()
|
auth_router._LOGIN_LIMITER._hits.clear()
|
||||||
monkeypatch.setattr(config.settings, "auth_mode", "dual")
|
monkeypatch.setattr(config.settings, "auth_mode", "dual")
|
||||||
|
# Каждый тест стартует в ДЕФОЛТНОМ режиме реестра (сегодняшний прод).
|
||||||
|
use_identity_mode(monkeypatch, "tradein")
|
||||||
# team.py / auth.py events go through schedule_event (own SessionLocal(), fire-
|
# team.py / auth.py events go through schedule_event (own SessionLocal(), fire-
|
||||||
# and-forget) — captured into a list instead of hitting a real DB.
|
# and-forget) — captured into a list instead of hitting a real DB.
|
||||||
monkeypatch.setattr(team_router, "schedule_event", lambda **kw: _EVENTS.append(kw))
|
monkeypatch.setattr(team_router, "schedule_event", lambda **kw: _EVENTS.append(kw))
|
||||||
|
|
@ -452,9 +516,23 @@ def store() -> _Store:
|
||||||
return _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
|
@pytest.fixture
|
||||||
def client(store: _Store, monkeypatch: pytest.MonkeyPatch) -> TestClient:
|
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
|
# base_url=https:// — login sets a Secure cookie; see test_auth_api.py for why
|
||||||
# a plain-http TestClient would silently drop it.
|
# a plain-http TestClient would silently drop it.
|
||||||
return TestClient(_build_test_app(store), base_url="https://testserver")
|
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:
|
def test_unblock_employee_event(client: TestClient, store: _Store) -> None:
|
||||||
mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager")
|
mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager")
|
||||||
emp_id = store.add_user(
|
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!")
|
_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})
|
resp = client.get(f"/api/v1/team/employees/{emp_id}/history", params={"limit": 500})
|
||||||
assert resp.status_code == 422
|
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;
|
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 {
|
function loginErrorMessage(error: unknown): string {
|
||||||
if (error instanceof HTTPError) {
|
if (error instanceof HTTPError) {
|
||||||
if (error.status === 401) return "Неверный логин или пароль";
|
if (error.status === 401) return "Неверный логин или пароль";
|
||||||
if (error.status === 429) {
|
if (error.status === 429) {
|
||||||
return "Слишком много попыток. Попробуйте через несколько минут";
|
return "Слишком много попыток. Попробуйте через несколько минут";
|
||||||
}
|
}
|
||||||
|
if (error.status === 403 && accessExpiredCode(error.body) === "access_expired") {
|
||||||
|
return "Пробный доступ закончился — обратитесь к менеджеру";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
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 type { Metadata } from "next";
|
||||||
import { IBM_Plex_Mono, Manrope } from "next/font/google";
|
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 на
|
* файл под `app/mera-public/**` не импортирует хуки и компоненты B2B-части:
|
||||||
* этом домене rewrite'ит запрос корня "/" в "/trade-in/mera-public" (тот
|
* useMe / useQuota / useBrand / useLogout / useEstimateHistory, RouteGuard,
|
||||||
* же образ tradein-frontend, что обслуживает и gendsgn.ru/trade-in/*, у него
|
* Topnav/UserMenu, провайдер чата поддержки. Единственные общие модули —
|
||||||
* запечён NEXT_PUBLIC_BASE_PATH=/trade-in) — пользователь префикс /trade-in
|
* заведомо безопасные, без сети и авторизации: `v2/tokens.ts` (палитра),
|
||||||
* никогда не видит, это внутренний Caddy→backend hop.
|
* `lib/city-registry.ts` (города области), `lib/source-registry.ts` (реестр
|
||||||
|
* источников), `lib/safeUrl.ts` (валидация href).
|
||||||
*
|
*
|
||||||
* Изоляция от B2B-дерева (намеренная, см. задачу ЭТАП 1):
|
* RouteGuard (`components/auth/RouteGuard.tsx`) отдаёт это поддерево ДО вызова
|
||||||
* - НЕ импортирует SupportChatProvider (тот живёт только в app/v2/layout.tsx —
|
* `useMe()` — на публичной странице запроса к `/api/v1/me` не происходит вовсе,
|
||||||
* поддержка нужна оценщикам с доступом, не анонимным посетителям заглушки).
|
* см. `PublicRouteBypass` там же.
|
||||||
* - 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 не
|
|
||||||
* импортируются вообще); функционала на этом этапе нет.
|
|
||||||
*
|
*
|
||||||
* 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({
|
const manrope = Manrope({
|
||||||
subsets: ["latin", "cyrillic"],
|
subsets: ["latin", "cyrillic"],
|
||||||
|
|
@ -42,9 +47,8 @@ const plexMono = IBM_Plex_Mono({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "МЕРА — оценка вторичного жилья",
|
title: "МЕРА — оценка квартиры на вторичном рынке",
|
||||||
description:
|
description: `Узнайте рыночную стоимость квартиры по адресу: сделки Росреестра и объявления площадок. ${REGION_NAME}.`,
|
||||||
"Онлайн-оценка стоимости квартиры на вторичном рынке. Сервис скоро откроется.",
|
|
||||||
robots: { index: false, follow: false },
|
robots: { index: false, follow: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -55,16 +59,20 @@ export default function MeraPublicLayout({
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`${manrope.variable} ${plexMono.variable}`}
|
className={`${manrope.variable} ${plexMono.variable} ${styles.page}`}
|
||||||
style={{
|
style={landingVars}
|
||||||
minHeight: "100vh",
|
|
||||||
background: pageBg,
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
|
{/* Цель ссылки — <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}
|
{children}
|
||||||
|
<SiteFooter />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,30 @@
|
||||||
import { tokens } from "@/components/trade-in/v2/tokens";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ЭТАП 1 B2C-плана — публичная заглушка, БЕЗ функционала (никаких форм,
|
* Публичный лэндинг «МЕРА» — первое публичное лицо продукта.
|
||||||
* запросов к API, авторизации). Server component намеренно: нет "use client",
|
*
|
||||||
* нет хуков — useMe/useQuota/useHistory здесь не используются вообще.
|
* Серверный компонент: никаких хуков, никакого клиентского JS, кроме одного
|
||||||
* Изоляция от RouteGuard/B2B-дерева — см. комментарий в layout.tsx.
|
* острова — `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() {
|
export default function MeraPublicPage() {
|
||||||
return (
|
return (
|
||||||
<main
|
<main id="content" className={styles.main} tabIndex={-1}>
|
||||||
style={{
|
<Hero />
|
||||||
fontFamily: tokens.font.sans,
|
<HowItWorks />
|
||||||
color: tokens.ink,
|
<WhatYouGet />
|
||||||
textAlign: "center",
|
<DataSources />
|
||||||
padding: "48px 24px",
|
<Faq />
|
||||||
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>
|
</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";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MIRROR of main frontend `frontend/src/components/auth/RouteGuard.tsx` —
|
* РАСХОДИТСЯ с зеркалом `frontend/src/components/auth/RouteGuard.tsx` (Site
|
||||||
* keep in sync manually.
|
* Finder) намеренно и уже не является его копией: там нет ни публичного
|
||||||
|
* B2C-периметра, ни выноса гварда в отдельный чанк. Синхронизировать построчно
|
||||||
|
* больше нельзя — при правках RBAC-логики править `GuardedRoute.tsx`, сверяясь
|
||||||
|
* с зеркалом по смыслу, а не по диффу.
|
||||||
*
|
*
|
||||||
* Особенность tradein-mvp: Next.js basePath=/trade-in (см. `next.config.ts`).
|
* Этот модуль намеренно ДЕРЖИТСЯ ПУСТЫМ по зависимостям: он импортирует только
|
||||||
* `usePathname()` возвращает путь БЕЗ basePath — например на странице
|
* `usePathname` и `next/dynamic`. Причина — `app/layout.tsx` оборачивает в него
|
||||||
* `gendsgn.ru/trade-in/scrapers/avito` хук вернёт `/scrapers/avito`.
|
* ВСЁ дерево, включая публичный лэндинг `/mera-public`, поэтому всё, что здесь
|
||||||
* RBAC config (`auth/roles.yaml`) использует абсолютные пути сайта
|
* импортировано статически, webpack кладёт в чанк root-layout'а и отдаёт
|
||||||
* (`/trade-in/**`, `/trade-in/api/v1/admin/**`), поэтому перед проверкой
|
* анонимному посетителю публичной страницы. До выноса `GuardedRoute` в
|
||||||
* isPathAllowed мы префиксим pathname через NEXT_PUBLIC_BASE_PATH.
|
* `next/dynamic` туда уезжали `useMe` → `lib/api` → `lib/sessionId`,
|
||||||
|
* `isPathAllowed` и — через `NoAccessScreen` → `AnonSupportWidget` — клиент
|
||||||
|
* чата поддержки; в публичном JS читались имена внутренних ручек и модель
|
||||||
|
* RBAC-полей. Подробный разбор — в шапке `GuardedRoute.tsx`.
|
||||||
*
|
*
|
||||||
* #2555 login redirect: `router.push()` (как и `usePathname()`) работает в
|
* НЕ добавляй сюда статических импортов из закрытого контура. Всё, что нужно
|
||||||
* пространстве путей БЕЗ basePath — Next сам префиксит basePath на навигации
|
* гварду, живёт за `dynamic()`.
|
||||||
* (см. `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, usePathname } from "next/navigation";
|
import dynamic from "next/dynamic";
|
||||||
import { useEffect } from "react";
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
import { NoAccessScreen } from "@/components/auth/NoAccessScreen";
|
// Точка разрыва графа модулей: отдельный чанк, который запрашивается ТОЛЬКО
|
||||||
import { HTTPError } from "@/lib/api";
|
// если компонент реально отрендерился, т.е. никогда — на публичном пути.
|
||||||
import { isPathAllowed } from "@/lib/isPathAllowed";
|
// ssr не отключаем: на сервере GuardedRoute и раньше отдавал null (useMe там
|
||||||
import { useMe } from "@/lib/useMe";
|
// всегда isLoading), поведение закрытых страниц не меняется.
|
||||||
|
const GuardedRoute = dynamic(() => import("@/components/auth/GuardedRoute"));
|
||||||
|
|
||||||
const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
|
// Публичный (B2C) периметр МЕРЫ. В отличие от NEXT_PUBLIC_ENABLE_PREVIEW —
|
||||||
// #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 выше —
|
|
||||||
// ЭТОТ bypass ВСЕГДА включён (в т.ч. в проде): страница публична по
|
// ЭТОТ 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"];
|
const PUBLIC_PATHS = ["/mera-public"];
|
||||||
|
|
||||||
|
function isPublicPath(rawPath: string): boolean {
|
||||||
|
return PUBLIC_PATHS.some((p) => rawPath === p || rawPath.startsWith(`${p}/`));
|
||||||
|
}
|
||||||
|
|
||||||
interface RouteGuardProps {
|
interface RouteGuardProps {
|
||||||
children: React.ReactNode;
|
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) {
|
export function RouteGuard({ children }: RouteGuardProps) {
|
||||||
const rawPath = usePathname() ?? "/";
|
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 →
|
if (isPublicPath(rawPath)) {
|
||||||
// редирект на /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}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoginPage) {
|
return <GuardedRoute rawPath={rawPath}>{children}</GuardedRoute>;
|
||||||
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}</>;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
import random
|
import random
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from datetime import date, datetime, timedelta
|
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 ─────────────────────────────────────
|
# ── Запись пачки результатов в Postgres ─────────────────────────────────────
|
||||||
def save_listings(
|
def save_listings(
|
||||||
db: Session,
|
db: Session,
|
||||||
|
|
@ -304,6 +321,8 @@ def save_listings(
|
||||||
run_id: int | None = None,
|
run_id: int | None = None,
|
||||||
skip_seen_today: bool = False,
|
skip_seen_today: bool = False,
|
||||||
city: str | None = None,
|
city: str | None = None,
|
||||||
|
city_anchor: tuple[float, float] | None = None,
|
||||||
|
city_radius_km: float | None = None,
|
||||||
) -> tuple[int, int]:
|
) -> tuple[int, int]:
|
||||||
"""Пишем list[ScrapedLot] в `listings` с upsert по dedup_hash.
|
"""Пишем list[ScrapedLot] в `listings` с upsert по dedup_hash.
|
||||||
|
|
||||||
|
|
@ -333,6 +352,21 @@ def save_listings(
|
||||||
admin/manual пути) — колонка остаётся NULL, backward-compatible.
|
admin/manual пути) — колонка остаётся NULL, backward-compatible.
|
||||||
ON CONFLICT — COALESCE (новое значение НЕ затирает уже известный город
|
ON CONFLICT — COALESCE (новое значение НЕ затирает уже известный город
|
||||||
NULL'ом, если какой-то caller ещё не передаёт city).
|
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:
|
Returns:
|
||||||
(inserted, updated) — counters для логов.
|
(inserted, updated) — counters для логов.
|
||||||
|
|
@ -346,14 +380,38 @@ def save_listings(
|
||||||
reconciled = 0 # UPDATE by (source,source_id) при dedup_hash-дрейфе
|
reconciled = 0 # UPDATE by (source,source_id) при dedup_hash-дрейфе
|
||||||
matched = 0
|
matched = 0
|
||||||
match_failures = 0
|
match_failures = 0
|
||||||
|
geo_guard_dropped = 0 # city NULL'ен из-за geo-guard (лот вне city_radius_km от anchor'а)
|
||||||
|
|
||||||
today_msk = datetime.now(_MSK).date()
|
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:
|
for lot in lots:
|
||||||
ppm2 = lot.price_per_m2 or lot.compute_price_per_m2()
|
ppm2 = lot.price_per_m2 or lot.compute_price_per_m2()
|
||||||
dedup = lot.compute_dedup_hash()
|
dedup = lot.compute_dedup_hash()
|
||||||
card_hash = lot.compute_card_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
|
# 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
|
# dedup_hash) BEFORE the upsert — needed to know the *prior* card
|
||||||
# content and to implement skip_seen_today logic.
|
# content and to implement skip_seen_today logic.
|
||||||
|
|
@ -389,7 +447,7 @@ def save_listings(
|
||||||
"dedup": dedup,
|
"dedup": dedup,
|
||||||
"region_code": region_code,
|
"region_code": region_code,
|
||||||
"address": lot.address,
|
"address": lot.address,
|
||||||
"city": city,
|
"city": lot_city,
|
||||||
"lat": lot.lat,
|
"lat": lot.lat,
|
||||||
"lon": lot.lon,
|
"lon": lot.lon,
|
||||||
"rooms": lot.rooms,
|
"rooms": lot.rooms,
|
||||||
|
|
@ -777,7 +835,7 @@ def save_listings(
|
||||||
db.commit()
|
db.commit()
|
||||||
logger.info(
|
logger.info(
|
||||||
"save_listings: source=%s inserted=%d updated=%d reconciled=%d "
|
"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 "?",
|
lots[0].source if lots else "?",
|
||||||
inserted,
|
inserted,
|
||||||
updated,
|
updated,
|
||||||
|
|
@ -785,6 +843,7 @@ def save_listings(
|
||||||
skipped,
|
skipped,
|
||||||
matched,
|
matched,
|
||||||
match_failures,
|
match_failures,
|
||||||
|
geo_guard_dropped,
|
||||||
len(lots),
|
len(lots),
|
||||||
)
|
)
|
||||||
return inserted, updated
|
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)
|
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)
|
@dataclass(frozen=True)
|
||||||
class CityLocation:
|
class CityLocation:
|
||||||
"""Per-provider гео-идентификаторы города-цели SERP-запроса (oblast rollout).
|
"""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)
|
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 = {
|
_CHROME_HEADERS = {
|
||||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
"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",
|
"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'ы
|
# #2594: город для save_listings(..., city=...) — один на весь sweep (все anchor'ы
|
||||||
# одного run'а бьют по одному city_slug), вычисляем один раз до цикла.
|
# одного run'а бьют по одному city_slug), вычисляем один раз до цикла.
|
||||||
_city_name = resolve_city_name(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))
|
counters = CitySweepCounters(anchors_total=len(_anchors))
|
||||||
all_touched_house_ids: set[int] = set()
|
all_touched_house_ids: set[int] = set()
|
||||||
|
|
||||||
|
|
@ -1078,6 +1145,8 @@ async def run_avito_city_sweep(
|
||||||
matcher=matcher,
|
matcher=matcher,
|
||||||
region_code=region_code,
|
region_code=region_code,
|
||||||
city=_city_name,
|
city=_city_name,
|
||||||
|
city_anchor=_city_anchor_point,
|
||||||
|
city_radius_km=_city_radius_km,
|
||||||
)
|
)
|
||||||
counters.lots_inserted += ins
|
counters.lots_inserted += ins
|
||||||
counters.lots_updated += upd
|
counters.lots_updated += upd
|
||||||
|
|
@ -1833,6 +1902,13 @@ async def run_yandex_city_sweep(
|
||||||
_loc = get_city_location(city_slug)
|
_loc = get_city_location(city_slug)
|
||||||
# #2594: город для save_listings(..., city=...) — один на весь sweep.
|
# #2594: город для save_listings(..., city=...) — один на весь sweep.
|
||||||
_city_name = resolve_city_name(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
|
||||||
|
)
|
||||||
|
|
||||||
_rooms_list = rooms_list or list(ROOM_PATH.keys())
|
_rooms_list = rooms_list or list(ROOM_PATH.keys())
|
||||||
_price_ranges = price_ranges or DEFAULT_PRICE_RANGES
|
_price_ranges = price_ranges or DEFAULT_PRICE_RANGES
|
||||||
|
|
@ -1937,6 +2013,8 @@ async def run_yandex_city_sweep(
|
||||||
region_code=region_code,
|
region_code=region_code,
|
||||||
run_id=run_id,
|
run_id=run_id,
|
||||||
city=_city_name,
|
city=_city_name,
|
||||||
|
city_anchor=_city_anchor_point,
|
||||||
|
city_radius_km=_city_radius_km,
|
||||||
)
|
)
|
||||||
counters.lots_inserted += ins
|
counters.lots_inserted += ins
|
||||||
counters.lots_updated += upd
|
counters.lots_updated += upd
|
||||||
|
|
@ -2346,6 +2424,13 @@ async def run_cian_city_sweep(
|
||||||
_loc = get_city_location(city_slug)
|
_loc = get_city_location(city_slug)
|
||||||
# #2594: город для save_listings(..., city=...) — один на весь sweep.
|
# #2594: город для save_listings(..., city=...) — один на весь sweep.
|
||||||
_city_name = resolve_city_name(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 = CianCitySweepCounters(anchors_total=len(_anchors))
|
counters = CianCitySweepCounters(anchors_total=len(_anchors))
|
||||||
consecutive_failures = 0
|
consecutive_failures = 0
|
||||||
cian_rotations_done = 0 # #1848: бюджет IP-ротаций на весь sweep
|
cian_rotations_done = 0 # #1848: бюджет IP-ротаций на весь sweep
|
||||||
|
|
@ -2447,6 +2532,8 @@ async def run_cian_city_sweep(
|
||||||
region_code=region_code,
|
region_code=region_code,
|
||||||
run_id=run_id,
|
run_id=run_id,
|
||||||
city=_city_name,
|
city=_city_name,
|
||||||
|
city_anchor=_city_anchor_point,
|
||||||
|
city_radius_km=_city_radius_km,
|
||||||
)
|
)
|
||||||
counters.lots_inserted += inserted
|
counters.lots_inserted += inserted
|
||||||
counters.lots_updated += updated
|
counters.lots_updated += updated
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue