Merge remote-tracking branch 'refs/remotes/forgejo/main' into feat/mera-b2c-perimeter
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 9s
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
CI Trade-In / frontend-checks (pull_request) Successful in 1m16s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 9s
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
CI Trade-In / frontend-checks (pull_request) Successful in 1m16s
# Conflicts: # tradein-mvp/frontend/src/components/auth/RouteGuard.tsx
This commit is contained in:
commit
536640a252
94 changed files with 10888 additions and 1022 deletions
|
|
@ -43,6 +43,13 @@ jobs:
|
|||
# [tool.uv.workspace] меняют реальные зависимости → гейт обязан бежать.
|
||||
- 'tradein-mvp/uv.lock'
|
||||
- 'tradein-mvp/pyproject.toml'
|
||||
# auth/roles.yaml — общий RBAC-конфиг обоих стеков, лежит В КОРНЕ
|
||||
# репы и монтируется в tradein-backend (/app/auth/roles.yaml).
|
||||
# tests/test_rbac.py читает именно его, поэтому правка ролей обязана
|
||||
# гонять и этот гейт. Без строки правка roles.yaml не запускала НИ
|
||||
# ОДИН сьют (та же дыра закрыта симметрично в ci.yml) — так на main
|
||||
# уехал красный test_get_role_known_users (2026-07-30 → PR #2587).
|
||||
- 'auth/**'
|
||||
- '.forgejo/workflows/ci-tradein.yml'
|
||||
frontend:
|
||||
- 'tradein-mvp/frontend/**'
|
||||
|
|
|
|||
|
|
@ -52,6 +52,14 @@ jobs:
|
|||
backend:
|
||||
- 'backend/**'
|
||||
- 'data/sql/**'
|
||||
# auth/roles.yaml — общий RBAC-конфиг ОБОИХ стеков (bind-mount в
|
||||
# backend и в tradein-backend). Правка ролей/пользователей меняет
|
||||
# поведение backend/tests/test_rbac.py, но сам файл лежит вне
|
||||
# 'backend/**' → без этой строки сьют no-op'ился, и правка уезжала
|
||||
# в main без единого прогона. Так и случилось 2026-07-30: user2
|
||||
# переведён в expired, test_get_role_known_users стал красным и
|
||||
# доехал до main незамеченным (починен в PR #2587).
|
||||
- 'auth/**'
|
||||
- '.forgejo/workflows/ci.yml'
|
||||
frontend:
|
||||
- 'frontend/**'
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ on:
|
|||
- ".forgejo/workflows/deploy.yml"
|
||||
- "data/sql/**"
|
||||
- "ops/glitchtip-auth-forwarder/**"
|
||||
# Bootstrap-SQL (создание БД auth, ALTER ROLE паролем из env) исполняется шагом
|
||||
# деплоя ниже — без этого триггера правка bootstrap-файла молча не доезжала бы
|
||||
# до прода до следующего чужого коммита в backend/.
|
||||
- "ops/db-bootstrap/**"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
|
@ -320,6 +324,70 @@ jobs:
|
|||
echo "⚠️ GENDESIGN_FDW_PASSWORD not set in backend/.env.runtime — skipping ALTER ROLE for tradein_fdw_reader"
|
||||
fi
|
||||
|
||||
# ── БД `auth` — единое хранилище доступов «Меры» и «Птицы» ──────────────
|
||||
# Расположение файлов: схема лежит в data/sql/auth/ (ПОДКАТАЛОГ, не плоский
|
||||
# data/sql/) — цикл миграций выше использует `ls -1 data/sql/*.sql`, который в
|
||||
# подкаталоги не рекурсирует. Значит эти файлы физически не могут примениться
|
||||
# в БД gendesign, даже если кто-то забудет про разделение; при этом триггер
|
||||
# `data/sql/**` (paths выше) подкаталог покрывает, деплой запускается сам.
|
||||
# Свой _schema_migrations живёт ВНУТРИ БД auth: отдельная база — отдельный
|
||||
# трекинг, имена файлов двух каталогов не конфликтуют между собой.
|
||||
# Порядок: сразу после bootstrap'а FDW-пароля и ДО `compose up -d` — падение
|
||||
# здесь останавливает деплой (exit 1) до подъёма нового кода.
|
||||
# `source backend/.env.runtime` уже выполнен выше (строка с FDW-паролем), из него
|
||||
# берётся AUTH_DB_PASSWORD.
|
||||
echo "→ Bootstrapping auth database (idempotent)"
|
||||
docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
|
||||
psql -U "$POSTGRES_USER" -d postgres -v ON_ERROR_STOP=on \
|
||||
< ops/db-bootstrap/create_auth_db.sql \
|
||||
|| { echo "FAILED to create auth database"; exit 1; }
|
||||
|
||||
docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
|
||||
psql -U "$POSTGRES_USER" -d auth -v ON_ERROR_STOP=on -c "
|
||||
CREATE TABLE IF NOT EXISTS _schema_migrations (
|
||||
filename TEXT PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
"
|
||||
|
||||
for sql_file in $(ls -1 data/sql/auth/*.sql 2>/dev/null | sort); do
|
||||
fname=$(basename "$sql_file")
|
||||
# `| tr -d '[:space:]'` — как в deploy-tradein.yml: без него psql-вывод с
|
||||
# лишним пробелом/CR ломает сравнение с "0" и миграция молча считается
|
||||
# применённой.
|
||||
applied=$(docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
|
||||
psql -U "$POSTGRES_USER" -d auth -tAc \
|
||||
"SELECT COUNT(*) FROM _schema_migrations WHERE filename='$fname'" \
|
||||
| tr -d '[:space:]')
|
||||
if [ "$applied" = "0" ]; then
|
||||
echo "→ Applying auth migration: $fname"
|
||||
docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
|
||||
psql -U "$POSTGRES_USER" -d auth -v ON_ERROR_STOP=on \
|
||||
< "$sql_file" \
|
||||
|| { echo "FAILED on auth migration: $fname"; exit 1; }
|
||||
docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
|
||||
psql -U "$POSTGRES_USER" -d auth -c \
|
||||
"INSERT INTO _schema_migrations (filename) VALUES ('$fname') ON CONFLICT DO NOTHING;"
|
||||
else
|
||||
echo "✓ Already applied (auth): $fname"
|
||||
fi
|
||||
done
|
||||
echo "All auth migrations applied."
|
||||
|
||||
# Пароль роли auth_app из env (post-migration bootstrap): миграция
|
||||
# data/sql/auth/002_auth_app_role.sql создаёт роль БЕЗ пароля, пароль живёт
|
||||
# только в /opt/gendesign/backend/.env.runtime. Пустая переменная — не ошибка:
|
||||
# PR-1 ещё никого не подключает к этой БД, роль просто остаётся без пароля.
|
||||
if [ -n "${AUTH_DB_PASSWORD:-}" ]; then
|
||||
echo "→ Applying auth_app password from env"
|
||||
docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
|
||||
psql -U "$POSTGRES_USER" -d auth -v ON_ERROR_STOP=on \
|
||||
-v "pw=$AUTH_DB_PASSWORD" \
|
||||
< ops/db-bootstrap/set_auth_app_password.sql
|
||||
else
|
||||
echo "⚠️ AUTH_DB_PASSWORD not set in backend/.env.runtime — skipping ALTER ROLE for auth_app"
|
||||
fi
|
||||
|
||||
# Build local-only sidecar images (glitchtip-auth-forwarder).
|
||||
# Эти services не в GHCR — сборка происходит на VPS на каждом deploy.
|
||||
# Cache-friendly: первый build ~30s, последующие 1-3s если файлы не менялись.
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
# Регресс-тест публичного B2C-периметра МЕРА (ЭТАП 1 плана B2C-запуска).
|
||||
#
|
||||
# НЕ pre-merge гейт — эти 4 проверки требуют реального DNS + выпущенного TLS-
|
||||
# сертификата для mera.gendsgn.ru, т.е. осмысленны ТОЛЬКО против прода после
|
||||
# сертификата для meraocenka.ru, т.е. осмысленны ТОЛЬКО против прода после
|
||||
# деплоя. Запускается вручную (workflow_dispatch) или раз в сутки (cron) —
|
||||
# страхует от случайной регрессии периметра (например, будущий PR по ошибке
|
||||
# открывает B2B-путь на публичном поддомене, или basic_auth gate на gendsgn.ru
|
||||
# открывает B2B-путь на публичном домене, или basic_auth gate на gendsgn.ru
|
||||
# случайно снимают).
|
||||
#
|
||||
# ДО того как появится DNS A-record mera.gendsgn.ru → IP VPS, проверки 1 и 2
|
||||
# ДО того как появится DNS A-record meraocenka.ru → IP VPS, проверки 1 и 2
|
||||
# (см. scripts/smoke-mera-perimeter.sh) ожидаемо КРАСНЫЕ — это не регресс,
|
||||
# просто поддомен ещё не резолвится. Проверки 3 и 4 не зависят от DNS нового
|
||||
# поддомена и обязаны быть зелёными всегда.
|
||||
# просто домен ещё не резолвится. Проверки 3 и 4 не зависят от DNS нового
|
||||
# домена и обязаны быть зелёными всегда.
|
||||
name: perimeter-smoke-mera
|
||||
|
||||
on:
|
||||
|
|
|
|||
117
Caddyfile
117
Caddyfile
|
|
@ -11,6 +11,13 @@
|
|||
# Users managed via caddy/users.caddy.snippet (git history = audit trail).
|
||||
# Public exclusions: /health (liveness probe), /preview/* (static mockups).
|
||||
#
|
||||
# #2558: с 2026-07 basic_auth гейтит ТОЛЬКО Site Finder (`/`, `/api/*`,
|
||||
# `/analytics` и т.д.). `/trade-in/*` (+ `/sale-share` redirect) вынесены ВЫШЕ
|
||||
# import'а — у trade-in своя авторизация (форма входа + opaque session-cookie,
|
||||
# см. #2552) поверх RBAC (`tradein-mvp/backend/app/core/rbac.py`). Site Finder
|
||||
# всё ещё легаси-пилотный basic_auth (roles.yaml dual-mode остаётся живым для
|
||||
# него — НЕ трогать caddy/users.caddy.snippet).
|
||||
#
|
||||
# IMPORTANT: route { } block is required to preserve directive order.
|
||||
# Without route { }, Caddy executes directives in hard-coded default order
|
||||
# (basic_auth runs before handle), making /health and /preview/* exclusions
|
||||
|
|
@ -70,26 +77,56 @@ gendsgn.ru {
|
|||
# Оба ДО auth-import, иначе ассеты страницы уходят в @tradein (под auth) → 401 → без CSS.
|
||||
@uipreview path /trade-in/ui-preview/* /trade-in/_next/static/*
|
||||
handle @uipreview {
|
||||
reverse_proxy tradein-frontend:3000
|
||||
reverse_proxy tradein-frontend:3000 {
|
||||
# #2558 review: тот же периметр-scrub, что и у /trade-in/api/* и
|
||||
# @tradein ниже — этот блок тоже теперь ДО basic_auth, клиент
|
||||
# мог бы прислать свой X-Authenticated-User. Сейчас инертно
|
||||
# (страница статична, у tradein-frontend нет секрета для
|
||||
# X-Internal-Auth-Secret), но убираем ради единообразия периметра,
|
||||
# а не полагаясь на то, что downstream ничего не делает с заголовком.
|
||||
header_up -X-Authenticated-User
|
||||
}
|
||||
}
|
||||
|
||||
# Auth gate (applies to all routes below within this route block).
|
||||
import caddy/users.caddy.snippet
|
||||
|
||||
# Trade-In MVP subproject (tradein-mvp/) — gendesign-tradein docker stack,
|
||||
# подключен через gendesign_shared network. Routes ДО универсального handle
|
||||
# потому что Caddy матчит handle-блоки сверху вниз.
|
||||
# #2558: Trade-In MVP subproject (tradein-mvp/) — gendesign-tradein docker
|
||||
# stack, подключен через gendesign_shared network. Секция ЦЕЛИКОМ ДО
|
||||
# `import caddy/users.caddy.snippet` ниже — /trade-in имеет собственную
|
||||
# авторизацию (форма входа + opaque session-cookie, #2552; RBAC-проверка
|
||||
# роли внутри tradein-backend, `app/core/rbac.py`), Site Finder basic_auth
|
||||
# ей больше не нужен и не должен применяться (short-circuit сверху вниз,
|
||||
# как /health и /preview/* выше).
|
||||
#
|
||||
# X-Authenticated-User — ЯВНОЕ УДАЛЕНИЕ (`header_up -X-Authenticated-User`),
|
||||
# НЕ `header_up X-Authenticated-User {http.auth.user.id}`. Причина: этот
|
||||
# блок больше не идёт ПОСЛЕ basic_auth, поэтому `{http.auth.user.id}`
|
||||
# никогда не резолвится авторизованным юзером на этом пути.
|
||||
# Проверено эмпирически (echo-стенд на образе caddy:2, `caddy adapt`):
|
||||
# старая Set-форма (`header_up X-Authenticated-User {http.auth.user.id}`)
|
||||
# НЕ пропустила бы клиентский заголовок насквозь и НЕ оставила бы поле
|
||||
# пустым — Caddy подставляет НЕРАЗРЕШЁННЫЙ плейсхолдер как ЛИТЕРАЛЬНУЮ
|
||||
# строку (`ReplaceKnown`), т.е. upstream получил бы буквально
|
||||
# `X-Authenticated-User: {http.auth.user.id}`. Для backend (auth_mode=
|
||||
# "dual", `app/core/config.py`) это НЕ подмена личности — legacy path
|
||||
# (`rbac.py:186`) сделал бы `get_role("{http.auth.user.id}")`, юзер не
|
||||
# найден в roles.yaml → 403 для всех. Т.е. старая форма была бы не
|
||||
# security-дырой, а fail-closed-but-сломанной (все trade-in запросы без
|
||||
# session-cookie получали бы 403 вместо ожидаемого 401/редиректа на логин).
|
||||
# `-Field` остаётся правильным выбором не потому что Set был бы дырой, а
|
||||
# потому что это ЕДИНСТВЕННАЯ форма с явно задокументированной семантикой
|
||||
# "удалить заголовок" (Caddyfile reverse_proxy directive: `-<field>` =
|
||||
# delete) — корректное поведение не должно зависеть от того, как именно
|
||||
# Caddy трактует нерезолвленный/пустой плейсхолдер в Set-операции.
|
||||
# X-Internal-Auth-Secret НЕ трогаем — #2213-секрет всегда перезаписывается
|
||||
# из env (Set-операция с непустым значением, никак не связана с auth-гейтом
|
||||
# basic_auth), это единственное, что теперь отсекает подделку заголовков
|
||||
# изнутри gendesign_shared network для legacy dual-mode пути.
|
||||
handle /trade-in/api/* {
|
||||
# `handle_path /trade-in/api/*` стрипал бы целиком /trade-in/api;
|
||||
# FastAPI router замаунтен на /api/v1/trade-in/* — нужен strip только
|
||||
# префикса basePath /trade-in (Next.js basePath leak).
|
||||
uri strip_prefix /trade-in
|
||||
reverse_proxy tradein-backend:8000 {
|
||||
header_up X-Authenticated-User {http.auth.user.id}
|
||||
# #2213 defense-in-depth: общий секрет Caddy↔tradein-backend. header_up
|
||||
# с value ПЕРЕЗАПИСЫВАЕТ (стирает) любой клиентский X-Internal-Auth-Secret —
|
||||
# тот же механизм, что защищает X-Authenticated-User выше. Пусто пока
|
||||
# TRADEIN_INTERNAL_AUTH_SECRET не задан в .env (fail-open, backend не проверяет).
|
||||
header_up -X-Authenticated-User
|
||||
header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET}
|
||||
}
|
||||
}
|
||||
|
|
@ -98,6 +135,23 @@ gendsgn.ru {
|
|||
# Next basePath=/trade-in → редиректим на канонический /trade-in/sale-share
|
||||
# (тот же tradein-frontend контейнер; query-string сохраняется). True vanity-URL
|
||||
# в адресной строке требует отдельного Next-app с basePath=/sale-share.
|
||||
# #2558: перенесён ВЫШЕ auth-import вместе с trade-in — редирект ведёт на
|
||||
# /trade-in/sale-share, для которого теперь нет Caddy basic_auth (как и
|
||||
# для остального /trade-in). Это НЕ делает страницу публичной: она всё
|
||||
# ещё за собственной авторизацией trade-in — `RouteGuard` во фронте
|
||||
# (`app/layout.tsx`) и сессия для `/api/v1/buildings/sale-share*` на
|
||||
# бэке; без валидной сессии юзер получит редирект на /login, а не
|
||||
# контент. Смысл переноса — не открыть страницу всем, а убрать
|
||||
# несогласованность: короткий URL не должен быть строже (Caddy
|
||||
# basic_auth) целевого адреса, к которому и так уже нет
|
||||
# basic_auth-барьера (только собственный login trade-in).
|
||||
#
|
||||
# ОБНОВЛЕНО 2026-07-31: доступ к разделу сузился с «pilot + admin» до
|
||||
# ТОЛЬКО admin — «Поиск домов» признан тестовым продуктом, клиентам не
|
||||
# показывается (deny в auth/roles.yaml для pilot и analyst + в
|
||||
# DB_ROLE_PATHS для employee/manager). Сам редирект не трогаем: он ведёт
|
||||
# на страницу, а гейт стоит на роли — для всех, кроме admin, короткий
|
||||
# адрес приведёт на NoAccessScreen.
|
||||
@saleshare path /sale-share /sale-share/
|
||||
handle @saleshare {
|
||||
redir /trade-in/sale-share permanent
|
||||
|
|
@ -110,13 +164,17 @@ gendsgn.ru {
|
|||
handle @tradein {
|
||||
# Next.js basePath=/trade-in — фронт сам ждёт префикса в URL
|
||||
reverse_proxy tradein-frontend:3000 {
|
||||
header_up X-Authenticated-User {http.auth.user.id}
|
||||
# #2213: симметрично с /trade-in/api/* — перезаписываем секрет из env
|
||||
# (стирает клиентский), на случай SSR-forwardʼa фронтом в backend.
|
||||
# См. комментарий над /trade-in/api/* выше — та же логика (явное
|
||||
# удаление вместо Set с пустым {http.auth.user.id}).
|
||||
header_up -X-Authenticated-User
|
||||
header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET}
|
||||
}
|
||||
}
|
||||
|
||||
# Auth gate — с #2558 применяется ТОЛЬКО к Site Finder (handle /api/* и
|
||||
# handle {} ниже). Trade-In уже отработал и short-circuit'нул выше.
|
||||
import caddy/users.caddy.snippet
|
||||
|
||||
handle /api/* {
|
||||
reverse_proxy backend:8000 {
|
||||
header_up X-Authenticated-User {http.auth.user.id}
|
||||
|
|
@ -137,7 +195,7 @@ www.gendsgn.ru {
|
|||
|
||||
# МЕРА B2C — публичный периметр (ЭТАП 1 плана B2C-запуска, БЕЗ функционала).
|
||||
#
|
||||
# Архитектурное решение: отдельный поддомен, а НЕ дырка в блоке gendsgn.ru
|
||||
# Архитектурное решение: отдельный домен, а НЕ дырка в блоке gendsgn.ru
|
||||
# выше. На gendsgn.ru модель "запрещено всё, кроме дырок ВЫШЕ auth-import" —
|
||||
# порядко-зависимая и общая для B2B (trade-in v2, admin, scrapers, /api/*).
|
||||
# Здесь, наоборот, allowlist-by-default: basic_auth НЕТ ВООБЩЕ (не импортируем
|
||||
|
|
@ -150,17 +208,17 @@ www.gendsgn.ru {
|
|||
#
|
||||
# Next.js basePath=/trade-in запечён в prod-образ tradein-frontend (тот же
|
||||
# контейнер, что обслуживает и gendsgn.ru/trade-in/*, см. build-args в
|
||||
# .forgejo/workflows/deploy-tradein.yml) — поэтому корень поддомена rewrite'ится
|
||||
# .forgejo/workflows/deploy-tradein.yml) — поэтому корень домена rewrite'ится
|
||||
# на internal-путь /trade-in/mera-public (страница-заглушка,
|
||||
# tradein-mvp/frontend/src/app/mera-public/). Пользователь префикс /trade-in
|
||||
# никогда не видит — rewrite меняет путь ТОЛЬКО для Caddy→backend запроса,
|
||||
# это не HTTP-редирект браузера.
|
||||
#
|
||||
# DNS: A-record mera.gendsgn.ru → IP VPS — ТРЕБУЕТСЯ ДО того, как сюда придёт
|
||||
# DNS: A-record meraocenka.ru → IP VPS — ТРЕБУЕТСЯ ДО того, как сюда придёт
|
||||
# реальный трафик. Если записи ещё нет на момент деплоя этого блока: `caddy
|
||||
# reload`/`up -d --force-recreate caddy` в deploy.yml НЕ падает (конфиг
|
||||
# синтаксически валиден, ошибка сертификата асинхронна и per-hostname) — Caddy
|
||||
# просто залогирует неудачную попытку ACME-выпуска для mera.gendsgn.ru (DNS не
|
||||
# просто залогирует неудачную попытку ACME-выпуска для meraocenka.ru (DNS не
|
||||
# резолвится на этот сервер → HTTP-01/TLS-ALPN challenge недостижим) и продолжит
|
||||
# ретраить с backoff, ПОКА запись не появится. Остальные site-блоки в этом же
|
||||
# Caddyfile (gendsgn.ru, obsidian.gendsgn.ru и т.д.) не затрагиваются —
|
||||
|
|
@ -169,11 +227,11 @@ www.gendsgn.ru {
|
|||
# появления DNS могут исчерпать rate-limit Let's Encrypt (5 failed
|
||||
# validations/hostname/hour) — не критично, просто подождать; `docker volume
|
||||
# rm gendesign_caddy_data` для этого НЕ нужен (и вообще требует user-approval).
|
||||
mera.gendsgn.ru {
|
||||
meraocenka.ru {
|
||||
encode zstd gzip
|
||||
|
||||
log {
|
||||
output file /var/log/caddy/mera.gendsgn.ru.log
|
||||
output file /var/log/caddy/meraocenka.ru.log
|
||||
}
|
||||
|
||||
# Единственная публичная страница этого этапа — заглушка "скоро".
|
||||
|
|
@ -197,6 +255,23 @@ mera.gendsgn.ru {
|
|||
}
|
||||
}
|
||||
|
||||
# Домены-спутники МЕРА → 301 на канонический meraocenka.ru.
|
||||
# Решение 2026-07-31: канонический адрес ровно один, остальные две регистрации
|
||||
# ловят (а) альтернативный транслит «оценка» — ocenka/otsenka, на слух
|
||||
# неразличимы, (б) прежний рабочий вариант merahome. Отдельные site-блоки, а не
|
||||
# matcher внутри основного: Caddy матчит по hostname и выпускает свой
|
||||
# сертификат на каждый, поэтому DNS A-record нужен для КАЖДОГО из них — иначе
|
||||
# ACME для этого хоста будет ретраиться (безвредно, см. комментарий выше, но
|
||||
# лучше завести записи сразу).
|
||||
# `{uri}` сохраняет путь и query — короткая ссылка с визитки не теряет ?id=.
|
||||
merahome.ru {
|
||||
redir https://meraocenka.ru{uri} permanent
|
||||
}
|
||||
|
||||
meraotsenka.ru {
|
||||
redir https://meraocenka.ru{uri} permanent
|
||||
}
|
||||
|
||||
# Obsidian Self-hosted LiveSync (CouchDB backend).
|
||||
# Auto-TLS Let's Encrypt. CORS уже включён на стороне CouchDB через bootstrap
|
||||
# (см. scripts/setup-couchdb.sh). Basic-auth — на стороне CouchDB (admin user).
|
||||
|
|
|
|||
|
|
@ -39,6 +39,39 @@ roles:
|
|||
- "/admin/**"
|
||||
- "/api/v1/admin/**"
|
||||
- "/trade-in/api/v1/admin/**"
|
||||
# Внутренние разделы, закрытые от клиентских аккаунтов (решение владельца
|
||||
# продукта 2026-07-31): «Доля в продаже» — аналитика рынка, «Кэш» —
|
||||
# состояние кэшей/скраперов. Зеркало deny-списка DB-ролей employee/manager
|
||||
# (tradein-mvp/backend/app/services/auth_session.py: DB_ROLE_PATHS).
|
||||
#
|
||||
# Зачем копия здесь, если клиенты ходят session-cookie'ой: снаружи легаси
|
||||
# trusted-header ветка НЕДОСТИЖИМА — с #2558 Caddy срезает входящий
|
||||
# X-Authenticated-User на всём /trade-in/* (`header_up
|
||||
# -X-Authenticated-User` в handle /trade-in/api/* и в @tradein), так что
|
||||
# ни один клиентский аккаунт по ней не ходит. Паттерны нужны для другого:
|
||||
# 1) ВНУТРИСЕТЕВОЙ dual-mode трафик — запросы изнутри gendesign_shared с
|
||||
# валидным X-Internal-Auth-Secret; ими ходят QA-смоуки вида
|
||||
# `docker exec tradein-backend curl localhost:8000
|
||||
# -H 'X-Authenticated-User: ...'` — они резолвятся именно через
|
||||
# roles.yaml, и без этих строк смоук показал бы 200 там, где
|
||||
# реальный клиент получает 403;
|
||||
# 2) чтобы legacy-pilot не расходился с DB-employee, если dual-режим
|
||||
# когда-нибудь снова окажется на периметре (откат #2558 / новый
|
||||
# фронт-прокси) — тогда расхождение молча откроет разделы.
|
||||
# НЕ удалять как «мёртвые»: они мёртвые только пока Caddy режет заголовок.
|
||||
#
|
||||
# Страницы + их API вместе: deny гейтит пункт меню (Topbar через /me),
|
||||
# саму страницу (RouteGuard) и серверные ручки (rbac_guard).
|
||||
#
|
||||
# cache-stats закрыт ГЛОБОМ, а не точным путём, намеренно: точный паттерн
|
||||
# обходится трейлинг-слэшем ('…/cache-stats/' не равен '…/cache-stats' →
|
||||
# allowed), и защита повисала бы на Starlette redirect_slashes, а не на
|
||||
# RBAC. '<prefix>/**' → '^<prefix>(?:/.*)?$': сам путь + слэш + подпути,
|
||||
# но НЕ соседи по префиксу ('…/cache-statistics' не матчится).
|
||||
- "/trade-in/sale-share/**"
|
||||
- "/trade-in/cache/**"
|
||||
- "/trade-in/api/v1/buildings/**"
|
||||
- "/trade-in/api/v1/trade-in/cache-stats/**"
|
||||
analyst:
|
||||
# #962 (EPIC18, ТЗ §19): analyst видит ВСЁ (deals, insights, exports,
|
||||
# site-finder, analytics, concept) КРОМЕ admin/data-management.
|
||||
|
|
@ -48,12 +81,28 @@ roles:
|
|||
# для любого role != "admin" → analyst авто-403 на admin-API без доп. кода.
|
||||
# deny ниже драйвит фронтовый RouteGuard (deny_paths из /me) для UI-gating
|
||||
# /admin/** страниц.
|
||||
# Клиентский deny 2026-07-31 (см. pilot выше) распространён на analyst
|
||||
# ЧАСТИЧНО — асимметрия намеренная, не недосмотр:
|
||||
# «Поиск домов» (/trade-in/sale-share + /api/v1/buildings/**) — ЗАКРЫТ.
|
||||
# Решение владельца продукта 2026-07-31: это ТЕСТОВЫЙ продукт, доступ
|
||||
# только у admin. «Только у админа» = включая внутренние роли, поэтому
|
||||
# analyst тоже в deny.
|
||||
# «Кэш» (/trade-in/cache + cache-stats) — ОСТАВЛЕН открытым: это не
|
||||
# продукт, а диагностика состояния кэшей/скраперов, т.е. ровно тот
|
||||
# рабочий инструмент, ради которого роль analyst и заведена
|
||||
# («видит ВСЁ кроме admin-управления», см. выше).
|
||||
# Обе стороны этой асимметрии запиннены тестом
|
||||
# tradein-mvp/backend/tests/test_rbac.py::test_yaml_roles_deliberately_outside_client_deny
|
||||
# — если решение поменяется, тест упадёт и заставит обновить и его, и этот
|
||||
# комментарий, а не тихо разойтись с реальностью.
|
||||
paths:
|
||||
- "/**"
|
||||
deny:
|
||||
- "/admin/**"
|
||||
- "/api/v1/admin/**"
|
||||
- "/trade-in/api/v1/admin/**"
|
||||
- "/trade-in/sale-share/**"
|
||||
- "/trade-in/api/v1/buildings/**"
|
||||
expired:
|
||||
# Пробный доступ закончился — нет доступа ни к чему. Аккаунт остаётся в
|
||||
# caddy/users.caddy.snippet (basic_auth), чтобы дойти до фронта и увидеть
|
||||
|
|
@ -70,7 +119,8 @@ users:
|
|||
admin: admin
|
||||
kopylov: pilot
|
||||
user1: pilot
|
||||
user2: pilot # «Брусника» — доступ восстановлен 2026-07-13 (снят trial-expire от 2026-07-09)
|
||||
user2: expired # «Брусника» — доступ закрыт 2026-07-30 (решение владельца продукта;
|
||||
# ранее: восстановлен 2026-07-13, trial-expire 2026-07-09)
|
||||
user3: pilot
|
||||
user4: pilot
|
||||
user5: pilot
|
||||
|
|
|
|||
160
backend/tests/sql/test_auth_sql_migrations.py
Normal file
160
backend/tests/sql/test_auth_sql_migrations.py
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
"""Инварианты миграций БД `auth` (data/sql/auth/*.sql) + её bootstrap (ops/db-bootstrap/*.sql).
|
||||
|
||||
Прецедента manifest-теста для КОРНЕВОГО data/sql в этом репозитории нет (он есть только
|
||||
в tradein: tradein-mvp/backend/tests/test_migrations_manifest.py по
|
||||
tradein-mvp/backend/data/sql/_manifest_applied.txt). Заводить manifest на 154 legacy-файла
|
||||
корневого каталога — не задача этого PR, поэтому здесь проверяются инварианты, которые
|
||||
можно проверить БЕЗ снимка «уже применённого»: они выполнимы на новом каталоге с первого
|
||||
дня и ловят регрессии, которые иначе всплывают только на проде во время деплоя.
|
||||
|
||||
Тест не требует БД — только чтение файлов.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
_AUTH_SQL_DIR = _REPO_ROOT / "data" / "sql" / "auth"
|
||||
_BOOTSTRAP_SQL_DIR = _REPO_ROOT / "ops" / "db-bootstrap"
|
||||
_DEPLOY_WORKFLOW = _REPO_ROOT / ".forgejo" / "workflows" / "deploy.yml"
|
||||
|
||||
_FILENAME_RE = re.compile(r"^(\d{3})_[a-z0-9_]+\.sql$")
|
||||
|
||||
# Признаки утёкшего пароля в git. bcrypt-хеши ($2a$/$2b$/$2y$) запрещены наравне с
|
||||
# plaintext: хеш из репозитория брутфорсится офлайн и переживает ротацию пароля,
|
||||
# оставаясь в истории коммитов. Конвенция репо — сид вставляет password_hash = NULL,
|
||||
# значения проставляются на проде (прецедент: tradein м.193).
|
||||
_SECRET_PATTERNS = (
|
||||
re.compile(r"\$2[aby]\$\d{2}\$"), # bcrypt hash
|
||||
re.compile(r"PASSWORD\s+'", re.IGNORECASE), # CREATE/ALTER ROLE ... PASSWORD 'literal'
|
||||
)
|
||||
|
||||
|
||||
def _auth_sql_files() -> list[Path]:
|
||||
"""Файлы, к которым применимы конвенции миграций (имя NNN_*, обёртка BEGIN/COMMIT)."""
|
||||
return sorted(_AUTH_SQL_DIR.glob("*.sql"))
|
||||
|
||||
|
||||
def _secret_scanned_files() -> list[Path]:
|
||||
"""Файлы, по которым гоняется поиск паролей/хешей — ШИРЕ, чем список миграций.
|
||||
|
||||
⚠️ НЕ «унифицируй» этот список с _auth_sql_files(): разделение намеренное.
|
||||
|
||||
* data/sql/auth/*.sql — миграции: обязаны иметь имя NNN_snake_case.sql и обёртку
|
||||
BEGIN;/COMMIT; (см. test_filenames_and_unique_prefix, test_migrations_are_transactional).
|
||||
* ops/db-bootstrap/*.sql — bootstrap: НЕ миграции, поэтому намеренно без NNN-префикса
|
||||
(порядок задан явными шагами deploy.yml, не сортировкой) и намеренно без транзакции
|
||||
(CREATE DATABASE запрещён внутри транзакционного блока). Прогонять по ним проверки
|
||||
имён/BEGIN-COMMIT — значит сломать тест на корректных файлах.
|
||||
|
||||
А вот запрет на пароли применим к обоим каталогам, и именно bootstrap здесь важнее:
|
||||
единственное место в репозитории с конструкцией `ALTER ROLE ... PASSWORD` — это
|
||||
ops/db-bootstrap/set_*_password.sql, то есть ровно тот файл, куда проще всего однажды
|
||||
«временно» вписать литерал вместо чтения из env. Другого контроля на это нет:
|
||||
в .pre-commit-config.yaml из секрет-сканеров только detect-private-key (bcrypt не ловит),
|
||||
а репо-wide grep невозможен — caddy/users.caddy.snippet легально содержит bcrypt-хеши
|
||||
действующих логинов.
|
||||
"""
|
||||
return _auth_sql_files() + sorted(_BOOTSTRAP_SQL_DIR.glob("*.sql"))
|
||||
|
||||
|
||||
def test_scanned_dirs_are_not_empty() -> None:
|
||||
"""Sanity: пути до каталогов не разъехались (иначе все проверки ниже — пустые).
|
||||
|
||||
Red => каталог переименован/перенесён, а тест этого не заметил бы: `glob` по
|
||||
несуществующему пути возвращает [], и все циклы ниже стали бы no-op'ами, оставаясь
|
||||
зелёными. Особенно опасно для проверки паролей — «зелено, потому что ничего не проверено».
|
||||
"""
|
||||
assert _auth_sql_files(), f"Не найдено *.sql в {_AUTH_SQL_DIR}"
|
||||
assert sorted(_BOOTSTRAP_SQL_DIR.glob("*.sql")), f"Не найдено *.sql в {_BOOTSTRAP_SQL_DIR}"
|
||||
|
||||
|
||||
def test_filenames_and_unique_prefix() -> None:
|
||||
"""Имя вида NNN_snake_case.sql, префикс NNN уникален.
|
||||
|
||||
Red => прод применяет файлы в порядке `ls | sort`; два файла с одним NNN дают
|
||||
неоднозначный порядок (например, роль/гранты раньше таблиц). Присвой следующий
|
||||
свободный номер.
|
||||
"""
|
||||
seen: dict[str, str] = {}
|
||||
bad_names: list[str] = []
|
||||
collisions: list[str] = []
|
||||
for path in _auth_sql_files():
|
||||
m = _FILENAME_RE.match(path.name)
|
||||
if m is None:
|
||||
bad_names.append(path.name)
|
||||
continue
|
||||
prefix = m.group(1)
|
||||
if prefix in seen:
|
||||
collisions.append(f"{path.name} (префикс {prefix} уже у {seen[prefix]})")
|
||||
else:
|
||||
seen[prefix] = path.name
|
||||
|
||||
assert not bad_names, (
|
||||
f"Имена не соответствуют NNN_snake_case.sql: {bad_names}. "
|
||||
"Порядок применения на проде определяется сортировкой имён."
|
||||
)
|
||||
assert not collisions, "Дублирующийся NNN-префикс: " + "; ".join(collisions)
|
||||
|
||||
|
||||
def test_migrations_are_transactional() -> None:
|
||||
"""Каждая миграция обёрнута в BEGIN; ... COMMIT; (.claude/rules/sql.md).
|
||||
|
||||
Red => частично применённая миграция оставит БД auth в промежуточном состоянии:
|
||||
деплой падает на ON_ERROR_STOP, а уже выполненный DDL не откатывается.
|
||||
"""
|
||||
broken: list[str] = []
|
||||
for path in _auth_sql_files():
|
||||
text = path.read_text(encoding="utf-8")
|
||||
statements = [
|
||||
line.strip()
|
||||
for line in text.splitlines()
|
||||
if line.strip() and not line.strip().startswith("--")
|
||||
]
|
||||
if not statements or statements[0] != "BEGIN;" or statements[-1] != "COMMIT;":
|
||||
broken.append(path.name)
|
||||
assert (
|
||||
not broken
|
||||
), f"Миграции без обёртки BEGIN;/COMMIT;: {broken} (.claude/rules/sql.md → Structure)."
|
||||
|
||||
|
||||
def test_no_password_material_in_auth_sql() -> None:
|
||||
"""Ни в data/sql/auth, ни в ops/db-bootstrap нет plaintext-паролей и bcrypt-хешей.
|
||||
|
||||
Покрытие шире каталога миграций сознательно — обоснование в _secret_scanned_files().
|
||||
|
||||
Red => пароль/хеш попал в git. Убери значение: сид вставляет password_hash = NULL,
|
||||
пароль роли ставится из env через ops/db-bootstrap/set_auth_app_password.sql
|
||||
(значение приезжает из .env.runtime на VPS и в репозитории не существует).
|
||||
"""
|
||||
hits: list[str] = []
|
||||
for path in _secret_scanned_files():
|
||||
rel = path.relative_to(_REPO_ROOT).as_posix()
|
||||
text = path.read_text(encoding="utf-8")
|
||||
for line_no, line in enumerate(text.splitlines(), start=1):
|
||||
if line.lstrip().startswith("--"):
|
||||
continue # комментарии описывают запрет, а не нарушают его
|
||||
for pattern in _SECRET_PATTERNS:
|
||||
if pattern.search(line):
|
||||
hits.append(f"{rel}:{line_no}: {line.strip()}")
|
||||
assert not hits, "Похоже на пароль/хеш в SQL: " + "; ".join(hits)
|
||||
|
||||
|
||||
def test_deploy_workflow_applies_auth_migrations() -> None:
|
||||
"""deploy.yml реально прогоняет data/sql/auth/*.sql.
|
||||
|
||||
Каталог обособлен намеренно: основной цикл миграций использует `ls -1 data/sql/*.sql`
|
||||
и в подкаталоги НЕ рекурсирует (чтобы файлы auth физически не могли примениться в БД
|
||||
gendesign). Обратная сторона — без отдельного цикла в deploy.yml эти файлы не
|
||||
применяются вообще и никто этого не заметит. Red => wiring удалён или переименован.
|
||||
"""
|
||||
workflow = _DEPLOY_WORKFLOW.read_text(encoding="utf-8")
|
||||
assert "data/sql/auth/*.sql" in workflow, (
|
||||
f"В {_DEPLOY_WORKFLOW.name} нет цикла по data/sql/auth/*.sql — миграции БД auth "
|
||||
"не применяются на деплое."
|
||||
)
|
||||
assert (
|
||||
"ops/db-bootstrap/create_auth_db.sql" in workflow
|
||||
), f"В {_DEPLOY_WORKFLOW.name} нет bootstrap-шага создания БД auth."
|
||||
|
|
@ -110,11 +110,24 @@ def client() -> TestClient:
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Пилотные логины user1..user10 в auth/roles.yaml. user2 — «Брусника»: доступ
|
||||
# закрыт владельцем продукта 2026-07-30, роль переведена pilot → expired. Это
|
||||
# ЕДИНСТВЕННОЕ отклонение от «все userN = pilot», и оно ожидаемое; хардкод
|
||||
# именно здесь, отдельной константой, а не магическим `if` в цикле.
|
||||
_EXPIRED_PILOT_LOGINS = {"user2": "«Брусника», доступ закрыт 2026-07-30"}
|
||||
|
||||
|
||||
def test_get_role_known_users() -> None:
|
||||
"""Ловит рассинхрон auth/roles.yaml с ожиданиями теста: roles.yaml лежит вне
|
||||
`backend/**`, поэтому правка ролей не попадает в paths-filter CI и такой
|
||||
рассинхрон CI молча пропускает (так и случилось с user2 → expired)."""
|
||||
assert auth_mod.get_role("admin") == "admin"
|
||||
assert auth_mod.get_role("kopylov") == "pilot"
|
||||
for n in range(1, 11):
|
||||
assert auth_mod.get_role(f"user{n}") == "pilot"
|
||||
login = f"user{n}"
|
||||
expected = "expired" if login in _EXPIRED_PILOT_LOGINS else "pilot"
|
||||
why = _EXPIRED_PILOT_LOGINS.get(login, "обычный пилотный логин")
|
||||
assert auth_mod.get_role(login) == expected, f"{login}: ожидали {expected} — {why}"
|
||||
|
||||
|
||||
def test_get_role_unknown_user_raises() -> None:
|
||||
|
|
|
|||
123
data/sql/auth/001_identity_schema.sql
Normal file
123
data/sql/auth/001_identity_schema.sql
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
-- auth/001: users + sessions — единое хранилище доступов для «Меры» и «Птицы».
|
||||
--
|
||||
-- WHY (почему отдельная БД и почему таблицы называются нейтрально):
|
||||
-- Владелец продукта решил (2026-07-31) свести вход в «Меру» (trade-in, /trade-in) и
|
||||
-- «Птицу» (раздел Site Finder, /site-finder/analysis/[cad]/ptica) к ОДНОЙ нейтральной
|
||||
-- форме входа, вместо браузерного popup'а Caddy basic_auth. Значит, у хранилища доступов
|
||||
-- два потребителя, и оно не должно принадлежать ни одному из них: живёт в отдельной БД
|
||||
-- `auth` на платформенном сервере gendesign-postgres (тот же кластер, отдельная база —
|
||||
-- новый контейнер не заводим; оба бэкенда сидят в сети gendesign_shared и TCP-достают
|
||||
-- до gendesign-postgres-1:5432, проверено на проде 2026-07-31).
|
||||
-- Отсюда имена без префикса продукта: `users`, а не `tradein_users`. Префикс продукта в
|
||||
-- нейтральном хранилище означал бы, что вторая система — гость в чужой таблице, и через
|
||||
-- полгода никто бы не помнил, кто владелец схемы.
|
||||
--
|
||||
-- Здесь НЕТ колонки `role` — сознательно. Идентичность («кто это, какой у него пароль,
|
||||
-- активен ли доступ») общая для двух продуктов; полномочия внутри продукта (admin/manager/
|
||||
-- employee в «Мере», админ-роуты в «Птице») — это знание продукта, оно остаётся в
|
||||
-- продуктовых БД (tradein_users.role) и не переезжает сюда. Иначе `auth` пришлось бы
|
||||
-- менять каждый раз, когда в одном из продуктов появляется новая роль.
|
||||
--
|
||||
-- WHAT:
|
||||
-- 1. users — identity. password_hash NULL допустим (см. комментарий к колонке): пароли
|
||||
-- НИКОГДА не попадают в git, ни plaintext, ни bcrypt-хешем — конвенция репо, прецедент
|
||||
-- tradein-mvp/backend/data/sql/193_tradein_users_seed.sql. Сид (003) вставляет строки
|
||||
-- с password_hash = NULL, хеши проставляются на проде отдельно.
|
||||
-- 2. sessions — токен-based сессии, ON DELETE CASCADE от users (удалили пользователя —
|
||||
-- его сессии теряют смысл). last_seen_at отдельно от created_at — для idle-timeout,
|
||||
-- иначе «сессия жива 30 дней» и «человек не заходил 30 дней» неразличимы.
|
||||
-- 3. ASCII-CHECK на username — обязателен ДО появления прод-данных (см. ниже).
|
||||
--
|
||||
-- IDEMPOTENCY:
|
||||
-- CREATE TABLE IF NOT EXISTS + CREATE INDEX IF NOT EXISTS; CHECK-констрейнты объявлены
|
||||
-- inline в CREATE TABLE, а не через ALTER — при повторном прогоне CREATE TABLE не
|
||||
-- выполняется вообще, значит констрейнт физически не может задублироваться (паттерн из
|
||||
-- 192_tradein_users_auth.sql).
|
||||
--
|
||||
-- Тип id: `bigint GENERATED ALWAYS AS IDENTITY` — стандартный (SQL-standard) эквивалент
|
||||
-- bigserial: та же bigint-колонка на той же последовательности, но sequence принадлежит
|
||||
-- таблице жёстко и не переживает DROP COLUMN сиротой, а прямой INSERT в id запрещён
|
||||
-- (случайная вставка «своего» id, ломающая счётчик, невозможна). Ровно так объявлен
|
||||
-- tradein_users.id в 192 — держим один тип на обе таблицы, чтобы будущий код, читающий
|
||||
-- обе, не спотыкался о разницу.
|
||||
--
|
||||
-- Dependencies: нет (пустая БД `auth`, создаётся bootstrap-шагом деплоя,
|
||||
-- см. ops/db-bootstrap/create_auth_db.sql).
|
||||
-- Deploy order: Foundation. Роль приложения + гранты — 002, сид — 003. Python-код логина,
|
||||
-- логин-страница и снятие Caddy basic_auth — отдельные PR'ы ПОСЛЕ этого
|
||||
-- (SQL-схема первой, см. .claude/rules/sql.md «Migration order»).
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
username text NOT NULL UNIQUE,
|
||||
password_hash text NULL,
|
||||
display_name text NULL,
|
||||
org_name text NULL,
|
||||
email text NULL,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT users_username_ascii_ck CHECK (username ~ '^[A-Za-z0-9._-]{3,64}$')
|
||||
);
|
||||
|
||||
COMMENT ON TABLE users IS
|
||||
'Единое хранилище доступов для «Меры» (trade-in) и «Птицы» (Site Finder) — только '
|
||||
'идентичность. Полномочия внутри продукта (роли) остаются в продуктовых БД: иначе эту '
|
||||
'таблицу пришлось бы менять при каждом изменении ролевой модели любого из продуктов.';
|
||||
|
||||
COMMENT ON COLUMN users.password_hash IS
|
||||
'NULL = пароль ещё не проставлен, вход по паролю для этой строки невозможен. Хеши '
|
||||
'НИКОГДА не хранятся в git (ни в сидах, ни в фикстурах) — их проставляют на проде '
|
||||
'отдельно от миграции; иначе один утёкший коммит открывает вход всем аккаунтам сразу.';
|
||||
|
||||
COMMENT ON COLUMN users.is_active IS
|
||||
'false = доступ закрыт владельцем продукта. Отдельная колонка, а не удаление строки: '
|
||||
'удаление каскадом снесло бы сессии и историю, а закрытие доступа обратимо и его надо '
|
||||
'уметь отличать от «такого пользователя никогда не было».';
|
||||
|
||||
COMMENT ON COLUMN users.org_name IS
|
||||
'Организация пользователя. NULL, пока реальные данные не подтверждены владельцем '
|
||||
'продукта — выдуманное название хуже пустого, оно выглядит достоверным.';
|
||||
|
||||
COMMENT ON CONSTRAINT users_username_ascii_ck ON users IS
|
||||
'Fail-closed запрет не-ASCII логинов (перенесено из tradein м.193, deep-review #2561): '
|
||||
'downstream-код кодирует username сессии через encode("latin-1","replace"), поэтому два '
|
||||
'кириллических логина ОДИНАКОВОЙ длины схлопываются в одну и ту же byte-строку из «?» — '
|
||||
'разные люди получают общую идентичность, общую квоту и взаимный IDOR (один видит данные '
|
||||
'другого). Констрейнт на уровне схемы, а не проверка в UI/API: проверку в коде однажды '
|
||||
'забудут добавить в новый путь создания пользователя, схему обойти нельзя.';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token text PRIMARY KEY,
|
||||
user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz NOT NULL,
|
||||
last_seen_at timestamptz NOT NULL DEFAULT now(),
|
||||
ip_address inet NULL,
|
||||
user_agent text NULL
|
||||
);
|
||||
|
||||
COMMENT ON TABLE sessions IS
|
||||
'Активные сессии единой формы входа (общие для «Меры» и «Птицы»). ON DELETE CASCADE от '
|
||||
'users: оставшаяся сессия удалённого пользователя — это действующий доступ без владельца.';
|
||||
|
||||
COMMENT ON COLUMN sessions.last_seen_at IS
|
||||
'Обновляется на каждом запросе — нужен для idle-timeout: без него «сессия не истекла» и '
|
||||
'«человек ещё работает» неразличимы, и забытая открытая вкладка живёт до expires_at.';
|
||||
|
||||
COMMENT ON COLUMN sessions.ip_address IS
|
||||
'IP на момент выдачи токена — для разбора инцидентов («откуда зашли под этим логином»), '
|
||||
'не для авторизации: привязка к IP ломает мобильных пользователей при смене сети.';
|
||||
|
||||
-- Индексы — как в tradein м.192: уборка протухших сессий по expires_at и выборка/отзыв
|
||||
-- всех сессий одного пользователя по user_id (FK сам по себе индекс не создаёт, а без него
|
||||
-- ON DELETE CASCADE на users делает seq scan по всей таблице сессий).
|
||||
CREATE INDEX IF NOT EXISTS sessions_expires_at_idx
|
||||
ON sessions (expires_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS sessions_user_id_idx
|
||||
ON sessions (user_id);
|
||||
|
||||
COMMIT;
|
||||
82
data/sql/auth/002_auth_app_role.sql
Normal file
82
data/sql/auth/002_auth_app_role.sql
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
-- auth/002: роль приложения auth_app + гранты (least privilege).
|
||||
--
|
||||
-- WHY:
|
||||
-- Миграции этой БД прогоняются суперюзером кластера ($POSTGRES_USER), он же владелец
|
||||
-- таблиц. Бэкенды «Меры» и «Птицы» ходить под суперюзером не должны: скомпрометированный
|
||||
-- бэкенд не обязан уметь DROP TABLE users. Поэтому отдельная login-роль с точечными
|
||||
-- грантами. БД `auth` НЕ принадлежит auth_app (владелец — суперюзер): владелец таблицы
|
||||
-- имеет на неё все права независимо от GRANT'ов, и разграничение ниже стало бы фикцией.
|
||||
--
|
||||
-- Пароль роли здесь НЕ задаётся — роль создаётся passwordless, пароль ставится отдельным
|
||||
-- bootstrap-шагом деплоя из env (AUTH_DB_PASSWORD в /opt/gendesign/backend/.env.runtime,
|
||||
-- см. ops/db-bootstrap/set_auth_app_password.sql). Ровно тот же паттерн, что у
|
||||
-- gendesign_reader (tradein м.101 + set_gendesign_reader_password.sql) и tradein_fdw_reader
|
||||
-- (data/sql/100_tradein_fdw_role.sql). Пароль в git не попадает ни при каких условиях.
|
||||
--
|
||||
-- Периметр прав (обосновано по-операционно):
|
||||
-- sessions — SELECT/INSERT/UPDATE/DELETE. Полный набор: выдать токен (INSERT), проверить
|
||||
-- на каждом запросе (SELECT), обновить last_seen_at (UPDATE), разлогинить и вычистить
|
||||
-- протухшие (DELETE).
|
||||
-- users — SELECT (найти по username, прочитать hash и is_active) + UPDATE (смена пароля
|
||||
-- самим пользователем и проставление хеша админом).
|
||||
-- users — INSERT/DELETE НЕ выдаются, сознательно:
|
||||
-- * INSERT — создание аккаунтов в PR-1 не существует ни как код, ни как UI. Выдать грант
|
||||
-- «на будущее» = держать открытой операцию, которой никто не пользуется и которую никто
|
||||
-- не тестирует. Когда появится админский путь создания пользователей, грант добавляется
|
||||
-- новой миграцией в одну строку (плюс GRANT USAGE на sequence, идентичность требует
|
||||
-- nextval). Обратная ошибка дороже: снять грант, на который уже опирается прод-код,
|
||||
-- нельзя без синхронного релиза.
|
||||
-- * DELETE — не выдаётся и дальше: закрытие доступа делается через is_active = false
|
||||
-- (см. комментарий к колонке в 001). Физическое удаление каскадом сносит сессии и
|
||||
-- обрывает связь с историей действий пользователя в продуктовых БД, где user_id/username
|
||||
-- остаются висеть; это операция уровня «руками через psql с осознанием последствий»,
|
||||
-- а не то, что должен уметь HTTP-хендлер.
|
||||
--
|
||||
-- IDEMPOTENCY:
|
||||
-- CREATE ROLE через DO-блок с проверкой pg_roles (нет ADD ROLE IF NOT EXISTS), GRANT/REVOKE
|
||||
-- идемпотентны по определению. Повторный прогон — no-op. Роли в PostgreSQL общие на кластер,
|
||||
-- поэтому DO-блок отработает корректно, даже если роль уже создана из другой БД.
|
||||
--
|
||||
-- Dependencies: 001_identity_schema.sql (гранты ссылаются на users/sessions).
|
||||
|
||||
BEGIN;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'auth_app') THEN
|
||||
CREATE ROLE auth_app LOGIN;
|
||||
END IF;
|
||||
END$$;
|
||||
|
||||
COMMENT ON ROLE auth_app IS
|
||||
'Прикладная роль единой формы входа («Мера» + «Птица»). Пароль ставится '
|
||||
'.forgejo/workflows/deploy.yml из env AUTH_DB_PASSWORD (backend/.env.runtime) через '
|
||||
'ops/db-bootstrap/set_auth_app_password.sql. Пароль никогда не хранится в SQL-миграциях.';
|
||||
|
||||
-- Никто, кроме владельца БД и явно поименованных ролей, не должен даже подключаться:
|
||||
-- по умолчанию PostgreSQL даёт CONNECT роли PUBLIC, то есть любая login-роль кластера
|
||||
-- (glitchtip, tradein_fdw_reader, gendesign_reader) может открыть сессию в `auth`.
|
||||
-- Хранилище паролей — не то место, где стоит полагаться на «а таблицы им всё равно не видны».
|
||||
--
|
||||
-- ЭТА СТРОКА ПРОДУБЛИРОВАНА в ops/db-bootstrap/create_auth_db.sql — намеренно, инвариант
|
||||
-- держится в двух местах. Здесь — ради самодостаточности миграции: применённая на пустую БД
|
||||
-- (scratch/staging, ручной psql -f) она обязана давать полный периметр прав, не полагаясь на
|
||||
-- то, что кто-то отдельно прогнал bootstrap. В bootstrap — ради переприменяемости: миграция
|
||||
-- выполняется РОВНО ОДИН РАЗ (трекинг в _schema_migrations), а БД может быть пересоздана из
|
||||
-- дампа в обход миграций, и тогда дефолтный PUBLIC-CONNECT вернулся бы молча. Не «сокращай»
|
||||
-- дубль — ни одна из копий не покрывает сценарий другой.
|
||||
REVOKE ALL ON DATABASE auth FROM PUBLIC;
|
||||
|
||||
-- Defense-in-depth: явный REVOKE-периметр перед точечными грантами — любые унаследованные
|
||||
-- или PUBLIC-гранты на существующих объектах обнуляются (паттерн из 100_tradein_fdw_role.sql).
|
||||
REVOKE ALL ON ALL TABLES IN SCHEMA public FROM auth_app;
|
||||
REVOKE ALL ON ALL SEQUENCES IN SCHEMA public FROM auth_app;
|
||||
REVOKE ALL ON ALL FUNCTIONS IN SCHEMA public FROM auth_app;
|
||||
|
||||
GRANT CONNECT ON DATABASE auth TO auth_app;
|
||||
GRANT USAGE ON SCHEMA public TO auth_app;
|
||||
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON sessions TO auth_app;
|
||||
GRANT SELECT, UPDATE ON users TO auth_app;
|
||||
|
||||
COMMIT;
|
||||
111
data/sql/auth/003_users_seed.sql
Normal file
111
data/sql/auth/003_users_seed.sql
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
-- auth/003: сид 13 существующих аккаунтов (org-карта владельца продукта, 2026-07-30/31).
|
||||
--
|
||||
-- WHY:
|
||||
-- 001 создала схему, но без данных единая форма входа не заработает: реальные аккаунты
|
||||
-- сейчас живут только в Caddy basic_auth (caddy/users.caddy.snippet + tradein auth/roles.yaml)
|
||||
-- и в tradein_users. Эта миграция переносит список людей — БЕЗ ЕДИНОГО ПАРОЛЯ.
|
||||
--
|
||||
-- password_hash = NULL у ВСЕХ строк. Это конвенция репо, а не недоделка: ни plaintext, ни
|
||||
-- bcrypt-хеш не должны попадать в git (прецедент — tradein-mvp/backend/data/sql/
|
||||
-- 193_tradein_users_seed.sql, там сид тоже вставляет NULL, хеши проставляются отдельно на
|
||||
-- проде). Хеш в git — это офлайн-brute-force для любого, кто получил доступ к репозиторию,
|
||||
-- и он переживает любую ротацию пароля в истории коммитов.
|
||||
-- Пока hash = NULL, вход по паролю через новую форму для строки невозможен, но доступ НЕ
|
||||
-- теряется: PR-1 ничего не переключает, прод продолжает пускать через существующий
|
||||
-- Caddy basic_auth ровно как сейчас. Переключение — отдельные PR'ы.
|
||||
--
|
||||
-- Состав (утверждён владельцем продукта):
|
||||
-- admin — владелец
|
||||
-- kopylov — отдельный клиент, display_name «Копылов»
|
||||
-- praktika — ГК «Практика»
|
||||
-- user1, user3..user10 — свободные слоты, is_active = true
|
||||
-- user2 — «Брусника», is_active = FALSE (доступ закрыт 2026-07-30);
|
||||
-- в roles.yaml он role=expired — расхождение семантики,
|
||||
-- см. ⚠️ у строки user2 в VALUES ниже
|
||||
-- display_name заполнен только у kopylov (единственная фамилия, подтверждённая в коде:
|
||||
-- tradein auth.py::_USERNAME_PROFILE). Остальным NULL — реальных данных нет, выдумывать
|
||||
-- нельзя: выдуманное ФИО в UI неотличимо от настоящего.
|
||||
-- QA-фикстуры НЕ мигрируются — им нечего делать в общем хранилище доступов двух продуктов.
|
||||
-- Состав фикстур неоднороден, и это важно при сверке списков (проверено по обоим файлам):
|
||||
-- admintest, pilottest — действующие логины: есть И в caddy/users.caddy.snippet
|
||||
-- (basic_auth-запись с хешем), И в auth/roles.yaml (role-mapping). Реально входят.
|
||||
-- analysttest, expiredtest — существуют ТОЛЬКО в auth/roles.yaml как role-mapping,
|
||||
-- basic_auth-записи в caddy/users.caddy.snippet у них нет, то есть войти под ними
|
||||
-- снаружи сегодня нельзя вообще. Это тестовые фикстуры, а не аккаунты: analysttest
|
||||
-- гоняется в backend/tests (test_rbac.py, test_insights.py, test_audit_middleware.py),
|
||||
-- expiredtest — в tradein-mvp/backend/tests/test_rbac.py как покрытие role=expired.
|
||||
--
|
||||
-- IDEMPOTENCY (логика и обоснование перенесены из tradein м.193, deep-review #2564):
|
||||
-- INSERT ... ON CONFLICT (username) DO UPDATE, но НЕ безусловно: password_hash, display_name,
|
||||
-- org_name, email защищены COALESCE(текущее, EXCLUDED). Если админ уже проставил пароль или
|
||||
-- поправил профиль между двумя прогонами файла (обычный auto-apply трекает filename в
|
||||
-- _schema_migrations и не запускает файл дважды на одном окружении — но ручной re-apply при
|
||||
-- recovery и scratch/staging БД такого трекинга не имеют), повторный прогон НЕ должен
|
||||
-- затереть это состояние NULL-ом. В м.193 это был живой баг: назначенный через API manager_id
|
||||
-- тихо обнулялся повторным прогоном сида.
|
||||
-- Направление COALESCE односторонее: NULL в БД можно дозаполнить значением из сида, но
|
||||
-- значение из БД никогда не перетирается сидом.
|
||||
--
|
||||
-- is_active НАМЕРЕННО отсутствует в SET — и не как COALESCE тоже: колонка NOT NULL, значит
|
||||
-- COALESCE(NOT NULL-значение, x) никогда не возьмёт x, это был бы мёртвый код с видимостью
|
||||
-- защиты. Открытие/закрытие доступа — решение владельца продукта, оно принимается в
|
||||
-- интерфейсе, а не повторным прогоном seed-файла: после первой вставки колонка сознательно
|
||||
-- «замораживается» на текущем значении в БД.
|
||||
-- (В м.193 в SET присутствовал ещё role — как источник истины org-карты. Здесь колонки role
|
||||
-- нет вовсе: полномочия остаются в продуктовых БД, см. заголовок 001.)
|
||||
--
|
||||
-- updated_at = now() выставляется на любом конфликте, даже когда ни одна колонка фактически
|
||||
-- не изменилась — паритет с м.193; «строка была затронута прогоном сида» это честно отражает.
|
||||
--
|
||||
-- Разрывы в users.id после повторного прогона — норма, НЕ следы удалённых строк. Дефолт
|
||||
-- GENERATED ALWAYS AS IDENTITY вычисляется ДО обнаружения конфликта, поэтому каждый
|
||||
-- повторный прогон сжигает 13 значений последовательности впустую. Функционально безвредно;
|
||||
-- упомянуто, чтобы дыры в id не увели разбор инцидента в сторону «кого-то удалили».
|
||||
--
|
||||
-- Dependencies: 001_identity_schema.sql (users + ASCII-CHECK на username; все логины ниже
|
||||
-- ASCII, констрейнту не противоречат).
|
||||
|
||||
BEGIN;
|
||||
|
||||
INSERT INTO users (username, password_hash, display_name, org_name, email, is_active)
|
||||
VALUES
|
||||
('admin', NULL, NULL, NULL, NULL, true),
|
||||
('kopylov', NULL, 'Копылов', NULL, NULL, true),
|
||||
('praktika', NULL, NULL, NULL, NULL, true),
|
||||
('user1', NULL, NULL, NULL, NULL, true),
|
||||
-- user2 — «Брусника», доступ закрыт владельцем продукта 2026-07-30.
|
||||
--
|
||||
-- ⚠️ ОТКРЫТАЯ РАЗВИЛКА, решается в PR-2/3 (переключение на единую форму входа), НЕ здесь:
|
||||
-- сегодня в auth/roles.yaml у user2 role=expired, и семантика ДРУГАЯ, чем is_active=false.
|
||||
-- expired != disabled: expired-юзер проходит гейт (basic_auth-запись в
|
||||
-- caddy/users.caddy.snippet у него есть), доходит до фронта и видит осмысленный экран
|
||||
-- «пробный доступ закончился» (roles.yaml → блок expired: paths: [] + deny "/**";
|
||||
-- frontend NoAccessScreen variant="trial"). is_active=false — это отказ на этапе входа,
|
||||
-- неотличимый для пользователя от «неверный пароль».
|
||||
-- Сейчас расхождение безобидно: PR-1 ничего не переключает, прод по-прежнему ходит через
|
||||
-- Caddy basic_auth + roles.yaml, и никакой код эту колонку не читает. Но в момент
|
||||
-- переключения trial-экран пропадёт МОЛЧА — тесты не упадут, роль просто перестанет
|
||||
-- существовать как состояние. Решать тогда: если trial-UX сохраняем, нужно отдельное
|
||||
-- состояние (колонка status / отдельная роль), а не булев флаг — is_active схлопывает
|
||||
-- «доступ закрыт» и «пробный период истёк» в одно значение. Схему в этом PR НЕ трогаем.
|
||||
('user2', NULL, NULL, NULL, NULL, false),
|
||||
('user3', NULL, NULL, NULL, NULL, true),
|
||||
('user4', NULL, NULL, NULL, NULL, true),
|
||||
('user5', NULL, NULL, NULL, NULL, true),
|
||||
('user6', NULL, NULL, NULL, NULL, true),
|
||||
('user7', NULL, NULL, NULL, NULL, true),
|
||||
('user8', NULL, NULL, NULL, NULL, true),
|
||||
('user9', NULL, NULL, NULL, NULL, true),
|
||||
('user10', NULL, NULL, NULL, NULL, true)
|
||||
ON CONFLICT (username) DO UPDATE SET
|
||||
-- COALESCE(текущее, EXCLUDED): сид дозаполняет пустые поля, но никогда не затирает
|
||||
-- уже проставленные вручную (в первую очередь password_hash — иначе повторный прогон
|
||||
-- отключал бы вход всем, кому пароль уже выдали).
|
||||
password_hash = COALESCE(users.password_hash, EXCLUDED.password_hash),
|
||||
display_name = COALESCE(users.display_name, EXCLUDED.display_name),
|
||||
org_name = COALESCE(users.org_name, EXCLUDED.org_name),
|
||||
email = COALESCE(users.email, EXCLUDED.email),
|
||||
-- is_active НЕ в SET: NOT NULL-колонка, COALESCE был бы мёртвым кодом (см. IDEMPOTENCY).
|
||||
updated_at = now();
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
| **Forgejo repo variables** (`vars.*`) | non-sensitive toggles (`LLM_ENABLED`, `OWN_DEVELOPER_IDS`) | ❌ нет | Forgejo Actions runner |
|
||||
| **GitHub repo secrets** (зеркало для `.github/workflows/`) | deploy SSH key (obsidian-стек) | ❌ нет | GitHub Actions (только obsidian deploy) |
|
||||
| **`/opt/gendesign/.env`** (VPS, root-only, chmod 600) | DB creds, GlitchTip infra-secrets, FDW/reader passwords, прокси, COMPOSE_PROFILES | ❌ `.gitignore` | docker compose (main + obsidian + tradein стеки) |
|
||||
| **`/opt/gendesign/backend/.env.runtime`** (VPS, chmod 600) | runtime overlay: `SENTRY_RELEASE`, `GLITCHTIP_DSN`, `OBJECTIVE_API_KEY`, `OPENAI_API_KEY`, `OWN_DEVELOPER_IDS`, `GENDESIGN_FDW_PASSWORD`, `COUCHDB_*` | ❌ `.gitignore` | backend/worker/beat/couchdb |
|
||||
| **`/opt/gendesign/backend/.env.runtime`** (VPS, chmod 600) | runtime overlay: `SENTRY_RELEASE`, `GLITCHTIP_DSN`, `OBJECTIVE_API_KEY`, `OPENAI_API_KEY`, `OWN_DEVELOPER_IDS`, `GENDESIGN_FDW_PASSWORD`, `AUTH_DB_PASSWORD`, `COUCHDB_*` | ❌ `.gitignore` | backend/worker/beat/couchdb |
|
||||
| **`/opt/gendesign/tradein-mvp/backend/.env.runtime`** (VPS, chmod 600) | tradein DB creds, Yandex/DaData ключи, прокси-URL, Cian-логин, reader password | ❌ `.gitignore` | tradein стек |
|
||||
| **`caddy/users.caddy.snippet`** (in git) | bcrypt-хеши basic_auth пилотных юзеров | ✅ да (хеши, не plaintext) | Caddy |
|
||||
| **Obsidian vault `meta/00_credentials.md`** | реестр **значений** всех секретов + audit-log ротаций | ❌ (вне репо) | Anton |
|
||||
|
|
@ -62,6 +62,7 @@
|
|||
| `POSTGRES_PASSWORD` | `.env` | Пароль роли `gendesign` (PostGIS 16) | **E** (DB password) |
|
||||
| `POSTGRES_USER` / `POSTGRES_DB` | `.env` | Имя роли / БД (не секрет, но в `.env`) | **E** |
|
||||
| `GENDESIGN_FDW_PASSWORD` | `backend/.env.runtime` | Пароль роли `tradein_fdw_reader` (FDW из main → tradein). Применяется через `ops/db-bootstrap/set_tradein_fdw_password.sql` | **E** |
|
||||
| `AUTH_DB_PASSWORD` | `backend/.env.runtime` | Пароль роли `auth_app` — БД `auth` на gendesign-postgres (единое хранилище доступов «Меры» и «Птицы»). Применяется через `ops/db-bootstrap/set_auth_app_password.sql` на деплое. Переменная задаётся на VPS вручную; пока не задана — шаг пропускается с warning'ом | **E** |
|
||||
| `COUCHDB_PASSWORD` / `COUCHDB_USER` | `backend/.env.runtime` | CouchDB (Obsidian LiveSync, `obsidian.gendsgn.ru`) | **E** |
|
||||
| `GLITCHTIP_DSN` | `backend/.env.runtime` | Backend GlitchTip DSN (перезаписывается deploy из `GLITCHTIP_BACKEND_DSN`) | **C** |
|
||||
| `GLITCHTIP_DB_PASS` | `.env` | Пароль БД GlitchTip-стека | **E** |
|
||||
|
|
|
|||
67
ops/db-bootstrap/create_auth_db.sql
Normal file
67
ops/db-bootstrap/create_auth_db.sql
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
-- Создание БД `auth` — единого хранилища доступов «Меры» и «Птицы» (идемпотентно).
|
||||
--
|
||||
-- Applied by .forgejo/workflows/deploy.yml ПЕРЕД миграциями data/sql/auth/*.sql:
|
||||
-- docker compose ... exec -T postgres psql -U "$POSTGRES_USER" -d postgres \
|
||||
-- -v ON_ERROR_STOP=on < ops/db-bootstrap/create_auth_db.sql
|
||||
-- Подключение обязательно к БД `postgres`: нельзя создать базу, находясь в ней самой.
|
||||
--
|
||||
-- ПОЧЕМУ ЭТО НЕ МИГРАЦИЯ:
|
||||
-- CREATE DATABASE запрещён внутри транзакционного блока, а .claude/rules/sql.md требует
|
||||
-- от каждого файла в data/sql обёртки BEGIN/COMMIT. Плюс миграции `auth` по определению
|
||||
-- выполняются уже ВНУТРИ БД `auth` — то есть создать её собой они не могут. Отсюда
|
||||
-- отдельный bootstrap-шаг, по образцу scripts/bootstrap_glitchtip.sh (там так же
|
||||
-- заводится вторая БД на этом же сервере).
|
||||
--
|
||||
-- ПОЧЕМУ \gexec, А НЕ DO-БЛОК:
|
||||
-- DO-блок — это функция, она выполняется внутри транзакции, значит CREATE DATABASE в ней
|
||||
-- недопустим. \gexec строит текст команды на стороне клиента и отправляет её отдельным
|
||||
-- стейтментом. Если WHERE NOT EXISTS отфильтровал строку, \gexec не получает ничего и
|
||||
-- молча ничего не делает — это и даёт идемпотентность без ошибки на повторном прогоне.
|
||||
-- ON_ERROR_STOP=on распространяется и на команды, выполненные через \gexec.
|
||||
--
|
||||
-- ВЛАДЕЛЕЦ БД — $POSTGRES_USER (суперюзер кластера), НЕ auth_app. Владелец объекта имеет на
|
||||
-- него все права в обход GRANT'ов; если бы БД и таблицы принадлежали прикладной роли,
|
||||
-- точечные гранты в data/sql/auth/002_auth_app_role.sql были бы декорацией. Роль auth_app
|
||||
-- создаётся миграцией 002 и получает только нужные DML-права.
|
||||
--
|
||||
-- TEMPLATE template0 — сознательно, а не template1 (шаблон по умолчанию): template0
|
||||
-- гарантированно пуст и неизменяем, а в template1 любой может доустановить расширения или
|
||||
-- объекты, и они молча окажутся в хранилище паролей. На образе postgis:16-3.4 сегодня
|
||||
-- postgis лежит в template_postgis, а template1 чист (проверено локально на том же образе),
|
||||
-- но полагаться на это как на инвариант незачем — template0 снимает вопрос навсегда.
|
||||
-- ENCODING 'UTF8' указан явно (кластер и так UTF8 — вся кириллица gendesign лежит в нём),
|
||||
-- чтобы кодировка хранилища логинов не зависела от того, с какими аргументами когда-нибудь
|
||||
-- пересоздадут кластер.
|
||||
--
|
||||
-- Пароля в этом файле нет и быть не может: роль создаётся passwordless в миграции 002,
|
||||
-- пароль ставится отдельным шагом из env (ops/db-bootstrap/set_auth_app_password.sql).
|
||||
|
||||
SELECT 'CREATE DATABASE auth TEMPLATE template0 ENCODING ''UTF8'';'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'auth')
|
||||
\gexec
|
||||
|
||||
-- Единственная преграда для «любая login-роль кластера (glitchtip, tradein_fdw_reader,
|
||||
-- gendesign_reader) открывает сессию в хранилище паролей»: по умолчанию PostgreSQL выдаёт
|
||||
-- CONNECT роли PUBLIC при создании БД.
|
||||
--
|
||||
-- ДУБЛЬ С data/sql/auth/002_auth_app_role.sql — НАМЕРЕННЫЙ, не копипаста. Инвариант держится
|
||||
-- в двух местах, потому что у файлов разный жизненный цикл:
|
||||
-- * здесь (bootstrap) — ради ПЕРЕПРИМЕНЯЕМОСТИ: этот файл гоняется на КАЖДОМ деплое, там же,
|
||||
-- где создаётся БД. Если `auth` восстановят из дампа или пересоздадут в обход миграций,
|
||||
-- база появится с дефолтным PUBLIC-CONNECT, а 002 уже числится применённой в
|
||||
-- _schema_migrations и второй раз не выполнится — REVOKE молча не вернётся.
|
||||
-- * в 002 — ради САМОДОСТАТОЧНОСТИ миграции: применённая на пустую БД (scratch/staging,
|
||||
-- ручной psql -f) она обязана давать полный периметр прав без чтения bootstrap-файлов.
|
||||
-- Удалять любую из двух копий нельзя: каждая закрывает сценарий, который другая не покрывает.
|
||||
--
|
||||
-- Выполнимо из подключения к БД `postgres` (мы именно в ней): права на объект DATABASE живут
|
||||
-- в pg_database.datacl — это общий на кластер каталог, не локальный для БД, в отличие от
|
||||
-- грантов на таблицы/схемы. Проверено эмпирически на postgis:16-3.4 (REVOKE из сессии в
|
||||
-- `postgres` по другой БД убирает `=Tc/` из datacl, has_database_privilege('public', …,
|
||||
-- 'CONNECT') → false). Команда идемпотентна — повторный прогон бесплатен.
|
||||
REVOKE ALL ON DATABASE auth FROM PUBLIC;
|
||||
|
||||
COMMENT ON DATABASE auth IS
|
||||
'Единое хранилище доступов: «Мера» (trade-in) и «Птица» (Site Finder). Схема — '
|
||||
'data/sql/auth/*.sql, применяется отдельным циклом миграций в .forgejo/workflows/deploy.yml '
|
||||
'(таблица _schema_migrations живёт внутри этой же БД).';
|
||||
50
ops/db-bootstrap/set_auth_app_password.sql
Normal file
50
ops/db-bootstrap/set_auth_app_password.sql
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
-- Set auth_app password from env.
|
||||
-- Applied by .forgejo/workflows/deploy.yml after auth DB migrations:
|
||||
-- psql -v pw="$AUTH_DB_PASSWORD" < ops/db-bootstrap/set_auth_app_password.sql
|
||||
-- Источник переменной: AUTH_DB_PASSWORD из /opt/gendesign/backend/.env.runtime (chmod 600,
|
||||
-- вне git). Зеркало паттерна ops/db-bootstrap/set_tradein_fdw_password.sql и
|
||||
-- tradein-mvp/ops/db-bootstrap/set_gendesign_reader_password.sql.
|
||||
--
|
||||
-- Idempotent: ALTER если роль существует, NOTICE и продолжает если нет (миграция
|
||||
-- data/sql/auth/002_auth_app_role.sql могла ещё не примениться на первом деплое).
|
||||
-- Пароль НИКОГДА не хранится в этом файле или в git — только имя переменной.
|
||||
--
|
||||
-- Format %L экранирует пароль как SQL string literal — безопасно даже с кавычками.
|
||||
--
|
||||
-- psql variable substitution (:'pw') НЕ интерполируется внутри dollar-quoted блока ($$...$$)
|
||||
-- — это правило psql, не bug. Поэтому password передаём в DO через сессионный GUC
|
||||
-- (set_config), который psql интерполирует ВНЕ dollar quote, и читаем внутри через
|
||||
-- current_setting(). По той же причине файл подаётся через stdin, а НЕ через `psql -c`.
|
||||
-- Reference incident: deploy 2026-05-24 (post-merge PR #503) упал на
|
||||
-- "syntax error at or near ':'" именно на этом.
|
||||
--
|
||||
-- ⚠️ `set_config(name, value, is_local) -> text` ВОЗВРАЩАЕТ установленное значение. Без
|
||||
-- `\o /dev/null` psql напечатал бы пароль на stdout → leak в Forgejo Actions deploy logs
|
||||
-- (retained, visible всем с repo read access). Поэтому оба set_config обёрнуты в
|
||||
-- `\o /dev/null` / `\o` — глушится только их вывод, NOTICE из DO block (сигнал
|
||||
-- идемпотентности) остаётся видимым.
|
||||
--
|
||||
-- Rollback path: НЕ revert этого файла (вернёт сломанный :'pw' внутри $$). Корректный
|
||||
-- rollback — unset AUTH_DB_PASSWORD в /opt/gendesign/backend/.env.runtime на VPS, deploy.yml
|
||||
-- тогда пропустит этот шаг полностью (роль останется без пароля = логин по паролю невозможен).
|
||||
|
||||
\o /dev/null
|
||||
SELECT set_config('app.auth_pw', :'pw', false);
|
||||
\o
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'auth_app') THEN
|
||||
EXECUTE format('ALTER ROLE auth_app WITH PASSWORD %L', current_setting('app.auth_pw'));
|
||||
RAISE NOTICE 'auth_app password set';
|
||||
ELSE
|
||||
RAISE NOTICE 'auth_app role missing — migration data/sql/auth/002_auth_app_role.sql not applied yet';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Clear GUC after use (defense-in-depth — не оставляем password в session state даже на
|
||||
-- short connection). Same \o trick — set_config return value is empty string here, но лишний
|
||||
-- row в stdout всё равно не нужен.
|
||||
\o /dev/null
|
||||
SELECT set_config('app.auth_pw', '', false);
|
||||
\o
|
||||
|
|
@ -1,26 +1,27 @@
|
|||
#!/usr/bin/env bash
|
||||
# Регресс-тест публичного B2C-периметра МЕРА (ЭТАП 1 плана B2C-запуска).
|
||||
#
|
||||
# Проверяет 4 инварианта периметра (см. корневой Caddyfile):
|
||||
# 1. mera.gendsgn.ru отдаёт 200 анонимно (публичная заглушка).
|
||||
# 2. mera.gendsgn.ru/v2 (B2B-путь) отдаёт 404 — allowlist-by-default,
|
||||
# Проверяет инварианты периметра (см. корневой Caddyfile):
|
||||
# 1. meraocenka.ru отдаёт 200 анонимно (публичная заглушка).
|
||||
# 2. meraocenka.ru/v2 (B2B-путь) отдаёт 404 — allowlist-by-default,
|
||||
# НЕ был случайно проброшен на B2B-дерево tradein-frontend.
|
||||
# 3. gendsgn.ru/trade-in/v2 по-прежнему отдаёт 401 анониму (basic_auth gate
|
||||
# не тронут этим этапом).
|
||||
# 4. gendsgn.ru/api/v1/admin/* отдаёт 401 анониму (тот же gate).
|
||||
# 3. trade-in API (/me, /history, /admin/*) отдаёт 401 анониму — данные B2B
|
||||
# закрыты. Именно API, а не страница: см. комментарий у проверки ниже.
|
||||
# 4. gendsgn.ru/api/v1/admin/* отдаёт 401 анониму (gate Site Finder).
|
||||
# 5. merahome.ru и meraotsenka.ru отдают 301 на канонический meraocenka.ru.
|
||||
#
|
||||
# ВАЖНО: проверки 1 и 2 требуют, чтобы DNS A-record mera.gendsgn.ru → IP VPS
|
||||
# ВАЖНО: проверки 1 и 2 требуют, чтобы DNS A-record meraocenka.ru → IP VPS
|
||||
# уже существовал И деплой прошёл (сертификат Let's Encrypt выпущен). Пока
|
||||
# записи нет — они ожидаемо падают (DNS resolution failure / TLS handshake
|
||||
# failure), это НЕ регресс периметра gendsgn.ru. Проверки 3 и 4 не зависят от
|
||||
# DNS нового поддомена и обязаны быть зелёными всегда.
|
||||
# DNS нового домена и обязаны быть зелёными всегда.
|
||||
#
|
||||
# Запуск вручную:
|
||||
# bash scripts/smoke-mera-perimeter.sh
|
||||
# Запуск в CI: .forgejo/workflows/perimeter-smoke.yml (workflow_dispatch + daily cron).
|
||||
set -uo pipefail
|
||||
|
||||
BASE_MERA="${SMOKE_MERA_BASE:-https://mera.gendsgn.ru}"
|
||||
BASE_MERA="${SMOKE_MERA_BASE:-https://meraocenka.ru}"
|
||||
BASE_MAIN="${SMOKE_MAIN_BASE:-https://gendsgn.ru}"
|
||||
|
||||
fail=0
|
||||
|
|
@ -39,20 +40,34 @@ check() {
|
|||
|
||||
echo "== МЕРА B2C perimeter smoke (ЭТАП 1) =="
|
||||
|
||||
# 1. Публичный поддомен отдаёт 200 анонимно.
|
||||
check "mera.gendsgn.ru root — public 200" "$BASE_MERA/" 200
|
||||
# 1. Публичный домен отдаёт 200 анонимно.
|
||||
check "meraocenka.ru root — public 200" "$BASE_MERA/" 200
|
||||
|
||||
# 2. B2B-путь на публичном поддомене — 404 (allowlist-by-default), не 200/401.
|
||||
check "mera.gendsgn.ru/v2 — B2B path must 404" "$BASE_MERA/v2" 404
|
||||
# 2. B2B-путь на публичном домене — 404 (allowlist-by-default), не 200/401.
|
||||
check "meraocenka.ru/v2 — B2B path must 404" "$BASE_MERA/v2" 404
|
||||
|
||||
# 3. gendsgn.ru/trade-in/v2 по-прежнему закрыт basic_auth анониму.
|
||||
check "gendsgn.ru/trade-in/v2 — still 401 anonymous" "$BASE_MAIN/trade-in/v2" 401
|
||||
# 3. B2B-данные trade-in по-прежнему закрыты анониму.
|
||||
#
|
||||
# ВНИМАНИЕ: проверять СТРАНИЦУ (/trade-in/v2) больше нельзя — она отдаёт 200.
|
||||
# После #2555/#2558 trade-in ушёл с Caddy basic_auth на собственный логин:
|
||||
# страница рендерится анониму, а RouteGuard уже на клиенте уводит на /login.
|
||||
# Гейт данных переехал на API — там и проверяем, иначе тест зелёный при
|
||||
# открытом наружу бэкенде.
|
||||
check "trade-in /api/v1/me — 401 anonymous" "$BASE_MAIN/trade-in/api/v1/me" 401
|
||||
check "trade-in /api/v1/history — 401 anonymous (чужие оценки)" "$BASE_MAIN/trade-in/api/v1/history" 401
|
||||
check "trade-in /api/v1/admin/* — 401 anonymous" "$BASE_MAIN/trade-in/api/v1/admin/users" 401
|
||||
|
||||
# 4. gendsgn.ru/api/v1/admin/* отдаёт 401 анониму (auth gate стоит ДО роутинга
|
||||
# в FastAPI — конкретный путь неважен, любой /api/v1/admin/* перехватывается
|
||||
# на уровне Caddy до бэкенда).
|
||||
check "gendsgn.ru/api/v1/admin/* — 401 anonymous" "$BASE_MAIN/api/v1/admin/users" 401
|
||||
|
||||
# 5. Домены-спутники ведут на канонический (301, без следования редиректу —
|
||||
# curl без -L, поэтому ждём именно код редиректа, а не 200 конечной страницы).
|
||||
# Как и проверки 1-2, требуют DNS + выпущенного сертификата.
|
||||
check "merahome.ru — 301 to canonical" "https://merahome.ru/" 301
|
||||
check "meraotsenka.ru — 301 to canonical" "https://meraotsenka.ru/" 301
|
||||
|
||||
echo "========================================"
|
||||
if [ "$fail" -eq 0 ]; then
|
||||
echo "ALL CHECKS PASSED"
|
||||
|
|
|
|||
|
|
@ -246,7 +246,8 @@ def _clean_address_for_geocode(addr: str) -> str:
|
|||
"""Чистим address для геокодера.
|
||||
|
||||
Cian отдаёт «улица Латвийская, 56/3 · р-н Чкаловский» — суффикс ' · ...'
|
||||
мешает Nominatim. Берём часть до ' · '. N1 отдаёт «Репина, 75/2 стр.» — ок.
|
||||
мешает Nominatim. Берём часть до ' · '. Остальные источники такого суффикса
|
||||
не используют — адрес остаётся без изменений.
|
||||
"""
|
||||
main = addr.split(" · ")[0].strip()
|
||||
return main or addr
|
||||
|
|
@ -260,7 +261,7 @@ async def geocode_missing(
|
|||
) -> dict:
|
||||
"""Геокодинг listings ИЛИ deals у которых нет lat/lon (используя address).
|
||||
|
||||
target=listings (по умолч.) — объявления Cian/N1; target=deals — сделки Росреестра.
|
||||
target=listings (по умолч.) — объявления; target=deals — сделки Росреестра.
|
||||
Чанк-обработка с бюджетом по времени (~240с, заведомо меньше cron
|
||||
`curl -m 320`): за вызов геокодим сколько успеваем, остаток уходит в
|
||||
`remaining`, cron вызывает в цикле пока `remaining` > 0.
|
||||
|
|
@ -269,12 +270,8 @@ async def geocode_missing(
|
|||
адреса не выбираются повторно 7 дней → cron-loop завершается, не зацикливается.
|
||||
geom обновляется автоматически триггером.
|
||||
"""
|
||||
# Доп. фильтр для listings — у Avito/N1 встречаются плейсхолдер-адреса.
|
||||
extra_filter = (
|
||||
"AND address NOT LIKE '%(Avito)%' AND address NOT LIKE '%(N1)%'"
|
||||
if target == "listings"
|
||||
else ""
|
||||
)
|
||||
# Доп. фильтр для listings — у Avito встречаются плейсхолдер-адреса.
|
||||
extra_filter = "AND address NOT LIKE '%(Avito)%'" if target == "listings" else ""
|
||||
rows = (
|
||||
db.execute(
|
||||
text(
|
||||
|
|
|
|||
156
tradein-mvp/backend/app/api/v1/auth.py
Normal file
156
tradein-mvp/backend/app/api/v1/auth.py
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
"""POST /api/v1/auth/login + /logout — DB-backed session auth (#2552, эпик #2549).
|
||||
|
||||
Переходный механизм, параллельный legacy Caddy trusted-header auth (roles.yaml).
|
||||
См. `app.core.rbac.rbac_guard` (dual-mode resolver) и `app.services.auth_session`
|
||||
(session CRUD). Mounted at `/api/v1/auth`; через Caddy `uri strip_prefix /trade-in`
|
||||
это `/trade-in/api/v1/auth/*` снаружи.
|
||||
|
||||
Security:
|
||||
- Неверные creds (неизвестный username / неактивен / password_hash NULL /
|
||||
неверный пароль) → ОДИНАКОВЫЙ 401 с generic сообщением — не раскрываем,
|
||||
существует ли username (user-enumeration защита).
|
||||
- #2552 post-review Medium 2: `verify_password` ВСЕГДА вызывается ровно
|
||||
один раз — для несуществующего username / NULL password_hash сверяем
|
||||
против статичного dummy-хеша (`_DUMMY_PASSWORD_HASH`, сгенерирован один
|
||||
раз на импорте модуля), результат игнорируется. Без этого короткое
|
||||
замыкание (`user is None → сразу 401`) давало наблюдаемую разницу во
|
||||
времени ответа (~1мс без bcrypt vs ~100-300мс с ним) — классический
|
||||
timing-oracle для user-enumeration, даже при одинаковом detail-сообщении.
|
||||
- Rate-limit по (username, IP) — ЖЁСТЧЕ общего `RateLimitMiddleware`
|
||||
(`/api/*`), т.к. login — типичная brute-force поверхность. Использует
|
||||
`SlidingWindowLimiter` (тот же примитив, что и общий rate-limit). Ключ
|
||||
length-prefixed (`len(username):username:ip`) — без этого произвольный
|
||||
username с `:` внутри мог бы схлопнуть бюджет с другой (username, ip)
|
||||
парой (IPv6-адреса тоже содержат `:`, так что просто эскейпить разделитель
|
||||
в username недостаточно — паразитная граница возможна с обеих сторон).
|
||||
- Raw-пароль НИКОГДА не логируется и не попадает в user_events payload —
|
||||
только username/ip/user_agent/path/method (см. schedule_event ниже).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.db import get_db
|
||||
from app.core.password import hash_password, verify_password
|
||||
from app.core.ratelimit import SlidingWindowLimiter, _client_ip
|
||||
from app.services.auth_session import create_session, get_user_by_username, revoke_session
|
||||
from app.services.user_events import schedule_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Отдельный, более узкий бюджет чем общий per-user/per-IP `/api/*` лимит
|
||||
# (см. app.core.ratelimit.SlidingWindowLimiter docstring — designed именно для
|
||||
# такого случая). Ключ = username+IP: не даёт распределённому brute-force по
|
||||
# ОДНОМУ аккаунту с разных IP уйти от лимита целиком (per-IP было бы недостаточно),
|
||||
# и не блокирует ВЕСЬ IP из-за перебора чужих логинов одним же клиентом.
|
||||
_LOGIN_LIMITER = SlidingWindowLimiter(
|
||||
limit=settings.login_rate_limit,
|
||||
window_s=settings.login_rate_limit_window_s,
|
||||
)
|
||||
|
||||
# Timing-oracle защита (см. module docstring): bcrypt-хеш случайного пароля,
|
||||
# сгенерированный ОДИН РАЗ на импорте модуля — используется вместо
|
||||
# password_hash, когда юзер не найден/деактивирован/без пароля, чтобы
|
||||
# `verify_password` (доминирующая по времени операция, ~100-300мс) всегда
|
||||
# отрабатывала полный bcrypt-компар, независимо от того, существует ли аккаунт.
|
||||
_DUMMY_PASSWORD_HASH = hash_password(secrets.token_urlsafe(16))
|
||||
|
||||
_INVALID_CREDENTIALS_DETAIL = "неверный логин или пароль"
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
ok: bool = True
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(
|
||||
body: LoginRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> LoginResponse:
|
||||
ip = _client_ip(request)
|
||||
user_agent = request.headers.get("user-agent")
|
||||
rate_key = f"{len(body.username)}:{body.username}:{ip}"
|
||||
|
||||
retry_after = _LOGIN_LIMITER.check(rate_key)
|
||||
if retry_after is not None:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="слишком много попыток входа, попробуйте позже",
|
||||
headers={"Retry-After": str(int(retry_after) + 1)},
|
||||
)
|
||||
|
||||
user = get_user_by_username(db, body.username)
|
||||
hash_to_check = (
|
||||
user["password_hash"]
|
||||
if user is not None and user["password_hash"] is not None
|
||||
else _DUMMY_PASSWORD_HASH
|
||||
)
|
||||
# ВСЕГДА вызывается — dummy-хеш при отсутствующем юзере/NULL password_hash
|
||||
# держит время ответа одинаковым независимо от существования аккаунта.
|
||||
password_ok = verify_password(body.password, hash_to_check)
|
||||
credentials_ok = user is not None and user["is_active"] and password_ok
|
||||
|
||||
if not credentials_ok:
|
||||
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)
|
||||
|
||||
assert user is not None # narrowed by credentials_ok above
|
||||
token = create_session(db, user_id=user["user_id"], ip=ip, user_agent=user_agent)
|
||||
|
||||
response.set_cookie(
|
||||
key=settings.session_cookie_name,
|
||||
value=token,
|
||||
max_age=settings.session_ttl_hours * 3600,
|
||||
httponly=True,
|
||||
secure=True,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
)
|
||||
|
||||
schedule_event(
|
||||
event_type="login_success",
|
||||
username=user["username"],
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
path="/api/v1/auth/login",
|
||||
method="POST",
|
||||
)
|
||||
|
||||
return LoginResponse(ok=True)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(
|
||||
request: Request,
|
||||
response: Response,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> dict[str, bool]:
|
||||
token = request.cookies.get(settings.session_cookie_name)
|
||||
if token:
|
||||
revoke_session(db, token)
|
||||
response.delete_cookie(key=settings.session_cookie_name, path="/")
|
||||
return {"ok": True}
|
||||
|
|
@ -21,14 +21,27 @@ router = APIRouter()
|
|||
async def lookup(
|
||||
address: Annotated[str, Query(min_length=3, max_length=500)],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
city_hint: Annotated[
|
||||
str | None,
|
||||
Query(
|
||||
max_length=100,
|
||||
description=(
|
||||
"Город, если известен вызывающему (например выбран пользователем "
|
||||
"на предыдущем шаге UI). #2576: без него геокодер БОЛЬШЕ НЕ "
|
||||
"подставляет 'Екатеринбург' молча — ответ может помечаться "
|
||||
"city_ambiguous=true."
|
||||
),
|
||||
),
|
||||
] = None,
|
||||
) -> GeocodeResult:
|
||||
"""Геокодинг адреса → lat/lon.
|
||||
|
||||
Примеры:
|
||||
/api/v1/geocode/lookup?address=ул.+Малышева+30+Екатеринбург
|
||||
/api/v1/geocode/lookup?address=Куйбышева+50+Екатеринбург
|
||||
/api/v1/geocode/lookup?address=Ленина+1&city_hint=Нижний+Тагил
|
||||
"""
|
||||
result = await geocode(address, db)
|
||||
result = await geocode(address, db, city_hint=city_hint)
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail=f"Address not found: {address}")
|
||||
return result
|
||||
|
|
@ -55,6 +68,16 @@ async def suggest_addresses(
|
|||
q: Annotated[str, Query(min_length=2, max_length=200, description="Запрос для автокомплита")],
|
||||
limit: Annotated[int, Query(ge=1, le=15)] = 8,
|
||||
db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment]
|
||||
city_hint: Annotated[
|
||||
str | None,
|
||||
Query(
|
||||
max_length=100,
|
||||
description=(
|
||||
"Город, если известен вызывающему (#2576) — без него подсказки "
|
||||
"БОЛЬШЕ НЕ ограничиваются молчаливо Екатеринбургом."
|
||||
),
|
||||
),
|
||||
] = None,
|
||||
) -> SuggestResponse:
|
||||
"""Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик,
|
||||
остаётся быстрым fast-path).
|
||||
|
|
@ -66,8 +89,9 @@ async def suggest_addresses(
|
|||
Пример:
|
||||
/api/v1/geocode/suggest?q=Малышева
|
||||
/api/v1/geocode/suggest?q=Цвиллинга # → пусто, такой улицы в ЕКБ нет
|
||||
/api/v1/geocode/suggest?q=Ленина+1&city_hint=Нижний+Тагил
|
||||
"""
|
||||
items = await suggest(q, db=db, limit=limit)
|
||||
items = await suggest(q, db=db, limit=limit, city_hint=city_hint)
|
||||
return SuggestResponse(
|
||||
items=[
|
||||
SuggestItem(
|
||||
|
|
@ -100,11 +124,11 @@ class ReverseResponse(BaseModel):
|
|||
precision: str = Field(
|
||||
...,
|
||||
description=(
|
||||
"Yandex-style: exact/number/street/range/near/locality/other/cadastral. "
|
||||
"exact/number/street/range/near/locality/other/cadastral. "
|
||||
"Фронт двигает marker только если exact/number/cadastral."
|
||||
),
|
||||
)
|
||||
provider: str = Field(..., description="cadastral | yandex | nominatim")
|
||||
provider: str = Field(..., description="cadastral | nominatim")
|
||||
|
||||
|
||||
@router.get("/reverse", response_model=ReverseResponse)
|
||||
|
|
|
|||
|
|
@ -7,16 +7,26 @@ Mounted at /api/v1/me; через Caddy `uri strip_prefix /trade-in` это ст
|
|||
Caddy basic_auth пропускает `X-Authenticated-User: <username>` через
|
||||
`header_up` в каждом reverse_proxy. Frontend дёргает /me чтобы понять
|
||||
кому что показывать.
|
||||
|
||||
#2552: session-first. Валидная DB-session cookie (см. app.services.auth_session)
|
||||
отдаёт scope из tradein_users (role/display_name/org/email) БЕЗ похода в
|
||||
roles.yaml. Без cookie (или невалидная/истёкшая) — legacy X-Authenticated-User
|
||||
путь, БЕЗ ИЗМЕНЕНИЙ (regression недопустим — существующие тесты держат его
|
||||
бит-в-бит).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.auth import UserScope, get_user_scope
|
||||
from app.core.config import settings
|
||||
from app.core.db import get_db
|
||||
from app.services.auth_session import get_db_role_scope, get_session_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -25,9 +35,40 @@ router = APIRouter()
|
|||
|
||||
@router.get("/me")
|
||||
async def me(
|
||||
request: Request,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
|
||||
) -> UserScope:
|
||||
"""Return the current user's RBAC scope (role + allowed/deny paths)."""
|
||||
) -> UserScope | dict[str, Any]:
|
||||
"""Return the current user's RBAC scope (role + allowed/deny paths).
|
||||
|
||||
Return type is a union (не только `UserScope`) — `UserScope.role` — это
|
||||
`Literal["admin","pilot","analyst","expired"]` (legacy roles.yaml names),
|
||||
а DB-роли (tradein_users.role) — `"admin"/"manager"/"employee"`. FastAPI
|
||||
строит response-схему из return-аннотации; жёсткий `UserScope` завернул бы
|
||||
"employee"/"manager" в ResponseValidationError. Итоговая JSON-форма
|
||||
ОДИНАКОВАЯ (те же 8 ключей) для обеих веток.
|
||||
"""
|
||||
token = request.cookies.get(settings.session_cookie_name)
|
||||
if token:
|
||||
try:
|
||||
session_user = get_session_user(db, token)
|
||||
except Exception:
|
||||
logger.exception("me: session lookup failed")
|
||||
session_user = None
|
||||
if session_user is not None:
|
||||
role = session_user["role"]
|
||||
allowed_paths, deny_paths = get_db_role_scope(role)
|
||||
return {
|
||||
"username": session_user["username"],
|
||||
"role": role,
|
||||
"allowed_paths": allowed_paths,
|
||||
"deny_paths": deny_paths,
|
||||
"brand": None,
|
||||
"display_name": session_user["display_name"],
|
||||
"org": session_user["org_name"],
|
||||
"email": session_user["email"],
|
||||
}
|
||||
|
||||
if not x_authenticated_user:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
|
|
|
|||
|
|
@ -29,20 +29,48 @@ support-моста (`app.services.tgbot.bridge`, data/sql/186_tg_support.sql).
|
|||
`username` — thread_id для отправки не нужен вообще, поэтому эту БД-операцию
|
||||
можно безопасно отложить до после успешного sendMessage. Бонус: неудачная
|
||||
отправка больше не создаёт тред.
|
||||
|
||||
Анонимная ветка (`/support/anon/*`, инцидент 2026-07-31)
|
||||
-------------------------------------------------------
|
||||
Ровно те же 4 действия, но БЕЗ авторизации — доступны с экрана входа. Причина:
|
||||
после cutover'а на свою авторизацию (#2558) единственным каналом в поддержку был
|
||||
чат ЗА логином, а самая частая причина писать в поддержку — как раз «не могу
|
||||
войти». 2026-07-31 «Практика» весь день билась в форму (5 login_failed, 0
|
||||
успешных) и достучаться из продукта не могла ничем.
|
||||
|
||||
Идентичность анонима — opaque-токен в httpOnly-куке (`_ANON_COOKIE_NAME`),
|
||||
тред живёт в тех же `web_support_threads` под ключом `anon:<token>`. Двоеточие
|
||||
делает коллизию с реальным логином структурно невозможной: `tradein_users`
|
||||
допускает только `^[A-Za-z0-9._-]{3,64}$` (CHECK из миграции 193 + Pydantic),
|
||||
двоеточия там быть не может — аноним НИКОГДА не попадёт в чужой тред и не
|
||||
«станет» существующим юзером.
|
||||
|
||||
Изоляция тредов та же, что у авторизованной ветки, и по той же причине:
|
||||
thread_id не принимается снаружи ни в каком виде, тред резолвится
|
||||
ИСКЛЮЧИТЕЛЬНО из куки. Кука здесь — bearer-токен своего треда, поэтому
|
||||
httpOnly+Secure+SameSite=Lax (как session-cookie) и `token_urlsafe(18)`
|
||||
(144 бита) вместо чего-то угадываемого.
|
||||
|
||||
В Telegram-топик уходит НЕ сам токен, а `anon-<6 hex от sha256(токен)>`
|
||||
(`_anon_display_id`): оператору нужен стабильный ярлык треда, а не bearer —
|
||||
зеркало топика читают люди и пересылают дальше.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.db import get_db
|
||||
from app.core.ratelimit import SlidingWindowLimiter
|
||||
from app.core.ratelimit import SlidingWindowLimiter, _client_ip
|
||||
from app.services.tgbot import web_support_storage as storage
|
||||
from app.services.tgbot.bridge import SERVICE_UNAVAILABLE_TEXT
|
||||
from app.services.tgbot.client import TelegramApiError, TelegramClient
|
||||
|
|
@ -262,3 +290,204 @@ def mark_support_read(
|
|||
storage.mark_read(db, thread_id=thread_id)
|
||||
db.commit()
|
||||
return StatusOut()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Анонимная ветка — поддержка без входа (см. блок в докстринге модуля)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ANON_COOKIE_NAME = "tradein_support_anon"
|
||||
# 30 дней: тред должен пережить «напишу вечером — отвечут утром», но не жить вечно.
|
||||
_ANON_COOKIE_MAX_AGE_S = 30 * 24 * 3600
|
||||
# Двоеточие → структурная невозможность коллизии с реальным логином (докстринг).
|
||||
_ANON_THREAD_PREFIX = "anon:"
|
||||
# Форма того, что МЫ выдаём (`token_urlsafe(18)` → 24 символа из [A-Za-z0-9_-]).
|
||||
# Кука клиент-контролируема: без этой проверки в ключ треда (а значит в SQL-параметр
|
||||
# и в лог) уехала бы произвольная строка из браузера. Не матчится — считаем куку
|
||||
# отсутствующей и выдаём новую, а не пытаемся «починить» присланное.
|
||||
_ANON_TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{16,64}\Z")
|
||||
|
||||
# Публичная ручка записи в общий Telegram-топик — поверхность для спама, которой у
|
||||
# авторизованной ветки нет. Два независимых бюджета:
|
||||
# 1) per-token (`_send_limiter`, 12/мин — тот же объект, ключи не пересекаются:
|
||||
# анонимные начинаются с "anon:", что невозможно для username);
|
||||
# 2) per-IP — именно он ловит обход ротацией куки (сбросил куку → новый токен →
|
||||
# бюджет (1) снова пуст). Окно широкое и щедрое для живого диалога: реальный
|
||||
# сценарий — «не могу войти, помогите», несколько сообщений подряд.
|
||||
_ANON_IP_RATE_LIMIT = 10
|
||||
_ANON_IP_RATE_WINDOW_S = 600.0
|
||||
_anon_ip_limiter = SlidingWindowLimiter(limit=_ANON_IP_RATE_LIMIT, window_s=_ANON_IP_RATE_WINDOW_S)
|
||||
|
||||
|
||||
def _anon_display_id(token: str) -> str:
|
||||
"""Стабильный НЕсекретный ярлык треда для оператора — см. докстринг модуля.
|
||||
|
||||
sha256, а не префикс токена: префикс — это часть bearer'а, а зеркало уходит
|
||||
в Telegram-топик, который читают люди и пересылают дальше.
|
||||
"""
|
||||
return f"anon-{hashlib.sha256(token.encode('utf-8')).hexdigest()[:6]}"
|
||||
|
||||
|
||||
def _read_anon_token(request: Request) -> str | None:
|
||||
"""Токен из куки, если он валидной формы; иначе None (кука считается отсутствующей)."""
|
||||
raw = request.cookies.get(_ANON_COOKIE_NAME)
|
||||
if raw is None or not _ANON_TOKEN_RE.match(raw):
|
||||
return None
|
||||
return raw
|
||||
|
||||
|
||||
def _anon_thread_key(token: str) -> str:
|
||||
return f"{_ANON_THREAD_PREFIX}{token}"
|
||||
|
||||
|
||||
def _set_anon_cookie(response: Response, token: str) -> None:
|
||||
response.set_cookie(
|
||||
key=_ANON_COOKIE_NAME,
|
||||
value=token,
|
||||
max_age=_ANON_COOKIE_MAX_AGE_S,
|
||||
httponly=True,
|
||||
secure=True,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/support/anon/messages", response_model=SupportMessageOut)
|
||||
async def send_anon_support_message(
|
||||
payload: SupportMessageInput,
|
||||
request: Request,
|
||||
response: Response,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> SupportMessageOut:
|
||||
"""Сообщение в поддержку БЕЗ входа. Порядок операций — как в авторизованной
|
||||
ветке (H1 в докстринге модуля): БД трогаем только после успешного sendMessage.
|
||||
|
||||
Кука выставляется тоже только на успехе — иначе первая же неудачная попытка
|
||||
(бот не настроен / Telegram лёг) закрепляла бы за посетителем пустой тред.
|
||||
"""
|
||||
if not _bot_configured():
|
||||
raise HTTPException(status_code=503, detail=SERVICE_UNAVAILABLE_TEXT)
|
||||
|
||||
token = _read_anon_token(request)
|
||||
is_new_token = token is None
|
||||
if token is None:
|
||||
token = secrets.token_urlsafe(18)
|
||||
thread_key = _anon_thread_key(token)
|
||||
ip = _client_ip(request)
|
||||
|
||||
# Оба бюджета — non-destructive peek (review L3): неудачная отправка не
|
||||
# должна стоить посетителю попытки. `.record()` только на успех, ниже.
|
||||
for retry_after in (_send_limiter.retry_after(thread_key), _anon_ip_limiter.retry_after(ip)):
|
||||
if retry_after is not None:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Слишком много сообщений. Попробуйте позже.",
|
||||
headers={"Retry-After": str(int(retry_after) + 1)},
|
||||
)
|
||||
|
||||
display_id = _anon_display_id(token)
|
||||
client = TelegramClient(settings.telegram_bot_token)
|
||||
try:
|
||||
mirrored = await client.send_message(
|
||||
chat_id=settings.telegram_support_chat_id,
|
||||
text=_format_anon_mirror_text(display_id, payload.text),
|
||||
message_thread_id=settings.telegram_support_topic_id or None,
|
||||
timeout=_INTERACTIVE_SEND_TIMEOUT_S,
|
||||
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
|
||||
)
|
||||
except TelegramApiError:
|
||||
# Ни текст сообщения (ПДн), ни токен (bearer треда) в лог не попадают.
|
||||
logger.exception(
|
||||
"web support (anon): не удалось отправить зеркало в топик (%s)", display_id
|
||||
)
|
||||
raise HTTPException(status_code=502, detail=SERVICE_UNAVAILABLE_TEXT) from None
|
||||
|
||||
_send_limiter.record(thread_key)
|
||||
_anon_ip_limiter.record(ip)
|
||||
|
||||
topic_message_id = mirrored.get("message_id") if isinstance(mirrored, dict) else None
|
||||
if topic_message_id is None:
|
||||
logger.warning(
|
||||
"web support (anon): Telegram sendMessage не вернул message_id (%s) — "
|
||||
"ответ оператора на это сообщение не будет смаршрутизирован",
|
||||
display_id,
|
||||
)
|
||||
|
||||
thread_id = storage.get_or_create_thread(db, thread_key)
|
||||
row = storage.record_inbound(
|
||||
db,
|
||||
thread_id=thread_id,
|
||||
text_body=payload.text,
|
||||
topic_message_id=topic_message_id,
|
||||
support_chat_id=settings.telegram_support_chat_id,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
if is_new_token:
|
||||
_set_anon_cookie(response, token)
|
||||
logger.info("web support (anon): message sent %s thread_id=%d", display_id, thread_id)
|
||||
return SupportMessageOut(**row)
|
||||
|
||||
|
||||
def _format_anon_mirror_text(display_id: str, message_text: str) -> str:
|
||||
"""Помечает зеркало как пришедшее с сайта ОТ НЕЗАЛОГИНЕННОГО посетителя.
|
||||
|
||||
Оператору это ключевой контекст: у такого обращения нет аккаунта, по которому
|
||||
можно посмотреть историю, и самая вероятная причина написать — как раз
|
||||
невозможность войти (инцидент 2026-07-31).
|
||||
"""
|
||||
return f"[С САЙТА · БЕЗ ВХОДА] {display_id}:\n{message_text}"
|
||||
|
||||
|
||||
@router.get("/support/anon/messages", response_model=list[SupportMessageOut])
|
||||
def list_anon_support_messages(
|
||||
request: Request,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
since: Annotated[int, Query(ge=0)] = 0,
|
||||
) -> list[SupportMessageOut]:
|
||||
"""Свой тред по куке. Нет куки / нет треда → пустой список, НЕ 401: виджет
|
||||
поллит эту ручку и до первого сообщения, 401 там был бы ложной ошибкой.
|
||||
|
||||
Sync `def` (review M3) — см. `list_support_messages`.
|
||||
"""
|
||||
token = _read_anon_token(request)
|
||||
if token is None:
|
||||
return []
|
||||
thread_id = storage.find_thread_id(db, _anon_thread_key(token))
|
||||
if thread_id is None:
|
||||
return []
|
||||
rows = storage.list_messages(
|
||||
db, thread_id=thread_id, since_id=since, limit=_LIST_MESSAGES_LIMIT
|
||||
)
|
||||
return [SupportMessageOut(**r) for r in rows]
|
||||
|
||||
|
||||
@router.get("/support/anon/unread", response_model=UnreadOut)
|
||||
def get_anon_support_unread(
|
||||
request: Request,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> UnreadOut:
|
||||
"""Sync `def` (review M3) — см. `list_support_messages`."""
|
||||
token = _read_anon_token(request)
|
||||
if token is None:
|
||||
return UnreadOut(unread=0)
|
||||
thread_id = storage.find_thread_id(db, _anon_thread_key(token))
|
||||
if thread_id is None:
|
||||
return UnreadOut(unread=0)
|
||||
return UnreadOut(unread=storage.count_unread(db, thread_id=thread_id))
|
||||
|
||||
|
||||
@router.post("/support/anon/read", response_model=StatusOut)
|
||||
def mark_anon_support_read(
|
||||
request: Request,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> StatusOut:
|
||||
"""Sync `def` (review M3) — см. `list_support_messages`."""
|
||||
token = _read_anon_token(request)
|
||||
if token is None:
|
||||
return StatusOut()
|
||||
thread_id = storage.find_thread_id(db, _anon_thread_key(token))
|
||||
if thread_id is not None:
|
||||
storage.mark_read(db, thread_id=thread_id)
|
||||
db.commit()
|
||||
return StatusOut()
|
||||
|
|
|
|||
713
tradein-mvp/backend/app/api/v1/team.py
Normal file
713
tradein-mvp/backend/app/api/v1/team.py
Normal file
|
|
@ -0,0 +1,713 @@
|
|||
"""Team-management API — CRUD сотрудников, квоты, история (#2554, эпик #2549).
|
||||
|
||||
Mounted at `/api/v1/team`; через Caddy `uri strip_prefix /trade-in` это
|
||||
`/trade-in/api/v1/team/*` снаружи. `app.services.auth_session.DB_ROLE_PATHS`
|
||||
уже закладывает `/api/v1/team/**` в scope роли `manager` (и `admin` через `/**`)
|
||||
для `rbac_guard` (см. `app.core.rbac`) — этот роутер добавляет ВТОРОЙ,
|
||||
более узкий барьер именно на identity:
|
||||
|
||||
- `current_team_actor` резолвит юзера ТОЛЬКО из session-cookie
|
||||
(`app.services.auth_session.get_session_user`). Legacy
|
||||
`X-Authenticated-User` (Caddy trusted-header, dual-mode) НЕ принимается
|
||||
здесь — team-API новый, не участвует в переходном dual-mode auth. Без
|
||||
валидной cookie — 401, даже если `rbac_guard` пропустил запрос по
|
||||
legacy-заголовку (напр. admin через roles.yaml).
|
||||
- Роль должна быть `admin` или `manager` — иначе 403.
|
||||
|
||||
Org-изоляция (главный инвариант фичи): manager видит/меняет ТОЛЬКО своих
|
||||
employee (`tradein_users.manager_id = actor.user_id`). Чужой/несуществующий
|
||||
employee_id → 404 (НЕ 403) — не подтверждаем/не опровергаем существование
|
||||
чужого сотрудника перед manager'ом. См. `_authorize_employee`.
|
||||
|
||||
Кого именно можно менять через этот роутер (`_MANAGEABLE_ROLES_BY_ACTOR`):
|
||||
- actor manager → только `role='employee'` И только своих (как было).
|
||||
- actor admin → `role IN ('employee','manager')`.
|
||||
|
||||
Почему admin'у отдали и менеджеров (инцидент 2026-07-31): после cutover'а на
|
||||
DB-auth (#2558) аккаунты `kopylov`/`praktika` сидят с `role='manager'`, а этот
|
||||
роутер жёстко фильтровал `role='employee'` — сбросить менеджеру пароль или
|
||||
заблокировать его было НЕЧЕМ, кроме ручного psql на проде. Роль manager вводилась
|
||||
как «владелец своей организации», а не как «неприкасаемый аккаунт».
|
||||
|
||||
`role='admin'` НЕ входит ни в один набор, и это несущий инвариант, а не
|
||||
экономия: он один держит невозможность self-lockout'а. Актёр этого роутера —
|
||||
всегда admin или manager (`current_team_actor`); manager до admin-строки не
|
||||
дотянется по своей ветке фильтра, а admin не дотянется до admin-строки вообще —
|
||||
в том числе до собственной. Поэтому ни один путь ниже (block, смена пароля +
|
||||
`revoke_user_sessions`) не может вырубить самого действующего админа или
|
||||
разжаловать другого. Раздача/отзыв роли admin остаётся операцией уровня
|
||||
миграции/psql — сознательно вне API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated, Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.engine import RowMapping
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.auth import get_role
|
||||
from app.core.config import settings
|
||||
from app.core.db import get_db
|
||||
from app.core.password import hash_password
|
||||
from app.schemas.team import (
|
||||
EmployeeCreateRequest,
|
||||
EmployeeHistoryEntry,
|
||||
EmployeeOut,
|
||||
EmployeeUpdateRequest,
|
||||
QuotaStatusOut,
|
||||
)
|
||||
from app.services import account_quota
|
||||
from app.services.auth_session import get_session_user, revoke_user_sessions
|
||||
from app.services.user_events import schedule_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@dataclass
|
||||
class TeamActor:
|
||||
"""Резолвленный из session-cookie актёр team-API — admin или manager."""
|
||||
|
||||
user_id: int
|
||||
username: str
|
||||
role: str # "admin" | "manager"
|
||||
|
||||
|
||||
async def current_team_actor(
|
||||
request: Request,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> TeamActor:
|
||||
"""Dependency: session-only identity, роль admin|manager, иначе 401/403.
|
||||
|
||||
Намеренно НЕ читает `X-Authenticated-User` — см. модульный docstring.
|
||||
"""
|
||||
token = request.cookies.get(settings.session_cookie_name)
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="valid session required")
|
||||
|
||||
try:
|
||||
session_user = get_session_user(db, token)
|
||||
except Exception:
|
||||
logger.exception("team: session lookup failed")
|
||||
raise HTTPException(status_code=401, detail="valid session required") from None
|
||||
|
||||
if session_user is None:
|
||||
raise HTTPException(status_code=401, detail="valid session required")
|
||||
|
||||
role = session_user["role"]
|
||||
if role not in ("admin", "manager"):
|
||||
raise HTTPException(status_code=403, detail="admin or manager role required")
|
||||
|
||||
return TeamActor(
|
||||
user_id=session_user["user_id"],
|
||||
username=session_user["username"],
|
||||
role=role,
|
||||
)
|
||||
|
||||
|
||||
def _origin_host_allowed(candidate: str) -> bool:
|
||||
"""True если scheme://netloc *candidate* совпадает с одним из `settings.cors_origins`.
|
||||
|
||||
`cors_origins` уже является источником правды для «какие origin'ы это наш
|
||||
фронт» (см. CORSMiddleware в app/main.py, ENV CORS_ORIGINS) — переиспользуем
|
||||
его вместо нового хардкода."""
|
||||
try:
|
||||
parsed = urlparse(candidate)
|
||||
except ValueError:
|
||||
return False
|
||||
if not parsed.scheme or not parsed.netloc:
|
||||
return False
|
||||
origin = f"{parsed.scheme}://{parsed.netloc}"
|
||||
return origin in settings.cors_origins
|
||||
|
||||
|
||||
def _require_same_origin(request: Request) -> None:
|
||||
"""CSRF defense-in-depth (issue #2554 DoD) для state-changing team-роутов
|
||||
(POST/PATCH): `Origin` (или `Referer` как fallback) обязан матчить один из
|
||||
`settings.cors_origins`, иначе 403.
|
||||
|
||||
Оба заголовка отсутствуют → ПРОПУСКАЕМ (не 403). Причина: это единственный
|
||||
надёжный сигнал non-browser клиента в этом стеке — curl-смоуки внутри
|
||||
контейнера (см. `.claude/rules/tradein.md` "Тестировать HTTP только ВНУТРИ
|
||||
контейнера", `docker exec tradein-backend curl ...`) не шлют ни один из этих
|
||||
заголовков, а реальный браузер (fetch/XHR/form) ВСЕГДА прикладывает Origin
|
||||
на unsafe-методах (POST/PATCH) — так что "оба отсутствуют" практически
|
||||
невозможно для настоящего кросс-сайтового CSRF через браузер. Session-cookie
|
||||
уже стоит на `SameSite=Lax` (см. `app.api.v1.auth.login`) — это первый рубеж
|
||||
против CSRF, Origin-check — второй.
|
||||
"""
|
||||
candidate = request.headers.get("origin") or request.headers.get("referer")
|
||||
if candidate is None:
|
||||
return
|
||||
if not _origin_host_allowed(candidate):
|
||||
logger.warning(
|
||||
"team: Origin/Referer mismatch %r on %s — possible CSRF", candidate, request.url.path
|
||||
)
|
||||
raise HTTPException(status_code=403, detail="origin not allowed")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Два статических варианта — НЕ динамическая сборка WHERE (та же мотивация, что
|
||||
# у `_LIST_EMPLOYEES_*_SQL` ниже: значения и так биндятся параметрами, но
|
||||
# статические ветки не провоцируют будущие правки в сторону конкатенации SQL).
|
||||
# Роль 'admin' не встречается ни в одной ветке — см. модульный docstring.
|
||||
_FETCH_MANAGED_EMPLOYEE_SQL = text(
|
||||
"""
|
||||
SELECT id, username, role, display_name, org_name, email, is_active,
|
||||
manager_id, created_at
|
||||
FROM tradein_users
|
||||
WHERE id = :id AND role = 'employee'
|
||||
"""
|
||||
)
|
||||
|
||||
_FETCH_MANAGED_ANY_SQL = text(
|
||||
"""
|
||||
SELECT id, username, role, display_name, org_name, email, is_active,
|
||||
manager_id, created_at
|
||||
FROM tradein_users
|
||||
WHERE id = :id AND role IN ('employee', 'manager')
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _fetch_employee_row(db: Session, employee_id: int, actor: TeamActor) -> RowMapping | None:
|
||||
"""Строка управляемого юзера в пределах прав *actor* — иначе None (→ 404).
|
||||
|
||||
Фильтр по роли делается ЗДЕСЬ, в SQL, а не в `_authorize_employee` ниже:
|
||||
для manager'а строка менеджера/админа не должна даже доехать до
|
||||
вызывающего кода. `None` для обоих случаев («нет такого id» и «этот id
|
||||
тебе не по зубам») — тот же принцип, что и 404-вместо-403 в
|
||||
`_authorize_employee`: не палим существование чужой строки.
|
||||
"""
|
||||
sql = _FETCH_MANAGED_ANY_SQL if actor.role == "admin" else _FETCH_MANAGED_EMPLOYEE_SQL
|
||||
return db.execute(sql, {"id": employee_id}).mappings().fetchone()
|
||||
|
||||
|
||||
def _authorize_employee(actor: TeamActor, row: RowMapping | None) -> RowMapping:
|
||||
"""404 (НЕ 403) если сотрудник не найден ИЛИ принадлежит другому manager'у.
|
||||
|
||||
Org-изоляция: manager может видеть/менять только `manager_id == actor.user_id`.
|
||||
404 вместо 403 — не палим существование чужого employee_id.
|
||||
|
||||
Для admin'а доп. проверки нет: набор строк, до которых он вообще может
|
||||
дотянуться, уже ограничен ролью в `_fetch_employee_row` (employee|manager,
|
||||
без admin). У менеджерских строк `manager_id` штатно NULL — сравнивать его
|
||||
с чем-либо здесь нечего.
|
||||
"""
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="employee not found")
|
||||
if actor.role == "manager" and row["manager_id"] != actor.user_id:
|
||||
raise HTTPException(status_code=404, detail="employee not found")
|
||||
return row
|
||||
|
||||
|
||||
def _upsert_quota_override(
|
||||
db: Session, username: str, monthly_limit: int, actor_username: str
|
||||
) -> None:
|
||||
"""Upsert персонального лимита. Явная установка monthly_limit — сигнал "хочу
|
||||
numeric-квоту", поэтому ВСЕГДА сбрасывает `unlimited=false` (иначе лимит может
|
||||
молча не применяться — прежний unlimited-грант выигрывал бы у нового limit).
|
||||
`note` — НЕ затирается, если уже задан (`COALESCE`): не перезаписываем
|
||||
человеко-читаемую причину прошлого гранта (напр. "пилот, грант ...") молча
|
||||
сгенерированной строкой; note проставляется только при первом upsert записи.
|
||||
"""
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO account_quota_overrides (username, monthly_limit, unlimited, note)
|
||||
VALUES (:username, CAST(:monthly_limit AS integer), false, :note)
|
||||
ON CONFLICT (username) DO UPDATE SET
|
||||
monthly_limit = EXCLUDED.monthly_limit,
|
||||
unlimited = false,
|
||||
note = COALESCE(account_quota_overrides.note, EXCLUDED.note),
|
||||
updated_at = now()
|
||||
"""
|
||||
),
|
||||
{
|
||||
"username": username,
|
||||
"monthly_limit": monthly_limit,
|
||||
"note": f"team-api: set by {actor_username}",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _batch_quota_status(db: Session, usernames: list[str]) -> dict[str, dict[str, Any]]:
|
||||
"""Батч-версия `account_quota.get_status` для N сотрудников — 2 SQL-запроса
|
||||
вместо 2N (было 2N+3 на GET /employees, HIGH/Medium2 review PR #2563).
|
||||
|
||||
Семантика ИДЕНТИЧНА `account_quota.is_unlimited`/`user_limit`/`get_status`
|
||||
(follow-up review PR #2563 п.2 — предыдущая версия расходилась: батч ВСЕГДА
|
||||
читал `account_quota_overrides.unlimited`, а `is_unlimited` — ТОЛЬКО для
|
||||
username, присутствующего в roles.yaml):
|
||||
- username НЕ в roles.yaml (`get_role` → KeyError) → unlimited=False ВСЕГДА,
|
||||
`account_quota_overrides.unlimited` даже не проверяется (roles.yaml —
|
||||
источник правды "кто вообще может быть unlimited", override — "у кого
|
||||
именно из известных roles.yaml-юзеров"). Сегодня недостижимо для DB-only
|
||||
сотрудников team-API (`_upsert_quota_override` всегда пишет
|
||||
`unlimited=false`), но станет достижимым при ручном UPDATE
|
||||
`account_quota_overrides` или расширении roles.yaml — расхождение с
|
||||
реальным enforcement (`check_and_raise`/`increment`, тот же `is_unlimited`)
|
||||
было бы честной ложью в списке: "без лимита", который движок всё равно
|
||||
считает.
|
||||
- username в roles.yaml и role == admin → unlimited=True (без похода в БД).
|
||||
- username в roles.yaml, role != admin → unlimited = override.unlimited.
|
||||
limit = override.monthly_limit (читается для ЛЮБОГО username, без gate по
|
||||
roles.yaml — так же ведёт себя `account_quota.user_limit`), иначе глобальный
|
||||
`account_quota.MONTHLY_LIMIT`.
|
||||
"""
|
||||
if not usernames:
|
||||
return {}
|
||||
|
||||
overrides = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT username, monthly_limit, unlimited
|
||||
FROM account_quota_overrides
|
||||
WHERE username = ANY(CAST(:usernames AS text[]))
|
||||
"""
|
||||
),
|
||||
{"usernames": usernames},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
override_by_username = {r["username"]: r for r in overrides}
|
||||
|
||||
period = account_quota.current_period()
|
||||
usage_rows = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT username, used
|
||||
FROM account_estimate_usage
|
||||
WHERE username = ANY(CAST(:usernames AS text[])) AND period_month = :period
|
||||
"""
|
||||
),
|
||||
{"usernames": usernames, "period": period},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
used_by_username = {r["username"]: r["used"] for r in usage_rows}
|
||||
|
||||
result: dict[str, dict[str, Any]] = {}
|
||||
for username in usernames:
|
||||
override = override_by_username.get(username)
|
||||
try:
|
||||
role = get_role(username)
|
||||
except KeyError:
|
||||
role = None
|
||||
if role == "admin":
|
||||
unlimited = True
|
||||
elif role is not None:
|
||||
unlimited = bool(override is not None and override["unlimited"])
|
||||
else:
|
||||
# username не в roles.yaml — is_unlimited() короткое замыкание на
|
||||
# False, override НЕ проверяется (см. докстринг выше).
|
||||
unlimited = False
|
||||
limit = (
|
||||
int(override["monthly_limit"])
|
||||
if override is not None and override["monthly_limit"] is not None
|
||||
else account_quota.MONTHLY_LIMIT
|
||||
)
|
||||
used = used_by_username.get(username, 0)
|
||||
if unlimited:
|
||||
result[username] = {
|
||||
"limit": limit,
|
||||
"used": used,
|
||||
"remaining": limit,
|
||||
"unlimited": True,
|
||||
}
|
||||
else:
|
||||
remaining = max(0, limit - max(0, used))
|
||||
result[username] = {
|
||||
"limit": limit,
|
||||
"used": used,
|
||||
"remaining": remaining,
|
||||
"unlimited": False,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def _employee_out(row: RowMapping, quota: dict[str, Any]) -> EmployeeOut:
|
||||
return EmployeeOut(
|
||||
id=row["id"],
|
||||
username=row["username"],
|
||||
role=row["role"],
|
||||
display_name=row["display_name"],
|
||||
org_name=row["org_name"],
|
||||
email=row["email"],
|
||||
is_active=row["is_active"],
|
||||
manager_id=row["manager_id"],
|
||||
created_at=row["created_at"],
|
||||
quota=QuotaStatusOut(**quota),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /employees
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/employees", response_model=EmployeeOut, status_code=201)
|
||||
async def create_employee(
|
||||
body: EmployeeCreateRequest,
|
||||
actor: Annotated[TeamActor, Depends(current_team_actor)],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
_origin_check: Annotated[None, Depends(_require_same_origin)],
|
||||
) -> EmployeeOut:
|
||||
"""Создать сотрудника. Роль всегда `employee`.
|
||||
|
||||
manager_id: для actor.role == manager — принудительно свой id (любое
|
||||
значение из тела ИГНОРИРУЕТСЯ, org-изоляция инвариант #2554). Для
|
||||
actor.role == admin — опционально из тела, валидируется что указанный id
|
||||
существует и role='manager' (иначе 422).
|
||||
"""
|
||||
existing = db.execute(
|
||||
text("SELECT id FROM tradein_users WHERE username = :u"),
|
||||
{"u": body.username},
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
raise HTTPException(status_code=409, detail="username already exists")
|
||||
|
||||
try:
|
||||
password_hash = hash_password(body.password)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=str(e)) from None
|
||||
|
||||
manager_id: int | None
|
||||
if actor.role == "manager":
|
||||
# Инвариант org-изоляции: manager не может создать сотрудника под
|
||||
# чужим manager_id — любое значение из тела игнорируется молча.
|
||||
manager_id = actor.user_id
|
||||
else:
|
||||
manager_id = body.manager_id
|
||||
if manager_id is not None:
|
||||
mgr = db.execute(
|
||||
text("SELECT id FROM tradein_users WHERE id = :id AND role = 'manager'"),
|
||||
{"id": manager_id},
|
||||
).fetchone()
|
||||
if mgr is None:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="manager_id does not reference an existing manager",
|
||||
)
|
||||
|
||||
try:
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO tradein_users
|
||||
(username, password_hash, role, manager_id, display_name, org_name,
|
||||
email, is_active)
|
||||
VALUES
|
||||
(:username, :password_hash, 'employee', :manager_id, :display_name,
|
||||
:org_name, :email, true)
|
||||
RETURNING id, username, role, display_name, org_name, email, is_active,
|
||||
manager_id, created_at
|
||||
"""
|
||||
),
|
||||
{
|
||||
"username": body.username,
|
||||
"password_hash": password_hash,
|
||||
"manager_id": manager_id,
|
||||
"display_name": body.display_name,
|
||||
"org_name": body.org_name,
|
||||
"email": body.email,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
.fetchone()
|
||||
)
|
||||
except IntegrityError:
|
||||
# TOCTOU: два конкурентных POST с одинаковым username между pre-check
|
||||
# выше и этим INSERT — UNIQUE-констрейнт на tradein_users.username ловит.
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=409, detail="username already exists") from None
|
||||
|
||||
assert row is not None # RETURNING на успешный INSERT всегда отдаёт строку
|
||||
|
||||
if body.monthly_limit is not None:
|
||||
_upsert_quota_override(db, body.username, body.monthly_limit, actor.username)
|
||||
|
||||
db.commit()
|
||||
|
||||
schedule_event(
|
||||
event_type="employee_created",
|
||||
username=actor.username,
|
||||
payload={
|
||||
"employee_id": row["id"],
|
||||
"employee_username": row["username"],
|
||||
"manager_id": manager_id,
|
||||
},
|
||||
)
|
||||
|
||||
quota = account_quota.get_status(db, body.username)
|
||||
return _employee_out(row, quota)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PATCH /employees/{id}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.patch("/employees/{employee_id}", response_model=EmployeeOut)
|
||||
async def update_employee(
|
||||
employee_id: int,
|
||||
body: EmployeeUpdateRequest,
|
||||
actor: Annotated[TeamActor, Depends(current_team_actor)],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
_origin_check: Annotated[None, Depends(_require_same_origin)],
|
||||
) -> EmployeeOut:
|
||||
"""Частичное обновление сотрудника — block/unblock, лимит, профиль, пароль.
|
||||
|
||||
manager может патчить ТОЛЬКО своих (manager_id == actor.user_id), иначе 404.
|
||||
При is_active=False ИЛИ смене пароля (new_password) — обязательно revoke всех
|
||||
сессий (HIGH, deep-review PR #2563): без этого блокировка/reset не подействуют
|
||||
до истечения TTL текущей сессии сотрудника — хуже того, sliding-refresh
|
||||
(`app.services.auth_session.get_session_user`) продлевает `expires_at` на
|
||||
КАЖДОМ запросе, так что скомпрометированная/чужая сессия живёт неограниченно
|
||||
долго, а не «до TTL». `revoke_user_sessions` сам называет смену пароля своим
|
||||
use-case — см. его докстринг.
|
||||
"""
|
||||
row = _fetch_employee_row(db, employee_id, actor)
|
||||
row = _authorize_employee(actor, row)
|
||||
|
||||
new_password_hash: str | None = None
|
||||
if body.new_password is not None:
|
||||
try:
|
||||
new_password_hash = hash_password(body.new_password)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=str(e)) from None
|
||||
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE tradein_users
|
||||
SET display_name = COALESCE(:display_name, display_name),
|
||||
org_name = COALESCE(:org_name, org_name),
|
||||
email = COALESCE(:email, email),
|
||||
is_active = COALESCE(CAST(:is_active AS boolean), is_active),
|
||||
password_hash = COALESCE(:password_hash, password_hash),
|
||||
updated_at = now()
|
||||
WHERE id = :id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"display_name": body.display_name,
|
||||
"org_name": body.org_name,
|
||||
"email": body.email,
|
||||
"is_active": body.is_active,
|
||||
"password_hash": new_password_hash,
|
||||
"id": employee_id,
|
||||
},
|
||||
)
|
||||
|
||||
if body.monthly_limit is not None:
|
||||
_upsert_quota_override(db, row["username"], body.monthly_limit, actor.username)
|
||||
|
||||
if body.is_active is False or body.new_password is not None:
|
||||
# Обязательно ПОСЛЕ UPDATE, ДО финального commit — revoke_user_sessions
|
||||
# коммитит сам (см. app.services.auth_session), это флашит и наш
|
||||
# предшествующий UPDATE/quota-upsert в той же сессии. Self-lockout
|
||||
# невозможен: _fetch_employee_row не отдаёт строки с role='admin'
|
||||
# НИКОМУ, а manager'у — ещё и только role='employee'; т.е. actor
|
||||
# (admin|manager) никогда не может патчить сам себя через этот роут.
|
||||
revoke_user_sessions(db, employee_id)
|
||||
|
||||
db.commit()
|
||||
|
||||
changed_profile_fields = [
|
||||
f
|
||||
for f, v in (
|
||||
("display_name", body.display_name),
|
||||
("org_name", body.org_name),
|
||||
("email", body.email),
|
||||
)
|
||||
if v is not None
|
||||
]
|
||||
if changed_profile_fields:
|
||||
schedule_event(
|
||||
event_type="employee_updated",
|
||||
username=actor.username,
|
||||
payload={
|
||||
"employee_id": employee_id,
|
||||
"employee_username": row["username"],
|
||||
"fields": changed_profile_fields,
|
||||
},
|
||||
)
|
||||
if body.new_password is not None:
|
||||
schedule_event(
|
||||
event_type="employee_password_reset",
|
||||
username=actor.username,
|
||||
payload={"employee_id": employee_id, "employee_username": row["username"]},
|
||||
)
|
||||
if body.is_active is not None:
|
||||
schedule_event(
|
||||
event_type="employee_blocked" if body.is_active is False else "employee_unblocked",
|
||||
username=actor.username,
|
||||
payload={"employee_id": employee_id, "employee_username": row["username"]},
|
||||
)
|
||||
if body.monthly_limit is not None:
|
||||
schedule_event(
|
||||
event_type="quota_changed",
|
||||
username=actor.username,
|
||||
payload={
|
||||
"employee_id": employee_id,
|
||||
"employee_username": row["username"],
|
||||
"monthly_limit": body.monthly_limit,
|
||||
},
|
||||
)
|
||||
|
||||
updated_row = _fetch_employee_row(db, employee_id, actor)
|
||||
assert updated_row is not None # только что успешно обновили эту же строку
|
||||
quota = account_quota.get_status(db, updated_row["username"])
|
||||
return _employee_out(updated_row, quota)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /employees
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Два статических варианта WHERE (НЕ f-string/динамическая сборка — Medium/
|
||||
# "заодно" review PR #2563: значения биндятся параметрами и без того безопасны,
|
||||
# но статические ветки не провоцируют будущие правки в сторону конкатенации SQL).
|
||||
#
|
||||
# ORDER BY created_at DESC, id DESC — тай-брейкер по `id` ОБЯЗАТЕЛЕН (follow-up
|
||||
# review PR #2563 п.1): `created_at DEFAULT now()` — время ТРАНЗАКЦИИ, а bulk-seed
|
||||
# (#2557) вставляет много юзеров одной транзакцией → идентичный timestamp у N строк.
|
||||
# Без тай-брейкера порядок между страницами (LIMIT/OFFSET) на PostgreSQL для
|
||||
# строк-«близнецов» не гарантирован — сотрудники пропадали/дублировались бы при
|
||||
# постраничном листании. `id` монотонно растёт (BIGINT IDENTITY) — детерминированный
|
||||
# tie-break без доп. индекса (созданные позже = бОльший id, тот же порядок что и
|
||||
# намерение DESC-сортировки по времени).
|
||||
_LIST_EMPLOYEES_BY_MANAGER_SQL = text(
|
||||
"""
|
||||
SELECT id, username, role, display_name, org_name, email, is_active, manager_id, created_at
|
||||
FROM tradein_users
|
||||
WHERE role = 'employee' AND manager_id = :manager_id
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
)
|
||||
|
||||
# Admin-ветка: сюда попадают И менеджеры (см. модульный docstring — иначе admin
|
||||
# не видит в UI строку, которой должен уметь сбросить пароль). `role='admin'`
|
||||
# по-прежнему невидим и неуправляем. Сортировка по (created_at, id) общая для
|
||||
# обеих ролей — намеренно: seed (#2557) вставил всех одной транзакцией, так что
|
||||
# группировка «сначала менеджеры» дала бы ложное ощущение иерархии там, где её
|
||||
# в данных нет; роль показывается колонкой (`EmployeeOut.role`).
|
||||
_LIST_EMPLOYEES_ALL_SQL = text(
|
||||
"""
|
||||
SELECT id, username, role, display_name, org_name, email, is_active, manager_id, created_at
|
||||
FROM tradein_users
|
||||
WHERE role IN ('employee', 'manager')
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
@router.get("/employees", response_model=list[EmployeeOut])
|
||||
async def list_employees(
|
||||
actor: Annotated[TeamActor, Depends(current_team_actor)],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
manager_id: Annotated[int | None, Query()] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
||||
offset: Annotated[int, Query(ge=0)] = 0,
|
||||
) -> list[EmployeeOut]:
|
||||
"""Список сотрудников. manager видит только своих; admin — всех, опц. ?manager_id=.
|
||||
|
||||
Квота — ОДИН батч-запрос на всю страницу (`_batch_quota_status`), не N+1
|
||||
(Medium2, review PR #2563: было 2N+3 SQL-запросов на N сотрудников).
|
||||
"""
|
||||
if actor.role == "manager":
|
||||
rows = (
|
||||
db.execute(
|
||||
_LIST_EMPLOYEES_BY_MANAGER_SQL,
|
||||
{"manager_id": actor.user_id, "limit": limit, "offset": offset},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
elif manager_id is not None:
|
||||
rows = (
|
||||
db.execute(
|
||||
_LIST_EMPLOYEES_BY_MANAGER_SQL,
|
||||
{"manager_id": manager_id, "limit": limit, "offset": offset},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
else:
|
||||
rows = (
|
||||
db.execute(_LIST_EMPLOYEES_ALL_SQL, {"limit": limit, "offset": offset}).mappings().all()
|
||||
)
|
||||
|
||||
quota_by_username = _batch_quota_status(db, [row["username"] for row in rows])
|
||||
return [_employee_out(row, quota_by_username[row["username"]]) for row in rows]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /employees/{id}/history
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/employees/{employee_id}/history", response_model=list[EmployeeHistoryEntry])
|
||||
async def employee_history(
|
||||
employee_id: int,
|
||||
actor: Annotated[TeamActor, Depends(current_team_actor)],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
||||
offset: Annotated[int, Query(ge=0)] = 0,
|
||||
) -> list[EmployeeHistoryEntry]:
|
||||
"""История оценок сотрудника (адрес/дата/результат) — из `user_events`,
|
||||
LEFT JOIN `trade_in_estimates` за фактическим результатом.
|
||||
|
||||
Та же org-проверка что и в PATCH: чужой employee_id → 404.
|
||||
"""
|
||||
row = _fetch_employee_row(db, employee_id, actor)
|
||||
row = _authorize_employee(actor, row)
|
||||
|
||||
rows = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT
|
||||
CAST(ue.estimate_id AS text) AS estimate_id,
|
||||
ue.payload ->> 'address' AS address,
|
||||
ue.payload ->> 'area_m2' AS area_m2,
|
||||
ue.payload ->> 'rooms' AS rooms,
|
||||
te.median_price,
|
||||
te.confidence,
|
||||
te.n_analogs,
|
||||
ue.created_at
|
||||
FROM user_events ue
|
||||
LEFT JOIN trade_in_estimates te ON te.id = ue.estimate_id
|
||||
WHERE ue.username = :username AND ue.event_type = 'estimate_request'
|
||||
ORDER BY ue.created_at DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
),
|
||||
{"username": row["username"], "limit": limit, "offset": offset},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
|
||||
return [EmployeeHistoryEntry.model_validate(dict(r)) for r in rows]
|
||||
|
|
@ -49,11 +49,28 @@ class Settings(BaseSettings):
|
|||
default="", validation_alias="TRADEIN_INTERNAL_AUTH_SECRET"
|
||||
)
|
||||
|
||||
# Geocoder. Env var name `YANDEX_GEOCODER_API_KEY` — consistent с scripts/
|
||||
# backfill_house_coords.py + audit_address_mismatch.py + main backend
|
||||
# OpenRouteService_API_KEY pattern. Renamed from YANDEX_GEOCODER_KEY (PR F).
|
||||
yandex_geocoder_api_key: str | None = None # 25K req/day free после регистрации
|
||||
yandex_suggest_key: str | None = None # для frontend autocomplete (proxy через backend)
|
||||
# ── #2550: DB-auth foundation (bcrypt password hashing + session cookie) ────
|
||||
# Подготовительные поля для #2549 (эпик). Enforcement непустого session_secret
|
||||
# (fail-fast при пустом значении в prod) добавится в #2552 — здесь дефолт
|
||||
# намеренно пустой, чтобы прод-контейнер не падал на старте до того как
|
||||
# секрет проставлен в .env.runtime. ENV: SESSION_SECRET.
|
||||
session_secret: str = Field(default="", validation_alias="SESSION_SECRET")
|
||||
# Имя cookie для DB-based сессии (отдельно от Caddy basic_auth / trusted-header).
|
||||
session_cookie_name: str = Field(
|
||||
default="tradein_session", validation_alias="SESSION_COOKIE_NAME"
|
||||
)
|
||||
# TTL сессии в часах. Дефолт 720ч (30 дней).
|
||||
session_ttl_hours: int = Field(default=720, validation_alias="SESSION_TTL_HOURS")
|
||||
# "dual" — переходный режим (Caddy trusted-header ИЛИ DB-сессия оба валидны);
|
||||
# "db_only" — только DB-сессия (Caddy basic_auth убран). Переключение — #2552+.
|
||||
auth_mode: Literal["dual", "db_only"] = Field(default="dual", validation_alias="AUTH_MODE")
|
||||
# Rate-limit на /login: не более login_rate_limit попыток за
|
||||
# login_rate_limit_window_s секунд на ключ (обычно IP или username).
|
||||
login_rate_limit: int = Field(default=5, validation_alias="LOGIN_RATE_LIMIT")
|
||||
login_rate_limit_window_s: int = Field(
|
||||
default=300, validation_alias="LOGIN_RATE_LIMIT_WINDOW_S"
|
||||
)
|
||||
|
||||
# для User-Agent в Nominatim (Nominatim Usage Policy)
|
||||
contact_email: str = "erginrajpopxbe@outlook.com"
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ This helper:
|
|||
- applies idempotent CREATE or ALTER mapping on every backend startup so
|
||||
password rotation through .env.runtime is picked up after restart.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
|
@ -37,7 +38,7 @@ def ensure_fdw_user_mapping(db: Session) -> None:
|
|||
logger.warning(
|
||||
"GENDESIGN_FDW_PASSWORD not set — skipping FDW user mapping "
|
||||
"(gendesign_cad_buildings queries will fail; cadastral lookups will "
|
||||
"fall back to Yandex/Nominatim)"
|
||||
"fall back to Nominatim)"
|
||||
)
|
||||
return
|
||||
|
||||
|
|
@ -62,16 +63,20 @@ def ensure_fdw_user_mapping(db: Session) -> None:
|
|||
).first()
|
||||
|
||||
if exists is None:
|
||||
db.execute(text(
|
||||
f"CREATE USER MAPPING FOR CURRENT_USER SERVER gendesign_remote "
|
||||
f"OPTIONS (user 'tradein_fdw_reader', password '{password}')"
|
||||
))
|
||||
db.execute(
|
||||
text(
|
||||
f"CREATE USER MAPPING FOR CURRENT_USER SERVER gendesign_remote "
|
||||
f"OPTIONS (user 'tradein_fdw_reader', password '{password}')"
|
||||
)
|
||||
)
|
||||
logger.info("created FDW user mapping for gendesign_remote")
|
||||
else:
|
||||
db.execute(text(
|
||||
f"ALTER USER MAPPING FOR CURRENT_USER SERVER gendesign_remote "
|
||||
f"OPTIONS (SET password '{password}')"
|
||||
))
|
||||
db.execute(
|
||||
text(
|
||||
f"ALTER USER MAPPING FOR CURRENT_USER SERVER gendesign_remote "
|
||||
f"OPTIONS (SET password '{password}')"
|
||||
)
|
||||
)
|
||||
logger.info("refreshed FDW user mapping password for gendesign_remote")
|
||||
|
||||
try:
|
||||
|
|
|
|||
61
tradein-mvp/backend/app/core/password.py
Normal file
61
tradein-mvp/backend/app/core/password.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"""Bcrypt password hashing для DB-auth (#2550 — foundation, эпик #2549).
|
||||
|
||||
bcrypt тихо обрезает пароли длиннее 72 байт (UTF-8) — это silent-truncation
|
||||
дыра (два разных пароля с общим 72-байтовым префиксом хешируются одинаково).
|
||||
`hash_password` явно ловит это и падает с ValueError вместо тихого поведения.
|
||||
`verify_password` на длинном пароле возвращает False (не raise) — сравнение
|
||||
паролей не должно ронять запрос авторизации.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import bcrypt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BCRYPT_MAX_BYTES = 72
|
||||
_BCRYPT_ROUNDS = 12
|
||||
|
||||
|
||||
def hash_password(plain: str) -> str:
|
||||
"""Хеширует пароль через bcrypt (rounds=12).
|
||||
|
||||
Raises:
|
||||
ValueError: пустой пароль или пароль длиннее 72 байт в UTF-8
|
||||
(bcrypt тихо обрезает — недопустимо, см. модульный docstring).
|
||||
"""
|
||||
if not plain:
|
||||
raise ValueError("password must not be empty")
|
||||
|
||||
encoded = plain.encode("utf-8")
|
||||
if len(encoded) > _BCRYPT_MAX_BYTES:
|
||||
raise ValueError(
|
||||
f"password too long: {len(encoded)} bytes (bcrypt max {_BCRYPT_MAX_BYTES})"
|
||||
)
|
||||
|
||||
salt = bcrypt.gensalt(rounds=_BCRYPT_ROUNDS)
|
||||
hashed = bcrypt.hashpw(encoded, salt)
|
||||
return hashed.decode("utf-8")
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
"""Сверяет пароль с bcrypt-хешем.
|
||||
|
||||
Пустой пароль или пароль длиннее 72 байт в UTF-8 → False (не raise —
|
||||
verify — это false/true проверка на этапе логина, а не валидация ввода).
|
||||
"""
|
||||
if not plain or not hashed:
|
||||
return False
|
||||
|
||||
encoded = plain.encode("utf-8")
|
||||
if len(encoded) > _BCRYPT_MAX_BYTES:
|
||||
return False
|
||||
|
||||
try:
|
||||
return bcrypt.checkpw(encoded, hashed.encode("utf-8"))
|
||||
except (ValueError, TypeError) as e:
|
||||
# Malformed hash (напр. не-bcrypt строка в БД) — не должно ронять login.
|
||||
logger.warning("verify_password: malformed hash rejected: %s", e)
|
||||
return False
|
||||
|
|
@ -7,12 +7,16 @@ manually". The copy drifted: it was missing the #2213
|
|||
``X-Internal-Auth-Secret`` defense-in-depth check that the real guard has,
|
||||
so a regression in that check would NOT have failed CI.
|
||||
|
||||
This module holds the real guard with no DB/lifespan/scheduler side effects
|
||||
(only ``app.core.auth`` + ``app.core.config``, both side-effect-free at
|
||||
import time beyond requiring ``DATABASE_URL`` in the environment for
|
||||
``Settings()``). ``app/main.py`` and the test apps both import THIS module,
|
||||
so tests exercise the exact production code path instead of a copy that can
|
||||
silently fall out of sync.
|
||||
This module holds the real guard. Historically it had "no DB/lifespan/scheduler
|
||||
side effects" beyond ``app.core.auth``/``app.core.config`` (both side-effect-free
|
||||
at import time). #2552 (dual-mode DB-session auth) adds a conditional per-request
|
||||
DB round trip via ``app.core.db.SessionLocal`` — но ТОЛЬКО когда запрос реально
|
||||
несёт session-cookie (``request.cookies.get(settings.session_cookie_name)``);
|
||||
без cookie (весь существующий тестовый трафик, legacy Caddy trusted-header
|
||||
запросы) ветка не выполняется — ноль новых DB-побочных эффектов для старых
|
||||
путей. ``app/main.py`` and the test apps both import THIS module, so tests
|
||||
exercise the exact production code path instead of a copy that can silently
|
||||
fall out of sync.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -21,12 +25,15 @@ import logging
|
|||
import re
|
||||
import secrets
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
|
||||
from app.core.auth import get_role, is_path_allowed
|
||||
from app.core.config import settings
|
||||
from app.core.db import SessionLocal
|
||||
from app.services.auth_session import get_db_role_scope, get_session_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -40,7 +47,37 @@ logger = logging.getLogger(__name__)
|
|||
# Public paths без auth (/health, /docs, /openapi.json) пропускаем —
|
||||
# X-Authenticated-User там не приходит из Caddy.
|
||||
_ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
|
||||
_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"})
|
||||
# #2552: /api/v1/auth/login + /logout — по определению вызываются ДО того, как
|
||||
# клиент аутентифицирован (login) или могут вызываться с уже протухшей/отсутствующей
|
||||
# сессией (logout — должен уметь чистить stale cookie без валидной auth). Свой
|
||||
# rate-limit у /login отдельный (app.api.v1.auth._LOGIN_LIMITER), RateLimitMiddleware
|
||||
# на /api/* всё равно применяется — это ослабляет ТОЛЬКО rbac_guard'овский
|
||||
# auth-required gate, не остальные защиты.
|
||||
#
|
||||
# Инцидент 2026-07-31: /api/v1/trade-in/support/anon/* — по той же логике. Единственным
|
||||
# каналом в поддержку был чат ЗА логином, а типовая причина писать в поддержку —
|
||||
# «не могу войти» (в тот день так и вышло: «Практика» билась в форму весь день и
|
||||
# достучаться из продукта не могла). Ветка НЕ трогает авторизованные
|
||||
# /api/v1/trade-in/support/* — те по-прежнему требуют identity; у анонимной свой,
|
||||
# заведомо более узкий бюджет (per-token + per-IP, см. app.api.v1.support) и своя
|
||||
# идентичность из httpOnly-куки, которая структурно не может совпасть с чьим-то
|
||||
# логином.
|
||||
_PUBLIC_PATHS = frozenset(
|
||||
{
|
||||
"/health",
|
||||
"/docs",
|
||||
"/redoc",
|
||||
"/openapi.json",
|
||||
"/api/v1/auth/login",
|
||||
"/api/v1/auth/logout",
|
||||
# NB: префикс — /api/v1/trade-in (app/main.py include_router), а Caddy
|
||||
# срезает ВНЕШНИЙ /trade-in ещё раньше. Т.е. снаружи это
|
||||
# /trade-in/api/v1/trade-in/support/anon/*, сюда приходит вот такое.
|
||||
"/api/v1/trade-in/support/anon/messages",
|
||||
"/api/v1/trade-in/support/anon/unread",
|
||||
"/api/v1/trade-in/support/anon/read",
|
||||
}
|
||||
)
|
||||
# #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед
|
||||
# tradein-backend, а globs в roles.yaml — ВНЕШНИЕ (/trade-in/api/v1/**). Для
|
||||
# scope-проверки восстанавливаем внешний путь.
|
||||
|
|
@ -51,6 +88,77 @@ _EXTERNAL_PREFIX = "/trade-in"
|
|||
_RBAC_BOOTSTRAP_EXEMPT = ("/api/v1/me", "/api/v1/brand")
|
||||
|
||||
|
||||
def _db_glob_match(pattern: str, path: str) -> bool:
|
||||
"""Мини-матчер для фиксированного набора DB-role паттернов
|
||||
(``app.services.auth_session.DB_ROLE_PATHS`` — только формы ``/**`` и
|
||||
``<prefix>/**``, не нужна полная semantics ``app.core.auth._glob_to_regex``
|
||||
— тот модуль private и MIRROR'ится вручную с основным бэкендом, лишний
|
||||
импорт private-символа оттуда увеличивал бы drift-риск)."""
|
||||
if pattern == "/**":
|
||||
return True
|
||||
if pattern.endswith("/**"):
|
||||
prefix = pattern[: -len("/**")]
|
||||
return path == prefix or path.startswith(prefix + "/")
|
||||
return path == pattern
|
||||
|
||||
|
||||
def _db_role_path_allowed(role: str, path: str) -> bool:
|
||||
paths, deny = get_db_role_scope(role)
|
||||
if any(_db_glob_match(p, path) for p in deny):
|
||||
return False
|
||||
return any(_db_glob_match(p, path) for p in paths)
|
||||
|
||||
|
||||
def _propagate_authenticated_user(request: Request, username: str) -> None:
|
||||
"""Инжектит ``X-Authenticated-User`` в ASGI scope — ПЕРЕЗАПИСЫВАЯ, а не
|
||||
только добавляя при отсутствии, — чтобы ``RateLimitMiddleware``/
|
||||
``RequestAuditMiddleware`` (оба читают сырой заголовок напрямую,
|
||||
#2213/#2550) и downstream route-хендлеры (читающие его через FastAPI
|
||||
``Header()``) видели РЕЗОЛВЛЕННОГО ИЗ СЕССИИ юзера — без правок в каждом
|
||||
из этих мест по отдельности (минимально инвазивный способ).
|
||||
|
||||
#2552 post-review fix (CRITICAL): раньше это была skip-if-present
|
||||
мутация (``if request.headers.get(...): return``) — сессия резолвилась
|
||||
ПЕРВОЙ (см. rbac_guard), но клиент-контролируемый ``X-Authenticated-User``
|
||||
(который Caddy шлёт на КАЖДЫЙ прод-запрос) выигрывал у неё для ВСЕГО
|
||||
downstream-трафика: атакующий с валидной cookie юзера ``alice`` мог
|
||||
подделать заголовок ``X-Authenticated-User: victim`` и получить доступ к
|
||||
данным victim в ~15 роутах, читающих заголовок напрямую
|
||||
(``_assert_estimate_access*``, ``account_quota``, ``/trade-in/history``,
|
||||
``support.py``) — работало в ОБОИХ auth_mode (dual и db_only), т.к. эти
|
||||
хендлеры не знают про rbac_guard'овский ``from_session`` флаг, только про
|
||||
сырой заголовок. Session-identity ДОЛЖНА быть источником истины, если
|
||||
сессия резолвлена — полная перезапись, не skip.
|
||||
|
||||
Механизм: ``request.scope`` — ОДИН и тот же dict-объект, прокинутый по
|
||||
ссылке через весь ASGI call chain (Starlette не копирует scope между
|
||||
слоями middleware). Мутация ``scope["headers"]`` ЗДЕСЬ видна:
|
||||
- downstream call_next() цепочке (ExceptionMiddleware → Router →
|
||||
endpoint) — т.к. rbac_guard мутирует scope ДО вызова call_next();
|
||||
- ``RequestAuditMiddleware`` — он внешний относительно rbac_guard
|
||||
(см. app/main.py: последний ``add_middleware`` оборачивает
|
||||
предыдущие) и читает ``request.headers`` уже ПОСЛЕ ``call_next()``
|
||||
отработал весь внутренний стек, включая эту мутацию.
|
||||
|
||||
ASGI header-имена — всегда lowercase bytes (см. ASGI spec), поэтому
|
||||
фильтр по ``b"x-authenticated-user"`` ловит заголовок независимо от
|
||||
регистра, в котором его прислал клиент (Starlette уже нормализует).
|
||||
|
||||
Известное ограничение: ``RateLimitMiddleware`` тоже внешний относительно
|
||||
rbac_guard, но читает заголовок ДО вызова call_next() (до того, как этот
|
||||
guard успевает отработать) — для ЭТОГО конкретного запроса сессионный
|
||||
юзер лимитируется по IP, а не по username (per-user множитель не
|
||||
применяется). Не регрессия (IP-лимит применялся бы и раньше — до
|
||||
добавления session-auth такие запросы вообще были 401), просто более
|
||||
строгий бюджет специфично для session-cookie-запросов; при необходимости
|
||||
точного per-user квотинга для DB-юзеров — переносить резолв сессии выше
|
||||
RateLimit в app/main.py отдельным issue.
|
||||
"""
|
||||
request.scope["headers"] = [
|
||||
(k, v) for k, v in request.scope.get("headers", []) if k != b"x-authenticated-user"
|
||||
] + [(b"x-authenticated-user", username.encode("latin-1", "replace"))]
|
||||
|
||||
|
||||
async def rbac_guard(
|
||||
request: Request,
|
||||
call_next: Callable[[Request], Awaitable[Response]],
|
||||
|
|
@ -59,40 +167,74 @@ async def rbac_guard(
|
|||
if path in _PUBLIC_PATHS:
|
||||
return await call_next(request)
|
||||
|
||||
username = request.headers.get("X-Authenticated-User")
|
||||
if not username:
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"detail": "no authenticated user (Caddy basic_auth required)"},
|
||||
)
|
||||
username: str | None = None
|
||||
role: str | None = None
|
||||
from_session = False
|
||||
|
||||
# #2213 defense-in-depth: если общий секрет задан — запрос с X-Authenticated-User
|
||||
# ОБЯЗАН нести валидный X-Internal-Auth-Secret (его добавляет Caddy из env).
|
||||
# Иначе это подделка заголовка мимо Caddy (напр. изнутри gendesign_shared) → 401.
|
||||
# Constant-time compare против timing-атак. Пусто = защита не активна (fail-open).
|
||||
secret = settings.tradein_internal_auth_secret
|
||||
if secret:
|
||||
provided = request.headers.get("X-Internal-Auth-Secret", "")
|
||||
if not secrets.compare_digest(provided, secret):
|
||||
logger.warning(
|
||||
"RBAC: X-Authenticated-User=%r без валидного X-Internal-Auth-Secret "
|
||||
"на %s — возможная подделка заголовка мимо Caddy",
|
||||
username,
|
||||
path,
|
||||
)
|
||||
# #2552: session-cookie резолвится ПЕРВЫМ. Если cookie нет вообще —
|
||||
# request.cookies.get() возвращает None без единого похода в БД (ноль
|
||||
# side-effects для всего существующего трафика без cookie).
|
||||
token = request.cookies.get(settings.session_cookie_name)
|
||||
if token:
|
||||
session_user: dict[str, Any] | None = None
|
||||
try:
|
||||
with SessionLocal() as db:
|
||||
session_user = get_session_user(db, token)
|
||||
except Exception:
|
||||
logger.exception("RBAC: session lookup failed for %s", path)
|
||||
if session_user is not None:
|
||||
username = session_user["username"]
|
||||
role = session_user["role"]
|
||||
from_session = True
|
||||
_propagate_authenticated_user(request, username)
|
||||
|
||||
if not from_session:
|
||||
# auth_mode == "db_only" — легаси trusted-header путь ПОЛНОСТЬЮ
|
||||
# отключён, даже если валидный X-Authenticated-User присутствует.
|
||||
if settings.auth_mode != "dual":
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"detail": "invalid or missing internal auth secret"},
|
||||
content={"detail": "valid session required"},
|
||||
)
|
||||
|
||||
try:
|
||||
role = get_role(username)
|
||||
except KeyError:
|
||||
logger.warning("RBAC: unknown user %r tried %s", username, path)
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={"detail": "user not in roles config"},
|
||||
)
|
||||
# ---- legacy trusted-header path — BIT-FOR-BIT как было до #2552 ----
|
||||
username = request.headers.get("X-Authenticated-User")
|
||||
if not username:
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"detail": "no authenticated user (Caddy basic_auth required)"},
|
||||
)
|
||||
|
||||
# #2213 defense-in-depth: если общий секрет задан — запрос с X-Authenticated-User
|
||||
# ОБЯЗАН нести валидный X-Internal-Auth-Secret (его добавляет Caddy из env).
|
||||
# Иначе это подделка заголовка мимо Caddy (напр. изнутри gendesign_shared) → 401.
|
||||
# Constant-time compare против timing-атак. Пусто = защита не активна (fail-open).
|
||||
secret = settings.tradein_internal_auth_secret
|
||||
if secret:
|
||||
provided = request.headers.get("X-Internal-Auth-Secret", "")
|
||||
if not secrets.compare_digest(provided, secret):
|
||||
logger.warning(
|
||||
"RBAC: X-Authenticated-User=%r без валидного X-Internal-Auth-Secret "
|
||||
"на %s — возможная подделка заголовка мимо Caddy",
|
||||
username,
|
||||
path,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"detail": "invalid or missing internal auth secret"},
|
||||
)
|
||||
|
||||
try:
|
||||
role = get_role(username)
|
||||
except KeyError:
|
||||
logger.warning("RBAC: unknown user %r tried %s", username, path)
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={"detail": "user not in roles config"},
|
||||
)
|
||||
|
||||
assert username is not None
|
||||
assert role is not None
|
||||
|
||||
if _ADMIN_API_RE.match(path) and role != "admin":
|
||||
logger.info("RBAC: blocked %s (role=%s) from %s", username, role, path)
|
||||
|
|
@ -101,25 +243,24 @@ async def rbac_guard(
|
|||
content={"detail": "admin only"},
|
||||
)
|
||||
|
||||
# #R2-H3: энфорсим roles.yaml scope (paths/deny) для ВСЕХ non-admin путей, а не
|
||||
# только /admin/*. Иначе revoked (role=expired, paths:[] deny:/**) или узко-
|
||||
# скоупленный аккаунт достаёт non-admin API (напр. POST /api/v1/search —
|
||||
# экспорт листингов), который roles.yaml ему запрещает. Bootstrap-пути (/me,
|
||||
# /brand) исключены выше по списку. roles.yaml globs внешние → восстанавливаем
|
||||
# внешний путь (Caddy срезал /trade-in). На сбой парса — fail-open + громкий
|
||||
# лог: не лочим платящего pilot из-за конфиг-бага (admin-гейт выше остаётся).
|
||||
# #R2-H3: энфорсим scope (paths/deny) для ВСЕХ non-admin путей, а не
|
||||
# только /admin/*. Bootstrap-пути (/me, /brand) исключены — иначе revoked/
|
||||
# scope-narrowed юзер не смог бы получить свою роль вовсе.
|
||||
if not path.startswith(_RBAC_BOOTSTRAP_EXEMPT):
|
||||
external_path = _EXTERNAL_PREFIX + path
|
||||
try:
|
||||
allowed = is_path_allowed(role, external_path)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"RBAC scope-check raised for %s %s (ext=%s) — fail-open",
|
||||
username,
|
||||
path,
|
||||
external_path,
|
||||
)
|
||||
allowed = True
|
||||
if from_session:
|
||||
allowed = _db_role_path_allowed(role, external_path)
|
||||
else:
|
||||
try:
|
||||
allowed = is_path_allowed(role, external_path)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"RBAC scope-check raised for %s %s (ext=%s) — fail-open",
|
||||
username,
|
||||
path,
|
||||
external_path,
|
||||
)
|
||||
allowed = True
|
||||
if not allowed:
|
||||
logger.info(
|
||||
"RBAC: scope-blocked %s (role=%s) from %s (ext=%s)",
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ from sentry_sdk.integrations.starlette import StarletteIntegration
|
|||
from app.api.v1 import (
|
||||
admin,
|
||||
audit,
|
||||
auth,
|
||||
brand,
|
||||
buildings,
|
||||
geocode,
|
||||
|
|
@ -30,6 +31,7 @@ from app.api.v1 import (
|
|||
me,
|
||||
search,
|
||||
support,
|
||||
team,
|
||||
trade_in,
|
||||
)
|
||||
from app.core.config import settings
|
||||
|
|
@ -106,6 +108,19 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||
".env.runtime ОБОИХ стеков (Caddy главного стека + tradein-backend)"
|
||||
)
|
||||
|
||||
# #2552: session_secret зарезервирован на будущее (напр. подписанные токены) —
|
||||
# opaque session-токены (secrets.token_urlsafe, см. app.services.auth_session)
|
||||
# НЕ требуют подписи, их валидность проверяется исключительно наличием строки
|
||||
# в tradein_sessions + expires_at/is_active. Пустой session_secret НЕ должен
|
||||
# ронять старт контейнера (не startup-fail) — только громкий WARNING, чтобы
|
||||
# прод не остался без него незамеченно до момента, когда он реально понадобится.
|
||||
if not settings.session_secret:
|
||||
logger.warning(
|
||||
"SESSION_SECRET пуст — не блокирует старт (opaque session-токены не "
|
||||
"требуют подписи), но задай его в .env.runtime до появления фич, "
|
||||
"которым подпись реально нужна"
|
||||
)
|
||||
|
||||
# FDW bootstrap: create/refresh USER MAPPING for gendesign_remote postgres_fdw server.
|
||||
# Best-effort: failure does not abort startup, just logs.
|
||||
try:
|
||||
|
|
@ -158,6 +173,7 @@ def health() -> dict[str, str]:
|
|||
return {"status": "ok", "environment": settings.environment}
|
||||
|
||||
|
||||
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
|
||||
app.include_router(geocode.router, prefix="/api/v1/geocode", tags=["geocode"])
|
||||
app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"])
|
||||
app.include_router(audit.router, prefix="/api/v1/admin", tags=["admin-audit"])
|
||||
|
|
@ -168,3 +184,4 @@ app.include_router(support.router, prefix="/api/v1/trade-in", tags=["trade-in-su
|
|||
app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["buildings"])
|
||||
app.include_router(search.router, prefix="/api/v1", tags=["search"])
|
||||
app.include_router(me.router, prefix="/api/v1", tags=["me"])
|
||||
app.include_router(team.router, prefix="/api/v1/team", tags=["team"])
|
||||
|
|
|
|||
116
tradein-mvp/backend/app/schemas/team.py
Normal file
116
tradein-mvp/backend/app/schemas/team.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
"""Pydantic-схемы team-management API (#2554, эпик #2549).
|
||||
|
||||
CRUD управляемых аккаунтов (`tradein_users.role IN ('employee','manager')` —
|
||||
manager'ы доступны только actor'у-admin, см. `app.api.v1.team`), квоты, история
|
||||
оценок. Org-изоляция (manager видит/меняет только своих employee) реализована в
|
||||
`app.api.v1.team`, эти схемы — только форма запросов/ответов.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
# ASCII-only — не-ASCII username ломает downstream identity-пропагацию
|
||||
# (`app.core.rbac._propagate_authenticated_user` кодирует latin-1 с
|
||||
# errors="replace"), поэтому валидация формы обязательна на границе API,
|
||||
# а не только на уровне БД.
|
||||
#
|
||||
# `\Z`, НЕ `$` — deep-review seed #2564: в Python `$` матчит перед trailing
|
||||
# newline (`re.match(r'...\$', 'admin\n')` → True), а Postgres `~` в CHECK
|
||||
# tradein_users_username_ascii_ck (миграция 193) — False. С `$` строка
|
||||
# "admin\n" проходила бы Pydantic-валидацию и падала уже в БД → 500 вместо
|
||||
# честного 422. `\Z` — конец строки БЕЗ поблажки на trailing newline, совпадает
|
||||
# с семантикой Postgres `~`.
|
||||
_USERNAME_RE = re.compile(r"^[A-Za-z0-9._-]{3,64}\Z")
|
||||
|
||||
|
||||
class QuotaStatusOut(BaseModel):
|
||||
"""Статус месячной квоты оценок — вложен в `EmployeeOut`."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
limit: int
|
||||
used: int
|
||||
remaining: int
|
||||
unlimited: bool
|
||||
|
||||
|
||||
class EmployeeCreateRequest(BaseModel):
|
||||
"""`POST /employees` — создать сотрудника. Роль всегда `employee` (не в теле)."""
|
||||
|
||||
username: str
|
||||
password: str
|
||||
display_name: str | None = None
|
||||
org_name: str | None = None
|
||||
email: str | None = None
|
||||
monthly_limit: int | None = Field(default=None, ge=1)
|
||||
# Только для actor.role == admin — опциональная привязка к конкретному manager.
|
||||
# Для actor.role == manager это поле ИГНОРИРУЕТСЯ (принудительно свой id) —
|
||||
# см. app.api.v1.team.create_employee.
|
||||
manager_id: int | None = None
|
||||
|
||||
@field_validator("username")
|
||||
@classmethod
|
||||
def _validate_username(cls, v: str) -> str:
|
||||
if not _USERNAME_RE.match(v):
|
||||
raise ValueError(
|
||||
"username must be 3-64 ASCII chars: letters, digits, dot, underscore, hyphen"
|
||||
)
|
||||
return v
|
||||
|
||||
|
||||
class EmployeeUpdateRequest(BaseModel):
|
||||
"""`PATCH /employees/{id}` — частичное обновление, все поля опциональны."""
|
||||
|
||||
is_active: bool | None = None
|
||||
monthly_limit: int | None = Field(default=None, ge=1)
|
||||
display_name: str | None = None
|
||||
org_name: str | None = None
|
||||
email: str | None = None
|
||||
new_password: str | None = None
|
||||
|
||||
|
||||
class EmployeeOut(BaseModel):
|
||||
"""Одна строка в `GET /employees` + ответ `POST`/`PATCH /employees/{id}`."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
username: str
|
||||
# 'employee' | 'manager' — admin управляет обоими, manager видит только
|
||||
# employee (см. app.api.v1.team, модульный docstring). Строки role='admin'
|
||||
# через этот API не отдаются никогда, поэтому в Literal их нет.
|
||||
role: Literal["employee", "manager"]
|
||||
display_name: str | None = None
|
||||
org_name: str | None = None
|
||||
email: str | None = None
|
||||
is_active: bool
|
||||
manager_id: int | None = None
|
||||
created_at: datetime
|
||||
quota: QuotaStatusOut
|
||||
|
||||
|
||||
class EmployeeHistoryEntry(BaseModel):
|
||||
"""Одна строка истории оценок сотрудника — `GET /employees/{id}/history`.
|
||||
|
||||
Источник — `user_events` (event_type='estimate_request', паттерн
|
||||
`app.api.v1.audit.account_drilldown`), LEFT JOIN на `trade_in_estimates`
|
||||
за фактическим результатом (median_price/confidence/n_analogs) — join
|
||||
может не сматчиться (старая запись без estimate_id / оценка insufficient_data),
|
||||
поэтому все result-поля nullable.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
estimate_id: str | None = None
|
||||
address: str | None = None
|
||||
area_m2: str | None = None
|
||||
rooms: str | None = None
|
||||
median_price: int | None = None
|
||||
confidence: str | None = None
|
||||
n_analogs: int | None = None
|
||||
created_at: datetime
|
||||
|
|
@ -27,6 +27,12 @@ class TradeInEstimateInput(BaseModel):
|
|||
# geocode() (который падает на DaData-формах при мёртвом Yandex-ключе).
|
||||
lat: float | None = Field(default=None, ge=-90, le=90)
|
||||
lon: float | None = Field(default=None, ge=-180, le=180)
|
||||
# #2576: город, если известен фронту (например выбран отдельным полем UI).
|
||||
# Опционально — без него geocode() внутри estimate_quality() БОЛЬШЕ НЕ
|
||||
# подставляет "Екатеринбург" молча (см. app.services.geocoder), что раньше
|
||||
# давало уверенно неверную цену для жителей других городов области (те же
|
||||
# улица+дом существуют и в ЕКБ, и, например, в Нижнем Тагиле).
|
||||
city_hint: str | None = Field(default=None, max_length=100)
|
||||
# ФИАС/ГАР OBJECTGUID целевого дома, если фронт разрешил его через suggest
|
||||
# (SuggestItem.fias_id у house-level кандидата). Прокидывается в матчер
|
||||
# (Tier 0.5 fias_exact) ПЕРВЫМ, до fias из DaData /clean. Additive/optional —
|
||||
|
|
@ -185,6 +191,12 @@ class AggregatedEstimate(BaseModel):
|
|||
target_address: str | None = None # geocoded full address
|
||||
target_lat: float | None = None
|
||||
target_lon: float | None = None
|
||||
# #2576: True если ни адрес, ни `TradeInEstimateInput.city_hint` не называли
|
||||
# город явно — итоговый город (и, соответственно, набор аналогов/цена)
|
||||
# определил геокодер-провайдер, а не пользователь. Честный сигнал для
|
||||
# UI (снизить доверие / переспросить город), НЕ персистится в БД
|
||||
# (ephemeral, только для текущего POST /estimate ответа).
|
||||
target_city_ambiguous: bool = False
|
||||
sources_used: list[str] = Field(default_factory=list) # ['avito', 'cian', 'rosreestr']
|
||||
data_freshness_minutes: int | None = None # сколько минут назад был самый свежий парсинг
|
||||
# абсолютный timestamp самого свежего парсинга аналогов
|
||||
|
|
|
|||
289
tradein-mvp/backend/app/services/auth_session.py
Normal file
289
tradein-mvp/backend/app/services/auth_session.py
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
"""Session-сервис для DB-backed auth (#2552, эпик #2549 — auth-core).
|
||||
|
||||
Схема: `tradein_users` + `tradein_sessions` (migration `192_tradein_users_auth.sql`).
|
||||
Опаковые (`secrets.token_urlsafe`) токены-сессии — не JWT, не подписаны: валидность
|
||||
проверяется исключительно наличием + `expires_at`/`is_active` строкой в БД, поэтому
|
||||
`SESSION_SECRET` НЕ обязателен для работы этого модуля (зарезервирован на будущее,
|
||||
см. `app.core.config.Settings.session_secret` docstring).
|
||||
|
||||
Все функции здесь принимают уже открытую `db: Session` — сами НЕ открывают
|
||||
`SessionLocal()` (вызывающая сторона решает время жизни транзакции: `rbac_guard`
|
||||
и `app.core.db.get_db()`-роуты открывают её по-разному). Это делает модуль
|
||||
тривиально unit-тестируемым без патчинга `SessionLocal` — тесты просто передают
|
||||
fake/real `Session`.
|
||||
|
||||
Ни одна функция не должна ронять вызывающий HTTP-запрос: DB-ошибки логируются
|
||||
через `logger` вызывающей стороной (см. `app.core.rbac.rbac_guard`,
|
||||
`app.api.v1.me`), сам сервис поднимает исключения как есть (это НЕ fire-and-forget
|
||||
аудит-лог вроде `app.services.user_events`, а часть auth-decision — сбой обязан
|
||||
быть виден вызывающему, чтобы тот мог fail-closed).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Sliding-window refresh: last_seen_at/expires_at продлеваются НЕ чаще раза в
|
||||
# 5 минут — иначе каждый API-запрос авторизованного юзера бил бы в БД лишним
|
||||
# UPDATE (RBAC гоняет get_session_user на КАЖДЫЙ non-public запрос).
|
||||
_SLIDING_REFRESH_INTERVAL = timedelta(minutes=5)
|
||||
|
||||
_TOKEN_BYTES = 32 # secrets.token_urlsafe(32) — 256 бит энтропии, ~43 символа
|
||||
|
||||
|
||||
def create_session(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
ip: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
) -> str:
|
||||
"""Создаёт новую сессию для *user_id* и возвращает opaque-токен.
|
||||
|
||||
`expires_at = now() + settings.session_ttl_hours`. Коммитит сам (self-contained,
|
||||
как `app.services.user_events.record_event`).
|
||||
"""
|
||||
token = secrets.token_urlsafe(_TOKEN_BYTES)
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO tradein_sessions (token, user_id, expires_at, ip_address, user_agent)
|
||||
VALUES (
|
||||
:token, :user_id,
|
||||
now() + make_interval(hours => CAST(:ttl_hours AS integer)),
|
||||
CAST(:ip AS inet), :user_agent
|
||||
)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"token": token,
|
||||
"user_id": user_id,
|
||||
"ttl_hours": settings.session_ttl_hours,
|
||||
"ip": ip,
|
||||
"user_agent": user_agent,
|
||||
},
|
||||
)
|
||||
db.commit()
|
||||
return token
|
||||
|
||||
|
||||
def get_session_user(db: Session, token: str) -> dict[str, Any] | None:
|
||||
"""Резолвит сессионный токен в данные юзера, или None если сессия
|
||||
невалидна (не найдена / истекла / юзер деактивирован).
|
||||
|
||||
Sliding refresh: если с последнего `last_seen_at` прошло >=5 минут —
|
||||
продлевает `expires_at`/`last_seen_at` ОДНИМ UPDATE. Сбой refresh
|
||||
(напр. read-replica) логируется и НЕ мешает вернуть валидного юзера —
|
||||
это best-effort продление, а не часть решения "валидна ли сессия".
|
||||
"""
|
||||
if not token:
|
||||
return None
|
||||
|
||||
row = db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT s.user_id, s.expires_at, s.last_seen_at,
|
||||
u.username, u.role, u.display_name, u.org_name, u.email, u.is_active
|
||||
FROM tradein_sessions s
|
||||
JOIN tradein_users u ON u.id = s.user_id
|
||||
WHERE s.token = :token
|
||||
"""
|
||||
),
|
||||
{"token": token},
|
||||
).fetchone()
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
now = datetime.now(UTC)
|
||||
if row.expires_at is None or row.expires_at <= now:
|
||||
return None
|
||||
if not row.is_active:
|
||||
return None
|
||||
|
||||
if row.last_seen_at is None or (now - row.last_seen_at) >= _SLIDING_REFRESH_INTERVAL:
|
||||
try:
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE tradein_sessions
|
||||
SET last_seen_at = now(),
|
||||
expires_at = now() + make_interval(hours => CAST(:ttl_hours AS integer))
|
||||
WHERE token = :token
|
||||
"""
|
||||
),
|
||||
{"ttl_hours": settings.session_ttl_hours, "token": token},
|
||||
)
|
||||
db.commit()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"auth_session: sliding refresh failed for user_id=%r", row.user_id, exc_info=True
|
||||
)
|
||||
db.rollback()
|
||||
|
||||
return {
|
||||
"user_id": row.user_id,
|
||||
"username": row.username,
|
||||
"role": row.role,
|
||||
"display_name": row.display_name,
|
||||
"org_name": row.org_name,
|
||||
"email": row.email,
|
||||
"is_active": row.is_active,
|
||||
}
|
||||
|
||||
|
||||
def get_user_by_username(db: Session, username: str) -> dict[str, Any] | None:
|
||||
"""Возвращает строку `tradein_users` по username, или None если не найден.
|
||||
|
||||
Используется login-флоу (`app.api.v1.auth.login`) для password-проверки.
|
||||
Отдаёт `password_hash` как есть (может быть NULL — переходный период,
|
||||
см. migration 192 docstring) — вызывающая сторона решает, что с ним делать.
|
||||
"""
|
||||
row = db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, username, password_hash, role, is_active,
|
||||
display_name, org_name, email
|
||||
FROM tradein_users
|
||||
WHERE username = :username
|
||||
"""
|
||||
),
|
||||
{"username": username},
|
||||
).fetchone()
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
return {
|
||||
"user_id": row.id,
|
||||
"username": row.username,
|
||||
"password_hash": row.password_hash,
|
||||
"role": row.role,
|
||||
"is_active": row.is_active,
|
||||
"display_name": row.display_name,
|
||||
"org_name": row.org_name,
|
||||
"email": row.email,
|
||||
}
|
||||
|
||||
|
||||
def revoke_session(db: Session, token: str) -> None:
|
||||
"""Удаляет одну сессию по токену (logout). No-op если токен не найден."""
|
||||
db.execute(text("DELETE FROM tradein_sessions WHERE token = :token"), {"token": token})
|
||||
db.commit()
|
||||
|
||||
|
||||
def revoke_user_sessions(db: Session, user_id: int) -> None:
|
||||
"""Удаляет ВСЕ сессии юзера (напр. смена пароля / принудительный logout всех
|
||||
устройств — не используется этим PR напрямую, задел для будущих admin-действий)."""
|
||||
db.execute(text("DELETE FROM tradein_sessions WHERE user_id = :user_id"), {"user_id": user_id})
|
||||
db.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DB-role → RBAC scope (paths/deny) — #2552 dual-mode.
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# tradein_users.role ('admin'|'manager'|'employee', CHECK-констрейнт migration 192)
|
||||
# НЕ являются ключами auth/roles.yaml (тот файл — legacy Caddy trusted-header путь,
|
||||
# который этот эпик намеренно не трогает). Маппинг ниже даёт DB-ролям тот же
|
||||
# paths/deny-смысл, что и legacy-ролям, БЕЗ правки roles.yaml:
|
||||
# employee -> клиентский доступ: весь /trade-in/** МИНУС внутренние разделы
|
||||
# (см. deny ниже — раньше было «ровно как legacy pilot»).
|
||||
# manager -> employee + /api/v1/team/** (дашборд команды, #2556).
|
||||
# admin -> полный доступ, как legacy admin.
|
||||
#
|
||||
# Почему «Доля в продаже» и «Кэш» в deny у ОБЕИХ клиентских ролей (2026-07-31,
|
||||
# решение владельца продукта): это внутренние инструменты, а не продукт клиента.
|
||||
# «Доля в продаже» — аналитика рынка (сколько квартир дома выставлено, срез по
|
||||
# домам/ЖК), «Кэш» — состояние кэшей и скраперов. Клиентские аккаунты видеть их
|
||||
# не должны; триггер — аккаунт praktika (DB-роль manager), у которого оба пункта
|
||||
# висели в топбаре на /trade-in/team.
|
||||
#
|
||||
# Почему в deny И страницы (/trade-in/sale-share, /trade-in/cache), И их API
|
||||
# (/trade-in/api/v1/buildings/**, /trade-in/api/v1/trade-in/cache-stats/**): один
|
||||
# deny-список гейтит СРАЗУ ТРИ места, потому что все трое сверяются с ним через
|
||||
# один и тот же матчер —
|
||||
# 1) пункт меню: Topbar фильтрует NAV_ITEMS по scopePath из /me;
|
||||
# 2) сама страница: RouteGuard проверяет абсолютный путь из /me;
|
||||
# 3) серверные ручки: app.core.rbac.rbac_guard (deny проверяется ПЕРВЫМ,
|
||||
# внешний путь реконструируется как '/trade-in' + path).
|
||||
# Только страницы = пункт исчез, но прямой URL и API остались открыты; только
|
||||
# API = мёртвый пункт меню с 403 на каждый фетч.
|
||||
#
|
||||
# Почему '/trade-in/api/v1/buildings/**' безопасно закрывать целиком: весь
|
||||
# роутер app/api/v1/buildings.py обслуживает ТОЛЬКО раздел sale-share
|
||||
# (/sale-share, /sale-share/summary, /{house_id}/listings). Экран оценки его не
|
||||
# использует — секция «Продажи в доме» питается estimate-хендлерами
|
||||
# (useEstimatePlacementHistory / useSalesVsListings), а BuildingListingsDrawer
|
||||
# импортируется единственной страницей app/sale-share/page.tsx.
|
||||
#
|
||||
# NB (границы глоба): '<prefix>/**' компилируется в '^<prefix>(?:/.*)?$' — матчит
|
||||
# сам prefix, его же с трейлинг-слэшем и подпути через '/', но НЕ соседей по
|
||||
# префиксу (см. app.core.rbac._db_glob_match и app.core.auth._glob_to_regex).
|
||||
# Поэтому '/trade-in/cache/**' не задевает '/trade-in/cache-stats', а
|
||||
# '/trade-in/api/v1/trade-in/cache-stats/**' — не '/…/cache-statistics'.
|
||||
#
|
||||
# Почему у cache-stats ГЛОБ, а не «более точный» '/trade-in/api/v1/trade-in/
|
||||
# cache-stats': точный паттерн — это строгое равенство, и его обходит обычный
|
||||
# трейлинг-слэш (измерено: '…/cache-stats/' → allowed=True). Сегодня от этого
|
||||
# спасает только Starlette redirect_slashes (307 на путь без слэша → там уже
|
||||
# 403), т.е. защита держалась бы на роутере, а не на RBAC — достаточно
|
||||
# выключить redirect_slashes или сменить роутер, и deny тихо перестанет
|
||||
# работать. Глоб закрывает и сам путь, и слэш, и любые будущие подпути.
|
||||
# НЕ «уточнять» обратно до точного пути.
|
||||
#
|
||||
# NB (ограничение мини-матчера — читать перед копированием паттернов):
|
||||
# DB_ROLE_PATHS и pilot.deny в auth/roles.yaml — зеркала по СМЫСЛУ, но матчеры
|
||||
# у них РАЗНЫЕ. app.core.rbac._db_glob_match понимает ТОЛЬКО три формы:
|
||||
# '/**' | '<prefix>/**' | точный путь (строгое равенство).
|
||||
# app.core.auth._glob_to_regex (roles.yaml) понимает сверх этого ещё одиночную
|
||||
# '*' ('/foo/*' = один сегмент). Паттерн с одиночной '*', скопированный сюда из
|
||||
# roles.yaml, станет ЛИТЕРАЛЬНОЙ строкой и МОЛЧА перестанет что-либо запрещать —
|
||||
# без ошибки на импорте и без падения тестов, если на него нет прямого теста.
|
||||
# Т.е. в DB_ROLE_PATHS допустимы только '/**', '<prefix>/**' и точный путь;
|
||||
# одиночная '*' здесь = silent no-op.
|
||||
DB_ROLE_PATHS: dict[str, tuple[list[str], list[str]]] = {
|
||||
"employee": (
|
||||
["/trade-in/**", "/trade-in/api/v1/**"],
|
||||
[
|
||||
"/admin/**",
|
||||
"/api/v1/admin/**",
|
||||
"/trade-in/api/v1/admin/**",
|
||||
"/trade-in/sale-share/**",
|
||||
"/trade-in/cache/**",
|
||||
"/trade-in/api/v1/buildings/**",
|
||||
"/trade-in/api/v1/trade-in/cache-stats/**",
|
||||
],
|
||||
),
|
||||
"manager": (
|
||||
["/trade-in/**", "/trade-in/api/v1/**", "/api/v1/team/**"],
|
||||
[
|
||||
"/admin/**",
|
||||
"/api/v1/admin/**",
|
||||
"/trade-in/api/v1/admin/**",
|
||||
"/trade-in/sale-share/**",
|
||||
"/trade-in/cache/**",
|
||||
"/trade-in/api/v1/buildings/**",
|
||||
"/trade-in/api/v1/trade-in/cache-stats/**",
|
||||
],
|
||||
),
|
||||
"admin": (["/**"], []),
|
||||
}
|
||||
|
||||
|
||||
def get_db_role_scope(role: str) -> tuple[list[str], list[str]]:
|
||||
"""Возвращает (allowed_paths, deny_paths) для DB-роли.
|
||||
|
||||
Неизвестная роль (не должно случиться — CHECK-констрейнт на колонке
|
||||
ограничивает role тремя значениями) -> fail-closed (пустой allow, deny всё).
|
||||
"""
|
||||
return DB_ROLE_PATHS.get(role, ([], ["/**"]))
|
||||
|
|
@ -1622,6 +1622,20 @@ def _normalize_building_key(
|
|||
(корпус) схлопываются к base (тот же дом). Литеры — РАЗНЫЕ дома (204г ≠ 204д).
|
||||
- street_core прогоняется через _STREET_ALIAS_MAP (ткачева→ткачей).
|
||||
|
||||
#2581: город/р-н НАМЕРЕННО дропается (не возвращается в ключе) — это
|
||||
единственное, что делает «Ткачёва 13»-вариант с городом и без города давать
|
||||
один ключ (см. test_normalize_tkachei13_all_db_variants_same_key). Городской
|
||||
токен как 4-й элемент ключа НЕ добавлен: (а) часть источников (Avito
|
||||
anonymous-адреса) вообще не несёт городской токен в тексте — ключ с
|
||||
обязательным городом сломал бы их матчинг; (б) написание города варьируется
|
||||
(ЕКБ/Екатеринбург/г. Екатеринбург) — ещё один normalization-слой, дающий
|
||||
те же false-negative риски, которые уже решает `_CITY_TOKENS`-дропинг.
|
||||
Кросс-городская коллизия (одноимённая улица+дом в разных городах области)
|
||||
закрыта на SQL-уровне через ST_DWithin от subject-координат — см. Tier A
|
||||
в `_fetch_anchor_comps` (#2581) и Tier S в `_fetch_analogs` (#oblast-D,
|
||||
f9ae6f0c) — геопредикат надёжнее строкового city-токена и не ломает то,
|
||||
что уже нормализуется здесь.
|
||||
|
||||
Returns (street_core, base_no, letter) — любой элемент None если не извлёкся.
|
||||
Best-effort: при пустом адресе → (None, None, None).
|
||||
"""
|
||||
|
|
@ -1722,6 +1736,18 @@ def _anchor_comp_from_row(r: Any) -> dict[str, Any]:
|
|||
}
|
||||
|
||||
|
||||
# #2581: Tier A ("same building") ST_DWithin safety-radius. Reuses the SAME
|
||||
# city-scale DEFAULT_RADIUS_M already used by Tier S's mirrored geo-bound fix
|
||||
# (f9ae6f0c, #oblast-D). The address-string match (_normalize_building_key +
|
||||
# _house_boundary_regex) already establishes "same street + house number" —
|
||||
# this radius only needs to reject GENUINELY cross-city collisions (e.g.
|
||||
# «улица Ленина» exists in both Екатеринбург AND Серов/Нижний Тагил, 150+ km
|
||||
# apart) — it does not need to be building-tight like Tier C's 500m
|
||||
# micro-radius (that tier's precision comes from proximity alone, without a
|
||||
# street/house string match to lean on).
|
||||
ANCHOR_TIER_A_RADIUS_M = DEFAULT_RADIUS_M
|
||||
|
||||
|
||||
def _fetch_anchor_comps(
|
||||
db: Session,
|
||||
*,
|
||||
|
|
@ -1734,9 +1760,19 @@ def _fetch_anchor_comps(
|
|||
) -> tuple[list[dict[str, Any]], str | None]:
|
||||
"""Тированный набор комплов для same-building якоря. Стоп на 1-м тире с ≥ min_comps.
|
||||
|
||||
Tier A — SAME BUILDING: normalized street + base house no (+ литера если есть).
|
||||
RELAXED rooms (без фильтра), БЕЗ area±15%. Не группируем по house_id_fk —
|
||||
один дом дробится на несколько fk (Хохрякова 48 = 7085/9878/12797).
|
||||
Tier A — SAME BUILDING: normalized street + base house no (+ литера если есть)
|
||||
+ ST_DWithin(ANCHOR_TIER_A_RADIUS_M) от subject lat/lon (#2581 — до этого
|
||||
SQL не имел ГЕО-предиката вовсе, и одноимённая улица+дом в ДРУГОМ городе
|
||||
(область — 368 городов, «Ленина»/«Мира»/... повторяются) молча матчила
|
||||
ЕКБ-листинги для областного subject'а — ~40 191 из ~40 200 активных
|
||||
листингов ЕКБ, distance неизвестна без фильтра). RELAXED rooms (без
|
||||
фильтра), БЕЗ area±15%. Не группируем по house_id_fk — один дом дробится
|
||||
на несколько fk (Хохрякова 48 = 7085/9878/12797); ST_DWithin — тот же
|
||||
компромисс, что и Tier S ниже (см. _fetch_analogs), не house_id_fk.
|
||||
lat/lon subject'а обязательны (гейт как у Tier C) — без них геопредикат
|
||||
невозможен, и Tier A целиком пропускается (в проде geo ВСЕГДА есть —
|
||||
estimate_quality возвращает _empty_estimate раньше при неудачном
|
||||
geocode, см. `if geo is None`).
|
||||
Tier C — micro-radius ≤500m (ST_DWithin) + вторичка-канон guard (#1186): NULL = legacy
|
||||
вторичка + rooms match + area±25%. (Tier B «тот же ЖК» — skip: complex_id/cian_zhk_url
|
||||
ненадёжны.)
|
||||
|
|
@ -1752,7 +1788,7 @@ def _fetch_anchor_comps(
|
|||
|
||||
# ── Tier A: same building ────────────────────────────────────────────────
|
||||
street, base_no, letter = _normalize_building_key(address)
|
||||
if street and base_no is not None:
|
||||
if street and base_no is not None and lat is not None and lon is not None:
|
||||
# ё→е в SQL для symmetry с нормализатором. psycopg v3: bind через :param,
|
||||
# оператор ~. Boundary-regex вынесен в _house_boundary_regex (общий с
|
||||
# Tier S radius-fallback ниже, см. _fetch_analogs).
|
||||
|
|
@ -1771,11 +1807,20 @@ def _fetch_anchor_comps(
|
|||
AND price_per_m2 > 0
|
||||
AND lower(translate(address, 'ёЁ', 'ее')) LIKE :street_like
|
||||
AND lower(translate(address, 'ёЁ', 'ее')) ~ :house_re
|
||||
AND geom IS NOT NULL
|
||||
AND ST_DWithin(
|
||||
geom::geography,
|
||||
ST_MakePoint(:lon, :lat)::geography,
|
||||
:radius
|
||||
)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"street_like": "%" + street + "%",
|
||||
"house_re": house_re,
|
||||
"lon": lon,
|
||||
"lat": lat,
|
||||
"radius": ANCHOR_TIER_A_RADIUS_M,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
|
|
@ -1900,12 +1945,17 @@ def _band_haircut(anchor_ppm2: float) -> float:
|
|||
LOW audit #3: 0.04/0.07 (и mid из settings.asking_to_sold_haircut) —
|
||||
EKB-secondary-market calibration constants, но применяются ENGINE-WIDE (нет
|
||||
city-параметра ни здесь, ни у единственного вызывающего
|
||||
`_compute_same_building_anchor`). Реального импакта на не-ЕКБ область пока нет
|
||||
(same-building anchor pool для oblast сейчас не формируется — anchor_ppm2 сюда
|
||||
просто не доходит), но это доверие к отсутствию данных, а не к дизайну. Как
|
||||
только oblast anchor pools появятся (см. #oblast-D fallback выше), эти пороги
|
||||
нужно пересмотреть/сделать per-city — не оставлять ЕКБ-калибровку по умолчанию
|
||||
для другого рынка. No behavior change here (doc-only).
|
||||
`_compute_same_building_anchor`). #2581 update: та формулировка была НЕВЕРНОЙ —
|
||||
same-building anchor pool для oblast ВСЕГДА мог сформироваться (Tier A до
|
||||
#2581 не имел гео-предиката вообще, поэтому for oblast-subject'ов он либо
|
||||
молча тянул ЕКБ-листинги по одноимённой улице/дому, либо — для действительно
|
||||
уникальных названий — честно матчил местные листинги, если они были). После
|
||||
#2581 (ST_DWithin(ANCHOR_TIER_A_RADIUS_M) на Tier A) anchor_ppm2 ДЛЯ ОБЛАСТИ
|
||||
доходит сюда легитимно (местные комплы того же дома в Серове/Тагиле/etc, если
|
||||
они есть в БД), но всё ещё через ЕКБ-калиброванный haircut — эти пороги
|
||||
по-прежнему стоит пересмотреть/сделать per-city, не оставлять ЕКБ-калибровку
|
||||
по умолчанию для другого рынка. No behavior change here (doc-only, кроме
|
||||
исправления ложной посылки).
|
||||
"""
|
||||
if anchor_ppm2 >= 350_000:
|
||||
return 0.04
|
||||
|
|
@ -3151,12 +3201,13 @@ async def estimate_quality(
|
|||
Returns:
|
||||
AggregatedEstimate с estimate_id, медианой, диапазоном, аналогами.
|
||||
"""
|
||||
# 1. Geocode (#654: time-budgeted — Yandex/Nominatim retry chain can stack
|
||||
# 1. Geocode (#654: time-budgeted — Nominatim retry chain can stack
|
||||
# multiple network round-trips + 1s Nominatim rate-limit sleeps).
|
||||
geo: GeocodeResult | None = None
|
||||
# Variant A: trust client-provided coords (resolved by autocomplete/map) when present
|
||||
# and inside the oblast bbox — skips the geocode() chain that fails on DaData-format
|
||||
# addresses with the Yandex key dead. Out-of-bbox / partial → ignore, geocode normally.
|
||||
# addresses (#2593: Yandex Geocoder, the previous fallback for those, removed).
|
||||
# Out-of-bbox / partial → ignore, geocode normally.
|
||||
# (oblast C2): was tight EKB-only bbox (60.40-60.85 / 56.65-56.95) — widened to
|
||||
# geocoder.is_within_oblast66_bbox (region 66) so client-coords from oblast towns also
|
||||
# get this perf fast-path instead of always paying the geocode() round-trip. Perf-only,
|
||||
|
|
@ -3180,8 +3231,12 @@ async def estimate_quality(
|
|||
payload.lon,
|
||||
)
|
||||
if geo is None and payload.address:
|
||||
# #2576: city_hint прокидывается из payload — БЕЗ него geocode() больше не
|
||||
# подставляет "Екатеринбург" молча (см. app.services.geocoder). Опционально:
|
||||
# фронт пока (до отдельного изменения UI) его не шлёт, geo.city_ambiguous
|
||||
# честно сигнализирует об этом ниже.
|
||||
geo = await _with_budget(
|
||||
geocode(payload.address, db),
|
||||
geocode(payload.address, db, city_hint=payload.city_hint),
|
||||
settings.estimate_geocode_budget_s,
|
||||
label="geocode",
|
||||
)
|
||||
|
|
@ -3880,6 +3935,7 @@ async def estimate_quality(
|
|||
target_address=geo.full_address,
|
||||
target_lat=geo.lat,
|
||||
target_lon=geo.lon,
|
||||
target_city_ambiguous=geo.city_ambiguous,
|
||||
sources_used=sources_used,
|
||||
data_freshness_minutes=freshness_min,
|
||||
last_scraped_at=last_scraped_at,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -113,6 +113,16 @@ async def _job_asking_to_sold_ratio(
|
|||
await loop.run_in_executor(None, recompute_asking_to_sold_ratios, db, run_id)
|
||||
|
||||
|
||||
# ── deal_city_price_bands_refresh — sync tier-aware re-derive в executor ──────
|
||||
async def _job_deal_city_price_bands_refresh(
|
||||
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||
) -> None:
|
||||
from app.tasks.deal_city_price_bands_refresh import refresh_deal_city_price_bands
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, refresh_deal_city_price_bands, db, run_id)
|
||||
|
||||
|
||||
# ── refresh_search_matview — REFRESH MATVIEW CONCURRENTLY (own connection) ────
|
||||
async def _job_refresh_search_matview(
|
||||
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||
|
|
@ -382,7 +392,7 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
|
|||
"""Реестр НЕ-sweep продуктовых source→Handler для kit build_registry.
|
||||
|
||||
Kit-native sweeps (avito/yandex/cian/domclick city/full-load/newbuilding) НЕ здесь —
|
||||
их даёт build_registry(_default_kit_handlers). Здесь — 18 именованных + 1 wildcard
|
||||
их даёт build_registry(_default_kit_handlers). Здесь — 19 именованных + 1 wildcard
|
||||
(deactivate_stale_*), покрывающие каждый НЕ-sweep source боевого scheduler-dispatch.
|
||||
|
||||
`ctx` — принят для симметрии контракта; сами Handler-job'ы получают ctx во время
|
||||
|
|
@ -399,6 +409,9 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
|
|||
"asking_to_sold_ratio_refresh": Handler(
|
||||
_job_asking_to_sold_ratio, "asking_to_sold_ratio_refresh"
|
||||
),
|
||||
"deal_city_price_bands_refresh": Handler(
|
||||
_job_deal_city_price_bands_refresh, "deal_city_price_bands_refresh"
|
||||
),
|
||||
"refresh_search_matview": Handler(_job_refresh_search_matview, "refresh_search_matview"),
|
||||
"yandex_address_backfill": Handler(_job_yandex_address_backfill, "yandex_address_backfill"),
|
||||
"sber_index_pull": Handler(_job_sber_index_pull, "sber_index_pull"),
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ from scraper_kit.avito_exceptions import (
|
|||
AvitoRateLimitedError,
|
||||
)
|
||||
from scraper_kit.browser_fetcher import BrowserFetcher
|
||||
from scraper_kit.orchestration.pipeline import CITY_LOCATIONS
|
||||
|
||||
# #2397 slice B (эпик #2277 decommission scrape_pipeline.py, Part E): раньше
|
||||
# _CHROME_HEADERS/_avito_proxies() импортировались из app.services.scrape_pipeline.
|
||||
|
|
@ -70,6 +71,31 @@ __all__ = [
|
|||
"run_avito_detail_backfill",
|
||||
]
|
||||
|
||||
# #2576 этап B: oblast-города (region 66, вне ЕКБ) уже дают листинги (Каменск-
|
||||
# Уральский), но snapshot-SELECT ниже раньше фильтровал ЖЁСТКО '%/ekaterinburg/%' —
|
||||
# у всех остальных detail_enriched_at оставался NULL навсегда (без detail-страницы
|
||||
# нет lat/lon -> листинг молча выпадает из подбора аналогов по радиусу).
|
||||
# CITY_LOCATIONS.avito_slug — единственный источник правды для avito URL-слага
|
||||
# города (может отличаться от нашего city_slug: kamensk-uralskiy через дефис,
|
||||
# verhnyaya_pyshma без "kh") -- дублировать список тут вместо импорта было бы
|
||||
# risk дрейфа при добавлении новых oblast-городов.
|
||||
#
|
||||
# #2578 review: Postgres LIKE трактует '_' как wildcard "один любой символ" (не
|
||||
# литерал) и '%' как wildcard "любая последовательность" -- два слага из пяти
|
||||
# (nizhniy_tagil, verhnyaya_pyshma) содержат '_', без экранирования это латентная
|
||||
# дыра: город с похожим слагом (напр. nizhniyXtagil) молча совпал бы. Сегодня
|
||||
# коллизий нет (проверено на проде: raw vs escaped паттерны дают одинаковые 776
|
||||
# совпадений), но экранируем сейчас, а не когда появится реальная коллизия.
|
||||
# LIKE по умолчанию использует '\' как escape-символ (без явного ESCAPE) —
|
||||
# подтверждено на живом Postgres 16.4 (см. коммит #2578-fixup): 'nizhniyXtagil'
|
||||
# матчит неэкранированный '%/nizhniy_tagil/%' (LIKE default '_'=wildcard) и НЕ
|
||||
# матчит экранированный '%/nizhniy\_tagil/%' (LIKE '\_' = литерал '_'); точный
|
||||
# слаг 'nizhniy_tagil' матчит оба варианта -- позитивный кейс не сломан.
|
||||
_OBLAST_AVITO_URL_PATTERNS = tuple(
|
||||
"%/" + loc.avito_slug.replace("\\", "\\\\").replace("_", "\\_").replace("%", "\\%") + "/%"
|
||||
for loc in CITY_LOCATIONS.values()
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AvitoDetailBackfillResult:
|
||||
|
|
@ -102,7 +128,12 @@ async def run_avito_detail_backfill(
|
|||
"""Backfill detail_enriched_at for legacy avito listings via mobile proxy.
|
||||
|
||||
Params (from default_params jsonb in scrape_schedules):
|
||||
batch_size: int -- snapshot size (SELECT LIMIT), default 800.
|
||||
batch_size: int -- ЕКБ snapshot size (SELECT LIMIT), default 800 (unchanged,
|
||||
#2576 -- volume/order for ЕКБ stay byte-identical to pre-oblast behaviour).
|
||||
oblast_batch_size: int -- ДОПОЛНИТЕЛЬНАЯ reserved-квота для листингов
|
||||
области (#2576), default 100. Отдельный LIMIT, НЕ отъедает от batch_size
|
||||
ЕКБ -- гарантирует области честную обработку и одновременно не даёт
|
||||
всплеску свежих oblast-листингов вытеснить ЕКБ из top-N по scraped_at.
|
||||
budget_sec: float -- wall-clock budget per run, default 3600s.
|
||||
request_delay_sec: float -- delay between listings, default 6.0s.
|
||||
max_consecutive_blocks: int -- abort threshold, default 5.
|
||||
|
|
@ -111,6 +142,7 @@ async def run_avito_detail_backfill(
|
|||
mark_done (incl. partial/block-abort) / mark_failed (exception only).
|
||||
"""
|
||||
batch_size = int(params.get("batch_size", 800))
|
||||
oblast_batch_size = int(params.get("oblast_batch_size", 100))
|
||||
budget_sec = float(params.get("budget_sec", 3600))
|
||||
request_delay_sec = float(params.get("request_delay_sec", 6.0))
|
||||
max_consecutive_blocks = int(params.get("max_consecutive_blocks", 5))
|
||||
|
|
@ -189,30 +221,59 @@ async def run_avito_detail_backfill(
|
|||
runs_mod.update_heartbeat(db, run_id, current_counters)
|
||||
|
||||
# SNAPSHOT: single SELECT at start -- NOT re-selected in loop.
|
||||
# Scope (#1814): только активные ЕКБ-листинги. region_code на insert
|
||||
# хардкодится в 66 (base.py) → НЕ дискриминирует legacy не-ЕКБ; реальный
|
||||
# признак региона у Avito — путь URL (/ekaterinburg/ для ЕКБ; legacy
|
||||
# Москва/СПб/Тюмень — /moskva//sankt-peterburg//tyumen/). browser-fetch
|
||||
# на legacy не-ЕКБ спотыкается → curl-fallback → 429-бан curl-фингерпринта.
|
||||
# Не тратим фетчи на мёртвые (is_active) и не-ЕКБ.
|
||||
# Scope (#1814, расширено #2576): активные листинги ЕКБ + известных oblast-
|
||||
# городов (region 66). region_code на insert хардкодится в 66 (base.py) →
|
||||
# НЕ дискриминирует город; реальный признак города у Avito — путь URL
|
||||
# (/ekaterinburg/ для ЕКБ; legacy Москва/СПб/Тюмень — /moskva//sankt-
|
||||
# peterburg//tyumen/ — те по-прежнему вне scope, НЕ входят ни в ekb, ни в
|
||||
# oblast CTE). browser-fetch на legacy не-ЕКБ/не-oblast спотыкается →
|
||||
# curl-fallback → 429-бан curl-фингерпринта. Не тратим фетчи на мёртвые
|
||||
# (is_active) и на регионы вне scope.
|
||||
#
|
||||
# Два CTE вместо одного WHERE ... OR ...: ekb сохраняет ТОЧНО прежний
|
||||
# LIMIT/ORDER (#2576 требование "ЕКБ не деградирует") -- oblast НЕ может
|
||||
# вытеснить ЕКБ из batch_size ни при каком всплеске свежих oblast-строк
|
||||
# (ORDER BY ... scraped_at DESC в общем WHERE отдал бы приоритет самым
|
||||
# свежим независимо от города). oblast получает отдельную честную квоту
|
||||
# oblast_batch_size, добавленную ПОСЛЕ ekb-квоты (не вычтенную из неё).
|
||||
snapshot = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, source_url
|
||||
FROM listings
|
||||
WHERE source = 'avito'
|
||||
AND detail_enriched_at IS NULL
|
||||
AND source_url IS NOT NULL
|
||||
AND is_active = TRUE
|
||||
AND source_url LIKE '%/ekaterinburg/%'
|
||||
-- сперва листинги без координат (#1967 — detail-страница даёт
|
||||
-- координаты здания), затем по свежести
|
||||
ORDER BY (lat IS NULL) DESC, scraped_at DESC NULLS LAST
|
||||
LIMIT CAST(:batch_size AS int)
|
||||
WITH ekb AS (
|
||||
SELECT id, source_url, 'ekb' AS city_scope
|
||||
FROM listings
|
||||
WHERE source = 'avito'
|
||||
AND detail_enriched_at IS NULL
|
||||
AND source_url IS NOT NULL
|
||||
AND is_active = TRUE
|
||||
AND source_url LIKE '%/ekaterinburg/%'
|
||||
-- сперва листинги без координат (#1967 — detail-страница
|
||||
-- даёт координаты здания), затем по свежести
|
||||
ORDER BY (lat IS NULL) DESC, scraped_at DESC NULLS LAST
|
||||
LIMIT CAST(:batch_size AS int)
|
||||
),
|
||||
oblast AS (
|
||||
SELECT id, source_url, 'oblast' AS city_scope
|
||||
FROM listings
|
||||
WHERE source = 'avito'
|
||||
AND detail_enriched_at IS NULL
|
||||
AND source_url IS NOT NULL
|
||||
AND is_active = TRUE
|
||||
AND source_url LIKE ANY(CAST(:oblast_patterns AS text[]))
|
||||
ORDER BY (lat IS NULL) DESC, scraped_at DESC NULLS LAST
|
||||
LIMIT CAST(:oblast_batch_size AS int)
|
||||
)
|
||||
SELECT id, source_url, city_scope FROM ekb
|
||||
UNION ALL
|
||||
SELECT id, source_url, city_scope FROM oblast
|
||||
"""
|
||||
),
|
||||
{"batch_size": batch_size},
|
||||
{
|
||||
"batch_size": batch_size,
|
||||
"oblast_patterns": list(_OBLAST_AVITO_URL_PATTERNS),
|
||||
"oblast_batch_size": oblast_batch_size,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
|
|
@ -227,11 +288,16 @@ async def run_avito_detail_backfill(
|
|||
runs_mod.mark_done(db, run_id, current_counters)
|
||||
return counters
|
||||
|
||||
# #2576: разбивка ekb/oblast только для наблюдаемости -- .get() консервативен
|
||||
# (city_scope нет в mock-снапшотах старых тестов, дефолт "ekb" их не ломает).
|
||||
oblast_count = sum(1 for row in snapshot if row.get("city_scope") == "oblast")
|
||||
logger.info(
|
||||
"avito_detail_backfill: run_id=%d snapshot=%d (budget=%.0fs "
|
||||
"delay=%.1fs max_blocks=%d mode=%s)",
|
||||
"avito_detail_backfill: run_id=%d snapshot=%d (ekb=%d oblast=%d, "
|
||||
"budget=%.0fs delay=%.1fs max_blocks=%d mode=%s)",
|
||||
run_id,
|
||||
len(snapshot),
|
||||
len(snapshot) - oblast_count,
|
||||
oblast_count,
|
||||
budget_sec,
|
||||
request_delay_sec,
|
||||
max_consecutive_blocks,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,20 @@
|
|||
Парсинг адреса — _parse_street_house из app.services.geocoder (готовый парсер),
|
||||
работающий с формами «г. Екатеринбург, ул. Малышева, 30, кв. 28».
|
||||
|
||||
Городской гейт (#2583, находка H3): в `listings` НЕТ отдельной колонки города — город
|
||||
известен только из текста адреса. `ekb_geoportal_buildings` — EKB-only реестр: улица+дом
|
||||
могут буквально совпасть между Екатеринбургом и другим городом области (например,
|
||||
«проспект Ленина 1» есть и в ЕКБ, и в Нижнем Тагиле). Без проверки города такой листинг
|
||||
получает екатеринбургские координаты, хотя находится в другом городе. Перед вызовом
|
||||
_geoportal_house_match каждый адрес проверяется через _names_non_ekb_city (та же функция,
|
||||
что гейтит EKB-only тиры внутри geocoder.geocode()) — адрес, явно называющий другой город
|
||||
региона, пропускается (counted как skipped_non_ekb) и остаётся lat IS NULL для
|
||||
geocode_missing_listings (oblast-aware Nominatim/Yandex, окно 06:00-09:00 UTC).
|
||||
Прямой вызов _geoportal_house_match (а не полноценный geocode()) оставлен намеренно —
|
||||
это pure local-DB матч без единого внешнего HTTP-запроса; полноценный geocode() на каждый
|
||||
non-EKB адрес добавил бы Nominatim/Yandex вызов на весь backlog (сотни-тысячи строк за
|
||||
ночь) — лишняя нагрузка на и так ограниченный Nominatim (Yandex сейчас 403, #2585).
|
||||
|
||||
Запуск:
|
||||
python -m app.tasks.backfill_listings_coords_geoportal
|
||||
python -m app.tasks.backfill_listings_coords_geoportal --limit 5000 --batch-size 200
|
||||
|
|
@ -19,7 +33,17 @@ migration 171) — run_geoportal_coords_backfill(). Local exact match, ника
|
|||
HTTP/rate-limit, поэтому окно ставится ПЕРЕД geocode_missing_listings (Nominatim/Yandex,
|
||||
coarse city-centroid fallback): точный house-level матч должен получить шанс первым,
|
||||
иначе Nominatim успевает проставить грубые coords и адрес выпадает из WHERE lat IS NULL
|
||||
(#1967 — было единичным manual-прогоном #1841, здесь становится recurring).
|
||||
(#1967 — было единичным manual-прогоном #1841, здесь становится recurring). С городским
|
||||
гейтом (#2583) порядок окон остаётся корректным: не-ЕКБ адреса больше не матчатся здесь
|
||||
вообще, поэтому «победа в гонке» больше не портит их координаты — они просто ждут
|
||||
geocode_missing_listings в следующем окне, как и раньше для адресов без geoportal-матча.
|
||||
|
||||
geo_precision: этот тир всегда даёт house-level точный матч (не city-centroid), поэтому
|
||||
_update_listing_coords НЕ проставляет geo_precision — он остаётся NULL, что в текущей
|
||||
конвенции (089_listings_geo_precision.sql, geocode_missing.py) означает «не coarse»
|
||||
(тот же смысл, что и geo_precision=None для precise-адресов в geocode_missing_listings).
|
||||
Downstream-фильтры (`geo_precision IS DISTINCT FROM 'city'`) корректно НЕ исключают такие
|
||||
строки — исключать нужно только 'city'-fallback, а не «пока не размечено».
|
||||
|
||||
Идемпотентность: UPDATE применяется только к строкам с lat IS NULL (WHERE id=:id AND lat IS NULL).
|
||||
Повторный прогон не затирает уже проставленные координаты.
|
||||
|
|
@ -37,7 +61,7 @@ from sqlalchemy.orm import Session
|
|||
|
||||
from app.core.db import SessionLocal
|
||||
from app.services import scrape_runs as runs_mod
|
||||
from app.services.geocoder import _geoportal_house_match, _parse_street_house
|
||||
from app.services.geocoder import _geoportal_house_match, _names_non_ekb_city, _parse_street_house
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -55,6 +79,7 @@ class BackfillCoordsResult:
|
|||
updated: int = 0 # реально обновлено (UPDATE rowcount)
|
||||
no_address: int = 0 # listing.address IS NULL / не распарсился
|
||||
no_match: int = 0 # адрес распарсился, но в реестре здания нет
|
||||
skipped_non_ekb: int = 0 # адрес явно называет другой город области (#2583 гейт)
|
||||
errors: int = 0 # исключения при обработке отдельной записи
|
||||
duration_sec: float = field(default=0.0)
|
||||
|
||||
|
|
@ -65,6 +90,7 @@ class BackfillCoordsResult:
|
|||
"updated": self.updated,
|
||||
"no_address": self.no_address,
|
||||
"no_match": self.no_match,
|
||||
"skipped_non_ekb": self.skipped_non_ekb,
|
||||
"errors": self.errors,
|
||||
"duration_sec": int(self.duration_sec),
|
||||
}
|
||||
|
|
@ -184,6 +210,14 @@ def backfill_coords_from_geoportal(
|
|||
res.no_address += 1
|
||||
continue
|
||||
|
||||
# Городской гейт (#2583, H3) — ekb_geoportal_buildings EKB-only,
|
||||
# улица+дом могут совпасть с другим городом области. Адрес, явно
|
||||
# называющий другой город региона, пропускаем — остаётся
|
||||
# lat IS NULL для oblast-aware geocode_missing_listings.
|
||||
if _names_non_ekb_city(address):
|
||||
res.skipped_non_ekb += 1
|
||||
continue
|
||||
|
||||
# Парсинг адреса — переиспользуем парсер geocoder'а
|
||||
parsed = _parse_street_house(address)
|
||||
if parsed is None:
|
||||
|
|
@ -259,12 +293,13 @@ def backfill_coords_from_geoportal(
|
|||
|
||||
logger.info(
|
||||
"backfill_coords: DONE — candidates=%d matched=%d updated=%d "
|
||||
"no_address=%d no_match=%d errors=%d duration=%.1fs",
|
||||
"no_address=%d no_match=%d skipped_non_ekb=%d errors=%d duration=%.1fs",
|
||||
res.candidates,
|
||||
res.matched,
|
||||
res.updated,
|
||||
res.no_address,
|
||||
res.no_match,
|
||||
res.skipped_non_ekb,
|
||||
res.errors,
|
||||
res.duration_sec,
|
||||
)
|
||||
|
|
@ -314,13 +349,14 @@ def run_geoportal_coords_backfill(
|
|||
runs_mod.mark_done(db, run_id, counters)
|
||||
logger.info(
|
||||
"run_geoportal_coords_backfill: run_id=%d DONE candidates=%d matched=%d "
|
||||
"updated=%d no_address=%d no_match=%d errors=%d duration=%.1fs",
|
||||
"updated=%d no_address=%d no_match=%d skipped_non_ekb=%d errors=%d duration=%.1fs",
|
||||
run_id,
|
||||
res.candidates,
|
||||
res.matched,
|
||||
res.updated,
|
||||
res.no_address,
|
||||
res.no_match,
|
||||
res.skipped_non_ekb,
|
||||
res.errors,
|
||||
res.duration_sec,
|
||||
)
|
||||
|
|
@ -376,12 +412,13 @@ def main() -> None:
|
|||
|
||||
logger.info(
|
||||
"Готово: кандидатов=%d сматчено=%d обновлено=%d "
|
||||
"без_адреса=%d без_матча=%d ошибок=%d время=%.1fs",
|
||||
"без_адреса=%d без_матча=%d не_ЕКБ=%d ошибок=%d время=%.1fs",
|
||||
result.candidates,
|
||||
result.matched,
|
||||
result.updated,
|
||||
result.no_address,
|
||||
result.no_match,
|
||||
result.skipped_non_ekb,
|
||||
result.errors,
|
||||
result.duration_sec,
|
||||
)
|
||||
|
|
|
|||
161
tradein-mvp/backend/app/tasks/deal_city_price_bands_refresh.py
Normal file
161
tradein-mvp/backend/app/tasks/deal_city_price_bands_refresh.py
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
"""Daily recompute of per-city ppm² plausible-deal guard-bands (#2576 Stage B).
|
||||
|
||||
ПРОБЛЕМА: deal_city_price_bands (migration 178, tier-схема — migration 194)
|
||||
засеяна ON CONFLICT DO UPDATE derivation-запросом. По мере ночного импорта новых
|
||||
ДКП-сделок (rosreestr_dkp_import) города переходят между tier ('region_fallback'
|
||||
N<10 → 'rough' N 10-29 → 'full' N>=30), а перцентили внутри tier дрейфуют — нужен
|
||||
периодический пересчёт по той же derivation.
|
||||
|
||||
Задача синхронная (DB-only, никаких внешних HTTP-вызовов) — запускается
|
||||
kit-scheduler'ом через product_handlers._job_deal_city_price_bands_refresh
|
||||
(run_in_executor), по образцу asking_to_sold_ratio.py / snapshot_listing_sources.
|
||||
|
||||
Окно расписания 07:00-08:00 UTC — ПОСЛЕ rosreestr_dkp_import (04:00-06:00 UTC) И
|
||||
asking_to_sold_ratio_refresh (06:00-07:00 UTC), чтобы бэнды считались по тому же
|
||||
свежему срезу deals, что и ratio-таблица того же дня.
|
||||
|
||||
SQL derivation ниже — БАЙТ-В-БАЙТ та же логика, что seed в
|
||||
data/sql/194_deal_city_price_bands_tiers.sql (region_stats / city_stats / tiered:
|
||||
трёхуровневая схема full N>=30 / rough N 10-29 / region_fallback N 1-9, см.
|
||||
комментарий в 194 для полного обоснования тиров и hard floor/ceiling клампов).
|
||||
|
||||
Нет DELETE перед re-derive (в отличие от asking_to_sold_ratio.py true-mirror
|
||||
паттерна) — множество городов монотонно растёт (rosreestr_dkp_import только
|
||||
INSERT/ON CONFLICT DO UPDATE, никогда не удаляет сделки), поэтому merge-по-city
|
||||
(ON CONFLICT DO UPDATE) достаточен: город, перешедший в другой tier, просто
|
||||
перезаписывается на следующем refresh. Екатеринбург НЕ включён (WHERE city <>
|
||||
'Екатеринбург') — estimator.py fallback на глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2
|
||||
для ЕКБ остаётся byte-identical (invariant из 178/194 сохранён).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services import scrape_runs as runs_mod
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Derivation + re-seed (БАЙТ-В-БАЙТ из 194) ─────────────────────────────────
|
||||
_REDERIVE_SQL = text(
|
||||
"""
|
||||
WITH region_stats AS (
|
||||
SELECT GREATEST(
|
||||
round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int,
|
||||
8000
|
||||
) AS region_ppm2_min
|
||||
FROM deals
|
||||
WHERE source = 'rosreestr'
|
||||
AND price_per_m2 IS NOT NULL
|
||||
AND city IS NOT NULL
|
||||
AND city <> 'Екатеринбург'
|
||||
),
|
||||
city_stats AS (
|
||||
SELECT
|
||||
city,
|
||||
GREATEST(round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int, 8000)
|
||||
AS ppm2_p1,
|
||||
LEAST(round(percentile_cont(0.99) WITHIN GROUP (ORDER BY price_per_m2))::int, 800000)
|
||||
AS ppm2_p99,
|
||||
count(*) AS n_deals
|
||||
FROM deals
|
||||
WHERE source = 'rosreestr'
|
||||
AND price_per_m2 IS NOT NULL
|
||||
AND city IS NOT NULL
|
||||
AND city <> 'Екатеринбург'
|
||||
GROUP BY city
|
||||
),
|
||||
tiered AS (
|
||||
SELECT city, ppm2_p1 AS ppm2_min, ppm2_p99 AS ppm2_max, n_deals,
|
||||
'full'::text AS tier
|
||||
FROM city_stats
|
||||
WHERE n_deals >= 30
|
||||
AND ppm2_p99 >= 8000
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT city, LEAST(ppm2_p1, 700000) AS ppm2_min, 800000 AS ppm2_max, n_deals,
|
||||
'rough'::text AS tier
|
||||
FROM city_stats
|
||||
WHERE n_deals BETWEEN 10 AND 29
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT c.city, r.region_ppm2_min AS ppm2_min, 800000 AS ppm2_max, c.n_deals,
|
||||
'region_fallback'::text AS tier
|
||||
FROM city_stats c
|
||||
CROSS JOIN region_stats r
|
||||
WHERE c.n_deals < 10
|
||||
)
|
||||
INSERT INTO deal_city_price_bands (city, ppm2_min, ppm2_max, n_deals, tier, refreshed_at)
|
||||
SELECT city, ppm2_min, ppm2_max, n_deals, tier, now()
|
||||
FROM tiered
|
||||
ON CONFLICT (city) DO UPDATE
|
||||
SET ppm2_min = EXCLUDED.ppm2_min,
|
||||
ppm2_max = EXCLUDED.ppm2_max,
|
||||
n_deals = EXCLUDED.n_deals,
|
||||
tier = EXCLUDED.tier,
|
||||
refreshed_at = EXCLUDED.refreshed_at
|
||||
"""
|
||||
)
|
||||
|
||||
# ── Post-insert counters ──────────────────────────────────────────────────────
|
||||
_COUNTERS_SQL = text(
|
||||
"""
|
||||
SELECT
|
||||
COUNT(*) AS rows_written,
|
||||
COUNT(*) FILTER (WHERE tier = 'full') AS full_rows,
|
||||
COUNT(*) FILTER (WHERE tier = 'rough') AS rough_rows,
|
||||
COUNT(*) FILTER (WHERE tier = 'region_fallback') AS region_fallback_rows
|
||||
FROM deal_city_price_bands
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def refresh_deal_city_price_bands(db: Session, run_id: int) -> dict[str, int]:
|
||||
"""Пересчитать deal_city_price_bands (#2576 Stage B — tier-aware refresh).
|
||||
|
||||
Sync (вызывается scheduler-триггером в executor, как recompute_asking_to_sold_ratios).
|
||||
Одна транзакция: re-derive INSERT ... ON CONFLICT DO UPDATE (нет DELETE — см.
|
||||
module docstring), затем counters из таблицы, commit, mark_done.
|
||||
|
||||
Финализирует scrape_runs (mark_done / mark_failed) и пишет counters.
|
||||
|
||||
Returns {"rows_written": N, "full_rows": .., "rough_rows": .., "region_fallback_rows": ..}.
|
||||
"""
|
||||
counters: dict[str, int] = {
|
||||
"rows_written": 0,
|
||||
"full_rows": 0,
|
||||
"rough_rows": 0,
|
||||
"region_fallback_rows": 0,
|
||||
}
|
||||
try:
|
||||
db.execute(_REDERIVE_SQL)
|
||||
|
||||
row = db.execute(_COUNTERS_SQL).mappings().first()
|
||||
if row is not None:
|
||||
counters["rows_written"] = int(row["rows_written"] or 0)
|
||||
counters["full_rows"] = int(row["full_rows"] or 0)
|
||||
counters["rough_rows"] = int(row["rough_rows"] or 0)
|
||||
counters["region_fallback_rows"] = int(row["region_fallback_rows"] or 0)
|
||||
|
||||
db.commit()
|
||||
runs_mod.mark_done(db, run_id, counters)
|
||||
logger.info(
|
||||
"refresh_deal_city_price_bands run_id=%d done: "
|
||||
"rows_written=%d full=%d rough=%d region_fallback=%d",
|
||||
run_id,
|
||||
counters["rows_written"],
|
||||
counters["full_rows"],
|
||||
counters["rough_rows"],
|
||||
counters["region_fallback_rows"],
|
||||
)
|
||||
return counters
|
||||
except Exception as exc:
|
||||
logger.exception("refresh_deal_city_price_bands run_id=%d failed", run_id)
|
||||
db.rollback()
|
||||
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
|
||||
raise
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
— wired into in-app scheduler, window 06:00-09:00 UTC.
|
||||
|
||||
Pattern: dedup по address (1 unique address → 1 geocode call → UPDATE all listings).
|
||||
Rate limit: Nominatim 1 req/sec. Yandex 25K/day если YANDEX_GEOCODER_API_KEY set.
|
||||
Rate limit: Nominatim 1 req/sec (#2593: Yandex Geocoder tier удалён из geocoder).
|
||||
|
||||
Отличие от /admin/geocode-missing (per-ID):
|
||||
- Этот модуль группирует по address → меньше API calls (dedup).
|
||||
|
|
|
|||
|
|
@ -13,7 +13,15 @@
|
|||
-- precision БЕЗ единого внешнего HTTP-запроса (в отличие от Nominatim) — но был ТОЛЬКО
|
||||
-- manual script (`python -m app.tasks.backfill_listings_coords_geoportal`), ни разу не
|
||||
-- запускавшийся на recurring основе. Один прошлый ручной прогон (#1841): 17241
|
||||
-- кандидатов → 1008 проставлено (не-ЕКБ адреса не матчатся — корректно, EKB-only реестр).
|
||||
-- кандидатов → 1008 проставлено.
|
||||
--
|
||||
-- ИСПРАВЛЕНО #2583 (находка H3): до фикса не-ЕКБ адреса region 66 (Нижний Тагил, Серов
|
||||
-- и т.д.) НЕ отсекались — street+house парсились без учёта города и слепо матчились
|
||||
-- против EKB-only реестра. Улица+дом могут буквально совпасть с ЕКБ ("проспект Ленина 1"
|
||||
-- есть и в ЕКБ, и в Нижнем Тагиле) — такой листинг получал координаты Екатеринбурга.
|
||||
-- Фикс: городской гейт _names_non_ekb_city перед вызовом _geoportal_house_match (тот же
|
||||
-- гейт, что и в geocoder.geocode()). Не "корректно, EKB-only реестр", как было написано
|
||||
-- здесь раньше — это была реальная утечка не-ЕКБ адресов в ЕКБ-координаты.
|
||||
--
|
||||
-- Решение: wire в in-app scheduler (source='geoportal_coords_backfill') по паттерну
|
||||
-- cadastral_geo_match (migration 125) — pure internal DB op, SAFE to enable=true.
|
||||
|
|
|
|||
88
tradein-mvp/backend/data/sql/192_tradein_users_auth.sql
Normal file
88
tradein-mvp/backend/data/sql/192_tradein_users_auth.sql
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
-- Migration 192: tradein_users + tradein_sessions — DB-backed auth (issue #2551, эпик #2549)
|
||||
--
|
||||
-- WHY:
|
||||
-- Trade-in auth сейчас держится на legacy Caddy basic-auth fallback (см. auth/roles.yaml,
|
||||
-- упомянут в 191_account_quota_unlimited_flag.sql как "хардкод username в коде"). Эпик #2549
|
||||
-- переводит auth на DB-backed модель: пользователи + сессии как данные, роли admin/manager/
|
||||
-- employee с иерархией manager -> employee. Эта миграция — только схема (Foundation),
|
||||
-- без seed-данных (seed — отдельная задача #2557) и без Python-кода (backend wiring — отдельно).
|
||||
--
|
||||
-- WHAT:
|
||||
-- 1. tradein_users — identity + role + org-иерархия.
|
||||
-- - password_hash NULL допустим: переходный период, когда логин ещё идёт через
|
||||
-- legacy Caddy fallback, а не через password verify в приложении.
|
||||
-- - role CHECK ('admin','manager','employee') — три уровня доступа.
|
||||
-- - manager_id — self-FK, ON DELETE SET NULL (увольнение/удаление manager'а не должно
|
||||
-- каскадно сносить его employees, они просто остаются без привязки).
|
||||
-- - CHECK role_manager_hierarchy: admin/manager обязаны иметь manager_id IS NULL
|
||||
-- (это top-level роли, у них нет "начальника" в этой модели); employee — manager_id
|
||||
-- любой, включая NULL (свободный слот employee без организации допустим).
|
||||
-- 2. tradein_sessions — токен-based сессии, привязаны к user_id, ON DELETE CASCADE
|
||||
-- (удалили пользователя — его сессии теряют смысл, каскадная очистка корректна).
|
||||
-- last_seen_at отдельно от created_at — для idle-timeout / активности сессии.
|
||||
-- 3. Индексы: expires_at (уборка протухших сессий), user_id (список сессий юзера),
|
||||
-- partial на manager_id (иерархия) — WHERE manager_id IS NOT NULL, т.к. большинство
|
||||
-- admin/manager строк это NULL и не участвуют в lookup "employees этого manager'а".
|
||||
--
|
||||
-- IDEMPOTENCY:
|
||||
-- CREATE TABLE IF NOT EXISTS + CREATE INDEX IF NOT EXISTS. Повторный прогон — no-op.
|
||||
-- CHECK-констрейнты добавлены inline в CREATE TABLE (не через ALTER) — при повторном
|
||||
-- запуске CREATE TABLE IF NOT EXISTS не выполнится вообще, констрейнт не задублируется.
|
||||
--
|
||||
-- Dependencies: нет (новые таблицы, ничего существующего не меняем).
|
||||
-- Deploy order: эта миграция — Foundation эпика #2549. Seed (#2557) и backend auth-код —
|
||||
-- отдельные PR'ы ПОСЛЕ этой (SQL-схема первой, см. .claude/rules/sql.md "Migration order").
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tradein_users (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
username text NOT NULL UNIQUE,
|
||||
password_hash text NULL,
|
||||
role text NOT NULL CHECK (role IN ('admin', 'manager', 'employee')),
|
||||
manager_id bigint NULL REFERENCES tradein_users(id) ON DELETE SET NULL,
|
||||
display_name text NULL,
|
||||
org_name text NULL,
|
||||
email text NULL,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT tradein_users_role_manager_hierarchy_ck CHECK (
|
||||
role NOT IN ('admin', 'manager') OR manager_id IS NULL
|
||||
)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE tradein_users IS
|
||||
'Trade-in DB-backed auth — пользователи (issue #2551, эпик #2549). password_hash NULL '
|
||||
'допустим в переходный период (логин через legacy Caddy fallback). Seed — отдельно (#2557).';
|
||||
COMMENT ON COLUMN tradein_users.password_hash IS
|
||||
'NULL = логин только через legacy Caddy basic-auth fallback, не через password verify.';
|
||||
COMMENT ON COLUMN tradein_users.manager_id IS
|
||||
'Self-FK на tradein_users(id). NULL для admin/manager (top-level, CHECK ниже) или для '
|
||||
'employee без назначенной организации.';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tradein_sessions (
|
||||
token text PRIMARY KEY,
|
||||
user_id bigint NOT NULL REFERENCES tradein_users(id) ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz NOT NULL,
|
||||
last_seen_at timestamptz NOT NULL DEFAULT now(),
|
||||
ip_address inet NULL,
|
||||
user_agent text NULL
|
||||
);
|
||||
|
||||
COMMENT ON TABLE tradein_sessions IS
|
||||
'Trade-in DB-backed auth — активные сессии (issue #2551, эпик #2549). '
|
||||
'ON DELETE CASCADE от tradein_users: удалённый пользователь теряет все сессии.';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS tradein_sessions_expires_at_idx
|
||||
ON tradein_sessions (expires_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS tradein_sessions_user_id_idx
|
||||
ON tradein_sessions (user_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS tradein_users_manager_id_idx
|
||||
ON tradein_users (manager_id)
|
||||
WHERE manager_id IS NOT NULL;
|
||||
|
||||
COMMIT;
|
||||
127
tradein-mvp/backend/data/sql/193_tradein_users_seed.sql
Normal file
127
tradein-mvp/backend/data/sql/193_tradein_users_seed.sql
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
-- Migration 193: seed существующих юзеров в tradein_users + ASCII-CHECK на username
|
||||
-- (issue #2557, эпик #2549)
|
||||
--
|
||||
-- WHY:
|
||||
-- Migration 192 создала schema (tradein_users/tradein_sessions), но без данных —
|
||||
-- DB-backed auth не может заработать, пока реальные аккаунты (сейчас живущие только
|
||||
-- в auth/roles.yaml + caddy/users.caddy.snippet, legacy Caddy basic-auth) не отражены
|
||||
-- в таблице. Эта миграция переносит org-карту, утверждённую владельцем продукта,
|
||||
-- в данные — без единого пароля (см. WHAT.2) и без Python-кода (backend wiring — #2556,
|
||||
-- team-UI для проставления паролей — отдельная задача, тоже #2556).
|
||||
--
|
||||
-- ASCII-CHECK (deep-review #2561, обязательное требование ДО прод-данных):
|
||||
-- rbac кодирует session-username через `encode("latin-1", "replace")`. Кириллические
|
||||
-- логины ОДИНАКОВОЙ длины схлопываются в одну и ту же byte-строку под этой кодировкой
|
||||
-- ("иванов" и "петров" оба 6 кириллических символов -> оба превращаются в одинаковую
|
||||
-- строку из '?' одной длины) -> общий downstream-identity между разными людьми, общая
|
||||
-- квота, взаимный IDOR (один видит сессии/данные другого). Все текущие org-логины уже
|
||||
-- ASCII (admin/kopylov/praktika/userN), поэтому constraint не конфликтует с seed'ом
|
||||
-- ниже; он существует, чтобы navsegda запретить будущим кириллическим логинам попасть
|
||||
-- в таблицу — fail-closed на уровне схемы, а не на уровне доверия к тому, что кто-то
|
||||
-- не забудет проверить в UI/API layer.
|
||||
--
|
||||
-- WHAT:
|
||||
-- 1. ASCII-CHECK: tradein_users_username_ascii_ck CHECK (username ~ '^[A-Za-z0-9._-]{3,64}$').
|
||||
-- Добавлен ДО seed-инсертов ниже для читаемости файла (CHECK — immediate constraint,
|
||||
-- Postgres валидирует им и ROW-строки транзакции независимо от того, в каком месте
|
||||
-- файла он объявлен относительно INSERT, так что порядок сам по себе не критичен).
|
||||
-- 2. Seed — org-карта, утверждённая владельцем продукта (2026-07-30):
|
||||
-- admin role=admin, manager_id=NULL, is_active=true (владелец)
|
||||
-- kopylov role=manager, manager_id=NULL, is_active=true (отдельный клиент)
|
||||
-- praktika role=manager, manager_id=NULL, is_active=true (ГК «Практика»)
|
||||
-- user1, user3-10 role=employee, manager_id=NULL, is_active=true (свободные слоты, без org)
|
||||
-- user2 role=employee, manager_id=NULL, is_active=false («Брусника», доступ
|
||||
-- закрыт 2026-07-30)
|
||||
-- password_hash = NULL для ВСЕХ — пароли админ проставит вручную через team-UI (#2556).
|
||||
-- NULL-hash делает password-логин невозможным для этой строки, но НЕ снимает доступ:
|
||||
-- в переходный период работает только legacy Caddy basic-auth fallback (dual-mode,
|
||||
-- см. комментарий password_hash в 192_tradein_users_auth.sql) — никто не теряет доступ
|
||||
-- из-за этой миграции.
|
||||
-- display_name = 'Копылов' для kopylov (источник — auth.py::_USERNAME_PROFILE, уже
|
||||
-- задокументированная фамилия). Для остальных — NULL, реальных данных нет, не выдумываем.
|
||||
-- НЕ мигрируем admintest/pilottest/analysttest/expiredtest — temp QA-фикстуры
|
||||
-- (auth/roles.yaml), остаются только там, в DB-backed auth не нужны.
|
||||
--
|
||||
-- IDEMPOTENCY:
|
||||
-- - ADD CONSTRAINT через DO-блок с проверкой pg_constraint (Postgres не поддерживает
|
||||
-- `ADD CONSTRAINT IF NOT EXISTS` для CHECK) — паттерн из
|
||||
-- 189_account_estimate_usage_nonnegative.sql.
|
||||
-- - INSERT ... ON CONFLICT (username) DO UPDATE, но НЕ безусловно: password_hash,
|
||||
-- manager_id, display_name, org_name, email защищены COALESCE(текущее, EXCLUDED) —
|
||||
-- если админ уже проставил пароль / назначил manager_id (team-API #2563 пишет
|
||||
-- manager_id при создании сотрудника менеджером) / поменял display_name вручную
|
||||
-- через team-UI (#2556) между двумя прогонами этого файла (например ручной re-apply
|
||||
-- при recovery — обычный auto-apply тречит filename в _schema_migrations и не
|
||||
-- запускает файл дважды на одном окружении, но scratch/staging БД такого
|
||||
-- трекинга не имеют), повторный прогон НЕ должен затереть это состояние NULL-ом /
|
||||
-- seed-дефолтом. Deep-review #2564 нашёл это живым багом: manager_id, назначенный
|
||||
-- через #2563, тихо обнулялся повторным прогоном сида — employee выпадал из
|
||||
-- `_LIST_EMPLOYEES_BY_MANAGER_SQL`, менеджер переставал видеть его в дашборде.
|
||||
-- role намеренно синкается с EXCLUDED (не защищён) — это и есть источник истины
|
||||
-- org-карты из этой миграции; если владелец продукта поправит эту таблицу новой
|
||||
-- миграцией поверх, DO UPDATE-ветка должна донести исправление роли, а не
|
||||
-- заморозить первый прогон навсегда.
|
||||
-- - is_active НАМЕРЕННО отсутствует в SET (не COALESCE — колонка NOT NULL DEFAULT
|
||||
-- true, COALESCE(NOT NULL, x) никогда не берёт x, это была бы мёртвая, вводящая в
|
||||
-- заблуждение симметрия с password_hash/manager_id, deep-review #2564 medium).
|
||||
-- Открытие/закрытие доступа (is_active) — решение владельца продукта, принимается
|
||||
-- через UI (#2556), НЕ повторным прогоном этого seed-файла: после первой вставки
|
||||
-- колонка сознательно «замораживается» на текущем значении в БД, seed её больше
|
||||
-- не трогает.
|
||||
--
|
||||
-- Dependencies: 192_tradein_users_auth.sql (создаёт tradein_users, tradein_sessions).
|
||||
-- Deploy order: после 192 (Foundation). Backend auth-код (login/password-verify) и
|
||||
-- team-UI (#2556) — отдельные PR'ы ПОСЛЕ этой миграции (SQL-схема+данные первыми, см.
|
||||
-- .claude/rules/sql.md "Migration order").
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Часть 1: ASCII-CHECK (immediate constraint — валидирует и вставляемые ниже строки).
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'tradein_users_username_ascii_ck'
|
||||
) THEN
|
||||
ALTER TABLE tradein_users
|
||||
ADD CONSTRAINT tradein_users_username_ascii_ck
|
||||
CHECK (username ~ '^[A-Za-z0-9._-]{3,64}$');
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMENT ON CONSTRAINT tradein_users_username_ascii_ck ON tradein_users IS
|
||||
'Fail-closed защита от кириллических/не-ASCII логинов (deep-review #2561): '
|
||||
'rbac кодирует session-username через encode("latin-1","replace"), не-ASCII '
|
||||
'логины одинаковой длины схлопываются в общий downstream-identity (IDOR).';
|
||||
|
||||
-- Часть 2: seed org-карты (владелец продукта, 2026-07-30).
|
||||
INSERT INTO tradein_users
|
||||
(username, password_hash, role, manager_id, display_name, org_name, email, is_active)
|
||||
VALUES
|
||||
('admin', NULL, 'admin', NULL, NULL, NULL, NULL, true),
|
||||
('kopylov', NULL, 'manager', NULL, 'Копылов', NULL, NULL, true),
|
||||
('praktika', NULL, 'manager', NULL, NULL, NULL, NULL, true),
|
||||
('user1', NULL, 'employee', NULL, NULL, NULL, NULL, true),
|
||||
('user2', NULL, 'employee', NULL, NULL, NULL, NULL, false),
|
||||
('user3', NULL, 'employee', NULL, NULL, NULL, NULL, true),
|
||||
('user4', NULL, 'employee', NULL, NULL, NULL, NULL, true),
|
||||
('user5', NULL, 'employee', NULL, NULL, NULL, NULL, true),
|
||||
('user6', NULL, 'employee', NULL, NULL, NULL, NULL, true),
|
||||
('user7', NULL, 'employee', NULL, NULL, NULL, NULL, true),
|
||||
('user8', NULL, 'employee', NULL, NULL, NULL, NULL, true),
|
||||
('user9', NULL, 'employee', NULL, NULL, NULL, NULL, true),
|
||||
('user10', NULL, 'employee', NULL, NULL, NULL, NULL, true)
|
||||
ON CONFLICT (username) DO UPDATE SET
|
||||
role = EXCLUDED.role,
|
||||
-- manager_id защищён COALESCE: team-API (#2563) пишет manager_id при назначении
|
||||
-- сотрудника менеджером, повторный прогон seed'а не должен тихо обнулять эту связь.
|
||||
manager_id = COALESCE(tradein_users.manager_id, EXCLUDED.manager_id),
|
||||
password_hash = COALESCE(tradein_users.password_hash, EXCLUDED.password_hash),
|
||||
display_name = COALESCE(tradein_users.display_name, EXCLUDED.display_name),
|
||||
org_name = COALESCE(tradein_users.org_name, EXCLUDED.org_name),
|
||||
email = COALESCE(tradein_users.email, EXCLUDED.email),
|
||||
-- is_active НЕ в SET: NOT NULL DEFAULT true колонка, COALESCE был бы мёртвым кодом
|
||||
-- (см. IDEMPOTENCY выше) — open/close доступа решается через UI (#2556), не seed'ом.
|
||||
updated_at = now();
|
||||
|
||||
COMMIT;
|
||||
150
tradein-mvp/backend/data/sql/194_deal_city_price_bands_tiers.sql
Normal file
150
tradein-mvp/backend/data/sql/194_deal_city_price_bands_tiers.sql
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
-- 194_deal_city_price_bands_tiers.sql
|
||||
-- Эпик #2576 Stage B — многоуровневые ценовые бэнды по городам + честный
|
||||
-- региональный фолбэк вместо ЕКБ-калиброванного порога.
|
||||
--
|
||||
-- ПРОБЛЕМА:
|
||||
-- Миграция 178 построила deal_city_price_bands РАЗОВО, только для городов
|
||||
-- с count(*) >= 30 сделок (HAVING count(*) >= 30) на момент прогона. Auto-refresh
|
||||
-- не был реализован (см. комментарий в 178). Город без строки в таблице
|
||||
-- попадает на глобальный DEAL_MIN_PPM2=50_000 (estimator.py) — порог,
|
||||
-- откалиброванный ИСКЛЮЧИТЕЛЬНО по Екатеринбургу. Для малых городов области
|
||||
-- это не anti-outlier guard, а cut-off легитимного рынка (Североуральск
|
||||
-- median ≈ 21.7k ₽/м²).
|
||||
--
|
||||
-- Замер по прод-данным deals (2026-07-31, source='rosreestr', city IS NOT NULL,
|
||||
-- city <> 'Екатеринбург', price_per_m2 IS NOT NULL — 47 253 сделки / 369 городов):
|
||||
-- N>=30 сделок → 80 городов (45 988 сделок, 97.3%) — уже покрыты 178.
|
||||
-- N 15-29 → 21 город ( 460 сделок) — падали на global-50k fallback.
|
||||
-- N 10-14 → 21 город ( 247 сделок) — падали на global-50k fallback.
|
||||
-- N 1-9 → 247 городов ( 558 сделок) — падали на global-50k fallback,
|
||||
-- per-city перцентиль на такой выборке статистически бессмысленен
|
||||
-- (n=1 → «перцентиль» = единственная сделка).
|
||||
-- Итого 289 городов / 1265 сделок (2.7% выборки, но 78% ДОЛГОГО ХВОСТА городов)
|
||||
-- получали ЕКБ-калиброванный пол вместо своей реальной цены.
|
||||
--
|
||||
-- РЕШЕНИЕ — трёхуровневая схема (колонка tier), вместо единого порога 30:
|
||||
-- 'full' N>=30 — own p1/p99 перцентиль (BYTE-IDENTICAL 178-derivation,
|
||||
-- ЕКБ и существующие 80 городов НЕ меняются).
|
||||
-- 'rough' 10<=N<30 — own p1 (floor), ceiling ФИКСИРОВАН на 800000
|
||||
-- (не деривится из тонкой выборки — p99 на <30 точках
|
||||
-- нестабилен, одна дорогая сделка исказит потолок).
|
||||
-- 'region_fallback' 1<=N<10 — own-данные города СЛИШКОМ тонкие даже для floor
|
||||
-- (единичная сделка = 100% перцентиля недостоверна).
|
||||
-- Используем ПУЛ по всей области (region_stats CTE,
|
||||
-- p1 по 47k+ не-ЕКБ сделкам = 15 263 ₽/м² на момент
|
||||
-- замера) вместо DEAL_MIN_PPM2=50000 (ЕКБ-калибровка).
|
||||
-- Честнее: 15k отражает реальный низ рынка обл.66,
|
||||
-- а не искусственно завышенный екб-порог.
|
||||
--
|
||||
-- Екатеринбург по-прежнему НЕ включён (estimator.py fallback на глобальные
|
||||
-- DEAL_MIN_PPM2/DEAL_MAX_PPM2 остаётся единственным путём для ЕКБ — invariant
|
||||
-- из 178 сохранён). После этой миграции ЕВСЕ 369 не-ЕКБ городов, встречающихся
|
||||
-- в deals, получают строку — Python-fallback в estimator.py (COALESCE(b.ppm2_min,
|
||||
-- :ppm_min)) отныне срабатывает практически только для ЕКБ (плюс узкое окно
|
||||
-- между refresh-циклами для только что появившегося города).
|
||||
--
|
||||
-- IDEMPOTENCY: ADD COLUMN IF NOT EXISTS + DO-блок guard на CHECK constraint
|
||||
-- (PG 16 не поддерживает ADD CONSTRAINT IF NOT EXISTS). INSERT ... ON CONFLICT
|
||||
-- DO UPDATE — повторный прогон рефрешит бэнды под свежие сделки (та же
|
||||
-- семантика, что и 178). Без DELETE — множество городов монотонно растёт
|
||||
-- (rosreestr_dkp_import только INSERT/UPDATE, никогда не удаляет), поэтому
|
||||
-- merge-по-ключу достаточен (см. app/tasks/deal_city_price_bands_refresh.py —
|
||||
-- периодический refresh, та же derivation байт-в-байт).
|
||||
--
|
||||
-- Dependencies: 177_deals_city_region.sql (deals.city), 178_deal_city_price_bands.sql
|
||||
-- (таблица + PK(city)).
|
||||
-- Apply after: --
|
||||
-- Deploy order: эта миграция ПЕРЕД деплоем backend-кода, который регистрирует
|
||||
-- scheduler-source 'deal_city_price_bands_refresh' (product_handlers.py) —
|
||||
-- см. 195_scrape_schedules_seed_deal_city_price_bands_refresh.sql (deploy after
|
||||
-- backend-код задеплоен, тот же порядок, что 088).
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE deal_city_price_bands
|
||||
ADD COLUMN IF NOT EXISTS tier text NOT NULL DEFAULT 'full';
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'deal_city_price_bands_tier_check'
|
||||
) THEN
|
||||
ALTER TABLE deal_city_price_bands
|
||||
ADD CONSTRAINT deal_city_price_bands_tier_check
|
||||
CHECK (tier IN ('full', 'rough', 'region_fallback'));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMENT ON COLUMN deal_city_price_bands.tier IS
|
||||
'full: N>=30 сделок, own p1/p99 band (миграция 178, unchanged). '
|
||||
'rough: 10<=N<30, own p1 floor + фиксированный 800000 ceiling (миграция 194). '
|
||||
'region_fallback: 1<=N<10, pooled Свердловская-обл. p1 floor (region_stats, '
|
||||
'все не-ЕКБ сделки) + фиксированный 800000 ceiling — вместо '
|
||||
'ЕКБ-калиброванного DEAL_MIN_PPM2=50000 (estimator.py).';
|
||||
|
||||
WITH region_stats AS (
|
||||
-- Пул по ВСЕЙ области (не-ЕКБ) — честный фолбэк для городов, где own-выборка
|
||||
-- (N<10) слишком тонкая для собственного перцентиля.
|
||||
SELECT GREATEST(
|
||||
round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int,
|
||||
8000
|
||||
) AS region_ppm2_min
|
||||
FROM deals
|
||||
WHERE source = 'rosreestr'
|
||||
AND price_per_m2 IS NOT NULL
|
||||
AND city IS NOT NULL
|
||||
AND city <> 'Екатеринбург'
|
||||
),
|
||||
city_stats AS (
|
||||
SELECT
|
||||
city,
|
||||
GREATEST(round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int, 8000)
|
||||
AS ppm2_p1,
|
||||
LEAST(round(percentile_cont(0.99) WITHIN GROUP (ORDER BY price_per_m2))::int, 800000)
|
||||
AS ppm2_p99,
|
||||
count(*) AS n_deals
|
||||
FROM deals
|
||||
WHERE source = 'rosreestr'
|
||||
AND price_per_m2 IS NOT NULL
|
||||
AND city IS NOT NULL
|
||||
AND city <> 'Екатеринбург'
|
||||
GROUP BY city
|
||||
),
|
||||
tiered AS (
|
||||
-- full — байт-в-байт исходная 178-derivation (own p1/p99), плюс тот же
|
||||
-- анти-мусорный инвариант (p99 < 8000 → город не матчил бы ни одну сделку).
|
||||
SELECT city, ppm2_p1 AS ppm2_min, ppm2_p99 AS ppm2_max, n_deals,
|
||||
'full'::text AS tier
|
||||
FROM city_stats
|
||||
WHERE n_deals >= 30
|
||||
AND ppm2_p99 >= 8000
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- rough — собственный p1 (floor), ceiling НЕ деривится (тонкая выборка).
|
||||
SELECT city, LEAST(ppm2_p1, 700000) AS ppm2_min, 800000 AS ppm2_max, n_deals,
|
||||
'rough'::text AS tier
|
||||
FROM city_stats
|
||||
WHERE n_deals BETWEEN 10 AND 29
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- region_fallback — собственных данных недостаточно даже для floor, берём
|
||||
-- пул по области целиком.
|
||||
SELECT c.city, r.region_ppm2_min AS ppm2_min, 800000 AS ppm2_max, c.n_deals,
|
||||
'region_fallback'::text AS tier
|
||||
FROM city_stats c
|
||||
CROSS JOIN region_stats r
|
||||
WHERE c.n_deals < 10
|
||||
)
|
||||
INSERT INTO deal_city_price_bands (city, ppm2_min, ppm2_max, n_deals, tier, refreshed_at)
|
||||
SELECT city, ppm2_min, ppm2_max, n_deals, tier, now()
|
||||
FROM tiered
|
||||
ON CONFLICT (city) DO UPDATE
|
||||
SET ppm2_min = EXCLUDED.ppm2_min,
|
||||
ppm2_max = EXCLUDED.ppm2_max,
|
||||
n_deals = EXCLUDED.n_deals,
|
||||
tier = EXCLUDED.tier,
|
||||
refreshed_at = EXCLUDED.refreshed_at;
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
-- 195_scrape_schedules_seed_deal_city_price_bands_refresh.sql
|
||||
-- Эпик #2576 Stage B — seed scrape_schedules row для daily-рефреша
|
||||
-- deal_city_price_bands (миграция 194).
|
||||
--
|
||||
-- ПРОБЛЕМА: 178/194 заполняют deal_city_price_bands на момент прогона миграции.
|
||||
-- По мере ночного импорта новых ДКП-сделок (rosreestr_dkp_import, 04:00-06:00 UTC)
|
||||
-- бэнды (own p1/p99, tier-границы N) устаревают — города переходят между tier
|
||||
-- ('region_fallback' → 'rough' → 'full') по мере накопления сделок, а сами
|
||||
-- перцентили внутри tier дрейфуют. Auto-refresh отсутствовал (см. follow-up
|
||||
-- в 178) — эта миграция закрывает разрыв.
|
||||
--
|
||||
-- Задача (app/tasks/deal_city_price_bands_refresh.py, byte-identical derivation
|
||||
-- 194) — pure-internal DB re-derivation, никаких внешних HTTP-вызовов. Запускается
|
||||
-- kit-scheduler'ом через product_handlers._job_deal_city_price_bands_refresh
|
||||
-- (run_in_executor, по образцу _job_asking_to_sold_ratio).
|
||||
--
|
||||
-- enabled = true — БЕЗОПАСНО включать сразу (тот же аргумент, что 082/088: pure DB,
|
||||
-- без анти-бота).
|
||||
-- Окно 07:00-08:00 UTC — ПОСЛЕ rosreestr_dkp_import (04:00-06:00, см. 072) И
|
||||
-- asking_to_sold_ratio_refresh (06:00-07:00, см. 082), чтобы бэнды считались по
|
||||
-- тому же свежему срезу deals, что и ratio-таблица того же дня.
|
||||
-- next_run_at = завтрашнее наступление окна (tomorrow + 07:00 UTC) — тот же паттерн,
|
||||
-- что 078/079/082/088 (иначе get_due_schedules() выстрелит сразу после деплоя).
|
||||
--
|
||||
-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)),
|
||||
-- 194_deal_city_price_bands_tiers.sql (tier-колонка, которую переиспользует refresh).
|
||||
-- Idempotent: ON CONFLICT (source) DO NOTHING — безопасно запускать повторно.
|
||||
-- Deploy order: применять ПОСЛЕ деплоя backend-кода, регистрирующего
|
||||
-- 'deal_city_price_bands_refresh' в product_handlers.build_product_handlers()
|
||||
-- (тот же порядок, что 088 relative к scheduler.py) — иначе kit-scheduler не
|
||||
-- найдёт Handler для нового source и упадёт в "unknown source" на первом due-run
|
||||
-- (не раньше завтрашнего окна — не блокирует деплой).
|
||||
|
||||
BEGIN;
|
||||
|
||||
INSERT INTO scrape_schedules (
|
||||
source,
|
||||
enabled,
|
||||
window_start_hour,
|
||||
window_end_hour,
|
||||
next_run_at,
|
||||
default_params
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
'deal_city_price_bands_refresh',
|
||||
true, -- SAFE: pure internal DB, no external calls
|
||||
7,
|
||||
8,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC',
|
||||
'{}'::jsonb
|
||||
)
|
||||
ON CONFLICT (source) DO NOTHING;
|
||||
|
||||
COMMENT ON TABLE scrape_schedules IS
|
||||
'In-app scheduler config (заменяет cron-script setup). '
|
||||
'Sources: avito_city_sweep, yandex_city_sweep (dormant, #561), '
|
||||
'cian_history_backfill, rosreestr_dkp_import, listing_source_snapshot (#570), '
|
||||
'asking_to_sold_ratio_refresh (#648), refresh_search_matview (#769), '
|
||||
'deal_city_price_bands_refresh (#2576 Stage B).';
|
||||
|
||||
COMMIT;
|
||||
44
tradein-mvp/backend/data/sql/196_listings_city.sql
Normal file
44
tradein-mvp/backend/data/sql/196_listings_city.sql
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
-- 196_listings_city.sql
|
||||
-- Issue #2594 — критичный дефект: скрапер знает город в момент сбора (city_slug из
|
||||
-- CITY_LOCATIONS/CITY_ANCHORS, packages/scraper-kit/.../orchestration/pipeline.py), но
|
||||
-- НИКУДА его не пишет. Провайдеры (avito/cian) часто отдают адрес БЕЗ города в тексте
|
||||
-- ("ул. Победы, 30" вместо "Нижний Тагил, ул. Победы, 30") — cian даже явно вырезает
|
||||
-- location-часть перед записью (skip_types = {"location", "metro"}, providers/cian/serp.py).
|
||||
-- Без явного города такой адрес при геокодинге считается «город не назван» → попадает
|
||||
-- в EKB-only локальные реестры (ekb_geoportal_buildings/gendesign_cad_buildings) и
|
||||
-- коллизирует с одноимённой екатеринбургской улицей (Ленина/Победы/Тенистая — сотни
|
||||
-- совпадений) → объявление получает координаты Екатеринбурга и тянет медиану чужих цен.
|
||||
--
|
||||
-- Fix:
|
||||
-- Add listings.city TEXT column. Проставляется НЕПОСРЕДСТВЕННО из контекста
|
||||
-- развёртки (город известен вызывающему коду — city_slug/CITY_LOCATIONS для oblast,
|
||||
-- "Екатеринбург" для EKB-развёрток) — НЕ парсингом текста адреса. См.
|
||||
-- scraper_kit.base.save_listings(..., city=...) + scraper_kit.orchestration.pipeline
|
||||
-- .resolve_city_name(). Раздельная колонка (а не дописывание города в address) —
|
||||
-- исходный текст адреса не портится, downstream text-парсеры (geocoder._parse_street_house,
|
||||
-- geocoder._names_non_ekb_city, estimator._parse_street_house, house-matching) продолжают
|
||||
-- работать НЕИЗМЕНЁННЫМИ на исходном сыром тексте — риск регрессии на bare-form адресах
|
||||
-- без street-маркера ("Дружинина, 33") исключён.
|
||||
--
|
||||
-- Scope (#2594): только write-path для НОВЫХ листингов (go-forward). Бэкфилл city для
|
||||
-- уже накопленных строк (restore по тому, какая развёртка их когда-то принесла) —
|
||||
-- отдельная задача, НЕ эта миграция.
|
||||
--
|
||||
-- Idempotency:
|
||||
-- ALTER TABLE ... ADD COLUMN IF NOT EXISTS — safe on re-run.
|
||||
-- BEGIN/COMMIT block.
|
||||
--
|
||||
-- Dependencies:
|
||||
-- 002_core_tables.sql (listings table).
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE listings ADD COLUMN IF NOT EXISTS city text;
|
||||
|
||||
COMMENT ON COLUMN listings.city IS
|
||||
'Город объявления (#2594) — проставляется из контекста развёртки '
|
||||
'(city_slug city-sweep / "Екатеринбург" default), НЕ парсингом address. '
|
||||
'NULL — листинг записан до этой миграции ИЛИ путём, ещё не проставляющим город '
|
||||
'(admin ad-hoc /admin/scrape, manual ingest-скрипты).';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -25,6 +25,7 @@ dependencies = [
|
|||
"sentry-sdk>=2.0.0", # мониторинг ошибок → GlitchTip (#396)
|
||||
"redis>=5.0.0", # async hot cache для /api/v1/search (Phase 3.2)
|
||||
"pyyaml>=6.0.0", # RBAC roles.yaml loader (app/core/auth.py)
|
||||
"bcrypt>=4.2.0", # password hashing для DB-auth (#2550)
|
||||
"playwright>=1.45", # Playwright client для connect к tradein-browser (#905)
|
||||
"scraper-kit", # internal workspace-package (#2137) — общие утилиты скрапперов;
|
||||
# резолвится из workspace (см. [tool.uv.sources]), не с PyPI.
|
||||
|
|
|
|||
|
|
@ -176,24 +176,18 @@ def _nom_result(address: str) -> ReverseGeocodeResult:
|
|||
|
||||
|
||||
async def test_reverse_geocode_prefers_cadastral_over_nominatim() -> None:
|
||||
"""Cadastral returns address → Yandex/Nominatim never called."""
|
||||
"""Cadastral returns address → Nominatim never called."""
|
||||
db = MagicMock()
|
||||
with (
|
||||
patch(
|
||||
"app.services.geocoder._cadastral_reverse_sync_full",
|
||||
return_value=("ул. Малышева, 30, Екатеринбург", 56.8385, 60.6052),
|
||||
) as mock_cad,
|
||||
patch("app.services.geocoder.settings") as mock_settings,
|
||||
patch(
|
||||
"app.services.geocoder._yandex_reverse",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_yandex,
|
||||
patch(
|
||||
"app.services.geocoder._nominatim_reverse",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_nom,
|
||||
):
|
||||
mock_settings.yandex_geocoder_api_key = "fake-key"
|
||||
result = await reverse_geocode(56.838, 60.605, db=db)
|
||||
|
||||
assert result is not None
|
||||
|
|
@ -203,41 +197,6 @@ async def test_reverse_geocode_prefers_cadastral_over_nominatim() -> None:
|
|||
assert result.precision == "number"
|
||||
assert result.provider == "cadastral"
|
||||
mock_cad.assert_called_once_with(db, 56.838, 60.605)
|
||||
mock_yandex.assert_not_called()
|
||||
mock_nom.assert_not_called()
|
||||
|
||||
|
||||
async def test_reverse_geocode_uses_yandex_when_cadastral_empty() -> None:
|
||||
"""Cadastral None + Yandex key set → Yandex called, Nominatim skipped."""
|
||||
db = MagicMock()
|
||||
yandex_result = ReverseGeocodeResult(
|
||||
address="улица Малышева, 51, Екатеринбург",
|
||||
snapped_lat=56.838004,
|
||||
snapped_lon=60.586155,
|
||||
precision="exact",
|
||||
provider="yandex",
|
||||
)
|
||||
with (
|
||||
patch("app.services.geocoder._cadastral_reverse_sync_full", return_value=None),
|
||||
patch("app.services.geocoder.settings") as mock_settings,
|
||||
patch(
|
||||
"app.services.geocoder._yandex_reverse",
|
||||
new_callable=AsyncMock,
|
||||
return_value=yandex_result,
|
||||
) as mock_yandex,
|
||||
patch(
|
||||
"app.services.geocoder._nominatim_reverse",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_nom,
|
||||
):
|
||||
mock_settings.yandex_geocoder_api_key = "fake-key"
|
||||
result = await reverse_geocode(56.838, 60.586, db=db)
|
||||
|
||||
assert result is not None
|
||||
assert result.provider == "yandex"
|
||||
assert result.precision == "exact"
|
||||
assert result.snapped_lat == 56.838004
|
||||
mock_yandex.assert_called_once()
|
||||
mock_nom.assert_not_called()
|
||||
|
||||
|
||||
|
|
@ -263,23 +222,16 @@ async def test_reverse_geocode_falls_back_to_nominatim_when_cadastral_none() ->
|
|||
|
||||
|
||||
async def test_reverse_geocode_returns_none_when_all_providers_fail() -> None:
|
||||
"""Cadastral + Yandex + Nominatim — все падают → returns None, no exception."""
|
||||
"""Cadastral + Nominatim — все падают → returns None, no exception."""
|
||||
db = MagicMock()
|
||||
with (
|
||||
patch("app.services.geocoder._cadastral_reverse_sync_full", return_value=None),
|
||||
patch("app.services.geocoder.settings") as mock_settings,
|
||||
patch(
|
||||
"app.services.geocoder._yandex_reverse",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=RuntimeError("Yandex 429 rate-limited"),
|
||||
),
|
||||
patch(
|
||||
"app.services.geocoder._nominatim_reverse",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=RuntimeError("Nominatim 403 Forbidden — IP banned"),
|
||||
),
|
||||
):
|
||||
mock_settings.yandex_geocoder_api_key = "fake-key"
|
||||
result = await reverse_geocode(56.838, 60.605, db=db)
|
||||
|
||||
# Must return None, not raise
|
||||
|
|
@ -287,19 +239,17 @@ async def test_reverse_geocode_returns_none_when_all_providers_fail() -> None:
|
|||
|
||||
|
||||
async def test_reverse_geocode_without_db_skips_cadastral() -> None:
|
||||
"""When db=None, cadastral tier is skipped entirely → goes straight to Yandex/Nominatim."""
|
||||
"""When db=None, cadastral tier is skipped entirely → goes straight to Nominatim."""
|
||||
with (
|
||||
patch(
|
||||
"app.services.geocoder._cadastral_reverse_sync_full",
|
||||
) as mock_cad,
|
||||
patch("app.services.geocoder.settings") as mock_settings,
|
||||
patch(
|
||||
"app.services.geocoder._nominatim_reverse",
|
||||
new_callable=AsyncMock,
|
||||
return_value=_nom_result("ул. Ленина, 1, Екатеринбург"),
|
||||
),
|
||||
):
|
||||
mock_settings.yandex_geocoder_api_key = None
|
||||
result = await reverse_geocode(56.838, 60.605, db=None)
|
||||
|
||||
mock_cad.assert_not_called()
|
||||
|
|
@ -307,36 +257,11 @@ async def test_reverse_geocode_without_db_skips_cadastral() -> None:
|
|||
assert result.address == "ул. Ленина, 1, Екатеринбург"
|
||||
|
||||
|
||||
async def test_reverse_geocode_falls_through_yandex_to_nominatim_on_exception() -> None:
|
||||
"""Yandex raises → Nominatim still tried."""
|
||||
db = MagicMock()
|
||||
with (
|
||||
patch("app.services.geocoder._cadastral_reverse_sync_full", return_value=None),
|
||||
patch("app.services.geocoder.settings") as mock_settings,
|
||||
patch(
|
||||
"app.services.geocoder._yandex_reverse",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=RuntimeError("Yandex 500"),
|
||||
),
|
||||
patch(
|
||||
"app.services.geocoder._nominatim_reverse",
|
||||
new_callable=AsyncMock,
|
||||
return_value=_nom_result("ул. Дублёр, 1"),
|
||||
) as mock_nom,
|
||||
):
|
||||
mock_settings.yandex_geocoder_api_key = "fake-key"
|
||||
result = await reverse_geocode(56.838, 60.605, db=db)
|
||||
|
||||
assert result is not None
|
||||
assert result.provider == "nominatim"
|
||||
mock_nom.assert_called_once()
|
||||
|
||||
|
||||
# ── geocode: cadastral as first tier ─────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_geocode_uses_cadastral_before_yandex() -> None:
|
||||
"""Cadastral FDW returns suggestion → geocode returns it without hitting Yandex."""
|
||||
async def test_geocode_uses_cadastral_before_nominatim() -> None:
|
||||
"""Cadastral FDW returns suggestion → geocode returns it without hitting Nominatim."""
|
||||
db = MagicMock()
|
||||
# Cache miss
|
||||
db.execute.side_effect = [
|
||||
|
|
@ -362,7 +287,7 @@ async def test_geocode_uses_cadastral_before_yandex() -> None:
|
|||
return_value=[cad_suggestion],
|
||||
) as mock_cad,
|
||||
patch("app.services.geocoder._cache_put"),
|
||||
patch("app.services.geocoder._yandex_lookup", new_callable=AsyncMock) as mock_yandex,
|
||||
patch("app.services.geocoder._nominatim_lookup", new_callable=AsyncMock) as mock_nominatim,
|
||||
):
|
||||
result = await geocode("Хохрякова 81", db)
|
||||
|
||||
|
|
@ -371,14 +296,14 @@ async def test_geocode_uses_cadastral_before_yandex() -> None:
|
|||
assert result.lon == 60.610
|
||||
assert result.confidence == "exact"
|
||||
mock_cad.assert_called_once()
|
||||
mock_yandex.assert_not_called()
|
||||
mock_nominatim.assert_not_called()
|
||||
|
||||
|
||||
# ── suggest: cadastral as first tier ─────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_suggest_uses_cadastral_before_yandex() -> None:
|
||||
"""Cadastral FDW returns results → suggest returns them without hitting Yandex."""
|
||||
async def test_suggest_uses_cadastral_before_nominatim() -> None:
|
||||
"""Cadastral FDW returns results → suggest returns them without hitting Nominatim."""
|
||||
db = MagicMock()
|
||||
cad_suggestions = [
|
||||
GeocodeSuggestion(
|
||||
|
|
@ -396,53 +321,20 @@ async def test_suggest_uses_cadastral_before_yandex() -> None:
|
|||
return_value=cad_suggestions,
|
||||
) as mock_cad,
|
||||
patch(
|
||||
"app.services.geocoder._yandex_suggest",
|
||||
"app.services.geocoder._nominatim_suggest",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_yandex,
|
||||
) as mock_nominatim,
|
||||
):
|
||||
results = await suggest("Малышева", db=db, limit=8)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].full_address == "ул. Малышева, 30, Екатеринбург"
|
||||
mock_cad.assert_called_once()
|
||||
mock_yandex.assert_not_called()
|
||||
mock_nominatim.assert_not_called()
|
||||
|
||||
|
||||
async def test_suggest_falls_back_to_yandex_when_cadastral_empty() -> None:
|
||||
"""Cadastral returns [] → DaData not configured → falls through to Yandex."""
|
||||
db = MagicMock()
|
||||
yandex_result = GeocodeSuggestion(
|
||||
label="Малышева · Октябрьский",
|
||||
full_address="улица Малышева, Екатеринбург",
|
||||
lat=56.838,
|
||||
lon=60.605,
|
||||
kind="street",
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.services.geocoder._cadastral_forward_sync", return_value=[]),
|
||||
patch("app.services.geocoder.settings") as mock_settings,
|
||||
patch(
|
||||
"app.services.geocoder._dadata_suggest",
|
||||
new_callable=AsyncMock,
|
||||
return_value=[], # DaData empty → falls through
|
||||
),
|
||||
patch(
|
||||
"app.services.geocoder._yandex_suggest",
|
||||
new_callable=AsyncMock,
|
||||
return_value=[yandex_result],
|
||||
) as mock_yandex,
|
||||
):
|
||||
mock_settings.dadata_api_token = "fake-dadata-token" # gate to call _dadata_suggest
|
||||
mock_settings.yandex_geocoder_api_key = "fake-key"
|
||||
results = await suggest("Малышева", db=db, limit=8)
|
||||
|
||||
assert len(results) == 1
|
||||
mock_yandex.assert_called_once()
|
||||
|
||||
|
||||
async def test_suggest_uses_dadata_before_yandex_when_cadastral_empty() -> None:
|
||||
"""Cadastral returns [] → DaData tier returns results → Yandex never called (PR Q2)."""
|
||||
async def test_suggest_uses_dadata_before_nominatim_when_cadastral_empty() -> None:
|
||||
"""Cadastral returns [] → DaData tier returns results → Nominatim never called (PR Q2)."""
|
||||
db = MagicMock()
|
||||
dadata_result = GeocodeSuggestion(
|
||||
label="г Екатеринбург, ул Малышева, д 30",
|
||||
|
|
@ -461,18 +353,17 @@ async def test_suggest_uses_dadata_before_yandex_when_cadastral_empty() -> None:
|
|||
return_value=[dadata_result],
|
||||
) as mock_dadata,
|
||||
patch(
|
||||
"app.services.geocoder._yandex_suggest",
|
||||
"app.services.geocoder._nominatim_suggest",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_yandex,
|
||||
) as mock_nominatim,
|
||||
):
|
||||
mock_settings.dadata_api_token = "fake-dadata-token"
|
||||
mock_settings.yandex_geocoder_api_key = "fake-yandex-key"
|
||||
results = await suggest("Малышева", db=db, limit=8)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].lat == 56.838011
|
||||
mock_dadata.assert_called_once()
|
||||
mock_yandex.assert_not_called()
|
||||
mock_nominatim.assert_not_called()
|
||||
|
||||
|
||||
# ── ensure_fdw_user_mapping: SQL injection / whitelist guards ─────────────────
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import fnmatch
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
|
@ -14,6 +15,7 @@ import pytest # noqa: E402
|
|||
|
||||
from app.core import shutdown as _sd # noqa: E402
|
||||
from app.tasks.avito_detail_backfill import ( # noqa: E402
|
||||
_OBLAST_AVITO_URL_PATTERNS,
|
||||
AvitoDetailBackfillResult,
|
||||
run_avito_detail_backfill,
|
||||
)
|
||||
|
|
@ -353,11 +355,14 @@ async def test_backfill_rotate_ip_called_on_each_block() -> None:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backfill_snapshot_filters_ekb_active_only() -> None:
|
||||
"""Снапшот-SELECT (#1814) фильтрует только активные ЕКБ-листинги.
|
||||
"""Снапшот-SELECT (#1814, расширено #2576) фильтрует активные ЕКБ- И
|
||||
известные oblast-листинги (region 66), НЕ всё подряд.
|
||||
|
||||
Проверяем, что текст запроса содержит `is_active = TRUE` и
|
||||
`LIKE '%/ekaterinburg/%'` — legacy не-ЕКБ (moskva/spb/tyumen) и мёртвые
|
||||
листинги не попадают в фетч, иначе browser спотыкается → curl-бан 429.
|
||||
Проверяем, что текст запроса содержит `is_active = TRUE`, `LIKE
|
||||
'%/ekaterinburg/%'` (ekb CTE, LIMIT batch_size НЕ сокращён) и `LIKE ANY(...)`
|
||||
по oblast-паттернам (oblast CTE, отдельный LIMIT oblast_batch_size) — legacy
|
||||
не-ЕКБ/не-область (moskva/spb/tyumen) и мёртвые листинги не попадают в фетч,
|
||||
иначе browser спотыкается → curl-бан 429.
|
||||
"""
|
||||
db = _mock_db([])
|
||||
runs = MagicMock()
|
||||
|
|
@ -376,8 +381,127 @@ async def test_backfill_snapshot_filters_ekb_active_only() -> None:
|
|||
sql_text = str(snapshot_call.args[0])
|
||||
assert "is_active = TRUE" in sql_text
|
||||
assert "/ekaterinburg/" in sql_text
|
||||
assert "LIKE ANY(CAST(:oblast_patterns AS text[]))" in sql_text
|
||||
assert "detail_enriched_at IS NULL" in sql_text
|
||||
assert "(lat IS NULL) DESC" in sql_text
|
||||
assert "CAST(:batch_size AS int)" in sql_text
|
||||
assert "CAST(:oblast_batch_size AS int)" in sql_text
|
||||
|
||||
# ekb-квота передаётся batch_size БЕЗ урезания (#2576 требование "ЕКБ не
|
||||
# деградирует") — oblast получает отдельный (не вычтенный) bind-параметр.
|
||||
bind_params = snapshot_call.args[1]
|
||||
assert bind_params["batch_size"] == 10
|
||||
assert bind_params["oblast_batch_size"] == 100 # default
|
||||
assert set(bind_params["oblast_patterns"]) == set(_OBLAST_AVITO_URL_PATTERNS)
|
||||
|
||||
|
||||
def test_oblast_avito_url_patterns_cover_region66_cities() -> None:
|
||||
"""#2576: _OBLAST_AVITO_URL_PATTERNS строится из CITY_LOCATIONS.avito_slug —
|
||||
список должен покрывать реальные Avito-слаги oblast-городов (в т.ч. те, что
|
||||
ОТЛИЧАЮТСЯ от нашего city_slug: kamensk-uralskiy через дефис, а не
|
||||
kamensk_uralskiy).
|
||||
|
||||
#2578 review: '_' в слаге -- LIKE wildcard, экранируем при построении паттерна
|
||||
('_' -> '\\_') -- nizhniy_tagil/verhnyaya_pyshma здесь ожидаются С обратным
|
||||
слэшем перед '_', НЕ голым подчёркиванием."""
|
||||
assert "%/kamensk-uralskiy/%" in _OBLAST_AVITO_URL_PATTERNS
|
||||
assert "%/nizhniy\\_tagil/%" in _OBLAST_AVITO_URL_PATTERNS
|
||||
assert "%/pervouralsk/%" in _OBLAST_AVITO_URL_PATTERNS
|
||||
assert "%/verhnyaya\\_pyshma/%" in _OBLAST_AVITO_URL_PATTERNS
|
||||
assert "%/serov/%" in _OBLAST_AVITO_URL_PATTERNS
|
||||
# ЕКБ обрабатывается отдельным жёстко закодированным паттерном (ekb CTE),
|
||||
# НЕ через этот oblast-список — не должен в него затесаться.
|
||||
assert not any("ekaterinburg" in p for p in _OBLAST_AVITO_URL_PATTERNS)
|
||||
|
||||
|
||||
def _like_pattern_to_fnmatch(pattern: str) -> str:
|
||||
"""Точный перевод семантики Postgres `LIKE` (default `ESCAPE '\\'`) в fnmatch-
|
||||
паттерн -- посимвольно, а НЕ наивным `.replace()`.
|
||||
|
||||
LIKE: `%` = любая последовательность символов, `_` = РОВНО один любой символ,
|
||||
`\\%`/`\\_`/`\\\\` = литералы (экранирование). fnmatch: `*` = любая
|
||||
последовательность, `?` = один любой символ; голые `_`/`%` в fnmatch не
|
||||
специальны (можно вставлять как литерал без экранирования).
|
||||
|
||||
#2578 review: наивный `pat.replace("%", "*")` (как было раньше) НЕ отражал бы
|
||||
семантику `_` вообще -- fnmatch трактует `_` как литерал, LIKE -- как wildcard.
|
||||
Из-за этого расхождения прежний тест не поймал бы латентный баг (нет
|
||||
экранирования `_` в продовых паттернах). Посимвольный разбор здесь корректно
|
||||
различает голый `_` (-> `?` wildcard) и экранированный `\\_` (-> литерал `_`).
|
||||
"""
|
||||
out: list[str] = []
|
||||
i = 0
|
||||
n = len(pattern)
|
||||
while i < n:
|
||||
ch = pattern[i]
|
||||
if ch == "\\" and i + 1 < n and pattern[i + 1] in ("%", "_", "\\"):
|
||||
out.append(pattern[i + 1]) # экранированный символ -> литерал as-is
|
||||
i += 2
|
||||
continue
|
||||
if ch == "%":
|
||||
out.append("*")
|
||||
elif ch == "_":
|
||||
out.append("?")
|
||||
else:
|
||||
out.append(ch)
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _in_oblast_or_ekb_scope(source_url: str) -> bool:
|
||||
"""Локальная реплика WHERE-условия snapshot-запроса (ekb CTE OR oblast CTE)
|
||||
через корректную LIKE-эмуляцию -- без поднятия БД."""
|
||||
if fnmatch.fnmatchcase(source_url, _like_pattern_to_fnmatch("%/ekaterinburg/%")):
|
||||
return True
|
||||
return any(
|
||||
fnmatch.fnmatchcase(source_url, _like_pattern_to_fnmatch(pat))
|
||||
for pat in _OBLAST_AVITO_URL_PATTERNS
|
||||
)
|
||||
|
||||
|
||||
def test_oblast_avito_url_patterns_include_oblast_and_ekb_exclude_foreign_region() -> None:
|
||||
"""#2576 DoD: листинг города области и екатеринбургский листинг проходят
|
||||
scope-фильтр; листинг чужого региона (Москва/СПб) — нет.
|
||||
|
||||
Использует корректную LIKE-эмуляцию (_like_pattern_to_fnmatch), а не наивный
|
||||
`%` -> `*` replace (#2578 review — тот не различал бы `_`-семантику)."""
|
||||
# Область (Каменск-Уральский, #2576 — реальный кейс из тикета) -- проходит.
|
||||
assert _in_oblast_or_ekb_scope("https://www.avito.ru/kamensk-uralskiy/kvartiry/prodam_123")
|
||||
# ЕКБ — по-прежнему проходит (не деградировал).
|
||||
assert _in_oblast_or_ekb_scope("https://www.avito.ru/ekaterinburg/kvartiry/prodam_456")
|
||||
# Чужой регион — НЕ проходит (иначе поехали бы Москва/СПб/Тюмень legacy-строки).
|
||||
assert not _in_oblast_or_ekb_scope("https://www.avito.ru/moskva/kvartiry/prodam_789")
|
||||
assert not _in_oblast_or_ekb_scope("https://www.avito.ru/sankt-peterburg/kvartiry/prodam_000")
|
||||
|
||||
|
||||
def test_like_underscore_wildcard_regression_caught_by_escaped_patterns() -> None:
|
||||
"""#2578 deep-review latent bug: Postgres `LIKE` трактует `_` как wildcard
|
||||
"ровно один любой символ", а НЕ литерал. Два слага из пяти (nizhniy_tagil,
|
||||
verhnyaya_pyshma) содержат `_` -- БЕЗ экранирования 'nizhniy_tagil' молча
|
||||
совпал бы с 'nizhniyXtagil' (X = любой символ), т.е. коллизия слагов при
|
||||
появлении похожего города. Сегодня коллизий нет (проверено на проде: raw vs
|
||||
escaped паттерны дают одинаковые 776 совпадений), но дыра латентная.
|
||||
|
||||
Этот тест ДОЛЖЕН падать на RAW (неэкранированном) варианте паттерна -- именно
|
||||
так выглядели продовые паттерны ДО фикса #2578 (`%/nizhniy_tagil/%`, без
|
||||
`\\`). Экранированный прод-паттерн (_OBLAST_AVITO_URL_PATTERNS, ПОСЛЕ фикса)
|
||||
коллизию отклоняет, точный слаг по-прежнему матчит (позитивный кейс жив).
|
||||
"""
|
||||
raw_pattern = "%/nizhniy_tagil/%" # как было бы БЕЗ фикса #2578 (голый '_')
|
||||
escaped_pattern = next(p for p in _OBLAST_AVITO_URL_PATTERNS if "nizhniy" in p)
|
||||
# Сам факт экранирования: прод-паттерн ДОЛЖЕН отличаться от raw ('_' -> '\_').
|
||||
assert escaped_pattern != raw_pattern, "фикс #2578 должен экранировать '_' в avito_slug"
|
||||
|
||||
collision_url = "https://www.avito.ru/nizhniyXtagil/kvartiry/prodam_1"
|
||||
exact_url = "https://www.avito.ru/nizhniy_tagil/kvartiry/prodam_1"
|
||||
|
||||
# RAW: '_' -- wildcard -> ложное совпадение с ЛЮБЫМ символом на его месте.
|
||||
assert fnmatch.fnmatchcase(collision_url, _like_pattern_to_fnmatch(raw_pattern))
|
||||
# Экранированный прод-паттерн (после фикса): '_' -- литерал -> коллизия отклонена.
|
||||
assert not fnmatch.fnmatchcase(collision_url, _like_pattern_to_fnmatch(escaped_pattern))
|
||||
# Позитивный кейс не сломан: точный слаг матчит ОБА варианта паттерна.
|
||||
assert fnmatch.fnmatchcase(exact_url, _like_pattern_to_fnmatch(raw_pattern))
|
||||
assert fnmatch.fnmatchcase(exact_url, _like_pattern_to_fnmatch(escaped_pattern))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:
|
|||
_wp_mock = MagicMock()
|
||||
sys.modules.setdefault("weasyprint", _wp_mock)
|
||||
|
||||
from app.services.geocoder import GeocodeSuggestion # noqa: E402
|
||||
from app.services.geocoder import GeocodeSuggestion, _names_non_ekb_city # noqa: E402
|
||||
from app.tasks import backfill_listings_coords_geoportal as bcg # noqa: E402
|
||||
from app.tasks.backfill_listings_coords_geoportal import ( # noqa: E402
|
||||
BackfillCoordsResult,
|
||||
|
|
@ -203,6 +203,76 @@ def test_unparseable_address_counted_as_no_address() -> None:
|
|||
mock_geo.assert_not_called()
|
||||
|
||||
|
||||
# ── городской гейт (#2583, H3) ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_non_ekb_address_skipped_not_matched_against_ekb_registry() -> None:
|
||||
"""Адрес другого города области (Нижний Тагил) НЕ должен матчиться против
|
||||
EKB-only ekb_geoportal_buildings, даже если street+house буквально совпадают
|
||||
с ЕКБ-домом («проспект Ленина 1» есть и в ЕКБ, и в Нижнем Тагиле, #2583 H3).
|
||||
|
||||
_names_non_ekb_city — РЕАЛЬНАЯ функция (не мок), чтобы проверить настоящий гейт,
|
||||
а не заглушку. _geoportal_house_match мокнут так, будто он ложно нашёл ЕКБ-дом —
|
||||
тест доказывает, что до этого вызова дело не доходит вообще.
|
||||
"""
|
||||
rows = [{"id": 100, "address": "г. Нижний Тагил, проспект Ленина, 1"}]
|
||||
db = _make_db([rows, []])
|
||||
|
||||
# Sanity: реальная функция действительно считает этот адрес не-ЕКБ.
|
||||
assert _names_non_ekb_city("г. Нижний Тагил, проспект Ленина, 1") is True
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.tasks.backfill_listings_coords_geoportal._geoportal_house_match",
|
||||
return_value=_HIT, # ложное совпадение по street+house, если бы гейт не сработал
|
||||
) 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
|
||||
assert res.updated == 0
|
||||
assert res.no_match == 0
|
||||
assert res.no_address == 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_ekb_address_still_matched_with_real_gate() -> None:
|
||||
"""Явно ЕКБ-адрес по-прежнему проходит гейт и матчится как раньше (не деградация).
|
||||
|
||||
_names_non_ekb_city — реальная функция: "Екатеринбург" в адресе имеет приоритет
|
||||
над любым омонимом другого города области (см. docstring _names_non_ekb_city).
|
||||
"""
|
||||
rows = [{"id": 101, "address": "г. Екатеринбург, проспект Ленина, 1"}]
|
||||
db = _make_db([rows, []])
|
||||
|
||||
assert _names_non_ekb_city("г. Екатеринбург, проспект Ленина, 1") 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=("проспект ленина", "1"),
|
||||
),
|
||||
):
|
||||
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, "проспект ленина", "1")
|
||||
|
||||
|
||||
# ── idempotency ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
613
tradein-mvp/backend/tests/test_auth_api.py
Normal file
613
tradein-mvp/backend/tests/test_auth_api.py
Normal file
|
|
@ -0,0 +1,613 @@
|
|||
"""Integration tests for #2552 auth-core: POST /login, /logout, dual-mode GET /me,
|
||||
and rbac_guard session-cookie resolution.
|
||||
|
||||
Uses the REAL `rbac_guard` (app.core.rbac) + REAL `auth.router` / `me.router` wired
|
||||
into an isolated FastAPI test app (same pattern as tests/test_rbac.py), with an
|
||||
in-memory fake DB standing in for `tradein_users`/`tradein_sessions`:
|
||||
- `app.core.rbac.SessionLocal` is monkeypatched (rbac_guard opens its own session,
|
||||
it's middleware — no FastAPI DI available there).
|
||||
- `app.core.db.get_db` is overridden via `app.dependency_overrides` (auth.py /
|
||||
me.py use `Depends(get_db)`, the idiomatic FastAPI-testable path).
|
||||
|
||||
Both point at the SAME `_Store` instance per test, so a session created by POST
|
||||
/login is immediately visible to rbac_guard's own DB round trip on the next request.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from typing import Annotated, Any
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, Header
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.v1 import auth as auth_router
|
||||
from app.api.v1 import me as me_router
|
||||
from app.core import auth as auth_mod
|
||||
from app.core import config
|
||||
from app.core.db import get_db
|
||||
from app.core.password import hash_password
|
||||
from app.core.rbac import rbac_guard
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fake DB backing tradein_users / tradein_sessions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _Store:
|
||||
def __init__(self) -> None:
|
||||
self.users: dict[str, dict[str, Any]] = {}
|
||||
self.sessions: dict[str, dict[str, Any]] = {}
|
||||
self._next_id = 1
|
||||
|
||||
def add_user(
|
||||
self,
|
||||
username: str,
|
||||
password_hash: str | None,
|
||||
*,
|
||||
role: str = "employee",
|
||||
is_active: bool = True,
|
||||
display_name: str | None = "Alice A.",
|
||||
org_name: str | None = "Org LLC",
|
||||
email: str | None = "alice@example.com",
|
||||
) -> int:
|
||||
uid = self._next_id
|
||||
self._next_id += 1
|
||||
self.users[username] = {
|
||||
"id": uid,
|
||||
"username": username,
|
||||
"password_hash": password_hash,
|
||||
"role": role,
|
||||
"is_active": is_active,
|
||||
"display_name": display_name,
|
||||
"org_name": org_name,
|
||||
"email": email,
|
||||
}
|
||||
return uid
|
||||
|
||||
def user_by_id(self, uid: int) -> dict[str, Any] | None:
|
||||
for u in self.users.values():
|
||||
if u["id"] == uid:
|
||||
return u
|
||||
return None
|
||||
|
||||
def add_expired_session(self, token: str, user_id: int) -> None:
|
||||
now = datetime.now(UTC)
|
||||
self.sessions[token] = {
|
||||
"user_id": user_id,
|
||||
"expires_at": now - timedelta(minutes=1),
|
||||
"last_seen_at": now - timedelta(minutes=1),
|
||||
}
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
"""Minimal Session stand-in dispatching on SQL text — see module docstring."""
|
||||
|
||||
def __init__(self, store: _Store) -> None:
|
||||
self.store = store
|
||||
|
||||
def __enter__(self) -> _FakeDB:
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc: object) -> bool:
|
||||
return False
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
def commit(self) -> None:
|
||||
pass
|
||||
|
||||
def rollback(self) -> None:
|
||||
pass
|
||||
|
||||
def execute(self, stmt: object, params: dict[str, Any] | None = None) -> SimpleNamespace:
|
||||
sql = str(stmt)
|
||||
p = params or {}
|
||||
|
||||
if "INSERT INTO tradein_sessions" in sql:
|
||||
now = datetime.now(UTC)
|
||||
self.store.sessions[p["token"]] = {
|
||||
"user_id": p["user_id"],
|
||||
"expires_at": now + timedelta(hours=p["ttl_hours"]),
|
||||
"last_seen_at": now,
|
||||
}
|
||||
return SimpleNamespace(fetchone=lambda: None)
|
||||
|
||||
if "UPDATE tradein_sessions" in sql and "SET last_seen_at" in sql:
|
||||
sess = self.store.sessions.get(p["token"])
|
||||
if sess is not None:
|
||||
now = datetime.now(UTC)
|
||||
sess["last_seen_at"] = now
|
||||
sess["expires_at"] = now + timedelta(hours=p["ttl_hours"])
|
||||
return SimpleNamespace(fetchone=lambda: None)
|
||||
|
||||
if "DELETE FROM tradein_sessions WHERE token" in sql:
|
||||
self.store.sessions.pop(p["token"], None)
|
||||
return SimpleNamespace(fetchone=lambda: None)
|
||||
|
||||
if "DELETE FROM tradein_sessions WHERE user_id" in sql:
|
||||
uid = p["user_id"]
|
||||
for tok in [t for t, s in self.store.sessions.items() if s["user_id"] == uid]:
|
||||
del self.store.sessions[tok]
|
||||
return SimpleNamespace(fetchone=lambda: None)
|
||||
|
||||
if "FROM tradein_sessions s" in sql and "JOIN tradein_users u" in sql:
|
||||
sess = self.store.sessions.get(p["token"])
|
||||
if sess is None:
|
||||
return SimpleNamespace(fetchone=lambda: None)
|
||||
user = self.store.user_by_id(sess["user_id"])
|
||||
if user is None:
|
||||
return SimpleNamespace(fetchone=lambda: None)
|
||||
row = SimpleNamespace(
|
||||
user_id=sess["user_id"],
|
||||
expires_at=sess["expires_at"],
|
||||
last_seen_at=sess["last_seen_at"],
|
||||
username=user["username"],
|
||||
role=user["role"],
|
||||
display_name=user["display_name"],
|
||||
org_name=user["org_name"],
|
||||
email=user["email"],
|
||||
is_active=user["is_active"],
|
||||
)
|
||||
return SimpleNamespace(fetchone=lambda: row)
|
||||
|
||||
if "FROM tradein_users" in sql:
|
||||
user = self.store.users.get(p["username"])
|
||||
if user is None:
|
||||
return SimpleNamespace(fetchone=lambda: None)
|
||||
row = SimpleNamespace(**user)
|
||||
return SimpleNamespace(fetchone=lambda: row)
|
||||
|
||||
raise AssertionError(f"unhandled fake SQL in test_auth_api: {sql!r}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test app
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_test_app(store: _Store) -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.middleware("http")(rbac_guard)
|
||||
app.include_router(auth_router.router, prefix="/api/v1/auth", tags=["auth"])
|
||||
app.include_router(me_router.router, prefix="/api/v1", tags=["me"])
|
||||
|
||||
@app.get("/api/v1/trade-in/dummy")
|
||||
async def tradein_dummy() -> dict:
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/api/v1/trade-in/whoami")
|
||||
async def tradein_whoami(
|
||||
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
|
||||
) -> dict:
|
||||
"""Echoes the X-Authenticated-User header exactly as a downstream handler
|
||||
(`_assert_estimate_access*`, `account_quota`, etc.) would see it — used to
|
||||
assert session-identity wins over a client-forged header (#2552 spoof fix)."""
|
||||
return {"user": x_authenticated_user}
|
||||
|
||||
# Внутренние инструменты, закрытые от клиентских DB-ролей 2026-07-31
|
||||
# («Доля в продаже» / «Кэш»). Ручки настоящие (не заглушки rbac_guard'а),
|
||||
# чтобы 403 приходил именно от scope-чека, а не от отсутствия роута.
|
||||
@app.get("/api/v1/buildings/sale-share")
|
||||
async def buildings_sale_share() -> dict:
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/api/v1/trade-in/cache-stats")
|
||||
async def tradein_cache_stats() -> dict:
|
||||
return {"ok": True}
|
||||
|
||||
def _override_get_db(): # generator dependency — matches app.core.db.get_db shape
|
||||
yield _FakeDB(store)
|
||||
|
||||
app.dependency_overrides[get_db] = _override_get_db
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
auth_mod.reset_cache_for_tests()
|
||||
auth_router._LOGIN_LIMITER._hits.clear()
|
||||
monkeypatch.setattr(config.settings, "auth_mode", "dual")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store() -> _Store:
|
||||
return _Store()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(store: _Store, monkeypatch: pytest.MonkeyPatch) -> TestClient:
|
||||
monkeypatch.setattr("app.core.rbac.SessionLocal", lambda: _FakeDB(store))
|
||||
# base_url=https:// — login sets the session cookie with Secure=True (real prod
|
||||
# behaviour, not weakened for tests); httpx's cookie jar silently drops Secure
|
||||
# cookies on a plain-http connection, so a plain http://testserver client would
|
||||
# never resend the cookie on subsequent requests within the same test.
|
||||
return TestClient(_build_test_app(store), base_url="https://testserver")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /login
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_login_happy_path_sets_cookie(client: TestClient, store: _Store) -> None:
|
||||
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 resp.json() == {"ok": True}
|
||||
cookie_name = config.settings.session_cookie_name
|
||||
assert cookie_name in resp.cookies
|
||||
assert resp.cookies[cookie_name]
|
||||
# Сессия реально создана в сторе под этим токеном.
|
||||
assert resp.cookies[cookie_name] in store.sessions
|
||||
|
||||
|
||||
def test_login_wrong_password_401_and_logs_failed_event(
|
||||
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
store.add_user("alice", hash_password("Secret123!"), role="employee")
|
||||
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": "alice", "password": "wrong"})
|
||||
|
||||
assert resp.status_code == 401
|
||||
assert "detail" in resp.json()
|
||||
assert config.settings.session_cookie_name not in resp.cookies
|
||||
assert any(e["event_type"] == "login_failed" for e in events)
|
||||
failed = next(e for e in events if e["event_type"] == "login_failed")
|
||||
assert failed["username"] == "alice"
|
||||
# Raw-пароль никогда не попадает в событие.
|
||||
assert "wrong" not in str(failed)
|
||||
|
||||
|
||||
def test_login_unknown_username_401_generic_message(client: TestClient) -> None:
|
||||
resp = client.post("/api/v1/auth/login", json={"username": "ghost", "password": "whatever"})
|
||||
assert resp.status_code == 401
|
||||
# НЕ раскрываем, что юзера не существует — то же сообщение, что и wrong-password.
|
||||
body_ghost = resp.json()["detail"]
|
||||
|
||||
resp2 = client.post("/api/v1/auth/login", json={"username": "ghost2", "password": "x"})
|
||||
assert resp2.json()["detail"] == body_ghost
|
||||
|
||||
|
||||
def test_login_inactive_user_401(client: TestClient, store: _Store) -> None:
|
||||
store.add_user("bob", hash_password("Secret123!"), role="employee", is_active=False)
|
||||
resp = client.post("/api/v1/auth/login", json={"username": "bob", "password": "Secret123!"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_login_null_password_hash_401(client: TestClient, store: _Store) -> None:
|
||||
store.add_user("carol", None, role="employee")
|
||||
resp = client.post("/api/v1/auth/login", json={"username": "carol", "password": "anything"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_login_always_calls_verify_password_timing_oracle_guard(
|
||||
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""#2552 post-review Medium 2: `verify_password` должен выполняться ровно
|
||||
один раз на КАЖДУЮ попытку логина — включая неизвестный username и NULL
|
||||
password_hash — иначе короткое замыкание даёт наблюдаемый timing-oracle
|
||||
для user-enumeration. Тест не измеряет тайминг (флейки в CI), а проверяет
|
||||
сам факт + аргумент вызова через monkeypatch-счётчик."""
|
||||
store.add_user("alice", hash_password("Secret123!"), role="employee")
|
||||
store.add_user("nullhash", None, role="employee")
|
||||
|
||||
calls: list[str] = []
|
||||
real_verify = auth_router.verify_password
|
||||
|
||||
def _counting_verify(plain: str, hashed: str) -> bool:
|
||||
calls.append(hashed)
|
||||
return real_verify(plain, hashed)
|
||||
|
||||
monkeypatch.setattr(auth_router, "verify_password", _counting_verify)
|
||||
|
||||
resp_unknown = client.post("/api/v1/auth/login", json={"username": "ghost", "password": "x"})
|
||||
assert resp_unknown.status_code == 401
|
||||
|
||||
resp_null_hash = client.post(
|
||||
"/api/v1/auth/login", json={"username": "nullhash", "password": "x"}
|
||||
)
|
||||
assert resp_null_hash.status_code == 401
|
||||
|
||||
resp_wrong_pw = client.post(
|
||||
"/api/v1/auth/login", json={"username": "alice", "password": "wrong"}
|
||||
)
|
||||
assert resp_wrong_pw.status_code == 401
|
||||
|
||||
assert len(calls) == 3
|
||||
# Unknown user / NULL hash — сверяется против dummy-хеша, не против NULL.
|
||||
assert calls[0] == auth_router._DUMMY_PASSWORD_HASH
|
||||
assert calls[1] == auth_router._DUMMY_PASSWORD_HASH
|
||||
# Реальный юзер с реальным hash — НЕ dummy.
|
||||
assert calls[2] != auth_router._DUMMY_PASSWORD_HASH
|
||||
|
||||
|
||||
def test_login_rate_limit_429(client: TestClient, store: _Store) -> None:
|
||||
store.add_user("dave", hash_password("Secret123!"), role="employee")
|
||||
limit = config.settings.login_rate_limit
|
||||
|
||||
for _ in range(limit):
|
||||
resp = client.post("/api/v1/auth/login", json={"username": "dave", "password": "wrong"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
resp = client.post("/api/v1/auth/login", json={"username": "dave", "password": "wrong"})
|
||||
assert resp.status_code == 429
|
||||
assert "Retry-After" in resp.headers
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /logout
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_logout_revokes_session_and_clears_cookie(client: TestClient, store: _Store) -> None:
|
||||
store.add_user("alice", hash_password("Secret123!"), role="employee")
|
||||
login_resp = client.post(
|
||||
"/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"}
|
||||
)
|
||||
token = login_resp.cookies[config.settings.session_cookie_name]
|
||||
assert token in store.sessions
|
||||
|
||||
logout_resp = client.post("/api/v1/auth/logout")
|
||||
assert logout_resp.status_code == 200
|
||||
assert logout_resp.json() == {"ok": True}
|
||||
assert token not in store.sessions
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /me — session-first + dual-mode legacy fallback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_me_with_session_cookie_returns_db_role(client: TestClient, store: _Store) -> None:
|
||||
store.add_user(
|
||||
"alice",
|
||||
hash_password("Secret123!"),
|
||||
role="employee",
|
||||
display_name="Алиса",
|
||||
org_name="ООО Ромашка",
|
||||
email="alice@romashka.ru",
|
||||
)
|
||||
client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"})
|
||||
|
||||
resp = client.get("/api/v1/me")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["username"] == "alice"
|
||||
assert body["role"] == "employee"
|
||||
assert "/trade-in/**" in body["allowed_paths"]
|
||||
assert "/admin/**" in body["deny_paths"]
|
||||
# Пункты меню «Доля в продаже» / «Кэш» прячет Topbar, фильтруя NAV_ITEMS по
|
||||
# deny_paths ИЗ /me — т.е. видимость держится на ЭТОМ выводе, а не только на
|
||||
# DB_ROLE_PATHS. Сборка dict-а в app/api/v1/me.py может регрессировать
|
||||
# независимо от get_db_role_scope, поэтому пиним её здесь.
|
||||
assert "/trade-in/sale-share/**" in body["deny_paths"]
|
||||
assert "/trade-in/cache/**" in body["deny_paths"]
|
||||
assert body["display_name"] == "Алиса"
|
||||
assert body["org"] == "ООО Ромашка"
|
||||
assert body["email"] == "alice@romashka.ru"
|
||||
|
||||
|
||||
def test_me_manager_role_gets_team_path(client: TestClient, store: _Store) -> None:
|
||||
store.add_user("mgr", hash_password("Secret123!"), role="manager")
|
||||
client.post("/api/v1/auth/login", json={"username": "mgr", "password": "Secret123!"})
|
||||
|
||||
resp = client.get("/api/v1/me")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert "/api/v1/team/**" in body["allowed_paths"]
|
||||
# Тот же пин, что и для employee: manager (роль praktika) не должен получать
|
||||
# из /me deny-список без внутренних разделов — иначе пункты вернутся в топбар.
|
||||
assert "/trade-in/sale-share/**" in body["deny_paths"]
|
||||
assert "/trade-in/cache/**" in body["deny_paths"]
|
||||
|
||||
|
||||
def test_me_without_cookie_dual_mode_legacy_still_works(client: TestClient) -> None:
|
||||
"""Regression guard: без сессии, auth_mode=dual — legacy X-Authenticated-User
|
||||
путь через roles.yaml работает БЕЗ ИЗМЕНЕНИЙ."""
|
||||
resp = client.get("/api/v1/me", headers={"X-Authenticated-User": "admin"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["username"] == "admin"
|
||||
assert body["role"] == "admin"
|
||||
assert body["allowed_paths"] == ["/**"]
|
||||
|
||||
|
||||
def test_me_no_cookie_no_header_401(client: TestClient) -> None:
|
||||
resp = client.get("/api/v1/me")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_expired_session_falls_back_to_no_auth(client: TestClient, store: _Store) -> None:
|
||||
"""Истёкшая сессия трактуется как отсутствие cookie: без legacy-заголовка — 401."""
|
||||
uid = store.add_user("alice", hash_password("Secret123!"), role="employee")
|
||||
store.add_expired_session("expired-tok", uid)
|
||||
|
||||
client.cookies.set(config.settings.session_cookie_name, "expired-tok")
|
||||
resp = client.get("/api/v1/trade-in/dummy")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_expired_session_falls_back_to_legacy_header_in_dual_mode(
|
||||
client: TestClient, store: _Store
|
||||
) -> None:
|
||||
"""Истёкшая сессия + валидный legacy header в dual-mode — header отрабатывает."""
|
||||
uid = store.add_user("alice", hash_password("Secret123!"), role="employee")
|
||||
store.add_expired_session("expired-tok", uid)
|
||||
|
||||
client.cookies.set(config.settings.session_cookie_name, "expired-tok")
|
||||
resp = client.get(
|
||||
"/api/v1/trade-in/dummy",
|
||||
headers={"X-Authenticated-User": "kopylov"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"ok": True}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# rbac_guard dual vs db_only
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_db_only_mode_rejects_legacy_header_without_session(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(config.settings, "auth_mode", "db_only")
|
||||
|
||||
resp = client.get(
|
||||
"/api/v1/trade-in/dummy",
|
||||
headers={"X-Authenticated-User": "admin"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
assert "session" in resp.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_db_only_mode_accepts_valid_session(
|
||||
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
store.add_user("alice", hash_password("Secret123!"), role="employee")
|
||||
client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"})
|
||||
|
||||
monkeypatch.setattr(config.settings, "auth_mode", "db_only")
|
||||
|
||||
resp = client.get("/api/v1/trade-in/dummy")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"ok": True}
|
||||
|
||||
|
||||
def test_session_user_can_reach_tradein_but_not_admin(client: TestClient, store: _Store) -> None:
|
||||
store.add_user("alice", hash_password("Secret123!"), role="employee")
|
||||
client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"})
|
||||
|
||||
ok = client.get("/api/v1/trade-in/dummy")
|
||||
assert ok.status_code == 200
|
||||
|
||||
denied = client.get("/api/v1/admin/dummy")
|
||||
# rbac_guard's admin-gate matches the path regex BEFORE routing even happens
|
||||
# (route isn't registered on this test app) — role=employee != admin -> 403,
|
||||
# never a 404 (a bare "any non-2xx" assertion would mask a rbac_guard typo).
|
||||
assert denied.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2026-07-31: внутренние разделы («Доля в продаже» / «Кэш») закрыты от клиентов —
|
||||
# СКВОЗЬ РЕАЛЬНЫЙ rbac_guard по SESSION-ветке (from_session=True).
|
||||
#
|
||||
# Тесты в tests/test_rbac.py проверяют матчеры напрямую + guard по ЛЕГАСИ
|
||||
# trusted-header ветке (is_path_allowed / roles.yaml). Но в проде клиентские
|
||||
# аккаунты (praktika и прочие DB-юзеры) ходят именно session-cookie'ой, где
|
||||
# scope считает ДРУГАЯ ветка — `_db_role_path_allowed(role, external_path)`.
|
||||
# Без тестов ниже её можно было сломать, не уронив ни одного теста.
|
||||
#
|
||||
# Пути тут — ВНУТРЕННИЕ (Caddy срезает внешний /trade-in), rbac_guard
|
||||
# восстанавливает внешний как '/trade-in' + path.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_INTERNAL_TOOL_API = ("/api/v1/buildings/sale-share", "/api/v1/trade-in/cache-stats")
|
||||
|
||||
|
||||
def test_session_manager_denied_on_internal_tool_api(client: TestClient, store: _Store) -> None:
|
||||
store.add_user("mgr", hash_password("Secret123!"), role="manager")
|
||||
client.post("/api/v1/auth/login", json={"username": "mgr", "password": "Secret123!"})
|
||||
|
||||
for path in _INTERNAL_TOOL_API:
|
||||
resp = client.get(path)
|
||||
assert resp.status_code == 403, f"manager {path}: {resp.status_code} {resp.text}"
|
||||
assert "forbidden for role" in resp.json()["detail"].lower()
|
||||
|
||||
# ...и при этом основной продукт для той же сессии открыт (иначе тест выше
|
||||
# проходил бы и на «сломали scope целиком»).
|
||||
ok = client.get("/api/v1/trade-in/dummy")
|
||||
assert ok.status_code == 200, ok.text
|
||||
|
||||
|
||||
def test_session_employee_denied_on_internal_tool_api(client: TestClient, store: _Store) -> None:
|
||||
store.add_user("emp", hash_password("Secret123!"), role="employee")
|
||||
client.post("/api/v1/auth/login", json={"username": "emp", "password": "Secret123!"})
|
||||
|
||||
for path in _INTERNAL_TOOL_API:
|
||||
resp = client.get(path)
|
||||
assert resp.status_code == 403, f"employee {path}: {resp.status_code} {resp.text}"
|
||||
assert "forbidden for role" in resp.json()["detail"].lower()
|
||||
|
||||
ok = client.get("/api/v1/trade-in/dummy")
|
||||
assert ok.status_code == 200, ok.text
|
||||
|
||||
|
||||
def test_session_admin_keeps_internal_tool_api(client: TestClient, store: _Store) -> None:
|
||||
"""Контрольная группа: DB-роль admin ('/**') разделы по-прежнему видит."""
|
||||
store.add_user("root", hash_password("Secret123!"), role="admin")
|
||||
client.post("/api/v1/auth/login", json={"username": "root", "password": "Secret123!"})
|
||||
|
||||
for path in _INTERNAL_TOOL_API:
|
||||
resp = client.get(path)
|
||||
assert resp.status_code == 200, f"admin {path}: {resp.text}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #2552 post-review CRITICAL fix: session identity must win over a spoofed
|
||||
# client-sent X-Authenticated-User header (was a skip-if-present bug — the
|
||||
# forged header used to override the session for every downstream reader of
|
||||
# the raw header: _assert_estimate_access*, account_quota, /trade-in/history,
|
||||
# support.py — in BOTH auth_mode=dual and db_only).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_session_identity_wins_over_spoofed_header_dual_mode(
|
||||
client: TestClient, store: _Store
|
||||
) -> None:
|
||||
store.add_user("alice", hash_password("Secret123!"), role="employee")
|
||||
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_session_identity_wins_over_spoofed_header_db_only_mode(
|
||||
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
store.add_user("alice", hash_password("Secret123!"), role="employee")
|
||||
store.add_user("victim", hash_password("Secret123!"), role="employee")
|
||||
client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"})
|
||||
|
||||
monkeypatch.setattr(config.settings, "auth_mode", "db_only")
|
||||
|
||||
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_cyrillic_username_session_propagation_does_not_500(
|
||||
client: TestClient, store: _Store
|
||||
) -> None:
|
||||
"""#2552 post-review Medium 1: `.encode("latin-1")` без errors="replace" на
|
||||
кириллическом username крашил бы КАЖДЫЙ запрос такого юзера с 500."""
|
||||
store.add_user("алиса", hash_password("Secret123!"), role="employee")
|
||||
login_resp = client.post(
|
||||
"/api/v1/auth/login", json={"username": "алиса", "password": "Secret123!"}
|
||||
)
|
||||
assert login_resp.status_code == 200, login_resp.text
|
||||
|
||||
resp = client.get("/api/v1/trade-in/whoami")
|
||||
assert resp.status_code == 200, resp.text
|
||||
# latin-1 "replace" гарантированно не крашит — точное значение (что именно
|
||||
# получится из non-latin1 байт) не является контрактом, важно отсутствие 500.
|
||||
assert resp.json()["user"] is not None
|
||||
327
tradein-mvp/backend/tests/test_auth_session.py
Normal file
327
tradein-mvp/backend/tests/test_auth_session.py
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
"""Tests for app.services.auth_session — session CRUD + DB-role scope mapping (#2552).
|
||||
|
||||
Coverage:
|
||||
- create_session: INSERT with CAST(...) (never `:x::type`), commit, unique tokens.
|
||||
- get_session_user: valid/expired/inactive/missing-row + sliding refresh (only when
|
||||
last_seen_at is stale, best-effort — a refresh failure still returns the user).
|
||||
- get_user_by_username: found/not-found.
|
||||
- revoke_session / revoke_user_sessions: DELETE + commit.
|
||||
- get_db_role_scope: employee/manager/admin/unknown mapping.
|
||||
|
||||
All functions here take `db: Session` as a plain argument (no SessionLocal() opened
|
||||
internally) — unit tests just pass a hand-rolled fake, mirroring the `_FakeSession`
|
||||
pattern from tests/test_user_events.py but adapted for `.fetchone()`-based reads.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from app.services import auth_session as svc
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fake DB session
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
"""Minimal `Session` stand-in: queued `.fetchone()` results per `execute()` call,
|
||||
in call order. `execute()` beyond the queue returns a result with `fetchone()
|
||||
-> None`. Set `.raise_on_call = N` to make the Nth `execute()` (1-indexed) raise.
|
||||
"""
|
||||
|
||||
def __init__(self, rows: list[Any] | None = None) -> None:
|
||||
self._rows: list[Any] = list(rows or [])
|
||||
self.executed: list[tuple[str, dict[str, Any] | None]] = []
|
||||
self.committed = 0
|
||||
self.rolled_back = 0
|
||||
self.raise_on_call: int | None = None
|
||||
|
||||
def execute(self, stmt: object, params: dict[str, Any] | None = None) -> SimpleNamespace:
|
||||
call_no = len(self.executed) + 1
|
||||
self.executed.append((str(stmt), params))
|
||||
if self.raise_on_call == call_no:
|
||||
raise RuntimeError("simulated DB failure")
|
||||
row = self._rows.pop(0) if self._rows else None
|
||||
return SimpleNamespace(fetchone=lambda: row)
|
||||
|
||||
def commit(self) -> None:
|
||||
self.committed += 1
|
||||
|
||||
def rollback(self) -> None:
|
||||
self.rolled_back += 1
|
||||
|
||||
|
||||
def _session_row(
|
||||
*,
|
||||
user_id: int = 1,
|
||||
expires_at: datetime | None = None,
|
||||
last_seen_at: datetime | None = None,
|
||||
username: str = "alice",
|
||||
role: str = "employee",
|
||||
is_active: bool = True,
|
||||
) -> SimpleNamespace:
|
||||
now = datetime.now(UTC)
|
||||
return SimpleNamespace(
|
||||
user_id=user_id,
|
||||
expires_at=expires_at if expires_at is not None else now + timedelta(hours=1),
|
||||
last_seen_at=last_seen_at if last_seen_at is not None else now,
|
||||
username=username,
|
||||
role=role,
|
||||
display_name="Alice A.",
|
||||
org_name="Org LLC",
|
||||
email="alice@example.com",
|
||||
is_active=is_active,
|
||||
)
|
||||
|
||||
|
||||
def _user_row(
|
||||
*,
|
||||
user_id: int = 1,
|
||||
username: str = "alice",
|
||||
password_hash: str | None = "hash",
|
||||
role: str = "employee",
|
||||
is_active: bool = True,
|
||||
) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
id=user_id,
|
||||
username=username,
|
||||
password_hash=password_hash,
|
||||
role=role,
|
||||
is_active=is_active,
|
||||
display_name="Alice A.",
|
||||
org_name="Org LLC",
|
||||
email="alice@example.com",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# create_session
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_session_inserts_and_commits() -> None:
|
||||
db = _FakeDB()
|
||||
token = svc.create_session(db, user_id=42, ip="1.2.3.4", user_agent="pytest")
|
||||
|
||||
assert db.committed == 1
|
||||
assert len(db.executed) == 1
|
||||
sql, params = db.executed[0]
|
||||
assert "INSERT INTO tradein_sessions" in sql
|
||||
assert params is not None
|
||||
assert params["user_id"] == 42
|
||||
assert params["ip"] == "1.2.3.4"
|
||||
assert params["user_agent"] == "pytest"
|
||||
assert params["token"] == token
|
||||
assert isinstance(token, str)
|
||||
assert len(token) >= 32
|
||||
|
||||
|
||||
def test_create_session_cast_not_doublecolon() -> None:
|
||||
db = _FakeDB()
|
||||
svc.create_session(db, user_id=1)
|
||||
sql, _ = db.executed[0]
|
||||
assert not re.search(r":\w+::\w", sql)
|
||||
assert "CAST(:ttl_hours AS integer)" in sql
|
||||
assert "CAST(:ip AS inet)" in sql
|
||||
|
||||
|
||||
def test_create_session_tokens_are_unique() -> None:
|
||||
db = _FakeDB()
|
||||
t1 = svc.create_session(db, user_id=1)
|
||||
t2 = svc.create_session(db, user_id=1)
|
||||
assert t1 != t2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_session_user
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_session_user_no_token_returns_none() -> None:
|
||||
db = _FakeDB()
|
||||
assert svc.get_session_user(db, "") is None
|
||||
assert db.executed == []
|
||||
|
||||
|
||||
def test_get_session_user_missing_row_returns_none() -> None:
|
||||
db = _FakeDB(rows=[None])
|
||||
assert svc.get_session_user(db, "tok") is None
|
||||
sql, params = db.executed[0]
|
||||
assert "FROM tradein_sessions s" in sql
|
||||
assert "JOIN tradein_users u" in sql
|
||||
assert params == {"token": "tok"}
|
||||
|
||||
|
||||
def test_get_session_user_expired_returns_none() -> None:
|
||||
now = datetime.now(UTC)
|
||||
db = _FakeDB(rows=[_session_row(expires_at=now - timedelta(minutes=1))])
|
||||
assert svc.get_session_user(db, "tok") is None
|
||||
# Никакого sliding-refresh UPDATE для невалидной сессии.
|
||||
assert len(db.executed) == 1
|
||||
|
||||
|
||||
def test_get_session_user_inactive_returns_none() -> None:
|
||||
db = _FakeDB(rows=[_session_row(is_active=False)])
|
||||
assert svc.get_session_user(db, "tok") is None
|
||||
assert len(db.executed) == 1
|
||||
|
||||
|
||||
def test_get_session_user_valid_recent_no_refresh() -> None:
|
||||
"""last_seen_at свежий (<5 мин) — sliding refresh НЕ триггерится."""
|
||||
now = datetime.now(UTC)
|
||||
db = _FakeDB(rows=[_session_row(last_seen_at=now - timedelta(minutes=1))])
|
||||
result = svc.get_session_user(db, "tok")
|
||||
|
||||
assert result is not None
|
||||
assert result["username"] == "alice"
|
||||
assert result["role"] == "employee"
|
||||
assert result["display_name"] == "Alice A."
|
||||
assert result["org_name"] == "Org LLC"
|
||||
assert result["email"] == "alice@example.com"
|
||||
assert result["user_id"] == 1
|
||||
# Только 1 execute (SELECT) — никакого UPDATE.
|
||||
assert len(db.executed) == 1
|
||||
assert db.committed == 0
|
||||
|
||||
|
||||
def test_get_session_user_stale_last_seen_triggers_refresh() -> None:
|
||||
"""last_seen_at старше 5 минут — один UPDATE (sliding refresh) + commit."""
|
||||
now = datetime.now(UTC)
|
||||
db = _FakeDB(rows=[_session_row(last_seen_at=now - timedelta(minutes=10))])
|
||||
result = svc.get_session_user(db, "tok")
|
||||
|
||||
assert result is not None
|
||||
assert len(db.executed) == 2
|
||||
update_sql, update_params = db.executed[1]
|
||||
assert "UPDATE tradein_sessions" in update_sql
|
||||
assert "SET last_seen_at" in update_sql
|
||||
assert not re.search(r":\w+::\w", update_sql)
|
||||
assert "CAST(:ttl_hours AS integer)" in update_sql
|
||||
assert update_params == {"ttl_hours": 720, "token": "tok"}
|
||||
assert db.committed == 1
|
||||
|
||||
|
||||
def test_get_session_user_refresh_failure_is_swallowed() -> None:
|
||||
"""Sliding-refresh UPDATE падает — всё равно возвращаем валидного юзера
|
||||
(best-effort refresh, не часть решения "валидна ли сессия")."""
|
||||
now = datetime.now(UTC)
|
||||
db = _FakeDB(rows=[_session_row(last_seen_at=now - timedelta(minutes=10))])
|
||||
db.raise_on_call = 2
|
||||
|
||||
result = svc.get_session_user(db, "tok")
|
||||
|
||||
assert result is not None
|
||||
assert result["username"] == "alice"
|
||||
assert db.rolled_back == 1
|
||||
assert db.committed == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_user_by_username
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_user_by_username_found() -> None:
|
||||
db = _FakeDB(rows=[_user_row()])
|
||||
user = svc.get_user_by_username(db, "alice")
|
||||
|
||||
assert user is not None
|
||||
assert user["username"] == "alice"
|
||||
assert user["password_hash"] == "hash"
|
||||
assert user["role"] == "employee"
|
||||
assert user["is_active"] is True
|
||||
sql, params = db.executed[0]
|
||||
assert "FROM tradein_users" in sql
|
||||
assert params == {"username": "alice"}
|
||||
|
||||
|
||||
def test_get_user_by_username_not_found() -> None:
|
||||
db = _FakeDB(rows=[None])
|
||||
assert svc.get_user_by_username(db, "ghost") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# revoke_session / revoke_user_sessions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_revoke_session_deletes_and_commits() -> None:
|
||||
db = _FakeDB()
|
||||
svc.revoke_session(db, "tok")
|
||||
|
||||
assert db.committed == 1
|
||||
sql, params = db.executed[0]
|
||||
assert "DELETE FROM tradein_sessions" in sql
|
||||
assert "token" in sql
|
||||
assert params == {"token": "tok"}
|
||||
|
||||
|
||||
def test_revoke_user_sessions_deletes_and_commits() -> None:
|
||||
db = _FakeDB()
|
||||
svc.revoke_user_sessions(db, 7)
|
||||
|
||||
assert db.committed == 1
|
||||
sql, params = db.executed[0]
|
||||
assert "DELETE FROM tradein_sessions" in sql
|
||||
assert "user_id" in sql
|
||||
assert params == {"user_id": 7}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_db_role_scope
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_db_role_scope_employee_matches_legacy_pilot() -> None:
|
||||
paths, deny = svc.get_db_role_scope("employee")
|
||||
assert "/trade-in/**" in paths
|
||||
assert "/trade-in/api/v1/**" in paths
|
||||
assert "/admin/**" in deny
|
||||
assert "/api/v1/admin/**" in deny
|
||||
|
||||
|
||||
def test_get_db_role_scope_manager_adds_team_path() -> None:
|
||||
paths, deny = svc.get_db_role_scope("manager")
|
||||
assert "/trade-in/**" in paths
|
||||
assert "/api/v1/team/**" in paths
|
||||
assert "/admin/**" in deny
|
||||
|
||||
|
||||
# «Доля в продаже» и «Кэш» — внутренние инструменты (аналитика рынка / состояние
|
||||
# кэшей и скраперов), клиентские роли их не видят (решение владельца 2026-07-31).
|
||||
# В deny И страницы, И их API: один список гейтит пункт меню (Topbar через /me),
|
||||
# страницу (RouteGuard) и серверные ручки (rbac_guard).
|
||||
_INTERNAL_TOOL_DENY = (
|
||||
"/trade-in/sale-share/**",
|
||||
"/trade-in/cache/**",
|
||||
"/trade-in/api/v1/buildings/**",
|
||||
# Глоб, а не точный путь: точный обходится трейлинг-слэшем (см. NB в
|
||||
# app.services.auth_session над DB_ROLE_PATHS).
|
||||
"/trade-in/api/v1/trade-in/cache-stats/**",
|
||||
)
|
||||
|
||||
|
||||
def test_get_db_role_scope_client_roles_deny_internal_tools() -> None:
|
||||
for role in ("manager", "employee"):
|
||||
_, deny = svc.get_db_role_scope(role)
|
||||
for pattern in _INTERNAL_TOOL_DENY:
|
||||
assert pattern in deny, f"{role} deny missing {pattern}"
|
||||
|
||||
|
||||
def test_get_db_role_scope_admin_full_access() -> None:
|
||||
paths, deny = svc.get_db_role_scope("admin")
|
||||
assert paths == ["/**"]
|
||||
assert deny == []
|
||||
|
||||
|
||||
def test_get_db_role_scope_unknown_role_denies_all() -> None:
|
||||
paths, deny = svc.get_db_role_scope("ghost")
|
||||
assert paths == []
|
||||
assert deny == ["/**"]
|
||||
|
|
@ -27,6 +27,41 @@ def test_ekb_anchors_count() -> None:
|
|||
assert isinstance(name, str) and name
|
||||
|
||||
|
||||
# ── resolve_city_name (#2594) ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_resolve_city_name_known_oblast_slugs() -> None:
|
||||
"""Каждый city_slug из CITY_LOCATIONS резолвится в человекочитаемое имя."""
|
||||
from scraper_kit.orchestration.pipeline import CITY_LOCATIONS, resolve_city_name
|
||||
|
||||
expected = {
|
||||
"nizhniy_tagil": "Нижний Тагил",
|
||||
"kamensk_uralskiy": "Каменск-Уральский",
|
||||
"pervouralsk": "Первоуральск",
|
||||
"verkhnyaya_pyshma": "Верхняя Пышма",
|
||||
"serov": "Серов",
|
||||
}
|
||||
# CITY_DISPLAY_NAMES обязан покрывать ровно те же slug'и, что CITY_LOCATIONS
|
||||
# (иначе oblast-город бы тихо получил ЕКБ-дефолт вместо своего имени).
|
||||
assert set(expected) == set(CITY_LOCATIONS)
|
||||
for slug, name in expected.items():
|
||||
assert resolve_city_name(slug) == name
|
||||
|
||||
|
||||
def test_resolve_city_name_none_defaults_to_ekaterinburg() -> None:
|
||||
"""city_slug=None — ЕКБ-развёртка той же функции, НЕ «город неизвестен» (#2594 симметрия)."""
|
||||
from scraper_kit.orchestration.pipeline import EKATERINBURG_CITY_NAME, resolve_city_name
|
||||
|
||||
assert resolve_city_name(None) == EKATERINBURG_CITY_NAME == "Екатеринбург"
|
||||
|
||||
|
||||
def test_resolve_city_name_unknown_slug_defaults_to_ekaterinburg() -> None:
|
||||
"""Неизвестный slug — тот же ЕКБ-дефолт, что и get_city_location/get_city_anchors."""
|
||||
from scraper_kit.orchestration.pipeline import resolve_city_name
|
||||
|
||||
assert resolve_city_name("nonexistent_city") == "Екатеринбург"
|
||||
|
||||
|
||||
# ── CitySweepCounters ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -260,7 +260,6 @@ async def test_geocode_uses_geoportal_first() -> None:
|
|||
patch("app.services.geocoder._cadastral_house_match") as mock_cad,
|
||||
patch("app.services.geocoder._cadastral_forward_sync") as mock_forward,
|
||||
patch("app.services.geocoder._cache_put"),
|
||||
patch("app.services.geocoder._yandex_lookup", new_callable=AsyncMock) as mock_yandex,
|
||||
):
|
||||
result = await geocode("Космонавтов 7б", db)
|
||||
|
||||
|
|
@ -273,7 +272,6 @@ async def test_geocode_uses_geoportal_first() -> None:
|
|||
# geoportal hit short-circuits everything downstream
|
||||
mock_cad.assert_not_called()
|
||||
mock_forward.assert_not_called()
|
||||
mock_yandex.assert_not_called()
|
||||
|
||||
|
||||
async def test_geocode_falls_through_to_cadastral_when_geoportal_misses() -> None:
|
||||
|
|
@ -295,7 +293,6 @@ async def test_geocode_falls_through_to_cadastral_when_geoportal_misses() -> Non
|
|||
patch("app.services.geocoder._cadastral_house_match", return_value=cad_hit) as mock_cad,
|
||||
patch("app.services.geocoder._cadastral_forward_sync") as mock_forward,
|
||||
patch("app.services.geocoder._cache_put"),
|
||||
patch("app.services.geocoder._yandex_lookup", new_callable=AsyncMock) as mock_yandex,
|
||||
):
|
||||
result = await geocode("Серова 27", db)
|
||||
|
||||
|
|
@ -304,7 +301,6 @@ async def test_geocode_falls_through_to_cadastral_when_geoportal_misses() -> Non
|
|||
mock_geo.assert_called_once()
|
||||
mock_cad.assert_called_once()
|
||||
mock_forward.assert_not_called()
|
||||
mock_yandex.assert_not_called()
|
||||
|
||||
|
||||
async def test_geocode_skips_geoportal_when_parse_fails() -> None:
|
||||
|
|
@ -319,14 +315,12 @@ async def test_geocode_skips_geoportal_when_parse_fails() -> None:
|
|||
patch("app.services.geocoder._cadastral_house_match") as mock_cad,
|
||||
patch("app.services.geocoder._cadastral_forward_sync", return_value=[]) as mock_forward,
|
||||
patch("app.services.geocoder._cache_put"),
|
||||
patch("app.services.geocoder.settings") as mock_settings,
|
||||
patch(
|
||||
"app.services.geocoder._nominatim_lookup",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
mock_settings.yandex_geocoder_api_key = None
|
||||
result = await geocode("полный мусор без дома", db)
|
||||
|
||||
assert result is None
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ DB-facing helpers only) — the same pattern as test_estimator_radius_floor.py.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
|
@ -100,6 +101,24 @@ def _run_estimate(
|
|||
# what the always-executed final fallback tier returns.
|
||||
return_value=(list(analogs), False, "W"),
|
||||
),
|
||||
# #2581: explicit, not accidental. Before this fix, `db = MagicMock()`
|
||||
# was left UNCONFIGURED for `_fetch_anchor_comps` — since it's not
|
||||
# patched here, it ran for REAL against the mock session, and
|
||||
# `db.execute(...).mappings().all()` on a bare MagicMock silently
|
||||
# returns `[]` (MagicMock's default `__iter__` == `iter([])`), so
|
||||
# `_fetch_anchor_comps` ALWAYS returned `([], None)` regardless of
|
||||
# what the real SQL would do. That made this whole test file blind
|
||||
# to the #2581 anchor cross-city leak: `anchor_tier` could never
|
||||
# observe becoming 'A' here, so a regression that makes Tier A
|
||||
# wrongly match an EKB listing for a Серов/Тагил subject (which
|
||||
# would then BLOCK this very deals-headline-fallback via the
|
||||
# `anchor_tier is None` gate, see _price_from_inputs #oblast-D) was
|
||||
# invisible. Patched explicitly now so the assumption is documented
|
||||
# and intentional. The behavioral regression test itself lives in
|
||||
# test_non_ekb_anchor_not_leaked_from_ekb_street_collision below,
|
||||
# which does NOT patch `_fetch_anchor_comps` — it exercises the
|
||||
# real SQL/geo-bound logic instead.
|
||||
patch("app.services.estimator._fetch_anchor_comps", return_value=([], None)),
|
||||
patch("app.services.estimator._fetch_deals", return_value=[]),
|
||||
patch(
|
||||
"app.services.estimator._get_or_fetch_imv_cached",
|
||||
|
|
@ -236,3 +255,258 @@ def test_ekb_with_dense_listings_ignores_deals_fallback() -> None:
|
|||
# n_analogs must reflect the real listing count (deals-fallback never ran).
|
||||
assert est.n_analogs == len(analogs)
|
||||
assert 140_000 <= est.median_price_per_m2 <= 150_000
|
||||
|
||||
|
||||
# ── #2581 regression: Tier A anchor must not leak cross-city street collisions ─
|
||||
#
|
||||
# Unlike the tests above (which explicitly patch `_fetch_anchor_comps` — see
|
||||
# the comment on that patch in `_run_estimate`), the tests below do NOT patch
|
||||
# it: they exercise the real Tier A SQL/geo-bound logic against a hand-rolled
|
||||
# `db.execute` fake that computes genuine haversine distance, mirroring what
|
||||
# Postgres' ST_DWithin would decide. This is what actually catches a #2581-class
|
||||
# regression; the tests above only prove the deals-fallback logic given
|
||||
# anchor_tier=None as an already-resolved input.
|
||||
|
||||
|
||||
def _haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""Real great-circle distance — stands in for what Postgres ST_DWithin computes."""
|
||||
r = 6_371_000.0
|
||||
p1, p2 = math.radians(lat1), math.radians(lat2)
|
||||
dphi = math.radians(lat2 - lat1)
|
||||
dlmb = math.radians(lon2 - lon1)
|
||||
a = math.sin(dphi / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dlmb / 2) ** 2
|
||||
return 2 * r * math.asin(math.sqrt(a))
|
||||
|
||||
|
||||
# A real EKB listing on "ул. Ленина" — SAME normalized street+house-number key
|
||||
# as the Серов subject below (_normalize_building_key drops the city token),
|
||||
# ~280 km away. Pre-#2581 the Tier A SQL had NO geo predicate at all, so an
|
||||
# address-string match against this row would have been returned
|
||||
# unconditionally — the actual reported bug (ЕКБ "Ленина 5" leaking into a
|
||||
# Серов estimate at ~150-190k ₽/м² vs. the real ~30k deal corridor).
|
||||
_EKB_LENINA_LISTING = {
|
||||
"price_per_m2": 186_000.0,
|
||||
"area_m2": 45.0,
|
||||
"rooms": 2,
|
||||
"floor": 5,
|
||||
"total_floors": 16,
|
||||
"address": "Екатеринбург, ул. Ленина, 5",
|
||||
"source": "cian",
|
||||
"source_url": "https://cian.ru/sale/flat/leak1/",
|
||||
"price_rub": 186_000.0 * 45.0,
|
||||
"listing_date": None,
|
||||
"days_on_market": 12,
|
||||
"photo_urls": [],
|
||||
"lat": 56.838,
|
||||
"lon": 60.595,
|
||||
"listing_segment": "vtorichka",
|
||||
"source_id": "leak1",
|
||||
}
|
||||
|
||||
|
||||
def _ekb_lenina_pool(prices_per_m2: list[float]) -> list[dict[str, Any]]:
|
||||
"""N distinct EKB "ул. Ленина" comps (>= estimate_sb_min_comps=4 needed for
|
||||
Tier A to actually FIRE — see _fetch_anchor_comps `len(comps) >= min_comps`).
|
||||
Distinct floor/source_id/price_rub per row so `_dedup_cross_source` (#2265,
|
||||
street+floor+area+price physical key) treats them as distinct units, not
|
||||
cross-posted duplicates of the same lot.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
**_EKB_LENINA_LISTING,
|
||||
"source_id": f"leak{i}",
|
||||
"source_url": f"https://cian.ru/sale/flat/leak{i}/",
|
||||
"floor": 3 + 2 * i,
|
||||
"price_per_m2": ppm2,
|
||||
"price_rub": ppm2 * _EKB_LENINA_LISTING["area_m2"],
|
||||
}
|
||||
for i, ppm2 in enumerate(prices_per_m2)
|
||||
]
|
||||
|
||||
|
||||
def _serov_geo() -> Any:
|
||||
from app.services.geocoder import GeocodeResult
|
||||
|
||||
return GeocodeResult(
|
||||
lat=59.6047,
|
||||
lon=60.5876,
|
||||
full_address="Свердловская обл., Серов, ул. Ленина, 5",
|
||||
provider="nominatim",
|
||||
)
|
||||
|
||||
|
||||
def _serov_payload() -> Any:
|
||||
from app.schemas.trade_in import TradeInEstimateInput
|
||||
|
||||
return TradeInEstimateInput(
|
||||
address="Серов, ул. Ленина, 5", area_m2=45.0, rooms=2, floor=5, total_floors=9
|
||||
)
|
||||
|
||||
|
||||
def _fake_anchor_sql_execute(
|
||||
row_pool: list[dict[str, Any]], subject_lat: float, subject_lon: float
|
||||
) -> Any:
|
||||
"""`db.execute` side_effect faking real ST_DWithin filtering for the Tier A
|
||||
anchor SQL only. Identifies that query by its distinctive bound params
|
||||
(`house_re` + `street_like`, unique to Tier A in the whole module).
|
||||
|
||||
If the query ALSO binds `lat`/`lon`/`radius` (post-#2581), computes the
|
||||
real haversine distance and returns rows from `row_pool` ONLY if within
|
||||
`radius` of the bound subject `lat`/`lon` — exactly what Postgres'
|
||||
ST_DWithin would decide. If those params are ABSENT (pre-#2581 — the SQL
|
||||
had no geo predicate at all), returns `row_pool` UNCONDITIONALLY — this is
|
||||
the faithful old-code behaviour (matched by address string alone,
|
||||
regardless of distance), NOT an error: the fake must reproduce the actual
|
||||
bug for the "prove it fails on old code" check to be meaningful, rather
|
||||
than accidentally passing via an unrelated KeyError caught by
|
||||
`_fetch_anchor_comps`'s own try/except.
|
||||
|
||||
Everything else (Tier C anchor, IMV anchor, etc.) degrades to the same
|
||||
empty-result default a bare, unconfigured `MagicMock()` gives.
|
||||
"""
|
||||
|
||||
def _side_effect(query: Any, params: dict[str, Any] | None = None) -> MagicMock:
|
||||
result = MagicMock()
|
||||
params = params or {}
|
||||
if "house_re" in params and "street_like" in params:
|
||||
if "lat" in params and "lon" in params and "radius" in params:
|
||||
dist = _haversine_m(params["lat"], params["lon"], subject_lat, subject_lon)
|
||||
rows = row_pool if dist <= params["radius"] else []
|
||||
else:
|
||||
rows = row_pool
|
||||
result.mappings.return_value.all.return_value = rows
|
||||
else:
|
||||
result.mappings.return_value.all.return_value = []
|
||||
return result
|
||||
|
||||
return _side_effect
|
||||
|
||||
|
||||
def test_non_ekb_anchor_not_leaked_from_ekb_street_collision() -> None:
|
||||
"""#2581: Серов «ул. Ленина, 5» must NOT anchor on an EKB «ул. Ленина, 5» listing.
|
||||
|
||||
Pre-fix this would have returned tier='A' from the EKB listing (~186k
|
||||
₽/м²) and, critically, the `anchor_tier is None` gate on the
|
||||
deals-headline-fallback (_price_from_inputs, #oblast-D) would then have
|
||||
BLOCKED the honest deal-corridor headline too — Серов would surface the
|
||||
EKB-leaked ~186k figure, never even reaching the deals-fallback path.
|
||||
"""
|
||||
dkp_raw = {
|
||||
"count": 12,
|
||||
"low_ppm2": 25_000,
|
||||
"median_ppm2": 30_000,
|
||||
"high_ppm2": 38_000,
|
||||
"period_months": 12,
|
||||
}
|
||||
db = MagicMock()
|
||||
# >= estimate_sb_min_comps EKB comps — realistic (40 191 of ~40 200 active
|
||||
# listings are EKB) and necessary for Tier A to actually fire pre-fix.
|
||||
db.execute.side_effect = _fake_anchor_sql_execute(
|
||||
_ekb_lenina_pool([178_000.0, 186_000.0, 190_000.0, 184_000.0]),
|
||||
subject_lat=56.838,
|
||||
subject_lon=60.595,
|
||||
)
|
||||
geo = _serov_geo()
|
||||
payload = _serov_payload()
|
||||
|
||||
async def _run() -> Any:
|
||||
from app.services.estimator import estimate_quality
|
||||
|
||||
with (
|
||||
patch("app.services.estimator.geocode", new=AsyncMock(return_value=geo)),
|
||||
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
|
||||
patch("app.services.estimator.match_house_readonly", return_value=None),
|
||||
patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
|
||||
patch("app.services.estimator._fetch_analogs", return_value=([], False, "W")),
|
||||
patch("app.services.estimator._fetch_deals", return_value=[]),
|
||||
patch(
|
||||
"app.services.estimator._get_or_fetch_imv_cached",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch(
|
||||
"app.services.estimator._get_or_fetch_yandex_valuation_cached",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch(
|
||||
"app.services.estimator.estimate_via_cian_valuation",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw),
|
||||
patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
|
||||
):
|
||||
return await estimate_quality(payload, db)
|
||||
|
||||
est = anyio.run(_run)
|
||||
|
||||
assert est.median_price_per_m2 == 30_000, (
|
||||
f"headline={est.median_price_per_m2} must come from the honest deal "
|
||||
"corridor (30_000), not an EKB-leaked Tier A anchor (~186k) — the "
|
||||
"cross-city street-name collision must be rejected by ST_DWithin"
|
||||
)
|
||||
assert est.median_price_per_m2 < 100_000, "must NOT be EKB-leaked (~186k)"
|
||||
assert est.n_analogs == 0
|
||||
assert est.confidence == "low"
|
||||
|
||||
|
||||
def test_ekb_anchor_still_works_with_real_same_city_comps() -> None:
|
||||
"""#2581 control: EKB same-building anchor must keep working post-fix.
|
||||
|
||||
Multiple EKB listings on the subject's own street/house, all within the
|
||||
ST_DWithin radius of the subject's own coordinates, must still form a
|
||||
Tier A anchor — proving the geo-bound only rejects genuinely distant
|
||||
(cross-city) collisions, not legitimate same-building EKB matches.
|
||||
"""
|
||||
from app.schemas.trade_in import TradeInEstimateInput
|
||||
from app.services.geocoder import GeocodeResult
|
||||
|
||||
subject_lat, subject_lon = 56.838, 60.595
|
||||
comps = _ekb_lenina_pool([140_000.0, 145_000.0, 150_000.0, 148_000.0])
|
||||
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = _fake_anchor_sql_execute(
|
||||
comps, subject_lat=subject_lat, subject_lon=subject_lon
|
||||
)
|
||||
|
||||
geo = GeocodeResult(
|
||||
lat=subject_lat,
|
||||
lon=subject_lon,
|
||||
full_address="Свердловская обл., Екатеринбург, ул. Ленина, 5",
|
||||
provider="nominatim",
|
||||
)
|
||||
payload = TradeInEstimateInput(
|
||||
address="Екатеринбург, ул. Ленина, 5", area_m2=45.0, rooms=2, floor=5, total_floors=16
|
||||
)
|
||||
|
||||
async def _run() -> Any:
|
||||
from app.services.estimator import estimate_quality
|
||||
|
||||
with (
|
||||
patch("app.services.estimator.geocode", new=AsyncMock(return_value=geo)),
|
||||
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
|
||||
patch("app.services.estimator.match_house_readonly", return_value=None),
|
||||
patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
|
||||
patch("app.services.estimator._fetch_analogs", return_value=([], False, "W")),
|
||||
patch("app.services.estimator._fetch_deals", return_value=[]),
|
||||
patch(
|
||||
"app.services.estimator._get_or_fetch_imv_cached",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch(
|
||||
"app.services.estimator._get_or_fetch_yandex_valuation_cached",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch(
|
||||
"app.services.estimator.estimate_via_cian_valuation",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch("app.services.estimator._fetch_dkp_corridor", return_value=None),
|
||||
patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
|
||||
):
|
||||
return await estimate_quality(payload, db)
|
||||
|
||||
est = anyio.run(_run)
|
||||
|
||||
# Same-building anchor engaged (EKB doesn't degrade): headline built from
|
||||
# the 4 same-building comps, not left n/a / not routed through deals.
|
||||
assert est.n_analogs == 4
|
||||
assert 138_000 <= est.median_price_per_m2 <= 152_000
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
"""Tests for _fetch_analogs per-address cap and per-source quota (source starvation fix).
|
||||
|
||||
Regression: Монтёрская 8/2 — 91 Avito listings с distance=0 выдавливали
|
||||
Cian/Yandex/N1 из топ-50, т.к. pure-distance sort + LIMIT 50.
|
||||
Cian/Yandex из топ-50, т.к. pure-distance sort + LIMIT 50.
|
||||
Fix: MAX_ANALOGS_PER_ADDRESS cap в SQL + MIN_ANALOGS_PER_SOURCE quota в Python.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
# Settings requires DATABASE_URL at init time. Set dummy DSN before any app import.
|
||||
|
|
@ -15,6 +16,7 @@ from unittest.mock import MagicMock
|
|||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_listing(
|
||||
*,
|
||||
source: str,
|
||||
|
|
@ -58,6 +60,7 @@ def _make_db_mock(rows: list[dict[str, Any]]) -> MagicMock:
|
|||
|
||||
# ── Test 1: per-address cap ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_address_cap_limits_per_address_listings() -> None:
|
||||
"""_fetch_analogs caps at MAX_ANALOGS_PER_ADDRESS listings from a single address.
|
||||
|
||||
|
|
@ -91,6 +94,7 @@ def test_address_cap_limits_per_address_listings() -> None:
|
|||
|
||||
# ── Test 2: source quota (regression for Cian starvation) ────────────────────
|
||||
|
||||
|
||||
def test_source_quota_prevents_cian_starvation() -> None:
|
||||
"""MIN_ANALOGS_PER_SOURCE guarantees Cian is represented despite Avito dominance.
|
||||
|
||||
|
|
@ -103,22 +107,28 @@ def test_source_quota_prevents_cian_starvation() -> None:
|
|||
# SQL already applied address cap. Simulate SQL result after cap:
|
||||
# 5 avito (cap applied to large block), 8 cian (different address, 200m away).
|
||||
avito_rows = [
|
||||
_make_listing(source="avito", address="ул. Монтёрская, 8/2", distance_m=0.0,
|
||||
relevance_score=float(i) * 0.01)
|
||||
_make_listing(
|
||||
source="avito",
|
||||
address="ул. Монтёрская, 8/2",
|
||||
distance_m=0.0,
|
||||
relevance_score=float(i) * 0.01,
|
||||
)
|
||||
for i in range(5)
|
||||
]
|
||||
cian_rows = [
|
||||
_make_listing(source="cian", address="ул. Монтёрская, 1", distance_m=200.0,
|
||||
relevance_score=0.2 + float(i) * 0.01)
|
||||
_make_listing(
|
||||
source="cian",
|
||||
address="ул. Монтёрская, 1",
|
||||
distance_m=200.0,
|
||||
relevance_score=0.2 + float(i) * 0.01,
|
||||
)
|
||||
for i in range(8)
|
||||
]
|
||||
# SQL returns avito first (better relevance), then cian
|
||||
sql_rows = avito_rows + cian_rows
|
||||
db = _make_db_mock(sql_rows)
|
||||
|
||||
result, _, _ = _fetch_analogs(
|
||||
db, lat=56.838, lon=60.595, rooms=1, area=38.0, radius_m=1000
|
||||
)
|
||||
result, _, _ = _fetch_analogs(db, lat=56.838, lon=60.595, rooms=1, area=38.0, radius_m=1000)
|
||||
|
||||
cian_count = sum(1 for r in result if r["source"] == "cian")
|
||||
expected_min = min(8, MIN_ANALOGS_PER_SOURCE)
|
||||
|
|
@ -130,6 +140,7 @@ def test_source_quota_prevents_cian_starvation() -> None:
|
|||
|
||||
# ── Test 3: no source starvation when quota > supply ─────────────────────────
|
||||
|
||||
|
||||
def test_source_quota_includes_all_when_supply_below_min() -> None:
|
||||
"""When a source has fewer listings than MIN_ANALOGS_PER_SOURCE, all are included.
|
||||
|
||||
|
|
@ -138,31 +149,38 @@ def test_source_quota_includes_all_when_supply_below_min() -> None:
|
|||
from app.services.estimator import _fetch_analogs
|
||||
|
||||
avito_rows = [
|
||||
_make_listing(source="avito", address="ул. Монтёрская, 8/2", distance_m=0.0,
|
||||
relevance_score=float(i) * 0.01)
|
||||
_make_listing(
|
||||
source="avito",
|
||||
address="ул. Монтёрская, 8/2",
|
||||
distance_m=0.0,
|
||||
relevance_score=float(i) * 0.01,
|
||||
)
|
||||
for i in range(5)
|
||||
]
|
||||
cian_rows = [
|
||||
_make_listing(source="cian", address="ул. Монтёрская, 3", distance_m=300.0,
|
||||
relevance_score=0.3 + float(i) * 0.01)
|
||||
_make_listing(
|
||||
source="cian",
|
||||
address="ул. Монтёрская, 3",
|
||||
distance_m=300.0,
|
||||
relevance_score=0.3 + float(i) * 0.01,
|
||||
)
|
||||
for i in range(3)
|
||||
]
|
||||
sql_rows = avito_rows + cian_rows
|
||||
db = _make_db_mock(sql_rows)
|
||||
|
||||
result, _, _ = _fetch_analogs(
|
||||
db, lat=56.838, lon=60.595, rooms=1, area=38.0, radius_m=1000
|
||||
)
|
||||
result, _, _ = _fetch_analogs(db, lat=56.838, lon=60.595, rooms=1, area=38.0, radius_m=1000)
|
||||
|
||||
cian_count = sum(1 for r in result if r["source"] == "cian")
|
||||
assert cian_count == 3, (
|
||||
f"All 3 Cian listings (below MIN quota) must be included, got {cian_count}"
|
||||
)
|
||||
assert (
|
||||
cian_count == 3
|
||||
), f"All 3 Cian listings (below MIN quota) must be included, got {cian_count}"
|
||||
assert len(result) == 8 # 5 avito + 3 cian
|
||||
|
||||
|
||||
# ── Test 4: fallback signal preserved ────────────────────────────────────────
|
||||
|
||||
|
||||
def test_fallback_signal_reflects_radius() -> None:
|
||||
"""_fetch_analogs returns correct fallback_used boolean based on radius_m.
|
||||
|
||||
|
|
@ -172,8 +190,9 @@ def test_fallback_signal_reflects_radius() -> None:
|
|||
from app.services.estimator import DEFAULT_RADIUS_M, FALLBACK_RADIUS_M, _fetch_analogs
|
||||
|
||||
rows = [
|
||||
_make_listing(source="avito", address="ул. Ленина, 1", distance_m=100.0,
|
||||
relevance_score=0.1),
|
||||
_make_listing(
|
||||
source="avito", address="ул. Ленина, 1", distance_m=100.0, relevance_score=0.1
|
||||
),
|
||||
]
|
||||
|
||||
db_default = _make_db_mock(rows)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ Bug context (issue #582 Phase 5):
|
|||
оставался где user кликнул — иногда в проезде / дворе.
|
||||
|
||||
Fix: backend теперь возвращает snapped_lat/snapped_lon (центр matched здания
|
||||
от Yandex/Nominatim/cadastral) + precision, фронт двигает marker если precision
|
||||
от Nominatim/cadastral) + precision, фронт двигает marker если precision
|
||||
in (exact, number, cadastral).
|
||||
"""
|
||||
|
||||
|
|
@ -46,6 +46,7 @@ def app() -> FastAPI:
|
|||
|
||||
# ── Endpoint response shape ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_reverse_endpoint_returns_snapped_fields(app: FastAPI) -> None:
|
||||
"""Endpoint should include address + lat/lon (echo) + snapped_lat/snapped_lon + precision."""
|
||||
client = TestClient(app)
|
||||
|
|
@ -54,7 +55,7 @@ def test_reverse_endpoint_returns_snapped_fields(app: FastAPI) -> None:
|
|||
snapped_lat=56.838004,
|
||||
snapped_lon=60.586155,
|
||||
precision="exact",
|
||||
provider="yandex",
|
||||
provider="cadastral",
|
||||
)
|
||||
with patch(
|
||||
"app.api.v1.geocode.reverse_geocode",
|
||||
|
|
@ -73,7 +74,7 @@ def test_reverse_endpoint_returns_snapped_fields(app: FastAPI) -> None:
|
|||
assert body["snapped_lat"] == 56.838004
|
||||
assert body["snapped_lon"] == 60.586155
|
||||
assert body["precision"] == "exact"
|
||||
assert body["provider"] == "yandex"
|
||||
assert body["provider"] == "cadastral"
|
||||
|
||||
|
||||
def test_reverse_endpoint_404_when_no_address(app: FastAPI) -> None:
|
||||
|
|
@ -113,6 +114,7 @@ def test_reverse_endpoint_street_precision_does_not_lose_snap_fields(app: FastAP
|
|||
|
||||
# ── snap_precision_useful helper ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_snap_precision_useful_exact_and_number() -> None:
|
||||
assert snap_precision_useful("exact") is True
|
||||
assert snap_precision_useful("number") is True
|
||||
|
|
@ -125,104 +127,3 @@ def test_snap_precision_useful_rejects_street_and_other() -> None:
|
|||
assert snap_precision_useful("locality") is False
|
||||
assert snap_precision_useful("other") is False
|
||||
assert snap_precision_useful("") is False
|
||||
|
||||
|
||||
# ── Yandex reverse parsing ───────────────────────────────────────────────────
|
||||
|
||||
async def test_yandex_reverse_parses_snapped_point_and_precision() -> None:
|
||||
"""`_yandex_reverse` извлекает Point.pos (lon lat) и precision из metaDataProperty."""
|
||||
import httpx
|
||||
|
||||
from app.services.geocoder import _yandex_reverse
|
||||
|
||||
sample = {
|
||||
"response": {
|
||||
"GeoObjectCollection": {
|
||||
"featureMember": [
|
||||
{
|
||||
"GeoObject": {
|
||||
"metaDataProperty": {
|
||||
"GeocoderMetaData": {
|
||||
"precision": "exact",
|
||||
"text": (
|
||||
"Россия, Свердловская область, "
|
||||
"Екатеринбург, улица Малышева, 51"
|
||||
),
|
||||
"kind": "house",
|
||||
}
|
||||
},
|
||||
"name": "улица Малышева, 51",
|
||||
"Point": {"pos": "60.586155 56.838004"},
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeResp:
|
||||
status_code = 200
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
return None
|
||||
|
||||
def json(self) -> dict:
|
||||
return sample
|
||||
|
||||
class _FakeClient:
|
||||
async def __aenter__(self) -> _FakeClient:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_: object) -> None:
|
||||
return None
|
||||
|
||||
async def get(self, *_: object, **__: object) -> _FakeResp:
|
||||
return _FakeResp()
|
||||
|
||||
with patch.object(httpx, "AsyncClient", lambda *a, **kw: _FakeClient()):
|
||||
result = await _yandex_reverse(56.8381, 60.5860, api_key="fake")
|
||||
|
||||
assert result is not None
|
||||
# Yandex pos формат: "lon lat" → snapped_lat=56.838004, snapped_lon=60.586155
|
||||
assert abs(result.snapped_lat - 56.838004) < 1e-6
|
||||
assert abs(result.snapped_lon - 60.586155) < 1e-6
|
||||
assert result.precision == "exact"
|
||||
assert result.provider == "yandex"
|
||||
# Address text должен быть очищен от "Россия, Свердловская область"
|
||||
assert "Россия" not in result.address
|
||||
assert "Свердловская область" not in result.address
|
||||
assert "Малышева" in result.address
|
||||
assert "51" in result.address
|
||||
|
||||
|
||||
async def test_yandex_reverse_returns_none_on_empty_results() -> None:
|
||||
"""Empty featureMember → None."""
|
||||
import httpx
|
||||
|
||||
from app.services.geocoder import _yandex_reverse
|
||||
|
||||
sample = {"response": {"GeoObjectCollection": {"featureMember": []}}}
|
||||
|
||||
class _FakeResp:
|
||||
status_code = 200
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
return None
|
||||
|
||||
def json(self) -> dict:
|
||||
return sample
|
||||
|
||||
class _FakeClient:
|
||||
async def __aenter__(self) -> _FakeClient:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_: object) -> None:
|
||||
return None
|
||||
|
||||
async def get(self, *_: object, **__: object) -> _FakeResp:
|
||||
return _FakeResp()
|
||||
|
||||
with patch.object(httpx, "AsyncClient", lambda *a, **kw: _FakeClient()):
|
||||
result = await _yandex_reverse(56.0, 60.0, api_key="fake")
|
||||
|
||||
assert result is None
|
||||
|
|
|
|||
|
|
@ -8,23 +8,23 @@
|
|||
инвариант OBLAST66 ⊇ TIGHT, дальние города области, известное ограничение (Тюмень
|
||||
внутри bbox координатно — компенсируется region cross-check на accept-сайтах);
|
||||
- _has_oblast_marker — word-boundary matching (не substring);
|
||||
- _nominatim_query / _yandex_lookup accept-сайты — two-pass tie-break (tight ЕКБ
|
||||
приоритетнее) + region cross-check (отсекает Тюмень и т.п. даже внутри bbox).
|
||||
- _nominatim_query accept-сайт — two-pass tie-break (tight ЕКБ приоритетнее) +
|
||||
region cross-check (отсекает Тюмень и т.п. даже внутри bbox).
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.services.geocoder import (
|
||||
_LOCALITY_MARKER_RE,
|
||||
EKB_BBOX_TIGHT,
|
||||
EKB_BBOX_WIDE,
|
||||
OBLAST66_BBOX,
|
||||
_ekb_local_tiers_allowed,
|
||||
_has_oblast_marker,
|
||||
_names_non_ekb_city,
|
||||
_names_unrecognized_locality,
|
||||
_nominatim_query,
|
||||
_yandex_lookup,
|
||||
is_within_ekb_bbox,
|
||||
is_within_ekb_bbox_wide,
|
||||
is_within_oblast66_bbox,
|
||||
|
|
@ -142,9 +142,9 @@ def test_oblast66_bbox_admits_tyumen_by_design() -> None:
|
|||
|
||||
Это ЗНАЕМОЕ и намеренное ограничение самого bbox-хелпера (дешевле
|
||||
false-positive у границы, чем false-negative на корректном адресе области).
|
||||
Реальное отсечение Тюмени происходит на accept-сайтах через region
|
||||
cross-check — см. test_nominatim_query_rejects_out_of_region_only_candidate /
|
||||
test_yandex_lookup_rejects_out_of_region_only_candidate ниже.
|
||||
Реальное отсечение Тюмени происходит на accept-сайте через region
|
||||
cross-check — см. test_nominatim_query_rejects_out_of_region_only_candidate
|
||||
ниже.
|
||||
"""
|
||||
assert is_within_oblast66_bbox(57.1530, 65.5343) is True
|
||||
|
||||
|
|
@ -202,6 +202,162 @@ def test_names_non_ekb_city(address: str, expected: bool) -> None:
|
|||
assert _names_non_ekb_city(address) is expected
|
||||
|
||||
|
||||
# ── _names_unrecognized_locality / _ekb_local_tiers_allowed (#2582) ─────────
|
||||
# Fail-closed гейт: EKB-only локальные тиры (geoportal/cad_buildings) больше
|
||||
# НЕ держатся на списке из 37 городов ("нет в списке → считаем ЕКБ"). Живые
|
||||
# прод-случаи из geocode_cache (все получили координаты ЕКБ по ошибке до
|
||||
# фикса): Ялта (другой регион), Трёхгорный (другой регион), Лёвиха/Реж
|
||||
# (реальные города Свердловской области ВНЕ списка 37).
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"address,expected",
|
||||
[
|
||||
# Живые прод-случаи (#2582) — региональный/settlement маркер есть,
|
||||
# но это не Екатеринбург и не город из известного списка:
|
||||
("республика крым, ялта, ул. репина, 18с2", True),
|
||||
("челябинская обл., трёхгорный, ул. ленина, 15", True),
|
||||
("свердловская обл., кировградский р-н, пос. лёвиха, ул. карла маркса, 8", True),
|
||||
("свердловская обл., реж, ул. ленина, 5", True),
|
||||
("ульяновская обл., димитровград, ул. менделеева, 11", True),
|
||||
# известный город списка ловится отдельно (_names_non_ekb_city),
|
||||
# эта функция на него не обязана реагировать без маркера:
|
||||
("нижний тагил, ленина 10", False),
|
||||
# города/региона вообще не названо — основной трафик, не топоним:
|
||||
("малышева 30", False),
|
||||
("серова 27", False), # word-boundary — не город "серов"
|
||||
("проспект ленина, 1", False),
|
||||
# явный Екатеринбург побеждает даже при наличии маркера "г":
|
||||
("г. екатеринбург, ул. малышева, 30", False),
|
||||
("г екатеринбург, ленинский р-н, ул серова, д 35", False),
|
||||
# ── Изолирующие кейсы (#2589 M3) — параметризация выше зелёная и на
|
||||
# маркер, и на «обл./республика/край» одновременно (все реальные
|
||||
# прод-адреса их несут вместе); ниже — маркер БЕЗ регионального
|
||||
# префикса, чтобы поймать регрессию именно сигнала-маркера (это и
|
||||
# проехало у H1: мёртвый «с\.» был бы незамечен без таких кейсов).
|
||||
(
|
||||
"с. новоалексеевское, ул. 8 марта,37",
|
||||
True,
|
||||
), # живой инцидент (#2589 H1) — «с.» БЕЗ доп. маркера
|
||||
("с. кашино, ул. ленина, 5", True), # изолированный «с.» (M3)
|
||||
("д. родина, ул. мира, 1", True), # изолированный «д.» (M3, lookahead-фикс)
|
||||
("пгт. шаля, ул. советская, 2", True), # изолированный «пгт.» (M3)
|
||||
(
|
||||
"пос. мичуринский, широкореченская ул., 45",
|
||||
True,
|
||||
), # пригородный посёлок ЕКБ — маркер закрывает гейт (M3, честно
|
||||
# закреплено: даже для формально «своих» посёлков без «Екатеринбург»
|
||||
# в тексте фолбэк на oblast-aware внешние провайдеры — не регрессия
|
||||
# координат, просто не через fast-path geoportal)
|
||||
("реж, ленина 5", False), # ИЗВЕСТНАЯ ДЫРА (#2589 M3): голый город вне
|
||||
# списка 37 БЕЗ маркера — не ловится, уходит в ЕКБ-дефолт. Честно
|
||||
# зафиксировано, не «фиксим» здесь (см. docstring — architectural
|
||||
# follow-up отдельно, не через список городов).
|
||||
# house-маркер НЕ должен матчить как топоним (регрессия #2582→#2589):
|
||||
("ул. ленина, д. 5", False),
|
||||
("д. 5", False),
|
||||
("д. 5 стр. 2", False),
|
||||
],
|
||||
)
|
||||
def test_names_unrecognized_locality(address: str, expected: bool) -> None:
|
||||
assert _names_unrecognized_locality(address) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text,expected",
|
||||
[
|
||||
# Ровно набор из deep-review (#2589 H1) — «Проверь сам на живом Python».
|
||||
("д. 5", False),
|
||||
("д. 5 стр. 2", False),
|
||||
("ул. ленина, д. 5", False),
|
||||
("с. кашино", True),
|
||||
("д. родина", True),
|
||||
("пгт. шаля", True),
|
||||
],
|
||||
)
|
||||
def test_locality_marker_re_isolated(text: str, expected: bool) -> None:
|
||||
"""Regex-уровень, изолированно от `_names_unrecognized_locality`'s
|
||||
Екатеринбург-early-return — прямая проверка `_LOCALITY_MARKER_RE` (#2589 M3/H1)."""
|
||||
assert bool(_LOCALITY_MARKER_RE.search(text)) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"address,expected",
|
||||
[
|
||||
# Живые прод-случаи (#2582) — гейт обязан закрыться:
|
||||
("республика крым, ялта, ул. репина, 18с2", False),
|
||||
("челябинская обл., трёхгорный, ул. ленина, 15", False),
|
||||
("свердловская обл., кировградский р-н, пос. лёвиха, ул. карла маркса, 8", False),
|
||||
("свердловская обл., реж, ул. ленина, 5", False),
|
||||
# известный город региона 66 (список) — тоже закрыт:
|
||||
("Нижний Тагил, проспект Ленина, 1", False),
|
||||
("Каменск-Уральский, ул. Ленина 1", False),
|
||||
# ЕКБ явно назван — гейт открыт (regardless маркера "г"):
|
||||
("г. Екатеринбург, ул. Малышева, 30", True),
|
||||
("620144, Свердловская обл, г Екатеринбург, Ленинский р-н, ул Серова, д 35", True),
|
||||
# города/региона вообще не названо — ОСНОВНОЙ ТРАФИК (форма оценки без
|
||||
# города) — дефолт ЕКБ должен остаться рабочим, иначе деградация #1:
|
||||
("Малышева 30", True),
|
||||
("Серова 27", True),
|
||||
("Космонавтов 7б", True),
|
||||
("проспект Ленина, 1", True),
|
||||
# мкр-исключение (район внутри ЕКБ, не ЗАТО Заречный) — гейт открыт:
|
||||
("мкр Заречный, ул. Ленина 5", True),
|
||||
# изолирующий кейс (#2589 H1/M3) — «с.»-маркер БЕЗ доп. «обл.»/«республика»:
|
||||
("с. Новоалексеевское, ул. 8 Марта,37", False),
|
||||
# house-маркер «д.» НЕ закрывает гейт (регрессия H1-фикса):
|
||||
("ул. Ленина, д. 5", True),
|
||||
],
|
||||
)
|
||||
def test_ekb_local_tiers_allowed(address: str, expected: bool) -> None:
|
||||
assert _ekb_local_tiers_allowed(address) is expected
|
||||
|
||||
|
||||
# ── _ekb_local_tiers_allowed × city_hint (#2580 merge, #2582, #2589 M2) ─────
|
||||
# Обе логики независимо закрывают гейт (OR трёх сигналов) — проверяем, что
|
||||
# ни одна из веток не стала недостижимой после слияния #2580/#2582/#2589-M2:
|
||||
# A. city_hint называет известный город региона 66 (не ЕКБ) → закрыто.
|
||||
# A2. city_hint — ЛЮБАЯ непустая строка, отличная от "Екатеринбург" (в т.ч.
|
||||
# НЕ из списка 37 — #2589 M2, было главной дырой: `city_hint="Реж"`
|
||||
# раньше держал гейт открытым, geoportal отдавал точный ЕКБ-дом с
|
||||
# `city_ambiguous=False` — уверенно-неверный результат) → закрыто.
|
||||
# B. незнакомый топоним в ТЕКСТЕ адреса (маркер) → закрыто.
|
||||
# C. ни того ни другого → открыто (дефолт ЕКБ).
|
||||
# D. явный city_hint="Екатеринбург" (без конфликта в тексте) → открыто.
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"address,city_hint,expected,label",
|
||||
[
|
||||
# A: hint называет известный не-ЕКБ город региона 66 → закрыто, даже
|
||||
# если сам текст адреса города вообще не называет (#2580 C1).
|
||||
("Ленина, 1", "Нижний Тагил", False, "A: hint=известный не-ЕКБ город"),
|
||||
("Серова 27", "Каменск-Уральский", False, "A: hint=известный не-ЕКБ город (2)"),
|
||||
# A2 (#2589 M2 — ключевой регрессионный кейс фикса): hint называет
|
||||
# город, КОТОРОГО НЕТ в списке 37 (произвольный текст через API, не
|
||||
# UI-selector) → тоже закрыто. До фикса `_names_non_ekb_city(city_hint)`
|
||||
# давал False (Реж не в списке) → гейт был открыт.
|
||||
("Ленина, 1", "Реж", False, "A2: hint=не-ЕКБ город ВНЕ списка 37"),
|
||||
("Серова 27", "Ялта", False, "A2: hint=город другого региона"),
|
||||
# B: незнакомый топоним в тексте (маркер) закрывает гейт независимо
|
||||
# от hint (#2582) — hint здесь не задан, чистая проверка B.
|
||||
("свердловская обл., реж, ул. ленина, 5", None, False, "B: маркер в тексте"),
|
||||
# C: ни маркера в тексте, ни (не-ЕКБ) hint'а → открыто, основной трафик.
|
||||
("Малышева 30", None, True, "C: ничего не названо"),
|
||||
("Серова 27", "", True, "C: пустой hint == отсутствию hint'а"),
|
||||
# D: явный city_hint="Екатеринбург" без конфликта в тексте → открыто —
|
||||
# ключевая проверка, что #2580 C1 / #2589 M2 не сломали дефолтный
|
||||
# ЕКБ-путь через hint.
|
||||
("Ленина, 1", "Екатеринбург", True, "D: hint=Екатеринбург"),
|
||||
("Серова 27", "екатеринбург", True, "D: hint=екатеринбург (регистр)"),
|
||||
],
|
||||
)
|
||||
def test_ekb_local_tiers_allowed_with_city_hint(
|
||||
address: str, city_hint: str | None, expected: bool, label: str
|
||||
) -> None:
|
||||
assert _ekb_local_tiers_allowed(address, city_hint) is expected, label
|
||||
|
||||
|
||||
# ── Accept-site two-pass tie-break + region cross-check ─────────────────────
|
||||
|
||||
|
||||
|
|
@ -260,66 +416,3 @@ async def test_nominatim_query_accepts_oblast_wide_when_region_absent() -> None:
|
|||
|
||||
|
||||
_REAL_ASYNC_CLIENT = httpx.AsyncClient
|
||||
|
||||
|
||||
def _yandex_client_factory(transport: httpx.MockTransport):
|
||||
"""Drop-in replacement для httpx.AsyncClient внутри geocoder._yandex_lookup."""
|
||||
|
||||
def factory(*_: object, **__: object) -> httpx.AsyncClient:
|
||||
return _REAL_ASYNC_CLIENT(transport=transport)
|
||||
|
||||
return factory
|
||||
|
||||
|
||||
def _yandex_geo_object(lat: str, lon: str, admin_area: str | None) -> dict:
|
||||
meta: dict = {"precision": "exact", "text": "тест"}
|
||||
if admin_area is not None:
|
||||
meta["AddressDetails"] = {
|
||||
"Country": {"AdministrativeArea": {"AdministrativeAreaName": admin_area}}
|
||||
}
|
||||
return {
|
||||
"GeoObject": {
|
||||
"Point": {"pos": f"{lon} {lat}"},
|
||||
"metaDataProperty": {"GeocoderMetaData": meta},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _yandex_payload(members: list[dict]) -> dict:
|
||||
return {"response": {"GeoObjectCollection": {"featureMember": members}}}
|
||||
|
||||
|
||||
async def test_yandex_lookup_prefers_tight_ekb_over_oblast_rank0() -> None:
|
||||
"""Тот же tie-break сценарий, что и для Nominatim, но для Yandex top-5."""
|
||||
members = [
|
||||
_yandex_geo_object("59.6297", "60.5541", "Свердловская область"), # Серов, rank0
|
||||
_yandex_geo_object("56.838", "60.605", "Свердловская область"), # ЕКБ tight, rank1
|
||||
]
|
||||
payload = _yandex_payload(members)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json=payload)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
with patch("app.services.geocoder.httpx.AsyncClient", _yandex_client_factory(transport)):
|
||||
result = await _yandex_lookup("Серова 27", "fake-key")
|
||||
|
||||
assert result is not None
|
||||
assert result.lat == pytest.approx(56.838)
|
||||
assert result.lon == pytest.approx(60.605)
|
||||
|
||||
|
||||
async def test_yandex_lookup_rejects_out_of_region_only_candidate() -> None:
|
||||
"""Единственный кандидат — Тюмень (bbox admits координатно, регион другой) →
|
||||
region cross-check отсекает даже в ultimate as-is fallback → None."""
|
||||
members = [_yandex_geo_object("57.1522", "65.5272", "Тюменская область")]
|
||||
payload = _yandex_payload(members)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json=payload)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
with patch("app.services.geocoder.httpx.AsyncClient", _yandex_client_factory(transport)):
|
||||
result = await _yandex_lookup("какой-то мусорный адрес", "fake-key")
|
||||
|
||||
assert result is None
|
||||
|
|
|
|||
|
|
@ -188,7 +188,6 @@ async def test_geocode_uses_house_match_before_legacy_forward() -> None:
|
|||
"app.services.geocoder._cadastral_forward_sync",
|
||||
) as mock_forward,
|
||||
patch("app.services.geocoder._cache_put"),
|
||||
patch("app.services.geocoder._yandex_lookup", new_callable=AsyncMock) as mock_yandex,
|
||||
):
|
||||
result = await geocode("Серова 27", db)
|
||||
|
||||
|
|
@ -199,7 +198,6 @@ async def test_geocode_uses_house_match_before_legacy_forward() -> None:
|
|||
mock_house.assert_called_once()
|
||||
# house-match hit → legacy raw-ILIKE forward never invoked
|
||||
mock_forward.assert_not_called()
|
||||
mock_yandex.assert_not_called()
|
||||
|
||||
|
||||
async def test_geocode_falls_back_to_legacy_forward_when_house_match_misses() -> None:
|
||||
|
|
@ -225,7 +223,6 @@ async def test_geocode_falls_back_to_legacy_forward_when_house_match_misses() ->
|
|||
return_value=[legacy_hit],
|
||||
) as mock_forward,
|
||||
patch("app.services.geocoder._cache_put"),
|
||||
patch("app.services.geocoder._yandex_lookup", new_callable=AsyncMock) as mock_yandex,
|
||||
):
|
||||
result = await geocode("Серова 27", db)
|
||||
|
||||
|
|
@ -233,7 +230,6 @@ async def test_geocode_falls_back_to_legacy_forward_when_house_match_misses() ->
|
|||
assert result.lat == 56.84
|
||||
mock_house.assert_called_once()
|
||||
mock_forward.assert_called_once()
|
||||
mock_yandex.assert_not_called()
|
||||
|
||||
|
||||
async def test_geocode_skips_house_match_when_parse_fails() -> None:
|
||||
|
|
@ -250,14 +246,12 @@ async def test_geocode_skips_house_match_when_parse_fails() -> None:
|
|||
return_value=[],
|
||||
) as mock_forward,
|
||||
patch("app.services.geocoder._cache_put"),
|
||||
patch("app.services.geocoder.settings") as mock_settings,
|
||||
patch(
|
||||
"app.services.geocoder._nominatim_lookup",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
mock_settings.yandex_geocoder_api_key = None
|
||||
result = await geocode("полный мусор без дома", db)
|
||||
|
||||
assert result is None
|
||||
|
|
@ -290,14 +284,12 @@ async def test_geocode_skips_ekb_local_tiers_for_non_ekb_city() -> None:
|
|||
patch("app.services.geocoder._cadastral_house_match") as mock_house,
|
||||
patch("app.services.geocoder._cadastral_forward_sync") as mock_forward,
|
||||
patch("app.services.geocoder._cache_put"),
|
||||
patch("app.services.geocoder.settings") as mock_settings,
|
||||
patch(
|
||||
"app.services.geocoder._nominatim_lookup",
|
||||
new_callable=AsyncMock,
|
||||
return_value=nominatim_result,
|
||||
) as mock_nominatim,
|
||||
):
|
||||
mock_settings.yandex_geocoder_api_key = None
|
||||
result = await geocode("Нижний Тагил, проспект Ленина, 1", db)
|
||||
|
||||
assert result is not None
|
||||
|
|
@ -309,6 +301,172 @@ async def test_geocode_skips_ekb_local_tiers_for_non_ekb_city() -> None:
|
|||
mock_nominatim.assert_called_once()
|
||||
|
||||
|
||||
# ── geocode() fail-closed gate for UNRECOGNIZED locality (#2582) ────────────
|
||||
# Живые прод-случаи из geocode_cache: адрес называет город/регион ВНЕ списка
|
||||
# из 37 (или другой регион РФ целиком) — до фикса `_names_non_ekb_city`
|
||||
# молчаливо считал это «город не назван» → EKB-only тиры отвечали первыми и
|
||||
# снапали улицу+дом в координаты ЕКБ.
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("address", "label"),
|
||||
[
|
||||
("республика крым, ялта, ул. репина, 18с2", "Ялта (другой регион)"),
|
||||
("челябинская обл., трёхгорный, ул. ленина, 15", "Трёхгорный (другой регион)"),
|
||||
(
|
||||
"свердловская обл., кировградский р-н, пос. лёвиха, ул. карла маркса, 8",
|
||||
"Лёвиха (город области ВНЕ списка 37)",
|
||||
),
|
||||
("свердловская обл., реж, ул. ленина, 5", "Реж (город области ВНЕ списка 37)"),
|
||||
(
|
||||
"с. Новоалексеевское, ул. 8 Марта,37",
|
||||
"Новоалексеевское (живой инцидент #2589 H1 — «с.»-маркер без доп. «обл.»)",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_geocode_skips_ekb_local_tiers_for_unrecognized_locality(
|
||||
address: str, label: str
|
||||
) -> None:
|
||||
"""EKB-only локальные тиры (geoportal/cadastral) НЕ вызываются для адреса,
|
||||
называющего незнакомый топоним — идём сразу к внешнему oblast/nation-aware
|
||||
провайдеру (#2582)."""
|
||||
db = MagicMock()
|
||||
nominatim_result = GeocodeResult(
|
||||
lat=45.0,
|
||||
lon=34.0,
|
||||
full_address=address,
|
||||
provider="nominatim",
|
||||
confidence="approximate",
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.services.geocoder._cache_get", return_value=None),
|
||||
patch("app.services.geocoder._geoportal_house_match") as mock_geoportal,
|
||||
patch("app.services.geocoder._cadastral_house_match") as mock_house,
|
||||
patch("app.services.geocoder._cadastral_forward_sync") as mock_forward,
|
||||
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(address, db)
|
||||
|
||||
assert result is not None, label
|
||||
mock_geoportal.assert_not_called()
|
||||
mock_house.assert_not_called()
|
||||
mock_forward.assert_not_called()
|
||||
mock_nominatim.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("address", "label"),
|
||||
[
|
||||
("Серова 27", "ЕКБ без города (основной трафик формы оценки)"),
|
||||
("Малышева 30", "ЕКБ без города (основной трафик формы оценки)"),
|
||||
("г. Екатеринбург, ул. Серова, д. 27", "ЕКБ с явным городом"),
|
||||
("ул. Серова, д. 27", "house-маркер «д.» не закрывает гейт (#2589 H1 regression)"),
|
||||
],
|
||||
)
|
||||
async def test_geocode_still_uses_ekb_local_tiers_for_real_ekb_address(
|
||||
address: str, label: str
|
||||
) -> None:
|
||||
"""Регрессия: реальный ЕКБ-адрес (с городом и без) по-прежнему резолвится
|
||||
через geoportal (fast-path) — fail-closed гейт не должен деградировать
|
||||
основной трафик (#2582)."""
|
||||
db = MagicMock()
|
||||
hit = GeocodeSuggestion(
|
||||
label="ул. Серова, д. 27, Екатеринбург",
|
||||
full_address="ул. Серова, д. 27, Екатеринбург",
|
||||
lat=56.81188,
|
||||
lon=60.59739,
|
||||
kind="house",
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.services.geocoder._cache_get", return_value=None),
|
||||
patch("app.services.geocoder._geoportal_house_match", return_value=hit) as mock_geoportal,
|
||||
patch("app.services.geocoder._cache_put"),
|
||||
):
|
||||
result = await geocode(address, db)
|
||||
|
||||
assert result is not None, label
|
||||
assert result.lat == pytest.approx(56.81188), label
|
||||
mock_geoportal.assert_called_once()
|
||||
|
||||
|
||||
async def test_geocode_explicit_ekaterinburg_city_hint_still_uses_local_tiers() -> None:
|
||||
"""Слияние #2580/#2582: явный `city_hint="Екатеринбург"` (без города в самом
|
||||
тексте адреса, без конфликта) по-прежнему открывает ЕКБ-only локальные
|
||||
тиры — ветка D из объединённого гейта `_ekb_local_tiers_allowed` не должна
|
||||
была стать недостижимой после merge."""
|
||||
db = MagicMock()
|
||||
hit = GeocodeSuggestion(
|
||||
label="ул. Серова, д. 27, Екатеринбург",
|
||||
full_address="ул. Серова, д. 27, Екатеринбург",
|
||||
lat=56.81188,
|
||||
lon=60.59739,
|
||||
kind="house",
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.services.geocoder._cache_get", return_value=None),
|
||||
patch("app.services.geocoder._geoportal_house_match", return_value=hit) as mock_geoportal,
|
||||
patch("app.services.geocoder._cache_put"),
|
||||
):
|
||||
result = await geocode("Серова 27", db, city_hint="Екатеринбург")
|
||||
|
||||
assert result is not None
|
||||
assert result.lat == pytest.approx(56.81188)
|
||||
mock_geoportal.assert_called_once()
|
||||
|
||||
|
||||
async def test_geocode_city_hint_unknown_city_skips_local_ekb_tiers() -> None:
|
||||
"""#2589 M2 (deep-review fix): `city_hint="Реж"` — город ВНЕ списка 37 и
|
||||
без структурного маркера в самом тексте адреса — обязан закрыть ЕКБ-only
|
||||
локальные тиры так же, как известный `city_hint`. До фикса
|
||||
`hint_names_non_ekb = _names_non_ekb_city(city_hint)` был list-based и
|
||||
держал гейт открытым для ЛЮБОГО города вне списка 37 (Реж, седьмой город
|
||||
после расширения UI-selector'а, произвольный текст через API) — geoportal
|
||||
отдавал точный ЕКБ-хит, а `city_ambiguous=False` (хинт ведь БЫЛ) делало
|
||||
это уверенно-неверным результатом (хуже, чем честный `city_ambiguous=True`
|
||||
без хинта вообще)."""
|
||||
db = MagicMock()
|
||||
ekb_hit = GeocodeSuggestion(
|
||||
label="Ленина, 1, Екатеринбург",
|
||||
full_address="Ленина, 1, Екатеринбург",
|
||||
lat=56.83788,
|
||||
lon=60.58018,
|
||||
kind="house",
|
||||
)
|
||||
rezh_result = GeocodeResult(
|
||||
lat=57.371, lon=61.386, full_address="Ленина, 1, Реж", provider="nominatim"
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.services.geocoder._cache_get", return_value=None),
|
||||
patch("app.services.geocoder._cache_put"),
|
||||
patch(
|
||||
"app.services.geocoder._geoportal_house_match", return_value=ekb_hit
|
||||
) as mock_geoportal,
|
||||
patch("app.services.geocoder._cadastral_house_match", return_value=ekb_hit) as mock_house,
|
||||
patch("app.services.geocoder._cadastral_forward_sync", return_value=[]),
|
||||
patch(
|
||||
"app.services.geocoder._nominatim_lookup",
|
||||
new_callable=AsyncMock,
|
||||
return_value=rezh_result,
|
||||
),
|
||||
):
|
||||
result = await geocode("Ленина, 1", db, city_hint="Реж")
|
||||
|
||||
mock_geoportal.assert_not_called()
|
||||
mock_house.assert_not_called()
|
||||
assert result is not None
|
||||
assert result.lat == pytest.approx(57.371) # Реж, НЕ подставленный ЕКБ-хит (56.83788)
|
||||
assert result.lat != pytest.approx(56.83788)
|
||||
|
||||
|
||||
# ── suggest() wiring ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -331,7 +489,6 @@ async def test_suggest_uses_house_match_before_legacy_forward() -> None:
|
|||
patch(
|
||||
"app.services.geocoder._cadastral_forward_sync",
|
||||
) as mock_forward,
|
||||
patch("app.services.geocoder._yandex_suggest", new_callable=AsyncMock) as mock_yandex,
|
||||
):
|
||||
results = await suggest("Серова 27", db=db, limit=8)
|
||||
|
||||
|
|
@ -339,7 +496,6 @@ async def test_suggest_uses_house_match_before_legacy_forward() -> None:
|
|||
assert results[0].lat == 56.81188
|
||||
mock_house.assert_called_once()
|
||||
mock_forward.assert_not_called()
|
||||
mock_yandex.assert_not_called()
|
||||
|
||||
|
||||
async def test_suggest_falls_back_to_legacy_forward_when_house_match_misses() -> None:
|
||||
|
|
@ -364,7 +520,6 @@ async def test_suggest_falls_back_to_legacy_forward_when_house_match_misses() ->
|
|||
"app.services.geocoder._cadastral_forward_sync",
|
||||
return_value=legacy,
|
||||
) as mock_forward,
|
||||
patch("app.services.geocoder._yandex_suggest", new_callable=AsyncMock) as mock_yandex,
|
||||
):
|
||||
results = await suggest("Серова 27", db=db, limit=8)
|
||||
|
||||
|
|
@ -372,7 +527,6 @@ async def test_suggest_falls_back_to_legacy_forward_when_house_match_misses() ->
|
|||
assert results[0].lat == 56.84
|
||||
mock_house.assert_called_once()
|
||||
mock_forward.assert_called_once()
|
||||
mock_yandex.assert_not_called()
|
||||
|
||||
|
||||
async def test_suggest_skips_ekb_local_tier_for_non_ekb_city() -> None:
|
||||
|
|
@ -401,7 +555,6 @@ async def test_suggest_skips_ekb_local_tier_for_non_ekb_city() -> None:
|
|||
) as mock_nominatim,
|
||||
):
|
||||
mock_settings.dadata_api_token = None
|
||||
mock_settings.yandex_geocoder_api_key = None
|
||||
results = await suggest("Нижний Тагил, проспект Ленина, 1", db=db, limit=8)
|
||||
|
||||
assert len(results) == 1
|
||||
|
|
@ -409,3 +562,45 @@ async def test_suggest_skips_ekb_local_tier_for_non_ekb_city() -> None:
|
|||
mock_house.assert_not_called()
|
||||
mock_forward.assert_not_called()
|
||||
mock_nominatim.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("query", "label"),
|
||||
[
|
||||
("республика крым, ялта, ул. репина, 18с2", "Ялта (другой регион)"),
|
||||
("свердловская обл., реж, ул. ленина, 5", "Реж (город области ВНЕ списка 37)"),
|
||||
],
|
||||
)
|
||||
async def test_suggest_skips_ekb_local_tier_for_unrecognized_locality(
|
||||
query: str, label: str
|
||||
) -> None:
|
||||
"""Незнакомый топоним (не в списке 37, не Екатеринбург) → EKB-only
|
||||
cadastral Tier 1 не вызывается; falls through к внешним тирам (#2582)."""
|
||||
db = MagicMock()
|
||||
nominatim_hit = [
|
||||
GeocodeSuggestion(
|
||||
label="ул. Репина, 18с2",
|
||||
full_address=query,
|
||||
lat=44.5,
|
||||
lon=34.16,
|
||||
kind="house",
|
||||
)
|
||||
]
|
||||
|
||||
with (
|
||||
patch("app.services.geocoder._cadastral_house_match") as mock_house,
|
||||
patch("app.services.geocoder._cadastral_forward_sync") as mock_forward,
|
||||
patch("app.services.geocoder.settings") as mock_settings,
|
||||
patch(
|
||||
"app.services.geocoder._nominatim_suggest",
|
||||
new_callable=AsyncMock,
|
||||
return_value=nominatim_hit,
|
||||
) as mock_nominatim,
|
||||
):
|
||||
mock_settings.dadata_api_token = None
|
||||
results = await suggest(query, db=db, limit=8)
|
||||
|
||||
assert len(results) == 1, label
|
||||
mock_house.assert_not_called()
|
||||
mock_forward.assert_not_called()
|
||||
mock_nominatim.assert_called_once()
|
||||
|
|
|
|||
433
tradein-mvp/backend/tests/test_geocoder_city_hint.py
Normal file
433
tradein-mvp/backend/tests/test_geocoder_city_hint.py
Normal file
|
|
@ -0,0 +1,433 @@
|
|||
"""Тесты #2576 — geocoder больше НЕ подставляет "Екатеринбург" молча.
|
||||
|
||||
Проблема (issue #2576 / эпик расширения на область): `_nominatim_lookup`,
|
||||
`_nominatim_suggest` при отсутствии маркера города/области в самом адресе
|
||||
всегда молча подставляли "Екатеринбург" — житель Нижнего Тагила, вводя
|
||||
«Ленина, 1», получал уверенно неверную цену по екатеринбургской улице Ленина
|
||||
(обе улицы называются одинаково).
|
||||
|
||||
#2593: Yandex Geocoder (`_yandex_lookup`/`_yandex_suggest`) удалён из geocoder —
|
||||
их тесты здесь удалены вместе с ним; покрытие тех же сценариев (bias/prefix
|
||||
без молчаливого "Екатеринбург") осталось на `_nominatim_lookup`.
|
||||
|
||||
Покрывают:
|
||||
- `_resolve_city_for_geocode` — приоритет: маркер в адресе > `city_hint` > None.
|
||||
- `_nominatim_suggest` — без города НЕ форсит city-суффикс молчаливо (dual-query);
|
||||
с `city_hint` — суффикс из hint'а; с явным "Екатеринбург" в адресе — поведение
|
||||
НЕ изменилось (как раньше).
|
||||
- `geocode()` — `city_ambiguous=True` когда город не указан ни в адресе, ни в
|
||||
`city_hint`; `False` когда указан явно (текстом или через `city_hint`).
|
||||
- Cache-ключ (`_cache_key`) — разные `city_hint` для одного текста адреса НЕ
|
||||
делят одну запись кэша (regression test на cache poisoning).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db")
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.geocoder import (
|
||||
GeocodeResult,
|
||||
GeocodeSuggestion,
|
||||
_cache_key,
|
||||
_nominatim_suggest,
|
||||
_resolve_city_for_geocode,
|
||||
geocode,
|
||||
suggest,
|
||||
)
|
||||
|
||||
# ── _resolve_city_for_geocode ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"address,city_hint,expected",
|
||||
[
|
||||
# Ни маркер, ни hint — раньше здесь молча подставлялся "Екатеринбург".
|
||||
("Ленина, 1", None, (None, False)),
|
||||
("Ленина, 1", "", (None, False)),
|
||||
("Ленина, 1", " ", (None, False)),
|
||||
# city_hint передан явно вызывающим кодом.
|
||||
("Ленина, 1", "Нижний Тагил", ("Нижний Тагил", True)),
|
||||
# Маркер уже в адресе — hint игнорируется (marker имеет приоритет).
|
||||
("Нижний Тагил, Ленина, 1", "Серов", (None, True)),
|
||||
("Екатеринбург, Малышева 30", None, (None, True)),
|
||||
("Екатеринбург, Малышева 30", "Серов", (None, True)),
|
||||
],
|
||||
)
|
||||
def test_resolve_city_for_geocode(
|
||||
address: str, city_hint: str | None, expected: tuple[str | None, bool]
|
||||
) -> None:
|
||||
assert _resolve_city_for_geocode(address, city_hint) == expected
|
||||
|
||||
|
||||
# ── _cache_key — cache poisoning между городами ──────────────────────────────
|
||||
|
||||
|
||||
def test_cache_key_without_hint_unchanged() -> None:
|
||||
"""Без city_hint формат ключа НЕ меняется — backward-compat с накопленным кэшем."""
|
||||
assert _cache_key("ленина, 1", None) == "ленина, 1"
|
||||
assert _cache_key("ленина, 1", "") == "ленина, 1"
|
||||
|
||||
|
||||
def test_cache_key_different_cities_do_not_collide() -> None:
|
||||
"""#2576: разные города для одного текста адреса — разные cache-ключи."""
|
||||
key_tagil = _cache_key("ленина, 1", "Нижний Тагил")
|
||||
key_ekb = _cache_key("ленина, 1", "Екатеринбург")
|
||||
key_none = _cache_key("ленина, 1", None)
|
||||
|
||||
assert key_tagil != key_ekb
|
||||
assert key_tagil != key_none
|
||||
assert key_ekb != key_none
|
||||
|
||||
|
||||
def test_cache_key_hint_normalized() -> None:
|
||||
"""city_hint нормализуется (case/whitespace) — не создаёт лишних ключей."""
|
||||
assert _cache_key("ленина, 1", "Нижний Тагил") == _cache_key("ленина, 1", "нижний тагил ")
|
||||
|
||||
|
||||
# ── _nominatim_suggest ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_nominatim_suggest_no_city_dual_query_both_variants_sent() -> None:
|
||||
"""#2580 (C2): без города — Nominatim получает ОБА запроса: bare (честный,
|
||||
без города) И ЕКБ-suffixed (majority-трафик). Не подмена одним вариантом —
|
||||
объединение (см. `test_nominatim_suggest_pobedy20_ekb_result_not_lost` ниже
|
||||
— чистое удаление суффикса теряло реальные ЕКБ-адреса).
|
||||
"""
|
||||
calls: list[str] = []
|
||||
|
||||
async def fake_query_multi(client, query, limit):
|
||||
calls.append(query)
|
||||
return []
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.services.geocoder._nominatim_query_multi",
|
||||
new=AsyncMock(side_effect=fake_query_multi),
|
||||
),
|
||||
patch("app.services.geocoder.asyncio.sleep", new=AsyncMock()),
|
||||
):
|
||||
result = await _nominatim_suggest("Ленина, 1")
|
||||
|
||||
assert result == []
|
||||
assert "Ленина, 1" in calls # bare — честный, без города
|
||||
assert "Ленина, 1, Екатеринбург" in calls # ЕКБ-вариант — не потерян
|
||||
|
||||
|
||||
async def test_nominatim_suggest_city_hint_suffix() -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
async def fake_query_multi(client, query, limit):
|
||||
calls.append(query)
|
||||
return []
|
||||
|
||||
with patch(
|
||||
"app.services.geocoder._nominatim_query_multi", new=AsyncMock(side_effect=fake_query_multi)
|
||||
):
|
||||
await _nominatim_suggest("Ленина, 1", city_hint="Нижний Тагил")
|
||||
|
||||
assert calls[0] == "Ленина, 1, Нижний Тагил"
|
||||
|
||||
|
||||
async def test_nominatim_suggest_explicit_ekb_unchanged() -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
async def fake_query_multi(client, query, limit):
|
||||
calls.append(query)
|
||||
return []
|
||||
|
||||
with patch(
|
||||
"app.services.geocoder._nominatim_query_multi", new=AsyncMock(side_effect=fake_query_multi)
|
||||
):
|
||||
await _nominatim_suggest("Екатеринбург, Малышева 30")
|
||||
|
||||
assert calls[0] == "Екатеринбург, Малышева 30"
|
||||
|
||||
|
||||
async def test_nominatim_suggest_pobedy20_ekb_result_not_lost() -> None:
|
||||
"""#2580 (C2) regression — "Победы 20" (реальный кейс с прода, подтверждён
|
||||
ревьюером): без города ЕКБ-кандидат ('Парк Победы' ЖК, Екатеринбург) должен
|
||||
остаться в подсказках, НЕ потеряться в пользу похожего street-level матча
|
||||
в Верхней Пышме.
|
||||
|
||||
Симулирует реальные координаты:
|
||||
'Победы 20, Екатеринбург' → 56.899, 60.579 (ЖК "Парк Победы", Екатеринбург)
|
||||
'Победы 20' → 56.964, 60.610 (ул. Победы, Верхняя Пышма)
|
||||
"""
|
||||
ekb_item = {
|
||||
"place_id": 1001,
|
||||
"lat": "56.899",
|
||||
"lon": "60.579",
|
||||
"display_name": 'ЖК "Парк Победы", Орджоникидзевский район, Екатеринбург',
|
||||
"address": {"road": "Победы", "house_number": "20", "suburb": "Орджоникидзевский район"},
|
||||
}
|
||||
pyshma_item = {
|
||||
"place_id": 1002,
|
||||
"lat": "56.964",
|
||||
"lon": "60.610",
|
||||
"display_name": "улица Победы, 20, Верхняя Пышма",
|
||||
"address": {"road": "улица Победы", "house_number": "20"},
|
||||
}
|
||||
|
||||
async def fake_query_multi(client, query, limit):
|
||||
if query.endswith(", Екатеринбург"):
|
||||
return [ekb_item]
|
||||
return [pyshma_item]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.services.geocoder._nominatim_query_multi",
|
||||
new=AsyncMock(side_effect=fake_query_multi),
|
||||
),
|
||||
patch("app.services.geocoder.asyncio.sleep", new=AsyncMock()),
|
||||
):
|
||||
result = await _nominatim_suggest("Победы 20")
|
||||
|
||||
assert result, "ожидались подсказки"
|
||||
ekb_hits = [r for r in result if r.lat == pytest.approx(56.899)]
|
||||
assert ekb_hits, "ЕКБ-кандидат ('Парк Победы') должен остаться в подсказках, не потеряться"
|
||||
# ЕКБ-кандидат идёт первым (majority-трафик — привычный порядок для основных пользователей).
|
||||
assert result[0].lat == pytest.approx(56.899)
|
||||
# Верхняя Пышма тоже осталась в списке — honest alternative, не подменена.
|
||||
pyshma_hits = [r for r in result if r.lat == pytest.approx(56.964)]
|
||||
assert pyshma_hits, "не-ЕКБ кандидат тоже должен остаться (объединение, не замена)"
|
||||
|
||||
|
||||
async def test_nominatim_suggest_dedupe_across_dual_query() -> None:
|
||||
"""Если bare и ЕКБ-suffixed запросы возвращают ОДИН и тот же item (по place_id)
|
||||
— он не дублируется в итоговом списке подсказок."""
|
||||
same_item = {
|
||||
"place_id": 42,
|
||||
"lat": "56.838",
|
||||
"lon": "60.605",
|
||||
"display_name": "ул. Малышева, 30, Екатеринбург",
|
||||
"address": {"road": "ул. Малышева", "house_number": "30"},
|
||||
}
|
||||
|
||||
async def fake_query_multi(client, query, limit):
|
||||
return [same_item]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.services.geocoder._nominatim_query_multi",
|
||||
new=AsyncMock(side_effect=fake_query_multi),
|
||||
),
|
||||
patch("app.services.geocoder.asyncio.sleep", new=AsyncMock()),
|
||||
):
|
||||
result = await _nominatim_suggest("Малышева 30")
|
||||
|
||||
assert len(result) == 1, "одинаковый place_id из обоих запросов не должен дублироваться"
|
||||
|
||||
|
||||
# ── geocode() — city_ambiguous flag ──────────────────────────────────────────
|
||||
|
||||
|
||||
def _geocode_patches(nominatim_result: GeocodeResult | None):
|
||||
return (
|
||||
patch("app.services.geocoder._cache_get", return_value=None),
|
||||
patch("app.services.geocoder._cache_put"),
|
||||
patch("app.services.geocoder._geoportal_house_match", return_value=None),
|
||||
patch("app.services.geocoder._cadastral_house_match", return_value=None),
|
||||
patch("app.services.geocoder._cadastral_forward_sync", return_value=[]),
|
||||
patch(
|
||||
"app.services.geocoder._nominatim_lookup", new=AsyncMock(return_value=nominatim_result)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ── C1 (#2580) — city_hint должен доходить до локальных ЕКБ-only тиров ──────
|
||||
|
||||
|
||||
async def test_geocode_city_hint_non_ekb_skips_local_ekb_tiers() -> None:
|
||||
"""#2580 (C1): city_hint="Нижний Тагил" должен ЗАПРЕТИТЬ geoportal/cadastral
|
||||
(ЕКБ-only базы) — иначе они возвращают "точный" ЕКБ-хит для улицы, которая
|
||||
совпадает по названию, а `city_ambiguous=False` (хинт был!) делает такой
|
||||
неверный результат ложно-уверенным. Мок geoportal нарочно возвращает ЕКБ-хит
|
||||
(как в проде) — фикс должен НЕ дать ему сработать вообще.
|
||||
"""
|
||||
db = MagicMock()
|
||||
ekb_hit = GeocodeSuggestion(
|
||||
label="Ленина, 1, Екатеринбург",
|
||||
full_address="Ленина, 1, Екатеринбург",
|
||||
lat=56.83788,
|
||||
lon=60.58018,
|
||||
kind="house",
|
||||
)
|
||||
tagil_result = GeocodeResult(
|
||||
lat=57.905, lon=59.950, full_address="Ленина, 1, Нижний Тагил", provider="nominatim"
|
||||
)
|
||||
with contextlib.ExitStack() as stack:
|
||||
stack.enter_context(patch("app.services.geocoder._cache_get", return_value=None))
|
||||
stack.enter_context(patch("app.services.geocoder._cache_put"))
|
||||
geoportal_mock = stack.enter_context(
|
||||
patch("app.services.geocoder._geoportal_house_match", return_value=ekb_hit)
|
||||
)
|
||||
cadastral_mock = stack.enter_context(
|
||||
patch("app.services.geocoder._cadastral_house_match", return_value=ekb_hit)
|
||||
)
|
||||
stack.enter_context(patch("app.services.geocoder._cadastral_forward_sync", return_value=[]))
|
||||
stack.enter_context(
|
||||
patch(
|
||||
"app.services.geocoder._nominatim_lookup",
|
||||
new=AsyncMock(return_value=tagil_result),
|
||||
)
|
||||
)
|
||||
result = await geocode("Ленина, 1", db, city_hint="Нижний Тагил")
|
||||
|
||||
geoportal_mock.assert_not_called()
|
||||
cadastral_mock.assert_not_called()
|
||||
assert result is not None
|
||||
assert result.lat == pytest.approx(57.905) # Тагил, НЕ подставленный ЕКБ-хит (56.838)
|
||||
assert result.lat != pytest.approx(56.83788)
|
||||
|
||||
|
||||
async def test_geocode_real_ekb_address_still_uses_local_tiers() -> None:
|
||||
"""Сквозной кейс: реальный ЕКБ-адрес БЕЗ city_hint по-прежнему резолвится через
|
||||
локальный geoportal-тир (ЕКБ-путь не деградировал после C1-фикса)."""
|
||||
db = MagicMock()
|
||||
ekb_hit = GeocodeSuggestion(
|
||||
label="Малышева, 30, Екатеринбург",
|
||||
full_address="Малышева, 30, Екатеринбург",
|
||||
lat=56.8389,
|
||||
lon=60.6057,
|
||||
kind="house",
|
||||
)
|
||||
with contextlib.ExitStack() as stack:
|
||||
stack.enter_context(patch("app.services.geocoder._cache_get", return_value=None))
|
||||
stack.enter_context(patch("app.services.geocoder._cache_put"))
|
||||
geoportal_mock = stack.enter_context(
|
||||
patch("app.services.geocoder._geoportal_house_match", return_value=ekb_hit)
|
||||
)
|
||||
result = await geocode("Малышева, 30", db)
|
||||
|
||||
geoportal_mock.assert_called_once()
|
||||
assert result is not None
|
||||
assert result.lat == pytest.approx(56.8389)
|
||||
assert result.confidence == "exact"
|
||||
assert result.city_ambiguous is True # город не указан — честный флаг
|
||||
|
||||
|
||||
async def test_suggest_city_hint_non_ekb_skips_cadastral_tier1() -> None:
|
||||
"""#2580 (C1): suggest(city_hint="Нижний Тагил") — Tier 1 (кадастр ЕКБ) НЕ должен
|
||||
вызываться (раньше был мёртвым параметром для этого тира)."""
|
||||
db = MagicMock()
|
||||
with contextlib.ExitStack() as stack:
|
||||
house_mock = stack.enter_context(patch("app.services.geocoder._cadastral_house_match"))
|
||||
forward_mock = stack.enter_context(patch("app.services.geocoder._cadastral_forward_sync"))
|
||||
mock_settings = stack.enter_context(patch("app.services.geocoder.settings"))
|
||||
mock_settings.dadata_api_token = None
|
||||
stack.enter_context(
|
||||
patch("app.services.geocoder._nominatim_suggest", new=AsyncMock(return_value=[]))
|
||||
)
|
||||
await suggest("Ленина, 1", db=db, city_hint="Нижний Тагил")
|
||||
|
||||
house_mock.assert_not_called()
|
||||
forward_mock.assert_not_called()
|
||||
|
||||
|
||||
async def test_geocode_city_ambiguous_true_when_no_city_known() -> None:
|
||||
"""Ни адрес, ни city_hint не называют город → city_ambiguous=True."""
|
||||
db = MagicMock()
|
||||
nominatim_result = GeocodeResult(
|
||||
lat=56.838, lon=60.605, full_address="что-то", provider="nominatim"
|
||||
)
|
||||
with contextlib.ExitStack() as stack:
|
||||
for cm in _geocode_patches(nominatim_result):
|
||||
stack.enter_context(cm)
|
||||
result = await geocode("Малышева, 30", db)
|
||||
|
||||
assert result is not None
|
||||
assert result.city_ambiguous is True
|
||||
|
||||
|
||||
async def test_geocode_city_ambiguous_false_when_marker_present() -> None:
|
||||
"""Явный "Екатеринбург" в адресе → город указан пользователем → city_ambiguous=False."""
|
||||
db = MagicMock()
|
||||
nominatim_result = GeocodeResult(
|
||||
lat=56.838, lon=60.605, full_address="Екатеринбург, Малышева, 30", provider="nominatim"
|
||||
)
|
||||
with contextlib.ExitStack() as stack:
|
||||
for cm in _geocode_patches(nominatim_result):
|
||||
stack.enter_context(cm)
|
||||
result = await geocode("Екатеринбург, Малышева, 30", db)
|
||||
|
||||
assert result is not None
|
||||
assert result.city_ambiguous is False
|
||||
|
||||
|
||||
async def test_geocode_city_ambiguous_false_when_city_hint_given() -> None:
|
||||
"""city_hint передан вызывающим кодом → город указан → city_ambiguous=False."""
|
||||
db = MagicMock()
|
||||
nominatim_result = GeocodeResult(
|
||||
lat=57.905, lon=59.950, full_address="Нижний Тагил, Ленина, 1", provider="nominatim"
|
||||
)
|
||||
with contextlib.ExitStack() as stack:
|
||||
for cm in _geocode_patches(nominatim_result):
|
||||
stack.enter_context(cm)
|
||||
result = await geocode("Ленина, 1", db, city_hint="Нижний Тагил")
|
||||
|
||||
assert result is not None
|
||||
assert result.city_ambiguous is False
|
||||
|
||||
|
||||
# ── geocode() — cache не смешивает города ────────────────────────────────────
|
||||
|
||||
|
||||
async def test_geocode_cache_does_not_mix_cities() -> None:
|
||||
"""#2576 regression: два города для одного текста адреса не делят cache-запись.
|
||||
|
||||
Без city_hint-aware ключа второй вызов (Тагил) читал бы уже закэшированный
|
||||
(первым вызовом, ЕКБ) результат — координаты ЕКБ вместо Тагила.
|
||||
"""
|
||||
store: dict[str, GeocodeResult] = {}
|
||||
|
||||
def fake_cache_get(db, addr_norm):
|
||||
return store.get(addr_norm)
|
||||
|
||||
def fake_cache_put(db, addr_norm, result):
|
||||
store[addr_norm] = result
|
||||
|
||||
async def fake_nominatim_lookup(address, city_hint=None):
|
||||
if city_hint == "Нижний Тагил":
|
||||
return GeocodeResult(
|
||||
lat=57.905, lon=59.950, full_address="Нижний Тагил, Ленина, 1", provider="nominatim"
|
||||
)
|
||||
return GeocodeResult(
|
||||
lat=56.838, lon=60.605, full_address="Екатеринбург, Ленина, 1", provider="nominatim"
|
||||
)
|
||||
|
||||
db = MagicMock()
|
||||
with contextlib.ExitStack() as stack:
|
||||
stack.enter_context(patch("app.services.geocoder._cache_get", side_effect=fake_cache_get))
|
||||
stack.enter_context(patch("app.services.geocoder._cache_put", side_effect=fake_cache_put))
|
||||
stack.enter_context(
|
||||
patch("app.services.geocoder._geoportal_house_match", return_value=None)
|
||||
)
|
||||
stack.enter_context(
|
||||
patch("app.services.geocoder._cadastral_house_match", return_value=None)
|
||||
)
|
||||
stack.enter_context(patch("app.services.geocoder._cadastral_forward_sync", return_value=[]))
|
||||
stack.enter_context(
|
||||
patch(
|
||||
"app.services.geocoder._nominatim_lookup",
|
||||
new=AsyncMock(side_effect=fake_nominatim_lookup),
|
||||
)
|
||||
)
|
||||
|
||||
r_ekb = await geocode("Ленина, 1", db, city_hint="Екатеринбург")
|
||||
r_tagil = await geocode("Ленина, 1", db, city_hint="Нижний Тагил")
|
||||
# Повторный запрос ЕКБ — должен снова попасть в СВОЙ кэш (не Тагила).
|
||||
r_ekb_again = await geocode("Ленина, 1", db, city_hint="Екатеринбург")
|
||||
|
||||
assert r_ekb is not None and r_tagil is not None and r_ekb_again is not None
|
||||
assert r_ekb.lat == pytest.approx(56.838)
|
||||
assert r_tagil.lat == pytest.approx(57.905)
|
||||
assert r_ekb_again.lat == pytest.approx(56.838)
|
||||
assert r_ekb.lat != r_tagil.lat
|
||||
# Два разных ключа реально осели в fake-store (не перезаписали друг друга).
|
||||
assert len(store) == 2
|
||||
232
tradein-mvp/backend/tests/test_listings_city_from_sweep.py
Normal file
232
tradein-mvp/backend/tests/test_listings_city_from_sweep.py
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
"""#2594: listings.city проставляется из контекста развёртки, не парсингом адреса.
|
||||
|
||||
Критичный дефект: скрапер ЗНАЕТ город в момент сбора (city_slug из
|
||||
scraper_kit.orchestration.pipeline.CITY_LOCATIONS/CITY_ANCHORS), но раньше нигде его
|
||||
не записывал. Провайдеры (avito/cian) часто отдают адрес БЕЗ города в тексте
|
||||
("ул. Победы, 30" вместо "Нижний Тагил, ул. Победы, 30" — cian даже явно вырезает
|
||||
location-часть, providers/cian/serp.py `_format_address` skip_types={"location",...}).
|
||||
Без города такой адрес при геокодинге считался «город не назван» и коллизировал с
|
||||
одноимённой ЕКБ-улицей (Ленина/Победы/Тенистая — сотни совпадений в ЕКБ-реестре).
|
||||
|
||||
Fix: отдельная колонка `listings.city`, проставляется из sweep-контекста (НЕ парсингом
|
||||
address) через `scraper_kit.base.save_listings(..., city=...)` +
|
||||
`scraper_kit.orchestration.pipeline.resolve_city_name(city_slug)`. Тесты здесь проверяют
|
||||
write-path (save_listings SQL) и pure resolve_city_name; orchestration-level проверки
|
||||
(save_listings вызывается с правильным city= из каждого sweep) — в
|
||||
test_scraper_kit_pipeline_parity.py / test_scraper_kit_pipeline_parity2.py.
|
||||
|
||||
Границы (#2594): бэкфилл уже накопленных строк — НЕ в этой задаче.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||
|
||||
from scraper_kit.base import ScrapedLot as KitLot
|
||||
from scraper_kit.base import save_listings as kit_save_listings
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _nested_ctx() -> Any:
|
||||
yield MagicMock()
|
||||
|
||||
|
||||
def _mock_db_insert_path(listing_id: int = 42) -> MagicMock:
|
||||
"""Session mock для fresh INSERT path (xmax = 0 → inserted)."""
|
||||
insert_row = MagicMock()
|
||||
insert_row.id = listing_id
|
||||
insert_row.inserted = True
|
||||
|
||||
db = MagicMock()
|
||||
|
||||
def _execute(sql: Any, params: dict[str, Any] | None = None) -> MagicMock:
|
||||
s = str(sql)
|
||||
res = MagicMock()
|
||||
if "SELECT card_hash" in s and "WHERE dedup_hash" in s:
|
||||
res.fetchone.return_value = None
|
||||
elif "FROM listings_snapshots" in s:
|
||||
res.fetchone.return_value = None
|
||||
elif "INSERT INTO listings (" in s:
|
||||
res.fetchone.return_value = insert_row
|
||||
else:
|
||||
res.fetchone.return_value = None
|
||||
return res
|
||||
|
||||
db.execute.side_effect = _execute
|
||||
db.begin_nested.side_effect = _nested_ctx
|
||||
return db
|
||||
|
||||
|
||||
def _find_call(db: MagicMock, needle: str) -> tuple[str, dict[str, Any]]:
|
||||
for call in db.execute.call_args_list:
|
||||
sql = str(call.args[0])
|
||||
if needle in sql:
|
||||
params = call.args[1] if len(call.args) > 1 else {}
|
||||
return sql, params
|
||||
raise AssertionError(f"SQL containing {needle!r} not found")
|
||||
|
||||
|
||||
def _kit_matcher() -> MagicMock:
|
||||
matcher = MagicMock()
|
||||
matcher.match_or_create_house.return_value = (101, 1.0, "new")
|
||||
matcher.upsert_listing_source.return_value = None
|
||||
return matcher
|
||||
|
||||
|
||||
def _lot(source: str = "avito", source_id: str = "1", address: str | None = None) -> KitLot:
|
||||
return KitLot(
|
||||
source=source,
|
||||
source_url=f"https://www.{source}.ru/item/{source_id}",
|
||||
source_id=source_id,
|
||||
address=address,
|
||||
price_rub=3_000_000,
|
||||
)
|
||||
|
||||
|
||||
# ── save_listings(..., city=...) — INSERT path ────────────────────────────────
|
||||
|
||||
|
||||
def test_save_listings_writes_city_into_insert_sql() -> None:
|
||||
"""city="Нижний Тагил" передаётся в SQL params И колонка есть в INSERT-списке."""
|
||||
db = _mock_db_insert_path()
|
||||
lot = _lot(address="ул. Победы, 30")
|
||||
|
||||
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 "city" in sql, "city column must be in INSERT column list"
|
||||
assert params["city"] == "Нижний Тагил"
|
||||
# address НЕ тронут — критичное требование #2594 (раздельная колонка, а не
|
||||
# дописывание города в текст адреса, чтобы не сломать downstream text-парсеры).
|
||||
assert params["address"] == "ул. Победы, 30"
|
||||
|
||||
|
||||
def test_save_listings_city_defaults_to_none_backward_compat() -> None:
|
||||
"""Caller без city= (старые/ad-hoc пути) — колонка остаётся NULL, backward-compatible."""
|
||||
db = _mock_db_insert_path()
|
||||
lot = _lot(address="ул. Малышева, 30")
|
||||
|
||||
with patch("scraper_kit.base.upsert_listing_snapshot", return_value=None):
|
||||
kit_save_listings(db, [lot], matcher=_kit_matcher(), region_code=66)
|
||||
|
||||
_sql, params = _find_call(db, "INSERT INTO listings (")
|
||||
assert params["city"] is None
|
||||
|
||||
|
||||
def test_save_listings_ekaterinburg_city_written_unchanged_address() -> None:
|
||||
"""ЕКБ-развёртка (city="Екатеринбург") — тот же путь, address не деградирует."""
|
||||
db = _mock_db_insert_path()
|
||||
lot = _lot(address="ул. Малышева, 30")
|
||||
|
||||
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"] == "Екатеринбург"
|
||||
assert params["address"] == "ул. Малышева, 30"
|
||||
|
||||
|
||||
# ── ON CONFLICT DO UPDATE / reconcile UPDATE — COALESCE не затирает known city ──
|
||||
|
||||
|
||||
def test_save_listings_on_conflict_coalesces_city() -> None:
|
||||
"""ON CONFLICT DO UPDATE — city = COALESCE(EXCLUDED.city, listings.city), не blind overwrite."""
|
||||
db = _mock_db_insert_path()
|
||||
lot = _lot(address="ул. Победы, 30")
|
||||
|
||||
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 "city = COALESCE(EXCLUDED.city, listings.city)" in sql
|
||||
|
||||
|
||||
def test_save_listings_reconcile_update_coalesces_city() -> None:
|
||||
"""dedup_hash-drift reconcile UPDATE path — тоже COALESCE(:city, city), не blind overwrite."""
|
||||
import psycopg.errors
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
uv_orig = psycopg.errors.UniqueViolation()
|
||||
integrity_err = IntegrityError("INSERT INTO listings ...", {}, uv_orig)
|
||||
rec_row = MagicMock()
|
||||
rec_row.id = 88
|
||||
|
||||
db = MagicMock()
|
||||
|
||||
def _execute(sql: Any, params: dict[str, Any] | None = None) -> MagicMock:
|
||||
s = str(sql)
|
||||
res = MagicMock()
|
||||
if "SELECT card_hash" in s and "WHERE dedup_hash" in s:
|
||||
res.fetchone.return_value = None
|
||||
elif "FROM listings_snapshots" in s:
|
||||
res.fetchone.return_value = None
|
||||
elif "INSERT INTO listings (" in s:
|
||||
raise integrity_err
|
||||
elif "UPDATE listings" in s and "SET dedup_hash" in s:
|
||||
res.fetchone.return_value = rec_row
|
||||
else:
|
||||
res.fetchone.return_value = None
|
||||
return res
|
||||
|
||||
db.execute.side_effect = _execute
|
||||
|
||||
@contextmanager
|
||||
def _nested() -> Any:
|
||||
try:
|
||||
yield MagicMock()
|
||||
except IntegrityError:
|
||||
raise
|
||||
|
||||
db.begin_nested.side_effect = _nested
|
||||
|
||||
lot = _lot(source="avito", source_id="7960764619", address="ул. Тенистая, 17")
|
||||
|
||||
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, "SET dedup_hash")
|
||||
assert "city = COALESCE(:city, city)" in sql
|
||||
assert params["city"] == "Серов"
|
||||
|
||||
|
||||
# ── Migration 196: listings.city column ────────────────────────────────────────
|
||||
|
||||
_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
|
||||
_MIGRATION_196 = _SQL_DIR / "196_listings_city.sql"
|
||||
|
||||
|
||||
def test_migration_196_exists() -> None:
|
||||
assert _MIGRATION_196.is_file(), f"missing migration: {_MIGRATION_196}"
|
||||
|
||||
|
||||
def test_migration_196_is_transactional() -> None:
|
||||
sql = _MIGRATION_196.read_text("utf-8")
|
||||
assert "BEGIN;" in sql
|
||||
assert "COMMIT;" in sql
|
||||
|
||||
|
||||
def test_migration_196_idempotent_add_column() -> None:
|
||||
sql = _MIGRATION_196.read_text("utf-8")
|
||||
assert "ADD COLUMN IF NOT EXISTS city" in sql
|
||||
|
||||
|
||||
def test_migration_196_no_psycopg_cast_trap() -> None:
|
||||
"""psycopg v3: никаких :param::type (не применимо тут — чистый DDL — но проверяем
|
||||
на будущее, если файл когда-нибудь обрастёт bind-параметрами)."""
|
||||
sql = _MIGRATION_196.read_text("utf-8")
|
||||
assert not re.search(r":\w+::", sql)
|
||||
|
||||
|
||||
def test_migration_196_non_destructive() -> None:
|
||||
sql = _MIGRATION_196.read_text("utf-8")
|
||||
assert "DROP" not in sql.upper()
|
||||
assert "DELETE" not in sql.upper()
|
||||
assert "TRUNCATE" not in sql.upper()
|
||||
79
tradein-mvp/backend/tests/test_password.py
Normal file
79
tradein-mvp/backend/tests/test_password.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"""Тесты для app/core/password.py — bcrypt hash/verify (#2550)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.password import hash_password, verify_password
|
||||
|
||||
|
||||
def test_roundtrip() -> None:
|
||||
"""hash_password → verify_password с тем же паролем возвращает True."""
|
||||
hashed = hash_password("correct horse battery staple")
|
||||
assert verify_password("correct horse battery staple", hashed) is True
|
||||
|
||||
|
||||
def test_wrong_password_returns_false() -> None:
|
||||
"""Неверный пароль против валидного хеша → False."""
|
||||
hashed = hash_password("correct horse battery staple")
|
||||
assert verify_password("wrong password", hashed) is False
|
||||
|
||||
|
||||
def test_hash_too_long_raises_value_error() -> None:
|
||||
"""Пароль >72 байт в UTF-8 → ValueError в hash_password (нет silent truncation)."""
|
||||
long_password = "a" * 73
|
||||
with pytest.raises(ValueError):
|
||||
hash_password(long_password)
|
||||
|
||||
|
||||
def test_hash_exactly_72_bytes_ok() -> None:
|
||||
"""Ровно 72 байта — граничное значение, ещё допустимо."""
|
||||
password = "a" * 72
|
||||
hashed = hash_password(password)
|
||||
assert verify_password(password, hashed) is True
|
||||
|
||||
|
||||
def test_hash_too_long_multibyte_raises_value_error() -> None:
|
||||
"""40 кириллических символов = 80 байт UTF-8 (2 байта/символ) → ValueError.
|
||||
|
||||
Проверяет, что лимит считается в байтах, а не в символах — иначе 40-символьный
|
||||
кириллический пароль (< 72 символов, но 80 байт) прошёл бы мимо guard'а.
|
||||
"""
|
||||
long_cyrillic_password = "а" * 40
|
||||
assert len(long_cyrillic_password.encode("utf-8")) == 80
|
||||
with pytest.raises(ValueError):
|
||||
hash_password(long_cyrillic_password)
|
||||
|
||||
|
||||
def test_verify_too_long_returns_false_not_raise() -> None:
|
||||
"""verify_password на >72-байтовом пароле возвращает False, НЕ raise."""
|
||||
hashed = hash_password("some valid password")
|
||||
long_password = "a" * 73
|
||||
assert verify_password(long_password, hashed) is False
|
||||
|
||||
|
||||
def test_hash_empty_raises_value_error() -> None:
|
||||
"""Пустой пароль → ValueError в hash_password."""
|
||||
with pytest.raises(ValueError):
|
||||
hash_password("")
|
||||
|
||||
|
||||
def test_verify_empty_returns_false() -> None:
|
||||
"""Пустой пароль в verify_password → False (не raise)."""
|
||||
hashed = hash_password("some valid password")
|
||||
assert verify_password("", hashed) is False
|
||||
|
||||
|
||||
def test_hash_is_unique_due_to_salt() -> None:
|
||||
"""Два хеша одного пароля различаются (уникальная соль на каждый вызов)."""
|
||||
password = "correct horse battery staple"
|
||||
hash1 = hash_password(password)
|
||||
hash2 = hash_password(password)
|
||||
assert hash1 != hash2
|
||||
assert verify_password(password, hash1) is True
|
||||
assert verify_password(password, hash2) is True
|
||||
|
||||
|
||||
def test_verify_malformed_hash_returns_false() -> None:
|
||||
"""Некорректный (не-bcrypt) хеш в verify_password → False, не raise."""
|
||||
assert verify_password("some password", "not-a-bcrypt-hash") is False
|
||||
|
|
@ -34,7 +34,7 @@ from fastapi.testclient import TestClient
|
|||
|
||||
from app.api.v1 import me as me_router
|
||||
from app.core import auth as auth_mod
|
||||
from app.core.rbac import rbac_guard
|
||||
from app.core.rbac import _db_role_path_allowed, rbac_guard
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
|
@ -69,10 +69,30 @@ def _build_test_app() -> FastAPI:
|
|||
async def brand_dummy() -> dict:
|
||||
return {"ok": True}
|
||||
|
||||
# Внутренние инструменты, закрытые от клиентских ролей 2026-07-31
|
||||
# (см. _INTERNAL_TOOL_PATHS ниже): API «Доли в продаже» и «Кэша».
|
||||
@app.get("/api/v1/buildings/sale-share")
|
||||
async def buildings_sale_share() -> dict:
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/api/v1/trade-in/cache-stats")
|
||||
async def tradein_cache_stats() -> dict:
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict:
|
||||
return {"status": "ok"}
|
||||
|
||||
# Анонимная поддержка (инцидент 2026-07-31) — публичная ветка рядом с
|
||||
# авторизованной, чтобы тесты ниже проверяли ИМЕННО границу между ними.
|
||||
@app.get("/api/v1/trade-in/support/anon/unread")
|
||||
async def anon_support_unread() -> dict:
|
||||
return {"unread": 0}
|
||||
|
||||
@app.get("/api/v1/trade-in/support/unread")
|
||||
async def support_unread() -> dict:
|
||||
return {"unread": 0}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
|
@ -90,7 +110,9 @@ def test_get_role_known_users() -> None:
|
|||
assert auth_mod.get_role("admin") == "admin"
|
||||
assert auth_mod.get_role("kopylov") == "pilot"
|
||||
for n in range(1, 11):
|
||||
assert auth_mod.get_role(f"user{n}") == "pilot"
|
||||
# user2 («Брусника») — доступ закрыт 2026-07-30 (#2548)
|
||||
expected = "expired" if n == 2 else "pilot"
|
||||
assert auth_mod.get_role(f"user{n}") == expected
|
||||
|
||||
|
||||
def test_get_role_unknown_user_raises() -> None:
|
||||
|
|
@ -251,6 +273,24 @@ def test_rbac_guard_skips_health(client: TestClient) -> None:
|
|||
assert resp.json()["status"] == "ok"
|
||||
|
||||
|
||||
def test_rbac_guard_lets_anon_support_through_without_identity(client: TestClient) -> None:
|
||||
"""Инцидент 2026-07-31: поддержка должна работать БЕЗ входа — иначе тот, кто
|
||||
не может залогиниться, не может и пожаловаться на это."""
|
||||
resp = client.get("/api/v1/trade-in/support/anon/unread")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"unread": 0}
|
||||
|
||||
|
||||
def test_rbac_guard_still_gates_authenticated_support(client: TestClient) -> None:
|
||||
"""Обратная сторона той же границы: анонимная ветка НЕ распахнула соседний
|
||||
авторизованный support (тред залогиненного юзера по-прежнему за identity)."""
|
||||
assert client.get("/api/v1/trade-in/support/unread").status_code == 401
|
||||
resp = client.get(
|
||||
"/api/v1/trade-in/support/unread", headers={"X-Authenticated-User": "nosuchuser"}
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_rbac_guard_pilot_can_hit_non_admin_api(client: TestClient) -> None:
|
||||
resp = client.get(
|
||||
"/api/v1/me",
|
||||
|
|
@ -325,3 +365,168 @@ def test_rbac_guard_no_header_on_non_admin_path_returns_401(client: TestClient)
|
|||
resp = client.get("/api/v1/me")
|
||||
assert resp.status_code == 401
|
||||
assert "no authenticated user" in resp.json()["detail"].lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2026-07-31: внутренние разделы («Доля в продаже» / «Кэш») закрыты от клиентов
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Решение владельца продукта: оба раздела — внутренние инструменты (аналитика
|
||||
# рынка / состояние кэшей и скраперов), клиентские аккаунты их видеть не должны
|
||||
# (триггер — praktika, DB-роль manager, у которого оба пункта висели в топбаре).
|
||||
# Deny заведён в DB_ROLE_PATHS (employee/manager) и зеркально в pilot.deny
|
||||
# (auth/roles.yaml) — страницы И их API, чтобы гейт сработал сразу в трёх местах:
|
||||
# пункт меню (Topbar через /me), страница (RouteGuard), ручки (rbac_guard).
|
||||
|
||||
# Внешние пути (как их видит RBAC-конфиг): 2 страницы + все API раздела.
|
||||
# Проверяются матчерами напрямую — регистрировать их в тестовом app не нужно.
|
||||
_INTERNAL_TOOL_PATHS = (
|
||||
"/trade-in/sale-share",
|
||||
"/trade-in/cache",
|
||||
"/trade-in/api/v1/buildings/sale-share",
|
||||
# Остальные ручки роутера buildings.py — глоб '/…/buildings/**' обязан
|
||||
# покрывать и их, включая параметризованную (самый вероятный кандидат на
|
||||
# переезд под другой префикс — тогда этот тест упадёт, а не промолчит).
|
||||
"/trade-in/api/v1/buildings/sale-share/summary",
|
||||
"/trade-in/api/v1/buildings/123/listings",
|
||||
"/trade-in/api/v1/trade-in/cache-stats",
|
||||
# Трейлинг-слэш: точный паттерн его НЕ ловил (allowed=True), защита висела
|
||||
# на Starlette redirect_slashes — поэтому deny переведён на глоб-форму.
|
||||
"/trade-in/api/v1/trade-in/cache-stats/",
|
||||
)
|
||||
|
||||
# Разрез тех же путей по ПРОДУКТАМ — нужен для ролей, у которых доступ
|
||||
# асимметричен (см. test_yaml_roles_deliberately_outside_client_deny).
|
||||
# «Поиск домов» — тестовый продукт, admin-only (решение владельца 2026-07-31).
|
||||
_SALE_SHARE_PATHS = (
|
||||
"/trade-in/sale-share",
|
||||
"/trade-in/api/v1/buildings/sale-share",
|
||||
"/trade-in/api/v1/buildings/sale-share/summary",
|
||||
"/trade-in/api/v1/buildings/123/listings",
|
||||
)
|
||||
# «Кэш» — не продукт, а диагностика состояния кэшей/скраперов.
|
||||
_CACHE_TOOL_PATHS = (
|
||||
"/trade-in/cache",
|
||||
"/trade-in/api/v1/trade-in/cache-stats",
|
||||
"/trade-in/api/v1/trade-in/cache-stats/",
|
||||
)
|
||||
|
||||
# Разрез обязан покрывать исходный набор целиком — иначе новый внутренний путь
|
||||
# добавят в _INTERNAL_TOOL_PATHS, забудут отнести к продукту, и роль analyst
|
||||
# останется непроверенной на нём.
|
||||
assert set(_SALE_SHARE_PATHS) | set(_CACHE_TOOL_PATHS) == set(_INTERNAL_TOOL_PATHS)
|
||||
|
||||
# Основной продукт — не должен быть задет deny выше.
|
||||
_CORE_PRODUCT_PATHS = ("/trade-in/", "/trade-in/api/v1/trade-in/estimate")
|
||||
|
||||
|
||||
def test_db_roles_denied_on_internal_tool_paths() -> None:
|
||||
"""manager/employee (DB-роли, session-auth ветка rbac_guard) → deny."""
|
||||
for role in ("manager", "employee"):
|
||||
for path in _INTERNAL_TOOL_PATHS:
|
||||
assert not _db_role_path_allowed(role, path), f"{role} must not reach {path}"
|
||||
|
||||
|
||||
def test_db_admin_still_allowed_on_internal_tool_paths() -> None:
|
||||
for path in _INTERNAL_TOOL_PATHS:
|
||||
assert _db_role_path_allowed("admin", path), f"admin lost access to {path}"
|
||||
|
||||
|
||||
def test_yaml_roles_deliberately_outside_client_deny() -> None:
|
||||
"""Пиннит ОБРАТНУЮ сторону правки 2026-07-31: роли, которые сознательно НЕ
|
||||
попали под клиентский deny.
|
||||
|
||||
Без этого теста «синхронизация» deny-списков между ролями в auth/roles.yaml
|
||||
(соблазн скопировать pilot.deny в соседей) молча отрезала бы админа от его
|
||||
же инструментов, и ни один тест бы не упал: roles.yaml лежит ВНЕ paths-фильтров
|
||||
`backend/**` и `tradein-mvp/**`, т.е. CI такую правку не проверяет вовсе —
|
||||
ровно тот класс рассинхрона, что уже случился с user2 (см.
|
||||
backend/tests/test_rbac.py::test_get_role_known_users).
|
||||
|
||||
`analyst` (внутренняя роль, paths "/**") попадает под клиентский deny
|
||||
ЧАСТИЧНО, и обе стороны асимметрии здесь запиннены намеренно:
|
||||
- «Поиск домов» ЗАКРЫТ — тестовый продукт, доступ только у admin
|
||||
(решение владельца 2026-07-31; «только у админа» включает и внутренние
|
||||
роли, поэтому analyst тоже в deny);
|
||||
- «Кэш» ОТКРЫТ — это не продукт, а диагностика кэшей/скраперов, ровно тот
|
||||
инструмент, ради которого роль analyst и заведена.
|
||||
Если решение поменяется — упадёт этот тест, а не пользователь на проде.
|
||||
"""
|
||||
for path in _INTERNAL_TOOL_PATHS:
|
||||
assert auth_mod.is_path_allowed("admin", path), f"admin lost access to {path}"
|
||||
|
||||
for path in _SALE_SHARE_PATHS:
|
||||
assert not auth_mod.is_path_allowed("analyst", path), (
|
||||
f"analyst не должен видеть «Поиск домов» ({path}) — тестовый продукт, "
|
||||
f"admin-only; если решение изменилось, обнови тест И комментарий у роли "
|
||||
f"analyst в auth/roles.yaml"
|
||||
)
|
||||
|
||||
for path in _CACHE_TOOL_PATHS:
|
||||
assert auth_mod.is_path_allowed("analyst", path), (
|
||||
f"analyst потерял «Кэш» ({path}) — это его рабочий инструмент; если "
|
||||
f"закрыли намеренно, обнови тест И комментарий у роли analyst"
|
||||
)
|
||||
|
||||
|
||||
def test_db_roles_still_allowed_on_core_product() -> None:
|
||||
"""Регресс: оценка (основной продукт) для клиентских ролей не задета."""
|
||||
for role in ("manager", "employee"):
|
||||
for path in _CORE_PRODUCT_PATHS:
|
||||
assert _db_role_path_allowed(role, path), f"{role} lost access to {path}"
|
||||
|
||||
|
||||
def test_legacy_pilot_denied_on_internal_tool_paths() -> None:
|
||||
"""Зеркало в auth/roles.yaml: пока auth_mode=dual, legacy-pilot не должен
|
||||
видеть то, что DB-employee уже не видит."""
|
||||
for path in _INTERNAL_TOOL_PATHS:
|
||||
assert not auth_mod.is_path_allowed("pilot", path), f"pilot must not reach {path}"
|
||||
for path in _CORE_PRODUCT_PATHS:
|
||||
assert auth_mod.is_path_allowed("pilot", path), f"pilot lost access to {path}"
|
||||
|
||||
|
||||
def test_rbac_guard_blocks_pilot_on_internal_tool_api(client: TestClient) -> None:
|
||||
"""Тот же deny через РЕАЛЬНЫЙ guard (legacy trusted-header ветка): ручки
|
||||
sale-share/кэша отдают 403, а не только прячутся из меню."""
|
||||
for path in ("/api/v1/buildings/sale-share", "/api/v1/trade-in/cache-stats"):
|
||||
resp = client.get(path, headers={"X-Authenticated-User": "kopylov"})
|
||||
assert resp.status_code == 403, f"pilot {path}: {resp.status_code}"
|
||||
assert "forbidden for role" in resp.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_rbac_guard_admin_keeps_internal_tool_api(client: TestClient) -> None:
|
||||
for path in ("/api/v1/buildings/sale-share", "/api/v1/trade-in/cache-stats"):
|
||||
resp = client.get(path, headers={"X-Authenticated-User": "admin"})
|
||||
assert resp.status_code == 200, f"admin {path}: {resp.text}"
|
||||
|
||||
|
||||
def test_internal_deny_globs_do_not_leak_to_sibling_prefixes() -> None:
|
||||
"""Граничный случай: '<prefix>/**' компилируется в '^<prefix>(?:/.*)?$' —
|
||||
матчит сам prefix, prefix со слэшем и подпути через '/', но НЕ соседей по
|
||||
префиксу (дефис не матчится). Именно поэтому глоб-форма безопасна как
|
||||
замена точного пути: '/trade-in/cache/**' не задевает страницу
|
||||
'/trade-in/cache-stats', а '/…/trade-in/cache-stats/**' — не гипотетическую
|
||||
'/…/trade-in/cache-statistics'. Фиксируем семантику тестом: если её однажды
|
||||
поменяют (напр. на префиксный startswith), соседние пути начнут молча
|
||||
падать в 403."""
|
||||
siblings_allowed = (
|
||||
"/trade-in/cache-stats",
|
||||
"/trade-in/sale-share-report",
|
||||
"/trade-in/api/v1/trade-in/cache-statistics",
|
||||
)
|
||||
section_denied = (
|
||||
"/trade-in/cache/detail",
|
||||
"/trade-in/sale-share/123",
|
||||
"/trade-in/api/v1/trade-in/cache-stats/reset",
|
||||
)
|
||||
for role in ("manager", "employee"):
|
||||
for path in siblings_allowed:
|
||||
assert _db_role_path_allowed(role, path), f"{role} lost sibling {path}"
|
||||
# ...при том что сам раздел и его подпути закрыты.
|
||||
for path in section_denied:
|
||||
assert not _db_role_path_allowed(role, path), f"{role} must not reach {path}"
|
||||
|
||||
for path in siblings_allowed:
|
||||
assert auth_mod.is_path_allowed("pilot", path), f"pilot lost sibling {path}"
|
||||
for path in section_denied:
|
||||
assert not auth_mod.is_path_allowed("pilot", path), f"pilot must not reach {path}"
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ class _Scenario:
|
|||
avito_serp_ok_not_banned: bool = True,
|
||||
avito_proxy_max_rotations: int = 0,
|
||||
lots_have_house_url: bool = False,
|
||||
city_slug: str | None = None,
|
||||
) -> None:
|
||||
self.anchors = anchors
|
||||
self.per_anchor = per_anchor
|
||||
|
|
@ -105,6 +106,9 @@ class _Scenario:
|
|||
self.avito_serp_ok_not_banned = avito_serp_ok_not_banned
|
||||
self.avito_proxy_max_rotations = avito_proxy_max_rotations
|
||||
self.lots_have_house_url = lots_have_house_url
|
||||
# #2594: city_slug развёртки — прокидывается в run_avito_city_sweep(city_slug=...)
|
||||
# для проверки, что save_listings получает правильный city=... из контекста.
|
||||
self.city_slug = city_slug
|
||||
|
||||
def _config(self) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
|
|
@ -170,11 +174,16 @@ def _async_session_cm() -> MagicMock:
|
|||
return sess
|
||||
|
||||
|
||||
async def _drive(scenario: _Scenario) -> _DriveResult:
|
||||
async def _drive(scenario: _Scenario, *, capture: dict[str, Any] | None = None) -> _DriveResult:
|
||||
"""capture: опциональный dict — если передан, кладём туда save_mock (#2594) для
|
||||
инспекции call_args (city=...) без изменения возвращаемого _DriveResult (backward-compat
|
||||
для всех существующих вызовов _drive без capture)."""
|
||||
recorder = _RunsRecorder()
|
||||
db = _make_db(scenario)
|
||||
scraper = _make_scraper(scenario, AvitoBlockedError)
|
||||
save_mock = MagicMock(side_effect=scenario._save_side_effects())
|
||||
if capture is not None:
|
||||
capture["save_mock"] = save_mock
|
||||
|
||||
imv_res = None
|
||||
if scenario.imv_result is not None:
|
||||
|
|
@ -208,6 +217,7 @@ async def _drive(scenario: _Scenario) -> _DriveResult:
|
|||
shutdown_requested=lambda: False,
|
||||
radius_m=1000,
|
||||
anchors=scenario.anchors,
|
||||
city_slug=scenario.city_slug,
|
||||
pages_per_anchor=1,
|
||||
enrich_houses=scenario.enrich_houses,
|
||||
detail_top_n=scenario.detail_top_n,
|
||||
|
|
@ -307,3 +317,43 @@ async def test_imv_phase_counters() -> None:
|
|||
assert counters["imv_attempted"] == 3
|
||||
assert counters["imv_enriched"] == 2
|
||||
assert counters["imv_failed"] == 1
|
||||
|
||||
|
||||
# ── #2594: listings.city проставляется из контекста развёртки ────────────────
|
||||
#
|
||||
# Критичный дефект: развёртка ЗНАЕТ город (city_slug), но раньше НИКУДА его не
|
||||
# писала — адрес без города в тексте ("ул. Победы, 30") при геокодинге считался
|
||||
# «город не назван» и коллизировал с одноимённой ЕКБ-улицей. Тесты проверяют, что
|
||||
# save_listings() теперь получает правильный city= для обоих случаев: явный
|
||||
# oblast-город (city_slug задан) И EKB-развёртка той же функции (city_slug=None —
|
||||
# симметрия, а не «не знаем город»).
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_city_stamped_from_city_slug() -> None:
|
||||
"""city_slug='nizhniy_tagil' → save_listings(..., city='Нижний Тагил')."""
|
||||
scenario = _Scenario(
|
||||
anchors=[(56.84, 60.60, "A1")],
|
||||
per_anchor=[("lots", 3, 3, 0)],
|
||||
city_slug="nizhniy_tagil",
|
||||
)
|
||||
capture: dict[str, Any] = {}
|
||||
await _drive(scenario, capture=capture)
|
||||
save_mock = capture["save_mock"]
|
||||
assert save_mock.call_args.kwargs["city"] == "Нижний Тагил"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_city_defaults_to_ekaterinburg_when_no_city_slug() -> None:
|
||||
"""city_slug=None (ЕКБ-развёртка той же run_avito_city_sweep) →
|
||||
save_listings(..., city='Екатеринбург') — симметрия с oblast-городами (#2594),
|
||||
а не оставленный NULL."""
|
||||
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"] == "Екатеринбург"
|
||||
|
|
|
|||
|
|
@ -136,7 +136,10 @@ def _yandex_scraper(combos: list[tuple[str, list[Any]]]) -> MagicMock:
|
|||
return _ctx_scraper(fetch_around_multi_room=_fetch)
|
||||
|
||||
|
||||
async def _drive_yandex_city() -> _DriveResult:
|
||||
async def _drive_yandex_city(
|
||||
*, city_slug: str | None = None, capture: dict[str, Any] | None = None
|
||||
) -> _DriveResult:
|
||||
"""capture: опционально — если передан, кладём save_mock (#2594, инспекция city=...)."""
|
||||
recorder = _RunsRecorder()
|
||||
db = MagicMock()
|
||||
combos = [
|
||||
|
|
@ -145,6 +148,8 @@ async def _drive_yandex_city() -> _DriveResult:
|
|||
]
|
||||
scraper = _yandex_scraper(combos)
|
||||
save_mock = MagicMock(side_effect=[(2, 0), (1, 0)])
|
||||
if capture is not None:
|
||||
capture["save_mock"] = save_mock
|
||||
cfg = _config()
|
||||
enrichment = MagicMock()
|
||||
enrichment.record_yandex_price_history = MagicMock(return_value=5)
|
||||
|
|
@ -160,6 +165,7 @@ async def _drive_yandex_city() -> _DriveResult:
|
|||
enrichment=enrichment,
|
||||
run_id=1,
|
||||
anchors=None,
|
||||
city_slug=city_slug,
|
||||
pages_per_anchor=1,
|
||||
request_delay_sec=0.0,
|
||||
enrich_address=False,
|
||||
|
|
@ -185,13 +191,18 @@ def _cian_lot(segment: str) -> MagicMock:
|
|||
return MagicMock(listing_segment=segment, house_source=None, house_ext_id=None)
|
||||
|
||||
|
||||
async def _drive_cian_city() -> _DriveResult:
|
||||
async def _drive_cian_city(
|
||||
*, city_slug: str | None = None, capture: dict[str, Any] | None = None
|
||||
) -> _DriveResult:
|
||||
"""capture: опционально — если передан, кладём save_mock (#2594, инспекция city=...)."""
|
||||
recorder = _RunsRecorder()
|
||||
db = MagicMock()
|
||||
# 3 novostroyki + 2 secondary → newbuilding_only оставит 3.
|
||||
lots = [_cian_lot("novostroyki")] * 3 + [_cian_lot("vtorichnaya")] * 2
|
||||
scraper = _ctx_scraper(fetch_around_multi_room=AsyncMock(return_value=lots))
|
||||
save_mock = MagicMock(side_effect=[(3, 0)])
|
||||
if capture is not None:
|
||||
capture["save_mock"] = save_mock
|
||||
cfg = _config()
|
||||
with (
|
||||
patch(f"{PFX}.CianScraper", return_value=scraper),
|
||||
|
|
@ -204,6 +215,7 @@ async def _drive_cian_city() -> _DriveResult:
|
|||
matcher=MagicMock(),
|
||||
run_id=1,
|
||||
anchors=[(56.84, 60.60, "A1")],
|
||||
city_slug=city_slug,
|
||||
radius_m=1000,
|
||||
pages_per_anchor=1,
|
||||
request_delay_sec=0.0,
|
||||
|
|
@ -228,7 +240,10 @@ async def test_cian_city_sweep() -> None:
|
|||
# ── DomClick city sweep ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _drive_domclick(*, lots_n: int, blocked: bool) -> _DriveResult:
|
||||
async def _drive_domclick(
|
||||
*, lots_n: int, blocked: bool, capture: dict[str, Any] | None = None
|
||||
) -> _DriveResult:
|
||||
"""capture: опционально — если передан, кладём save_mock (#2594, инспекция city=...)."""
|
||||
recorder = _RunsRecorder()
|
||||
db = MagicMock()
|
||||
lots = [MagicMock() for _ in range(lots_n)]
|
||||
|
|
@ -239,6 +254,8 @@ async def _drive_domclick(*, lots_n: int, blocked: bool) -> _DriveResult:
|
|||
fetch_errors=0,
|
||||
)
|
||||
save_mock = MagicMock(side_effect=[(lots_n, 0)] if lots_n else [])
|
||||
if capture is not None:
|
||||
capture["save_mock"] = save_mock
|
||||
cfg = _config()
|
||||
with (
|
||||
patch(f"{PFX}.DomClickScraper", return_value=scraper),
|
||||
|
|
@ -272,7 +289,8 @@ async def test_domclick_city_sweep_blocked_failed() -> None:
|
|||
# ── Avito newbuilding sweep ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _drive_nb_sweep() -> _DriveResult:
|
||||
async def _drive_nb_sweep(*, capture: dict[str, Any] | None = None) -> _DriveResult:
|
||||
"""capture: опционально — если передан, кладём save_mock (#2594, инспекция city=...)."""
|
||||
recorder = _RunsRecorder()
|
||||
db = MagicMock()
|
||||
lots = [MagicMock() for _ in range(6)]
|
||||
|
|
@ -281,6 +299,8 @@ async def _drive_nb_sweep() -> _DriveResult:
|
|||
scraper._browser = None
|
||||
scraper.fetch_newbuildings = AsyncMock(return_value=lots)
|
||||
save_mock = MagicMock(side_effect=[(5, 1)])
|
||||
if capture is not None:
|
||||
capture["save_mock"] = save_mock
|
||||
cfg = _config()
|
||||
with (
|
||||
patch(f"{PFX}.AvitoScraper", return_value=scraper),
|
||||
|
|
@ -319,7 +339,8 @@ def _full_load_scraper(buckets: list[tuple[str, list[Any]]]) -> MagicMock:
|
|||
return scraper
|
||||
|
||||
|
||||
async def _drive_full_load(*, source: str) -> _DriveResult:
|
||||
async def _drive_full_load(*, source: str, capture: dict[str, Any] | None = None) -> _DriveResult:
|
||||
"""capture: опционально — если передан, кладём save_mock (#2594, инспекция city=...)."""
|
||||
recorder = _RunsRecorder()
|
||||
db = MagicMock()
|
||||
buckets = [
|
||||
|
|
@ -328,6 +349,8 @@ async def _drive_full_load(*, source: str) -> _DriveResult:
|
|||
]
|
||||
scraper = _full_load_scraper(buckets)
|
||||
save_mock = MagicMock(side_effect=[(2, 0), (1, 0)])
|
||||
if capture is not None:
|
||||
capture["save_mock"] = save_mock
|
||||
cfg = _config()
|
||||
|
||||
fn_map = {
|
||||
|
|
@ -365,3 +388,77 @@ async def test_full_load_smoke(source: str) -> None:
|
|||
assert counters["saved_inserted"] == 3
|
||||
assert counters["saved_updated"] == 0
|
||||
assert calls[-1][0] == "mark_done"
|
||||
|
||||
|
||||
# ── #2594: listings.city проставляется из контекста развёртки ────────────────
|
||||
#
|
||||
# Критичный дефект: развёртка ЗНАЕТ город (city_slug), но раньше НИКУДА его не
|
||||
# писала. Тесты проверяют save_listings(..., city=...) для yandex/cian city-sweep
|
||||
# (oblast + EKB-симметрия), domclick (EKB-only city_id) и full_load'ов (ЕКБ вторичка).
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_yandex_city_sweep_stamps_city_from_slug() -> None:
|
||||
"""city_slug='kamensk_uralskiy' → save_listings(..., city='Каменск-Уральский')."""
|
||||
capture: dict[str, Any] = {}
|
||||
await _drive_yandex_city(city_slug="kamensk_uralskiy", capture=capture)
|
||||
save_mock = capture["save_mock"]
|
||||
assert save_mock.call_args_list[-1].kwargs["city"] == "Каменск-Уральский"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_yandex_city_sweep_stamps_ekaterinburg_when_no_city_slug() -> None:
|
||||
"""city_slug=None (ЕКБ-развёртка) → save_listings(..., city='Екатеринбург')."""
|
||||
capture: dict[str, Any] = {}
|
||||
await _drive_yandex_city(city_slug=None, capture=capture)
|
||||
save_mock = capture["save_mock"]
|
||||
assert save_mock.call_args_list[-1].kwargs["city"] == "Екатеринбург"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cian_city_sweep_stamps_city_from_slug() -> None:
|
||||
"""city_slug='pervouralsk' → save_listings(..., city='Первоуральск')."""
|
||||
capture: dict[str, Any] = {}
|
||||
await _drive_cian_city(city_slug="pervouralsk", capture=capture)
|
||||
save_mock = capture["save_mock"]
|
||||
assert save_mock.call_args.kwargs["city"] == "Первоуральск"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cian_city_sweep_stamps_ekaterinburg_when_no_city_slug() -> None:
|
||||
"""city_slug=None (ЕКБ-развёртка) → save_listings(..., city='Екатеринбург')."""
|
||||
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"] == "Екатеринбург"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_domclick_city_sweep_stamps_ekaterinburg_for_default_city_id() -> None:
|
||||
"""city_id=DOMCLICK_DEFAULT_CITY_ID (4, ЕКБ) → save_listings(..., city='Екатеринбург')."""
|
||||
capture: dict[str, Any] = {}
|
||||
await _drive_domclick(lots_n=4, blocked=False, capture=capture)
|
||||
save_mock = capture["save_mock"]
|
||||
assert save_mock.call_args.kwargs["city"] == "Екатеринбург"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_avito_newbuilding_sweep_stamps_ekaterinburg() -> None:
|
||||
"""Citywide novostroyka-обход — только ЕКБ → save_listings(..., city='Екатеринбург')."""
|
||||
capture: dict[str, Any] = {}
|
||||
await _drive_nb_sweep(capture=capture)
|
||||
save_mock = capture["save_mock"]
|
||||
assert save_mock.call_args.kwargs["city"] == "Екатеринбург"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("source", ["avito", "cian", "yandex"])
|
||||
async def test_full_load_stamps_ekaterinburg(source: str) -> None:
|
||||
"""Exhaustive региональный сбор — только ЕКБ вторичка → city='Екатеринбург' на КАЖДОМ
|
||||
бакете (on_bucket сохраняет инкрементально, не один batch на весь run)."""
|
||||
capture: dict[str, Any] = {}
|
||||
await _drive_full_load(source=source, capture=capture)
|
||||
save_mock = capture["save_mock"]
|
||||
assert save_mock.call_count > 0
|
||||
for call in save_mock.call_args_list:
|
||||
assert call.kwargs["city"] == "Екатеринбург"
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ _PRODUCT_SOURCES: set[str] = {
|
|||
"rosreestr_dkp_import",
|
||||
"listing_source_snapshot",
|
||||
"asking_to_sold_ratio_refresh",
|
||||
"deal_city_price_bands_refresh",
|
||||
"refresh_search_matview",
|
||||
"yandex_address_backfill",
|
||||
"deactivate_stale_avito",
|
||||
|
|
|
|||
|
|
@ -87,7 +87,11 @@ def client(db: MagicMock) -> TestClient:
|
|||
yield db
|
||||
|
||||
app.dependency_overrides[get_db] = fake_db
|
||||
return TestClient(app)
|
||||
# https, а не дефолтный http: анонимная ветка ставит идентити-куку с
|
||||
# `secure=True` (как session-cookie), и по http httpx её не вернул бы в
|
||||
# следующем запросе — тесты «тот же тред / тот же бюджет лимита» тихо
|
||||
# проверяли бы каждый раз НОВОГО анонима. Прод и так только https.
|
||||
return TestClient(app, base_url="https://testserver")
|
||||
|
||||
|
||||
def _auth(username: str = "alice") -> dict[str, str]:
|
||||
|
|
@ -417,17 +421,19 @@ def test_list_messages_returns_thread_scoped_rows(
|
|||
monkeypatch.setattr(
|
||||
support_module.storage,
|
||||
"list_messages",
|
||||
lambda db, *, thread_id, since_id, limit: [
|
||||
{
|
||||
"id": 1,
|
||||
"direction": "in",
|
||||
"text_body": "hi",
|
||||
"operator_tg_id": None,
|
||||
"created_at": "2026-07-26T00:00:00+00:00",
|
||||
}
|
||||
]
|
||||
if thread_id == 7
|
||||
else [],
|
||||
lambda db, *, thread_id, since_id, limit: (
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"direction": "in",
|
||||
"text_body": "hi",
|
||||
"operator_tg_id": None,
|
||||
"created_at": "2026-07-26T00:00:00+00:00",
|
||||
}
|
||||
]
|
||||
if thread_id == 7
|
||||
else []
|
||||
),
|
||||
)
|
||||
r = client.get("/api/v1/trade-in/support/messages", params={"since": 0}, headers=_auth("alice"))
|
||||
assert r.status_code == 200
|
||||
|
|
@ -534,3 +540,194 @@ def test_mark_read_calls_storage_when_thread_exists(
|
|||
assert r.status_code == 200
|
||||
assert mark_called == [7]
|
||||
assert db.commit.called
|
||||
|
||||
|
||||
# ── анонимная ветка: поддержка без входа (инцидент 2026-07-31) ────────────────
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fresh_anon_ip_limiter(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Как `_fresh_rate_limiter`, но для per-IP бюджета анонимной ветки — иначе
|
||||
состояние течёт между тестами в одном процессе pytest."""
|
||||
monkeypatch.setattr(
|
||||
support_module, "_anon_ip_limiter", SlidingWindowLimiter(limit=1000, window_s=60.0)
|
||||
)
|
||||
|
||||
|
||||
def _patch_anon_storage(monkeypatch: pytest.MonkeyPatch) -> list[str]:
|
||||
"""Мокает storage для send-пути и возвращает список ключей тредов, с которыми
|
||||
его позвали (проверяем, что аноним адресуется `anon:<token>`, а не логином)."""
|
||||
seen_keys: list[str] = []
|
||||
|
||||
def fake_get_or_create(db: Any, username: str) -> int:
|
||||
seen_keys.append(username)
|
||||
return 1
|
||||
|
||||
monkeypatch.setattr(support_module.storage, "get_or_create_thread", fake_get_or_create)
|
||||
monkeypatch.setattr(
|
||||
support_module.storage,
|
||||
"record_inbound",
|
||||
lambda *a, **kw: {
|
||||
"id": 1,
|
||||
"direction": "in",
|
||||
"text_body": kw["text_body"],
|
||||
"operator_tg_id": None,
|
||||
"created_at": "2026-07-31T00:00:00+00:00",
|
||||
},
|
||||
)
|
||||
return seen_keys
|
||||
|
||||
|
||||
def test_anon_send_without_any_auth_succeeds_and_sets_cookie(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Суть фичи: залогиниться нельзя, а написать в поддержку — можно."""
|
||||
seen_keys = _patch_anon_storage(monkeypatch)
|
||||
|
||||
r = client.post("/api/v1/trade-in/support/anon/messages", json={"text": "не могу войти"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["text_body"] == "не могу войти"
|
||||
|
||||
token = client.cookies.get(support_module._ANON_COOKIE_NAME)
|
||||
assert token is not None
|
||||
assert support_module._ANON_TOKEN_RE.match(token)
|
||||
# Тред адресован анонимным ключом, не голым токеном и не чьим-то логином.
|
||||
assert seen_keys == [f"anon:{token}"]
|
||||
|
||||
|
||||
def test_anon_cookie_reused_across_messages_same_thread(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
seen_keys = _patch_anon_storage(monkeypatch)
|
||||
|
||||
client.post("/api/v1/trade-in/support/anon/messages", json={"text": "первое"})
|
||||
token_after_first = client.cookies.get(support_module._ANON_COOKIE_NAME)
|
||||
client.post("/api/v1/trade-in/support/anon/messages", json={"text": "второе"})
|
||||
|
||||
assert client.cookies.get(support_module._ANON_COOKIE_NAME) == token_after_first
|
||||
assert seen_keys == [f"anon:{token_after_first}"] * 2
|
||||
|
||||
|
||||
def test_anon_mirror_is_labelled_and_never_leaks_token(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
|
||||
) -> None:
|
||||
"""Оператор видит, что это НЕзалогиненный посетитель, но bearer треда в
|
||||
Telegram-топик не уходит (топик читают люди и пересылают дальше)."""
|
||||
_patch_anon_storage(monkeypatch)
|
||||
|
||||
client.post("/api/v1/trade-in/support/anon/messages", json={"text": "помогите"})
|
||||
token = client.cookies.get(support_module._ANON_COOKIE_NAME)
|
||||
sent_text = _fake_telegram_client.calls[-1]["text"]
|
||||
|
||||
assert sent_text.startswith("[С САЙТА · БЕЗ ВХОДА] anon-")
|
||||
assert "помогите" in sent_text
|
||||
assert token not in sent_text
|
||||
assert support_module._anon_display_id(token) in sent_text
|
||||
|
||||
|
||||
def test_anon_read_paths_without_cookie_are_empty_not_401(client: TestClient) -> None:
|
||||
"""Виджет поллит эти ручки ДО первого сообщения — 401 там был бы ложной ошибкой."""
|
||||
assert client.get("/api/v1/trade-in/support/anon/messages").status_code == 200
|
||||
assert client.get("/api/v1/trade-in/support/anon/messages").json() == []
|
||||
assert client.get("/api/v1/trade-in/support/anon/unread").json() == {"unread": 0}
|
||||
assert client.post("/api/v1/trade-in/support/anon/read").json() == {"status": "ok"}
|
||||
|
||||
|
||||
def test_anon_malformed_cookie_ignored_and_never_reaches_storage(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Кука клиент-контролируема: мусор из браузера не должен становиться ключом
|
||||
треда. Считаем куку отсутствующей и выдаём новую."""
|
||||
seen_keys = _patch_anon_storage(monkeypatch)
|
||||
bogus = "not-a-valid-token!@#$%^"
|
||||
client.cookies.set(support_module._ANON_COOKIE_NAME, bogus)
|
||||
|
||||
r = client.post("/api/v1/trade-in/support/anon/messages", json={"text": "hi"})
|
||||
assert r.status_code == 200
|
||||
|
||||
assert len(seen_keys) == 1
|
||||
assert bogus not in seen_keys[0]
|
||||
assert seen_keys[0].startswith("anon:")
|
||||
assert support_module._ANON_TOKEN_RE.match(seen_keys[0].removeprefix("anon:"))
|
||||
|
||||
|
||||
def test_anon_read_path_with_malformed_cookie_returns_empty(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
find_calls = []
|
||||
monkeypatch.setattr(
|
||||
support_module.storage,
|
||||
"find_thread_id",
|
||||
lambda db, username: find_calls.append(username),
|
||||
)
|
||||
client.cookies.set(support_module._ANON_COOKIE_NAME, "!!not-a-token!!")
|
||||
|
||||
assert client.get("/api/v1/trade-in/support/anon/messages").json() == []
|
||||
assert find_calls == [] # до storage мусор не доехал вообще
|
||||
|
||||
|
||||
def test_anon_per_ip_rate_limit_429(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Главный анти-абуз: per-token бюджет обходится сбросом куки, per-IP — нет."""
|
||||
_patch_anon_storage(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
support_module, "_anon_ip_limiter", SlidingWindowLimiter(limit=1, window_s=60.0)
|
||||
)
|
||||
|
||||
assert (
|
||||
client.post("/api/v1/trade-in/support/anon/messages", json={"text": "1"}).status_code == 200
|
||||
)
|
||||
# Ротация куки НЕ спасает — бюджет привязан к IP.
|
||||
client.cookies.delete(support_module._ANON_COOKIE_NAME)
|
||||
r = client.post("/api/v1/trade-in/support/anon/messages", json={"text": "2"})
|
||||
assert r.status_code == 429
|
||||
assert "Retry-After" in r.headers
|
||||
|
||||
|
||||
def test_anon_per_token_rate_limit_429(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_patch_anon_storage(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
support_module, "_send_limiter", SlidingWindowLimiter(limit=1, window_s=60.0)
|
||||
)
|
||||
|
||||
assert (
|
||||
client.post("/api/v1/trade-in/support/anon/messages", json={"text": "1"}).status_code == 200
|
||||
)
|
||||
assert (
|
||||
client.post("/api/v1/trade-in/support/anon/messages", json={"text": "2"}).status_code == 429
|
||||
)
|
||||
|
||||
|
||||
def test_anon_failed_send_sets_no_cookie_and_writes_nothing(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
|
||||
) -> None:
|
||||
"""Неудачная отправка не должна закреплять за посетителем пустой тред."""
|
||||
seen_keys = _patch_anon_storage(monkeypatch)
|
||||
_fake_telegram_client._response = TelegramApiError("sendMessage", 500, "boom")
|
||||
|
||||
r = client.post("/api/v1/trade-in/support/anon/messages", json={"text": "hi"})
|
||||
assert r.status_code == 502
|
||||
assert seen_keys == []
|
||||
assert client.cookies.get(support_module._ANON_COOKIE_NAME) is None
|
||||
|
||||
|
||||
def test_anon_bot_not_configured_503(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(support_module.settings, "telegram_bot_token", "")
|
||||
r = client.post("/api/v1/trade-in/support/anon/messages", json={"text": "hi"})
|
||||
assert r.status_code == 503
|
||||
assert client.cookies.get(support_module._ANON_COOKIE_NAME) is None
|
||||
|
||||
|
||||
def test_anon_blank_text_422(client: TestClient) -> None:
|
||||
r = client.post("/api/v1/trade-in/support/anon/messages", json={"text": " "})
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_anon_thread_key_cannot_collide_with_real_username() -> None:
|
||||
"""Инвариант изоляции: `anon:` невозможен в реальном логине (CHECK миграции
|
||||
193 + Pydantic `^[A-Za-z0-9._-]{3,64}$`), значит аноним структурно не может
|
||||
попасть в тред существующего пользователя."""
|
||||
from app.schemas.team import _USERNAME_RE
|
||||
|
||||
key = support_module._anon_thread_key(support_module.secrets.token_urlsafe(18))
|
||||
assert key.startswith("anon:")
|
||||
assert _USERNAME_RE.match(key) is None
|
||||
|
|
|
|||
1159
tradein-mvp/backend/tests/test_team_api.py
Normal file
1159
tradein-mvp/backend/tests/test_team_api.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -76,14 +76,30 @@ def _db_mock(rows: list[dict[str, Any]]) -> MagicMock:
|
|||
return db
|
||||
|
||||
|
||||
def _fetch(db: MagicMock) -> tuple[list[dict[str, Any]], str | None]:
|
||||
"""Вызов _fetch_anchor_comps с Tier A-релевантными аргументами (без lat/lon → Tier C skip)."""
|
||||
# #2581: subject lat/lon — совпадают с _row()'s hardcoded lat/lon (56.838/60.595),
|
||||
# т.е. subject и comps в одной точке ЕКБ → ST_DWithin(ANCHOR_TIER_A_RADIUS_M)
|
||||
# тривиально проходит, не мешая #1774 novostroyki-gating semantics ниже.
|
||||
_LAT = 56.838
|
||||
_LON = 60.595
|
||||
|
||||
|
||||
def _fetch(
|
||||
db: MagicMock, *, lat: float | None = None, lon: float | None = None
|
||||
) -> tuple[list[dict[str, Any]], str | None]:
|
||||
"""Вызов _fetch_anchor_comps с Tier A-релевантными аргументами.
|
||||
|
||||
lat/lon по умолчанию None — тесты, ожидающие tier=None (novostroyki-gate
|
||||
отбраковал все comps до min_comps), передают None намеренно: с #2581
|
||||
геогейтом Tier A целиком пропускается без lat/lon (см. Tier C — тот же
|
||||
паттерн), что и раньше давало tier=None (Tier C тоже требовал lat/lon).
|
||||
Тесты, ожидающие tier=='A', передают _LAT/_LON явно.
|
||||
"""
|
||||
return _fetch_anchor_comps(
|
||||
db,
|
||||
address=_ADDRESS,
|
||||
target_house_id=None,
|
||||
lat=None,
|
||||
lon=None,
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
rooms=2,
|
||||
area=50.0,
|
||||
)
|
||||
|
|
@ -106,7 +122,7 @@ def test_tier_a_includes_novostroyki_when_secondary_present() -> None:
|
|||
]
|
||||
db = _db_mock(rows)
|
||||
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
||||
comps, tier = _fetch(db)
|
||||
comps, tier = _fetch(db, lat=_LAT, lon=_LON)
|
||||
assert tier == "A"
|
||||
# Все 6 (4 вторички + 2 novostroyki-переуступки) учтены.
|
||||
assert len(comps) == 6
|
||||
|
|
@ -178,7 +194,7 @@ def test_tier_a_dedup_same_source_id_collapses() -> None:
|
|||
]
|
||||
db = _db_mock(rows)
|
||||
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
||||
comps, tier = _fetch(db)
|
||||
comps, tier = _fetch(db, lat=_LAT, lon=_LON)
|
||||
assert tier == "A"
|
||||
# 6 строк, но 2 cian-строки с одинаковым source_id → 1 comp → итого 5.
|
||||
assert len(comps) == 5
|
||||
|
|
@ -214,7 +230,7 @@ def test_tier_a_dedup_same_source_id_different_url_collapses() -> None:
|
|||
]
|
||||
db = _db_mock(rows)
|
||||
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
||||
comps, tier = _fetch(db)
|
||||
comps, tier = _fetch(db, lat=_LAT, lon=_LON)
|
||||
assert tier == "A"
|
||||
# source_id-primary схлопывает несмотря на разные url → 5 comps.
|
||||
assert len(comps) == 5
|
||||
|
|
@ -233,7 +249,7 @@ def test_tier_a_dedup_null_url_keeps_distinct_rows() -> None:
|
|||
]
|
||||
db = _db_mock(rows)
|
||||
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
||||
comps, tier = _fetch(db)
|
||||
comps, tier = _fetch(db, lat=_LAT, lon=_LON)
|
||||
assert tier == "A"
|
||||
# 4 разных лота (разные source_id/площадь) → 4 comps, ничего не схлопнуто.
|
||||
assert len(comps) == 4
|
||||
|
|
|
|||
305
tradein-mvp/frontend/src/app/login/page.tsx
Normal file
305
tradein-mvp/frontend/src/app/login/page.tsx
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* #2555 (эпик #2549) — login-форма для новой DB-backed session auth
|
||||
* (POST /api/v1/auth/login, см. `tradein-mvp/backend/app/api/v1/auth.py`).
|
||||
*
|
||||
* Не гейтится RouteGuard'ом (см. `components/auth/RouteGuard.tsx` —
|
||||
* `isLoginPage` bypass) — иначе редирект-петля: 401 от /me на /login тоже
|
||||
* пытался бы увести на /login.
|
||||
*
|
||||
* `next=` читаем вручную из `window.location.search` (SSR-guard), а НЕ
|
||||
* через `next/navigation` `useSearchParams()` — тот форсит Suspense boundary
|
||||
* и ломает `next build` (см. `app/v2/page.tsx: readUrlId` — тот же паттерн,
|
||||
* уже принятый в этом репо).
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import type { CSSProperties, FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch, HTTPError } from "@/lib/api";
|
||||
import { ME_QUERY_KEY } from "@/lib/useMe";
|
||||
import { AnonSupportWidget } from "@/components/auth/AnonSupportWidget";
|
||||
|
||||
interface LoginInput {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
async function loginRequest(input: LoginInput): Promise<void> {
|
||||
await apiFetch<{ ok: boolean }>("/api/v1/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
function readNextParam(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
return new URLSearchParams(window.location.search).get("next");
|
||||
}
|
||||
|
||||
/**
|
||||
* Open-redirect guard: принимаем только внутренний путь, начинающийся
|
||||
* ровно с одного "/" — не "//host" (protocol-relative URL) и не "/\host"
|
||||
* (браузеры местами трактуют backslash как forward slash в URL-парсинге).
|
||||
*
|
||||
* PR #2562 review finding 1: WHATWG URL-парсер (который `router.push`
|
||||
* использует под капотом) убирает ВСЕ ASCII tab/CR/LF из строки ПЕРЕД
|
||||
* парсингом — так `"/\t//evil"` для наивного regex выглядит как безопасный
|
||||
* путь с одним leading slash (символ в позиции 1 — таб, не "/" и не "\"),
|
||||
* а после навигации превращается в `"//evil"` (protocol-relative → чужой
|
||||
* origin). Убираем те же символы ДО валидации, чтобы regex видел ту же
|
||||
* строку, что увидит парсер.
|
||||
*
|
||||
* PR #2562 review finding 2: `next=/login` (или `/login?...`) после успешного
|
||||
* логина кидал бы юзера обратно на форму входа (RouteGuard не гейтит
|
||||
* `/login`) — dead-end. Фолбэк на "/" в этом случае.
|
||||
*/
|
||||
function sanitizeNext(next: string | null): string {
|
||||
if (!next) return "/";
|
||||
const cleaned = next.replace(/[\t\r\n]/g, "");
|
||||
if (!/^\/(?!\/|\\)/.test(cleaned)) return "/";
|
||||
if (
|
||||
cleaned === "/login" ||
|
||||
cleaned.startsWith("/login?") ||
|
||||
cleaned.startsWith("/login#")
|
||||
) {
|
||||
return "/";
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
function loginErrorMessage(error: unknown): string {
|
||||
if (error instanceof HTTPError) {
|
||||
if (error.status === 401) return "Неверный логин или пароль";
|
||||
if (error.status === 429) {
|
||||
return "Слишком много попыток. Попробуйте через несколько минут";
|
||||
}
|
||||
}
|
||||
return "Не удалось войти. Проверьте подключение и попробуйте ещё раз";
|
||||
}
|
||||
|
||||
const cardStyle: CSSProperties = {
|
||||
background: "var(--bg-card)",
|
||||
border: "1px solid var(--border-card)",
|
||||
borderRadius: 12,
|
||||
padding: "32px 28px",
|
||||
maxWidth: 380,
|
||||
width: "100%",
|
||||
};
|
||||
|
||||
const labelStyle: CSSProperties = {
|
||||
display: "block",
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
color: "var(--fg-secondary)",
|
||||
marginBottom: 6,
|
||||
};
|
||||
|
||||
const inputStyle: CSSProperties = {
|
||||
width: "100%",
|
||||
boxSizing: "border-box",
|
||||
height: 40,
|
||||
padding: "0 12px",
|
||||
border: "1px solid var(--border-card)",
|
||||
borderRadius: 8,
|
||||
fontSize: 14,
|
||||
color: "var(--fg-primary)",
|
||||
background: "var(--bg-card)",
|
||||
fontFamily: "inherit",
|
||||
};
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: loginRequest,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ME_QUERY_KEY });
|
||||
router.push(sanitizeNext(readNextParam()));
|
||||
},
|
||||
});
|
||||
|
||||
function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
if (loginMutation.isPending) return;
|
||||
loginMutation.mutate({ username: username.trim(), password });
|
||||
}
|
||||
|
||||
return (
|
||||
<main
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
background: "var(--bg-app)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 24,
|
||||
fontFamily: "Inter, -apple-system, 'Segoe UI', system-ui, sans-serif",
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
@keyframes login-spin { to { transform: rotate(360deg); } }
|
||||
.login-spinner { animation: login-spin .7s linear infinite; }
|
||||
.login-input:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent-soft);
|
||||
}
|
||||
.login-submit:hover:not(:disabled) { background: var(--accent-hover); }
|
||||
`}</style>
|
||||
|
||||
<form onSubmit={handleSubmit} style={cardStyle} aria-label="Вход в Меру">
|
||||
<h1
|
||||
style={{
|
||||
margin: "0 0 8px",
|
||||
fontSize: 22,
|
||||
fontWeight: 600,
|
||||
color: "var(--fg-primary)",
|
||||
lineHeight: 1.25,
|
||||
}}
|
||||
>
|
||||
Вход
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
margin: "0 0 24px",
|
||||
fontSize: 14,
|
||||
color: "var(--fg-secondary)",
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
Войдите, чтобы продолжить работу с Мерой.
|
||||
</p>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={labelStyle} htmlFor="login-username">
|
||||
Логин
|
||||
</label>
|
||||
<input
|
||||
id="login-username"
|
||||
name="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
required
|
||||
autoFocus
|
||||
className="login-input"
|
||||
style={inputStyle}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
disabled={loginMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<label style={labelStyle} htmlFor="login-password">
|
||||
Пароль
|
||||
</label>
|
||||
<input
|
||||
id="login-password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="login-input"
|
||||
style={inputStyle}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={loginMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loginMutation.isError ? (
|
||||
<p
|
||||
role="alert"
|
||||
style={{
|
||||
margin: "0 0 16px",
|
||||
padding: "8px 12px",
|
||||
borderRadius: 8,
|
||||
background: "var(--danger-soft)",
|
||||
color: "var(--danger)",
|
||||
fontSize: 13,
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{loginErrorMessage(loginMutation.error)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="login-submit"
|
||||
disabled={loginMutation.isPending}
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
background: "var(--accent)",
|
||||
color: "#FFFFFF",
|
||||
border: "none",
|
||||
borderRadius: 8,
|
||||
padding: "10px 16px",
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
cursor: loginMutation.isPending ? "wait" : "pointer",
|
||||
opacity: loginMutation.isPending ? 0.75 : 1,
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
>
|
||||
{loginMutation.isPending ? (
|
||||
<>
|
||||
<svg
|
||||
className="login-spinner"
|
||||
width={16}
|
||||
height={16}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="9"
|
||||
stroke="rgba(255,255,255,0.35)"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
<path
|
||||
d="M21 12a9 9 0 0 0-9-9"
|
||||
stroke="#FFFFFF"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
Входим…
|
||||
</>
|
||||
) : (
|
||||
"Войти"
|
||||
)}
|
||||
</button>
|
||||
|
||||
<p
|
||||
style={{
|
||||
margin: "16px 0 0",
|
||||
fontSize: 12,
|
||||
color: "var(--fg-tertiary)",
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
Не получается войти? Напишите нам — кнопка «Поддержка» в правом нижнем
|
||||
углу. Отвечаем без входа в систему.
|
||||
</p>
|
||||
</form>
|
||||
|
||||
{/* Инцидент 2026-07-31: без этого тот, кто не может залогиниться, не
|
||||
может и сообщить об этом — единственный чат был за логином. */}
|
||||
<AnonSupportWidget />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,9 +6,9 @@ import { pageBg } from "@/components/trade-in/v2/tokens";
|
|||
/**
|
||||
* ЭТАП 1 плана B2C-запуска МЕРА: публичный периметр БЕЗ функционала.
|
||||
*
|
||||
* Обслуживается ОТДЕЛЬНЫМ поддоменом mera.gendsgn.ru (см. корневой Caddyfile,
|
||||
* site-блок `mera.gendsgn.ru { ... }`) — не напрямую по basePath. Caddy на
|
||||
* этом поддомене rewrite'ит запрос корня "/" в "/trade-in/mera-public" (тот
|
||||
* Обслуживается ОТДЕЛЬНЫМ доменом meraocenka.ru (см. корневой Caddyfile,
|
||||
* site-блок `meraocenka.ru { ... }`) — не напрямую по basePath. Caddy на
|
||||
* этом домене rewrite'ит запрос корня "/" в "/trade-in/mera-public" (тот
|
||||
* же образ tradein-frontend, что обслуживает и gendsgn.ru/trade-in/*, у него
|
||||
* запечён NEXT_PUBLIC_BASE_PATH=/trade-in) — пользователь префикс /trade-in
|
||||
* никогда не видит, это внутренний Caddy→backend hop.
|
||||
|
|
|
|||
|
|
@ -5,7 +5,18 @@
|
|||
* Порог % → дома вторички, где доля квартир, выставленных на продажу, ≥ порога.
|
||||
* Сигнал для девелопера: расселение / инвест-выход / проблемный дом.
|
||||
*
|
||||
* Доступ: pilot + admin (RBAC roles.yaml: pilot paths `/trade-in/**`).
|
||||
* Доступ: ТОЛЬКО admin (с 2026-07-31). Это ТЕСТОВЫЙ продукт — решение владельца
|
||||
* продукта: клиентам не показывать, доступ только у админа. Явный deny
|
||||
* `/trade-in/sale-share/**` + `/trade-in/api/v1/buildings/**` заведён для
|
||||
* DB-ролей employee/manager (`app/services/auth_session.py: DB_ROLE_PATHS`),
|
||||
* для legacy `pilot` И для внутренней `analyst` (`auth/roles.yaml`) — «только у
|
||||
* админа» включает и внутренние роли. Ср. соседний «Кэш»: он закрыт от клиентов,
|
||||
* но analyst'у оставлен, т.к. это диагностика, а не продукт.
|
||||
*
|
||||
* NB: короткий адрес `gendsgn.ru/sale-share` (301 → сюда, см. Caddyfile) ведёт
|
||||
* на NoAccessScreen для всех, кроме admin. Если раздел когда-нибудь станет
|
||||
* продаваемым, одним снятием deny не обойтись: нужен per-account carve-out —
|
||||
* скоуп сейчас только ролевой, выдать раздел отдельному клиенту нечем.
|
||||
*/
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
|
|
|
|||
180
tradein-mvp/frontend/src/app/team/page.tsx
Normal file
180
tradein-mvp/frontend/src/app/team/page.tsx
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Team-дашборд менеджера (#2556, эпик #2549) — единая страница для ролей
|
||||
* `admin` и `manager` (backend сам скоупит список по org-изоляции, см.
|
||||
* `app/api/v1/team.py::list_employees`), UI не дублируется под роли.
|
||||
*
|
||||
* Доступ: `useMe().role` проверяется на клиенте ДО первого запроса (не ждём
|
||||
* доомed 403 round-trip), но HTTPError 403 от самого списка сотрудников тоже
|
||||
* ловится — тот же defense-in-depth паттерн, что и `app/admin/audit/page.tsx`
|
||||
* (прямой заход на URL не крашит страницу, даже если nav её уже прячет).
|
||||
*
|
||||
* #2556 fix (эпик #2549, live-audit): team-роуты требуют session-cookie
|
||||
* (см. team-api.ts header comment) — легаси Caddy-заголовок (`/me` 200 с
|
||||
* ролью admin/manager) через него НЕ проходит. Юзер, прошедший только Caddy
|
||||
* basic_auth (без входа в форму `/login`), видел здесь непонятную красную
|
||||
* плашку «Не удалось выполнить запрос». Ловим именно 401 от employeesQ
|
||||
* ОТДЕЛЬНО от 403 (403 = «нет прав», это ожидаемо и есть у другой роли; 401
|
||||
* = «нет сессии вообще», это решается логином) и уводим на /login?next=/team
|
||||
* — тот же redirect-паттерн, что RouteGuard.tsx использует для 401 от /me.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import "@/components/trade-in/trade-in.css";
|
||||
import { NoAccessScreen } from "@/components/auth/NoAccessScreen";
|
||||
import { CreateEmployeeForm } from "@/components/team/CreateEmployeeForm";
|
||||
import { EmployeeHistoryDrawer } from "@/components/team/EmployeeHistoryDrawer";
|
||||
import { EmployeeTable } from "@/components/team/EmployeeTable";
|
||||
import { QuotaEditor } from "@/components/team/QuotaEditor";
|
||||
import { Topbar } from "@/components/trade-in/Topbar";
|
||||
import { HTTPError } from "@/lib/api";
|
||||
import type { Employee } from "@/lib/team-api";
|
||||
import { teamErrorMessage, useEmployees } from "@/lib/team-api";
|
||||
import { useMe } from "@/lib/useMe";
|
||||
|
||||
const PAGE_LIMIT = 50;
|
||||
const TEAM_LOGIN_NEXT = `/login?next=${encodeURIComponent("/team")}`;
|
||||
|
||||
export default function TeamPage() {
|
||||
const meQ = useMe();
|
||||
const router = useRouter();
|
||||
// Вычисляем ДО useEmployees (не после) — иначе для employee/analyst/pilot
|
||||
// при прямом заходе на /team улетает обречённый GET (401/403) ещё до того,
|
||||
// как ниже отрисуется role-gate. Порядок хуков не меняется — это просто
|
||||
// производное значение, не условный вызов хука.
|
||||
const role = meQ.data?.role;
|
||||
const isAllowedRole = role === "admin" || role === "manager";
|
||||
|
||||
const [offset, setOffset] = useState(0);
|
||||
const employeesQ = useEmployees(PAGE_LIMIT, offset, isAllowedRole);
|
||||
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [quotaEmployee, setQuotaEmployee] = useState<Employee | null>(null);
|
||||
const [historyEmployee, setHistoryEmployee] = useState<Employee | null>(null);
|
||||
|
||||
// 401 = легаси Caddy-роль прошла role-gate выше, но team-эндпоинт требует
|
||||
// session-cookie, которой у юзера нет (см. header comment). Отдельно от 403
|
||||
// (та ветка — «роль есть, прав недостаточно», ниже).
|
||||
const sessionRequired =
|
||||
employeesQ.error instanceof HTTPError && employeesQ.error.status === 401;
|
||||
|
||||
// Редирект — побочный эффект (нельзя router.push во время рендера), тот же
|
||||
// паттерн, что RouteGuard.tsx использует для 401 от /me. NODE_ENV-гейт
|
||||
// зеркалит RouteGuard: в dev без Caddy/логина 401 — обычное дело, авто-
|
||||
// редирект там мешал бы локальной разработке; вместо него — сообщение с
|
||||
// кнопкой ниже.
|
||||
useEffect(() => {
|
||||
if (!sessionRequired) return;
|
||||
if (process.env.NODE_ENV !== "production") return;
|
||||
router.push(TEAM_LOGIN_NEXT);
|
||||
}, [sessionRequired, router]);
|
||||
|
||||
// Роль ещё не загружена — не решаем ничего, RouteGuard уже отрисовал
|
||||
// страницу (401/403 верхнего уровня он ловит сам), здесь только role-gate.
|
||||
if (meQ.isPending) {
|
||||
return (
|
||||
<>
|
||||
<Topbar active="team" />
|
||||
<main className="page scraper-page" style={{ maxWidth: 1180 }}>
|
||||
<p className="scraper-hint">Загрузка…</p>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAllowedRole) {
|
||||
return <NoAccessScreen variant="user" />;
|
||||
}
|
||||
|
||||
if (sessionRequired) {
|
||||
// Prod: редирект уже запущен эффектом выше — не мигаем error-текстом,
|
||||
// пока навигация не завершится (тот же приём, что RouteGuard.tsx).
|
||||
if (process.env.NODE_ENV === "production") return null;
|
||||
return (
|
||||
<>
|
||||
<Topbar active="team" />
|
||||
<main className="page scraper-page" style={{ maxWidth: 1180 }}>
|
||||
<h1 className="scraper-h1">Команда</h1>
|
||||
<p className="scraper-result scraper-result--error">
|
||||
Войдите в систему, чтобы управлять командой.
|
||||
</p>
|
||||
<a
|
||||
href={TEAM_LOGIN_NEXT}
|
||||
className="team-btn-primary"
|
||||
style={{ display: "inline-block", textDecoration: "none" }}
|
||||
>
|
||||
Войти
|
||||
</a>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Backend 403 регардлесс роли фронта (напр. session истекла между /me и
|
||||
// /employees) — тот же fallback, что и в admin/audit.
|
||||
if (employeesQ.error instanceof HTTPError && employeesQ.error.status === 403) {
|
||||
return <NoAccessScreen variant="user" />;
|
||||
}
|
||||
|
||||
const employees = employeesQ.data ?? [];
|
||||
const hasNextPage = employees.length === PAGE_LIMIT;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Topbar active="team" />
|
||||
<main className="page scraper-page" style={{ maxWidth: 1180 }}>
|
||||
<h1 className="scraper-h1">Команда</h1>
|
||||
<p className="scraper-subtitle">
|
||||
Сотрудники, их доступ и месячные квоты на оценку. История оценок — по клику
|
||||
«История».
|
||||
</p>
|
||||
|
||||
<section className="scraper-section">
|
||||
<div className="team-toolbar">
|
||||
<h2 style={{ margin: 0 }}>Сотрудники</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="team-btn-primary"
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
>
|
||||
+ Добавить сотрудника
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{employeesQ.isPending ? <p className="scraper-hint">Загрузка…</p> : null}
|
||||
{employeesQ.isError && !(employeesQ.error instanceof HTTPError && employeesQ.error.status === 403) ? (
|
||||
<p className="scraper-result scraper-result--error">
|
||||
{teamErrorMessage(employeesQ.error)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{employeesQ.data ? (
|
||||
<EmployeeTable
|
||||
employees={employees}
|
||||
onEditQuota={setQuotaEmployee}
|
||||
onViewHistory={setHistoryEmployee}
|
||||
limit={PAGE_LIMIT}
|
||||
offset={offset}
|
||||
onOffsetChange={setOffset}
|
||||
hasNextPage={hasNextPage}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{showCreateForm ? <CreateEmployeeForm onClose={() => setShowCreateForm(false)} /> : null}
|
||||
{quotaEmployee ? (
|
||||
<QuotaEditor employee={quotaEmployee} onClose={() => setQuotaEmployee(null)} />
|
||||
) : null}
|
||||
{historyEmployee ? (
|
||||
<EmployeeHistoryDrawer
|
||||
employee={historyEmployee}
|
||||
onClose={() => setHistoryEmployee(null)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -53,6 +53,7 @@ import type {
|
|||
TradeInEstimateInput,
|
||||
} from "@/types/trade-in";
|
||||
import { HTTPError } from "@/lib/api";
|
||||
import { isPathAllowed } from "@/lib/isPathAllowed";
|
||||
import {
|
||||
useEstimate,
|
||||
useEstimateHistory,
|
||||
|
|
@ -66,7 +67,7 @@ import {
|
|||
} from "@/lib/trade-in-api";
|
||||
import { useQuota } from "@/lib/useQuota";
|
||||
import { useMe } from "@/lib/useMe";
|
||||
import { logout } from "@/lib/logout";
|
||||
import { useLogout } from "@/lib/useLogout";
|
||||
|
||||
// OUTER HUD FRAME + 4 corner brackets (design lines 31-37). Decorative,
|
||||
// non-interactive overlay drawn over the artboard gradient. The frame has
|
||||
|
|
@ -524,6 +525,10 @@ export default function TradeInV2Page() {
|
|||
// (#2046) — known profile fields fall back to username / brand ?? role / ""
|
||||
// when absent (never invented). undefined while loading → TopNav «Гость».
|
||||
const me = useMe();
|
||||
// #2555: session-logout (POST /api/v1/auth/logout + local /me-cache
|
||||
// invalidate + redirect на /login) — replaces the legacy basic_auth-cache
|
||||
// -bust `logout()` for the v2 TopNav (new /login-form users).
|
||||
const logoutMutation = useLogout();
|
||||
|
||||
// Dashboard sub-hooks — each resolves independently; failure degrades its
|
||||
// section via the mappers (null input) rather than blanking the page.
|
||||
|
|
@ -662,6 +667,21 @@ export default function TradeInV2Page() {
|
|||
};
|
||||
}, [me.data]);
|
||||
|
||||
// #2556 (эпик #2549): вход в раздел «Команда» из v2-навигации — раньше
|
||||
// TopNav (v2) не знал про team-дашборд вообще, он был достижим только из
|
||||
// legacy Topbar.tsx (пункт в NAV_ITEMS), а `/trade-in/` редиректит на
|
||||
// `/trade-in/v2` → дашборд существовал, но был недостижим кликом. Тот же
|
||||
// двойной гейт, что и Topbar.NAV_ITEMS "team": isPathAllowed(scopePath) —
|
||||
// DB-роль `manager` получает голый `/api/v1/team/**` в allowed_paths (см.
|
||||
// DB_ROLE_PATHS, tradein-mvp/backend/app/services/auth_session.py) — ПЛЮС
|
||||
// roleGate поверх, потому что legacy-роль `analyst` (auth/roles.yaml,
|
||||
// paths: "/**", без явного deny на /api/v1/team) тоже прошла бы
|
||||
// isPathAllowed, но не должна видеть «Команда» (scope: только admin/manager).
|
||||
const showTeamNavItem =
|
||||
me.data != null &&
|
||||
isPathAllowed(me.data.allowed_paths, me.data.deny_paths, "/api/v1/team") &&
|
||||
(me.data.role === "admin" || me.data.role === "manager");
|
||||
|
||||
// Analog price pins for the 01 map, projected from the real estimate. No
|
||||
// estimate → mapMarkers(null) → [] → ParamsPanel renders only the subject pin
|
||||
// (Finding #2: never the static fixture price markers).
|
||||
|
|
@ -973,7 +993,8 @@ export default function TradeInV2Page() {
|
|||
onNavigate={setNav}
|
||||
reports={reportsCount ?? 0}
|
||||
user={topNavUser}
|
||||
onLogout={logout}
|
||||
onLogout={() => logoutMutation.mutate()}
|
||||
showTeamLink={showTeamNavItem}
|
||||
/>
|
||||
</nav>
|
||||
<main
|
||||
|
|
@ -1008,6 +1029,9 @@ export default function TradeInV2Page() {
|
|||
error={apiError}
|
||||
initialValues={initialValues}
|
||||
markers={markers}
|
||||
// #2576 — city_hint contract (backend PR #2580): honest
|
||||
// heads-up when the geocoder picked the city itself.
|
||||
cityAmbiguous={estimate?.target_city_ambiguous ?? false}
|
||||
/>
|
||||
{middleContent}
|
||||
{/* #2275: on mobile ObjectSummary is rendered fluid in the
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Чат поддержки для экранов БЕЗ входа — экран логина и «доступа нет».
|
||||
*
|
||||
* WHY (инцидент 2026-07-31): после cutover'а на свою авторизацию (#2558)
|
||||
* единственным каналом в поддержку был чат ЗА логином, а самая частая причина
|
||||
* писать в поддержку — как раз «не могу войти». В тот день «Практика» весь день
|
||||
* билась в форму входа (5 неудачных попыток с трёх разных IP, ни одной успешной)
|
||||
* и достучаться до нас из продукта не могла ничем: на `/login` не было ни чата,
|
||||
* ни контакта.
|
||||
*
|
||||
* Технически это тот же `SupportButton` + `SupportChatPanel`, что и в `/v2`,
|
||||
* только в анонимном scope (`SupportScope = "anon"`, ручки `/support/anon/*`) —
|
||||
* тред резолвится из httpOnly-куки, а не из идентити. Провайдер здесь свой:
|
||||
* `SupportChatProvider` живёт в `app/v2/layout.tsx`, куда эти экраны не входят.
|
||||
*
|
||||
* Отдельный компонент, а не копипаста в двух местах: точек монтирования две
|
||||
* (login-страница и `NoAccessScreen`), и обе — тупики, из которых пользователю
|
||||
* больше некуда идти.
|
||||
*/
|
||||
|
||||
import { SupportButton } from "@/components/trade-in/v2/SupportButton";
|
||||
import { SupportChatProvider } from "@/components/trade-in/v2/SupportChatContext";
|
||||
|
||||
export function AnonSupportWidget() {
|
||||
return (
|
||||
<SupportChatProvider>
|
||||
<SupportButton scope="anon" />
|
||||
</SupportChatProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,9 +7,15 @@
|
|||
*
|
||||
* Fullscreen «доступа нет» — для 403 от /me или для denied path.
|
||||
* Token-based styling per `.claude/rules/ui-tokens.md` (см. globals.css).
|
||||
*
|
||||
* СОЗНАТЕЛЬНОЕ расхождение с зеркалом (2026-07-31): здесь внизу монтируется
|
||||
* `AnonSupportWidget`, в копии Site Finder'а его нет и быть не может — виджет
|
||||
* ходит в trade-in'овый support-бридж (`/api/v1/trade-in/support/anon/*`),
|
||||
* которого в том бэкенде не существует. Всё остальное держим в синхроне.
|
||||
*/
|
||||
|
||||
import { logout } from "@/lib/logout";
|
||||
import { AnonSupportWidget } from "@/components/auth/AnonSupportWidget";
|
||||
|
||||
interface NoAccessScreenProps {
|
||||
variant: "user" | "path" | "session" | "trial" | "error";
|
||||
|
|
@ -168,6 +174,12 @@ export function NoAccessScreen({ variant, path }: NoAccessScreenProps) {
|
|||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Второй тупик, из которого пользователю некуда идти (первый — /login).
|
||||
Анонимный scope, а не авторизованный: на variant="session"/"error"
|
||||
идентити уже нет, а разное поведение чата на соседних вариантах одного
|
||||
экрана — лишняя развилка на ровном месте. Инцидент 2026-07-31. */}
|
||||
<AnonSupportWidget />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,9 +10,17 @@
|
|||
* 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 { usePathname } from "next/navigation";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { NoAccessScreen } from "@/components/auth/NoAccessScreen";
|
||||
import { HTTPError } from "@/lib/api";
|
||||
|
|
@ -22,7 +30,7 @@ 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";
|
||||
// ЭТАП 1 B2C-плана МЕРА: публичный периметр (mera.gendsgn.ru → rewrite на
|
||||
// ЭТАП 1 B2C-плана МЕРА: публичный периметр (meraocenka.ru → rewrite на
|
||||
// /trade-in/mera-public, см. Caddyfile). В отличие от ENABLE_PREVIEW выше —
|
||||
// ЭТОТ bypass ВСЕГДА включён (в т.ч. в проде): страница публична по
|
||||
// продуктовому решению, а не временный QA-артефакт.
|
||||
|
|
@ -34,6 +42,7 @@ interface RouteGuardProps {
|
|||
|
||||
export function RouteGuard({ children }: RouteGuardProps) {
|
||||
const rawPath = usePathname() ?? "/";
|
||||
const router = useRouter();
|
||||
// Абсолютный путь сайта: BASE_PATH + rawPath. Аккуратно с двойным слэшем
|
||||
// на `/`: `BASE_PATH = "/trade-in"` + `"/"` → `/trade-in/` (ок).
|
||||
const absolutePath = BASE_PATH
|
||||
|
|
@ -41,12 +50,42 @@ export function RouteGuard({ children }: RouteGuardProps) {
|
|||
: 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}</>;
|
||||
}
|
||||
|
||||
// ЭТАП 1 B2C: публичная заглушка МЕРА не ждёт /api/v1/me и не проверяет
|
||||
// RBAC — рендерится сразу для анонимного посетителя. useMe() выше уже
|
||||
// вызван (Rules of Hooks — нельзя условно), но его результат здесь
|
||||
|
|
@ -60,11 +99,11 @@ export function RouteGuard({ children }: RouteGuardProps) {
|
|||
|
||||
if (error instanceof HTTPError && error.status === 401) {
|
||||
// Dev without Caddy: 401 is normal, mount the app so local dev works.
|
||||
// Prod: mounting children on 401 causes TanStack Query re-subscribe storm
|
||||
// (each new observer on errored query triggers a refetch). Show session screen
|
||||
// instead — prevents the subtree from mounting, kills the loop.
|
||||
if (process.env.NODE_ENV !== "production") return <>{children}</>;
|
||||
return <NoAccessScreen variant="session" />;
|
||||
// Prod: редирект уже запущен эффектом выше — ничего не рендерим, пока
|
||||
// навигация не завершится (mounting children on 401 causes TanStack
|
||||
// Query re-subscribe storm, см. историю до #2555 в git blame).
|
||||
return null;
|
||||
}
|
||||
|
||||
if (error instanceof HTTPError && error.status === 403) {
|
||||
|
|
|
|||
219
tradein-mvp/frontend/src/components/team/CreateEmployeeForm.tsx
Normal file
219
tradein-mvp/frontend/src/components/team/CreateEmployeeForm.tsx
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Модалка создания сотрудника (#2556, эпик #2549) — `POST /api/v1/team/employees`.
|
||||
*
|
||||
* Пароль задаётся вручную менеджером/админом — email-рассылки нет (см. issue
|
||||
* DoD), поэтому после успешного создания показываем явное «передайте пароль
|
||||
* сотруднику» вместо тихого закрытия модалки.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
|
||||
import {
|
||||
USERNAME_HINT,
|
||||
USERNAME_PATTERN,
|
||||
teamErrorMessage,
|
||||
useCreateEmployee,
|
||||
} from "@/lib/team-api";
|
||||
|
||||
interface CreateEmployeeFormProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function CreateEmployeeForm({ onClose }: CreateEmployeeFormProps) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [orgName, setOrgName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [monthlyLimit, setMonthlyLimit] = useState("");
|
||||
const [usernameTouched, setUsernameTouched] = useState(false);
|
||||
|
||||
const createMutation = useCreateEmployee();
|
||||
|
||||
const usernameValid = USERNAME_PATTERN.test(username);
|
||||
const usernameInvalid = usernameTouched && username.length > 0 && !usernameValid;
|
||||
|
||||
function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setUsernameTouched(true);
|
||||
if (createMutation.isPending) return;
|
||||
if (!USERNAME_PATTERN.test(username)) return;
|
||||
if (password.length === 0) return;
|
||||
|
||||
const parsedLimit = monthlyLimit.trim() === "" ? undefined : Number(monthlyLimit);
|
||||
|
||||
createMutation.mutate({
|
||||
username: username.trim(),
|
||||
password,
|
||||
display_name: displayName.trim() || undefined,
|
||||
org_name: orgName.trim() || undefined,
|
||||
email: email.trim() || undefined,
|
||||
monthly_limit:
|
||||
parsedLimit !== undefined && Number.isFinite(parsedLimit) && parsedLimit >= 1
|
||||
? Math.trunc(parsedLimit)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (createMutation.isSuccess) {
|
||||
return (
|
||||
<div className="team-modal-backdrop" role="presentation" onClick={onClose}>
|
||||
<div
|
||||
className="team-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Сотрудник создан"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2>Сотрудник создан</h2>
|
||||
<p className="team-form-success">
|
||||
Логин «{createMutation.data.username}» готов. Передайте пароль сотруднику —
|
||||
он не сохраняется в системе и не отправляется автоматически.
|
||||
</p>
|
||||
<div className="team-modal-actions">
|
||||
<button type="button" className="team-btn-primary" onClick={onClose}>
|
||||
Готово
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="team-modal-backdrop" role="presentation" onClick={onClose}>
|
||||
<form
|
||||
className="team-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Новый сотрудник"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<h2>Новый сотрудник</h2>
|
||||
<p className="team-modal-subtitle">
|
||||
Пароль задаётся вручную — передайте его сотруднику лично, рассылки нет.
|
||||
</p>
|
||||
|
||||
{createMutation.isError ? (
|
||||
<p role="alert" className="team-form-error">
|
||||
{teamErrorMessage(createMutation.error)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="team-field">
|
||||
<label htmlFor="team-new-username">Логин</label>
|
||||
<input
|
||||
id="team-new-username"
|
||||
name="username"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
required
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
onBlur={() => setUsernameTouched(true)}
|
||||
disabled={createMutation.isPending}
|
||||
aria-invalid={usernameInvalid}
|
||||
/>
|
||||
<p className="team-field-hint">{USERNAME_HINT}</p>
|
||||
{usernameInvalid ? (
|
||||
<p className="team-field-hint" style={{ color: "var(--danger, #b3261e)" }}>
|
||||
Логин не соответствует формату
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="team-field team-password-row">
|
||||
<label htmlFor="team-new-password">Пароль</label>
|
||||
<input
|
||||
id="team-new-password"
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={createMutation.isPending}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="team-password-toggle"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? "Скрыть" : "Показать"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="team-field">
|
||||
<label htmlFor="team-new-display-name">Имя (необязательно)</label>
|
||||
<input
|
||||
id="team-new-display-name"
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
disabled={createMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="team-field">
|
||||
<label htmlFor="team-new-org">Организация (необязательно)</label>
|
||||
<input
|
||||
id="team-new-org"
|
||||
type="text"
|
||||
value={orgName}
|
||||
onChange={(e) => setOrgName(e.target.value)}
|
||||
disabled={createMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="team-field">
|
||||
<label htmlFor="team-new-email">Email (необязательно)</label>
|
||||
<input
|
||||
id="team-new-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={createMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="team-field">
|
||||
<label htmlFor="team-new-limit">Месячный лимит оценок (необязательно)</label>
|
||||
<input
|
||||
id="team-new-limit"
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
placeholder="по умолчанию"
|
||||
value={monthlyLimit}
|
||||
onChange={(e) => setMonthlyLimit(e.target.value)}
|
||||
disabled={createMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="team-modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="team-btn-secondary"
|
||||
onClick={onClose}
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="team-btn-primary"
|
||||
disabled={createMutation.isPending || password.length === 0 || !usernameValid}
|
||||
>
|
||||
{createMutation.isPending ? "Создаём…" : "Создать"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Drawer с историей оценок сотрудника (#2556, эпик #2549) —
|
||||
* `GET /api/v1/team/employees/{id}/history`.
|
||||
*/
|
||||
|
||||
import { teamErrorMessage, useEmployeeHistory } from "@/lib/team-api";
|
||||
import type { Employee } from "@/lib/team-api";
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleString("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function formatPrice(value: number | null): string {
|
||||
if (value === null) return "—";
|
||||
return `${value.toLocaleString("ru-RU")} ₽`;
|
||||
}
|
||||
|
||||
interface EmployeeHistoryDrawerProps {
|
||||
employee: Employee;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function EmployeeHistoryDrawer({ employee, onClose }: EmployeeHistoryDrawerProps) {
|
||||
const historyQ = useEmployeeHistory(employee.id, 50, 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="team-drawer-backdrop" role="presentation" onClick={onClose} />
|
||||
<aside
|
||||
className="team-drawer"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`История оценок сотрудника ${employee.username}`}
|
||||
>
|
||||
<div className="team-drawer-header">
|
||||
<div>
|
||||
<h2>История оценок</h2>
|
||||
<p className="scraper-hint" style={{ margin: 0 }}>
|
||||
{employee.display_name ?? employee.username}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="team-drawer-close"
|
||||
onClick={onClose}
|
||||
aria-label="Закрыть"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{historyQ.isPending ? <p className="scraper-hint">Загрузка…</p> : null}
|
||||
{historyQ.isError ? (
|
||||
<p className="scraper-result scraper-result--error">
|
||||
{teamErrorMessage(historyQ.error)}
|
||||
</p>
|
||||
) : null}
|
||||
{historyQ.data && historyQ.data.length === 0 ? (
|
||||
<p className="scraper-hint">У сотрудника пока нет оценок.</p>
|
||||
) : null}
|
||||
|
||||
{historyQ.data && historyQ.data.length > 0 ? (
|
||||
<div>
|
||||
{historyQ.data.map((entry, i) => (
|
||||
<div className="team-history-row" key={entry.estimate_id ?? i}>
|
||||
<p className="team-history-address">{entry.address ?? "Адрес не указан"}</p>
|
||||
<p className="team-history-meta">
|
||||
{formatDateTime(entry.created_at)}
|
||||
{entry.rooms ? ` · ${entry.rooms}к` : ""}
|
||||
{entry.area_m2 ? ` · ${entry.area_m2} м²` : ""}
|
||||
{entry.median_price !== null ? ` · ${formatPrice(entry.median_price)}` : ""}
|
||||
{entry.confidence ? ` · точность: ${entry.confidence}` : ""}
|
||||
{entry.n_analogs !== null ? ` · аналогов: ${entry.n_analogs}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
222
tradein-mvp/frontend/src/components/team/EmployeeTable.tsx
Normal file
222
tradein-mvp/frontend/src/components/team/EmployeeTable.tsx
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Таблица сотрудников для team-дашборда менеджера (#2556, эпик #2549).
|
||||
*
|
||||
* Блокировка/разблокировка — прямое действие в строке (один PATCH-запрос
|
||||
* с `is_active`), с `window.confirm` перед деструктивным изменением (спек
|
||||
* DoD: «действия деструктивные — с подтверждением»). Квота + сброс пароля
|
||||
* вынесены в отдельный модал (`QuotaEditor`) — они бьют в тот же PATCH
|
||||
* эндпоинт одним запросом, см. `app/api/v1/team.py::update_employee`.
|
||||
*/
|
||||
|
||||
import { Fragment, useState } from "react";
|
||||
|
||||
import type { Employee } from "@/lib/team-api";
|
||||
import { teamErrorMessage, useUpdateEmployee } from "@/lib/team-api";
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Винительный падеж для confirm-текста: список admin'а содержит и менеджеров
|
||||
* (см. `Employee.role`), а «Заблокировать сотрудника «praktika»?» про владельца
|
||||
* организации — прямая дезинформация в самом опасном диалоге дашборда.
|
||||
*/
|
||||
function roleAccusative(role: Employee["role"]): string {
|
||||
return role === "manager" ? "менеджера" : "сотрудника";
|
||||
}
|
||||
|
||||
function QuotaCell({ quota }: { quota: Employee["quota"] }) {
|
||||
if (quota.unlimited) {
|
||||
return <span className="team-quota-unlimited">без лимита</span>;
|
||||
}
|
||||
const low = quota.remaining <= 0;
|
||||
return (
|
||||
<span className={low ? "team-quota-low" : undefined}>
|
||||
{quota.used}/{quota.limit}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface EmployeeTableProps {
|
||||
employees: Employee[];
|
||||
onEditQuota: (employee: Employee) => void;
|
||||
onViewHistory: (employee: Employee) => void;
|
||||
limit: number;
|
||||
offset: number;
|
||||
onOffsetChange: (offset: number) => void;
|
||||
hasNextPage: boolean;
|
||||
}
|
||||
|
||||
export function EmployeeTable({
|
||||
employees,
|
||||
onEditQuota,
|
||||
onViewHistory,
|
||||
limit,
|
||||
offset,
|
||||
onOffsetChange,
|
||||
hasNextPage,
|
||||
}: EmployeeTableProps) {
|
||||
const updateMutation = useUpdateEmployee();
|
||||
const [pendingId, setPendingId] = useState<number | null>(null);
|
||||
const [rowError, setRowError] = useState<{ id: number; message: string } | null>(null);
|
||||
|
||||
function handleToggleActive(employee: Employee) {
|
||||
const nextActive = !employee.is_active;
|
||||
const who = roleAccusative(employee.role);
|
||||
const confirmed = window.confirm(
|
||||
nextActive
|
||||
? `Разблокировать ${who} «${employee.username}»?`
|
||||
: `Заблокировать ${who} «${employee.username}»? Все его текущие сессии будут завершены.`,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
setRowError(null);
|
||||
setPendingId(employee.id);
|
||||
updateMutation.mutate(
|
||||
{ employeeId: employee.id, patch: { is_active: nextActive } },
|
||||
{
|
||||
onSettled: () => setPendingId(null),
|
||||
onError: (error) => setRowError({ id: employee.id, message: teamErrorMessage(error) }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const isEmpty = employees.length === 0;
|
||||
// Пустая ПЕРВАЯ страница = сотрудников вообще нет (предлагаем создать).
|
||||
// Пустая страница при offset>0 = перешли за границу списка (напр. ровно
|
||||
// 50/100/150 сотрудников и клик «Далее» после последней полной страницы)
|
||||
// — это НЕ «сотрудников нет», нужен путь назад, а не тупик без пейджера.
|
||||
const isEmptyPastEnd = isEmpty && offset > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isEmpty ? (
|
||||
<p className="scraper-hint">
|
||||
{isEmptyPastEnd
|
||||
? "На этой странице пусто — вы перешли за конец списка."
|
||||
: "Сотрудников пока нет. Добавьте первого через «+ Добавить сотрудника»."}
|
||||
</p>
|
||||
) : (
|
||||
<table className="runs-table team-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Логин</th>
|
||||
<th scope="col">Имя</th>
|
||||
<th scope="col">Статус</th>
|
||||
<th scope="col">Квота</th>
|
||||
<th scope="col">Создан</th>
|
||||
<th scope="col">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{employees.map((employee) => (
|
||||
<Fragment key={employee.id}>
|
||||
<tr>
|
||||
<td>
|
||||
{employee.username}
|
||||
{employee.role === "manager" ? (
|
||||
<span className="team-role-badge" title="Менеджер организации">
|
||||
менеджер
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="run-muted">
|
||||
{employee.display_name ?? "—"}
|
||||
{employee.org_name ? ` · ${employee.org_name}` : ""}
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={
|
||||
employee.is_active
|
||||
? "team-status-badge team-status-badge--active"
|
||||
: "team-status-badge team-status-badge--blocked"
|
||||
}
|
||||
>
|
||||
{employee.is_active ? "Активен" : "Заблокирован"}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<QuotaCell quota={employee.quota} />
|
||||
</td>
|
||||
<td className="run-muted">{formatDate(employee.created_at)}</td>
|
||||
<td>
|
||||
<div className="team-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="team-action-btn"
|
||||
onClick={() => onViewHistory(employee)}
|
||||
>
|
||||
История
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="team-action-btn"
|
||||
onClick={() => onEditQuota(employee)}
|
||||
>
|
||||
Изменить
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
employee.is_active
|
||||
? "team-action-btn team-action-btn--danger"
|
||||
: "team-action-btn"
|
||||
}
|
||||
disabled={pendingId === employee.id}
|
||||
onClick={() => handleToggleActive(employee)}
|
||||
>
|
||||
{employee.is_active ? "Заблокировать" : "Разблокировать"}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{rowError?.id === employee.id ? (
|
||||
<tr>
|
||||
<td colSpan={6} style={{ padding: "0 10px 8px" }}>
|
||||
<p className="team-form-error" style={{ margin: 0 }}>
|
||||
{rowError.message}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<div className="team-pager">
|
||||
<button
|
||||
type="button"
|
||||
disabled={offset === 0}
|
||||
onClick={() => onOffsetChange(Math.max(0, offset - limit))}
|
||||
>
|
||||
← Назад
|
||||
</button>
|
||||
{!isEmpty ? (
|
||||
<span>
|
||||
{offset + 1}–{offset + employees.length}
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
disabled={isEmpty || !hasNextPage}
|
||||
onClick={() => onOffsetChange(offset + limit)}
|
||||
>
|
||||
Далее →
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
147
tradein-mvp/frontend/src/components/team/QuotaEditor.tsx
Normal file
147
tradein-mvp/frontend/src/components/team/QuotaEditor.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Модалка изменения сотрудника (#2556, эпик #2549) — месячная квота + (опц.)
|
||||
* сброс пароля. Один PATCH-запрос `/api/v1/team/employees/{id}` — backend
|
||||
* принимает `monthly_limit` и `new_password` в одном теле (см.
|
||||
* `app/api/v1/team.py::update_employee`), поэтому оба поля живут в одной
|
||||
* форме вместо двух раздельных round-trip'ов.
|
||||
*
|
||||
* Явная установка `monthly_limit` ВСЕГДА сбрасывает `unlimited=false` на
|
||||
* бэкенде (см. `_upsert_quota_override`) — предупреждаем об этом в тексте,
|
||||
* если у сотрудника сейчас безлимит.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
|
||||
import type { Employee, UpdateEmployeeInput } from "@/lib/team-api";
|
||||
import { teamErrorMessage, useUpdateEmployee } from "@/lib/team-api";
|
||||
|
||||
interface QuotaEditorProps {
|
||||
employee: Employee;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function QuotaEditor({ employee, onClose }: QuotaEditorProps) {
|
||||
const [monthlyLimit, setMonthlyLimit] = useState(
|
||||
employee.quota.unlimited ? "" : String(employee.quota.limit),
|
||||
);
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
// Список admin'а смешанный (сотрудники + менеджеры организаций) — «Изменить
|
||||
// сотрудника» над карточкой владельца организации вводит в заблуждение.
|
||||
const who = employee.role === "manager" ? "менеджера" : "сотрудника";
|
||||
|
||||
const updateMutation = useUpdateEmployee();
|
||||
|
||||
function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
if (updateMutation.isPending) return;
|
||||
|
||||
const patch: UpdateEmployeeInput = {};
|
||||
|
||||
const trimmedLimit = monthlyLimit.trim();
|
||||
if (trimmedLimit !== "") {
|
||||
const parsed = Number(trimmedLimit);
|
||||
if (Number.isFinite(parsed) && parsed >= 1) {
|
||||
patch.monthly_limit = Math.trunc(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
if (newPassword.length > 0) {
|
||||
patch.new_password = newPassword;
|
||||
}
|
||||
|
||||
if (Object.keys(patch).length === 0) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
updateMutation.mutate(
|
||||
{ employeeId: employee.id, patch },
|
||||
{ onSuccess: onClose },
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="team-modal-backdrop" role="presentation" onClick={onClose}>
|
||||
<form
|
||||
className="team-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Изменить ${who} ${employee.username}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<h2>Изменить {who}</h2>
|
||||
<p className="team-modal-subtitle">{employee.username}</p>
|
||||
|
||||
{updateMutation.isError ? (
|
||||
<p role="alert" className="team-form-error">
|
||||
{teamErrorMessage(updateMutation.error)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="team-field">
|
||||
<label htmlFor="team-quota-limit">Месячный лимит оценок</label>
|
||||
<input
|
||||
id="team-quota-limit"
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={monthlyLimit}
|
||||
onChange={(e) => setMonthlyLimit(e.target.value)}
|
||||
disabled={updateMutation.isPending}
|
||||
/>
|
||||
<p className="team-field-hint">
|
||||
Сейчас использовано {employee.quota.used}
|
||||
{employee.quota.unlimited
|
||||
? " · без лимита"
|
||||
: ` из ${employee.quota.limit} (осталось ${employee.quota.remaining})`}
|
||||
{employee.quota.unlimited
|
||||
? ". Заполнение поля снимет безлимитный статус."
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="team-field team-password-row">
|
||||
<label htmlFor="team-quota-password">Новый пароль (необязательно)</label>
|
||||
<input
|
||||
id="team-quota-password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="new-password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
disabled={updateMutation.isPending}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="team-password-toggle"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? "Скрыть" : "Показать"}
|
||||
</button>
|
||||
<p className="team-field-hint">
|
||||
Смена пароля завершает все текущие сессии {who}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="team-modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="team-btn-secondary"
|
||||
onClick={onClose}
|
||||
disabled={updateMutation.isPending}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" className="team-btn-primary" disabled={updateMutation.isPending}>
|
||||
{updateMutation.isPending ? "Сохраняем…" : "Сохранить"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import { API_BASE_URL, HTTPError } from "@/lib/api";
|
|||
import { isPathAllowed } from "@/lib/isPathAllowed";
|
||||
import { safeUrl } from "@/lib/safeUrl";
|
||||
import { useBrand } from "@/lib/useBrand";
|
||||
import type { Role } from "@/lib/useMe";
|
||||
import { useMe } from "@/lib/useMe";
|
||||
|
||||
/** Telegram-канал обратной связи для пилота. Build-time env — пусто до тех
|
||||
|
|
@ -84,7 +85,8 @@ export type ActiveTab =
|
|||
| "cian"
|
||||
| "yandex"
|
||||
| "audit"
|
||||
| "analytics";
|
||||
| "analytics"
|
||||
| "team";
|
||||
|
||||
interface TopbarProps {
|
||||
active: ActiveTab;
|
||||
|
|
@ -105,15 +107,43 @@ interface TopbarProps {
|
|||
* путь, чтобы pilot их не видел в навигации. Direct URL access на
|
||||
* `/trade-in/scrapers/avito` НЕ блокируется (RouteGuard следует yaml). Если
|
||||
* нужна полная блокировка — добавить `/trade-in/scrapers/**` в pilot.deny.
|
||||
*
|
||||
* Исключение из этого caveat — `sale-share` и `cache` (2026-07-31): для них
|
||||
* заведён ЯВНЫЙ deny (`/trade-in/sale-share/**`, `/trade-in/cache/**` + их API)
|
||||
* в `DB_ROLE_PATHS` (employee/manager) и в `pilot.deny` (auth/roles.yaml).
|
||||
* Т.е. это НЕ scopePath-трюк, как у скрапперов: гейт реальный, а не только
|
||||
* косметический.
|
||||
*
|
||||
* Но точность важнее красивой формулировки — где именно он стоит:
|
||||
* - пункт меню исчезает (фильтр ниже, deny из `/me`);
|
||||
* - страница по прямому URL отдаёт HTTP **200** с HTML (Next.js рендерит
|
||||
* маршрут всегда) — её закрывает КЛИЕНТСКИЙ `RouteGuard` (app/layout.tsx),
|
||||
* рисуя NoAccessScreen вместо контента;
|
||||
* - единственный СЕРВЕРНЫЙ рубеж — API: `/api/v1/buildings/**` и
|
||||
* `/api/v1/trade-in/cache-stats/**` дают 403 из `rbac_guard`.
|
||||
* Данные без API недостижимы, поэтому 200 на HTML безвреден — но не читай это
|
||||
* как «страница блокируется на сервере»: следующий, кто добавит сюда раздел с
|
||||
* SSR-данными, обязан закрывать именно его API, а не только этот список.
|
||||
*/
|
||||
const NAV_ITEMS: Array<{
|
||||
key: ActiveTab;
|
||||
href: string;
|
||||
scopePath: string;
|
||||
label: string;
|
||||
// Доп. gate ПОВЕРХ isPathAllowed — нужен для "team": DB-роль `manager`
|
||||
// получает `/api/v1/team/**` в allowed_paths (см. DB_ROLE_PATHS,
|
||||
// tradein-mvp/backend/app/services/auth_session.py), но legacy-роль
|
||||
// `analyst` (auth/roles.yaml, paths: "/**", БЕЗ явного deny на /api/v1/team)
|
||||
// тоже прошла бы обычный isPathAllowed-фильтр — analyst не должен видеть
|
||||
// пункт «Команда» (#2556 scope: только admin/manager).
|
||||
roleGate?: (role: Role) => boolean;
|
||||
}> = [
|
||||
{ key: "estimate", href: "/", scopePath: "/trade-in/", label: "Оценка" },
|
||||
// Доля квартир дома в продаже — доступно pilot (scopePath под /trade-in/**).
|
||||
// Доля квартир дома в продаже — ВНУТРЕННИЙ инструмент (аналитика рынка).
|
||||
// Скрыт для employee/manager/pilot явным deny `/trade-in/sale-share/**`
|
||||
// (DB_ROLE_PATHS + auth/roles.yaml), а не scopePath-трюком как у скрапперов:
|
||||
// scopePath остаётся честным путём страницы, фильтр ниже — прежний
|
||||
// isPathAllowed, просто deny побеждает allow `/trade-in/**`.
|
||||
{
|
||||
key: "sale-share",
|
||||
href: "/sale-share",
|
||||
|
|
@ -121,6 +151,8 @@ const NAV_ITEMS: Array<{
|
|||
label: "Доля в продаже",
|
||||
},
|
||||
{ key: "history", href: "/history", scopePath: "/trade-in/history", label: "История" },
|
||||
// Кэш — внутренний инструмент (состояние кэшей/скраперов). Скрыт тем же
|
||||
// способом, что и sale-share выше: явный deny `/trade-in/cache/**`.
|
||||
{ key: "cache", href: "/cache", scopePath: "/trade-in/cache", label: "Кэш" },
|
||||
// Скраперы — admin-only UI. Маппим на admin-deny path, чтобы pilot их не видел.
|
||||
{
|
||||
|
|
@ -162,6 +194,16 @@ const NAV_ITEMS: Array<{
|
|||
scopePath: "/trade-in/api/v1/admin/analytics",
|
||||
label: "Активность",
|
||||
},
|
||||
// Team-дашборд менеджера (#2556, эпик #2549) — CRUD сотрудников/квоты/история.
|
||||
// scopePath БЕЗ /trade-in-префикса — DB_ROLE_PATHS отдаёт голый /api/v1/team/**
|
||||
// (не /trade-in/api/v1/team/**), см. комментарий у roleGate выше.
|
||||
{
|
||||
key: "team",
|
||||
href: "/team",
|
||||
scopePath: "/api/v1/team",
|
||||
label: "Команда",
|
||||
roleGate: (role) => role === "admin" || role === "manager",
|
||||
},
|
||||
];
|
||||
|
||||
export function Topbar({ active }: TopbarProps) {
|
||||
|
|
@ -176,8 +218,10 @@ export function Topbar({ active }: TopbarProps) {
|
|||
const isDev401 = error instanceof HTTPError && error.status === 401;
|
||||
const items =
|
||||
data && !isDev401
|
||||
? NAV_ITEMS.filter((item) =>
|
||||
isPathAllowed(data.allowed_paths, data.deny_paths, item.scopePath),
|
||||
? NAV_ITEMS.filter(
|
||||
(item) =>
|
||||
isPathAllowed(data.allowed_paths, data.deny_paths, item.scopePath) &&
|
||||
(item.roleGate ? item.roleGate(data.role) : true),
|
||||
)
|
||||
: NAV_ITEMS;
|
||||
|
||||
|
|
|
|||
|
|
@ -2820,3 +2820,335 @@ html, body { overflow-x: clip; }
|
|||
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* ── Team dashboard (#2556, эпик #2549) ── */
|
||||
|
||||
.team-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.team-status-badge {
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.team-status-badge--active {
|
||||
background: var(--success-soft, #dcfce7);
|
||||
color: var(--success, #0a7a3a);
|
||||
}
|
||||
|
||||
.team-status-badge--blocked {
|
||||
background: var(--danger-soft, #fee2e2);
|
||||
color: var(--danger, #b3261e);
|
||||
}
|
||||
|
||||
/* Пометка «менеджер» рядом с логином в списке admin'а: список смешанный
|
||||
(сотрудники + менеджеры организаций), без метки строки неразличимы. */
|
||||
.team-role-badge {
|
||||
margin-left: 6px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
background: var(--accent-soft, #e0edff);
|
||||
color: var(--accent, #0d6fd6);
|
||||
}
|
||||
|
||||
.team-quota-unlimited {
|
||||
color: var(--fg-secondary, #5b6066);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.team-quota-low {
|
||||
color: var(--danger, #b3261e);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.team-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.team-action-btn {
|
||||
padding: 4px 10px;
|
||||
background: var(--bg-card-alt, #fafbfc);
|
||||
color: var(--fg-primary, #111111);
|
||||
border: 1px solid var(--border-strong, #d1d5db);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.team-action-btn:hover:not(:disabled) {
|
||||
background: var(--accent-soft, #dbeafe);
|
||||
border-color: var(--accent, #1d4ed8);
|
||||
}
|
||||
|
||||
.team-action-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.team-action-btn--danger {
|
||||
color: var(--danger, #b3261e);
|
||||
border-color: var(--danger, #b3261e);
|
||||
}
|
||||
|
||||
.team-action-btn--danger:hover:not(:disabled) {
|
||||
background: var(--danger-soft, #fee2e2);
|
||||
}
|
||||
|
||||
.team-pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--fg-secondary, #5b6066);
|
||||
}
|
||||
|
||||
.team-pager button {
|
||||
padding: 6px 12px;
|
||||
background: var(--bg-card, #ffffff);
|
||||
border: 1px solid var(--border-strong, #d1d5db);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.team-pager button:hover:not(:disabled) {
|
||||
background: var(--accent-soft, #dbeafe);
|
||||
border-color: var(--accent, #1d4ed8);
|
||||
}
|
||||
|
||||
.team-pager button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Modal (CreateEmployeeForm / QuotaEditor) — box-shadow допустим для
|
||||
modals/popovers per .claude/rules/ui-tokens.md. */
|
||||
.team-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 48px 16px;
|
||||
z-index: 100;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.team-modal {
|
||||
background: var(--bg-card, #ffffff);
|
||||
border: 1px solid var(--border-card, #e6e8ec);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.2);
|
||||
padding: 24px;
|
||||
max-width: 440px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.team-modal h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 4px;
|
||||
color: var(--fg-primary, #111111);
|
||||
}
|
||||
|
||||
.team-modal p.team-modal-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--fg-secondary, #5b6066);
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
.team-field {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.team-field label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--fg-secondary, #5b6066);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.team-field input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
height: 38px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--border-card, #e6e8ec);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--fg-primary, #111111);
|
||||
background: var(--bg-card, #ffffff);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.team-field input:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--accent, #1d4ed8);
|
||||
box-shadow: 0 0 0 2px var(--accent-soft, #dbeafe);
|
||||
}
|
||||
|
||||
.team-field-hint {
|
||||
margin: 6px 0 0;
|
||||
font-size: 11px;
|
||||
color: var(--fg-tertiary, #73767e);
|
||||
}
|
||||
|
||||
.team-password-row {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.team-password-toggle {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 30px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--fg-secondary, #5b6066);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.team-form-error {
|
||||
margin: 0 0 16px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--danger-soft, #fee2e2);
|
||||
color: var(--danger, #b3261e);
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.team-form-success {
|
||||
margin: 0 0 16px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--success-soft, #dcfce7);
|
||||
color: var(--success, #0a7a3a);
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.team-modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.team-btn-primary {
|
||||
padding: 9px 16px;
|
||||
background: var(--accent, #1d4ed8);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.team-btn-primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover, #1e40af);
|
||||
}
|
||||
|
||||
.team-btn-primary:disabled {
|
||||
background: var(--border-strong, #d1d5db);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.team-btn-secondary {
|
||||
padding: 9px 16px;
|
||||
background: var(--bg-card, #ffffff);
|
||||
color: var(--fg-primary, #111111);
|
||||
border: 1px solid var(--border-strong, #d1d5db);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.team-btn-secondary:hover:not(:disabled) {
|
||||
background: var(--bg-card-alt, #fafbfc);
|
||||
}
|
||||
|
||||
/* Drawer (EmployeeHistoryDrawer) */
|
||||
.team-drawer-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.35);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.team-drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: min(480px, 100vw);
|
||||
background: var(--bg-card, #ffffff);
|
||||
border-left: 1px solid var(--border-card, #e6e8ec);
|
||||
box-shadow: -16px 0 40px rgba(15, 23, 42, 0.15);
|
||||
z-index: 101;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.team-drawer-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.team-drawer-header h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 4px;
|
||||
color: var(--fg-primary, #111111);
|
||||
}
|
||||
|
||||
.team-drawer-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
color: var(--fg-secondary, #5b6066);
|
||||
cursor: pointer;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.team-history-row {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border-soft, #eef0f3);
|
||||
}
|
||||
|
||||
.team-history-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.team-history-address {
|
||||
font-size: 13px;
|
||||
color: var(--fg-primary, #111111);
|
||||
margin: 0 0 2px;
|
||||
}
|
||||
|
||||
.team-history-meta {
|
||||
font-size: 11px;
|
||||
color: var(--fg-secondary, #5b6066);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,12 @@ import {
|
|||
type MapMarker,
|
||||
} from "./mappers";
|
||||
import { useGeocodeSuggest } from "@/lib/trade-in-api";
|
||||
import {
|
||||
CITY_LABELS,
|
||||
DEFAULT_CITY,
|
||||
UNCONFIRMED_CITY_LABEL,
|
||||
detectCityInText,
|
||||
} from "@/lib/city-registry";
|
||||
import type {
|
||||
GeocodeSuggestion,
|
||||
HouseType,
|
||||
|
|
@ -45,7 +51,7 @@ import type {
|
|||
TradeInEstimateInput,
|
||||
} from "@/types/trade-in";
|
||||
|
||||
type DdKey = "rooms" | "houseType" | "repair" | "radius" | null;
|
||||
type DdKey = "rooms" | "houseType" | "repair" | "radius" | "city" | null;
|
||||
|
||||
// Russian plural picker (one / few / many) for SR announcements.
|
||||
function pluralRu(n: number, one: string, few: string, many: string): string {
|
||||
|
|
@ -523,6 +529,18 @@ const errorText: CSSProperties = {
|
|||
color: tokens.danger,
|
||||
};
|
||||
|
||||
// #2576 — honest, calm (не danger) heads-up когда бэкенд вернул
|
||||
// target_city_ambiguous: true: город определил геокодер, не пользователь.
|
||||
// Тот же тон, что и другие честные caption'ы в этой панели (не паника, не
|
||||
// ошибка формы — просто прозрачность + предложение уточнить).
|
||||
const cityAmbiguousText: CSSProperties = {
|
||||
marginTop: 4,
|
||||
fontSize: 10.5,
|
||||
letterSpacing: 0.3,
|
||||
lineHeight: 1.4,
|
||||
color: tokens.warn,
|
||||
};
|
||||
|
||||
// РАДИУС dropdown panel — mirrors the <Dd> HUD panel (surface.w98 + soft blue
|
||||
// shadow), sized to the narrow radius trigger and dropped just beneath it.
|
||||
const radiusPanel: CSSProperties = {
|
||||
|
|
@ -551,6 +569,12 @@ interface ParamsPanelProps {
|
|||
error?: string | null;
|
||||
/** Prefill for restore-by-id (?id=) — maps API enums back to RU dropdown labels. */
|
||||
initialValues?: Partial<TradeInEstimateInput>;
|
||||
/**
|
||||
* #2576: true когда последняя оценка вернулась с `target_city_ambiguous` —
|
||||
* геокодер сам угадал город (пользователь его не назвал явно). Рисует
|
||||
* честную некритичную подсказку у поля адреса, не блокирует форму.
|
||||
*/
|
||||
cityAmbiguous?: boolean;
|
||||
/** Analog price pins, projected from the real estimate via mapMarkers() onto
|
||||
* the OLD decorative SVG's fixed 0-100% grid (never a real geo scale — see
|
||||
* mapMarkers() comment in ./mappers.ts). Kept in the prop contract for
|
||||
|
|
@ -578,6 +602,31 @@ function initRepairLabel(rs: RepairState | undefined): string {
|
|||
return rs ? REPAIR_RU[rs] : "Не указано";
|
||||
}
|
||||
|
||||
// Город формы (#2576) — ДВЕ раздельных величины, не одна:
|
||||
// - `city` (initCityLabel) — best-guess ДЛЯ ОТОБРАЖЕНИЯ/подстановки после
|
||||
// подтверждения. Restore-by-id: detect из сохранённого адреса (иначе сброс
|
||||
// на Екатеринбург молча увёл бы восстановленный отчёт другого города назад
|
||||
// на ЕКБ-подсказки); новая форма без initialValues → DEFAULT_CITY.
|
||||
// - `cityConfirmed` (initCityConfirmed, ниже) — ГЕЙТ на отправку city_hint.
|
||||
// Deep-review R2 (#2576): слать `city` в city_hint ВСЕГДА (независимо от того,
|
||||
// подтверждён он или нет) обнуляло весь смысл фичи — бэкенд трактует любой
|
||||
// city_hint как «пользователь назвал город», так что необнаруженный житель
|
||||
// Нижнего Тагила (дропдаун не тронут, город не упомянут в тексте) молча
|
||||
// резолвился бы в Екатеринбург, а target_city_ambiguous был бы false
|
||||
// практически всегда. `city` НИКОГДА не отправляется сам по себе — только
|
||||
// вместе с cityConfirmed=true (см. handleSubmit / useGeocodeSuggest ниже).
|
||||
function initCityLabel(address: string | undefined): string {
|
||||
return detectCityInText(address) ?? DEFAULT_CITY.label;
|
||||
}
|
||||
|
||||
// true ⇔ город реально известен (restore: обнаружен в сохранённом адресе).
|
||||
// false для свежей формы — «Екатеринбург» в `city` в этот момент ТОЛЬКО
|
||||
// внутренний best-guess (для честного текста плашки), НЕ подтверждённое
|
||||
// значение; дропдаун показывает UNCONFIRMED_CITY_LABEL, а не "Екатеринбург".
|
||||
function initCityConfirmed(address: string | undefined): boolean {
|
||||
return detectCityInText(address) != null;
|
||||
}
|
||||
|
||||
// РАДИУС options. "Авто" (default) sends no radius_m → the backend keeps its
|
||||
// two-tier default (1000 m primary / 2000 m fallback). A fixed value overrides
|
||||
// both ("ищем строго в пределах X м"). Design dropdown was values-only.
|
||||
|
|
@ -603,6 +652,7 @@ export default function ParamsPanel({
|
|||
hasEstimate = false,
|
||||
error = null,
|
||||
initialValues,
|
||||
cityAmbiguous = false,
|
||||
// markers intentionally not destructured — see the ParamsPanelProps.markers
|
||||
// doc comment: its %-positions belong to the retired decorative SVG grid and
|
||||
// do not correspond to real lat/lon on the Leaflet map below.
|
||||
|
|
@ -615,6 +665,16 @@ export default function ParamsPanel({
|
|||
const radiusListId = useId();
|
||||
const [radiusActive, setRadiusActive] = useState(-1);
|
||||
const [address, setAddress] = useState(initialValues?.address ?? "");
|
||||
// Город формы (#2576 city_hint) — см. initCityLabel doc comment. Independent
|
||||
// control от адреса: меняется через дропдаун ИЛИ автоопределяется из
|
||||
// набранного/выбранного текста (см. handleAddressChange/pickSuggestion).
|
||||
const [city, setCity] = useState(initCityLabel(initialValues?.address));
|
||||
// Гейт на отправку city_hint (deep-review R2) — см. initCityConfirmed doc
|
||||
// comment. true только после явного выбора в дропдауне или совпадения
|
||||
// detectCityInText; нетронутый дефолт держит его false.
|
||||
const [cityConfirmed, setCityConfirmed] = useState(
|
||||
initCityConfirmed(initialValues?.address),
|
||||
);
|
||||
const [area, setArea] = useState(
|
||||
initialValues?.area_m2 != null ? String(initialValues.area_m2) : "",
|
||||
);
|
||||
|
|
@ -676,7 +736,15 @@ export default function ParamsPanel({
|
|||
// БАЛКОН radiogroup focus targets (roving tabindex).
|
||||
const balNoRef = useRef<HTMLButtonElement>(null);
|
||||
const balYesRef = useRef<HTMLButtonElement>(null);
|
||||
const suggest = useGeocodeSuggest(addressQuery);
|
||||
// #2576 / deep-review R2: подсказки скопированы по городу ТОЛЬКО когда он
|
||||
// подтверждён (cityConfirmed) — иначе suggest() уходит без city_hint, и
|
||||
// бэкенд смещает bias на всю область (OBLAST66_VIEWBOX), а не молча на ЕКБ.
|
||||
// Это и есть исправление: нетронутая форма для «Ленина 1» теперь видит
|
||||
// кандидатов из ВСЕЙ области (в т.ч. Нижний Тагил), а не только ЕКБ.
|
||||
// cityHintForApi в зависимостях useGeocodeSuggest — подтверждение города
|
||||
// обязано рефетчить (city/cityConfirmed оба в queryKey через этот аргумент).
|
||||
const cityHintForApi = cityConfirmed ? city : undefined;
|
||||
const suggest = useGeocodeSuggest(addressQuery, cityHintForApi);
|
||||
|
||||
const handleAddressChange = (v: string) => {
|
||||
setAddress(v);
|
||||
|
|
@ -686,6 +754,18 @@ export default function ParamsPanel({
|
|||
setAddressActive(-1); // a new query invalidates the previous highlight
|
||||
if (fieldErrors.address)
|
||||
setFieldErrors((prev) => ({ ...prev, address: undefined }));
|
||||
// Autodetect (#2576): если пользователь сам печатает город в тексте
|
||||
// адреса ("Нижний Тагил, Ленина 1"), подхватываем его в дропдаун сразу —
|
||||
// не ждём выбора подсказки. Найденное совпадение — реальное подтверждение
|
||||
// (пользователь сам его написал), поэтому это ЕДИНСТВЕННОЕ место, где
|
||||
// cityConfirmed включается без явного клика по дропдауну. Отсутствие
|
||||
// совпадения НЕ трогает city/cityConfirmed — предыдущее подтверждение
|
||||
// (если было) остаётся в силе при мелкой правке текста.
|
||||
const detected = detectCityInText(v);
|
||||
if (detected) {
|
||||
setCity(detected);
|
||||
setCityConfirmed(true);
|
||||
}
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => setAddressQuery(v), 200);
|
||||
};
|
||||
|
|
@ -697,6 +777,30 @@ export default function ParamsPanel({
|
|||
setFias(s.fias_id ?? null);
|
||||
setSuggestOpen(false);
|
||||
setAddressActive(-1);
|
||||
// Autodetect (#2576): выбор реального гео-резолвленного адреса — сильный
|
||||
// сигнал подтверждения (даже сильнее, чем просто набранный текст).
|
||||
// full_address обычно содержит город независимо от того, был ли отправлен
|
||||
// city_hint (провайдер возвращает его как часть резолвленного адреса) —
|
||||
// так первый же pick из НЕподтверждённого поиска (bias на всю область)
|
||||
// подтверждает город без отдельного клика по дропдауну.
|
||||
const detected = detectCityInText(s.full_address);
|
||||
if (detected) {
|
||||
setCity(detected);
|
||||
setCityConfirmed(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Явный выбор города из дропдауна — прямое подтверждение пользователем
|
||||
// (deep-review R2: единственный способ гарантированно включить cityConfirmed
|
||||
// независимо от текста адреса). Сбрасывает ранее выбранные координаты/ФИАС —
|
||||
// тот же текст адреса под другим городом это уже другая точка (зеркалит
|
||||
// handleAddressChange's stale-guard для ручной правки).
|
||||
const selectCity = (v: string) => {
|
||||
setCity(v);
|
||||
setCityConfirmed(true);
|
||||
setOpenDd(null);
|
||||
setCoords(null);
|
||||
setFias(null);
|
||||
};
|
||||
|
||||
// Address combobox keyboard (M6). aria-activedescendant pattern: focus STAYS on
|
||||
|
|
@ -830,6 +934,12 @@ export default function ParamsPanel({
|
|||
lon: coords?.lon ?? null,
|
||||
// Детерминированный ключ дома — только из выбранной подсказки (иначе null).
|
||||
target_fias_id: fias,
|
||||
// #2576 / deep-review R2 — город уходит ТОЛЬКО когда подтверждён
|
||||
// (явный выбор в дропдауне или совпадение в тексте/подсказке); иначе
|
||||
// city_hint не отправляется вовсе (undefined → ключ выпадает из JSON),
|
||||
// и бэкенд честно помечает target_city_ambiguous=true вместо того
|
||||
// чтобы получить непроверенный "Екатеринбург" и промолчать.
|
||||
city_hint: cityConfirmed ? city : undefined,
|
||||
// "Авто" → null → backend keeps its two-tier default (1000 m primary /
|
||||
// 2000 m fallback). A fixed value overrides both. (Не отправлять 500 по
|
||||
// умолчанию — это сузило бы поиск аналогов и убрало fallback.)
|
||||
|
|
@ -1384,12 +1494,45 @@ export default function ParamsPanel({
|
|||
}}
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="pp-address"
|
||||
style={{ ...hintLabel, display: "block" }}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
АДРЕС (YANDEX / NOMINATIM)
|
||||
</label>
|
||||
<label htmlFor="pp-address" style={hintLabel}>
|
||||
АДРЕС (YANDEX / NOMINATIM)
|
||||
</label>
|
||||
{/* Город (#2576) — компактный дропдаун рядом с лейблом адреса, не
|
||||
отдельная строка: не отнимает вертикальное место у и так плотной
|
||||
панели. Explicit pick сразу подтверждает город (city_hint
|
||||
отправляется дальше). До подтверждения показывает
|
||||
UNCONFIRMED_CITY_LABEL, НЕ "Екатеринбург" (deep-review R2) —
|
||||
иначе дропдаун выдавал бы наш внутренний best-guess за
|
||||
подтверждённый пользователем выбор, хотя ничего ещё не
|
||||
отправлялось. Для ЕКБ-сценария это не лишний клик: как только
|
||||
пользователь печатает/выбирает адрес, detectCityInText обычно
|
||||
находит город сам (см. handleAddressChange/pickSuggestion) и
|
||||
лейбл сменится на «Екатеринбург» автоматически. Список городов
|
||||
растёт — см. city-registry.ts. */}
|
||||
<div style={{ position: "relative", width: 176, flex: "0 0 auto" }}>
|
||||
<Dd
|
||||
open={openDd === "city"}
|
||||
onToggle={() => toggle("city")}
|
||||
value={cityConfirmed ? city : UNCONFIRMED_CITY_LABEL}
|
||||
options={CITY_LABELS}
|
||||
onSelect={selectCity}
|
||||
mono={false}
|
||||
triggerFontSize={11}
|
||||
optionFontSize={11.5}
|
||||
triggerHeight={22}
|
||||
variant="dashed"
|
||||
ariaLabel="Город"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ position: "relative" }}>
|
||||
{/* Address autocomplete (geocode suggest, ЕКБ viewbox). Typed text
|
||||
is debounced into `addressQuery`; the dropdown below mirrors the
|
||||
|
|
@ -1508,6 +1651,33 @@ export default function ParamsPanel({
|
|||
{fieldErrors.address}
|
||||
</div>
|
||||
)}
|
||||
{/* #2576 — честная, не блокирующая подсказка: последняя оценка
|
||||
вернулась с target_city_ambiguous, город определил геокодер
|
||||
сам. role="status" (не alert) — это информация, не ошибка.
|
||||
Deep-review R3: текст различает cityConfirmed — если бэкенд
|
||||
не получил city_hint (`cityConfirmed === false`), `city` в
|
||||
этот момент внутренний best-guess ("Екатеринбург"), а НЕ то,
|
||||
что реально нашёл геокодер (в ответе вообще нет угаданного
|
||||
города, только сам булев признак) — называть конкретный город
|
||||
в этом случае было бы той же нечестностью, которую фикс
|
||||
убирал из запроса, только в тексте плашки. */}
|
||||
{cityAmbiguous && !fieldErrors.address && (
|
||||
<div style={cityAmbiguousText} role="status">
|
||||
{cityConfirmed ? (
|
||||
<>
|
||||
Город определён автоматически — результат может
|
||||
относиться к другому населённому пункту области. Если это
|
||||
не {city}, выберите верный город выше и повторите оценку.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Город определён автоматически — результат может
|
||||
относиться к другому населённому пункту области. Если это
|
||||
неверно, выберите город выше и повторите оценку.
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -89,7 +89,6 @@ const SOURCE_COLORS: Record<string, string> = {
|
|||
cian: "#1baf7a",
|
||||
yandex: "#eda100",
|
||||
domklik: "#008300",
|
||||
n1: "#4a3aa7",
|
||||
};
|
||||
const FALLBACK_SOURCE_COLOR = "#6b7280"; // неизвестный/прочий листинговый источник
|
||||
const COLOR_TARGET = "#f59e0b"; // та же семантика, что и MapCard.COLOR_TARGET
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import { tokens } from "./tokens";
|
|||
import { useSupportChat } from "./SupportChatContext";
|
||||
import { SupportChatPanel } from "./SupportChatPanel";
|
||||
import { useSupportUnread } from "@/lib/useSupportChat";
|
||||
import type { SupportScope } from "@/lib/useSupportChat";
|
||||
|
||||
const { accent, accentDeep, onAccent, surface, font, danger } = tokens;
|
||||
|
||||
|
|
@ -45,7 +46,16 @@ const styles = `
|
|||
}
|
||||
`;
|
||||
|
||||
export function SupportButton() {
|
||||
interface SupportButtonProps {
|
||||
/**
|
||||
* "anon" — экран входа / «доступа нет», где идентити нет и быть не может.
|
||||
* Дефолт "auth" — все существующие места монтирования (v2 layout) не меняются.
|
||||
* См. `SupportScope` в `@/lib/useSupportChat`.
|
||||
*/
|
||||
scope?: SupportScope;
|
||||
}
|
||||
|
||||
export function SupportButton({ scope = "auth" }: SupportButtonProps = {}) {
|
||||
// Portal-mount guard (SSR-safe): `document` only exists after mount
|
||||
// (mirrors MapPicker.tsx:107-108 / BuildingListingsDrawer.tsx:29-30).
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
|
@ -54,7 +64,7 @@ export function SupportButton() {
|
|||
const { open, toggleChat, closeChat } = useSupportChat();
|
||||
// Unread badge only matters while the panel is closed — see
|
||||
// useSupportUnread's docstring for why polling stops entirely once open.
|
||||
const unreadQuery = useSupportUnread(!open);
|
||||
const unreadQuery = useSupportUnread(!open, scope);
|
||||
const unread = unreadQuery.data?.unread ?? 0;
|
||||
|
||||
if (!mounted) return null;
|
||||
|
|
@ -139,7 +149,7 @@ export function SupportButton() {
|
|||
</span>
|
||||
)}
|
||||
</button>
|
||||
<SupportChatPanel open={open} onClose={closeChat} />
|
||||
<SupportChatPanel open={open} onClose={closeChat} scope={scope} />
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -24,11 +24,13 @@ import {
|
|||
useSendSupportMessage,
|
||||
useSupportMessages,
|
||||
} from "@/lib/useSupportChat";
|
||||
import type { SupportMessage } from "@/lib/useSupportChat";
|
||||
import type { SupportMessage, SupportScope } from "@/lib/useSupportChat";
|
||||
|
||||
interface SupportChatPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** См. `SupportScope` — "anon" для экрана входа / «доступа нет». */
|
||||
scope?: SupportScope;
|
||||
}
|
||||
|
||||
const TIME_FORMAT = new Intl.DateTimeFormat("ru-RU", {
|
||||
|
|
@ -54,7 +56,11 @@ const PANEL_STYLES = `
|
|||
.support-chat-close:hover { border-color: ${tokens.accent}; color: ${tokens.accent}; }
|
||||
`;
|
||||
|
||||
export function SupportChatPanel({ open, onClose }: SupportChatPanelProps) {
|
||||
export function SupportChatPanel({
|
||||
open,
|
||||
onClose,
|
||||
scope = "auth",
|
||||
}: SupportChatPanelProps) {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const listEndRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -64,9 +70,9 @@ export function SupportChatPanel({ open, onClose }: SupportChatPanelProps) {
|
|||
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
const messagesQuery = useSupportMessages(open);
|
||||
const sendMessage = useSendSupportMessage();
|
||||
const markRead = useMarkSupportRead();
|
||||
const messagesQuery = useSupportMessages(open, scope);
|
||||
const sendMessage = useSendSupportMessage(scope);
|
||||
const markRead = useMarkSupportRead(scope);
|
||||
|
||||
// Mark the thread read on BOTH the closed->open and open->closed edges (the
|
||||
// effect fires on mount-while-open and its cleanup fires on the reverse
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@
|
|||
import { useState } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import { API_BASE_URL } from "@/lib/api";
|
||||
|
||||
import { tokens } from "./tokens";
|
||||
import { navLabels, version } from "./fixtures";
|
||||
import { useSupportChat } from "./SupportChatContext";
|
||||
|
|
@ -35,6 +37,12 @@ interface TopNavProps {
|
|||
user?: TopNavUser;
|
||||
// Sign out — page wires this to logout(); closes the menu first.
|
||||
onLogout?: () => void;
|
||||
// #2556 (эпик #2549): показывать пункт «Команда» в user-меню — вычисляется
|
||||
// страницей из useMe() (роль admin/manager + isPathAllowed на /api/v1/team,
|
||||
// тот же двойной гейт, что и Topbar.tsx NAV_ITEMS "team"). false/undefined
|
||||
// → пункт не рендерится (роль employee/pilot/analyst/expired или /me ещё
|
||||
// не загрузился).
|
||||
showTeamLink?: boolean;
|
||||
}
|
||||
|
||||
// Neutral fallback when the user prop is absent (loading / unauthenticated).
|
||||
|
|
@ -57,6 +65,24 @@ const menuItemStyle: CSSProperties = {
|
|||
transition: "background .12s",
|
||||
};
|
||||
|
||||
/** Inline Users icon (lucide-react `Users` SVG path, stroke 1.2) — для
|
||||
пункта «Команда» в user-меню (#2556). Тот же inline-SVG паттерн (15x15,
|
||||
tokens.muted), что и остальные иконки этого дропдауна. */
|
||||
function UsersIcon() {
|
||||
return (
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
|
||||
<circle cx="5.3" cy="5" r="2.2" stroke={tokens.muted} strokeWidth="1.2" />
|
||||
<path
|
||||
d="M1 13c0-2.6 1.9-3.9 4.3-3.9S9.6 10.4 9.6 13"
|
||||
stroke={tokens.muted}
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
<path d="M9.3 1.8a2.1 2.1 0 0 1 0 4" stroke={tokens.muted} strokeWidth="1.2" />
|
||||
<path d="M11 9.5c1.9.4 3 1.6 3 3.5" stroke={tokens.muted} strokeWidth="1.2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Профиль / Настройки have no pages yet — render them dimmed and
|
||||
// non-interactive (no hover class, default cursor) so they read as disabled.
|
||||
// «Помощь» used to be in this group too, then linked out to the Telegram
|
||||
|
|
@ -74,6 +100,7 @@ export default function TopNav({
|
|||
reports = 0,
|
||||
user,
|
||||
onLogout,
|
||||
showTeamLink = false,
|
||||
}: TopNavProps) {
|
||||
const [userOpen, setUserOpen] = useState(false);
|
||||
const { openChat } = useSupportChat();
|
||||
|
|
@ -489,6 +516,28 @@ export default function TopNav({
|
|||
Помощь
|
||||
</button>
|
||||
|
||||
{/* «Команда» (#2556, эпик #2549) — team-дашборд (`/team`) отдельный
|
||||
роут, не таб SectionOverlay, поэтому это ссылка в user-меню
|
||||
рядом с «Выйти», а не пункт в tnav-tab-ряду выше: v2-табы —
|
||||
это разделы ОДНОЙ страницы (переключают `active`/onNavigate),
|
||||
а «Команда» уводит на другой URL. Гейт (showTeamLink) считает
|
||||
страница из useMe() — здесь только рендер. */}
|
||||
{showTeamLink && (
|
||||
<a
|
||||
role="menuitem"
|
||||
href={`${API_BASE_URL}/team`}
|
||||
className="tnav-menuitem"
|
||||
style={{
|
||||
...menuItemStyle,
|
||||
textDecoration: "none",
|
||||
}}
|
||||
onClick={() => setUserOpen(false)}
|
||||
>
|
||||
<UsersIcon />
|
||||
Команда
|
||||
</a>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
height: "1px",
|
||||
|
|
|
|||
105
tradein-mvp/frontend/src/lib/city-registry.ts
Normal file
105
tradein-mvp/frontend/src/lib/city-registry.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
/**
|
||||
* city-registry — единый источник правды по городам Свердловской области,
|
||||
* поддержанным сбором «Меры» (#2576 этап B: расширение на область).
|
||||
*
|
||||
* Раньше геокодер молча подставлял "Екатеринбург, " в любой запрос без явного
|
||||
* города в тексте адреса — житель Нижнего Тагила, вводя «Ленина, 1», получал
|
||||
* уверенно неверную цену по одноимённой екатеринбургской улице (backend fix
|
||||
* #2576, PR #2580: `city_hint` query-параметр + `TradeInEstimateInput.city_hint`
|
||||
* + честный `AggregatedEstimate.target_city_ambiguous`).
|
||||
*
|
||||
* ВАЖНО (deep-review R2 на #2576 UI-часть): `city_hint` — сигнал «пользователь
|
||||
* НАЗВАЛ город», а не «форма его подставила». Если слать дефолт всегда, бэкенд
|
||||
* трактует ЛЮБОЙ запрос как «город указан» → `target_city_ambiguous` перестаёт
|
||||
* быть честным, а необнаруженный Нижний Тагил (дропдаун не тронут, город не
|
||||
* упомянут в тексте) молча резолвится как Екатеринбург — ровно баг, который
|
||||
* чинил backend. Поэтому вызывающий код (ParamsPanel.tsx) обязан слать
|
||||
* `city_hint` ТОЛЬКО когда город реально известен (явный выбор в дропдауне ИЛИ
|
||||
* `detectCityInText` нашёл совпадение) — см. `cityConfirmed` в ParamsPanel.tsx.
|
||||
* Нетронутый дефолт → город НЕ отправляется.
|
||||
*
|
||||
* Список городов растёт по мере расширения покрытия скрейперами — не хардкодь
|
||||
* города россыпью по компонентам, добавляй сюда (мирроит паттерн
|
||||
* source-registry.ts). Parity-риск (deep-review R2, дёшево): эти 6 лейблов
|
||||
* должны оставаться подмножеством `SVERDLOVSK_OBLAST_CITIES`
|
||||
* (tradein-mvp/backend/app/services/geocoder.py) — та же кириллица в нижнем
|
||||
* регистре. Расхождение не ломается типами (`city_hint` — свободный текст,
|
||||
* бэкенд его не валидирует по списку), но подсунет город без реального
|
||||
* покрытия скрейпером. Перед добавлением 7-го города — сверить оба списка
|
||||
* вручную (codegen/тест на это пока нет, backend — другой PR).
|
||||
*/
|
||||
|
||||
export interface OblastCity {
|
||||
/** Стабильный id (React key / будущая аналитика) — НЕ отправляется бэкенду. */
|
||||
readonly id: string;
|
||||
/**
|
||||
* Человекочитаемое имя — ровно то, что отправляется бэкенду как `city_hint`
|
||||
* (backend/app/services/geocoder.py::_resolve_city_for_geocode принимает
|
||||
* произвольный текст города, не enum/slug).
|
||||
*/
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
// ЕКБ первым — это DEFAULT_CITY (см. ниже) и основной трафик; порядок
|
||||
// остальных — по охвату сбора, не алфавитный.
|
||||
export const OBLAST_CITIES: readonly OblastCity[] = [
|
||||
{ id: "ekaterinburg", label: "Екатеринбург" },
|
||||
{ id: "nizhny_tagil", label: "Нижний Тагил" },
|
||||
{ id: "kamensk_uralsky", label: "Каменск-Уральский" },
|
||||
{ id: "pervouralsk", label: "Первоуральск" },
|
||||
{ id: "verkhnyaya_pyshma", label: "Верхняя Пышма" },
|
||||
{ id: "serov", label: "Серов" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Дефолт формы — Екатеринбург (визуальный/best-guess, для текста подсказки и
|
||||
* подстановки значения ПОСЛЕ подтверждения). НЕ означает "отправляется по
|
||||
* умолчанию" — см. предупреждение в шапке файла: пока город не подтверждён
|
||||
* (`cityConfirmed` в ParamsPanel.tsx), в запрос ничего не уходит.
|
||||
*/
|
||||
export const DEFAULT_CITY: OblastCity = OBLAST_CITIES[0];
|
||||
|
||||
/**
|
||||
* Лейбл дропдауна ДО подтверждения города (свежая форма, город ещё не
|
||||
* выбран/определён). Честнее статичного "Екатеринбург": не выдаёт наш
|
||||
* best-guess за подтверждённый выбор пользователя (deep-review R2 —
|
||||
* `city_hint` не должен отправляться в этом состоянии).
|
||||
*
|
||||
* Fix (эпик #2576, живая проверка прода после #2584): дропдаун-триггер в
|
||||
* ParamsPanel.tsx — фиксированные 176×22px, font-size 11px. Прежний текст
|
||||
* "Определить автоматически" (~146px в Manrope 400 — измерено
|
||||
* opentype.js-сравнением с рендером на проде) не влезал в однострочный
|
||||
* бюджет ~143px (176 - padding 2×13 - место под "▼"), переносился на вторую
|
||||
* строку и обрезался высотой триггера. "Автоопределение" (~96px) укладывается
|
||||
* с большим запасом, сохраняя смысл — сравни с "Авто" у РАДИУС АНАЛИЗА
|
||||
* (тот же проектный паттерн для автоматического режима), но без риска
|
||||
* двусмысленности рядом с названиями городов. Самое длинное название города
|
||||
* в CITY_LABELS, "Каменск-Уральский" (~108px), укладывается в тот же бюджет
|
||||
* без переноса — проверено тем же замером, отдельного фикса не требует.
|
||||
*/
|
||||
export const UNCONFIRMED_CITY_LABEL = "Автоопределение";
|
||||
|
||||
/** RU-лейблы для дропдауна — тот же формат (string[]), что dropdownOptions.* в v2/fixtures.ts. */
|
||||
export const CITY_LABELS: string[] = OBLAST_CITIES.map((c) => c.label);
|
||||
|
||||
/**
|
||||
* Best-effort поиск известного города области как ЦЕЛОГО токена в
|
||||
* произвольном тексте (набранный адрес / `full_address` выбранной подсказки)
|
||||
* — автоопределение города наравне с явным выбором из дропдауна.
|
||||
*
|
||||
* Токен обязан быть delimited началом/концом строки, запятой или пробелом —
|
||||
* иначе "Серов" ложно сматчился бы внутри "ул. Серова" (реальная
|
||||
* екатеринбургская улица, названная в честь лётчика Серова) и увёл бы выбор
|
||||
* города от Екатеринбурга на Серов. JS `\b` не работает вокруг кириллицы
|
||||
* (только ASCII `\w`), поэтому границы токена — явные символьные классы, как
|
||||
* в v2/mappers.ts::STREET_RE.
|
||||
*/
|
||||
export function detectCityInText(text: string | null | undefined): string | undefined {
|
||||
if (!text) return undefined;
|
||||
return OBLAST_CITIES.find((c) => cityTokenRe(c.label).test(text))?.label;
|
||||
}
|
||||
|
||||
function cityTokenRe(label: string): RegExp {
|
||||
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return new RegExp(`(^|[,\\s])${escaped}([,\\s]|$)`, "i");
|
||||
}
|
||||
202
tradein-mvp/frontend/src/lib/team-api.ts
Normal file
202
tradein-mvp/frontend/src/lib/team-api.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Team-management API client (#2556, эпик #2549) — CRUD сотрудников, квоты,
|
||||
* история. Mirrors `app/api/v1/team.py` + `app/schemas/team.py` (tradein-mvp
|
||||
* backend, PR #2554/#2563). Hand-written types — tradein-mvp/frontend has no
|
||||
* `codegen` script (см. `src/lib/admin-audit-api.ts` для того же паттерна).
|
||||
*
|
||||
* Auth: team-роуты требуют session-cookie (`current_team_actor` в team.py —
|
||||
* читает ТОЛЬКО cookie, не legacy X-Authenticated-User). `apiFetch` не задаёт
|
||||
* `credentials` явно — запросы same-origin (basePath `/trade-in` за тем же
|
||||
* Caddy), браузер по умолчанию шлёт cookie на same-origin fetch.
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch, HTTPError } from "./api";
|
||||
|
||||
const BASE = "/api/v1/team";
|
||||
|
||||
// Тот же regex что и `app.schemas.team._USERNAME_RE` — клиентская валидация
|
||||
// зеркалит серверную, чтобы показывать понятную подсказку ДО round-trip.
|
||||
export const USERNAME_PATTERN = /^[A-Za-z0-9._-]{3,64}$/;
|
||||
export const USERNAME_HINT =
|
||||
"Латиница, цифры, точка, дефис, подчёркивание; 3–64 символа";
|
||||
|
||||
// ---- Types (mirror app/schemas/team.py) ------------------------------------
|
||||
|
||||
export interface QuotaStatus {
|
||||
limit: number;
|
||||
used: number;
|
||||
remaining: number;
|
||||
unlimited: boolean;
|
||||
}
|
||||
|
||||
export interface Employee {
|
||||
id: number;
|
||||
username: string;
|
||||
/**
|
||||
* Кем является строка в списке. Бэкенд отдаёт менеджеров ТОЛЬКО актору-admin
|
||||
* (`app/api/v1/team.py`, `_LIST_EMPLOYEES_ALL_SQL`) — менеджер в своём списке
|
||||
* видит по-прежнему только `"employee"`. Строк `role: "admin"` этот API не
|
||||
* отдаёт никогда, поэтому в юнионе их нет.
|
||||
*/
|
||||
role: "employee" | "manager";
|
||||
display_name: string | null;
|
||||
org_name: string | null;
|
||||
email: string | null;
|
||||
is_active: boolean;
|
||||
manager_id: number | null;
|
||||
created_at: string;
|
||||
quota: QuotaStatus;
|
||||
}
|
||||
|
||||
export interface EmployeeHistoryEntry {
|
||||
estimate_id: string | null;
|
||||
address: string | null;
|
||||
area_m2: string | null;
|
||||
rooms: string | null;
|
||||
median_price: number | null;
|
||||
confidence: string | null;
|
||||
n_analogs: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreateEmployeeInput {
|
||||
username: string;
|
||||
password: string;
|
||||
display_name?: string;
|
||||
org_name?: string;
|
||||
email?: string;
|
||||
monthly_limit?: number;
|
||||
}
|
||||
|
||||
export interface UpdateEmployeeInput {
|
||||
is_active?: boolean;
|
||||
monthly_limit?: number;
|
||||
display_name?: string;
|
||||
org_name?: string;
|
||||
email?: string;
|
||||
new_password?: string;
|
||||
}
|
||||
|
||||
// ---- Hooks -------------------------------------------------------------------
|
||||
|
||||
const EMPLOYEES_LIST_KEY = ["team", "employees"] as const;
|
||||
|
||||
/**
|
||||
* GET /api/v1/team/employees?limit=&offset=
|
||||
* Backend сам скоупит по роли (manager → только свои, admin → все).
|
||||
*
|
||||
* `enabled` (default true) — вызывающая страница обязана передать `false`
|
||||
* для ролей, которым эндпоинт всё равно ответит 401/403 (employee/analyst/
|
||||
* pilot и т.п.): без этого прямой заход на /team шлёт обречённый round-trip
|
||||
* ДО отрисовки role-gate, который лишний и палит наличие эндпоинта.
|
||||
*/
|
||||
export function useEmployees(limit: number, offset: number, enabled = true) {
|
||||
return useQuery<Employee[]>({
|
||||
queryKey: [...EMPLOYEES_LIST_KEY, limit, offset],
|
||||
queryFn: () =>
|
||||
apiFetch<Employee[]>(`${BASE}/employees?limit=${limit}&offset=${offset}`),
|
||||
enabled,
|
||||
staleTime: 15_000,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/team/employees/{id}/history?limit=&offset=
|
||||
* `enabled: false` пока employeeId === null — ленивая загрузка при открытии drawer.
|
||||
*/
|
||||
export function useEmployeeHistory(
|
||||
employeeId: number | null,
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
) {
|
||||
return useQuery<EmployeeHistoryEntry[]>({
|
||||
queryKey: ["team", "employees", employeeId, "history", limit, offset],
|
||||
queryFn: () =>
|
||||
apiFetch<EmployeeHistoryEntry[]>(
|
||||
`${BASE}/employees/${employeeId}/history?limit=${limit}&offset=${offset}`,
|
||||
),
|
||||
enabled: employeeId !== null,
|
||||
staleTime: 15_000,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** POST /api/v1/team/employees — создать сотрудника. */
|
||||
export function useCreateEmployee() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<Employee, Error, CreateEmployeeInput>({
|
||||
mutationFn: (input) =>
|
||||
apiFetch<Employee>(`${BASE}/employees`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: EMPLOYEES_LIST_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/team/employees/{id} — частичное обновление (блокировка,
|
||||
* квота, профиль, сброс пароля — любая комбинация полей в одном запросе,
|
||||
* зеркалит `EmployeeUpdateRequest`).
|
||||
*/
|
||||
export function useUpdateEmployee() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<
|
||||
Employee,
|
||||
Error,
|
||||
{ employeeId: number; patch: UpdateEmployeeInput }
|
||||
>({
|
||||
mutationFn: ({ employeeId, patch }) =>
|
||||
apiFetch<Employee>(`${BASE}/employees/${employeeId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(patch),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: EMPLOYEES_LIST_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Error mapping -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Достаёт человеко-читаемый текст из FastAPI error body — либо `{detail:
|
||||
* string}` (наши ручные `HTTPException(...)`), либо Pydantic-валидация
|
||||
* `{detail: [{msg: string, ...}]}` (напр. username не прошёл field_validator
|
||||
* на этапе парсинга тела запроса, ДО хендлера).
|
||||
*/
|
||||
function extractDetailMessage(body: unknown): string | null {
|
||||
if (body === null || typeof body !== "object" || !("detail" in body)) {
|
||||
return null;
|
||||
}
|
||||
const detail = (body as { detail?: unknown }).detail;
|
||||
if (typeof detail === "string") return detail;
|
||||
if (Array.isArray(detail) && detail.length > 0) {
|
||||
const first: unknown = detail[0];
|
||||
if (first !== null && typeof first === "object" && "msg" in first) {
|
||||
const msg = (first as { msg?: unknown }).msg;
|
||||
if (typeof msg === "string") return msg;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Русский текст ошибки для team-мутаций/запросов — по коду статуса. */
|
||||
export function teamErrorMessage(error: unknown): string {
|
||||
if (error instanceof HTTPError) {
|
||||
if (error.status === 409) return "Логин уже занят";
|
||||
if (error.status === 403) return "Недостаточно прав";
|
||||
if (error.status === 404) return "Сотрудник не найден";
|
||||
if (error.status === 422) {
|
||||
return extractDetailMessage(error.body) ?? "Проверьте правильность заполнения формы";
|
||||
}
|
||||
}
|
||||
return "Не удалось выполнить запрос. Попробуйте ещё раз";
|
||||
}
|
||||
|
|
@ -257,22 +257,35 @@ export function useEstimateHistory(limit = 50) {
|
|||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/geocode/suggest?q=&limit=
|
||||
* Автокомплит адресов в пределах ЕКБ для поля адреса (ParamsPanel). Debounce-
|
||||
* friendly: вызывающий компонент дебаунсит строку query, хук кешируется по
|
||||
* queryKey; `enabled` срабатывает только начиная с 3 символов (бэкенд min 2,
|
||||
* берём 3 чтобы не дёргать на 1-2 символа). `select` разворачивает обёртку
|
||||
* {items} → GeocodeSuggestion[]; keepPreviousData убирает мерцание списка между
|
||||
* последовательными запросами.
|
||||
* GET /api/v1/geocode/suggest?q=&limit=&city_hint=
|
||||
* Автокомплит адресов в Свердловской области для поля адреса (ParamsPanel).
|
||||
* Debounce-friendly: вызывающий компонент дебаунсит строку query, хук
|
||||
* кешируется по queryKey; `enabled` срабатывает только начиная с 3 символов
|
||||
* (бэкенд min 2, берём 3 чтобы не дёргать на 1-2 символа). `select`
|
||||
* разворачивает обёртку {items} → GeocodeSuggestion[]; keepPreviousData
|
||||
* убирает мерцание списка между последовательными запросами.
|
||||
*
|
||||
* `cityHint` — #2576 (backend PR #2580): без него геокодер больше НЕ
|
||||
* подставляет "Екатеринбург" молча (см. src/lib/city-registry.ts — форма
|
||||
* держит дефолт "Екатеринбург", так что ЕКБ-сценарий не деградирует). Часть
|
||||
* queryKey — переключение города в форме обязано рефетчить подсказки.
|
||||
*/
|
||||
export function useGeocodeSuggest(query: string, limit = 8) {
|
||||
export function useGeocodeSuggest(
|
||||
query: string,
|
||||
cityHint?: string | null,
|
||||
limit = 8,
|
||||
) {
|
||||
const q = query.trim();
|
||||
const hint = (cityHint ?? "").trim();
|
||||
return useQuery<GeocodeSuggestResponse, Error, GeocodeSuggestion[]>({
|
||||
queryKey: ["trade-in", "geocode-suggest", q, limit],
|
||||
queryFn: () =>
|
||||
apiFetch<GeocodeSuggestResponse>(
|
||||
`${GEOCODE_BASE}/suggest?q=${encodeURIComponent(q)}&limit=${limit}`,
|
||||
),
|
||||
queryKey: ["trade-in", "geocode-suggest", q, hint, limit],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams({ q, limit: String(limit) });
|
||||
if (hint) params.set("city_hint", hint);
|
||||
return apiFetch<GeocodeSuggestResponse>(
|
||||
`${GEOCODE_BASE}/suggest?${params.toString()}`,
|
||||
);
|
||||
},
|
||||
select: (r) => r.items,
|
||||
enabled: q.length >= 3,
|
||||
staleTime: 5 * 60_000,
|
||||
|
|
|
|||
40
tradein-mvp/frontend/src/lib/useLogout.ts
Normal file
40
tradein-mvp/frontend/src/lib/useLogout.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* #2555: session-logout — POST /api/v1/auth/logout (revoke DB session +
|
||||
* очистка httponly cookie tradein_session), затем чистим локальный
|
||||
* TanStack Query /me-кэш и уходим на /login.
|
||||
*
|
||||
* NB: это НЕ замена legacy `@/lib/logout.ts` (Caddy basic_auth cache-bust +
|
||||
* hard reload) — тот остаётся для страниц/пользователей на старом
|
||||
* trusted-header механизме (см. `app.core.rbac` dual-mode resolver).
|
||||
* useLogout — для юзеров, залогиненных через новую /login форму (#2552).
|
||||
*
|
||||
* Backend logout — best-effort по духу (revoke конкретной сессии), поэтому
|
||||
* локальный logout (кэш + редирект) выполняется в `onSettled`, а не только
|
||||
* `onSuccess`: сетевой сбой / уже-протухшая сессия не должны запирать юзера
|
||||
* на странице без возможности разлогиниться.
|
||||
*/
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { ME_QUERY_KEY } from "@/lib/useMe";
|
||||
|
||||
async function logoutRequest(): Promise<void> {
|
||||
await apiFetch<{ ok: boolean }>("/api/v1/auth/logout", { method: "POST" });
|
||||
}
|
||||
|
||||
export function useLogout() {
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: logoutRequest,
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ME_QUERY_KEY });
|
||||
router.push("/login");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -14,7 +14,18 @@ import { useQuery } from "@tanstack/react-query";
|
|||
|
||||
import { apiFetchWithStatus, HTTPError } from "@/lib/api";
|
||||
|
||||
export type Role = "admin" | "pilot" | "expired";
|
||||
// #2555: session-auth (POST /api/v1/auth/login) вводит новые роли
|
||||
// admin|manager|employee. Legacy Caddy trusted-header роли (pilot|analyst|
|
||||
// expired) остаются — backend `/api/v1/me` может отдать любую из обеих
|
||||
// групп в зависимости от того, каким механизмом пришёл юзер (dual-mode
|
||||
// resolver, см. `tradein-mvp/backend/app/core/rbac.py`).
|
||||
export type Role =
|
||||
| "admin"
|
||||
| "manager"
|
||||
| "employee"
|
||||
| "pilot"
|
||||
| "analyst"
|
||||
| "expired";
|
||||
|
||||
export interface UserScope {
|
||||
username: string;
|
||||
|
|
|
|||
|
|
@ -39,6 +39,21 @@ import { apiFetch, HTTPError } from "@/lib/api";
|
|||
|
||||
const BASE = "/api/v1/trade-in/support";
|
||||
|
||||
/**
|
||||
* Какой поддержкой пользуемся:
|
||||
* "auth" — тред залогиненного юзера, резолвится сервером из сессии/идентити;
|
||||
* "anon" — тред посетителя БЕЗ входа (экран логина, «доступа нет»), резолвится
|
||||
* из httpOnly-куки, которую ставит бэкенд (`/support/anon/*`).
|
||||
*
|
||||
* Появилось после инцидента 2026-07-31: единственный канал в поддержку был ЗА
|
||||
* логином, а типовая причина писать — «не могу войти».
|
||||
*/
|
||||
export type SupportScope = "auth" | "anon";
|
||||
|
||||
function scopeBase(scope: SupportScope): string {
|
||||
return scope === "anon" ? `${BASE}/anon` : BASE;
|
||||
}
|
||||
|
||||
// Mirrors backend `MAX_MESSAGE_LENGTH` (app/api/v1/support.py) — enforced
|
||||
// client-side too so the send button disables before the round-trip 422/400.
|
||||
export const MAX_SUPPORT_MESSAGE_LENGTH = 4000;
|
||||
|
|
@ -57,8 +72,12 @@ export interface SupportUnread {
|
|||
unread: number;
|
||||
}
|
||||
|
||||
const SUPPORT_MESSAGES_KEY = ["trade-in", "support", "messages"] as const;
|
||||
const SUPPORT_UNREAD_KEY = ["trade-in", "support", "unread"] as const;
|
||||
// Scope — часть ключа: анонимный и авторизованный треды физически разные, общий
|
||||
// ключ склеил бы их в кэше (после логина в панели висела бы переписка анонима).
|
||||
const messagesKey = (scope: SupportScope) =>
|
||||
["trade-in", "support", scope, "messages"] as const;
|
||||
const unreadKey = (scope: SupportScope) =>
|
||||
["trade-in", "support", scope, "unread"] as const;
|
||||
|
||||
const MESSAGES_POLL_MS = 6_000;
|
||||
const UNREAD_POLL_MS = 20_000;
|
||||
|
|
@ -67,10 +86,11 @@ const UNREAD_POLL_MS = 20_000;
|
|||
* Polls the caller's own thread. `enabled` should be the chat-panel `open`
|
||||
* flag — see module docstring.
|
||||
*/
|
||||
export function useSupportMessages(enabled: boolean) {
|
||||
export function useSupportMessages(enabled: boolean, scope: SupportScope = "auth") {
|
||||
return useQuery<SupportMessage[]>({
|
||||
queryKey: SUPPORT_MESSAGES_KEY,
|
||||
queryFn: () => apiFetch<SupportMessage[]>(`${BASE}/messages?since=0`),
|
||||
queryKey: messagesKey(scope),
|
||||
queryFn: () =>
|
||||
apiFetch<SupportMessage[]>(`${scopeBase(scope)}/messages?since=0`),
|
||||
enabled,
|
||||
staleTime: 0,
|
||||
refetchInterval: enabled ? MESSAGES_POLL_MS : false,
|
||||
|
|
@ -81,26 +101,26 @@ export function useSupportMessages(enabled: boolean) {
|
|||
* Feeds the unread badge on the closed floating button. `enabled` should be
|
||||
* `!open` — see module docstring.
|
||||
*/
|
||||
export function useSupportUnread(enabled: boolean) {
|
||||
export function useSupportUnread(enabled: boolean, scope: SupportScope = "auth") {
|
||||
return useQuery<SupportUnread>({
|
||||
queryKey: SUPPORT_UNREAD_KEY,
|
||||
queryFn: () => apiFetch<SupportUnread>(`${BASE}/unread`),
|
||||
queryKey: unreadKey(scope),
|
||||
queryFn: () => apiFetch<SupportUnread>(`${scopeBase(scope)}/unread`),
|
||||
enabled,
|
||||
staleTime: 0,
|
||||
refetchInterval: enabled ? UNREAD_POLL_MS : false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSendSupportMessage() {
|
||||
export function useSendSupportMessage(scope: SupportScope = "auth") {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<SupportMessage, Error, string>({
|
||||
mutationFn: (text) =>
|
||||
apiFetch<SupportMessage>(`${BASE}/messages`, {
|
||||
apiFetch<SupportMessage>(`${scopeBase(scope)}/messages`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ text }),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: SUPPORT_MESSAGES_KEY });
|
||||
queryClient.invalidateQueries({ queryKey: messagesKey(scope) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -111,14 +131,16 @@ export function useSendSupportMessage() {
|
|||
* unread badge never counts messages the visitor demonstrably already saw
|
||||
* while the panel was open.
|
||||
*/
|
||||
export function useMarkSupportRead() {
|
||||
export function useMarkSupportRead(scope: SupportScope = "auth") {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, Error, void>({
|
||||
mutationFn: async () => {
|
||||
await apiFetch<{ status: string }>(`${BASE}/read`, { method: "POST" });
|
||||
await apiFetch<{ status: string }>(`${scopeBase(scope)}/read`, {
|
||||
method: "POST",
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.setQueryData<SupportUnread>(SUPPORT_UNREAD_KEY, { unread: 0 });
|
||||
queryClient.setQueryData<SupportUnread>(unreadKey(scope), { unread: 0 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,6 +75,13 @@ export interface TradeInEstimateInput {
|
|||
// до геокодинга строки. Отправляется ТОЛЬКО когда адрес выбран из подсказки;
|
||||
// сбрасывается в null при ручной правке адреса или сдвиге пина на карте.
|
||||
target_fias_id?: string | null;
|
||||
// #2576 (backend PR #2580): город, выбранный пользователем в форме (см.
|
||||
// src/lib/city-registry.ts) — без него геокодер больше НЕ подставляет
|
||||
// "Екатеринбург" молча (житель другого города области раньше получал
|
||||
// уверенно неверную цену по одноимённой екатеринбургской улице). Дефолт формы
|
||||
// уже "Екатеринбург" (city-registry.ts::DEFAULT_CITY), так что ЕКБ-сценарий
|
||||
// отправляет ровно то, что геокодер раньше подставлял сам.
|
||||
city_hint?: string | null;
|
||||
}
|
||||
|
||||
export interface AnalogLot {
|
||||
|
|
@ -169,6 +176,12 @@ export interface AggregatedEstimate {
|
|||
target_address: string | null;
|
||||
target_lat: number | null;
|
||||
target_lon: number | null;
|
||||
// #2576 (backend PR #2580): true если ни адрес, ни `TradeInEstimateInput
|
||||
// .city_hint` не называли город явно — итоговый город (и, соответственно,
|
||||
// набор аналогов/цена) определил геокодер-провайдер, а не пользователь.
|
||||
// Ephemeral (не персистится в БД, только для текущего ответа) — optional,
|
||||
// т.к. оценки, посчитанные до деплоя бэкенда, поле не содержат.
|
||||
target_city_ambiguous?: boolean;
|
||||
sources_used: string[]; // ['avito', 'cian', 'rosreestr']
|
||||
// #2043 (BE-1): достоверность выборки — реальный коэффициент вариации ₽/м² (std/mean),
|
||||
// счётчики аналогов по источнику, момент создания оценки. Все optional: старые
|
||||
|
|
|
|||
|
|
@ -303,6 +303,7 @@ def save_listings(
|
|||
region_code: int,
|
||||
run_id: int | None = None,
|
||||
skip_seen_today: bool = False,
|
||||
city: str | None = None,
|
||||
) -> tuple[int, int]:
|
||||
"""Пишем list[ScrapedLot] в `listings` с upsert по dedup_hash.
|
||||
|
||||
|
|
@ -324,6 +325,14 @@ def save_listings(
|
|||
Используется full_load для экономии redundant upsert + price-trigger churn
|
||||
при повторном прогоне в тот же день. Новые листинги (prior_row=None) всегда
|
||||
вставляются. False = старое поведение (всегда upsert).
|
||||
city: человекочитаемое имя города-цели ЭТОГО batch'а (#2594), например
|
||||
"Нижний Тагил"/"Екатеринбург". Развёртка знает город из своего контекста
|
||||
(city_slug) — ОДИН на весь вызов save_listings (все lots одного batch'а
|
||||
принадлежат одному city-sweep run'у), поэтому это kwarg, а НЕ поле
|
||||
ScrapedLot. None (default) — вызывающая сторона город не знает (ad-hoc
|
||||
admin/manual пути) — колонка остаётся NULL, backward-compatible.
|
||||
ON CONFLICT — COALESCE (новое значение НЕ затирает уже известный город
|
||||
NULL'ом, если какой-то caller ещё не передаёт city).
|
||||
|
||||
Returns:
|
||||
(inserted, updated) — counters для логов.
|
||||
|
|
@ -380,6 +389,7 @@ def save_listings(
|
|||
"dedup": dedup,
|
||||
"region_code": region_code,
|
||||
"address": lot.address,
|
||||
"city": city,
|
||||
"lat": lot.lat,
|
||||
"lon": lot.lon,
|
||||
"rooms": lot.rooms,
|
||||
|
|
@ -439,7 +449,7 @@ def save_listings(
|
|||
"""
|
||||
INSERT INTO listings (
|
||||
source, source_url, source_id, dedup_hash,
|
||||
address, lat, lon, region_code,
|
||||
address, city, lat, lon, region_code,
|
||||
rooms, area_m2, floor, total_floors, year_built,
|
||||
house_type, repair_state, has_balcony,
|
||||
kitchen_area_m2, ceiling_height, ceiling_height_m,
|
||||
|
|
@ -460,7 +470,7 @@ def save_listings(
|
|||
scraped_at, last_seen_at
|
||||
) VALUES (
|
||||
:source, :source_url, :source_id, :dedup,
|
||||
:address, :lat, :lon, :region_code,
|
||||
:address, :city, :lat, :lon, :region_code,
|
||||
:rooms, :area_m2, :floor, :total_floors, :year_built,
|
||||
:house_type, :repair_state, :has_balcony,
|
||||
-- ceiling: один param :ceiling_height_m пишем в ОБЕ колонки —
|
||||
|
|
@ -516,6 +526,9 @@ def save_listings(
|
|||
metro_stations = EXCLUDED.metro_stations,
|
||||
listing_date = COALESCE(EXCLUDED.listing_date, listings.listing_date),
|
||||
area_m2 = COALESCE(EXCLUDED.area_m2, listings.area_m2),
|
||||
-- #2594: город развёртки — COALESCE, чтобы caller без city (ad-hoc
|
||||
-- admin/manual пути, city=None) не затирал уже известный город.
|
||||
city = COALESCE(EXCLUDED.city, listings.city),
|
||||
-- kitchen/ceiling (#2007): COALESCE — SERP re-scrape источника без
|
||||
-- этих полей (avito SERP → NULL) НЕ затирает detail-enriched значение
|
||||
-- (avito_detail пишет ceiling_height_m отдельным UPDATE).
|
||||
|
|
@ -627,6 +640,7 @@ def save_listings(
|
|||
metro_stations = CAST(:metro_stations AS jsonb),
|
||||
listing_date = COALESCE(:listing_date, listing_date),
|
||||
area_m2 = COALESCE(:area_m2, area_m2),
|
||||
city = COALESCE(:city, city),
|
||||
kitchen_area_m2 = COALESCE(:kitchen_area_m2, kitchen_area_m2),
|
||||
ceiling_height = COALESCE(:ceiling_height_m, ceiling_height),
|
||||
ceiling_height_m = COALESCE(:ceiling_height_m, ceiling_height_m),
|
||||
|
|
|
|||
|
|
@ -343,6 +343,41 @@ def get_city_location(city_slug: str | None) -> CityLocation | None:
|
|||
return CITY_LOCATIONS.get(city_slug)
|
||||
|
||||
|
||||
# Человекочитаемые названия городов области — пишутся в `listings.city` (#2594).
|
||||
# Развёртка ЗНАЕТ город из своего контекста (city_slug), но раньше нигде его не
|
||||
# записывала — листинг терял привязку к городу и адрес без города в тексте
|
||||
# ("ул. Победы, 30" — так отдают и Avito, и Cian, см. providers/cian/serp.py
|
||||
# `_format_address` skip_types={"location",...}) при геокодинге считался «город не
|
||||
# назван» и коллизировал с одноимённой ЕКБ-улицей. Ключи СОВПАДАЮТ с CITY_LOCATIONS/
|
||||
# CITY_ANCHORS; значения — те же формы, что уже есть в geocoder.SVERDLOVSK_OBLAST_CITIES
|
||||
# (lower + word-boundary матчинг там регистронезависим, поэтому регистр здесь не
|
||||
# критичен, но человекочитаемый — для админки/логов/дальнейшего QA).
|
||||
CITY_DISPLAY_NAMES: dict[str, str] = {
|
||||
"nizhniy_tagil": "Нижний Тагил",
|
||||
"kamensk_uralskiy": "Каменск-Уральский",
|
||||
"pervouralsk": "Первоуральск",
|
||||
"verkhnyaya_pyshma": "Верхняя Пышма",
|
||||
"serov": "Серов",
|
||||
}
|
||||
EKATERINBURG_CITY_NAME = "Екатеринбург"
|
||||
|
||||
|
||||
def resolve_city_name(city_slug: str | None) -> str:
|
||||
"""Человекочитаемое имя города для `save_listings(..., city=...)` (#2594).
|
||||
|
||||
city_slug=None → Екатеринбург. Это НЕ заглушка «не знаем» — это симметрия с
|
||||
get_city_location/get_city_anchors (тот же None-путь = ЕКБ-дефолт): EKB-варианты
|
||||
city-sweep функций (run_avito_city_sweep и т.д., вызванные БЕЗ city_slug) реально
|
||||
собирают ЕКБ, поэтому их листинги тоже должны получать city="Екатеринбург" —
|
||||
иначе была бы обратная асимметрия «у области город проставлен, у ЕКБ — нет».
|
||||
Неизвестный slug (не в CITY_DISPLAY_NAMES) — тоже ЕКБ-дефолт, тем же путём, что и
|
||||
get_city_location/get_city_anchors для неизвестных slug'ов.
|
||||
"""
|
||||
if city_slug is None:
|
||||
return EKATERINBURG_CITY_NAME
|
||||
return CITY_DISPLAY_NAMES.get(city_slug, EKATERINBURG_CITY_NAME)
|
||||
|
||||
|
||||
_CHROME_HEADERS = {
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
|
||||
|
|
@ -896,6 +931,9 @@ async def run_avito_city_sweep(
|
|||
# kamensk-uralskiy (дефис) / verhnyaya_pyshma (kh→h) отличаются от нашего city_slug —
|
||||
# вычисляем один раз до цикла anchor'ов, не внутри closure на каждый anchor.
|
||||
_avito_slug = _loc.avito_slug if _loc else city_slug
|
||||
# #2594: город для save_listings(..., city=...) — один на весь sweep (все anchor'ы
|
||||
# одного run'а бьют по одному city_slug), вычисляем один раз до цикла.
|
||||
_city_name = resolve_city_name(city_slug)
|
||||
counters = CitySweepCounters(anchors_total=len(_anchors))
|
||||
all_touched_house_ids: set[int] = set()
|
||||
|
||||
|
|
@ -1035,7 +1073,11 @@ async def run_avito_city_sweep(
|
|||
if anchor_lots:
|
||||
try:
|
||||
ins, upd = save_listings(
|
||||
db, anchor_lots, matcher=matcher, region_code=region_code
|
||||
db,
|
||||
anchor_lots,
|
||||
matcher=matcher,
|
||||
region_code=region_code,
|
||||
city=_city_name,
|
||||
)
|
||||
counters.lots_inserted += ins
|
||||
counters.lots_updated += upd
|
||||
|
|
@ -1662,7 +1704,14 @@ async def run_avito_newbuilding_sweep(
|
|||
counters.lots_fetched += len(lots)
|
||||
if lots:
|
||||
try:
|
||||
ins, upd = save_listings(db, lots, matcher=matcher, region_code=region_code)
|
||||
# #2594: citywide novostroyka-обход — только ЕКБ (см. docstring).
|
||||
ins, upd = save_listings(
|
||||
db,
|
||||
lots,
|
||||
matcher=matcher,
|
||||
region_code=region_code,
|
||||
city=EKATERINBURG_CITY_NAME,
|
||||
)
|
||||
counters.lots_inserted += ins
|
||||
counters.lots_updated += upd
|
||||
except Exception as save_exc:
|
||||
|
|
@ -1782,6 +1831,8 @@ async def run_yandex_city_sweep(
|
|||
# city_slug (#12): rgid города-цели → YandexRealtyScraper.city_rgid скоупит SERP
|
||||
# на город вместо дефолтного ЕКБ. None/неизвестный slug → ЕКБ-дефолт в конструкторе.
|
||||
_loc = get_city_location(city_slug)
|
||||
# #2594: город для save_listings(..., city=...) — один на весь sweep.
|
||||
_city_name = resolve_city_name(city_slug)
|
||||
|
||||
_rooms_list = rooms_list or list(ROOM_PATH.keys())
|
||||
_price_ranges = price_ranges or DEFAULT_PRICE_RANGES
|
||||
|
|
@ -1880,7 +1931,12 @@ async def run_yandex_city_sweep(
|
|||
counters.lots_fetched += len(new_lots)
|
||||
try:
|
||||
ins, upd = save_listings(
|
||||
db, new_lots, matcher=matcher, region_code=region_code, run_id=run_id
|
||||
db,
|
||||
new_lots,
|
||||
matcher=matcher,
|
||||
region_code=region_code,
|
||||
run_id=run_id,
|
||||
city=_city_name,
|
||||
)
|
||||
counters.lots_inserted += ins
|
||||
counters.lots_updated += upd
|
||||
|
|
@ -2288,6 +2344,8 @@ async def run_cian_city_sweep(
|
|||
# city_slug (#12): region_id города-цели → CianScraper.city_region_id скоупит SERP
|
||||
# на город вместо дефолтного ЕКБ. None/неизвестный slug → ЕКБ-дефолт в конструкторе.
|
||||
_loc = get_city_location(city_slug)
|
||||
# #2594: город для save_listings(..., city=...) — один на весь sweep.
|
||||
_city_name = resolve_city_name(city_slug)
|
||||
counters = CianCitySweepCounters(anchors_total=len(_anchors))
|
||||
consecutive_failures = 0
|
||||
cian_rotations_done = 0 # #1848: бюджет IP-ротаций на весь sweep
|
||||
|
|
@ -2383,7 +2441,12 @@ async def run_cian_city_sweep(
|
|||
counters.lots_dropped_secondary += _before - len(anchor_lots)
|
||||
if anchor_lots:
|
||||
inserted, updated = save_listings(
|
||||
db, anchor_lots, matcher=matcher, region_code=region_code, run_id=run_id
|
||||
db,
|
||||
anchor_lots,
|
||||
matcher=matcher,
|
||||
region_code=region_code,
|
||||
run_id=run_id,
|
||||
city=_city_name,
|
||||
)
|
||||
counters.lots_inserted += inserted
|
||||
counters.lots_updated += updated
|
||||
|
|
@ -2785,6 +2848,8 @@ async def run_cian_full_load(
|
|||
region_code=region_code,
|
||||
run_id=run_id,
|
||||
skip_seen_today=config.scraper_skip_seen_today,
|
||||
# #2594: exhaustive региональный сбор — только ЕКБ (см. docstring run_*_full_load).
|
||||
city=EKATERINBURG_CITY_NAME,
|
||||
)
|
||||
# save_listings вызывает db.commit() внутри — данные в БД сразу
|
||||
counters.saved_inserted += inserted
|
||||
|
|
@ -3084,6 +3149,8 @@ async def run_yandex_full_load(
|
|||
region_code=region_code,
|
||||
run_id=run_id,
|
||||
skip_seen_today=config.scraper_skip_seen_today,
|
||||
# #2594: exhaustive региональный сбор — только ЕКБ (см. docstring run_*_full_load).
|
||||
city=EKATERINBURG_CITY_NAME,
|
||||
)
|
||||
# save_listings вызывает db.commit() внутри — данные в БД сразу
|
||||
counters.saved_inserted += inserted
|
||||
|
|
@ -3289,6 +3356,8 @@ async def run_avito_full_load(
|
|||
region_code=region_code,
|
||||
run_id=run_id,
|
||||
skip_seen_today=config.scraper_skip_seen_today,
|
||||
# #2594: exhaustive региональный сбор — только ЕКБ (см. docstring run_*_full_load).
|
||||
city=EKATERINBURG_CITY_NAME,
|
||||
)
|
||||
# save_listings вызывает db.commit() внутри — данные в БД сразу
|
||||
counters.saved_inserted += inserted
|
||||
|
|
@ -3499,8 +3568,18 @@ async def run_domclick_city_sweep(
|
|||
lots = await _scraper.fetch_city(city_id=city_id, rooms=rooms, pages=pages)
|
||||
counters.lots_fetched += len(lots)
|
||||
if lots:
|
||||
# #2594: domclick oblast-rollout (B2) ещё не wired (нет city_id→slug
|
||||
# мэппинга, см. CITY_LOCATIONS) — известный ЕКБ city_id получает
|
||||
# "Екатеринбург", любой другой (будущий B2) честно остаётся None, а не
|
||||
# угадывается.
|
||||
_dc_city = EKATERINBURG_CITY_NAME if city_id == DOMCLICK_DEFAULT_CITY_ID else None
|
||||
inserted, updated = save_listings(
|
||||
db, lots, matcher=matcher, region_code=region_code, run_id=run_id
|
||||
db,
|
||||
lots,
|
||||
matcher=matcher,
|
||||
region_code=region_code,
|
||||
run_id=run_id,
|
||||
city=_dc_city,
|
||||
)
|
||||
counters.lots_inserted += inserted
|
||||
counters.lots_updated += updated
|
||||
|
|
|
|||
68
tradein-mvp/uv.lock
generated
68
tradein-mvp/uv.lock
generated
|
|
@ -43,6 +43,72 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bcrypt"
|
||||
version = "5.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "1.2.0"
|
||||
|
|
@ -1609,6 +1675,7 @@ name = "tradein-mvp-backend"
|
|||
version = "0.1.0"
|
||||
source = { virtual = "backend" }
|
||||
dependencies = [
|
||||
{ name = "bcrypt" },
|
||||
{ name = "curl-cffi" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "geoalchemy2" },
|
||||
|
|
@ -1644,6 +1711,7 @@ dev = [
|
|||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "bcrypt", specifier = ">=4.2.0" },
|
||||
{ name = "curl-cffi", specifier = ">=0.7.0" },
|
||||
{ name = "fastapi", specifier = ">=0.115.0" },
|
||||
{ name = "geoalchemy2", specifier = ">=0.15.0" },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue