Compare commits
83 commits
fix/rbac-t
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d56103219a | |||
|
|
7fd40f6ea5 | ||
| fcfc777baa | |||
|
|
b579fa4ced | ||
| 698576e77b | |||
| 67b50bf713 | |||
| c61ed5a96e | |||
| e61c2debe5 | |||
| c904fbf94e | |||
| 9fd6396fbc | |||
| 8b7af92ee3 | |||
| 2c5b30f8c9 | |||
| d530602fbd | |||
| 32037af6de | |||
| 5647c2b40e | |||
| 749c96cd0c | |||
| 987dce11bc | |||
| b5477362f0 | |||
| 60c21ae2a5 | |||
| 3bd0873230 | |||
| 5e73b35206 | |||
| 69ca746673 | |||
| 2cd24ecfbc | |||
| 92371e7d9e | |||
| 7fa52bb3ad | |||
| 348be445e3 | |||
|
|
8fdcd8df62 | ||
| c98a879a79 | |||
|
|
2ef3e42da1 | ||
|
|
720cee2c8e | ||
| 73f932d60a | |||
| e7c388dde7 | |||
|
|
07275c3c97 | ||
|
|
d92ff5c3d3 | ||
|
|
d31b1e01ee | ||
| 51473fc841 | |||
| c3d0f86f37 | |||
|
|
219cdfdbcc | ||
|
|
bf4ce1ba36 | ||
| 519b482cdd | |||
| e4bb2fc2e3 | |||
|
|
85321d1d1a | ||
| b1ac551451 | |||
| 032fd95a7e | |||
|
|
0583fb0efd | ||
| a3dfa2a99f | |||
|
|
20bdc7dec8 | ||
|
|
6487813ec5 | ||
|
|
1c9f0e1161 | ||
| 0f4ed2f6e9 | |||
|
|
27d1b09c72 | ||
|
|
c1a01fdbf9 | ||
| fb123bd97e | |||
|
|
f9ae6f0c53 | ||
| 3293df8e14 | |||
| 4c56c5189a | |||
|
|
25a865f27b | ||
|
|
69f8b50934 | ||
|
|
7801ac6cc1 | ||
| 5d7c3e1c30 | |||
| 6936558f6f | |||
|
|
f631af4de3 | ||
|
|
4a579e74e0 | ||
|
|
b93d26990b | ||
| 90a6e4bfe9 | |||
|
|
cf91ac6b24 | ||
| 74017b39c4 | |||
|
|
e250869c9d | ||
| b0b2c94ece | |||
|
|
e2502a0134 | ||
| 51952ebd70 | |||
|
|
c0cbdc2f99 | ||
| fcc37c46ab | |||
| 95f0b0aa64 | |||
|
|
752cb1044b | ||
| 338ccbb78f | |||
| a88e755403 | |||
| 4f205bd7da | |||
| 8dd35c01d8 | |||
| ed504cbbd9 | |||
| cc231946c2 | |||
| d22e0e00ae | |||
| 346767ba90 |
134 changed files with 13928 additions and 1726 deletions
|
|
@ -483,7 +483,11 @@ jobs:
|
||||||
# рискует ложно отменить НЕ относящийся к этому recreate run (напр.
|
# рискует ложно отменить НЕ относящийся к этому recreate run (напр.
|
||||||
# admin-triggered scrape внутри backend, если backend в этом деплое
|
# admin-triggered scrape внутри backend, если backend в этом деплое
|
||||||
# не пересоздавался — его heartbeat продолжит расти после checkpoint'а).
|
# не пересоздавался — его heartbeat продолжит расти после checkpoint'а).
|
||||||
SERVICES="browser backend frontend"
|
# tgbot: тот же backend-образ (rebuild уже покрыт filters.backend —
|
||||||
|
# tradein-mvp/backend/** включает app/tgbot_main.py), никакого
|
||||||
|
# in-flight state вроде scrape_runs → пересоздаётся безусловно вместе
|
||||||
|
# с browser/backend/frontend, отдельного graceful-drain не требует.
|
||||||
|
SERVICES="browser backend frontend tgbot"
|
||||||
SCRAPER_STOP_TS=""
|
SCRAPER_STOP_TS=""
|
||||||
if [ "${SCRAPER_CHANGED:-true}" = "true" ]; then
|
if [ "${SCRAPER_CHANGED:-true}" = "true" ]; then
|
||||||
echo "→ scraper paths changed — waiting for in-flight scrape_runs to drain (up to 5 min)"
|
echo "→ scraper paths changed — waiting for in-flight scrape_runs to drain (up to 5 min)"
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ users:
|
||||||
admin: admin
|
admin: admin
|
||||||
kopylov: pilot
|
kopylov: pilot
|
||||||
user1: pilot
|
user1: pilot
|
||||||
user2: expired # «Брусника» — пробный доступ закрыт 2026-07-09 (аналогично praktika) — NoAccessScreen variant="trial"
|
user2: pilot # «Брусника» — доступ восстановлен 2026-07-13 (снят trial-expire от 2026-07-09)
|
||||||
user3: pilot
|
user3: pilot
|
||||||
user4: pilot
|
user4: pilot
|
||||||
user5: pilot
|
user5: pilot
|
||||||
|
|
|
||||||
|
|
@ -114,9 +114,7 @@ def test_get_role_known_users() -> None:
|
||||||
assert auth_mod.get_role("admin") == "admin"
|
assert auth_mod.get_role("admin") == "admin"
|
||||||
assert auth_mod.get_role("kopylov") == "pilot"
|
assert auth_mod.get_role("kopylov") == "pilot"
|
||||||
for n in range(1, 11):
|
for n in range(1, 11):
|
||||||
# user2 = «Брусника»: пробный доступ закрыт 2026-07-09 (roles.yaml)
|
assert auth_mod.get_role(f"user{n}") == "pilot"
|
||||||
expected = "expired" if n == 2 else "pilot"
|
|
||||||
assert auth_mod.get_role(f"user{n}") == expected
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_role_unknown_user_raises() -> None:
|
def test_get_role_unknown_user_raises() -> None:
|
||||||
|
|
|
||||||
|
|
@ -40,3 +40,14 @@ DADATA_API_SECRET=
|
||||||
POSTGRES_USER=tradein
|
POSTGRES_USER=tradein
|
||||||
POSTGRES_PASSWORD=tradein
|
POSTGRES_PASSWORD=tradein
|
||||||
POSTGRES_DB=tradein
|
POSTGRES_DB=tradein
|
||||||
|
|
||||||
|
# === Telegram support-bot bridge (tgbot service, docker-compose.prod.yml) ===
|
||||||
|
# Long-polling worker: пересылает support-обращения в Telegram-топик. Не FastAPI,
|
||||||
|
# отдельный процесс (app/tgbot_main.py), env читается из backend/.env.runtime на VPS.
|
||||||
|
#
|
||||||
|
# BotFather token. Пусто = бот не стартует (выключен).
|
||||||
|
TELEGRAM_BOT_TOKEN=
|
||||||
|
# ID супергруппы-форума с включёнными топиками (вида -100XXXXXXXXXX).
|
||||||
|
TELEGRAM_SUPPORT_CHAT_ID=
|
||||||
|
# ID топика (thread) внутри супергруппы, куда падают support-сообщения.
|
||||||
|
TELEGRAM_SUPPORT_TOPIC_ID=
|
||||||
|
|
|
||||||
|
|
@ -89,10 +89,47 @@ GLITCHTIP_DSN=<dsn или пусто>
|
||||||
# Регистрация ключей: https://dadata.ru/api/clean/
|
# Регистрация ключей: https://dadata.ru/api/clean/
|
||||||
DADATA_API_TOKEN=<token или пусто>
|
DADATA_API_TOKEN=<token или пусто>
|
||||||
DADATA_API_SECRET=<secret или пусто>
|
DADATA_API_SECRET=<secret или пусто>
|
||||||
|
|
||||||
|
# Telegram support-bot bridge (сервис tgbot, docker-compose.prod.yml).
|
||||||
|
# Long-polling воркер (app/tgbot_main.py), тот же образ что backend/scraper,
|
||||||
|
# отдельный контейнер tradein-tgbot. Пусто TELEGRAM_BOT_TOKEN = бот НЕ падает
|
||||||
|
# и НЕ рестарт-лупится — процесс стартует, уходит в idle-блокировку и просто
|
||||||
|
# висит (это норма для окружений без токена, не сбой; см. tgbot_main.py).
|
||||||
|
#
|
||||||
|
# 1. TELEGRAM_BOT_TOKEN — токен от @BotFather (/newbot). Пусто = бот выключен
|
||||||
|
# (idle, не polling).
|
||||||
|
TELEGRAM_BOT_TOKEN=<token или пусто>
|
||||||
|
# 2. TELEGRAM_SUPPORT_CHAT_ID — id супергруппы-форума (Topics включены в
|
||||||
|
# настройках группы), вида -100XXXXXXXXXX. Получить: добавить бота в группу,
|
||||||
|
# отправить любое сообщение в любой топик, дернуть
|
||||||
|
# https://api.telegram.org/bot<token>/getUpdates — в ответе
|
||||||
|
# message.chat.id (для супергруппы всегда отрицательный, начинается с -100).
|
||||||
|
TELEGRAM_SUPPORT_CHAT_ID=<-100... или пусто>
|
||||||
|
# 3. TELEGRAM_SUPPORT_TOPIC_ID — id конкретного топика (thread) внутри группы,
|
||||||
|
# куда падают support-обращения. Открыть нужный топик в Telegram Desktop/Web →
|
||||||
|
# в URL топика (t.me/c/<chat>/<topic_id>) последнее число — это topic_id.
|
||||||
|
# Либо взять message_thread_id из того же getUpdates-ответа (п.2), отправив
|
||||||
|
# тестовое сообщение именно в целевой топик.
|
||||||
|
TELEGRAM_SUPPORT_TOPIC_ID=<topic_id или пусто>
|
||||||
```
|
```
|
||||||
|
|
||||||
Оба файла создаются вручную при первом деплое.
|
Оба файла создаются вручную при первом деплое.
|
||||||
|
|
||||||
|
### Деплой / рестарт `tgbot`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env.runtime читается на старте container — `compose restart` НЕ перечитывает.
|
||||||
|
docker compose -p gendesign-tradein -f docker-compose.prod.yml \
|
||||||
|
up -d --force-recreate --no-deps tgbot
|
||||||
|
```
|
||||||
|
|
||||||
|
`restart: unless-stopped` + `stop_grace_period: 120s` в compose (см. `docker-compose.prod.yml`)
|
||||||
|
— автозапуск после ребута VPS гарантирован (`on-failure` сюда не годится: код выхода
|
||||||
|
контейнера при ребуте — гонка с long-poll таймаутом 30с, `unless-stopped`/`always`
|
||||||
|
не зависят от exit-кода). 120s grace даёт time докрутить long-poll + отработать
|
||||||
|
кооперативный drain (`_DRAIN_TIMEOUT_S=100s` в `tgbot_main.py`) до docker SIGKILL —
|
||||||
|
паттерн скопирован с `scraper` (см. комментарий там же).
|
||||||
|
|
||||||
### После изменения `backend/.env.runtime`
|
### После изменения `backend/.env.runtime`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
280
tradein-mvp/backend/app/api/v1/audit.py
Normal file
280
tradein-mvp/backend/app/api/v1/audit.py
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
"""Admin read API над `user_events` — Feature 2 (login/IP audit) + Feature 3 (behavior
|
||||||
|
analytics dashboard data).
|
||||||
|
|
||||||
|
Read-only: только SELECT, никаких мутаций. Auth не нужен в этом файле — вся ветка
|
||||||
|
`/api/v1/admin/*` уже гейтится `rbac_guard` middleware в app/main.py
|
||||||
|
(`_ADMIN_API_RE`, role != admin → 403).
|
||||||
|
|
||||||
|
`user_events` (migration `184_user_events.sql`) может быть пустой (feature только
|
||||||
|
что задеплоена) — все запросы ниже написаны так, чтобы на пустой таблице отдавать
|
||||||
|
пустые списки/нулевые счётчики, а не падать.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Path, Query
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.db import get_db
|
||||||
|
from app.schemas.audit import (
|
||||||
|
AccountActivityEntry,
|
||||||
|
AccountDeviceEntry,
|
||||||
|
AccountDrilldown,
|
||||||
|
AccountIpEntry,
|
||||||
|
AccountSearchEntry,
|
||||||
|
AccountSummary,
|
||||||
|
AnalyticsByAccount,
|
||||||
|
AnalyticsDailyPoint,
|
||||||
|
AnalyticsDashboard,
|
||||||
|
AnalyticsSummary,
|
||||||
|
AnalyticsTopPath,
|
||||||
|
AnalyticsTopSearch,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/audit/accounts", response_model=list[AccountSummary])
|
||||||
|
async def list_accounts(
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
) -> list[AccountSummary]:
|
||||||
|
"""Список аккаунтов, по одной строке на username, с базовой сводкой активности."""
|
||||||
|
rows = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT username,
|
||||||
|
min(created_at) AS first_seen_at,
|
||||||
|
max(created_at) AS last_seen_at,
|
||||||
|
count(DISTINCT ip_address) AS distinct_ips,
|
||||||
|
count(DISTINCT user_agent) AS distinct_devices,
|
||||||
|
count(*) FILTER (WHERE event_type = 'login') AS login_count,
|
||||||
|
count(*) FILTER (WHERE event_type = 'api_request') AS request_count,
|
||||||
|
count(*) FILTER (WHERE event_type = 'estimate_request') AS search_count
|
||||||
|
FROM user_events
|
||||||
|
GROUP BY username
|
||||||
|
ORDER BY last_seen_at DESC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [AccountSummary.model_validate(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/audit/accounts/{username}", response_model=AccountDrilldown)
|
||||||
|
async def account_drilldown(
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
username: Annotated[str, Path(min_length=1, max_length=200)],
|
||||||
|
) -> AccountDrilldown:
|
||||||
|
"""Drilldown по одному username: IP-адреса, устройства, поиски, недавняя активность.
|
||||||
|
|
||||||
|
Неизвестный username — НЕ 404, а пустой отчёт (все 4 списка == []): аудит не
|
||||||
|
подтверждает/опровергает существование аккаунта, просто нет событий.
|
||||||
|
"""
|
||||||
|
params = {"username": username}
|
||||||
|
|
||||||
|
ips = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT CAST(ip_address AS text) AS ip_address,
|
||||||
|
count(*) AS event_count,
|
||||||
|
min(created_at) AS first_seen,
|
||||||
|
max(created_at) AS last_seen
|
||||||
|
FROM user_events
|
||||||
|
WHERE username = :username AND ip_address IS NOT NULL
|
||||||
|
GROUP BY ip_address
|
||||||
|
ORDER BY last_seen DESC
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
devices = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT user_agent,
|
||||||
|
count(*) AS event_count,
|
||||||
|
min(created_at) AS first_seen,
|
||||||
|
max(created_at) AS last_seen
|
||||||
|
FROM user_events
|
||||||
|
WHERE username = :username AND user_agent IS NOT NULL
|
||||||
|
GROUP BY user_agent
|
||||||
|
ORDER BY last_seen DESC
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
searches = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT payload ->> 'address' AS address,
|
||||||
|
payload ->> 'area_m2' AS area_m2,
|
||||||
|
payload ->> 'rooms' AS rooms,
|
||||||
|
CAST(estimate_id AS text) AS estimate_id,
|
||||||
|
CAST(ip_address AS text) AS ip_address,
|
||||||
|
created_at
|
||||||
|
FROM user_events
|
||||||
|
WHERE username = :username AND event_type = 'estimate_request'
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 200
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
recent_activity = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT event_type,
|
||||||
|
path,
|
||||||
|
method,
|
||||||
|
CAST(ip_address AS text) AS ip_address,
|
||||||
|
created_at
|
||||||
|
FROM user_events
|
||||||
|
WHERE username = :username
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 200
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
return AccountDrilldown(
|
||||||
|
ips=[AccountIpEntry.model_validate(r) for r in ips],
|
||||||
|
devices=[AccountDeviceEntry.model_validate(r) for r in devices],
|
||||||
|
searches=[AccountSearchEntry.model_validate(r) for r in searches],
|
||||||
|
recent_activity=[AccountActivityEntry.model_validate(r) for r in recent_activity],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/analytics", response_model=AnalyticsDashboard)
|
||||||
|
async def analytics_dashboard(
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
days: Annotated[int, Query(ge=1, le=365)] = 30,
|
||||||
|
) -> AnalyticsDashboard:
|
||||||
|
"""Feature 3 dashboard bundle: сводка, дневной time-series, топ-поиски/пути/аккаунты."""
|
||||||
|
summary_row = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT count(*) AS total_events,
|
||||||
|
count(DISTINCT username) AS distinct_users,
|
||||||
|
count(*) FILTER (
|
||||||
|
WHERE created_at >= now() - INTERVAL '24 hours'
|
||||||
|
) AS events_last_24h,
|
||||||
|
count(DISTINCT username) FILTER (
|
||||||
|
WHERE created_at >= now() - INTERVAL '24 hours'
|
||||||
|
) AS active_users_last_24h
|
||||||
|
FROM user_events
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
|
||||||
|
daily_rows = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT date_trunc('day', created_at)::date AS day,
|
||||||
|
count(*) AS events,
|
||||||
|
count(DISTINCT username) AS users
|
||||||
|
FROM user_events
|
||||||
|
WHERE created_at >= now() - make_interval(days => CAST(:days AS int))
|
||||||
|
GROUP BY date_trunc('day', created_at)::date
|
||||||
|
ORDER BY day
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"days": days},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
top_searches_rows = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT payload ->> 'address' AS address, count(*) AS n
|
||||||
|
FROM user_events
|
||||||
|
WHERE event_type = 'estimate_request'
|
||||||
|
AND payload ->> 'address' IS NOT NULL
|
||||||
|
GROUP BY payload ->> 'address'
|
||||||
|
ORDER BY n DESC
|
||||||
|
LIMIT 20
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
top_paths_rows = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT path, count(*) AS n
|
||||||
|
FROM user_events
|
||||||
|
WHERE event_type = 'api_request'
|
||||||
|
GROUP BY path
|
||||||
|
ORDER BY n DESC
|
||||||
|
LIMIT 20
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
by_account_rows = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT username,
|
||||||
|
count(*) AS events,
|
||||||
|
count(*) FILTER (WHERE event_type = 'estimate_request') AS searches,
|
||||||
|
max(created_at) AS last_seen
|
||||||
|
FROM user_events
|
||||||
|
GROUP BY username
|
||||||
|
ORDER BY events DESC
|
||||||
|
LIMIT 50
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
return AnalyticsDashboard(
|
||||||
|
summary=AnalyticsSummary.model_validate(summary_row),
|
||||||
|
daily=[AnalyticsDailyPoint.model_validate(r) for r in daily_rows],
|
||||||
|
top_searches=[AnalyticsTopSearch.model_validate(r) for r in top_searches_rows],
|
||||||
|
top_paths=[AnalyticsTopPath.model_validate(r) for r in top_paths_rows],
|
||||||
|
by_account=[AnalyticsByAccount.model_validate(r) for r in by_account_rows],
|
||||||
|
)
|
||||||
|
|
@ -56,10 +56,12 @@ async def suggest_addresses(
|
||||||
limit: Annotated[int, Query(ge=1, le=15)] = 8,
|
limit: Annotated[int, Query(ge=1, le=15)] = 8,
|
||||||
db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment]
|
db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment]
|
||||||
) -> SuggestResponse:
|
) -> SuggestResponse:
|
||||||
"""Автокомплит адресов в пределах ЕКБ.
|
"""Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик,
|
||||||
|
остаётся быстрым fast-path).
|
||||||
|
|
||||||
Используется в EstimateForm для подсказок пока пользователь печатает.
|
Используется в EstimateForm для подсказок пока пользователь печатает.
|
||||||
Bounded viewbox = ЕКБ (lon 60.40-60.85, lat 56.65-56.95).
|
Bounded viewbox — генеральный по всей области (см. geocoder.OBLAST66_VIEWBOX),
|
||||||
|
ЕКБ (lon 60.40-60.85, lat 56.65-56.95) внутри него остаётся быстрым fast-path.
|
||||||
|
|
||||||
Пример:
|
Пример:
|
||||||
/api/v1/geocode/suggest?q=Малышева
|
/api/v1/geocode/suggest?q=Малышева
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"""Trade-in lead capture endpoint (issue #2376, sub-issue родителя #1971).
|
"""Trade-in lead capture endpoint (issue #2376, sub-issue родителя #1971).
|
||||||
|
|
||||||
POST /api/v1/trade-in/lead — контактная заявка с результата оценки (или лендинга):
|
POST /api/v1/trade-in/lead — контактная заявка с результата оценки:
|
||||||
телефон + явное согласие на обработку персональных данных. Persist в
|
телефон + явное согласие на обработку персональных данных. Persist в
|
||||||
trade_in_leads. Notification (Telegram/email) — вне scope: нет существующей
|
trade_in_leads. Notification (Telegram/email) — вне scope: нет существующей
|
||||||
SMTP/Telegram интеграции в коде (подтверждено при разборе issue), только
|
SMTP/Telegram интеграции в коде (подтверждено при разборе issue), только
|
||||||
|
|
@ -10,11 +10,12 @@ persist + log; `notified_at` в таблице зарезервирован по
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from typing import Annotated, Any, Literal
|
from typing import Annotated, Any, Literal
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, field_validator
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
@ -28,13 +29,45 @@ router = APIRouter()
|
||||||
# скобки/дефисы/точки, 5-32 символа. Полная нормализация в E.164 — вне scope MVP,
|
# скобки/дефисы/точки, 5-32 символа. Полная нормализация в E.164 — вне scope MVP,
|
||||||
# см. #2376 DoD ("простая regex, не EmailStr-подобное").
|
# см. #2376 DoD ("простая regex, не EmailStr-подобное").
|
||||||
_PHONE_PATTERN = r"^[+]?[\d\s().-]{5,32}$"
|
_PHONE_PATTERN = r"^[+]?[\d\s().-]{5,32}$"
|
||||||
|
# Маска выше делает цифры ОПЦИОНАЛЬНЫМИ: "(()) -- .." её проходит (0 цифр).
|
||||||
|
# Поэтому дополнительно требуем правдоподобное число реальных цифр. RU-мобильный =
|
||||||
|
# 11 цифр; берём лениентный диапазон 10-15 (нац. номер без/с кодом страны).
|
||||||
|
_PHONE_MIN_DIGITS = 10
|
||||||
|
_PHONE_MAX_DIGITS = 15
|
||||||
|
|
||||||
|
# Версия политики обработки ПДн (152-ФЗ), под которую собрано согласие. Персистится
|
||||||
|
# per-row в trade_in_leads.consent_policy_version (migration 182) — до неё писалась
|
||||||
|
# только в audit-лог (#2497 TODO, теперь закрыт).
|
||||||
|
_CONSENT_POLICY_VERSION = "2026-07"
|
||||||
|
|
||||||
|
# Снимок точного текста согласия, который видит пользователь при отправке лида.
|
||||||
|
# Должен ДОСЛОВНО совпадать с чекбоксом в LeadForm.tsx (frontend/src/components/
|
||||||
|
# trade-in/v2/LeadForm.tsx) — если текст политики меняется, здесь нужно поднять
|
||||||
|
# _CONSENT_POLICY_VERSION И обновить этот снимок в одном PR, иначе новые строки
|
||||||
|
# будут нести устаревший snapshot под новой version-меткой.
|
||||||
|
_CONSENT_TEXT_SNAPSHOT = (
|
||||||
|
"Согласен(-на) на обработку персональных данных в соответствии с "
|
||||||
|
"Федеральным законом «О персональных данных» № 152-ФЗ"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TradeInLeadInput(BaseModel):
|
class TradeInLeadInput(BaseModel):
|
||||||
phone: str = Field(min_length=5, max_length=32, pattern=_PHONE_PATTERN)
|
phone: str = Field(min_length=5, max_length=32, pattern=_PHONE_PATTERN)
|
||||||
estimate_id: UUID | None = None
|
estimate_id: UUID | None = None
|
||||||
consent: Literal[True]
|
consent: Literal[True]
|
||||||
source: Literal["result", "landing"] = "result"
|
# "landing"-воронка недостижима: /api/v1/trade-in/lead закрыт rbac_guard
|
||||||
|
# (main.py — путь не в _PUBLIC_PATHS => 401 без X-Authenticated-User), а
|
||||||
|
# публичного лендинг-роута нет. Убрали мёртвый литерал, чтобы контракт не
|
||||||
|
# обещал невозможную воронку (#2376). Вернуть, если появится public-роут.
|
||||||
|
source: Literal["result"] = "result"
|
||||||
|
|
||||||
|
@field_validator("phone")
|
||||||
|
@classmethod
|
||||||
|
def _phone_has_enough_digits(cls, value: str) -> str:
|
||||||
|
digits = len(re.sub(r"\D", "", value))
|
||||||
|
if not (_PHONE_MIN_DIGITS <= digits <= _PHONE_MAX_DIGITS):
|
||||||
|
raise ValueError(f"phone must contain {_PHONE_MIN_DIGITS}-{_PHONE_MAX_DIGITS} digits")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
@router.post("/lead")
|
@router.post("/lead")
|
||||||
|
|
@ -53,13 +86,31 @@ async def create_trade_in_lead(
|
||||||
raise HTTPException(status_code=404, detail="estimate not found")
|
raise HTTPException(status_code=404, detail="estimate not found")
|
||||||
|
|
||||||
user_agent = request.headers.get("user-agent")
|
user_agent = request.headers.get("user-agent")
|
||||||
|
# 152-ФЗ audit trail: реальный клиентский IP из X-Forwarded-For (его ставит
|
||||||
|
# фронтящий Caddy), fallback — прямой peer. Персистится per-row в
|
||||||
|
# trade_in_leads.client_ip (migration 182), а не только в лог.
|
||||||
|
client_ip = request.headers.get("x-forwarded-for")
|
||||||
|
if client_ip:
|
||||||
|
client_ip = client_ip.split(",")[0].strip()
|
||||||
|
elif request.client is not None:
|
||||||
|
client_ip = request.client.host
|
||||||
|
|
||||||
|
# 152-ФЗ proof-of-consent: client_ip / consent_policy_version /
|
||||||
|
# consent_text_snapshot теперь durable-колонки на trade_in_leads (migration 182,
|
||||||
|
# ранее — только audit-лог, #2497 TODO). client_ip может быть None (нет
|
||||||
|
# X-Forwarded-For и request.client) — колонка nullable, CAST(NULL AS inet) валиден.
|
||||||
row = (
|
row = (
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
INSERT INTO trade_in_leads (estimate_id, phone, consent, source, user_agent)
|
INSERT INTO trade_in_leads (
|
||||||
VALUES (CAST(:estimate_id AS uuid), :phone, :consent, :source, :user_agent)
|
estimate_id, phone, consent, source, user_agent,
|
||||||
|
client_ip, consent_policy_version, consent_text_snapshot
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
CAST(:estimate_id AS uuid), :phone, :consent, :source, :user_agent,
|
||||||
|
CAST(:client_ip AS inet), :consent_policy_version, :consent_text_snapshot
|
||||||
|
)
|
||||||
RETURNING CAST(id AS text), created_at
|
RETURNING CAST(id AS text), created_at
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
|
|
@ -69,6 +120,9 @@ async def create_trade_in_lead(
|
||||||
"consent": payload.consent,
|
"consent": payload.consent,
|
||||||
"source": payload.source,
|
"source": payload.source,
|
||||||
"user_agent": user_agent,
|
"user_agent": user_agent,
|
||||||
|
"client_ip": client_ip,
|
||||||
|
"consent_policy_version": _CONSENT_POLICY_VERSION,
|
||||||
|
"consent_text_snapshot": _CONSENT_TEXT_SNAPSHOT,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.mappings()
|
.mappings()
|
||||||
|
|
@ -78,10 +132,12 @@ async def create_trade_in_lead(
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"trade_in_lead saved id=%s estimate_id=%s source=%s",
|
"trade_in_lead saved id=%s estimate_id=%s source=%s ip=%s policy=%s",
|
||||||
row["id"],
|
row["id"],
|
||||||
payload.estimate_id,
|
payload.estimate_id,
|
||||||
payload.source,
|
payload.source,
|
||||||
|
client_ip,
|
||||||
|
_CONSENT_POLICY_VERSION,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,13 @@ from datetime import UTC, date, datetime, timedelta
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, Header, HTTPException, Response, UploadFile
|
from fastapi import APIRouter, Depends, File, Header, HTTPException, Request, Response, UploadFile
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
|
from app.core.ratelimit import _client_ip
|
||||||
from app.schemas.trade_in import (
|
from app.schemas.trade_in import (
|
||||||
AggregatedEstimate,
|
AggregatedEstimate,
|
||||||
AnalogLot,
|
AnalogLot,
|
||||||
|
|
@ -45,6 +46,7 @@ from app.schemas.trade_in import (
|
||||||
from app.services import account_quota
|
from app.services import account_quota
|
||||||
from app.services.exporters.trade_in_pdf import generate_trade_in_pdf
|
from app.services.exporters.trade_in_pdf import generate_trade_in_pdf
|
||||||
from app.services.image_sanitizer import ImageSanitizationError, sanitize_image
|
from app.services.image_sanitizer import ImageSanitizationError, sanitize_image
|
||||||
|
from app.services.user_events import schedule_event
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -147,6 +149,7 @@ def _resolve_target_house_id(
|
||||||
@router.post("/estimate", response_model=AggregatedEstimate)
|
@router.post("/estimate", response_model=AggregatedEstimate)
|
||||||
async def estimate(
|
async def estimate(
|
||||||
payload: TradeInEstimateInput,
|
payload: TradeInEstimateInput,
|
||||||
|
request: Request,
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
|
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
|
||||||
) -> AggregatedEstimate:
|
) -> AggregatedEstimate:
|
||||||
|
|
@ -180,9 +183,29 @@ async def estimate(
|
||||||
) from None
|
) from None
|
||||||
# #747: атомарно-условный инкремент — источник истины по лимиту. check_and_raise
|
# #747: атомарно-условный инкремент — источник истины по лимиту. check_and_raise
|
||||||
# выше остаётся быстрым pre-check (429 до дорогой оценки), но финальное решение
|
# выше остаётся быстрым pre-check (429 до дорогой оценки), но финальное решение
|
||||||
# тут: при гонке двух /estimate на used=MONTHLY_LIMIT-1 второй получит False.
|
# тут: при гонке двух /estimate на used=lim-1 второй получит False.
|
||||||
if not account_quota.increment(db, x_authenticated_user):
|
# Не списываем квоту за пустой результат (нерезолвящийся адрес и т.п.) — иначе
|
||||||
|
# платный слот сгорает за insufficient_data=True (median=0, n_analogs=0) с HTTP 200.
|
||||||
|
if not result.insufficient_data and not account_quota.increment(db, x_authenticated_user):
|
||||||
raise HTTPException(status_code=429, detail=account_quota.LIMIT_EXHAUSTED_MESSAGE)
|
raise HTTPException(status_code=429, detail=account_quota.LIMIT_EXHAUSTED_MESSAGE)
|
||||||
|
|
||||||
|
# Feature 2/3 foundation: "что искали" — обогащённая estimate_request-запись
|
||||||
|
# в user_events (адрес/площадь/комнаты + estimate_id для join с trade_in_estimates).
|
||||||
|
# schedule_event сам никогда не raises — сбой аудита не должен ронять ответ.
|
||||||
|
schedule_event(
|
||||||
|
event_type="estimate_request",
|
||||||
|
username=x_authenticated_user or "",
|
||||||
|
ip=_client_ip(request),
|
||||||
|
user_agent=request.headers.get("user-agent"),
|
||||||
|
path=str(request.url.path),
|
||||||
|
method="POST",
|
||||||
|
estimate_id=str(result.estimate_id),
|
||||||
|
payload={
|
||||||
|
"address": payload.address,
|
||||||
|
"area_m2": payload.area_m2,
|
||||||
|
"rooms": payload.rooms,
|
||||||
|
},
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -244,6 +267,7 @@ def get_estimate(
|
||||||
_fetch_house_imv_anchor,
|
_fetch_house_imv_anchor,
|
||||||
_fetch_price_trend,
|
_fetch_price_trend,
|
||||||
_qc_geo_to_precision,
|
_qc_geo_to_precision,
|
||||||
|
_resolve_target_city,
|
||||||
_source_counts,
|
_source_counts,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -283,7 +307,16 @@ def get_estimate(
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
|
||||||
dkp_raw = _fetch_dkp_corridor(db, address=row.address, rooms=row.rooms, area=area_f)
|
# (oblast C2): city-scope корридора на GET-rehydrate. NB: row.address здесь =
|
||||||
|
# geo.full_address (персистится на estimate-time), который РОНЯЕТ город для
|
||||||
|
# не-ЕКБ («Ленина, 1») → target_city=None → corridor unscoped/None для не-ЕКБ
|
||||||
|
# (KNOWN LIMITATION: POST-path чинит через payload.address; GET получит паритет
|
||||||
|
# когда raw payload.address начнёт персиститься — follow-up). Для ЕКБ ок; reorder
|
||||||
|
# ниже безвреден (оба source city-stripped для не-ЕКБ, оба екб для ЕКБ).
|
||||||
|
target_city = _resolve_target_city(row.address) or _resolve_target_city(row.canonical_address)
|
||||||
|
dkp_raw = _fetch_dkp_corridor(
|
||||||
|
db, address=row.address, rooms=row.rooms, area=area_f, city=target_city
|
||||||
|
)
|
||||||
dkp_corridor = DkpCorridor(**dkp_raw) if dkp_raw else None
|
dkp_corridor = DkpCorridor(**dkp_raw) if dkp_raw else None
|
||||||
|
|
||||||
imv_raw = _fetch_house_imv_anchor(
|
imv_raw = _fetch_house_imv_anchor(
|
||||||
|
|
@ -1628,7 +1661,12 @@ def get_street_deals(
|
||||||
После PR-A (#549) таблица deals содержит только ДКП (ДДУ-первичка отфильтрована
|
После PR-A (#549) таблица deals содержит только ДКП (ДДУ-первичка отфильтрована
|
||||||
в import-rosreestr.sh).
|
в import-rosreestr.sh).
|
||||||
"""
|
"""
|
||||||
from app.services.estimator import _deal_to_analog, _percentile, extract_street_name
|
from app.services.estimator import (
|
||||||
|
_deal_to_analog,
|
||||||
|
_percentile,
|
||||||
|
_resolve_target_city,
|
||||||
|
extract_street_name,
|
||||||
|
)
|
||||||
|
|
||||||
now = datetime.now(tz=UTC)
|
now = datetime.now(tz=UTC)
|
||||||
# #1381: отображаемое окно должно совпадать с SQL-фильтром ниже, который
|
# #1381: отображаемое окно должно совпадать с SQL-фильтром ниже, который
|
||||||
|
|
@ -1663,16 +1701,25 @@ def get_street_deals(
|
||||||
area_min = area_m2 * (1.0 - area_tolerance)
|
area_min = area_m2 * (1.0 - area_tolerance)
|
||||||
area_max = area_m2 * (1.0 + area_tolerance)
|
area_max = area_m2 * (1.0 + area_tolerance)
|
||||||
|
|
||||||
|
# #C1 city-scope (п.3, консистентно с estimator._fetch_dkp_corridor): резолвим
|
||||||
|
# город целевого адреса через _resolve_target_city (словарь ~30 городов обл.66
|
||||||
|
# вкл. ЕКБ + sweep-города) и фильтруем сделки по этому городу — одноимённые улицы
|
||||||
|
# др. городов не контаминируют витрину. None (адрес вне словаря) → фильтр не
|
||||||
|
# применяется. `city_filter` — литерал (не user-input), значение идёт bind-параметром.
|
||||||
|
target_city = _resolve_target_city(address)
|
||||||
|
city_filter = "AND LOWER(city) = CAST(:target_city AS text)" if target_city else ""
|
||||||
|
|
||||||
rows = (
|
rows = (
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
f"""
|
||||||
SELECT address, area_m2, rooms, floor, total_floors,
|
SELECT address, area_m2, rooms, floor, total_floors,
|
||||||
price_rub, price_per_m2, deal_date, source
|
price_rub, price_per_m2, deal_date, source
|
||||||
FROM deals
|
FROM deals
|
||||||
WHERE source = 'rosreestr'
|
WHERE source = 'rosreestr'
|
||||||
AND address ILIKE :street_pattern
|
AND address ILIKE :street_pattern
|
||||||
AND address ~* :street_regex
|
AND address ~* :street_regex
|
||||||
|
{city_filter}
|
||||||
AND rooms = CAST(:rooms AS integer)
|
AND rooms = CAST(:rooms AS integer)
|
||||||
AND area_m2 BETWEEN :area_min AND :area_max
|
AND area_m2 BETWEEN :area_min AND :area_max
|
||||||
AND deal_date > NOW() - (CAST(:period_months AS integer) || ' months')::interval
|
AND deal_date > NOW() - (CAST(:period_months AS integer) || ' months')::interval
|
||||||
|
|
@ -1683,6 +1730,7 @@ def get_street_deals(
|
||||||
{
|
{
|
||||||
"street_pattern": "%" + street_name + "%",
|
"street_pattern": "%" + street_name + "%",
|
||||||
"street_regex": r"\m" + street_name + r"\M",
|
"street_regex": r"\m" + street_name + r"\M",
|
||||||
|
"target_city": target_city.lower() if target_city else None,
|
||||||
"rooms": rooms,
|
"rooms": rooms,
|
||||||
"area_min": area_min,
|
"area_min": area_min,
|
||||||
"area_max": area_max,
|
"area_max": area_max,
|
||||||
|
|
|
||||||
|
|
@ -106,9 +106,7 @@ class Settings(BaseSettings):
|
||||||
# house_imv_evaluations, либо Yandex sale) выше нашей медианы более чем в
|
# house_imv_evaluations, либо Yandex sale) выше нашей медианы более чем в
|
||||||
# `threshold` раз — подмешиваем якорь к медиане с весом `weight` и
|
# `threshold` раз — подмешиваем якорь к медиане с весом `weight` и
|
||||||
# расширяем верх диапазона. ОДНОНАПРАВЛЕННО: только повышаем (баг — занижение).
|
# расширяем верх диапазона. ОДНОНАПРАВЛЕННО: только повышаем (баг — занижение).
|
||||||
# Полностью за флагами — безопасно выкатить до демо; при отсутствии IMV/Yandex
|
# При отсутствии IMV/Yandex no-op (медиана не меняется).
|
||||||
# no-op (медиана не меняется).
|
|
||||||
estimate_imv_blend_enabled: bool = True
|
|
||||||
estimate_imv_blend_weight: float = 0.5 # вес якоря в blend: median*(1-w)+A*w
|
estimate_imv_blend_weight: float = 0.5 # вес якоря в blend: median*(1-w)+A*w
|
||||||
estimate_imv_blend_threshold: float = 1.15 # якорь должен быть > медианы ×1.15
|
estimate_imv_blend_threshold: float = 1.15 # якорь должен быть > медианы ×1.15
|
||||||
|
|
||||||
|
|
@ -117,9 +115,8 @@ class Settings(BaseSettings):
|
||||||
# комфорт −15-25%. v2 берёт PRIMARY якорь из комплов ТОГО ЖЕ ДОМА (Tier A),
|
# комфорт −15-25%. v2 берёт PRIMARY якорь из комплов ТОГО ЖЕ ДОМА (Tier A),
|
||||||
# similarity-weighted по площади/комнатам, premium-uplift к ~p70 для топ-юнита
|
# similarity-weighted по площади/комнатам, premium-uplift к ~p70 для топ-юнита
|
||||||
# дома, asking→sold haircut (banded по ppm²), hard guardrail (est ≥ min-comp×0.95)
|
# дома, asking→sold haircut (banded по ppm²), hard guardrail (est ≥ min-comp×0.95)
|
||||||
# и tighter FSD-диапазон. Полностью за флагом — OFF ⇒ точно текущее поведение.
|
# и tighter FSD-диапазон.
|
||||||
# Спек+KPI: vault inbox 2026-05-30 tradein-valuation-algorithm-v2.
|
# Спек+KPI: vault inbox 2026-05-30 tradein-valuation-algorithm-v2.
|
||||||
estimate_same_building_anchor_enabled: bool = True
|
|
||||||
estimate_sb_min_comps: int = 4 # стоп на первом тире с ≥ N активных комплов
|
estimate_sb_min_comps: int = 4 # стоп на первом тире с ≥ N активных комплов
|
||||||
estimate_sb_area_sigma: float = 0.18 # σ log-нормального area-веса (Gaussian)
|
estimate_sb_area_sigma: float = 0.18 # σ log-нормального area-веса (Gaussian)
|
||||||
estimate_sb_rooms_match_boost: float = 1.6 # ×вес если rooms компла == target
|
estimate_sb_rooms_match_boost: float = 1.6 # ×вес если rooms компла == target
|
||||||
|
|
@ -138,8 +135,7 @@ class Settings(BaseSettings):
|
||||||
# интервал, выданный за полный). Эмпирически отношение actual_sold/expected_sold
|
# интервал, выданный за полный). Эмпирически отношение actual_sold/expected_sold
|
||||||
# по 2366 прод-сделкам имеет p10=0.649, p90=1.392 → band
|
# по 2366 прод-сделкам имеет p10=0.649, p90=1.392 → band
|
||||||
# expected_sold × [low_mult, high_mult] = настоящий ~80% prediction interval
|
# expected_sold × [low_mult, high_mult] = настоящий ~80% prediction interval
|
||||||
# (проверено: 80.0% coverage на тех же 2366). OFF ⇒ точно старое IQR-поведение.
|
# (проверено: 80.0% coverage на тех же 2366).
|
||||||
estimate_calibrated_pi_enabled: bool = True
|
|
||||||
estimate_pi_low_mult: float = 0.649 # empirical p10 of sold/expected_sold (#1966, n=2366)
|
estimate_pi_low_mult: float = 0.649 # empirical p10 of sold/expected_sold (#1966, n=2366)
|
||||||
estimate_pi_high_mult: float = 1.392 # empirical p90 of sold/expected_sold (#1966, n=2366)
|
estimate_pi_high_mult: float = 1.392 # empirical p90 of sold/expected_sold (#1966, n=2366)
|
||||||
# ── #2002: hedonic year+area correction на точку expected_sold ─────────────
|
# ── #2002: hedonic year+area correction на точку expected_sold ─────────────
|
||||||
|
|
@ -171,7 +167,6 @@ class Settings(BaseSettings):
|
||||||
# median_ppm2 > high_ppm2×(1+slack) И count≥min_n И anchor_tier != "A"
|
# median_ppm2 > high_ppm2×(1+slack) И count≥min_n И anchor_tier != "A"
|
||||||
# (Tier A = реальные комплы того же дома → EXEMPT) — жёстко прижимаем headline
|
# (Tier A = реальные комплы того же дома → EXEMPT) — жёстко прижимаем headline
|
||||||
# к high_ppm2×(1+slack) и пропорционально пересчитываем price/range/expected_sold.
|
# к high_ppm2×(1+slack) и пропорционально пересчитываем price/range/expected_sold.
|
||||||
estimate_corridor_clamp_enabled: bool = True
|
|
||||||
estimate_corridor_clamp_min_n: int = 10
|
estimate_corridor_clamp_min_n: int = 10
|
||||||
# cap = corridor_high×(1+slack) = ×1.40; даёт премиум-домам без own-листингов
|
# cap = corridor_high×(1+slack) = ×1.40; даёт премиум-домам без own-листингов
|
||||||
# (tier-C) больше воздуха над sold-коридором, не возвращая исходную 2× инфляцию
|
# (tier-C) больше воздуха над sold-коридором, не возвращая исходную 2× инфляцию
|
||||||
|
|
@ -181,9 +176,8 @@ class Settings(BaseSettings):
|
||||||
# Если итоговый median_ppm2 < dkp_low_ppm2 × factor — поднимаем до floor.
|
# Если итоговый median_ppm2 < dkp_low_ppm2 × factor — поднимаем до floor.
|
||||||
# Применяется ТОЛЬКО на radius-пути (anchor_tier is None) и при dkp_raw.
|
# Применяется ТОЛЬКО на radius-пути (anchor_tier is None) и при dkp_raw.
|
||||||
# factor=0.8: 20% зазор ниже P10 коридора → floor достаточно мягкий для эконома
|
# factor=0.8: 20% зазор ниже P10 коридора → floor достаточно мягкий для эконома
|
||||||
# (избегаем ложных подъёмов) и ловит явный undershoot. False → no-op.
|
# (избегаем ложных подъёмов) и ловит явный undershoot.
|
||||||
# ENV: ESTIMATE_RADIUS_FLOOR_ENABLED, ESTIMATE_RADIUS_FLOOR_FACTOR.
|
# ENV: ESTIMATE_RADIUS_FLOOR_FACTOR.
|
||||||
estimate_radius_floor_enabled: bool = True
|
|
||||||
estimate_radius_floor_factor: float = 0.8
|
estimate_radius_floor_factor: float = 0.8
|
||||||
# Шаг 5 — clamp expected_sold <= asking: ratio > 1.0 физически невозможен для
|
# Шаг 5 — clamp expected_sold <= asking: ratio > 1.0 физически невозможен для
|
||||||
# trade-in (ожидаемая цена сделки не должна превышать цену объявления).
|
# trade-in (ожидаемая цена сделки не должна превышать цену объявления).
|
||||||
|
|
@ -215,7 +209,6 @@ class Settings(BaseSettings):
|
||||||
# "novostroyki"-тег = переуступки/перепродажи собственниками (sale_type=free).
|
# "novostroyki"-тег = переуступки/перепродажи собственниками (sale_type=free).
|
||||||
# Чисто-первичный дом (0 вторички) → гард #1186 сохраняется. Tier C / радиус /
|
# Чисто-первичный дом (0 вторички) → гард #1186 сохраняется. Tier C / радиус /
|
||||||
# ratio — не затрагиваются.
|
# ratio — не затрагиваются.
|
||||||
estimate_sb_tier_a_allow_primary_if_secondary_present: bool = True
|
|
||||||
asking_to_sold_haircut: float = 0.05 # дефолтная asking→sold скидка (banded по ppm²)
|
asking_to_sold_haircut: float = 0.05 # дефолтная asking→sold скидка (banded по ppm²)
|
||||||
estimate_fsd_k: float = 1.65 # множитель FSD → полуширина диапазона
|
estimate_fsd_k: float = 1.65 # множитель FSD → полуширина диапазона
|
||||||
|
|
||||||
|
|
@ -225,8 +218,6 @@ class Settings(BaseSettings):
|
||||||
# здоровые якоря (n≥4 с FSD<0.15) проходят без изменений.
|
# здоровые якоря (n≥4 с FSD<0.15) проходят без изменений.
|
||||||
# estimate_sb_gate_min_n=3 : при n<3 И FSD>max_fsd гейт срабатывает
|
# estimate_sb_gate_min_n=3 : при n<3 И FSD>max_fsd гейт срабатывает
|
||||||
# estimate_sb_gate_max_fsd=0.20: FSD>0.20 при малом n → ненадёжный якорь
|
# estimate_sb_gate_max_fsd=0.20: FSD>0.20 при малом n → ненадёжный якорь
|
||||||
# Отдельный флаг: False → точно старое поведение (гейта нет).
|
|
||||||
estimate_sb_low_conf_gate_enabled: bool = True
|
|
||||||
estimate_sb_gate_min_n: int = 3
|
estimate_sb_gate_min_n: int = 3
|
||||||
estimate_sb_gate_max_fsd: float = 0.20
|
estimate_sb_gate_max_fsd: float = 0.20
|
||||||
|
|
||||||
|
|
@ -235,25 +226,12 @@ class Settings(BaseSettings):
|
||||||
# Дефолт 6 (консервативно); аудит предложил 3 — конфигурируемо.
|
# Дефолт 6 (консервативно); аудит предложил 3 — конфигурируемо.
|
||||||
estimate_price_trend_max_age_months: int = 6
|
estimate_price_trend_max_age_months: int = 6
|
||||||
|
|
||||||
# ── Mera-audit fix-3: cross-source dedup в price_trend ────────────────────
|
|
||||||
# Один объект на avito_imv + yandex_valuation с разными ext_item_id даёт
|
|
||||||
# double-count в house_placement_history → шум в помесячной медиане тренда.
|
|
||||||
# True (дефолт) = дедуплицировать строки перед агрегацией по ключу
|
|
||||||
# (round(area_m2,0), floor, COALESCE(last_price,start_price),
|
|
||||||
# COALESCE(last_price_date,start_price_date)), приоритет avito_imv.
|
|
||||||
# False = старое поведение без дедупа (backward-compat).
|
|
||||||
# ENV: ESTIMATE_PRICE_TREND_DEDUP_ENABLED.
|
|
||||||
estimate_price_trend_dedup_enabled: bool = True
|
|
||||||
|
|
||||||
# ── #1871 P1.2: ghost-anchor confidence floor ─────────────────────────────
|
# ── #1871 P1.2: ghost-anchor confidence floor ─────────────────────────────
|
||||||
# True (дефолт) = форсировать confidence='low' + добавлять caveat в explanation
|
# True (дефолт) = форсировать confidence='low' + добавлять caveat в explanation
|
||||||
# когда n_analogs == 0 (нет радиусных/anchor-аналогов) но confidence не 'low'.
|
# когда n_analogs == 0 (нет радиусных/anchor-аналогов) но confidence не 'low'.
|
||||||
# Защита от ghost-anchor: внешние оценочные сервисы (yandex_valuation,
|
# Защита от ghost-anchor: внешние оценочные сервисы (yandex_valuation,
|
||||||
# cian_valuation, avito_imv) могут дать median без единого реального рыночного
|
# cian_valuation, avito_imv) могут дать median без единого реального рыночного
|
||||||
# аналога → headline выглядит достоверным при нулевой реальной базе.
|
# аналога → headline выглядит достоверным при нулевой реальной базе.
|
||||||
# False = старое поведение без принудительного понижения (для отката).
|
|
||||||
# ENV: ESTIMATE_CONFIDENCE_FLOOR_NO_ANALOGS.
|
|
||||||
estimate_confidence_floor_no_analogs: bool = True
|
|
||||||
|
|
||||||
# ── #2002 #4: manual-review recommendation (derived FLAG, НЕ ценовой сигнал) ─
|
# ── #2002 #4: manual-review recommendation (derived FLAG, НЕ ценовой сигнал) ─
|
||||||
# Помечает оценки, которые НЕ стоит авто-оффэрить — нужна ручная оценка
|
# Помечает оценки, которые НЕ стоит авто-оффэрить — нужна ручная оценка
|
||||||
|
|
@ -261,8 +239,6 @@ class Settings(BaseSettings):
|
||||||
# от отделки/вида, чего нет в данных сделок). Триггеры: премиальный дом,
|
# от отделки/вида, чего нет в данных сделок). Триггеры: премиальный дом,
|
||||||
# высокая стоимость, низкая уверенность, слишком широкий диапазон цены.
|
# высокая стоимость, низкая уверенность, слишком широкий диапазон цены.
|
||||||
# Чисто метаданные — не трогает median/expected_sold/ranges (gate byte-stable).
|
# Чисто метаданные — не трогает median/expected_sold/ranges (gate byte-stable).
|
||||||
# False = флаг всегда выключен (manual_review_recommended=False, reasons=[]).
|
|
||||||
estimate_manual_review_enabled: bool = True
|
|
||||||
estimate_manual_review_high_value_rub: int = 20_000_000 # ≥ этого — ручная оценка
|
estimate_manual_review_high_value_rub: int = 20_000_000 # ≥ этого — ручная оценка
|
||||||
estimate_manual_review_wide_range_ratio: float = 1.9 # range_high/range_low ≥ — неопределённо
|
estimate_manual_review_wide_range_ratio: float = 1.9 # range_high/range_low ≥ — неопределённо
|
||||||
# asking ₽/м² ≥ этого → дорогой сегмент, авто-оценка консервативна
|
# asking ₽/м² ≥ этого → дорогой сегмент, авто-оценка консервативна
|
||||||
|
|
@ -274,12 +250,8 @@ class Settings(BaseSettings):
|
||||||
# (rn_addr <= MAX_ANALOGS_PER_ADDRESS), но (source, source_id)-дубли делят один
|
# (rn_addr <= MAX_ANALOGS_PER_ADDRESS), но (source, source_id)-дубли делят один
|
||||||
# address и выживают на разных rn_addr рангах → раздувают n_analogs (prod
|
# address и выживают на разных rn_addr рангах → раздувают n_analogs (prod
|
||||||
# 2026-06-23: yandex 48, cian 9, n1 5 excess). Anchor-путь дедупит по
|
# 2026-06-23: yandex 48, cian 9, n1 5 excess). Anchor-путь дедупит по
|
||||||
# (source, source_id) — radius нет. True (дефолт) добавляет rn_dup=1 фильтр в
|
# (source, source_id) — radius нет. Добавляет rn_dup=1 фильтр в каждом тире
|
||||||
# каждом тире (freshest scraped_at на (source, source_id|source_url|ctid)).
|
# (freshest scraped_at на (source, source_id|source_url|ctid)).
|
||||||
# False = старое поведение без radius-дедупа (для отката). Окно rn_dup всегда
|
|
||||||
# в SQL (безвредно без фильтра); флаг управляет только применением фильтра.
|
|
||||||
# ENV: ESTIMATE_RADIUS_DEDUP_ENABLED.
|
|
||||||
estimate_radius_dedup_enabled: bool = True
|
|
||||||
|
|
||||||
# ── #2087 H4: кросс-source физический дедуп аналогов ──────────────────────
|
# ── #2087 H4: кросс-source физический дедуп аналогов ──────────────────────
|
||||||
# Radius-дедуп выше ловит только повторы ВНУТРИ одного source (source, source_id).
|
# Radius-дедуп выше ловит только повторы ВНУТРИ одного source (source, source_id).
|
||||||
|
|
@ -302,7 +274,7 @@ class Settings(BaseSettings):
|
||||||
# ТУ ЖЕ цену → нулевой вклад в дисперсию) → cv/коридор НЕ сужаются. Это фикс
|
# ТУ ЖЕ цену → нулевой вклад в дисперсию) → cv/коридор НЕ сужаются. Это фикс
|
||||||
# ЧЕСТНОСТИ СЧЁТА (n_analogs не раздут ×3 кросс-постами, source_counts по
|
# ЧЕСТНОСТИ СЧЁТА (n_analogs не раздут ×3 кросс-постами, source_counts по
|
||||||
# физлотам), accuracy-нейтральный, а НЕ рычаг сужения cv (рычаг cv→коридор —
|
# физлотам), accuracy-нейтральный, а НЕ рычаг сужения cv (рычаг cv→коридор —
|
||||||
# estimate_sb_clip_after_weight, уже ON). Default ON (#2173): бэктест #1966 OFF vs
|
# post-weight MAD-clip, уже ON). Default ON (#2173): бэктест #1966 OFF vs
|
||||||
# ON accuracy-идентичен (MAPE 13.89%, coverage 83.33%, bias −3.83%, median width/cv
|
# ON accuracy-идентичен (MAPE 13.89%, coverage 83.33%, bias −3.83%, median width/cv
|
||||||
# без изменений), меняется только user-visible n_analogs — перестаёт быть раздутым
|
# без изменений), меняется только user-visible n_analogs — перестаёт быть раздутым
|
||||||
# кросс-постингом ×3. ENV: ESTIMATE_DEDUP_ANALOGS_ENABLED (=false откатывает).
|
# кросс-постингом ×3. ENV: ESTIMATE_DEDUP_ANALOGS_ENABLED (=false откатывает).
|
||||||
|
|
@ -367,11 +339,9 @@ class Settings(BaseSettings):
|
||||||
# Tier A (same-building) матчит по address-regex (намеренно НЕ house_id — дом
|
# Tier A (same-building) матчит по address-regex (намеренно НЕ house_id — дом
|
||||||
# дробится на несколько house_id). На split-доме разной этажности comp_min..max
|
# дробится на несколько house_id). На split-доме разной этажности comp_min..max
|
||||||
# растягивается через несколько ценовых режимов → коридор range_low/high
|
# растягивается через несколько ценовых режимов → коридор range_low/high
|
||||||
# 148%/170%. Коридор честно широкий, но юзер видит 170% без объяснения. True +
|
# 148%/170%. Коридор честно широкий, но юзер видит 170% без объяснения. Tier A +
|
||||||
# Tier A + corridor_pct > threshold → понижаем confidence на ступень и дописываем
|
# corridor_pct > threshold → понижаем confidence на ступень и дописываем
|
||||||
# disclosure в explanation. НЕ трогает point/median/range.
|
# disclosure в explanation. НЕ трогает point/median/range.
|
||||||
# ENV: ESTIMATE_WIDE_CORRIDOR_DISCLOSURE_ENABLED.
|
|
||||||
estimate_wide_corridor_disclosure_enabled: bool = True
|
|
||||||
# Порог ширины коридора (range_high-range_low)/median_price для disclosure.
|
# Порог ширины коридора (range_high-range_low)/median_price для disclosure.
|
||||||
# 1.2 (120%): по prod-данным corridor_pct median≈0.48, p90≈0.93 — порог 0.6
|
# 1.2 (120%): по prod-данным corridor_pct median≈0.48, p90≈0.93 — порог 0.6
|
||||||
# фаерил бы на ~31% оценок (широкий коридор ≠ split-дом, ложная атрибуция).
|
# фаерил бы на ~31% оценок (широкий коридор ≠ split-дом, ложная атрибуция).
|
||||||
|
|
@ -388,11 +358,6 @@ class Settings(BaseSettings):
|
||||||
cian_valuation_min_rub: float = 500_000
|
cian_valuation_min_rub: float = 500_000
|
||||||
cian_valuation_max_rub: float = 500_000_000
|
cian_valuation_max_rub: float = 500_000_000
|
||||||
|
|
||||||
# ── #audit-4: MAD-clip after similarity-weighting ─────────────────────────
|
|
||||||
# True = clip происходит ПОСЛЕ similarity-weighting (на взвешенных ppm²).
|
|
||||||
# False = clip ДО weighting (старое поведение). Дефолт True.
|
|
||||||
estimate_sb_clip_after_weight: bool = True
|
|
||||||
|
|
||||||
# ── #audit-5: data-age guards ─────────────────────────────────────────────
|
# ── #audit-5: data-age guards ─────────────────────────────────────────────
|
||||||
# sber_index_max_age_days: максимальный допустимый возраст последнего месяца
|
# sber_index_max_age_days: максимальный допустимый возраст последнего месяца
|
||||||
# СберИндекс-серии (дней). Если latest месяц старее — логируем warning.
|
# СберИндекс-серии (дней). Если latest месяц старее — логируем warning.
|
||||||
|
|
@ -410,8 +375,6 @@ class Settings(BaseSettings):
|
||||||
# между квартальным уровнем целевого объекта и усреднённым квартальным уровнем
|
# между квартальным уровнем целевого объекта и усреднённым квартальным уровнем
|
||||||
# аналогов — не дублирует location, уже заложенный в медиану аналогов.
|
# аналогов — не дублирует location, уже заложенный в медиану аналогов.
|
||||||
# Формула: adjusted_ppm2 = base_ppm2 × target_index / avg_analog_index.
|
# Формула: adjusted_ppm2 = base_ppm2 × target_index / avg_analog_index.
|
||||||
# За флагом: False → точно текущее поведение (backward-compatible).
|
|
||||||
estimate_quarter_index_enabled: bool = True
|
|
||||||
# Минимальное число сделок в квартале (sparse fallback: меньше — no-op).
|
# Минимальное число сделок в квартале (sparse fallback: меньше — no-op).
|
||||||
estimate_quarter_index_min_n_deals: int = 10
|
estimate_quarter_index_min_n_deals: int = 10
|
||||||
# Guard-2 (no double-count): если доля аналогов ИЗ ТОГО ЖЕ квартала > порога —
|
# Guard-2 (no double-count): если доля аналогов ИЗ ТОГО ЖЕ квартала > порога —
|
||||||
|
|
@ -701,5 +664,19 @@ class Settings(BaseSettings):
|
||||||
# честная маркировка.
|
# честная маркировка.
|
||||||
sell_time_sensitivity_min_n_lots: int = 10
|
sell_time_sensitivity_min_n_lots: int = 10
|
||||||
|
|
||||||
|
# ── Telegram support bridge (@MERAsupport_bot) ───────────────────────────
|
||||||
|
# Клиент пишет боту в личку → зеркалится в топик support-группы → оператор
|
||||||
|
# отвечает реплаем в топике → бот доставляет ответ клиенту. Standalone
|
||||||
|
# long-polling воркер (app.tgbot_main), НЕ webhook — см. app/services/tgbot/.
|
||||||
|
# Пусто/0 = бот выключен: tgbot_main логирует «disabled» и выходит с кодом 0
|
||||||
|
# (чтобы контейнер без секрета не крутил рестарт-луп). ENV: TELEGRAM_BOT_TOKEN,
|
||||||
|
# TELEGRAM_SUPPORT_CHAT_ID, TELEGRAM_SUPPORT_TOPIC_ID.
|
||||||
|
telegram_bot_token: str = Field(default="", validation_alias="TELEGRAM_BOT_TOKEN")
|
||||||
|
# Telegram id форум-группы (супергруппы с включёнными топиками), куда
|
||||||
|
# зеркалятся обращения клиентов. Отрицательный для supergroup id (напр. -100...).
|
||||||
|
telegram_support_chat_id: int = Field(default=0, validation_alias="TELEGRAM_SUPPORT_CHAT_ID")
|
||||||
|
# message_thread_id топика внутри support-группы, в который идут зеркала.
|
||||||
|
telegram_support_topic_id: int = Field(default=0, validation_alias="TELEGRAM_SUPPORT_TOPIC_ID")
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
|
||||||
71
tradein-mvp/backend/app/core/request_audit.py
Normal file
71
tradein-mvp/backend/app/core/request_audit.py
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
"""RequestAuditMiddleware — пишет `api_request` (+ дедуплицированный `login`)
|
||||||
|
события в `user_events` для каждого аутентифицированного `/api/*` запроса.
|
||||||
|
|
||||||
|
Foundation для Feature 2 (login/IP audit) и базы Feature 3 (behavior analytics).
|
||||||
|
Логирование выполняется ПОСЛЕ `call_next` (не задерживает и не ветвит реальный
|
||||||
|
ответ клиенту) и через fire-and-forget `schedule_event` — сбой аудита никогда
|
||||||
|
не влияет на HTTP-ответ.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
|
from app.core.ratelimit import _client_ip
|
||||||
|
from app.services.user_events import schedule_event, should_log_login
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Зеркалит app.main._PUBLIC_PATHS. Не импортируем напрямую из app.main — оно
|
||||||
|
# импортирует этот модуль (регистрирует middleware), обратный импорт дал бы
|
||||||
|
# циклическую зависимость.
|
||||||
|
_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"})
|
||||||
|
|
||||||
|
|
||||||
|
class RequestAuditMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Логирует активность аутентифицированных пользователей в `user_events`."""
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next): # type: ignore[no-untyped-def]
|
||||||
|
response: Response = await call_next(request)
|
||||||
|
|
||||||
|
try:
|
||||||
|
username = request.headers.get("x-authenticated-user")
|
||||||
|
path = request.url.path
|
||||||
|
if username and path.startswith("/api/") and path not in _PUBLIC_PATHS:
|
||||||
|
ip = _client_ip(request)
|
||||||
|
ua = request.headers.get("user-agent")
|
||||||
|
|
||||||
|
# Общий behavior/activity-поток — каждый authenticated API-запрос.
|
||||||
|
# /api/v1/admin/* исключаем: это ops-действия (просмотр самих
|
||||||
|
# дашбордов аудита/аналитики), а не поведение пилота — иначе
|
||||||
|
# запросы дашборда зашумляют top_paths и счётчики активности.
|
||||||
|
# login ниже логируем всегда (вход админа с IP — валидный аудит).
|
||||||
|
if not path.startswith("/api/v1/admin/"):
|
||||||
|
schedule_event(
|
||||||
|
event_type="api_request",
|
||||||
|
username=username,
|
||||||
|
ip=ip,
|
||||||
|
user_agent=ua,
|
||||||
|
path=path,
|
||||||
|
method=request.method,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Дедуплицированный login/IP-audit сигнал — максимум раз в день
|
||||||
|
# на (юзер, IP, устройство).
|
||||||
|
if should_log_login(username, ip, ua):
|
||||||
|
schedule_event(
|
||||||
|
event_type="login",
|
||||||
|
username=username,
|
||||||
|
ip=ip,
|
||||||
|
user_agent=ua,
|
||||||
|
path=path,
|
||||||
|
method=request.method,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("RequestAuditMiddleware: failed to record event", exc_info=True)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
@ -23,12 +23,13 @@ from sentry_sdk.integrations.logging import LoggingIntegration
|
||||||
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
|
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
|
||||||
from sentry_sdk.integrations.starlette import StarletteIntegration
|
from sentry_sdk.integrations.starlette import StarletteIntegration
|
||||||
|
|
||||||
from app.api.v1 import admin, brand, buildings, geocode, lead, me, search, trade_in
|
from app.api.v1 import admin, audit, brand, buildings, geocode, lead, me, search, trade_in
|
||||||
from app.core.auth import get_role
|
from app.core.auth import get_role, is_path_allowed
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.db import SessionLocal
|
from app.core.db import SessionLocal
|
||||||
from app.core.fdw import ensure_fdw_user_mapping
|
from app.core.fdw import ensure_fdw_user_mapping
|
||||||
from app.core.ratelimit import RateLimitMiddleware
|
from app.core.ratelimit import RateLimitMiddleware
|
||||||
|
from app.core.request_audit import RequestAuditMiddleware
|
||||||
from app.observability.sentry_scrub import scrub_pii_event
|
from app.observability.sentry_scrub import scrub_pii_event
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -108,6 +109,14 @@ app = FastAPI(
|
||||||
# X-Authenticated-User там не приходит из Caddy.
|
# X-Authenticated-User там не приходит из Caddy.
|
||||||
_ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
|
_ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
|
||||||
_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"})
|
_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"})
|
||||||
|
# #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед
|
||||||
|
# tradein-backend, а globs в roles.yaml — ВНЕШНИЕ (/trade-in/api/v1/**). Для
|
||||||
|
# scope-проверки восстанавливаем внешний путь.
|
||||||
|
_EXTERNAL_PREFIX = "/trade-in"
|
||||||
|
# Bootstrap-пути, доступные ЛЮБОМУ известному юзеру независимо от роли: /me отдаёт
|
||||||
|
# роль (expired → trial-экран), /brand/* — брендинг login/trial-экрана. Без них
|
||||||
|
# expired (roles.yaml paths:[] deny:/**) не получил бы роль и не увидел trial-экран.
|
||||||
|
_RBAC_BOOTSTRAP_EXEMPT = ("/api/v1/me", "/api/v1/brand")
|
||||||
|
|
||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
|
|
@ -160,6 +169,39 @@ async def rbac_guard(
|
||||||
status_code=403,
|
status_code=403,
|
||||||
content={"detail": "admin only"},
|
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-гейт выше остаётся).
|
||||||
|
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 not allowed:
|
||||||
|
logger.info(
|
||||||
|
"RBAC: scope-blocked %s (role=%s) from %s (ext=%s)",
|
||||||
|
username,
|
||||||
|
role,
|
||||||
|
path,
|
||||||
|
external_path,
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=403,
|
||||||
|
content={"detail": "forbidden for role"},
|
||||||
|
)
|
||||||
|
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -172,6 +214,8 @@ app.add_middleware(
|
||||||
)
|
)
|
||||||
# Rate-limit публичного API (per-user / per-IP sliding window) — защита от абуза.
|
# Rate-limit публичного API (per-user / per-IP sliding window) — защита от абуза.
|
||||||
app.add_middleware(RateLimitMiddleware)
|
app.add_middleware(RateLimitMiddleware)
|
||||||
|
# Request-audit: пишет api_request/login события в user_events (Feature 2/3 foundation).
|
||||||
|
app.add_middleware(RequestAuditMiddleware)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|
@ -181,6 +225,7 @@ def health() -> dict[str, str]:
|
||||||
|
|
||||||
app.include_router(geocode.router, prefix="/api/v1/geocode", tags=["geocode"])
|
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(admin.router, prefix="/api/v1/admin", tags=["admin"])
|
||||||
|
app.include_router(audit.router, prefix="/api/v1/admin", tags=["admin-audit"])
|
||||||
app.include_router(brand.router, prefix="/api/v1/brand", tags=["brand"])
|
app.include_router(brand.router, prefix="/api/v1/brand", tags=["brand"])
|
||||||
app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"])
|
app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"])
|
||||||
app.include_router(lead.router, prefix="/api/v1/trade-in", tags=["trade-in"])
|
app.include_router(lead.router, prefix="/api/v1/trade-in", tags=["trade-in"])
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,51 @@
|
||||||
"""Хук before_send для GlitchTip/Sentry SDK (tradein-local, #396).
|
"""Хуки before_send для GlitchTip/Sentry SDK (tradein-local, #396, #tgsupport).
|
||||||
|
|
||||||
Redact-ит consumer-PII (client_name / client_phone / client_email и пр.)
|
Redact-ит consumer-PII (client_name / client_phone / client_email и пр.)
|
||||||
из error events до отправки в GlitchTip — estimator/trade-in flow таскает
|
из error events до отправки в GlitchTip — estimator/trade-in flow таскает
|
||||||
эти поля, а send_default_pii=False их не покрывает (это user-data в
|
эти поля, а send_default_pii=False их не покрывает (это user-data в
|
||||||
request.data / extra / contexts, не PII-заголовки).
|
request.data / extra / contexts, не PII-заголовки).
|
||||||
|
|
||||||
|
`redact_telegram_bot_token` — отдельный хук (#tgsupport review): Telegram Bot
|
||||||
|
API токен живёт в URL-пути (`https://api.telegram.org/bot<id>:<secret>/...`),
|
||||||
|
а не в query/userinfo, поэтому НЕ покрывается sentry_sdk `sanitize_url` (тот
|
||||||
|
режет только `user:pass@` и query-параметры). Токен утекает ДВУМЯ путями,
|
||||||
|
которые `_scrub`/`scrub_pii_event` (ключ-based, PII-словарь) не ловят:
|
||||||
|
1. `include_local_variables=True` (sentry_sdk default) кладёт locals
|
||||||
|
stack-фрейма (`self._base`, `url` в `TelegramClient._request`) в
|
||||||
|
traceback → полный токен открытым текстом.
|
||||||
|
2. `HttpxIntegration` кладёт полный request URL в span `data` (виден при
|
||||||
|
любом ненулевом `traces_sample_rate`), а не только в traceback.
|
||||||
|
Поэтому редактор — НЕ ключ-based, а regex full-text по КАЖДОЙ строке во всём
|
||||||
|
event (глубокий обход dict/list/tuple) — токен может всплыть в любом поле.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sentry_sdk.types import Event
|
from sentry_sdk.types import Event
|
||||||
|
|
||||||
_REDACTED = "[REDACTED]"
|
_REDACTED = "[REDACTED]"
|
||||||
# Ключи consumer-PII (нижний регистр; сверка case-insensitive).
|
# Ключи consumer-PII (нижний регистр; сверка case-insensitive).
|
||||||
_PII_KEYS = frozenset(
|
_PII_KEYS = frozenset({"client_name", "client_phone", "client_email", "phone", "email", "name"})
|
||||||
{"client_name", "client_phone", "client_email", "phone", "email", "name"}
|
|
||||||
)
|
# Telegram Bot API токен в пути URL: /bot<numeric_id>:<secret-part>/<method>.
|
||||||
|
# Матчим ровно этот сегмент (не весь URL) — сохраняет остальной путь/query
|
||||||
|
# читаемым для диагностики (метод API, error code и т.п.).
|
||||||
|
_TG_BOT_TOKEN_RE = re.compile(r"/bot\d+:[A-Za-z0-9_-]+")
|
||||||
|
_TG_BOT_TOKEN_REPLACEMENT = "/bot[REDACTED]"
|
||||||
|
|
||||||
|
# Тот же токен БЕЗ префикса `/bot` — форма `<numeric_id>:<secret>` сама по себе
|
||||||
|
# (напр. локаль `token` в конструкторе TelegramClient, или если его кто-то
|
||||||
|
# засунет в log-сообщение). Сейчас единственный путь такой формы в event —
|
||||||
|
# locals стек-фрейма, а они выключены через include_local_variables=False в
|
||||||
|
# tgbot_main. Но именно на отказ того флага этот редактор и страхует: без этой
|
||||||
|
# ветки рубеж был бы один, а не два. Формат токена BotFather: 8-12 цифр `:` 35
|
||||||
|
# символов base64url — нижние границы взяты с запасом, чтобы не промахнуться
|
||||||
|
# на нестандартных id, но остаться уже, чем `\d+:\S+` (тот бил бы по любым
|
||||||
|
# `id:value` в логах, напр. `chat_id:12345`).
|
||||||
|
_TG_BOT_TOKEN_BARE_RE = re.compile(r"\b\d{6,12}:[A-Za-z0-9_-]{30,}\b")
|
||||||
|
|
||||||
|
|
||||||
def _scrub(obj: Any) -> None:
|
def _scrub(obj: Any) -> None:
|
||||||
|
|
@ -42,3 +71,34 @@ def scrub_pii_event(event: Event, _hint: dict[str, Any]) -> Event | None:
|
||||||
_scrub(event.get("extra"))
|
_scrub(event.get("extra"))
|
||||||
_scrub(event.get("contexts"))
|
_scrub(event.get("contexts"))
|
||||||
return event
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
def _redact_strings(obj: Any) -> Any:
|
||||||
|
"""Рекурсивно проходит dict/list/tuple и прогоняет обе токен-регулярки по КАЖДОЙ
|
||||||
|
строке (не только по конкретным ключам) — токен может оказаться в locals
|
||||||
|
stack-фрейма, span data, breadcrumb message, request.url и т.д. Возвращает
|
||||||
|
НОВУЮ структуру (не мутирует `obj` — в отличие от `_scrub`, чтобы не зависеть
|
||||||
|
от того, какие контейнеры sentry_sdk считает mutable в своём event dict)."""
|
||||||
|
if isinstance(obj, str):
|
||||||
|
redacted = _TG_BOT_TOKEN_RE.sub(_TG_BOT_TOKEN_REPLACEMENT, obj)
|
||||||
|
return _TG_BOT_TOKEN_BARE_RE.sub(_REDACTED, redacted)
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return {k: _redact_strings(v) for k, v in obj.items()}
|
||||||
|
if isinstance(obj, list):
|
||||||
|
return [_redact_strings(v) for v in obj]
|
||||||
|
if isinstance(obj, tuple):
|
||||||
|
return tuple(_redact_strings(v) for v in obj)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def redact_telegram_bot_token(event: Event, _hint: dict[str, Any]) -> Event | None:
|
||||||
|
"""Full-text regex redaction Telegram Bot API токена по ВСЕМУ event (#tgsupport).
|
||||||
|
|
||||||
|
Ловит оба вектора утечки токена в GlitchTip, которые ключ-based `scrub_pii_event`
|
||||||
|
не покрывает: locals stack-фреймов (`include_local_variables=True`) и httpx-span
|
||||||
|
`data` (полный request URL). Композировать с `scrub_pii_event`, не вместо него —
|
||||||
|
разные классы секретов (PII полей формы vs bot-токен в URL).
|
||||||
|
"""
|
||||||
|
if not isinstance(event, dict):
|
||||||
|
return event
|
||||||
|
return _redact_strings(event) # type: ignore[return-value]
|
||||||
|
|
|
||||||
146
tradein-mvp/backend/app/schemas/audit.py
Normal file
146
tradein-mvp/backend/app/schemas/audit.py
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
"""Pydantic-схемы admin read API над `user_events` (Feature 2 audit + Feature 3 analytics).
|
||||||
|
|
||||||
|
`user_events` (migration `184_user_events.sql`) — unified append-only событийный лог
|
||||||
|
(login/IP audit + behavior analytics), admin-read-only. Эти схемы описывают ответы
|
||||||
|
`GET /api/v1/admin/audit/*` и `GET /api/v1/admin/analytics` (см. app/api/v1/audit.py).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class AccountSummary(BaseModel):
|
||||||
|
"""Одна строка списка `GET /audit/accounts` — сводка по одному username."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
username: str
|
||||||
|
first_seen_at: datetime
|
||||||
|
last_seen_at: datetime
|
||||||
|
distinct_ips: int
|
||||||
|
distinct_devices: int
|
||||||
|
login_count: int
|
||||||
|
request_count: int
|
||||||
|
search_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class AccountIpEntry(BaseModel):
|
||||||
|
"""Одна строка `ips` в drilldown `GET /audit/accounts/{username}`."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
ip_address: str | None = None
|
||||||
|
event_count: int
|
||||||
|
first_seen: datetime
|
||||||
|
last_seen: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AccountDeviceEntry(BaseModel):
|
||||||
|
"""Одна строка `devices` в drilldown `GET /audit/accounts/{username}`."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
user_agent: str | None = None
|
||||||
|
event_count: int
|
||||||
|
first_seen: datetime
|
||||||
|
last_seen: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AccountSearchEntry(BaseModel):
|
||||||
|
"""Одна строка `searches` в drilldown — недавний estimate_request."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
address: str | None = None
|
||||||
|
area_m2: str | None = None
|
||||||
|
rooms: str | None = None
|
||||||
|
estimate_id: str | None = None
|
||||||
|
ip_address: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AccountActivityEntry(BaseModel):
|
||||||
|
"""Одна строка `recent_activity` в drilldown — сырое событие."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
event_type: str
|
||||||
|
path: str | None = None
|
||||||
|
method: str | None = None
|
||||||
|
ip_address: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AccountDrilldown(BaseModel):
|
||||||
|
"""Полный ответ `GET /audit/accounts/{username}` — 4 списка.
|
||||||
|
|
||||||
|
Неизвестный username НЕ 404 — это просто пустой отчёт (все списки == []).
|
||||||
|
"""
|
||||||
|
|
||||||
|
ips: list[AccountIpEntry]
|
||||||
|
devices: list[AccountDeviceEntry]
|
||||||
|
searches: list[AccountSearchEntry]
|
||||||
|
recent_activity: list[AccountActivityEntry]
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsSummary(BaseModel):
|
||||||
|
"""Верхнеуровневые счётчики дашборда `GET /analytics`."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
total_events: int
|
||||||
|
distinct_users: int
|
||||||
|
events_last_24h: int
|
||||||
|
active_users_last_24h: int
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsDailyPoint(BaseModel):
|
||||||
|
"""Одна точка time-series `daily` — события/пользователи за день."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
day: date
|
||||||
|
events: int
|
||||||
|
users: int
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsTopSearch(BaseModel):
|
||||||
|
"""Одна строка `top_searches` — самый частый искомый адрес."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
address: str | None = None
|
||||||
|
n: int
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsTopPath(BaseModel):
|
||||||
|
"""Одна строка `top_paths` — самый частый API-путь."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
path: str | None = None
|
||||||
|
n: int
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsByAccount(BaseModel):
|
||||||
|
"""Одна строка `by_account` — сводка активности по username."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
username: str
|
||||||
|
events: int
|
||||||
|
searches: int
|
||||||
|
last_seen: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsDashboard(BaseModel):
|
||||||
|
"""Полный ответ `GET /analytics` — бандл для дашборда Feature 3."""
|
||||||
|
|
||||||
|
summary: AnalyticsSummary
|
||||||
|
daily: list[AnalyticsDailyPoint]
|
||||||
|
top_searches: list[AnalyticsTopSearch]
|
||||||
|
top_paths: list[AnalyticsTopPath]
|
||||||
|
by_account: list[AnalyticsByAccount]
|
||||||
|
|
@ -222,7 +222,9 @@ class AggregatedEstimate(BaseModel):
|
||||||
# конкретному дому за 12 мес) — другая база и другой смысл «торга», см. докстринг
|
# конкретному дому за 12 мес) — другая база и другой смысл «торга», см. докстринг
|
||||||
# HouseAnalyticsKpi.
|
# HouseAnalyticsKpi.
|
||||||
asking_to_sold_ratio: float | None = None # =sold/asking, ~0.72–0.93
|
asking_to_sold_ratio: float | None = None # =sold/asking, ~0.72–0.93
|
||||||
ratio_basis: str | None = None # 'per_rooms' | 'global_fallback'
|
# 'per_rooms' | 'global_fallback' | 'expected_over_median' (LOW audit #2: set when
|
||||||
|
# #2141's honest_ratio recompute overwrites the raw ratio_resolver value below).
|
||||||
|
ratio_basis: str | None = None
|
||||||
# ── DaData enrichment (PR Q1) — on-demand для target адреса ──
|
# ── DaData enrichment (PR Q1) — on-demand для target адреса ──
|
||||||
# canonical_address — DaData-нормализованная форма (с улицей в short form).
|
# canonical_address — DaData-нормализованная форма (с улицей в short form).
|
||||||
# house_cadnum — кадастровый номер ДОМА (для будущего matching Росреестра).
|
# house_cadnum — кадастровый номер ДОМА (для будущего matching Росреестра).
|
||||||
|
|
@ -266,7 +268,7 @@ class AggregatedEstimate(BaseModel):
|
||||||
# оценка человеком (премиальный дом / высокая стоимость / низкая уверенность /
|
# оценка человеком (премиальный дом / высокая стоимость / низкая уверенность /
|
||||||
# широкий диапазон). Дефолт False — не трогает median/expected_sold/ranges.
|
# широкий диапазон). Дефолт False — не трогает median/expected_sold/ranges.
|
||||||
# manual_review_reasons — человекочитаемые RU-причины для ревьюера; пусто, когда
|
# manual_review_reasons — человекочитаемые RU-причины для ревьюера; пусто, когда
|
||||||
# рекомендация не сработала (или флаг estimate_manual_review_enabled выключен).
|
# рекомендация не сработала.
|
||||||
manual_review_recommended: bool = False
|
manual_review_recommended: bool = False
|
||||||
manual_review_reasons: list[str] = Field(default_factory=list)
|
manual_review_reasons: list[str] = Field(default_factory=list)
|
||||||
# ── #2043 (BE-1): метрики достоверности выборки — уже считаются, отдаём наружу ──
|
# ── #2043 (BE-1): метрики достоверности выборки — уже считаются, отдаём наружу ──
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
"""Сервис квоты оценок trade-in — N успешных оценок в месяц на аккаунт.
|
"""Сервис квоты оценок trade-in — N успешных оценок в месяц на аккаунт.
|
||||||
|
|
||||||
Правила:
|
Правила:
|
||||||
- Лимит = settings.estimate_quota_limit успешных оценок за календарный месяц
|
- Лимит по умолчанию = settings.estimate_quota_limit успешных оценок за календарный
|
||||||
(UTC, период 'YYYY-MM'); конфигурируется через env ESTIMATE_QUOTA_LIMIT, default 15.
|
месяц (UTC, период 'YYYY-MM'); конфигурируется через env ESTIMATE_QUOTA_LIMIT,
|
||||||
|
default 15.
|
||||||
|
- Персональный override: таблица account_quota_overrides (username → monthly_limit),
|
||||||
|
см. миграцию 185_account_quota_overrides.sql. Заменяет прежний хак бонусных попыток
|
||||||
|
через negative `used` (ломал /quota — «Осталось 50 из 15»).
|
||||||
- Без лимита (unlimited): роль admin ИЛИ username == 'kopylov'.
|
- Без лимита (unlimited): роль admin ИЛИ username == 'kopylov'.
|
||||||
- Учитываются ТОЛЬКО успешные оценки (инкремент ПОСЛЕ estimate_quality).
|
- Учитываются ТОЛЬКО успешные оценки (инкремент ПОСЛЕ estimate_quality).
|
||||||
- Если заголовок X-Authenticated-User отсутствует (dev без Caddy) → unlimited,
|
- Если заголовок X-Authenticated-User отсутствует (dev без Caddy) → unlimited,
|
||||||
|
|
@ -53,11 +57,34 @@ def is_unlimited(username: str) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def user_limit(db: Session, username: str) -> int:
|
||||||
|
"""Персональный месячный лимит для username, иначе глобальный MONTHLY_LIMIT.
|
||||||
|
|
||||||
|
Источник override — таблица account_quota_overrides (см. миграцию
|
||||||
|
185_account_quota_overrides.sql). Заменяет прежний хак бонусных попыток через
|
||||||
|
negative `used`, который ломал /quota (limit=15, used=-35 → remaining=50 —
|
||||||
|
«Осталось 50 из 15»).
|
||||||
|
"""
|
||||||
|
row = db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT monthly_limit FROM account_quota_overrides
|
||||||
|
WHERE username = :u
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"u": username},
|
||||||
|
).fetchone()
|
||||||
|
if row is not None and row.monthly_limit is not None:
|
||||||
|
return int(row.monthly_limit)
|
||||||
|
return MONTHLY_LIMIT
|
||||||
|
|
||||||
|
|
||||||
def get_status(db: Session, username: str | None) -> dict:
|
def get_status(db: Session, username: str | None) -> dict:
|
||||||
"""Возвращает статус квоты для пользователя.
|
"""Возвращает статус квоты для пользователя.
|
||||||
|
|
||||||
Если username is None → unlimited True, used 0, remaining 15.
|
Если username is None → unlimited True, used 0, remaining = MONTHLY_LIMIT.
|
||||||
Если unlimited → used = фактический или 0, remaining = limit.
|
Если unlimited → used = фактический или 0, remaining = limit (per-user override
|
||||||
|
или глобальный MONTHLY_LIMIT).
|
||||||
"""
|
"""
|
||||||
if username is None:
|
if username is None:
|
||||||
return {
|
return {
|
||||||
|
|
@ -69,6 +96,7 @@ def get_status(db: Session, username: str | None) -> dict:
|
||||||
|
|
||||||
unlimited = is_unlimited(username)
|
unlimited = is_unlimited(username)
|
||||||
period = current_period()
|
period = current_period()
|
||||||
|
limit = user_limit(db, username)
|
||||||
|
|
||||||
row = db.execute(
|
row = db.execute(
|
||||||
text(
|
text(
|
||||||
|
|
@ -84,15 +112,18 @@ def get_status(db: Session, username: str | None) -> dict:
|
||||||
|
|
||||||
if unlimited:
|
if unlimited:
|
||||||
return {
|
return {
|
||||||
"limit": MONTHLY_LIMIT,
|
"limit": limit,
|
||||||
"used": used,
|
"used": used,
|
||||||
"remaining": MONTHLY_LIMIT,
|
"remaining": limit,
|
||||||
"unlimited": True,
|
"unlimited": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
remaining = max(0, MONTHLY_LIMIT - used)
|
# Защитный кламп: remaining никогда не превышает limit, даже если used всё же
|
||||||
|
# снова просочится отрицательным (прежний бонус-хак) — max(0, used) обнуляет
|
||||||
|
# отрицательный used перед вычитанием.
|
||||||
|
remaining = max(0, limit - max(0, used))
|
||||||
return {
|
return {
|
||||||
"limit": MONTHLY_LIMIT,
|
"limit": limit,
|
||||||
"used": used,
|
"used": used,
|
||||||
"remaining": remaining,
|
"remaining": remaining,
|
||||||
"unlimited": False,
|
"unlimited": False,
|
||||||
|
|
@ -111,6 +142,7 @@ def check_and_raise(db: Session, username: str | None) -> None:
|
||||||
return
|
return
|
||||||
|
|
||||||
period = current_period()
|
period = current_period()
|
||||||
|
limit = user_limit(db, username)
|
||||||
row = db.execute(
|
row = db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
|
|
@ -122,13 +154,13 @@ def check_and_raise(db: Session, username: str | None) -> None:
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
used = row.used if row is not None else 0
|
used = row.used if row is not None else 0
|
||||||
if used >= MONTHLY_LIMIT:
|
if used >= limit:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"quota exhausted: username=%r period=%s used=%d limit=%d",
|
"quota exhausted: username=%r period=%s used=%d limit=%d",
|
||||||
username,
|
username,
|
||||||
period,
|
period,
|
||||||
used,
|
used,
|
||||||
MONTHLY_LIMIT,
|
limit,
|
||||||
)
|
)
|
||||||
raise HTTPException(status_code=429, detail=LIMIT_EXHAUSTED_MESSAGE)
|
raise HTTPException(status_code=429, detail=LIMIT_EXHAUSTED_MESSAGE)
|
||||||
|
|
||||||
|
|
@ -140,14 +172,16 @@ def increment(db: Session, username: str | None) -> bool:
|
||||||
None / unlimited → True (no-op success).
|
None / unlimited → True (no-op success).
|
||||||
|
|
||||||
Защита от TOCTOU: предикат `WHERE used < :lim` применяется к ветке DO UPDATE —
|
Защита от TOCTOU: предикат `WHERE used < :lim` применяется к ветке DO UPDATE —
|
||||||
два параллельных запроса при used=MONTHLY_LIMIT-1 не могут оба инкрементировать
|
два параллельных запроса при used=lim-1 не могут оба инкрементировать (второй
|
||||||
(второй упрётся в WHERE → RETURNING пуст → False). Свежая вставка (used=1) НЕ
|
упрётся в WHERE → RETURNING пуст → False). Свежая вставка (used=1) НЕ задевается
|
||||||
задевается WHERE (он только для DO UPDATE), поэтому первая оценка месяца проходит.
|
WHERE (он только для DO UPDATE), поэтому первая оценка месяца проходит. `lim` —
|
||||||
|
персональный лимит (user_limit), НЕ жёстко зашитый глобальный MONTHLY_LIMIT.
|
||||||
"""
|
"""
|
||||||
if username is None or is_unlimited(username):
|
if username is None or is_unlimited(username):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
period = current_period()
|
period = current_period()
|
||||||
|
lim = user_limit(db, username)
|
||||||
row = db.execute(
|
row = db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
|
|
@ -161,7 +195,7 @@ def increment(db: Session, username: str | None) -> bool:
|
||||||
RETURNING used
|
RETURNING used
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{"u": username, "p": period, "lim": MONTHLY_LIMIT},
|
{"u": username, "p": period, "lim": lim},
|
||||||
).fetchone()
|
).fetchone()
|
||||||
db.commit()
|
db.commit()
|
||||||
ok = row is not None
|
ok = row is not None
|
||||||
|
|
@ -172,6 +206,6 @@ def increment(db: Session, username: str | None) -> bool:
|
||||||
"quota increment refused (atomic, #747): username=%r period=%s limit=%d",
|
"quota increment refused (atomic, #747): username=%r period=%s limit=%d",
|
||||||
username,
|
username,
|
||||||
period,
|
period,
|
||||||
MONTHLY_LIMIT,
|
lim,
|
||||||
)
|
)
|
||||||
return ok
|
return ok
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ class Brand:
|
||||||
|
|
||||||
_DEFAULT = Brand(
|
_DEFAULT = Brand(
|
||||||
slug="generic",
|
slug="generic",
|
||||||
name="Trade-In Estimator",
|
name="МЕРА",
|
||||||
logo_url=None,
|
logo_url=None,
|
||||||
primary_color="#1d4ed8",
|
primary_color="#1d4ed8",
|
||||||
accent_color="#f59e0b",
|
accent_color="#f59e0b",
|
||||||
|
|
|
||||||
|
|
@ -167,10 +167,27 @@ async def clean_address(address: str) -> DadataAddressResult | None:
|
||||||
logger.warning("dadata: HTTP 429 — quota exceeded (100/день demo limit?)")
|
logger.warning("dadata: HTTP 429 — quota exceeded (100/день demo limit?)")
|
||||||
return None
|
return None
|
||||||
if status in (401, 403):
|
if status in (401, 403):
|
||||||
logger.error(
|
body_preview = (response.text or "")[:200]
|
||||||
"dadata: HTTP %d — auth/secret rejected. Проверь DADATA_API_TOKEN/SECRET.",
|
# 403 «Feature 'CLEAN' disabled for token …» ≠ отклонённый токен: токен валиден,
|
||||||
status,
|
# но услуга «Стандартизация» (CLEAN) не подключена на аккаунте. Refresh токена НЕ
|
||||||
)
|
# поможет — нужно включить услугу в кабинете DaData ИЛИ полагаться на suggest-fallback
|
||||||
|
# (enrich_address). Разделяем сообщения, чтобы не гонять зря за ротацией токена.
|
||||||
|
if status == 403 and (
|
||||||
|
"disabled" in body_preview.lower() or "feature" in body_preview.lower()
|
||||||
|
):
|
||||||
|
logger.error(
|
||||||
|
"dadata: HTTP 403 — услуга CLEAN (Стандартизация) выключена на аккаунте "
|
||||||
|
"(токен валиден, НЕ отклонён). Включи услугу в кабинете DaData или "
|
||||||
|
"полагайся на suggest-fallback (enrich_address). Ответ: %r",
|
||||||
|
body_preview,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
"dadata: HTTP %d — auth/secret rejected. "
|
||||||
|
"Проверь DADATA_API_TOKEN/SECRET. Ответ: %r",
|
||||||
|
status,
|
||||||
|
body_preview,
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
if status >= 500:
|
if status >= 500:
|
||||||
logger.warning("dadata: HTTP %d — transient server error", status)
|
logger.warning("dadata: HTTP %d — transient server error", status)
|
||||||
|
|
@ -246,6 +263,12 @@ class DadataSuggestion:
|
||||||
street: str | None
|
street: str | None
|
||||||
city: str | None
|
city: str | None
|
||||||
kind: str
|
kind: str
|
||||||
|
# #dadata-403 — доп. поля для suggest-fallback в enrich_address (когда CLEAN
|
||||||
|
# выключен на аккаунте). house_fias_id — ФИАС ДОМА (для house-level == fias_id);
|
||||||
|
# kladr_id / qc_geo дублируют то, что отдаёт /clean, чтобы деградация была равноценной.
|
||||||
|
house_fias_id: str | None = None
|
||||||
|
kladr_id: str | None = None
|
||||||
|
qc_geo: int | None = None
|
||||||
|
|
||||||
|
|
||||||
def _classify_kind(fias_level: Any) -> str:
|
def _classify_kind(fias_level: Any) -> str:
|
||||||
|
|
@ -295,11 +318,17 @@ def _parse_suggestion(item: dict[str, Any]) -> DadataSuggestion | None:
|
||||||
street=data.get("street"),
|
street=data.get("street"),
|
||||||
city=data.get("city"),
|
city=data.get("city"),
|
||||||
kind=_classify_kind(data.get("fias_level")),
|
kind=_classify_kind(data.get("fias_level")),
|
||||||
|
house_fias_id=data.get("house_fias_id"),
|
||||||
|
kladr_id=data.get("kladr_id"),
|
||||||
|
qc_geo=_coerce_int(data.get("qc_geo")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def suggest_addresses(
|
async def suggest_addresses(
|
||||||
query: str, limit: int = 8, city: str = "Екатеринбург"
|
query: str,
|
||||||
|
limit: int = 8,
|
||||||
|
city: str | None = "Екатеринбург",
|
||||||
|
region: str | None = None,
|
||||||
) -> list[DadataSuggestion]:
|
) -> list[DadataSuggestion]:
|
||||||
"""Автокомплит адресов через DaData /suggest/address.
|
"""Автокомплит адресов через DaData /suggest/address.
|
||||||
|
|
||||||
|
|
@ -307,6 +336,13 @@ async def suggest_addresses(
|
||||||
query: текст для подсказки (минимум 2 символа).
|
query: текст для подсказки (минимум 2 символа).
|
||||||
limit: сколько вариантов вернуть (DaData параметр `count`, max 20).
|
limit: сколько вариантов вернуть (DaData параметр `count`, max 20).
|
||||||
city: locality для constraint (передаётся в `locations`). По умолчанию ЕКБ.
|
city: locality для constraint (передаётся в `locations`). По умолчанию ЕКБ.
|
||||||
|
Игнорируется, если задан `region`.
|
||||||
|
region: область для hard-constraint в `locations` — используется вместо
|
||||||
|
`city`, когда нужен охват шире одного города (например, вся
|
||||||
|
Свердловская область / region 66). DaData `locations` сам по себе уже
|
||||||
|
жёсткий фильтр (не boost) на уровне указанного admin-поля — доп.
|
||||||
|
параметров не требуется. По умолчанию не задан — поведение (и body
|
||||||
|
запроса) для существующих вызовов не меняется.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
list[DadataSuggestion] — пустой список если:
|
list[DadataSuggestion] — пустой список если:
|
||||||
|
|
@ -334,7 +370,13 @@ async def suggest_addresses(
|
||||||
"query": query.strip(),
|
"query": query.strip(),
|
||||||
"count": max(1, min(int(limit), 20)),
|
"count": max(1, min(int(limit), 20)),
|
||||||
}
|
}
|
||||||
if city:
|
if region:
|
||||||
|
# `locations` с полем region — уже жёсткий фильтр сам по себе (DaData
|
||||||
|
# ограничивает выдачу этим регионом). `restrict_value` — ТОП-LEVEL параметр
|
||||||
|
# body (не ключ внутри объекта locations) — здесь он был бы silent no-op,
|
||||||
|
# поэтому не добавляем.
|
||||||
|
body["locations"] = [{"region": region}]
|
||||||
|
elif city:
|
||||||
body["locations"] = [{"city": city}]
|
body["locations"] = [{"city": city}]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -352,9 +394,7 @@ async def suggest_addresses(
|
||||||
logger.warning("dadata suggest: HTTP 429 — quota exceeded (10k/день free?)")
|
logger.warning("dadata suggest: HTTP 429 — quota exceeded (10k/день free?)")
|
||||||
return []
|
return []
|
||||||
if status in (401, 403):
|
if status in (401, 403):
|
||||||
logger.error(
|
logger.error("dadata suggest: HTTP %d — auth rejected. Проверь DADATA_API_TOKEN.", status)
|
||||||
"dadata suggest: HTTP %d — auth rejected. Проверь DADATA_API_TOKEN.", status
|
|
||||||
)
|
|
||||||
return []
|
return []
|
||||||
if status >= 500:
|
if status >= 500:
|
||||||
logger.warning("dadata suggest: HTTP %d — transient server error", status)
|
logger.warning("dadata suggest: HTTP %d — transient server error", status)
|
||||||
|
|
@ -387,3 +427,55 @@ async def suggest_addresses(
|
||||||
|
|
||||||
logger.info("dadata suggest: %r → %d вариантов", query[:60], len(out))
|
logger.info("dadata suggest: %r → %d вариантов", query[:60], len(out))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ── Enrichment orchestrator (clean → suggest fallback) ───────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def enrich_address(address: str) -> DadataAddressResult | None:
|
||||||
|
"""Обогащение целевого адреса с graceful-деградацией CLEAN → SUGGEST.
|
||||||
|
|
||||||
|
Порядок:
|
||||||
|
1. /clean/address (token+secret) — canonical + house_cadnum + house_fias_id +
|
||||||
|
qc_geo + metro. Если вернул результат — используем его.
|
||||||
|
2. Fallback: /suggest/address (token-only, доступен на free-tier 10k/день) —
|
||||||
|
берём top house-level кандидата. Даёт house_fias_id + гео + kladr_id + qc_geo,
|
||||||
|
но НЕ canonical_address / house_cadnum (их умеет только CLEAN) → они остаются
|
||||||
|
None. Честная деградация: недостающие поля не выдумываем.
|
||||||
|
|
||||||
|
Мотивация (#dadata-403, vault Mera_Hard_Audit_0712): на проде у аккаунта услуга
|
||||||
|
CLEAN выключена (HTTP 403 «Feature CLEAN disabled»), из-за чего house_fias_id —
|
||||||
|
ключ join с houses/ДОМ.РФ (year_built и пр.) — терялся, хотя /suggest его отдаёт
|
||||||
|
бесплатно. Город НЕ ограничиваем (city="") — enrichment работает по всей обл. (66).
|
||||||
|
"""
|
||||||
|
cleaned = await clean_address(address)
|
||||||
|
if cleaned is not None:
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
suggestions = await suggest_addresses(address, limit=5, city="")
|
||||||
|
for s in suggestions:
|
||||||
|
fias = s.house_fias_id or s.fias_id
|
||||||
|
if s.kind == "house" and fias:
|
||||||
|
# house-level ФИАС → гео дома точное; qc_geo из ответа либо 0 (exact) по умолчанию.
|
||||||
|
qc_geo = s.qc_geo if s.qc_geo is not None else 0
|
||||||
|
logger.info(
|
||||||
|
"dadata: CLEAN недоступен → suggest-fallback %r → house_fias_id=%s "
|
||||||
|
"(canonical/cadnum недоступны без CLEAN)",
|
||||||
|
address[:60],
|
||||||
|
fias,
|
||||||
|
)
|
||||||
|
return DadataAddressResult(
|
||||||
|
canonical_address=None,
|
||||||
|
house_cadnum=None,
|
||||||
|
house_fias_id=fias,
|
||||||
|
lat=s.lat,
|
||||||
|
lon=s.lon,
|
||||||
|
qc_geo=qc_geo,
|
||||||
|
qc_house=None,
|
||||||
|
kladr_id=s.kladr_id,
|
||||||
|
okato=None,
|
||||||
|
oktmo=None,
|
||||||
|
metro=[],
|
||||||
|
raw={"_source": "suggest_fallback", "value": s.value},
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
|
||||||
578
tradein-mvp/backend/app/services/domrf_kapremont_loader.py
Normal file
578
tradein-mvp/backend/app/services/domrf_kapremont_loader.py
Normal file
|
|
@ -0,0 +1,578 @@
|
||||||
|
"""ДОМ.РФ капремонт open data loader: houses.year_built/material_walls (issue #2013).
|
||||||
|
|
||||||
|
CONTEXT: houses.year_built заполнен только на 38%, houses.material_walls — на 0%.
|
||||||
|
Существующий ГИС-ЖКХ loader (zhkh_flats_loader.py, мигр. 146/149) заполняет
|
||||||
|
houses.zhkh_year (70%) и houses.zhkh_floors (70%), НО никогда не копирует их в
|
||||||
|
houses.year_built/total_floors — это основной пробел. material_walls вообще ни разу
|
||||||
|
не заполнялся никаким источником. Estimator (app/services/estimator.py) фильтрует
|
||||||
|
когорту по `year_built BETWEEN ...` — реальные годы напрямую двигают точность оценки.
|
||||||
|
|
||||||
|
ИСТОЧНИК: ДОМ.РФ капремонт open data (free, no auth), region 66 (Свердловская обл.):
|
||||||
|
КР1.1 house registry (export/190) — zip → CSV, delimiter ';', UTF-8 BOM. Колонки:
|
||||||
|
mkd_code, houseguid (ФИАС GUID дома), address, commission_year (год ввода, int),
|
||||||
|
total_sq (decimal, запятая — «909,80»), number_floors_max (int), + служебные.
|
||||||
|
КР1.2 constructive elements (export/275) — zip → CSV, тот же delimiter/encoding.
|
||||||
|
LONG FORMAT: одна строка на конструктивный элемент на mkd_code; wall_material
|
||||||
|
заполнен ТОЛЬКО на строке construction_element_type='фасад'. Чтобы получить
|
||||||
|
материал стен на дом — группируем по mkd_code, берём первую строку с непустым
|
||||||
|
wall_material.
|
||||||
|
|
||||||
|
TLS: домен домрф.рф отдаёт RU-сертификат, который httpx с default trust store не
|
||||||
|
верифицирует ("unable to get local issuer certificate") — та же ситуация, что и у
|
||||||
|
sber_index.py. Открытые неавторизованные данные, поэтому используем verify=False,
|
||||||
|
как остальные RU-gov loader'ы в этом репо.
|
||||||
|
|
||||||
|
Матч staging→houses: domrf_kapremont.houseguid = COALESCE(houses.gar_house_guid,
|
||||||
|
houses.house_fias_id, houses.zhkh_house_guid) — простой приоритетный COALESCE-джойн
|
||||||
|
(без multi-attempt fallback-если-нет-матча — see PR discussion, "keep it simple/safe").
|
||||||
|
|
||||||
|
psycopg v3: SQL через `text(...)` использует `CAST(:x AS type)`, НИКОГДА `:x::type`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Константы
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# export/190 (КР1.1) и export/275 (КР1.2) без доп. параметров отдают Свердловскую
|
||||||
|
# обл. (region 66) — подтверждено инспекцией скачанных файлов. Для других регионов
|
||||||
|
# понадобится region-gid параметр (не реализовано — вне скоупа #2013).
|
||||||
|
KR11_URL = "https://xn--80adsazqn.xn--p1aee.xn--p1ai/opendata/export/190"
|
||||||
|
KR12_URL = "https://xn--80adsazqn.xn--p1aee.xn--p1ai/opendata/export/275"
|
||||||
|
|
||||||
|
DOWNLOAD_TIMEOUT_SEC = 180
|
||||||
|
|
||||||
|
# UPSERT в staging чанками (в одном SAVEPOINT), как zhkh_flats_loader.SAVEPOINT_CHUNK —
|
||||||
|
# сбойный чанк откатывается изолированно, остальные доезжают.
|
||||||
|
UPSERT_CHUNK_SIZE = 500
|
||||||
|
|
||||||
|
# Правдоподобные границы года постройки. Значение вне диапазона — почти наверняка
|
||||||
|
# опечатка/мусор: для МКД-реестра капремонта дом < 1850 нереален, а > текущий+2 ещё
|
||||||
|
# физически не введён в эксплуатацию. Такой год НЕ должен (а) попадать из ДОМ.РФ CSV
|
||||||
|
# в staging (parse-гейт в parse_int_field), (б) экранировать авторитетный ДОМ.РФ
|
||||||
|
# commission_year при бэкфилле houses.year_built (SQL-гейт в _BACKFILL_*_SQL).
|
||||||
|
YEAR_BUILT_MIN = 1850
|
||||||
|
YEAR_BUILT_FUTURE_SLACK = 2 # дом «в стройке» — текущий год + 2 ещё допустим
|
||||||
|
|
||||||
|
|
||||||
|
def plausible_year_max() -> int:
|
||||||
|
"""Верхняя граница правдоподобного года постройки (текущий год + slack)."""
|
||||||
|
return date.today().year + YEAR_BUILT_FUTURE_SLACK
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Чистые хелперы парсинга (юнит-тестируются без сети/БД)
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
def parse_decimal_comma(raw: str | None) -> float | None:
|
||||||
|
"""«909,80» / «909.80» / «» / None → float|None. Запятая — decimal separator ДОМ.РФ CSV."""
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
s = raw.strip()
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(s.replace(",", "."))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_int_field(
|
||||||
|
raw: str | None,
|
||||||
|
*,
|
||||||
|
min_value: int | None = None,
|
||||||
|
max_value: int | None = None,
|
||||||
|
) -> int | None:
|
||||||
|
"""Устойчивый str → int|None (commission_year / number_floors_max).
|
||||||
|
|
||||||
|
Пусто/None/нечисло → None. Терпит decimal-строки («5,0») через float-фоллбек —
|
||||||
|
ДОМ.РФ CSV в принципе может так отдать целочисленные поля.
|
||||||
|
|
||||||
|
min_value/max_value — опциональный sanity-гейт: распарсенное значение вне
|
||||||
|
[min_value, max_value] трактуется как None (для commission_year передаём
|
||||||
|
YEAR_BUILT_MIN..plausible_year_max(), чтобы мусорный год не попал в staging;
|
||||||
|
для number_floors_max границы не задаём — там гейт не требуется).
|
||||||
|
"""
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
s = raw.strip()
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
value: int | None = None
|
||||||
|
try:
|
||||||
|
value = int(s)
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
value = int(float(s.replace(",", ".")))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if min_value is not None and value < min_value:
|
||||||
|
return None
|
||||||
|
if max_value is not None and value > max_value:
|
||||||
|
return None
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class Kr11Row:
|
||||||
|
"""Одна строка КР1.1 house registry (house-per-row)."""
|
||||||
|
|
||||||
|
mkd_code: str
|
||||||
|
houseguid: str | None
|
||||||
|
address: str | None
|
||||||
|
commission_year: int | None
|
||||||
|
number_floors_max: int | None
|
||||||
|
total_sq: float | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class DomrfHouse:
|
||||||
|
"""Объединённая КР1.1 + КР1.2(wall_material) строка — готова к UPSERT в staging."""
|
||||||
|
|
||||||
|
mkd_code: str
|
||||||
|
houseguid: str | None
|
||||||
|
address: str | None
|
||||||
|
commission_year: int | None
|
||||||
|
number_floors_max: int | None
|
||||||
|
total_sq: float | None
|
||||||
|
wall_material: str | None
|
||||||
|
|
||||||
|
|
||||||
|
def _open_csv(path: str | Path) -> Iterator[dict[str, str]]:
|
||||||
|
"""csv.DictReader по ДОМ.РФ CSV: delimiter ';', UTF-8 BOM (utf-8-sig)."""
|
||||||
|
with open(path, encoding="utf-8-sig", newline="") as f:
|
||||||
|
reader = csv.DictReader(f, delimiter=";")
|
||||||
|
yield from reader
|
||||||
|
|
||||||
|
|
||||||
|
def parse_kr11_csv(path: str | Path) -> dict[str, Kr11Row]:
|
||||||
|
"""КР1.1 → dict по mkd_code. Строки без mkd_code пропускаются.
|
||||||
|
|
||||||
|
Дубликат mkd_code (не ожидается — mkd_code PK в реестре ДОМ.РФ) — последняя
|
||||||
|
строка побеждает (совпадает с семантикой ON CONFLICT DO UPDATE ниже).
|
||||||
|
"""
|
||||||
|
rows: dict[str, Kr11Row] = {}
|
||||||
|
for raw in _open_csv(path):
|
||||||
|
mkd_code = (raw.get("mkd_code") or "").strip()
|
||||||
|
if not mkd_code:
|
||||||
|
continue
|
||||||
|
rows[mkd_code] = Kr11Row(
|
||||||
|
mkd_code=mkd_code,
|
||||||
|
houseguid=(raw.get("houseguid") or "").strip() or None,
|
||||||
|
address=(raw.get("address") or "").strip() or None,
|
||||||
|
# sanity-гейт: год вне [1850, текущий+2] — мусор, не пускаем в staging
|
||||||
|
# (иначе он позже мог бы записаться в houses.year_built как «авторитетный»).
|
||||||
|
commission_year=parse_int_field(
|
||||||
|
raw.get("commission_year"),
|
||||||
|
min_value=YEAR_BUILT_MIN,
|
||||||
|
max_value=plausible_year_max(),
|
||||||
|
),
|
||||||
|
number_floors_max=parse_int_field(raw.get("number_floors_max")),
|
||||||
|
total_sq=parse_decimal_comma(raw.get("total_sq")),
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def parse_kr12_wall_materials(path: str | Path) -> dict[str, str]:
|
||||||
|
"""КР1.2 (long-format) → dict mkd_code → wall_material.
|
||||||
|
|
||||||
|
wall_material непустой ТОЛЬКО на строке construction_element_type='фасад' —
|
||||||
|
группировка не по этому полю, а просто "первая непустая wall_material на
|
||||||
|
mkd_code" (устойчиво даже если разметка типа элемента когда-то изменится).
|
||||||
|
"""
|
||||||
|
materials: dict[str, str] = {}
|
||||||
|
for raw in _open_csv(path):
|
||||||
|
mkd_code = (raw.get("mkd_code") or "").strip()
|
||||||
|
wall = (raw.get("wall_material") or "").strip()
|
||||||
|
if mkd_code and wall and mkd_code not in materials:
|
||||||
|
materials[mkd_code] = wall
|
||||||
|
return materials
|
||||||
|
|
||||||
|
|
||||||
|
def build_domrf_houses(
|
||||||
|
kr11: dict[str, Kr11Row], wall_materials: dict[str, str]
|
||||||
|
) -> list[DomrfHouse]:
|
||||||
|
"""КР1.1 rows + КР1.2 wall_material lookup → список DomrfHouse (для UPSERT)."""
|
||||||
|
return [
|
||||||
|
DomrfHouse(
|
||||||
|
mkd_code=row.mkd_code,
|
||||||
|
houseguid=row.houseguid,
|
||||||
|
address=row.address,
|
||||||
|
commission_year=row.commission_year,
|
||||||
|
number_floors_max=row.number_floors_max,
|
||||||
|
total_sq=row.total_sq,
|
||||||
|
wall_material=wall_materials.get(row.mkd_code),
|
||||||
|
)
|
||||||
|
for row in kr11.values()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# HTTP: скачивание zip + извлечение CSV
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
def _download_zip(url: str, *, client: httpx.Client) -> bytes:
|
||||||
|
resp = client.get(url, timeout=DOWNLOAD_TIMEOUT_SEC)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.content
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_csv_from_zip(data: bytes, dest_dir: Path) -> Path:
|
||||||
|
"""Распаковывает первый *.csv из zip-байтов в dest_dir, возвращает путь."""
|
||||||
|
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||||
|
csv_names = [n for n in zf.namelist() if n.lower().endswith(".csv")]
|
||||||
|
if not csv_names:
|
||||||
|
raise ValueError(f"zip не содержит .csv записей: {zf.namelist()!r}")
|
||||||
|
extracted = zf.extract(csv_names[0], dest_dir)
|
||||||
|
return Path(extracted)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_domrf_csvs(dest_dir: Path, *, client: httpx.Client) -> tuple[Path, Path]:
|
||||||
|
"""Скачивает КР1.1 + КР1.2 zip'ы, распаковывает в dest_dir. Возвращает (kr11, kr12)."""
|
||||||
|
kr11_path = _extract_csv_from_zip(_download_zip(KR11_URL, client=client), dest_dir)
|
||||||
|
kr12_path = _extract_csv_from_zip(_download_zip(KR12_URL, client=client), dest_dir)
|
||||||
|
return kr11_path, kr12_path
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# UPSERT staging (domrf_kapremont, мигр. 176)
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
_UPSERT_SQL = text(
|
||||||
|
"""
|
||||||
|
INSERT INTO domrf_kapremont (
|
||||||
|
mkd_code, houseguid, address, commission_year,
|
||||||
|
number_floors_max, total_sq, wall_material, loaded_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
CAST(:mkd_code AS text), CAST(:houseguid AS text), CAST(:address AS text),
|
||||||
|
CAST(:commission_year AS smallint), CAST(:number_floors_max AS int),
|
||||||
|
CAST(:total_sq AS numeric), CAST(:wall_material AS text), now()
|
||||||
|
)
|
||||||
|
ON CONFLICT (mkd_code) DO UPDATE SET
|
||||||
|
houseguid = EXCLUDED.houseguid,
|
||||||
|
address = EXCLUDED.address,
|
||||||
|
commission_year = EXCLUDED.commission_year,
|
||||||
|
number_floors_max = EXCLUDED.number_floors_max,
|
||||||
|
total_sq = EXCLUDED.total_sq,
|
||||||
|
wall_material = EXCLUDED.wall_material,
|
||||||
|
loaded_at = now()
|
||||||
|
WHERE domrf_kapremont.houseguid IS DISTINCT FROM EXCLUDED.houseguid
|
||||||
|
OR domrf_kapremont.address IS DISTINCT FROM EXCLUDED.address
|
||||||
|
OR domrf_kapremont.commission_year IS DISTINCT FROM EXCLUDED.commission_year
|
||||||
|
OR domrf_kapremont.number_floors_max IS DISTINCT FROM EXCLUDED.number_floors_max
|
||||||
|
OR domrf_kapremont.total_sq IS DISTINCT FROM EXCLUDED.total_sq
|
||||||
|
OR domrf_kapremont.wall_material IS DISTINCT FROM EXCLUDED.wall_material
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _chunk_houses(items: list[DomrfHouse], size: int) -> Iterator[list[DomrfHouse]]:
|
||||||
|
for i in range(0, len(items), size):
|
||||||
|
yield items[i : i + size]
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_domrf_kapremont(
|
||||||
|
db: Session, houses: list[DomrfHouse], *, chunk_size: int = UPSERT_CHUNK_SIZE
|
||||||
|
) -> int:
|
||||||
|
"""UPSERT списка DomrfHouse в domrf_kapremont, чанками по SAVEPOINT. Не коммитит (caller).
|
||||||
|
|
||||||
|
Идемпотентно (IS DISTINCT FROM gate в _UPSERT_SQL — повторный прогон с теми же
|
||||||
|
данными не трогает уже актуальные строки). Сбойный чанк откатывается изолированно
|
||||||
|
(та же схема, что zhkh_flats_loader.match_houses_to_zhkh).
|
||||||
|
"""
|
||||||
|
upserted = 0
|
||||||
|
for chunk in _chunk_houses(houses, chunk_size):
|
||||||
|
try:
|
||||||
|
with db.begin_nested():
|
||||||
|
for h in chunk:
|
||||||
|
res = db.execute(
|
||||||
|
_UPSERT_SQL,
|
||||||
|
{
|
||||||
|
"mkd_code": h.mkd_code,
|
||||||
|
"houseguid": h.houseguid,
|
||||||
|
"address": h.address,
|
||||||
|
"commission_year": h.commission_year,
|
||||||
|
"number_floors_max": h.number_floors_max,
|
||||||
|
"total_sq": h.total_sq,
|
||||||
|
"wall_material": h.wall_material,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
upserted += res.rowcount
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"domrf_kapremont upsert: чанк из %d строк сбойнул (откат savepoint)",
|
||||||
|
len(chunk),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return upserted
|
||||||
|
|
||||||
|
|
||||||
|
def load_domrf_kapremont(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
kr11_path: str | Path | None = None,
|
||||||
|
kr12_path: str | Path | None = None,
|
||||||
|
work_dir: str | Path | None = None,
|
||||||
|
chunk_size: int = UPSERT_CHUNK_SIZE,
|
||||||
|
dry_run: bool = False,
|
||||||
|
) -> dict[str, int]:
|
||||||
|
"""Скачивает (если пути не заданы) КР1.1+КР1.2, парсит, UPSERT в domrf_kapremont.
|
||||||
|
|
||||||
|
kr11_path/kr12_path — локальные CSV (пропустить скачивание; используется тестами
|
||||||
|
и ops-дебагом). work_dir — куда распаковывать скачанные zip (по умолчанию —
|
||||||
|
временный каталог, удаляется после загрузки). dry_run — парс происходит, но НИ
|
||||||
|
ОДНОЙ записи в БД не делается. Не коммитит (caller).
|
||||||
|
"""
|
||||||
|
tmp_ctx: tempfile.TemporaryDirectory[str] | None = None
|
||||||
|
if kr11_path is None or kr12_path is None:
|
||||||
|
if work_dir is not None:
|
||||||
|
dest = Path(work_dir)
|
||||||
|
dest.mkdir(parents=True, exist_ok=True)
|
||||||
|
else:
|
||||||
|
tmp_ctx = tempfile.TemporaryDirectory()
|
||||||
|
dest = Path(tmp_ctx.name)
|
||||||
|
# verify=False: домрф.рф отдаёт RU-сертификат, не верифицируемый дефолтным
|
||||||
|
# trust store'ом (та же ситуация, что sber_index.py). Публичные open data,
|
||||||
|
# без auth/PII — приемлемо skip'нуть TLS-верификацию.
|
||||||
|
with httpx.Client(timeout=DOWNLOAD_TIMEOUT_SEC, verify=False) as client:
|
||||||
|
if kr11_path is None:
|
||||||
|
kr11_path = _extract_csv_from_zip(_download_zip(KR11_URL, client=client), dest)
|
||||||
|
if kr12_path is None:
|
||||||
|
kr12_path = _extract_csv_from_zip(_download_zip(KR12_URL, client=client), dest)
|
||||||
|
|
||||||
|
try:
|
||||||
|
kr11 = parse_kr11_csv(kr11_path)
|
||||||
|
wall_materials = parse_kr12_wall_materials(kr12_path)
|
||||||
|
houses = build_domrf_houses(kr11, wall_materials)
|
||||||
|
upserted = 0 if dry_run else upsert_domrf_kapremont(db, houses, chunk_size=chunk_size)
|
||||||
|
finally:
|
||||||
|
if tmp_ctx is not None:
|
||||||
|
tmp_ctx.cleanup()
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"kr11_rows": len(kr11),
|
||||||
|
"kr12_wall_rows": len(wall_materials),
|
||||||
|
"houses_built": len(houses),
|
||||||
|
"upserted": upserted,
|
||||||
|
}
|
||||||
|
logger.info("domrf_kapremont load DONE (dry_run=%s): %s", dry_run, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Backfill houses.year_built/material_walls/total_floors
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Шаг 1: дома с domrf-матчем — year_built/material_walls/total_floors из domrf,
|
||||||
|
# С ЗАФОЛЖЕННЫМ zhkh_year/zhkh_floors фоллбеком (COALESCE, как в issue #2013).
|
||||||
|
#
|
||||||
|
# ПРИОРИТЕТ year_built (Fix A): ВАЛИДНОЕ существующее значение выигрывает (COALESCE
|
||||||
|
# семантика сохранена для valid). НО невозможное существующее значение (< :ymin,
|
||||||
|
# > :ymax или NULL) НЕ должно экранировать авторитетный ДОМ.РФ commission_year —
|
||||||
|
# для домов реестра капремонта commission_year это источник истины по году ввода.
|
||||||
|
# Поэтому impossible/NULL заменяется валидным commission_year → затем валидным
|
||||||
|
# zhkh_year; если валидной замены нет — оставляем существующее как есть (не обнуляем
|
||||||
|
# даже мусор, чтобы не терять данные без выигрыша). Кандидаты замены тоже проходят
|
||||||
|
# плаузибилити-гейт [:ymin, :ymax], чтобы в houses.year_built не попал мусорный год.
|
||||||
|
# material_walls/total_floors — прежняя COALESCE-семантика (только заполнить NULL).
|
||||||
|
_BACKFILL_HOUSES_FROM_DOMRF_SQL = text(
|
||||||
|
"""
|
||||||
|
UPDATE houses h
|
||||||
|
SET year_built = CASE
|
||||||
|
WHEN h.year_built BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
|
||||||
|
THEN h.year_built
|
||||||
|
ELSE COALESCE(
|
||||||
|
CASE WHEN d.commission_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
|
||||||
|
THEN d.commission_year END,
|
||||||
|
CASE WHEN h.zhkh_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
|
||||||
|
THEN h.zhkh_year END,
|
||||||
|
h.year_built
|
||||||
|
)
|
||||||
|
END,
|
||||||
|
material_walls = COALESCE(h.material_walls, d.wall_material),
|
||||||
|
total_floors = COALESCE(h.total_floors, d.number_floors_max, h.zhkh_floors)
|
||||||
|
FROM domrf_kapremont d
|
||||||
|
WHERE d.houseguid = COALESCE(h.gar_house_guid, h.house_fias_id, h.zhkh_house_guid)
|
||||||
|
AND (
|
||||||
|
(
|
||||||
|
(h.year_built IS NULL
|
||||||
|
OR h.year_built NOT BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int))
|
||||||
|
AND COALESCE(
|
||||||
|
CASE WHEN d.commission_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
|
||||||
|
THEN d.commission_year END,
|
||||||
|
CASE WHEN h.zhkh_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
|
||||||
|
THEN h.zhkh_year END
|
||||||
|
) IS NOT NULL
|
||||||
|
)
|
||||||
|
OR (h.material_walls IS NULL AND d.wall_material IS NOT NULL)
|
||||||
|
OR (h.total_floors IS NULL AND COALESCE(d.number_floors_max, h.zhkh_floors) IS NOT NULL)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
_BACKFILL_HOUSES_FROM_DOMRF_COUNT_SQL = text(
|
||||||
|
"""
|
||||||
|
SELECT count(*)
|
||||||
|
FROM houses h
|
||||||
|
JOIN domrf_kapremont d
|
||||||
|
ON d.houseguid = COALESCE(h.gar_house_guid, h.house_fias_id, h.zhkh_house_guid)
|
||||||
|
WHERE (
|
||||||
|
(h.year_built IS NULL
|
||||||
|
OR h.year_built NOT BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int))
|
||||||
|
AND COALESCE(
|
||||||
|
CASE WHEN d.commission_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
|
||||||
|
THEN d.commission_year END,
|
||||||
|
CASE WHEN h.zhkh_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
|
||||||
|
THEN h.zhkh_year END
|
||||||
|
) IS NOT NULL
|
||||||
|
)
|
||||||
|
OR (h.material_walls IS NULL AND d.wall_material IS NOT NULL)
|
||||||
|
OR (h.total_floors IS NULL AND COALESCE(d.number_floors_max, h.zhkh_floors) IS NOT NULL)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Шаг 2: ЛЮБОЙ дом (включая без domrf-матча вообще — шаг 1 INNER JOIN их не трогает)
|
||||||
|
# добивается zhkh_year/zhkh_floors, если ещё NULL. Домам, уже тронутым шагом 1, шаг 2
|
||||||
|
# ничего не меняет: их COALESCE в шаге 1 уже включал zhkh_* как fallback, поэтому
|
||||||
|
# гейт `IS NULL AND zhkh_* IS NOT NULL` здесь для них ложен — шаги взаимоисключающи
|
||||||
|
# (не двойной счёт при суммировании houses_updated).
|
||||||
|
_BACKFILL_HOUSES_ZHKH_FALLBACK_SQL = text(
|
||||||
|
"""
|
||||||
|
UPDATE houses
|
||||||
|
SET year_built = COALESCE(year_built, zhkh_year),
|
||||||
|
total_floors = COALESCE(total_floors, zhkh_floors)
|
||||||
|
WHERE (year_built IS NULL AND zhkh_year IS NOT NULL)
|
||||||
|
OR (total_floors IS NULL AND zhkh_floors IS NOT NULL)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
_BACKFILL_HOUSES_ZHKH_FALLBACK_COUNT_SQL = text(
|
||||||
|
"""
|
||||||
|
SELECT count(*)
|
||||||
|
FROM houses
|
||||||
|
WHERE (year_built IS NULL AND zhkh_year IS NOT NULL)
|
||||||
|
OR (total_floors IS NULL AND zhkh_floors IS NOT NULL)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_houses_from_domrf(db: Session, *, dry_run: bool = False) -> dict[str, int]:
|
||||||
|
"""COALESCE(year_built, domrf.commission_year, zhkh_year) + material_walls + total_floors.
|
||||||
|
|
||||||
|
Двухшаговый UPDATE (мирроринг zhkh guid-match → cadastre-fallback паттерна из
|
||||||
|
zhkh_flats_loader.py):
|
||||||
|
1) дома с domrf-матчем (COALESCE(gar_house_guid, house_fias_id, zhkh_house_guid) =
|
||||||
|
domrf_kapremont.houseguid) — заполняются из domrf, с zhkh_year/zhkh_floors
|
||||||
|
фоллбеком внутри той же COALESCE.
|
||||||
|
2) ЛЮБОЙ дом (в т.ч. без domrf-матча) — добивает year_built/total_floors из
|
||||||
|
zhkh_year/zhkh_floors, если шаг 1 их не тронул.
|
||||||
|
Только COALESCE (заполняет NULL, никогда не перезаписывает existing non-null).
|
||||||
|
Не коммитит (caller). dry_run — ноль записей, только SELECT count(*) по тем же
|
||||||
|
предикатам.
|
||||||
|
"""
|
||||||
|
year_bounds = {"ymin": YEAR_BUILT_MIN, "ymax": plausible_year_max()}
|
||||||
|
if dry_run:
|
||||||
|
domrf_matched = db.execute(_BACKFILL_HOUSES_FROM_DOMRF_COUNT_SQL, year_bounds).scalar_one()
|
||||||
|
zhkh_fallback = db.execute(_BACKFILL_HOUSES_ZHKH_FALLBACK_COUNT_SQL).scalar_one()
|
||||||
|
result = {
|
||||||
|
"domrf_matched": domrf_matched,
|
||||||
|
"zhkh_fallback": zhkh_fallback,
|
||||||
|
"houses_updated": 0,
|
||||||
|
}
|
||||||
|
logger.info("backfill_houses_from_domrf DRY-RUN: %s", result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
domrf_updated = db.execute(_BACKFILL_HOUSES_FROM_DOMRF_SQL, year_bounds).rowcount
|
||||||
|
zhkh_updated = db.execute(_BACKFILL_HOUSES_ZHKH_FALLBACK_SQL).rowcount
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"domrf_matched": domrf_updated,
|
||||||
|
"zhkh_fallback": zhkh_updated,
|
||||||
|
"houses_updated": domrf_updated + zhkh_updated,
|
||||||
|
}
|
||||||
|
logger.info("backfill_houses_from_domrf DONE: %s", result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Propagate houses.year_built → listings.year_built
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Только NULL listings.year_built, только когда houses.year_built уже известен —
|
||||||
|
# никогда не перезаписывает существующий listings.year_built (source-provided данные
|
||||||
|
# приоритетнее houses-агрегата).
|
||||||
|
#
|
||||||
|
# LINK-CONSISTENCY GUARD (Fix B): раньше пропагация шла по house_id_fk БЕЗ проверки,
|
||||||
|
# что listing реально относится к этому дому. Перепривязанный FK (house dedup/merge
|
||||||
|
# «съезжает» на дом за сотни метров) впрыскивал бы чужой когортный год в живой comp.
|
||||||
|
# Пропагируем только если координаты самого объявления в пределах
|
||||||
|
# PROPAGATE_MAX_DISTANCE_M от геометрии дома. Если у объявления нет координат —
|
||||||
|
# консервативный address-фоллбек: адрес объявления должен содержать short_address
|
||||||
|
# дома (тот же дом по адресной строке); нет short_address — не пропагируем.
|
||||||
|
PROPAGATE_MAX_DISTANCE_M = 500
|
||||||
|
|
||||||
|
# Общий link-consistency предикат для UPDATE и COUNT (единый источник — одна правка
|
||||||
|
# порога PROPAGATE_MAX_DISTANCE_M меняет обе SQL). Порог — int-константа, не bind-param
|
||||||
|
# (в ST_DWithin проще литерал; значение доверенное, не из пользовательского ввода).
|
||||||
|
_PROPAGATE_LINK_GUARD = f"""
|
||||||
|
AND (
|
||||||
|
(l.geom IS NOT NULL AND h.geom IS NOT NULL
|
||||||
|
AND ST_DWithin(
|
||||||
|
CAST(l.geom AS geography), CAST(h.geom AS geography),
|
||||||
|
{PROPAGATE_MAX_DISTANCE_M}))
|
||||||
|
OR (l.geom IS NULL
|
||||||
|
AND h.short_address IS NOT NULL AND l.address IS NOT NULL
|
||||||
|
AND l.address ILIKE '%' || h.short_address || '%')
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
_PROPAGATE_LISTINGS_YEAR_SQL = text(
|
||||||
|
f"""
|
||||||
|
UPDATE listings l
|
||||||
|
SET year_built = h.year_built
|
||||||
|
FROM houses h
|
||||||
|
WHERE l.house_id_fk = h.id
|
||||||
|
AND l.year_built IS NULL
|
||||||
|
AND h.year_built IS NOT NULL
|
||||||
|
{_PROPAGATE_LINK_GUARD}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
_PROPAGATE_LISTINGS_YEAR_COUNT_SQL = text(
|
||||||
|
f"""
|
||||||
|
SELECT count(*)
|
||||||
|
FROM listings l
|
||||||
|
JOIN houses h ON l.house_id_fk = h.id
|
||||||
|
WHERE l.year_built IS NULL
|
||||||
|
AND h.year_built IS NOT NULL
|
||||||
|
{_PROPAGATE_LINK_GUARD}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def propagate_listings_year_from_houses(db: Session, *, dry_run: bool = False) -> dict[str, int]:
|
||||||
|
"""UPDATE listings.year_built = houses.year_built где listing.year_built ещё NULL.
|
||||||
|
|
||||||
|
Не коммитит (caller). dry_run — ноль записей, только SELECT count(*) по тому же
|
||||||
|
предикату (ключ результата — would_update вместо listings_updated).
|
||||||
|
"""
|
||||||
|
if dry_run:
|
||||||
|
would_update = db.execute(_PROPAGATE_LISTINGS_YEAR_COUNT_SQL).scalar_one()
|
||||||
|
logger.info("propagate_listings_year_from_houses DRY-RUN: would_update=%d", would_update)
|
||||||
|
return {"listings_updated": 0, "would_update": would_update}
|
||||||
|
|
||||||
|
updated = db.execute(_PROPAGATE_LISTINGS_YEAR_SQL).rowcount
|
||||||
|
logger.info("propagate_listings_year_from_houses DONE: listings_updated=%d", updated)
|
||||||
|
return {"listings_updated": updated}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -40,7 +40,6 @@ import logging
|
||||||
import pathlib
|
import pathlib
|
||||||
import re
|
import re
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
import matplotlib
|
import matplotlib
|
||||||
|
|
||||||
|
|
@ -376,11 +375,60 @@ def _mono(text: str) -> str:
|
||||||
return f"<span class='mono'>{text}</span>"
|
return f"<span class='mono'>{text}</span>"
|
||||||
|
|
||||||
|
|
||||||
def _report_number(estimate_id: UUID) -> str:
|
# Город (region 66) → буквенный префикс № отчёта. Раньше _report_number
|
||||||
"""№ отчёта: 'EKБ-NNNN-XXXXXXX' где NNNN — короткий код."""
|
# захардкоживал «EKБ» для ВСЕХ оценок — объект в Серове / Нижнем Тагиле получал
|
||||||
short = int(estimate_id.int) % 10_000
|
# екатеринбургский код на обложке, в шапках и в футере (#pdf-honesty). Префикс
|
||||||
long = int(estimate_id.int) % 10_000_000_000
|
# теперь выводится из адреса оценки; ключи в нижнем регистре, ё→е (симметрично
|
||||||
return f"EKБ-{short:04d}-{long:010d}"
|
# _resolve_target_city в estimator.py). Город вне карты → _DEFAULT_REPORT_PREFIX.
|
||||||
|
_CITY_REPORT_PREFIX: dict[str, str] = {
|
||||||
|
"екатеринбург": "ЕКБ",
|
||||||
|
"нижний тагил": "НТ",
|
||||||
|
"каменск-уральский": "КУ",
|
||||||
|
"первоуральск": "ПРВ",
|
||||||
|
"серов": "СЕР",
|
||||||
|
"новоуральск": "НВУ",
|
||||||
|
"ревда": "РЕВ",
|
||||||
|
"полевской": "ПЛВ",
|
||||||
|
"асбест": "АСБ",
|
||||||
|
"верхняя пышма": "ВП",
|
||||||
|
"березовский": "БРЗ",
|
||||||
|
"краснотурьинск": "КРТ",
|
||||||
|
"камышлов": "КМШ",
|
||||||
|
}
|
||||||
|
# Нейтральный префикс, когда город адреса не распознан (пустой / вне региона) —
|
||||||
|
# НЕ выдумываем локацию, не подставляем ложный «EKБ» (#pdf-honesty).
|
||||||
|
_DEFAULT_REPORT_PREFIX = "МЕРА"
|
||||||
|
# Длинные имена раньше коротких — чтобы «нижний тагил» матчился целиком.
|
||||||
|
_REPORT_CITY_RE = re.compile(
|
||||||
|
r"\b(?:"
|
||||||
|
+ "|".join(re.escape(c) for c in sorted(_CITY_REPORT_PREFIX, key=len, reverse=True))
|
||||||
|
+ r")\b"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _report_city_prefix(address: str | None) -> str:
|
||||||
|
"""Буквенный префикс № отчёта по городу из адреса (region 66).
|
||||||
|
|
||||||
|
«МЕРА» если город не распознан — не выдумываем локацию (раньше был хардкод
|
||||||
|
«EKБ» для любого адреса, включая не-екатеринбургские). ё→е нормализация до
|
||||||
|
поиска (симметрично _resolve_target_city в estimator.py)."""
|
||||||
|
if not address:
|
||||||
|
return _DEFAULT_REPORT_PREFIX
|
||||||
|
norm = address.replace("ё", "е").replace("Ё", "е").lower()
|
||||||
|
m = _REPORT_CITY_RE.search(norm)
|
||||||
|
return _CITY_REPORT_PREFIX[m.group(0)] if m else _DEFAULT_REPORT_PREFIX
|
||||||
|
|
||||||
|
|
||||||
|
def _report_number(estimate: AggregatedEstimate) -> str:
|
||||||
|
"""№ отчёта: 'PREFIX-NNNN-XXXXXXXXXX'.
|
||||||
|
|
||||||
|
PREFIX — буквенный код города оценки (ЕКБ / НТ / … по target_address, «МЕРА»
|
||||||
|
если город не распознан); NNNN/XXXXXXXXXX — детерминированные коды от
|
||||||
|
estimate_id. Раньше PREFIX был захардкожен «EKБ» для всех оценок (#pdf-honesty)."""
|
||||||
|
prefix = _report_city_prefix(estimate.target_address or estimate.canonical_address)
|
||||||
|
short = int(estimate.estimate_id.int) % 10_000
|
||||||
|
long = int(estimate.estimate_id.int) % 10_000_000_000
|
||||||
|
return f"{prefix}-{short:04d}-{long:010d}"
|
||||||
|
|
||||||
|
|
||||||
def _expires_date(estimate: AggregatedEstimate) -> dt.date:
|
def _expires_date(estimate: AggregatedEstimate) -> dt.date:
|
||||||
|
|
@ -518,7 +566,7 @@ def _page_footer(
|
||||||
# тела страницы) и был источником сложности; заменён на простую тонкую
|
# тела страницы) и был источником сложности; заменён на простую тонкую
|
||||||
# градиентную линию-разделитель между строками meta/wordmark.
|
# градиентную линию-разделитель между строками meta/wordmark.
|
||||||
return f"""
|
return f"""
|
||||||
<div style="margin-top:20pt;">
|
<div style="margin-top:6pt;">
|
||||||
{note_html}
|
{note_html}
|
||||||
<div style="border-top:1pt solid {_LINE_SOFT};padding-top:8pt;
|
<div style="border-top:1pt solid {_LINE_SOFT};padding-top:8pt;
|
||||||
font-family:{mono_family};font-size:{_FS_XS};letter-spacing:0.06em;
|
font-family:{mono_family};font-size:{_FS_XS};letter-spacing:0.06em;
|
||||||
|
|
@ -811,8 +859,8 @@ def _dual_price_block(estimate: AggregatedEstimate, brand) -> str: # type: igno
|
||||||
)
|
)
|
||||||
|
|
||||||
explainer = (
|
explainer = (
|
||||||
f'<p style="margin:6pt 0 2pt 0;padding:6pt 10pt;background:{_ACCENT_SOFT};'
|
f'<p style="margin:4pt 0 2pt 0;padding:4pt 10pt;background:{_ACCENT_SOFT};'
|
||||||
f'border-radius:4pt;font-size:{_FS_SM};color:{_MUTED};line-height:1.3;">'
|
f'border-radius:4pt;font-size:{_FS_SM};color:{_MUTED};line-height:1.25;">'
|
||||||
"<strong>Запрос</strong> — по чему выставлены сопоставимые квартиры в объявлениях. "
|
"<strong>Запрос</strong> — по чему выставлены сопоставимые квартиры в объявлениях. "
|
||||||
"<strong>Ожидаемая цена продажи</strong> — реалистичная цена сделки по данным ДКП "
|
"<strong>Ожидаемая цена продажи</strong> — реалистичная цена сделки по данным ДКП "
|
||||||
"Росреестра, обычно ниже запроса."
|
"Росреестра, обычно ниже запроса."
|
||||||
|
|
@ -821,7 +869,7 @@ def _dual_price_block(estimate: AggregatedEstimate, brand) -> str: # type: igno
|
||||||
|
|
||||||
return (
|
return (
|
||||||
f'<table style="width:100%;border-collapse:separate;border-spacing:8pt 0;'
|
f'<table style="width:100%;border-collapse:separate;border-spacing:8pt 0;'
|
||||||
f'margin:6pt 0 0 0;"><tr>{asking_cell}{sold_cell}</tr></table>'
|
f'margin:3pt 0 0 0;"><tr>{asking_cell}{sold_cell}</tr></table>'
|
||||||
f"{explainer}"
|
f"{explainer}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -970,7 +1018,7 @@ def _quality_card_html(estimate: AggregatedEstimate) -> str:
|
||||||
cv_str = f"{cv}%" if cv is not None else "—"
|
cv_str = f"{cv}%" if cv is not None else "—"
|
||||||
gauge = _data_quality_gauge_svg(estimate.confidence)
|
gauge = _data_quality_gauge_svg(estimate.confidence)
|
||||||
return f"""
|
return f"""
|
||||||
<div class="card-glass bracket-tr" style="margin:6pt 0 6pt 0;padding:8pt 12pt 7pt 12pt;
|
<div class="card-glass bracket-tr" style="margin:3pt 0 3pt 0;padding:6pt 12pt 5pt 12pt;
|
||||||
display:flex;align-items:center;gap:12pt;flex-wrap:wrap;">
|
display:flex;align-items:center;gap:12pt;flex-wrap:wrap;">
|
||||||
{gauge}
|
{gauge}
|
||||||
<div style="flex:1;min-width:170pt;overflow-wrap:anywhere;">
|
<div style="flex:1;min-width:170pt;overflow-wrap:anywhere;">
|
||||||
|
|
@ -1000,7 +1048,7 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
|
||||||
if estimate.expires_at is not None
|
if estimate.expires_at is not None
|
||||||
else today + dt.timedelta(days=30)
|
else today + dt.timedelta(days=30)
|
||||||
)
|
)
|
||||||
report_num = _report_number(estimate.estimate_id)
|
report_num = _report_number(estimate)
|
||||||
|
|
||||||
# Короткий адрес (для cover): берём первую часть до запятой
|
# Короткий адрес (для cover): берём первую часть до запятой
|
||||||
full_address = input_snapshot.get("address", "—")
|
full_address = input_snapshot.get("address", "—")
|
||||||
|
|
@ -1038,30 +1086,38 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
|
||||||
house_label = house_labels.get(house_type, "—") if house_type else "—"
|
house_label = house_labels.get(house_type, "—") if house_type else "—"
|
||||||
repair_label = repair_labels.get(repair_state, "Не указано") if repair_state else "Не указано"
|
repair_label = repair_labels.get(repair_state, "Не указано") if repair_state else "Не указано"
|
||||||
balcony_label = "Есть" if has_balcony else "Нет" if has_balcony is False else "—"
|
balcony_label = "Есть" if has_balcony else "Нет" if has_balcony is False else "—"
|
||||||
|
# #pdf-none: year_built может быть None (адрес без house_metadata) — раньше в
|
||||||
|
# таблицу утекало литеральное «None»; показываем «—» как в остальных пустых полях.
|
||||||
|
year_label = _mono(str(year_built)) if year_built else "—"
|
||||||
|
|
||||||
# Active market subband — 4-118 days range (fallback без данных).
|
# Срок экспозиции показываем ТОЛЬКО при реальных days_on_market в analogs;
|
||||||
# Если есть days_on_market в analogs — берём min/max, иначе фиксированно.
|
# если данных нет — days не рисуем (не выдумываем «4-118 дней», #pdf-honesty).
|
||||||
days_min, days_max = _days_on_market_range(estimate.analogs)
|
days_range = _days_on_market_range(estimate.analogs)
|
||||||
|
|
||||||
# Deals range — если deals есть, считаем; иначе fallback к listings range
|
|
||||||
deals_low, deals_high = _deals_range(
|
|
||||||
estimate.actual_deals, fallback=(estimate.range_low_rub, estimate.range_high_rub)
|
|
||||||
)
|
|
||||||
|
|
||||||
listings_bar = _price_range_chart_svg(
|
listings_bar = _price_range_chart_svg(
|
||||||
estimate.range_low_rub,
|
estimate.range_low_rub,
|
||||||
estimate.range_high_rub,
|
estimate.range_high_rub,
|
||||||
sub_label="Активный рынок с аналогичным состоянием ремонта",
|
sub_label="Активный рынок с аналогичным состоянием ремонта",
|
||||||
days_min=days_min,
|
days_min=days_range[0] if days_range else None,
|
||||||
days_max=days_max,
|
days_max=days_range[1] if days_range else None,
|
||||||
show_days=True,
|
show_days=days_range is not None,
|
||||||
)
|
)
|
||||||
|
|
||||||
deals_bar = _price_range_chart_svg(
|
# Совет «Цены в объявлениях — ожидания собственников» ссылается на РЕАЛЬНЫЙ
|
||||||
deals_low,
|
# рассчитанный дисконт запрос→продажа (тот же, что chip «−N%» в dual-price),
|
||||||
deals_high,
|
# а не хардкод «10–18%», который противоречил бы вычисленному «−N%» (#pdf-honesty).
|
||||||
sub_label="Диапазон цен по фактическим сделкам",
|
discount_pct = _discount_pct(estimate)
|
||||||
)
|
if discount_pct is not None:
|
||||||
|
advice_discount_text = (
|
||||||
|
f"Фактические сделки проходят ниже цен в объявлениях — по этому объекту "
|
||||||
|
f"на {discount_pct}% (см. «Ожидаемая цена продажи»); подтверждают Росреестр, "
|
||||||
|
f"ДомКлик и продажи агентств недвижимости"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
advice_discount_text = (
|
||||||
|
"Фактические сделки проходят ниже цен в объявлениях, что подтверждают "
|
||||||
|
"Росреестр, ДомКлик и продажи агентств недвижимости"
|
||||||
|
)
|
||||||
|
|
||||||
disclaimer_html = ""
|
disclaimer_html = ""
|
||||||
if brand.pdf_disclaimer:
|
if brand.pdf_disclaimer:
|
||||||
|
|
@ -1070,8 +1126,6 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
|
||||||
f"border-top:1pt solid {_LINE};padding-top:6pt;overflow-wrap:anywhere;'>"
|
f"border-top:1pt solid {_LINE};padding-top:6pt;overflow-wrap:anywhere;'>"
|
||||||
f"{_html.escape(brand.pdf_disclaimer)}</p>"
|
f"{_html.escape(brand.pdf_disclaimer)}</p>"
|
||||||
)
|
)
|
||||||
footer_html = _page_footer(brand, report_num, today, valid_until=expires)
|
|
||||||
|
|
||||||
heading_html = _section_heading(
|
heading_html = _section_heading(
|
||||||
"01",
|
"01",
|
||||||
"АНАЛИЗ РЫНКА И РАСЧЕТ ВЫКУПНОЙ СТОИМОСТИ КВАРТИРЫ",
|
"АНАЛИЗ РЫНКА И РАСЧЕТ ВЫКУПНОЙ СТОИМОСТИ КВАРТИРЫ",
|
||||||
|
|
@ -1082,12 +1136,10 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
|
||||||
return f"""
|
return f"""
|
||||||
<div style="page-break-after:always;">
|
<div style="page-break-after:always;">
|
||||||
|
|
||||||
{_page_header(brand, report_num, today)}
|
|
||||||
|
|
||||||
{heading_html}
|
{heading_html}
|
||||||
|
|
||||||
<!-- Параметры квартиры (dotted-row — ObjectSummary.tsx summary-row идиома) -->
|
<!-- Параметры квартиры (dotted-row — ObjectSummary.tsx summary-row идиома) -->
|
||||||
<table class="params-table" style="width:100%;border-collapse:collapse;margin-bottom:10pt;">
|
<table class="params-table" style="width:100%;border-collapse:collapse;margin-bottom:6pt;">
|
||||||
<tr><td class="dotted-row">№ отчета</td>
|
<tr><td class="dotted-row">№ отчета</td>
|
||||||
<td class="bold mono dotted-row">{_mono(report_num)}</td></tr>
|
<td class="bold mono dotted-row">{_mono(report_num)}</td></tr>
|
||||||
<tr><td class="dotted-row">Дата отчета</td>
|
<tr><td class="dotted-row">Дата отчета</td>
|
||||||
|
|
@ -1096,7 +1148,7 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
|
||||||
<td class="bold dotted-row">до {_mono(expires.strftime("%d.%m.%Y"))}</td></tr>
|
<td class="bold dotted-row">до {_mono(expires.strftime("%d.%m.%Y"))}</td></tr>
|
||||||
<tr><td class="dotted-row">Адрес</td><td class="bold dotted-row">{address}</td></tr>
|
<tr><td class="dotted-row">Адрес</td><td class="bold dotted-row">{address}</td></tr>
|
||||||
<tr><td class="dotted-row">Год постройки</td>
|
<tr><td class="dotted-row">Год постройки</td>
|
||||||
<td class="bold dotted-row">{_mono(str(year_built))}</td></tr>
|
<td class="bold dotted-row">{year_label}</td></tr>
|
||||||
<tr><td class="dotted-row">Тип дома</td><td class="bold dotted-row">{house_label}</td></tr>
|
<tr><td class="dotted-row">Тип дома</td><td class="bold dotted-row">{house_label}</td></tr>
|
||||||
<tr><td class="dotted-row">Этаж / этажность</td>
|
<tr><td class="dotted-row">Этаж / этажность</td>
|
||||||
<td class="bold dotted-row">{_mono(f"{floor} / {total_floors}")}</td></tr>
|
<td class="bold dotted-row">{_mono(f"{floor} / {total_floors}")}</td></tr>
|
||||||
|
|
@ -1116,58 +1168,56 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
|
||||||
{_dual_price_block(estimate, brand)}
|
{_dual_price_block(estimate, brand)}
|
||||||
|
|
||||||
<div style="break-inside:avoid;page-break-inside:avoid;">
|
<div style="break-inside:avoid;page-break-inside:avoid;">
|
||||||
<p style="margin:6pt 0 2pt 0;font-size:{_FS_MD};font-weight:700;">
|
<p style="margin:3pt 0 2pt 0;font-size:{_FS_MD};font-weight:700;">
|
||||||
Диапазон цен в объявлениях
|
Диапазон цен в объявлениях
|
||||||
<span style="font-weight:400;font-size:{_FS_SM};color:{_MUTED};">(без учета ремонта)</span>
|
<span style="font-weight:400;font-size:{_FS_SM};color:{_MUTED};">(без учета ремонта)</span>
|
||||||
</p>
|
</p>
|
||||||
{listings_bar}
|
{listings_bar}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="break-inside:avoid;page-break-inside:avoid;">
|
|
||||||
<p style="margin:6pt 0 2pt 0;font-size:{_FS_MD};font-weight:700;">
|
|
||||||
Диапазон цен по фактическим сделкам
|
|
||||||
</p>
|
|
||||||
{deals_bar}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Что важно при оценке -->
|
<!-- Что важно при оценке -->
|
||||||
<h3 style="margin-top:8pt;font-size:{_FS_MD};font-weight:700;">
|
<h3 style="margin-top:4pt;font-size:{_FS_MD};font-weight:700;">
|
||||||
Что важно при оценке стоимости квартиры:
|
Что важно при оценке стоимости квартиры:
|
||||||
</h3>
|
</h3>
|
||||||
<table class="advice-table" style="width:100%;border-collapse:collapse;margin-top:4pt;">
|
<table class="advice-table" style="width:100%;border-collapse:collapse;margin-top:2pt;">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="advice-title">Цены в объявлениях — ожидания собственников</td>
|
<td class="advice-title">Цены в объявлениях — ожидания собственников</td>
|
||||||
<td class="advice-text">Фактические сделки проходят на 10–18% ниже, что подтверждают
|
<td class="advice-text">{advice_discount_text}</td>
|
||||||
Росреестр, ДомКлик и продажи агентств недвижимости</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="advice-title">Ремонт оценивается по состоянию, а не по вложенным суммам</td>
|
<td class="advice-title">Ремонт оценивается по состоянию, а не по вложенным суммам</td>
|
||||||
<td class="advice-text">Инвестиции в 400 – 600 тыс. руб. повышают цену объекта всего
|
<td class="advice-text">Инвестиции в 400 – 600 тыс. руб. повышают цену объекта всего
|
||||||
на 150 – 250 тыс. руб — покупатель оценивает общее состояние квартиры</td>
|
на 150 – 250 тыс. руб — покупатель оценивает общее состояние квартиры</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
|
||||||
<td class="advice-title">Неочевидные расходы при самостоятельной продаже</td>
|
|
||||||
<td class="advice-text">При самостоятельной продаже суммарные расходы могут достигать
|
|
||||||
до 15% стоимости квартиры (торг, риелтор, нотариус, справки)</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
</table>
|
||||||
|
<!-- #4-pages-fix: 3-я строка advice-table («Неочевидные расходы при
|
||||||
|
самостоятельной продаже — до 15%») убрана — дублирует детальную
|
||||||
|
постатейную разбивку на Offer-странице (см. _build_offer_page: торг
|
||||||
|
5-15% + риелтор 2-5% + аренда + юрсопровождение + реклама), которая
|
||||||
|
даёт ту же цифру НАМНОГО подробнее. Единственный content-cut в этом
|
||||||
|
фиксе (см. PR/задача про 2 пустые страницы); остальной контент cover
|
||||||
|
сохранён полностью. -->
|
||||||
|
|
||||||
<p style="margin-top:6pt;font-size:{_FS_XS};color:{_MUTED};border-top:1pt solid {_LINE};
|
<p style="margin-top:3pt;font-size:{_FS_XS};color:{_MUTED};border-top:1pt solid {_LINE};
|
||||||
padding-top:4pt;overflow-wrap:anywhere;">
|
padding-top:3pt;overflow-wrap:anywhere;">
|
||||||
<strong>Этот отчёт онлайн:</strong> {settings.public_url}?id={estimate.estimate_id}
|
<strong>Этот отчёт онлайн:</strong> {settings.public_url}?id={estimate.estimate_id}
|
||||||
</p>
|
</p>
|
||||||
{disclaimer_html}
|
{disclaimer_html}
|
||||||
{footer_html}
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _days_on_market_range(lots: list[AnalogLot]) -> tuple[int, int]:
|
def _days_on_market_range(lots: list[AnalogLot]) -> tuple[int, int] | None:
|
||||||
"""Min/max days_on_market по аналогам. Fallback 4-118 при отсутствии данных."""
|
"""Min/max days_on_market по аналогам, либо None если реальных данных нет.
|
||||||
|
|
||||||
|
Раньше возвращал хардкод (4, 118) при отсутствии days_on_market — обложка и
|
||||||
|
страница объявлений печатали этот выдуманный коридор как измеренный срок
|
||||||
|
экспозиции (~48% оценок без days_on_market). Теперь None → caller не рисует
|
||||||
|
срок (show_days=False), как уже делает страница сделок (#pdf-honesty)."""
|
||||||
days = [lot.days_on_market for lot in lots if lot.days_on_market is not None]
|
days = [lot.days_on_market for lot in lots if lot.days_on_market is not None]
|
||||||
if not days:
|
if not days:
|
||||||
return 4, 118
|
return None
|
||||||
return min(days), max(days)
|
return min(days), max(days)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1225,38 +1275,27 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
|
||||||
)
|
)
|
||||||
rooms_label = "Студия" if rooms == 0 else f"{rooms} комнаты"
|
rooms_label = "Студия" if rooms == 0 else f"{rooms} комнаты"
|
||||||
|
|
||||||
# Полоска диапазона
|
# Полоска диапазона — срок экспозиции только при реальных days_on_market
|
||||||
days_min, days_max = _days_on_market_range(estimate.analogs)
|
# (иначе не рисуем, не выдумываем «4-118 дней», #pdf-honesty).
|
||||||
|
days_range = _days_on_market_range(estimate.analogs)
|
||||||
range_bar = _price_range_chart_svg(
|
range_bar = _price_range_chart_svg(
|
||||||
estimate.range_low_rub,
|
estimate.range_low_rub,
|
||||||
estimate.range_high_rub,
|
estimate.range_high_rub,
|
||||||
sub_label="Рынок",
|
sub_label="Рынок",
|
||||||
days_min=days_min,
|
days_min=days_range[0] if days_range else None,
|
||||||
days_max=days_max,
|
days_max=days_range[1] if days_range else None,
|
||||||
show_days=True,
|
show_days=days_range is not None,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Топ-5 примеров (отсортированных по distance)
|
# Топ-5 примеров (отсортированных по distance)
|
||||||
top5 = sorted(estimate.analogs, key=lambda x: x.distance_m or 9999)[:5]
|
top5 = sorted(estimate.analogs, key=lambda x: x.distance_m or 9999)[:5]
|
||||||
examples_rows = _examples_rows(top5)
|
examples_rows = _examples_rows(top5)
|
||||||
|
|
||||||
report_num = _report_number(estimate.estimate_id)
|
|
||||||
today = dt.date.today()
|
|
||||||
footer_html = _page_footer(
|
|
||||||
brand,
|
|
||||||
report_num,
|
|
||||||
today,
|
|
||||||
f"{_html.escape(brand.name)} · Анализ рынка вторичной недвижимости · стр. 2",
|
|
||||||
valid_until=_expires_date(estimate),
|
|
||||||
)
|
|
||||||
|
|
||||||
heading_html = _section_heading("02", "РЫНОК КВАРТИР – АНАЛОГОВ ПО ОБЪЯВЛЕНИЯМ")
|
heading_html = _section_heading("02", "РЫНОК КВАРТИР – АНАЛОГОВ ПО ОБЪЯВЛЕНИЯМ")
|
||||||
|
|
||||||
return f"""
|
return f"""
|
||||||
<div style="page-break-after:always;">
|
<div style="page-break-after:always;">
|
||||||
|
|
||||||
{_page_header(brand, report_num, today)}
|
|
||||||
|
|
||||||
{heading_html}
|
{heading_html}
|
||||||
|
|
||||||
<!-- Два колонки: левая (counts + источники), правая (params card + bracket-tr) -->
|
<!-- Два колонки: левая (counts + источники), правая (params card + bracket-tr) -->
|
||||||
|
|
@ -1306,8 +1345,6 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
|
||||||
<tbody>{examples_rows}</tbody>
|
<tbody>{examples_rows}</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
{footer_html}
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -1391,6 +1428,21 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
|
||||||
today = dt.date.today()
|
today = dt.date.today()
|
||||||
period_start = today - dt.timedelta(days=estimate.period_months * 30)
|
period_start = today - dt.timedelta(days=estimate.period_months * 30)
|
||||||
|
|
||||||
|
# Баннер дисконта ссылается на РЕАЛЬНЫЙ рассчитанный дисконт запрос→продажа
|
||||||
|
# (тот же _discount_pct, что chip «−N%» на обложке), а не хардкод «10–18%»,
|
||||||
|
# который противоречил бы вычисленному «−N%» в том же PDF. Убран и ложный
|
||||||
|
# хвост «(Екатеринбург, 2026)» — локация не привязана к объекту (#pdf-honesty).
|
||||||
|
discount_pct = _discount_pct(estimate)
|
||||||
|
if discount_pct is not None:
|
||||||
|
deals_discount_text = (
|
||||||
|
"По данным реальных сделок, квартиры продаются дешевле, чем заявлено в "
|
||||||
|
f"объявлениях — по этому объекту на {discount_pct}%"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
deals_discount_text = (
|
||||||
|
"По данным реальных сделок, квартиры продаются дешевле, чем заявлено в объявлениях"
|
||||||
|
)
|
||||||
|
|
||||||
# Источники для сделок — берём из estimate.sources_used (не захардкоженный список).
|
# Источники для сделок — берём из estimate.sources_used (не захардкоженный список).
|
||||||
# Фильтруем по известным источникам сделок; fallback к пустому (не fabricate).
|
# Фильтруем по известным источникам сделок; fallback к пустому (не fabricate).
|
||||||
_deal_source_keys = {"etazhi", "domklik", "rosreestr"}
|
_deal_source_keys = {"etazhi", "domklik", "rosreestr"}
|
||||||
|
|
@ -1438,22 +1490,11 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
|
||||||
top5 = estimate.actual_deals[:5]
|
top5 = estimate.actual_deals[:5]
|
||||||
examples_rows = _examples_rows(top5)
|
examples_rows = _examples_rows(top5)
|
||||||
|
|
||||||
report_num = _report_number(estimate.estimate_id)
|
|
||||||
footer_html = _page_footer(
|
|
||||||
brand,
|
|
||||||
report_num,
|
|
||||||
today,
|
|
||||||
f"{_html.escape(brand.name)} · Анализ рынка вторичной недвижимости · стр. 3",
|
|
||||||
valid_until=_expires_date(estimate),
|
|
||||||
)
|
|
||||||
|
|
||||||
heading_html = _section_heading("03", "ФАКТИЧЕСКИЕ СДЕЛКИ ПО КВАРТИРАМ — АНАЛОГАМ")
|
heading_html = _section_heading("03", "ФАКТИЧЕСКИЕ СДЕЛКИ ПО КВАРТИРАМ — АНАЛОГАМ")
|
||||||
|
|
||||||
return f"""
|
return f"""
|
||||||
<div style="page-break-after:always;">
|
<div style="page-break-after:always;">
|
||||||
|
|
||||||
{_page_header(brand, report_num, today)}
|
|
||||||
|
|
||||||
{heading_html}
|
{heading_html}
|
||||||
|
|
||||||
<table style="width:100%;border-collapse:collapse;margin-bottom:14pt;">
|
<table style="width:100%;border-collapse:collapse;margin-bottom:14pt;">
|
||||||
|
|
@ -1495,8 +1536,7 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
|
||||||
|
|
||||||
<div style="margin-top:14pt;padding:10pt 14pt;border-left:3pt solid {_DANGER};
|
<div style="margin-top:14pt;padding:10pt 14pt;border-left:3pt solid {_DANGER};
|
||||||
background:{_DANGER_SOFT};font-size:{_FS_MD};color:{_DANGER};font-weight:600;">
|
background:{_DANGER_SOFT};font-size:{_FS_MD};color:{_DANGER};font-weight:600;">
|
||||||
По данным реальных сделок, квартиры продаются в среднем на 10–18% дешевле,
|
{deals_discount_text}
|
||||||
чем заявлено в объявлениях (Екатеринбург, 2026)
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 style="margin-top:16pt;font-size:{_FS_MD};font-weight:700;">
|
<h3 style="margin-top:16pt;font-size:{_FS_MD};font-weight:700;">
|
||||||
|
|
@ -1508,8 +1548,6 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
|
||||||
<tbody>{examples_rows}</tbody>
|
<tbody>{examples_rows}</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
{footer_html}
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -1517,7 +1555,7 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
|
||||||
# ── Page 4: Offer (Trade-In vs Самопродажа) ──────────────────────────────────
|
# ── Page 4: Offer (Trade-In vs Самопродажа) ──────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def _build_offer_page(estimate: AggregatedEstimate, brand) -> str: # type: ignore[no-untyped-def]
|
def _build_offer_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> str: # type: ignore[no-untyped-def,type-arg]
|
||||||
median = estimate.median_price_rub
|
median = estimate.median_price_rub
|
||||||
|
|
||||||
# Расчёт расходов: часть — на основе медианы (торг «от цены в объявлении»),
|
# Расчёт расходов: часть — на основе медианы (торг «от цены в объявлении»),
|
||||||
|
|
@ -1544,8 +1582,24 @@ def _build_offer_page(estimate: AggregatedEstimate, brand) -> str: # type: igno
|
||||||
rieltor_low = int(sold_price * rieltor_pct_low / 100)
|
rieltor_low = int(sold_price * rieltor_pct_low / 100)
|
||||||
rieltor_high = int(sold_price * rieltor_pct_high / 100)
|
rieltor_high = int(sold_price * rieltor_pct_high / 100)
|
||||||
|
|
||||||
# 3 месяца аренды 1-комнатной квартиры, ЕКБ 2026: ~28-45 тыс. руб./мес.
|
# 3 месяца аренды квартиры, ЕКБ 2026 (базовая ставка — студия/1-комн., ~28-45
|
||||||
rent_low, rent_high = 84_000, 135_000
|
# тыс. руб./мес.), масштабируется по числу комнат объекта (#pdf-honesty: раньше
|
||||||
|
# был плоский 1-комнатный тариф для ЛЮБОГО объекта, включая 3-4-комнатные).
|
||||||
|
# Ориентировочные месячные ставки аренды по комнатности, ЕКБ 2026:
|
||||||
|
# студия/1к ~28-45k, 2к ~40-60k, 3к ~55-80k, 4к+ ~75-110k.
|
||||||
|
# Множители — грубое отношение верхних границ этих диапазонов к базовой (1к).
|
||||||
|
rooms = int(input_snapshot.get("rooms", 0) or 0)
|
||||||
|
if rooms <= 1:
|
||||||
|
rent_multiplier = 1.0
|
||||||
|
elif rooms == 2:
|
||||||
|
rent_multiplier = 1.35
|
||||||
|
elif rooms == 3:
|
||||||
|
rent_multiplier = 1.8
|
||||||
|
else:
|
||||||
|
rent_multiplier = 2.4
|
||||||
|
rent_base_low, rent_base_high = 84_000, 135_000
|
||||||
|
rent_low = round(rent_base_low * rent_multiplier / 1000) * 1000
|
||||||
|
rent_high = round(rent_base_high * rent_multiplier / 1000) * 1000
|
||||||
# Юридическое сопровождение сделки — диапазон (не фикс), варьируется по объёму работ.
|
# Юридическое сопровождение сделки — диапазон (не фикс), варьируется по объёму работ.
|
||||||
juridical_low, juridical_high = 15_000, 50_000
|
juridical_low, juridical_high = 15_000, 50_000
|
||||||
# Реклама/продвижение за 3 месяца — низкая уверенность в оценке (нет единого
|
# Реклама/продвижение за 3 месяца — низкая уверенность в оценке (нет единого
|
||||||
|
|
@ -1558,22 +1612,9 @@ def _build_offer_page(estimate: AggregatedEstimate, brand) -> str: # type: igno
|
||||||
brand_short = _html.escape(brand.name)
|
brand_short = _html.escape(brand.name)
|
||||||
trade_in_label = f"{brand_short}.Обмен" if brand.slug != "generic" else "Trade-In"
|
trade_in_label = f"{brand_short}.Обмен" if brand.slug != "generic" else "Trade-In"
|
||||||
|
|
||||||
report_num = _report_number(estimate.estimate_id)
|
|
||||||
today = dt.date.today()
|
|
||||||
footer_html = _page_footer(
|
|
||||||
brand,
|
|
||||||
report_num,
|
|
||||||
today,
|
|
||||||
f"{brand_short} · Анализ рынка вторичной недвижимости · стр. 4 · "
|
|
||||||
"Расчёт носит ориентировочный характер и не является офертой.",
|
|
||||||
valid_until=_expires_date(estimate),
|
|
||||||
)
|
|
||||||
|
|
||||||
return f"""
|
return f"""
|
||||||
<div><!-- last page: NO page-break-after -->
|
<div><!-- last page: NO page-break-after -->
|
||||||
|
|
||||||
{_page_header(brand, report_num, today)}
|
|
||||||
|
|
||||||
{_section_heading("04", "ФОРМИРОВАНИЕ ВЫКУПНОЙ СТОИМОСТИ")}
|
{_section_heading("04", "ФОРМИРОВАНИЕ ВЫКУПНОЙ СТОИМОСТИ")}
|
||||||
|
|
||||||
<h3 style="font-size:{_FS_MD};font-weight:700;margin-bottom:8pt;">
|
<h3 style="font-size:{_FS_MD};font-weight:700;margin-bottom:8pt;">
|
||||||
|
|
@ -1626,7 +1667,8 @@ def _build_offer_page(estimate: AggregatedEstimate, brand) -> str: # type: igno
|
||||||
<tr style="border-bottom:1px solid {_BORDER};">
|
<tr style="border-bottom:1px solid {_BORDER};">
|
||||||
<td style="padding:6pt 4pt;">
|
<td style="padding:6pt 4pt;">
|
||||||
<div class="bold">Аренда после сделки</div>
|
<div class="bold">Аренда после сделки</div>
|
||||||
<div style="font-size:{_FS_XS};color:{_MUTED};">однокомнатной квартиры на 3 месяца</div>
|
<div style="font-size:{_FS_XS};color:{_MUTED};">
|
||||||
|
{f"{rooms}-комн. квартиры на 3 месяца" if rooms > 0 else "квартиры на 3 месяца"}</div>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding:6pt 4pt;text-align:right;color:{_SUCCESS};font-weight:700;">
|
<td style="padding:6pt 4pt;text-align:right;color:{_SUCCESS};font-weight:700;">
|
||||||
бесплатно</td>
|
бесплатно</td>
|
||||||
|
|
@ -1738,7 +1780,9 @@ def _build_offer_page(estimate: AggregatedEstimate, brand) -> str: # type: igno
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{footer_html}
|
<p style="margin-top:14pt;font-size:{_FS_XS};color:{_MUTED};">
|
||||||
|
Расчёт носит ориентировочный характер и не является офертой.
|
||||||
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
|
|
@ -1794,8 +1838,12 @@ def _build_css(brand=None) -> str: # type: ignore[no-untyped-def]
|
||||||
}}
|
}}
|
||||||
@page {{
|
@page {{
|
||||||
size: A4;
|
size: A4;
|
||||||
margin: 20mm 18mm 20mm 18mm;
|
margin: 25mm 18mm 19mm 18mm;
|
||||||
|
@top-center {{ content: element(runningHeader); vertical-align: bottom; }}
|
||||||
|
@bottom-center {{ content: element(runningFooter); vertical-align: top; }}
|
||||||
}}
|
}}
|
||||||
|
.running-header {{ position: running(runningHeader); }}
|
||||||
|
.running-footer {{ position: running(runningFooter); }}
|
||||||
* {{ box-sizing: border-box; }}
|
* {{ box-sizing: border-box; }}
|
||||||
body {{
|
body {{
|
||||||
font-family: 'PT Sans', 'DejaVu Sans', sans-serif;
|
font-family: 'PT Sans', 'DejaVu Sans', sans-serif;
|
||||||
|
|
@ -2015,7 +2063,7 @@ def _build_insufficient_data_page(estimate: AggregatedEstimate, input_snapshot:
|
||||||
Предотвращает публикацию «0,0 млн» и fabricated таблиц потерь.
|
Предотвращает публикацию «0,0 млн» и fabricated таблиц потерь.
|
||||||
"""
|
"""
|
||||||
address = _html.escape(input_snapshot.get("address", "—"))
|
address = _html.escape(input_snapshot.get("address", "—"))
|
||||||
report_num = _report_number(estimate.estimate_id)
|
report_num = _report_number(estimate)
|
||||||
today = dt.date.today()
|
today = dt.date.today()
|
||||||
return f"""
|
return f"""
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -2103,11 +2151,28 @@ def generate_trade_in_pdf(
|
||||||
if estimate.insufficient_data:
|
if estimate.insufficient_data:
|
||||||
body_html = _build_insufficient_data_page(estimate, input_snapshot, brand)
|
body_html = _build_insufficient_data_page(estimate, input_snapshot, brand)
|
||||||
else:
|
else:
|
||||||
|
# Running header/footer — единый HUD-хром печатается WeasyPrint'ом в page
|
||||||
|
# margin-boxes (@top-center/@bottom-center, см. _build_css) на КАЖДОЙ странице
|
||||||
|
# документа, а не только там, где явно вставлен в поток секции-билдера.
|
||||||
|
# Рендерится один раз здесь (не внутри каждого _build_*_page) — иначе WeasyPrint
|
||||||
|
# печатает независимый running-элемент с последнего "видимого" перед разрывом
|
||||||
|
# страницы, что дало бы 4 разных чанка вместо одного персистентного хрома.
|
||||||
|
_chrome_report_num = _report_number(estimate)
|
||||||
|
_chrome_today = dt.date.today()
|
||||||
|
_chrome_expires = _expires_date(estimate)
|
||||||
|
chrome_html = (
|
||||||
|
f'<div class="running-header">'
|
||||||
|
f"{_page_header(brand, _chrome_report_num, _chrome_today)}</div>"
|
||||||
|
f'<div class="running-footer">'
|
||||||
|
f"{_page_footer(brand, _chrome_report_num, _chrome_today, valid_until=_chrome_expires)}"
|
||||||
|
f"</div>"
|
||||||
|
)
|
||||||
body_html = (
|
body_html = (
|
||||||
_build_cover(estimate, input_snapshot, brand)
|
chrome_html
|
||||||
|
+ _build_cover(estimate, input_snapshot, brand)
|
||||||
+ _build_listings_page(estimate, input_snapshot, brand)
|
+ _build_listings_page(estimate, input_snapshot, brand)
|
||||||
+ _build_deals_page(estimate, input_snapshot, brand)
|
+ _build_deals_page(estimate, input_snapshot, brand)
|
||||||
+ _build_offer_page(estimate, brand)
|
+ _build_offer_page(estimate, input_snapshot, brand)
|
||||||
)
|
)
|
||||||
|
|
||||||
html_str = (
|
html_str = (
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,165 @@ def is_within_ekb_bbox_wide(lat: float, lon: float) -> bool:
|
||||||
return is_within_ekb_bbox(lat, lon, EKB_BBOX_WIDE)
|
return is_within_ekb_bbox(lat, lon, EKB_BBOX_WIDE)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Sverdlovsk oblast (region 66) ────────────────────────────────────────────
|
||||||
|
# Генеральный bbox всей области — от южной границы (ЕКБ/Полевской) до
|
||||||
|
# северных городов (Ивдель/Североуральск/Краснотурьинск). Специально щедрый:
|
||||||
|
# используется как fallback-accept для провайдеров БЕЗ структурного region-поля
|
||||||
|
# (сырые Point.pos от Yandex/Nominatim) — когда нет ФИАС/КЛАДР для точной
|
||||||
|
# проверки region==66. Дороже false-positive у самой границы соседних регионов,
|
||||||
|
# дешевле false-negative (выбросить корректный областной адрес — исходный баг).
|
||||||
|
# bbox = (lat_min, lat_max, lon_min, lon_max).
|
||||||
|
OBLAST66_BBOX = (55.8, 62.2, 56.7, 66.6)
|
||||||
|
|
||||||
|
# Nominatim/Yandex viewport для областного (не только ЕКБ) поиска — тот же
|
||||||
|
# формат что EKB_BBOX ниже, но покрывает всю область. viewbox — Nominatim
|
||||||
|
# (lon1,lat1,lon2,lat2, NW→SE); ll/spn — Yandex центр+охват (lon,lat).
|
||||||
|
OBLAST66_VIEWBOX = {
|
||||||
|
"viewbox": "56.7,62.2,66.6,55.8",
|
||||||
|
"ll": "61.65,59.0",
|
||||||
|
"spn": "9.9,6.4",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_within_oblast66_bbox(lat: float, lon: float) -> bool:
|
||||||
|
"""True если (lat, lon) в генеральном bbox Свердловской области (регион 66)."""
|
||||||
|
return is_within_ekb_bbox(lat, lon, OBLAST66_BBOX)
|
||||||
|
|
||||||
|
|
||||||
|
# Крупные города области (кроме ЕКБ) — НЕ исчерпывающий список (в области ~30
|
||||||
|
# городов), покрывает основные региональные центры. Используется чтобы:
|
||||||
|
# (a) не навязывать "Екатеринбург, " в запрос, если пользователь уже указал
|
||||||
|
# другой город региона (геокодер forward/suggest prefix-логика);
|
||||||
|
# (b) как known-locality set в _yandex_reverse при перестановке "улица, город".
|
||||||
|
SVERDLOVSK_OBLAST_CITIES = frozenset(
|
||||||
|
{
|
||||||
|
"екатеринбург",
|
||||||
|
"нижний тагил",
|
||||||
|
"каменск-уральский",
|
||||||
|
"первоуральск",
|
||||||
|
"серов",
|
||||||
|
"новоуральск",
|
||||||
|
"ревда",
|
||||||
|
"полевской",
|
||||||
|
"асбест",
|
||||||
|
"богданович",
|
||||||
|
"ирбит",
|
||||||
|
"красноуфимск",
|
||||||
|
"верхняя пышма",
|
||||||
|
"берёзовский",
|
||||||
|
"березовский",
|
||||||
|
"заречный",
|
||||||
|
"качканар",
|
||||||
|
"краснотурьинск",
|
||||||
|
"североуральск",
|
||||||
|
"ивдель",
|
||||||
|
"тавда",
|
||||||
|
"туринск",
|
||||||
|
"сысерть",
|
||||||
|
"среднеуральск",
|
||||||
|
"дегтярск",
|
||||||
|
"верхняя салда",
|
||||||
|
"нижняя салда",
|
||||||
|
"невьянск",
|
||||||
|
"артёмовский",
|
||||||
|
"артемовский",
|
||||||
|
"камышлов",
|
||||||
|
"алапаевск",
|
||||||
|
"сухой лог",
|
||||||
|
"кушва",
|
||||||
|
"красноуральск",
|
||||||
|
"карпинск",
|
||||||
|
"нижняя тура",
|
||||||
|
"верхний тагил",
|
||||||
|
"нижние серги",
|
||||||
|
# НЕ добавляем "лесной": ekb_geoportal_buildings содержит реальную ЕКБ-улицу
|
||||||
|
# «Лесной» (3 дома) — точное совпадение целого слова, не отличимое от
|
||||||
|
# города ЗАТО Лесной word-boundary'ом (в отличие от «Серова»/«Серов»,
|
||||||
|
# где разные словоформы). Бары-адрес "Лесной, 5" без "Екатеринбург" и
|
||||||
|
# без district-префикса ложно ушёл бы в non-EKB gate.
|
||||||
|
}
|
||||||
|
)
|
||||||
|
SVERDLOVSK_OBLAST_REGION = "Свердловская область"
|
||||||
|
|
||||||
|
# Word/phrase-boundary regex — НЕ substring — чтобы «Серова 27» не матчил город
|
||||||
|
# «Серов», «Ирбитская 5» — «Ирбит», «Асбестовский пер.» — «Асбест», «Невьянский
|
||||||
|
# пер.» — «Невьянск», «Богдановича» — «Богданович» (все реальные EKB-адреса,
|
||||||
|
# substring-матч ложно ронял "Екатеринбург, "-префикс на них → неверный город
|
||||||
|
# → неверная цена). Python `\b` для str-паттернов юникод-осведомлён (Cyrillic
|
||||||
|
# входит в `\w`), поэтому «серова» корректно НЕ матчит `\bсеров\b` (после
|
||||||
|
# «серов» идёт «а» — тоже \w, границы нет).
|
||||||
|
_OBLAST_CITY_RE = re.compile(
|
||||||
|
r"\b(?:" + "|".join(re.escape(city) for city in SVERDLOVSK_OBLAST_CITIES) + r")\b"
|
||||||
|
)
|
||||||
|
_OBLAST_MARKER_RE = re.compile(r"\bсвердловск\w*\b")
|
||||||
|
# Приставки, после которых токен обозначает РАЙОН/микрорайон города, а не сам
|
||||||
|
# город региона: «мкр Заречный» — микрорайон ВНУТРИ другого города (не ЗАТО
|
||||||
|
# Заречный). Без этого исключения точное совпадение слова "заречный" всё равно
|
||||||
|
# ложно сработало бы (это не substring-баг, а реальная омонимия имени).
|
||||||
|
_DISTRICT_PREFIXES = frozenset({"мкр", "мкр.", "микрорайон", "р-н", "р-он", "район", "жк"})
|
||||||
|
|
||||||
|
|
||||||
|
def _has_oblast_marker(text_lower: str) -> bool:
|
||||||
|
"""True если текст уже содержит упоминание области/города региона 66.
|
||||||
|
|
||||||
|
Используется чтобы НЕ навязывать "Екатеринбург, " в запрос, когда адрес
|
||||||
|
уже привязан к другому городу/области — иначе получим двойной город
|
||||||
|
("Екатеринбург, Нижний Тагил, Ленина 10") и провайдер вернёт мусор/пусто.
|
||||||
|
|
||||||
|
Матчинг — по границе слова/фразы (см. `_OBLAST_CITY_RE`), НЕ substring —
|
||||||
|
и с исключением "мкр/микрорайон/р-н <город>" (район ВНУТРИ другого города).
|
||||||
|
"""
|
||||||
|
normalized = " ".join(text_lower.split())
|
||||||
|
if _OBLAST_MARKER_RE.search(normalized):
|
||||||
|
return True
|
||||||
|
for m in _OBLAST_CITY_RE.finditer(normalized):
|
||||||
|
prefix_words = normalized[: m.start()].split()
|
||||||
|
if prefix_words and prefix_words[-1] in _DISTRICT_PREFIXES:
|
||||||
|
continue # «мкр Заречный» — район, не город-ЗАТО Заречный
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# Города региона 66 КРОМЕ Екатеринбурга — используется чтобы отсечь EKB-only
|
||||||
|
# локальные тиры (geoportal/cadastral, см. `geocode()`) от адреса другого
|
||||||
|
# города области. re.escape на элементах SVERDLOVSK_OBLAST_CITIES-{ekb}.
|
||||||
|
_NON_EKB_OBLAST_CITIES = SVERDLOVSK_OBLAST_CITIES - {"екатеринбург"}
|
||||||
|
_NON_EKB_CITY_RE = re.compile(
|
||||||
|
r"\b(?:" + "|".join(re.escape(c) for c in _NON_EKB_OBLAST_CITIES) + r")\b"
|
||||||
|
)
|
||||||
|
# Word-boundary, НЕ substring — «екатеринбургское шоссе» (реальная улица в
|
||||||
|
# городах-спутниках, напр. Первоуральске) не должно матчить как «Екатеринбург».
|
||||||
|
_EKATERINBURG_RE = re.compile(r"\bекатеринбург\b")
|
||||||
|
|
||||||
|
|
||||||
|
def _names_non_ekb_city(address: str) -> bool:
|
||||||
|
"""True если адрес явно называет город Свердловской области, ОТЛИЧНЫЙ от
|
||||||
|
Екатеринбурга (по границе слова, с исключением district-приставок «мкр/р-н
|
||||||
|
<город>»). Используется чтобы НЕ пускать EKB-only локальные тиры
|
||||||
|
(geoportal/cadastral) на не-ЕКБ адрес: улица+дом, совпадающие с ЕКБ-домом
|
||||||
|
(проспект Ленина 1 есть в обоих городах), иначе адрес снапается в ЕКБ (#11).
|
||||||
|
|
||||||
|
Явное "Екатеринбург" в адресе имеет приоритет над любым омонимом другого
|
||||||
|
города региона — иначе ЖК/посёлки с названием, совпадающим с другим
|
||||||
|
городом области (ЖК «Заречный», пос. «Сысерть» внутри ЕКБ), ложно
|
||||||
|
отсекали бы EKB-only тиры для реального ЕКБ-адреса.
|
||||||
|
"""
|
||||||
|
normalized = " ".join(address.lower().split())
|
||||||
|
# Явное "Екатеринбург" (отдельным словом — см. `_EKATERINBURG_RE`) в адресе
|
||||||
|
# → это ЕКБ, даже если в тексте встречается омоним соседнего города (ЖК
|
||||||
|
# «Заречный», пос. Сысерть в составе МО «город Екатеринбург» и т.п. —
|
||||||
|
# реальные false-positive из прод-кэша). Word-boundary же не даёт слову
|
||||||
|
# «Екатеринбургское» (шоссе в городах-спутниках) ложно сработать как город.
|
||||||
|
if _EKATERINBURG_RE.search(normalized):
|
||||||
|
return False
|
||||||
|
for m in _NON_EKB_CITY_RE.finditer(normalized):
|
||||||
|
prefix_words = normalized[: m.start()].split()
|
||||||
|
if prefix_words and prefix_words[-1] in _DISTRICT_PREFIXES:
|
||||||
|
continue # «мкр Заречный» — район внутри другого города, не ЗАТО Заречный
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
# ── Address normalisation ───────────────────────────────────────────────────
|
# ── Address normalisation ───────────────────────────────────────────────────
|
||||||
def normalize_address(address: str) -> str:
|
def normalize_address(address: str) -> str:
|
||||||
"""Нормализация для cache lookup: lowercase + trim + collapse whitespace.
|
"""Нормализация для cache lookup: lowercase + trim + collapse whitespace.
|
||||||
|
|
@ -166,11 +325,35 @@ def _cache_put(db: Session, address_norm: str, result: GeocodeResult) -> None:
|
||||||
|
|
||||||
|
|
||||||
# ── Provider: Nominatim (OSM, без ключа) ────────────────────────────────────
|
# ── Provider: Nominatim (OSM, без ключа) ────────────────────────────────────
|
||||||
async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | None:
|
def _nominatim_region_ok(item: dict) -> bool | None:
|
||||||
"""Single Nominatim search. Возвращает первый item или None.
|
"""Кросс-чек региона по Nominatim `address.state` (доступно т.к. addressdetails=1).
|
||||||
|
|
||||||
ВАЖНО: фильтруем результаты по ЕКБ bbox прямо тут, чтобы при опечатках
|
True/False если state однозначно про/не про Свердловскую область. None если
|
||||||
не возвращать Пермский край / Челябинск.
|
поле отсутствует/не строка — тогда accept-логика падает обратно на bbox.
|
||||||
|
Ловит Тюмень/Шадринск/Кунгур/Снежинск — они внутри генерального OBLAST66_BBOX
|
||||||
|
(специально щедрого), но их state явно другой регион.
|
||||||
|
"""
|
||||||
|
addr = item.get("address")
|
||||||
|
if not isinstance(addr, dict):
|
||||||
|
return None
|
||||||
|
state = addr.get("state")
|
||||||
|
if not isinstance(state, str) or not state:
|
||||||
|
return None
|
||||||
|
return "свердловск" in state.lower()
|
||||||
|
|
||||||
|
|
||||||
|
async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | None:
|
||||||
|
"""Single Nominatim search. Возвращает лучший item или None.
|
||||||
|
|
||||||
|
ВАЖНО: фильтруем результаты по bbox области (region 66) прямо тут, чтобы при
|
||||||
|
опечатках не возвращать Пермский край / Челябинск — но не резать легитимные
|
||||||
|
Нижний Тагил / Серов и т.д. (генеральный bbox всей Свердловской области).
|
||||||
|
|
||||||
|
Two-pass tie-break: среди кандидатов предпочитаем того, кто попадает в TIGHT
|
||||||
|
ЕКБ-bbox (byte-identical для ЕКБ-запросов, даже если Nominatim ранжировал его
|
||||||
|
не первым) — иначе первый кандидат внутри OBLAST66. Плюс region cross-check
|
||||||
|
(`address.state`) — отсекает кандидатов ЯВНО из другого региона (Тюмень и
|
||||||
|
т.п.), даже если координаты попали в генеральный bbox.
|
||||||
"""
|
"""
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
"https://nominatim.openstreetmap.org/search",
|
"https://nominatim.openstreetmap.org/search",
|
||||||
|
|
@ -180,21 +363,26 @@ async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | No
|
||||||
"limit": "3",
|
"limit": "3",
|
||||||
"countrycodes": "ru",
|
"countrycodes": "ru",
|
||||||
"addressdetails": "1",
|
"addressdetails": "1",
|
||||||
"viewbox": EKB_BBOX["viewbox"],
|
"viewbox": OBLAST66_VIEWBOX["viewbox"],
|
||||||
"bounded": "1", # строго в ЕКБ bbox
|
"bounded": "1", # строго в пределах области (region 66)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
oblast_fallback: dict | None = None
|
||||||
for item in data:
|
for item in data:
|
||||||
try:
|
try:
|
||||||
lat_f = float(item["lat"])
|
lat_f = float(item["lat"])
|
||||||
lon_f = float(item["lon"])
|
lon_f = float(item["lon"])
|
||||||
if is_within_ekb_bbox(lat_f, lon_f):
|
|
||||||
return item
|
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
return None
|
if _nominatim_region_ok(item) is False:
|
||||||
|
continue # регион явно не Свердловская область — не рассматриваем
|
||||||
|
if is_within_ekb_bbox(lat_f, lon_f):
|
||||||
|
return item # tight-ЕКБ приоритетнее — тот же результат, что и раньше
|
||||||
|
if oblast_fallback is None and is_within_oblast66_bbox(lat_f, lon_f):
|
||||||
|
oblast_fallback = item
|
||||||
|
return oblast_fallback
|
||||||
|
|
||||||
|
|
||||||
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
|
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
|
||||||
|
|
@ -202,8 +390,8 @@ async def _nominatim_lookup(address: str) -> GeocodeResult | None:
|
||||||
"""OSM Nominatim — бесплатно, без ключа, 1 req/sec policy.
|
"""OSM Nominatim — бесплатно, без ключа, 1 req/sec policy.
|
||||||
|
|
||||||
Бан-policy: User-Agent с email обязателен.
|
Бан-policy: User-Agent с email обязателен.
|
||||||
Tier 1: bounded ЕКБ на оригинальный адрес.
|
Tier 1: bounded область (region 66) на оригинальный адрес.
|
||||||
Tier 2: bounded ЕКБ на typo-варианты (Цвилинга → Цвиллинга).
|
Tier 2: bounded область (region 66) на typo-варианты (Цвилинга → Цвиллинга).
|
||||||
"""
|
"""
|
||||||
headers = {
|
headers = {
|
||||||
"User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})",
|
"User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})",
|
||||||
|
|
@ -237,6 +425,30 @@ async def _nominatim_lookup(address: str) -> GeocodeResult | None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _yandex_region_ok(geo_object: dict) -> bool | None:
|
||||||
|
"""Кросс-чек региона по Yandex `AdministrativeAreaName`.
|
||||||
|
|
||||||
|
True/False если Yandex вернул поле и оно однозначно про/не про Свердловскую
|
||||||
|
область. None если поля нет / оно не строка — тогда accept-логика падает
|
||||||
|
обратно на bbox. Ловит Тюмень/Шадринск/Кунгур/Снежинск — они внутри
|
||||||
|
генерального OBLAST66_BBOX (специально щедрого), но их регион явно другой.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
admin_area = (
|
||||||
|
geo_object.get("metaDataProperty", {})
|
||||||
|
.get("GeocoderMetaData", {})
|
||||||
|
.get("AddressDetails", {})
|
||||||
|
.get("Country", {})
|
||||||
|
.get("AdministrativeArea", {})
|
||||||
|
.get("AdministrativeAreaName")
|
||||||
|
)
|
||||||
|
except AttributeError:
|
||||||
|
return None
|
||||||
|
if not isinstance(admin_area, str) or not admin_area:
|
||||||
|
return None
|
||||||
|
return "свердловск" in admin_area.lower()
|
||||||
|
|
||||||
|
|
||||||
# ── Provider: Yandex Geocoder (требует key, лучшее покрытие РФ) ─────────────
|
# ── Provider: Yandex Geocoder (требует key, лучшее покрытие РФ) ─────────────
|
||||||
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
|
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
|
||||||
async def _yandex_lookup(address: str, api_key: str) -> GeocodeResult | None:
|
async def _yandex_lookup(address: str, api_key: str) -> GeocodeResult | None:
|
||||||
|
|
@ -247,8 +459,9 @@ async def _yandex_lookup(address: str, api_key: str) -> GeocodeResult | None:
|
||||||
Запрашиваем с ll+spn (центр ЕКБ) для приоритизации местных результатов,
|
Запрашиваем с ll+spn (центр ЕКБ) для приоритизации местных результатов,
|
||||||
но БЕЗ rspn — чтобы fuzzy matching работал при опечатках.
|
но БЕЗ rspn — чтобы fuzzy matching работал при опечатках.
|
||||||
"""
|
"""
|
||||||
# Не запихиваем "Екатеринбург" если оно уже есть в адресе (типичный кейс из suggest)
|
# Не навязываем "Екатеринбург, " если в адресе уже есть город/область региона 66
|
||||||
geocode_query = address if "екатеринбург" in address.lower() else f"Екатеринбург, {address}"
|
# (типичный кейс из suggest, либо явный запрос по другому городу области).
|
||||||
|
geocode_query = address if _has_oblast_marker(address.lower()) else f"Екатеринбург, {address}"
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
"https://geocode-maps.yandex.ru/1.x/",
|
"https://geocode-maps.yandex.ru/1.x/",
|
||||||
|
|
@ -269,22 +482,44 @@ async def _yandex_lookup(address: str, api_key: str) -> GeocodeResult | None:
|
||||||
if not members:
|
if not members:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Фильтруем top-5 по ЕКБ bbox — игнорируем Челябинск/Уфу/Москву при опечатке
|
# Фильтруем top-5 по bbox области (region 66) — игнорируем Челябинск/Уфу/Москву
|
||||||
|
# при опечатке, но принимаем легитимные Нижний Тагил/Серов/etc. Two-pass
|
||||||
|
# tie-break: tight-ЕКБ приоритетнее (byte-identical для ЕКБ-запросов, даже
|
||||||
|
# если Yandex ранжировал его не первым), иначе первый oblast-bbox кандидат.
|
||||||
|
# Region cross-check (AdministrativeAreaName) отсекает кандидатов ЯВНО из
|
||||||
|
# другого региона (Тюмень и т.п. попадают в генеральный bbox координатно).
|
||||||
best = None
|
best = None
|
||||||
|
oblast_fallback = None
|
||||||
for m in members:
|
for m in members:
|
||||||
obj = m.get("GeoObject", {})
|
obj = m.get("GeoObject", {})
|
||||||
try:
|
try:
|
||||||
lon_str, lat_str = obj["Point"]["pos"].split()
|
lon_str, lat_str = obj["Point"]["pos"].split()
|
||||||
lat_f, lon_f = float(lat_str), float(lon_str)
|
lat_f, lon_f = float(lat_str), float(lon_str)
|
||||||
if is_within_ekb_bbox(lat_f, lon_f):
|
|
||||||
best = obj
|
|
||||||
break
|
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
if _yandex_region_ok(obj) is False:
|
||||||
|
continue # регион явно не Свердловская область — не рассматриваем
|
||||||
|
if is_within_ekb_bbox(lat_f, lon_f):
|
||||||
|
best = obj
|
||||||
|
break
|
||||||
|
if oblast_fallback is None and is_within_oblast66_bbox(lat_f, lon_f):
|
||||||
|
oblast_fallback = obj
|
||||||
|
|
||||||
if best is None:
|
if best is None:
|
||||||
# Никто из top-5 не попал в ЕКБ → берем первый «как есть» (вне ЕКБ — но хоть что-то)
|
best = oblast_fallback
|
||||||
best = members[0]["GeoObject"]
|
|
||||||
|
if best is None:
|
||||||
|
# Никто из top-5 не попал ни в tight, ни в oblast bbox (или все явно
|
||||||
|
# отсеяны по региону) → берём первый «как есть», НО только если его
|
||||||
|
# регион не опровергнут явно — иначе весь смысл region cross-check
|
||||||
|
# теряется на garbage-query, где top-1 — гарантированно не тот регион
|
||||||
|
# (например, Тюмень).
|
||||||
|
first_obj = members[0]["GeoObject"]
|
||||||
|
if _yandex_region_ok(first_obj) is not False:
|
||||||
|
best = first_obj
|
||||||
|
|
||||||
|
if best is None:
|
||||||
|
return None
|
||||||
|
|
||||||
lon_str, lat_str = best["Point"]["pos"].split()
|
lon_str, lat_str = best["Point"]["pos"].split()
|
||||||
precision_raw = (
|
precision_raw = (
|
||||||
|
|
@ -310,6 +545,8 @@ async def _yandex_lookup(address: str, api_key: str) -> GeocodeResult | None:
|
||||||
|
|
||||||
# ── Suggest (автокомплит) ───────────────────────────────────────────────────
|
# ── Suggest (автокомплит) ───────────────────────────────────────────────────
|
||||||
# ЕКБ bounding box (приблизительно): юг 56.65, запад 60.40, север 56.95, восток 60.85
|
# ЕКБ bounding box (приблизительно): юг 56.65, запад 60.40, север 56.95, восток 60.85
|
||||||
|
# Это ЕКБ fast-path (Yandex rspn=1 bounded tier + soft-bias ll/spn). Для областного
|
||||||
|
# охвата (region 66) — см. OBLAST66_VIEWBOX выше.
|
||||||
EKB_BBOX = {
|
EKB_BBOX = {
|
||||||
"viewbox": "60.40,56.95,60.85,56.65", # Nominatim format: lon1,lat1,lon2,lat2 (NW,SE)
|
"viewbox": "60.40,56.95,60.85,56.65", # Nominatim format: lon1,lat1,lon2,lat2 (NW,SE)
|
||||||
"ll": "60.605,56.838", # Yandex center (lon,lat)
|
"ll": "60.605,56.838", # Yandex center (lon,lat)
|
||||||
|
|
@ -375,9 +612,13 @@ async def _dadata_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]
|
||||||
типа город/район, для autocomplete с привязкой к карте они бесполезны).
|
типа город/район, для autocomplete с привязкой к карте они бесполезны).
|
||||||
|
|
||||||
Label собирается из DaData `value` (короткая форма «ул Малышева, д 30»).
|
Label собирается из DaData `value` (короткая форма «ул Малышева, д 30»).
|
||||||
Без city suffix — query уже ограничен `locations=[{city: 'Екатеринбург'}]`.
|
Constraint — вся область (region='Свердловская область', hard-filter внутри
|
||||||
|
`suggest_addresses`), а не один город ЕКБ — иначе Нижний Тагил/Серов/etc
|
||||||
|
никогда не появились бы в подсказках.
|
||||||
"""
|
"""
|
||||||
raw = await dadata.suggest_addresses(query, limit=limit)
|
raw = await dadata.suggest_addresses(
|
||||||
|
query, limit=limit, city=None, region=SVERDLOVSK_OBLAST_REGION
|
||||||
|
)
|
||||||
out: list[GeocodeSuggestion] = []
|
out: list[GeocodeSuggestion] = []
|
||||||
for s in raw:
|
for s in raw:
|
||||||
if s.lat is None or s.lon is None:
|
if s.lat is None or s.lon is None:
|
||||||
|
|
@ -424,16 +665,19 @@ async def _yandex_geocode_request(
|
||||||
async def _yandex_suggest(query: str, api_key: str, limit: int = 8) -> list[GeocodeSuggestion]:
|
async def _yandex_suggest(query: str, api_key: str, limit: int = 8) -> list[GeocodeSuggestion]:
|
||||||
"""Yandex Geocoder с авто-fallback на typo-tolerant режим.
|
"""Yandex Geocoder с авто-fallback на typo-tolerant режим.
|
||||||
|
|
||||||
Tier 1: bounded ЕКБ (rspn=1) на оригинальный query.
|
Tier 1: bounded ЕКБ (rspn=1) — быстрый путь для основного (ЕКБ) трафика.
|
||||||
Tier 2: bounded ЕКБ на typo-variants (удвоение согласных).
|
Tier 2: bounded ЕКБ на typo-variants (удвоение согласных).
|
||||||
Tier 3: без rspn — fuzzy по всей стране, фильтр результатов по ЕКБ bbox.
|
Tier 3: без rspn — fuzzy по всей стране, фильтр результатов по bbox области
|
||||||
|
(region 66) — ловит легитимные Нижний Тагил/Серов/etc, которые Tier 1/2
|
||||||
|
(bounded строго ЕКБ) структурно вернуть не могут.
|
||||||
"""
|
"""
|
||||||
|
prefixed_query = query if _has_oblast_marker(query.lower()) else f"Екатеринбург, {query}"
|
||||||
async with httpx.AsyncClient(timeout=8.0) as client:
|
async with httpx.AsyncClient(timeout=8.0) as client:
|
||||||
# Tier 1: strict bounded на оригинал
|
# Tier 1: strict bounded на оригинал (ЕКБ fast path)
|
||||||
members = await _yandex_geocode_request(
|
members = await _yandex_geocode_request(
|
||||||
client,
|
client,
|
||||||
api_key,
|
api_key,
|
||||||
f"Екатеринбург, {query}",
|
prefixed_query,
|
||||||
limit,
|
limit,
|
||||||
bounded=True,
|
bounded=True,
|
||||||
)
|
)
|
||||||
|
|
@ -441,12 +685,15 @@ async def _yandex_suggest(query: str, api_key: str, limit: int = 8) -> list[Geoc
|
||||||
if results:
|
if results:
|
||||||
return results
|
return results
|
||||||
|
|
||||||
# Tier 2: bounded на typo-варианты
|
# Tier 2: bounded на typo-варианты (тот же ЕКБ fast path)
|
||||||
for variant in _typo_variants(query, limit=4):
|
for variant in _typo_variants(query, limit=4):
|
||||||
|
variant_query = (
|
||||||
|
variant if _has_oblast_marker(variant.lower()) else f"Екатеринбург, {variant}"
|
||||||
|
)
|
||||||
members = await _yandex_geocode_request(
|
members = await _yandex_geocode_request(
|
||||||
client,
|
client,
|
||||||
api_key,
|
api_key,
|
||||||
f"Екатеринбург, {variant}",
|
variant_query,
|
||||||
limit,
|
limit,
|
||||||
bounded=True,
|
bounded=True,
|
||||||
)
|
)
|
||||||
|
|
@ -454,21 +701,21 @@ async def _yandex_suggest(query: str, api_key: str, limit: int = 8) -> list[Geoc
|
||||||
if results:
|
if results:
|
||||||
return results
|
return results
|
||||||
|
|
||||||
# Tier 3: без rspn — даём fuzzy сделать своё дело, фильтр по bbox
|
# Tier 3: без rspn — даём fuzzy сделать своё дело, фильтр по bbox области
|
||||||
members = await _yandex_geocode_request(
|
members = await _yandex_geocode_request(
|
||||||
client,
|
client,
|
||||||
api_key,
|
api_key,
|
||||||
f"Екатеринбург, {query}",
|
prefixed_query,
|
||||||
limit,
|
limit,
|
||||||
bounded=False,
|
bounded=False,
|
||||||
)
|
)
|
||||||
results = _parse_yandex_members(members)
|
results = _parse_yandex_members(members)
|
||||||
in_ekb = [r for r in results if 60.40 <= r.lon <= 60.85 and 56.65 <= r.lat <= 56.95]
|
in_oblast = [r for r in results if is_within_oblast66_bbox(r.lat, r.lon)]
|
||||||
return in_ekb
|
return in_oblast
|
||||||
|
|
||||||
|
|
||||||
async def _nominatim_query_multi(client: httpx.AsyncClient, query: str, limit: int) -> list[dict]:
|
async def _nominatim_query_multi(client: httpx.AsyncClient, query: str, limit: int) -> list[dict]:
|
||||||
"""Один Nominatim search с фильтром по ЕКБ bbox. Возвращает up to N items."""
|
"""Один Nominatim search с фильтром по bbox области (region 66). Возвращает up to N items."""
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
"https://nominatim.openstreetmap.org/search",
|
"https://nominatim.openstreetmap.org/search",
|
||||||
params={
|
params={
|
||||||
|
|
@ -476,7 +723,7 @@ async def _nominatim_query_multi(client: httpx.AsyncClient, query: str, limit: i
|
||||||
"format": "json",
|
"format": "json",
|
||||||
"limit": str(limit),
|
"limit": str(limit),
|
||||||
"countrycodes": "ru",
|
"countrycodes": "ru",
|
||||||
"viewbox": EKB_BBOX["viewbox"],
|
"viewbox": OBLAST66_VIEWBOX["viewbox"],
|
||||||
"bounded": "1",
|
"bounded": "1",
|
||||||
"addressdetails": "1",
|
"addressdetails": "1",
|
||||||
},
|
},
|
||||||
|
|
@ -488,21 +735,29 @@ async def _nominatim_query_multi(client: httpx.AsyncClient, query: str, limit: i
|
||||||
|
|
||||||
@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4))
|
@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4))
|
||||||
async def _nominatim_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]:
|
async def _nominatim_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]:
|
||||||
"""Nominatim в режиме suggest. С typo-fallback (для случаев когда Yandex недоступен)."""
|
"""Nominatim в режиме suggest. С typo-fallback (для случаев когда Yandex недоступен).
|
||||||
|
|
||||||
|
Суффикс ", Екатеринбург" навязывается ТОЛЬКО если в запросе ещё нет города/области
|
||||||
|
региона 66 — иначе не режем явные запросы по другим городам области.
|
||||||
|
"""
|
||||||
headers = {
|
headers = {
|
||||||
"User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})",
|
"User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})",
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
"Accept-Language": "ru,en;q=0.8",
|
"Accept-Language": "ru,en;q=0.8",
|
||||||
}
|
}
|
||||||
|
suffixed_query = query if _has_oblast_marker(query.lower()) else f"{query}, Екатеринбург"
|
||||||
async with httpx.AsyncClient(timeout=8.0, headers=headers) as client:
|
async with httpx.AsyncClient(timeout=8.0, headers=headers) as client:
|
||||||
# Tier 1: оригинальный query
|
# Tier 1: оригинальный query
|
||||||
data = await _nominatim_query_multi(client, f"{query}, Екатеринбург", limit)
|
data = await _nominatim_query_multi(client, suffixed_query, limit)
|
||||||
|
|
||||||
# Tier 2: typo-варианты если оригинал пустой
|
# Tier 2: typo-варианты если оригинал пустой
|
||||||
if not data:
|
if not data:
|
||||||
for variant in _typo_variants(query, limit=3):
|
for variant in _typo_variants(query, limit=3):
|
||||||
await asyncio.sleep(1.0) # Nominatim 1 req/sec
|
await asyncio.sleep(1.0) # Nominatim 1 req/sec
|
||||||
data = await _nominatim_query_multi(client, f"{variant}, Екатеринбург", limit)
|
variant_query = (
|
||||||
|
variant if _has_oblast_marker(variant.lower()) else f"{variant}, Екатеринбург"
|
||||||
|
)
|
||||||
|
data = await _nominatim_query_multi(client, variant_query, limit)
|
||||||
if data:
|
if data:
|
||||||
logger.info("nominatim suggest typo-fixed: %s → %s", query, variant)
|
logger.info("nominatim suggest typo-fixed: %s → %s", query, variant)
|
||||||
break
|
break
|
||||||
|
|
@ -832,7 +1087,8 @@ def _cadastral_reverse_sync(db: Session, lat: float, lon: float, radius_m: int =
|
||||||
|
|
||||||
|
|
||||||
async def suggest(query: str, db: Session | None = None, limit: int = 8) -> list[GeocodeSuggestion]:
|
async def suggest(query: str, db: Session | None = None, limit: int = 8) -> list[GeocodeSuggestion]:
|
||||||
"""Автокомплит адресов в ЕКБ. Cadastral FDW → DaData → Yandex → Nominatim → [].
|
"""Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик,
|
||||||
|
остаётся быстрым fast-path). Cadastral FDW → DaData → Yandex → Nominatim → [].
|
||||||
|
|
||||||
db: если передан — cadastral lookup через gendesign_cad_buildings (первый tier).
|
db: если передан — cadastral lookup через gendesign_cad_buildings (первый tier).
|
||||||
DaData /suggest (PR Q2) — token-only, 10k/день, заменяет Yandex который
|
DaData /suggest (PR Q2) — token-only, 10k/день, заменяет Yandex который
|
||||||
|
|
@ -842,8 +1098,12 @@ async def suggest(query: str, db: Session | None = None, limit: int = 8) -> list
|
||||||
if not query or len(query.strip()) < 2:
|
if not query or len(query.strip()) < 2:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Tier 1: cadastral FDW (если db доступна) — самый быстрый, без внешних запросов
|
# Tier 1: cadastral FDW (если db доступна) — самый быстрый, без внешних запросов.
|
||||||
if db is not None:
|
# EKB-only (см. `geocode()` #11) — пропускаем, если query явно называет
|
||||||
|
# другой город области, иначе не-ЕКБ автокомплит может всплыть ЕКБ-домом
|
||||||
|
# с совпадающими улица+дом. Внешние тиры (2/3/4 ниже) не гейтим — они уже
|
||||||
|
# oblast-aware.
|
||||||
|
if db is not None and not _names_non_ekb_city(query):
|
||||||
# 1a. Anchored house-match: парсим street+house → точный матч по дом-маркеру.
|
# 1a. Anchored house-match: парсим street+house → точный матч по дом-маркеру.
|
||||||
# Решает кейс «Серова 27» где raw-ILIKE по readable_address давал 0 hits.
|
# Решает кейс «Серова 27» где raw-ILIKE по readable_address давал 0 hits.
|
||||||
parsed = _parse_street_house(query.strip())
|
parsed = _parse_street_house(query.strip())
|
||||||
|
|
@ -910,8 +1170,14 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
|
||||||
# 2. Локальные источники по street+house (без внешнего API).
|
# 2. Локальные источники по street+house (без внешнего API).
|
||||||
parsed = _parse_street_house(address.strip())
|
parsed = _parse_street_house(address.strip())
|
||||||
|
|
||||||
|
# #11: EKB-only локальные тиры (geoportal/cadastral) не должны отвечать на
|
||||||
|
# адрес другого города области — иначе улица+дом, коллизящие с ЕКБ-домом
|
||||||
|
# (напр. "проспект Ленина 1" есть и в Нижнем Тагиле, и в ЕКБ), снапаются в
|
||||||
|
# ЕКБ. Пропускаем сразу к oblast-aware внешним провайдерам ниже (3/4).
|
||||||
|
use_local_ekb = not _names_non_ekb_city(address)
|
||||||
|
|
||||||
# 2a. Геопортал ЕКБ — ПЕРВЫЙ локальный tier (полнее cad_buildings ~на 70%).
|
# 2a. Геопортал ЕКБ — ПЕРВЫЙ локальный tier (полнее cad_buildings ~на 70%).
|
||||||
if parsed is not None:
|
if use_local_ekb and parsed is not None:
|
||||||
street, house = parsed
|
street, house = parsed
|
||||||
try:
|
try:
|
||||||
hit = await asyncio.to_thread(_geoportal_house_match, db, street, house)
|
hit = await asyncio.to_thread(_geoportal_house_match, db, street, house)
|
||||||
|
|
@ -939,7 +1205,7 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
|
||||||
# Anchored house-match: парсим street+house → точный матч по дом-маркеру.
|
# Anchored house-match: парсим street+house → точный матч по дом-маркеру.
|
||||||
# raw-ILIKE по полному readable_address давал 0 hits для «Серова 27» / DaData-форм
|
# raw-ILIKE по полному readable_address давал 0 hits для «Серова 27» / DaData-форм
|
||||||
# (литеральная подстрока не совпадает).
|
# (литеральная подстрока не совпадает).
|
||||||
if parsed is not None:
|
if use_local_ekb and parsed is not None:
|
||||||
street, house = parsed
|
street, house = parsed
|
||||||
hit = await asyncio.to_thread(_cadastral_house_match, db, street, house)
|
hit = await asyncio.to_thread(_cadastral_house_match, db, street, house)
|
||||||
if hit is not None:
|
if hit is not None:
|
||||||
|
|
@ -960,29 +1226,32 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# 2d. Fallback: legacy raw-ILIKE forward search (для нераспарсенных форм)
|
# 2d. Fallback: legacy raw-ILIKE forward search (для нераспарсенных форм)
|
||||||
cad_suggestions = await asyncio.to_thread(_cadastral_forward_sync, db, address.strip(), limit=1)
|
if use_local_ekb:
|
||||||
if cad_suggestions:
|
cad_suggestions = await asyncio.to_thread(
|
||||||
s = cad_suggestions[0]
|
_cadastral_forward_sync, db, address.strip(), limit=1
|
||||||
result = GeocodeResult(
|
|
||||||
lat=s.lat,
|
|
||||||
lon=s.lon,
|
|
||||||
full_address=s.full_address,
|
|
||||||
provider="nominatim", # treat as "local" — same confidence as nominatim
|
|
||||||
confidence="exact",
|
|
||||||
)
|
)
|
||||||
await asyncio.to_thread(_cache_put, db, addr_norm, result)
|
if cad_suggestions:
|
||||||
logger.info("geocode cadastral fdw: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon)
|
s = cad_suggestions[0]
|
||||||
return result
|
result = GeocodeResult(
|
||||||
|
lat=s.lat,
|
||||||
|
lon=s.lon,
|
||||||
|
full_address=s.full_address,
|
||||||
|
provider="nominatim", # treat as "local" — same confidence as nominatim
|
||||||
|
confidence="exact",
|
||||||
|
)
|
||||||
|
await asyncio.to_thread(_cache_put, db, addr_norm, result)
|
||||||
|
logger.info(
|
||||||
|
"geocode cadastral fdw: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
# 3. Yandex (если есть key) с typo-fallback
|
# 3. Yandex (если есть key) с typo-fallback
|
||||||
if settings.yandex_geocoder_api_key:
|
if settings.yandex_geocoder_api_key:
|
||||||
try:
|
try:
|
||||||
result = await _yandex_lookup(address, settings.yandex_geocoder_api_key)
|
result = await _yandex_lookup(address, settings.yandex_geocoder_api_key)
|
||||||
# Если результат вне ЕКБ — пробуем typo-варианты
|
# Если результат вне области (region 66) — пробуем typo-варианты
|
||||||
in_ekb = (
|
in_oblast = result is not None and is_within_oblast66_bbox(result.lat, result.lon)
|
||||||
result is not None and 60.40 <= result.lon <= 60.85 and 56.65 <= result.lat <= 56.95
|
if result is not None and in_oblast:
|
||||||
)
|
|
||||||
if result is not None and in_ekb:
|
|
||||||
await asyncio.to_thread(_cache_put, db, addr_norm, result)
|
await asyncio.to_thread(_cache_put, db, addr_norm, result)
|
||||||
logger.info("geocode yandex: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon)
|
logger.info("geocode yandex: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon)
|
||||||
return result
|
return result
|
||||||
|
|
@ -994,7 +1263,7 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
|
||||||
continue
|
continue
|
||||||
if result is None:
|
if result is None:
|
||||||
continue
|
continue
|
||||||
if 60.40 <= result.lon <= 60.85 and 56.65 <= result.lat <= 56.95:
|
if is_within_oblast66_bbox(result.lat, result.lon):
|
||||||
await asyncio.to_thread(_cache_put, db, addr_norm, result)
|
await asyncio.to_thread(_cache_put, db, addr_norm, result)
|
||||||
logger.info(
|
logger.info(
|
||||||
"geocode yandex typo-fixed: %s → %s → (%.5f, %.5f)",
|
"geocode yandex typo-fixed: %s → %s → (%.5f, %.5f)",
|
||||||
|
|
@ -1128,11 +1397,12 @@ async def _yandex_reverse(lat: float, lon: float, api_key: str) -> ReverseGeocod
|
||||||
for p in parts
|
for p in parts
|
||||||
if p not in {"Россия", "Свердловская область"} and not p.startswith("городской округ")
|
if p not in {"Россия", "Свердловская область"} and not p.startswith("городской округ")
|
||||||
]
|
]
|
||||||
# Найдём locality (Екатеринбург / Берёзовский / …) и переставим в конец
|
# Найдём locality (Екатеринбург / Нижний Тагил / Берёзовский / …) и переставим
|
||||||
|
# в конец — любой город региона 66, не только ЕКБ (case-insensitive match).
|
||||||
locality = None
|
locality = None
|
||||||
rest: list[str] = []
|
rest: list[str] = []
|
||||||
for p in filtered:
|
for p in filtered:
|
||||||
if locality is None and p in {"Екатеринбург"}:
|
if locality is None and p.lower() in SVERDLOVSK_OBLAST_CITIES:
|
||||||
locality = p
|
locality = p
|
||||||
else:
|
else:
|
||||||
rest.append(p)
|
rest.append(p)
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,24 @@ logger = logging.getLogger(__name__)
|
||||||
# small enough to bound storage & decode cost.
|
# small enough to bound storage & decode cost.
|
||||||
_MAX_DIMENSION = 2400
|
_MAX_DIMENSION = 2400
|
||||||
|
|
||||||
|
# Hard cap on declared pixel count (width*height) enforced BEFORE any decode.
|
||||||
|
# A highly-compressible ≤10 MB upload can declare ~89-178 MP; once img.load()
|
||||||
|
# inflates it to raw RGB (3 B/px) that is a 260-530 MB buffer (+ transpose/convert
|
||||||
|
# copies) → OOM-kills the 768 MiB backend (#2214), amplified ×12 photo slots.
|
||||||
|
# 40 MP (~8000×5000) sits far above any real listing photo yet well below the
|
||||||
|
# ~89 MP default Pillow bomb threshold. img.size is read from the header on
|
||||||
|
# open() without decoding pixels, so this check is cheap and pre-decode.
|
||||||
|
_MAX_PIXELS = 40_000_000
|
||||||
|
|
||||||
|
# Backstop byte cap (the upload endpoint already enforces 10 MB → 413, but this
|
||||||
|
# service is standalone; keep it self-defending against oversized inputs).
|
||||||
|
_MAX_BYTES = 10 * 1024 * 1024
|
||||||
|
|
||||||
|
# Belt-and-braces: lower Pillow's own DecompressionBombError threshold from its
|
||||||
|
# ~89 MP default to our cap, so oversized declared dimensions raise even on any
|
||||||
|
# decode path that bypasses the explicit size guard below.
|
||||||
|
Image.MAX_IMAGE_PIXELS = _MAX_PIXELS
|
||||||
|
|
||||||
# JPEG quality. 85 is industry standard for photos — visually lossless,
|
# JPEG quality. 85 is industry standard for photos — visually lossless,
|
||||||
# ~70% size reduction vs original phone JPEGs.
|
# ~70% size reduction vs original phone JPEGs.
|
||||||
_JPEG_QUALITY = 85
|
_JPEG_QUALITY = 85
|
||||||
|
|
@ -46,10 +64,28 @@ def sanitize_image(content: bytes) -> tuple[bytes, str]:
|
||||||
(sanitized_bytes, "image/jpeg") — both ready for storage/serving.
|
(sanitized_bytes, "image/jpeg") — both ready for storage/serving.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ImageSanitizationError: bytes are not a recognized image format.
|
ImageSanitizationError: bytes are not a recognized image format, or the
|
||||||
|
declared dimensions / byte size exceed the pixel-flood safety caps.
|
||||||
"""
|
"""
|
||||||
|
if len(content) > _MAX_BYTES:
|
||||||
|
raise ImageSanitizationError(f"image too large: {len(content)} bytes exceeds cap")
|
||||||
try:
|
try:
|
||||||
with Image.open(io.BytesIO(content)) as img:
|
with Image.open(io.BytesIO(content)) as img:
|
||||||
|
# #2214: reject pixel-flood bombs BEFORE img.load(). img.size comes
|
||||||
|
# from the header (no pixel decode); loading an oversized declared
|
||||||
|
# image would inflate a small file into a hundreds-of-MB RGB buffer.
|
||||||
|
width, height = img.size
|
||||||
|
if width * height > _MAX_PIXELS:
|
||||||
|
logger.warning(
|
||||||
|
"image rejected: %dx%d (%d px) exceeds %d px cap",
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
width * height,
|
||||||
|
_MAX_PIXELS,
|
||||||
|
)
|
||||||
|
raise ImageSanitizationError(
|
||||||
|
f"image too large: {width}x{height} exceeds {_MAX_PIXELS} px cap"
|
||||||
|
)
|
||||||
img.load() # force full decode; surfaces UnidentifiedImageError early
|
img.load() # force full decode; surfaces UnidentifiedImageError early
|
||||||
# Bake EXIF Orientation into pixels BEFORE stripping EXIF, else phone
|
# Bake EXIF Orientation into pixels BEFORE stripping EXIF, else phone
|
||||||
# portrait photos (Orientation=6/8) end up rotated 90/270° once the tag
|
# portrait photos (Orientation=6/8) end up rotated 90/270° once the tag
|
||||||
|
|
@ -65,10 +101,16 @@ def sanitize_image(content: bytes) -> tuple[bytes, str]:
|
||||||
# save() does NOT carry over .info / EXIF unless we pass exif=...; we don't.
|
# save() does NOT carry over .info / EXIF unless we pass exif=...; we don't.
|
||||||
img.save(buf, format="JPEG", quality=_JPEG_QUALITY, optimize=True)
|
img.save(buf, format="JPEG", quality=_JPEG_QUALITY, optimize=True)
|
||||||
return buf.getvalue(), SANITIZED_CONTENT_TYPE
|
return buf.getvalue(), SANITIZED_CONTENT_TYPE
|
||||||
|
except ImageSanitizationError:
|
||||||
|
raise # already a handled, user-facing rejection (byte/pixel cap)
|
||||||
except UnidentifiedImageError as e:
|
except UnidentifiedImageError as e:
|
||||||
raise ImageSanitizationError("not a recognized image format") from e
|
raise ImageSanitizationError("not a recognized image format") from e
|
||||||
|
except Image.DecompressionBombError as e:
|
||||||
|
# Any decode path that outran the size guard above (e.g. >2× cap) lands here.
|
||||||
|
logger.warning("decompression bomb rejected: %s", e)
|
||||||
|
raise ImageSanitizationError("image dimensions exceed safe limits") from e
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# PIL can raise broad errors (DecompressionBombError, OSError on truncated, ...);
|
# PIL can raise broad errors (OSError on truncated, DecompressionBombWarning
|
||||||
# normalize to one user-facing error type.
|
# promoted to error, ...); normalize to one user-facing error type.
|
||||||
logger.warning("image sanitization failed: %s", e)
|
logger.warning("image sanitization failed: %s", e)
|
||||||
raise ImageSanitizationError(f"image decode failed: {e}") from e
|
raise ImageSanitizationError(f"image decode failed: {e}") from e
|
||||||
|
|
|
||||||
|
|
@ -53,11 +53,35 @@ CATEGORY_WEIGHTS: dict[str, float] = {
|
||||||
DEFAULT_RADIUS_M = 1200
|
DEFAULT_RADIUS_M = 1200
|
||||||
DEFAULT_TOP_N = 7
|
DEFAULT_TOP_N = 7
|
||||||
|
|
||||||
# Теоретический максимум суммы весов top-7 POI при идеальном расположении (d=0):
|
# Теоретический максимум суммы весов top-7 POI.
|
||||||
# w_i = cat_weight_i / (0 + 100) → max_sum = Σ(top7 cat_weights) / 100.
|
#
|
||||||
|
# ИСТОРИЯ (audit R2 #7a): раньше максимум считался при d=0 (все top-7 категорий прямо у
|
||||||
|
# порога) — недостижимо на практике. Аудит показал, что даже сильная центральная локация в
|
||||||
|
# Екб (транспорт/садик/супермаркет рядом, школа/метро/ТЦ уже заметно дальше — типичный
|
||||||
|
# профиль квартала, реалистичные дистанции по категориям ~80-700м) даёт raw_sum лишь
|
||||||
|
# ~0.08-0.095, т.е. score ~25-30/100 при старой нормировке (/100 при d=0) → coef ~0.975-0.98.
|
||||||
|
# Сильная локация систематически читалась как "немного снижает стоимость" — знаменатель
|
||||||
|
# сжимал реалистичные scores в нижнюю треть шкалы 0-100.
|
||||||
|
#
|
||||||
|
# ФИКС: нормируем не на d=0, а на РЕФЕРЕНСНОЕ расстояние _REF_DISTANCE_M — top-7 весов,
|
||||||
|
# как если бы каждая категория была на этом расстоянии. REF=100м ровно удваивает
|
||||||
|
# знаменатель (distance_m + 100): 100+100=200 вместо 0+100=100 → теоретический максимум
|
||||||
|
# вдвое ниже прежнего → реалистичные scores вдвое выше: сильный центр ~25-30 → ~50-60
|
||||||
|
# (coef ~1.00-1.01), с запасом выше 1.0 для исключительно плотных локаций (все top-7
|
||||||
|
# категорий <150м). Слабые/разреженные локации остаются далеко ниже 1.0; пустая
|
||||||
|
# osm_poi_ekb_local (рефреш не запускался) по-прежнему даёт coef=1.0 через отдельный
|
||||||
|
# graceful-fallback путь в compute_location_coef, эту нормировку не трогающий.
|
||||||
|
#
|
||||||
|
# ВНИМАНИЕ: REF_DIST — калибровочная константа для MVP-эвристики (см. _score_to_coef),
|
||||||
|
# НЕ откалибрована на реальных ценовых дельтах — только на распределении реалистичных POI-
|
||||||
|
# профилей, чтобы диапазон [0.95, 1.05] соответствовал интуиции "сильный центр ≈ 1.0".
|
||||||
|
#
|
||||||
# Top-7 категорий по убыванию веса: 6.0+5.0+4.5+4.5+4.0+4.0+3.5 = 31.5 (тот же набор, что у Ptica).
|
# Top-7 категорий по убыванию веса: 6.0+5.0+4.5+4.5+4.0+4.0+3.5 = 31.5 (тот же набор, что у Ptica).
|
||||||
_TOP7_WEIGHT_SUM: float = sum(sorted(CATEGORY_WEIGHTS.values(), reverse=True)[:7])
|
_TOP7_WEIGHT_SUM: float = sum(sorted(CATEGORY_WEIGHTS.values(), reverse=True)[:7])
|
||||||
_MAX_STRAIGHT_SCORE: float = _TOP7_WEIGHT_SUM / 100.0 # ≈ 0.315
|
_REF_DISTANCE_M: float = 100.0
|
||||||
|
_MAX_STRAIGHT_SCORE: float = _TOP7_WEIGHT_SUM / (
|
||||||
|
_REF_DISTANCE_M + 100.0
|
||||||
|
) # ≈ 0.1575 (было 0.315 при d=0)
|
||||||
|
|
||||||
# coef диапазон ±5% вокруг 1.0 — heuristic v1, НЕ откалибровано на реальных ценовых дельтах
|
# coef диапазон ±5% вокруг 1.0 — heuristic v1, НЕ откалибровано на реальных ценовых дельтах
|
||||||
# (в отличие от Ptica, где poi_weighted_score — ранжирующая метрика, не ценовой множитель).
|
# (в отличие от Ptica, где poi_weighted_score — ранжирующая метрика, не ценовой множитель).
|
||||||
|
|
|
||||||
|
|
@ -16,14 +16,25 @@ from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.services.matching.normalize import (
|
from app.services.matching.normalize import (
|
||||||
|
EKB_CITY_TOKEN,
|
||||||
address_fingerprint,
|
address_fingerprint,
|
||||||
|
has_city_token,
|
||||||
has_house_number,
|
has_house_number,
|
||||||
house_number_token,
|
house_number_token,
|
||||||
normalize_address,
|
normalize_address,
|
||||||
|
resolve_city_token,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Tier-2b oblast guard radius (metres). A bare street+number normalized_address carries
|
||||||
|
# NO city token yet is globally unique in house_address_aliases, so 'улица ленина 100'
|
||||||
|
# physically exists in ЕКБ, Н.Тагил, Каменск… When the incoming card HAS coords we accept
|
||||||
|
# the alias only if its house geom is within this radius. Cities in обл.66 are ≥ ~9-15 km
|
||||||
|
# apart, so 3 km cleanly separates them while absorbing the intra-city geocoder drift that
|
||||||
|
# Tier-2b exists to bridge (same building, different provider coords).
|
||||||
|
_TIER2B_GUARD_M = 3000
|
||||||
|
|
||||||
|
|
||||||
def match_or_create_house(
|
def match_or_create_house(
|
||||||
db: Session,
|
db: Session,
|
||||||
|
|
@ -180,30 +191,95 @@ def match_or_create_house(
|
||||||
# Without 2b, two scrapers for the same house with slightly different lat/lon (beyond
|
# Without 2b, two scrapers for the same house with slightly different lat/lon (beyond
|
||||||
# the 4-decimal rounding tolerance) would produce distinct fingerprints, miss Tier 2a,
|
# the 4-decimal rounding tolerance) would produce distinct fingerprints, miss Tier 2a,
|
||||||
# and each potentially create a duplicate house row.
|
# and each potentially create a duplicate house row.
|
||||||
row = (
|
#
|
||||||
db.execute(
|
# Tier-2a/2b oblast city guard (#2500 follow-up, closes the Tier-2a mis-bucket that the
|
||||||
text("SELECT house_id FROM house_address_aliases " "WHERE fingerprint = :fp LIMIT 1"),
|
# #2500 Tier-2b geo-guard could not reach): a coord-less card whose address explicitly
|
||||||
{"fp": fp},
|
# resolves a NON-ЕКБ обл.66 city must NOT match a globally-unique alias (fingerprint OR
|
||||||
|
# normalized_address) that almost certainly belongs to ЕКБ/another city — that would
|
||||||
|
# corrupt ЕКБ house data. When it fires we skip both alias lookups and fall through to a
|
||||||
|
# New house (Tier 3 is coord-gated, so coord-less cards skip it too).
|
||||||
|
#
|
||||||
|
# This PREVENTS ЕКБ CORRUPTION. Full oblast-internal dedup (deduping two listings of the
|
||||||
|
# SAME oblast building) still needs city-keyed aliases — a separate follow-up, out of
|
||||||
|
# scope, only relevant once the oblast sweep is enabled.
|
||||||
|
#
|
||||||
|
# EKB happy-path is byte-identical: the guard fires ONLY when the address names a non-ЕКБ
|
||||||
|
# city AND no coords disambiguate. ЕКБ cards (resolved city = екатеринбург) and the
|
||||||
|
# dominant bare/city-less Avito coord-less cards (resolved city None) run Tier-2a/2b
|
||||||
|
# exactly as before. NB: a BARE oblast card (no city token in the address — today's Avito
|
||||||
|
# SERP format) carries no signal here and is deliberately left on the unchanged path; that
|
||||||
|
# residual needs sweep-context and is out of this fix's scope.
|
||||||
|
_resolved_city = resolve_city_token(norm_addr) if (lat is None and lon is None) else None
|
||||||
|
_skip_oblast_alias = _resolved_city is not None and _resolved_city != EKB_CITY_TOKEN
|
||||||
|
if _skip_oblast_alias:
|
||||||
|
logger.info(
|
||||||
|
"house tier2a/2b skip: coord-less non-ЕКБ city %r na=%r src=%s",
|
||||||
|
_resolved_city,
|
||||||
|
norm_addr,
|
||||||
|
ext_source,
|
||||||
)
|
)
|
||||||
.mappings()
|
|
||||||
.first()
|
row = None
|
||||||
)
|
if not _skip_oblast_alias:
|
||||||
if row is None and norm_addr and has_num:
|
|
||||||
# Tier 2b: same normalized address, possibly different coords fingerprint.
|
|
||||||
# Gated on has_num (P1): never match a bare-street normalized_address — any
|
|
||||||
# numberless listing would otherwise collapse into whichever house first
|
|
||||||
# registered that street.
|
|
||||||
row = (
|
row = (
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text("SELECT house_id FROM house_address_aliases WHERE fingerprint = :fp LIMIT 1"),
|
||||||
"SELECT house_id FROM house_address_aliases "
|
{"fp": fp},
|
||||||
"WHERE normalized_address = :na LIMIT 1"
|
|
||||||
),
|
|
||||||
{"na": norm_addr},
|
|
||||||
)
|
)
|
||||||
.mappings()
|
.mappings()
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
if row is None and norm_addr and has_num and not _skip_oblast_alias:
|
||||||
|
# Tier 2b: same normalized address, possibly different coords fingerprint.
|
||||||
|
# Gated on has_num (P1): never match a bare-street normalized_address — any
|
||||||
|
# numberless listing would otherwise collapse into whichever house first
|
||||||
|
# registered that street.
|
||||||
|
#
|
||||||
|
# Oblast guard (bug #2, обл.66 per-city rollout): normalized_address has NO city
|
||||||
|
# token, so a bare street+number is globally unique in the alias table yet exists
|
||||||
|
# physically in several cities. Accept the match only when the incoming listing is
|
||||||
|
# confirmably the same place:
|
||||||
|
# • coords present → aliased house geom within _TIER2B_GUARD_M metres;
|
||||||
|
# • coords absent → norm_addr carries a city token (self-disambiguating —
|
||||||
|
# 'екатеринбург улица ленина 5' can't collide with a Н.Тагил address);
|
||||||
|
# • neither → skip. A bare common-street with no geo signal is too
|
||||||
|
# ambiguous — matching would mis-bucket an oblast card into a same-named ЕКБ
|
||||||
|
# house. Conservative: fall through to a New house rather than mis-match.
|
||||||
|
if lat is not None and lon is not None:
|
||||||
|
row = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT a.house_id FROM house_address_aliases a "
|
||||||
|
"JOIN houses h ON h.id = a.house_id "
|
||||||
|
"WHERE a.normalized_address = :na "
|
||||||
|
" AND h.geom IS NOT NULL "
|
||||||
|
" AND ST_DWithin(h.geom::geography, "
|
||||||
|
" ST_MakePoint(:lon, :lat)::geography, :thr) "
|
||||||
|
"LIMIT 1"
|
||||||
|
),
|
||||||
|
{"na": norm_addr, "lon": lon, "lat": lat, "thr": _TIER2B_GUARD_M},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
elif has_city_token(norm_addr):
|
||||||
|
row = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT house_id FROM house_address_aliases "
|
||||||
|
"WHERE normalized_address = :na LIMIT 1"
|
||||||
|
),
|
||||||
|
{"na": norm_addr},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
"house tier2b skip: bare common-street, no coords/city na=%r src=%s",
|
||||||
|
norm_addr,
|
||||||
|
ext_source,
|
||||||
|
)
|
||||||
if row:
|
if row:
|
||||||
house_id = int(row["house_id"])
|
house_id = int(row["house_id"])
|
||||||
_upsert_house_source(
|
_upsert_house_source(
|
||||||
|
|
@ -520,6 +596,12 @@ def _insert_alias(
|
||||||
row with the latest fingerprint, which is then found by Tier 2a on the next scrape.
|
row with the latest fingerprint, which is then found by Tier 2a on the next scrape.
|
||||||
house_id is not updated on conflict: the first writer wins canonical ownership.
|
house_id is not updated on conflict: the first writer wins canonical ownership.
|
||||||
|
|
||||||
|
Oblast guard (bug #2): the fingerprint/source are refreshed ONLY when the conflicting
|
||||||
|
row belongs to the SAME house. A DIFFERENT house colliding on a bare common-street
|
||||||
|
normalized_address (an oblast card that fell through Tier-2b's guard to a New INSERT)
|
||||||
|
must NOT rewrite the owner's fingerprint — doing so would downgrade a safe coord-bearing
|
||||||
|
alias to a coord-less one and let the next no-coord card mis-bucket via Tier 2a.
|
||||||
|
|
||||||
P1: a bare-street normalized_address (no house number) is NOT registered as an
|
P1: a bare-street normalized_address (no house number) is NOT registered as an
|
||||||
alias — it is too ambiguous to serve as a building key. Any later numberless
|
alias — it is too ambiguous to serve as a building key. Any later numberless
|
||||||
listing would otherwise hit that alias via Tier 2b and be mass-dumped into the
|
listing would otherwise hit that alias via Tier 2b and be mass-dumped into the
|
||||||
|
|
@ -533,8 +615,12 @@ def _insert_alias(
|
||||||
INSERT INTO house_address_aliases (house_id, normalized_address, fingerprint, source)
|
INSERT INTO house_address_aliases (house_id, normalized_address, fingerprint, source)
|
||||||
VALUES (CAST(:hid AS bigint), :na, :fp, :src)
|
VALUES (CAST(:hid AS bigint), :na, :fp, :src)
|
||||||
ON CONFLICT (normalized_address) DO UPDATE SET
|
ON CONFLICT (normalized_address) DO UPDATE SET
|
||||||
fingerprint = EXCLUDED.fingerprint,
|
fingerprint = CASE
|
||||||
source = EXCLUDED.source
|
WHEN house_address_aliases.house_id = EXCLUDED.house_id
|
||||||
|
THEN EXCLUDED.fingerprint ELSE house_address_aliases.fingerprint END,
|
||||||
|
source = CASE
|
||||||
|
WHEN house_address_aliases.house_id = EXCLUDED.house_id
|
||||||
|
THEN EXCLUDED.source ELSE house_address_aliases.source END
|
||||||
"""),
|
"""),
|
||||||
{
|
{
|
||||||
"hid": house_id,
|
"hid": house_id,
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,62 @@ def house_number_token(normalized: str | None) -> str | None:
|
||||||
return m.group(1) if m else None
|
return m.group(1) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
# Екатеринбург — the home city; every other _CITY_TOKENS entry is a non-ЕКБ обл.66 city.
|
||||||
|
# The Tier-2a oblast guard treats a resolved non-ЕКБ city specially (see resolve_city_token).
|
||||||
|
EKB_CITY_TOKEN = "екатеринбург"
|
||||||
|
|
||||||
|
# Cities covered by the обл.66 sweep rollout (ЕКБ + oblast per-city schedules).
|
||||||
|
# Normalized form: lowercase, hyphens collapsed to spaces (mirrors normalize_address,
|
||||||
|
# e.g. 'каменск-уральский' -> 'каменск уральский'). Mirrors CITY_ANCHORS in
|
||||||
|
# scraper_kit.orchestration.pipeline — extend together as the sweep adds cities.
|
||||||
|
# EKB_CITY_TOKEN must be first (leftmost-alternation determinism / documentation).
|
||||||
|
_CITY_TOKENS: tuple[str, ...] = (
|
||||||
|
EKB_CITY_TOKEN,
|
||||||
|
"нижний тагил",
|
||||||
|
"каменск уральский",
|
||||||
|
"первоуральск",
|
||||||
|
"верхняя пышма",
|
||||||
|
"серов",
|
||||||
|
)
|
||||||
|
# Token-bounded (not substring) so a street named after a city does NOT false-positive:
|
||||||
|
# 'улица серова 5' has token 'серова' (trailing 'а'), never bare 'серов'.
|
||||||
|
_CITY_TOKEN_RE = re.compile(
|
||||||
|
r"(?:^|\s)(?:" + "|".join(re.escape(c) for c in _CITY_TOKENS) + r")(?:\s|$)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_city_token(normalized: str | None) -> str | None:
|
||||||
|
"""Return the обл.66 city token present in the normalized address, else None.
|
||||||
|
|
||||||
|
Token-bounded (see _CITY_TOKEN_RE) so a street named after a city never matches
|
||||||
|
('улица серова 5' -> None, not 'серов'). When the address names ЕКБ, returns
|
||||||
|
EKB_CITY_TOKEN; a non-ЕКБ oblast city returns its own token. Used by the Tier-2a
|
||||||
|
oblast guard in match_or_create_house: a coord-less card that resolves a NON-ЕКБ city
|
||||||
|
must not fingerprint/normalized_address-match a globally-unique alias that (almost
|
||||||
|
certainly) belongs to ЕКБ/another city — that would corrupt ЕКБ house data.
|
||||||
|
"""
|
||||||
|
if not normalized:
|
||||||
|
return None
|
||||||
|
m = _CITY_TOKEN_RE.search(normalized)
|
||||||
|
if m is None:
|
||||||
|
return None
|
||||||
|
# group(0) carries the (?:^|\s)...(?:\s|$) boundary whitespace — strip it to the token.
|
||||||
|
return m.group(0).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def has_city_token(normalized: str | None) -> bool:
|
||||||
|
"""True if the normalized address carries a known обл.66 city token.
|
||||||
|
|
||||||
|
A city token makes the normalized_address city-scoped — two addresses in different
|
||||||
|
cities can then never collapse to the same key, so the alias is a safe cross-source
|
||||||
|
building key. Used by the Tier-2b oblast guard in match_or_create_house: a bare
|
||||||
|
common-street address with NO city token AND no coords is too ambiguous to match a
|
||||||
|
globally-unique normalized_address alias (it would mis-bucket an oblast card into a
|
||||||
|
same-named ЕКБ house — bug #2 oblast rollout).
|
||||||
|
"""
|
||||||
|
return resolve_city_token(normalized) is not None
|
||||||
|
|
||||||
|
|
||||||
def address_fingerprint(address: str | None, lat: float | None, lon: float | None) -> str:
|
def address_fingerprint(address: str | None, lat: float | None, lon: float | None) -> str:
|
||||||
"""SHA-256 fingerprint of normalized address + rounded coordinates (4 dp ≈ 11 m).
|
"""SHA-256 fingerprint of normalized address + rounded coordinates (4 dp ≈ 11 m).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -193,6 +193,16 @@ async def _job_deals_freshness_monitor(
|
||||||
await loop.run_in_executor(None, check_deals_freshness, db, run_id, params)
|
await loop.run_in_executor(None, check_deals_freshness, db, run_id, params)
|
||||||
|
|
||||||
|
|
||||||
|
# ── sber_freshness_monitor — sync DB-only freshness check в executor ──────────
|
||||||
|
async def _job_sber_freshness_monitor(
|
||||||
|
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||||
|
) -> None:
|
||||||
|
from app.tasks.sber_freshness_monitor import check_sber_freshness
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
await loop.run_in_executor(None, check_sber_freshness, db, run_id, params)
|
||||||
|
|
||||||
|
|
||||||
# ── newbuilding_enrich — async, owns lifecycle ───────────────────────────────
|
# ── newbuilding_enrich — async, owns lifecycle ───────────────────────────────
|
||||||
async def _job_newbuilding_enrich(
|
async def _job_newbuilding_enrich(
|
||||||
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||||
|
|
@ -394,6 +404,7 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
|
||||||
"sber_index_pull": Handler(_job_sber_index_pull, "sber_index_pull"),
|
"sber_index_pull": Handler(_job_sber_index_pull, "sber_index_pull"),
|
||||||
"rosreestr_quarter_poll": Handler(_job_rosreestr_quarter_poll, "rosreestr_quarter_poll"),
|
"rosreestr_quarter_poll": Handler(_job_rosreestr_quarter_poll, "rosreestr_quarter_poll"),
|
||||||
"deals_freshness_monitor": Handler(_job_deals_freshness_monitor, "deals_freshness_monitor"),
|
"deals_freshness_monitor": Handler(_job_deals_freshness_monitor, "deals_freshness_monitor"),
|
||||||
|
"sber_freshness_monitor": Handler(_job_sber_freshness_monitor, "sber_freshness_monitor"),
|
||||||
"newbuilding_enrich": Handler(_job_newbuilding_enrich, "newbuilding_enrich"),
|
"newbuilding_enrich": Handler(_job_newbuilding_enrich, "newbuilding_enrich"),
|
||||||
"yandex_newbuilding_sweep": Handler(
|
"yandex_newbuilding_sweep": Handler(
|
||||||
_job_yandex_newbuilding_sweep, "yandex_newbuilding_sweep"
|
_job_yandex_newbuilding_sweep, "yandex_newbuilding_sweep"
|
||||||
|
|
|
||||||
|
|
@ -5,31 +5,62 @@
|
||||||
открытых данных Росреестра.
|
открытых данных Росреестра.
|
||||||
- ALERT: залогировать actionable-сообщение, если квартал доступен.
|
- ALERT: залогировать actionable-сообщение, если квартал доступен.
|
||||||
- НЕ скачивает много-гигабайтный ZIP и НЕ вызывает shell-loaders — это ручной
|
- НЕ скачивает много-гигабайтный ZIP и НЕ вызывает shell-loaders — это ручной
|
||||||
ops-шаг. После алерта оператор запускает:
|
ops-шаг. После алерта оператор запускает (оба скрипта живут вне этого
|
||||||
|
репозитория — при смене базового пути их тоже нужно поправить вручную):
|
||||||
data/sql/02_load_all_quarters.sh
|
data/sql/02_load_all_quarters.sh
|
||||||
tradein-mvp/deploy/import-rosreestr.sh
|
tradein-mvp/deploy/import-rosreestr.sh
|
||||||
|
|
||||||
Архив открытых данных Росреестра (сделки ДКП/ДДУ) публикуется на портале:
|
ИСТОРИЯ (важно для будущих правок): раньше архив открытых данных Росреестра
|
||||||
https://rosreestr.gov.ru/opendata/
|
(сделки ДКП/ДДУ) публиковался на https://rosreestr.gov.ru/opendata/ с индексом
|
||||||
|
/opendata/f.json. Этот портал был переделан на Bitrix — прямые ссылки на ZIP
|
||||||
|
теперь отвечают HTTP 200 с Content-Type: text/html (soft-404 "заглушка"), а
|
||||||
|
/opendata/f.json больше не отдаёт JSON. Старая версия поллера трактовала любой
|
||||||
|
HTTP 200 как "квартал доступен" и из-за soft-404 стала ложно репортить КАЖДЫЙ
|
||||||
|
квартал как доступный — это и есть баг #issue, который чинит этот модуль.
|
||||||
|
|
||||||
Файлы именуются по шаблону:
|
АКТУАЛЬНОЕ (2026-07) расположение датасетов — открытый Apache autoindex без
|
||||||
dataset_СДЕЛКИ_r-r_01-92_y_{YYYY}_q_{N}.csv.zip
|
авторизации:
|
||||||
|
https://rosreestr.gov.ru/data-sets/
|
||||||
|
Структура:
|
||||||
|
- Папки кварталов названы в кодировке Windows-1251 (percent-encoded href —
|
||||||
|
именно cp1251, НЕ utf-8), например "1 квартал 2026г." →
|
||||||
|
href="1%20%EA%E2%E0%F0%F2%E0%EB%202026%E3./". Плюс папка
|
||||||
|
"Архив до 2023г. включительно/" для старых периодов.
|
||||||
|
- Внутри папки квартала лежит dataset_СДЕЛКИ_r-r_01-92_y_{YYYY}_q_{N}.csv.zip
|
||||||
|
(тоже cp1251-кодированный href, например
|
||||||
|
"dataset_%D1%C4%C5%CB%CA%C8_r-r_01-92_y_2026_q_1.csv.zip"), рядом —
|
||||||
|
dataset_КАДАСТРСТОИМОСТЬ_* файлы (не наши).
|
||||||
|
|
||||||
Индексный JSON живёт по адресу:
|
Вместо того чтобы вручную высчитывать percent-encoding (хрупко: имена папок
|
||||||
https://rosreestr.gov.ru/opendata/f.json (список всех датасетов)
|
расставляют руками, возможны отличия в пробелах/пунктуации между кварталами) —
|
||||||
|
поллер запрашивает autoindex HTML и парсит href'ы, декодируя каждый
|
||||||
|
percent-encoded href как cp1251 (urllib.parse.unquote(href, encoding="cp1251")),
|
||||||
|
затем ищет папку/файл по совпадению с ожидаемым (год, квартал) в декодированном
|
||||||
|
имени. См. check_new_quarter_available().
|
||||||
|
|
||||||
Проверяем HEAD-запросом (без скачивания) прямой ссылки на следующий квартал.
|
КЛЮЧЕВАЯ проверка доступности (защита от soft-404, см. "ИСТОРИЯ" выше): найденный
|
||||||
Формат ссылки:
|
файл датасета считается ДОСТУПНЫМ только если HEAD-ответ имеет
|
||||||
https://rosreestr.gov.ru/opendata/dataset_СДЕЛКИ_r-r_01-92_y_{YYYY}_q_{N}.csv.zip
|
Content-Type: application/zip И Content-Length больше ~100 KB. Голый HTTP 200
|
||||||
|
недостаточен.
|
||||||
|
|
||||||
При сетевой ошибке / HTTP 5xx — логируем warning, возвращаем available=False.
|
rosreestr.gov.ru отдаёт сертификат от российского TLS root CA, которому нет
|
||||||
Статус 404 → квартал не опубликован → available=False (штатный случай до начала августа).
|
доверия в стандартном trust store — соединение делаем с verify=False, как
|
||||||
|
sber_index.py для sberindex.ru (см. #922, тот же паттерн: публичные
|
||||||
|
неавторизованные открытые данные, TLS verify отключаем осознанно). Сервер также
|
||||||
|
отвечает HTTP 403 без браузерного User-Agent — шлём Chrome UA (тот же паттерн,
|
||||||
|
что DEFAULT_UA в zhkh_flats_loader.py).
|
||||||
|
|
||||||
|
При сетевой ошибке / HTTP 5xx / таймауте — логируем warning, возвращаем
|
||||||
|
available=False. Отсутствие папки/файла квартала → available=False (штатный
|
||||||
|
случай до публикации квартала, до начала следующего месяца после конца квартала).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import quote, unquote, urljoin
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
@ -37,16 +68,24 @@ from sqlalchemy.orm import Session
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Базовый URL открытых данных Росреестра (прямая ссылка на ZIP-файл квартала).
|
# Открытый Apache autoindex открытых данных Росреестра (см. докстринг модуля).
|
||||||
# Шаблон подтверждён по документации портала opendata.rosreestr.gov.ru.
|
_DATA_SETS_BASE_URL = "https://rosreestr.gov.ru/data-sets/"
|
||||||
_ROSREESTR_DATASET_URL_TEMPLATE = (
|
|
||||||
"https://rosreestr.gov.ru/opendata/"
|
# Браузерный UA — без него rosreestr.gov.ru отвечает HTTP 403.
|
||||||
"dataset_%D0%A1%D0%94%D0%95%D0%9B%D0%9A%D0%98_r-r_01-92_y_{year}_q_{quarter}.csv.zip"
|
# Тот же паттерн, что DEFAULT_UA в app/services/zhkh_flats_loader.py.
|
||||||
|
_BROWSER_USER_AGENT = (
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||||
|
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Таймаут HEAD-запроса. Росреестр может быть медленным — 15s достаточно.
|
# Порог отличия реального ZIP-архива от soft-404 заглушки/пустого файла.
|
||||||
|
_MIN_DATASET_SIZE_BYTES = 100_000 # 100 KB
|
||||||
|
|
||||||
|
# Таймаут запросов. Росреестр может быть медленным — 15s достаточно.
|
||||||
_HTTP_TIMEOUT = 15.0
|
_HTTP_TIMEOUT = 15.0
|
||||||
|
|
||||||
|
_HREF_RE = re.compile(r'href="([^"]+)"', re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
def _next_quarter(year: int, quarter: int) -> tuple[int, int]:
|
def _next_quarter(year: int, quarter: int) -> tuple[int, int]:
|
||||||
"""Вернуть (year, quarter) для следующего квартала.
|
"""Вернуть (year, quarter) для следующего квартала.
|
||||||
|
|
@ -117,11 +156,68 @@ def latest_loaded_quarter(db: Session) -> tuple[int, int] | None:
|
||||||
|
|
||||||
|
|
||||||
def rosreestr_dataset_url(year: int, quarter: int) -> str:
|
def rosreestr_dataset_url(year: int, quarter: int) -> str:
|
||||||
"""Сформировать URL датасета Росреестра для заданного квартала.
|
"""Сформировать best-effort прямую ссылку на ZIP датасета (для alert-сообщения оператору).
|
||||||
|
|
||||||
Возвращает прямую ссылку на ZIP-архив (percent-encoded шаблон).
|
ВНИМАНИЕ: это ДЕТЕРМИНИРОВАННАЯ догадка по известному шаблону именования —
|
||||||
|
папка "{quarter} квартал {year}г.", файл
|
||||||
|
"dataset_СДЕЛКИ_r-r_01-92_y_{year}_q_{quarter}.csv.zip" — закодированная как
|
||||||
|
cp1251 percent-encoding (подтверждено вручную против реального URL портала).
|
||||||
|
Она НЕ используется для самой проверки доступности: check_new_quarter_available()
|
||||||
|
парсит реальный autoindex и не полагается на угаданный URL, потому что
|
||||||
|
Росреестр расставляет имена папок вручную и они могут отличаться в мелочах
|
||||||
|
(лишний пробел, пунктуация) от квартала к кварталу.
|
||||||
|
|
||||||
|
Если угаданная ссылка не откроется — ориентир для оператора: _DATA_SETS_BASE_URL
|
||||||
|
(https://rosreestr.gov.ru/data-sets/), там нужно найти папку глазами.
|
||||||
"""
|
"""
|
||||||
return _ROSREESTR_DATASET_URL_TEMPLATE.format(year=year, quarter=quarter)
|
folder_name = f"{quarter} квартал {year}г."
|
||||||
|
file_name = f"dataset_СДЕЛКИ_r-r_01-92_y_{year}_q_{quarter}.csv.zip"
|
||||||
|
folder_enc = quote(folder_name.encode("cp1251"), safe="")
|
||||||
|
file_enc = quote(file_name.encode("cp1251"), safe="")
|
||||||
|
return f"{_DATA_SETS_BASE_URL}{folder_enc}/{file_enc}"
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_cp1251_href(href: str) -> str:
|
||||||
|
"""Декодировать percent-encoded href как cp1251 (портал кодирует кириллицу так, НЕ utf-8)."""
|
||||||
|
try:
|
||||||
|
return unquote(href, encoding="cp1251", errors="strict")
|
||||||
|
except (UnicodeDecodeError, LookupError):
|
||||||
|
# href без кириллицы (например "../") или неожиданная кодировка — fallback.
|
||||||
|
return unquote(href)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_ws(s: str) -> str:
|
||||||
|
"""Схлопнуть повторяющиеся пробелы — Росреестр расставляет имена папок вручную."""
|
||||||
|
return " ".join(s.split())
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_hrefs(html: str) -> list[str]:
|
||||||
|
"""Достать все href="..." из HTML autoindex-страницы (простой regex, не наш HTML)."""
|
||||||
|
return _HREF_RE.findall(html)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_quarter_folder_href(html: str, year: int, quarter: int) -> str | None:
|
||||||
|
"""Найти (не декодированный) href папки квартала (year, quarter) в HTML /data-sets/.
|
||||||
|
|
||||||
|
Сравнение — по декодированному (cp1251) и нормализованному по пробелам имени
|
||||||
|
папки, ожидаемый паттерн "{quarter} квартал {year}г.".
|
||||||
|
"""
|
||||||
|
expected_name = f"{quarter} квартал {year}г."
|
||||||
|
for href in _extract_hrefs(html):
|
||||||
|
decoded = _normalize_ws(_decode_cp1251_href(href).rstrip("/"))
|
||||||
|
if decoded == expected_name:
|
||||||
|
return href
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _find_dataset_file_href(html: str, year: int, quarter: int) -> str | None:
|
||||||
|
"""Найти href файла dataset_СДЕЛКИ_..._y_{year}_q_{quarter}.csv.zip в HTML папки квартала."""
|
||||||
|
expected_suffix = f"_y_{year}_q_{quarter}.csv.zip"
|
||||||
|
for href in _extract_hrefs(html):
|
||||||
|
decoded = _decode_cp1251_href(href)
|
||||||
|
if decoded.startswith("dataset_СДЕЛКИ") and decoded.endswith(expected_suffix):
|
||||||
|
return href
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def check_new_quarter_available(
|
async def check_new_quarter_available(
|
||||||
|
|
@ -129,48 +225,109 @@ async def check_new_quarter_available(
|
||||||
year: int,
|
year: int,
|
||||||
quarter: int,
|
quarter: int,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Проверить, опубликован ли датасет Росреестра для (year, quarter).
|
"""Проверить, опубликован ли датасет СДЕЛКИ Росреестра для (year, quarter).
|
||||||
|
|
||||||
HEAD-запрос к прямой ссылке на ZIP — без скачивания файла.
|
Шаги (см. докстринг модуля за подробностями):
|
||||||
|
1. GET autoindex _DATA_SETS_BASE_URL, найти папку квартала по декодированному
|
||||||
|
(cp1251) имени "{quarter} квартал {year}г.".
|
||||||
|
2. GET найденную папку, найти файл dataset_СДЕЛКИ_..._y_{year}_q_{quarter}.csv.zip.
|
||||||
|
3. HEAD найденный файл — считаем ДОСТУПНЫМ, только если Content-Type
|
||||||
|
начинается с application/zip И Content-Length > _MIN_DATASET_SIZE_BYTES.
|
||||||
|
Голый HTTP 200 НЕ считается доступностью — это была причина бага
|
||||||
|
(Bitrix-заглушка /opendata/ тоже отвечала 200).
|
||||||
|
|
||||||
Возвращает True если сервер ответил 200 (или 2xx/3xx с redirect).
|
Отсутствие папки/файла квартала → available=False (нормально до публикации).
|
||||||
Возвращает False при 404 (квартал ещё не опубликован) или сетевой ошибке.
|
Сетевая ошибка / таймаут / неожиданное исключение → warning, available=False.
|
||||||
Никогда не поднимает исключения в вызывающий код (scheduler-safe).
|
Никогда не поднимает исключения в вызывающий код (scheduler-safe).
|
||||||
"""
|
"""
|
||||||
url = rosreestr_dataset_url(year, quarter)
|
|
||||||
try:
|
try:
|
||||||
resp = await client.head(url, follow_redirects=True)
|
index_resp = await client.get(_DATA_SETS_BASE_URL, follow_redirects=True)
|
||||||
if resp.status_code == 200:
|
if index_resp.status_code != 200:
|
||||||
logger.info(
|
logger.warning(
|
||||||
"rosreestr_poll: Q%d %d available at %s (HTTP %d)",
|
"rosreestr_poll: unexpected HTTP %d listing %s — treating Q%d %d as unavailable",
|
||||||
quarter,
|
index_resp.status_code,
|
||||||
year,
|
_DATA_SETS_BASE_URL,
|
||||||
url,
|
|
||||||
resp.status_code,
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
if resp.status_code == 404:
|
|
||||||
logger.info(
|
|
||||||
"rosreestr_poll: Q%d %d not yet published (HTTP 404)",
|
|
||||||
quarter,
|
quarter,
|
||||||
year,
|
year,
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
# Другие коды (403, 5xx, etc.) — логируем как warning, не кидаем
|
|
||||||
logger.warning(
|
folder_href = _find_quarter_folder_href(index_resp.text, year, quarter)
|
||||||
"rosreestr_poll: unexpected HTTP %d for Q%d %d url=%s — treating as unavailable",
|
if folder_href is None:
|
||||||
resp.status_code,
|
logger.info(
|
||||||
|
"rosreestr_poll: quarter folder for Q%d %d not found under %s — not yet published",
|
||||||
|
quarter,
|
||||||
|
year,
|
||||||
|
_DATA_SETS_BASE_URL,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
folder_url = urljoin(_DATA_SETS_BASE_URL, folder_href)
|
||||||
|
folder_resp = await client.get(folder_url, follow_redirects=True)
|
||||||
|
if folder_resp.status_code != 200:
|
||||||
|
logger.warning(
|
||||||
|
"rosreestr_poll: unexpected HTTP %d listing folder %s — "
|
||||||
|
"treating Q%d %d as unavailable",
|
||||||
|
folder_resp.status_code,
|
||||||
|
folder_url,
|
||||||
|
quarter,
|
||||||
|
year,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
file_href = _find_dataset_file_href(folder_resp.text, year, quarter)
|
||||||
|
if file_href is None:
|
||||||
|
logger.info(
|
||||||
|
"rosreestr_poll: dataset_СДЕЛКИ file for Q%d %d not found in folder %s",
|
||||||
|
quarter,
|
||||||
|
year,
|
||||||
|
folder_url,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
file_url = urljoin(folder_url, file_href)
|
||||||
|
file_resp = await client.head(file_url, follow_redirects=True)
|
||||||
|
|
||||||
|
content_type = file_resp.headers.get("content-type", "")
|
||||||
|
try:
|
||||||
|
content_length = int(file_resp.headers.get("content-length") or "0")
|
||||||
|
except ValueError:
|
||||||
|
content_length = 0
|
||||||
|
|
||||||
|
if (
|
||||||
|
file_resp.status_code == 200
|
||||||
|
and content_type.startswith("application/zip")
|
||||||
|
and content_length > _MIN_DATASET_SIZE_BYTES
|
||||||
|
):
|
||||||
|
logger.info(
|
||||||
|
"rosreestr_poll: Q%d %d available at %s (HTTP 200, %s, %d bytes)",
|
||||||
|
quarter,
|
||||||
|
year,
|
||||||
|
file_url,
|
||||||
|
content_type,
|
||||||
|
content_length,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"rosreestr_poll: Q%d %d file found (%s) but failed availability check "
|
||||||
|
"(HTTP %d, Content-Type=%r, Content-Length=%d) — soft-404 guard, "
|
||||||
|
"treating as unavailable",
|
||||||
quarter,
|
quarter,
|
||||||
year,
|
year,
|
||||||
url,
|
file_url,
|
||||||
|
file_resp.status_code,
|
||||||
|
content_type,
|
||||||
|
content_length,
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except httpx.TimeoutException:
|
except httpx.TimeoutException:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"rosreestr_poll: timeout checking Q%d %d url=%s — treating as unavailable",
|
"rosreestr_poll: timeout checking Q%d %d under %s — treating as unavailable",
|
||||||
quarter,
|
quarter,
|
||||||
year,
|
year,
|
||||||
url,
|
_DATA_SETS_BASE_URL,
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
except httpx.RequestError as exc:
|
except httpx.RequestError as exc:
|
||||||
|
|
@ -197,7 +354,7 @@ async def poll_rosreestr_new_quarter(db: Session) -> dict[str, Any]:
|
||||||
Шаги:
|
Шаги:
|
||||||
1. Найти MAX(deal_date) WHERE source='rosreestr' → (loaded_year, loaded_quarter).
|
1. Найти MAX(deal_date) WHERE source='rosreestr' → (loaded_year, loaded_quarter).
|
||||||
2. Вычислить next = _next_quarter(loaded_year, loaded_quarter).
|
2. Вычислить next = _next_quarter(loaded_year, loaded_quarter).
|
||||||
3. HEAD-запросом проверить наличие датасета на rosreestr.gov.ru.
|
3. Проверить наличие датасета на rosreestr.gov.ru/data-sets/ (autoindex + Content-Type).
|
||||||
4. Если доступен — логировать actionable INFO с инструкцией по запуску ingest.
|
4. Если доступен — логировать actionable INFO с инструкцией по запуску ingest.
|
||||||
5. Вернуть dict с результатом.
|
5. Вернуть dict с результатом.
|
||||||
|
|
||||||
|
|
@ -230,18 +387,27 @@ async def poll_rosreestr_new_quarter(db: Session) -> dict[str, Any]:
|
||||||
loaded_year,
|
loaded_year,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 3. Проверка наличия
|
# 3. Проверка наличия (verify=False: см. докстринг модуля — RU root CA не в trust store;
|
||||||
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT) as client:
|
# браузерный UA: без него rosreestr.gov.ru отвечает 403)
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
timeout=_HTTP_TIMEOUT,
|
||||||
|
verify=False,
|
||||||
|
headers={"User-Agent": _BROWSER_USER_AGENT},
|
||||||
|
) as client:
|
||||||
available = await check_new_quarter_available(client, next_year, next_quarter)
|
available = await check_new_quarter_available(client, next_year, next_quarter)
|
||||||
|
|
||||||
# 4. Алерт если доступен
|
# 4. Алерт если доступен
|
||||||
if available:
|
if available:
|
||||||
logger.info(
|
logger.info(
|
||||||
"rosreestr_poll: NEW QUARTER AVAILABLE — Q%d %d. "
|
"rosreestr_poll: NEW QUARTER AVAILABLE — Q%d %d (%s). "
|
||||||
"Run ingest to load: "
|
"Run ingest to load: data/sql/02_load_all_quarters.sh + "
|
||||||
"data/sql/02_load_all_quarters.sh + tradein-mvp/deploy/import-rosreestr.sh",
|
"tradein-mvp/deploy/import-rosreestr.sh "
|
||||||
|
"(both live outside this repo — if they still point at the old "
|
||||||
|
"rosreestr.gov.ru/opendata/ base, update them to %s first)",
|
||||||
next_quarter,
|
next_quarter,
|
||||||
next_year,
|
next_year,
|
||||||
|
rosreestr_dataset_url(next_year, next_quarter),
|
||||||
|
_DATA_SETS_BASE_URL,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,11 @@ from app.services import scrape_runs as runs_mod
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# import_rosreestr_dkp: доля per-row INSERT-ошибок (rows_errored / rows_fetched), выше
|
||||||
|
# которой прогон помечается FAILED, а не silent-green (Fix C). Единичные битые строки
|
||||||
|
# (редкий bad row) не валят импорт; систематический сбой (≈100% ошибок) — валит.
|
||||||
|
DKP_IMPORT_ERROR_RATE_THRESHOLD = 0.05
|
||||||
|
|
||||||
|
|
||||||
def compute_next_run_at(
|
def compute_next_run_at(
|
||||||
window_start_hour: int,
|
window_start_hour: int,
|
||||||
|
|
@ -180,9 +185,16 @@ def import_rosreestr_dkp(
|
||||||
SERVER gendesign_remote настроен в 060_postgres_fdw_extension.sql.
|
SERVER gendesign_remote настроен в 060_postgres_fdw_extension.sql.
|
||||||
USER MAPPING создаётся при startup в core/fdw.py (tradein_fdw_reader).
|
USER MAPPING создаётся при startup в core/fdw.py (tradein_fdw_reader).
|
||||||
|
|
||||||
|
Область покрытия: вся Свердловская область (region_code=66), не только Екатеринбург —
|
||||||
|
прежний ILIKE-фильтр по подстроке города (ограничивавший импорт одним Екатеринбургом)
|
||||||
|
снят (Mera trade-in расширяется на весь регион, unlocks +47183 сделок вне ЕКБ уже
|
||||||
|
сидящих в source foreign table). address и deals.city строятся из реального city
|
||||||
|
источника (не хардкод "Екатеринбург"), deals.region_code заполняется из строки
|
||||||
|
источника (= 66 при текущем фильтре).
|
||||||
|
|
||||||
Фильтры (совпадают с import-rosreestr.sh + Fix_Rosreestr_Dkp_Filter_May24):
|
Фильтры (совпадают с import-rosreestr.sh + Fix_Rosreestr_Dkp_Filter_May24):
|
||||||
- region_code = 66 (Свердловская область)
|
- region_code = 66 (вся Свердловская область, все города)
|
||||||
- city ILIKE '%катеринбург%'
|
- city IS NOT NULL AND trim(city) != '' (непустой город → корректный address)
|
||||||
- realestate_type_code = '002001003000' (квартира)
|
- realestate_type_code = '002001003000' (квартира)
|
||||||
- area BETWEEN 18 AND 200
|
- area BETWEEN 18 AND 200
|
||||||
- deal_price BETWEEN 1000000 AND 100000000
|
- deal_price BETWEEN 1000000 AND 100000000
|
||||||
|
|
@ -210,7 +222,16 @@ def import_rosreestr_dkp(
|
||||||
counters: dict[str, int] = {
|
counters: dict[str, int] = {
|
||||||
"rows_fetched": 0,
|
"rows_fetched": 0,
|
||||||
"rows_inserted": 0,
|
"rows_inserted": 0,
|
||||||
|
# rows_updated: ON CONFLICT DO UPDATE обновил существующую строку
|
||||||
|
# (исправленный/переопубликованный квартал — Fix D).
|
||||||
|
"rows_updated": 0,
|
||||||
|
# rows_skipped: ТОЛЬКО легитимный dedup-пропуск (строка уже есть, факты
|
||||||
|
# идентичны — DO UPDATE ... WHERE distinct не сработал).
|
||||||
"rows_skipped": 0,
|
"rows_skipped": 0,
|
||||||
|
# rows_errored: реальные per-row INSERT-ошибки, отделены от dedup-skip (Fix C),
|
||||||
|
# раньше обе категории клались в rows_skipped → систематический сбой выглядел
|
||||||
|
# как обычный дедуп и прогон рапортовал success.
|
||||||
|
"rows_errored": 0,
|
||||||
"batches_done": 0,
|
"batches_done": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -248,8 +269,9 @@ def import_rosreestr_dkp(
|
||||||
# #1182 Phase 2: кооперативный SIGTERM-drain (деплой recreate scraper).
|
# #1182 Phase 2: кооперативный SIGTERM-drain (деплой recreate scraper).
|
||||||
# Это НЕ user-cancel → mark_done (partial), не mark_cancelled. Курсор
|
# Это НЕ user-cancel → mark_done (partial), не mark_cancelled. Курсор
|
||||||
# уже зафиксирован update_heartbeat'ом каждый батч; следующий run
|
# уже зафиксирован update_heartbeat'ом каждый батч; следующий run
|
||||||
# пере-сканирует с id=0 (INSERT ... ON CONFLICT DO NOTHING идемпотентен)
|
# пере-сканирует с id=0 — INSERT ... ON CONFLICT DO UPDATE идемпотентен
|
||||||
# → уже вставленные строки пропускаются, остаток до-импортируется.
|
# для неизменных строк (WHERE ... IS DISTINCT FROM пропускает совпадающие
|
||||||
|
# факты), поэтому повторный проход не плодит дубликаты и не трогает данные.
|
||||||
# mark_done выводит run из 'running' → reap_zombies его не тронет.
|
# mark_done выводит run из 'running' → reap_zombies его не тронет.
|
||||||
logger.info(
|
logger.info(
|
||||||
"rosreestr_dkp_import run_id=%d: SIGTERM-drain — committing partial "
|
"rosreestr_dkp_import run_id=%d: SIGTERM-drain — committing partial "
|
||||||
|
|
@ -273,7 +295,9 @@ def import_rosreestr_dkp(
|
||||||
id,
|
id,
|
||||||
id AS source_id_src,
|
id AS source_id_src,
|
||||||
'ros:dkp:' || CAST(id AS text) AS dedup_hash,
|
'ros:dkp:' || CAST(id AS text) AS dedup_hash,
|
||||||
'Екатеринбург, ' || trim(street) AS address,
|
trim(city) || ', ' || trim(street) AS address,
|
||||||
|
region_code,
|
||||||
|
trim(city) AS city,
|
||||||
CASE
|
CASE
|
||||||
WHEN area < 30 THEN 0
|
WHEN area < 30 THEN 0
|
||||||
WHEN area < 44 THEN 1
|
WHEN area < 44 THEN 1
|
||||||
|
|
@ -296,7 +320,7 @@ def import_rosreestr_dkp(
|
||||||
period_start_date AS deal_date
|
period_start_date AS deal_date
|
||||||
FROM gendesign_rosreestr_deals
|
FROM gendesign_rosreestr_deals
|
||||||
WHERE region_code = 66
|
WHERE region_code = 66
|
||||||
AND city ILIKE '%катеринбург%'
|
AND city IS NOT NULL AND trim(city) <> ''
|
||||||
AND realestate_type_code = '002001003000'
|
AND realestate_type_code = '002001003000'
|
||||||
AND area BETWEEN 18 AND 200
|
AND area BETWEEN 18 AND 200
|
||||||
AND deal_price BETWEEN 1000000 AND 100000000
|
AND deal_price BETWEEN 1000000 AND 100000000
|
||||||
|
|
@ -318,7 +342,9 @@ def import_rosreestr_dkp(
|
||||||
|
|
||||||
total_batches += 1
|
total_batches += 1
|
||||||
batch_inserted = 0
|
batch_inserted = 0
|
||||||
|
batch_updated = 0
|
||||||
batch_skipped = 0
|
batch_skipped = 0
|
||||||
|
batch_errored = 0
|
||||||
batch_max_id = last_id
|
batch_max_id = last_id
|
||||||
|
|
||||||
for row in batch_rows:
|
for row in batch_rows:
|
||||||
|
|
@ -328,17 +354,30 @@ def import_rosreestr_dkp(
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with db.begin_nested(): # SAVEPOINT per row
|
with db.begin_nested(): # SAVEPOINT per row
|
||||||
inserted = db.execute(
|
# Fix D: ON CONFLICT DO UPDATE вместо прежнего no-op-дедупа — при
|
||||||
|
# повторном импорте ИСПРАВЛЕННОГО/переопубликованного квартала обновляем
|
||||||
|
# сырые факты Росреестра. WHERE ... IS DISTINCT FROM оставляет
|
||||||
|
# неизменные строки нетронутыми (идемпотентность resume: строка
|
||||||
|
# без изменений → 0 returned → legit dedup-skip). source/source_id
|
||||||
|
# (identity) и dedup_hash (ключ конфликта) стабильны, не трогаем.
|
||||||
|
# Обогащение (lat/lon/geom/geocode_tried_at, cadastral_number,
|
||||||
|
# total_floors, house_type ...) НЕ в EXCLUDED-списке → сохраняется.
|
||||||
|
# RETURNING (xmax = 0): freshly-inserted → xmax=0 (was_inserted),
|
||||||
|
# обновлённая по ON CONFLICT → xmax<>0; отличаем insert от update.
|
||||||
|
result = db.execute(
|
||||||
text("""
|
text("""
|
||||||
INSERT INTO deals (
|
INSERT INTO deals (
|
||||||
source, dedup_hash, source_id, address, rooms, area_m2,
|
source, dedup_hash, source_id, address, region_code, city,
|
||||||
floor, year_built, price_rub, price_per_m2, deal_date
|
rooms, area_m2, floor, year_built, price_rub, price_per_m2,
|
||||||
|
deal_date
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
'rosreestr',
|
'rosreestr',
|
||||||
CAST(:dedup_hash AS text),
|
CAST(:dedup_hash AS text),
|
||||||
CAST(:source_id AS text),
|
CAST(:source_id AS text),
|
||||||
CAST(:address AS text),
|
CAST(:address AS text),
|
||||||
|
CAST(:region_code AS int),
|
||||||
|
CAST(:city AS text),
|
||||||
CAST(:rooms AS int),
|
CAST(:rooms AS int),
|
||||||
CAST(:area_m2 AS numeric),
|
CAST(:area_m2 AS numeric),
|
||||||
CAST(:floor_num AS int),
|
CAST(:floor_num AS int),
|
||||||
|
|
@ -347,13 +386,35 @@ def import_rosreestr_dkp(
|
||||||
CAST(:price_per_m2 AS int),
|
CAST(:price_per_m2 AS int),
|
||||||
CAST(:deal_date AS date)
|
CAST(:deal_date AS date)
|
||||||
)
|
)
|
||||||
ON CONFLICT (dedup_hash) DO NOTHING
|
ON CONFLICT (dedup_hash) DO UPDATE SET
|
||||||
RETURNING id
|
address = EXCLUDED.address,
|
||||||
|
region_code = EXCLUDED.region_code,
|
||||||
|
city = EXCLUDED.city,
|
||||||
|
rooms = EXCLUDED.rooms,
|
||||||
|
area_m2 = EXCLUDED.area_m2,
|
||||||
|
floor = EXCLUDED.floor,
|
||||||
|
year_built = EXCLUDED.year_built,
|
||||||
|
price_rub = EXCLUDED.price_rub,
|
||||||
|
price_per_m2 = EXCLUDED.price_per_m2,
|
||||||
|
deal_date = EXCLUDED.deal_date
|
||||||
|
WHERE deals.address IS DISTINCT FROM EXCLUDED.address
|
||||||
|
OR deals.region_code IS DISTINCT FROM EXCLUDED.region_code
|
||||||
|
OR deals.city IS DISTINCT FROM EXCLUDED.city
|
||||||
|
OR deals.rooms IS DISTINCT FROM EXCLUDED.rooms
|
||||||
|
OR deals.area_m2 IS DISTINCT FROM EXCLUDED.area_m2
|
||||||
|
OR deals.floor IS DISTINCT FROM EXCLUDED.floor
|
||||||
|
OR deals.year_built IS DISTINCT FROM EXCLUDED.year_built
|
||||||
|
OR deals.price_rub IS DISTINCT FROM EXCLUDED.price_rub
|
||||||
|
OR deals.price_per_m2 IS DISTINCT FROM EXCLUDED.price_per_m2
|
||||||
|
OR deals.deal_date IS DISTINCT FROM EXCLUDED.deal_date
|
||||||
|
RETURNING (xmax = 0) AS was_inserted
|
||||||
"""),
|
"""),
|
||||||
{
|
{
|
||||||
"dedup_hash": row["dedup_hash"],
|
"dedup_hash": row["dedup_hash"],
|
||||||
"source_id": str(row["source_id_src"]),
|
"source_id": str(row["source_id_src"]),
|
||||||
"address": row["address"],
|
"address": row["address"],
|
||||||
|
"region_code": row["region_code"],
|
||||||
|
"city": row["city"],
|
||||||
"rooms": row["rooms"],
|
"rooms": row["rooms"],
|
||||||
"area_m2": row["area_m2"],
|
"area_m2": row["area_m2"],
|
||||||
"floor_num": row["floor_num"],
|
"floor_num": row["floor_num"],
|
||||||
|
|
@ -363,25 +424,33 @@ def import_rosreestr_dkp(
|
||||||
"deal_date": row["deal_date"],
|
"deal_date": row["deal_date"],
|
||||||
},
|
},
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if inserted is not None:
|
if result is None:
|
||||||
|
# DO UPDATE ... WHERE distinct не сработал → строка есть и
|
||||||
|
# факты идентичны = легитимный dedup-skip (Fix C: только это
|
||||||
|
# теперь считается skip, INSERT-ошибки — отдельно ниже).
|
||||||
|
batch_skipped += 1
|
||||||
|
elif result[0]:
|
||||||
batch_inserted += 1
|
batch_inserted += 1
|
||||||
else:
|
else:
|
||||||
batch_skipped += 1
|
batch_updated += 1
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
# Fix C: реальная per-row INSERT-ошибка — ОТДЕЛЬНЫЙ счётчик, не skip.
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"rosreestr_dkp_import run_id=%d: row id=%d INSERT failed: %s",
|
"rosreestr_dkp_import run_id=%d: row id=%d INSERT failed: %s",
|
||||||
run_id,
|
run_id,
|
||||||
row_id,
|
row_id,
|
||||||
exc,
|
exc,
|
||||||
)
|
)
|
||||||
batch_skipped += 1
|
batch_errored += 1
|
||||||
|
|
||||||
last_id = batch_max_id
|
last_id = batch_max_id
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
counters["rows_fetched"] += len(batch_rows)
|
counters["rows_fetched"] += len(batch_rows)
|
||||||
counters["rows_inserted"] += batch_inserted
|
counters["rows_inserted"] += batch_inserted
|
||||||
|
counters["rows_updated"] += batch_updated
|
||||||
counters["rows_skipped"] += batch_skipped
|
counters["rows_skipped"] += batch_skipped
|
||||||
|
counters["rows_errored"] += batch_errored
|
||||||
counters["batches_done"] = total_batches
|
counters["batches_done"] = total_batches
|
||||||
counters["last_id"] = last_id # type: ignore[assignment]
|
counters["last_id"] = last_id # type: ignore[assignment]
|
||||||
|
|
||||||
|
|
@ -389,26 +458,42 @@ def import_rosreestr_dkp(
|
||||||
runs_mod.update_heartbeat(db, run_id, counters)
|
runs_mod.update_heartbeat(db, run_id, counters)
|
||||||
logger.info(
|
logger.info(
|
||||||
"rosreestr_dkp_import run_id=%d: batch=%d fetched=%d "
|
"rosreestr_dkp_import run_id=%d: batch=%d fetched=%d "
|
||||||
"inserted=%d skipped=%d last_id=%d",
|
"inserted=%d updated=%d skipped=%d errored=%d last_id=%d",
|
||||||
run_id,
|
run_id,
|
||||||
total_batches,
|
total_batches,
|
||||||
len(batch_rows),
|
len(batch_rows),
|
||||||
batch_inserted,
|
batch_inserted,
|
||||||
|
batch_updated,
|
||||||
batch_skipped,
|
batch_skipped,
|
||||||
|
batch_errored,
|
||||||
last_id,
|
last_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
if len(batch_rows) < batch_size:
|
if len(batch_rows) < batch_size:
|
||||||
break # Last partial batch — no more rows
|
break # Last partial batch — no more rows
|
||||||
|
|
||||||
|
# Fix C: систематический per-row INSERT-сбой больше не рапортует success.
|
||||||
|
# Отделив rows_errored от rows_skipped, проверяем долю ошибок: выше порога —
|
||||||
|
# прогон FAILED (raise → внешний except → mark_failed), а не silent-green.
|
||||||
|
fetched = counters["rows_fetched"]
|
||||||
|
errored = counters["rows_errored"]
|
||||||
|
if fetched > 0 and errored / fetched > DKP_IMPORT_ERROR_RATE_THRESHOLD:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"rosreestr_dkp_import: per-row INSERT error rate "
|
||||||
|
f"{errored}/{fetched} ({errored / fetched:.1%}) exceeds "
|
||||||
|
f"{DKP_IMPORT_ERROR_RATE_THRESHOLD:.0%} threshold — marking run failed"
|
||||||
|
)
|
||||||
|
|
||||||
runs_mod.mark_done(db, run_id, counters)
|
runs_mod.mark_done(db, run_id, counters)
|
||||||
logger.info(
|
logger.info(
|
||||||
"rosreestr_dkp_import run_id=%d done: "
|
"rosreestr_dkp_import run_id=%d done: "
|
||||||
"total_fetched=%d inserted=%d skipped=%d batches=%d",
|
"total_fetched=%d inserted=%d updated=%d skipped=%d errored=%d batches=%d",
|
||||||
run_id,
|
run_id,
|
||||||
counters["rows_fetched"],
|
counters["rows_fetched"],
|
||||||
counters["rows_inserted"],
|
counters["rows_inserted"],
|
||||||
|
counters["rows_updated"],
|
||||||
counters["rows_skipped"],
|
counters["rows_skipped"],
|
||||||
|
counters["rows_errored"],
|
||||||
total_batches,
|
total_batches,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
9
tradein-mvp/backend/app/services/tgbot/__init__.py
Normal file
9
tradein-mvp/backend/app/services/tgbot/__init__.py
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
"""Telegram support bridge (@MERAsupport_bot) — long-polling мост клиент↔оператор.
|
||||||
|
|
||||||
|
Клиент пишет боту в личку → зеркалится в топик support-группы (`client.py` —
|
||||||
|
тонкая HTTP-обёртка над Bot API; `bridge.py` — маршрутизация апдейтов и
|
||||||
|
персистентность через tg_support_* таблицы). Standalone entrypoint —
|
||||||
|
`app.tgbot_main` (long-polling воркер, НЕ webhook, отдельный контейнер/процесс).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
537
tradein-mvp/backend/app/services/tgbot/bridge.py
Normal file
537
tradein-mvp/backend/app/services/tgbot/bridge.py
Normal file
|
|
@ -0,0 +1,537 @@
|
||||||
|
"""Маршрутизация Telegram-апдейтов для support-моста (#tgsupport).
|
||||||
|
|
||||||
|
Поток:
|
||||||
|
A) Клиент пишет боту в личку (chat.type == 'private') →
|
||||||
|
upsert tg_support_users → (если первое сообщение за последний час — шапка
|
||||||
|
с идентификацией клиента в топик) → copyMessage контента в support-топик →
|
||||||
|
запись в tg_support_messages (direction='in', topic_message_id — ключ
|
||||||
|
маршрутизации ответа).
|
||||||
|
B) Оператор отвечает РЕПЛАЕМ в support-группе на зеркало клиента →
|
||||||
|
находим chat_id по topic_message_id → copyMessage ответа в личку клиента →
|
||||||
|
запись (direction='out'). Реплай не на зеркало (или не реплай вообще) —
|
||||||
|
обычная болтовня в топике, тихий игнор. Telegram 403 (клиент заблокировал
|
||||||
|
бота) → is_blocked=true + уведомление в топике.
|
||||||
|
C) Дедуп: update_id <= сохранённого offset — skip. Offset сохраняется И
|
||||||
|
коммитится в той же транзакции, что и запись сообщения (см. `process_update`
|
||||||
|
`finally`), после КАЖДОГО апдейта — рестарт воркера не переигрывает уже
|
||||||
|
обработанные апдейты и не подвисает вечно на «ядовитом» апдейте.
|
||||||
|
D) TELEGRAM_BOT_TOKEN пуст → бот выключен — проверяется в `app.tgbot_main`
|
||||||
|
(entrypoint), не здесь.
|
||||||
|
E) /start клиенту → короткое приветствие МЕРЫ, без зеркалирования в топик
|
||||||
|
(команда — не содержательное обращение, не должна засорять топик).
|
||||||
|
|
||||||
|
Персистентность вынесена за `BridgeStorage`-протокол — маршрутизирующая логика
|
||||||
|
(`process_update` и приватные `_handle_*`) не завязана на реальную БД, тестируется
|
||||||
|
на in-memory fake storage + mock httpx (см. tests/services/tgbot/).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.shutdown import shutdown_requested
|
||||||
|
from app.services.tgbot.client import TelegramApiError, TelegramClient
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Ключ в tg_support_state под который сохраняется last processed update_id
|
||||||
|
# (см. data/sql/186_tg_support.sql — комментарий на колонке .key).
|
||||||
|
_OFFSET_KEY = "last_update_id"
|
||||||
|
|
||||||
|
# Окно, за которое повторное сообщение клиента НЕ дублирует шапку-идентификацию
|
||||||
|
# в топике (одна шапка на "сессию" обращения).
|
||||||
|
_HEADER_THROTTLE_WINDOW_S = 3600
|
||||||
|
|
||||||
|
GREETING_TEXT = (
|
||||||
|
"Здравствуйте! Это служба поддержки МЕРА (сервис trade-in квартир). "
|
||||||
|
"Опишите ваш вопрос — оператор ответит вам в этом чате в ближайшее время."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Отправляется клиенту вместо тихой потери сообщения, если TELEGRAM_SUPPORT_CHAT_ID
|
||||||
|
# не сконфигурирован (иначе клиент ждёт ответа, которого никогда не будет — #5 review).
|
||||||
|
SERVICE_UNAVAILABLE_TEXT = (
|
||||||
|
"Служба поддержки временно недоступна. Пожалуйста, попробуйте написать позже."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Kinds, задокументированные в data/sql/186_tg_support.sql (COMMENT ON COLUMN
|
||||||
|
# tg_support_messages.kind): "text | photo | document | video | voice | other".
|
||||||
|
_KNOWN_KINDS = ("text", "photo", "document", "video", "voice")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Storage abstraction (testable без реальной БД) ──────────────────────────
|
||||||
|
class BridgeStorage(Protocol):
|
||||||
|
"""Persistence-контракт моста. `SqlBridgeStorage` — прод-реализация поверх
|
||||||
|
tg_support_* (см. data/sql/186_tg_support.sql). Тесты используют in-memory fake."""
|
||||||
|
|
||||||
|
def get_offset(self) -> int: ...
|
||||||
|
|
||||||
|
def save_offset(self, update_id: int) -> None: ...
|
||||||
|
|
||||||
|
def commit(self) -> None: ...
|
||||||
|
|
||||||
|
def rollback(self) -> None: ...
|
||||||
|
|
||||||
|
def upsert_user(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
chat_id: int,
|
||||||
|
username: str | None,
|
||||||
|
first_name: str | None,
|
||||||
|
last_name: str | None,
|
||||||
|
language_code: str | None,
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
def had_recent_inbound(self, chat_id: int, window_seconds: int) -> bool: ...
|
||||||
|
|
||||||
|
def record_message(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
chat_id: int,
|
||||||
|
direction: str,
|
||||||
|
tg_message_id: int | None,
|
||||||
|
topic_message_id: int | None,
|
||||||
|
kind: str,
|
||||||
|
text_body: str | None,
|
||||||
|
operator_tg_id: int | None,
|
||||||
|
) -> int | None: ...
|
||||||
|
|
||||||
|
def find_chat_by_topic_message(self, topic_message_id: int) -> int | None: ...
|
||||||
|
|
||||||
|
def mark_blocked(self, chat_id: int) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class SqlBridgeStorage:
|
||||||
|
"""`BridgeStorage` поверх SQLAlchemy Session (psycopg v3), tg_support_* таблицы.
|
||||||
|
|
||||||
|
Методы исполняют SQL немедленно, но НЕ коммитят по отдельности — коммит
|
||||||
|
один раз в конце `process_update` (после записи сообщения И offset'а), чтобы
|
||||||
|
оба изменения фиксировались атомарно в одной транзакции (требование C).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, db: Session) -> None:
|
||||||
|
self._db = db
|
||||||
|
|
||||||
|
def get_offset(self) -> int:
|
||||||
|
row = self._db.execute(
|
||||||
|
text("SELECT value FROM tg_support_state WHERE key = CAST(:key AS text)"),
|
||||||
|
{"key": _OFFSET_KEY},
|
||||||
|
).fetchone()
|
||||||
|
if row is None or row[0] is None:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
return int(row[0])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
logger.warning("tgbot storage: невалидный offset в БД (%r) — считаем 0", row[0])
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def save_offset(self, update_id: int) -> None:
|
||||||
|
self._db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO tg_support_state (key, value, updated_at)
|
||||||
|
VALUES (CAST(:key AS text), CAST(:value AS text), NOW())
|
||||||
|
ON CONFLICT (key) DO UPDATE
|
||||||
|
SET value = EXCLUDED.value, updated_at = NOW()
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"key": _OFFSET_KEY, "value": str(update_id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
def commit(self) -> None:
|
||||||
|
self._db.commit()
|
||||||
|
|
||||||
|
def rollback(self) -> None:
|
||||||
|
"""Откатывает текущую (возможно failed-transaction) сессию перед save_offset.
|
||||||
|
|
||||||
|
Нужно, когда исключение пришло от самой БД (напр. обрыв коннекта к
|
||||||
|
postgres при деплое) — SQLAlchemy Session после такого исключения
|
||||||
|
переходит в failed-transaction state, и ЛЮБОЙ следующий `execute()`
|
||||||
|
(включая `save_offset`) кидает `PendingRollbackError` без явного
|
||||||
|
rollback() (#3 review — иначе update_id не сдвигается, апдейт
|
||||||
|
переигрывается на следующей итерации, copyMessage дублирует зеркало
|
||||||
|
клиента в топик на каждый повтор).
|
||||||
|
"""
|
||||||
|
self._db.rollback()
|
||||||
|
|
||||||
|
def upsert_user(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
chat_id: int,
|
||||||
|
username: str | None,
|
||||||
|
first_name: str | None,
|
||||||
|
last_name: str | None,
|
||||||
|
language_code: str | None,
|
||||||
|
) -> None:
|
||||||
|
self._db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO tg_support_users
|
||||||
|
(chat_id, username, first_name, last_name, language_code,
|
||||||
|
created_at, last_seen_at, is_blocked)
|
||||||
|
VALUES
|
||||||
|
(CAST(:chat_id AS bigint), :username, :first_name, :last_name,
|
||||||
|
:language_code, NOW(), NOW(), FALSE)
|
||||||
|
ON CONFLICT (chat_id) DO UPDATE
|
||||||
|
SET username = EXCLUDED.username,
|
||||||
|
first_name = EXCLUDED.first_name,
|
||||||
|
last_name = EXCLUDED.last_name,
|
||||||
|
language_code = EXCLUDED.language_code,
|
||||||
|
last_seen_at = NOW(),
|
||||||
|
is_blocked = FALSE
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"username": username,
|
||||||
|
"first_name": first_name,
|
||||||
|
"last_name": last_name,
|
||||||
|
"language_code": language_code,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def had_recent_inbound(self, chat_id: int, window_seconds: int) -> bool:
|
||||||
|
row = self._db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT 1
|
||||||
|
FROM tg_support_messages
|
||||||
|
WHERE chat_id = CAST(:chat_id AS bigint)
|
||||||
|
AND direction = 'in'
|
||||||
|
AND created_at > NOW() - make_interval(secs => CAST(:window_seconds AS integer))
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"chat_id": chat_id, "window_seconds": window_seconds},
|
||||||
|
).fetchone()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
def record_message(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
chat_id: int,
|
||||||
|
direction: str,
|
||||||
|
tg_message_id: int | None,
|
||||||
|
topic_message_id: int | None,
|
||||||
|
kind: str,
|
||||||
|
text_body: str | None,
|
||||||
|
operator_tg_id: int | None,
|
||||||
|
) -> int | None:
|
||||||
|
row = self._db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO tg_support_messages
|
||||||
|
(chat_id, direction, tg_message_id, topic_message_id, kind,
|
||||||
|
text_body, operator_tg_id, created_at)
|
||||||
|
VALUES
|
||||||
|
(CAST(:chat_id AS bigint), CAST(:direction AS text),
|
||||||
|
CAST(:tg_message_id AS bigint), CAST(:topic_message_id AS bigint),
|
||||||
|
CAST(:kind AS text), :text_body, CAST(:operator_tg_id AS bigint), NOW())
|
||||||
|
RETURNING id
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"direction": direction,
|
||||||
|
"tg_message_id": tg_message_id,
|
||||||
|
"topic_message_id": topic_message_id,
|
||||||
|
"kind": kind,
|
||||||
|
"text_body": text_body,
|
||||||
|
"operator_tg_id": operator_tg_id,
|
||||||
|
},
|
||||||
|
).fetchone()
|
||||||
|
return int(row[0]) if row is not None else None
|
||||||
|
|
||||||
|
def find_chat_by_topic_message(self, topic_message_id: int) -> int | None:
|
||||||
|
row = self._db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT chat_id
|
||||||
|
FROM tg_support_messages
|
||||||
|
WHERE topic_message_id = CAST(:topic_message_id AS bigint)
|
||||||
|
AND direction = 'in'
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"topic_message_id": topic_message_id},
|
||||||
|
).fetchone()
|
||||||
|
return int(row[0]) if row is not None else None
|
||||||
|
|
||||||
|
def mark_blocked(self, chat_id: int) -> None:
|
||||||
|
self._db.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE tg_support_users SET is_blocked = TRUE "
|
||||||
|
"WHERE chat_id = CAST(:chat_id AS bigint)"
|
||||||
|
),
|
||||||
|
{"chat_id": chat_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pure helpers ──────────────────────────────────────────────────────────────
|
||||||
|
def _infer_kind(message: dict[str, Any]) -> str:
|
||||||
|
"""Content-type сообщения → kind-строка. Неизвестные типы (voice/sticker/location/
|
||||||
|
etc.) сворачиваются в 'other' — см. документированный набор в COMMENT ON COLUMN."""
|
||||||
|
for field in _KNOWN_KINDS:
|
||||||
|
if field in message:
|
||||||
|
return field
|
||||||
|
return "other"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_topic_header(
|
||||||
|
chat_id: int, username: str | None, first_name: str | None, last_name: str | None
|
||||||
|
) -> str:
|
||||||
|
"""Короткая шапка-идентификация клиента для support-топика."""
|
||||||
|
display_name = " ".join(p for p in (first_name, last_name) if p) or "Без имени"
|
||||||
|
username_part = f", @{username}" if username else ""
|
||||||
|
return f"Новое обращение от {display_name}{username_part} (chat_id={chat_id})"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Update routing ────────────────────────────────────────────────────────────
|
||||||
|
async def _handle_private_message(
|
||||||
|
message: dict[str, Any], client: TelegramClient, storage: BridgeStorage
|
||||||
|
) -> None:
|
||||||
|
"""A) Личка клиента → бот. upsert user → (опц. шапка) → зеркало в топик."""
|
||||||
|
chat = message.get("chat") or {}
|
||||||
|
chat_id = chat.get("id")
|
||||||
|
if not isinstance(chat_id, int):
|
||||||
|
logger.warning("tgbot bridge: приватное сообщение без валидного chat.id — игнор")
|
||||||
|
return
|
||||||
|
|
||||||
|
from_user = message.get("from") or {}
|
||||||
|
username = from_user.get("username")
|
||||||
|
first_name = from_user.get("first_name")
|
||||||
|
last_name = from_user.get("last_name")
|
||||||
|
language_code = from_user.get("language_code")
|
||||||
|
|
||||||
|
storage.upsert_user(
|
||||||
|
chat_id=chat_id,
|
||||||
|
username=username,
|
||||||
|
first_name=first_name,
|
||||||
|
last_name=last_name,
|
||||||
|
language_code=language_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
text_body = message.get("text")
|
||||||
|
if text_body == "/start":
|
||||||
|
# E) команда — не содержательное обращение, топик не засоряем.
|
||||||
|
await client.send_message(chat_id=chat_id, text=GREETING_TEXT)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not settings.telegram_support_chat_id:
|
||||||
|
logger.warning(
|
||||||
|
"tgbot bridge: TELEGRAM_SUPPORT_CHAT_ID не задан — сообщение от chat_id=%d "
|
||||||
|
"не может быть зеркалировано; отвечаем клиенту вместо тихой потери (#5 review)",
|
||||||
|
chat_id,
|
||||||
|
)
|
||||||
|
# Не молчим клиенту (#5 review) — иначе он ждёт ответа, которого никогда не будет.
|
||||||
|
await client.send_message(chat_id=chat_id, text=SERVICE_UNAVAILABLE_TEXT)
|
||||||
|
return
|
||||||
|
|
||||||
|
message_id = message.get("message_id")
|
||||||
|
if not isinstance(message_id, int):
|
||||||
|
logger.warning("tgbot bridge: приватное сообщение без message_id — игнор")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Шапка — только на первое сообщение клиента за окно, иначе топик засоряется.
|
||||||
|
if not storage.had_recent_inbound(chat_id, window_seconds=_HEADER_THROTTLE_WINDOW_S):
|
||||||
|
header = _format_topic_header(chat_id, username, first_name, last_name)
|
||||||
|
await client.send_message(
|
||||||
|
chat_id=settings.telegram_support_chat_id,
|
||||||
|
text=header,
|
||||||
|
message_thread_id=settings.telegram_support_topic_id or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
mirrored = await client.copy_message(
|
||||||
|
chat_id=settings.telegram_support_chat_id,
|
||||||
|
from_chat_id=chat_id,
|
||||||
|
message_id=message_id,
|
||||||
|
message_thread_id=settings.telegram_support_topic_id or None,
|
||||||
|
)
|
||||||
|
topic_message_id = mirrored.get("message_id") if isinstance(mirrored, dict) else None
|
||||||
|
|
||||||
|
storage.record_message(
|
||||||
|
chat_id=chat_id,
|
||||||
|
direction="in",
|
||||||
|
tg_message_id=message_id,
|
||||||
|
topic_message_id=topic_message_id,
|
||||||
|
kind=_infer_kind(message),
|
||||||
|
text_body=text_body or message.get("caption"),
|
||||||
|
operator_tg_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_group_reply(
|
||||||
|
message: dict[str, Any], client: TelegramClient, storage: BridgeStorage
|
||||||
|
) -> None:
|
||||||
|
"""B) Реплай оператора в support-группе → доставка ответа клиенту."""
|
||||||
|
reply_to = message.get("reply_to_message")
|
||||||
|
if not isinstance(reply_to, dict):
|
||||||
|
return # не реплай вообще — обычная болтовня в топике, тихий игнор
|
||||||
|
|
||||||
|
mirror_message_id = reply_to.get("message_id")
|
||||||
|
if not isinstance(mirror_message_id, int):
|
||||||
|
return
|
||||||
|
|
||||||
|
target_chat_id = storage.find_chat_by_topic_message(mirror_message_id)
|
||||||
|
if target_chat_id is None:
|
||||||
|
# Обычная болтовня в топике (реплай на чьё-то ещё сообщение) — не логируем,
|
||||||
|
# это ожидаемый шум. НО реплай на сообщение, отправленное САМИМ БОТОМ
|
||||||
|
# (is_bot=True) и при этом отсутствующее в tg_support_messages — подозрительно:
|
||||||
|
# вероятная причина — осиротевшее зеркало (воркер упал МЕЖДУ copyMessage и
|
||||||
|
# storage.commit() в `_handle_private_message` — зеркало ушло в Telegram, а
|
||||||
|
# запись в БД потерялась). Дискриминатор неидеальный (шапка-идентификация
|
||||||
|
# тоже от бота, но не routing-ключ — тоже даст этот WARNING), но лучше редкий
|
||||||
|
# ложный WARNING, чем оператор молча решает, что ответ клиенту доставлен,
|
||||||
|
# хотя реплай тихо утонул (#4 review — двухфазный протокол НЕ делаем, overkill).
|
||||||
|
reply_from = reply_to.get("from") or {}
|
||||||
|
if reply_from.get("is_bot"):
|
||||||
|
logger.warning(
|
||||||
|
"tgbot bridge: реплай на сообщение бота (message_id=%d) не найден в "
|
||||||
|
"tg_support_messages как зеркало клиента — возможно, осиротевшее "
|
||||||
|
"зеркало (крах между copyMessage и commit) или шапка-идентификация; "
|
||||||
|
"ответ оператора НЕ доставлен клиенту",
|
||||||
|
mirror_message_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
message_id = message.get("message_id")
|
||||||
|
if not isinstance(message_id, int):
|
||||||
|
return
|
||||||
|
|
||||||
|
operator = message.get("from") or {}
|
||||||
|
operator_id = operator.get("id")
|
||||||
|
|
||||||
|
try:
|
||||||
|
delivered = await client.copy_message(
|
||||||
|
chat_id=target_chat_id,
|
||||||
|
from_chat_id=settings.telegram_support_chat_id,
|
||||||
|
message_id=message_id,
|
||||||
|
)
|
||||||
|
except TelegramApiError as exc:
|
||||||
|
if exc.error_code == 403:
|
||||||
|
# Клиент заблокировал бота — фиксируем и уведомляем оператора в топике.
|
||||||
|
storage.mark_blocked(target_chat_id)
|
||||||
|
await client.send_message(
|
||||||
|
chat_id=settings.telegram_support_chat_id,
|
||||||
|
text=(
|
||||||
|
f"Не удалось доставить сообщение клиенту (chat_id={target_chat_id}) — "
|
||||||
|
"бот заблокирован."
|
||||||
|
),
|
||||||
|
message_thread_id=settings.telegram_support_topic_id or None,
|
||||||
|
reply_to_message_id=message_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
raise
|
||||||
|
|
||||||
|
tg_message_id = delivered.get("message_id") if isinstance(delivered, dict) else None
|
||||||
|
storage.record_message(
|
||||||
|
chat_id=target_chat_id,
|
||||||
|
direction="out",
|
||||||
|
tg_message_id=tg_message_id,
|
||||||
|
topic_message_id=None,
|
||||||
|
kind=_infer_kind(message),
|
||||||
|
text_body=message.get("text") or message.get("caption"),
|
||||||
|
operator_tg_id=operator_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def process_update(
|
||||||
|
update: dict[str, Any], client: TelegramClient, storage: BridgeStorage
|
||||||
|
) -> None:
|
||||||
|
"""Маршрутизирует один Telegram update. Дедуп (C) + атомарный offset-commit.
|
||||||
|
|
||||||
|
Дедуп: update_id <= сохранённого offset — skip без side-effects. Offset
|
||||||
|
сохраняется и коммитится ПОСЛЕ обработки (в т.ч. если обработка упала —
|
||||||
|
иначе «ядовитый» апдейт блокировал бы весь поток навсегда).
|
||||||
|
|
||||||
|
Различаем сбой БД (`SQLAlchemyError`) от прочих (Telegram API и т.п.):
|
||||||
|
сбой БД оставляет сессию в failed-transaction state — `rollback()` ОБЯЗАН
|
||||||
|
отработать ПЕРЕД `save_offset`, иначе тот сам кинет `PendingRollbackError`,
|
||||||
|
`process_update` вылетит без сохранения offset'а, следующая итерация
|
||||||
|
`run_poll_loop` получит СТАРЫЙ offset от `get_offset()` и переиграет тот же
|
||||||
|
апдейт заново — copyMessage задублирует зеркало клиента в топике на
|
||||||
|
каждый повтор поллинга (#3 review, воспроизведено).
|
||||||
|
"""
|
||||||
|
update_id = update.get("update_id")
|
||||||
|
if not isinstance(update_id, int):
|
||||||
|
logger.warning("tgbot bridge: update без валидного update_id — игнор")
|
||||||
|
return
|
||||||
|
|
||||||
|
current_offset = storage.get_offset()
|
||||||
|
if update_id <= current_offset:
|
||||||
|
logger.debug(
|
||||||
|
"tgbot bridge: update_id=%d уже обработан (offset=%d) — skip",
|
||||||
|
update_id,
|
||||||
|
current_offset,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
message = update.get("message")
|
||||||
|
try:
|
||||||
|
if isinstance(message, dict):
|
||||||
|
chat = message.get("chat") or {}
|
||||||
|
chat_type = chat.get("type")
|
||||||
|
chat_id = chat.get("id")
|
||||||
|
if chat_type == "private":
|
||||||
|
await _handle_private_message(message, client, storage)
|
||||||
|
elif settings.telegram_support_chat_id and chat_id == settings.telegram_support_chat_id:
|
||||||
|
await _handle_group_reply(message, client, storage)
|
||||||
|
# иначе — необрабатываемый тип чата/апдейта (edited_message, канал и
|
||||||
|
# т.п.) — тихий игнор, но offset всё равно сдвигаем ниже.
|
||||||
|
except SQLAlchemyError:
|
||||||
|
logger.exception(
|
||||||
|
"tgbot bridge: DB-ошибка на update_id=%d — rollback перед сохранением "
|
||||||
|
"offset (иначе save_offset сам упадёт на failed-transaction state)",
|
||||||
|
update_id,
|
||||||
|
)
|
||||||
|
storage.rollback()
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"tgbot bridge: обработка update_id=%d упала — offset всё равно сдвигаем "
|
||||||
|
"(не блокируем поток на 'ядовитом' апдейте)",
|
||||||
|
update_id,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
storage.save_offset(update_id)
|
||||||
|
storage.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Long-polling loop ─────────────────────────────────────────────────────────
|
||||||
|
async def run_poll_loop(
|
||||||
|
client: TelegramClient,
|
||||||
|
session_factory: Callable[[], Session],
|
||||||
|
poll_timeout_s: int = 30,
|
||||||
|
) -> None:
|
||||||
|
"""Бесконечный long-polling цикл до `shutdown_requested()`.
|
||||||
|
|
||||||
|
Свежая DB-сессия на каждую итерацию (одна итерация = один getUpdates-вызов +
|
||||||
|
обработка полученной пачки апдейтов) — не держим соединение открытым на
|
||||||
|
неопределённый срок между итерациями.
|
||||||
|
"""
|
||||||
|
logger.info("tgbot bridge: старт poll loop (timeout=%ds)", poll_timeout_s)
|
||||||
|
consecutive_errors = 0
|
||||||
|
while not shutdown_requested():
|
||||||
|
try:
|
||||||
|
with session_factory() as db:
|
||||||
|
storage = SqlBridgeStorage(db)
|
||||||
|
offset = storage.get_offset()
|
||||||
|
updates = await client.get_updates(
|
||||||
|
offset=offset + 1, timeout=poll_timeout_s, allowed_updates=["message"]
|
||||||
|
)
|
||||||
|
for update in updates:
|
||||||
|
await process_update(update, client, storage)
|
||||||
|
consecutive_errors = 0
|
||||||
|
except Exception:
|
||||||
|
consecutive_errors += 1
|
||||||
|
backoff = min(5 * consecutive_errors, 60)
|
||||||
|
logger.exception("tgbot bridge: итерация poll loop упала — retry через %ds", backoff)
|
||||||
|
await asyncio.sleep(backoff)
|
||||||
|
logger.info("tgbot bridge: poll loop остановлен (shutdown)")
|
||||||
249
tradein-mvp/backend/app/services/tgbot/client.py
Normal file
249
tradein-mvp/backend/app/services/tgbot/client.py
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
"""Тонкая httpx-обёртка над Telegram Bot API (#tgsupport).
|
||||||
|
|
||||||
|
Зачем свой клиент, а не aiogram: единственные нужные методы — `getUpdates`
|
||||||
|
(long-polling), `copyMessage` (зеркалирование ЛЮБОГО типа контента без ре-аплоада)
|
||||||
|
и `sendMessage` (заголовки/приветствия/уведомления). aiogram — избыточная
|
||||||
|
зависимость (webhook-framework, dispatcher, FSM) ради трёх HTTP-вызовов; в стеке
|
||||||
|
уже есть httpx (см. `app.services.dadata`, `app.services.geocoder` — тот же паттерн
|
||||||
|
retry/timeout).
|
||||||
|
|
||||||
|
Docs: https://core.telegram.org/bots/api
|
||||||
|
|
||||||
|
Ретраи:
|
||||||
|
- HTTP 429 (Too Many Requests) — уважаем `parameters.retry_after` из тела ответа
|
||||||
|
(Telegram сам говорит сколько ждать), fallback на `_DEFAULT_RETRY_AFTER_S`.
|
||||||
|
- HTTP 5xx / сетевые ошибки (timeout/connect) — экспоненциальный backoff,
|
||||||
|
`capped` на `_MAX_BACKOFF_S`.
|
||||||
|
- Любая другая 4xx (400/401/403/404) — НЕ ретраится, сразу `TelegramApiError`
|
||||||
|
(запрос некорректен или прав нет — повтор не поможет).
|
||||||
|
|
||||||
|
БЕЗОПАСНОСТЬ: наши `logger.*`-вызовы здесь содержат только имя метода API,
|
||||||
|
HTTP-статус и `description` из ответа Telegram — токен туда не пишем.
|
||||||
|
Это НЕ гарантирует, что токен не утечёт по другим стокам: он живёт в
|
||||||
|
`self._base`/`url` (локальные переменные stack-фрейма `_request`), а GlitchTip
|
||||||
|
(sentry_sdk) по умолчанию прикладывает locals к traceback и Httpx-интеграция
|
||||||
|
кладёт полный URL в span data. Эти стоки закрываются НЕ здесь, а в
|
||||||
|
`app.tgbot_main` (`include_local_variables=False`, `before_send`-редактор,
|
||||||
|
`traces_sample_rate=0.0`) и подавлением INFO-логов самого `httpx`-логгера
|
||||||
|
(который печатает полный request URL, включая токен, на уровне INFO).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_DEFAULT_TIMEOUT_S = 15.0
|
||||||
|
_DEFAULT_RETRY_AFTER_S = 5.0
|
||||||
|
_MAX_BACKOFF_S = 30.0
|
||||||
|
_DEFAULT_MAX_RETRIES = 5
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramApiError(Exception):
|
||||||
|
"""Telegram Bot API ответил `ok: false` (после исчерпания ретраев, если применимо)."""
|
||||||
|
|
||||||
|
def __init__(self, method: str, error_code: int, description: str) -> None:
|
||||||
|
self.method = method
|
||||||
|
self.error_code = error_code
|
||||||
|
self.description = description
|
||||||
|
super().__init__(f"Telegram API {method} failed: {error_code} {description}")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_retry_after(
|
||||||
|
response: httpx.Response, default: float = _DEFAULT_RETRY_AFTER_S
|
||||||
|
) -> float:
|
||||||
|
"""Достаёт `parameters.retry_after` из тела 429-ответа. Fallback — `default`."""
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return default
|
||||||
|
params = data.get("parameters")
|
||||||
|
if isinstance(params, dict):
|
||||||
|
retry_after = params.get("retry_after")
|
||||||
|
if isinstance(retry_after, int | float):
|
||||||
|
return float(retry_after)
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _error_from_body(response: httpx.Response) -> tuple[int, str]:
|
||||||
|
"""Парсит (error_code, description) из тела ответа Telegram; fallback на HTTP-статус."""
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
except ValueError:
|
||||||
|
return response.status_code, (response.text or "")[:200]
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return response.status_code, str(data)[:200]
|
||||||
|
error_code = data.get("error_code", response.status_code)
|
||||||
|
description = data.get("description", "")
|
||||||
|
code = int(error_code) if isinstance(error_code, int | float) else response.status_code
|
||||||
|
return code, str(description)
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramClient:
|
||||||
|
"""Bot API клиент на httpx.AsyncClient. Каждый вызов — отдельное короткоживущее соединение."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
token: str,
|
||||||
|
base_url: str = "https://api.telegram.org",
|
||||||
|
timeout: float = _DEFAULT_TIMEOUT_S,
|
||||||
|
) -> None:
|
||||||
|
self._base = f"{base_url}/bot{token}"
|
||||||
|
self._timeout = timeout
|
||||||
|
|
||||||
|
async def _request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
*,
|
||||||
|
timeout: float | None = None,
|
||||||
|
max_retries: int = _DEFAULT_MAX_RETRIES,
|
||||||
|
) -> Any:
|
||||||
|
"""POST `method` с JSON-телом `payload`. Ретраит 429/5xx/network, иначе raise сразу."""
|
||||||
|
url = f"{self._base}/{method}"
|
||||||
|
effective_timeout = timeout if timeout is not None else self._timeout
|
||||||
|
attempt = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
attempt += 1
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=effective_timeout) as client:
|
||||||
|
response = await client.post(url, json=payload)
|
||||||
|
except (httpx.TimeoutException, httpx.NetworkError) as exc:
|
||||||
|
if attempt > max_retries:
|
||||||
|
logger.error(
|
||||||
|
"tg client: %s — network error после %d попыток: %s", method, attempt, exc
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
backoff = min(2.0**attempt, _MAX_BACKOFF_S)
|
||||||
|
logger.warning(
|
||||||
|
"tg client: %s — network error (попытка %d/%d): %s — retry через %.0fs",
|
||||||
|
method,
|
||||||
|
attempt,
|
||||||
|
max_retries,
|
||||||
|
exc,
|
||||||
|
backoff,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(backoff)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if response.status_code == 429:
|
||||||
|
retry_after = _extract_retry_after(response)
|
||||||
|
if attempt > max_retries:
|
||||||
|
error_code, description = _error_from_body(response)
|
||||||
|
logger.error("tg client: %s — 429 после %d попыток, сдаёмся", method, attempt)
|
||||||
|
raise TelegramApiError(method, error_code, description)
|
||||||
|
logger.warning(
|
||||||
|
"tg client: %s — HTTP 429 (попытка %d/%d), retry_after=%.0fs",
|
||||||
|
method,
|
||||||
|
attempt,
|
||||||
|
max_retries,
|
||||||
|
retry_after,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(retry_after)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if response.status_code >= 500:
|
||||||
|
if attempt > max_retries:
|
||||||
|
error_code, description = _error_from_body(response)
|
||||||
|
logger.error(
|
||||||
|
"tg client: %s — HTTP %d после %d попыток, сдаёмся",
|
||||||
|
method,
|
||||||
|
response.status_code,
|
||||||
|
attempt,
|
||||||
|
)
|
||||||
|
raise TelegramApiError(method, error_code, description)
|
||||||
|
backoff = min(2.0**attempt, _MAX_BACKOFF_S)
|
||||||
|
logger.warning(
|
||||||
|
"tg client: %s — HTTP %d (попытка %d/%d) — retry через %.0fs",
|
||||||
|
method,
|
||||||
|
response.status_code,
|
||||||
|
attempt,
|
||||||
|
max_retries,
|
||||||
|
backoff,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(backoff)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if response.status_code >= 400:
|
||||||
|
# 4xx кроме 429 — запрос некорректен/прав нет, повтор не поможет.
|
||||||
|
error_code, description = _error_from_body(response)
|
||||||
|
raise TelegramApiError(method, error_code, description)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise TelegramApiError(
|
||||||
|
method, response.status_code, f"invalid json: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if not isinstance(data, dict) or not data.get("ok"):
|
||||||
|
error_code, description = _error_from_body(response)
|
||||||
|
raise TelegramApiError(method, error_code, description)
|
||||||
|
|
||||||
|
return data.get("result")
|
||||||
|
|
||||||
|
async def get_updates(
|
||||||
|
self,
|
||||||
|
offset: int,
|
||||||
|
timeout: int = 30,
|
||||||
|
allowed_updates: list[str] | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Long-polling getUpdates. `timeout` — сколько Telegram держит запрос открытым (сек).
|
||||||
|
|
||||||
|
HTTP-таймаут запроса берётся с запасом (`timeout + 10s`), чтобы не обрывать
|
||||||
|
соединение раньше, чем ответит сам Telegram long-poll.
|
||||||
|
"""
|
||||||
|
payload: dict[str, Any] = {"offset": offset, "timeout": timeout}
|
||||||
|
if allowed_updates is not None:
|
||||||
|
payload["allowed_updates"] = allowed_updates
|
||||||
|
result = await self._request(
|
||||||
|
"getUpdates", payload, timeout=float(timeout) + 10.0, max_retries=3
|
||||||
|
)
|
||||||
|
return result if isinstance(result, list) else []
|
||||||
|
|
||||||
|
async def copy_message(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
chat_id: int,
|
||||||
|
from_chat_id: int,
|
||||||
|
message_id: int,
|
||||||
|
message_thread_id: int | None = None,
|
||||||
|
reply_to_message_id: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""copyMessage — зеркалит ЛЮБОЙ тип контента без ре-аплоада файла."""
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"from_chat_id": from_chat_id,
|
||||||
|
"message_id": message_id,
|
||||||
|
}
|
||||||
|
if message_thread_id:
|
||||||
|
payload["message_thread_id"] = message_thread_id
|
||||||
|
if reply_to_message_id:
|
||||||
|
payload["reply_to_message_id"] = reply_to_message_id
|
||||||
|
result = await self._request("copyMessage", payload)
|
||||||
|
return result if isinstance(result, dict) else {}
|
||||||
|
|
||||||
|
async def send_message(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
chat_id: int,
|
||||||
|
text: str,
|
||||||
|
message_thread_id: int | None = None,
|
||||||
|
reply_to_message_id: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""sendMessage — текстовое сообщение (заголовки, приветствия, уведомления об ошибке)."""
|
||||||
|
payload: dict[str, Any] = {"chat_id": chat_id, "text": text}
|
||||||
|
if message_thread_id:
|
||||||
|
payload["message_thread_id"] = message_thread_id
|
||||||
|
if reply_to_message_id:
|
||||||
|
payload["reply_to_message_id"] = reply_to_message_id
|
||||||
|
result = await self._request("sendMessage", payload)
|
||||||
|
return result if isinstance(result, dict) else {}
|
||||||
146
tradein-mvp/backend/app/services/user_events.py
Normal file
146
tradein-mvp/backend/app/services/user_events.py
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
"""Сервис записи в `user_events` — Feature 2 (login/IP audit) + база для Feature 3
|
||||||
|
(behavior analytics).
|
||||||
|
|
||||||
|
`user_events` (migration `184_user_events.sql`) — unified append-only event log,
|
||||||
|
admin-read-only. Запись события НИКОГДА не должна ронять реальный HTTP-запрос:
|
||||||
|
любая ошибка (БД недоступна, сетевой сбой, схема разъехалась) ловится и логируется
|
||||||
|
через `logger.warning`, без re-raise — вызывающий код (middleware / handler)
|
||||||
|
продолжает работать так, как если бы аудит был выключен.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.core.db import SessionLocal
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def record_event(
|
||||||
|
*,
|
||||||
|
event_type: str,
|
||||||
|
username: str,
|
||||||
|
ip: str | None = None,
|
||||||
|
user_agent: str | None = None,
|
||||||
|
path: str | None = None,
|
||||||
|
method: str | None = None,
|
||||||
|
estimate_id: str | None = None,
|
||||||
|
payload: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Синхронно вставляет одну строку в `user_events`.
|
||||||
|
|
||||||
|
Открывает СОБСТВЕННУЮ сессию (`SessionLocal()`), декаплённую от транзакции
|
||||||
|
вызывающего запроса — событие коммитится независимо и переживает rollback
|
||||||
|
основного хендлера (и наоборот: сбой записи события не трогает основную tx,
|
||||||
|
т.к. она уже закоммичена/не связана с этой сессией).
|
||||||
|
|
||||||
|
Никогда не поднимает исключение — вызывающий код (middleware / endpoint)
|
||||||
|
не должен падать из-за проблем с аудит-логом.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with SessionLocal() as db:
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO user_events
|
||||||
|
(event_type, username, ip_address, user_agent, path, method,
|
||||||
|
estimate_id, payload)
|
||||||
|
VALUES
|
||||||
|
(:event_type, :username, CAST(:ip AS inet), :user_agent, :path,
|
||||||
|
:method, CAST(:estimate_id AS uuid), CAST(:payload AS jsonb))
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"event_type": event_type,
|
||||||
|
"username": username,
|
||||||
|
"ip": ip,
|
||||||
|
"user_agent": user_agent,
|
||||||
|
"path": path,
|
||||||
|
"method": method,
|
||||||
|
"estimate_id": estimate_id,
|
||||||
|
"payload": json.dumps(payload or {}, ensure_ascii=False),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"user_events: failed to record event_type=%r username=%r path=%r",
|
||||||
|
event_type,
|
||||||
|
username,
|
||||||
|
path,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_event(**kwargs: Any) -> None:
|
||||||
|
"""Fire-and-forget обёртка над `record_event` — никогда не блокирует запрос и
|
||||||
|
никогда не поднимает исключение наружу.
|
||||||
|
|
||||||
|
- Есть running event loop (обычный async FastAPI handler / middleware) →
|
||||||
|
INSERT уводится в отдельный поток через `asyncio.to_thread`, вызывающая
|
||||||
|
корутина не ждёт результата. Ошибки ловятся done-callback'ом и просто
|
||||||
|
логируются (иначе asyncio ругался бы "Task exception was never retrieved").
|
||||||
|
- Нет running loop (sync-контекст: тесты, Celery task, скрипт) → вызывает
|
||||||
|
`record_event(**kwargs)` синхронно inline (она сама никогда не raises).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
except RuntimeError:
|
||||||
|
record_event(**kwargs)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
task = loop.create_task(asyncio.to_thread(record_event, **kwargs))
|
||||||
|
except Exception:
|
||||||
|
logger.warning("user_events: failed to schedule background event", exc_info=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
def _log_task_exception(t: asyncio.Task[None]) -> None:
|
||||||
|
if t.cancelled():
|
||||||
|
return
|
||||||
|
exc = t.exception()
|
||||||
|
if exc is not None:
|
||||||
|
logger.warning("user_events: background record_event failed: %s", exc)
|
||||||
|
|
||||||
|
task.add_done_callback(_log_task_exception)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# In-process best-effort dedup для login-события.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_LOGIN_DEDUP_LOCK = threading.Lock()
|
||||||
|
_LOGIN_DEDUP_SEEN: set[str] = set()
|
||||||
|
_LOGIN_DEDUP_MAX = 50_000
|
||||||
|
|
||||||
|
|
||||||
|
def should_log_login(username: str, ip: str | None, user_agent: str | None) -> bool:
|
||||||
|
"""True максимум один раз в сутки на комбинацию (username, ip, user_agent) в
|
||||||
|
рамках жизни процесса — держит объём `event_type='login'` строк на уровне
|
||||||
|
~одной записи на аккаунт+устройство в день, а не одной на каждый API-запрос.
|
||||||
|
|
||||||
|
Best-effort/не персистентно: рестарт процесса (деплой, worker respawn)
|
||||||
|
сбрасывает in-memory set, так что после рестарта первая комбинация дня
|
||||||
|
залогируется заново — недо-дедуп, не потеря данных (для отчётов по
|
||||||
|
user_events всё равно используется `DISTINCT (username, ip_address,
|
||||||
|
user_agent, date_trunc('day', created_at))`, так что дубликаты не искажают
|
||||||
|
метрики, лишь чуть увеличивают объём записи).
|
||||||
|
"""
|
||||||
|
day = datetime.now(UTC).strftime("%Y-%m-%d")
|
||||||
|
key = f"{username}|{ip or ''}|{user_agent or ''}|{day}"
|
||||||
|
|
||||||
|
with _LOGIN_DEDUP_LOCK:
|
||||||
|
if key in _LOGIN_DEDUP_SEEN:
|
||||||
|
return False
|
||||||
|
if len(_LOGIN_DEDUP_SEEN) >= _LOGIN_DEDUP_MAX:
|
||||||
|
_LOGIN_DEDUP_SEEN.clear()
|
||||||
|
_LOGIN_DEDUP_SEEN.add(key)
|
||||||
|
return True
|
||||||
|
|
@ -40,6 +40,14 @@ from app.services import scrape_runs as runs_mod
|
||||||
|
|
||||||
# Нижняя граница ppm² — отсекает нежилые/технические сделки; не меняется.
|
# Нижняя граница ppm² — отсекает нежилые/технические сделки; не меняется.
|
||||||
_PPM2_MIN: int = 30_000
|
_PPM2_MIN: int = 30_000
|
||||||
|
|
||||||
|
# #C2 — asking-сторона (listings) покрыта скрейпом ТОЛЬКО по ЕКБ (per-city scrape B1/B2
|
||||||
|
# ещё нет; в listings даже нет колонки city). Миграция 177 залила ДКП-сделки по всей
|
||||||
|
# обл.66 (368 городов) → sold-медиана смешивала дешёвую область с ЕКБ-asking и обваливала
|
||||||
|
# ratio (0.877→0.62, «выкупная» −29% системно). Скоупим SOLD-сторону (deal_side/deal_global)
|
||||||
|
# на ЕКБ, чтобы sold и asking считались по ОДНОМУ рынку. Когда появятся oblast-листинги —
|
||||||
|
# заменить на per-city ratio через зарезервированный столбец `district` (#647).
|
||||||
|
_ASKING_CITY_PATTERN: str = "%Екатеринбург%"
|
||||||
# Верхняя граница берётся из settings.asking_ratio_ppm2_max (default 1_200_000).
|
# Верхняя граница берётся из settings.asking_ratio_ppm2_max (default 1_200_000).
|
||||||
# QA-note: точное значение сверить с `SELECT max(price_per_m2) FROM deals
|
# QA-note: точное значение сверить с `SELECT max(price_per_m2) FROM deals
|
||||||
# WHERE source='rosreestr'` на проде — ceiling должен быть > max(ppm²) premium-сделок.
|
# WHERE source='rosreestr'` на проде — ceiling должен быть > max(ppm²) premium-сделок.
|
||||||
|
|
@ -80,6 +88,7 @@ _REDERIVE_SQL = text(
|
||||||
FROM deals
|
FROM deals
|
||||||
WHERE source = 'rosreestr'
|
WHERE source = 'rosreestr'
|
||||||
AND rooms IS NOT NULL
|
AND rooms IS NOT NULL
|
||||||
|
AND city ILIKE :asking_city -- #C2 SOLD-сторона на ЕКБ (match asking-рынок)
|
||||||
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
|
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
|
||||||
AND deal_date >= CURRENT_DATE - INTERVAL '12 months'
|
AND deal_date >= CURRENT_DATE - INTERVAL '12 months'
|
||||||
GROUP BY LEAST(GREATEST(rooms, 0), 4)
|
GROUP BY LEAST(GREATEST(rooms, 0), 4)
|
||||||
|
|
@ -128,6 +137,7 @@ _REDERIVE_SQL = text(
|
||||||
FROM deals
|
FROM deals
|
||||||
WHERE source = 'rosreestr'
|
WHERE source = 'rosreestr'
|
||||||
AND rooms IS NOT NULL
|
AND rooms IS NOT NULL
|
||||||
|
AND city ILIKE :asking_city -- #C2 SOLD-сторона на ЕКБ (match asking-рынок)
|
||||||
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
|
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
|
||||||
AND deal_date >= CURRENT_DATE - INTERVAL '12 months'
|
AND deal_date >= CURRENT_DATE - INTERVAL '12 months'
|
||||||
),
|
),
|
||||||
|
|
@ -213,7 +223,11 @@ def recompute_asking_to_sold_ratios(db: Session, run_id: int) -> dict[str, int]:
|
||||||
db.execute(_DELETE_SQL)
|
db.execute(_DELETE_SQL)
|
||||||
db.execute(
|
db.execute(
|
||||||
_REDERIVE_SQL,
|
_REDERIVE_SQL,
|
||||||
{"ppm2_min": _PPM2_MIN, "ppm2_max": settings.asking_ratio_ppm2_max},
|
{
|
||||||
|
"ppm2_min": _PPM2_MIN,
|
||||||
|
"ppm2_max": settings.asking_ratio_ppm2_max,
|
||||||
|
"asking_city": _ASKING_CITY_PATTERN,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
row = db.execute(_COUNTERS_SQL).mappings().first()
|
row = db.execute(_COUNTERS_SQL).mappings().first()
|
||||||
|
|
|
||||||
124
tradein-mvp/backend/app/tasks/domrf_kapremont_load.py
Normal file
124
tradein-mvp/backend/app/tasks/domrf_kapremont_load.py
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
"""CLI: ДОМ.РФ капремонт open data → houses.year_built/material_walls (issue #2013).
|
||||||
|
|
||||||
|
Тянет КР1.1 (house registry) + КР1.2 (constructive elements) с открытых данных
|
||||||
|
ДОМ.РФ (region 66), UPSERT'ит в staging `domrf_kapremont` (мигр. 176), затем:
|
||||||
|
1. backfill_houses_from_domrf — COALESCE(houses.year_built, domrf.commission_year,
|
||||||
|
houses.zhkh_year) + material_walls + total_floors (fold-in уже загруженного
|
||||||
|
ГИС-ЖКХ года — houses без domrf-матча тоже выигрывают).
|
||||||
|
2. propagate_listings_year_from_houses — UPDATE listings.year_built = houses.year_built
|
||||||
|
там, где listing ещё NULL. Именно это открывает estimator'у (year_built BETWEEN
|
||||||
|
когорта-фильтр) реальные годы на источниках вроде avito (0% year_built, 52.5%
|
||||||
|
linked к houses).
|
||||||
|
|
||||||
|
Запуск из контейнера tradein-backend (у прода есть интернет к домрф.рф):
|
||||||
|
|
||||||
|
python -m app.tasks.domrf_kapremont_load # полный прогон
|
||||||
|
python -m app.tasks.domrf_kapremont_load --dry-run # без записи, только подсчёт
|
||||||
|
python -m app.tasks.domrf_kapremont_load --load-only # только staging, без backfill
|
||||||
|
python -m app.tasks.domrf_kapremont_load --backfill-only # staging уже загружена — только
|
||||||
|
# backfill+propagate
|
||||||
|
python -m app.tasks.domrf_kapremont_load \\
|
||||||
|
--kr11-path /tmp/kr11.csv --kr12-path /tmp/kr12.csv # локальные CSV, без скачивания
|
||||||
|
|
||||||
|
В режиме --dry-run скачивание/парс (если применимо) происходят, но НИ ОДНОЙ записи
|
||||||
|
в БД не делается.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.core.db import SessionLocal
|
||||||
|
from app.services.domrf_kapremont_loader import (
|
||||||
|
backfill_houses_from_domrf,
|
||||||
|
load_domrf_kapremont,
|
||||||
|
propagate_listings_year_from_houses,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
"""Парсер CLI (вынесен для тестируемости флагов без запуска main)."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=(
|
||||||
|
"ДОМ.РФ капремонт loader: КР1.1+КР1.2 → domrf_kapremont staging → "
|
||||||
|
"houses.year_built/material_walls/total_floors → listings.year_built"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--kr11-path", default=None, help="локальный CSV КР1.1 (пропустить скачивание)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--kr12-path", default=None, help="локальный CSV КР1.2 (пропустить скачивание)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--work-dir",
|
||||||
|
default=None,
|
||||||
|
help="каталог для скачанных/распакованных CSV (по умолчанию — временный, удаляется после)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run", action="store_true", help="без записи в БД — только скачивание/парс/подсчёт"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--load-only",
|
||||||
|
action="store_true",
|
||||||
|
help="только скачать+распарсить+UPSERT staging, без backfill houses/propagate listings",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--backfill-only",
|
||||||
|
action="store_true",
|
||||||
|
help="пропустить скачивание/load staging (считаем что уже загружена этим же "
|
||||||
|
"или предыдущим прогоном) — только backfill houses + propagate listings",
|
||||||
|
)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||||
|
)
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.load_only and args.backfill_only:
|
||||||
|
parser.error("--load-only и --backfill-only взаимоисключающи")
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
load_counts: dict[str, int] = {}
|
||||||
|
if not args.backfill_only:
|
||||||
|
load_counts = load_domrf_kapremont(
|
||||||
|
db,
|
||||||
|
kr11_path=args.kr11_path,
|
||||||
|
kr12_path=args.kr12_path,
|
||||||
|
work_dir=args.work_dir,
|
||||||
|
dry_run=args.dry_run,
|
||||||
|
)
|
||||||
|
if not args.dry_run:
|
||||||
|
db.commit()
|
||||||
|
logger.info("domrf_kapremont_load: load stage done: %s", load_counts)
|
||||||
|
|
||||||
|
houses_counts: dict[str, int] = {}
|
||||||
|
listings_counts: dict[str, int] = {}
|
||||||
|
if not args.load_only:
|
||||||
|
houses_counts = backfill_houses_from_domrf(db, dry_run=args.dry_run)
|
||||||
|
listings_counts = propagate_listings_year_from_houses(db, dry_run=args.dry_run)
|
||||||
|
if not args.dry_run:
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"domrf_kapremont_load DONE: dry_run=%s load=%s houses=%s listings=%s",
|
||||||
|
args.dry_run,
|
||||||
|
load_counts,
|
||||||
|
houses_counts,
|
||||||
|
listings_counts,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
196
tradein-mvp/backend/app/tasks/sber_freshness_monitor.py
Normal file
196
tradein-mvp/backend/app/tasks/sber_freshness_monitor.py
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
"""Мониторинг свежести ДАННЫХ СберИндекса (не статуса джобы) — audit п.1.
|
||||||
|
|
||||||
|
Проблема аудита: estimator._load_sber_index_series (#794/#audit-5a) применяет
|
||||||
|
СберИндекс time-adjustment к ДКП-сделкам и лишь ЛОГИРУЕТ per-estimate warning,
|
||||||
|
когда latest месяц серии старее settings.sber_index_max_age_days (35д). Джоба
|
||||||
|
`sber_index_pull` крутится ежемесячно (enabled), а источник СберИндекса публикует
|
||||||
|
данные с лагом ~1-2 месяца, поэтому `sber_price_index.period_month` дрейфит
|
||||||
|
(на 2026-07-12 latest=2026-05-01, ~72д). Это НЕ silent failure, но staleness
|
||||||
|
видна только в debug-подобном per-estimate warning'е, тонущем в логах оценок.
|
||||||
|
|
||||||
|
Этот монитор смотрит на `max(period_month)` вторичного сегмента по региону и
|
||||||
|
поднимает per-day WARNING-алерт, когда данные устарели СВЕРХ допустимого лага
|
||||||
|
публикации — так ops видит дрейф на MONITOR-частоте, а не по крупицам в логах.
|
||||||
|
|
||||||
|
Порог алерта (документирование выбора):
|
||||||
|
Per-estimate guard (estimator): age > settings.sber_index_max_age_days (35д).
|
||||||
|
Монитор: age > sber_index_max_age_days + lag_allowance.
|
||||||
|
lag_allowance (DEFAULT_LAG_ALLOWANCE_DAYS=25) — запас на ИНХЕРЕНТНЫЙ лаг
|
||||||
|
публикации СберИндекса: источник отстаёт на 1-2 месяца, period_month — лейбл
|
||||||
|
ПЕРВОГО числа месяца, а месячный pull ещё не подтянул новейший период. Итог:
|
||||||
|
35 + 25 = 60д. Ниже 60д latest считается «нормально отстающим» → алерта нет
|
||||||
|
(иначе daily-шум на штатном лаге). Выше 60д данные застряли сверх ~2 месяцев
|
||||||
|
→ алерт. Проверено на проде 2026-07-12: max=2026-05-01, age=72д > 60 → alert=1.
|
||||||
|
|
||||||
|
Задача синхронная (DB-only, один SELECT max(period_month)) — запускается
|
||||||
|
kit-scheduler'ом через product_handlers._job_sber_freshness_monitor в
|
||||||
|
run_in_executor, по образцу deals_freshness_monitor. Вердикт вычисляет ЧИСТАЯ
|
||||||
|
функция evaluate_sber_freshness() (frozen-now тестируется без БД).
|
||||||
|
|
||||||
|
Прогон НЕ помечается failed при алерте (это МОНИТОР, а не сбой джобы) — WARNING
|
||||||
|
достаточен. mark_failed только если sber_price_index недоступна/пуста (нечего
|
||||||
|
оценивать).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, date, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.services import scrape_runs as runs_mod
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DEFAULT_LAG_ALLOWANCE_DAYS",
|
||||||
|
"SberFreshnessVerdict",
|
||||||
|
"check_sber_freshness",
|
||||||
|
"evaluate_sber_freshness",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Запас на инхерентный лаг публикации СберИндекса (дней) СВЕРХ per-estimate
|
||||||
|
# guard'а settings.sber_index_max_age_days. Читается из default_params.lag_allowance_days.
|
||||||
|
DEFAULT_LAG_ALLOWANCE_DAYS = 25
|
||||||
|
|
||||||
|
# Регион продукта (Trade-in — Свердловская область). Совпадает с city-значениями
|
||||||
|
# sber_price_index для областного уровня.
|
||||||
|
SBER_MONITOR_CITY = "Свердловская область"
|
||||||
|
|
||||||
|
_LATEST_SBER_PERIOD_SQL = text("""
|
||||||
|
SELECT max(period_month) AS latest
|
||||||
|
FROM sber_price_index
|
||||||
|
WHERE city = CAST(:city AS text)
|
||||||
|
-- #R2-H1: только вторичный рынок (эстиматор — вторичка); первичка
|
||||||
|
-- (новостройки) = направленно неверная коррекция. Зеркалит фильтр
|
||||||
|
-- estimator._load_sber_index_series.
|
||||||
|
AND (segment IS NULL OR segment ILIKE '%вторичн%')
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SberFreshnessVerdict:
|
||||||
|
"""Вердикт свежести СберИндекса по max(period_month)."""
|
||||||
|
|
||||||
|
latest_period: date
|
||||||
|
age_days: int
|
||||||
|
stale: bool
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_sber_freshness(
|
||||||
|
latest_period: date,
|
||||||
|
now: datetime,
|
||||||
|
max_age_days: int,
|
||||||
|
) -> SberFreshnessVerdict:
|
||||||
|
"""Чистая логика: устарел ли latest период СберИндекса.
|
||||||
|
|
||||||
|
stale = age_days > max_age_days, где age_days = now.date() - latest_period.
|
||||||
|
`max_age_days` — ПОЛНЫЙ порог монитора (per-estimate guard + lag_allowance),
|
||||||
|
вычисляется вызывающим check_sber_freshness. Тестируется с frozen `now` без БД.
|
||||||
|
"""
|
||||||
|
age_days = (now.date() - latest_period).days
|
||||||
|
stale = age_days > max_age_days
|
||||||
|
return SberFreshnessVerdict(
|
||||||
|
latest_period=latest_period,
|
||||||
|
age_days=age_days,
|
||||||
|
stale=stale,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def check_sber_freshness(
|
||||||
|
db: Session,
|
||||||
|
run_id: int,
|
||||||
|
params: dict | None = None, # type: ignore[type-arg]
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> dict[str, int]:
|
||||||
|
"""Проверить свежесть СберИндекса по max(period_month) и алертить при staleness.
|
||||||
|
|
||||||
|
Sync (вызывается scheduler-триггером в executor, как check_deals_freshness).
|
||||||
|
Читает один SELECT max(period_month) вторичного сегмента по региону, считает
|
||||||
|
вердикт чистой функцией, логирует WARNING при stale (per-day surfacing для ops)
|
||||||
|
и финализирует run.
|
||||||
|
|
||||||
|
Params (default_params jsonb):
|
||||||
|
lag_allowance_days: int — запас сверх sber_index_max_age_days (default 25).
|
||||||
|
`now` инъектируется в тестах (frozen); в проде — None → datetime.now(UTC).
|
||||||
|
|
||||||
|
Returns counters {latest_year, latest_month, age_days, alert}.
|
||||||
|
mark_failed только если sber_price_index пуста/недоступна (нечего оценивать);
|
||||||
|
при алерте прогон помечается done (это монитор, не сбой джобы).
|
||||||
|
"""
|
||||||
|
params = params or {}
|
||||||
|
now = now or datetime.now(UTC)
|
||||||
|
counters: dict[str, int] = {
|
||||||
|
"latest_year": 0,
|
||||||
|
"latest_month": 0,
|
||||||
|
"age_days": 0,
|
||||||
|
"alert": 0,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
runs_mod.update_heartbeat(db, run_id, counters)
|
||||||
|
|
||||||
|
row = db.execute(_LATEST_SBER_PERIOD_SQL, {"city": SBER_MONITOR_CITY}).first()
|
||||||
|
latest: date | None = row.latest if row is not None else None
|
||||||
|
if latest is None:
|
||||||
|
logger.warning(
|
||||||
|
"sber freshness: sber_price_index пуст/недоступен для region=%s "
|
||||||
|
"(вторичка) — оценить свежесть нельзя",
|
||||||
|
SBER_MONITOR_CITY,
|
||||||
|
)
|
||||||
|
runs_mod.mark_failed(db, run_id, "sber_price_index empty or unavailable", counters)
|
||||||
|
return counters
|
||||||
|
|
||||||
|
lag_days = int(params.get("lag_allowance_days", DEFAULT_LAG_ALLOWANCE_DAYS))
|
||||||
|
max_age_days = settings.sber_index_max_age_days + lag_days
|
||||||
|
verdict = evaluate_sber_freshness(latest, now, max_age_days)
|
||||||
|
|
||||||
|
counters = {
|
||||||
|
"latest_year": latest.year,
|
||||||
|
"latest_month": latest.month,
|
||||||
|
"age_days": verdict.age_days,
|
||||||
|
"alert": int(verdict.stale),
|
||||||
|
}
|
||||||
|
|
||||||
|
if verdict.stale:
|
||||||
|
logger.warning(
|
||||||
|
"sber freshness: max(period_month)=%s устарел на %d дней "
|
||||||
|
"(> порога %d = sber_index_max_age_days %d + lag %d); "
|
||||||
|
"СберИндекс time-adjustment ДКП-сделок мог отстать — "
|
||||||
|
"проверь sber_index_pull и доступность новых периодов источника",
|
||||||
|
latest,
|
||||||
|
verdict.age_days,
|
||||||
|
max_age_days,
|
||||||
|
settings.sber_index_max_age_days,
|
||||||
|
lag_days,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
"sber freshness: max(period_month)=%s свежий (age=%d дней ≤ порога %d) "
|
||||||
|
"region=%s — алерта нет",
|
||||||
|
latest,
|
||||||
|
verdict.age_days,
|
||||||
|
max_age_days,
|
||||||
|
SBER_MONITOR_CITY,
|
||||||
|
)
|
||||||
|
|
||||||
|
runs_mod.mark_done(db, run_id, counters)
|
||||||
|
logger.info(
|
||||||
|
"check_sber_freshness run_id=%d done: latest=%s alert=%d age_days=%d",
|
||||||
|
run_id,
|
||||||
|
latest,
|
||||||
|
counters["alert"],
|
||||||
|
counters["age_days"],
|
||||||
|
)
|
||||||
|
return counters
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("check_sber_freshness run_id=%d failed", run_id)
|
||||||
|
try:
|
||||||
|
db.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
|
||||||
|
raise
|
||||||
180
tradein-mvp/backend/app/tgbot_main.py
Normal file
180
tradein-mvp/backend/app/tgbot_main.py
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
"""Standalone entrypoint для Telegram support-bridge воркера (#tgsupport).
|
||||||
|
|
||||||
|
Зачем отдельный процесс/контейнер: `getUpdates` long-polling держит открытый
|
||||||
|
HTTP-запрос к Telegram до 30с за раз в бесконечном цикле — деплой основного API
|
||||||
|
(docker restart tradein-backend) не должен обрывать эту петлю на середине, как и
|
||||||
|
API не должен блокироваться долгим poll'ом. Тот же паттерн, что и
|
||||||
|
`scheduler_main.py` (#1182) для scraper'ов — отдельный контейнер с тем же образом,
|
||||||
|
другая команда.
|
||||||
|
|
||||||
|
Запуск: python -m app.tgbot_main
|
||||||
|
|
||||||
|
Kill-switch: TELEGRAM_BOT_TOKEN пуст (дефолт) → воркер логирует «disabled» и
|
||||||
|
блокируется на `wait_for_shutdown()` (idle, ~0 CPU) — НЕ `sys.exit(0)`. Сервис в
|
||||||
|
compose поднят с `restart: unless-stopped`, который рестартует контейнер
|
||||||
|
независимо от кода выхода — чистый exit(0) без токена дал бы бесконечный
|
||||||
|
рестарт-луп. Idle-блокировка держит процесс живым (автозапуск после ребута VPS
|
||||||
|
работает штатно через restart-policy) без CPU-луп и без спама рестартов;
|
||||||
|
SIGTERM просто убивает процесс — восстанавливать здесь нечего (bridge-задача
|
||||||
|
не запущена).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
from contextlib import suppress
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.db import SessionLocal
|
||||||
|
from app.core.shutdown import request_shutdown, shutdown_requested, wait_for_shutdown
|
||||||
|
from app.services.tgbot.bridge import run_poll_loop
|
||||||
|
from app.services.tgbot.client import TelegramClient
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||||
|
)
|
||||||
|
|
||||||
|
# httpx INFO-логи печатают ПОЛНЫЙ request URL, включая Telegram Bot API токен
|
||||||
|
# в пути (https://api.telegram.org/bot<id>:<secret>/...) — `httpx: HTTP Request:
|
||||||
|
# POST https://api.telegram.org/bot<TOKEN>/getMe "HTTP/1.1 401 Unauthorized"`.
|
||||||
|
# При бесконечном long-polling'e это боевой токен в `docker logs` каждые ~30с.
|
||||||
|
# WARNING+ у httpx не логирует URL запроса (#tgsupport review, воспроизведено).
|
||||||
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Тот же safety-net паттерн, что scheduler_main.py — ниже docker stop_grace_period.
|
||||||
|
_DRAIN_TIMEOUT_S = 100.0
|
||||||
|
|
||||||
|
# Мониторинг ошибок — GlitchTip (Sentry-совместимый, #396). Только integrations
|
||||||
|
# без Starlette/FastAPI — здесь нет ASGI-приложения (тот же выбор что scheduler_main).
|
||||||
|
if settings.glitchtip_dsn:
|
||||||
|
import sentry_sdk
|
||||||
|
from sentry_sdk.integrations.httpx import HttpxIntegration
|
||||||
|
from sentry_sdk.integrations.logging import LoggingIntegration
|
||||||
|
|
||||||
|
from app.observability.sentry_scrub import redact_telegram_bot_token, scrub_pii_event
|
||||||
|
|
||||||
|
def _before_send(event: Any, hint: dict[str, Any]) -> Any:
|
||||||
|
"""Композиция PII-scrub (form-данные) + Telegram bot-токен redaction
|
||||||
|
(#tgsupport review). Токен утекает ДВУМЯ независимыми векторами, которые
|
||||||
|
`include_local_variables=False` ниже и этот хук закрывают вместе:
|
||||||
|
1. `include_local_variables=True` (sentry_sdk default) кладёт stack-frame
|
||||||
|
locals (`self._base`/`url` в `TelegramClient._request`) в traceback —
|
||||||
|
закрыто через `include_local_variables=False` в `sentry_sdk.init`.
|
||||||
|
2. `HttpxIntegration` кладёт полный request URL в span `data` (не только
|
||||||
|
traceback) — `traces_sample_rate=0.0` спасает СЕЙЧАС, но молча
|
||||||
|
перестанет спасать, если трейсинг когда-нибудь включат. Regex-редактор
|
||||||
|
— belt-and-suspenders на случай #1 (если include_local_variables
|
||||||
|
случайно вернут) И на span data.
|
||||||
|
"""
|
||||||
|
scrubbed = scrub_pii_event(event, hint)
|
||||||
|
if scrubbed is None:
|
||||||
|
return None
|
||||||
|
return redact_telegram_bot_token(scrubbed, hint)
|
||||||
|
|
||||||
|
sentry_sdk.init(
|
||||||
|
dsn=settings.glitchtip_dsn,
|
||||||
|
environment=settings.environment,
|
||||||
|
release=os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown",
|
||||||
|
traces_sample_rate=0.0,
|
||||||
|
send_default_pii=False,
|
||||||
|
include_local_variables=False,
|
||||||
|
before_send=_before_send,
|
||||||
|
integrations=[
|
||||||
|
HttpxIntegration(),
|
||||||
|
LoggingIntegration(level=logging.INFO, event_level=logging.ERROR),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
logger.info("GlitchTip monitoring enabled (tgbot_main)")
|
||||||
|
|
||||||
|
|
||||||
|
def _should_run() -> bool:
|
||||||
|
"""Kill-switch: TELEGRAM_BOT_TOKEN не задан → бот выключен (dev/staging без секрета)."""
|
||||||
|
return bool(settings.telegram_bot_token)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_bridge() -> None:
|
||||||
|
client = TelegramClient(settings.telegram_bot_token)
|
||||||
|
await run_poll_loop(client, SessionLocal)
|
||||||
|
|
||||||
|
|
||||||
|
async def _await_bridge(task: asyncio.Task[None]) -> None:
|
||||||
|
"""Кооперативный SIGTERM-drain — идентичная семантика scheduler_main._await_scheduler.
|
||||||
|
|
||||||
|
`run_poll_loop` сам проверяет `shutdown_requested()` между итерациями (между
|
||||||
|
getUpdates-вызовами) — long-polling запрос к Telegram (до 30с) докручивается,
|
||||||
|
затем цикл выходит сам. Safety-net здесь на случай зависшего HTTP-вызова.
|
||||||
|
"""
|
||||||
|
shutdown_waiter = asyncio.create_task(wait_for_shutdown())
|
||||||
|
try:
|
||||||
|
await asyncio.wait({task, shutdown_waiter}, return_when=asyncio.FIRST_COMPLETED)
|
||||||
|
finally:
|
||||||
|
shutdown_waiter.cancel()
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await shutdown_waiter
|
||||||
|
|
||||||
|
if task.done():
|
||||||
|
task.result()
|
||||||
|
logger.info("tgbot_main: bridge task exited cleanly")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"tgbot_main: SIGTERM-drain — waiting up to %.0fs for current poll iteration to finish",
|
||||||
|
_DRAIN_TIMEOUT_S,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(task, timeout=_DRAIN_TIMEOUT_S)
|
||||||
|
logger.info("tgbot_main: bridge drained and exited cleanly")
|
||||||
|
except TimeoutError:
|
||||||
|
logger.warning(
|
||||||
|
"tgbot_main: drain exceeded %.0fs grace — hard-cancelling bridge task",
|
||||||
|
_DRAIN_TIMEOUT_S,
|
||||||
|
)
|
||||||
|
task.cancel()
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
|
||||||
|
|
||||||
|
async def _run() -> None:
|
||||||
|
task = asyncio.create_task(_run_bridge())
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
def _on_signal(signum: int) -> None:
|
||||||
|
logger.info("tgbot_main: signal %d received — requesting cooperative drain", signum)
|
||||||
|
request_shutdown()
|
||||||
|
|
||||||
|
try:
|
||||||
|
loop.add_signal_handler(signal.SIGTERM, lambda: _on_signal(signal.SIGTERM))
|
||||||
|
loop.add_signal_handler(signal.SIGINT, lambda: _on_signal(signal.SIGINT))
|
||||||
|
except NotImplementedError:
|
||||||
|
# Windows dev: signal handlers через loop не поддерживаются
|
||||||
|
logger.warning("tgbot_main: loop.add_signal_handler not supported (Windows dev)")
|
||||||
|
|
||||||
|
await _await_bridge(task)
|
||||||
|
|
||||||
|
if shutdown_requested():
|
||||||
|
logger.info("tgbot_main: bridge drained cleanly (SIGTERM)")
|
||||||
|
else:
|
||||||
|
logger.info("tgbot_main: bridge task exited")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if not _should_run():
|
||||||
|
# NOT sys.exit(0): compose service has `restart: unless-stopped`, который
|
||||||
|
# рестартует контейнер независимо от кода выхода — чистый exit(0) без
|
||||||
|
# токена дал бы бесконечный рестарт-луп. Idle-блокировка вместо этого:
|
||||||
|
# ~0 CPU, SIGTERM просто убивает процесс (нечего дренировать).
|
||||||
|
logger.warning(
|
||||||
|
"tgbot_main: TELEGRAM_BOT_TOKEN не задан — бот выключен, "
|
||||||
|
"блокируемся на idle (не exit, чтобы не было рестарт-лупа с restart:unless-stopped)"
|
||||||
|
)
|
||||||
|
asyncio.run(wait_for_shutdown())
|
||||||
|
else:
|
||||||
|
asyncio.run(_run())
|
||||||
|
|
@ -22,6 +22,19 @@
|
||||||
--
|
--
|
||||||
-- Dependencies: 002_core_tables.sql (house_id space). No backend schema coupling.
|
-- Dependencies: 002_core_tables.sql (house_id space). No backend schema coupling.
|
||||||
-- Re-run safe: CREATE TABLE IF NOT EXISTS + idempotent upsert.
|
-- Re-run safe: CREATE TABLE IF NOT EXISTS + idempotent upsert.
|
||||||
|
--
|
||||||
|
-- LOW audit #4 — EKB scope (doc-only, no behavior change): this seed and its
|
||||||
|
-- `premium_houses` MV fallback (data/sql/139) are both curated/calibrated against
|
||||||
|
-- Ekaterinburg secondary-market listings ONLY. Non-EKB descriptions encountered
|
||||||
|
-- during curation were treated as noise and actively DEMOTED (is_premium=false) —
|
||||||
|
-- e.g. house_id=372944 below ("г. Нижний Новгород, Александровская слобода... это
|
||||||
|
-- НЕ ЕКБ" — mislabeled, not premium). Consequence: an oblast (non-EKB) elite
|
||||||
|
-- building will NOT receive the `premium_building` flag from this table or its MV
|
||||||
|
-- fallback — the estimator's `_is_premium_building` (app/services/estimator.py)
|
||||||
|
-- has no per-city variant of this overlay. This is not a silent gap: the estimator's
|
||||||
|
-- `_manual_review` still routes genuinely high-value objects to manual review via
|
||||||
|
-- absolute-value thresholds (estimate_manual_review_high_value_rub,
|
||||||
|
-- estimate_manual_review_elite_ppm2) independent of the premium_building flag.
|
||||||
|
|
||||||
BEGIN;
|
BEGIN;
|
||||||
|
|
||||||
|
|
|
||||||
67
tradein-mvp/backend/data/sql/176_domrf_kapremont.sql
Normal file
67
tradein-mvp/backend/data/sql/176_domrf_kapremont.sql
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
-- 176_domrf_kapremont.sql
|
||||||
|
--
|
||||||
|
-- CONTEXT: issue #2013 — houses.year_built заполнен только на 38%, houses.material_walls
|
||||||
|
-- на 0%. Существующий ГИС-ЖКХ loader (app/tasks/zhkh_flats_load.py, мигр. 146/149)
|
||||||
|
-- заполняет houses.zhkh_year (70% = 5002/7128) и houses.zhkh_floors (70%), НО НИКОГДА
|
||||||
|
-- не копирует их в houses.year_built/total_floors — это основной пробел. material_walls
|
||||||
|
-- вообще ни разу не заполнялся никаким источником (0%). Estimator
|
||||||
|
-- (app/services/estimator.py) фильтрует когорту по `year_built BETWEEN ...` — реальные
|
||||||
|
-- годы напрямую двигают точность оценки. Это REAL fix для #2013 (взамен запаркованного
|
||||||
|
-- когорт-исключения PR #2474).
|
||||||
|
--
|
||||||
|
-- Источник: ДОМ.РФ капремонт open data (free, no auth), region 66 (Свердловская обл.):
|
||||||
|
-- КР1.1 house registry (export/190) — zip → CSV. mkd_code, houseguid (ФИАС GUID дома),
|
||||||
|
-- address, commission_year (год ввода, int), total_sq, number_floors_max.
|
||||||
|
-- КР1.2 constructive elements (export/275) — zip → CSV, LONG FORMAT (строка на
|
||||||
|
-- конструктивный элемент на mkd_code); wall_material заполнен только на строке
|
||||||
|
-- элемента «фасад».
|
||||||
|
-- listings→houses линковка через listings.house_id_fk (avito 52.5% linked, year_built
|
||||||
|
-- 0% на avito → backfill поднимет до ~36-52% через houses.year_built propagation).
|
||||||
|
--
|
||||||
|
-- WHAT:
|
||||||
|
-- Таблица domrf_kapremont — staging-приёмник КР1.1+КР1.2 (region 66), ключ mkd_code.
|
||||||
|
-- Заполняется отдельным loader'ом (app/services/domrf_kapremont_loader.py,
|
||||||
|
-- app/tasks/domrf_kapremont_load.py). Backfill houses.year_built/material_walls/
|
||||||
|
-- total_floors и propagation listings.year_built — отдельный код-путь той же таски,
|
||||||
|
-- НЕ в этой миграции (только схема здесь, никакого DML над houses/listings).
|
||||||
|
--
|
||||||
|
-- DEPENDENCIES (existing prod objects): houses (gar_house_guid, house_fias_id,
|
||||||
|
-- zhkh_house_guid, zhkh_year, zhkh_floors, year_built, material_walls, total_floors —
|
||||||
|
-- все уже существуют: мигр. 009/010/070/146/149/152), listings (house_id_fk, year_built —
|
||||||
|
-- мигр. 002/130).
|
||||||
|
--
|
||||||
|
-- SAFETY / IDEMPOTENCY: только CREATE TABLE/INDEX IF NOT EXISTS + COMMENT. Повторный
|
||||||
|
-- прогон — no-op. Деплой-раннер гонит файл через psql -v ON_ERROR_STOP=on БЕЗ
|
||||||
|
-- --single-transaction → транзакцию открывает САМ файл (BEGIN/COMMIT ниже), как 143/149.
|
||||||
|
--
|
||||||
|
-- NB по нумерации: последний занятый = 175 (ls data/sql | grep '^17[0-9]'); следующий
|
||||||
|
-- свободный sequential = 176.
|
||||||
|
--
|
||||||
|
-- Deploy order: после 175_scrape_schedules_seed_domclick_detail_backfill.sql.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS domrf_kapremont (
|
||||||
|
mkd_code text PRIMARY KEY, -- ДОМ.РФ код МКД (КР1.1/КР1.2 join-ключ)
|
||||||
|
houseguid text, -- ФИАС GUID дома (КР1.1 houseguid)
|
||||||
|
address text,
|
||||||
|
commission_year smallint, -- год ввода в эксплуатацию (КР1.1 commission_year)
|
||||||
|
number_floors_max int, -- этажность (КР1.1 number_floors_max)
|
||||||
|
total_sq numeric(10, 2), -- общая площадь дома, м² (КР1.1 total_sq)
|
||||||
|
wall_material text, -- материал стен (КР1.2, строка «фасад»; wall_material)
|
||||||
|
loaded_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS domrf_kapremont_houseguid_idx
|
||||||
|
ON domrf_kapremont (houseguid)
|
||||||
|
WHERE houseguid IS NOT NULL;
|
||||||
|
|
||||||
|
COMMENT ON TABLE domrf_kapremont IS
|
||||||
|
'ДОМ.РФ капремонт open data (КР1.1 house registry export/190 + КР1.2 constructive '
|
||||||
|
'elements export/275), region 66 (Свердловская обл.). Ключ mkd_code. houseguid — ФИАС '
|
||||||
|
'GUID дома, матчится к houses через COALESCE(gar_house_guid, house_fias_id, '
|
||||||
|
'zhkh_house_guid) (app/services/domrf_kapremont_loader.py). wall_material берётся из '
|
||||||
|
'КР1.2 со строки конструктивного элемента «фасад» (единственная строка на mkd_code, '
|
||||||
|
'где это поле непустое). Заполняется app/tasks/domrf_kapremont_load.py (issue #2013).';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
51
tradein-mvp/backend/data/sql/177_deals_city_region.sql
Normal file
51
tradein-mvp/backend/data/sql/177_deals_city_region.sql
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
-- 177_deals_city_region.sql
|
||||||
|
-- deals.city + region_code backfill — расширение Mera trade-in estimator с
|
||||||
|
-- Екатеринбурга на ВСЮ Свердловскую область (region_code=66, все города).
|
||||||
|
--
|
||||||
|
-- WHY:
|
||||||
|
-- Ночной import_rosreestr_dkp (scheduler.py, source='rosreestr_dkp_import')
|
||||||
|
-- до сих пор фильтровал `city ILIKE '%катеринбург%'` и хардкодил
|
||||||
|
-- 'Екатеринбург, ' в address, а deals.region_code никогда не заполнялся
|
||||||
|
-- (NULL на всех 49791 текущих строках). Foreign table
|
||||||
|
-- gendesign_rosreestr_deals уже отдаёт чистые city + region_code из
|
||||||
|
-- source-таблицы (migration 072) — снятие фильтра открывает +47183
|
||||||
|
-- не-ЕКБ сделок, уже сидящих в источнике.
|
||||||
|
--
|
||||||
|
-- WHAT:
|
||||||
|
-- - deals.region_code уже существует (002_core_tables.sql), но deals.city
|
||||||
|
-- отсутствовал (было только текстовое поле address с хардкод-префиксом
|
||||||
|
-- 'Екатеринбург, '). Добавляем city text — импортёр (scheduler.py,
|
||||||
|
-- тот же PR) теперь заполняет его из реального source.city.
|
||||||
|
-- - Индекс (city, deal_date) — под будущие per-city аналитические запросы
|
||||||
|
-- (аналог deals_source_idx для (source, deal_date)).
|
||||||
|
-- - Backfill существующих 49791 строк: все текущие deals с source='rosreestr'
|
||||||
|
-- импортированы ДО этой миграции старым EKB-only кодом, т.е. фактически
|
||||||
|
-- все они — Екатеринбург (то же допущение, что зашито в address
|
||||||
|
-- 'Екатеринбург, ' + street). Простановка region_code=66, city='Екатеринбург'
|
||||||
|
-- задним числом делает старые строки self-describing наравне с новыми.
|
||||||
|
--
|
||||||
|
-- SCOPE: только schema (city column + index) + backfill старых rosreestr-строк.
|
||||||
|
-- Новые импорты (после деплоя scheduler.py) заполняют оба поля сами.
|
||||||
|
--
|
||||||
|
-- IDEMPOTENCY / SAFETY:
|
||||||
|
-- - ADD COLUMN IF NOT EXISTS / CREATE INDEX IF NOT EXISTS — безопасный re-run.
|
||||||
|
-- - Backfill UPDATE ограничен `region_code IS NULL OR city IS NULL` —
|
||||||
|
-- повторный прогон no-op (после первого раза условие уже false).
|
||||||
|
--
|
||||||
|
-- Dependencies: 002_core_tables.sql (deals, deals.region_code)
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE deals ADD COLUMN IF NOT EXISTS city text;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS deals_city_deal_date_idx ON deals (city, deal_date);
|
||||||
|
|
||||||
|
-- Backfill: все существующие rosreestr-сделки импортированы старым EKB-only
|
||||||
|
-- кодом (до этой миграции) => гарантированно Екатеринбург, region_code=66.
|
||||||
|
UPDATE deals
|
||||||
|
SET region_code = 66,
|
||||||
|
city = 'Екатеринбург'
|
||||||
|
WHERE source = 'rosreestr'
|
||||||
|
AND (region_code IS NULL OR city IS NULL);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
87
tradein-mvp/backend/data/sql/178_deal_city_price_bands.sql
Normal file
87
tradein-mvp/backend/data/sql/178_deal_city_price_bands.sql
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
-- 178_deal_city_price_bands.sql
|
||||||
|
-- Per-city ppm² plausible-deal guard-bands — снятие Екатеринбург-центричного
|
||||||
|
-- глобального DEAL_MIN_PPM2=50_000 (estimator.py) для остальной Свердловской обл.
|
||||||
|
--
|
||||||
|
-- WHY:
|
||||||
|
-- Миграция 177 открыла +47183 не-ЕКБ сделок (deals.city заполнен для всей
|
||||||
|
-- области). Но estimator.py всё ещё режет ДКП-сделки абсолютным guard-band
|
||||||
|
-- [DEAL_MIN_PPM2=50_000, DEAL_MAX_PPM2=800_000] ₽/м² — калиброванным
|
||||||
|
-- ИСКЛЮЧИТЕЛЬНО под ЕКБ-вторичку (см. комментарий у DEAL_MIN_PPM2 в
|
||||||
|
-- estimator.py). Для дешёвых городов области этот пол — не anti-outlier
|
||||||
|
-- guard, а cut-off легитимного рынка:
|
||||||
|
-- Североуральск median ≈ 21.7k ₽/м², Новоуральск ≈ 36k, Асбест ≈ 41k —
|
||||||
|
-- ВСЕ ниже 50k → 46.6% не-ЕКБ сделок молча дропаются как «доля/ошибка»,
|
||||||
|
-- хотя это нормальная цена для города.
|
||||||
|
--
|
||||||
|
-- WHAT:
|
||||||
|
-- Таблица deal_city_price_bands — per-city [ppm2_min, ppm2_max] band,
|
||||||
|
-- вычисленный из РЕАЛЬНЫХ rosreestr-сделок города (p1/p99 персентиль,
|
||||||
|
-- зажатый абсолютными hard-floor/ceiling 8000/800000 — доли и опечатки
|
||||||
|
-- режутся даже в самых дешёвых городах). Екатеринбург НАМЕРЕННО исключён
|
||||||
|
-- из популяции — estimator.py при отсутствии строки в этой таблице
|
||||||
|
-- fallback'ит на глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2 константы, т.е.
|
||||||
|
-- ЕКБ-поведение byte-identical сегодняшнему (см. invariant в PR).
|
||||||
|
--
|
||||||
|
-- Города с < 30 сделок исключены (HAVING count(*) >= 30) — слишком тонкая
|
||||||
|
-- выборка для устойчивого перцентиля, estimator fallback'ит на global band.
|
||||||
|
--
|
||||||
|
-- USAGE (estimator.py, тот же PR):
|
||||||
|
-- - SQL enforcement (_fetch_dkp_corridor, ~line 1263): LEFT JOIN на
|
||||||
|
-- deals.city = deal_city_price_bands.city, COALESCE(b.ppm2_min/max, global).
|
||||||
|
-- - Python enforcement (_is_plausible_deal via _fetch_deals): bands dict,
|
||||||
|
-- загружен один раз через _load_city_price_bands(db), .get(city, global).
|
||||||
|
--
|
||||||
|
-- IDEMPOTENCY / SAFETY:
|
||||||
|
-- CREATE TABLE IF NOT EXISTS — безопасный re-run. Populate — INSERT ...
|
||||||
|
-- ON CONFLICT (city) DO UPDATE, т.е. повторный прогон РЕФРЕШИТ бэнды под
|
||||||
|
-- свежие сделки (не no-op по дизайну — таблица предполагает периодический
|
||||||
|
-- refresh по мере роста deals; scheduler для авто-рефреша НЕ входит в этот
|
||||||
|
-- PR, см. follow-up в PR description).
|
||||||
|
--
|
||||||
|
-- Dependencies: 177_deals_city_region.sql (deals.city, backfill).
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS deal_city_price_bands (
|
||||||
|
city text PRIMARY KEY,
|
||||||
|
ppm2_min int NOT NULL,
|
||||||
|
ppm2_max int NOT NULL,
|
||||||
|
n_deals int NOT NULL,
|
||||||
|
refreshed_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE deal_city_price_bands IS
|
||||||
|
'Per-city ppm² plausible-deal guard-bands (estimator.py _is_plausible_deal / '
|
||||||
|
'_fetch_dkp_corridor). Екатеринбург НЕ включён — estimator fallback на глобальные '
|
||||||
|
'DEAL_MIN_PPM2=50000/DEAL_MAX_PPM2=800000 сохраняет ЕКБ-поведение byte-identical. '
|
||||||
|
'Refresh: повторный прогон этого файла (ON CONFLICT DO UPDATE); авто-scheduler '
|
||||||
|
'пока не реализован (см. issue follow-up).';
|
||||||
|
|
||||||
|
INSERT INTO deal_city_price_bands (city, ppm2_min, ppm2_max, n_deals, refreshed_at)
|
||||||
|
SELECT
|
||||||
|
city,
|
||||||
|
GREATEST(round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int, 8000)
|
||||||
|
AS ppm2_min,
|
||||||
|
LEAST(round(percentile_cont(0.99) WITHIN GROUP (ORDER BY price_per_m2))::int, 800000)
|
||||||
|
AS ppm2_max,
|
||||||
|
count(*) AS n_deals,
|
||||||
|
now() AS refreshed_at
|
||||||
|
FROM deals
|
||||||
|
WHERE source = 'rosreestr'
|
||||||
|
AND price_per_m2 IS NOT NULL
|
||||||
|
AND city IS NOT NULL
|
||||||
|
AND city <> 'Екатеринбург'
|
||||||
|
GROUP BY city
|
||||||
|
HAVING count(*) >= 30
|
||||||
|
-- Инвариант ppm2_max >= ppm2_min: если весь распределение города < 8000 ₽/м²
|
||||||
|
-- (p99 < 8000), LEAST(p99,800000) < GREATEST(p1,8000) → band [8000, <8000] не
|
||||||
|
-- матчит ничего → все сделки города молча дропались бы. Гейтим такие города
|
||||||
|
-- (для жилой ДКП недостижимо, но airtight против мусорного города).
|
||||||
|
AND round(percentile_cont(0.99) WITHIN GROUP (ORDER BY price_per_m2))::int >= 8000
|
||||||
|
ON CONFLICT (city) DO UPDATE
|
||||||
|
SET ppm2_min = EXCLUDED.ppm2_min,
|
||||||
|
ppm2_max = EXCLUDED.ppm2_max,
|
||||||
|
n_deals = EXCLUDED.n_deals,
|
||||||
|
refreshed_at = EXCLUDED.refreshed_at;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -0,0 +1,199 @@
|
||||||
|
-- 179_scrape_schedules_seed_oblast_city_sweeps.sql
|
||||||
|
-- Seed rows для oblast-wide city-sweep (Свердловская область, region 66) — B1 rollout:
|
||||||
|
-- avito/cian/yandex city-sweep за пределами Екатеринбурга для 5 городов области.
|
||||||
|
-- Domclick (BFF, city_id-based) — отдельный B2 rollout, сюда НЕ входит.
|
||||||
|
--
|
||||||
|
-- !!! DORMANT BY DESIGN !!! Все 15 строк ship enabled = false.
|
||||||
|
-- Причина: в проксях-пуле сейчас только ОДИН здоровый прокси (см. scrape_proxies,
|
||||||
|
-- 157/173) — параллельный/резкий rollout на 5 новых городов рискует забанить его.
|
||||||
|
-- Capability полностью wired (pipeline.CITY_ANCHORS/get_city_anchors,
|
||||||
|
-- scheduler._job_{avito,cian,yandex}_city_sweep читают default_params->>'city' и
|
||||||
|
-- резолвят anchors города, wildcard-registry "*_city_sweep_*" в
|
||||||
|
-- scraper_kit.orchestration.scheduler._default_kit_handlers), но schedule'ы
|
||||||
|
-- намеренно ВЫКЛЮЧЕНЫ. Оператор включает ВРУЧНУЮ по одному городу за раз:
|
||||||
|
-- UPDATE scrape_schedules SET enabled = true WHERE source = 'avito_city_sweep_nizhniy_tagil';
|
||||||
|
--
|
||||||
|
-- WHY отдельный source на город (а не один source + params.city со списком городов):
|
||||||
|
-- scrape_schedules.source — UNIQUE (052_scrape_schedules.sql). Несколько строк с
|
||||||
|
-- одним source='avito_city_sweep' невозможны (второй INSERT/UPDATE тем же source
|
||||||
|
-- либо конфликтует, либо перезаписывает первую строку — по факту только один город
|
||||||
|
-- был бы шедулирован). Поэтому каждый (source_type, город) — своя строка с source =
|
||||||
|
-- '<source_type>_<city_slug>' (например 'cian_city_sweep_kamensk_uralskiy'), а
|
||||||
|
-- `resolve_handler` в scraper_kit.orchestration.scheduler резолвит её на тот же
|
||||||
|
-- handler, что и EKB source, через wildcard-запись "avito_city_sweep_*" (и т.п.,
|
||||||
|
-- тот же механизм, что уже используется для "deactivate_stale_*"). Job читает
|
||||||
|
-- default_params->>'city' и подставляет anchors города вместо EKB_ANCHORS.
|
||||||
|
--
|
||||||
|
-- default_params: source's текущие прод-дефолты (см. SELECT ниже) + {"city": "<slug>"}.
|
||||||
|
-- Никакие другие поля (radius_m/pages_per_anchor/...) НЕ меняются в этой миграции —
|
||||||
|
-- один anchor на город (pipeline.CITY_ANCHORS) может не покрывать город целиком на
|
||||||
|
-- текущем radius_m (1500м у avito/cian) — ОПЕРАТОР должен пересмотреть radius_m перед
|
||||||
|
-- enable (город крупнее одного 1500м-круга — напр. Н.Тагил/Каменск-Уральский).
|
||||||
|
--
|
||||||
|
-- window_start_hour/window_end_hour (UTC, 1-часовые окна) — расставлены НЕ пересекаясь
|
||||||
|
-- ни друг с другом, ни с уже enabled EKB-окнами (avito 6-7, cian 2-5, yandex 16-17) —
|
||||||
|
-- на случай если оператор включит несколько строк сразу (proxy-pool = 1, конкурентные
|
||||||
|
-- sweep'ы разных источников делили бы тот же прокси).
|
||||||
|
--
|
||||||
|
-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)).
|
||||||
|
-- Idempotent: ON CONFLICT (source) DO NOTHING — каждый source в этой миграции уникален
|
||||||
|
-- по построению (SELECT ниже подтверждает текущие прод-дефолты трёх EKB source'ов,
|
||||||
|
-- НЕ вставляется повторно и не конфликтует с ними — city_sweep_<slug> != city_sweep).
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
INSERT INTO scrape_schedules (
|
||||||
|
source,
|
||||||
|
enabled,
|
||||||
|
window_start_hour,
|
||||||
|
window_end_hour,
|
||||||
|
next_run_at,
|
||||||
|
default_params
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
-- ── avito_city_sweep_<city> (база: radius_m 1500, detail_top_n 20, enrich_houses true,
|
||||||
|
-- pages_per_anchor 3, request_delay_sec 7 — прод-дефолты avito_city_sweep) ──────────
|
||||||
|
(
|
||||||
|
'avito_city_sweep_nizhniy_tagil',
|
||||||
|
false,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 0)) AT TIME ZONE 'UTC',
|
||||||
|
'{"pages_per_anchor": 3, "detail_top_n": 20, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 1500, "city": "nizhniy_tagil"}'::jsonb
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'avito_city_sweep_kamensk_uralskiy',
|
||||||
|
false,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC',
|
||||||
|
'{"pages_per_anchor": 3, "detail_top_n": 20, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 1500, "city": "kamensk_uralskiy"}'::jsonb
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'avito_city_sweep_pervouralsk',
|
||||||
|
false,
|
||||||
|
5,
|
||||||
|
6,
|
||||||
|
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 5)) AT TIME ZONE 'UTC',
|
||||||
|
'{"pages_per_anchor": 3, "detail_top_n": 20, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 1500, "city": "pervouralsk"}'::jsonb
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'avito_city_sweep_verkhnyaya_pyshma',
|
||||||
|
false,
|
||||||
|
7,
|
||||||
|
8,
|
||||||
|
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC',
|
||||||
|
'{"pages_per_anchor": 3, "detail_top_n": 20, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 1500, "city": "verkhnyaya_pyshma"}'::jsonb
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'avito_city_sweep_serov',
|
||||||
|
false,
|
||||||
|
8,
|
||||||
|
9,
|
||||||
|
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 8)) AT TIME ZONE 'UTC',
|
||||||
|
'{"pages_per_anchor": 3, "detail_top_n": 20, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 1500, "city": "serov"}'::jsonb
|
||||||
|
),
|
||||||
|
-- ── cian_city_sweep_<city> (база: radius_m 1500, detail_top_n 10, enrich_houses true,
|
||||||
|
-- pages_per_anchor 3, request_delay_sec 5 — прод-дефолты cian_city_sweep) ─────────
|
||||||
|
(
|
||||||
|
'cian_city_sweep_nizhniy_tagil',
|
||||||
|
false,
|
||||||
|
9,
|
||||||
|
10,
|
||||||
|
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 9)) AT TIME ZONE 'UTC',
|
||||||
|
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 1500, "detail_top_n": 10, "enrich_houses": true, "city": "nizhniy_tagil"}'::jsonb
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'cian_city_sweep_kamensk_uralskiy',
|
||||||
|
false,
|
||||||
|
10,
|
||||||
|
11,
|
||||||
|
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 10)) AT TIME ZONE 'UTC',
|
||||||
|
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 1500, "detail_top_n": 10, "enrich_houses": true, "city": "kamensk_uralskiy"}'::jsonb
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'cian_city_sweep_pervouralsk',
|
||||||
|
false,
|
||||||
|
11,
|
||||||
|
12,
|
||||||
|
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 11)) AT TIME ZONE 'UTC',
|
||||||
|
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 1500, "detail_top_n": 10, "enrich_houses": true, "city": "pervouralsk"}'::jsonb
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'cian_city_sweep_verkhnyaya_pyshma',
|
||||||
|
false,
|
||||||
|
12,
|
||||||
|
13,
|
||||||
|
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 12)) AT TIME ZONE 'UTC',
|
||||||
|
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 1500, "detail_top_n": 10, "enrich_houses": true, "city": "verkhnyaya_pyshma"}'::jsonb
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'cian_city_sweep_serov',
|
||||||
|
false,
|
||||||
|
13,
|
||||||
|
14,
|
||||||
|
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 13)) AT TIME ZONE 'UTC',
|
||||||
|
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 1500, "detail_top_n": 10, "enrich_houses": true, "city": "serov"}'::jsonb
|
||||||
|
),
|
||||||
|
-- ── yandex_city_sweep_<city> (база: radius_m 25000, pages_per_anchor 3,
|
||||||
|
-- request_delay_sec 9 — прод-дефолты yandex_city_sweep; radius_m большой ok —
|
||||||
|
-- per 119_yandex_city_sweep_center_combos.sql это лишь fallback-фильтр, не
|
||||||
|
-- query-ограничение, лишний охват не увеличивает load) ────────────────────────
|
||||||
|
(
|
||||||
|
'yandex_city_sweep_nizhniy_tagil',
|
||||||
|
false,
|
||||||
|
14,
|
||||||
|
15,
|
||||||
|
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 14)) AT TIME ZONE 'UTC',
|
||||||
|
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "city": "nizhniy_tagil"}'::jsonb
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'yandex_city_sweep_kamensk_uralskiy',
|
||||||
|
false,
|
||||||
|
15,
|
||||||
|
16,
|
||||||
|
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 15)) AT TIME ZONE 'UTC',
|
||||||
|
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "city": "kamensk_uralskiy"}'::jsonb
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'yandex_city_sweep_pervouralsk',
|
||||||
|
false,
|
||||||
|
17,
|
||||||
|
18,
|
||||||
|
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 17)) AT TIME ZONE 'UTC',
|
||||||
|
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "city": "pervouralsk"}'::jsonb
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'yandex_city_sweep_verkhnyaya_pyshma',
|
||||||
|
false,
|
||||||
|
18,
|
||||||
|
19,
|
||||||
|
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 18)) AT TIME ZONE 'UTC',
|
||||||
|
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "city": "verkhnyaya_pyshma"}'::jsonb
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'yandex_city_sweep_serov',
|
||||||
|
false,
|
||||||
|
19,
|
||||||
|
20,
|
||||||
|
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 19)) AT TIME ZONE 'UTC',
|
||||||
|
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "city": "serov"}'::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, cian_city_sweep (EKB, enabled), '
|
||||||
|
'avito_full_load, avito_full_load_exhaustive, cian_full_load, domclick_city_sweep, '
|
||||||
|
'avito_city_sweep_<city>/cian_city_sweep_<city>/yandex_city_sweep_<city> '
|
||||||
|
'(oblast B1 rollout — nizhniy_tagil/kamensk_uralskiy/pervouralsk/'
|
||||||
|
'verkhnyaya_pyshma/serov, dormant, #179 — resolve_handler wildcard "*_city_sweep_*"), '
|
||||||
|
'cian_history_backfill, rosreestr_dkp_import, listing_source_snapshot, '
|
||||||
|
'asking_to_sold_ratio_refresh, refresh_search_matview, yandex_address_backfill, '
|
||||||
|
'sber_index_pull, rosreestr_quarter_poll, deals_freshness_monitor, newbuilding_enrich, '
|
||||||
|
'yandex_newbuilding_sweep, geoportal_coords_backfill, geocode_missing_listings, '
|
||||||
|
'avito_detail_backfill, yandex_detail_backfill, cadastral_geo_match, '
|
||||||
|
'osm_poi_ekb_refresh, house_imv_backfill, house_dedup_merge, proxy_healthcheck, '
|
||||||
|
'deactivate_stale_avito/yandex/cian.';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
-- 180_seed_sber_freshness_monitor.sql
|
||||||
|
-- Audit п.1 — seed scrape_schedules row for the daily СберИндекс data-freshness monitor.
|
||||||
|
--
|
||||||
|
-- Context (verified on prod 2026-07-12):
|
||||||
|
-- estimator._load_sber_index_series (#794/#audit-5a) applies a СберИндекс time-adjustment
|
||||||
|
-- to ДКП deals and only LOGS a per-estimate warning when the latest series month is older
|
||||||
|
-- than settings.sber_index_max_age_days (35d). The `sber_index_pull` job runs monthly
|
||||||
|
-- (enabled), but СберИндекс publishes with an inherent ~1-2 month lag, so
|
||||||
|
-- sber_price_index.period_month drifts. Current max(period_month) for the oblast secondary
|
||||||
|
-- segment = 2026-05-01 (~72d old). NOT a silent failure, but the staleness is buried in
|
||||||
|
-- per-estimate warnings — invisible at monitor cadence.
|
||||||
|
--
|
||||||
|
-- Audit gap: ops has no monitor-frequency signal for СберИндекс staleness. This monitor
|
||||||
|
-- watches max(period_month) directly and raises a per-day WARNING when the data is stale
|
||||||
|
-- beyond the allowed publication lag.
|
||||||
|
--
|
||||||
|
-- Staleness threshold (app/tasks/sber_freshness_monitor.py::evaluate_sber_freshness):
|
||||||
|
-- age_days = now() - max(period_month)
|
||||||
|
-- max_age_days = settings.sber_index_max_age_days (35) + lag_allowance_days (25) = 60
|
||||||
|
-- stale = age_days > max_age_days
|
||||||
|
-- The +25 lag_allowance covers the INHERENT СберИндекс publication lag (source is 1-2mo
|
||||||
|
-- behind; period_month is the first-of-month label; the monthly pull has not yet caught the
|
||||||
|
-- newest period). Below 60d the latest period is treated as normally-lagging -> NO alert
|
||||||
|
-- (avoids daily noise on штатный lag). Above 60d the data is stuck beyond ~2 months -> alert.
|
||||||
|
-- At 2026-07-12: max=2026-05-01, age=72d > 60 -> alert=1 (surfaces the current drift).
|
||||||
|
--
|
||||||
|
-- Components deployed together:
|
||||||
|
-- 1. app/tasks/sber_freshness_monitor.py — check_sber_freshness() + evaluate_sber_freshness()
|
||||||
|
-- 2. app/services/product_handlers.py — kit Handler 'sber_freshness_monitor'
|
||||||
|
-- 3. This migration — seeds the scrape_schedules row (enabled=true, daily window)
|
||||||
|
--
|
||||||
|
-- Schedule window 09:00-10:00 UTC:
|
||||||
|
-- Daily cadence, placed AFTER deals_freshness_monitor (08:00-09:00 UTC) so the two
|
||||||
|
-- data-freshness monitors do not overlap. Cheap: one SELECT max(period_month), no external
|
||||||
|
-- HTTP — safe to run daily even though sber_index_pull itself is monthly.
|
||||||
|
--
|
||||||
|
-- default_params.lag_allowance_days = 25:
|
||||||
|
-- Allowance for СберИндекс publication lag on top of the 35d per-estimate guard (-> 60d).
|
||||||
|
--
|
||||||
|
-- next_run_at bootstrapped to tomorrow 09:00 UTC — scheduler will not fire immediately
|
||||||
|
-- on deploy (same pattern as 162/093/096/160).
|
||||||
|
--
|
||||||
|
-- Idempotent: ON CONFLICT (source) DO NOTHING — safe to re-apply.
|
||||||
|
--
|
||||||
|
-- Dependencies:
|
||||||
|
-- 052_scrape_schedules.sql (table + UNIQUE(source))
|
||||||
|
-- sber_price_index table (populated by app.tasks.sber_index_pull)
|
||||||
|
-- app/services/product_handlers.py + tasks/sber_freshness_monitor.py deployed
|
||||||
|
--
|
||||||
|
-- Deploy order:
|
||||||
|
-- Apply after deploying the product_handlers.py + task changes so the dispatch can resolve
|
||||||
|
-- 'sber_freshness_monitor' correctly on first fire.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
INSERT INTO scrape_schedules (
|
||||||
|
source,
|
||||||
|
enabled,
|
||||||
|
window_start_hour,
|
||||||
|
window_end_hour,
|
||||||
|
next_run_at,
|
||||||
|
default_params
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
'sber_freshness_monitor',
|
||||||
|
true, -- SAFE: single SELECT max(period_month), no ext calls
|
||||||
|
9, -- window 09:00-10:00 UTC (after deals_freshness_monitor)
|
||||||
|
10,
|
||||||
|
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 9)) AT TIME ZONE 'UTC',
|
||||||
|
'{"lag_allowance_days": 25}'::jsonb -- publication-lag allowance over the 35d per-estimate guard
|
||||||
|
)
|
||||||
|
ON CONFLICT (source) DO NOTHING;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
16
tradein-mvp/backend/data/sql/181_clamp_bad_listing_dates.sql
Normal file
16
tradein-mvp/backend/data/sql/181_clamp_bad_listing_dates.sql
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
-- 181_clamp_bad_listing_dates.sql
|
||||||
|
-- LOW audit R2 (#6): null out implausible listing_date/publish_date left by scrapers
|
||||||
|
-- before the clamp guard landed (Avito 1970-epoch: ~698 rows; near-future: ~79 rows).
|
||||||
|
-- Idempotent: re-running is a no-op once values are already NULL / in-window.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
UPDATE listings SET listing_date = NULL
|
||||||
|
WHERE listing_date IS NOT NULL
|
||||||
|
AND (listing_date < DATE '2010-01-01' OR listing_date > CURRENT_DATE + 2);
|
||||||
|
|
||||||
|
UPDATE listings SET publish_date = NULL
|
||||||
|
WHERE publish_date IS NOT NULL
|
||||||
|
AND (publish_date < DATE '2010-01-01' OR publish_date > CURRENT_DATE + 2);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
-- 182_trade_in_leads_consent_proof.sql
|
||||||
|
-- LOW audit R2 (#9): durable 152-ФЗ proof-of-consent for trade_in_leads.
|
||||||
|
--
|
||||||
|
-- WHY:
|
||||||
|
-- #2497 already computes client_ip (X-Forwarded-For, fallback direct peer)
|
||||||
|
-- and pins _CONSENT_POLICY_VERSION = "2026-07" in app/api/v1/lead.py, but
|
||||||
|
-- both were written ONLY to the audit log — trade_in_leads had no columns
|
||||||
|
-- to hold them (explicit TODO at the INSERT in lead.py). A bare boolean
|
||||||
|
-- `consent` column plus a log line is not durable proof: logs rotate/expire,
|
||||||
|
-- the row itself carries no evidence of what was agreed to, under what
|
||||||
|
-- policy version, or from what IP.
|
||||||
|
--
|
||||||
|
-- WHAT:
|
||||||
|
-- Three nullable columns on trade_in_leads:
|
||||||
|
-- - client_ip (inet) — same value already computed in
|
||||||
|
-- create_trade_in_lead, now persisted
|
||||||
|
-- per-row instead of log-only.
|
||||||
|
-- - consent_policy_version (text) — snapshot of _CONSENT_POLICY_VERSION
|
||||||
|
-- at the time this lead was captured.
|
||||||
|
-- - consent_text_snapshot (text) — snapshot of the exact consent sentence
|
||||||
|
-- shown to the user (_CONSENT_TEXT_SNAPSHOT
|
||||||
|
-- in lead.py, same PR).
|
||||||
|
--
|
||||||
|
-- IDEMPOTENCY / SAFETY:
|
||||||
|
-- ADD COLUMN IF NOT EXISTS x3 — safe re-run. All nullable, no DEFAULT, no
|
||||||
|
-- backfill: existing rows keep NULL (no consent proof was captured for them
|
||||||
|
-- pre-migration — that's an honest reflection of what actually happened,
|
||||||
|
-- not a schema gap). Purely additive: no existing reads/writes break.
|
||||||
|
--
|
||||||
|
-- Dependencies: 172_trade_in_leads.sql (trade_in_leads table).
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE trade_in_leads
|
||||||
|
ADD COLUMN IF NOT EXISTS client_ip inet,
|
||||||
|
ADD COLUMN IF NOT EXISTS consent_policy_version text,
|
||||||
|
ADD COLUMN IF NOT EXISTS consent_text_snapshot text;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN trade_in_leads.client_ip IS '152-ФЗ proof-of-consent: клиентский IP на момент согласия (ранее только в audit-логе, #2497 TODO).';
|
||||||
|
COMMENT ON COLUMN trade_in_leads.consent_policy_version IS '152-ФЗ proof-of-consent: снимок _CONSENT_POLICY_VERSION на момент согласия.';
|
||||||
|
COMMENT ON COLUMN trade_in_leads.consent_text_snapshot IS '152-ФЗ proof-of-consent: снимок текста согласия, показанного пользователю (_CONSENT_TEXT_SNAPSHOT).';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
-- 183_reenable_deactivate_stale_domklik.sql
|
||||||
|
--
|
||||||
|
-- ПРОДУКТОВОЕ РЕШЕНИЕ (user, 2026-07-13): ВКЛЮЧАЕМ обратно TTL-деактивацию
|
||||||
|
-- deactivate_stale_domklik (была отключена миграцией 163 «до возобновления
|
||||||
|
-- регулярных domclick-свипов»).
|
||||||
|
--
|
||||||
|
-- Условие из 163 снято: свипы domclick_city_sweep + domclick_detail_backfill
|
||||||
|
-- возобновлены и стабильно бегают ежедневно (scraped_at свежий, диапазон
|
||||||
|
-- последней недели), т.е. причина отключения больше не действует.
|
||||||
|
--
|
||||||
|
-- Безопасность re-enable (LOW audit R2 #10):
|
||||||
|
-- • Джоб ключуется на scraped_at (whitelist last_seen_at/scraped_at в
|
||||||
|
-- deactivate_stale_avito.py::_ALLOWED_STALENESS_COLUMNS), НЕ на listing_date —
|
||||||
|
-- исходная гипотеза «выпадет весь domklik по NULL listing_date» неприменима.
|
||||||
|
-- • При живых свипах scraped_at < NOW()-14d только у реально протухших строк,
|
||||||
|
-- а не у всего пула (в отличие от ситуации 163, где свипы стояли) —
|
||||||
|
-- деактивация корректна, а не тотальна. Чинит завышенный freshness domklik.
|
||||||
|
--
|
||||||
|
-- Идемпотентно: повторный прогон — no-op (enabled уже true). Row создаётся
|
||||||
|
-- миграцией 160; если отсутствует — UPDATE затронет 0 строк (безопасно).
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
UPDATE scrape_schedules
|
||||||
|
SET enabled = true
|
||||||
|
WHERE source = 'deactivate_stale_domklik';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
60
tradein-mvp/backend/data/sql/184_user_events.sql
Normal file
60
tradein-mvp/backend/data/sql/184_user_events.sql
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
-- 184_user_events.sql
|
||||||
|
-- Foundation schema for Features 2 & 3: unified user-event tracking.
|
||||||
|
--
|
||||||
|
-- WHY:
|
||||||
|
-- trade-in had no durable event log for login/IP audit or behavior
|
||||||
|
-- analytics. A near-identical design already existed once — `audit_log`
|
||||||
|
-- (event_type, ip_address inet, user_agent, estimate_id uuid, payload
|
||||||
|
-- jsonb, created_at) was defined in 002_core_tables.sql and DROPPED in
|
||||||
|
-- 095_dead_schema.sql as unused dead schema at the time. Product now
|
||||||
|
-- needs exactly that shape again, so this migration resurrects the
|
||||||
|
-- design under a new name, `user_events`, as the ONE unified
|
||||||
|
-- append-only table serving:
|
||||||
|
-- - login-audit (who logged in, from what IP/UA, when)
|
||||||
|
-- - search-audit (estimate requests, listing lookups)
|
||||||
|
-- - behavior-analytics (pdf_download, listing_click, page_view,
|
||||||
|
-- drawer_open, and future event_type values)
|
||||||
|
--
|
||||||
|
-- WHAT:
|
||||||
|
-- `user_events` — append-only, admin-read-only. No 152-ФЗ consent
|
||||||
|
-- gating and no mandatory retention policy for MVP (product-owner
|
||||||
|
-- decision): we log IP/UA/path/method/payload unconditionally for every
|
||||||
|
-- tracked event. `estimate_id` is a plain uuid column with NO FK
|
||||||
|
-- constraint on purpose — this keeps the log decoupled/append-only so
|
||||||
|
-- estimate deletion (or any future estimate lifecycle change) never
|
||||||
|
-- blocks or cascades into event rows.
|
||||||
|
--
|
||||||
|
-- IDEMPOTENCY / SAFETY:
|
||||||
|
-- CREATE TABLE IF NOT EXISTS + CREATE INDEX IF NOT EXISTS throughout —
|
||||||
|
-- safe re-run. Purely additive: no existing table/view/column is
|
||||||
|
-- touched.
|
||||||
|
--
|
||||||
|
-- Dependencies: none (new standalone table). Auto-applied on deploy via
|
||||||
|
-- _schema_migrations tracking (tradein-mvp/backend/data/sql convention).
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_events (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
event_type text NOT NULL,
|
||||||
|
username text NOT NULL,
|
||||||
|
ip_address inet,
|
||||||
|
user_agent text,
|
||||||
|
path text,
|
||||||
|
method text,
|
||||||
|
estimate_id uuid,
|
||||||
|
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE user_events IS 'Unified append-only event log (login/IP audit + behavior analytics), admin-read-only. Resurrects the design of audit_log (002_core_tables.sql, dropped in 095_dead_schema.sql).';
|
||||||
|
COMMENT ON COLUMN user_events.event_type IS 'e.g. login / estimate_request / pdf_download / listing_click / page_view / drawer_open.';
|
||||||
|
COMMENT ON COLUMN user_events.username IS 'X-Authenticated-User value at the time of the event.';
|
||||||
|
COMMENT ON COLUMN user_events.estimate_id IS 'No FK constraint by design — keeps the log decoupled/append-only from trade_in_estimates lifecycle.';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS user_events_username_created_at_idx ON user_events (username, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS user_events_event_type_created_at_idx ON user_events (event_type, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS user_events_ip_address_idx ON user_events (ip_address);
|
||||||
|
CREATE INDEX IF NOT EXISTS user_events_created_at_idx ON user_events (created_at DESC);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
59
tradein-mvp/backend/data/sql/185_account_quota_overrides.sql
Normal file
59
tradein-mvp/backend/data/sql/185_account_quota_overrides.sql
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
-- Migration 185: account_quota_overrides — персональные месячные лимиты оценок
|
||||||
|
--
|
||||||
|
-- WHY:
|
||||||
|
-- Бонусные попытки для платящих пилотов раздавались хаком через SQL-runbook:
|
||||||
|
-- `UPDATE account_estimate_usage SET used = used - N` (negative used). Для user2
|
||||||
|
-- (Брусника) это дало used=-35 при глобальном limit=15 → GET /quota отдавал
|
||||||
|
-- {limit:15, used:-35, remaining:50} → фронт (EstimateForm.tsx) рендерил абсурд
|
||||||
|
-- «Осталось 50 из 15». Правильное решение — персональный лимит, а не отрицательный
|
||||||
|
-- счётчик использования.
|
||||||
|
--
|
||||||
|
-- WHAT:
|
||||||
|
-- 1. Таблица account_quota_overrides(username PK, monthly_limit, note, updated_at) —
|
||||||
|
-- per-user override глобального settings.estimate_quota_limit. Читается через
|
||||||
|
-- app.services.account_quota.user_limit().
|
||||||
|
-- 2. Seed: user2 (Брусника) → monthly_limit=50, пилотный грант 2026-07-13.
|
||||||
|
-- 3. Сброс хака: account_estimate_usage.used < 0 → 0 (для user2, единственного
|
||||||
|
-- затронутого аккаунта). После этой миграции user2: limit=50, used=0,
|
||||||
|
-- remaining=50 — корректно вместо «50 из 15».
|
||||||
|
--
|
||||||
|
-- IDEMPOTENCY:
|
||||||
|
-- CREATE TABLE IF NOT EXISTS + INSERT ... ON CONFLICT DO UPDATE (upsert, безопасен
|
||||||
|
-- при повторном применении) + UPDATE ... WHERE used < 0 (после первого прогона
|
||||||
|
-- used=0, условие больше не матчит — безопасный no-op при ре-apply).
|
||||||
|
--
|
||||||
|
-- Dependencies: 076_account_estimate_quota.sql (account_estimate_usage).
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS account_quota_overrides (
|
||||||
|
username text PRIMARY KEY,
|
||||||
|
monthly_limit integer NOT NULL,
|
||||||
|
note text,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE account_quota_overrides IS
|
||||||
|
'Персональный override месячного лимита оценок trade-in (вместо глобального '
|
||||||
|
'settings.estimate_quota_limit). Читается app.services.account_quota.user_limit().';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN account_quota_overrides.monthly_limit IS
|
||||||
|
'Персональный лимит успешных оценок в календарный месяц для username.';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN account_quota_overrides.note IS
|
||||||
|
'Причина override (grant / pilot / manual bump) — для аудита ручных изменений.';
|
||||||
|
|
||||||
|
INSERT INTO account_quota_overrides (username, monthly_limit, note)
|
||||||
|
VALUES ('user2', 50, 'Брусника — пилот, грант 2026-07-13')
|
||||||
|
ON CONFLICT (username) DO UPDATE SET
|
||||||
|
monthly_limit = EXCLUDED.monthly_limit,
|
||||||
|
note = EXCLUDED.note,
|
||||||
|
updated_at = now();
|
||||||
|
|
||||||
|
-- Сбрасываем прежний хак бонусных попыток (negative used), который ломал /quota:
|
||||||
|
-- limit=15, used=-35 → remaining=50 → фронт рендерил «Осталось 50 из 15».
|
||||||
|
UPDATE account_estimate_usage
|
||||||
|
SET used = 0, updated_at = now()
|
||||||
|
WHERE username = 'user2' AND used < 0;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
93
tradein-mvp/backend/data/sql/186_tg_support.sql
Normal file
93
tradein-mvp/backend/data/sql/186_tg_support.sql
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
-- 186_tg_support.sql
|
||||||
|
-- Telegram support bridge: @MERAsupport_bot mirrors client DMs into a
|
||||||
|
-- support-group topic; an operator replies in-thread; the bot relays the
|
||||||
|
-- reply back to the client's private chat.
|
||||||
|
--
|
||||||
|
-- WHY:
|
||||||
|
-- No durable state existed for this flow. Two things are required to make
|
||||||
|
-- it work reliably:
|
||||||
|
-- 1. A mapping from "message mirrored into the support topic" back to
|
||||||
|
-- "which client chat_id it came from" — this is how an operator's
|
||||||
|
-- reply (a Telegram reply-to a topic message) gets routed to the
|
||||||
|
-- right client. `topic_message_id` on tg_support_messages is that
|
||||||
|
-- routing key.
|
||||||
|
-- 2. A durable long-polling offset (`tg_support_state`) so a worker
|
||||||
|
-- restart does not replay already-processed Telegram updates.
|
||||||
|
--
|
||||||
|
-- WHAT:
|
||||||
|
-- - tg_support_users — one row per client Telegram private chat
|
||||||
|
-- (chat_id is the Telegram chat id, stable per
|
||||||
|
-- client, used directly as PK — no surrogate key
|
||||||
|
-- needed).
|
||||||
|
-- - tg_support_messages — full conversation log, both directions.
|
||||||
|
-- - tg_support_state — singleton key/value store for worker offsets
|
||||||
|
-- (e.g. key='last_update_id').
|
||||||
|
--
|
||||||
|
-- 152-FZ:
|
||||||
|
-- tg_support_users / tg_support_messages hold personal data (Telegram
|
||||||
|
-- username/name + free-text conversation content). ON DELETE CASCADE from
|
||||||
|
-- tg_support_users -> tg_support_messages makes client erasure a single
|
||||||
|
-- `DELETE FROM tg_support_users WHERE chat_id = :chat_id` statement, no
|
||||||
|
-- separate cleanup pass needed.
|
||||||
|
--
|
||||||
|
-- IDEMPOTENCY / SAFETY:
|
||||||
|
-- CREATE TABLE IF NOT EXISTS + CREATE INDEX IF NOT EXISTS throughout —
|
||||||
|
-- safe re-run. Purely additive: no existing table/view/column touched.
|
||||||
|
--
|
||||||
|
-- Dependencies: none (new standalone tables). Auto-applied on deploy via
|
||||||
|
-- _schema_migrations tracking (tradein-mvp/backend/data/sql convention).
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tg_support_users (
|
||||||
|
chat_id bigint PRIMARY KEY,
|
||||||
|
username text,
|
||||||
|
first_name text,
|
||||||
|
last_name text,
|
||||||
|
language_code text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
last_seen_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
is_blocked boolean NOT NULL DEFAULT false
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE tg_support_users IS '152-ФЗ: ПДн клиентов Telegram-поддержки (@MERAsupport_bot). Удаление клиента — DELETE FROM tg_support_users WHERE chat_id=...; ON DELETE CASCADE в tg_support_messages подчищает переписку одной операцией.';
|
||||||
|
COMMENT ON COLUMN tg_support_users.chat_id IS 'Telegram private chat id клиента (стабильный, используется как PK напрямую).';
|
||||||
|
COMMENT ON COLUMN tg_support_users.is_blocked IS 'true, если клиент заблокировал бота (Telegram 403 на отправку) — бот перестаёт пытаться слать сообщения.';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tg_support_messages (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
chat_id bigint NOT NULL REFERENCES tg_support_users (chat_id) ON DELETE CASCADE,
|
||||||
|
direction text NOT NULL CHECK (direction IN ('in', 'out')),
|
||||||
|
tg_message_id bigint,
|
||||||
|
topic_message_id bigint,
|
||||||
|
kind text NOT NULL,
|
||||||
|
text_body text,
|
||||||
|
operator_tg_id bigint,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE tg_support_messages IS '152-ФЗ: полный лог переписки Telegram-поддержки (ПДн, содержимое сообщений). Каскадно удаляется вместе с tg_support_users по chat_id.';
|
||||||
|
COMMENT ON COLUMN tg_support_messages.direction IS '''in'' — сообщение от клиента боту; ''out'' — ответ бота/оператора клиенту.';
|
||||||
|
COMMENT ON COLUMN tg_support_messages.tg_message_id IS 'id сообщения в личном чате с клиентом (Telegram message_id в chat_id).';
|
||||||
|
COMMENT ON COLUMN tg_support_messages.topic_message_id IS 'id зеркала сообщения в support-топике группы — ключ маршрутизации: реплай оператора на это сообщение адресуется данному chat_id.';
|
||||||
|
COMMENT ON COLUMN tg_support_messages.kind IS 'text | photo | document | video | voice | other.';
|
||||||
|
COMMENT ON COLUMN tg_support_messages.text_body IS 'Текст сообщения или caption медиа; NULL для медиа без подписи.';
|
||||||
|
COMMENT ON COLUMN tg_support_messages.operator_tg_id IS 'Telegram user id оператора, ответившего в топике; заполняется только для direction=''out''.';
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS tg_support_messages_topic_message_id_uq
|
||||||
|
ON tg_support_messages (topic_message_id)
|
||||||
|
WHERE topic_message_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS tg_support_messages_chat_id_created_at_idx
|
||||||
|
ON tg_support_messages (chat_id, created_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tg_support_state (
|
||||||
|
key text PRIMARY KEY,
|
||||||
|
value text NOT NULL,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE tg_support_state IS 'Singleton key/value store для состояния Telegram-поддержки (например last_update_id для long-polling), переживает рестарт воркера.';
|
||||||
|
COMMENT ON COLUMN tg_support_state.key IS 'e.g. ''last_update_id''.';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -86,6 +86,9 @@ USAGE
|
||||||
|
|
||||||
# machine-readable:
|
# machine-readable:
|
||||||
python -m scripts.backtest_estimator --json
|
python -m scripts.backtest_estimator --json
|
||||||
|
|
||||||
|
# oblast D: per-city validation (exact deals.city name, not a slug):
|
||||||
|
python -m scripts.backtest_estimator --city "Нижний Тагил" --sample 300
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -181,6 +184,14 @@ logger = logging.getLogger("backtest_estimator")
|
||||||
# Price-per-m² sanity band — shared by the deal sample and the listings
|
# Price-per-m² sanity band — shared by the deal sample and the listings
|
||||||
# subquery. Mirrors the estimator's working range for EKB вторичка and drops
|
# subquery. Mirrors the estimator's working range for EKB вторичка and drops
|
||||||
# obvious data-entry garbage / commercial outliers.
|
# obvious data-entry garbage / commercial outliers.
|
||||||
|
# (oblast D) When --city is set, _load_sample sources a PER-CITY band from
|
||||||
|
# deal_city_price_bands instead (see _resolve_city_ppm2_band) — these globals
|
||||||
|
# remain the fallback (default/unscoped sample, and any city with no band row,
|
||||||
|
# e.g. Екатеринбург — intentionally excluded from that table).
|
||||||
|
# NB: the listings subquery (_CANDIDATES_SQL, asking-core engine only) still
|
||||||
|
# always uses these globals — left global deliberately (not city-scoped) to
|
||||||
|
# keep the change minimal; the asking-core engine is the legacy comparison
|
||||||
|
# path, not the primary oblast-D target (the full engine, default).
|
||||||
PPM2_MIN = 30_000
|
PPM2_MIN = 30_000
|
||||||
PPM2_MAX = 600_000
|
PPM2_MAX = 600_000
|
||||||
|
|
||||||
|
|
@ -1043,22 +1054,115 @@ _CANDIDATES_SQL = text(
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# (oblast D) Per-city PPM2 sanity band — same table the estimator's
|
||||||
|
# _fetch_dkp_corridor COALESCEs against (migration 178). Looked up only when
|
||||||
|
# --city is set (see _resolve_city_ppm2_band); the default (city=None) path
|
||||||
|
# never issues this query.
|
||||||
|
_CITY_PPM2_BAND_SQL = text(
|
||||||
|
"""
|
||||||
|
SELECT ppm2_min, ppm2_max
|
||||||
|
FROM deal_city_price_bands
|
||||||
|
WHERE city = CAST(:city AS text)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
def _load_sample(db: Session, *, sample: int, since: str) -> list[DealSample]:
|
|
||||||
"""Run the held-out ДКП deal sampling SELECT → list[DealSample]."""
|
def _sample_sql(city: str | None) -> Any:
|
||||||
rows = (
|
"""ДКП deal-sample SELECT — optionally scoped to one ``deals.city`` (oblast D).
|
||||||
db.execute(
|
|
||||||
_SAMPLE_SQL,
|
``city is None`` (default) returns the SAME ``_SAMPLE_SQL`` object used
|
||||||
{
|
before this change — literal identity, not just equal text — so the
|
||||||
"ppm2_min": PPM2_MIN,
|
default CLI invocation (and the frozen EKB regression gate, which never
|
||||||
"ppm2_max": PPM2_MAX,
|
calls this path at all) see byte-identical SQL.
|
||||||
"since": since,
|
|
||||||
"sample": sample,
|
When ``city`` is set, an extra ``AND city = CAST(:city AS text)`` predicate
|
||||||
},
|
scopes the sample to that ``deals.city`` value — populated oblast-wide by
|
||||||
)
|
the #C1 migration (368 cities, 96,974 deals). Pass the EXACT canonical
|
||||||
.mappings()
|
Russian name as stored in ``deals.city`` (e.g. ``'Нижний Тагил'``), not a
|
||||||
.all()
|
slug — see the ``--city`` CLI help for the naming decision.
|
||||||
|
"""
|
||||||
|
if city is None:
|
||||||
|
return _SAMPLE_SQL
|
||||||
|
return text(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
ST_X(geom::geometry) AS lon,
|
||||||
|
ST_Y(geom::geometry) AS lat,
|
||||||
|
rooms,
|
||||||
|
price_per_m2 AS sold_ppm2,
|
||||||
|
deal_date,
|
||||||
|
area_m2,
|
||||||
|
address,
|
||||||
|
floor,
|
||||||
|
total_floors,
|
||||||
|
year_built,
|
||||||
|
house_type
|
||||||
|
FROM deals
|
||||||
|
WHERE source = 'rosreestr'
|
||||||
|
AND geom IS NOT NULL
|
||||||
|
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
|
||||||
|
AND rooms IS NOT NULL
|
||||||
|
AND area_m2 IS NOT NULL
|
||||||
|
AND area_m2 > 0
|
||||||
|
AND deal_date >= CAST(:since AS date)
|
||||||
|
AND city = CAST(:city AS text)
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT CAST(:sample AS integer)
|
||||||
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_city_ppm2_band(db: Session, city: str | None) -> tuple[float, float]:
|
||||||
|
"""Per-city PPM2 sanity band (oblast D) — falls back to the module globals.
|
||||||
|
|
||||||
|
``city is None`` → ``(PPM2_MIN, PPM2_MAX)``, unchanged default behaviour,
|
||||||
|
NO extra query. Otherwise looks up ``deal_city_price_bands`` for that
|
||||||
|
city's own ``[ppm2_min, ppm2_max]`` — a region-66 town can have a
|
||||||
|
materially different sane ₽/m² range than the EKB-tuned 30k..600k globals
|
||||||
|
(e.g. Нижний Тагил: 16 955..108 175). No row for the city (e.g.
|
||||||
|
Екатеринбург — intentionally excluded from the table, mirrors
|
||||||
|
estimator._fetch_dkp_corridor's own COALESCE fallback) or any DB error →
|
||||||
|
the globals; read-only best-effort, never raises.
|
||||||
|
"""
|
||||||
|
if city is None:
|
||||||
|
return float(PPM2_MIN), float(PPM2_MAX)
|
||||||
|
try:
|
||||||
|
row = db.execute(_CITY_PPM2_BAND_SQL, {"city": city}).mappings().first()
|
||||||
|
except Exception as exc: # pragma: no cover — defensive, read-only best-effort
|
||||||
|
logger.warning("city PPM2 band lookup failed for %r (fallback to global): %s", city, exc)
|
||||||
|
return float(PPM2_MIN), float(PPM2_MAX)
|
||||||
|
if row is None or row["ppm2_min"] is None or row["ppm2_max"] is None:
|
||||||
|
return float(PPM2_MIN), float(PPM2_MAX)
|
||||||
|
return float(row["ppm2_min"]), float(row["ppm2_max"])
|
||||||
|
|
||||||
|
|
||||||
|
def _load_sample(
|
||||||
|
db: Session, *, sample: int, since: str, city: str | None = None
|
||||||
|
) -> list[DealSample]:
|
||||||
|
"""Run the held-out ДКП deal sampling SELECT → list[DealSample].
|
||||||
|
|
||||||
|
``city`` (oblast D, default None) scopes the sample to one ``deals.city``
|
||||||
|
value via ``_sample_sql`` and sources the PPM2 sanity band from
|
||||||
|
``deal_city_price_bands`` for that city (``_resolve_city_ppm2_band``,
|
||||||
|
falls back to the module globals). Default None is byte-identical to the
|
||||||
|
pre-oblast-D behaviour: same SQL object, same PPM2_MIN/PPM2_MAX globals,
|
||||||
|
no extra query.
|
||||||
|
"""
|
||||||
|
if city is None:
|
||||||
|
ppm2_min: float = PPM2_MIN
|
||||||
|
ppm2_max: float = PPM2_MAX
|
||||||
|
else:
|
||||||
|
ppm2_min, ppm2_max = _resolve_city_ppm2_band(db, city)
|
||||||
|
params: dict[str, Any] = {
|
||||||
|
"ppm2_min": ppm2_min,
|
||||||
|
"ppm2_max": ppm2_max,
|
||||||
|
"since": since,
|
||||||
|
"sample": sample,
|
||||||
|
}
|
||||||
|
if city is not None:
|
||||||
|
params["city"] = city
|
||||||
|
rows = db.execute(_sample_sql(city), params).mappings().all()
|
||||||
out: list[DealSample] = []
|
out: list[DealSample] = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
if r["lon"] is None or r["lat"] is None or r["sold_ppm2"] is None:
|
if r["lon"] is None or r["lat"] is None or r["sold_ppm2"] is None:
|
||||||
|
|
@ -1516,11 +1620,22 @@ def _predict_full_spine(
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Pre-fetch the spine inputs (same calls estimate_quality hoists) ───────
|
# ── Pre-fetch the spine inputs (same calls estimate_quality hoists) ───────
|
||||||
dkp_raw = m._fetch_dkp_corridor(db, address=deal.address, rooms=deal.rooms, area=deal.area_m2)
|
# (oblast C2 parity): estimate_quality now resolves a target city and passes
|
||||||
|
# it to _fetch_dkp_corridor (estimator.py:3210-3220) — deals.city is
|
||||||
|
# populated oblast-wide (368 cities), and an unscoped corridor lets
|
||||||
|
# same-named streets in OTHER region-66 towns (e.g. "Ленина" exists in many)
|
||||||
|
# contaminate the corridor. Mirror that resolve+pass here UNCONDITIONALLY
|
||||||
|
# (independent of --city, which only scopes WHICH deals get sampled) so this
|
||||||
|
# harness measures the SAME corridor prod actually computes today, not the
|
||||||
|
# pre-C2 unscoped behaviour — else the backtest validates stale semantics.
|
||||||
|
target_city = m._resolve_target_city(deal.address)
|
||||||
|
dkp_raw = m._fetch_dkp_corridor(
|
||||||
|
db, address=deal.address, rooms=deal.rooms, area=deal.area_m2, city=target_city
|
||||||
|
)
|
||||||
# #1966 prod parity: same-building anchor pre-fetch is GATED exactly like
|
# #1966 prod parity: same-building anchor pre-fetch is GATED exactly like
|
||||||
# estimate_quality (estimator.py L2862-2881) — disabled / no-area / no-address
|
# estimate_quality — no-area / no-address → ([], None) instead of an
|
||||||
# → ([], None) instead of an unconditional fetch.
|
# unconditional fetch.
|
||||||
if settings.estimate_same_building_anchor_enabled and deal.area_m2 and deal.address:
|
if deal.area_m2 and deal.address:
|
||||||
# NB(#2002): kept at None deliberately — the deliverable scopes the
|
# NB(#2002): kept at None deliberately — the deliverable scopes the
|
||||||
# resolved-id threading to the Tier-S analog ladder + the house IMV anchor.
|
# resolved-id threading to the Tier-S analog ladder + the house IMV anchor.
|
||||||
# Prod ALSO resolves this same-building anchor (estimator.py:2782); flip to
|
# Prod ALSO resolves this same-building anchor (estimator.py:2782); flip to
|
||||||
|
|
@ -1733,6 +1848,7 @@ def run_backtest(
|
||||||
radius: int,
|
radius: int,
|
||||||
rooms_tolerance: int,
|
rooms_tolerance: int,
|
||||||
holdout_split: bool = False,
|
holdout_split: bool = False,
|
||||||
|
city: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Drive the full read-only backtest and return a metrics dict.
|
"""Drive the full read-only backtest and return a metrics dict.
|
||||||
|
|
||||||
|
|
@ -1747,9 +1863,13 @@ def run_backtest(
|
||||||
so its bias is near-zero by construction — it proves the MECHANISM, not
|
so its bias is near-zero by construction — it proves the MECHANISM, not
|
||||||
out-of-sample accuracy (see _derive_room_ratios). Pass ``holdout_split=True``
|
out-of-sample accuracy (see _derive_room_ratios). Pass ``holdout_split=True``
|
||||||
to fit on even-id deals and evaluate on the odd-id half for an honest number.
|
to fit on even-id deals and evaluate on the odd-id half for an honest number.
|
||||||
|
|
||||||
|
``city`` (oblast D, default None) scopes the deal sample to one
|
||||||
|
``deals.city`` value — see ``_load_sample``. Default None is unscoped
|
||||||
|
(byte-identical to the pre-oblast-D behaviour).
|
||||||
"""
|
"""
|
||||||
deals = _load_sample(db, sample=sample, since=since)
|
deals = _load_sample(db, sample=sample, since=since, city=city)
|
||||||
logger.info("loaded sample: %d ДКП deals (since=%s)", len(deals), since)
|
logger.info("loaded sample: %d ДКП deals (since=%s, city=%s)", len(deals), since, city)
|
||||||
|
|
||||||
matched_rows: list[tuple[float, float, int]] = []
|
matched_rows: list[tuple[float, float, int]] = []
|
||||||
matched_ids: list[int] = []
|
matched_ids: list[int] = []
|
||||||
|
|
@ -1809,6 +1929,7 @@ def run_backtest(
|
||||||
"n_matched": len(matched_rows),
|
"n_matched": len(matched_rows),
|
||||||
"n_no_analogs": n_no_analogs,
|
"n_no_analogs": n_no_analogs,
|
||||||
"holdout_split": holdout_split,
|
"holdout_split": holdout_split,
|
||||||
|
"city": city,
|
||||||
}
|
}
|
||||||
return metrics
|
return metrics
|
||||||
|
|
||||||
|
|
@ -1820,6 +1941,7 @@ def run_backtest_full(
|
||||||
since: str,
|
since: str,
|
||||||
dump_fixture: str | None = None,
|
dump_fixture: str | None = None,
|
||||||
resolve_house_id: bool = False,
|
resolve_house_id: bool = False,
|
||||||
|
city: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Drive the FULL-spine read-only backtest and return a metrics dict (#1966).
|
"""Drive the FULL-spine read-only backtest and return a metrics dict (#1966).
|
||||||
|
|
||||||
|
|
@ -1844,10 +1966,18 @@ def run_backtest_full(
|
||||||
the Tier-S analog ladder + the house Avito-IMV anchor, mirroring prod, and a
|
the Tier-S analog ladder + the house Avito-IMV anchor, mirroring prod, and a
|
||||||
``house_id_resolution`` coverage block (resolved / total / imv_reachable) is
|
``house_id_resolution`` coverage block (resolved / total / imv_reachable) is
|
||||||
attached to the returned metrics. Default False → byte-identical prior output.
|
attached to the returned metrics. Default False → byte-identical prior output.
|
||||||
|
|
||||||
|
``city`` (oblast D, default None) scopes the deal sample to one
|
||||||
|
``deals.city`` value — see ``_load_sample``. Default None is unscoped
|
||||||
|
(byte-identical to the pre-oblast-D behaviour). Independently of this flag,
|
||||||
|
``_predict_full_spine`` ALWAYS resolves + passes a per-deal target city to
|
||||||
|
``_fetch_dkp_corridor`` (oblast C2 parity fix) — see its docstring.
|
||||||
"""
|
"""
|
||||||
est = _import_estimator_full()
|
est = _import_estimator_full()
|
||||||
deals = _load_sample(db, sample=sample, since=since)
|
deals = _load_sample(db, sample=sample, since=since, city=city)
|
||||||
logger.info("loaded sample: %d ДКП deals (since=%s) [full spine]", len(deals), since)
|
logger.info(
|
||||||
|
"loaded sample: %d ДКП deals (since=%s, city=%s) [full spine]", len(deals), since, city
|
||||||
|
)
|
||||||
|
|
||||||
predictions: list[Prediction] = []
|
predictions: list[Prediction] = []
|
||||||
n_no_prediction = 0
|
n_no_prediction = 0
|
||||||
|
|
@ -1922,6 +2052,7 @@ def run_backtest_full(
|
||||||
"n_matched": len(predictions),
|
"n_matched": len(predictions),
|
||||||
"n_no_prediction": n_no_prediction,
|
"n_no_prediction": n_no_prediction,
|
||||||
"price_segments_ppm2": [list(seg) for seg in _price_segments()],
|
"price_segments_ppm2": [list(seg) for seg in _price_segments()],
|
||||||
|
"city": city,
|
||||||
}
|
}
|
||||||
|
|
||||||
# #2002: house_id resolution coverage — the key Tier-S + IMV reach number.
|
# #2002: house_id resolution coverage — the key Tier-S + IMV reach number.
|
||||||
|
|
@ -2011,6 +2142,20 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
default="2025-06-01",
|
default="2025-06-01",
|
||||||
help="Only deals with deal_date >= this ISO date (default 2025-06-01).",
|
help="Only deals with deal_date >= this ISO date (default 2025-06-01).",
|
||||||
)
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--city",
|
||||||
|
default=None,
|
||||||
|
metavar="NAME",
|
||||||
|
help="Oblast D: scope the held-out ДКП deal sample to ONE deals.city value "
|
||||||
|
"(region 66 is oblast-wide — deals.city covers 368 cities, 96,974 deals). "
|
||||||
|
"Pass the EXACT canonical Russian name as stored in deals.city, e.g. "
|
||||||
|
"'Нижний Тагил' or 'Каменск-Уральский' — NOT a slug/transliteration (run "
|
||||||
|
"`SELECT DISTINCT city FROM deals` to check the exact spelling). Also "
|
||||||
|
"sources the deal-sample PPM2 sanity band from deal_city_price_bands for "
|
||||||
|
"that city (falls back to the global PPM2_MIN/PPM2_MAX when the city has "
|
||||||
|
"no band row, e.g. Екатеринбург — intentionally excluded from that table). "
|
||||||
|
"Default None → unscoped sample, byte-identical to the pre-oblast-D SQL.",
|
||||||
|
)
|
||||||
p.add_argument(
|
p.add_argument(
|
||||||
"--radius",
|
"--radius",
|
||||||
type=int,
|
type=int,
|
||||||
|
|
@ -2115,7 +2260,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"backtest start: engine=%s sample=%d since=%s radius=%dm "
|
"backtest start: engine=%s sample=%d since=%s radius=%dm "
|
||||||
"rooms_tolerance=%d holdout_split=%s dump_fixture=%s resolve_house_id=%s",
|
"rooms_tolerance=%d holdout_split=%s dump_fixture=%s resolve_house_id=%s city=%s",
|
||||||
args.engine,
|
args.engine,
|
||||||
args.sample,
|
args.sample,
|
||||||
args.since,
|
args.since,
|
||||||
|
|
@ -2124,6 +2269,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
args.holdout_split,
|
args.holdout_split,
|
||||||
args.dump_fixture,
|
args.dump_fixture,
|
||||||
args.resolve_house_id,
|
args.resolve_house_id,
|
||||||
|
args.city,
|
||||||
)
|
)
|
||||||
|
|
||||||
db = _session()
|
db = _session()
|
||||||
|
|
@ -2135,6 +2281,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
since=args.since,
|
since=args.since,
|
||||||
dump_fixture=args.dump_fixture,
|
dump_fixture=args.dump_fixture,
|
||||||
resolve_house_id=args.resolve_house_id,
|
resolve_house_id=args.resolve_house_id,
|
||||||
|
city=args.city,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
metrics = run_backtest(
|
metrics = run_backtest(
|
||||||
|
|
@ -2144,6 +2291,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
radius=args.radius,
|
radius=args.radius,
|
||||||
rooms_tolerance=args.rooms_tolerance,
|
rooms_tolerance=args.rooms_tolerance,
|
||||||
holdout_split=args.holdout_split,
|
holdout_split=args.holdout_split,
|
||||||
|
city=args.city,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
|
||||||
|
|
@ -7,16 +7,16 @@
|
||||||
"n_covered": 0
|
"n_covered": 0
|
||||||
},
|
},
|
||||||
"low": {
|
"low": {
|
||||||
"coverage_pct": 81.82,
|
"coverage_pct": 81.88,
|
||||||
"mape_pct": 13.23,
|
"mape_pct": 13.25,
|
||||||
"n": 275,
|
"n": 276,
|
||||||
"n_covered": 225
|
"n_covered": 226
|
||||||
},
|
},
|
||||||
"medium": {
|
"medium": {
|
||||||
"coverage_pct": 100.0,
|
"coverage_pct": 100.0,
|
||||||
"mape_pct": 14.64,
|
"mape_pct": 6.99,
|
||||||
"n": 2,
|
"n": 1,
|
||||||
"n_covered": 2
|
"n_covered": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"confidence_order": [
|
"confidence_order": [
|
||||||
|
|
@ -136,14 +136,14 @@
|
||||||
"n_covered": 0
|
"n_covered": 0
|
||||||
},
|
},
|
||||||
"low": {
|
"low": {
|
||||||
"coverage_pct": 81.82,
|
"coverage_pct": 81.88,
|
||||||
"n": 275,
|
"n": 276,
|
||||||
"n_covered": 225
|
"n_covered": 226
|
||||||
},
|
},
|
||||||
"medium": {
|
"medium": {
|
||||||
"coverage_pct": 100.0,
|
"coverage_pct": 100.0,
|
||||||
"n": 2,
|
"n": 1,
|
||||||
"n_covered": 2
|
"n_covered": 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
60
tradein-mvp/backend/tests/scrapers/test_base_clamp_date.py
Normal file
60
tradein-mvp/backend/tests/scrapers/test_base_clamp_date.py
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
"""LOW audit R2 (#6) — `_clamp_plausible_date` в `scraper_kit.base`.
|
||||||
|
|
||||||
|
Choke point для `listing_date`/`publish_date` в `save_listings()` (единый для
|
||||||
|
avito/cian/yandex/domclick). Проверено в БД: avito пишет 1970-01-01
|
||||||
|
(epoch-sentinel, ~698 строк) и даты до +11 дней в будущем (~79 строк); у
|
||||||
|
yandex — единичные аномально старые годы. Правдоподобный диапазон —
|
||||||
|
[2010-01-01, today+2d]; всё остальное → None (колонка nullable).
|
||||||
|
|
||||||
|
Офлайн: без сети/curl_cffi/БД, только импорт + чистая функция.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||||
|
|
||||||
|
from scraper_kit.base import _clamp_plausible_date
|
||||||
|
|
||||||
|
|
||||||
|
def test_avito_epoch_sentinel_rejected() -> None:
|
||||||
|
"""1970-01-01 — классический Avito 0-epoch sentinel."""
|
||||||
|
assert _clamp_plausible_date(date(1970, 1, 1)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_just_before_min_plausible_rejected() -> None:
|
||||||
|
assert _clamp_plausible_date(date(2009, 12, 31)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_far_future_rejected() -> None:
|
||||||
|
assert _clamp_plausible_date(date(2099, 1, 1)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_none_passes_through_as_none() -> None:
|
||||||
|
assert _clamp_plausible_date(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_normal_recent_date_passes_through() -> None:
|
||||||
|
d = date(2024, 6, 1)
|
||||||
|
assert _clamp_plausible_date(d) == d
|
||||||
|
|
||||||
|
|
||||||
|
def test_today_passes() -> None:
|
||||||
|
today = date.today()
|
||||||
|
assert _clamp_plausible_date(today) == today
|
||||||
|
|
||||||
|
|
||||||
|
def test_today_plus_one_day_passes() -> None:
|
||||||
|
d = date.today() + timedelta(days=1)
|
||||||
|
assert _clamp_plausible_date(d) == d
|
||||||
|
|
||||||
|
|
||||||
|
def test_today_plus_three_days_rejected() -> None:
|
||||||
|
assert _clamp_plausible_date(date.today() + timedelta(days=3)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_min_plausible_boundary_passes() -> None:
|
||||||
|
"""2010-01-01 сам по себе — граница включительно (в окне)."""
|
||||||
|
assert _clamp_plausible_date(date(2010, 1, 1)) == date(2010, 1, 1)
|
||||||
|
|
@ -614,12 +614,12 @@ async def test_suggest_addresses_classifies_kind() -> None:
|
||||||
results = await dadata.suggest_addresses("test")
|
results = await dadata.suggest_addresses("test")
|
||||||
|
|
||||||
assert len(results) == 6
|
assert len(results) == 6
|
||||||
assert results[0].kind == "house" # 8
|
assert results[0].kind == "house" # 8
|
||||||
assert results[1].kind == "street" # 7
|
assert results[1].kind == "street" # 7
|
||||||
assert results[2].kind == "city" # 4
|
assert results[2].kind == "city" # 4
|
||||||
assert results[3].kind == "city" # 1
|
assert results[3].kind == "city" # 1
|
||||||
assert results[4].kind == "house" # 9 (>=8)
|
assert results[4].kind == "house" # 9 (>=8)
|
||||||
assert results[5].kind == "city" # missing → default
|
assert results[5].kind == "city" # missing → default
|
||||||
|
|
||||||
|
|
||||||
async def test_suggest_addresses_sends_proper_request() -> None:
|
async def test_suggest_addresses_sends_proper_request() -> None:
|
||||||
|
|
@ -652,6 +652,55 @@ async def test_suggest_addresses_sends_proper_request() -> None:
|
||||||
assert captured["x_secret"] is None
|
assert captured["x_secret"] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_suggest_addresses_region_body_shape_no_restrict_value() -> None:
|
||||||
|
"""region= constrains `locations` by region, WITHOUT a restrict_value key.
|
||||||
|
|
||||||
|
`restrict_value` is a top-level DaData body param (not a per-location dict
|
||||||
|
key) — putting it inside the locations object would be a silent no-op.
|
||||||
|
The region constraint in `locations` is already a hard filter on its own.
|
||||||
|
"""
|
||||||
|
from app.services import dadata
|
||||||
|
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
captured["body"] = _json.loads(request.content.decode("utf-8"))
|
||||||
|
return httpx.Response(200, json=SAMPLE_SUGGEST_PAYLOAD)
|
||||||
|
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
with _patch_settings(token="my-token"), _patch_async_client(transport):
|
||||||
|
await dadata.suggest_addresses("Ленина", limit=5, city=None, region="Свердловская область")
|
||||||
|
|
||||||
|
body = captured["body"]
|
||||||
|
assert isinstance(body, dict)
|
||||||
|
assert body["locations"] == [{"region": "Свердловская область"}]
|
||||||
|
assert "restrict_value" not in body["locations"][0]
|
||||||
|
assert "restrict_value" not in body
|
||||||
|
|
||||||
|
|
||||||
|
async def test_suggest_addresses_region_overrides_city() -> None:
|
||||||
|
"""Когда задан и `city`, и `region` — region побеждает, city игнорируется."""
|
||||||
|
from app.services import dadata
|
||||||
|
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
captured["body"] = _json.loads(request.content.decode("utf-8"))
|
||||||
|
return httpx.Response(200, json=SAMPLE_SUGGEST_PAYLOAD)
|
||||||
|
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
with _patch_settings(), _patch_async_client(transport):
|
||||||
|
await dadata.suggest_addresses("Ленина", city="Екатеринбург", region="Свердловская область")
|
||||||
|
|
||||||
|
body = captured["body"]
|
||||||
|
assert isinstance(body, dict)
|
||||||
|
assert body["locations"] == [{"region": "Свердловская область"}]
|
||||||
|
|
||||||
|
|
||||||
async def test_suggest_addresses_clamps_limit() -> None:
|
async def test_suggest_addresses_clamps_limit() -> None:
|
||||||
"""count должен быть clamped в [1, 20] — DaData ограничение."""
|
"""count должен быть clamped в [1, 20] — DaData ограничение."""
|
||||||
from app.services import dadata
|
from app.services import dadata
|
||||||
|
|
@ -671,3 +720,168 @@ async def test_suggest_addresses_clamps_limit() -> None:
|
||||||
body = captured["body"]
|
body = captured["body"]
|
||||||
assert isinstance(body, dict)
|
assert isinstance(body, dict)
|
||||||
assert body["count"] == 20 # clamped to max
|
assert body["count"] == 20 # clamped to max
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# #dadata-403 — clean feature-disabled log + enrich_address (clean → suggest fallback)
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
import logging as _logging # noqa: E402
|
||||||
|
|
||||||
|
# Real prod 403 body when услуга CLEAN не подключена (не отклонённый токен).
|
||||||
|
CLEAN_FEATURE_DISABLED_BODY = {
|
||||||
|
"timestamp": "2026-07-12T16:37:54.585+00:00",
|
||||||
|
"status": 403,
|
||||||
|
"error": "Forbidden",
|
||||||
|
"message": (
|
||||||
|
"Feature 'CLEAN' disabled for token 'xxx'. "
|
||||||
|
"See https://dadata.userecho.com/topics/7784 for help."
|
||||||
|
),
|
||||||
|
"path": "/api/v1/clean/address",
|
||||||
|
}
|
||||||
|
|
||||||
|
# House-level suggest candidate carrying house_fias_id/kladr_id/qc_geo (что нужно fallback'у).
|
||||||
|
SUGGEST_HOUSE_PAYLOAD = {
|
||||||
|
"suggestions": [
|
||||||
|
{
|
||||||
|
"value": "г Екатеринбург, ул Малышева, д 4",
|
||||||
|
"unrestricted_value": "620014, Свердловская обл, г Екатеринбург, ул Малышева, д 4",
|
||||||
|
"data": {
|
||||||
|
"geo_lat": "56.831463",
|
||||||
|
"geo_lon": "60.580943",
|
||||||
|
"fias_id": "bfa7153e-fc05-40a4-a86d-779e65faaade",
|
||||||
|
"house_fias_id": "bfa7153e-fc05-40a4-a86d-779e65faaade",
|
||||||
|
"kladr_id": "6600000100006480017",
|
||||||
|
"qc_geo": "0",
|
||||||
|
"fias_level": "8",
|
||||||
|
"house": "4",
|
||||||
|
"street": "Малышева",
|
||||||
|
"city": "Екатеринбург",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_enrich_transport(
|
||||||
|
clean_status: int, clean_body: object, suggest_body: object
|
||||||
|
) -> httpx.MockTransport:
|
||||||
|
"""Routes /clean vs /suggest by URL — enrich_address зовёт оба через один AsyncClient."""
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
url = str(request.url)
|
||||||
|
if "cleaner.dadata.ru" in url:
|
||||||
|
return httpx.Response(clean_status, json=clean_body)
|
||||||
|
if "suggestions.dadata.ru" in url:
|
||||||
|
return httpx.Response(200, json=suggest_body)
|
||||||
|
return httpx.Response(404, json={"error": "unexpected url"})
|
||||||
|
|
||||||
|
return httpx.MockTransport(handler)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_clean_address_logs_feature_disabled_distinctly(caplog) -> None:
|
||||||
|
"""403 «Feature CLEAN disabled» → None + сообщение про выключенную услугу (не про токен)."""
|
||||||
|
from app.services import dadata
|
||||||
|
|
||||||
|
transport = _mock_transport_returning(403, CLEAN_FEATURE_DISABLED_BODY)
|
||||||
|
with _patch_settings(), _patch_async_client(transport):
|
||||||
|
with caplog.at_level(_logging.ERROR, logger="app.services.dadata"):
|
||||||
|
result = await dadata.clean_address("Екатеринбург, Малышева 4")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
text = caplog.text
|
||||||
|
assert "Стандартизация" in text or "выключена" in text
|
||||||
|
# Не должны обвинять токен при feature-disabled.
|
||||||
|
assert "auth/secret rejected" not in text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_clean_address_logs_real_auth_rejection_as_auth(caplog) -> None:
|
||||||
|
"""401 (или 403 без 'disabled') → сообщение про креды."""
|
||||||
|
from app.services import dadata
|
||||||
|
|
||||||
|
transport = _mock_transport_returning(401, {"message": "Unauthorized"})
|
||||||
|
with _patch_settings(), _patch_async_client(transport):
|
||||||
|
with caplog.at_level(_logging.ERROR, logger="app.services.dadata"):
|
||||||
|
result = await dadata.clean_address("Екатеринбург, Малышева 4")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
assert "auth/secret rejected" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_suggest_addresses_captures_house_fias_kladr_qc() -> None:
|
||||||
|
"""_parse_suggestion теперь тянет house_fias_id / kladr_id / qc_geo из data."""
|
||||||
|
from app.services import dadata
|
||||||
|
|
||||||
|
transport = _mock_suggest_transport(200, SUGGEST_HOUSE_PAYLOAD)
|
||||||
|
with _patch_settings(), _patch_async_client(transport):
|
||||||
|
results = await dadata.suggest_addresses("Малышева 4")
|
||||||
|
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0].house_fias_id == "bfa7153e-fc05-40a4-a86d-779e65faaade"
|
||||||
|
assert results[0].kladr_id == "6600000100006480017"
|
||||||
|
assert results[0].qc_geo == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_enrich_address_uses_clean_when_available() -> None:
|
||||||
|
"""CLEAN 200 → берём его результат (canonical/cadnum), suggest не подменяет."""
|
||||||
|
from app.services import dadata
|
||||||
|
|
||||||
|
transport = _mock_enrich_transport(200, SAMPLE_OK_PAYLOAD, {"suggestions": []})
|
||||||
|
with _patch_settings(), _patch_async_client(transport):
|
||||||
|
result = await dadata.enrich_address("Екатеринбург, ул. Малышева, 125")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.canonical_address == "г Екатеринбург, ул Малышева, д 125"
|
||||||
|
assert result.house_cadnum == "66:41:0704045:350"
|
||||||
|
assert result.raw.get("_source") != "suggest_fallback"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_enrich_address_falls_back_to_suggest_when_clean_unavailable() -> None:
|
||||||
|
"""CLEAN 403 disabled → suggest-fallback даёт house_fias_id/гео; canonical/cadnum=None."""
|
||||||
|
from app.services import dadata
|
||||||
|
|
||||||
|
transport = _mock_enrich_transport(403, CLEAN_FEATURE_DISABLED_BODY, SUGGEST_HOUSE_PAYLOAD)
|
||||||
|
with _patch_settings(), _patch_async_client(transport):
|
||||||
|
result = await dadata.enrich_address("Екатеринбург, ул. Малышева, 4")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.house_fias_id == "bfa7153e-fc05-40a4-a86d-779e65faaade"
|
||||||
|
assert result.kladr_id == "6600000100006480017"
|
||||||
|
assert result.qc_geo == 0
|
||||||
|
assert result.lat is not None and abs(result.lat - 56.831463) < 1e-6
|
||||||
|
# Честная деградация: без CLEAN нет canonical/cadnum — не выдумываем.
|
||||||
|
assert result.canonical_address is None
|
||||||
|
assert result.house_cadnum is None
|
||||||
|
assert result.raw.get("_source") == "suggest_fallback"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_enrich_address_ignores_non_house_suggestions() -> None:
|
||||||
|
"""Если suggest вернул только street/city (не house) → fallback не даёт house_fias_id → None."""
|
||||||
|
from app.services import dadata
|
||||||
|
|
||||||
|
street_only = {
|
||||||
|
"suggestions": [
|
||||||
|
{
|
||||||
|
"value": "г Екатеринбург, ул Малышева",
|
||||||
|
"unrestricted_value": "Свердловская обл, г Екатеринбург, ул Малышева",
|
||||||
|
"data": {"geo_lat": "56.83", "geo_lon": "60.58", "fias_id": "s", "fias_level": "7"},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
transport = _mock_enrich_transport(403, CLEAN_FEATURE_DISABLED_BODY, street_only)
|
||||||
|
with _patch_settings(), _patch_async_client(transport):
|
||||||
|
result = await dadata.enrich_address("Екатеринбург, ул. Малышева")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_enrich_address_returns_none_when_clean_and_suggest_both_fail() -> None:
|
||||||
|
"""CLEAN недоступен + suggest пустой → None (graceful)."""
|
||||||
|
from app.services import dadata
|
||||||
|
|
||||||
|
transport = _mock_enrich_transport(403, CLEAN_FEATURE_DISABLED_BODY, {"suggestions": []})
|
||||||
|
with _patch_settings(), _patch_async_client(transport):
|
||||||
|
result = await dadata.enrich_address("Малышева 4")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
|
||||||
112
tradein-mvp/backend/tests/services/test_image_sanitizer.py
Normal file
112
tradein-mvp/backend/tests/services/test_image_sanitizer.py
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
"""Unit tests for `app.services.image_sanitizer.sanitize_image`.
|
||||||
|
|
||||||
|
Coverage:
|
||||||
|
- happy path: a small RGBA PNG re-encodes to a clean JPEG (RGB, EXIF stripped)
|
||||||
|
- resize path: an image above _MAX_DIMENSION is thumbnailed down, still decodes
|
||||||
|
- pixel-flood DoS: an image declaring > _MAX_PIXELS is rejected via img.size
|
||||||
|
BEFORE img.load() is ever called (no full decode → no OOM) — #2214
|
||||||
|
- byte-size backstop: content over _MAX_BYTES rejected before Image.open
|
||||||
|
- Pillow's own bomb threshold (MAX_IMAGE_PIXELS) is lowered to our cap
|
||||||
|
- garbage bytes still raise ImageSanitizationError (regression guard)
|
||||||
|
|
||||||
|
The oversized-dimension case is asserted via monkeypatch rather than a real
|
||||||
|
100 MP buffer: allocating the bomb is exactly the OOM we are defending against,
|
||||||
|
so the test proves img.load() is NOT reached.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# DATABASE_URL required by config before any app import (см. sibling service tests).
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from app.services import image_sanitizer
|
||||||
|
from app.services.image_sanitizer import (
|
||||||
|
SANITIZED_CONTENT_TYPE,
|
||||||
|
ImageSanitizationError,
|
||||||
|
sanitize_image,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _png_bytes(width: int, height: int, mode: str = "RGBA") -> bytes:
|
||||||
|
buf = io.BytesIO()
|
||||||
|
Image.new(mode, (width, height), color=(120, 60, 30, 255)[: len(mode)]).save(buf, format="PNG")
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def test_small_image_passes_and_becomes_jpeg() -> None:
|
||||||
|
out, ctype = sanitize_image(_png_bytes(100, 80))
|
||||||
|
assert ctype == SANITIZED_CONTENT_TYPE
|
||||||
|
# Output is a valid JPEG, decoded back in RGB (transparency dropped).
|
||||||
|
reloaded = Image.open(io.BytesIO(out))
|
||||||
|
assert reloaded.format == "JPEG"
|
||||||
|
assert reloaded.mode == "RGB"
|
||||||
|
assert reloaded.size == (100, 80)
|
||||||
|
|
||||||
|
|
||||||
|
def test_oversized_but_under_pixel_cap_is_resized() -> None:
|
||||||
|
# 3000x2000 = 6 MP (< 40 MP cap) but longest edge > _MAX_DIMENSION → thumbnailed.
|
||||||
|
out, _ = sanitize_image(_png_bytes(3000, 2000, mode="RGB"))
|
||||||
|
reloaded = Image.open(io.BytesIO(out))
|
||||||
|
assert max(reloaded.size) == image_sanitizer._MAX_DIMENSION
|
||||||
|
assert reloaded.size == (2400, 1600)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pixel_flood_rejected_without_decode(monkeypatch) -> None:
|
||||||
|
"""Image declaring > cap pixels is rejected via img.size, never img.load()."""
|
||||||
|
fake_img = MagicMock()
|
||||||
|
fake_img.size = (10_000, 10_000) # 100 MP >> 40 MP cap
|
||||||
|
fake_img.__enter__.return_value = fake_img
|
||||||
|
fake_img.__exit__.return_value = False
|
||||||
|
monkeypatch.setattr(image_sanitizer.Image, "open", lambda *a, **k: fake_img)
|
||||||
|
|
||||||
|
with pytest.raises(ImageSanitizationError) as excinfo:
|
||||||
|
sanitize_image(b"\x89PNG\r\n\x1a\n-tiny-header")
|
||||||
|
|
||||||
|
assert "exceeds" in str(excinfo.value)
|
||||||
|
# The whole point of the fix: no full decode happened → no hundreds-of-MB buffer.
|
||||||
|
fake_img.load.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_pixel_cap_boundary_allows_at_cap(monkeypatch) -> None:
|
||||||
|
"""Exactly _MAX_PIXELS is allowed through to load() (strict '>' comparison)."""
|
||||||
|
fake_img = MagicMock()
|
||||||
|
fake_img.size = (image_sanitizer._MAX_PIXELS, 1) # width*height == cap
|
||||||
|
fake_img.mode = "RGB"
|
||||||
|
fake_img.__enter__.return_value = fake_img
|
||||||
|
fake_img.__exit__.return_value = False
|
||||||
|
monkeypatch.setattr(image_sanitizer.Image, "open", lambda *a, **k: fake_img)
|
||||||
|
monkeypatch.setattr(image_sanitizer.ImageOps, "exif_transpose", lambda im: im)
|
||||||
|
|
||||||
|
sanitize_image(b"whatever")
|
||||||
|
fake_img.load.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_byte_size_backstop_rejects_before_open(monkeypatch) -> None:
|
||||||
|
called = {"open": False}
|
||||||
|
|
||||||
|
def _guard(*_a, **_k):
|
||||||
|
called["open"] = True
|
||||||
|
raise AssertionError("Image.open must not be called for oversized bytes")
|
||||||
|
|
||||||
|
monkeypatch.setattr(image_sanitizer.Image, "open", _guard)
|
||||||
|
with pytest.raises(ImageSanitizationError):
|
||||||
|
sanitize_image(b"\x00" * (image_sanitizer._MAX_BYTES + 1))
|
||||||
|
assert called["open"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_pillow_bomb_threshold_lowered_to_cap() -> None:
|
||||||
|
assert image_sanitizer._MAX_PIXELS == 40_000_000
|
||||||
|
assert Image.MAX_IMAGE_PIXELS == image_sanitizer._MAX_PIXELS
|
||||||
|
|
||||||
|
|
||||||
|
def test_garbage_bytes_rejected() -> None:
|
||||||
|
with pytest.raises(ImageSanitizationError):
|
||||||
|
sanitize_image(b"definitely not an image")
|
||||||
|
|
@ -40,9 +40,21 @@ def test_category_weight_unknown_and_none_fall_back_to_default() -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_top7_weight_sum_matches_ptica() -> None:
|
def test_top7_weight_sum_matches_ptica() -> None:
|
||||||
"""Same category set as Site Finder → identical top-7 normalization constant (31.5)."""
|
"""Same category set as Site Finder → identical top-7 weight-sum constant (31.5)."""
|
||||||
assert lc._TOP7_WEIGHT_SUM == 31.5
|
assert lc._TOP7_WEIGHT_SUM == 31.5
|
||||||
assert abs(lc._MAX_STRAIGHT_SCORE - 0.315) < 1e-9
|
|
||||||
|
|
||||||
|
def test_max_straight_score_normalized_at_reference_distance_not_zero() -> None:
|
||||||
|
"""Audit R2 #7a: denominator uses _REF_DISTANCE_M (100m), not an unreachable d=0 max.
|
||||||
|
|
||||||
|
Old d=0 normalization was _TOP7_WEIGHT_SUM / 100 == 0.315 — the theoretical max with all
|
||||||
|
top-7 categories AT the doorstep. That compressed realistic scores (~25-30/100 for even a
|
||||||
|
strong central address) into the bottom third of the range. The new normalization halves
|
||||||
|
the max (denominator distance+100 doubles from 100 to 200 at REF=100m), doubling realistic
|
||||||
|
scores instead.
|
||||||
|
"""
|
||||||
|
assert lc._REF_DISTANCE_M == 100.0
|
||||||
|
assert abs(lc._MAX_STRAIGHT_SCORE - 0.1575) < 1e-9
|
||||||
|
|
||||||
|
|
||||||
def test_score_to_coef_bounds() -> None:
|
def test_score_to_coef_bounds() -> None:
|
||||||
|
|
@ -142,6 +154,45 @@ def test_compute_location_coef_weights_and_ranks_top_n() -> None:
|
||||||
assert 0.95 <= result.coef <= 1.05
|
assert 0.95 <= result.coef <= 1.05
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_location_coef_strong_central_fixture_lands_near_one() -> None:
|
||||||
|
"""Audit R2 #7a: a realistic strong central-EKB POI profile now reads as ~1.0, not ~0.98.
|
||||||
|
|
||||||
|
Fixture mirrors a real strong central address audit: near transit/kindergarten/pharmacy,
|
||||||
|
but school/metro/mall noticeably farther (a typical quarter profile, NOT everything at
|
||||||
|
the doorstep). Under the OLD d=0 normalization this fixture scores ~28/100 → coef ~0.978
|
||||||
|
(verified separately against the pre-fix formula). After re-centering on _REF_DISTANCE_M
|
||||||
|
it should land close to 1.0 (within the documented ±0.01 calibration target).
|
||||||
|
"""
|
||||||
|
rows = [
|
||||||
|
{"name": "Остановка", "category": "bus_stop", "distance_m": 80.0},
|
||||||
|
{"name": "Аптека", "category": "pharmacy", "distance_m": 120.0},
|
||||||
|
{"name": "Супермаркет", "category": "shop_supermarket", "distance_m": 180.0},
|
||||||
|
{"name": "Детсад №5", "category": "kindergarten", "distance_m": 220.0},
|
||||||
|
{"name": "Школа №10", "category": "school", "distance_m": 350.0},
|
||||||
|
{"name": "Метро Геологическая", "category": "metro_stop", "distance_m": 500.0},
|
||||||
|
{"name": "ТЦ Гринвич", "category": "shop_mall", "distance_m": 700.0},
|
||||||
|
]
|
||||||
|
db = _FakeDB([_FakeResult(scalar_value=len(rows)), _FakeResult(mapping_rows=rows)])
|
||||||
|
result = lc.compute_location_coef(db, lat=56.838, lon=60.605, top_n=7)
|
||||||
|
|
||||||
|
assert result.geo_source == "osm_poi_ekb"
|
||||||
|
assert len(result.factors) == 7
|
||||||
|
assert abs(result.coef - 1.0) <= 0.01
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_location_coef_weak_fixture_stays_well_below_one() -> None:
|
||||||
|
"""A sparse/poor location (only far, low-weight POI) must still stay well below 1.0."""
|
||||||
|
rows = [
|
||||||
|
{"name": "Магазинчик", "category": "shop_small", "distance_m": 950.0},
|
||||||
|
{"name": "Прочее", "category": "some_unknown_tag", "distance_m": 1100.0},
|
||||||
|
]
|
||||||
|
db = _FakeDB([_FakeResult(scalar_value=len(rows)), _FakeResult(mapping_rows=rows)])
|
||||||
|
result = lc.compute_location_coef(db, lat=56.838, lon=60.605, top_n=7)
|
||||||
|
|
||||||
|
assert result.geo_source == "osm_poi_ekb"
|
||||||
|
assert result.coef < 0.98
|
||||||
|
|
||||||
|
|
||||||
def test_compute_location_coef_limits_to_top_n() -> None:
|
def test_compute_location_coef_limits_to_top_n() -> None:
|
||||||
"""More than top_n candidates → only top_n factors surface in the response."""
|
"""More than top_n candidates → only top_n factors surface in the response."""
|
||||||
rows = [
|
rows = [
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:
|
||||||
_wp_mock = MagicMock()
|
_wp_mock = MagicMock()
|
||||||
sys.modules.setdefault("weasyprint", _wp_mock)
|
sys.modules.setdefault("weasyprint", _wp_mock)
|
||||||
|
|
||||||
from app.schemas.trade_in import AggregatedEstimate # noqa: E402
|
from app.schemas.trade_in import AggregatedEstimate, AnalogLot # noqa: E402
|
||||||
from app.services.brand import Brand # noqa: E402
|
from app.services.brand import Brand # noqa: E402
|
||||||
from app.services.exporters import trade_in_pdf as mod # noqa: E402
|
from app.services.exporters import trade_in_pdf as mod # noqa: E402
|
||||||
|
|
||||||
|
|
@ -79,7 +79,16 @@ class _WellFormed(HTMLParser):
|
||||||
"""Minimal balanced-tag check: every non-void tag that opens must close."""
|
"""Minimal balanced-tag check: every non-void tag that opens must close."""
|
||||||
|
|
||||||
_VOID: ClassVar[set[str]] = {
|
_VOID: ClassVar[set[str]] = {
|
||||||
"br", "img", "hr", "meta", "input", "rect", "path", "circle", "line", "polyline",
|
"br",
|
||||||
|
"img",
|
||||||
|
"hr",
|
||||||
|
"meta",
|
||||||
|
"input",
|
||||||
|
"rect",
|
||||||
|
"path",
|
||||||
|
"circle",
|
||||||
|
"line",
|
||||||
|
"polyline",
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
|
|
@ -224,7 +233,9 @@ def test_build_cover_with_sold_contains_dual_block() -> None:
|
||||||
assert "Ожидаемая цена продажи" in html
|
assert "Ожидаемая цена продажи" in html
|
||||||
# existing sections untouched
|
# existing sections untouched
|
||||||
assert "Диапазон цен в объявлениях" in html
|
assert "Диапазон цен в объявлениях" in html
|
||||||
assert "Диапазон цен по фактическим сделкам" in html
|
# #pdf-empty-pages: дубль-бар «по фактическим сделкам» убран с cover
|
||||||
|
# (остался на стр. 03) — cover ужат до одной A4-страницы.
|
||||||
|
assert "Диапазон цен по фактическим сделкам" not in html
|
||||||
|
|
||||||
|
|
||||||
def test_build_cover_without_sold_renders_only_asking() -> None:
|
def test_build_cover_without_sold_renders_only_asking() -> None:
|
||||||
|
|
@ -234,4 +245,151 @@ def test_build_cover_without_sold_renders_only_asking() -> None:
|
||||||
assert "Ожидаемая цена продажи" not in html
|
assert "Ожидаемая цена продажи" not in html
|
||||||
# existing layout intact
|
# existing layout intact
|
||||||
assert "Диапазон цен в объявлениях" in html
|
assert "Диапазон цен в объявлениях" in html
|
||||||
assert "Диапазон цен по фактическим сделкам" in html
|
# #pdf-empty-pages: дубль-бар «по фактическим сделкам» убран с cover (см. выше).
|
||||||
|
assert "Диапазон цен по фактическим сделкам" not in html
|
||||||
|
|
||||||
|
|
||||||
|
# ── _days_on_market_range — no fabricated (4, 118) corridor (#pdf-honesty) ─────
|
||||||
|
|
||||||
|
|
||||||
|
def _lot(days_on_market: int | None, price_rub: int = 10_000_000) -> AnalogLot:
|
||||||
|
return AnalogLot(
|
||||||
|
address="Екатеринбург, ул. Ленина, 1",
|
||||||
|
area_m2=50.0,
|
||||||
|
rooms=2,
|
||||||
|
floor=3,
|
||||||
|
total_floors=9,
|
||||||
|
price_rub=price_rub,
|
||||||
|
price_per_m2=int(price_rub / 50),
|
||||||
|
listing_date=None,
|
||||||
|
days_on_market=days_on_market,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_days_range_none_when_no_lots() -> None:
|
||||||
|
# Раньше возвращал хардкод (4, 118) — выдуманный срок экспозиции.
|
||||||
|
assert mod._days_on_market_range([]) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_days_range_none_when_all_days_missing() -> None:
|
||||||
|
assert mod._days_on_market_range([_lot(None), _lot(None)]) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_days_range_min_max_when_data_present() -> None:
|
||||||
|
assert mod._days_on_market_range([_lot(12), _lot(None), _lot(88)]) == (12, 88)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cover_without_days_data_still_renders_range_chart() -> None:
|
||||||
|
# Нет days_on_market → show_days=False, но ценовой бар всё равно строится.
|
||||||
|
est = _estimate(analogs=[_lot(None)])
|
||||||
|
html = mod._build_cover(est, _SNAPSHOT, _BRAND)
|
||||||
|
_assert_well_formed(html)
|
||||||
|
assert "Диапазон цен в объявлениях" in html
|
||||||
|
|
||||||
|
|
||||||
|
# ── _report_number — city-aware prefix, never hardcoded «EKБ» (#pdf-honesty) ───
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_number_ekb_prefix_for_ekb_address() -> None:
|
||||||
|
est = _estimate(target_address="Екатеринбург, ул. Ленина, 1")
|
||||||
|
assert mod._report_number(est).startswith("ЕКБ-")
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_number_city_prefix_for_non_ekb_address() -> None:
|
||||||
|
assert mod._report_number(_estimate(target_address="Нижний Тагил, ул. Мира, 5")).startswith(
|
||||||
|
"НТ-"
|
||||||
|
)
|
||||||
|
assert mod._report_number(_estimate(target_address="Серов, ул. Ленина, 3")).startswith("СЕР-")
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_number_neutral_prefix_when_city_unknown() -> None:
|
||||||
|
# None адрес и адрес вне региона → нейтральный «МЕРА», НЕ ложный «EKБ».
|
||||||
|
assert mod._report_number(_estimate(target_address=None)).startswith("МЕРА-")
|
||||||
|
assert mod._report_number(_estimate(target_address="Москва, ул. Тверская, 1")).startswith(
|
||||||
|
"МЕРА-"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_number_never_hardcodes_old_ekb_literal() -> None:
|
||||||
|
# Старый баг: «EKБ-» (латинские EK + кириллическая Б) для ЛЮБОГО адреса.
|
||||||
|
for addr in (None, "Серов, ул. Ленина, 3", "Москва, ул. Тверская, 1"):
|
||||||
|
assert not mod._report_number(_estimate(target_address=addr)).startswith("EK")
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_number_falls_back_to_canonical_address() -> None:
|
||||||
|
est = _estimate(target_address=None, canonical_address="Первоуральск, ул. Вайнера, 2")
|
||||||
|
assert mod._report_number(est).startswith("ПРВ-")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Static «10–18%» haircut replaced by computed «−N%» (#pdf-honesty) ──────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_cover_advice_uses_computed_discount_not_static_range() -> None:
|
||||||
|
est = _estimate(expected_sold_price_rub=8_000_000, asking_to_sold_ratio=0.80) # −20%
|
||||||
|
html = mod._build_cover(est, _SNAPSHOT, _BRAND)
|
||||||
|
assert "10–18%" not in html
|
||||||
|
assert "на 20%" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_cover_advice_neutral_when_no_discount() -> None:
|
||||||
|
html = mod._build_cover(_estimate(expected_sold_price_rub=None), _SNAPSHOT, _BRAND)
|
||||||
|
assert "10–18%" not in html
|
||||||
|
assert "проходят ниже цен в объявлениях" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_deals_banner_uses_computed_discount_not_static_range() -> None:
|
||||||
|
est = _estimate(expected_sold_price_rub=8_000_000, asking_to_sold_ratio=0.80) # −20%
|
||||||
|
html = mod._build_deals_page(est, _SNAPSHOT, _BRAND)
|
||||||
|
_assert_well_formed(html)
|
||||||
|
assert "10–18%" not in html
|
||||||
|
# Ложный локейшн-хвост «(Екатеринбург, 2026)» удалён.
|
||||||
|
assert "Екатеринбург, 2026" not in html
|
||||||
|
assert "на 20%" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_deals_banner_neutral_when_no_discount() -> None:
|
||||||
|
html = mod._build_deals_page(_estimate(expected_sold_price_rub=None), _SNAPSHOT, _BRAND)
|
||||||
|
_assert_well_formed(html)
|
||||||
|
assert "10–18%" not in html
|
||||||
|
assert "продаются дешевле, чем заявлено" in html
|
||||||
|
|
||||||
|
|
||||||
|
# ── _build_offer_page — rent scales with room count, not a flat 1-room figure ──
|
||||||
|
# (#pdf-honesty LOW audit R2 #5: «Общие финансовые потери» hardcoded a 1-room
|
||||||
|
# 3-month rent for EVERY object, including 3-4-room ones.)
|
||||||
|
|
||||||
|
|
||||||
|
def test_offer_page_rent_scales_up_for_larger_objects() -> None:
|
||||||
|
est = _estimate()
|
||||||
|
snapshot_1room = {**_SNAPSHOT, "rooms": 1}
|
||||||
|
snapshot_3room = {**_SNAPSHOT, "rooms": 3}
|
||||||
|
|
||||||
|
html_1room = mod._build_offer_page(est, snapshot_1room, _BRAND)
|
||||||
|
html_3room = mod._build_offer_page(est, snapshot_3room, _BRAND)
|
||||||
|
_assert_well_formed(html_1room)
|
||||||
|
_assert_well_formed(html_3room)
|
||||||
|
|
||||||
|
# Base (1-room) rent range unchanged: 84 000 – 135 000.
|
||||||
|
assert mod._fmt_rub(84_000) in html_1room
|
||||||
|
assert mod._fmt_rub(135_000) in html_1room
|
||||||
|
# 3-room rent scaled up (×1.8 heuristic): 151 000 – 243 000.
|
||||||
|
assert mod._fmt_rub(151_000) in html_3room
|
||||||
|
assert mod._fmt_rub(243_000) in html_3room
|
||||||
|
# The flat 1-room figure must NOT leak into the 3-room object's report.
|
||||||
|
assert mod._fmt_rub(135_000) not in html_3room
|
||||||
|
|
||||||
|
|
||||||
|
def test_offer_page_rent_subtitle_reflects_room_count() -> None:
|
||||||
|
est = _estimate()
|
||||||
|
|
||||||
|
html_1room = mod._build_offer_page(est, {**_SNAPSHOT, "rooms": 1}, _BRAND)
|
||||||
|
assert "1-комн. квартиры на 3 месяца" in html_1room
|
||||||
|
assert "однокомнатной квартиры на 3 месяца" not in html_1room
|
||||||
|
|
||||||
|
html_3room = mod._build_offer_page(est, {**_SNAPSHOT, "rooms": 3}, _BRAND)
|
||||||
|
assert "3-комн. квартиры на 3 месяца" in html_3room
|
||||||
|
|
||||||
|
# Unknown room count (0/missing) → neutral subtitle, no fabricated room number.
|
||||||
|
html_unknown = mod._build_offer_page(est, {**_SNAPSHOT, "rooms": 0}, _BRAND)
|
||||||
|
assert "квартиры на 3 месяца" in html_unknown
|
||||||
|
assert "-комн. квартиры на 3 месяца" not in html_unknown
|
||||||
|
|
|
||||||
0
tradein-mvp/backend/tests/services/tgbot/__init__.py
Normal file
0
tradein-mvp/backend/tests/services/tgbot/__init__.py
Normal file
654
tradein-mvp/backend/tests/services/tgbot/test_bridge.py
Normal file
654
tradein-mvp/backend/tests/services/tgbot/test_bridge.py
Normal file
|
|
@ -0,0 +1,654 @@
|
||||||
|
"""Unit tests for `app.services.tgbot.bridge` — чистая логика роутинга.
|
||||||
|
|
||||||
|
Coverage (per task spec + review follow-up):
|
||||||
|
- user → topic (личка клиента зеркалится в support-топик, с шапкой на первое
|
||||||
|
сообщение за throttle-окно, без шапки на повторное В окне, и снова с шапкой
|
||||||
|
после истечения окна — #6 review)
|
||||||
|
- реплай оператора → user (доставка ответа клиенту + запись direction='out')
|
||||||
|
- реплай не на зеркало (или не реплай вообще) — тихий игнор, не мусорим в чат;
|
||||||
|
реплай на СООБЩЕНИЕ БОТА без записи в БД — WARNING про осиротевшее зеркало
|
||||||
|
(#4 review)
|
||||||
|
- дедуп update_id (<=offset — skip без side-effects; poison-pill апдейт всё
|
||||||
|
равно сдвигает offset, чтобы не подвесить весь поток)
|
||||||
|
- сбой БД (SQLAlchemyError) во время обработки → rollback() ПЕРЕД save_offset,
|
||||||
|
offset всё равно сдвигается — без этого следующий поллинг переиграл бы тот
|
||||||
|
же апдейт и задублировал зеркало клиента в топике (#3 review)
|
||||||
|
- /start → приветствие без зеркалирования
|
||||||
|
- Telegram 403 на доставку оператору → is_blocked + уведомление в топике
|
||||||
|
- TELEGRAM_SUPPORT_CHAT_ID не задан → клиенту уходит "сервис недоступен"
|
||||||
|
вместо тихой потери сообщения (#5 review)
|
||||||
|
|
||||||
|
NEVER calls real Telegram API — все HTTP-запросы mock'аются через
|
||||||
|
httpx.MockTransport (consistent с tests/services/test_dadata.py).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
|
# DATABASE_URL required by app.core.config before any app import (см. test_dadata.py).
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
from app.services.tgbot import bridge
|
||||||
|
from app.services.tgbot.client import TelegramClient
|
||||||
|
|
||||||
|
SUPPORT_CHAT_ID = -100123456789
|
||||||
|
SUPPORT_TOPIC_ID = 42
|
||||||
|
|
||||||
|
|
||||||
|
# ── Fake in-memory storage (БД не нужна) ─────────────────────────────────────
|
||||||
|
class FakeBridgeStorage:
|
||||||
|
"""In-memory `BridgeStorage` — никакой реальной БД, чистая логика роутинга.
|
||||||
|
|
||||||
|
`clock_s` — управляемые тестом "фейковые часы" (просто float, продвигается
|
||||||
|
вручную через `storage.clock_s += ...`), чтобы честно проверить throttle-окно
|
||||||
|
в `had_recent_inbound` (#6 review) без реального `time.sleep`/datetime-моков.
|
||||||
|
|
||||||
|
`fail_next_record_message` — если True, следующий вызов `record_message`
|
||||||
|
кидает `SQLAlchemyError` (симулирует обрыв коннекта к БД) и сбрасывается в
|
||||||
|
False — для теста rollback-пути в `process_update` (#3 review).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, offset: int = 0) -> None:
|
||||||
|
self._offset = offset
|
||||||
|
self.users: dict[int, dict[str, Any]] = {}
|
||||||
|
self.messages: list[dict[str, Any]] = []
|
||||||
|
self.blocked: set[int] = set()
|
||||||
|
self.commits = 0
|
||||||
|
self.rollbacks = 0
|
||||||
|
self._next_id = 1
|
||||||
|
self.clock_s: float = 0.0
|
||||||
|
self.fail_next_record_message = False
|
||||||
|
|
||||||
|
def get_offset(self) -> int:
|
||||||
|
return self._offset
|
||||||
|
|
||||||
|
def save_offset(self, update_id: int) -> None:
|
||||||
|
self._offset = update_id
|
||||||
|
|
||||||
|
def commit(self) -> None:
|
||||||
|
self.commits += 1
|
||||||
|
|
||||||
|
def rollback(self) -> None:
|
||||||
|
self.rollbacks += 1
|
||||||
|
|
||||||
|
def upsert_user(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
chat_id: int,
|
||||||
|
username: str | None,
|
||||||
|
first_name: str | None,
|
||||||
|
last_name: str | None,
|
||||||
|
language_code: str | None,
|
||||||
|
) -> None:
|
||||||
|
self.users[chat_id] = {
|
||||||
|
"username": username,
|
||||||
|
"first_name": first_name,
|
||||||
|
"last_name": last_name,
|
||||||
|
"language_code": language_code,
|
||||||
|
}
|
||||||
|
|
||||||
|
def had_recent_inbound(self, chat_id: int, window_seconds: int) -> bool:
|
||||||
|
return any(
|
||||||
|
m["chat_id"] == chat_id
|
||||||
|
and m["direction"] == "in"
|
||||||
|
and (self.clock_s - m["recorded_at_s"]) < window_seconds
|
||||||
|
for m in self.messages
|
||||||
|
)
|
||||||
|
|
||||||
|
def record_message(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
chat_id: int,
|
||||||
|
direction: str,
|
||||||
|
tg_message_id: int | None,
|
||||||
|
topic_message_id: int | None,
|
||||||
|
kind: str,
|
||||||
|
text_body: str | None,
|
||||||
|
operator_tg_id: int | None,
|
||||||
|
) -> int:
|
||||||
|
if self.fail_next_record_message:
|
||||||
|
self.fail_next_record_message = False
|
||||||
|
raise SQLAlchemyError("simulated DB failure (deploy connection reset)")
|
||||||
|
row_id = self._next_id
|
||||||
|
self._next_id += 1
|
||||||
|
self.messages.append(
|
||||||
|
{
|
||||||
|
"id": row_id,
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"direction": direction,
|
||||||
|
"tg_message_id": tg_message_id,
|
||||||
|
"topic_message_id": topic_message_id,
|
||||||
|
"kind": kind,
|
||||||
|
"text_body": text_body,
|
||||||
|
"operator_tg_id": operator_tg_id,
|
||||||
|
"recorded_at_s": self.clock_s,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return row_id
|
||||||
|
|
||||||
|
def find_chat_by_topic_message(self, topic_message_id: int) -> int | None:
|
||||||
|
for m in reversed(self.messages):
|
||||||
|
if m["direction"] == "in" and m["topic_message_id"] == topic_message_id:
|
||||||
|
return m["chat_id"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def mark_blocked(self, chat_id: int) -> None:
|
||||||
|
self.blocked.add(chat_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ── httpx mocking helpers (mirrors tests/services/test_dadata.py) ───────────
|
||||||
|
_REAL_ASYNC_CLIENT = httpx.AsyncClient
|
||||||
|
|
||||||
|
|
||||||
|
def _method_from_url(url: httpx.URL) -> str:
|
||||||
|
return str(url).rsplit("/", 1)[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def _make_client(
|
||||||
|
responses: dict[str, Any], calls: list[tuple[str, dict[str, Any]]]
|
||||||
|
) -> TelegramClient:
|
||||||
|
"""TelegramClient wired to a MockTransport. `responses[method]` may be a dict
|
||||||
|
(returned as Bot API `result`), an int (HTTP error status), or a callable
|
||||||
|
`(payload) -> dict`. Every request is recorded into `calls`."""
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
method = _method_from_url(request.url)
|
||||||
|
payload = _json.loads(request.content.decode("utf-8")) if request.content else {}
|
||||||
|
calls.append((method, payload))
|
||||||
|
|
||||||
|
canned = responses.get(method)
|
||||||
|
if isinstance(canned, int):
|
||||||
|
return httpx.Response(
|
||||||
|
canned, json={"ok": False, "error_code": canned, "description": "mocked error"}
|
||||||
|
)
|
||||||
|
if callable(canned):
|
||||||
|
canned = canned(payload)
|
||||||
|
result = canned if canned is not None else {"message_id": 999}
|
||||||
|
return httpx.Response(200, json={"ok": True, "result": result})
|
||||||
|
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
|
||||||
|
def factory(*_: object, **__: object) -> httpx.AsyncClient:
|
||||||
|
return _REAL_ASYNC_CLIENT(transport=transport)
|
||||||
|
|
||||||
|
client = TelegramClient(token="fake-token")
|
||||||
|
import unittest.mock as mock
|
||||||
|
|
||||||
|
# Патчим httpx.AsyncClient ТОЛЬКО внутри client-модуля — не трогаем глобальный httpx.
|
||||||
|
patcher = mock.patch("app.services.tgbot.client.httpx.AsyncClient", factory)
|
||||||
|
patcher.start()
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _support_chat_settings(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Все тесты по умолчанию считают support-группу/топик настроенными."""
|
||||||
|
monkeypatch.setattr(bridge.settings, "telegram_support_chat_id", SUPPORT_CHAT_ID)
|
||||||
|
monkeypatch.setattr(bridge.settings, "telegram_support_topic_id", SUPPORT_TOPIC_ID)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _stop_patches():
|
||||||
|
"""Останавливает httpx.AsyncClient monkeypatch после каждого теста (unittest.mock.patch.start()
|
||||||
|
без контекст-менеджера требует явного stop, чтобы не утекать в соседние тесты)."""
|
||||||
|
import unittest.mock as mock
|
||||||
|
|
||||||
|
yield
|
||||||
|
mock.patch.stopall()
|
||||||
|
|
||||||
|
|
||||||
|
def _private_message(
|
||||||
|
*,
|
||||||
|
message_id: int = 1,
|
||||||
|
chat_id: int = 555,
|
||||||
|
text: str | None = "Здравствуйте, вопрос по trade-in",
|
||||||
|
username: str | None = "client_ivan",
|
||||||
|
first_name: str | None = "Иван",
|
||||||
|
last_name: str | None = "Петров",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
msg: dict[str, Any] = {
|
||||||
|
"message_id": message_id,
|
||||||
|
"chat": {"id": chat_id, "type": "private"},
|
||||||
|
"from": {
|
||||||
|
"id": chat_id,
|
||||||
|
"username": username,
|
||||||
|
"first_name": first_name,
|
||||||
|
"last_name": last_name,
|
||||||
|
"language_code": "ru",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if text is not None:
|
||||||
|
msg["text"] = text
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
def _group_reply_message(
|
||||||
|
*,
|
||||||
|
message_id: int = 200,
|
||||||
|
reply_to_message_id: int | None = 100,
|
||||||
|
text: str = "Ответ оператора",
|
||||||
|
operator_id: int = 777,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
msg: dict[str, Any] = {
|
||||||
|
"message_id": message_id,
|
||||||
|
"chat": {"id": SUPPORT_CHAT_ID, "type": "supergroup"},
|
||||||
|
"from": {"id": operator_id, "username": "operator1"},
|
||||||
|
"text": text,
|
||||||
|
}
|
||||||
|
if reply_to_message_id is not None:
|
||||||
|
msg["reply_to_message"] = {"message_id": reply_to_message_id}
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
# ── A) user → topic ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def test_private_message_mirrors_to_topic_with_header_on_first_contact() -> None:
|
||||||
|
calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
client = _make_client({"copyMessage": {"message_id": 555}}, calls)
|
||||||
|
storage = FakeBridgeStorage()
|
||||||
|
|
||||||
|
update = {"update_id": 10, "message": _private_message()}
|
||||||
|
await bridge.process_update(update, client, storage)
|
||||||
|
|
||||||
|
methods = [m for m, _ in calls]
|
||||||
|
# Первое сообщение за окно → шапка ПЕРЕД зеркалом контента.
|
||||||
|
assert methods == ["sendMessage", "copyMessage"]
|
||||||
|
|
||||||
|
header_call = calls[0][1]
|
||||||
|
assert header_call["chat_id"] == SUPPORT_CHAT_ID
|
||||||
|
assert header_call["message_thread_id"] == SUPPORT_TOPIC_ID
|
||||||
|
assert "Иван Петров" in header_call["text"]
|
||||||
|
assert "@client_ivan" in header_call["text"]
|
||||||
|
|
||||||
|
mirror_call = calls[1][1]
|
||||||
|
assert mirror_call["from_chat_id"] == 555
|
||||||
|
assert mirror_call["chat_id"] == SUPPORT_CHAT_ID
|
||||||
|
assert mirror_call["message_id"] == 1
|
||||||
|
assert mirror_call["message_thread_id"] == SUPPORT_TOPIC_ID
|
||||||
|
|
||||||
|
assert len(storage.messages) == 1
|
||||||
|
rec = storage.messages[0]
|
||||||
|
assert rec["direction"] == "in"
|
||||||
|
assert rec["chat_id"] == 555
|
||||||
|
assert rec["topic_message_id"] == 555 # copyMessage result.message_id
|
||||||
|
assert rec["kind"] == "text"
|
||||||
|
assert rec["text_body"] == "Здравствуйте, вопрос по trade-in"
|
||||||
|
|
||||||
|
assert storage.get_offset() == 10
|
||||||
|
assert storage.commits == 1
|
||||||
|
assert 555 in storage.users
|
||||||
|
|
||||||
|
|
||||||
|
async def test_private_message_second_message_within_window_skips_header() -> None:
|
||||||
|
calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
client = _make_client({"copyMessage": {"message_id": 556}}, calls)
|
||||||
|
storage = FakeBridgeStorage()
|
||||||
|
# Симулируем уже существующее inbound-сообщение за последний час.
|
||||||
|
storage.record_message(
|
||||||
|
chat_id=555,
|
||||||
|
direction="in",
|
||||||
|
tg_message_id=0,
|
||||||
|
topic_message_id=100,
|
||||||
|
kind="text",
|
||||||
|
text_body="первое сообщение",
|
||||||
|
operator_tg_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
update = {"update_id": 11, "message": _private_message(message_id=2)}
|
||||||
|
await bridge.process_update(update, client, storage)
|
||||||
|
|
||||||
|
methods = [m for m, _ in calls]
|
||||||
|
# Шапка НЕ отправляется повторно — только зеркало.
|
||||||
|
assert methods == ["copyMessage"]
|
||||||
|
assert len(storage.messages) == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_private_message_header_resent_after_window_expires() -> None:
|
||||||
|
"""#6 review: throttle-окно (3600с) реально проверяется по времени — после
|
||||||
|
истечения окна шапка отправляется заново (не одна на весь чат навсегда)."""
|
||||||
|
calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
client = _make_client({"copyMessage": {"message_id": 557}}, calls)
|
||||||
|
storage = FakeBridgeStorage()
|
||||||
|
storage.record_message(
|
||||||
|
chat_id=555,
|
||||||
|
direction="in",
|
||||||
|
tg_message_id=0,
|
||||||
|
topic_message_id=100,
|
||||||
|
kind="text",
|
||||||
|
text_body="первое сообщение (час назад)",
|
||||||
|
operator_tg_id=None,
|
||||||
|
)
|
||||||
|
# Продвигаем фейковые часы за throttle-окно (3600с).
|
||||||
|
storage.clock_s += bridge._HEADER_THROTTLE_WINDOW_S + 1
|
||||||
|
|
||||||
|
update = {"update_id": 13, "message": _private_message(message_id=3)}
|
||||||
|
await bridge.process_update(update, client, storage)
|
||||||
|
|
||||||
|
methods = [m for m, _ in calls]
|
||||||
|
assert methods == ["sendMessage", "copyMessage"] # шапка снова отправлена
|
||||||
|
assert len(storage.messages) == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_private_message_start_sends_greeting_without_mirroring() -> None:
|
||||||
|
calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
client = _make_client({}, calls)
|
||||||
|
storage = FakeBridgeStorage()
|
||||||
|
|
||||||
|
update = {"update_id": 12, "message": _private_message(text="/start")}
|
||||||
|
await bridge.process_update(update, client, storage)
|
||||||
|
|
||||||
|
methods = [m for m, _ in calls]
|
||||||
|
assert methods == ["sendMessage"]
|
||||||
|
greeting_call = calls[0][1]
|
||||||
|
assert greeting_call["chat_id"] == 555
|
||||||
|
assert "МЕРА" in greeting_call["text"]
|
||||||
|
# /start не зеркалируется и не попадает в лог переписки.
|
||||||
|
assert storage.messages == []
|
||||||
|
assert storage.get_offset() == 12
|
||||||
|
|
||||||
|
|
||||||
|
async def test_private_message_notifies_client_when_support_chat_unset(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""#5 review: TELEGRAM_SUPPORT_CHAT_ID не задан → клиент получает "сервис
|
||||||
|
недоступен" вместо того, чтобы молча ждать ответа, который никогда не придёт."""
|
||||||
|
monkeypatch.setattr(bridge.settings, "telegram_support_chat_id", 0)
|
||||||
|
calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
client = _make_client({}, calls)
|
||||||
|
storage = FakeBridgeStorage()
|
||||||
|
|
||||||
|
update = {"update_id": 14, "message": _private_message()}
|
||||||
|
await bridge.process_update(update, client, storage)
|
||||||
|
|
||||||
|
methods = [m for m, _ in calls]
|
||||||
|
assert methods == ["sendMessage"]
|
||||||
|
notify_call = calls[0][1]
|
||||||
|
assert notify_call["chat_id"] == 555
|
||||||
|
assert notify_call["text"] == bridge.SERVICE_UNAVAILABLE_TEXT
|
||||||
|
# Ничего не зеркалируется и не пишется в лог переписки — support-группа не настроена.
|
||||||
|
assert storage.messages == []
|
||||||
|
assert storage.get_offset() == 14
|
||||||
|
|
||||||
|
|
||||||
|
# ── B) реплай оператора → user ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def test_group_reply_delivers_to_client_and_records_outbound() -> None:
|
||||||
|
calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
client = _make_client({"copyMessage": {"message_id": 42}}, calls)
|
||||||
|
storage = FakeBridgeStorage()
|
||||||
|
storage.record_message(
|
||||||
|
chat_id=555,
|
||||||
|
direction="in",
|
||||||
|
tg_message_id=1,
|
||||||
|
topic_message_id=100,
|
||||||
|
kind="text",
|
||||||
|
text_body="вопрос клиента",
|
||||||
|
operator_tg_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
update = {
|
||||||
|
"update_id": 20,
|
||||||
|
"message": _group_reply_message(reply_to_message_id=100),
|
||||||
|
}
|
||||||
|
await bridge.process_update(update, client, storage)
|
||||||
|
|
||||||
|
methods = [m for m, _ in calls]
|
||||||
|
assert methods == ["copyMessage"]
|
||||||
|
delivery = calls[0][1]
|
||||||
|
assert delivery["chat_id"] == 555
|
||||||
|
assert delivery["from_chat_id"] == SUPPORT_CHAT_ID
|
||||||
|
|
||||||
|
assert len(storage.messages) == 2
|
||||||
|
out_rec = storage.messages[-1]
|
||||||
|
assert out_rec["direction"] == "out"
|
||||||
|
assert out_rec["chat_id"] == 555
|
||||||
|
assert out_rec["operator_tg_id"] == 777
|
||||||
|
assert out_rec["text_body"] == "Ответ оператора"
|
||||||
|
assert storage.get_offset() == 20
|
||||||
|
|
||||||
|
|
||||||
|
async def test_group_reply_not_a_reply_is_ignored() -> None:
|
||||||
|
"""Обычное сообщение в топике (не реплай) — тихий игнор, никаких Telegram-вызовов."""
|
||||||
|
calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
client = _make_client({}, calls)
|
||||||
|
storage = FakeBridgeStorage()
|
||||||
|
|
||||||
|
update = {
|
||||||
|
"update_id": 21,
|
||||||
|
"message": _group_reply_message(reply_to_message_id=None),
|
||||||
|
}
|
||||||
|
await bridge.process_update(update, client, storage)
|
||||||
|
|
||||||
|
assert calls == []
|
||||||
|
assert storage.messages == []
|
||||||
|
# offset всё равно сдвигается — апдейт "обработан" (даже если ничего не сделано).
|
||||||
|
assert storage.get_offset() == 21
|
||||||
|
|
||||||
|
|
||||||
|
async def test_group_reply_to_unknown_message_is_ignored() -> None:
|
||||||
|
"""Реплай на сообщение, которого нет в tg_support_messages как зеркало клиента —
|
||||||
|
тихий игнор (обычная болтовня в топике на постороннее сообщение, не от бота)."""
|
||||||
|
calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
client = _make_client({}, calls)
|
||||||
|
storage = FakeBridgeStorage()
|
||||||
|
|
||||||
|
update = {
|
||||||
|
"update_id": 22,
|
||||||
|
"message": _group_reply_message(reply_to_message_id=999999),
|
||||||
|
}
|
||||||
|
await bridge.process_update(update, client, storage)
|
||||||
|
|
||||||
|
assert calls == []
|
||||||
|
assert storage.messages == []
|
||||||
|
assert storage.get_offset() == 22
|
||||||
|
|
||||||
|
|
||||||
|
async def test_group_reply_to_bot_message_without_record_logs_orphaned_mirror_warning(
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""#4 review: реплай на сообщение БОТА, которого нет в tg_support_messages, —
|
||||||
|
вероятное осиротевшее зеркало (крах между copyMessage и commit). WARNING, не
|
||||||
|
тихий игнор — оператор иначе решит, что ответ клиенту доставлен."""
|
||||||
|
calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
client = _make_client({}, calls)
|
||||||
|
storage = FakeBridgeStorage()
|
||||||
|
|
||||||
|
message = _group_reply_message(reply_to_message_id=100)
|
||||||
|
message["reply_to_message"]["from"] = {"id": 999, "is_bot": True, "username": "MERAsupport_bot"}
|
||||||
|
update = {"update_id": 24, "message": message}
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING, logger="app.services.tgbot.bridge"):
|
||||||
|
await bridge.process_update(update, client, storage)
|
||||||
|
|
||||||
|
assert calls == [] # ответ НЕ доставлен — routing-ключ потерян
|
||||||
|
assert "осиротевшее" in caplog.text
|
||||||
|
assert storage.get_offset() == 24
|
||||||
|
|
||||||
|
|
||||||
|
async def test_group_reply_to_non_bot_message_without_record_stays_silent(
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Обычный реплай на сообщение ДРУГОГО ЧЕЛОВЕКА (не бота) в топике — реальная
|
||||||
|
болтовня, никакого WARNING (дискриминатор `is_bot` работает в обе стороны)."""
|
||||||
|
calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
client = _make_client({}, calls)
|
||||||
|
storage = FakeBridgeStorage()
|
||||||
|
|
||||||
|
message = _group_reply_message(reply_to_message_id=101)
|
||||||
|
message["reply_to_message"]["from"] = {"id": 42, "is_bot": False, "username": "colleague"}
|
||||||
|
update = {"update_id": 25, "message": message}
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING, logger="app.services.tgbot.bridge"):
|
||||||
|
await bridge.process_update(update, client, storage)
|
||||||
|
|
||||||
|
assert calls == []
|
||||||
|
assert caplog.text == ""
|
||||||
|
|
||||||
|
|
||||||
|
async def test_group_reply_403_marks_blocked_and_notifies_topic() -> None:
|
||||||
|
calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
client = _make_client({"copyMessage": 403}, calls)
|
||||||
|
storage = FakeBridgeStorage()
|
||||||
|
storage.record_message(
|
||||||
|
chat_id=555,
|
||||||
|
direction="in",
|
||||||
|
tg_message_id=1,
|
||||||
|
topic_message_id=100,
|
||||||
|
kind="text",
|
||||||
|
text_body="вопрос клиента",
|
||||||
|
operator_tg_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
update = {
|
||||||
|
"update_id": 23,
|
||||||
|
"message": _group_reply_message(reply_to_message_id=100),
|
||||||
|
}
|
||||||
|
await bridge.process_update(update, client, storage)
|
||||||
|
|
||||||
|
methods = [m for m, _ in calls]
|
||||||
|
assert methods == ["copyMessage", "sendMessage"]
|
||||||
|
notify_call = calls[1][1]
|
||||||
|
assert notify_call["chat_id"] == SUPPORT_CHAT_ID
|
||||||
|
assert "заблокирован" in notify_call["text"]
|
||||||
|
|
||||||
|
assert 555 in storage.blocked
|
||||||
|
# Неудачная доставка НЕ должна создавать фейковую запись 'out'.
|
||||||
|
assert len(storage.messages) == 1
|
||||||
|
assert storage.get_offset() == 23
|
||||||
|
|
||||||
|
|
||||||
|
# ── C) дедуп ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def test_dedup_update_id_leq_offset_is_skipped_without_side_effects() -> None:
|
||||||
|
calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
client = _make_client({}, calls)
|
||||||
|
storage = FakeBridgeStorage(offset=50)
|
||||||
|
|
||||||
|
update = {"update_id": 50, "message": _private_message()}
|
||||||
|
await bridge.process_update(update, client, storage)
|
||||||
|
|
||||||
|
assert calls == []
|
||||||
|
assert storage.messages == []
|
||||||
|
assert storage.commits == 0 # ранний return — offset уже актуален, коммитить нечего
|
||||||
|
assert storage.get_offset() == 50
|
||||||
|
|
||||||
|
update_older = {"update_id": 10, "message": _private_message()}
|
||||||
|
await bridge.process_update(update_older, client, storage)
|
||||||
|
assert calls == []
|
||||||
|
assert storage.get_offset() == 50
|
||||||
|
|
||||||
|
|
||||||
|
async def test_poison_pill_update_still_advances_offset() -> None:
|
||||||
|
"""Апдейт, на котором обработчик упал (например, message без chat), не должен
|
||||||
|
подвесить весь поток — offset сдвигается даже при исключении внутри handler'а."""
|
||||||
|
calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
client = _make_client({}, calls)
|
||||||
|
storage = FakeBridgeStorage()
|
||||||
|
|
||||||
|
malformed_message = {"message_id": 1, "chat": {"type": "private"}} # нет chat.id
|
||||||
|
update = {"update_id": 30, "message": malformed_message}
|
||||||
|
await bridge.process_update(update, client, storage)
|
||||||
|
|
||||||
|
assert storage.get_offset() == 30
|
||||||
|
assert storage.commits == 1
|
||||||
|
assert storage.messages == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_db_error_during_processing_rolls_back_and_still_advances_offset() -> None:
|
||||||
|
"""#3 review: SQLAlchemyError (напр. обрыв коннекта к БД при деплое) во время
|
||||||
|
`record_message` → storage.rollback() ПЕРЕД save_offset, offset всё равно
|
||||||
|
сдвигается. Без rollback() save_offset сам кинул бы PendingRollbackError →
|
||||||
|
process_update вылетел бы без сохранения offset'а → следующая итерация
|
||||||
|
переиграла бы тот же апдейт → copyMessage задублировал бы зеркало в топике
|
||||||
|
на каждый повтор поллинга (воспроизведено ревьюером)."""
|
||||||
|
calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
client = _make_client({"copyMessage": {"message_id": 558}}, calls)
|
||||||
|
storage = FakeBridgeStorage()
|
||||||
|
storage.fail_next_record_message = True
|
||||||
|
|
||||||
|
update = {"update_id": 15, "message": _private_message()}
|
||||||
|
await bridge.process_update(update, client, storage)
|
||||||
|
|
||||||
|
# copyMessage успел уйти в Telegram (реальная утечка мирроринга при DB-сбое —
|
||||||
|
# известное ограничение атомарности между внешним API и БД, вне scope этого фикса),
|
||||||
|
# но rollback() отработал, offset сдвинут, commit вызван РОВНО один раз (в finally).
|
||||||
|
assert storage.rollbacks == 1
|
||||||
|
assert storage.commits == 1
|
||||||
|
assert storage.get_offset() == 15
|
||||||
|
# Запись сообщения НЕ попала в storage (record_message упал до append).
|
||||||
|
assert storage.messages == []
|
||||||
|
|
||||||
|
# Повторный вызов с тем же update_id теперь корректно дедупится — НЕ переигрывается.
|
||||||
|
calls.clear()
|
||||||
|
await bridge.process_update(update, client, storage)
|
||||||
|
assert calls == []
|
||||||
|
assert storage.get_offset() == 15
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_without_update_id_is_ignored() -> None:
|
||||||
|
calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
client = _make_client({}, calls)
|
||||||
|
storage = FakeBridgeStorage()
|
||||||
|
|
||||||
|
await bridge.process_update({"message": _private_message()}, client, storage)
|
||||||
|
|
||||||
|
assert calls == []
|
||||||
|
assert storage.commits == 0
|
||||||
|
assert storage.get_offset() == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── kind inference ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("message_extra", "expected_kind"),
|
||||||
|
[
|
||||||
|
({"text": "hi"}, "text"),
|
||||||
|
({"photo": [{"file_id": "x"}]}, "photo"),
|
||||||
|
({"document": {"file_id": "x"}}, "document"),
|
||||||
|
({"video": {"file_id": "x"}}, "video"),
|
||||||
|
({"voice": {"file_id": "x"}}, "voice"),
|
||||||
|
({"sticker": {"file_id": "x"}}, "other"),
|
||||||
|
({"location": {"latitude": 1, "longitude": 2}}, "other"),
|
||||||
|
({}, "other"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_infer_kind(message_extra: dict[str, Any], expected_kind: str) -> None:
|
||||||
|
message = {"message_id": 1, "chat": {"id": 1, "type": "private"}, **message_extra}
|
||||||
|
assert bridge._infer_kind(message) == expected_kind
|
||||||
|
|
||||||
|
|
||||||
|
# ── unrelated chat types ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_from_unrelated_chat_is_ignored_but_offset_advances() -> None:
|
||||||
|
"""Апдейт не из личного чата и не из support-группы (например, другой чат/канал)
|
||||||
|
— молча игнорируется, offset всё равно сдвигается."""
|
||||||
|
calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
client = _make_client({}, calls)
|
||||||
|
storage = FakeBridgeStorage()
|
||||||
|
|
||||||
|
message = {
|
||||||
|
"message_id": 1,
|
||||||
|
"chat": {"id": -999, "type": "group"},
|
||||||
|
"from": {"id": 1},
|
||||||
|
"text": "болтовня в постороннем чате",
|
||||||
|
}
|
||||||
|
update = {"update_id": 40, "message": message}
|
||||||
|
await bridge.process_update(update, client, storage)
|
||||||
|
|
||||||
|
assert calls == []
|
||||||
|
assert storage.messages == []
|
||||||
|
assert storage.get_offset() == 40
|
||||||
164
tradein-mvp/backend/tests/services/tgbot/test_client.py
Normal file
164
tradein-mvp/backend/tests/services/tgbot/test_client.py
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
"""Unit tests for `app.services.tgbot.client.TelegramClient` retry/backoff logic.
|
||||||
|
|
||||||
|
NEVER calls real Telegram API — httpx.MockTransport only (consistent с
|
||||||
|
tests/services/test_dadata.py). `asyncio.sleep` is patched to a no-op so retry
|
||||||
|
tests run instantly regardless of configured backoff/retry_after durations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
from app.services.tgbot.client import TelegramApiError, TelegramClient
|
||||||
|
|
||||||
|
_REAL_ASYNC_CLIENT = httpx.AsyncClient
|
||||||
|
|
||||||
|
|
||||||
|
def _install_transport(handler) -> None:
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
|
||||||
|
def factory(*_: object, **__: object) -> httpx.AsyncClient:
|
||||||
|
return _REAL_ASYNC_CLIENT(transport=transport)
|
||||||
|
|
||||||
|
mock.patch("app.services.tgbot.client.httpx.AsyncClient", factory).start()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _stop_patches_and_noop_sleep():
|
||||||
|
sleep_patcher = mock.patch("app.services.tgbot.client.asyncio.sleep", return_value=None)
|
||||||
|
sleep_patcher.start()
|
||||||
|
yield
|
||||||
|
mock.patch.stopall()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_updates_happy_path_returns_list() -> None:
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
assert request.url.path.endswith("/getUpdates")
|
||||||
|
return httpx.Response(200, json={"ok": True, "result": [{"update_id": 1}]})
|
||||||
|
|
||||||
|
_install_transport(handler)
|
||||||
|
client = TelegramClient(token="fake-token")
|
||||||
|
updates = await client.get_updates(offset=1)
|
||||||
|
assert updates == [{"update_id": 1}]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_never_logs_or_leaks_token_in_request_url_host() -> None:
|
||||||
|
"""Sanity: token lives only in the path, base host stays api.telegram.org."""
|
||||||
|
captured: dict[str, str] = {}
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
captured["url"] = str(request.url)
|
||||||
|
return httpx.Response(200, json={"ok": True, "result": {}})
|
||||||
|
|
||||||
|
_install_transport(handler)
|
||||||
|
client = TelegramClient(token="super-secret-token")
|
||||||
|
await client.send_message(chat_id=1, text="hi")
|
||||||
|
assert "bot" + "super-secret-token" in captured["url"] # goes over the wire, not logged
|
||||||
|
|
||||||
|
|
||||||
|
async def test_copy_message_retries_on_429_then_succeeds() -> None:
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
calls["n"] += 1
|
||||||
|
if calls["n"] == 1:
|
||||||
|
return httpx.Response(
|
||||||
|
429,
|
||||||
|
json={
|
||||||
|
"ok": False,
|
||||||
|
"error_code": 429,
|
||||||
|
"description": "Too Many Requests",
|
||||||
|
"parameters": {"retry_after": 3},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return httpx.Response(200, json={"ok": True, "result": {"message_id": 5}})
|
||||||
|
|
||||||
|
_install_transport(handler)
|
||||||
|
client = TelegramClient(token="fake-token")
|
||||||
|
result = await client.copy_message(chat_id=1, from_chat_id=2, message_id=3)
|
||||||
|
|
||||||
|
assert result == {"message_id": 5}
|
||||||
|
assert calls["n"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_send_message_retries_on_5xx_then_succeeds() -> None:
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
calls["n"] += 1
|
||||||
|
if calls["n"] < 3:
|
||||||
|
return httpx.Response(
|
||||||
|
502, json={"ok": False, "error_code": 502, "description": "bad gw"}
|
||||||
|
)
|
||||||
|
return httpx.Response(200, json={"ok": True, "result": {"message_id": 9}})
|
||||||
|
|
||||||
|
_install_transport(handler)
|
||||||
|
client = TelegramClient(token="fake-token")
|
||||||
|
result = await client.send_message(chat_id=1, text="retrying")
|
||||||
|
|
||||||
|
assert result == {"message_id": 9}
|
||||||
|
assert calls["n"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
async def test_send_message_raises_immediately_on_non_retryable_4xx() -> None:
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
calls["n"] += 1
|
||||||
|
return httpx.Response(
|
||||||
|
403, json={"ok": False, "error_code": 403, "description": "Forbidden: bot blocked"}
|
||||||
|
)
|
||||||
|
|
||||||
|
_install_transport(handler)
|
||||||
|
client = TelegramClient(token="fake-token")
|
||||||
|
|
||||||
|
with pytest.raises(TelegramApiError) as exc_info:
|
||||||
|
await client.send_message(chat_id=1, text="hi")
|
||||||
|
|
||||||
|
assert exc_info.value.error_code == 403
|
||||||
|
assert calls["n"] == 1 # НЕ ретраится
|
||||||
|
|
||||||
|
|
||||||
|
async def test_copy_message_gives_up_after_max_retries_on_persistent_5xx() -> None:
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(500, json={"ok": False, "error_code": 500, "description": "boom"})
|
||||||
|
|
||||||
|
_install_transport(handler)
|
||||||
|
client = TelegramClient(token="fake-token")
|
||||||
|
|
||||||
|
with pytest.raises(TelegramApiError) as exc_info:
|
||||||
|
await client.copy_message(chat_id=1, from_chat_id=2, message_id=3)
|
||||||
|
|
||||||
|
assert exc_info.value.error_code == 500
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_updates_returns_empty_list_on_malformed_result() -> None:
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(200, json={"ok": True, "result": "not-a-list"})
|
||||||
|
|
||||||
|
_install_transport(handler)
|
||||||
|
client = TelegramClient(token="fake-token")
|
||||||
|
assert await client.get_updates(offset=1) == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_optional_thread_and_reply_params_omitted_when_falsy() -> None:
|
||||||
|
captured: dict[str, Any] = {}
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
captured["body"] = _json.loads(request.content.decode("utf-8"))
|
||||||
|
return httpx.Response(200, json={"ok": True, "result": {"message_id": 1}})
|
||||||
|
|
||||||
|
_install_transport(handler)
|
||||||
|
client = TelegramClient(token="fake-token")
|
||||||
|
await client.copy_message(chat_id=1, from_chat_id=2, message_id=3, message_thread_id=0)
|
||||||
|
|
||||||
|
assert "message_thread_id" not in captured["body"]
|
||||||
|
|
@ -82,7 +82,6 @@ def _run_qa_estimate(
|
||||||
ratio_tuple: tuple[float | None, str | None] = (0.92, "per_rooms"),
|
ratio_tuple: tuple[float | None, str | None] = (0.92, "per_rooms"),
|
||||||
radius_analogs: list[dict[str, Any]] | None = None,
|
radius_analogs: list[dict[str, Any]] | None = None,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
from app.core.config import settings
|
|
||||||
from app.schemas.trade_in import TradeInEstimateInput
|
from app.schemas.trade_in import TradeInEstimateInput
|
||||||
from app.services.estimator import estimate_quality
|
from app.services.estimator import estimate_quality
|
||||||
from app.services.geocoder import GeocodeResult
|
from app.services.geocoder import GeocodeResult
|
||||||
|
|
@ -105,7 +104,6 @@ def _run_qa_estimate(
|
||||||
|
|
||||||
async def _run() -> Any:
|
async def _run() -> Any:
|
||||||
with (
|
with (
|
||||||
patch.object(settings, "estimate_same_building_anchor_enabled", True),
|
|
||||||
patch("app.services.estimator.geocode", new=AsyncMock(return_value=geo)),
|
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.dadata_clean_address", new=AsyncMock(return_value=None)),
|
||||||
patch("app.services.estimator.match_house_readonly", return_value=None),
|
patch("app.services.estimator.match_house_readonly", return_value=None),
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@ Coverage:
|
||||||
(c) increment растит used счётчик
|
(c) increment растит used счётчик
|
||||||
(d) get_status корректен для different сценариев
|
(d) get_status корректен для different сценариев
|
||||||
(e) отсутствие заголовка X-Authenticated-User = unlimited (fail-open)
|
(e) отсутствие заголовка X-Authenticated-User = unlimited (fail-open)
|
||||||
|
(f) #747 — атомарно-условный increment (TOCTOU fix)
|
||||||
|
(g) account_quota_overrides — персональный лимит вместо negative-used хака
|
||||||
|
(h) insufficient_data результат НЕ инкрементит квоту (geocode-fail не сжигает слот)
|
||||||
|
|
||||||
DB мокируется через MagicMock — реальная БД не требуется.
|
DB мокируется через MagicMock — реальная БД не требуется.
|
||||||
"""
|
"""
|
||||||
|
|
@ -14,7 +17,9 @@ from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from unittest.mock import MagicMock
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
# psycopg v3 driver required; stub DATABASE_URL before any app import
|
# psycopg v3 driver required; stub DATABASE_URL before any app import
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
@ -29,6 +34,7 @@ import pytest # noqa: E402
|
||||||
from fastapi import FastAPI # noqa: E402
|
from fastapi import FastAPI # noqa: E402
|
||||||
from fastapi.testclient import TestClient # noqa: E402
|
from fastapi.testclient import TestClient # noqa: E402
|
||||||
|
|
||||||
|
from app.schemas.trade_in import AggregatedEstimate # noqa: E402
|
||||||
from app.services.account_quota import ( # noqa: E402
|
from app.services.account_quota import ( # noqa: E402
|
||||||
LIMIT_EXHAUSTED_MESSAGE,
|
LIMIT_EXHAUSTED_MESSAGE,
|
||||||
MONTHLY_LIMIT,
|
MONTHLY_LIMIT,
|
||||||
|
|
@ -36,6 +42,7 @@ from app.services.account_quota import ( # noqa: E402
|
||||||
get_status,
|
get_status,
|
||||||
increment,
|
increment,
|
||||||
is_unlimited,
|
is_unlimited,
|
||||||
|
user_limit,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -43,23 +50,54 @@ from app.services.account_quota import ( # noqa: E402
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _db_with_used(used: int) -> MagicMock:
|
class _Row:
|
||||||
"""DB session mock whose execute().fetchone() returns row with .used = N."""
|
"""Row stand-in поддерживающий и .used/.monthly_limit, и индексный доступ row[0]."""
|
||||||
row = MagicMock()
|
|
||||||
row.used = used
|
def __init__(self, value: int) -> None:
|
||||||
execute_result = MagicMock()
|
self.used = value
|
||||||
execute_result.fetchone.return_value = row
|
self.monthly_limit = value
|
||||||
|
self._t = (value,)
|
||||||
|
|
||||||
|
def __getitem__(self, i: int) -> int:
|
||||||
|
return self._t[i]
|
||||||
|
|
||||||
|
|
||||||
|
def _result(row: _Row | None) -> MagicMock:
|
||||||
|
result = MagicMock()
|
||||||
|
result.fetchone.return_value = row
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _override_result(limit: int | None) -> MagicMock:
|
||||||
|
"""Mock результата запроса account_quota_overrides.monthly_limit (user_limit())."""
|
||||||
|
return _result(None if limit is None else _Row(limit))
|
||||||
|
|
||||||
|
|
||||||
|
def _used_result(used: int | None) -> MagicMock:
|
||||||
|
"""Mock результата запроса account_estimate_usage.used (или UPSERT RETURNING used)."""
|
||||||
|
return _result(None if used is None else _Row(used))
|
||||||
|
|
||||||
|
|
||||||
|
def _db_with_used(used: int, *, override_limit: int | None = None) -> MagicMock:
|
||||||
|
"""DB session mock для get_status/check_and_raise: ровно 2 execute() —
|
||||||
|
(1) user_limit() override lookup, (2) account_estimate_usage.used lookup."""
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
db.execute.return_value = execute_result
|
db.execute.side_effect = [_override_result(override_limit), _used_result(used)]
|
||||||
return db
|
return db
|
||||||
|
|
||||||
|
|
||||||
def _db_no_row() -> MagicMock:
|
def _db_no_row(*, override_limit: int | None = None) -> MagicMock:
|
||||||
"""DB session mock where no row exists yet (first estimate of the month)."""
|
"""DB session mock где строки usage ещё нет (первая оценка месяца)."""
|
||||||
execute_result = MagicMock()
|
|
||||||
execute_result.fetchone.return_value = None
|
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
db.execute.return_value = execute_result
|
db.execute.side_effect = [_override_result(override_limit), _used_result(None)]
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
def _db_for_increment(*, override_limit: int | None, upsert_used: int | None) -> MagicMock:
|
||||||
|
"""DB session mock для increment(): (1) user_limit() override lookup,
|
||||||
|
(2) UPSERT ... RETURNING used (None если WHERE used < lim не матчит)."""
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute.side_effect = [_override_result(override_limit), _used_result(upsert_used)]
|
||||||
return db
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -127,7 +165,7 @@ def test_check_and_raise_pilot_not_blocked_at_14() -> None:
|
||||||
def test_check_and_raise_pilot_not_blocked_at_15_boundary() -> None:
|
def test_check_and_raise_pilot_not_blocked_at_15_boundary() -> None:
|
||||||
"""used=15 == MONTHLY_LIMIT → 429 (15-я — последняя допустимая, 16-я блокируется).
|
"""used=15 == MONTHLY_LIMIT → 429 (15-я — последняя допустимая, 16-я блокируется).
|
||||||
|
|
||||||
Логика: used >= MONTHLY_LIMIT → block. После 15-й успешной оценки
|
Логика: used >= limit → block. После 15-й успешной оценки
|
||||||
increment делает used=15, поэтому следующий запрос (16-й) блокируется.
|
increment делает used=15, поэтому следующий запрос (16-й) блокируется.
|
||||||
"""
|
"""
|
||||||
db = _db_with_used(MONTHLY_LIMIT)
|
db = _db_with_used(MONTHLY_LIMIT)
|
||||||
|
|
@ -153,7 +191,7 @@ def test_check_and_raise_pilot_blocked_exact_detail() -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_check_and_raise_pilot_blocked_over_limit() -> None:
|
def test_check_and_raise_pilot_blocked_over_limit() -> None:
|
||||||
"""used=20 тоже блокируется."""
|
"""used=20 тоже блокируется (нет override → глобальный лимит)."""
|
||||||
db = _db_with_used(20)
|
db = _db_with_used(20)
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
|
@ -168,14 +206,15 @@ def test_check_and_raise_pilot_blocked_over_limit() -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_increment_pilot_calls_upsert() -> None:
|
def test_increment_pilot_calls_upsert() -> None:
|
||||||
"""increment для pilot → выполняет db.execute (UPSERT) и db.commit."""
|
"""increment для pilot → выполняет user_limit lookup + UPSERT (2 execute), 1 commit."""
|
||||||
db = MagicMock()
|
db = _db_for_increment(override_limit=None, upsert_used=1)
|
||||||
increment(db, "user1")
|
result = increment(db, "user1")
|
||||||
db.execute.assert_called_once()
|
assert result is True
|
||||||
|
assert db.execute.call_count == 2
|
||||||
db.commit.assert_called_once()
|
db.commit.assert_called_once()
|
||||||
|
|
||||||
# Проверяем что SQL содержит ON CONFLICT ... DO UPDATE
|
# Второй вызов — UPSERT; проверяем что SQL содержит ON CONFLICT ... DO UPDATE
|
||||||
call_args = db.execute.call_args
|
call_args = db.execute.call_args_list[1]
|
||||||
sql_text = str(call_args[0][0]) # first positional arg — text() object
|
sql_text = str(call_args[0][0]) # first positional arg — text() object
|
||||||
assert "ON CONFLICT" in sql_text
|
assert "ON CONFLICT" in sql_text
|
||||||
assert "used" in sql_text
|
assert "used" in sql_text
|
||||||
|
|
@ -190,9 +229,9 @@ def test_increment_none_username_is_noop() -> None:
|
||||||
|
|
||||||
def test_increment_pilot_first_estimate_of_month() -> None:
|
def test_increment_pilot_first_estimate_of_month() -> None:
|
||||||
"""Первый инкремент (нет строки в БД) — должен всё равно выполнить UPSERT."""
|
"""Первый инкремент (нет строки в БД) — должен всё равно выполнить UPSERT."""
|
||||||
db = MagicMock()
|
db = _db_for_increment(override_limit=None, upsert_used=1)
|
||||||
increment(db, "user5")
|
increment(db, "user5")
|
||||||
db.execute.assert_called_once()
|
assert db.execute.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -212,7 +251,7 @@ def test_get_status_none_username() -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_get_status_admin() -> None:
|
def test_get_status_admin() -> None:
|
||||||
"""admin → unlimited, remaining=15 вне зависимости от used."""
|
"""admin → unlimited, remaining=limit вне зависимости от used."""
|
||||||
db = _db_with_used(7)
|
db = _db_with_used(7)
|
||||||
status = get_status(db, "admin")
|
status = get_status(db, "admin")
|
||||||
assert status["unlimited"] is True
|
assert status["unlimited"] is True
|
||||||
|
|
@ -396,18 +435,7 @@ def test_estimate_no_header_not_blocked(estimate_app_exhausted: FastAPI) -> None
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class _Row:
|
class _AtomicResult:
|
||||||
"""Row stand-in поддерживающий и .used, и индексный доступ row[0]."""
|
|
||||||
|
|
||||||
def __init__(self, used: int) -> None:
|
|
||||||
self.used = used
|
|
||||||
self._t = (used,)
|
|
||||||
|
|
||||||
def __getitem__(self, i: int) -> int:
|
|
||||||
return self._t[i]
|
|
||||||
|
|
||||||
|
|
||||||
class _Result:
|
|
||||||
def __init__(self, row: _Row | None) -> None:
|
def __init__(self, row: _Row | None) -> None:
|
||||||
self._row = row
|
self._row = row
|
||||||
|
|
||||||
|
|
@ -416,28 +444,33 @@ class _Result:
|
||||||
|
|
||||||
|
|
||||||
class _AtomicQuotaFakeDB:
|
class _AtomicQuotaFakeDB:
|
||||||
"""In-memory fake, моделирующий атомарный conditional UPSERT (#747).
|
"""In-memory fake, воспроизводящий user_limit() override-lookup + атомарный
|
||||||
|
conditional UPSERT (#747):
|
||||||
Воспроизводит семантику `INSERT ... ON CONFLICT DO UPDATE SET used=used+1
|
- execute() без ключа "lim" в params → user_limit() override lookup —
|
||||||
WHERE used < :lim RETURNING used`:
|
возвращает override_limit (или None → increment() падает на глобальный
|
||||||
- строки ещё нет (used=None) → INSERT used=1, RETURNING row (WHERE не для INSERT);
|
MONTHLY_LIMIT как :lim в следующем вызове).
|
||||||
- used < lim → used+=1, RETURNING row;
|
- execute() с ключом "lim" → UPSERT: строки ещё нет (used=None) → INSERT used=1,
|
||||||
- used >= lim → конфликтная строка НЕ обновлена, RETURNING пуст (None).
|
RETURNING row (WHERE не применяется к INSERT); used < lim → used+=1, RETURNING
|
||||||
|
row; used >= lim → конфликтная строка НЕ обновлена, RETURNING пуст (None).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, *, used: int | None) -> None:
|
def __init__(self, *, used: int | None, override_limit: int | None = None) -> None:
|
||||||
self.used = used
|
self.used = used
|
||||||
|
self.override_limit = override_limit
|
||||||
self.commits = 0
|
self.commits = 0
|
||||||
|
|
||||||
def execute(self, _stmt: object, params: dict) -> _Result:
|
def execute(self, _stmt: object, params: dict) -> _AtomicResult:
|
||||||
|
if "lim" not in params:
|
||||||
|
row = _Row(self.override_limit) if self.override_limit is not None else None
|
||||||
|
return _AtomicResult(row)
|
||||||
lim = params["lim"]
|
lim = params["lim"]
|
||||||
if self.used is None:
|
if self.used is None:
|
||||||
self.used = 1
|
self.used = 1
|
||||||
elif self.used < lim:
|
elif self.used < lim:
|
||||||
self.used += 1
|
self.used += 1
|
||||||
else:
|
else:
|
||||||
return _Result(None)
|
return _AtomicResult(None)
|
||||||
return _Result(_Row(self.used))
|
return _AtomicResult(_Row(self.used))
|
||||||
|
|
||||||
def commit(self) -> None:
|
def commit(self) -> None:
|
||||||
self.commits += 1
|
self.commits += 1
|
||||||
|
|
@ -472,3 +505,207 @@ def test_increment_atomic_unlimited_noop_returns_true() -> None:
|
||||||
assert increment(db, "admin") is True
|
assert increment(db, "admin") is True
|
||||||
assert db.commits == 0
|
assert db.commits == 0
|
||||||
assert db.used == MONTHLY_LIMIT # не тронут
|
assert db.used == MONTHLY_LIMIT # не тронут
|
||||||
|
|
||||||
|
|
||||||
|
def test_increment_atomic_respects_override_limit() -> None:
|
||||||
|
"""override=50 (user2): used=49 → increment True (used→50); used=50 → False.
|
||||||
|
Глобальный MONTHLY_LIMIT (15) НЕ применяется — используется персональный override."""
|
||||||
|
db = _AtomicQuotaFakeDB(used=49, override_limit=50)
|
||||||
|
assert increment(db, "user2") is True
|
||||||
|
assert db.used == 50
|
||||||
|
assert increment(db, "user2") is False
|
||||||
|
assert db.used == 50 # не 51
|
||||||
|
|
||||||
|
|
||||||
|
def test_increment_atomic_override_below_global_blocks_early() -> None:
|
||||||
|
"""override=5 (ниже глобального 15): used=5 уже блокирует, хотя < MONTHLY_LIMIT.
|
||||||
|
|
||||||
|
Демонстрирует что per-user override заменяет глобальный лимит полностью —
|
||||||
|
не является дополнительным потолком поверх него.
|
||||||
|
"""
|
||||||
|
db = _AtomicQuotaFakeDB(used=5, override_limit=5)
|
||||||
|
assert increment(db, "user_low_override") is False
|
||||||
|
assert db.used == 5 # не выросло
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# (g) account_quota_overrides — персональный лимит (замена negative-used хака)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_limit_no_override_returns_global() -> None:
|
||||||
|
"""Нет строки в account_quota_overrides → user_limit() возвращает MONTHLY_LIMIT."""
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute.return_value = _override_result(None)
|
||||||
|
assert user_limit(db, "user1") == MONTHLY_LIMIT
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_limit_with_override_returns_override() -> None:
|
||||||
|
"""Есть строка override → user_limit() возвращает monthly_limit из неё."""
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute.return_value = _override_result(50)
|
||||||
|
assert user_limit(db, "user2") == 50
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_status_override_limit() -> None:
|
||||||
|
"""user2 с override=50, used=0 (после сброса хака) → limit=50, remaining=50."""
|
||||||
|
db = _db_with_used(0, override_limit=50)
|
||||||
|
status = get_status(db, "user2")
|
||||||
|
assert status["limit"] == 50
|
||||||
|
assert status["remaining"] == 50
|
||||||
|
assert status["used"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_status_override_remaining_clamped_even_if_used_negative() -> None:
|
||||||
|
"""Кламп: даже если used снова просочится отрицательным (regression прежнего
|
||||||
|
negative-used хака), remaining НЕ превышает limit — не «Осталось 50 из 15»."""
|
||||||
|
db = _db_with_used(-35, override_limit=50)
|
||||||
|
status = get_status(db, "user2")
|
||||||
|
assert status["limit"] == 50
|
||||||
|
assert status["used"] == -35 # raw used не скрываем — диагностическая честность
|
||||||
|
assert status["remaining"] == 50 # clamp: max(0, 50 - max(0, -35)) == 50
|
||||||
|
assert status["remaining"] <= status["limit"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_and_raise_override_blocks_below_global_limit() -> None:
|
||||||
|
"""override=5 (ниже глобального 15) — used=5 блокируется, хотя < MONTHLY_LIMIT."""
|
||||||
|
db = _db_with_used(5, override_limit=5)
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
check_and_raise(db, "user_low_override")
|
||||||
|
assert exc_info.value.status_code == 429
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_and_raise_override_allows_above_global_limit() -> None:
|
||||||
|
"""override=50 — used=20 (> глобального 15) НЕ блокируется."""
|
||||||
|
db = _db_with_used(20, override_limit=50)
|
||||||
|
check_and_raise(db, "user2") # не должно поднять исключение
|
||||||
|
|
||||||
|
|
||||||
|
def test_increment_override_blocks_at_override_not_global() -> None:
|
||||||
|
"""increment уважает per-user override: used>=override → False, даже если
|
||||||
|
used < MONTHLY_LIMIT (15)."""
|
||||||
|
db = _db_for_increment(override_limit=5, upsert_used=None) # WHERE used<5 не матчит
|
||||||
|
assert increment(db, "user_low_override") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_increment_override_allows_above_global_limit() -> None:
|
||||||
|
"""increment с override=50: used=20 (>15 глобального) успешно инкрементит."""
|
||||||
|
db = _db_for_increment(override_limit=50, upsert_used=21)
|
||||||
|
assert increment(db, "user2") is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# (h) insufficient_data результат НЕ инкрементит квоту (geocode-fail не сжигает слот)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _insufficient_estimate() -> AggregatedEstimate:
|
||||||
|
"""Пустой результат — как _empty_estimate() в estimator.py: median=0 → #697
|
||||||
|
computed_field insufficient_data=True."""
|
||||||
|
return AggregatedEstimate(
|
||||||
|
estimate_id=uuid4(),
|
||||||
|
median_price_rub=0,
|
||||||
|
range_low_rub=0,
|
||||||
|
range_high_rub=0,
|
||||||
|
median_price_per_m2=0,
|
||||||
|
confidence="low",
|
||||||
|
confidence_explanation="address_not_geocoded",
|
||||||
|
n_analogs=0,
|
||||||
|
period_months=24,
|
||||||
|
analogs=[],
|
||||||
|
actual_deals=[],
|
||||||
|
expires_at=datetime.now(tz=UTC) + timedelta(hours=24),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _real_estimate() -> AggregatedEstimate:
|
||||||
|
"""Непустой результат — insufficient_data=False (median_price_rub > 0)."""
|
||||||
|
return AggregatedEstimate(
|
||||||
|
estimate_id=uuid4(),
|
||||||
|
median_price_rub=5_000_000,
|
||||||
|
range_low_rub=4_500_000,
|
||||||
|
range_high_rub=5_500_000,
|
||||||
|
median_price_per_m2=100_000,
|
||||||
|
confidence="medium",
|
||||||
|
n_analogs=8,
|
||||||
|
period_months=24,
|
||||||
|
analogs=[],
|
||||||
|
actual_deals=[],
|
||||||
|
expires_at=datetime.now(tz=UTC) + timedelta(hours=24),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def estimate_app_ok() -> FastAPI:
|
||||||
|
"""FastAPI app где check_and_raise проходит (used=0, без override) —
|
||||||
|
estimate_quality мокается отдельно в каждом тесте."""
|
||||||
|
from app.api.v1 import trade_in as trade_in_module
|
||||||
|
from app.core.db import get_db
|
||||||
|
|
||||||
|
application = FastAPI()
|
||||||
|
application.include_router(trade_in_module.router, prefix="/api/v1/trade-in")
|
||||||
|
|
||||||
|
def _override_db():
|
||||||
|
yield _db_no_row()
|
||||||
|
|
||||||
|
application.dependency_overrides[get_db] = _override_db
|
||||||
|
return application
|
||||||
|
|
||||||
|
|
||||||
|
def test_estimate_insufficient_data_does_not_increment_quota(
|
||||||
|
estimate_app_ok: FastAPI,
|
||||||
|
) -> None:
|
||||||
|
"""POST /estimate с insufficient_data=True результатом (нерезолвящийся адрес) →
|
||||||
|
account_quota.increment НЕ вызывается (пустой результат не списывает платный слот)."""
|
||||||
|
client = TestClient(estimate_app_ok, raise_server_exceptions=False)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"app.services.estimator.estimate_quality",
|
||||||
|
new=AsyncMock(return_value=_insufficient_estimate()),
|
||||||
|
),
|
||||||
|
patch("app.services.account_quota.increment") as mock_increment,
|
||||||
|
):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/trade-in/estimate",
|
||||||
|
json={
|
||||||
|
"address": "г. Екатеринбург, несуществующий адрес xyz",
|
||||||
|
"area_m2": 50.0,
|
||||||
|
"rooms": 2,
|
||||||
|
},
|
||||||
|
headers={"X-Authenticated-User": "user1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["median_price_rub"] == 0
|
||||||
|
assert resp.json()["insufficient_data"] is True
|
||||||
|
mock_increment.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_estimate_real_result_still_increments_quota(estimate_app_ok: FastAPI) -> None:
|
||||||
|
"""Контрольный тест: непустой результат (insufficient_data=False) — increment
|
||||||
|
вызывается как обычно (регресс-guard, не ломаем #747 TOCTOU-семантику)."""
|
||||||
|
client = TestClient(estimate_app_ok, raise_server_exceptions=False)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"app.services.estimator.estimate_quality",
|
||||||
|
new=AsyncMock(return_value=_real_estimate()),
|
||||||
|
),
|
||||||
|
patch("app.services.account_quota.increment", return_value=True) as mock_increment,
|
||||||
|
):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/trade-in/estimate",
|
||||||
|
json={
|
||||||
|
"address": "г. Екатеринбург, ул. Малышева, 1",
|
||||||
|
"area_m2": 50.0,
|
||||||
|
"rooms": 2,
|
||||||
|
},
|
||||||
|
headers={"X-Authenticated-User": "user1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["insufficient_data"] is False
|
||||||
|
mock_increment.assert_called_once()
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,22 @@ def test_rederivation_cte_blocks_match_080() -> None:
|
||||||
assert f"{cte} AS" in _REDERIVE_SQL, f"missing CTE {cte!r}"
|
assert f"{cte} AS" in _REDERIVE_SQL, f"missing CTE {cte!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rederivation_scopes_sold_side_to_asking_city() -> None:
|
||||||
|
"""#C2: SOLD-сторона (deal_side + deal_global) скоупится на город asking-стороны (ЕКБ).
|
||||||
|
|
||||||
|
Миграция 177 залила ДКП по всей обл.66, а asking (listings) — только ЕКБ. Без скоупа
|
||||||
|
sold-медиана смешивала дешёвую область → ratio 0.877→0.62, «выкупная» −29%. Оба
|
||||||
|
deal-CTE (per-rooms + global) должны нести предикат; ask-стороны — НЕ трогаем.
|
||||||
|
"""
|
||||||
|
assert ratio_mod._ASKING_CITY_PATTERN == "%Екатеринбург%"
|
||||||
|
# Оба deal-CTE (deal_side + deal_global) скоупятся — ровно 2 вхождения.
|
||||||
|
assert _REDERIVE_SQL.count("AND city ILIKE :asking_city") == 2
|
||||||
|
# ask-стороны (listings) НЕ фильтруются по городу (в listings нет колонки city).
|
||||||
|
_a = _REDERIVE_SQL.index("ask_side AS")
|
||||||
|
_b = _REDERIVE_SQL.index("per_bucket AS")
|
||||||
|
assert "asking_city" not in _REDERIVE_SQL[_a:_b]
|
||||||
|
|
||||||
|
|
||||||
def _strip_sql(s: str) -> str:
|
def _strip_sql(s: str) -> str:
|
||||||
"""Drop -- line comments and collapse whitespace — leaves only the executable SQL.
|
"""Drop -- line comments and collapse whitespace — leaves only the executable SQL.
|
||||||
|
|
||||||
|
|
@ -159,9 +175,14 @@ def test_migration_080_derivation_is_subset_of_refresh_sql() -> None:
|
||||||
s,
|
s,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert _strip_sql(_normalise_ppm2(_drop_segment_guard(seed_derivation))) in _strip_sql(
|
def _drop_city_guard(s: str) -> str:
|
||||||
_normalise_ppm2(_drop_segment_guard(_REDERIVE_SQL))
|
"""Remove the #C2 EKB city-scope predicate on the SOLD side (absent in the 080 seed)."""
|
||||||
)
|
return re.sub(r"AND\s+city\s+ILIKE\s+:asking_city", "", s)
|
||||||
|
|
||||||
|
def _norm(s: str) -> str:
|
||||||
|
return _strip_sql(_normalise_ppm2(_drop_city_guard(_drop_segment_guard(s))))
|
||||||
|
|
||||||
|
assert _norm(seed_derivation) in _norm(_REDERIVE_SQL)
|
||||||
|
|
||||||
|
|
||||||
# ── Counters query ────────────────────────────────────────────────────────────
|
# ── Counters query ────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
344
tradein-mvp/backend/tests/test_audit_api.py
Normal file
344
tradein-mvp/backend/tests/test_audit_api.py
Normal file
|
|
@ -0,0 +1,344 @@
|
||||||
|
"""Tests for app.api.v1.audit — admin read API over `user_events` (Feature 2/3).
|
||||||
|
|
||||||
|
Coverage:
|
||||||
|
(a) static SQL guard — CAST(:x AS type), never `:x::type` (psycopg v3 trap).
|
||||||
|
(b) GET /audit/accounts — empty table → [] (never errors); populated → shape-valid
|
||||||
|
AccountSummary rows pass through response_model unchanged.
|
||||||
|
(c) GET /audit/accounts/{username} — empty table (incl. unknown username, NOT 404) →
|
||||||
|
all 4 lists == []; populated → shape-valid drilldown rows.
|
||||||
|
(d) GET /analytics — empty table → zeroed summary + empty lists (never errors);
|
||||||
|
populated → shape-valid dashboard bundle, `days` query param clamped [1, 365].
|
||||||
|
(e) optional real-Postgres round trip — inserts a couple of user_events rows and
|
||||||
|
asserts aggregation through the real DB; self-SKIPS without a reachable,
|
||||||
|
non-placeholder Postgres (mirrors tests/test_house_dedup_merge.py's live-DB
|
||||||
|
pattern).
|
||||||
|
|
||||||
|
Router is tested in isolation on a minimal FastAPI app (mirrors tests/test_ratelimit.py
|
||||||
|
and the quota_app fixture in tests/test_user_events.py) — no need to pull in the full
|
||||||
|
app.main (sentry/scheduler/CORS/rate-limit wiring).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, date, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# psycopg v3 driver required; stub DATABASE_URL before any app import (mirrors other tests).
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.api.v1 import audit as audit_module
|
||||||
|
from app.core.db import get_db
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# (a) Static SQL guard
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_AUDIT_SRC = inspect.getsource(audit_module)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_psycopg_v3_doublecolon_cast() -> None:
|
||||||
|
"""CAST(:x AS type) — НИКОГДА `:x::type` (psycopg v3 trap, .claude/rules/backend.md)."""
|
||||||
|
assert not re.search(r":\w+::\w", _AUDIT_SRC)
|
||||||
|
|
||||||
|
|
||||||
|
def test_days_param_uses_cast_as_int() -> None:
|
||||||
|
assert "CAST(:days AS int)" in _AUDIT_SRC
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fakes — mirror the mocked-DB convention used across tests/test_user_events.py etc.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeMappingResult:
|
||||||
|
def __init__(self, rows: list[dict[str, Any]]) -> None:
|
||||||
|
self._rows = rows
|
||||||
|
|
||||||
|
def all(self) -> list[dict[str, Any]]:
|
||||||
|
return list(self._rows)
|
||||||
|
|
||||||
|
def one(self) -> dict[str, Any]:
|
||||||
|
return self._rows[0]
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeExecResult:
|
||||||
|
def __init__(self, rows: list[dict[str, Any]]) -> None:
|
||||||
|
self._rows = rows
|
||||||
|
|
||||||
|
def mappings(self) -> _FakeMappingResult:
|
||||||
|
return _FakeMappingResult(self._rows)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeSession:
|
||||||
|
"""Returns queued row-lists in the exact order audit.py issues db.execute() calls."""
|
||||||
|
|
||||||
|
def __init__(self, responses: list[list[dict[str, Any]]]) -> None:
|
||||||
|
self._responses = list(responses)
|
||||||
|
self._i = 0
|
||||||
|
|
||||||
|
def execute(self, _stmt: object, _params: dict[str, Any] | None = None) -> _FakeExecResult:
|
||||||
|
rows = self._responses[self._i]
|
||||||
|
self._i += 1
|
||||||
|
return _FakeExecResult(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_app(responses: list[list[dict[str, Any]]]) -> FastAPI:
|
||||||
|
application = FastAPI()
|
||||||
|
application.include_router(audit_module.router, prefix="/api/v1/admin")
|
||||||
|
|
||||||
|
def _override_db() -> Any:
|
||||||
|
yield _FakeSession(responses)
|
||||||
|
|
||||||
|
application.dependency_overrides[get_db] = _override_db
|
||||||
|
return application
|
||||||
|
|
||||||
|
|
||||||
|
_NOW = datetime(2026, 7, 13, 12, 0, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# (b) GET /audit/accounts
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_accounts_empty_table() -> None:
|
||||||
|
app = _make_app(responses=[[]])
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.get("/api/v1/admin/audit/accounts")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_accounts_populated_shape() -> None:
|
||||||
|
row = {
|
||||||
|
"username": "user1",
|
||||||
|
"first_seen_at": _NOW,
|
||||||
|
"last_seen_at": _NOW,
|
||||||
|
"distinct_ips": 3,
|
||||||
|
"distinct_devices": 2,
|
||||||
|
"login_count": 5,
|
||||||
|
"request_count": 40,
|
||||||
|
"search_count": 7,
|
||||||
|
}
|
||||||
|
app = _make_app(responses=[[row]])
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.get("/api/v1/admin/audit/accounts")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert len(data) == 1
|
||||||
|
assert data[0]["username"] == "user1"
|
||||||
|
assert data[0]["distinct_ips"] == 3
|
||||||
|
assert data[0]["login_count"] == 5
|
||||||
|
assert data[0]["request_count"] == 40
|
||||||
|
assert data[0]["search_count"] == 7
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# (c) GET /audit/accounts/{username}
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_account_drilldown_empty_table_unknown_user_not_404() -> None:
|
||||||
|
"""Unknown username → 200 with all 4 lists empty, NOT a 404."""
|
||||||
|
app = _make_app(responses=[[], [], [], []])
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.get("/api/v1/admin/audit/accounts/ghost_user_xyz")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data == {"ips": [], "devices": [], "searches": [], "recent_activity": []}
|
||||||
|
|
||||||
|
|
||||||
|
def test_account_drilldown_populated_shape() -> None:
|
||||||
|
ips = [{"ip_address": "1.2.3.4", "event_count": 10, "first_seen": _NOW, "last_seen": _NOW}]
|
||||||
|
devices = [
|
||||||
|
{"user_agent": "pytest-agent", "event_count": 10, "first_seen": _NOW, "last_seen": _NOW}
|
||||||
|
]
|
||||||
|
searches = [
|
||||||
|
{
|
||||||
|
"address": "ул. Малышева, 1",
|
||||||
|
"area_m2": "50.0",
|
||||||
|
"rooms": "2",
|
||||||
|
"estimate_id": str(uuid.uuid4()),
|
||||||
|
"ip_address": "1.2.3.4",
|
||||||
|
"created_at": _NOW,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
recent_activity = [
|
||||||
|
{
|
||||||
|
"event_type": "api_request",
|
||||||
|
"path": "/api/v1/trade-in/estimate",
|
||||||
|
"method": "POST",
|
||||||
|
"ip_address": "1.2.3.4",
|
||||||
|
"created_at": _NOW,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
app = _make_app(responses=[ips, devices, searches, recent_activity])
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.get("/api/v1/admin/audit/accounts/user1")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["ips"][0]["ip_address"] == "1.2.3.4"
|
||||||
|
assert data["ips"][0]["event_count"] == 10
|
||||||
|
assert data["devices"][0]["user_agent"] == "pytest-agent"
|
||||||
|
assert data["searches"][0]["address"] == "ул. Малышева, 1"
|
||||||
|
assert data["searches"][0]["area_m2"] == "50.0"
|
||||||
|
assert data["recent_activity"][0]["event_type"] == "api_request"
|
||||||
|
assert data["recent_activity"][0]["path"] == "/api/v1/trade-in/estimate"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# (d) GET /analytics
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_ZERO_SUMMARY = {
|
||||||
|
"total_events": 0,
|
||||||
|
"distinct_users": 0,
|
||||||
|
"events_last_24h": 0,
|
||||||
|
"active_users_last_24h": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_analytics_empty_table_never_errors() -> None:
|
||||||
|
app = _make_app(responses=[[_ZERO_SUMMARY], [], [], [], []])
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.get("/api/v1/admin/analytics")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["summary"] == _ZERO_SUMMARY
|
||||||
|
assert data["daily"] == []
|
||||||
|
assert data["top_searches"] == []
|
||||||
|
assert data["top_paths"] == []
|
||||||
|
assert data["by_account"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_analytics_populated_shape() -> None:
|
||||||
|
summary = {
|
||||||
|
"total_events": 120,
|
||||||
|
"distinct_users": 4,
|
||||||
|
"events_last_24h": 15,
|
||||||
|
"active_users_last_24h": 2,
|
||||||
|
}
|
||||||
|
daily = [{"day": date(2026, 7, 13), "events": 15, "users": 2}]
|
||||||
|
top_searches = [{"address": "ул. Малышева, 1", "n": 3}]
|
||||||
|
top_paths = [{"path": "/api/v1/trade-in/estimate", "n": 40}]
|
||||||
|
by_account = [{"username": "user1", "events": 60, "searches": 7, "last_seen": _NOW}]
|
||||||
|
app = _make_app(responses=[[summary], daily, top_searches, top_paths, by_account])
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.get("/api/v1/admin/analytics")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["summary"]["total_events"] == 120
|
||||||
|
assert data["daily"][0]["events"] == 15
|
||||||
|
assert data["daily"][0]["day"] == "2026-07-13"
|
||||||
|
assert data["top_searches"][0]["address"] == "ул. Малышева, 1"
|
||||||
|
assert data["top_paths"][0]["n"] == 40
|
||||||
|
assert data["by_account"][0]["username"] == "user1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_analytics_days_query_param_clamped() -> None:
|
||||||
|
"""days=0 (below ge=1) → 422; days=9999 (above le=365) → 422."""
|
||||||
|
app = _make_app(responses=[[_ZERO_SUMMARY], [], [], [], []])
|
||||||
|
client = TestClient(app)
|
||||||
|
assert client.get("/api/v1/admin/analytics?days=0").status_code == 422
|
||||||
|
assert client.get("/api/v1/admin/analytics?days=9999").status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_analytics_days_query_param_default_30() -> None:
|
||||||
|
app = _make_app(responses=[[_ZERO_SUMMARY], [], [], [], []])
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.get("/api/v1/admin/analytics")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_analytics_days_query_param_accepted_in_range() -> None:
|
||||||
|
app = _make_app(responses=[[_ZERO_SUMMARY], [], [], [], []])
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.get("/api/v1/admin/analytics?days=7")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# (e) Optional real-Postgres round trip (self-skips without a reachable DB)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _live_session() -> Any | None:
|
||||||
|
"""Return a SQLAlchemy Session if a non-placeholder Postgres is reachable, else None."""
|
||||||
|
try:
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy import text as _t
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
|
||||||
|
if not dsn or "localhost:5432/test" in dsn:
|
||||||
|
return None
|
||||||
|
engine = create_engine(dsn, future=True)
|
||||||
|
conn = engine.connect()
|
||||||
|
conn.execute(_t("SELECT 1"))
|
||||||
|
conn.close()
|
||||||
|
return sessionmaker(bind=engine, future=True)()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
|
||||||
|
def test_real_accounts_and_analytics_aggregate_inserted_rows() -> None:
|
||||||
|
"""End-to-end on a real DB: insert 2 user_events rows for a throwaway test username,
|
||||||
|
assert /audit/accounts, /audit/accounts/{username} and /analytics reflect them."""
|
||||||
|
from sqlalchemy import text as _t
|
||||||
|
|
||||||
|
from app.core.db import SessionLocal
|
||||||
|
|
||||||
|
db = _live_session()
|
||||||
|
assert db is not None
|
||||||
|
username = f"audit_test_{uuid.uuid4().hex[:8]}"
|
||||||
|
try:
|
||||||
|
db.execute(
|
||||||
|
_t(
|
||||||
|
"""
|
||||||
|
INSERT INTO user_events
|
||||||
|
(event_type, username, ip_address, user_agent, path, method, payload)
|
||||||
|
VALUES
|
||||||
|
('login', :u, CAST('9.9.9.9' AS inet), 'pytest-ua', NULL, NULL,
|
||||||
|
CAST('{}' AS jsonb)),
|
||||||
|
('estimate_request', :u, CAST('9.9.9.9' AS inet), 'pytest-ua',
|
||||||
|
'/api/v1/trade-in/estimate', 'POST',
|
||||||
|
CAST(:payload AS jsonb))
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"u": username, "payload": '{"address": "ул. Тестовая, 1", "area_m2": "42.0"}'},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
application = FastAPI()
|
||||||
|
application.include_router(audit_module.router, prefix="/api/v1/admin")
|
||||||
|
|
||||||
|
def _override_db() -> Any:
|
||||||
|
yield SessionLocal()
|
||||||
|
|
||||||
|
application.dependency_overrides[get_db] = _override_db
|
||||||
|
client = TestClient(application)
|
||||||
|
|
||||||
|
accounts = client.get("/api/v1/admin/audit/accounts").json()
|
||||||
|
row = next(r for r in accounts if r["username"] == username)
|
||||||
|
assert row["login_count"] == 1
|
||||||
|
assert row["search_count"] == 1
|
||||||
|
|
||||||
|
drilldown = client.get(f"/api/v1/admin/audit/accounts/{username}").json()
|
||||||
|
assert len(drilldown["searches"]) == 1
|
||||||
|
assert drilldown["searches"][0]["address"] == "ул. Тестовая, 1"
|
||||||
|
assert len(drilldown["recent_activity"]) == 2
|
||||||
|
|
||||||
|
dashboard = client.get("/api/v1/admin/analytics?days=1").json()
|
||||||
|
assert dashboard["summary"]["total_events"] >= 2
|
||||||
|
finally:
|
||||||
|
db.rollback()
|
||||||
|
db.execute(_t("DELETE FROM user_events WHERE username = :u"), {"u": username})
|
||||||
|
db.commit()
|
||||||
84
tradein-mvp/backend/tests/test_avito_serp_city_slug.py
Normal file
84
tradein-mvp/backend/tests/test_avito_serp_city_slug.py
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
"""Avito SERP city-slug filter (#2487 oblast rollout).
|
||||||
|
|
||||||
|
`_parse_html` drops padding cards ("по всей России") when `avito_serp_ekb_only`
|
||||||
|
is on. The kept city-slug used to be hardcoded `/ekaterinburg/`, so an oblast
|
||||||
|
per-city sweep (`_job_avito_city_sweep` with a `city` param) discarded 100% of the
|
||||||
|
target city's cards. The slug is now parameterized via `AvitoScraper.target_city_slug`
|
||||||
|
(default → "ekaterinburg", i.e. ЕКБ behavior unchanged when no target is set).
|
||||||
|
|
||||||
|
No network / no DB — `_parse_html` runs on inline HTML with a stub config.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
from scraper_kit.providers.avito.serp import AvitoScraper
|
||||||
|
|
||||||
|
_BASE_URL = "https://www.avito.ru/ekaterinburg/kvartiry/prodam-ASgBAgICAUSSA8YQ?p=1"
|
||||||
|
|
||||||
|
|
||||||
|
def _card(slug: str, item_id: str, price: int = 5_000_000) -> str:
|
||||||
|
"""Minimal Avito SERP card whose per-card href carries `slug` as the city segment."""
|
||||||
|
href = f"/{slug}/kvartiry/2k_kvartira_50_m_5_5et_{item_id}"
|
||||||
|
return (
|
||||||
|
f'<div data-marker="item" data-item-id="{item_id}">'
|
||||||
|
f'<a data-marker="item-title" href="{href}">2-к. квартира, 50 м², 5/9 эт.</a>'
|
||||||
|
f'<meta itemprop="price" content="{price}">'
|
||||||
|
f"</div>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _html(*cards: str) -> str:
|
||||||
|
return "<html><body>" + "".join(cards) + "</body></html>"
|
||||||
|
|
||||||
|
|
||||||
|
def _ekb_only_cfg() -> SimpleNamespace:
|
||||||
|
# _parse_html only reads config.avito_serp_ekb_only; a stub keeps the test
|
||||||
|
# deterministic regardless of AVITO_SERP_EKB_ONLY env in the runner.
|
||||||
|
return SimpleNamespace(avito_serp_ekb_only=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_keeps_target_oblast_city_drops_off_target_padding() -> None:
|
||||||
|
"""Sweep targeting Н.Тагил keeps its cards; ЕКБ/Москва padding is dropped."""
|
||||||
|
s = AvitoScraper(_ekb_only_cfg(), target_city_slug="nizhniy_tagil") # type: ignore[arg-type]
|
||||||
|
html = _html(
|
||||||
|
_card("nizhniy_tagil", "nt1"), # target city → KEPT
|
||||||
|
_card("ekaterinburg", "ekb1"), # off-target padding → DROPPED
|
||||||
|
_card("moskva", "msk1"), # off-target padding → DROPPED
|
||||||
|
)
|
||||||
|
lots = s._parse_html(html, source_url_base=_BASE_URL)
|
||||||
|
urls = [lot.source_url or "" for lot in lots]
|
||||||
|
|
||||||
|
assert len(lots) == 1, urls
|
||||||
|
assert "/nizhniy_tagil/" in urls[0]
|
||||||
|
assert all("/ekaterinburg/" not in u for u in urls)
|
||||||
|
assert all("/moskva/" not in u for u in urls)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_default_target_is_ekb_and_drops_oblast_padding() -> None:
|
||||||
|
"""No target (ЕКБ sweep/full-load): kept slug defaults to 'ekaterinburg' — behavior
|
||||||
|
identical to the previous hardcode. ЕКБ card kept, oblast padding dropped."""
|
||||||
|
s = AvitoScraper(_ekb_only_cfg(), target_city_slug=None) # type: ignore[arg-type]
|
||||||
|
html = _html(
|
||||||
|
_card("ekaterinburg", "e1"), # ЕКБ → KEPT
|
||||||
|
_card("nizhniy_tagil", "nt1"), # off-target padding → DROPPED
|
||||||
|
)
|
||||||
|
lots = s._parse_html(html, source_url_base=_BASE_URL)
|
||||||
|
urls = [lot.source_url or "" for lot in lots]
|
||||||
|
|
||||||
|
assert len(lots) == 1, urls
|
||||||
|
assert "/ekaterinburg/" in urls[0]
|
||||||
|
assert all("/nizhniy_tagil/" not in u for u in urls)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_ekb_only_disabled_keeps_all_cities() -> None:
|
||||||
|
"""avito_serp_ekb_only=False → no city filter at all, every card is kept."""
|
||||||
|
cfg = SimpleNamespace(avito_serp_ekb_only=False)
|
||||||
|
s = AvitoScraper(cfg, target_city_slug="nizhniy_tagil") # type: ignore[arg-type]
|
||||||
|
html = _html(_card("nizhniy_tagil", "nt1"), _card("ekaterinburg", "e1"))
|
||||||
|
lots = s._parse_html(html, source_url_base=_BASE_URL)
|
||||||
|
assert len(lots) == 2
|
||||||
|
|
@ -832,3 +832,177 @@ def test_render_full_table_handles_empty_sample() -> None:
|
||||||
out = bt._render_full_table(m)
|
out = bt._render_full_table(m)
|
||||||
assert "n/a" in out # None metrics render as n/a, no crash
|
assert "n/a" in out # None metrics render as n/a, no crash
|
||||||
assert "BACKTEST" in out
|
assert "BACKTEST" in out
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Oblast D — `--city` deal-sample scoping (parse + SQL predicate + PPM2 band).
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_argparse_city_defaults_none() -> None:
|
||||||
|
assert bt._parse_args([]).city is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_argparse_city_override() -> None:
|
||||||
|
ns = bt._parse_args(["--city", "Нижний Тагил"])
|
||||||
|
assert ns.city == "Нижний Тагил"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sample_sql_default_is_unscoped_and_same_object() -> None:
|
||||||
|
# city=None must return the SAME _SAMPLE_SQL object used before oblast D —
|
||||||
|
# literal identity, not just equal text — so the default CLI invocation (and
|
||||||
|
# the frozen EKB regression gate, which never calls this path at all) see a
|
||||||
|
# byte-identical query.
|
||||||
|
assert bt._sample_sql(None) is bt._SAMPLE_SQL
|
||||||
|
|
||||||
|
|
||||||
|
def test_sample_sql_default_has_no_city_predicate() -> None:
|
||||||
|
assert ":city" not in bt._SAMPLE_SQL.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_sample_sql_city_scoped_adds_city_predicate() -> None:
|
||||||
|
sql = bt._sample_sql("Нижний Тагил")
|
||||||
|
assert sql is not bt._SAMPLE_SQL
|
||||||
|
built = sql.text
|
||||||
|
assert "AND city = CAST(:city AS text)" in built
|
||||||
|
# City-scoping is additive — the base predicates are still present.
|
||||||
|
assert "source = 'rosreestr'" in built
|
||||||
|
assert "deal_date >= CAST(:since AS date)" in built
|
||||||
|
assert "ORDER BY id DESC" in built
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Oblast D — _resolve_city_ppm2_band (per-city PPM2 sanity band, no live DB).
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeBandResult:
|
||||||
|
"""Minimal stand-in for a SQLAlchemy Result exposing .mappings().first()."""
|
||||||
|
|
||||||
|
def __init__(self, row: dict[str, object] | None) -> None:
|
||||||
|
self._row = row
|
||||||
|
|
||||||
|
def mappings(self) -> "_FakeBandResult":
|
||||||
|
return self
|
||||||
|
|
||||||
|
def first(self) -> dict[str, object] | None:
|
||||||
|
return self._row
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeBandSession:
|
||||||
|
"""Minimal stand-in for a Session — only .execute() is exercised here."""
|
||||||
|
|
||||||
|
def __init__(self, row: dict[str, object] | None = None, *, raise_exc: bool = False) -> None:
|
||||||
|
self._row = row
|
||||||
|
self._raise = raise_exc
|
||||||
|
|
||||||
|
def execute(self, *_args: object, **_kwargs: object) -> _FakeBandResult:
|
||||||
|
if self._raise:
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
return _FakeBandResult(self._row)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_city_ppm2_band_none_city_returns_globals_no_query() -> None:
|
||||||
|
# city=None must not even touch the DB (no _FakeBandSession.execute call).
|
||||||
|
class _NoExecuteSession:
|
||||||
|
def execute(self, *_a: object, **_kw: object) -> None:
|
||||||
|
raise AssertionError("must not query DB when city is None")
|
||||||
|
|
||||||
|
assert bt._resolve_city_ppm2_band(_NoExecuteSession(), None) == (
|
||||||
|
float(bt.PPM2_MIN),
|
||||||
|
float(bt.PPM2_MAX),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_city_ppm2_band_found_row() -> None:
|
||||||
|
db = _FakeBandSession({"ppm2_min": 16955, "ppm2_max": 108175})
|
||||||
|
assert bt._resolve_city_ppm2_band(db, "Нижний Тагил") == (16955.0, 108175.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_city_ppm2_band_no_row_falls_back_to_globals() -> None:
|
||||||
|
db = _FakeBandSession(None)
|
||||||
|
assert bt._resolve_city_ppm2_band(db, "Екатеринбург") == (
|
||||||
|
float(bt.PPM2_MIN),
|
||||||
|
float(bt.PPM2_MAX),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_city_ppm2_band_db_error_falls_back_to_globals() -> None:
|
||||||
|
db = _FakeBandSession(raise_exc=True)
|
||||||
|
assert bt._resolve_city_ppm2_band(db, "Нижний Тагил") == (
|
||||||
|
float(bt.PPM2_MIN),
|
||||||
|
float(bt.PPM2_MAX),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Oblast C2 parity — _predict_full_spine must resolve + pass a target city to
|
||||||
|
# _fetch_dkp_corridor, mirroring estimate_quality (estimator.py:3210-3220).
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_predict_full_spine_passes_resolved_city_to_corridor(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from app.services import estimator as est_mod
|
||||||
|
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
|
def _fake_resolve_target_city(address: str | None) -> str | None:
|
||||||
|
return "нижний тагил"
|
||||||
|
|
||||||
|
def _fake_fetch_dkp_corridor(
|
||||||
|
_db: object,
|
||||||
|
*,
|
||||||
|
address: object,
|
||||||
|
rooms: object,
|
||||||
|
area: object,
|
||||||
|
city: object = None,
|
||||||
|
**_kw: object,
|
||||||
|
) -> None:
|
||||||
|
captured["called"] = True
|
||||||
|
captured["city"] = city
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _fake_price_from_inputs(**_kwargs: object) -> SimpleNamespace:
|
||||||
|
return SimpleNamespace(
|
||||||
|
median_price=100_000.0,
|
||||||
|
median_ppm2=100_000.0,
|
||||||
|
confidence="low",
|
||||||
|
anchor_tier=None,
|
||||||
|
expected_sold_per_m2=95_000.0,
|
||||||
|
expected_sold_price=4_750_000.0,
|
||||||
|
expected_sold_range_low=4_000_000.0,
|
||||||
|
expected_sold_range_high=5_500_000.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(est_mod, "_resolve_target_city", _fake_resolve_target_city)
|
||||||
|
monkeypatch.setattr(est_mod, "_fetch_dkp_corridor", _fake_fetch_dkp_corridor)
|
||||||
|
monkeypatch.setattr(est_mod, "_fetch_anchor_comps", lambda *a, **kw: ([], None))
|
||||||
|
monkeypatch.setattr(est_mod, "_fetch_house_imv_anchor", lambda *a, **kw: None)
|
||||||
|
monkeypatch.setattr(est_mod, "_price_from_inputs", _fake_price_from_inputs)
|
||||||
|
monkeypatch.setattr(bt, "_select_analogs_full", lambda *a, **kw: ([], "W", False, False))
|
||||||
|
|
||||||
|
est = bt._import_estimator_full()
|
||||||
|
deal = bt.DealSample(
|
||||||
|
id=1,
|
||||||
|
lon=60.6,
|
||||||
|
lat=58.05,
|
||||||
|
rooms=2,
|
||||||
|
sold_ppm2=90_000.0,
|
||||||
|
deal_date=None,
|
||||||
|
area_m2=50.0,
|
||||||
|
address="Нижний Тагил, ул. Ленина, 5",
|
||||||
|
floor=3,
|
||||||
|
total_floors=9,
|
||||||
|
year_built=2000,
|
||||||
|
house_type="панель",
|
||||||
|
)
|
||||||
|
|
||||||
|
pred = bt._predict_full_spine(None, deal, est)
|
||||||
|
|
||||||
|
assert captured.get("called") is True
|
||||||
|
assert captured.get("city") == "нижний тагил"
|
||||||
|
assert pred is not None
|
||||||
|
|
|
||||||
443
tradein-mvp/backend/tests/test_domrf_kapremont_loader.py
Normal file
443
tradein-mvp/backend/tests/test_domrf_kapremont_loader.py
Normal file
|
|
@ -0,0 +1,443 @@
|
||||||
|
"""Тесты ДОМ.РФ капремонт loader'а (app/services/domrf_kapremont_loader.py, мигр. 176, #2013).
|
||||||
|
|
||||||
|
Coverage:
|
||||||
|
- Чистые парс-хелперы: parse_decimal_comma / parse_int_field.
|
||||||
|
- parse_kr11_csv / parse_kr12_wall_materials — фикстуры собраны из РЕАЛЬНЫХ строк
|
||||||
|
региона 66 (export-kr1_1-66-20260701.csv / export-kr1_2-66-20260701.csv, дом
|
||||||
|
2170017185 «Ломоносова 3» и 2170025270 «Ломоносова 4»): КР1.2 long-format,
|
||||||
|
wall_material непустой только на строке construction_element_type='фасад'.
|
||||||
|
- build_domrf_houses — join КР1.1+КР1.2 по mkd_code.
|
||||||
|
- fetch_domrf_csvs — скачивание+распаковка zip, HTTP замокан через httpx.MockTransport
|
||||||
|
(никакой живой сети).
|
||||||
|
- Статические asserts по SQL: _UPSERT_SQL (ON CONFLICT, IS DISTINCT FROM, CAST),
|
||||||
|
backfill houses (COALESCE-порядок year_built/domrf/zhkh_year, only-fill-NULL),
|
||||||
|
propagate listings (только NULL listings.year_built).
|
||||||
|
- backfill_houses_from_domrf / propagate_listings_year_from_houses — dry_run вызывает
|
||||||
|
COUNT-SQL и не пишет, real-run суммирует rowcount обоих шагов (MagicMock db).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
# settings/SessionLocal не импортируем, но пакет app.* на импорте может тронуть DSN — как в
|
||||||
|
# sibling-тестах (test_zhkh_flats_loader) задаём заглушку (ядро статично/без реальной БД).
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services import domrf_kapremont_loader as dkl
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Фикстуры: РЕАЛЬНЫЕ строки региона 66 (export-kr1_1/kr1_2-66-20260701.csv)
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# КР1.1 house registry — 2 дома (Ломоносова 3 / Ломоносова 4, п. Арамиль).
|
||||||
|
_KR11_HEADER = (
|
||||||
|
"subject_rf;mun_obr_oktmo;mun_obr;mkd_code;houseguid;address;commission_year;"
|
||||||
|
"architectural_monument_category;total_sq;total_rooms_amount;living_rooms_amount;"
|
||||||
|
"total_rooms_sq;living_rooms_sq;total_ppl;number_floors_max;money_collecting_way;"
|
||||||
|
"money_ppl_collected;money_ppl_collected_debts;overhaul_funds_spent_all;"
|
||||||
|
"overhaul_funds_spent_subsidy;overhaul_fund_spent_other;overhaul_funds_balance;"
|
||||||
|
"update_date_of_information;money_ppl_collected_date;owners_payment;energy_efficiency;"
|
||||||
|
"alarm_document_date;exclude_date_from_program;inclusion_date_to_program;comment;"
|
||||||
|
"last_update;house_id"
|
||||||
|
)
|
||||||
|
_KR11_ROW_3 = (
|
||||||
|
'"Свердловская область";65729000;"Арамильский городской округ Свердловской области";'
|
||||||
|
"2170017185;04b98bcb-3232-4b26-b0fa-3c4a47adf394;"
|
||||||
|
'"Арамильский городской округ Свердловской области, пос. Арамиль, ул. Ломоносова, д. 3";'
|
||||||
|
'1964;;399,90;;;366,60;333,30;20;2;"Счет регионального оператора";486,84407;72,59638;'
|
||||||
|
"1580,29712;0,00000;0,00000;-1093,45305;2026-05-31;01.10.2014;18,81;"
|
||||||
|
'"не присвоен";;;22.04.2014;;15.05.2026;8857551'
|
||||||
|
)
|
||||||
|
_KR11_ROW_4 = (
|
||||||
|
'"Свердловская область";65729000;"Арамильский городской округ Свердловской области";'
|
||||||
|
"2170025270;15064d04-3aec-4266-985b-da26e9a5f392;"
|
||||||
|
'"Арамильский городской округ Свердловской области, пос. Арамиль, ул. Ломоносова, д. 4";'
|
||||||
|
'1984;;906,40;;;;;;2;"Счет регионального оператора";1046,79431;204,75296;0,00000;'
|
||||||
|
'0,00000;0,00000;1046,79431;2026-05-31;01.10.2014;18,81;"не присвоен";;;22.04.2014;;'
|
||||||
|
"15.05.2026;8857552"
|
||||||
|
)
|
||||||
|
KR11_CSV = "\n".join([_KR11_HEADER, _KR11_ROW_3, _KR11_ROW_4]) + "\n"
|
||||||
|
|
||||||
|
# КР1.2 constructive elements — long-format: обычная строка (wall_material пуст) +
|
||||||
|
# строка "фасад" (wall_material='кирпич') на каждый из тех же 2 домов.
|
||||||
|
_KR12_HEADER = (
|
||||||
|
"subject_rf;mun_obr_oktmo;mun_obr;mkd_code;address;construction_element_code;"
|
||||||
|
"construction_element_type;system_type;lift_type;stops_count;commissioning_date;"
|
||||||
|
"decommissioning_date;roof_type;roofing_area;basement_area;facade_type;facade_area;"
|
||||||
|
"foundation_type;wall_material;comment;last_update"
|
||||||
|
)
|
||||||
|
_KR12_ROW_3_OTHER = (
|
||||||
|
'"Свердловская область";65729000;"Арамильский городской округ Свердловской области";'
|
||||||
|
"2170017185;"
|
||||||
|
'"Арамильский городской округ Свердловской области, пос. Арамиль, ул. Ломоносова, д. 3";'
|
||||||
|
'580000;"система дымоудаления и (или) противопожарной автоматики";;;;;;;;;;;;;;15.05.2026'
|
||||||
|
)
|
||||||
|
_KR12_ROW_3_FACADE = (
|
||||||
|
'"Свердловская область";65729000;"Арамильский городской округ Свердловской области";'
|
||||||
|
"2170017185;"
|
||||||
|
'"Арамильский городской округ Свердловской области, пос. Арамиль, ул. Ломоносова, д. 3";'
|
||||||
|
'60000;фасад;;;;;;;;;"облицованный камнем";480,00;;кирпич;;15.05.2026'
|
||||||
|
)
|
||||||
|
_KR12_ROW_4_OTHER = (
|
||||||
|
'"Свердловская область";65729000;"Арамильский городской округ Свердловской области";'
|
||||||
|
"2170025270;"
|
||||||
|
'"Арамильский городской округ Свердловской области, пос. Арамиль, ул. Ломоносова, д. 4";'
|
||||||
|
'100000;"инженерная система водоснабжения (ХВС)";центральная;;;;;;;;;;;;;15.05.2026'
|
||||||
|
)
|
||||||
|
_KR12_ROW_4_FACADE = (
|
||||||
|
'"Свердловская область";65729000;"Арамильский городской округ Свердловской области";'
|
||||||
|
"2170025270;"
|
||||||
|
'"Арамильский городской округ Свердловской области, пос. Арамиль, ул. Ломоносова, д. 4";'
|
||||||
|
"60000;фасад;;;;;;;;;;;;кирпич;;15.05.2026"
|
||||||
|
)
|
||||||
|
KR12_CSV = (
|
||||||
|
"\n".join(
|
||||||
|
[_KR12_HEADER, _KR12_ROW_3_OTHER, _KR12_ROW_3_FACADE, _KR12_ROW_4_OTHER, _KR12_ROW_4_FACADE]
|
||||||
|
)
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def kr11_path(tmp_path: Path) -> Path:
|
||||||
|
p = tmp_path / "kr11.csv"
|
||||||
|
p.write_text(KR11_CSV, encoding="utf-8-sig")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def kr12_path(tmp_path: Path) -> Path:
|
||||||
|
p = tmp_path / "kr12.csv"
|
||||||
|
p.write_text(KR12_CSV, encoding="utf-8-sig")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# parse_decimal_comma / parse_int_field
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
def test_parse_decimal_comma() -> None:
|
||||||
|
assert dkl.parse_decimal_comma("909,80") == pytest.approx(909.80)
|
||||||
|
assert dkl.parse_decimal_comma("399,90") == pytest.approx(399.90)
|
||||||
|
assert dkl.parse_decimal_comma("100.5") == pytest.approx(100.5)
|
||||||
|
assert dkl.parse_decimal_comma("") is None
|
||||||
|
assert dkl.parse_decimal_comma(None) is None
|
||||||
|
assert dkl.parse_decimal_comma("не число") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_int_field() -> None:
|
||||||
|
assert dkl.parse_int_field("1964") == 1964
|
||||||
|
assert dkl.parse_int_field("2") == 2
|
||||||
|
assert dkl.parse_int_field("") is None
|
||||||
|
assert dkl.parse_int_field(None) is None
|
||||||
|
assert dkl.parse_int_field("x") is None
|
||||||
|
assert dkl.parse_int_field("5,0") == 5 # decimal-фоллбек
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_int_field_year_sanity_gate() -> None:
|
||||||
|
# Fix A: значение вне [min_value, max_value] → None (мусорный год не в staging).
|
||||||
|
ymax = dkl.plausible_year_max()
|
||||||
|
assert dkl.parse_int_field("1841", min_value=1850, max_value=ymax) is None # слишком старый
|
||||||
|
assert dkl.parse_int_field("0", min_value=1850, max_value=ymax) is None # ноль
|
||||||
|
assert dkl.parse_int_field(str(ymax + 5), min_value=1850, max_value=ymax) is None # будущее
|
||||||
|
assert dkl.parse_int_field("1964", min_value=1850, max_value=ymax) == 1964 # валидный
|
||||||
|
assert dkl.parse_int_field("1850", min_value=1850, max_value=ymax) == 1850 # граница вкл.
|
||||||
|
# Без границ (number_floors_max) — гейт не применяется.
|
||||||
|
assert dkl.parse_int_field("1841") == 1841
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_kr11_gates_impossible_commission_year(tmp_path: Path) -> None:
|
||||||
|
# Fix A: строка КР1.1 с невозможным годом (1200) → commission_year=None.
|
||||||
|
bad_row = _KR11_ROW_3.replace("1964;", "1200;", 1)
|
||||||
|
csv_text = "\n".join([_KR11_HEADER, bad_row]) + "\n"
|
||||||
|
p = tmp_path / "bad_year.csv"
|
||||||
|
p.write_text(csv_text, encoding="utf-8-sig")
|
||||||
|
rows = dkl.parse_kr11_csv(p)
|
||||||
|
assert rows["2170017185"].commission_year is None
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# parse_kr11_csv — house registry
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
def test_parse_kr11_csv_real_rows(kr11_path: Path) -> None:
|
||||||
|
rows = dkl.parse_kr11_csv(kr11_path)
|
||||||
|
assert set(rows.keys()) == {"2170017185", "2170025270"}
|
||||||
|
|
||||||
|
house3 = rows["2170017185"]
|
||||||
|
assert house3.houseguid == "04b98bcb-3232-4b26-b0fa-3c4a47adf394"
|
||||||
|
assert house3.commission_year == 1964
|
||||||
|
assert house3.number_floors_max == 2
|
||||||
|
assert house3.total_sq == pytest.approx(399.90)
|
||||||
|
assert "Ломоносова, д. 3" in (house3.address or "")
|
||||||
|
|
||||||
|
house4 = rows["2170025270"]
|
||||||
|
assert house4.houseguid == "15064d04-3aec-4266-985b-da26e9a5f392"
|
||||||
|
assert house4.commission_year == 1984
|
||||||
|
assert house4.total_sq == pytest.approx(906.40)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_kr11_csv_skips_blank_mkd_code(tmp_path: Path) -> None:
|
||||||
|
csv_text = _KR11_HEADER + "\n" + (";" * (_KR11_HEADER.count(";"))) + "\n"
|
||||||
|
p = tmp_path / "blank.csv"
|
||||||
|
p.write_text(csv_text, encoding="utf-8-sig")
|
||||||
|
assert dkl.parse_kr11_csv(p) == {}
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# parse_kr12_wall_materials — long-format, wall_material только на строке 'фасад'
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
def test_parse_kr12_wall_materials_extracts_facade_row_only(kr12_path: Path) -> None:
|
||||||
|
materials = dkl.parse_kr12_wall_materials(kr12_path)
|
||||||
|
# Обе non-facade строки (дымоудаление / ХВС) имели wall_material='' — исключены.
|
||||||
|
assert materials == {"2170017185": "кирпич", "2170025270": "кирпич"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_kr12_wall_materials_empty_when_no_facade_row(tmp_path: Path) -> None:
|
||||||
|
csv_text = "\n".join([_KR12_HEADER, _KR12_ROW_3_OTHER]) + "\n"
|
||||||
|
p = tmp_path / "no_facade.csv"
|
||||||
|
p.write_text(csv_text, encoding="utf-8-sig")
|
||||||
|
assert dkl.parse_kr12_wall_materials(p) == {}
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# build_domrf_houses — join КР1.1 + КР1.2(wall_material)
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
def test_build_domrf_houses_joins_wall_material(kr11_path: Path, kr12_path: Path) -> None:
|
||||||
|
kr11 = dkl.parse_kr11_csv(kr11_path)
|
||||||
|
walls = dkl.parse_kr12_wall_materials(kr12_path)
|
||||||
|
houses = dkl.build_domrf_houses(kr11, walls)
|
||||||
|
|
||||||
|
by_code = {h.mkd_code: h for h in houses}
|
||||||
|
assert len(houses) == 2
|
||||||
|
assert by_code["2170017185"].wall_material == "кирпич"
|
||||||
|
assert by_code["2170017185"].commission_year == 1964
|
||||||
|
assert by_code["2170025270"].wall_material == "кирпич"
|
||||||
|
|
||||||
|
|
||||||
|
# Дом без строки 'фасад' в КР1.2 → wall_material остаётся None (не KeyError/пустая строка).
|
||||||
|
def test_wall_material_none_when_house_missing_from_kr12(tmp_path: Path, kr11_path: Path) -> None:
|
||||||
|
kr11 = dkl.parse_kr11_csv(kr11_path)
|
||||||
|
houses = dkl.build_domrf_houses(kr11, {}) # пустой КР1.2 lookup
|
||||||
|
assert all(h.wall_material is None for h in houses)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# fetch_domrf_csvs — скачивание+распаковка zip, HTTP замокан (MockTransport)
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
def _zip_bytes(inner_name: str, content: str) -> bytes:
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, "w") as zf:
|
||||||
|
zf.writestr(inner_name, content.encode("utf-8-sig"))
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_domrf_csvs_downloads_and_extracts(tmp_path: Path) -> None:
|
||||||
|
kr11_zip = _zip_bytes("export-kr1_1-66-20260701.csv", KR11_CSV)
|
||||||
|
kr12_zip = _zip_bytes("export-kr1_2-66-20260701.csv", KR12_CSV)
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
url = str(request.url)
|
||||||
|
if url == dkl.KR11_URL:
|
||||||
|
return httpx.Response(200, content=kr11_zip)
|
||||||
|
if url == dkl.KR12_URL:
|
||||||
|
return httpx.Response(200, content=kr12_zip)
|
||||||
|
raise AssertionError(f"unexpected URL {url}")
|
||||||
|
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
with httpx.Client(transport=transport) as client:
|
||||||
|
kr11_out, kr12_out = dkl.fetch_domrf_csvs(tmp_path, client=client)
|
||||||
|
|
||||||
|
assert kr11_out.exists() and kr11_out.name.endswith(".csv")
|
||||||
|
assert kr12_out.exists() and kr12_out.name.endswith(".csv")
|
||||||
|
|
||||||
|
# Круглый путь: распакованные файлы парсятся так же, как локальные фикстуры.
|
||||||
|
rows = dkl.parse_kr11_csv(kr11_out)
|
||||||
|
assert set(rows.keys()) == {"2170017185", "2170025270"}
|
||||||
|
materials = dkl.parse_kr12_wall_materials(kr12_out)
|
||||||
|
assert materials == {"2170017185": "кирпич", "2170025270": "кирпич"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_csv_from_zip_raises_on_no_csv_entry(tmp_path: Path) -> None:
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, "w") as zf:
|
||||||
|
zf.writestr("readme.txt", "not a csv")
|
||||||
|
with pytest.raises(ValueError, match="csv"):
|
||||||
|
dkl._extract_csv_from_zip(buf.getvalue(), tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Статические asserts по _UPSERT_SQL (psycopg v3, ON CONFLICT, идемпотентность)
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
_UPSERT_SQL = str(dkl._UPSERT_SQL.text)
|
||||||
|
|
||||||
|
|
||||||
|
def test_upsert_sql_uses_psycopg_v3_cast_not_double_colon() -> None:
|
||||||
|
assert not re.search(r":\w+::", _UPSERT_SQL)
|
||||||
|
assert "CAST(:mkd_code AS text)" in _UPSERT_SQL
|
||||||
|
assert "CAST(:commission_year AS smallint)" in _UPSERT_SQL
|
||||||
|
assert "CAST(:total_sq AS numeric)" in _UPSERT_SQL
|
||||||
|
|
||||||
|
|
||||||
|
def test_upsert_sql_idempotent_on_conflict() -> None:
|
||||||
|
flat = re.sub(r"\s+", " ", _UPSERT_SQL)
|
||||||
|
assert "INSERT INTO domrf_kapremont" in flat
|
||||||
|
assert "ON CONFLICT (mkd_code) DO UPDATE SET" in flat
|
||||||
|
assert "domrf_kapremont.houseguid IS DISTINCT FROM EXCLUDED.houseguid" in flat
|
||||||
|
assert "domrf_kapremont.wall_material IS DISTINCT FROM EXCLUDED.wall_material" in flat
|
||||||
|
|
||||||
|
|
||||||
|
def test_upsert_uses_savepoint_per_chunk() -> None:
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
src = inspect.getsource(dkl.upsert_domrf_kapremont)
|
||||||
|
assert "with db.begin_nested():" in src
|
||||||
|
assert "db.commit()" not in src # не коммитит сам (коммитит caller)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Статические asserts: backfill houses — COALESCE(year_built, domrf, zhkh_year)
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
_BACKFILL_DOMRF_SQL = re.sub(r"\s+", " ", str(dkl._BACKFILL_HOUSES_FROM_DOMRF_SQL.text))
|
||||||
|
_BACKFILL_ZHKH_SQL = re.sub(r"\s+", " ", str(dkl._BACKFILL_HOUSES_ZHKH_FALLBACK_SQL.text))
|
||||||
|
|
||||||
|
|
||||||
|
def test_backfill_domrf_sql_year_priority_and_plausibility_guard() -> None:
|
||||||
|
bound = "BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)"
|
||||||
|
# Fix A: year_built — валидное существующее (в границах :ymin..:ymax) выигрывает,
|
||||||
|
# иначе (NULL/impossible) заменяется авторитетным commission_year → zhkh_year.
|
||||||
|
assert f"WHEN h.year_built {bound}" in _BACKFILL_DOMRF_SQL
|
||||||
|
assert "THEN h.year_built" in _BACKFILL_DOMRF_SQL
|
||||||
|
# commission_year и zhkh_year тоже проходят плаузибилити-гейт перед записью.
|
||||||
|
assert f"CASE WHEN d.commission_year {bound}" in _BACKFILL_DOMRF_SQL
|
||||||
|
# Невозможный существующий год НЕ экранирует замену (WHERE ловит NOT BETWEEN).
|
||||||
|
assert f"h.year_built NOT {bound}" in _BACKFILL_DOMRF_SQL
|
||||||
|
# material_walls / total_floors — прежняя COALESCE-семантика (только заполнить NULL).
|
||||||
|
assert "COALESCE(h.material_walls, d.wall_material)" in _BACKFILL_DOMRF_SQL
|
||||||
|
assert "COALESCE(h.total_floors, d.number_floors_max, h.zhkh_floors)" in _BACKFILL_DOMRF_SQL
|
||||||
|
|
||||||
|
|
||||||
|
def test_backfill_domrf_sql_matches_by_guid_coalesce_priority() -> None:
|
||||||
|
# gar_house_guid первый (78% coverage), затем house_fias_id, затем zhkh_house_guid.
|
||||||
|
assert (
|
||||||
|
"d.houseguid = COALESCE(h.gar_house_guid, h.house_fias_id, h.zhkh_house_guid)"
|
||||||
|
in _BACKFILL_DOMRF_SQL
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_backfill_domrf_sql_only_fills_null_never_overwrites() -> None:
|
||||||
|
# Гейт: каждый SET-таргет требует h.<col> IS NULL — COALESCE никогда не перезапишет
|
||||||
|
# существующее non-null значение (SQL COALESCE тоже это гарантирует, но гейт защищает
|
||||||
|
# от no-op UPDATE на уже заполненных строках).
|
||||||
|
assert "h.year_built IS NULL" in _BACKFILL_DOMRF_SQL
|
||||||
|
assert "h.material_walls IS NULL" in _BACKFILL_DOMRF_SQL
|
||||||
|
assert "h.total_floors IS NULL" in _BACKFILL_DOMRF_SQL
|
||||||
|
|
||||||
|
|
||||||
|
def test_backfill_zhkh_fallback_sql_folds_zhkh_for_unmatched_houses() -> None:
|
||||||
|
# Шаг 2 — без JOIN на domrf_kapremont вообще: применяется к ЛЮБОМУ дому, включая
|
||||||
|
# те, что не заматчились в шаге 1 (issue: "houses without a ДОМ.РФ match still benefit").
|
||||||
|
assert "domrf_kapremont" not in _BACKFILL_ZHKH_SQL
|
||||||
|
assert "COALESCE(year_built, zhkh_year)" in _BACKFILL_ZHKH_SQL
|
||||||
|
assert "COALESCE(total_floors, zhkh_floors)" in _BACKFILL_ZHKH_SQL
|
||||||
|
assert "year_built IS NULL AND zhkh_year IS NOT NULL" in _BACKFILL_ZHKH_SQL
|
||||||
|
|
||||||
|
|
||||||
|
def test_backfill_sql_no_psycopg_v3_colon_colon_cast() -> None:
|
||||||
|
assert not re.search(r":\w+::", _BACKFILL_DOMRF_SQL)
|
||||||
|
assert not re.search(r":\w+::", _BACKFILL_ZHKH_SQL)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# backfill_houses_from_domrf — dry_run vs real-run (MagicMock db)
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
def test_backfill_houses_real_run_sums_both_steps_rowcount() -> None:
|
||||||
|
db = MagicMock()
|
||||||
|
results = [MagicMock(rowcount=3), MagicMock(rowcount=2)]
|
||||||
|
db.execute.side_effect = results
|
||||||
|
|
||||||
|
out = dkl.backfill_houses_from_domrf(db, dry_run=False)
|
||||||
|
|
||||||
|
assert out == {"domrf_matched": 3, "zhkh_fallback": 2, "houses_updated": 5}
|
||||||
|
assert db.execute.call_count == 2
|
||||||
|
executed_sql = [str(call.args[0]) for call in db.execute.call_args_list]
|
||||||
|
assert "UPDATE houses" in executed_sql[0]
|
||||||
|
assert "UPDATE houses" in executed_sql[1]
|
||||||
|
# Fix A: domrf-шаг прокидывает плаузибилити-границы года как bind-параметры.
|
||||||
|
domrf_params = db.execute.call_args_list[0].args[1]
|
||||||
|
assert domrf_params["ymin"] == dkl.YEAR_BUILT_MIN
|
||||||
|
assert domrf_params["ymax"] == dkl.plausible_year_max()
|
||||||
|
|
||||||
|
|
||||||
|
def test_backfill_houses_dry_run_only_counts_no_update() -> None:
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute.return_value.scalar_one.side_effect = [7, 4]
|
||||||
|
|
||||||
|
out = dkl.backfill_houses_from_domrf(db, dry_run=True)
|
||||||
|
|
||||||
|
assert out == {"domrf_matched": 7, "zhkh_fallback": 4, "houses_updated": 0}
|
||||||
|
for call in db.execute.call_args_list:
|
||||||
|
sql_text = str(call.args[0]).upper()
|
||||||
|
assert "UPDATE" not in sql_text
|
||||||
|
assert "SELECT COUNT(*)" in sql_text
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Статические asserts + behavior: propagate_listings_year_from_houses
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
_PROPAGATE_SQL = re.sub(r"\s+", " ", str(dkl._PROPAGATE_LISTINGS_YEAR_SQL.text))
|
||||||
|
|
||||||
|
|
||||||
|
def test_propagate_sql_only_fills_null_listings_year() -> None:
|
||||||
|
assert "SET year_built = h.year_built" in _PROPAGATE_SQL
|
||||||
|
assert "l.year_built IS NULL" in _PROPAGATE_SQL
|
||||||
|
assert "h.year_built IS NOT NULL" in _PROPAGATE_SQL
|
||||||
|
assert "l.house_id_fk = h.id" in _PROPAGATE_SQL
|
||||||
|
|
||||||
|
|
||||||
|
def test_propagate_sql_has_link_consistency_distance_guard() -> None:
|
||||||
|
# Fix B: пропагация только если координаты объявления в пределах порога от дома
|
||||||
|
# (ST_DWithin, geography → метры) — перепривязанный FK не впрыснет чужой год.
|
||||||
|
assert "ST_DWithin(" in _PROPAGATE_SQL
|
||||||
|
assert "CAST(l.geom AS geography)" in _PROPAGATE_SQL
|
||||||
|
assert "CAST(h.geom AS geography)" in _PROPAGATE_SQL
|
||||||
|
assert str(dkl.PROPAGATE_MAX_DISTANCE_M) in _PROPAGATE_SQL
|
||||||
|
# Оба SQL (UPDATE и COUNT) несут один и тот же guard.
|
||||||
|
count_sql = re.sub(r"\s+", " ", str(dkl._PROPAGATE_LISTINGS_YEAR_COUNT_SQL.text))
|
||||||
|
assert "ST_DWithin(" in count_sql
|
||||||
|
|
||||||
|
|
||||||
|
def test_propagate_sql_no_coords_falls_back_to_address_check() -> None:
|
||||||
|
# Fix B: объявление без координат → консервативный address-фоллбек
|
||||||
|
# (адрес объявления содержит short_address дома), не пропагируем «вслепую».
|
||||||
|
assert "l.geom IS NULL" in _PROPAGATE_SQL
|
||||||
|
assert "h.short_address IS NOT NULL" in _PROPAGATE_SQL
|
||||||
|
assert "l.address ILIKE" in _PROPAGATE_SQL
|
||||||
|
|
||||||
|
|
||||||
|
def test_propagate_listings_year_real_run_returns_rowcount() -> None:
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute.return_value.rowcount = 42
|
||||||
|
|
||||||
|
out = dkl.propagate_listings_year_from_houses(db, dry_run=False)
|
||||||
|
|
||||||
|
assert out == {"listings_updated": 42}
|
||||||
|
sql_text = str(db.execute.call_args[0][0])
|
||||||
|
assert "UPDATE listings" in sql_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_propagate_listings_year_dry_run_uses_count_sql() -> None:
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute.return_value.scalar_one.return_value = 99
|
||||||
|
|
||||||
|
out = dkl.propagate_listings_year_from_houses(db, dry_run=True)
|
||||||
|
|
||||||
|
assert out == {"listings_updated": 0, "would_update": 99}
|
||||||
|
sql_text = str(db.execute.call_args[0][0]).upper()
|
||||||
|
assert "UPDATE" not in sql_text
|
||||||
|
|
@ -139,6 +139,9 @@ def _stub_precision_and_pdf():
|
||||||
_fetch_price_trend=lambda *a, **k: None,
|
_fetch_price_trend=lambda *a, **k: None,
|
||||||
_fetch_dkp_corridor=lambda *a, **k: None,
|
_fetch_dkp_corridor=lambda *a, **k: None,
|
||||||
_fetch_house_imv_anchor=lambda *a, **k: None,
|
_fetch_house_imv_anchor=lambda *a, **k: None,
|
||||||
|
# (oblast C2): GET-rehydrate also resolves the target city (for
|
||||||
|
# _fetch_dkp_corridor city-scoping) before calling the corridor helper.
|
||||||
|
_resolve_target_city=lambda *a, **k: None,
|
||||||
# #2043 (BE-1): GET-rehydrate also recomputes cv / source_counts from the
|
# #2043 (BE-1): GET-rehydrate also recomputes cv / source_counts from the
|
||||||
# persisted analogs. Empty analogs in the fixture → None / {} (real behaviour).
|
# persisted analogs. Empty analogs in the fixture → None / {} (real behaviour).
|
||||||
_cv_from_ppm2=lambda *a, **k: None,
|
_cv_from_ppm2=lambda *a, **k: None,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,261 @@
|
||||||
|
"""Display-consistency fix — показанные карточки аналогов согласованы с n_analogs
|
||||||
|
и не содержат ни MAD-clip-отсечённых ценовых выбросов, ни кросс-source дублей
|
||||||
|
одного физлота (live QA прод-смоук: «Екатеринбург, ул. Малышева, 51»).
|
||||||
|
|
||||||
|
Два независимых root cause (см. PR):
|
||||||
|
|
||||||
|
1. `n_analogs` считался ПОСЛЕ MAD-clip (_compute_same_building_anchor: n = len
|
||||||
|
выживших ppm2), но отображаемые карточки строились из ИСХОДНОГО (пред-clip)
|
||||||
|
anchor_comps — клип-отсечённый ценовой выброс оставался видимым в UI, даже
|
||||||
|
когда он не участвовал в headline/n_analogs. Фикс: anchor_comps_used теперь =
|
||||||
|
anchor["comps"] (тот же пост-clip пул, что дал n).
|
||||||
|
|
||||||
|
2. Статистический кросс-source дедуп (_dedup_cross_source) требует совпадения
|
||||||
|
ЦЕНОВОГО бакета (price_bucket) — реальный кросс-пост одного физлота на разных
|
||||||
|
площадках почти всегда имеет небольшой ценовой дрейф (перевыставили дешевле/
|
||||||
|
дороже), который может увести пару в разные бакеты → дубль выживает в
|
||||||
|
популяции. Фикс: отдельный display-only дедуп (_dedup_display_lots) — тот же
|
||||||
|
физический ключ (building/floor/area), но БЕЗ price_bucket — ловит именно эти
|
||||||
|
остаточные дубли ТОЛЬКО на уровне отображаемых карточек, не трогая
|
||||||
|
n_analogs/median/cv (frozen backtest regression gate не задет — см.
|
||||||
|
tests/test_backtest_regression_gate.py, который эту ветку кода не вызывает).
|
||||||
|
|
||||||
|
Test A воспроизводит буквальный прод-паттерн (кросс-source дубль-пара, которая
|
||||||
|
ЦЕЛИКОМ является ценовым выбросом, — «Гоголя 18» из QA-смоука): MAD-clip убирает
|
||||||
|
ОБЕ копии из anchor["comps"] → они пропадают из карточек естественным образом
|
||||||
|
(fix #1 самодостаточен) → len(analogs) == n_analogs И нет дубль-адресов.
|
||||||
|
|
||||||
|
Test B покрывает менее тривиальный случай — дубль-пара НЕ является ценовым
|
||||||
|
выбросом (обычная цена, просто с небольшим дрейфом между площадками) и
|
||||||
|
переживает MAD-clip как 2 отдельные записи (её и ловит fix #2, display-only
|
||||||
|
дедуп). Здесь len(analogs) < n_analogs ЧЕСТНО (статистика не трогается) — тест
|
||||||
|
документирует именно это, а не искусственное равенство.
|
||||||
|
|
||||||
|
Полный estimate-путь со всеми I/O застаблен через harness _run_estimate из
|
||||||
|
test_same_building_anchor.py (anchor_tier="A" → same-building anchor ветка, где
|
||||||
|
живёт MAD-clip).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# Settings требует DATABASE_URL при инициализации (fail-fast, C-3).
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||||
|
|
||||||
|
from app.services import estimator
|
||||||
|
|
||||||
|
_ANCHOR_TEST = Path(__file__).parent / "test_same_building_anchor.py"
|
||||||
|
_spec = importlib.util.spec_from_file_location("_anchor_harness_display_consistency", _ANCHOR_TEST)
|
||||||
|
assert _spec is not None and _spec.loader is not None
|
||||||
|
_h = importlib.util.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(_h)
|
||||||
|
|
||||||
|
|
||||||
|
def _comp(
|
||||||
|
*,
|
||||||
|
source: str,
|
||||||
|
address: str,
|
||||||
|
ppm2: float,
|
||||||
|
area_m2: float = 146.2,
|
||||||
|
rooms: int = 4,
|
||||||
|
scraped_at: datetime | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Минимальный same-building comp-dict (без floor/total_floors → нейтральный
|
||||||
|
floor-вес; area_m2 по умолчанию = target площадь _make_payload(), area-вес ~1.0).
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"source": source,
|
||||||
|
"source_url": f"https://{source}.example/offer/{address}/{ppm2}",
|
||||||
|
"address": address,
|
||||||
|
"area_m2": area_m2,
|
||||||
|
"rooms": rooms,
|
||||||
|
"price_per_m2": ppm2,
|
||||||
|
"price_rub": ppm2 * area_m2,
|
||||||
|
"scraped_at": scraped_at or datetime(2026, 6, 1, tzinfo=UTC),
|
||||||
|
"photo_urls": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Test A — outlier дубль-пара («Гоголя 18»-паттерн): MAD-clip убирает ОБЕ копии
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_outlier_duplicate_pair_excluded_from_both_count_and_cards() -> None:
|
||||||
|
"""5 обычных комплов (разные дома, MAD-clip не трогает) + кросс-source
|
||||||
|
дубль-пара одного физлота, которая ЦЕЛИКОМ ценовой выброс (~4.4× медианы,
|
||||||
|
как «Гоголя 18» в QA-смоуке) → MAD-clip убирает ОБЕ копии из
|
||||||
|
anchor["comps"]. Т.к. карточки теперь строятся из anchor["comps"] (не из
|
||||||
|
исходного anchor_comps), выброс не просачивается в UI — количество карточек
|
||||||
|
равно n_analogs, и обеих копий дубля нет ни в счётчике, ни в карточках.
|
||||||
|
"""
|
||||||
|
clean = [
|
||||||
|
_comp(source="cian", address="Екатеринбург, ул. Хохрякова, 48", ppm2=195_000.0),
|
||||||
|
_comp(source="avito", address="Екатеринбург, ул. Хохрякова, 50", ppm2=200_000.0),
|
||||||
|
_comp(source="domklik", address="Екатеринбург, ул. Хохрякова, 52", ppm2=205_000.0),
|
||||||
|
_comp(source="yandex", address="Екатеринбург, ул. Хохрякова, 54", ppm2=210_000.0),
|
||||||
|
_comp(source="cian", address="Екатеринбург, ул. Хохрякова, 58", ppm2=215_000.0),
|
||||||
|
]
|
||||||
|
# Кросс-source дубль одного физлота — ~4.4-4.5× дороже остальных (целиком
|
||||||
|
# ценовой выброс, как реальный прод-кейс «Гоголя 18»).
|
||||||
|
outlier_dup = [
|
||||||
|
_comp(source="avito", address="Екатеринбург, ул. Гоголя, 18", ppm2=900_000.0),
|
||||||
|
_comp(source="domklik", address="Екатеринбург, ул. Гоголя, 18", ppm2=920_000.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
est = _h._run_estimate(anchor_comps=clean + outlier_dup, anchor_tier="A")
|
||||||
|
|
||||||
|
# Выброс не участвовал в headline/n_analogs (MAD-clip) — фикс #1 не менял эту
|
||||||
|
# часть: сверяем, что n_analogs действительно = 5 (7 - 2 отсечённых).
|
||||||
|
assert est.n_analogs == 5
|
||||||
|
|
||||||
|
# Главный инвариант display-consistency: показанные карточки == n_analogs.
|
||||||
|
assert len(est.analogs) == est.n_analogs == 5
|
||||||
|
|
||||||
|
# Ни одна из копий выброса не попала в карточки.
|
||||||
|
shown_addresses = {a.address for a in est.analogs}
|
||||||
|
assert "Екатеринбург, ул. Гоголя, 18" not in shown_addresses
|
||||||
|
shown_ppm2 = {a.price_per_m2 for a in est.analogs}
|
||||||
|
assert 900_000 not in shown_ppm2
|
||||||
|
assert 920_000 not in shown_ppm2
|
||||||
|
|
||||||
|
# Нет дублей адресов среди карточек.
|
||||||
|
assert len(shown_addresses) == len(est.analogs)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Test B — non-outlier дубль-пара («Пушкина 9»-паттерн): переживает MAD-clip,
|
||||||
|
# ловится ТОЛЬКО display-only дедупом (price_bucket-строгий стат-дедуп её
|
||||||
|
# пропускает из-за ценового дрейфа, бакет-straddle).
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_outlier_cross_source_duplicate_collapses_in_cards_only() -> None:
|
||||||
|
"""4 обычных компла + кросс-source дубль-пара ОБЫЧНОЙ цены (небольшой дрейф
|
||||||
|
2-25к, «Пушкина 9»-паттерн из QA-смоука) — НЕ выброс, переживает MAD-clip как
|
||||||
|
2 отдельные записи (n_analogs их считает по отдельности, статистика не
|
||||||
|
тронута). display-only дедуп (без price_bucket) схлопывает пару в ОДНУ
|
||||||
|
карточку — карточек становится МЕНЬШЕ n_analogs (честно: это НЕ баг, а
|
||||||
|
следствие того, что стат-пайплайн и display-дедуп используют разные ключи
|
||||||
|
по дизайну — см. docstring _dedup_display_lots). Инвариант, который держит
|
||||||
|
этот тест: адрес дубля встречается в карточках РОВНО один раз.
|
||||||
|
"""
|
||||||
|
clean = [
|
||||||
|
_comp(source="cian", address="Екатеринбург, ул. Хохрякова, 48", ppm2=195_000.0),
|
||||||
|
_comp(source="avito", address="Екатеринбург, ул. Хохрякова, 50", ppm2=200_000.0),
|
||||||
|
_comp(source="domklik", address="Екатеринбург, ул. Хохрякова, 52", ppm2=205_000.0),
|
||||||
|
_comp(source="yandex", address="Екатеринбург, ул. Хохрякова, 54", ppm2=210_000.0),
|
||||||
|
]
|
||||||
|
# Один физлот, кросс-source (yandex/domklik), лёгкий ценовой дрейф ~1.6% —
|
||||||
|
# достаточно, чтобы price_bucket (_DEDUP_PRICE_BUCKET_RUB=100_000 руб.)
|
||||||
|
# развёл пару по РАЗНЫМ бакетам (311 vs 316), но НЕ выброс (в пределах
|
||||||
|
# MAD-clip диапазона остальных 4).
|
||||||
|
dup_a = _comp(
|
||||||
|
source="yandex",
|
||||||
|
address="Екатеринбург, ул. Пушкина, 9",
|
||||||
|
ppm2=219_000.0,
|
||||||
|
area_m2=142.0,
|
||||||
|
scraped_at=datetime(2026, 6, 1, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
dup_b = _comp(
|
||||||
|
source="domklik",
|
||||||
|
address="Екатеринбург, ул. Пушкина, 9",
|
||||||
|
ppm2=222_500.0,
|
||||||
|
area_m2=142.0,
|
||||||
|
scraped_at=datetime(2026, 6, 3, tzinfo=UTC), # свежее → представитель
|
||||||
|
)
|
||||||
|
|
||||||
|
# sanity: стат-дедуп (price_bucket-строгий) ДЕЙСТВИТЕЛЬНО пропускает пару —
|
||||||
|
# иначе тест проверял бы не то, что заявлено в docstring.
|
||||||
|
assert estimator.settings.estimate_dedup_analogs_enabled is True
|
||||||
|
deduped_upstream = estimator._dedup_cross_source([dup_a, dup_b])
|
||||||
|
assert len(deduped_upstream) == 2, "price_bucket dedup ошибочно поймал дрейф — тест не то мерит"
|
||||||
|
|
||||||
|
est = _h._run_estimate(anchor_comps=[*clean, dup_a, dup_b], anchor_tier="A")
|
||||||
|
|
||||||
|
# Статистика ЧЕСТНО считает обе копии (стат-дедуп их не поймал, MAD-clip не
|
||||||
|
# выброс) — n_analogs = 6.
|
||||||
|
assert est.n_analogs == 6
|
||||||
|
|
||||||
|
# display-only дедуп схлопнул пару → карточек МЕНЬШЕ n_analogs.
|
||||||
|
assert len(est.analogs) == 5
|
||||||
|
assert len(est.analogs) < est.n_analogs
|
||||||
|
|
||||||
|
# Инвариант: адрес дубля встречается РОВНО один раз среди карточек.
|
||||||
|
shown_addresses = [a.address for a in est.analogs]
|
||||||
|
assert shown_addresses.count("Екатеринбург, ул. Пушкина, 9") == 1
|
||||||
|
# Представитель — свежайший scraped_at (domklik, 222_500).
|
||||||
|
pushkina_card = next(a for a in est.analogs if a.address == "Екатеринбург, ул. Пушкина, 9")
|
||||||
|
assert pushkina_card.price_per_m2 == 222_500
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Unit-level: _dedup_display_lots — прямые проверки поведения helper'а
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def _lot(
|
||||||
|
*,
|
||||||
|
source: str,
|
||||||
|
address: str = "ул. Ленина, 5",
|
||||||
|
area: float = 60.0,
|
||||||
|
price: float = 12_000_000.0,
|
||||||
|
floor: int | None = 5,
|
||||||
|
scraped_at: datetime | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"source": source,
|
||||||
|
"address": address,
|
||||||
|
"area_m2": area,
|
||||||
|
"price_rub": price,
|
||||||
|
"price_per_m2": price / area if area else 0,
|
||||||
|
"floor": floor,
|
||||||
|
"scraped_at": scraped_at or datetime(2026, 6, 1, tzinfo=UTC),
|
||||||
|
"photo_urls": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_dedup_display_lots_collapses_price_drifted_cross_source_pair() -> None:
|
||||||
|
# Тот же building/floor/area, РАЗНЫЙ source, цена отличается на 20% — стат-
|
||||||
|
# дедуп (price_bucket) не поймал бы такой дрейф, display-дедуп игнорирует
|
||||||
|
# цену вовсе.
|
||||||
|
lots = [
|
||||||
|
_lot(source="avito", price=12_000_000.0, scraped_at=datetime(2026, 6, 1, tzinfo=UTC)),
|
||||||
|
_lot(source="cian", price=14_400_000.0, scraped_at=datetime(2026, 6, 5, tzinfo=UTC)),
|
||||||
|
]
|
||||||
|
out = estimator._dedup_display_lots(lots)
|
||||||
|
assert len(out) == 1
|
||||||
|
assert out[0]["source"] == "cian" # свежайший scraped_at
|
||||||
|
|
||||||
|
|
||||||
|
def test_dedup_display_lots_same_source_pair_not_merged() -> None:
|
||||||
|
# Тот же building/floor/area, ОДИНАКОВЫЙ source, разная цена — НЕ кросс-пост
|
||||||
|
# (либо два разных реальных юнита, случайно совпавших по округлённой
|
||||||
|
# площади/этажу), display-дедуп НЕ должен их схлопывать (require_diff_source
|
||||||
|
# guard).
|
||||||
|
lots = [
|
||||||
|
_lot(source="cian", price=12_000_000.0),
|
||||||
|
_lot(source="cian", price=13_000_000.0),
|
||||||
|
]
|
||||||
|
out = estimator._dedup_display_lots(lots)
|
||||||
|
assert len(out) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_dedup_display_lots_different_addresses_stay_distinct() -> None:
|
||||||
|
lots = [
|
||||||
|
_lot(source="avito", address="ул. Ленина, 5", price=12_000_000.0),
|
||||||
|
_lot(source="cian", address="ул. Мира, 3", price=12_000_000.0),
|
||||||
|
]
|
||||||
|
out = estimator._dedup_display_lots(lots)
|
||||||
|
assert len(out) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_dedup_display_lots_noop_under_two_lots() -> None:
|
||||||
|
lots = [_lot(source="avito")]
|
||||||
|
out = estimator._dedup_display_lots(lots)
|
||||||
|
assert out is lots
|
||||||
|
|
@ -56,8 +56,8 @@ def test_fix1_low_conf_anchor_suppressed_by_gate() -> None:
|
||||||
assert anchor_raw is not None, "Должен построить якорь без гейта"
|
assert anchor_raw is not None, "Должен построить якорь без гейта"
|
||||||
assert anchor_raw["confidence"] == "low", "Ожидаем low confidence при большом разбросе"
|
assert anchor_raw["confidence"] == "low", "Ожидаем low confidence при большом разбросе"
|
||||||
|
|
||||||
# Гейт: settings.estimate_sb_low_conf_gate_enabled=True
|
# Гейт low-confidence якоря применяется в estimate_quality (выше уровня
|
||||||
# Это применяется в estimate_quality (выше уровня _compute_same_building_anchor),
|
# _compute_same_building_anchor),
|
||||||
# поэтому тестируем сигнал: если confidence=low — гейт должен подавить.
|
# поэтому тестируем сигнал: если confidence=low — гейт должен подавить.
|
||||||
assert anchor_raw["confidence"] == "low"
|
assert anchor_raw["confidence"] == "low"
|
||||||
# Проверяем что гейт-условие срабатывает:
|
# Проверяем что гейт-условие срабатывает:
|
||||||
|
|
@ -299,17 +299,15 @@ def test_fix4_floor_sigma_zero_does_not_crash() -> None:
|
||||||
def test_fix4_premium_comp_survives_post_weight_clip() -> None:
|
def test_fix4_premium_comp_survives_post_weight_clip() -> None:
|
||||||
"""Видовой компл (высокий ppm²) с правильными весами не выкидывается.
|
"""Видовой компл (высокий ppm²) с правильными весами не выкидывается.
|
||||||
|
|
||||||
Логика: при post-weight clip (estimate_sb_clip_after_weight=True) MAD-clip
|
Логика: post-weight MAD-clip применяется к ppm² ПОСЛЕ weighting. Если видовой
|
||||||
применяется к ppm² ПОСЛЕ weighting. Если видовой компл близок по площади/
|
компл близок по площади/комнатам — его вес высок, и он не должен быть outlier
|
||||||
комнатам — его вес высок, и он не должен быть outlier после clip.
|
после clip.
|
||||||
"""
|
"""
|
||||||
# 4 стандартных компла + 1 видовой (выше на 30%)
|
# 4 стандартных компла + 1 видовой (выше на 30%)
|
||||||
base = 200_000
|
base = 200_000
|
||||||
comps = [_make_comp(base) for _ in range(4)] + [_make_comp(base * 1.3)]
|
comps = [_make_comp(base) for _ in range(4)] + [_make_comp(base * 1.3)]
|
||||||
|
|
||||||
with patch("app.services.estimator.settings") as mock_settings:
|
with patch("app.services.estimator.settings") as mock_settings:
|
||||||
# Активируем post-weight clip
|
|
||||||
mock_settings.estimate_sb_clip_after_weight = True
|
|
||||||
mock_settings.estimate_sb_mad_k_small_n = 2.5
|
mock_settings.estimate_sb_mad_k_small_n = 2.5
|
||||||
mock_settings.estimate_sb_small_n_threshold = 10
|
mock_settings.estimate_sb_small_n_threshold = 10
|
||||||
mock_settings.avito_imv_thin_market_threshold = 10
|
mock_settings.avito_imv_thin_market_threshold = 10
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,238 @@
|
||||||
|
"""Tests for the oblast-D deals-headline-fallback (non-EKB accuracy gap).
|
||||||
|
|
||||||
|
Context: `listings` coverage is ~40k EKB / ~0 non-EKB (Нижний Тагил / Серов /
|
||||||
|
Каменск-Уральский). Before this fix, a non-EKB subject either:
|
||||||
|
(a) leaked a DISTANT EKB listing median via the Tier S address-prefix
|
||||||
|
fallback (no geo bound — see `_fetch_analogs` Tier S fix), or
|
||||||
|
(b) surfaced an empty ("n/a") headline once (a) was fixed and the
|
||||||
|
geo-bound radius tiers legitimately found 0 local listings.
|
||||||
|
|
||||||
|
This fallback builds the headline from the ДКП deal corridor (`dkp_raw`,
|
||||||
|
already city+street-scoped via `_resolve_target_city`/`_fetch_dkp_corridor`)
|
||||||
|
instead — honestly flagged 'low' confidence, deals-only.
|
||||||
|
|
||||||
|
These tests exercise `estimate_quality` end-to-end (real spine code, mocked
|
||||||
|
DB-facing helpers only) — the same pattern as test_estimator_radius_floor.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import anyio
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
|
||||||
|
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _make_listing(*, price_per_m2: float, area_m2: float = 45.0) -> dict[str, Any]:
|
||||||
|
"""An EKB analog — used only in the "EKB unaffected" control test."""
|
||||||
|
return {
|
||||||
|
"source": "cian",
|
||||||
|
"source_url": "https://cian.ru/offer/1",
|
||||||
|
"address": "ЕКБ, ул. Малышева, 30",
|
||||||
|
"lat": 56.838,
|
||||||
|
"lon": 60.595,
|
||||||
|
"rooms": 2,
|
||||||
|
"area_m2": area_m2,
|
||||||
|
"floor": 5,
|
||||||
|
"total_floors": 16,
|
||||||
|
"price_rub": price_per_m2 * area_m2,
|
||||||
|
"price_per_m2": price_per_m2,
|
||||||
|
"listing_date": datetime(2026, 5, 1),
|
||||||
|
"days_on_market": 10,
|
||||||
|
"photo_urls": [],
|
||||||
|
"scraped_at": datetime(2026, 5, 20, tzinfo=UTC),
|
||||||
|
"distance_m": 150.0,
|
||||||
|
"relevance_score": 0.1,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_geo_tagil():
|
||||||
|
from app.services.geocoder import GeocodeResult
|
||||||
|
|
||||||
|
return GeocodeResult(
|
||||||
|
lat=57.9094,
|
||||||
|
lon=59.9789,
|
||||||
|
full_address="Свердловская обл., Нижний Тагил, ул. Ленина, 5",
|
||||||
|
provider="nominatim",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_payload_tagil():
|
||||||
|
from app.schemas.trade_in import TradeInEstimateInput
|
||||||
|
|
||||||
|
return TradeInEstimateInput(
|
||||||
|
address="Нижний Тагил, ул. Ленина, 5",
|
||||||
|
area_m2=45.0,
|
||||||
|
rooms=2,
|
||||||
|
floor=5,
|
||||||
|
total_floors=9,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_estimate(
|
||||||
|
*,
|
||||||
|
analogs: list[dict[str, Any]],
|
||||||
|
dkp_raw: dict[str, Any] | None,
|
||||||
|
geo: Any,
|
||||||
|
payload: Any,
|
||||||
|
) -> Any:
|
||||||
|
from app.services.estimator import estimate_quality
|
||||||
|
|
||||||
|
db = MagicMock()
|
||||||
|
|
||||||
|
async def _run() -> Any:
|
||||||
|
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",
|
||||||
|
# Post Part-1 (geo-bound Tier S) reality for a non-EKB town: the
|
||||||
|
# radius/tier ladder legitimately returns NOTHING — tier='W' is
|
||||||
|
# what the always-executed final fallback tier returns.
|
||||||
|
return_value=(list(analogs), 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)
|
||||||
|
|
||||||
|
return anyio.run(_run)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Нижний Тагил: no local listings, deal corridor present ───────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_ekb_empty_listings_uses_deals_headline() -> None:
|
||||||
|
"""0 local listings + a usable ДКП corridor → headline comes from deals.
|
||||||
|
|
||||||
|
Mirrors the reported Нижний Тагил gap: deal_median ≈ 85 911 ₽/м² (accurate)
|
||||||
|
vs the old EKB-leaked asking headline ≈ 186 461 (~6x over). After the fix,
|
||||||
|
the headline must equal the deal corridor's median — nowhere near the
|
||||||
|
EKB-range figure — and confidence must be honestly 'low' (deals-only, zero
|
||||||
|
scraped analogs).
|
||||||
|
"""
|
||||||
|
dkp_raw = {
|
||||||
|
"count": 12,
|
||||||
|
"low_ppm2": 70_000,
|
||||||
|
"median_ppm2": 85_911,
|
||||||
|
"high_ppm2": 100_000,
|
||||||
|
"period_months": 12,
|
||||||
|
}
|
||||||
|
est = _run_estimate(
|
||||||
|
analogs=[], # 0 listings — the honest post-geo-bound-fix reality
|
||||||
|
dkp_raw=dkp_raw,
|
||||||
|
geo=_make_geo_tagil(),
|
||||||
|
payload=_make_payload_tagil(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert est.median_price_per_m2 == 85_911, (
|
||||||
|
f"headline={est.median_price_per_m2} must equal the deal corridor "
|
||||||
|
f"median, not 0/n-a and nowhere near an EKB-range figure (~186k)"
|
||||||
|
)
|
||||||
|
assert est.median_price_per_m2 < 120_000, "must NOT be EKB-leaked (~186k)"
|
||||||
|
assert est.n_analogs == 0, "honest: zero scraped-listing analogs were used"
|
||||||
|
assert est.confidence == "low", "deals-only headline must be honestly low-confidence"
|
||||||
|
assert est.median_price_rub == round(85_911 * 45.0)
|
||||||
|
# Range should bracket the corridor's P10/P90, not collapse to a point.
|
||||||
|
assert est.range_low_rub <= est.median_price_rub <= est.range_high_rub
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_ekb_empty_listings_no_deals_stays_insufficient() -> None:
|
||||||
|
"""0 listings + NO deal corridor either → stays honest n/a (median=0).
|
||||||
|
|
||||||
|
Guards against the fallback inventing a number when there is truly no
|
||||||
|
signal at all (e.g. Каменск-Уральский with an unresolvable street).
|
||||||
|
"""
|
||||||
|
est = _run_estimate(
|
||||||
|
analogs=[],
|
||||||
|
dkp_raw=None,
|
||||||
|
geo=_make_geo_tagil(),
|
||||||
|
payload=_make_payload_tagil(),
|
||||||
|
)
|
||||||
|
assert est.median_price_per_m2 == 0
|
||||||
|
assert est.n_analogs == 0
|
||||||
|
assert est.confidence == "low"
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_ekb_thin_deal_corridor_below_min_n_stays_insufficient() -> None:
|
||||||
|
"""Deal corridor exists but below DEALS_HEADLINE_FALLBACK_MIN_N → no fallback.
|
||||||
|
|
||||||
|
A single stale sold price should not become the town's headline.
|
||||||
|
"""
|
||||||
|
dkp_raw = {
|
||||||
|
"count": 1,
|
||||||
|
"low_ppm2": 70_000,
|
||||||
|
"median_ppm2": 85_911,
|
||||||
|
"high_ppm2": 100_000,
|
||||||
|
"period_months": 12,
|
||||||
|
}
|
||||||
|
est = _run_estimate(
|
||||||
|
analogs=[],
|
||||||
|
dkp_raw=dkp_raw,
|
||||||
|
geo=_make_geo_tagil(),
|
||||||
|
payload=_make_payload_tagil(),
|
||||||
|
)
|
||||||
|
assert est.median_price_per_m2 == 0
|
||||||
|
assert est.n_analogs == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── EKB control: dense local listings → deals-fallback must NOT engage ───────
|
||||||
|
|
||||||
|
|
||||||
|
def test_ekb_with_dense_listings_ignores_deals_fallback() -> None:
|
||||||
|
"""EKB has plenty of local listings — the radius-path headline must win,
|
||||||
|
NOT the deal corridor, even though dkp_raw is present (byte-green guard:
|
||||||
|
EKB must stay on the existing listings-median path unconditionally).
|
||||||
|
"""
|
||||||
|
from app.schemas.trade_in import TradeInEstimateInput
|
||||||
|
from app.services.geocoder import GeocodeResult
|
||||||
|
|
||||||
|
analogs = [
|
||||||
|
_make_listing(price_per_m2=140_000.0),
|
||||||
|
_make_listing(price_per_m2=145_000.0),
|
||||||
|
_make_listing(price_per_m2=150_000.0),
|
||||||
|
]
|
||||||
|
dkp_raw = {
|
||||||
|
"count": 20,
|
||||||
|
"low_ppm2": 120_000,
|
||||||
|
"median_ppm2": 144_000,
|
||||||
|
"high_ppm2": 160_000,
|
||||||
|
"period_months": 12,
|
||||||
|
}
|
||||||
|
geo = GeocodeResult(
|
||||||
|
lat=56.838,
|
||||||
|
lon=60.595,
|
||||||
|
full_address="Свердловская обл., Екатеринбург, ул. Малышева, 30",
|
||||||
|
provider="nominatim",
|
||||||
|
)
|
||||||
|
payload = TradeInEstimateInput(
|
||||||
|
address="ЕКБ, ул. Малышева, 30", area_m2=45.0, rooms=2, floor=5, total_floors=16
|
||||||
|
)
|
||||||
|
est = _run_estimate(analogs=analogs, dkp_raw=dkp_raw, geo=geo, payload=payload)
|
||||||
|
|
||||||
|
# Headline built from the LISTINGS median (~145k), not silently replaced —
|
||||||
|
# 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
|
||||||
|
|
@ -313,7 +313,6 @@ def _run_estimate_anchor_only(
|
||||||
anchor_tier: str | None = "A",
|
anchor_tier: str | None = "A",
|
||||||
):
|
):
|
||||||
"""estimate_quality: пустой радиус (listings_clean=[]), якорь задаёт median_price."""
|
"""estimate_quality: пустой радиус (listings_clean=[]), якорь задаёт median_price."""
|
||||||
from app.core.config import settings
|
|
||||||
from app.services.estimator import estimate_quality
|
from app.services.estimator import estimate_quality
|
||||||
|
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
|
|
@ -322,7 +321,6 @@ def _run_estimate_anchor_only(
|
||||||
|
|
||||||
async def _run():
|
async def _run():
|
||||||
with (
|
with (
|
||||||
patch.object(settings, "estimate_same_building_anchor_enabled", True),
|
|
||||||
patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_fake_geo())),
|
patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_fake_geo())),
|
||||||
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
|
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.match_house_readonly", return_value=None),
|
||||||
|
|
|
||||||
|
|
@ -5,22 +5,19 @@
|
||||||
blend, rehydrate, миграция), который наполнит median/sources_used без единого
|
blend, rehydrate, миграция), который наполнит median/sources_used без единого
|
||||||
реального аналога ("ghost-anchor", напр. legacy-строка 5fcc1e99: n=0, high,
|
реального аналога ("ghost-anchor", напр. legacy-строка 5fcc1e99: n=0, high,
|
||||||
38.45M, yandex_valuation). Mainline уже честен — это belt-and-suspenders.
|
38.45M, yandex_valuation). Mainline уже честен — это belt-and-suspenders.
|
||||||
|
|
||||||
Гард за флагом settings.estimate_confidence_floor_no_analogs (дефолт True).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
# pydantic Settings требует DATABASE_URL при инициализации.
|
# pydantic Settings требует DATABASE_URL при инициализации.
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||||
# WeasyPrint stubbed in CI.
|
# WeasyPrint stubbed in CI.
|
||||||
sys.modules.setdefault("weasyprint", MagicMock())
|
sys.modules.setdefault("weasyprint", MagicMock())
|
||||||
|
|
||||||
import app.services.estimator as est # noqa: E402
|
|
||||||
from app.services.estimator import _enforce_zero_analog_low # noqa: E402
|
from app.services.estimator import _enforce_zero_analog_low # noqa: E402
|
||||||
|
|
||||||
# Suffix-маркер из гарда — проверяем его наличие/отсутствие, не дублирование.
|
# Suffix-маркер из гарда — проверяем его наличие/отсутствие, не дублирование.
|
||||||
|
|
@ -108,23 +105,8 @@ def test_none_explanation_returns_empty_string_not_none() -> None:
|
||||||
assert isinstance(explanation, str)
|
assert isinstance(explanation, str)
|
||||||
|
|
||||||
|
|
||||||
def test_flag_default_true() -> None:
|
def test_enforce_zero_analog_low_always_downgrades() -> None:
|
||||||
"""estimate_confidence_floor_no_analogs по умолчанию True (гард активен)."""
|
"""_enforce_zero_analog_low всегда понижает high→low при n_analogs=0 (гард всегда ON)."""
|
||||||
from app.core.config import settings
|
|
||||||
|
|
||||||
assert settings.estimate_confidence_floor_no_analogs is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_flag_off_does_not_downgrade() -> None:
|
|
||||||
"""Флаг OFF: n_analogs=0 + confidence='high' → guard пропускается, high сохраняется.
|
|
||||||
|
|
||||||
Проверяет escape-hatch: при estimate_confidence_floor_no_analogs=False
|
|
||||||
_enforce_zero_analog_low не вызывается (вызов обёрнут в if-гейт в estimator.py).
|
|
||||||
Тест патчит settings через patch.object, вызывает функцию напрямую — убеждается
|
|
||||||
что сам гард всё ещё понижает (без флага); интеграционный эффект флага проверяется
|
|
||||||
через patch в estimate_quality (ниже).
|
|
||||||
"""
|
|
||||||
# Напрямую: _enforce_zero_analog_low всегда понижает (флаг только в if-гейте caller'а)
|
|
||||||
confidence, _ = _enforce_zero_analog_low(
|
confidence, _ = _enforce_zero_analog_low(
|
||||||
"high",
|
"high",
|
||||||
0,
|
0,
|
||||||
|
|
@ -133,19 +115,3 @@ def test_flag_off_does_not_downgrade() -> None:
|
||||||
sources_used=["yandex_valuation"],
|
sources_used=["yandex_valuation"],
|
||||||
)
|
)
|
||||||
assert confidence == "low" # сама функция не смотрит на settings
|
assert confidence == "low" # сама функция не смотрит на settings
|
||||||
|
|
||||||
# Через патч: гейт в estimate_quality при флаге OFF пропускает вызов.
|
|
||||||
with patch.object(est.settings, "estimate_confidence_floor_no_analogs", False):
|
|
||||||
# _enforce_zero_analog_low не должна быть вызвана —
|
|
||||||
# мокаем её и убеждаемся что мок не тронут.
|
|
||||||
mock_guard = MagicMock(return_value=("high", "unchanged"))
|
|
||||||
with patch.object(est, "_enforce_zero_analog_low", mock_guard):
|
|
||||||
# Вызываем блок кода напрямую: повторяем if-гейт как в estimate_quality.
|
|
||||||
confidence_in = "high"
|
|
||||||
n = 0
|
|
||||||
expl = "test"
|
|
||||||
if est.settings.estimate_confidence_floor_no_analogs:
|
|
||||||
confidence_in, expl = est._enforce_zero_analog_low(
|
|
||||||
confidence_in, n, expl, median_price=0, sources_used=[]
|
|
||||||
)
|
|
||||||
mock_guard.assert_not_called()
|
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,7 @@
|
||||||
- комбинированный случай (несколько причин сразу);
|
- комбинированный случай (несколько причин сразу);
|
||||||
- all-clear (нет причин → recommended False);
|
- all-clear (нет причин → recommended False);
|
||||||
- граничные значения порогов (>= high_value, >= wide_ratio);
|
- граничные значения порогов (>= high_value, >= wide_ratio);
|
||||||
- div-guard на диапазон (range_low None/0 → без деления на ноль);
|
- div-guard на диапазон (range_low None/0 → без деления на ноль).
|
||||||
- выключенный флаг estimate_manual_review_enabled → (False, []).
|
|
||||||
|
|
||||||
Плюс smoke-проверка дефолтов AggregatedEstimate (флаг — метаданные: сериализация
|
Плюс smoke-проверка дефолтов AggregatedEstimate (флаг — метаданные: сериализация
|
||||||
без изменений, когда ручная оценка не рекомендована).
|
без изменений, когда ручная оценка не рекомендована).
|
||||||
|
|
@ -36,14 +35,12 @@ ELITE_REASON = (
|
||||||
|
|
||||||
def _settings(
|
def _settings(
|
||||||
*,
|
*,
|
||||||
enabled: bool = True,
|
|
||||||
high_value: int = 20_000_000,
|
high_value: int = 20_000_000,
|
||||||
wide_ratio: float = 1.9,
|
wide_ratio: float = 1.9,
|
||||||
elite_ppm2: int = 250_000,
|
elite_ppm2: int = 250_000,
|
||||||
) -> SimpleNamespace:
|
) -> SimpleNamespace:
|
||||||
"""Duck-typed заглушка Settings — helper читает только эти поля."""
|
"""Duck-typed заглушка Settings — helper читает только эти поля."""
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
estimate_manual_review_enabled=enabled,
|
|
||||||
estimate_manual_review_high_value_rub=high_value,
|
estimate_manual_review_high_value_rub=high_value,
|
||||||
estimate_manual_review_wide_range_ratio=wide_ratio,
|
estimate_manual_review_wide_range_ratio=wide_ratio,
|
||||||
estimate_manual_review_elite_ppm2=elite_ppm2,
|
estimate_manual_review_elite_ppm2=elite_ppm2,
|
||||||
|
|
@ -320,23 +317,6 @@ def test_wide_range_div_guard(range_low: int | None, range_high: int | None) ->
|
||||||
assert WIDE_RANGE_REASON not in reasons
|
assert WIDE_RANGE_REASON not in reasons
|
||||||
|
|
||||||
|
|
||||||
# ── Выключенный флаг ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_disabled_flag_suppresses_all() -> None:
|
|
||||||
"""estimate_manual_review_enabled=False → (False, []) даже когда всё триггерит."""
|
|
||||||
rec, reasons = _manual_review(
|
|
||||||
premium_building=True,
|
|
||||||
expected_sold_price=99_000_000,
|
|
||||||
confidence="low",
|
|
||||||
range_low=10_000_000,
|
|
||||||
range_high=99_000_000,
|
|
||||||
settings=_settings(enabled=False),
|
|
||||||
)
|
|
||||||
assert rec is False
|
|
||||||
assert reasons == []
|
|
||||||
|
|
||||||
|
|
||||||
# ── invariant recommended == bool(reasons) ───────────────────────────────────
|
# ── invariant recommended == bool(reasons) ───────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""Mera-audit fix-3: cross-source dedup в _fetch_price_trend.
|
"""Mera-audit fix-3: cross-source dedup в _fetch_price_trend.
|
||||||
|
|
||||||
Проверяет что при dedup_enabled=True один объект на avito_imv + yandex_valuation
|
Проверяет что один объект на avito_imv + yandex_valuation с разными ext_item_id
|
||||||
с разными ext_item_id не double-count'ится в месячной медиане.
|
не double-count'ится в месячной медиане (dedup всегда включён).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -40,19 +40,11 @@ def _make_mock_db(source1_rows: list[dict], source2_rows: list[dict]) -> MagicMo
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_dedup_enabled_flag_is_true_by_default() -> None:
|
def test_price_trend_dedup_sql_uses_distinct_on() -> None:
|
||||||
"""estimate_price_trend_dedup_enabled дефолт True."""
|
"""SQL-запрос к house_placement_history всегда содержит DISTINCT ON (dedup)."""
|
||||||
from app.core.config import settings
|
|
||||||
|
|
||||||
assert settings.estimate_price_trend_dedup_enabled is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_price_trend_dedup_sql_uses_distinct_on_when_enabled() -> None:
|
|
||||||
"""Когда dedup включён, SQL-запрос к house_placement_history содержит DISTINCT ON."""
|
|
||||||
mock_db = _make_mock_db([], [{"month": "2026-01", "ppm2": 150_000}])
|
mock_db = _make_mock_db([], [{"month": "2026-01", "ppm2": 150_000}])
|
||||||
|
|
||||||
with patch("app.services.estimator.settings") as mock_settings:
|
with patch("app.services.estimator.settings") as mock_settings:
|
||||||
mock_settings.estimate_price_trend_dedup_enabled = True
|
|
||||||
mock_settings.estimate_price_trend_max_age_months = 6
|
mock_settings.estimate_price_trend_max_age_months = 6
|
||||||
|
|
||||||
_fetch_price_trend(mock_db, target_house_id=42, min_points=1)
|
_fetch_price_trend(mock_db, target_house_id=42, min_points=1)
|
||||||
|
|
@ -64,24 +56,8 @@ def test_price_trend_dedup_sql_uses_distinct_on_when_enabled() -> None:
|
||||||
assert "DISTINCT ON" in second_sql.upper() or "distinct on" in second_sql.lower()
|
assert "DISTINCT ON" in second_sql.upper() or "distinct on" in second_sql.lower()
|
||||||
|
|
||||||
|
|
||||||
def test_price_trend_dedup_disabled_no_distinct_on() -> None:
|
def test_price_trend_returns_result_with_dedup() -> None:
|
||||||
"""Когда dedup выключен, SQL НЕ содержит DISTINCT ON (legacy path)."""
|
"""Функция корректно возвращает точки тренда (dedup включён)."""
|
||||||
mock_db = _make_mock_db([], [{"month": "2026-01", "ppm2": 150_000}])
|
|
||||||
|
|
||||||
with patch("app.services.estimator.settings") as mock_settings:
|
|
||||||
mock_settings.estimate_price_trend_dedup_enabled = False
|
|
||||||
mock_settings.estimate_price_trend_max_age_months = 6
|
|
||||||
|
|
||||||
_fetch_price_trend(mock_db, target_house_id=42, min_points=1)
|
|
||||||
|
|
||||||
calls = mock_db.execute.call_args_list
|
|
||||||
assert len(calls) >= 2
|
|
||||||
second_sql = str(calls[1].args[0].text) if calls[1].args else ""
|
|
||||||
assert "distinct on" not in second_sql.lower()
|
|
||||||
|
|
||||||
|
|
||||||
def test_price_trend_returns_result_with_dedup_enabled() -> None:
|
|
||||||
"""Функция корректно возвращает точки тренда при dedup_enabled=True."""
|
|
||||||
rows = [
|
rows = [
|
||||||
{"month": "2025-11", "ppm2": 148_000},
|
{"month": "2025-11", "ppm2": 148_000},
|
||||||
{"month": "2025-12", "ppm2": 152_000},
|
{"month": "2025-12", "ppm2": 152_000},
|
||||||
|
|
@ -90,7 +66,6 @@ def test_price_trend_returns_result_with_dedup_enabled() -> None:
|
||||||
mock_db = _make_mock_db([], rows)
|
mock_db = _make_mock_db([], rows)
|
||||||
|
|
||||||
with patch("app.services.estimator.settings") as mock_settings:
|
with patch("app.services.estimator.settings") as mock_settings:
|
||||||
mock_settings.estimate_price_trend_dedup_enabled = True
|
|
||||||
mock_settings.estimate_price_trend_max_age_months = 6
|
mock_settings.estimate_price_trend_max_age_months = 6
|
||||||
|
|
||||||
result = _fetch_price_trend(mock_db, target_house_id=42, min_points=3)
|
result = _fetch_price_trend(mock_db, target_house_id=42, min_points=3)
|
||||||
|
|
@ -101,25 +76,6 @@ def test_price_trend_returns_result_with_dedup_enabled() -> None:
|
||||||
assert result[0]["ppm2"] == 148_000
|
assert result[0]["ppm2"] == 148_000
|
||||||
|
|
||||||
|
|
||||||
def test_price_trend_dedup_disabled_returns_same_points() -> None:
|
|
||||||
"""Флаг OFF не ломает возврат данных (backward-compat)."""
|
|
||||||
rows = [
|
|
||||||
{"month": "2025-11", "ppm2": 148_000},
|
|
||||||
{"month": "2025-12", "ppm2": 152_000},
|
|
||||||
{"month": "2026-01", "ppm2": 155_000},
|
|
||||||
]
|
|
||||||
mock_db = _make_mock_db([], rows)
|
|
||||||
|
|
||||||
with patch("app.services.estimator.settings") as mock_settings:
|
|
||||||
mock_settings.estimate_price_trend_dedup_enabled = False
|
|
||||||
mock_settings.estimate_price_trend_max_age_months = 6
|
|
||||||
|
|
||||||
result = _fetch_price_trend(mock_db, target_house_id=42, min_points=3)
|
|
||||||
|
|
||||||
assert result is not None
|
|
||||||
assert len(result) == 3
|
|
||||||
|
|
||||||
|
|
||||||
def test_price_trend_source1_preferred_over_dedup_path() -> None:
|
def test_price_trend_source1_preferred_over_dedup_path() -> None:
|
||||||
"""Если Source1 (houses_price_dynamics) вернул ≥ min_points → Source2 не вызывается."""
|
"""Если Source1 (houses_price_dynamics) вернул ≥ min_points → Source2 не вызывается."""
|
||||||
source1_rows = [
|
source1_rows = [
|
||||||
|
|
@ -130,7 +86,6 @@ def test_price_trend_source1_preferred_over_dedup_path() -> None:
|
||||||
mock_db = _make_mock_db(source1_rows, [])
|
mock_db = _make_mock_db(source1_rows, [])
|
||||||
|
|
||||||
with patch("app.services.estimator.settings") as mock_settings:
|
with patch("app.services.estimator.settings") as mock_settings:
|
||||||
mock_settings.estimate_price_trend_dedup_enabled = True
|
|
||||||
mock_settings.estimate_price_trend_max_age_months = 6
|
mock_settings.estimate_price_trend_max_age_months = 6
|
||||||
|
|
||||||
result = _fetch_price_trend(mock_db, target_house_id=42, min_points=3)
|
result = _fetch_price_trend(mock_db, target_house_id=42, min_points=3)
|
||||||
|
|
|
||||||
|
|
@ -181,18 +181,49 @@ def test_confidence_high_with_7_unique_addresses_and_tight_iqr() -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_confidence_medium_via_4_unique_addresses() -> None:
|
def test_confidence_medium_via_4_unique_addresses() -> None:
|
||||||
# 4 unique addresses → medium branch (independent of IQR).
|
# #R2-H2: 4 unique addresses AND IQR/median < 0.35 → medium. avg = 4/4 = 1.0 (no downgrade).
|
||||||
# Wide IQR ensures it is NOT "high". avg = 4/4 = 1.0 (no downgrade).
|
listings = _addr_lots(["a", "b", "c", "d"])
|
||||||
|
level, _ = estimator._compute_confidence(
|
||||||
|
n_analogs=4,
|
||||||
|
median_ppm2=100,
|
||||||
|
q1=90,
|
||||||
|
q3=115, # IQR/median = 0.25 < 0.35 → medium
|
||||||
|
fallback_radius_used=False,
|
||||||
|
listings=listings,
|
||||||
|
)
|
||||||
|
assert level == "medium"
|
||||||
|
|
||||||
|
|
||||||
|
def test_confidence_4_addresses_wide_iqr_now_low() -> None:
|
||||||
|
# #R2-H2: 4 unique addresses but IQR/median = 0.60 (huge dispersion) → low, NOT medium.
|
||||||
|
# Dispersion ceiling: the badge must not contradict a ±30% spread in its own explanation.
|
||||||
listings = _addr_lots(["a", "b", "c", "d"])
|
listings = _addr_lots(["a", "b", "c", "d"])
|
||||||
level, _ = estimator._compute_confidence(
|
level, _ = estimator._compute_confidence(
|
||||||
n_analogs=4,
|
n_analogs=4,
|
||||||
median_ppm2=100,
|
median_ppm2=100,
|
||||||
q1=70,
|
q1=70,
|
||||||
q3=130, # IQR/median = 0.60 → not high
|
q3=130, # IQR/median = 0.60
|
||||||
fallback_radius_used=False,
|
fallback_radius_used=False,
|
||||||
listings=listings,
|
listings=listings,
|
||||||
)
|
)
|
||||||
assert level == "medium"
|
assert level == "low"
|
||||||
|
|
||||||
|
|
||||||
|
def test_confidence_force_low_on_radius_widen_with_dispersion() -> None:
|
||||||
|
# #R2-H2: a pool that would be medium (4 addr, IQR 0.32 < 0.35) is FORCED low when it
|
||||||
|
# was radius-widened due to sparse data AND spread > 0.30 — badge can't say "medium"
|
||||||
|
# while the explanation admits "расширили радиус … из-за нехватки данных".
|
||||||
|
listings = _addr_lots(["a", "b", "c", "d"])
|
||||||
|
level, expl = estimator._compute_confidence(
|
||||||
|
n_analogs=4,
|
||||||
|
median_ppm2=100,
|
||||||
|
q1=84,
|
||||||
|
q3=116, # IQR/median = 0.32 → medium base, then forced low
|
||||||
|
fallback_radius_used=True,
|
||||||
|
listings=listings,
|
||||||
|
)
|
||||||
|
assert level == "low"
|
||||||
|
assert "расширили радиус" in expl
|
||||||
|
|
||||||
|
|
||||||
def test_confidence_medium_via_2_unique_addresses_and_tight_iqr() -> None:
|
def test_confidence_medium_via_2_unique_addresses_and_tight_iqr() -> None:
|
||||||
|
|
@ -313,7 +344,6 @@ def _clamp(
|
||||||
tier: str | None,
|
tier: str | None,
|
||||||
slack: float = 0.25,
|
slack: float = 0.25,
|
||||||
min_n: int = 10,
|
min_n: int = 10,
|
||||||
enabled: bool = True,
|
|
||||||
median_price: int = 0,
|
median_price: int = 0,
|
||||||
range_low: int = 0,
|
range_low: int = 0,
|
||||||
range_high: int = 0,
|
range_high: int = 0,
|
||||||
|
|
@ -328,7 +358,6 @@ def _clamp(
|
||||||
anchor_tier=tier,
|
anchor_tier=tier,
|
||||||
slack=slack,
|
slack=slack,
|
||||||
min_n=min_n,
|
min_n=min_n,
|
||||||
enabled=enabled,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -374,15 +403,6 @@ def test_corridor_clamp_low_n_is_noop() -> None:
|
||||||
assert new_ppm2 == 296_000
|
assert new_ppm2 == 296_000
|
||||||
|
|
||||||
|
|
||||||
def test_corridor_clamp_disabled_flag_is_noop() -> None:
|
|
||||||
# enabled=False → точно старое поведение.
|
|
||||||
new_ppm2, _, _, _, clamped = _clamp(
|
|
||||||
median_ppm2=296_000, corridor_high=138_000, count=20, tier="C", enabled=False
|
|
||||||
)
|
|
||||||
assert clamped is False
|
|
||||||
assert new_ppm2 == 296_000
|
|
||||||
|
|
||||||
|
|
||||||
def test_corridor_clamp_tier_none_clamps() -> None:
|
def test_corridor_clamp_tier_none_clamps() -> None:
|
||||||
# anchor не сработал (tier=None, чистый радиус) — клампим как и Tier C.
|
# anchor не сработал (tier=None, чистый радиус) — клампим как и Tier C.
|
||||||
new_ppm2, _, _, _, clamped = _clamp(
|
new_ppm2, _, _, _, clamped = _clamp(
|
||||||
|
|
|
||||||
|
|
@ -379,7 +379,6 @@ def _run_estimate_qi(
|
||||||
dadata_cadnum: str | None,
|
dadata_cadnum: str | None,
|
||||||
qi_lookup_result: tuple[float, int] | None,
|
qi_lookup_result: tuple[float, int] | None,
|
||||||
*,
|
*,
|
||||||
flag_enabled: bool = True,
|
|
||||||
anchor_tier_override: str | None = None,
|
anchor_tier_override: str | None = None,
|
||||||
):
|
):
|
||||||
"""Запускает estimate_quality с полным stub-пачем I/O; возвращает AggregatedEstimate."""
|
"""Запускает estimate_quality с полным stub-пачем I/O; возвращает AggregatedEstimate."""
|
||||||
|
|
@ -442,10 +441,6 @@ def _run_estimate_qi(
|
||||||
"app.services.estimator._lookup_quarter_indexes",
|
"app.services.estimator._lookup_quarter_indexes",
|
||||||
side_effect=_fake_lookup_indexes,
|
side_effect=_fake_lookup_indexes,
|
||||||
),
|
),
|
||||||
patch(
|
|
||||||
"app.services.estimator.settings.estimate_quarter_index_enabled",
|
|
||||||
flag_enabled,
|
|
||||||
),
|
|
||||||
):
|
):
|
||||||
return await estimate_quality(payload, db)
|
return await estimate_quality(payload, db)
|
||||||
|
|
||||||
|
|
@ -488,7 +483,6 @@ def test_quarter_index_correction_applied() -> None:
|
||||||
analogs=analogs_no_cadnum,
|
analogs=analogs_no_cadnum,
|
||||||
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
||||||
qi_lookup_result=(1.2, 30),
|
qi_lookup_result=(1.2, 30),
|
||||||
flag_enabled=True,
|
|
||||||
)
|
)
|
||||||
expected_median = round(base_median * 1.2)
|
expected_median = round(base_median * 1.2)
|
||||||
assert est.median_price_rub == expected_median
|
assert est.median_price_rub == expected_median
|
||||||
|
|
@ -497,21 +491,6 @@ def test_quarter_index_correction_applied() -> None:
|
||||||
assert "квартал" in est.confidence_explanation.lower()
|
assert "квартал" in est.confidence_explanation.lower()
|
||||||
|
|
||||||
|
|
||||||
def test_quarter_index_flag_off_exact_old_behavior() -> None:
|
|
||||||
"""При estimate_quarter_index_enabled=False — точное старое поведение."""
|
|
||||||
analogs_no_cadnum = [
|
|
||||||
_make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None) for _ in range(3)
|
|
||||||
]
|
|
||||||
est = _run_estimate_qi(
|
|
||||||
analogs=analogs_no_cadnum,
|
|
||||||
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
|
||||||
qi_lookup_result=(1.2, 30),
|
|
||||||
flag_enabled=False,
|
|
||||||
)
|
|
||||||
base_median = round(_BASE_PPM2 * _AREA)
|
|
||||||
assert est.median_price_rub == base_median
|
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Guard-2: >0.6 аналогов в целевом квартале → no-op
|
# Guard-2: >0.6 аналогов в целевом квартале → no-op
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -534,7 +513,6 @@ def test_guard2_skip_when_majority_analogs_in_target_quarter() -> None:
|
||||||
analogs=analogs,
|
analogs=analogs,
|
||||||
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
||||||
qi_lookup_result=(1.5, 30), # high index — but guard-2 should skip
|
qi_lookup_result=(1.5, 30), # high index — but guard-2 should skip
|
||||||
flag_enabled=True,
|
|
||||||
)
|
)
|
||||||
# Медиана НЕ должна изменяться
|
# Медиана НЕ должна изменяться
|
||||||
assert est.median_price_rub == base_median
|
assert est.median_price_rub == base_median
|
||||||
|
|
@ -555,7 +533,6 @@ def test_sparse_fallback_no_row_noop() -> None:
|
||||||
analogs=analogs_no_cadnum,
|
analogs=analogs_no_cadnum,
|
||||||
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
||||||
qi_lookup_result=None, # sparse
|
qi_lookup_result=None, # sparse
|
||||||
flag_enabled=True,
|
|
||||||
)
|
)
|
||||||
assert est.median_price_rub == base_median
|
assert est.median_price_rub == base_median
|
||||||
|
|
||||||
|
|
@ -575,7 +552,6 @@ def test_bimodal_guard_skips_high_index_small_n() -> None:
|
||||||
analogs=analogs_no_cadnum,
|
analogs=analogs_no_cadnum,
|
||||||
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
||||||
qi_lookup_result=(3.5, 20), # index>2.0 AND n<50 → bimodal guard
|
qi_lookup_result=(3.5, 20), # index>2.0 AND n<50 → bimodal guard
|
||||||
flag_enabled=True,
|
|
||||||
)
|
)
|
||||||
assert est.median_price_rub == base_median
|
assert est.median_price_rub == base_median
|
||||||
|
|
||||||
|
|
@ -594,7 +570,6 @@ def test_bimodal_guard_allows_high_index_large_n() -> None:
|
||||||
analogs=analogs_no_cadnum,
|
analogs=analogs_no_cadnum,
|
||||||
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
||||||
qi_lookup_result=(2.5, 60), # index>2.0 но n=60>=50 → bimodal guard не срабатывает
|
qi_lookup_result=(2.5, 60), # index>2.0 но n=60>=50 → bimodal guard не срабатывает
|
||||||
flag_enabled=True,
|
|
||||||
)
|
)
|
||||||
# Коррекция применена: медиана != base_median (bimodal guard не заблокировал).
|
# Коррекция применена: медиана != base_median (bimodal guard не заблокировал).
|
||||||
# factor=2.5 > max_factor=1.8 → зажат до 1.8 (#859).
|
# factor=2.5 > max_factor=1.8 → зажат до 1.8 (#859).
|
||||||
|
|
@ -620,6 +595,10 @@ def test_guard1_anchor_tier_prevents_correction() -> None:
|
||||||
payload = _make_payload_qi()
|
payload = _make_payload_qi()
|
||||||
dadata_obj = _make_fake_dadata(f"{_TARGET_QUARTER}:350")
|
dadata_obj = _make_fake_dadata(f"{_TARGET_QUARTER}:350")
|
||||||
|
|
||||||
|
fake_comps = [
|
||||||
|
_make_listing_qi(price_per_m2=_BASE_PPM2 * 1.5, building_cadastral_number=None)
|
||||||
|
for _ in range(3)
|
||||||
|
]
|
||||||
fake_anchor = {
|
fake_anchor = {
|
||||||
"anchor_ppm2": _BASE_PPM2 * 1.5,
|
"anchor_ppm2": _BASE_PPM2 * 1.5,
|
||||||
"anchor_sold_ppm2": _BASE_PPM2 * 1.4,
|
"anchor_sold_ppm2": _BASE_PPM2 * 1.4,
|
||||||
|
|
@ -631,11 +610,10 @@ def test_guard1_anchor_tier_prevents_correction() -> None:
|
||||||
"comp_max_ppm2": _BASE_PPM2 * 1.7,
|
"comp_max_ppm2": _BASE_PPM2 * 1.7,
|
||||||
"used_uplift": False,
|
"used_uplift": False,
|
||||||
"haircut": 0.05,
|
"haircut": 0.05,
|
||||||
|
# display-consistency fix: contract now requires "comps" — the final
|
||||||
|
# post-MAD-clip survivors (see _compute_same_building_anchor docstring).
|
||||||
|
"comps": fake_comps,
|
||||||
}
|
}
|
||||||
fake_comps = [
|
|
||||||
_make_listing_qi(price_per_m2=_BASE_PPM2 * 1.5, building_cadastral_number=None)
|
|
||||||
for _ in range(3)
|
|
||||||
]
|
|
||||||
|
|
||||||
analogs_no_cadnum = [
|
analogs_no_cadnum = [
|
||||||
_make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None) for _ in range(3)
|
_make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None) for _ in range(3)
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ Radius-путь кэпит только per-address (rn_addr <= MAX_ANALOGS_PER_
|
||||||
(source, source_id)-дубли делят один address и выживают на разных rn_addr рангах →
|
(source, source_id)-дубли делят один address и выживают на разных rn_addr рангах →
|
||||||
раздувают n_analogs. Anchor-путь дедупит по (source, source_id); radius — нет.
|
раздувают n_analogs. Anchor-путь дедупит по (source, source_id); radius — нет.
|
||||||
|
|
||||||
Эти тесты проверяют SQL-фрагмент (rn_dup-окно всегда; `AND rn_dup = 1` фильтр за
|
Эти тесты проверяют SQL-фрагмент (rn_dup-окно + `AND rn_dup = 1` фильтр) во ВСЕХ
|
||||||
флагом) во ВСЕХ четырёх тирах (S-canonical, S-fallback, H, W) + дедупную семантику
|
четырёх тирах (S-canonical, S-fallback, H, W) + дедупную семантику
|
||||||
окна на уровне Python-симуляции (freshest scraped_at, namespaced ключ, NULL-guard).
|
окна на уровне Python-симуляции (freshest scraped_at, namespaced ключ, NULL-guard).
|
||||||
Полный radius-путь требует PostGIS+БД, поэтому SQL проверяется на сгенерированном
|
Полный radius-путь требует PostGIS+БД, поэтому SQL проверяется на сгенерированном
|
||||||
тексте (как test_estimator_price_trend_dedup.py).
|
тексте (как test_estimator_price_trend_dedup.py).
|
||||||
|
|
@ -14,7 +14,7 @@ Radius-путь кэпит только per-address (rn_addr <= MAX_ANALOGS_PER_
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
|
@ -23,7 +23,7 @@ import pytest
|
||||||
import app.services.estimator as est
|
import app.services.estimator as est
|
||||||
|
|
||||||
|
|
||||||
def _capture_tier_sql(*, dedup_enabled: bool) -> list[str]:
|
def _capture_tier_sql() -> list[str]:
|
||||||
"""Прогоняет _fetch_analogs с mock db, возвращает SQL-текст каждого тира.
|
"""Прогоняет _fetch_analogs с mock db, возвращает SQL-текст каждого тира.
|
||||||
|
|
||||||
target_house_id + short_addr + year/floors заданы так, чтобы SQL ВСЕХ четырёх
|
target_house_id + short_addr + year/floors заданы так, чтобы SQL ВСЕХ четырёх
|
||||||
|
|
@ -40,63 +40,42 @@ def _capture_tier_sql(*, dedup_enabled: bool) -> list[str]:
|
||||||
|
|
||||||
db.execute.side_effect = side_effect
|
db.execute.side_effect = side_effect
|
||||||
|
|
||||||
with patch.object(est.settings, "estimate_radius_dedup_enabled", dedup_enabled):
|
est._fetch_analogs(
|
||||||
est._fetch_analogs(
|
db,
|
||||||
db,
|
lat=56.83,
|
||||||
lat=56.83,
|
lon=60.6,
|
||||||
lon=60.6,
|
rooms=2,
|
||||||
rooms=2,
|
area=50.0,
|
||||||
area=50.0,
|
radius_m=2000,
|
||||||
radius_m=2000,
|
full_address="г Екатеринбург, ул Малышева, д 30",
|
||||||
full_address="г Екатеринбург, ул Малышева, д 30",
|
year_built=2010,
|
||||||
year_built=2010,
|
house_type="монолит",
|
||||||
house_type="монолит",
|
total_floors=20,
|
||||||
total_floors=20,
|
target_house_id=123,
|
||||||
target_house_id=123,
|
)
|
||||||
)
|
|
||||||
return captured
|
return captured
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Флаг по умолчанию
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_radius_dedup_flag_default_true() -> None:
|
|
||||||
from app.core.config import settings
|
|
||||||
|
|
||||||
assert settings.estimate_radius_dedup_enabled is True
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# SQL-фрагмент во всех тирах
|
# SQL-фрагмент во всех тирах
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_all_four_tiers_render() -> None:
|
def test_all_four_tiers_render() -> None:
|
||||||
sqls = _capture_tier_sql(dedup_enabled=True)
|
sqls = _capture_tier_sql()
|
||||||
assert len(sqls) == 4, "ожидаем S-canonical, S-fallback, H, W"
|
assert len(sqls) == 4, "ожидаем S-canonical, S-fallback, H, W"
|
||||||
|
|
||||||
|
|
||||||
def test_rn_dup_window_present_in_every_tier_when_enabled() -> None:
|
def test_rn_dup_window_present_in_every_tier() -> None:
|
||||||
sqls = _capture_tier_sql(dedup_enabled=True)
|
sqls = _capture_tier_sql()
|
||||||
for i, sql in enumerate(sqls):
|
for i, sql in enumerate(sqls):
|
||||||
assert "AS rn_dup" in sql, f"tier#{i} без rn_dup-окна"
|
assert "AS rn_dup" in sql, f"tier#{i} без rn_dup-окна"
|
||||||
assert "rn_dup = 1" in sql, f"tier#{i} без `AND rn_dup = 1` фильтра"
|
assert "rn_dup = 1" in sql, f"tier#{i} без `AND rn_dup = 1` фильтра"
|
||||||
|
|
||||||
|
|
||||||
def test_dup_filter_dropped_when_disabled_but_window_stays() -> None:
|
|
||||||
"""Флаг OFF: фильтр `AND rn_dup = 1` убран, окно остаётся (безвредно)."""
|
|
||||||
sqls = _capture_tier_sql(dedup_enabled=False)
|
|
||||||
assert len(sqls) == 4
|
|
||||||
for i, sql in enumerate(sqls):
|
|
||||||
assert "AS rn_dup" in sql, f"tier#{i}: окно должно оставаться даже при OFF"
|
|
||||||
assert "rn_dup = 1" not in sql, f"tier#{i}: фильтр должен исчезнуть при OFF"
|
|
||||||
|
|
||||||
|
|
||||||
def test_rn_dup_key_namespaced_and_freshest() -> None:
|
def test_rn_dup_key_namespaced_and_freshest() -> None:
|
||||||
"""Ключ окна namespaced (id:/url:/ctid:) + ORDER BY scraped_at DESC."""
|
"""Ключ окна namespaced (id:/url:/ctid:) + ORDER BY scraped_at DESC."""
|
||||||
sql = _capture_tier_sql(dedup_enabled=True)[0]
|
sql = _capture_tier_sql()[0]
|
||||||
assert "PARTITION BY source" in sql
|
assert "PARTITION BY source" in sql
|
||||||
assert "'id:' || source_id::text" in sql
|
assert "'id:' || source_id::text" in sql
|
||||||
assert "'url:' || source_url" in sql
|
assert "'url:' || source_url" in sql
|
||||||
|
|
@ -117,7 +96,7 @@ def test_no_bind_param_double_colon_cast() -> None:
|
||||||
import re
|
import re
|
||||||
|
|
||||||
bad = re.compile(r":[a-z_]+::[a-z]")
|
bad = re.compile(r":[a-z_]+::[a-z]")
|
||||||
for i, sql in enumerate(_capture_tier_sql(dedup_enabled=True)):
|
for i, sql in enumerate(_capture_tier_sql()):
|
||||||
assert not bad.search(sql), f"tier#{i}: найден запрещённый :bind::type"
|
assert not bad.search(sql), f"tier#{i}: найден запрещённый :bind::type"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@
|
||||||
- radius median выше dkp_low × factor → no-op (медиана не изменена)
|
- radius median выше dkp_low × factor → no-op (медиана не изменена)
|
||||||
- dkp_raw is None → no-op (нет базы для floor)
|
- dkp_raw is None → no-op (нет базы для floor)
|
||||||
- anchor-путь (anchor_tier != None) → не затронут floor'ом
|
- anchor-путь (anchor_tier != None) → не затронут floor'ом
|
||||||
- флаг estimate_radius_floor_enabled=False → no-op
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -75,7 +74,6 @@ def _run_estimate(
|
||||||
analogs: list[dict[str, Any]],
|
analogs: list[dict[str, Any]],
|
||||||
dkp_raw: dict[str, Any] | None,
|
dkp_raw: dict[str, Any] | None,
|
||||||
*,
|
*,
|
||||||
radius_floor_enabled: bool = True,
|
|
||||||
radius_floor_factor: float = 0.8,
|
radius_floor_factor: float = 0.8,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
@ -109,7 +107,6 @@ def _run_estimate(
|
||||||
),
|
),
|
||||||
patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw),
|
patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw),
|
||||||
patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
|
patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
|
||||||
patch.object(settings, "estimate_radius_floor_enabled", radius_floor_enabled),
|
|
||||||
patch.object(settings, "estimate_radius_floor_factor", radius_floor_factor),
|
patch.object(settings, "estimate_radius_floor_factor", radius_floor_factor),
|
||||||
):
|
):
|
||||||
return await estimate_quality(payload, db)
|
return await estimate_quality(payload, db)
|
||||||
|
|
@ -188,62 +185,3 @@ def test_no_dkp_raw_no_floor() -> None:
|
||||||
assert (
|
assert (
|
||||||
est.median_price_per_m2 < 100_000
|
est.median_price_per_m2 < 100_000
|
||||||
), f"median_ppm2={est.median_price_per_m2} без dkp_raw не должна расти"
|
), f"median_ppm2={est.median_price_per_m2} без dkp_raw не должна расти"
|
||||||
|
|
||||||
|
|
||||||
# ── тест 4: флаг OFF → no-op ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_radius_floor_disabled_no_op() -> None:
|
|
||||||
"""estimate_radius_floor_enabled=False → floor не применяется."""
|
|
||||||
analogs = [
|
|
||||||
_make_listing(price_per_m2=75_000.0),
|
|
||||||
_make_listing(price_per_m2=80_000.0),
|
|
||||||
_make_listing(price_per_m2=85_000.0),
|
|
||||||
]
|
|
||||||
dkp_raw = {
|
|
||||||
"count": 15,
|
|
||||||
"low_ppm2": 150_000,
|
|
||||||
"median_ppm2": 180_000,
|
|
||||||
"high_ppm2": 220_000,
|
|
||||||
"period_months": 12,
|
|
||||||
}
|
|
||||||
# Без флага — median не должна подняться
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.services.estimator import estimate_quality
|
|
||||||
|
|
||||||
db = MagicMock()
|
|
||||||
payload = _make_payload()
|
|
||||||
|
|
||||||
async def _run() -> Any:
|
|
||||||
with (
|
|
||||||
patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_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=(list(analogs), False, "S"),
|
|
||||||
),
|
|
||||||
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)),
|
|
||||||
patch.object(settings, "estimate_radius_floor_enabled", False),
|
|
||||||
):
|
|
||||||
return await estimate_quality(payload, db)
|
|
||||||
|
|
||||||
est = anyio.run(_run)
|
|
||||||
assert (
|
|
||||||
est.median_price_per_m2 < 100_000
|
|
||||||
), f"Флаг OFF: median_ppm2={est.median_price_per_m2} не должна расти"
|
|
||||||
|
|
|
||||||
|
|
@ -47,11 +47,8 @@ def _payload(): # type: ignore[no-untyped-def]
|
||||||
return _h._make_payload(area=60.0, rooms=2)
|
return _h._make_payload(area=60.0, rooms=2)
|
||||||
|
|
||||||
|
|
||||||
def _run(*, flag: bool, anchor_tier: str, comps=None, threshold: float = 0.6): # type: ignore[no-untyped-def]
|
def _run(*, anchor_tier: str, comps=None, threshold: float = 0.6): # type: ignore[no-untyped-def]
|
||||||
with (
|
with patch.object(settings, "estimate_wide_corridor_threshold", threshold):
|
||||||
patch.object(settings, "estimate_wide_corridor_disclosure_enabled", flag),
|
|
||||||
patch.object(settings, "estimate_wide_corridor_threshold", threshold),
|
|
||||||
):
|
|
||||||
return _h._run_estimate(
|
return _h._run_estimate(
|
||||||
anchor_comps=_WIDE_SPLIT_COMPS if comps is None else comps,
|
anchor_comps=_WIDE_SPLIT_COMPS if comps is None else comps,
|
||||||
anchor_tier=anchor_tier,
|
anchor_tier=anchor_tier,
|
||||||
|
|
@ -63,15 +60,10 @@ _DISCLOSURE_MARKER = "разбит на секции"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Флаг по умолчанию
|
# Порог по умолчанию
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_disclosure_flag_default_on() -> None:
|
|
||||||
# Включено после валидации порога на prod-данных (corridor_pct median≈0.48).
|
|
||||||
assert settings.estimate_wide_corridor_disclosure_enabled is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_threshold_default_1_2() -> None:
|
def test_threshold_default_1_2() -> None:
|
||||||
# 1.2: 0.6 фаерил бы на ~31% оценок (широкий коридор ≠ split-дом);
|
# 1.2: 0.6 фаерил бы на ~31% оценок (широкий коридор ≠ split-дом);
|
||||||
# genuine split-дома аудита = 148-170% → 1.2 ловит только экстремальный хвост.
|
# genuine split-дома аудита = 148-170% → 1.2 ловит только экстремальный хвост.
|
||||||
|
|
@ -83,21 +75,21 @@ def test_threshold_default_1_2() -> None:
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_tier_a_wide_corridor_flag_on_downgrades_and_discloses() -> None:
|
def test_tier_a_wide_corridor_downgrades_and_discloses() -> None:
|
||||||
"""Tier A + corridor_pct > threshold + флаг ON → confidence понижен + disclosure."""
|
"""Tier A + corridor_pct > threshold → confidence понижен + disclosure."""
|
||||||
on = _run(flag=True, anchor_tier="A")
|
on = _run(anchor_tier="A")
|
||||||
pct = (on.range_high_rub - on.range_low_rub) / on.median_price_rub
|
pct = (on.range_high_rub - on.range_low_rub) / on.median_price_rub
|
||||||
assert pct > 0.6, "fixture должен давать коридор > 60%"
|
assert pct > 0.6, "fixture должен давать коридор > 60%"
|
||||||
assert _DISCLOSURE_MARKER in (on.confidence_explanation or "")
|
assert _DISCLOSURE_MARKER in (on.confidence_explanation or "")
|
||||||
# confidence понижен относительно flag-OFF baseline (medium → low).
|
# confidence понижен относительно no-fire baseline (порог выше коридора).
|
||||||
off = _run(flag=False, anchor_tier="A")
|
off = _run(anchor_tier="A", threshold=99.0)
|
||||||
assert _downgrade_confidence(off.confidence) == on.confidence
|
assert _downgrade_confidence(off.confidence) == on.confidence
|
||||||
assert on.confidence != off.confidence
|
assert on.confidence != off.confidence
|
||||||
|
|
||||||
|
|
||||||
def test_flag_off_no_change() -> None:
|
def test_threshold_above_corridor_no_change() -> None:
|
||||||
"""Флаг OFF → ничего не делает (ни confidence, ни explanation)."""
|
"""Порог выше фактического коридора → ничего не делает (ни confidence, ни explanation)."""
|
||||||
off = _run(flag=False, anchor_tier="A")
|
off = _run(anchor_tier="A", threshold=99.0)
|
||||||
assert _DISCLOSURE_MARKER not in (off.confidence_explanation or "")
|
assert _DISCLOSURE_MARKER not in (off.confidence_explanation or "")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -108,7 +100,7 @@ def test_flag_off_no_change() -> None:
|
||||||
|
|
||||||
def test_corridor_fires_when_above_threshold() -> None:
|
def test_corridor_fires_when_above_threshold() -> None:
|
||||||
"""corridor_pct (≈1.045) > threshold (1.0) → disclosure фаерит (механизм работает)."""
|
"""corridor_pct (≈1.045) > threshold (1.0) → disclosure фаерит (механизм работает)."""
|
||||||
on = _run(flag=True, anchor_tier="A", threshold=1.0)
|
on = _run(anchor_tier="A", threshold=1.0)
|
||||||
pct = (on.range_high_rub - on.range_low_rub) / on.median_price_rub
|
pct = (on.range_high_rub - on.range_low_rub) / on.median_price_rub
|
||||||
assert pct > 1.0
|
assert pct > 1.0
|
||||||
assert _DISCLOSURE_MARKER in (on.confidence_explanation or "")
|
assert _DISCLOSURE_MARKER in (on.confidence_explanation or "")
|
||||||
|
|
@ -117,7 +109,7 @@ def test_corridor_fires_when_above_threshold() -> None:
|
||||||
def test_moderate_corridor_no_fire_at_prod_threshold_1_2() -> None:
|
def test_moderate_corridor_no_fire_at_prod_threshold_1_2() -> None:
|
||||||
"""Прод-дефолт 1.2: умеренный коридор (0.6<pct<1.2) НЕ фаерит — нет ложной
|
"""Прод-дефолт 1.2: умеренный коридор (0.6<pct<1.2) НЕ фаерит — нет ложной
|
||||||
атрибуции split-дома на ~31% оценок, которую дал бы старый порог 0.6."""
|
атрибуции split-дома на ~31% оценок, которую дал бы старый порог 0.6."""
|
||||||
on = _run(flag=True, anchor_tier="A", threshold=1.2) # _WIDE_SPLIT_COMPS ≈ 1.045
|
on = _run(anchor_tier="A", threshold=1.2) # _WIDE_SPLIT_COMPS ≈ 1.045
|
||||||
pct = (on.range_high_rub - on.range_low_rub) / on.median_price_rub
|
pct = (on.range_high_rub - on.range_low_rub) / on.median_price_rub
|
||||||
assert 0.6 < pct < 1.2, "moderate-fixture должен быть между 0.6 и 1.2"
|
assert 0.6 < pct < 1.2, "moderate-fixture должен быть между 0.6 и 1.2"
|
||||||
assert _DISCLOSURE_MARKER not in (on.confidence_explanation or "")
|
assert _DISCLOSURE_MARKER not in (on.confidence_explanation or "")
|
||||||
|
|
@ -125,8 +117,8 @@ def test_moderate_corridor_no_fire_at_prod_threshold_1_2() -> None:
|
||||||
|
|
||||||
def test_does_not_touch_point_median_range() -> None:
|
def test_does_not_touch_point_median_range() -> None:
|
||||||
"""Disclosure НЕ трогает point/median/range — только confidence+explanation."""
|
"""Disclosure НЕ трогает point/median/range — только confidence+explanation."""
|
||||||
off = _run(flag=False, anchor_tier="A")
|
off = _run(anchor_tier="A", threshold=99.0)
|
||||||
on = _run(flag=True, anchor_tier="A")
|
on = _run(anchor_tier="A")
|
||||||
assert on.median_price_rub == off.median_price_rub
|
assert on.median_price_rub == off.median_price_rub
|
||||||
assert on.range_low_rub == off.range_low_rub
|
assert on.range_low_rub == off.range_low_rub
|
||||||
assert on.range_high_rub == off.range_high_rub
|
assert on.range_high_rub == off.range_high_rub
|
||||||
|
|
@ -134,13 +126,13 @@ def test_does_not_touch_point_median_range() -> None:
|
||||||
|
|
||||||
def test_below_threshold_no_disclosure() -> None:
|
def test_below_threshold_no_disclosure() -> None:
|
||||||
"""corridor_pct ниже порога → без изменений (порог поднят выше фактического)."""
|
"""corridor_pct ниже порога → без изменений (порог поднят выше фактического)."""
|
||||||
high_threshold = _run(flag=True, anchor_tier="A", threshold=5.0)
|
high_threshold = _run(anchor_tier="A", threshold=5.0)
|
||||||
assert _DISCLOSURE_MARKER not in (high_threshold.confidence_explanation or "")
|
assert _DISCLOSURE_MARKER not in (high_threshold.confidence_explanation or "")
|
||||||
|
|
||||||
|
|
||||||
def test_non_tier_a_not_affected() -> None:
|
def test_non_tier_a_not_affected() -> None:
|
||||||
"""Не-Tier-A (Tier C) → disclosure не применяется (даже с флагом ON)."""
|
"""Не-Tier-A (Tier C) → disclosure не применяется."""
|
||||||
c = _run(flag=True, anchor_tier="C")
|
c = _run(anchor_tier="C")
|
||||||
assert _DISCLOSURE_MARKER not in (c.confidence_explanation or "")
|
assert _DISCLOSURE_MARKER not in (c.confidence_explanation or "")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,33 @@
|
||||||
"""Тесты для ЕКБ bbox-хелперов geocoder (#1871).
|
"""Тесты для ЕКБ/oblast66 bbox-хелперов geocoder (#1871, oblast-66 expansion).
|
||||||
|
|
||||||
Покрывают:
|
Покрывают:
|
||||||
- is_within_ekb_bbox_wide — ingest-guard (avito_detail координаты);
|
- is_within_ekb_bbox_wide — ingest-guard (avito_detail координаты);
|
||||||
- is_within_ekb_bbox — geocoder tight-фильтр (поведение НЕ изменилось после DRY-рефактора);
|
- is_within_ekb_bbox — geocoder tight-фильтр (поведение НЕ изменилось после DRY-рефактора);
|
||||||
- инвариант WIDE ⊇ TIGHT.
|
- инвариант WIDE ⊇ TIGHT;
|
||||||
|
- OBLAST66_BBOX / is_within_oblast66_bbox — генеральный bbox всей области (region 66),
|
||||||
|
инвариант 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).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.services.geocoder import (
|
from app.services.geocoder import (
|
||||||
EKB_BBOX_TIGHT,
|
EKB_BBOX_TIGHT,
|
||||||
EKB_BBOX_WIDE,
|
EKB_BBOX_WIDE,
|
||||||
|
OBLAST66_BBOX,
|
||||||
|
_has_oblast_marker,
|
||||||
|
_names_non_ekb_city,
|
||||||
|
_nominatim_query,
|
||||||
|
_yandex_lookup,
|
||||||
is_within_ekb_bbox,
|
is_within_ekb_bbox,
|
||||||
is_within_ekb_bbox_wide,
|
is_within_ekb_bbox_wide,
|
||||||
|
is_within_oblast66_bbox,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Центр ЕКБ (Плотинка) — внутри обоих bbox.
|
# Центр ЕКБ (Плотинка) — внутри обоих bbox.
|
||||||
|
|
@ -86,3 +101,225 @@ def test_wide_strictly_contains_tight() -> None:
|
||||||
for lon in (t_lon_min, t_lon_max):
|
for lon in (t_lon_min, t_lon_max):
|
||||||
assert is_within_ekb_bbox(lat, lon) is True
|
assert is_within_ekb_bbox(lat, lon) is True
|
||||||
assert is_within_ekb_bbox_wide(lat, lon) is True
|
assert is_within_ekb_bbox_wide(lat, lon) is True
|
||||||
|
|
||||||
|
|
||||||
|
# ── OBLAST66_BBOX (region 66 expansion) ─────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_oblast66_strictly_contains_tight() -> None:
|
||||||
|
"""Инвариант: OBLAST66_BBOX ⊇ EKB_BBOX_TIGHT — генерализация не режет ЕКБ.
|
||||||
|
|
||||||
|
Мирроор test_wide_strictly_contains_tight, но для областного bbox.
|
||||||
|
"""
|
||||||
|
t_lat_min, t_lat_max, t_lon_min, t_lon_max = EKB_BBOX_TIGHT
|
||||||
|
o_lat_min, o_lat_max, o_lon_min, o_lon_max = OBLAST66_BBOX
|
||||||
|
assert o_lat_min <= t_lat_min
|
||||||
|
assert o_lat_max >= t_lat_max
|
||||||
|
assert o_lon_min <= t_lon_min
|
||||||
|
assert o_lon_max >= t_lon_max
|
||||||
|
for lat in (t_lat_min, t_lat_max):
|
||||||
|
for lon in (t_lon_min, t_lon_max):
|
||||||
|
assert is_within_ekb_bbox(lat, lon) is True
|
||||||
|
assert is_within_oblast66_bbox(lat, lon) is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"lat,lon,label",
|
||||||
|
[
|
||||||
|
(60.6935, 60.4235, "Ивдель"),
|
||||||
|
(59.6297, 60.5541, "Серов"),
|
||||||
|
(58.0378, 65.2753, "Тавда"),
|
||||||
|
(56.6152, 57.7666, "Красноуфимск"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_oblast66_accepts_far_towns(lat: float, lon: float, label: str) -> None:
|
||||||
|
"""Дальние города области (вне EKB tight/wide) — приняты oblast66-bbox."""
|
||||||
|
assert is_within_oblast66_bbox(lat, lon) is True, f"{label} должен быть внутри OBLAST66_BBOX"
|
||||||
|
|
||||||
|
|
||||||
|
def test_oblast66_bbox_admits_tyumen_by_design() -> None:
|
||||||
|
"""OBLAST66_BBOX — генеральный (щедрый) bbox, координатно admits Тюмень.
|
||||||
|
|
||||||
|
Это ЗНАЕМОЕ и намеренное ограничение самого 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 ниже.
|
||||||
|
"""
|
||||||
|
assert is_within_oblast66_bbox(57.1530, 65.5343) is True
|
||||||
|
|
||||||
|
|
||||||
|
# ── _has_oblast_marker — word/phrase-boundary matching (не substring) ───────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"text,expected",
|
||||||
|
[
|
||||||
|
("екатеринбург, малышева 30", True),
|
||||||
|
("нижний тагил, ленина 10", True),
|
||||||
|
("свердловская область, серова 27", True),
|
||||||
|
("каменск-уральский, мира 1", True),
|
||||||
|
("заречный, мира 5", True), # ЗАТО-город (без district-префикса)
|
||||||
|
# false-positives, которые substring-match ловил ошибочно (#oblast66 bug):
|
||||||
|
("серова 27", False), # НЕ город "серов"
|
||||||
|
("ирбитская 5", False), # НЕ город "ирбит"
|
||||||
|
("асбестовский пер., 3", False), # НЕ город "асбест"
|
||||||
|
("невьянский пер., 10", False), # НЕ город "невьянск"
|
||||||
|
("богдановича, 22", False), # НЕ город "богданович"
|
||||||
|
("мкр заречный, мира 5", False), # район внутри города, не город-ЗАТО
|
||||||
|
("микрорайон заречный, мира 5", False),
|
||||||
|
("малышева 30", False), # обычный ЕКБ bare-адрес
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_has_oblast_marker(text: str, expected: bool) -> None:
|
||||||
|
assert _has_oblast_marker(text) is expected
|
||||||
|
|
||||||
|
|
||||||
|
# ── _names_non_ekb_city — gate для EKB-only локальных tier'ов (#11) ─────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"address,expected",
|
||||||
|
[
|
||||||
|
("Нижний Тагил, проспект Ленина, 1", True),
|
||||||
|
("г. Екатеринбург, проспект Ленина, 1", False),
|
||||||
|
("проспект Ленина, 1", False),
|
||||||
|
("Серова 27", False), # word-boundary — НЕ город "Серов"
|
||||||
|
("мкр Заречный, ул. Ленина 5", False), # район внутри города, не ЗАТО Заречный
|
||||||
|
("Каменск-Уральский, ул. Ленина 1", True),
|
||||||
|
# явное "Екатеринбург" перевешивает омоним соседнего города в названии
|
||||||
|
# ЖК/района внутри города (реальный false-positive из прод-кэша, #11):
|
||||||
|
("24, опалихинская улица, заречный, Верх-Исетский район, Екатеринбург", False),
|
||||||
|
# word-boundary на "екатеринбург" — "Екатеринбургское шоссе" НЕ город
|
||||||
|
# Екатеринбург (реальная улица в городах-спутниках, напр. Первоуральске):
|
||||||
|
("Первоуральск, Екатеринбургское шоссе, 5", True),
|
||||||
|
# расширенный gazetteer (deep-review fast-follow):
|
||||||
|
("Верхняя Пышма, ул. Ленина 5", True),
|
||||||
|
("Сухой Лог, ул. Мира 3", True),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_names_non_ekb_city(address: str, expected: bool) -> None:
|
||||||
|
assert _names_non_ekb_city(address) is expected
|
||||||
|
|
||||||
|
|
||||||
|
# ── Accept-site two-pass tie-break + region cross-check ─────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def test_nominatim_query_prefers_tight_ekb_over_oblast_rank0() -> None:
|
||||||
|
"""rank0 — Серов (внутри OBLAST66, вне tight), rank1 — ЕКБ (tight).
|
||||||
|
|
||||||
|
Two-pass tie-break должен выбрать rank1 (tight), а не rank0, несмотря на то
|
||||||
|
что Nominatim ранжировал его вторым.
|
||||||
|
"""
|
||||||
|
payload = [
|
||||||
|
{"lat": "59.6297", "lon": "60.5541", "address": {"state": "Свердловская область"}},
|
||||||
|
{"lat": "56.838", "lon": "60.605", "address": {"state": "Свердловская область"}},
|
||||||
|
]
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(200, json=payload)
|
||||||
|
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
async with httpx.AsyncClient(transport=transport) as client:
|
||||||
|
item = await _nominatim_query(client, "Серова 27")
|
||||||
|
|
||||||
|
assert item is not None
|
||||||
|
assert float(item["lat"]) == pytest.approx(56.838)
|
||||||
|
assert float(item["lon"]) == pytest.approx(60.605)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_nominatim_query_rejects_out_of_region_only_candidate() -> None:
|
||||||
|
"""Единственный кандидат — Тюмень (координатно внутри OBLAST66, но регион другой)
|
||||||
|
→ region cross-check отсекает его → None (не просто "первый как есть")."""
|
||||||
|
payload = [{"lat": "57.1522", "lon": "65.5272", "address": {"state": "Тюменская область"}}]
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(200, json=payload)
|
||||||
|
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
async with httpx.AsyncClient(transport=transport) as client:
|
||||||
|
item = await _nominatim_query(client, "какой-то мусорный адрес")
|
||||||
|
|
||||||
|
assert item is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_nominatim_query_accepts_oblast_wide_when_region_absent() -> None:
|
||||||
|
"""Нет tight-совпадения, `address.state` отсутствует (Nominatim не всегда его
|
||||||
|
отдаёт) — fallback на oblast-bbox (Серов), region cross-check не блокирует."""
|
||||||
|
payload = [{"lat": "59.6297", "lon": "60.5541"}]
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(200, json=payload)
|
||||||
|
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
async with httpx.AsyncClient(transport=transport) as client:
|
||||||
|
item = await _nominatim_query(client, "Серов, Ленина 1")
|
||||||
|
|
||||||
|
assert item is not None
|
||||||
|
assert float(item["lat"]) == pytest.approx(59.6297)
|
||||||
|
|
||||||
|
|
||||||
|
_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
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ sys.modules.setdefault("weasyprint", _wp_mock)
|
||||||
|
|
||||||
|
|
||||||
from app.services.geocoder import ( # noqa: E402
|
from app.services.geocoder import ( # noqa: E402
|
||||||
|
GeocodeResult,
|
||||||
GeocodeSuggestion,
|
GeocodeSuggestion,
|
||||||
_cadastral_house_match,
|
_cadastral_house_match,
|
||||||
_parse_street_house,
|
_parse_street_house,
|
||||||
|
|
@ -264,6 +265,50 @@ async def test_geocode_skips_house_match_when_parse_fails() -> None:
|
||||||
mock_forward.assert_called_once()
|
mock_forward.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
# ── geocode() non-EKB city gate (#11) ────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def test_geocode_skips_ekb_local_tiers_for_non_ekb_city() -> None:
|
||||||
|
"""Non-EKB oblast-адрес (Нижний Тагил) → EKB-only локальные тиры (geoportal/
|
||||||
|
cadastral) НЕ вызываются, результат идёт от oblast-aware Nominatim (#11).
|
||||||
|
|
||||||
|
Без гейта «проспект Ленина, 1» коллизирует с одноимённым ЕКБ-домом через
|
||||||
|
geoportal/cadastral и снапается в ЕКБ вместо Нижнего Тагила.
|
||||||
|
"""
|
||||||
|
db = MagicMock()
|
||||||
|
nominatim_result = GeocodeResult(
|
||||||
|
lat=57.905,
|
||||||
|
lon=59.950,
|
||||||
|
full_address="проспект Ленина, 1, Нижний Тагил",
|
||||||
|
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.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
|
||||||
|
assert result.lat == pytest.approx(57.905)
|
||||||
|
assert result.lon == pytest.approx(59.950)
|
||||||
|
mock_geoportal.assert_not_called()
|
||||||
|
mock_house.assert_not_called()
|
||||||
|
mock_forward.assert_not_called()
|
||||||
|
mock_nominatim.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
# ── suggest() wiring ─────────────────────────────────────────────────────────
|
# ── suggest() wiring ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -328,3 +373,39 @@ async def test_suggest_falls_back_to_legacy_forward_when_house_match_misses() ->
|
||||||
mock_house.assert_called_once()
|
mock_house.assert_called_once()
|
||||||
mock_forward.assert_called_once()
|
mock_forward.assert_called_once()
|
||||||
mock_yandex.assert_not_called()
|
mock_yandex.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_suggest_skips_ekb_local_tier_for_non_ekb_city() -> None:
|
||||||
|
"""Non-EKB oblast query (Нижний Тагил) → EKB-only cadastral Tier 1
|
||||||
|
(`_cadastral_house_match` / `_cadastral_forward_sync`) NOT called; falls
|
||||||
|
through to external tiers, same root cause as `geocode()` (#11)."""
|
||||||
|
db = MagicMock()
|
||||||
|
nominatim_hit = [
|
||||||
|
GeocodeSuggestion(
|
||||||
|
label="проспект Ленина, 1",
|
||||||
|
full_address="проспект Ленина, 1, Нижний Тагил",
|
||||||
|
lat=57.905,
|
||||||
|
lon=59.950,
|
||||||
|
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
|
||||||
|
mock_settings.yandex_geocoder_api_key = None
|
||||||
|
results = await suggest("Нижний Тагил, проспект Ленина, 1", db=db, limit=8)
|
||||||
|
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0].lat == pytest.approx(57.905)
|
||||||
|
mock_house.assert_not_called()
|
||||||
|
mock_forward.assert_not_called()
|
||||||
|
mock_nominatim.assert_called_once()
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,13 @@ from app.services.matching.conflict_resolution import (
|
||||||
update_canonical_fields,
|
update_canonical_fields,
|
||||||
)
|
)
|
||||||
from app.services.matching.normalize import (
|
from app.services.matching.normalize import (
|
||||||
|
EKB_CITY_TOKEN,
|
||||||
address_fingerprint,
|
address_fingerprint,
|
||||||
|
has_city_token,
|
||||||
has_house_number,
|
has_house_number,
|
||||||
house_number_token,
|
house_number_token,
|
||||||
normalize_address,
|
normalize_address,
|
||||||
|
resolve_city_token,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -168,6 +171,68 @@ def test_fingerprint_all_none():
|
||||||
assert len(fp) == 32
|
assert len(fp) == 32
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# has_city_token — обл.66 city-token detection (Tier-2b oblast guard)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_has_city_token_detects_ekb():
|
||||||
|
assert has_city_token("екатеринбург улица ленина 5") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_has_city_token_detects_multiword_oblast_city():
|
||||||
|
assert has_city_token("нижний тагил улица ленина 100") is True
|
||||||
|
assert has_city_token("каменск уральский улица мира 7") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_has_city_token_bare_street_is_false():
|
||||||
|
# The collision-prone case: a bare street+number carries no city token.
|
||||||
|
assert has_city_token("улица ленина 100") is False
|
||||||
|
assert has_city_token("уральская улица 62к1") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_has_city_token_no_false_positive_on_street_named_after_city():
|
||||||
|
# 'улица серова' must NOT match the city token 'серов' (word-bounded, not substring).
|
||||||
|
assert has_city_token("улица серова 5") is False
|
||||||
|
assert has_city_token("первоуральская улица 3") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_has_city_token_handles_empty():
|
||||||
|
assert has_city_token("") is False
|
||||||
|
assert has_city_token(None) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# resolve_city_token — which обл.66 city (or None); drives the Tier-2a oblast guard
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_city_token_returns_ekb():
|
||||||
|
assert resolve_city_token("екатеринбург улица ленина 5") == EKB_CITY_TOKEN
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_city_token_returns_non_ekb_city():
|
||||||
|
assert resolve_city_token("нижний тагил улица ленина 100") == "нижний тагил"
|
||||||
|
assert resolve_city_token("свердловская область каменск уральский улица мира 7") == (
|
||||||
|
"каменск уральский"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_city_token_bare_street_is_none():
|
||||||
|
# The dominant Avito SERP format — no city token → None (guard stays dormant).
|
||||||
|
assert resolve_city_token("улица ленина 100") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_city_token_no_false_positive_on_street_named_after_city():
|
||||||
|
assert resolve_city_token("улица серова 5") is None
|
||||||
|
assert resolve_city_token("первоуральская улица 3") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_city_token_handles_empty():
|
||||||
|
assert resolve_city_token("") is None
|
||||||
|
assert resolve_city_token(None) is None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# match_or_create_house — mock DB tier routing
|
# match_or_create_house — mock DB tier routing
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -622,6 +687,204 @@ def test_insert_alias_executes_when_house_number_present():
|
||||||
db.execute.assert_called_once()
|
db.execute.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_insert_alias_conflict_only_rebinds_same_house():
|
||||||
|
"""Oblast guard (#2): ON CONFLICT refreshes fingerprint/source ONLY for the same
|
||||||
|
house. A different house colliding on a bare common-street normalized_address must
|
||||||
|
not rewrite the owner's fingerprint (that would downgrade a coord-bearing alias to a
|
||||||
|
coord-less one and let the next no-coord card mis-bucket via Tier 2a)."""
|
||||||
|
from app.services.matching.houses import _insert_alias
|
||||||
|
|
||||||
|
db = MagicMock()
|
||||||
|
_insert_alias(db, house_id=1, address="улица Ленина 100", fp="c" * 32, source="avito")
|
||||||
|
sql = " ".join(str(db.execute.call_args.args[0]).split())
|
||||||
|
assert "ON CONFLICT (normalized_address) DO UPDATE" in sql
|
||||||
|
# fingerprint/source updated only when the existing owner == the incoming house.
|
||||||
|
assert "house_address_aliases.house_id = EXCLUDED.house_id" in sql
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# match_or_create_house — Tier-2b oblast geo/city guard (bug #2)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_tier2b_coord_present_uses_geo_guard_and_matches_within_radius():
|
||||||
|
"""Coords present → Tier-2b runs the ST_DWithin JOIN guard; a house within the
|
||||||
|
radius is accepted as a fingerprint match (0.9)."""
|
||||||
|
from app.services.matching.houses import match_or_create_house
|
||||||
|
|
||||||
|
db = _make_db(
|
||||||
|
[
|
||||||
|
None, # pg_advisory_xact_lock
|
||||||
|
None, # house_sources miss (Tier 1)
|
||||||
|
None, # fingerprint miss (Tier 2a)
|
||||||
|
{"house_id": 77}, # Tier 2b JOIN+ST_DWithin hit (same place, within radius)
|
||||||
|
None, # _upsert_house_source
|
||||||
|
None, # _insert_alias
|
||||||
|
]
|
||||||
|
)
|
||||||
|
house_id, conf, method = match_or_create_house(
|
||||||
|
db, "avito", "ext-2b-geo", address="улица Ленина 100", lat=57.910, lon=59.980
|
||||||
|
)
|
||||||
|
assert (house_id, conf, method) == (77, 0.9, "fingerprint")
|
||||||
|
# The Tier-2b lookup must be the geo-guarded JOIN, not a bare normalized_address SELECT.
|
||||||
|
tier2b_sql = _executed_sqls(db)[3]
|
||||||
|
assert "ST_DWithin" in tier2b_sql
|
||||||
|
assert "JOIN houses" in tier2b_sql
|
||||||
|
|
||||||
|
|
||||||
|
def test_tier2b_coord_present_cross_city_falls_through_to_new():
|
||||||
|
"""A bare street+number oblast card WITH coords whose only same-street alias is a
|
||||||
|
far-away (cross-city) house: the ST_DWithin guard filters it out (JOIN miss) → the
|
||||||
|
card falls through to a New house instead of mis-bucketing into the ЕКБ house."""
|
||||||
|
from app.services.matching.houses import match_or_create_house
|
||||||
|
|
||||||
|
db = _make_db(
|
||||||
|
[
|
||||||
|
None, # pg_advisory_xact_lock
|
||||||
|
None, # house_sources miss
|
||||||
|
None, # fingerprint miss (Tier 2a)
|
||||||
|
None, # Tier 2b JOIN+ST_DWithin miss (candidate house is >3km away)
|
||||||
|
None, # geo miss (Tier 3 — coords present)
|
||||||
|
{"id": 909}, # INSERT RETURNING id (New house)
|
||||||
|
None, # _upsert_house_source
|
||||||
|
None, # _insert_alias
|
||||||
|
]
|
||||||
|
)
|
||||||
|
house_id, conf, method = match_or_create_house(
|
||||||
|
db, "avito", "ext-2b-xcity", address="улица Ленина 100", lat=57.910, lon=59.980
|
||||||
|
)
|
||||||
|
assert (house_id, conf, method) == (909, 1.0, "new")
|
||||||
|
|
||||||
|
|
||||||
|
def test_tier2b_no_coords_no_city_token_skips_and_creates_new():
|
||||||
|
"""No coords AND a bare common-street (no city token) → Tier-2b is SKIPPED entirely
|
||||||
|
(no normalized_address lookup). The card falls through to a New house — conservative:
|
||||||
|
do not mis-bucket a bare 'улица ленина 100' into a same-named ЕКБ alias."""
|
||||||
|
from app.services.matching.houses import match_or_create_house
|
||||||
|
|
||||||
|
db = _make_db(
|
||||||
|
[
|
||||||
|
None, # pg_advisory_xact_lock
|
||||||
|
None, # house_sources miss
|
||||||
|
None, # fingerprint miss (Tier 2a); Tier 2b SKIPPED (no coords, no city token)
|
||||||
|
{"id": 910}, # INSERT RETURNING id (New house) — no Tier 3 (no coords)
|
||||||
|
None, # _upsert_house_source
|
||||||
|
None, # _insert_alias
|
||||||
|
]
|
||||||
|
)
|
||||||
|
house_id, conf, method = match_or_create_house(
|
||||||
|
db, "avito", "ext-2b-bare", address="улица Ленина 100"
|
||||||
|
)
|
||||||
|
assert (house_id, conf, method) == (910, 1.0, "new")
|
||||||
|
assert not any(
|
||||||
|
"normalized_address = :na" in s for s in _executed_sqls(db)
|
||||||
|
), "bare common-street with no coords/city must not run a Tier-2b lookup"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tier2b_no_coords_with_city_token_matches():
|
||||||
|
"""No coords but the normalized_address carries a city token → self-disambiguating,
|
||||||
|
so Tier-2b matches on normalized_address alone (0.9). ЕКБ cross-source dedup for
|
||||||
|
city-qualified addresses is preserved."""
|
||||||
|
from app.services.matching.houses import match_or_create_house
|
||||||
|
|
||||||
|
db = _make_db(
|
||||||
|
[
|
||||||
|
None, # pg_advisory_xact_lock
|
||||||
|
None, # house_sources miss
|
||||||
|
None, # fingerprint miss (Tier 2a)
|
||||||
|
{"house_id": 33}, # Tier 2b normalized_address hit (city token present)
|
||||||
|
None, # _upsert_house_source
|
||||||
|
None, # _insert_alias
|
||||||
|
]
|
||||||
|
)
|
||||||
|
house_id, conf, method = match_or_create_house(
|
||||||
|
db, "avito", "ext-2b-city", address="Екатеринбург, улица Ленина, 100"
|
||||||
|
)
|
||||||
|
assert (house_id, conf, method) == (33, 0.9, "fingerprint")
|
||||||
|
tier2b_sql = _executed_sqls(db)[3]
|
||||||
|
assert "normalized_address = :na" in tier2b_sql
|
||||||
|
assert "ST_DWithin" not in tier2b_sql
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# match_or_create_house — Tier-2a coord-less oblast city guard (#2500 follow-up)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_tier2a_coord_less_non_ekb_city_skips_both_lookups_and_creates_new():
|
||||||
|
"""A coord-less card whose address resolves a NON-ЕКБ city (Н.Тагил) must NOT match a
|
||||||
|
globally-unique alias by fingerprint OR normalized_address (either would corrupt ЕКБ
|
||||||
|
house data). The guard skips both alias lookups → New house."""
|
||||||
|
from app.services.matching.houses import match_or_create_house
|
||||||
|
|
||||||
|
db = _make_db(
|
||||||
|
[
|
||||||
|
None, # pg_advisory_xact_lock
|
||||||
|
None, # house_sources miss (Tier 1)
|
||||||
|
# Tier 2a (fingerprint) SKIPPED, Tier 2b (normalized_address) SKIPPED,
|
||||||
|
# Tier 3 SKIPPED (no coords) — guard forces fall-through to New.
|
||||||
|
{"id": 701}, # INSERT RETURNING id (New house)
|
||||||
|
None, # _upsert_house_source
|
||||||
|
None, # _insert_alias (address carries a house number → executes)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
house_id, conf, method = match_or_create_house(
|
||||||
|
db, "avito", "ext-2a-nt", address="Нижний Тагил, ул. Ленина, 100"
|
||||||
|
)
|
||||||
|
assert (house_id, conf, method) == (701, 1.0, "new")
|
||||||
|
sqls = _executed_sqls(db)
|
||||||
|
assert not any(
|
||||||
|
"fingerprint = :fp" in s for s in sqls
|
||||||
|
), "coord-less non-ЕКБ card must NOT run the Tier-2a fingerprint lookup"
|
||||||
|
assert not any(
|
||||||
|
"normalized_address = :na" in s for s in sqls
|
||||||
|
), "coord-less non-ЕКБ card must NOT run the Tier-2b normalized_address lookup"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tier2a_coord_less_ekb_city_still_matches():
|
||||||
|
"""EKB happy-path: a coord-less card that resolves ЕКБ runs Tier-2a EXACTLY as before —
|
||||||
|
the guard does not fire (екатеринбург == EKB_CITY_TOKEN)."""
|
||||||
|
from app.services.matching.houses import match_or_create_house
|
||||||
|
|
||||||
|
db = _make_db(
|
||||||
|
[
|
||||||
|
None, # pg_advisory_xact_lock
|
||||||
|
None, # house_sources miss
|
||||||
|
{"house_id": 88}, # Tier 2a fingerprint HIT (guard did NOT skip it)
|
||||||
|
None, # _upsert_house_source
|
||||||
|
None, # _insert_alias
|
||||||
|
]
|
||||||
|
)
|
||||||
|
house_id, conf, method = match_or_create_house(
|
||||||
|
db, "avito", "ext-2a-ekb", address="Екатеринбург, ул. Ленина, 5"
|
||||||
|
)
|
||||||
|
assert (house_id, conf, method) == (88, 0.9, "fingerprint")
|
||||||
|
assert any(
|
||||||
|
"fingerprint = :fp" in s for s in _executed_sqls(db)
|
||||||
|
), "ЕКБ coord-less card must still run the Tier-2a fingerprint lookup"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tier2a_coord_less_bare_street_still_runs_tier2a():
|
||||||
|
"""EKB happy-path (dominant): a coord-less BARE card (no city token — today's Avito SERP
|
||||||
|
format) resolves to None → guard dormant → Tier-2a fingerprint dedup UNCHANGED."""
|
||||||
|
from app.services.matching.houses import match_or_create_house
|
||||||
|
|
||||||
|
db = _make_db(
|
||||||
|
[
|
||||||
|
None, # pg_advisory_xact_lock
|
||||||
|
None, # house_sources miss
|
||||||
|
{"house_id": 99}, # Tier 2a fingerprint HIT (bare card → guard did not skip)
|
||||||
|
None, # _upsert_house_source
|
||||||
|
None, # _insert_alias
|
||||||
|
]
|
||||||
|
)
|
||||||
|
house_id, conf, method = match_or_create_house(
|
||||||
|
db, "avito", "ext-2a-bare", address="ул. Ленина, 5"
|
||||||
|
)
|
||||||
|
assert (house_id, conf, method) == (99, 0.9, "fingerprint")
|
||||||
|
assert any("fingerprint = :fp" in s for s in _executed_sqls(db))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# match_or_create_listing — mock DB tier routing
|
# match_or_create_listing — mock DB tier routing
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,10 @@ def test_insufficient_data_page_contains_address_and_report_num() -> None:
|
||||||
est = _zero_estimate()
|
est = _zero_estimate()
|
||||||
html = mod._build_insufficient_data_page(est, _SNAPSHOT, _GENERIC)
|
html = mod._build_insufficient_data_page(est, _SNAPSHOT, _GENERIC)
|
||||||
assert "Ленина" in html # address
|
assert "Ленина" in html # address
|
||||||
assert "EKБ-" in html # report number prefix
|
# #pdf-honesty: № отчёта city-aware — у оценки без target_address город не распознан
|
||||||
|
# → нейтральный префикс «МЕРА» (раньше был захардкожен «EKБ» для любого объекта).
|
||||||
|
assert "МЕРА-" in html
|
||||||
|
assert "EKБ-" not in html # старый mixed-script хардкод убран
|
||||||
|
|
||||||
|
|
||||||
def test_generate_pdf_insufficient_skips_offer_page() -> None:
|
def test_generate_pdf_insufficient_skips_offer_page() -> None:
|
||||||
|
|
@ -197,20 +200,23 @@ def test_cover_disclaimer_absent_when_none() -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_cover_renders_logo_img_when_valid_url() -> None:
|
def test_cover_renders_logo_img_when_valid_url() -> None:
|
||||||
est = _estimate()
|
# #4-pages-fix: _page_header больше не встроен в _build_cover — он рендерится
|
||||||
html = mod._build_cover(est, _SNAPSHOT, _PRAKTIKA)
|
# ОДИН раз в generate_trade_in_pdf как running-header (position:running(...),
|
||||||
assert "<img" in html
|
# печатается WeasyPrint'ом в @top-center margin-box на каждой странице), см.
|
||||||
assert "gk-praktika.ru" in html
|
# trade_in_pdf.py::generate_trade_in_pdf. Логотип/wordmark проверяем напрямую
|
||||||
|
# через _page_header, не через _build_cover (симметрично уже существовавшему
|
||||||
|
# паттерну в test_cover_falls_back_to_name_when_logo_none ниже).
|
||||||
|
header_html = mod._page_header(_PRAKTIKA, "EKБ-0001-0000000001", datetime.now(UTC).date())
|
||||||
|
assert "<img" in header_html
|
||||||
|
assert "gk-praktika.ru" in header_html
|
||||||
|
|
||||||
|
|
||||||
def test_cover_falls_back_to_name_when_logo_none() -> None:
|
def test_cover_falls_back_to_name_when_logo_none() -> None:
|
||||||
est = _estimate()
|
|
||||||
html = mod._build_cover(est, _SNAPSHOT, _GENERIC)
|
|
||||||
assert "TRADE-IN" in html.upper()
|
|
||||||
# Logo-specific check via _page_header directly — не проверяем отсутствие <img> во
|
# Logo-specific check via _page_header directly — не проверяем отсутствие <img> во
|
||||||
# всей cover-странице, т.к. с #2327-follow-up диапазоны цен рендерятся как
|
# всей cover-странице, т.к. с #2327-follow-up диапазоны цен рендерятся как
|
||||||
# matplotlib SVG <img> (см. _price_range_chart_svg), не связанные с логотипом бренда.
|
# matplotlib SVG <img> (см. _price_range_chart_svg), не связанные с логотипом бренда.
|
||||||
header_html = mod._page_header(_GENERIC, "EKБ-0001-0000000001", datetime.now(UTC).date())
|
header_html = mod._page_header(_GENERIC, "EKБ-0001-0000000001", datetime.now(UTC).date())
|
||||||
|
assert "TRADE-IN" in header_html.upper()
|
||||||
assert "<img" not in header_html
|
assert "<img" not in header_html
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -278,3 +278,45 @@ async def test_fetch_serp_html_routes_through_browser() -> None:
|
||||||
|
|
||||||
assert html == "<html><body>real serp listing</body></html>"
|
assert html == "<html><body>real serp listing</body></html>"
|
||||||
scraper._browser.fetch.assert_awaited_once()
|
scraper._browser.fetch.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
# ── 6. SERP browser sidecar timeout → transient retry, not bare exception ────
|
||||||
|
# #2472: BrowserFetcher.fetch (browser_fetcher.py) retries once internally on
|
||||||
|
# httpx.HTTPError/TransportError, then re-raises. Before this fix, non-
|
||||||
|
# HTTPStatusError exceptions (httpx.TimeoutException/TransportError — e.g.
|
||||||
|
# ReadTimeout/ConnectError/RemoteProtocolError) propagated unhandled through
|
||||||
|
# _fetch_serp_html_browser straight into pipeline.run_avito_full_load's generic
|
||||||
|
# `except Exception` → mark_failed(str(exc)) (str(ReadTimeout())=="" → empty
|
||||||
|
# error, and mark_failed does NOT persist done_buckets, unlike mark_banned).
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_serp_html_browser_timeout_routes_transient_not_bare_exception() -> None:
|
||||||
|
"""httpx.ReadTimeout from browser.fetch() must NOT propagate as an unhandled
|
||||||
|
exception. It's classified transient (status=0, no soft-ban keyword match)
|
||||||
|
and, once the bounded retry budget is exhausted, surfaces as
|
||||||
|
AvitoRateLimitedError — the same graceful banned-path a sidecar
|
||||||
|
HTTPStatusError takes — never as a bare/generic exception."""
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from scraper_kit.avito_exceptions import AvitoRateLimitedError
|
||||||
|
from scraper_kit.providers.avito import serp as serp_module
|
||||||
|
from scraper_kit.providers.avito.serp import AvitoScraper
|
||||||
|
|
||||||
|
from app.services.scraper_adapters import RealScraperConfig
|
||||||
|
|
||||||
|
scraper = AvitoScraper(RealScraperConfig())
|
||||||
|
assert scraper._cffi is None
|
||||||
|
scraper._browser = AsyncMock()
|
||||||
|
scraper._browser.fetch = AsyncMock(side_effect=httpx.ReadTimeout(""))
|
||||||
|
|
||||||
|
with patch.object(serp_module.asyncio, "sleep", AsyncMock()):
|
||||||
|
with pytest.raises(AvitoRateLimitedError):
|
||||||
|
await scraper._fetch_serp_html(
|
||||||
|
"https://www.avito.ru/ekaterinburg/kvartiry/prodam", page=1
|
||||||
|
)
|
||||||
|
|
||||||
|
# 1 initial attempt + 2 transient retries (_AVITO_SIDECAR_TRANSIENT_RETRIES) = 3,
|
||||||
|
# no IP rotation involved (RealScraperConfig has no rotate_url configured in tests).
|
||||||
|
assert scraper._browser.fetch.await_count == 3
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,9 @@ def _reset_auth_cache() -> None:
|
||||||
|
|
||||||
_ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
|
_ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
|
||||||
_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"})
|
_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"})
|
||||||
|
# MIRROR of app.main (#R2-H3): keep in sync with the real rbac_guard.
|
||||||
|
_EXTERNAL_PREFIX = "/trade-in"
|
||||||
|
_RBAC_BOOTSTRAP_EXEMPT = ("/api/v1/me", "/api/v1/brand")
|
||||||
|
|
||||||
|
|
||||||
def _build_test_app() -> FastAPI:
|
def _build_test_app() -> FastAPI:
|
||||||
|
|
@ -76,6 +79,18 @@ def _build_test_app() -> FastAPI:
|
||||||
status_code=403,
|
status_code=403,
|
||||||
content={"detail": "admin only"},
|
content={"detail": "admin only"},
|
||||||
)
|
)
|
||||||
|
# #R2-H3 mirror: enforce roles.yaml scope on non-bootstrap paths.
|
||||||
|
if not path.startswith(_RBAC_BOOTSTRAP_EXEMPT):
|
||||||
|
external_path = _EXTERNAL_PREFIX + path
|
||||||
|
try:
|
||||||
|
allowed = auth_mod.is_path_allowed(role, external_path)
|
||||||
|
except Exception:
|
||||||
|
allowed = True
|
||||||
|
if not allowed:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=403,
|
||||||
|
content={"detail": "forbidden for role"},
|
||||||
|
)
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
app.include_router(me_router.router, prefix="/api/v1", tags=["me"])
|
app.include_router(me_router.router, prefix="/api/v1", tags=["me"])
|
||||||
|
|
@ -84,6 +99,18 @@ def _build_test_app() -> FastAPI:
|
||||||
async def admin_dummy() -> dict:
|
async def admin_dummy() -> dict:
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
@app.get("/api/v1/search")
|
||||||
|
async def search_dummy() -> dict:
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@app.get("/api/v1/trade-in/dummy")
|
||||||
|
async def tradein_dummy() -> dict:
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@app.get("/api/v1/brand/dummy")
|
||||||
|
async def brand_dummy() -> dict:
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health() -> dict:
|
async def health() -> dict:
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
@ -105,9 +132,7 @@ def test_get_role_known_users() -> None:
|
||||||
assert auth_mod.get_role("admin") == "admin"
|
assert auth_mod.get_role("admin") == "admin"
|
||||||
assert auth_mod.get_role("kopylov") == "pilot"
|
assert auth_mod.get_role("kopylov") == "pilot"
|
||||||
for n in range(1, 11):
|
for n in range(1, 11):
|
||||||
# user2 = «Брусника»: пробный доступ закрыт 2026-07-09 (roles.yaml)
|
assert auth_mod.get_role(f"user{n}") == "pilot"
|
||||||
expected = "expired" if n == 2 else "pilot"
|
|
||||||
assert auth_mod.get_role(f"user{n}") == expected
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_role_unknown_user_raises() -> None:
|
def test_get_role_unknown_user_raises() -> None:
|
||||||
|
|
@ -288,6 +313,50 @@ def test_rbac_guard_unknown_user_blocked_on_non_admin_path(client: TestClient) -
|
||||||
assert "not in roles config" in resp.json()["detail"].lower()
|
assert "not in roles config" in resp.json()["detail"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
# --- #R2-H3: roles.yaml scope enforced on ALL non-admin paths (not just /admin/*) ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_rbac_expired_denied_on_non_admin_api(client: TestClient) -> None:
|
||||||
|
"""#R2-H3: revoked (role=expired, paths:[] deny:/**) НЕ достаёт non-admin API.
|
||||||
|
Раньше rbac_guard гейтил только /admin/* → expired имел полный non-admin доступ
|
||||||
|
(POST /search экспорт листингов и т.д.). Теперь scope-blocked → 403."""
|
||||||
|
for user in ("praktika",): # role=expired (trial отозван; user2 восстановлен 2026-07-13)
|
||||||
|
for path in ("/api/v1/search", "/api/v1/trade-in/dummy"):
|
||||||
|
resp = client.get(path, headers={"X-Authenticated-User": user})
|
||||||
|
assert resp.status_code == 403, f"{user} {path}: {resp.status_code}"
|
||||||
|
assert "forbidden for role" in resp.json()["detail"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_rbac_expired_allowed_on_bootstrap(client: TestClient) -> None:
|
||||||
|
"""expired ДОЛЖЕН достучаться до /me (получить role=expired → trial-экран) и
|
||||||
|
/brand/* (брендинг trial-экрана) — иначе UX сломан. Bootstrap-исключение."""
|
||||||
|
resp_me = client.get("/api/v1/me", headers={"X-Authenticated-User": "praktika"})
|
||||||
|
assert resp_me.status_code == 200, resp_me.text
|
||||||
|
assert resp_me.json()["role"] == "expired"
|
||||||
|
resp_brand = client.get("/api/v1/brand/dummy", headers={"X-Authenticated-User": "praktika"})
|
||||||
|
assert resp_brand.status_code == 200, resp_brand.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_rbac_pilot_allowed_on_tradein_api(client: TestClient) -> None:
|
||||||
|
"""pilot по roles.yaml имеет /trade-in/api/v1/** → доступ к trade-in/search сохраняется."""
|
||||||
|
for path in ("/api/v1/search", "/api/v1/trade-in/dummy"):
|
||||||
|
resp = client.get(path, headers={"X-Authenticated-User": "kopylov"})
|
||||||
|
assert resp.status_code == 200, f"pilot {path}: {resp.text}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rbac_admin_allowed_everywhere(client: TestClient) -> None:
|
||||||
|
for path in ("/api/v1/search", "/api/v1/trade-in/dummy", "/api/v1/admin/dummy"):
|
||||||
|
resp = client.get(path, headers={"X-Authenticated-User": "admin"})
|
||||||
|
assert resp.status_code == 200, f"admin {path}: {resp.text}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rbac_analyst_allowed_non_admin_denied_admin(client: TestClient) -> None:
|
||||||
|
ok = client.get("/api/v1/search", headers={"X-Authenticated-User": "analysttest"})
|
||||||
|
assert ok.status_code == 200, ok.text
|
||||||
|
denied = client.get("/api/v1/admin/dummy", headers={"X-Authenticated-User": "analysttest"})
|
||||||
|
assert denied.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
def test_rbac_guard_no_header_on_non_admin_path_returns_401(client: TestClient) -> None:
|
def test_rbac_guard_no_header_on_non_admin_path_returns_401(client: TestClient) -> None:
|
||||||
"""Любой non-public path без X-Authenticated-User → 401."""
|
"""Любой non-public path без X-Authenticated-User → 401."""
|
||||||
resp = client.get("/api/v1/me")
|
resp = client.get("/api/v1/me")
|
||||||
|
|
|
||||||
140
tradein-mvp/backend/tests/test_request_audit.py
Normal file
140
tradein-mvp/backend/tests/test_request_audit.py
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
"""Tests for RequestAuditMiddleware (app/core/request_audit.py).
|
||||||
|
|
||||||
|
Тестируем middleware в изоляции на минимальном FastAPI-приложении (как
|
||||||
|
tests/test_ratelimit.py) — не тянем тяжёлый app.main. schedule_event
|
||||||
|
и should_log_login мокируются, реальная запись в user_events не требуется
|
||||||
|
(она уже покрыта tests/test_user_events.py).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.core.request_audit import RequestAuditMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client() -> TestClient:
|
||||||
|
app = FastAPI()
|
||||||
|
app.add_middleware(RequestAuditMiddleware)
|
||||||
|
|
||||||
|
@app.get("/api/v1/ping")
|
||||||
|
def ping() -> dict[str, bool]:
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health() -> dict[str, str]:
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_authenticated_api_request_schedules_api_request_event(client: TestClient) -> None:
|
||||||
|
with (
|
||||||
|
patch("app.core.request_audit.schedule_event") as mock_schedule,
|
||||||
|
patch("app.core.request_audit.should_log_login", return_value=False),
|
||||||
|
):
|
||||||
|
resp = client.get("/api/v1/ping", headers={"X-Authenticated-User": "alice"})
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
mock_schedule.assert_called_once()
|
||||||
|
kwargs = mock_schedule.call_args.kwargs
|
||||||
|
assert kwargs["event_type"] == "api_request"
|
||||||
|
assert kwargs["username"] == "alice"
|
||||||
|
assert kwargs["path"] == "/api/v1/ping"
|
||||||
|
assert kwargs["method"] == "GET"
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_event_scheduled_when_should_log_login_true(client: TestClient) -> None:
|
||||||
|
with (
|
||||||
|
patch("app.core.request_audit.schedule_event") as mock_schedule,
|
||||||
|
patch("app.core.request_audit.should_log_login", return_value=True),
|
||||||
|
):
|
||||||
|
client.get("/api/v1/ping", headers={"X-Authenticated-User": "alice"})
|
||||||
|
|
||||||
|
event_types = [c.kwargs["event_type"] for c in mock_schedule.call_args_list]
|
||||||
|
assert event_types == ["api_request", "login"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_event_not_scheduled_when_should_log_login_false(client: TestClient) -> None:
|
||||||
|
with (
|
||||||
|
patch("app.core.request_audit.schedule_event") as mock_schedule,
|
||||||
|
patch("app.core.request_audit.should_log_login", return_value=False),
|
||||||
|
):
|
||||||
|
client.get("/api/v1/ping", headers={"X-Authenticated-User": "alice"})
|
||||||
|
|
||||||
|
event_types = [c.kwargs["event_type"] for c in mock_schedule.call_args_list]
|
||||||
|
assert event_types == ["api_request"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_username_header_skips_audit(client: TestClient) -> None:
|
||||||
|
with patch("app.core.request_audit.schedule_event") as mock_schedule:
|
||||||
|
resp = client.get("/api/v1/ping")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
mock_schedule.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_path_skips_audit_even_with_username(client: TestClient) -> None:
|
||||||
|
with patch("app.core.request_audit.schedule_event") as mock_schedule:
|
||||||
|
resp = client.get("/health", headers={"X-Authenticated-User": "alice"})
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
mock_schedule.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_api_path_skips_audit(client: TestClient) -> None:
|
||||||
|
"""Path not under /api/ (and not one of the FastAPI-generated docs paths) is skipped."""
|
||||||
|
app = FastAPI()
|
||||||
|
app.add_middleware(RequestAuditMiddleware)
|
||||||
|
|
||||||
|
@app.get("/other")
|
||||||
|
def other() -> dict[str, bool]:
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
with patch("app.core.request_audit.schedule_event") as mock_schedule:
|
||||||
|
resp = TestClient(app).get("/other", headers={"X-Authenticated-User": "alice"})
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
mock_schedule.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_audit_failure_does_not_break_response(client: TestClient) -> None:
|
||||||
|
"""A raising schedule_event must never turn a 200 into a 500 (dispatch swallows it)."""
|
||||||
|
with patch("app.core.request_audit.schedule_event", side_effect=RuntimeError("boom")):
|
||||||
|
resp = client.get("/api/v1/ping", headers={"X-Authenticated-User": "alice"})
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() == {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_path_excluded_from_api_request_but_login_kept() -> None:
|
||||||
|
"""/api/v1/admin/* — ops-действия (просмотр дашбордов аудита/аналитики), не
|
||||||
|
поведение пилота: api_request НЕ пишем (иначе дашборд зашумляет активность),
|
||||||
|
а login (вход админа с IP) остаётся валидным аудитом."""
|
||||||
|
app = FastAPI()
|
||||||
|
app.add_middleware(RequestAuditMiddleware)
|
||||||
|
|
||||||
|
@app.get("/api/v1/admin/analytics")
|
||||||
|
def analytics() -> dict[str, bool]:
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.core.request_audit.schedule_event") as mock_schedule,
|
||||||
|
patch("app.core.request_audit.should_log_login", return_value=True),
|
||||||
|
):
|
||||||
|
resp = TestClient(app).get(
|
||||||
|
"/api/v1/admin/analytics", headers={"X-Authenticated-User": "admin"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
event_types = [c.kwargs["event_type"] for c in mock_schedule.call_args_list]
|
||||||
|
assert event_types == ["login"]
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
Чисто-юнит: ассертим SQL-текст, эмитируемый import_rosreestr_dkp (через inspect.getsource),
|
Чисто-юнит: ассертим SQL-текст, эмитируемый import_rosreestr_dkp (через inspect.getsource),
|
||||||
плюс содержимое backfill-миграции 077 — без живого FDW / DB-фикстуры.
|
плюс содержимое backfill-миграции 077 — без живого FDW / DB-фикстуры.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import inspect
|
import inspect
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -45,9 +46,67 @@ def test_import_populates_source_id() -> None:
|
||||||
assert '"source_id": str(row["source_id_src"])' in _IMPORT_SRC
|
assert '"source_id": str(row["source_id_src"])' in _IMPORT_SRC
|
||||||
|
|
||||||
|
|
||||||
def test_import_keeps_on_conflict_dedup_hash() -> None:
|
def test_import_uses_on_conflict_do_update_for_corrections() -> None:
|
||||||
"""Дедуп по-прежнему через ON CONFLICT (dedup_hash) DO NOTHING."""
|
"""Fix D: дедуп по dedup_hash, но ON CONFLICT DO UPDATE (не DO NOTHING) —
|
||||||
assert "ON CONFLICT (dedup_hash) DO NOTHING" in _IMPORT_SRC
|
|
||||||
|
исправленный/переопубликованный квартал обновляет сырые факты Росреестра.
|
||||||
|
"""
|
||||||
|
assert "ON CONFLICT (dedup_hash) DO UPDATE SET" in _IMPORT_SRC
|
||||||
|
assert "DO NOTHING" not in _IMPORT_BODY
|
||||||
|
# Обновляются изменяемые сырые факты...
|
||||||
|
for col in (
|
||||||
|
"address = EXCLUDED.address",
|
||||||
|
"region_code = EXCLUDED.region_code",
|
||||||
|
"city = EXCLUDED.city",
|
||||||
|
"rooms = EXCLUDED.rooms",
|
||||||
|
"area_m2 = EXCLUDED.area_m2",
|
||||||
|
"floor = EXCLUDED.floor",
|
||||||
|
"year_built = EXCLUDED.year_built",
|
||||||
|
"price_rub = EXCLUDED.price_rub",
|
||||||
|
"price_per_m2 = EXCLUDED.price_per_m2",
|
||||||
|
"deal_date = EXCLUDED.deal_date",
|
||||||
|
):
|
||||||
|
assert col in _IMPORT_SRC, f"missing DO UPDATE SET column: {col!r}"
|
||||||
|
# ...но идемпотентность через IS DISTINCT FROM (неизменные строки не трогаются).
|
||||||
|
assert "IS DISTINCT FROM EXCLUDED.price_rub" in _IMPORT_SRC
|
||||||
|
# xmax-дискриминатор insert vs update.
|
||||||
|
assert "RETURNING (xmax = 0)" in _IMPORT_SRC
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_do_update_never_overwrites_enrichment_columns() -> None:
|
||||||
|
"""Fix D: обогащение (lat/lon/geom/geocode/cadastral_number...) не в SET-списке.
|
||||||
|
|
||||||
|
Эти колонки заполняются пост-импортом (geocode_deals_*) — повторный импорт не
|
||||||
|
должен их затирать. Отсутствие в SET DO UPDATE гарантирует сохранность.
|
||||||
|
"""
|
||||||
|
conflict_clause = _IMPORT_SRC.split("ON CONFLICT (dedup_hash) DO UPDATE SET", 1)[1]
|
||||||
|
set_and_where = conflict_clause.split("RETURNING", 1)[0]
|
||||||
|
for enrich in (
|
||||||
|
"lat =",
|
||||||
|
"lon =",
|
||||||
|
"geom =",
|
||||||
|
"geocode_tried_at =",
|
||||||
|
"cadastral_number =",
|
||||||
|
"total_floors =",
|
||||||
|
"house_type =",
|
||||||
|
):
|
||||||
|
assert enrich not in set_and_where, f"enrichment column leaked into UPDATE: {enrich!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_separates_errors_from_dedup_skips() -> None:
|
||||||
|
"""Fix C: INSERT-ошибки считаются отдельным счётчиком, не как dedup-skip."""
|
||||||
|
assert '"rows_errored": 0' in _IMPORT_SRC
|
||||||
|
assert '"rows_updated": 0' in _IMPORT_SRC
|
||||||
|
assert "batch_errored += 1" in _IMPORT_SRC
|
||||||
|
# Per-row except-ветка (после лога "INSERT failed", до конца батча) инкрементит
|
||||||
|
# ТОЛЬКО errored — больше НЕ skip (это был баг silent-green: ошибки маскировались
|
||||||
|
# под дедуп).
|
||||||
|
per_row_except = _IMPORT_SRC.split("INSERT failed", 1)[1].split("last_id = batch_max_id", 1)[0]
|
||||||
|
assert "batch_errored += 1" in per_row_except
|
||||||
|
assert "batch_skipped += 1" not in per_row_except
|
||||||
|
# Порог ошибок → run failed, а не silent-green.
|
||||||
|
assert "DKP_IMPORT_ERROR_RATE_THRESHOLD" in _IMPORT_SRC
|
||||||
|
assert "marking run failed" in _IMPORT_SRC
|
||||||
|
|
||||||
|
|
||||||
def test_import_uses_psycopg_v3_casts_not_double_colon() -> None:
|
def test_import_uses_psycopg_v3_casts_not_double_colon() -> None:
|
||||||
|
|
@ -89,12 +148,18 @@ def test_migration_077_converts_md5_to_plain_key() -> None:
|
||||||
assert "BEGIN;" in sql and "COMMIT;" in sql
|
assert "BEGIN;" in sql and "COMMIT;" in sql
|
||||||
|
|
||||||
|
|
||||||
def test_migration_077_filter_matches_live_import() -> None:
|
def test_migration_077_shared_filter_matches_live_import() -> None:
|
||||||
"""Фильтр src CTE байт-в-байт совпадает с живым импортом → все строки сконвертируются."""
|
"""Дедуп-релевантные фильтры src CTE миграции 077 совпадают с живым импортом.
|
||||||
|
|
||||||
|
Исключение — city ILIKE (см. test_live_import_dropped_ekb_city_filter ниже):
|
||||||
|
077 backfill'ил ЕКБ-строки под EKB-only scope того времени; живой импорт расширен
|
||||||
|
на всю Свердловскую область (region_code=66, все города), поэтому city-фильтр из
|
||||||
|
живого импорта СНЯТ намеренно. Остальные клозы обязаны совпадать байт-в-байт,
|
||||||
|
иначе 077 конвертировал бы не тот набор строк.
|
||||||
|
"""
|
||||||
sql = _MIGRATION_077.read_text("utf-8")
|
sql = _MIGRATION_077.read_text("utf-8")
|
||||||
for clause in (
|
for clause in (
|
||||||
"region_code = 66",
|
"region_code = 66",
|
||||||
"city ILIKE '%катеринбург%'",
|
|
||||||
"realestate_type_code = '002001003000'",
|
"realestate_type_code = '002001003000'",
|
||||||
"area BETWEEN 18 AND 200",
|
"area BETWEEN 18 AND 200",
|
||||||
"deal_price BETWEEN 1000000 AND 100000000",
|
"deal_price BETWEEN 1000000 AND 100000000",
|
||||||
|
|
@ -103,3 +168,17 @@ def test_migration_077_filter_matches_live_import() -> None:
|
||||||
):
|
):
|
||||||
assert clause in sql, f"missing filter clause in migration: {clause!r}"
|
assert clause in sql, f"missing filter clause in migration: {clause!r}"
|
||||||
assert clause in _IMPORT_SRC, f"missing filter clause in import: {clause!r}"
|
assert clause in _IMPORT_SRC, f"missing filter clause in import: {clause!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_import_dropped_ekb_city_filter() -> None:
|
||||||
|
"""Живой импорт БОЛЬШЕ не фильтрует по городу — Mera расширена на всю обл. 66.
|
||||||
|
|
||||||
|
Историческая миграция 077 (уже применена на прод, трогать нельзя) сохраняет свой
|
||||||
|
EKB-only city-фильтр; живой импорт — нет. Guard против случайного возврата
|
||||||
|
ЕКБ-скоупа, который снова отрезал бы +47k не-ЕКБ сделок.
|
||||||
|
"""
|
||||||
|
sql = _MIGRATION_077.read_text("utf-8")
|
||||||
|
# 077 — исторический EKB-scope, city-фильтр там остаётся неизменным.
|
||||||
|
assert "city ILIKE '%катеринбург%'" in sql
|
||||||
|
# Живой импорт city-фильтр снял намеренно (region_code=66 = вся область).
|
||||||
|
assert "ILIKE '%катеринбург%'" not in _IMPORT_SRC
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,17 @@
|
||||||
Coverage:
|
Coverage:
|
||||||
(a) _next_quarter: boundary cases (Q4 → Q1 of next year, mid-year).
|
(a) _next_quarter: boundary cases (Q4 → Q1 of next year, mid-year).
|
||||||
(b) latest_loaded_quarter: derives correct (year, quarter) from a known deal_date.
|
(b) latest_loaded_quarter: derives correct (year, quarter) from a known deal_date.
|
||||||
(c) poll_rosreestr_new_quarter available=True path (mocked httpx + fake DB).
|
(c) rosreestr_dataset_url: cp1251 percent-encoding matches the real portal URL.
|
||||||
(d) poll_rosreestr_new_quarter available=False path.
|
(d) check_new_quarter_available — new /data-sets/ autoindex flow:
|
||||||
(e) Network error → available=False (never raises into caller).
|
- real application/zip + large Content-Length → True
|
||||||
(f) No DB data → fallback baseline 2025Q4, checks 2026Q1.
|
- soft-404 regression: file found but Content-Type text/html → False
|
||||||
|
(this is the exact bug that broke the old /opendata/ flat-URL poller)
|
||||||
|
- tiny Content-Length (placeholder/error file) → False
|
||||||
|
- quarter folder missing from autoindex → False (not yet published)
|
||||||
|
- dataset file missing from quarter folder → False
|
||||||
|
- network error / timeout → False, never raises
|
||||||
|
(e) poll_rosreestr_new_quarter orchestration: available=True/False end-to-end,
|
||||||
|
no DB data fallback, network error propagation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -15,6 +22,7 @@ import os
|
||||||
import sys
|
import sys
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
|
@ -33,6 +41,46 @@ from app.services.rosreestr_poll import ( # noqa: E402
|
||||||
rosreestr_dataset_url,
|
rosreestr_dataset_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# cp1251 href fixtures — built the same way the real portal encodes folder/file
|
||||||
|
# names (percent-encoded cp1251 bytes), so tests exercise the actual decode path.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _cp1251_href(name: str) -> str:
|
||||||
|
return quote(name.encode("cp1251"), safe="")
|
||||||
|
|
||||||
|
|
||||||
|
def _autoindex_html(*folder_names: str) -> str:
|
||||||
|
"""Build a fake Apache-autoindex-style HTML listing for /data-sets/."""
|
||||||
|
links = ['<a href="../">../</a>']
|
||||||
|
for name in folder_names:
|
||||||
|
href = _cp1251_href(name) + "/"
|
||||||
|
links.append(f'<a href="{href}">{name}/</a>')
|
||||||
|
return "<html><body><pre>" + "\n".join(links) + "</pre></body></html>"
|
||||||
|
|
||||||
|
|
||||||
|
def _folder_html(*file_names: str) -> str:
|
||||||
|
"""Build a fake Apache-autoindex-style HTML listing for a quarter folder."""
|
||||||
|
links = ['<a href="../">../</a>']
|
||||||
|
for name in file_names:
|
||||||
|
href = _cp1251_href(name)
|
||||||
|
links.append(f'<a href="{href}">{name}</a>')
|
||||||
|
return "<html><body><pre>" + "\n".join(links) + "</pre></body></html>"
|
||||||
|
|
||||||
|
|
||||||
|
_SDELKI_FILE_2026_Q1 = "dataset_СДЕЛКИ_r-r_01-92_y_2026_q_1.csv.zip"
|
||||||
|
_KADASTR_FILE_2026_Q1 = "dataset_КАДАСТРСТОИМОСТЬ_r-r_01-92_y_2026_q_1.csv.zip"
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_response(*, status_code: int = 200, text: str = "", headers: dict | None = None):
|
||||||
|
resp = MagicMock()
|
||||||
|
resp.status_code = status_code
|
||||||
|
resp.text = text
|
||||||
|
resp.headers = headers or {}
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# (a) _next_quarter
|
# (a) _next_quarter
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -62,10 +110,25 @@ def test_period_start_to_quarter_from_string() -> None:
|
||||||
assert _period_start_to_quarter("2026-07-01") == (2026, 3)
|
assert _period_start_to_quarter("2026-07-01") == (2026, 3)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# (c) rosreestr_dataset_url — cp1251 percent-encoding must match the real portal.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_rosreestr_dataset_url_matches_verified_real_encoding() -> None:
|
||||||
|
"""Encoding must byte-match the live-verified rosreestr.gov.ru/data-sets/ URL."""
|
||||||
|
url = rosreestr_dataset_url(2026, 1)
|
||||||
|
assert url == (
|
||||||
|
"https://rosreestr.gov.ru/data-sets/"
|
||||||
|
"1%20%EA%E2%E0%F0%F2%E0%EB%202026%E3./"
|
||||||
|
"dataset_%D1%C4%C5%CB%CA%C8_r-r_01-92_y_2026_q_1.csv.zip"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_rosreestr_dataset_url_contains_year_quarter() -> None:
|
def test_rosreestr_dataset_url_contains_year_quarter() -> None:
|
||||||
url = rosreestr_dataset_url(2026, 2)
|
url = rosreestr_dataset_url(2026, 2)
|
||||||
assert "2026" in url
|
assert "2026" in url
|
||||||
assert "_2" in url or "q_2" in url # quarter embedded
|
assert "q_2" in url
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -125,22 +188,181 @@ def test_latest_loaded_quarter_returns_none_when_empty() -> None:
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# (c) poll_rosreestr_new_quarter — available=True
|
# (d) check_new_quarter_available — /data-sets/ autoindex flow
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_poll_available_true_returns_correct_next_quarter() -> None:
|
async def test_check_available_true_for_real_zip() -> None:
|
||||||
"""If latest loaded is Q1 2026 and head returns 200, returns available=True for Q2 2026."""
|
"""Folder found, file found, HEAD returns application/zip + large Content-Length → True."""
|
||||||
db = _FakeDB(date(2026, 3, 31)) # latest = Q1 2026
|
index_html = _autoindex_html("1 квартал 2026г.", "4 квартал 2025г.")
|
||||||
|
folder_html = _folder_html(_SDELKI_FILE_2026_Q1, _KADASTR_FILE_2026_Q1)
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.status_code = 200
|
|
||||||
|
|
||||||
mock_client = AsyncMock()
|
mock_client = AsyncMock()
|
||||||
mock_client.head.return_value = mock_response
|
mock_client.get.side_effect = [
|
||||||
|
_mock_response(text=index_html),
|
||||||
|
_mock_response(text=folder_html),
|
||||||
|
]
|
||||||
|
mock_client.head.return_value = _mock_response(
|
||||||
|
headers={"content-type": "application/zip", "content-length": "9500000"}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await check_new_quarter_available(mock_client, 2026, 1) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert mock_client.get.call_count == 2
|
||||||
|
assert mock_client.head.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_available_false_soft_404_text_html() -> None:
|
||||||
|
"""Regression guard: HTTP 200 with Content-Type text/html (Bitrix soft-404) → False.
|
||||||
|
|
||||||
|
This is the exact failure mode that broke the old /opendata/ flat-URL poller —
|
||||||
|
it treated bare HTTP 200 as availability. Even if a matching file href is found,
|
||||||
|
a non-zip Content-Type must never be treated as available.
|
||||||
|
"""
|
||||||
|
index_html = _autoindex_html("1 квартал 2026г.")
|
||||||
|
folder_html = _folder_html(_SDELKI_FILE_2026_Q1)
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.get.side_effect = [
|
||||||
|
_mock_response(text=index_html),
|
||||||
|
_mock_response(text=folder_html),
|
||||||
|
]
|
||||||
|
mock_client.head.return_value = _mock_response(
|
||||||
|
status_code=200,
|
||||||
|
headers={"content-type": "text/html; charset=utf-8", "content-length": "1234"},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await check_new_quarter_available(mock_client, 2026, 1) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_available_false_tiny_content_length() -> None:
|
||||||
|
"""application/zip but tiny Content-Length (placeholder/error file) → False."""
|
||||||
|
index_html = _autoindex_html("1 квартал 2026г.")
|
||||||
|
folder_html = _folder_html(_SDELKI_FILE_2026_Q1)
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.get.side_effect = [
|
||||||
|
_mock_response(text=index_html),
|
||||||
|
_mock_response(text=folder_html),
|
||||||
|
]
|
||||||
|
mock_client.head.return_value = _mock_response(
|
||||||
|
headers={"content-type": "application/zip", "content-length": "512"}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await check_new_quarter_available(mock_client, 2026, 1) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_available_false_quarter_folder_missing() -> None:
|
||||||
|
"""Autoindex doesn't have the target quarter folder yet → False, no further GET/HEAD."""
|
||||||
|
index_html = _autoindex_html("4 квартал 2025г.") # 2026 Q1 not published yet
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.get.return_value = _mock_response(text=index_html)
|
||||||
|
|
||||||
|
result = await check_new_quarter_available(mock_client, 2026, 1) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
assert mock_client.get.call_count == 1 # never fetched a (non-existent) folder
|
||||||
|
mock_client.head.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_available_false_dataset_file_missing_in_folder() -> None:
|
||||||
|
"""Quarter folder exists but has no dataset_СДЕЛКИ file (only КАДАСТРСТОИМОСТЬ) → False."""
|
||||||
|
index_html = _autoindex_html("1 квартал 2026г.")
|
||||||
|
folder_html = _folder_html(_KADASTR_FILE_2026_Q1) # no СДЕЛКИ file
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.get.side_effect = [
|
||||||
|
_mock_response(text=index_html),
|
||||||
|
_mock_response(text=folder_html),
|
||||||
|
]
|
||||||
|
|
||||||
|
result = await check_new_quarter_available(mock_client, 2026, 1) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
mock_client.head.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_available_false_on_index_404() -> None:
|
||||||
|
"""Unexpected non-200 on the base autoindex → False, no raise."""
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.get.return_value = _mock_response(status_code=404, text="")
|
||||||
|
|
||||||
|
result = await check_new_quarter_available(mock_client, 2026, 1) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_new_quarter_available_timeout_returns_false() -> None:
|
||||||
|
"""Timeout → returns False, no exception."""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.get.side_effect = httpx.TimeoutException("timeout")
|
||||||
|
|
||||||
|
result = await check_new_quarter_available(mock_client, 2026, 2) # type: ignore[arg-type]
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_new_quarter_available_network_error_returns_false() -> None:
|
||||||
|
"""Network error mid-flow (folder GET) → returns False, no exception."""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
index_html = _autoindex_html("1 квартал 2026г.")
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.get.side_effect = [
|
||||||
|
_mock_response(text=index_html),
|
||||||
|
httpx.ConnectError("simulated network error"),
|
||||||
|
]
|
||||||
|
|
||||||
|
result = await check_new_quarter_available(mock_client, 2026, 1) # type: ignore[arg-type]
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# (e) poll_rosreestr_new_quarter — orchestration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _patched_client_for(index_html: str, folder_html: str, head_response) -> AsyncMock:
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.get.side_effect = [
|
||||||
|
_mock_response(text=index_html),
|
||||||
|
_mock_response(text=folder_html),
|
||||||
|
]
|
||||||
|
mock_client.head.return_value = head_response
|
||||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
return mock_client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_poll_available_true_returns_correct_next_quarter() -> None:
|
||||||
|
"""If latest loaded is Q1 2026 and the Q2 2026 dataset is a real zip, available=True."""
|
||||||
|
db = _FakeDB(date(2026, 3, 31)) # latest = Q1 2026 → next = Q2 2026
|
||||||
|
|
||||||
|
index_html = _autoindex_html("2 квартал 2026г.")
|
||||||
|
file_name = "dataset_СДЕЛКИ_r-r_01-92_y_2026_q_2.csv.zip"
|
||||||
|
folder_html = _folder_html(file_name)
|
||||||
|
head_response = _mock_response(
|
||||||
|
headers={"content-type": "application/zip", "content-length": "9500000"}
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_client = _patched_client_for(index_html, folder_html, head_response)
|
||||||
|
|
||||||
with patch("app.services.rosreestr_poll.httpx.AsyncClient", return_value=mock_client):
|
with patch("app.services.rosreestr_poll.httpx.AsyncClient", return_value=mock_client):
|
||||||
result = await poll_rosreestr_new_quarter(db) # type: ignore[arg-type]
|
result = await poll_rosreestr_new_quarter(db) # type: ignore[arg-type]
|
||||||
|
|
@ -152,21 +374,15 @@ async def test_poll_available_true_returns_correct_next_quarter() -> None:
|
||||||
assert result["latest_loaded_quarter"] == 1
|
assert result["latest_loaded_quarter"] == 1
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# (d) poll_rosreestr_new_quarter — available=False (404)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_poll_available_false_on_404() -> None:
|
async def test_poll_available_false_when_folder_not_published() -> None:
|
||||||
"""404 from server → available=False."""
|
"""Quarter folder not yet published (autoindex has no matching entry) → available=False."""
|
||||||
db = _FakeDB(date(2026, 3, 15)) # Q1 2026
|
db = _FakeDB(date(2026, 3, 15)) # Q1 2026 → next = Q2 2026
|
||||||
|
|
||||||
mock_response = MagicMock()
|
index_html = _autoindex_html("1 квартал 2026г.") # Q2 2026 missing
|
||||||
mock_response.status_code = 404
|
|
||||||
|
|
||||||
mock_client = AsyncMock()
|
mock_client = AsyncMock()
|
||||||
mock_client.head.return_value = mock_response
|
mock_client.get.return_value = _mock_response(text=index_html)
|
||||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
|
@ -178,11 +394,6 @@ async def test_poll_available_false_on_404() -> None:
|
||||||
assert result["quarter"] == 2
|
assert result["quarter"] == 2
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# (e) Network error → available=False (never raises into caller)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_poll_network_error_returns_false_no_raise() -> None:
|
async def test_poll_network_error_returns_false_no_raise() -> None:
|
||||||
"""Network error → available=False, no exception propagated."""
|
"""Network error → available=False, no exception propagated."""
|
||||||
|
|
@ -191,7 +402,7 @@ async def test_poll_network_error_returns_false_no_raise() -> None:
|
||||||
db = _FakeDB(date(2026, 1, 20)) # Q1 2026
|
db = _FakeDB(date(2026, 1, 20)) # Q1 2026
|
||||||
|
|
||||||
mock_client = AsyncMock()
|
mock_client = AsyncMock()
|
||||||
mock_client.head.side_effect = httpx.ConnectError("simulated network error")
|
mock_client.get.side_effect = httpx.ConnectError("simulated network error")
|
||||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
|
@ -202,18 +413,6 @@ async def test_poll_network_error_returns_false_no_raise() -> None:
|
||||||
assert result["available"] is False
|
assert result["available"] is False
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_check_new_quarter_available_timeout_returns_false() -> None:
|
|
||||||
"""Timeout → returns False, no exception."""
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
mock_client = AsyncMock()
|
|
||||||
mock_client.head.side_effect = httpx.TimeoutException("timeout")
|
|
||||||
|
|
||||||
result = await check_new_quarter_available(mock_client, 2026, 2) # type: ignore[arg-type]
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# (f) No DB data → fallback baseline checks 2026Q1
|
# (f) No DB data → fallback baseline checks 2026Q1
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -224,11 +423,10 @@ async def test_poll_fallback_when_no_db_data() -> None:
|
||||||
"""No rosreestr deals in DB → fallback 2025Q4 baseline, checks 2026Q1."""
|
"""No rosreestr deals in DB → fallback 2025Q4 baseline, checks 2026Q1."""
|
||||||
db = _FakeDB(None)
|
db = _FakeDB(None)
|
||||||
|
|
||||||
mock_response = MagicMock()
|
index_html = _autoindex_html("4 квартал 2025г.") # 2026 Q1 not yet published
|
||||||
mock_response.status_code = 404 # not yet available
|
|
||||||
|
|
||||||
mock_client = AsyncMock()
|
mock_client = AsyncMock()
|
||||||
mock_client.head.return_value = mock_response
|
mock_client.get.return_value = _mock_response(text=index_html)
|
||||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
|
@ -238,6 +436,7 @@ async def test_poll_fallback_when_no_db_data() -> None:
|
||||||
# Fallback: 2025Q4 → check 2026Q1
|
# Fallback: 2025Q4 → check 2026Q1
|
||||||
assert result["year"] == 2026
|
assert result["year"] == 2026
|
||||||
assert result["quarter"] == 1
|
assert result["quarter"] == 1
|
||||||
|
assert result["available"] is False
|
||||||
# latest_loaded_year/quarter should be None (no DB data)
|
# latest_loaded_year/quarter should be None (no DB data)
|
||||||
assert result["latest_loaded_year"] is None
|
assert result["latest_loaded_year"] is None
|
||||||
assert result["latest_loaded_quarter"] is None
|
assert result["latest_loaded_quarter"] is None
|
||||||
|
|
|
||||||
|
|
@ -426,7 +426,6 @@ def _run_estimate(
|
||||||
*,
|
*,
|
||||||
anchor_comps: list[dict[str, Any]],
|
anchor_comps: list[dict[str, Any]],
|
||||||
anchor_tier: str | None,
|
anchor_tier: str | None,
|
||||||
flag_enabled: bool = True,
|
|
||||||
ratio_tuple: tuple[float | None, str | None] = (0.92, "per_rooms"),
|
ratio_tuple: tuple[float | None, str | None] = (0.92, "per_rooms"),
|
||||||
payload=None,
|
payload=None,
|
||||||
radius_analogs: list[dict[str, Any]] | None = None,
|
radius_analogs: list[dict[str, Any]] | None = None,
|
||||||
|
|
@ -439,7 +438,6 @@ def _run_estimate(
|
||||||
ST_DWithin не нашёл радиусных аналогов (median=0) — якорь обязан сработать
|
ST_DWithin не нашёл радиусных аналогов (median=0) — якорь обязан сработать
|
||||||
по same-building комплам независимо.
|
по same-building комплам независимо.
|
||||||
"""
|
"""
|
||||||
from app.core.config import settings
|
|
||||||
from app.services.estimator import estimate_quality
|
from app.services.estimator import estimate_quality
|
||||||
|
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
|
|
@ -448,7 +446,6 @@ def _run_estimate(
|
||||||
|
|
||||||
async def _run():
|
async def _run():
|
||||||
with (
|
with (
|
||||||
patch.object(settings, "estimate_same_building_anchor_enabled", flag_enabled),
|
|
||||||
patch(
|
patch(
|
||||||
"app.services.estimator.geocode",
|
"app.services.estimator.geocode",
|
||||||
new=AsyncMock(return_value=geo_result or _make_fake_geo()),
|
new=AsyncMock(return_value=geo_result or _make_fake_geo()),
|
||||||
|
|
@ -546,14 +543,6 @@ def test_estimate_economy_no_regression() -> None:
|
||||||
assert est.median_price_per_m2 >= 100_000
|
assert est.median_price_per_m2 >= 100_000
|
||||||
|
|
||||||
|
|
||||||
def test_estimate_flag_off_unchanged_radius_result() -> None:
|
|
||||||
"""(e) Флаг OFF ⇒ headline = радиусная медиана (210k×146.2), якорь не трогает."""
|
|
||||||
est_off = _run_estimate(anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A", flag_enabled=False)
|
|
||||||
# Радиусная медиана из _RADIUS_ANALOGS = median(200,210,220)=210k.
|
|
||||||
assert est_off.median_price_per_m2 == 210_000
|
|
||||||
assert est_off.median_price_rub == int(210_000 * 146.2)
|
|
||||||
|
|
||||||
|
|
||||||
def test_estimate_expected_sold_distinct_after_anchor() -> None:
|
def test_estimate_expected_sold_distinct_after_anchor() -> None:
|
||||||
"""(f) При сработавшем якоре headline = ASKING (anchor_ppm2, pre-haircut), а
|
"""(f) При сработавшем якоре headline = ASKING (anchor_ppm2, pre-haircut), а
|
||||||
expected_sold = headline × per-rooms ratio → DISTINCT, строго ниже median.
|
expected_sold = headline × per-rooms ratio → DISTINCT, строго ниже median.
|
||||||
|
|
|
||||||
227
tradein-mvp/backend/tests/test_sber_freshness_monitor.py
Normal file
227
tradein-mvp/backend/tests/test_sber_freshness_monitor.py
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
"""Freshness-монитор данных СберИндекса по max(period_month) — audit п.1.
|
||||||
|
|
||||||
|
Покрывает:
|
||||||
|
1. Чистую логику evaluate_sber_freshness (frozen now, без БД):
|
||||||
|
- fresh: age <= max_age_days (алерта нет);
|
||||||
|
- stale: age > max_age_days (алерт);
|
||||||
|
- граница порога (== max_age_days → нет алерта; +1 день → алерт).
|
||||||
|
2. check_sber_freshness с FakeDB (fresh / stale / empty→mark_failed / кастомный lag).
|
||||||
|
3. Свойства миграции 180 (по образцу test_deals_freshness_monitor).
|
||||||
|
4. Регистрацию в kit product_handlers (registry остаётся зелёным).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from datetime import UTC, date, datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.services.product_handlers import build_product_handlers
|
||||||
|
from app.tasks import sber_freshness_monitor as mon
|
||||||
|
|
||||||
|
_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
|
||||||
|
_MIGRATION_180 = _SQL_DIR / "180_seed_sber_freshness_monitor.sql"
|
||||||
|
|
||||||
|
# max(period_month) вторичного сегмента = 2026-05-01 (проверено на проде 2026-07-12).
|
||||||
|
_MAY_2026 = date(2026, 5, 1)
|
||||||
|
|
||||||
|
|
||||||
|
# ── evaluate_sber_freshness (чистая логика, frozen now) ───────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _now(y: int, m: int, d: int) -> datetime:
|
||||||
|
return datetime(y, m, d, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fresh_within_max_age() -> None:
|
||||||
|
"""age=30 ≤ max_age_days=60 — свежий, алерта нет."""
|
||||||
|
v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 5, 31), max_age_days=60)
|
||||||
|
assert v.stale is False
|
||||||
|
assert v.age_days == 30
|
||||||
|
assert v.latest_period == _MAY_2026
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_beyond_max_age() -> None:
|
||||||
|
"""Прод-состояние: 2026-05-01 @ 2026-07-12 — age=72 > 60 → алерт."""
|
||||||
|
v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 7, 12), max_age_days=60)
|
||||||
|
assert v.stale is True
|
||||||
|
assert v.age_days == 72
|
||||||
|
|
||||||
|
|
||||||
|
def test_threshold_boundary_exact_no_alert() -> None:
|
||||||
|
"""Ровно на пороге (age == max_age_days) алерта ещё нет (строгое >)."""
|
||||||
|
v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 6, 30), max_age_days=60)
|
||||||
|
assert v.age_days == 60
|
||||||
|
assert v.stale is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_threshold_boundary_next_day_alert() -> None:
|
||||||
|
"""Порог + 1 день (age=61) — первый алерт."""
|
||||||
|
v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 7, 1), max_age_days=60)
|
||||||
|
assert v.age_days == 61
|
||||||
|
assert v.stale is True
|
||||||
|
|
||||||
|
|
||||||
|
# ── check_sber_freshness (FakeDB) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class _Row:
|
||||||
|
def __init__(self, latest: date | None) -> None:
|
||||||
|
self.latest = latest
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResult:
|
||||||
|
def __init__(self, latest: date | None) -> None:
|
||||||
|
self._latest = latest
|
||||||
|
|
||||||
|
def first(self) -> _Row:
|
||||||
|
return _Row(self._latest)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeDB:
|
||||||
|
def __init__(self, latest: date | None) -> None:
|
||||||
|
self._latest = latest
|
||||||
|
self.rolled_back = False
|
||||||
|
|
||||||
|
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult:
|
||||||
|
return _FakeResult(self._latest)
|
||||||
|
|
||||||
|
def rollback(self) -> None:
|
||||||
|
self.rolled_back = True
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_runs(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
|
||||||
|
calls: dict[str, Any] = {"done": None, "failed": None, "heartbeat": 0}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
mon.runs_mod,
|
||||||
|
"update_heartbeat",
|
||||||
|
lambda *a, **k: calls.__setitem__("heartbeat", calls["heartbeat"] + 1),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
mon.runs_mod,
|
||||||
|
"mark_done",
|
||||||
|
lambda _db, run_id, counters: calls.__setitem__("done", dict(counters)),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
mon.runs_mod,
|
||||||
|
"mark_failed",
|
||||||
|
lambda _db, run_id, err, counters: calls.__setitem__("failed", err),
|
||||||
|
)
|
||||||
|
return calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_fresh_marks_done(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
calls = _patch_runs(monkeypatch)
|
||||||
|
db = _FakeDB(_MAY_2026)
|
||||||
|
# @2026-05-31: age=30 ≤ 35+25=60 → нет алерта.
|
||||||
|
out = mon.check_sber_freshness(db, run_id=1, params={}, now=_now(2026, 5, 31)) # type: ignore[arg-type]
|
||||||
|
assert out == {"latest_year": 2026, "latest_month": 5, "age_days": 30, "alert": 0}
|
||||||
|
assert calls["done"] == out
|
||||||
|
assert calls["failed"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_stale_marks_done_with_alert(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
calls = _patch_runs(monkeypatch)
|
||||||
|
db = _FakeDB(_MAY_2026)
|
||||||
|
# @2026-07-12 (прод): age=72 > 60 → алерт.
|
||||||
|
out = mon.check_sber_freshness(db, run_id=2, params={}, now=_now(2026, 7, 12)) # type: ignore[arg-type]
|
||||||
|
assert out["alert"] == 1
|
||||||
|
assert out["age_days"] == 72
|
||||||
|
assert out["latest_month"] == 5
|
||||||
|
# Монитор НЕ падает при алерте — прогон done, а не failed.
|
||||||
|
assert calls["done"] == out
|
||||||
|
assert calls["failed"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_empty_index_marks_failed(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
calls = _patch_runs(monkeypatch)
|
||||||
|
db = _FakeDB(None)
|
||||||
|
out = mon.check_sber_freshness(db, run_id=3, params={}, now=_now(2026, 7, 12)) # type: ignore[arg-type]
|
||||||
|
assert out["alert"] == 0
|
||||||
|
assert calls["done"] is None
|
||||||
|
assert calls["failed"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_reads_lag_from_params(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
_patch_runs(monkeypatch)
|
||||||
|
db = _FakeDB(_MAY_2026)
|
||||||
|
# lag=0 → порог = sber_index_max_age_days (35) → @2026-07-12 (age=72) просрочено.
|
||||||
|
out = mon.check_sber_freshness(
|
||||||
|
db, run_id=4, params={"lag_allowance_days": 0}, now=_now(2026, 7, 12)
|
||||||
|
) # type: ignore[arg-type]
|
||||||
|
assert out["alert"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_default_threshold_uses_setting_plus_lag(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Дефолтный порог = sber_index_max_age_days + DEFAULT_LAG_ALLOWANCE_DAYS."""
|
||||||
|
_patch_runs(monkeypatch)
|
||||||
|
db = _FakeDB(_MAY_2026)
|
||||||
|
threshold = settings.sber_index_max_age_days + mon.DEFAULT_LAG_ALLOWANCE_DAYS
|
||||||
|
# Ровно на пороге (age == threshold) — алерта нет; +1 день — алерт.
|
||||||
|
exact = datetime(2026, 5, 1, tzinfo=UTC) + timedelta(days=threshold)
|
||||||
|
out_exact = mon.check_sber_freshness(db, run_id=5, params={}, now=exact) # type: ignore[arg-type]
|
||||||
|
assert out_exact["age_days"] == threshold
|
||||||
|
assert out_exact["alert"] == 0
|
||||||
|
out_over = mon.check_sber_freshness(db, run_id=6, params={}, now=exact + timedelta(days=1)) # type: ignore[arg-type]
|
||||||
|
assert out_over["alert"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── Миграция 180 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_180_exists() -> None:
|
||||||
|
assert _MIGRATION_180.is_file(), f"missing migration: {_MIGRATION_180}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_180_seeds_source() -> None:
|
||||||
|
sql = _MIGRATION_180.read_text("utf-8")
|
||||||
|
assert "'sber_freshness_monitor'" in sql
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_180_enabled_true() -> None:
|
||||||
|
sql = _MIGRATION_180.read_text("utf-8")
|
||||||
|
assert "true" in sql
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_180_is_idempotent() -> None:
|
||||||
|
sql = _MIGRATION_180.read_text("utf-8")
|
||||||
|
assert "ON CONFLICT (source) DO NOTHING" in sql
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_180_is_transactional() -> None:
|
||||||
|
sql = _MIGRATION_180.read_text("utf-8")
|
||||||
|
assert "BEGIN;" in sql
|
||||||
|
assert "COMMIT;" in sql
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_180_window_9_to_10_utc() -> None:
|
||||||
|
sql = _MIGRATION_180.read_text("utf-8")
|
||||||
|
assert re.search(r"\b9\b", sql), "window_start_hour 9 missing"
|
||||||
|
assert re.search(r"\b10\b", sql), "window_end_hour 10 missing"
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_180_lag_allowance_25() -> None:
|
||||||
|
sql = _MIGRATION_180.read_text("utf-8")
|
||||||
|
assert "lag_allowance_days" in sql
|
||||||
|
assert "25" in sql
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_180_no_psycopg_trap() -> None:
|
||||||
|
sql = _MIGRATION_180.read_text("utf-8")
|
||||||
|
assert not re.search(r":\w+::", sql)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Регистрация в kit registry ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_kit_product_handler_registered() -> None:
|
||||||
|
# ctx не нужен для сборки dict ключей — build_product_handlers его не замыкает.
|
||||||
|
handlers = build_product_handlers(ctx=None) # type: ignore[arg-type]
|
||||||
|
assert "sber_freshness_monitor" in handlers
|
||||||
|
|
@ -28,11 +28,15 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||||
|
|
||||||
from scraper_kit.orchestration import scheduler as kit_sched
|
from scraper_kit.orchestration import scheduler as kit_sched
|
||||||
|
from scraper_kit.orchestration.pipeline import CITY_ANCHORS
|
||||||
from scraper_kit.orchestration.scheduler import (
|
from scraper_kit.orchestration.scheduler import (
|
||||||
Handler,
|
Handler,
|
||||||
SchedulerContext,
|
SchedulerContext,
|
||||||
_claim_run,
|
_claim_run,
|
||||||
_dispatch,
|
_dispatch,
|
||||||
|
_job_avito_city_sweep,
|
||||||
|
_job_cian_city_sweep,
|
||||||
|
_job_yandex_city_sweep,
|
||||||
build_registry,
|
build_registry,
|
||||||
reap_zombies,
|
reap_zombies,
|
||||||
reschedule_after_minutes,
|
reschedule_after_minutes,
|
||||||
|
|
@ -74,14 +78,20 @@ _PRODUCT_SOURCES: set[str] = {
|
||||||
"proxy_healthcheck",
|
"proxy_healthcheck",
|
||||||
}
|
}
|
||||||
|
|
||||||
# kit-native (тело в scraper_kit.orchestration.pipeline) — регистрируются встроенно
|
# kit-native (тело в scraper_kit.orchestration.pipeline) — регистрируются встроенно.
|
||||||
|
# "*_city_sweep_*" — wildcard-семья oblast B1 rollout (region 66, dormant, #179):
|
||||||
|
# per-city source (avito_city_sweep_nizhniy_tagil и т.п.) резолвится на тот же
|
||||||
|
# handler, что EKB source, через resolve_handler-префикс (см. _default_kit_handlers).
|
||||||
_KIT_NATIVE_SOURCES = {
|
_KIT_NATIVE_SOURCES = {
|
||||||
"avito_city_sweep",
|
"avito_city_sweep",
|
||||||
|
"avito_city_sweep_*",
|
||||||
"avito_full_load",
|
"avito_full_load",
|
||||||
"avito_full_load_exhaustive",
|
"avito_full_load_exhaustive",
|
||||||
"avito_newbuilding_sweep",
|
"avito_newbuilding_sweep",
|
||||||
"yandex_city_sweep",
|
"yandex_city_sweep",
|
||||||
|
"yandex_city_sweep_*",
|
||||||
"cian_city_sweep",
|
"cian_city_sweep",
|
||||||
|
"cian_city_sweep_*",
|
||||||
"cian_full_load",
|
"cian_full_load",
|
||||||
"domclick_city_sweep",
|
"domclick_city_sweep",
|
||||||
}
|
}
|
||||||
|
|
@ -156,7 +166,10 @@ def test_real_build_product_handlers_covers_all_scheduled_sources() -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_kit_native_handler_set() -> None:
|
def test_kit_native_handler_set() -> None:
|
||||||
"""Ровно 8 kit-native sweep-обработчиков зарегистрированы встроенно."""
|
"""Ровно 11 kit-native sweep-обработчиков зарегистрированы встроенно (8 + 3 oblast
|
||||||
|
|
||||||
|
"*_city_sweep_*" wildcard, #179).
|
||||||
|
"""
|
||||||
registry = build_registry()
|
registry = build_registry()
|
||||||
assert set(registry) == _KIT_NATIVE_SOURCES
|
assert set(registry) == _KIT_NATIVE_SOURCES
|
||||||
for src in _KIT_NATIVE_SOURCES:
|
for src in _KIT_NATIVE_SOURCES:
|
||||||
|
|
@ -434,3 +447,87 @@ async def test_dispatch_post_claim_hook_runs() -> None:
|
||||||
await asyncio.gather(*list(ctx._inflight_tasks))
|
await asyncio.gather(*list(ctx._inflight_tasks))
|
||||||
post.assert_called_once()
|
post.assert_called_once()
|
||||||
assert post.call_args[0][1] == 300 # run_id
|
assert post.call_args[0][1] == 300 # run_id
|
||||||
|
|
||||||
|
|
||||||
|
# ── 6. B1 oblast rollout — city param → city anchors, wildcard dispatch ──────────
|
||||||
|
|
||||||
|
|
||||||
|
def _oblast_ctx() -> SchedulerContext:
|
||||||
|
return SchedulerContext(
|
||||||
|
config=MagicMock(),
|
||||||
|
matcher=MagicMock(),
|
||||||
|
enrichment=MagicMock(),
|
||||||
|
session_factory=MagicMock(),
|
||||||
|
runs=MagicMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_job_avito_city_sweep_with_city_param_uses_city_anchors() -> None:
|
||||||
|
"""params={"city": "nizhniy_tagil"} → Н.Тагил anchors переданы в run_avito_city_sweep."""
|
||||||
|
with patch.object(kit_sched, "run_avito_city_sweep", AsyncMock()) as mock_run:
|
||||||
|
await _job_avito_city_sweep(MagicMock(), 1, {"city": "nizhniy_tagil"}, _oblast_ctx())
|
||||||
|
|
||||||
|
mock_run.assert_awaited_once()
|
||||||
|
_args, kwargs = mock_run.call_args
|
||||||
|
assert kwargs["anchors"] == CITY_ANCHORS["nizhniy_tagil"]
|
||||||
|
assert kwargs["anchors"] == [(57.910, 59.980, "Н.Тагил центр")]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_job_cian_city_sweep_with_city_param_uses_city_anchors() -> None:
|
||||||
|
"""params={"city": "kamensk_uralskiy"} → anchors города переданы в run_cian_city_sweep."""
|
||||||
|
with patch.object(kit_sched, "run_cian_city_sweep", AsyncMock()) as mock_run:
|
||||||
|
await _job_cian_city_sweep(MagicMock(), 1, {"city": "kamensk_uralskiy"}, _oblast_ctx())
|
||||||
|
|
||||||
|
mock_run.assert_awaited_once()
|
||||||
|
_args, kwargs = mock_run.call_args
|
||||||
|
assert kwargs["anchors"] == CITY_ANCHORS["kamensk_uralskiy"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_job_yandex_city_sweep_with_city_param_uses_city_anchors() -> None:
|
||||||
|
"""params={"city": "serov"} → anchors города переданы в run_yandex_city_sweep."""
|
||||||
|
with patch.object(kit_sched, "run_yandex_city_sweep", AsyncMock()) as mock_run:
|
||||||
|
await _job_yandex_city_sweep(MagicMock(), 1, {"city": "serov"}, _oblast_ctx())
|
||||||
|
|
||||||
|
mock_run.assert_awaited_once()
|
||||||
|
_args, kwargs = mock_run.call_args
|
||||||
|
assert kwargs["anchors"] == CITY_ANCHORS["serov"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_job_avito_city_sweep_without_city_param_falls_back_to_none() -> None:
|
||||||
|
"""Без city (EKB-schedule, back-compat) — anchors=None передан (run_* сам берёт EKB_ANCHORS)."""
|
||||||
|
with patch.object(kit_sched, "run_avito_city_sweep", AsyncMock()) as mock_run:
|
||||||
|
await _job_avito_city_sweep(MagicMock(), 1, {}, _oblast_ctx())
|
||||||
|
|
||||||
|
mock_run.assert_awaited_once()
|
||||||
|
_args, kwargs = mock_run.call_args
|
||||||
|
assert kwargs["anchors"] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_job_avito_city_sweep_unknown_city_falls_back_to_none() -> None:
|
||||||
|
"""Неизвестный city slug (typo/будущий город без CITY_ANCHORS-записи) → anchors=None,
|
||||||
|
|
||||||
|
НЕ падает с KeyError.
|
||||||
|
"""
|
||||||
|
with patch.object(kit_sched, "run_avito_city_sweep", AsyncMock()) as mock_run:
|
||||||
|
await _job_avito_city_sweep(MagicMock(), 1, {"city": "nonexistent_town"}, _oblast_ctx())
|
||||||
|
|
||||||
|
mock_run.assert_awaited_once()
|
||||||
|
_args, kwargs = mock_run.call_args
|
||||||
|
assert kwargs["anchors"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_wildcard_resolves_oblast_city_sweep_sources() -> None:
|
||||||
|
"""resolve_handler матчит per-city oblast source на wildcard "*_city_sweep_*"."""
|
||||||
|
registry = build_registry()
|
||||||
|
assert (
|
||||||
|
resolve_handler("avito_city_sweep_nizhniy_tagil", registry)
|
||||||
|
is registry["avito_city_sweep_*"]
|
||||||
|
)
|
||||||
|
assert resolve_handler("cian_city_sweep_serov", registry) is registry["cian_city_sweep_*"]
|
||||||
|
assert (
|
||||||
|
resolve_handler("yandex_city_sweep_pervouralsk", registry)
|
||||||
|
is registry["yandex_city_sweep_*"]
|
||||||
|
)
|
||||||
|
# EKB source (no city) — по-прежнему резолвится на exact non-wildcard entry.
|
||||||
|
assert resolve_handler("avito_city_sweep", registry) is registry["avito_city_sweep"]
|
||||||
|
assert registry["avito_city_sweep"] is not registry["avito_city_sweep_*"]
|
||||||
|
|
|
||||||
|
|
@ -67,16 +67,13 @@ def test_tier_a_anchor_gated_python_side_not_sql_guard() -> None:
|
||||||
relaxation (впускает novostroyki только если в доме есть вторичка/NULL).
|
relaxation (впускает novostroyki только если в доме есть вторичка/NULL).
|
||||||
|
|
||||||
Assert: (a) Tier C всё ещё несёт канон-guard в SQL, (b) Tier A делает
|
Assert: (a) Tier C всё ещё несёт канон-guard в SQL, (b) Tier A делает
|
||||||
Python-side gating — источник содержит и флаг настройки, и hardened
|
Python-side gating — источник содержит hardened secondary_count/primary_count
|
||||||
secondary_count/primary_count gate (#1774, после code-review).
|
gate (#1774, после code-review).
|
||||||
"""
|
"""
|
||||||
src = inspect.getsource(est_mod._fetch_anchor_comps)
|
src = inspect.getsource(est_mod._fetch_anchor_comps)
|
||||||
# (a) Tier C SQL guard сохранён (поиск находит единственное вхождение — Tier C).
|
# (a) Tier C SQL guard сохранён (поиск находит единственное вхождение — Tier C).
|
||||||
assert _GUARD_RE.search(src), "Tier C anchor SQL lost the canonical novostroyki guard"
|
assert _GUARD_RE.search(src), "Tier C anchor SQL lost the canonical novostroyki guard"
|
||||||
# (b) Tier A теперь gated Python-side (#1774).
|
# (b) Tier A теперь gated Python-side (#1774).
|
||||||
assert (
|
|
||||||
"estimate_sb_tier_a_allow_primary_if_secondary_present" in src
|
|
||||||
), "Tier A no longer references the #1774 gating flag — gated relaxation missing"
|
|
||||||
# Hardened gate: secondary_count ≥ primary_count (заменил has_secondary).
|
# Hardened gate: secondary_count ≥ primary_count (заменил has_secondary).
|
||||||
assert "secondary_count" in src, "Tier A lacks secondary_count Python-side gate (#1774)"
|
assert "secondary_count" in src, "Tier A lacks secondary_count Python-side gate (#1774)"
|
||||||
assert "primary_count" in src, "Tier A lacks primary_count comparison in hardened gate (#1774)"
|
assert "primary_count" in src, "Tier A lacks primary_count comparison in hardened gate (#1774)"
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,21 @@
|
||||||
"""Unit-тесты consumer-PII scrubber для GlitchTip (#396)."""
|
"""Unit-тесты consumer-PII scrubber для GlitchTip (#396) + Telegram bot-токен
|
||||||
|
redaction (#tgsupport review — CRITICAL: токен утекал в GlitchTip двумя
|
||||||
|
независимыми векторами, воспроизведёнными ревьюером на реальном событии
|
||||||
|
sentry_sdk: stack-frame locals (`include_local_variables=True`) и httpx-span
|
||||||
|
`data` (`HttpxIntegration`). Тесты ниже конструируют event-словари ровно в той
|
||||||
|
форме, в которой их реально отдаёт sentry_sdk (frames[].vars, spans[].data),
|
||||||
|
чтобы regression на этот конкретный CRITICAL не проскочил молча."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
from app.observability.sentry_scrub import scrub_pii_event
|
from app.observability.sentry_scrub import (
|
||||||
|
redact_telegram_bot_token,
|
||||||
|
scrub_pii_event,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_redacts_pii_in_request_data() -> None:
|
def test_redacts_pii_in_request_data() -> None:
|
||||||
|
|
@ -88,3 +100,162 @@ def test_returns_event_not_none() -> None:
|
||||||
out = scrub_pii_event(event, {})
|
out = scrub_pii_event(event, {})
|
||||||
assert out is not None
|
assert out is not None
|
||||||
assert out is event
|
assert out is event
|
||||||
|
|
||||||
|
|
||||||
|
# ── Telegram bot-token redaction (#tgsupport review, CRITICAL) ──────────────
|
||||||
|
|
||||||
|
_LEAKED_TOKEN_URL = "https://api.telegram.org/bot8663867262:AAExampleSecretPartAbCdEf123/getMe"
|
||||||
|
|
||||||
|
|
||||||
|
def test_redacts_token_in_stack_frame_locals() -> None:
|
||||||
|
"""Вектор #1 (ревьюер): `include_local_variables=True` кладёт locals
|
||||||
|
`TelegramClient._request` (`self`, `url`) в traceback frame `vars`."""
|
||||||
|
event = {
|
||||||
|
"exception": {
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"type": "NetworkError",
|
||||||
|
"stacktrace": {
|
||||||
|
"frames": [
|
||||||
|
{
|
||||||
|
"function": "_request",
|
||||||
|
"vars": {
|
||||||
|
"url": _LEAKED_TOKEN_URL,
|
||||||
|
"self": {"_base": _LEAKED_TOKEN_URL.rsplit("/", 1)[0]},
|
||||||
|
"method": "getMe",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = redact_telegram_bot_token(event, {})
|
||||||
|
assert out is not None
|
||||||
|
frame_vars = out["exception"]["values"][0]["stacktrace"]["frames"][0]["vars"]
|
||||||
|
assert "8663867262:AAExampleSecretPartAbCdEf123" not in frame_vars["url"]
|
||||||
|
assert "8663867262:AAExampleSecretPartAbCdEf123" not in frame_vars["self"]["_base"]
|
||||||
|
assert frame_vars["url"] == "https://api.telegram.org/bot[REDACTED]/getMe"
|
||||||
|
# Не PII/секрет — остаётся как есть.
|
||||||
|
assert frame_vars["method"] == "getMe"
|
||||||
|
|
||||||
|
|
||||||
|
def test_redacts_token_in_httpx_span_data() -> None:
|
||||||
|
"""Вектор #2 (ревьюер): `HttpxIntegration` кладёт полный request URL в span
|
||||||
|
`data` независимо от traceback — `traces_sample_rate=0.0` спасает сейчас, но
|
||||||
|
редактор — belt-and-suspenders на случай если трейсинг когда-нибудь включат."""
|
||||||
|
event = {
|
||||||
|
"spans": [
|
||||||
|
{
|
||||||
|
"op": "http.client",
|
||||||
|
"description": "POST " + _LEAKED_TOKEN_URL,
|
||||||
|
"data": {"url": _LEAKED_TOKEN_URL, "http.method": "POST"},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
out = redact_telegram_bot_token(event, {})
|
||||||
|
assert out is not None
|
||||||
|
span = out["spans"][0]
|
||||||
|
assert "8663867262:AAExampleSecretPartAbCdEf123" not in span["data"]["url"]
|
||||||
|
assert "8663867262:AAExampleSecretPartAbCdEf123" not in span["description"]
|
||||||
|
assert span["data"]["http.method"] == "POST"
|
||||||
|
|
||||||
|
|
||||||
|
def test_redacts_token_in_breadcrumb_message() -> None:
|
||||||
|
"""httpx-логгер (INFO, до нашего getLogger('httpx').setLevel(WARNING) в
|
||||||
|
tgbot_main) может всплыть breadcrumb'ом с полным URL через LoggingIntegration."""
|
||||||
|
event = {
|
||||||
|
"breadcrumbs": {
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"category": "httpx",
|
||||||
|
"message": f'HTTP Request: POST {_LEAKED_TOKEN_URL} "HTTP/1.1 401"',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = redact_telegram_bot_token(event, {})
|
||||||
|
assert out is not None
|
||||||
|
msg = out["breadcrumbs"]["values"][0]["message"]
|
||||||
|
assert "8663867262:AAExampleSecretPartAbCdEf123" not in msg
|
||||||
|
assert "/bot[REDACTED]/getMe" in msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_redact_token_leaves_unrelated_urls_and_strings_untouched() -> None:
|
||||||
|
event = {"extra": {"public_url": "https://gendsgn.ru/trade-in", "region": "66"}}
|
||||||
|
out = redact_telegram_bot_token(event, {})
|
||||||
|
assert out is not None
|
||||||
|
assert out["extra"]["public_url"] == "https://gendsgn.ru/trade-in"
|
||||||
|
assert out["extra"]["region"] == "66"
|
||||||
|
|
||||||
|
|
||||||
|
def test_redact_token_handles_non_dict_event() -> None:
|
||||||
|
assert redact_telegram_bot_token(None, {}) is None # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
# Голая форма `<id>:<secret>` без префикса `/bot` — так токен выглядит в локали
|
||||||
|
# `token` конструктора TelegramClient. Штатно в event не попадает
|
||||||
|
# (include_local_variables=False в tgbot_main), но редактор обязан покрывать и
|
||||||
|
# этот случай: иначе рубеж против утечки токена всего один — флаг SDK.
|
||||||
|
_LEAKED_TOKEN_BARE = "8663867262:AAFakeSecretPartForTestsOnly1234567"
|
||||||
|
|
||||||
|
|
||||||
|
def test_redacts_bare_token_without_bot_prefix() -> None:
|
||||||
|
event = {
|
||||||
|
"logentry": {"message": f"init failed for {_LEAKED_TOKEN_BARE}"},
|
||||||
|
"exception": {
|
||||||
|
"values": [{"stacktrace": {"frames": [{"vars": {"token": _LEAKED_TOKEN_BARE}}]}}]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out = redact_telegram_bot_token(event, {})
|
||||||
|
assert out is not None
|
||||||
|
assert "AAFakeSecretPartForTestsOnly1234567" not in repr(out)
|
||||||
|
frame = out["exception"]["values"][0]["stacktrace"]["frames"][0]
|
||||||
|
assert frame["vars"]["token"] == "[REDACTED]"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"benign",
|
||||||
|
[
|
||||||
|
"chat_id:12345",
|
||||||
|
"ratio 3:4",
|
||||||
|
"2026-07-16T10:00:00",
|
||||||
|
"postgresql://user:pass@postgres:5432/tradein",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_bare_token_redaction_leaves_benign_colon_strings_untouched(benign: str) -> None:
|
||||||
|
"""Голая регулярка не должна бить по любым `x:y` — иначе диагностика ослепнет."""
|
||||||
|
out = redact_telegram_bot_token({"logentry": {"message": benign}}, {})
|
||||||
|
assert out is not None
|
||||||
|
assert out["logentry"]["message"] == benign
|
||||||
|
|
||||||
|
|
||||||
|
def test_composed_before_send_scrubs_pii_and_token_together() -> None:
|
||||||
|
"""Композиция, реально используемая в `app.tgbot_main._before_send`: PII-scrub
|
||||||
|
(ключ-based) И token-redaction (regex full-text) применяются оба, не заменяя
|
||||||
|
друг друга — разные классы секретов, разные механизмы обнаружения."""
|
||||||
|
event = {
|
||||||
|
"request": {"data": {"client_phone": "+79991234567"}},
|
||||||
|
"exception": {
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"stacktrace": {
|
||||||
|
"frames": [{"vars": {"url": _LEAKED_TOKEN_URL}}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def composed_before_send(evt, hint):
|
||||||
|
scrubbed = scrub_pii_event(evt, hint)
|
||||||
|
if scrubbed is None:
|
||||||
|
return None
|
||||||
|
return redact_telegram_bot_token(scrubbed, hint)
|
||||||
|
|
||||||
|
out = composed_before_send(event, {})
|
||||||
|
assert out is not None
|
||||||
|
assert out["request"]["data"]["client_phone"] == "[REDACTED]"
|
||||||
|
frame_url = out["exception"]["values"][0]["stacktrace"]["frames"][0]["vars"]["url"]
|
||||||
|
assert "8663867262:AAExampleSecretPartAbCdEf123" not in frame_url
|
||||||
|
|
|
||||||
104
tradein-mvp/backend/tests/test_serp_city_location_urls.py
Normal file
104
tradein-mvp/backend/tests/test_serp_city_location_urls.py
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
"""Per-city SERP location URL builders (#12 fix EKB-leak в oblast city-sweep).
|
||||||
|
|
||||||
|
Проблема: avito/cian/yandex провайдеры хардкодили ЕКБ-локацию (путь /ekaterinburg/,
|
||||||
|
CIAN_EKB_REGION_ID, _EKB_RGID) мимо anchor'а oblast-города → city-sweep целевого
|
||||||
|
города (напр. Н.Тагил) фактически скрейпил ЕКБ, n_analogs=0. Фикс — новые
|
||||||
|
kw-параметры (avito: target_city_slug уже был #2487, теперь и в ПУТИ URL;
|
||||||
|
cian: city_region_id; yandex: city_rgid), все дефолт None → ЕКБ-поведение
|
||||||
|
byte-identical прежнему (back-compat critical).
|
||||||
|
|
||||||
|
No network / no DB — чистые URL-билдеры на stub-конфиге.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
from scraper_kit.providers.avito.serp import NOVOSTROYKA_SLUG, AvitoScraper
|
||||||
|
from scraper_kit.providers.cian.serp import CIAN_EKB_REGION_ID, CianScraper
|
||||||
|
from scraper_kit.providers.yandex.serp import _EKB_RGID, YandexRealtyScraper
|
||||||
|
|
||||||
|
_STUB_CFG = SimpleNamespace()
|
||||||
|
|
||||||
|
|
||||||
|
def _query(url: str) -> dict[str, list[str]]:
|
||||||
|
return parse_qs(urlparse(url).query)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Avito: city-slug в ПУТЬ URL (не только SERP-фильтр) ────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_avito_web_url_uses_target_city_slug_in_path() -> None:
|
||||||
|
s = AvitoScraper(_STUB_CFG, target_city_slug="nizhniy_tagil") # type: ignore[arg-type]
|
||||||
|
url = s._build_web_url(57.910, 59.980, radius_km=2, page=1)
|
||||||
|
assert "/nizhniy_tagil/" in url
|
||||||
|
assert "/ekaterinburg/" not in url
|
||||||
|
|
||||||
|
|
||||||
|
def test_avito_citywide_url_uses_target_city_slug_in_path() -> None:
|
||||||
|
s = AvitoScraper(_STUB_CFG, target_city_slug="kamensk-uralskiy") # type: ignore[arg-type]
|
||||||
|
url = s._build_citywide_url(page=2)
|
||||||
|
assert "/kamensk-uralskiy/" in url
|
||||||
|
assert "/ekaterinburg/" not in url
|
||||||
|
|
||||||
|
|
||||||
|
def test_avito_newbuilding_url_uses_target_city_slug_in_path() -> None:
|
||||||
|
s = AvitoScraper(_STUB_CFG, target_city_slug="verhnyaya_pyshma") # type: ignore[arg-type]
|
||||||
|
url = s._build_newbuilding_url(page=1)
|
||||||
|
assert "/verhnyaya_pyshma/" in url
|
||||||
|
assert "/ekaterinburg/" not in url
|
||||||
|
assert NOVOSTROYKA_SLUG in url
|
||||||
|
|
||||||
|
|
||||||
|
def test_avito_rooms_url_uses_target_city_slug_in_path() -> None:
|
||||||
|
s = AvitoScraper(_STUB_CFG, target_city_slug="serov") # type: ignore[arg-type]
|
||||||
|
url = s._build_rooms_url("2k", page=1)
|
||||||
|
assert "/serov/" in url
|
||||||
|
assert "/ekaterinburg/" not in url
|
||||||
|
|
||||||
|
|
||||||
|
def test_avito_urls_default_to_ekaterinburg_when_no_target_city_slug() -> None:
|
||||||
|
"""Back-compat: target_city_slug=None → путь /ekaterinburg/ (ЕКБ full_load/pipeline)."""
|
||||||
|
s = AvitoScraper(_STUB_CFG, target_city_slug=None) # type: ignore[arg-type]
|
||||||
|
assert "/ekaterinburg/" in s._build_web_url(56.84, 60.60, radius_km=1, page=1)
|
||||||
|
assert "/ekaterinburg/" in s._build_citywide_url(page=1)
|
||||||
|
assert "/ekaterinburg/" in s._build_newbuilding_url(page=1)
|
||||||
|
assert "/ekaterinburg/" in s._build_rooms_url("2k", page=1)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Cian: city_region_id → region= (subdomain НЕ трогается) ────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_cian_build_url_uses_city_region_id() -> None:
|
||||||
|
s = CianScraper(_STUB_CFG, city_region_id=4886) # type: ignore[arg-type]
|
||||||
|
url = s._build_url()
|
||||||
|
assert _query(url)["region"] == ["4886"]
|
||||||
|
assert urlparse(url).hostname == "ekb.cian.ru"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cian_build_url_defaults_to_ekb_region_when_no_city_region_id() -> None:
|
||||||
|
"""Back-compat: city_region_id=None → region=CIAN_EKB_REGION_ID (4743)."""
|
||||||
|
s = CianScraper(_STUB_CFG, city_region_id=None) # type: ignore[arg-type]
|
||||||
|
url = s._build_url()
|
||||||
|
assert _query(url)["region"] == [str(CIAN_EKB_REGION_ID)]
|
||||||
|
assert urlparse(url).hostname == "ekb.cian.ru"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Yandex: city_rgid → rgid= ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_yandex_build_url_uses_city_rgid() -> None:
|
||||||
|
s = YandexRealtyScraper(_STUB_CFG, city_rgid=328286) # type: ignore[arg-type]
|
||||||
|
url = s._build_url()
|
||||||
|
assert _query(url)["rgid"] == ["328286"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_yandex_build_url_defaults_to_ekb_rgid_when_no_city_rgid() -> None:
|
||||||
|
"""Back-compat: city_rgid=None → rgid=_EKB_RGID (559132)."""
|
||||||
|
s = YandexRealtyScraper(_STUB_CFG, city_rgid=None) # type: ignore[arg-type]
|
||||||
|
url = s._build_url()
|
||||||
|
assert _query(url)["rgid"] == [str(_EKB_RGID)]
|
||||||
|
|
@ -105,12 +105,7 @@ def test_tier_a_includes_novostroyki_when_secondary_present() -> None:
|
||||||
_row(source="cian", source_id="c2", listing_segment="novostroyki"),
|
_row(source="cian", source_id="c2", listing_segment="novostroyki"),
|
||||||
]
|
]
|
||||||
db = _db_mock(rows)
|
db = _db_mock(rows)
|
||||||
with (
|
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
||||||
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
|
|
||||||
patch.object(
|
|
||||||
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True
|
|
||||||
),
|
|
||||||
):
|
|
||||||
comps, tier = _fetch(db)
|
comps, tier = _fetch(db)
|
||||||
assert tier == "A"
|
assert tier == "A"
|
||||||
# Все 6 (4 вторички + 2 novostroyki-переуступки) учтены.
|
# Все 6 (4 вторички + 2 novostroyki-переуступки) учтены.
|
||||||
|
|
@ -135,12 +130,7 @@ def test_tier_a_excludes_novostroyki_when_primary_dominated() -> None:
|
||||||
_row(source="cian", source_id="c5", listing_segment="novostroyki"),
|
_row(source="cian", source_id="c5", listing_segment="novostroyki"),
|
||||||
]
|
]
|
||||||
db = _db_mock(rows)
|
db = _db_mock(rows)
|
||||||
with (
|
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
||||||
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
|
|
||||||
patch.object(
|
|
||||||
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True
|
|
||||||
),
|
|
||||||
):
|
|
||||||
comps, tier = _fetch(db)
|
comps, tier = _fetch(db)
|
||||||
# primary-dominated → 5 novostroyki отброшены, 1 vtorichka < min_comps → Tier A skip.
|
# primary-dominated → 5 novostroyki отброшены, 1 vtorichka < min_comps → Tier A skip.
|
||||||
assert tier is None
|
assert tier is None
|
||||||
|
|
@ -162,42 +152,13 @@ def test_tier_a_excludes_novostroyki_when_pure_primary() -> None:
|
||||||
_row(source="cian", source_id="c4", listing_segment="novostroyki"),
|
_row(source="cian", source_id="c4", listing_segment="novostroyki"),
|
||||||
]
|
]
|
||||||
db = _db_mock(rows)
|
db = _db_mock(rows)
|
||||||
with (
|
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
||||||
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
|
|
||||||
patch.object(
|
|
||||||
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True
|
|
||||||
),
|
|
||||||
):
|
|
||||||
comps, tier = _fetch(db)
|
comps, tier = _fetch(db)
|
||||||
# Все отброшены гардом → 0 comps < 4 → Tier A skip (lat/lon None → Tier D).
|
# Все отброшены гардом → 0 comps < 4 → Tier A skip (lat/lon None → Tier D).
|
||||||
assert tier is None
|
assert tier is None
|
||||||
assert comps == []
|
assert comps == []
|
||||||
|
|
||||||
|
|
||||||
def test_tier_a_flag_off_excludes_novostroyki_even_with_secondary() -> None:
|
|
||||||
"""Flag OFF → оригинальное поведение #1186: novostroyki ВСЕГДА исключены в Tier A,
|
|
||||||
даже если в доме есть вторичка. Остаются только 4 vtorichka/NULL."""
|
|
||||||
rows = [
|
|
||||||
_row(source="avito", source_id="a1", listing_segment="vtorichka"),
|
|
||||||
_row(source="avito", source_id="a2", listing_segment=None),
|
|
||||||
_row(source="yandex", source_id="y1", listing_segment="vtorichka"),
|
|
||||||
_row(source="yandex", source_id="y2", listing_segment=None),
|
|
||||||
_row(source="cian", source_id="c1", listing_segment="novostroyki"),
|
|
||||||
_row(source="cian", source_id="c2", listing_segment="novostroyki"),
|
|
||||||
]
|
|
||||||
db = _db_mock(rows)
|
|
||||||
with (
|
|
||||||
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
|
|
||||||
patch.object(
|
|
||||||
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", False
|
|
||||||
),
|
|
||||||
):
|
|
||||||
comps, tier = _fetch(db)
|
|
||||||
assert tier == "A"
|
|
||||||
# novostroyki отброшены → только 4 вторички.
|
|
||||||
assert len(comps) == 4
|
|
||||||
|
|
||||||
|
|
||||||
def test_tier_a_dedup_same_source_id_collapses() -> None:
|
def test_tier_a_dedup_same_source_id_collapses() -> None:
|
||||||
"""#1774 dedup: дубликат одного объявления (source_id=330047129, 2 active-строки —
|
"""#1774 dedup: дубликат одного объявления (source_id=330047129, 2 active-строки —
|
||||||
одна с house_id_fk, одна NULL) схлопывается в 1 comp по PRIMARY ключу
|
одна с house_id_fk, одна NULL) схлопывается в 1 comp по PRIMARY ключу
|
||||||
|
|
@ -216,12 +177,7 @@ def test_tier_a_dedup_same_source_id_collapses() -> None:
|
||||||
_row(source="cian", source_url=dup_url, source_id="330047129", listing_segment=None),
|
_row(source="cian", source_url=dup_url, source_id="330047129", listing_segment=None),
|
||||||
]
|
]
|
||||||
db = _db_mock(rows)
|
db = _db_mock(rows)
|
||||||
with (
|
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
||||||
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
|
|
||||||
patch.object(
|
|
||||||
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True
|
|
||||||
),
|
|
||||||
):
|
|
||||||
comps, tier = _fetch(db)
|
comps, tier = _fetch(db)
|
||||||
assert tier == "A"
|
assert tier == "A"
|
||||||
# 6 строк, но 2 cian-строки с одинаковым source_id → 1 comp → итого 5.
|
# 6 строк, но 2 cian-строки с одинаковым source_id → 1 comp → итого 5.
|
||||||
|
|
@ -257,12 +213,7 @@ def test_tier_a_dedup_same_source_id_different_url_collapses() -> None:
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
db = _db_mock(rows)
|
db = _db_mock(rows)
|
||||||
with (
|
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
||||||
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
|
|
||||||
patch.object(
|
|
||||||
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True
|
|
||||||
),
|
|
||||||
):
|
|
||||||
comps, tier = _fetch(db)
|
comps, tier = _fetch(db)
|
||||||
assert tier == "A"
|
assert tier == "A"
|
||||||
# source_id-primary схлопывает несмотря на разные url → 5 comps.
|
# source_id-primary схлопывает несмотря на разные url → 5 comps.
|
||||||
|
|
@ -281,12 +232,7 @@ def test_tier_a_dedup_null_url_keeps_distinct_rows() -> None:
|
||||||
_row(source="avito", source_url=None, source_id="a4", area_m2=80.0),
|
_row(source="avito", source_url=None, source_id="a4", area_m2=80.0),
|
||||||
]
|
]
|
||||||
db = _db_mock(rows)
|
db = _db_mock(rows)
|
||||||
with (
|
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
||||||
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
|
|
||||||
patch.object(
|
|
||||||
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True
|
|
||||||
),
|
|
||||||
):
|
|
||||||
comps, tier = _fetch(db)
|
comps, tier = _fetch(db)
|
||||||
assert tier == "A"
|
assert tier == "A"
|
||||||
# 4 разных лота (разные source_id/площадь) → 4 comps, ничего не схлопнуто.
|
# 4 разных лота (разные source_id/площадь) → 4 comps, ничего не схлопнуто.
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
- happy path: consent=True + валидный телефон -> 200, INSERT + commit
|
- happy path: consent=True + валидный телефон -> 200, INSERT + commit
|
||||||
- consent=False -> 422 (Literal[True] guard)
|
- consent=False -> 422 (Literal[True] guard)
|
||||||
- невалидный формат телефона -> 422
|
- невалидный формат телефона -> 422
|
||||||
|
- телефон без цифр / слишком мало цифр -> 422 (digit-guard, #2376 hardening)
|
||||||
|
- source="landing" (мёртвая воронка) -> 422 (литерал убран из схемы)
|
||||||
- estimate_id, которого нет в trade_in_estimates -> 404
|
- estimate_id, которого нет в trade_in_estimates -> 404
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -53,12 +55,15 @@ def _insert_result(lead_id: str) -> MagicMock:
|
||||||
|
|
||||||
|
|
||||||
def test_lead_happy_path(client: TestClient, db: MagicMock) -> None:
|
def test_lead_happy_path(client: TestClient, db: MagicMock) -> None:
|
||||||
|
from app.api.v1 import lead as lead_module
|
||||||
|
|
||||||
lead_id = str(uuid4())
|
lead_id = str(uuid4())
|
||||||
db.execute.return_value = _insert_result(lead_id)
|
db.execute.return_value = _insert_result(lead_id)
|
||||||
|
|
||||||
r = client.post(
|
r = client.post(
|
||||||
"/api/v1/trade-in/lead",
|
"/api/v1/trade-in/lead",
|
||||||
json={"phone": "+7 (912) 345-67-89", "consent": True},
|
json={"phone": "+7 (912) 345-67-89", "consent": True},
|
||||||
|
headers={"x-forwarded-for": "203.0.113.7, 10.0.0.1"},
|
||||||
)
|
)
|
||||||
assert r.status_code == 200, r.text
|
assert r.status_code == 200, r.text
|
||||||
body = r.json()
|
body = r.json()
|
||||||
|
|
@ -71,6 +76,27 @@ def test_lead_happy_path(client: TestClient, db: MagicMock) -> None:
|
||||||
assert params["consent"] is True
|
assert params["consent"] is True
|
||||||
assert params["source"] == "result"
|
assert params["source"] == "result"
|
||||||
assert params["estimate_id"] is None
|
assert params["estimate_id"] is None
|
||||||
|
# 152-ФЗ proof-of-consent (migration 182): client_ip / policy version / text
|
||||||
|
# snapshot must reach the INSERT params, not just the audit log (#2497 TODO).
|
||||||
|
assert params["client_ip"] == "203.0.113.7" # first hop of X-Forwarded-For
|
||||||
|
assert params["consent_policy_version"] == lead_module._CONSENT_POLICY_VERSION
|
||||||
|
assert params["consent_text_snapshot"] == lead_module._CONSENT_TEXT_SNAPSHOT
|
||||||
|
|
||||||
|
|
||||||
|
def test_lead_client_ip_falls_back_to_peer_when_no_xff(client: TestClient, db: MagicMock) -> None:
|
||||||
|
# No X-Forwarded-For header -> falls back to request.client.host (TestClient
|
||||||
|
# reports "testclient"), still persisted rather than silently None.
|
||||||
|
lead_id = str(uuid4())
|
||||||
|
db.execute.return_value = _insert_result(lead_id)
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
"/api/v1/trade-in/lead",
|
||||||
|
json={"phone": "+79123456789", "consent": True},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
params = db.execute.call_args.args[1]
|
||||||
|
assert params["client_ip"] == "testclient"
|
||||||
|
|
||||||
|
|
||||||
def test_lead_consent_false_422(client: TestClient, db: MagicMock) -> None:
|
def test_lead_consent_false_422(client: TestClient, db: MagicMock) -> None:
|
||||||
|
|
@ -121,10 +147,41 @@ def test_lead_with_known_estimate_id_200(client: TestClient, db: MagicMock) -> N
|
||||||
"phone": "+79123456789",
|
"phone": "+79123456789",
|
||||||
"consent": True,
|
"consent": True,
|
||||||
"estimate_id": estimate_id,
|
"estimate_id": estimate_id,
|
||||||
"source": "landing",
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert r.status_code == 200, r.text
|
assert r.status_code == 200, r.text
|
||||||
params = db.execute.call_args.args[1]
|
params = db.execute.call_args.args[1]
|
||||||
assert params["estimate_id"] == estimate_id
|
assert params["estimate_id"] == estimate_id
|
||||||
assert params["source"] == "landing"
|
assert params["source"] == "result"
|
||||||
|
|
||||||
|
|
||||||
|
def test_lead_digit_free_phone_422(client: TestClient, db: MagicMock) -> None:
|
||||||
|
# "(()) -- .." проходит regex-маску (только +/скобки/дефисы/точки/пробелы),
|
||||||
|
# но содержит 0 цифр -> должно отклоняться digit-валидатором.
|
||||||
|
r = client.post(
|
||||||
|
"/api/v1/trade-in/lead",
|
||||||
|
json={"phone": "(()) -- ..", "consent": True},
|
||||||
|
)
|
||||||
|
assert r.status_code == 422, r.text
|
||||||
|
assert not db.execute.called
|
||||||
|
|
||||||
|
|
||||||
|
def test_lead_too_few_digits_phone_422(client: TestClient, db: MagicMock) -> None:
|
||||||
|
# 6 цифр < минимума 10 -> 422 (маску проходит по длине, digit-guard режет).
|
||||||
|
r = client.post(
|
||||||
|
"/api/v1/trade-in/lead",
|
||||||
|
json={"phone": "123-456", "consent": True},
|
||||||
|
)
|
||||||
|
assert r.status_code == 422, r.text
|
||||||
|
assert not db.execute.called
|
||||||
|
|
||||||
|
|
||||||
|
def test_lead_source_landing_rejected_422(client: TestClient, db: MagicMock) -> None:
|
||||||
|
# "landing"-воронка недостижима (rbac_guard закрывает /lead) — литерал убран
|
||||||
|
# из схемы, поэтому явный source="landing" теперь 422.
|
||||||
|
r = client.post(
|
||||||
|
"/api/v1/trade-in/lead",
|
||||||
|
json={"phone": "+79123456789", "consent": True, "source": "landing"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 422, r.text
|
||||||
|
assert not db.execute.called
|
||||||
|
|
|
||||||
292
tradein-mvp/backend/tests/test_user_events.py
Normal file
292
tradein-mvp/backend/tests/test_user_events.py
Normal file
|
|
@ -0,0 +1,292 @@
|
||||||
|
"""Tests for app.services.user_events — record_event / schedule_event / should_log_login.
|
||||||
|
|
||||||
|
Coverage:
|
||||||
|
(a) record_event inserts exactly one row via a mocked SessionLocal, commits, and the
|
||||||
|
SQL uses CAST(:x AS type) (never the psycopg-v3-broken `:x::type` form).
|
||||||
|
(b) record_event swallows exceptions — a DB failure (connect or execute) never raises.
|
||||||
|
(c) schedule_event in a sync context (no running event loop) calls record_event inline.
|
||||||
|
(d) schedule_event in an async context schedules a non-blocking background task that
|
||||||
|
still calls through, and a failure inside it is logged, not raised.
|
||||||
|
(e) should_log_login: True once per (user, ip, ua, day), False on repeat; independent
|
||||||
|
per user / ip / user_agent; prunes the in-process set past capacity.
|
||||||
|
(f) optional real-DB round trip against app.core.db.SessionLocal (the exact session
|
||||||
|
record_event uses) — self-skips without a reachable, non-placeholder Postgres,
|
||||||
|
mirroring tests/test_house_dedup_merge.py's live-DB pattern.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
# psycopg v3 driver required; stub DATABASE_URL before any app import (mirrors other tests).
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services import user_events as ue
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fakes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResult:
|
||||||
|
def fetchone(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeSession:
|
||||||
|
"""SessionLocal() stand-in supporting the `with SessionLocal() as db:` pattern."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.executed: list[tuple[str, dict[str, Any] | None]] = []
|
||||||
|
self.committed = False
|
||||||
|
|
||||||
|
def execute(self, stmt: object, params: dict[str, Any] | None = None) -> _FakeResult:
|
||||||
|
self.executed.append((str(stmt), params))
|
||||||
|
return _FakeResult()
|
||||||
|
|
||||||
|
def commit(self) -> None:
|
||||||
|
self.committed = True
|
||||||
|
|
||||||
|
def __enter__(self) -> _FakeSession:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc: object) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# (a) record_event — successful insert
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_event_inserts_and_commits() -> None:
|
||||||
|
fake = _FakeSession()
|
||||||
|
with patch.object(ue, "SessionLocal", return_value=fake):
|
||||||
|
ue.record_event(
|
||||||
|
event_type="estimate_request",
|
||||||
|
username="user1",
|
||||||
|
ip="1.2.3.4",
|
||||||
|
user_agent="pytest-agent",
|
||||||
|
path="/api/v1/trade-in/estimate",
|
||||||
|
method="POST",
|
||||||
|
estimate_id="11111111-1111-1111-1111-111111111111",
|
||||||
|
payload={"address": "ул. Ленина, 1", "area_m2": 50.0, "rooms": 2},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert fake.committed is True
|
||||||
|
assert len(fake.executed) == 1
|
||||||
|
sql, params = fake.executed[0]
|
||||||
|
assert params is not None
|
||||||
|
assert "INSERT INTO user_events" in sql
|
||||||
|
assert params["event_type"] == "estimate_request"
|
||||||
|
assert params["username"] == "user1"
|
||||||
|
assert params["ip"] == "1.2.3.4"
|
||||||
|
assert params["estimate_id"] == "11111111-1111-1111-1111-111111111111"
|
||||||
|
# payload сериализован в JSON-строку (CAST(:payload AS jsonb) на стороне SQL).
|
||||||
|
assert isinstance(params["payload"], str)
|
||||||
|
assert "Ленина" in params["payload"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_event_cast_not_doublecolon() -> None:
|
||||||
|
"""CAST(:x AS type) — НИКОГДА `:x::type` (psycopg v3 trap, .claude/rules/backend.md)."""
|
||||||
|
fake = _FakeSession()
|
||||||
|
with patch.object(ue, "SessionLocal", return_value=fake):
|
||||||
|
ue.record_event(event_type="login", username="user1")
|
||||||
|
|
||||||
|
sql, _ = fake.executed[0]
|
||||||
|
assert not re.search(r":\w+::\w", sql)
|
||||||
|
assert "CAST(:ip AS inet)" in sql
|
||||||
|
assert "CAST(:estimate_id AS uuid)" in sql
|
||||||
|
assert "CAST(:payload AS jsonb)" in sql
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_event_default_payload_is_empty_dict() -> None:
|
||||||
|
fake = _FakeSession()
|
||||||
|
with patch.object(ue, "SessionLocal", return_value=fake):
|
||||||
|
ue.record_event(event_type="login", username="user2")
|
||||||
|
|
||||||
|
_, params = fake.executed[0]
|
||||||
|
assert params is not None
|
||||||
|
assert params["payload"] == "{}"
|
||||||
|
assert params["ip"] is None
|
||||||
|
assert params["estimate_id"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# (b) record_event never raises
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_event_swallows_session_open_errors() -> None:
|
||||||
|
class _BrokenSession:
|
||||||
|
def __enter__(self) -> _BrokenSession:
|
||||||
|
raise RuntimeError("db unreachable")
|
||||||
|
|
||||||
|
def __exit__(self, *exc: object) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
with patch.object(ue, "SessionLocal", return_value=_BrokenSession()):
|
||||||
|
# Should NOT raise.
|
||||||
|
ue.record_event(event_type="login", username="user1")
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_event_swallows_execute_errors() -> None:
|
||||||
|
class _ExplodingSession(_FakeSession):
|
||||||
|
def execute(self, stmt: object, params: dict[str, Any] | None = None) -> _FakeResult:
|
||||||
|
raise RuntimeError("syntax error at or near ':'")
|
||||||
|
|
||||||
|
with patch.object(ue, "SessionLocal", return_value=_ExplodingSession()):
|
||||||
|
ue.record_event(event_type="login", username="user1")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# (c)/(d) schedule_event
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_event_sync_context_calls_record_event_inline() -> None:
|
||||||
|
"""No running loop (plain sync call, e.g. from a script/test) → inline call."""
|
||||||
|
with patch.object(ue, "record_event") as mock_record:
|
||||||
|
ue.schedule_event(event_type="login", username="user1", ip="9.9.9.9")
|
||||||
|
|
||||||
|
mock_record.assert_called_once_with(event_type="login", username="user1", ip="9.9.9.9")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_schedule_event_async_context_is_non_blocking_and_calls_through() -> None:
|
||||||
|
"""Running loop present → backgrounded via asyncio.to_thread; does not block the
|
||||||
|
calling coroutine and eventually calls record_event."""
|
||||||
|
with patch.object(ue, "record_event") as mock_record:
|
||||||
|
ue.schedule_event(event_type="api_request", username="user1", path="/api/v1/x")
|
||||||
|
# schedule_event must return immediately without awaiting the background task.
|
||||||
|
# Give the scheduled task a chance to run before asserting.
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
mock_record.assert_called_once_with(
|
||||||
|
event_type="api_request", username="user1", path="/api/v1/x"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_schedule_event_background_failure_is_logged_not_raised() -> None:
|
||||||
|
"""A record_event failure inside the background task must not propagate/crash the
|
||||||
|
caller — the done-callback swallows it (logs a warning)."""
|
||||||
|
with patch.object(ue, "record_event", side_effect=RuntimeError("boom")):
|
||||||
|
# Must not raise synchronously (the task runs in the background).
|
||||||
|
ue.schedule_event(event_type="login", username="user1")
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
# Reaching here without an unhandled-exception bubbling up confirms the
|
||||||
|
# done-callback caught it.
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# (e) should_log_login dedup
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_log_login_true_once_then_false_same_day() -> None:
|
||||||
|
ue._LOGIN_DEDUP_SEEN.clear()
|
||||||
|
assert ue.should_log_login("alice", "1.1.1.1", "chrome") is True
|
||||||
|
assert ue.should_log_login("alice", "1.1.1.1", "chrome") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_log_login_independent_per_ip() -> None:
|
||||||
|
ue._LOGIN_DEDUP_SEEN.clear()
|
||||||
|
assert ue.should_log_login("alice", "1.1.1.1", "chrome") is True
|
||||||
|
assert ue.should_log_login("alice", "2.2.2.2", "chrome") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_log_login_independent_per_user() -> None:
|
||||||
|
ue._LOGIN_DEDUP_SEEN.clear()
|
||||||
|
assert ue.should_log_login("alice", "1.1.1.1", "chrome") is True
|
||||||
|
assert ue.should_log_login("bob", "1.1.1.1", "chrome") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_log_login_independent_per_user_agent() -> None:
|
||||||
|
ue._LOGIN_DEDUP_SEEN.clear()
|
||||||
|
assert ue.should_log_login("alice", "1.1.1.1", "chrome") is True
|
||||||
|
assert ue.should_log_login("alice", "1.1.1.1", "firefox") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_log_login_none_ip_and_ua_are_valid_keys() -> None:
|
||||||
|
ue._LOGIN_DEDUP_SEEN.clear()
|
||||||
|
assert ue.should_log_login("alice", None, None) is True
|
||||||
|
assert ue.should_log_login("alice", None, None) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_log_login_prunes_when_over_capacity() -> None:
|
||||||
|
ue._LOGIN_DEDUP_SEEN.clear()
|
||||||
|
for i in range(ue._LOGIN_DEDUP_MAX):
|
||||||
|
ue._LOGIN_DEDUP_SEEN.add(f"filler-{i}")
|
||||||
|
assert len(ue._LOGIN_DEDUP_SEEN) == ue._LOGIN_DEDUP_MAX
|
||||||
|
|
||||||
|
# Next call must prune (clear the whole set) before adding, not grow unbounded.
|
||||||
|
assert ue.should_log_login("alice", "1.1.1.1", "chrome") is True
|
||||||
|
assert len(ue._LOGIN_DEDUP_SEEN) == 1
|
||||||
|
ue._LOGIN_DEDUP_SEEN.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# (f) optional real-DB round trip (self-skips without a reachable test DB)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _db_reachable() -> bool:
|
||||||
|
"""True if app.core.db's engine (the one record_event actually uses) points at a
|
||||||
|
real, non-placeholder Postgres and is reachable — mirrors the live-DB skip pattern
|
||||||
|
in tests/test_house_dedup_merge.py."""
|
||||||
|
dsn = os.environ.get("DATABASE_URL", "")
|
||||||
|
if not dsn or "localhost:5432/test" in dsn:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
from sqlalchemy import text as _t
|
||||||
|
|
||||||
|
from app.core.db import engine
|
||||||
|
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(_t("SELECT 1"))
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not _db_reachable(), reason="no reachable Postgres test DB")
|
||||||
|
def test_real_record_event_inserts_row() -> None:
|
||||||
|
"""End-to-end on the real DB record_event() is bound to (app.core.db.SessionLocal):
|
||||||
|
persists exactly the given fields and is queryable back."""
|
||||||
|
from sqlalchemy import text as _t
|
||||||
|
|
||||||
|
from app.core.db import SessionLocal
|
||||||
|
|
||||||
|
ue.record_event(
|
||||||
|
event_type="pytest_probe",
|
||||||
|
username="pytest_user_events_test",
|
||||||
|
ip="203.0.113.7",
|
||||||
|
user_agent="pytest-real-db",
|
||||||
|
path="/api/v1/trade-in/estimate",
|
||||||
|
method="POST",
|
||||||
|
payload={"probe": True},
|
||||||
|
)
|
||||||
|
with SessionLocal() as db:
|
||||||
|
try:
|
||||||
|
row = db.execute(
|
||||||
|
_t(
|
||||||
|
"SELECT event_type, username, ip_address, user_agent, payload "
|
||||||
|
"FROM user_events WHERE username = :u ORDER BY id DESC LIMIT 1"
|
||||||
|
),
|
||||||
|
{"u": "pytest_user_events_test"},
|
||||||
|
).fetchone()
|
||||||
|
assert row is not None
|
||||||
|
assert row.event_type == "pytest_probe"
|
||||||
|
assert str(row.ip_address) == "203.0.113.7"
|
||||||
|
assert row.payload == {"probe": True}
|
||||||
|
finally:
|
||||||
|
db.execute(
|
||||||
|
_t("DELETE FROM user_events WHERE username = :u"),
|
||||||
|
{"u": "pytest_user_events_test"},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
@ -27,7 +27,8 @@ docker exec "$DST_PG" psql -U tradein -d tradein -v ON_ERROR_STOP=on -c "
|
||||||
-- запуск упал между CREATE и финальным DROP под старым кодом (без source_id).
|
-- запуск упал между CREATE и финальным DROP под старым кодом (без source_id).
|
||||||
DROP TABLE IF EXISTS deals_ros_staging;
|
DROP TABLE IF EXISTS deals_ros_staging;
|
||||||
CREATE TABLE deals_ros_staging (
|
CREATE TABLE deals_ros_staging (
|
||||||
dedup_hash text PRIMARY KEY, source_id text, address text, rooms int, area_m2 numeric,
|
dedup_hash text PRIMARY KEY, source_id text, address text, region_code int, city text,
|
||||||
|
rooms int, area_m2 numeric,
|
||||||
floor int, year_built int, price_rub bigint, price_per_m2 int, deal_date date
|
floor int, year_built int, price_rub bigint, price_per_m2 int, deal_date date
|
||||||
);
|
);
|
||||||
"
|
"
|
||||||
|
|
@ -38,7 +39,9 @@ docker exec "$SRC_PG" psql -U "$SRC_USER" -d "$SRC_DB" -v ON_ERROR_STOP=on -c "
|
||||||
SELECT
|
SELECT
|
||||||
'ros:dkp:' || id::text AS dedup_hash,
|
'ros:dkp:' || id::text AS dedup_hash,
|
||||||
id::text AS source_id,
|
id::text AS source_id,
|
||||||
'Екатеринбург, ' || trim(street) AS address,
|
trim(city) || ', ' || trim(street) AS address,
|
||||||
|
region_code AS region_code,
|
||||||
|
trim(city) AS city,
|
||||||
CASE
|
CASE
|
||||||
WHEN area < 30 THEN 0 WHEN area < 44 THEN 1
|
WHEN area < 30 THEN 0 WHEN area < 44 THEN 1
|
||||||
WHEN area < 62 THEN 2 WHEN area < 85 THEN 3
|
WHEN area < 62 THEN 2 WHEN area < 85 THEN 3
|
||||||
|
|
@ -51,7 +54,7 @@ docker exec "$SRC_PG" psql -U "$SRC_USER" -d "$SRC_DB" -v ON_ERROR_STOP=on -c "
|
||||||
period_start_date AS deal_date
|
period_start_date AS deal_date
|
||||||
FROM rosreestr_deals
|
FROM rosreestr_deals
|
||||||
WHERE region_code = 66
|
WHERE region_code = 66
|
||||||
AND city ILIKE '%катеринбург%'
|
AND city IS NOT NULL AND trim(city) <> ''
|
||||||
AND realestate_type_code = '002001003000'
|
AND realestate_type_code = '002001003000'
|
||||||
AND area BETWEEN 18 AND 200
|
AND area BETWEEN 18 AND 200
|
||||||
AND deal_price BETWEEN 1000000 AND 100000000
|
AND deal_price BETWEEN 1000000 AND 100000000
|
||||||
|
|
@ -71,10 +74,10 @@ docker exec "$DST_PG" psql -U tradein -d tradein -v ON_ERROR_STOP=on -c "
|
||||||
DELETE FROM deals WHERE address = 'Екатеринбург, реальная сделка';
|
DELETE FROM deals WHERE address = 'Екатеринбург, реальная сделка';
|
||||||
|
|
||||||
INSERT INTO deals (
|
INSERT INTO deals (
|
||||||
source, dedup_hash, source_id, address, rooms, area_m2, floor,
|
source, dedup_hash, source_id, address, region_code, city, rooms, area_m2, floor,
|
||||||
year_built, price_rub, price_per_m2, deal_date
|
year_built, price_rub, price_per_m2, deal_date
|
||||||
)
|
)
|
||||||
SELECT 'rosreestr', dedup_hash, source_id, address, rooms, area_m2, floor,
|
SELECT 'rosreestr', dedup_hash, source_id, address, region_code, city, rooms, area_m2, floor,
|
||||||
year_built, price_rub, price_per_m2, deal_date
|
year_built, price_rub, price_per_m2, deal_date
|
||||||
FROM deals_ros_staging
|
FROM deals_ros_staging
|
||||||
ON CONFLICT (dedup_hash) DO NOTHING;
|
ON CONFLICT (dedup_hash) DO NOTHING;
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,49 @@ services:
|
||||||
networks:
|
networks:
|
||||||
- tradein-net
|
- tradein-net
|
||||||
|
|
||||||
|
# Telegram support-bot bridge: long-polling worker (asyncio/httpx), тот же
|
||||||
|
# backend-образ что и scraper/backend (один Dockerfile, другая команда).
|
||||||
|
# Ничего не слушает (long-polling исходящий к Telegram API) → без expose/ports.
|
||||||
|
# НЕ подписан на gendesign_shared — не проксируется Caddy, только tradein-net
|
||||||
|
# (нужен доступ к postgres). TELEGRAM_* переменные — ТОЛЬКО из backend/.env.runtime
|
||||||
|
# (env_file ниже); пусто/нет токена = бот молча не стартует (см. tgbot_main.py).
|
||||||
|
# ⚠️ НЕ дублировать TELEGRAM_* в блоке environment: (см. предупреждение у backend
|
||||||
|
# выше про environment: перекрывающий env_file при пустой host-env).
|
||||||
|
tgbot:
|
||||||
|
image: ghcr.io/lekss361/gendesign-tradein-backend:${IMAGE_TAG:-latest}
|
||||||
|
container_name: tradein-tgbot
|
||||||
|
# mem: нет прод-замера (новый сервис, 2026-07-16) — консервативный старт по
|
||||||
|
# аналогии с профилем scraper (лёгкая asyncio-оркестрация, без headless-браузера
|
||||||
|
# и без больших payload'ов). Пересмотреть после первого docker stats на проде.
|
||||||
|
mem_limit: 256m
|
||||||
|
memswap_limit: 256m
|
||||||
|
logging: *default-logging
|
||||||
|
command: ["python", "-m", "app.tgbot_main"]
|
||||||
|
env_file:
|
||||||
|
- path: ./backend/.env.runtime
|
||||||
|
required: false
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@postgres:5432/tradein"
|
||||||
|
ENVIRONMENT: "production"
|
||||||
|
GLITCHTIP_DSN: "${GLITCHTIP_DSN:-}"
|
||||||
|
# Этот процесс — не scheduler_main; false на всякий случай, если общий
|
||||||
|
# Settings()-объект образа где-то читает флаг при импорте (defense-in-depth,
|
||||||
|
# аналогично backend). tgbot_main.py не должен зависеть от этого значения.
|
||||||
|
SCHEDULER_ENABLE: "false"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
# unless-stopped (как и весь остальной стек): только always/unless-stopped
|
||||||
|
# гарантируют автозапуск после ребута VPS. Kill-switch при пустом
|
||||||
|
# TELEGRAM_BOT_TOKEN реализован idle-блокировкой в tgbot_main.py, а НЕ exit(0) —
|
||||||
|
# иначе эта политика дала бы рестарт-луп (перезапускает независимо от кода).
|
||||||
|
restart: unless-stopped
|
||||||
|
# >_DRAIN_TIMEOUT_S (100s) в tgbot_main.py, иначе docker убьёт по дефолтным 10с
|
||||||
|
# раньше, чем докрутится long-poll (до 30с), и кооперативный drain не сработает.
|
||||||
|
stop_grace_period: 120s
|
||||||
|
networks:
|
||||||
|
- tradein-net
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image: ghcr.io/lekss361/gendesign-tradein-frontend:${IMAGE_TAG:-latest}
|
image: ghcr.io/lekss361/gendesign-tradein-frontend:${IMAGE_TAG:-latest}
|
||||||
container_name: tradein-frontend
|
container_name: tradein-frontend
|
||||||
|
|
|
||||||
252
tradein-mvp/frontend/src/app/admin/analytics/page.tsx
Normal file
252
tradein-mvp/frontend/src/app/admin/analytics/page.tsx
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Активность (Feature 3) — поведенческая аналитика по user_events: сколько
|
||||||
|
* событий, кто активен, что чаще ищут/дёргают. Названо «Активность», не
|
||||||
|
* «Аналитика» — этот label уже занят market-analytics overlay (АНАЛИТИКА ДОМА,
|
||||||
|
* v2/AnalyticsView.tsx) внутри самой оценки, здесь другой домен (юзер-активность).
|
||||||
|
*
|
||||||
|
* Admin-only — тот же паттерн gating, что и /admin/audit (см. комментарий там).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
CartesianGrid,
|
||||||
|
Line,
|
||||||
|
LineChart,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
|
||||||
|
import "@/components/trade-in/trade-in.css";
|
||||||
|
import { NoAccessScreen } from "@/components/auth/NoAccessScreen";
|
||||||
|
import { Topbar } from "@/components/trade-in/Topbar";
|
||||||
|
import { HTTPError } from "@/lib/api";
|
||||||
|
import { useUserAnalytics } from "@/lib/admin-audit-api";
|
||||||
|
|
||||||
|
const DAYS_OPTIONS = [7, 30, 90] as const;
|
||||||
|
type DaysOption = (typeof DAYS_OPTIONS)[number];
|
||||||
|
|
||||||
|
function formatDay(iso: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" });
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(iso: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleString("ru-RU", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiCard({ label, value }: { label: string; value: number }) {
|
||||||
|
return (
|
||||||
|
<div className="admin-kpi-card">
|
||||||
|
<div className="admin-kpi-label">{label}</div>
|
||||||
|
<div className="admin-kpi-value">{value.toLocaleString("ru-RU")}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DailyChartPoint {
|
||||||
|
day: string;
|
||||||
|
events: number;
|
||||||
|
users: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminAnalyticsPage() {
|
||||||
|
const [days, setDays] = useState<DaysOption>(30);
|
||||||
|
const analyticsQ = useUserAnalytics(days);
|
||||||
|
|
||||||
|
const chartData = useMemo<DailyChartPoint[]>(
|
||||||
|
() => (analyticsQ.data?.daily ?? []).map((p) => ({ ...p, label: formatDay(p.day) })),
|
||||||
|
[analyticsQ.data],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (analyticsQ.error instanceof HTTPError && analyticsQ.error.status === 403) {
|
||||||
|
return <NoAccessScreen variant="user" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Topbar active="analytics" />
|
||||||
|
<main className="page scraper-page" style={{ maxWidth: 1180 }}>
|
||||||
|
<h1 className="scraper-h1">Активность</h1>
|
||||||
|
<p className="scraper-subtitle">
|
||||||
|
Поведенческая аналитика по всем аккаунтам: события, активные
|
||||||
|
пользователи, частые поиски и API-пути.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="admin-days-filter">
|
||||||
|
{DAYS_OPTIONS.map((d) => (
|
||||||
|
<button
|
||||||
|
key={d}
|
||||||
|
type="button"
|
||||||
|
className={days === d ? "is-active" : undefined}
|
||||||
|
onClick={() => setDays(d)}
|
||||||
|
>
|
||||||
|
{d} дн.
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{analyticsQ.isPending && <p className="scraper-hint">Загрузка…</p>}
|
||||||
|
{analyticsQ.isError && (
|
||||||
|
<p className="scraper-result scraper-result--error">
|
||||||
|
Ошибка загрузки: {analyticsQ.error.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{analyticsQ.data && (
|
||||||
|
<>
|
||||||
|
<div className="admin-kpi-grid">
|
||||||
|
<KpiCard label="Всего событий" value={analyticsQ.data.summary.total_events} />
|
||||||
|
<KpiCard
|
||||||
|
label="Уникальных пользователей"
|
||||||
|
value={analyticsQ.data.summary.distinct_users}
|
||||||
|
/>
|
||||||
|
<KpiCard label="События за 24ч" value={analyticsQ.data.summary.events_last_24h} />
|
||||||
|
<KpiCard
|
||||||
|
label="Активны за 24ч"
|
||||||
|
value={analyticsQ.data.summary.active_users_last_24h}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="scraper-section">
|
||||||
|
<h2>Динамика по дням</h2>
|
||||||
|
<p className="scraper-hint">События и уникальные пользователи в день.</p>
|
||||||
|
{chartData.length < 2 ? (
|
||||||
|
<p className="scraper-hint" style={{ margin: 0 }}>
|
||||||
|
Недостаточно данных для графика — нужно минимум 2 дня наблюдений.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div aria-hidden="true" style={{ width: "100%", height: 260 }}>
|
||||||
|
<ResponsiveContainer>
|
||||||
|
<LineChart data={chartData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
|
||||||
|
<CartesianGrid stroke="var(--border-card, #e6e8ec)" strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="label" tick={{ fontSize: 11 }} />
|
||||||
|
<YAxis tick={{ fontSize: 11 }} allowDecimals={false} />
|
||||||
|
<Tooltip labelFormatter={(label: string) => label} />
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="events"
|
||||||
|
stroke="var(--accent, #1d4ed8)"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 2 }}
|
||||||
|
name="События"
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="users"
|
||||||
|
stroke="var(--success, #0a7a3a)"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 2 }}
|
||||||
|
name="Пользователи"
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="admin-two-col">
|
||||||
|
<section className="scraper-section">
|
||||||
|
<h2>Топ поисковых адресов</h2>
|
||||||
|
{analyticsQ.data.top_searches.length === 0 ? (
|
||||||
|
<p className="scraper-hint" style={{ margin: 0 }}>
|
||||||
|
Пока нет данных.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<table className="runs-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Адрес</th>
|
||||||
|
<th scope="col">Поисков</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{analyticsQ.data.top_searches.map((row, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td>{row.address ?? "—"}</td>
|
||||||
|
<td>{row.n}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="scraper-section">
|
||||||
|
<h2>Топ API-путей</h2>
|
||||||
|
{analyticsQ.data.top_paths.length === 0 ? (
|
||||||
|
<p className="scraper-hint" style={{ margin: 0 }}>
|
||||||
|
Пока нет данных.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<table className="runs-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Путь</th>
|
||||||
|
<th scope="col">Запросов</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{analyticsQ.data.top_paths.map((row, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td>{row.path ?? "—"}</td>
|
||||||
|
<td>{row.n}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="scraper-section">
|
||||||
|
<h2>Активность по аккаунтам</h2>
|
||||||
|
{analyticsQ.data.by_account.length === 0 ? (
|
||||||
|
<p className="scraper-hint" style={{ margin: 0 }}>
|
||||||
|
Пока нет данных.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<table className="runs-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Аккаунт</th>
|
||||||
|
<th scope="col">События</th>
|
||||||
|
<th scope="col">Поиски</th>
|
||||||
|
<th scope="col">Последняя активность</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{analyticsQ.data.by_account.map((row) => (
|
||||||
|
<tr key={row.username}>
|
||||||
|
<td>{row.username}</td>
|
||||||
|
<td>{row.events}</td>
|
||||||
|
<td>{row.searches}</td>
|
||||||
|
<td className="run-muted">{formatDateTime(row.last_seen)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue