fix(okn): expand registry coverage + load detail-card name/address (#1159)
okn_objects_sync собирал 428 ОКН из ~798 ЕГРОКН и сохранял raw_props={id}
без названия/адреса — analyze показывал 'ОКН рядом' без указания КАКОГО.
Coverage: расширил _ADDRESS_PASSES с 2 до 11 подстрок (Екатеринбург,
г. Екатеринбург, 7 районов ЕКБ, 2 формы Свердловской обл.). _PASSES =
декартово (address × category). first-wins дедуп сохраняет высшую категорию.
Detail: fetch_okn_detail(source_id) — GET Show/Show?id=, BeautifulSoup
парсит HTML (dl/dt/dd и tr/td), извлекает name, address, protection_category,
object_kind, dating. _enrich_with_detail сливает в raw_props с graceful
failover (cap=1500, exception per-object не валит прогон).
Lookup: parcel_okn_objects возвращает name/address из raw_props->>'name'.
Existing ird_analyze тесты не сломаны.
49 okn-related тестов passed, ruff clean.
Closes #1159
This commit is contained in:
parent
2e428405d2
commit
c3c6e2cef1
5 changed files with 808 additions and 177 deletions
|
|
@ -1,15 +1,22 @@
|
|||
"""Клиент АИС ЕГРКН Минкультуры — поиск ОКН-объектов на карте (#1066).
|
||||
"""Клиент АИС ЕГРКН Минкультуры — поиск ОКН-объектов на карте + detail-card (#1066, #1159).
|
||||
|
||||
Источник:
|
||||
- list: ``POST https://okn-mk.mkrf.ru/Maps/searchMap`` (Content-Type: form-urlencoded).
|
||||
- detail: ``GET https://okn-mk.mkrf.ru/Show/Show?id={source_id}`` (HTML-карточка).
|
||||
|
||||
Источник: https://okn-mk.mkrf.ru/Maps/searchMap
|
||||
Метод: POST, Content-Type: application/x-www-form-urlencoded
|
||||
Аутентификация: не требуется (открытый endpoint).
|
||||
|
||||
Ответ: GeoJSON FeatureCollection.
|
||||
Каждая фича: {id, geometry: {type: "Point", coordinates: [lat, lon]}, properties: {...}}.
|
||||
Ответ searchMap: GeoJSON FeatureCollection.
|
||||
Каждая фича: {id, geometry: {type: "Point", coordinates: [lat, lon]}, properties: {id}}.
|
||||
|
||||
ВНИМАНИЕ: ЕГРКН использует Яндекс-порядок координат [lat, lon], НЕ [lon, lat].
|
||||
Для PostGIS: ST_MakePoint(coordinates[1], coordinates[0]) → (lon, lat).
|
||||
|
||||
searchMap отдаёт в properties **только {id}** — название/адрес/датировка приходят
|
||||
отдельным detail-endpoint'ом ``Show/Show?id=`` (HTML-карточка ОКН с метаданными).
|
||||
Парсим название/адрес/категорию охраны через BeautifulSoup, ошибки detail-парсинга
|
||||
не валят прогон (raw_props сохраняется без detail-полей, баг #1159 acceptance 2).
|
||||
|
||||
TLS: mkrf.ru использует сертификат, верификация по умолчанию включена.
|
||||
При SSLError автоматический fallback на verify=False (аналогично ekburg_permits #242).
|
||||
"""
|
||||
|
|
@ -17,17 +24,23 @@ TLS: mkrf.ru использует сертификат, верификация
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import urllib.parse
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SEARCHMAP_URL = "https://okn-mk.mkrf.ru/Maps/searchMap"
|
||||
_DETAIL_URL = "https://okn-mk.mkrf.ru/Show/Show"
|
||||
|
||||
# Таймаут на один HTTP-запрос к ЕГРКН (секунды)
|
||||
# Таймаут на один HTTP-запрос к ЕГРКН (секунды).
|
||||
# Detail-вызов короче (10 s) — карточки лёгкие, длинный timeout растягивал бы прогон
|
||||
# на сотни секунд при ~700 объектах.
|
||||
_REQUEST_TIMEOUT = 30.0
|
||||
_DETAIL_TIMEOUT = 10.0
|
||||
|
||||
# Browser headers -- required: without User-Agent the server returns 403.
|
||||
# Content-Type включён явно: при content= (raw bytes/str) httpx не выставляет его
|
||||
|
|
@ -43,6 +56,15 @@ _HEADERS: dict[str, str] = {
|
|||
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
}
|
||||
|
||||
# Detail-карточка отдаёт HTML, не JSON — Content-Type form-urlencoded убираем,
|
||||
# X-Requested-With оставляем для совместимости (на случай SSR-условного рендера).
|
||||
_DETAIL_HEADERS: dict[str, str] = {
|
||||
"User-Agent": _HEADERS["User-Agent"],
|
||||
"Referer": "https://okn-mk.mkrf.ru/Maps",
|
||||
"Accept": "text/html,application/xhtml+xml",
|
||||
"Accept-Language": "ru-RU,ru;q=0.9",
|
||||
}
|
||||
|
||||
|
||||
def _build_form_data(address: str, category_type: int) -> list[tuple[str, str]]:
|
||||
"""Построить form-data в формате jQuery serializeArray для поиска по карте.
|
||||
|
|
@ -68,7 +90,8 @@ def fetch_okn_points(address: str, category_type: int) -> list[dict[str, Any]]:
|
|||
Returns:
|
||||
Список feature-dict'ов из GeoJSON FeatureCollection.
|
||||
Каждый dict содержит поля: id, geometry (с coordinates=[lat, lon]),
|
||||
properties (сырые свойства из ЕГРКН, часть полей требует авторизации).
|
||||
properties (обычно {id} — название/адрес/датировку нужно догружать
|
||||
через ``fetch_okn_detail``).
|
||||
Пустой список при ошибке или пустом ответе.
|
||||
|
||||
Note:
|
||||
|
|
@ -144,4 +167,146 @@ def _do_request(
|
|||
return features
|
||||
|
||||
|
||||
__all__ = ["fetch_okn_points"]
|
||||
# ── Detail-карточка ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
# Поля карточки ЕГРКН: «Наименование», «Адрес», «Категория историко-культурного значения»,
|
||||
# «Вид объекта», «Датировка», «Документ о постановке на гос. охрану», «Регистрационный номер».
|
||||
# HTML-структура — labelled rows, label слева, value справа в одном tr/td-блоке.
|
||||
# Используем устойчивые keys-маркеры (lowercased substring match) для тегов <dt>/<th>/<label>.
|
||||
_FIELD_MARKERS: dict[str, list[str]] = {
|
||||
"name": ["наименование"],
|
||||
"address": ["адрес"],
|
||||
"protection_category": ["категория", "историко-культурного значения"],
|
||||
"object_kind": ["вид объекта"],
|
||||
"dating": ["датировка"],
|
||||
"protection_doc": ["документ о постановке"],
|
||||
"regnumber": ["регистрационный номер"],
|
||||
}
|
||||
|
||||
# Лёгкая нормализация пробелов в извлечённом тексте.
|
||||
_WS_RE = re.compile(r"\s+")
|
||||
|
||||
|
||||
def _norm_text(s: str | None) -> str | None:
|
||||
"""Сжать whitespace и trim. Пустую строку → None."""
|
||||
if not s:
|
||||
return None
|
||||
value = _WS_RE.sub(" ", s).strip()
|
||||
return value or None
|
||||
|
||||
|
||||
def _match_marker(label: str, markers: list[str]) -> bool:
|
||||
"""Все маркеры присутствуют в lowercased label (substring AND)."""
|
||||
lab = label.lower()
|
||||
return all(m in lab for m in markers)
|
||||
|
||||
|
||||
def _extract_label_value_pairs(soup: BeautifulSoup) -> dict[str, str]:
|
||||
"""Извлечь пары (label → value) из карточки ОКН.
|
||||
|
||||
HTML ЕГРКН построен на стандартных Bootstrap-«row»'ах: первая колонка — label,
|
||||
вторая — value. Также встречаются <dl><dt>/<dd>. Парсер собирает пары по обоим
|
||||
шаблонам; пары накапливаются в порядке появления.
|
||||
|
||||
Возвращает {raw_label_lower: value}, без интерпретации.
|
||||
"""
|
||||
pairs: dict[str, str] = {}
|
||||
|
||||
# 1) <dl><dt>...</dt><dd>...</dd>
|
||||
for dt in soup.find_all("dt"):
|
||||
dd = dt.find_next_sibling("dd")
|
||||
if dd is None:
|
||||
continue
|
||||
label = _norm_text(dt.get_text(" "))
|
||||
value = _norm_text(dd.get_text(" "))
|
||||
if label and value:
|
||||
pairs.setdefault(label.lower(), value)
|
||||
|
||||
# 2) Bootstrap-row / table-row: пара дочерних элементов в одном контейнере.
|
||||
for row in soup.find_all(["tr", "div"]):
|
||||
kids = [c for c in row.find_all(recursive=False) if c.name in {"td", "th", "div", "span"}]
|
||||
if len(kids) < 2:
|
||||
continue
|
||||
label = _norm_text(kids[0].get_text(" "))
|
||||
value = _norm_text(kids[1].get_text(" "))
|
||||
if label and value and len(label) < 120:
|
||||
pairs.setdefault(label.lower(), value)
|
||||
|
||||
return pairs
|
||||
|
||||
|
||||
def _parse_detail_html(html: str) -> dict[str, Any]:
|
||||
"""Распарсить HTML-карточку ОКН в {name, address, …}.
|
||||
|
||||
Невалидный HTML или отсутствие распознанных полей → пустой dict. Не raise.
|
||||
"""
|
||||
if not html or not html.strip():
|
||||
return {}
|
||||
try:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
except Exception as exc:
|
||||
logger.warning("okn_egrkn_client: BS4 parse failed: %s", exc)
|
||||
return {}
|
||||
|
||||
pairs = _extract_label_value_pairs(soup)
|
||||
if not pairs:
|
||||
return {}
|
||||
|
||||
out: dict[str, Any] = {}
|
||||
for field, markers in _FIELD_MARKERS.items():
|
||||
for label, value in pairs.items():
|
||||
if _match_marker(label, markers):
|
||||
out[field] = value
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def fetch_okn_detail(source_id: str) -> dict[str, Any]:
|
||||
"""Запросить detail-карточку ОКН и распарсить метаданные.
|
||||
|
||||
Args:
|
||||
source_id: id фичи из searchMap (он же id записи в ЕГРКН-реестре).
|
||||
|
||||
Returns:
|
||||
dict с подмножеством {name, address, protection_category, object_kind,
|
||||
dating, protection_doc, regnumber}. Пустой dict при ошибке (любая)
|
||||
— never raise, ingest продолжается с базовыми полями.
|
||||
"""
|
||||
if not source_id:
|
||||
return {}
|
||||
return _do_detail_request(source_id, verify=True)
|
||||
|
||||
|
||||
def _do_detail_request(source_id: str, *, verify: bool) -> dict[str, Any]:
|
||||
"""HTTP GET карточки + парсинг. SSL-fallback аналогичен searchMap."""
|
||||
params = {"id": source_id}
|
||||
try:
|
||||
with httpx.Client(
|
||||
timeout=_DETAIL_TIMEOUT, verify=verify, headers=_DETAIL_HEADERS
|
||||
) as client:
|
||||
resp = client.get(_DETAIL_URL, params=params)
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
logger.warning(
|
||||
"okn_egrkn_client: detail HTTP %d source_id=%s",
|
||||
exc.response.status_code,
|
||||
source_id,
|
||||
)
|
||||
return {}
|
||||
except httpx.ConnectError as exc:
|
||||
if verify and _is_ssl_error(exc):
|
||||
logger.warning(
|
||||
"okn_egrkn_client: SSL-ошибка detail с verify=True, retry verify=False: %s", exc
|
||||
)
|
||||
return _do_detail_request(source_id, verify=False)
|
||||
logger.warning("okn_egrkn_client: detail connect error source_id=%s: %s", source_id, exc)
|
||||
return {}
|
||||
except Exception as exc:
|
||||
logger.warning("okn_egrkn_client: detail request error source_id=%s: %s", source_id, exc)
|
||||
return {}
|
||||
|
||||
return _parse_detail_html(resp.text)
|
||||
|
||||
|
||||
__all__ = ["fetch_okn_detail", "fetch_okn_points"]
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
"""ОКН-lookup для analyze_parcel (#1066).
|
||||
"""ОКН-lookup для analyze_parcel (#1066, #1159).
|
||||
|
||||
Читает ``okn_objects`` (м.141) и отдаёт объекты культурного наследия
|
||||
в заданном радиусе от участка — [{source_id, category, distance_m}].
|
||||
в заданном радиусе от участка — [{source_id, category, distance_m, name,
|
||||
address}].
|
||||
|
||||
ST_DWithin по geography (метрическое расстояние в метрах) через GIST-индекс
|
||||
idx_okn_objects_geom. Участок передаётся как WKT в EPSG:4326.
|
||||
|
||||
``name``/``address`` извлекаются из ``raw_props`` (обогащаются воркером через
|
||||
detail-карточку ЕГРКН). Если detail недоступен — возвращается None для этих
|
||||
полей, остальные поля сохраняются. Без detail-фикса (#1159 acceptance 2)
|
||||
analyze-таблица показывала «ОКН рядом» без указания КАКОГО — критично для ИРД.
|
||||
|
||||
Graceful: если таблица ``okn_objects`` ещё не задеплоена (pre-migration)
|
||||
или БД-ошибка — возвращает пустой список (analyze не падает).
|
||||
|
||||
Wiring в analyze — следующий PR (analyze-wiring, follow-up #1066).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -25,6 +29,8 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
# Пространственный запрос: ST_DWithin по geography даёт расстояние в метрах.
|
||||
# CAST psycopg v3 (никогда :param::type — vault Pattern_CAST_AS_Type).
|
||||
# raw_props->>'name'/'address' — JSON-string accessor (NULL если ключа нет
|
||||
# или raw_props=NULL). Безопасно для строк без detail-обогащения.
|
||||
_OKN_DWITHIN_SQL = text("""
|
||||
SELECT
|
||||
source_id,
|
||||
|
|
@ -34,7 +40,9 @@ _OKN_DWITHIN_SQL = text("""
|
|||
geom::geography,
|
||||
ST_GeomFromText(CAST(:parcel_wkt AS text), 4326)::geography
|
||||
)
|
||||
)::integer AS distance_m
|
||||
)::integer AS distance_m,
|
||||
raw_props->>'name' AS name,
|
||||
raw_props->>'address' AS address
|
||||
FROM okn_objects
|
||||
WHERE ST_DWithin(
|
||||
geom::geography,
|
||||
|
|
@ -58,9 +66,10 @@ def parcel_okn_objects(
|
|||
radius_m: радиус поиска в метрах (по умолчанию 100 м).
|
||||
|
||||
Returns:
|
||||
Список dict [{source_id, category, distance_m}], отсортированный
|
||||
по расстоянию ASC. Пустой список при отсутствии участка / таблицы /
|
||||
объектов в радиусе (analyze не падает).
|
||||
Список dict [{source_id, category, distance_m, name, address}],
|
||||
отсортированный по расстоянию ASC. ``name``/``address`` могут быть
|
||||
None если detail-карточка не догружена. Пустой список при
|
||||
отсутствии участка / таблицы / объектов в радиусе (analyze не падает).
|
||||
"""
|
||||
if not parcel_wkt:
|
||||
return []
|
||||
|
|
@ -83,6 +92,8 @@ def parcel_okn_objects(
|
|||
"source_id": row[0],
|
||||
"category": row[1],
|
||||
"distance_m": row[2],
|
||||
"name": row[3],
|
||||
"address": row[4],
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -1,18 +1,28 @@
|
|||
"""Celery task: ingest ОКН-объектов ЕКБ/Свердл.обл из АИС ЕГРКН (#1066).
|
||||
"""Celery task: ingest ОКН-объектов ЕКБ/Свердл.обл из АИС ЕГРКН (#1066, #1159).
|
||||
|
||||
Обходит 6 комбинаций (address, category_type):
|
||||
- «Екатеринбург», 1/2/4
|
||||
- «Свердловская», 1/2/4
|
||||
Обходит расширенный список ``(address, category_type)``-проходов: помимо базовых
|
||||
«Екатеринбург»/«Свердловская» (3 категории каждая) добавлены варианты подстрок
|
||||
адреса — районы ЕКБ, городские округа, обиходные склонения области. Запрос
|
||||
``Maps/searchMap`` фильтрует ОКН по подстроке в поле ``cob_address``: объекты,
|
||||
у которых адрес начинается с улицы без слова «Екатеринбург», ловятся только
|
||||
запросом по району/округу. Это закрывает coverage gap (428 vs ~798 ЕГРОКН).
|
||||
|
||||
Один объект может встречаться в нескольких проходах (например «Екатеринбург»-категория-2
|
||||
и «Свердловская»-категория-2). Дедупликация по source_id: первый проход, выдавший объект,
|
||||
устанавливает category (самая «высокая» категория первой встретившейся).
|
||||
Один объект может встречаться в нескольких проходах. Дедупликация по
|
||||
``source_id``: первый проход устанавливает category (самая «высокая» категория
|
||||
первой встретившейся). Порядок проходов: federal → regional → local;
|
||||
«Екатеринбург» раньше областных подстрок → при конфликте source_id выбирается
|
||||
наиболее значимая категория.
|
||||
|
||||
Порядок проходов (от федерального к местному, Екатеринбург раньше области) обеспечивает,
|
||||
что при конфликте source_id category берётся из наиболее значимой категории.
|
||||
После сбора уникальных фич воркер догружает per-object detail-карточку
|
||||
``Show/Show?id=…``: searchMap отдаёт ``properties={id}``, а название/адрес/датировка/
|
||||
категория охраны лежат отдельно. ``raw_props`` обогащается полями ``name``,
|
||||
``address``, ``protection_category``, ``object_kind``, ``dating``,
|
||||
``protection_doc``, ``regnumber`` (#1159 acceptance 2). Detail-фейлы не валят
|
||||
прогон — объект остаётся с базовыми полями (минимум {id}).
|
||||
|
||||
UPSERT: ON CONFLICT (source_id) DO UPDATE обновляет geom, fetched_at (category не меняется
|
||||
при повторном импорте — первый wins). SAVEPOINT per-row через db.begin_nested().
|
||||
UPSERT: ON CONFLICT (source_id) DO UPDATE обновляет geom, raw_props, fetched_at
|
||||
(category не меняется при повторном импорте — first wins). SAVEPOINT per-row через
|
||||
``db.begin_nested()``.
|
||||
|
||||
Beat: еженедельно в воскресенье в 04:30 МСК — до ird-harvest (понедельник 05:00).
|
||||
"""
|
||||
|
|
@ -26,7 +36,7 @@ from typing import Any
|
|||
from sqlalchemy import text
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
from app.services.scrapers.okn_egrkn_client import fetch_okn_points
|
||||
from app.services.scrapers.okn_egrkn_client import fetch_okn_detail, fetch_okn_points
|
||||
from app.workers.celery_app import celery_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -38,17 +48,47 @@ _CATEGORY_MAP: dict[int, str] = {
|
|||
4: "local",
|
||||
}
|
||||
|
||||
# Порядок проходов: (address, category_type).
|
||||
# Федеральные идут первыми — при source_id-конфликте category='federal' выигрывает.
|
||||
_PASSES: list[tuple[str, int]] = [
|
||||
("Екатеринбург", 1),
|
||||
("Екатеринбург", 2),
|
||||
("Екатеринбург", 4),
|
||||
("Свердловская", 1),
|
||||
("Свердловская", 2),
|
||||
("Свердловская", 4),
|
||||
# Категории по приоритету (federal → regional → local): порядок важен,
|
||||
# при конфликте source_id у одного и того же объекта побеждает более высокая.
|
||||
_CATEGORY_ORDER: list[int] = [1, 2, 4]
|
||||
|
||||
# Подстроки для cob_address. ЕГРКН не возвращает все объекты «Свердловской обл.»
|
||||
# по одному запросу — обходим расширенным списком:
|
||||
# - город целиком («Екатеринбург»),
|
||||
# - области («Свердловская», «Свердловской области»),
|
||||
# - районы ЕКБ — для записей без слова «Екатеринбург», только улица + район.
|
||||
# Порядок: «Екатеринбург» раньше других → при дедупе district-вариант не
|
||||
# перетирает category «городского» прохода.
|
||||
_ADDRESS_PASSES: list[str] = [
|
||||
"Екатеринбург",
|
||||
"г. Екатеринбург",
|
||||
"Кировский",
|
||||
"Ленинский",
|
||||
"Октябрьский",
|
||||
"Орджоникидзевский",
|
||||
"Чкаловский",
|
||||
"Верх-Исетский",
|
||||
"Железнодорожный",
|
||||
"Свердловская",
|
||||
"Свердловской области",
|
||||
]
|
||||
|
||||
# Декартово произведение (address × category) — генерируется единожды при импорте.
|
||||
# Категория федеральная идёт первой по каждому address: при конфликте source_id
|
||||
# (тот же объект встречается в нескольких категориях) federal/regional перебивают
|
||||
# local в той же address-strata.
|
||||
_PASSES: list[tuple[str, int]] = [
|
||||
(addr, cat) for addr in _ADDRESS_PASSES for cat in _CATEGORY_ORDER
|
||||
]
|
||||
|
||||
# Лимит per-run на detail-fetch. ~700 объектов * ~0.3 s сетевой задержки ≈ 4 min,
|
||||
# плюс retries — приемлемо для weekly cron. Жёсткий cap защищает от runaway
|
||||
# при сетевом шторме.
|
||||
_DETAIL_FETCH_LIMIT = 1500
|
||||
|
||||
# Логгировать прогресс detail-fetch каждые N объектов.
|
||||
_DETAIL_PROGRESS_EVERY = 100
|
||||
|
||||
_UPSERT_SQL = text("""
|
||||
INSERT INTO okn_objects (source_id, category, geom, raw_props, fetched_at)
|
||||
VALUES (
|
||||
|
|
@ -100,19 +140,88 @@ def _extract_coords(feature: dict[str, Any]) -> tuple[float, float] | None:
|
|||
return None
|
||||
|
||||
|
||||
@celery_app.task(name="tasks.okn_objects_sync.sync_okn_objects", queue="celery")
|
||||
def sync_okn_objects() -> dict[str, int]:
|
||||
"""Ingest ОКН-объектов из ЕГРКН в okn_objects (#1066).
|
||||
def _enrich_with_detail(
|
||||
to_upsert: list[dict[str, Any]],
|
||||
*,
|
||||
detail_fetcher: Any = fetch_okn_detail,
|
||||
limit: int = _DETAIL_FETCH_LIMIT,
|
||||
) -> int:
|
||||
"""Обогатить raw_props detail-полями (name, address, …).
|
||||
|
||||
Проходит 6 комбинаций (address, category_type), дедуплицирует по source_id
|
||||
(первый проход устанавливает category), UPSERT в БД с SAVEPOINT per-row.
|
||||
Принимает список row-dict'ов, уже подготовленных к UPSERT. Для каждой
|
||||
строки вызывает ``detail_fetcher(source_id)`` и сливает результат в JSON
|
||||
``raw_props`` (detail-поля имеют приоритет над сохранёнными ранее).
|
||||
Записи без detail-полей сохраняются с тем же ``raw_props={id}`` — детали
|
||||
могут быть недоступны (объект удалён из реестра / detail-endpoint лежит).
|
||||
|
||||
Args:
|
||||
to_upsert: список row-dict для UPSERT (мутируется in-place).
|
||||
detail_fetcher: callable source_id → dict (для DI в тестах).
|
||||
limit: максимум detail-вызовов за прогон.
|
||||
|
||||
Returns:
|
||||
{"okn": N} — число успешно обработанных фич (upsert + skip дублей).
|
||||
Число фич, для которых detail-поля были успешно подмёрджены.
|
||||
"""
|
||||
enriched = 0
|
||||
total = min(len(to_upsert), limit)
|
||||
for idx, row in enumerate(to_upsert[:limit], 1):
|
||||
try:
|
||||
detail = detail_fetcher(row["source_id"])
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"okn_objects_sync: detail-fetch source_id=%s raised: %s",
|
||||
row["source_id"],
|
||||
exc,
|
||||
)
|
||||
detail = {}
|
||||
|
||||
if not detail:
|
||||
continue
|
||||
|
||||
try:
|
||||
base_props = json.loads(row["raw_props"]) if row["raw_props"] else {}
|
||||
except (TypeError, ValueError):
|
||||
base_props = {}
|
||||
if not isinstance(base_props, dict):
|
||||
base_props = {}
|
||||
|
||||
merged = {**base_props, **detail}
|
||||
row["raw_props"] = json.dumps(merged, ensure_ascii=False)
|
||||
enriched += 1
|
||||
|
||||
if idx % _DETAIL_PROGRESS_EVERY == 0:
|
||||
logger.info(
|
||||
"okn_objects_sync: detail-fetch progress %d/%d (enriched=%d)",
|
||||
idx,
|
||||
total,
|
||||
enriched,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"okn_objects_sync: detail-fetch done enriched=%d of %d (limit=%d)",
|
||||
enriched,
|
||||
total,
|
||||
limit,
|
||||
)
|
||||
return enriched
|
||||
|
||||
|
||||
@celery_app.task(name="tasks.okn_objects_sync.sync_okn_objects", queue="celery")
|
||||
def sync_okn_objects() -> dict[str, int]:
|
||||
"""Ingest ОКН-объектов из ЕГРКН в okn_objects (#1066, #1159).
|
||||
|
||||
Проходит ``_PASSES`` — декартово (адресная подстрока × category_type),
|
||||
дедуплицирует по source_id (первый проход устанавливает category),
|
||||
догружает detail-карточку для каждого уникального source_id и
|
||||
UPSERT'ит в БД с SAVEPOINT per-row.
|
||||
|
||||
Returns:
|
||||
{"okn": N, "enriched": M} — N успешно обработанных фич (upsert),
|
||||
M фич, для которых detail-карточка была подмёрджена в raw_props.
|
||||
"""
|
||||
# Дедуп по source_id в памяти: source_id → category (first-wins)
|
||||
seen: dict[str, str] = {}
|
||||
# Очередь для UPSERT: (source_id, category, lon, lat, raw_props_json)
|
||||
# Очередь для UPSERT: row-dict для исполнения _UPSERT_SQL.
|
||||
to_upsert: list[dict[str, Any]] = []
|
||||
|
||||
for address, category_type in _PASSES:
|
||||
|
|
@ -160,6 +269,9 @@ def sync_okn_objects() -> dict[str, int]:
|
|||
|
||||
logger.info("okn_objects_sync: всего уникальных фич для UPSERT: %d", len(to_upsert))
|
||||
|
||||
# Догрузить detail (#1159 acceptance 2). Не валит прогон при сетевых ошибках.
|
||||
enriched = _enrich_with_detail(to_upsert)
|
||||
|
||||
inserted = 0
|
||||
errors = 0
|
||||
|
||||
|
|
@ -179,11 +291,12 @@ def sync_okn_objects() -> dict[str, int]:
|
|||
db.commit()
|
||||
|
||||
logger.info(
|
||||
"okn_objects_sync: done inserted/updated=%d errors=%d",
|
||||
"okn_objects_sync: done inserted/updated=%d enriched=%d errors=%d",
|
||||
inserted,
|
||||
enriched,
|
||||
errors,
|
||||
)
|
||||
return {"okn": inserted}
|
||||
return {"okn": inserted, "enriched": enriched}
|
||||
|
||||
|
||||
__all__ = ["sync_okn_objects"]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""Тесты клиента АИС ЕГРКН (#1066) — без живой сети.
|
||||
"""Тесты клиента АИС ЕГРКН (#1066, #1159) — без живой сети.
|
||||
|
||||
Покрывает:
|
||||
- coord swap: фейк-фича coordinates=[lat, lon] → lon/lat для PostGIS (критично).
|
||||
|
|
@ -6,6 +6,8 @@
|
|||
- Некорректный JSON / HTTP-ошибка → пустой список, не raise.
|
||||
- _build_form_data: формат jQuery serializeArray.
|
||||
- form-encoding: content= содержит urlencoded-строку с повторяющимися ключами (не data=).
|
||||
- fetch_okn_detail: HTML карточки → {name, address, …}; graceful при ошибках.
|
||||
- _parse_detail_html: <dl>/<dt>/<dd> и <tr>/<td> структура парсится; маркеры label.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -16,6 +18,8 @@ from unittest.mock import MagicMock, patch
|
|||
from app.services.scrapers.okn_egrkn_client import (
|
||||
_HEADERS,
|
||||
_build_form_data,
|
||||
_parse_detail_html,
|
||||
fetch_okn_detail,
|
||||
fetch_okn_points,
|
||||
)
|
||||
|
||||
|
|
@ -114,6 +118,21 @@ def _mock_response(payload: dict[str, Any], status: int = 200) -> MagicMock:
|
|||
return resp
|
||||
|
||||
|
||||
def _mock_html_response(html: str, status: int = 200) -> MagicMock:
|
||||
"""Создать мок httpx.Response с HTML-содержимым (для detail-эндпойнта)."""
|
||||
resp = MagicMock()
|
||||
resp.status_code = status
|
||||
resp.text = html
|
||||
resp.raise_for_status = MagicMock()
|
||||
if status >= 400:
|
||||
import httpx
|
||||
|
||||
resp.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
"error", request=MagicMock(), response=resp
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
def test_fetch_okn_points_returns_features() -> None:
|
||||
"""fetch_okn_points возвращает список фич из FeatureCollection."""
|
||||
features = [
|
||||
|
|
@ -336,3 +355,130 @@ def test_build_form_data_category_type_as_string() -> None:
|
|||
result = _build_form_data("Свердловская", 4)
|
||||
values = dict(result)
|
||||
assert values["data[1][value]"] == "4"
|
||||
|
||||
|
||||
# ── fetch_okn_detail / _parse_detail_html (#1159) ────────────────────────────
|
||||
|
||||
|
||||
_DETAIL_HTML_DL = """
|
||||
<html><body>
|
||||
<dl class="okn-card">
|
||||
<dt>Наименование</dt><dd>Дом Севастьянова</dd>
|
||||
<dt>Адрес</dt><dd>г. Екатеринбург, пр. Ленина, 35</dd>
|
||||
<dt>Категория историко-культурного значения</dt><dd>федерального</dd>
|
||||
<dt>Вид объекта</dt><dd>памятник архитектуры</dd>
|
||||
<dt>Датировка</dt><dd>1866</dd>
|
||||
<dt>Документ о постановке на гос. охрану</dt><dd>Постановление Правительства РФ № 527</dd>
|
||||
<dt>Регистрационный номер</dt><dd>661510234190006</dd>
|
||||
</dl>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
|
||||
_DETAIL_HTML_TABLE = """
|
||||
<html><body>
|
||||
<table class="okn">
|
||||
<tr><td>Наименование</td><td>Гостиница «Большой Урал»</td></tr>
|
||||
<tr><td>Адрес</td><td>г. Екатеринбург, ул. Красноармейская, 1</td></tr>
|
||||
<tr><td>Категория историко-культурного значения</td><td>регионального</td></tr>
|
||||
</table>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
|
||||
def test_parse_detail_html_dl_extracts_all_fields() -> None:
|
||||
"""<dl><dt>/<dd> структура → все поля извлечены."""
|
||||
result = _parse_detail_html(_DETAIL_HTML_DL)
|
||||
assert result["name"] == "Дом Севастьянова"
|
||||
assert result["address"] == "г. Екатеринбург, пр. Ленина, 35"
|
||||
assert result["protection_category"] == "федерального"
|
||||
assert result["object_kind"] == "памятник архитектуры"
|
||||
assert result["dating"] == "1866"
|
||||
assert result["protection_doc"] == "Постановление Правительства РФ № 527"
|
||||
assert result["regnumber"] == "661510234190006"
|
||||
|
||||
|
||||
def test_parse_detail_html_table_extracts_name_address() -> None:
|
||||
"""<table><tr><td>label</td><td>value</td></tr> → парсится."""
|
||||
result = _parse_detail_html(_DETAIL_HTML_TABLE)
|
||||
assert result["name"] == "Гостиница «Большой Урал»"
|
||||
assert result["address"] == "г. Екатеринбург, ул. Красноармейская, 1"
|
||||
assert result["protection_category"] == "регионального"
|
||||
|
||||
|
||||
def test_parse_detail_html_empty_returns_empty_dict() -> None:
|
||||
"""Пустой/None HTML → {}."""
|
||||
assert _parse_detail_html("") == {}
|
||||
assert _parse_detail_html(" ") == {}
|
||||
|
||||
|
||||
def test_parse_detail_html_no_recognized_fields_returns_empty() -> None:
|
||||
"""HTML без полей-маркеров → пустой dict."""
|
||||
html = "<html><body><p>404 not found</p></body></html>"
|
||||
assert _parse_detail_html(html) == {}
|
||||
|
||||
|
||||
def test_fetch_okn_detail_returns_parsed_fields_on_success() -> None:
|
||||
"""GET карточки + парсинг → {name, address, …}."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.__enter__ = MagicMock(return_value=mock_client)
|
||||
mock_client.__exit__ = MagicMock(return_value=False)
|
||||
mock_client.get.return_value = _mock_html_response(_DETAIL_HTML_DL)
|
||||
|
||||
with patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client):
|
||||
result = fetch_okn_detail("661510234190006")
|
||||
|
||||
assert result["name"] == "Дом Севастьянова"
|
||||
assert result["address"] == "г. Екатеринбург, пр. Ленина, 35"
|
||||
# GET был вызван с params={"id": "..."}.
|
||||
mock_client.get.assert_called_once()
|
||||
call_kwargs = mock_client.get.call_args.kwargs
|
||||
assert call_kwargs.get("params") == {"id": "661510234190006"}
|
||||
|
||||
|
||||
def test_fetch_okn_detail_http_error_returns_empty() -> None:
|
||||
"""HTTP 500 на detail → пустой dict, не raise."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.__enter__ = MagicMock(return_value=mock_client)
|
||||
mock_client.__exit__ = MagicMock(return_value=False)
|
||||
mock_client.get.return_value = _mock_html_response("", status=500)
|
||||
|
||||
with patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client):
|
||||
result = fetch_okn_detail("001")
|
||||
|
||||
assert result == {}
|
||||
|
||||
|
||||
def test_fetch_okn_detail_empty_source_id_returns_empty() -> None:
|
||||
"""Пустой source_id → пустой dict без HTTP-вызова."""
|
||||
with patch("app.services.scrapers.okn_egrkn_client.httpx.Client") as mock_cls:
|
||||
result = fetch_okn_detail("")
|
||||
assert result == {}
|
||||
mock_cls.assert_not_called()
|
||||
|
||||
|
||||
def test_fetch_okn_detail_ssl_error_retries_with_verify_false() -> None:
|
||||
"""SSL ConnectError на detail → retry с verify=False (как у searchMap)."""
|
||||
import httpx
|
||||
|
||||
clients: list[dict] = []
|
||||
|
||||
def _make_client(**kwargs: Any) -> MagicMock:
|
||||
verify = kwargs.get("verify", True)
|
||||
clients.append({"verify": verify})
|
||||
mc = MagicMock()
|
||||
mc.__enter__ = MagicMock(return_value=mc)
|
||||
mc.__exit__ = MagicMock(return_value=False)
|
||||
if verify:
|
||||
mc.get.side_effect = httpx.ConnectError("ssl cert verify failed")
|
||||
else:
|
||||
mc.get.return_value = _mock_html_response(_DETAIL_HTML_DL)
|
||||
return mc
|
||||
|
||||
with patch("app.services.scrapers.okn_egrkn_client.httpx.Client", side_effect=_make_client):
|
||||
result = fetch_okn_detail("test-id")
|
||||
|
||||
assert result["name"] == "Дом Севастьянова"
|
||||
assert len(clients) == 2
|
||||
assert clients[0]["verify"] is True
|
||||
assert clients[1]["verify"] is False
|
||||
|
|
|
|||
|
|
@ -1,19 +1,31 @@
|
|||
"""Тесты воркера okn_objects_sync (#1066) — без живой сети.
|
||||
"""Тесты воркера okn_objects_sync (#1066, #1159) — без живой сети.
|
||||
|
||||
Покрывает:
|
||||
- upsert идемпотентность: два вызова с одними source_id → один insert.
|
||||
- дедуп source_id across проходов: category берётся из первого (более приоритетного).
|
||||
- _extract_coords: правильный порядок [lat, lon] → (lon, lat) для PostGIS.
|
||||
- _extract_coords: невалидные / выходящие за bbox координаты → None.
|
||||
- sync_okn_objects: мок client + мок DB, проверка UPSERT-вызовов.
|
||||
- _PASSES: расширенный список адресных подстрок + декартово с категориями.
|
||||
- sync_okn_objects: дедуп source_id across проходов (federal wins).
|
||||
- sync_okn_objects: coverage — фичи из district-passes добавляются к city-pass.
|
||||
- sync_okn_objects: upsert идемпотентность.
|
||||
- _enrich_with_detail: merge name/address в raw_props; пустой detail → noop.
|
||||
- _enrich_with_detail: detail-фейл (raise) не валит прогон.
|
||||
- _enrich_with_detail: limit ограничивает число fetch'ей.
|
||||
- parcel_okn_objects: name/address возвращаются из БД-строки.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.workers.tasks.okn_objects_sync import _extract_coords
|
||||
from app.workers.tasks.okn_objects_sync import (
|
||||
_ADDRESS_PASSES,
|
||||
_CATEGORY_ORDER,
|
||||
_PASSES,
|
||||
_enrich_with_detail,
|
||||
_extract_coords,
|
||||
)
|
||||
|
||||
# ── _extract_coords ───────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -109,6 +121,63 @@ def test_extract_coords_single_coordinate_returns_none() -> None:
|
|||
assert _extract_coords(feature) is None
|
||||
|
||||
|
||||
# ── _PASSES / _ADDRESS_PASSES структура (#1159 coverage) ──────────────────────
|
||||
|
||||
|
||||
def test_address_passes_include_ekb_districts() -> None:
|
||||
"""Поверх «Екатеринбург»/«Свердловская» добавлены районы ЕКБ.
|
||||
|
||||
Defect 1 (#1159): searchMap не возвращает объекты с адресом без слова
|
||||
«Екатеринбург». Запросы по районам ловят такие записи.
|
||||
"""
|
||||
expected_districts = {
|
||||
"Кировский",
|
||||
"Ленинский",
|
||||
"Октябрьский",
|
||||
"Орджоникидзевский",
|
||||
"Чкаловский",
|
||||
"Верх-Исетский",
|
||||
"Железнодорожный",
|
||||
}
|
||||
assert expected_districts <= set(_ADDRESS_PASSES), (
|
||||
f"Не все районы ЕКБ в _ADDRESS_PASSES: missing="
|
||||
f"{expected_districts - set(_ADDRESS_PASSES)}"
|
||||
)
|
||||
|
||||
|
||||
def test_address_passes_order_city_before_district() -> None:
|
||||
"""Городские запросы идут раньше районных.
|
||||
|
||||
При дедупе source_id first-wins: если объект всплыл в проходе «Екатеринбург»
|
||||
как federal, проход «Кировский» не должен перетереть его category.
|
||||
"""
|
||||
city_idx = _ADDRESS_PASSES.index("Екатеринбург")
|
||||
district_idx = _ADDRESS_PASSES.index("Кировский")
|
||||
assert city_idx < district_idx
|
||||
|
||||
|
||||
def test_passes_cartesian_product_address_x_category() -> None:
|
||||
"""_PASSES = декартово (address × category_order)."""
|
||||
expected_size = len(_ADDRESS_PASSES) * len(_CATEGORY_ORDER)
|
||||
assert len(_PASSES) == expected_size
|
||||
# Каждый адрес покрыт всеми тремя категориями (federal/regional/local).
|
||||
for addr in _ADDRESS_PASSES:
|
||||
cats = [cat for a, cat in _PASSES if a == addr]
|
||||
assert sorted(cats) == sorted(_CATEGORY_ORDER), f"{addr}: {cats}"
|
||||
|
||||
|
||||
def test_passes_federal_before_local_per_address() -> None:
|
||||
"""Для каждого address federal появляется раньше regional раньше local.
|
||||
|
||||
Это гарантирует что при конфликте source_id в одной address-страте
|
||||
более «высокая» категория выигрывает.
|
||||
"""
|
||||
for addr in _ADDRESS_PASSES:
|
||||
positions = [i for i, (a, _c) in enumerate(_PASSES) if a == addr]
|
||||
cats_for_addr = [_PASSES[i][1] for i in positions]
|
||||
assert cats_for_addr == _CATEGORY_ORDER, f"{addr}: {cats_for_addr}"
|
||||
|
||||
|
||||
# ── sync_okn_objects: дедуп source_id ────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -120,155 +189,272 @@ def _make_feature_full(
|
|||
return {
|
||||
"id": feature_id,
|
||||
"geometry": {"type": "Point", "coordinates": [lat, lon]},
|
||||
"properties": {"name": f"ОКН-{feature_id}"},
|
||||
"properties": {"id": feature_id},
|
||||
}
|
||||
|
||||
|
||||
def _build_mock_db(captured: list[dict[str, Any]]) -> MagicMock:
|
||||
mock_db = MagicMock()
|
||||
mock_db.__enter__ = MagicMock(return_value=mock_db)
|
||||
mock_db.__exit__ = MagicMock(return_value=False)
|
||||
mock_db.begin_nested.return_value.__enter__ = MagicMock(return_value=None)
|
||||
mock_db.begin_nested.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
def _exec(sql: Any, row: Any = None) -> MagicMock:
|
||||
if row:
|
||||
captured.append(dict(row))
|
||||
return MagicMock()
|
||||
|
||||
mock_db.execute = _exec
|
||||
return mock_db
|
||||
|
||||
|
||||
def test_sync_okn_objects_dedup_source_id_across_passes() -> None:
|
||||
"""Один source_id из нескольких проходов → category из первого прохода (federal wins)."""
|
||||
from app.workers.tasks.okn_objects_sync import sync_okn_objects
|
||||
|
||||
# «001» встречается в проходе (Екатеринбург, 1) = federal
|
||||
# и в проходе (Екатеринбург, 2) = regional
|
||||
# category должна быть federal
|
||||
pass_results: dict[tuple[str, int], list[dict[str, Any]]] = {
|
||||
("Екатеринбург", 1): [_make_feature_full("001")],
|
||||
("Екатеринбург", 2): [_make_feature_full("001")], # дубль
|
||||
("Екатеринбург", 4): [],
|
||||
("Свердловская", 1): [],
|
||||
("Свердловская", 2): [],
|
||||
("Свердловская", 4): [],
|
||||
}
|
||||
# и в проходе (Екатеринбург, 2) = regional → category должна быть federal.
|
||||
feat = _make_feature_full("001")
|
||||
|
||||
def _mock_fetch(address: str, category_type: int) -> list[dict[str, Any]]:
|
||||
return pass_results.get((address, category_type), [])
|
||||
if address == "Екатеринбург" and category_type in (1, 2):
|
||||
return [feat]
|
||||
return []
|
||||
|
||||
upserted_rows: list[dict[str, Any]] = []
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.__enter__ = MagicMock(return_value=mock_db)
|
||||
mock_db.__exit__ = MagicMock(return_value=False)
|
||||
mock_db.begin_nested.return_value.__enter__ = MagicMock(return_value=None)
|
||||
mock_db.begin_nested.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
def _capture_execute(sql: Any, row: Any = None) -> MagicMock:
|
||||
if row:
|
||||
upserted_rows.append(dict(row))
|
||||
return MagicMock()
|
||||
|
||||
mock_db.execute = _capture_execute
|
||||
mock_db = _build_mock_db(upserted_rows)
|
||||
|
||||
with (
|
||||
patch("app.workers.tasks.okn_objects_sync.fetch_okn_points", side_effect=_mock_fetch),
|
||||
patch("app.workers.tasks.okn_objects_sync.SessionLocal", return_value=mock_db),
|
||||
):
|
||||
result = sync_okn_objects()
|
||||
|
||||
# Один объект, один upsert
|
||||
assert result == {"okn": 1}
|
||||
assert len(upserted_rows) == 1
|
||||
assert upserted_rows[0]["source_id"] == "001"
|
||||
assert upserted_rows[0]["category"] == "federal"
|
||||
|
||||
|
||||
def test_sync_okn_objects_upsert_idempotent() -> None:
|
||||
"""Два вызова sync_okn_objects с одними source_id → два upsert (ON CONFLICT DO UPDATE в БД).
|
||||
|
||||
Тест проверяет что воркер передаёт все source_id для UPSERT при каждом прогоне —
|
||||
идемпотентность гарантирует SQL ON CONFLICT, не фильтрация в Python.
|
||||
"""
|
||||
from app.workers.tasks.okn_objects_sync import sync_okn_objects
|
||||
|
||||
features = [_make_feature_full("001"), _make_feature_full("002")]
|
||||
pass_results: dict[tuple[str, int], list[dict[str, Any]]] = {
|
||||
("Екатеринбург", 1): features,
|
||||
("Екатеринбург", 2): [],
|
||||
("Екатеринбург", 4): [],
|
||||
("Свердловская", 1): [],
|
||||
("Свердловская", 2): [],
|
||||
("Свердловская", 4): [],
|
||||
}
|
||||
|
||||
upsert_counts: list[int] = []
|
||||
|
||||
def _run_sync() -> dict[str, int]:
|
||||
upserted: list[dict[str, Any]] = []
|
||||
mock_db = MagicMock()
|
||||
mock_db.__enter__ = MagicMock(return_value=mock_db)
|
||||
mock_db.__exit__ = MagicMock(return_value=False)
|
||||
mock_db.begin_nested.return_value.__enter__ = MagicMock(return_value=None)
|
||||
mock_db.begin_nested.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
def _exec(sql: Any, row: Any = None) -> MagicMock:
|
||||
if row:
|
||||
upserted.append(dict(row))
|
||||
return MagicMock()
|
||||
|
||||
mock_db.execute = _exec
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.workers.tasks.okn_objects_sync.fetch_okn_points",
|
||||
side_effect=lambda a, c: pass_results.get((a, c), []),
|
||||
),
|
||||
patch("app.workers.tasks.okn_objects_sync.SessionLocal", return_value=mock_db),
|
||||
):
|
||||
res = sync_okn_objects()
|
||||
|
||||
upsert_counts.append(len(upserted))
|
||||
return res
|
||||
|
||||
r1 = _run_sync()
|
||||
r2 = _run_sync()
|
||||
|
||||
assert r1 == {"okn": 2}
|
||||
assert r2 == {"okn": 2}
|
||||
# Оба прогона делают UPSERT (идемпотентность обеспечена SQL ON CONFLICT)
|
||||
assert upsert_counts == [2, 2]
|
||||
|
||||
|
||||
def test_sync_okn_objects_returns_total_unique_count() -> None:
|
||||
"""sync_okn_objects возвращает {"okn": N} — число уникальных source_id после дедупа."""
|
||||
from app.workers.tasks.okn_objects_sync import sync_okn_objects
|
||||
|
||||
# 3 уникальных + 1 дубль = 3 upsert
|
||||
pass_results: dict[tuple[str, int], list[dict[str, Any]]] = {
|
||||
("Екатеринбург", 1): [_make_feature_full("A"), _make_feature_full("B")],
|
||||
("Екатеринбург", 2): [_make_feature_full("C"), _make_feature_full("A")], # A дубль
|
||||
("Екатеринбург", 4): [],
|
||||
("Свердловская", 1): [],
|
||||
("Свердловская", 2): [],
|
||||
("Свердловская", 4): [],
|
||||
}
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.__enter__ = MagicMock(return_value=mock_db)
|
||||
mock_db.__exit__ = MagicMock(return_value=False)
|
||||
mock_db.begin_nested.return_value.__enter__ = MagicMock(return_value=None)
|
||||
mock_db.begin_nested.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.workers.tasks.okn_objects_sync.fetch_okn_points",
|
||||
side_effect=lambda a, c: pass_results.get((a, c), []),
|
||||
"app.workers.tasks.okn_objects_sync.fetch_okn_detail",
|
||||
return_value={},
|
||||
),
|
||||
patch("app.workers.tasks.okn_objects_sync.SessionLocal", return_value=mock_db),
|
||||
):
|
||||
result = sync_okn_objects()
|
||||
|
||||
assert result == {"okn": 3}
|
||||
# Один объект, один upsert
|
||||
assert result == {"okn": 1, "enriched": 0}
|
||||
assert len(upserted_rows) == 1
|
||||
assert upserted_rows[0]["source_id"] == "001"
|
||||
assert upserted_rows[0]["category"] == "federal"
|
||||
|
||||
|
||||
# ── parcel_okn_objects lookup ─────────────────────────────────────────────────
|
||||
def test_sync_okn_objects_coverage_collects_from_multiple_address_passes() -> None:
|
||||
"""Фичи, появляющиеся только в district-проходе, добавляются к city-проходу (#1159).
|
||||
|
||||
Симулируем реальный кейс: 1 объект ловится «Екатеринбург», 1 — только
|
||||
«Кировский» (улицный адрес без слова Екатеринбург), 1 — только
|
||||
«Свердловская» (за городом). После прогона все три попадают в upsert.
|
||||
"""
|
||||
from app.workers.tasks.okn_objects_sync import sync_okn_objects
|
||||
|
||||
city_feat = _make_feature_full("city-001")
|
||||
district_feat = _make_feature_full("district-002")
|
||||
region_feat = _make_feature_full("region-003")
|
||||
|
||||
def _mock_fetch(address: str, category_type: int) -> list[dict[str, Any]]:
|
||||
if category_type != 2: # для краткости — все «региональные»
|
||||
return []
|
||||
if address == "Екатеринбург":
|
||||
return [city_feat]
|
||||
if address == "Кировский":
|
||||
return [city_feat, district_feat] # дубль city-001 + новый
|
||||
if address == "Свердловская":
|
||||
return [region_feat]
|
||||
return []
|
||||
|
||||
upserted_rows: list[dict[str, Any]] = []
|
||||
mock_db = _build_mock_db(upserted_rows)
|
||||
|
||||
with (
|
||||
patch("app.workers.tasks.okn_objects_sync.fetch_okn_points", side_effect=_mock_fetch),
|
||||
patch(
|
||||
"app.workers.tasks.okn_objects_sync.fetch_okn_detail",
|
||||
return_value={},
|
||||
),
|
||||
patch("app.workers.tasks.okn_objects_sync.SessionLocal", return_value=mock_db),
|
||||
):
|
||||
result = sync_okn_objects()
|
||||
|
||||
source_ids = {r["source_id"] for r in upserted_rows}
|
||||
assert source_ids == {"city-001", "district-002", "region-003"}, source_ids
|
||||
assert result["okn"] == 3
|
||||
|
||||
|
||||
def test_parcel_okn_objects_returns_nearby() -> None:
|
||||
"""parcel_okn_objects: мок DB → список [{source_id, category, distance_m}]."""
|
||||
def test_sync_okn_objects_upsert_idempotent() -> None:
|
||||
"""Два вызова с одними source_id → два upsert (ON CONFLICT в БД, не Python-фильтр)."""
|
||||
from app.workers.tasks.okn_objects_sync import sync_okn_objects
|
||||
|
||||
features = [_make_feature_full("001"), _make_feature_full("002")]
|
||||
|
||||
def _mock_fetch(address: str, category_type: int) -> list[dict[str, Any]]:
|
||||
if address == "Екатеринбург" and category_type == 1:
|
||||
return features
|
||||
return []
|
||||
|
||||
counts: list[int] = []
|
||||
|
||||
def _run() -> dict[str, int]:
|
||||
upserted: list[dict[str, Any]] = []
|
||||
mock_db = _build_mock_db(upserted)
|
||||
with (
|
||||
patch(
|
||||
"app.workers.tasks.okn_objects_sync.fetch_okn_points",
|
||||
side_effect=_mock_fetch,
|
||||
),
|
||||
patch(
|
||||
"app.workers.tasks.okn_objects_sync.fetch_okn_detail",
|
||||
return_value={},
|
||||
),
|
||||
patch("app.workers.tasks.okn_objects_sync.SessionLocal", return_value=mock_db),
|
||||
):
|
||||
res = sync_okn_objects()
|
||||
counts.append(len(upserted))
|
||||
return res
|
||||
|
||||
r1 = _run()
|
||||
r2 = _run()
|
||||
assert r1["okn"] == 2
|
||||
assert r2["okn"] == 2
|
||||
assert counts == [2, 2]
|
||||
|
||||
|
||||
def test_sync_okn_objects_returns_total_unique_count() -> None:
|
||||
"""Возвращает {"okn": N, "enriched": M}: N уникальных, M обогащённых."""
|
||||
from app.workers.tasks.okn_objects_sync import sync_okn_objects
|
||||
|
||||
feats_1 = [_make_feature_full("A"), _make_feature_full("B")]
|
||||
feats_2 = [_make_feature_full("C"), _make_feature_full("A")] # A дубль
|
||||
|
||||
def _mock_fetch(address: str, category_type: int) -> list[dict[str, Any]]:
|
||||
if address == "Екатеринбург" and category_type == 1:
|
||||
return feats_1
|
||||
if address == "Екатеринбург" and category_type == 2:
|
||||
return feats_2
|
||||
return []
|
||||
|
||||
upserted_rows: list[dict[str, Any]] = []
|
||||
mock_db = _build_mock_db(upserted_rows)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.workers.tasks.okn_objects_sync.fetch_okn_points",
|
||||
side_effect=_mock_fetch,
|
||||
),
|
||||
patch(
|
||||
"app.workers.tasks.okn_objects_sync.fetch_okn_detail",
|
||||
return_value={},
|
||||
),
|
||||
patch("app.workers.tasks.okn_objects_sync.SessionLocal", return_value=mock_db),
|
||||
):
|
||||
result = sync_okn_objects()
|
||||
|
||||
assert result["okn"] == 3
|
||||
assert result["enriched"] == 0
|
||||
|
||||
|
||||
# ── _enrich_with_detail (#1159 acceptance 2) ─────────────────────────────────
|
||||
|
||||
|
||||
def _row(source_id: str, base_props: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"source_id": source_id,
|
||||
"category": "regional",
|
||||
"lon": 60.61,
|
||||
"lat": 56.83,
|
||||
"raw_props": json.dumps(base_props or {"id": source_id}, ensure_ascii=False),
|
||||
}
|
||||
|
||||
|
||||
def test_enrich_merges_name_address_into_raw_props() -> None:
|
||||
"""detail (name, address, …) подмёрджен в raw_props; baseline id сохранён."""
|
||||
rows = [_row("001"), _row("002")]
|
||||
details = {
|
||||
"001": {"name": "Дом Севастьянова", "address": "пр. Ленина, 35"},
|
||||
"002": {"name": "Гостиница «Большой Урал»", "address": "ул. Красноармейская, 1"},
|
||||
}
|
||||
|
||||
enriched = _enrich_with_detail(rows, detail_fetcher=lambda sid: details[sid])
|
||||
assert enriched == 2
|
||||
|
||||
parsed_0 = json.loads(rows[0]["raw_props"])
|
||||
assert parsed_0 == {"id": "001", "name": "Дом Севастьянова", "address": "пр. Ленина, 35"}
|
||||
parsed_1 = json.loads(rows[1]["raw_props"])
|
||||
assert parsed_1["address"] == "ул. Красноармейская, 1"
|
||||
assert parsed_1["id"] == "002"
|
||||
|
||||
|
||||
def test_enrich_with_empty_detail_keeps_baseline() -> None:
|
||||
"""Пустой detail → raw_props не меняется, enriched=0."""
|
||||
rows = [_row("001")]
|
||||
original = rows[0]["raw_props"]
|
||||
|
||||
enriched = _enrich_with_detail(rows, detail_fetcher=lambda _sid: {})
|
||||
assert enriched == 0
|
||||
assert rows[0]["raw_props"] == original
|
||||
|
||||
|
||||
def test_enrich_tolerates_detail_exception() -> None:
|
||||
"""Detail-fetcher raises → строка пропускается, прогон не падает."""
|
||||
rows = [_row("001"), _row("002")]
|
||||
|
||||
def _fetcher(source_id: str) -> dict[str, Any]:
|
||||
if source_id == "001":
|
||||
raise RuntimeError("network down")
|
||||
return {"name": "OK", "address": "Х"}
|
||||
|
||||
enriched = _enrich_with_detail(rows, detail_fetcher=_fetcher)
|
||||
assert enriched == 1
|
||||
# 001 baseline, 002 enriched
|
||||
parsed_1 = json.loads(rows[1]["raw_props"])
|
||||
assert parsed_1["name"] == "OK"
|
||||
|
||||
|
||||
def test_enrich_respects_limit() -> None:
|
||||
"""limit=N → только первые N source_id запрошены."""
|
||||
rows = [_row(f"{i:03d}") for i in range(10)]
|
||||
calls: list[str] = []
|
||||
|
||||
def _fetcher(source_id: str) -> dict[str, Any]:
|
||||
calls.append(source_id)
|
||||
return {"name": f"N-{source_id}"}
|
||||
|
||||
enriched = _enrich_with_detail(rows, detail_fetcher=_fetcher, limit=3)
|
||||
assert enriched == 3
|
||||
assert len(calls) == 3
|
||||
assert calls == ["000", "001", "002"]
|
||||
|
||||
|
||||
def test_enrich_handles_non_dict_base_props() -> None:
|
||||
"""raw_props с битым JSON (не dict) → пересоздаётся как пустой dict + merge detail."""
|
||||
row = {
|
||||
"source_id": "001",
|
||||
"category": "regional",
|
||||
"lon": 60.61,
|
||||
"lat": 56.83,
|
||||
"raw_props": json.dumps(["broken", "list"]), # не dict
|
||||
}
|
||||
enriched = _enrich_with_detail(
|
||||
[row], detail_fetcher=lambda _sid: {"name": "X", "address": "Y"}
|
||||
)
|
||||
assert enriched == 1
|
||||
parsed = json.loads(row["raw_props"])
|
||||
assert parsed == {"name": "X", "address": "Y"}
|
||||
|
||||
|
||||
# ── parcel_okn_objects lookup (#1159 acceptance 2: name/address) ─────────────
|
||||
|
||||
|
||||
def test_parcel_okn_objects_returns_nearby_with_name_address() -> None:
|
||||
"""parcel_okn_objects: мок DB → [{source_id, category, distance_m, name, address}]."""
|
||||
from app.services.site_finder.okn_lookup import parcel_okn_objects
|
||||
|
||||
rows = [
|
||||
("ekb-001", "federal", 42),
|
||||
("ekb-002", "regional", 95),
|
||||
("ekb-001", "federal", 42, "Дом Севастьянова", "пр. Ленина, 35"),
|
||||
("ekb-002", "regional", 95, None, None), # detail не догружен
|
||||
]
|
||||
|
||||
mock_result = MagicMock()
|
||||
|
|
@ -280,8 +466,16 @@ def test_parcel_okn_objects_returns_nearby() -> None:
|
|||
result = parcel_okn_objects(db, "POLYGON((60 56,61 56,61 57,60 57,60 56))", radius_m=100)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0] == {"source_id": "ekb-001", "category": "federal", "distance_m": 42}
|
||||
assert result[1] == {"source_id": "ekb-002", "category": "regional", "distance_m": 95}
|
||||
assert result[0] == {
|
||||
"source_id": "ekb-001",
|
||||
"category": "federal",
|
||||
"distance_m": 42,
|
||||
"name": "Дом Севастьянова",
|
||||
"address": "пр. Ленина, 35",
|
||||
}
|
||||
assert result[1]["name"] is None
|
||||
assert result[1]["address"] is None
|
||||
assert result[1]["source_id"] == "ekb-002"
|
||||
|
||||
|
||||
def test_parcel_okn_objects_empty_wkt_returns_empty() -> None:
|
||||
|
|
@ -345,10 +539,10 @@ def test_parcel_okn_objects_no_results_returns_empty() -> None:
|
|||
|
||||
|
||||
def test_parcel_okn_objects_distance_mapping() -> None:
|
||||
"""distance_m берётся из колонки [2] (третий элемент row-кортежа)."""
|
||||
"""distance_m / name / address берутся из колонок [2..4] (порядок SELECT)."""
|
||||
from app.services.site_finder.okn_lookup import parcel_okn_objects
|
||||
|
||||
rows = [("id-x", "local", 7)]
|
||||
rows = [("id-x", "local", 7, "Памятник Х", "ул. Y")]
|
||||
mock_result = MagicMock()
|
||||
mock_result.fetchall.return_value = rows
|
||||
|
||||
|
|
@ -359,3 +553,5 @@ def test_parcel_okn_objects_distance_mapping() -> None:
|
|||
assert result[0]["distance_m"] == 7
|
||||
assert result[0]["source_id"] == "id-x"
|
||||
assert result[0]["category"] == "local"
|
||||
assert result[0]["name"] == "Памятник Х"
|
||||
assert result[0]["address"] == "ул. Y"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue