Some checks failed
CI / changes (push) Successful in 7s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (push) Successful in 1m45s
CI / openapi-codegen-check (pull_request) Successful in 1m43s
CI / backend-tests (push) Failing after 8m36s
CI / backend-tests (pull_request) Failing after 8m36s
Имплементация фиксов 2-го аудита backend/app/** (после merge #1543). Воркер на файл, точечные правки. Верификация: py_compile 58/58 .py. Полностью исправлено (82). Оставлены открытыми (13): partial/needs-cross-file/needs-leha — #1569, #1590, #1593, #1606, #1609, #1617, #1633, #1635, #1637, #1638, #1640, #1642, #1650. Closes #1560 Closes #1561 Closes #1562 Closes #1563 Closes #1564 Closes #1565 Closes #1566 Closes #1567 Closes #1570 Closes #1571 Closes #1572 Closes #1573 Closes #1574 Closes #1576 Closes #1577 Closes #1578 Closes #1579 Closes #1580 Closes #1581 Closes #1582 Closes #1583 Closes #1584 Closes #1585 Closes #1586 Closes #1587 Closes #1588 Closes #1589 Closes #1591 Closes #1592 Closes #1594 Closes #1595 Closes #1596 Closes #1597 Closes #1598 Closes #1599 Closes #1600 Closes #1601 Closes #1602 Closes #1603 Closes #1604 Closes #1605 Closes #1607 Closes #1608 Closes #1610 Closes #1611 Closes #1612 Closes #1613 Closes #1614 Closes #1615 Closes #1616 Closes #1618 Closes #1619 Closes #1620 Closes #1621 Closes #1622 Closes #1623 Closes #1624 Closes #1625 Closes #1626 Closes #1627 Closes #1628 Closes #1629 Closes #1630 Closes #1631 Closes #1632 Closes #1634 Closes #1636 Closes #1639 Closes #1641 Closes #1643 Closes #1644 Closes #1645 Closes #1646 Closes #1647 Closes #1648 Closes #1649 Closes #1651 Closes #1652 Closes #1653 Closes #1654 Closes #1655 Closes #1656
224 lines
8.9 KiB
Python
224 lines
8.9 KiB
Python
"""Extractor for 6 «Проверено на наш.дом.рф» checks per object.
|
||
|
||
Issue #297, sub-task 22f. Table created in PR #303 (data/sql/111_22f_domrf_obj_checks.sql).
|
||
|
||
Check types (canonical):
|
||
no_problems / docs / timing / photos / bankruptcy / declaration
|
||
|
||
Source endpoint: /сервисы/api/object/{obj_id}/checks
|
||
(pattern analogичен /infrastructure и /photos — те же сервисы/api/object/{obj_id}/ prefix)
|
||
URL not verified via devtools — структура payload выведена из аудита страницы ЖК.
|
||
Если endpoint не существует — scraper получит HTTP-ошибку, которую _classify_and_log
|
||
запишет в kn_scrape_failures, данные в domrf_obj_checks не поступят, scrape не упадёт.
|
||
|
||
Expected payload shape (предположительно):
|
||
{
|
||
"data": {
|
||
"noProblemObjects": true, # no_problems
|
||
"hasDocuments": true, # docs
|
||
"meetsDeadlines": true, # timing
|
||
"hasPhotos": true, # photos
|
||
"notBankrupt": true, # bankruptcy
|
||
"hasDeclaration": true # declaration
|
||
}
|
||
}
|
||
|
||
OR possibly an array:
|
||
[{"checkType": "no_problems", "passed": true}, ...]
|
||
|
||
Поддерживаются оба варианта: dict-payload (поля маппятся через CHECK_FIELD_MAP)
|
||
и list-payload (поля check_type + passed/value).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from typing import Any
|
||
|
||
from sqlalchemy import text
|
||
from sqlalchemy.orm import Session
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
CHECK_TYPES = ["no_problems", "docs", "timing", "photos", "bankruptcy", "declaration"]
|
||
|
||
# Mapping of possible API field names → canonical check_type.
|
||
# Best-guess from DOM.РФ API field naming conventions (кейс camelCase).
|
||
_CHECK_FIELD_MAP: dict[str, str] = {
|
||
"noProblemObjects": "no_problems",
|
||
"noProblemFlg": "no_problems",
|
||
"hasDocuments": "docs",
|
||
"documentsFlg": "docs",
|
||
"meetsDeadlines": "timing",
|
||
"deadlinesFlg": "timing",
|
||
"hasPhotos": "photos",
|
||
"photosFlg": "photos",
|
||
"notBankrupt": "bankruptcy",
|
||
"bankruptcyFlg": "bankruptcy",
|
||
"hasDeclaration": "declaration",
|
||
"declarationFlg": "declaration",
|
||
}
|
||
|
||
# Canonical check_type name → possible API field name aliases
|
||
_CHECK_TYPE_ALIASES: dict[str, list[str]] = {
|
||
"no_problems": ["no_problems", "noProblemObjects", "noProblemFlg"],
|
||
"docs": ["docs", "hasDocuments", "documentsFlg"],
|
||
"timing": ["timing", "meetsDeadlines", "deadlinesFlg"],
|
||
"photos": ["photos", "hasPhotos", "photosFlg"],
|
||
"bankruptcy": ["bankruptcy", "notBankrupt", "bankruptcyFlg"],
|
||
"declaration": ["declaration", "hasDeclaration", "declarationFlg"],
|
||
}
|
||
|
||
# Строковые флаги, которые сторонний API может прислать вместо bool.
|
||
# Схема payload не верифицирована (см. docstring), поэтому приводим явно.
|
||
_TRUE_STRINGS = {"true", "1", "yes", "y", "да", "passed", "ok"}
|
||
_FALSE_STRINGS = {"false", "0", "no", "n", "нет", "failed", "not_passed"}
|
||
|
||
|
||
def _coerce_flag(value: Any) -> bool | None:
|
||
"""Привести значение флага проверки к bool либо None (UNKNOWN).
|
||
|
||
bool(value) ломается на строках ('false'/'0'/'нет' → True) и не отличает
|
||
отсутствие данных от False. Возвращаем None, если значение нераспознаваемо —
|
||
вызывающий код НЕ должен фабриковать False для UNKNOWN.
|
||
"""
|
||
if isinstance(value, bool):
|
||
return value
|
||
if value is None:
|
||
return None
|
||
if isinstance(value, (int, float)):
|
||
return bool(value)
|
||
if isinstance(value, str):
|
||
s = value.strip().lower()
|
||
if s in _TRUE_STRINGS:
|
||
return True
|
||
if s in _FALSE_STRINGS:
|
||
return False
|
||
return None
|
||
return None
|
||
|
||
|
||
_UPSERT_CHECKS_SQL = text(
|
||
"""
|
||
INSERT INTO domrf_obj_checks (obj_id, check_type, passed, checked_at, scraped_at)
|
||
VALUES (:obj_id, :check_type, :passed, NOW(), NOW())
|
||
ON CONFLICT (obj_id, check_type) DO UPDATE SET
|
||
passed = EXCLUDED.passed,
|
||
checked_at = NOW(),
|
||
scraped_at = NOW()
|
||
"""
|
||
)
|
||
|
||
|
||
def extract_obj_checks(raw_payload: Any) -> list[dict[str, Any]]:
|
||
"""Извлечь 6 чекбоксов из payload endpoint /object/{obj_id}/checks.
|
||
|
||
Поддерживает два варианта payload:
|
||
1. dict с полями (ожидаемый API-формат): {"data": {"noProblemObjects": true, ...}}
|
||
2. list объектов: [{"checkType": "no_problems", "passed": true}, ...]
|
||
|
||
Для неизвестных полей и неподдерживаемых форматов — WARNING + пустой список.
|
||
"""
|
||
if not raw_payload:
|
||
return []
|
||
|
||
# Вариант 1: dict с data-обёрткой
|
||
data: Any = raw_payload
|
||
if isinstance(raw_payload, dict):
|
||
data = raw_payload.get("data") or raw_payload
|
||
|
||
results: list[dict[str, Any]] = []
|
||
|
||
if isinstance(data, dict):
|
||
# Map известных полей к canonical check_type
|
||
found: dict[str, bool] = {}
|
||
for field, value in data.items():
|
||
ct = _CHECK_FIELD_MAP.get(field)
|
||
if ct and ct not in found:
|
||
flag = _coerce_flag(value)
|
||
if flag is not None:
|
||
found[ct] = flag
|
||
# Также проверить canonical names напрямую
|
||
for ct in CHECK_TYPES:
|
||
if ct not in found and ct in data:
|
||
flag = _coerce_flag(data[ct])
|
||
if flag is not None:
|
||
found[ct] = flag
|
||
if found:
|
||
# Только фактически найденные флаги: отсутствие в payload = UNKNOWN,
|
||
# а не FAILED — не фабрикуем passed=False для непришедших проверок.
|
||
for ct in CHECK_TYPES:
|
||
if ct in found:
|
||
results.append({"check_type": ct, "passed": found[ct]})
|
||
return results
|
||
# dict не содержит известных полей — попробуем как list-формат ниже
|
||
logger.warning(
|
||
"domrf obj_checks: dict payload has no known check fields: %s", list(data)[:10]
|
||
)
|
||
return []
|
||
|
||
if isinstance(data, list):
|
||
found_list: dict[str, bool] = {}
|
||
for item in data:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
ct_raw = item.get("checkType") or item.get("check_type") or item.get("type")
|
||
if ct_raw and str(ct_raw) in CHECK_TYPES:
|
||
# Не or-коалесинг: легитимный False теряется (False or 'n/a' → 'n/a').
|
||
# Берём первый ключ, который реально присутствует в item.
|
||
if "passed" in item:
|
||
passed_raw = item["passed"]
|
||
elif "value" in item:
|
||
passed_raw = item["value"]
|
||
elif "status" in item:
|
||
passed_raw = item["status"]
|
||
else:
|
||
passed_raw = None
|
||
flag = _coerce_flag(passed_raw)
|
||
if flag is not None:
|
||
found_list[str(ct_raw)] = flag
|
||
if found_list:
|
||
# См. dict-ветку: только найденные флаги, UNKNOWN не равно FAILED.
|
||
for ct in CHECK_TYPES:
|
||
if ct in found_list:
|
||
results.append({"check_type": ct, "passed": found_list[ct]})
|
||
return results
|
||
logger.warning(
|
||
"domrf obj_checks: list payload has no recognisable check items: %s", data[:3]
|
||
)
|
||
return []
|
||
|
||
logger.warning("domrf obj_checks: unexpected payload type %s", type(raw_payload))
|
||
return []
|
||
|
||
|
||
def upsert_obj_checks(db: Session, obj_id: int, checks: list[dict[str, Any]]) -> int:
|
||
"""UPSERT 6 чек-строк в domrf_obj_checks. Returns count of inserted/updated rows.
|
||
|
||
Использует SAVEPOINT (begin_nested) per-row — одна битая строка не откатывает
|
||
всю транзакцию.
|
||
"""
|
||
if not checks:
|
||
return 0
|
||
ok = 0
|
||
for c in checks:
|
||
try:
|
||
with db.begin_nested():
|
||
db.execute(
|
||
_UPSERT_CHECKS_SQL,
|
||
{
|
||
"obj_id": obj_id,
|
||
"check_type": c["check_type"],
|
||
"passed": c["passed"],
|
||
},
|
||
)
|
||
ok += 1
|
||
except Exception as exc:
|
||
logger.warning(
|
||
"upsert obj_checks obj=%s check_type=%s failed: %s",
|
||
obj_id,
|
||
c.get("check_type"),
|
||
exc,
|
||
)
|
||
db.commit()
|
||
return ok
|