From e027f0a6ea888cf2dcf00e07a4944db7cf31d081 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sat, 13 Jun 2026 23:12:28 +0500 Subject: [PATCH] feat(admin): freshness endpoint (#73) + README Forgejo refresh (#83) + migrations doc (#72) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #73: GET /admin/scrape/freshness — last_success/age/freshness/status для 5 источников (kn/objective/nspd/nspd_geo/cadastre) + overall_status (worst по critical). Prod-verified имена таблиц, FILTER/COALESCE корректны, CAST(:d AS interval). +4 теста. #83: README — GitHub→Forgejo CI/CD, секция 6 subagents, ссылки перемаплены. #72: docs/Schema_Migrations_vs_Alembic.md — _schema_migrations паттерн (не Alembic), рекомендация снести пустой alembic-скелет (отдельный PR). Closes #73 Closes #83 Closes #72 --- README.md | 82 ++++--- backend/app/api/v1/admin_scrape.py | 229 ++++++++++++++++++ .../api/v1/test_admin_scrape_freshness.py | 197 +++++++++++++++ docs/Schema_Migrations_vs_Alembic.md | 176 ++++++++++++++ 4 files changed, 656 insertions(+), 28 deletions(-) create mode 100644 backend/tests/api/v1/test_admin_scrape_freshness.py create mode 100644 docs/Schema_Migrations_vs_Alembic.md diff --git a/README.md b/README.md index 8e9f0d55..ea88dc1e 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,9 @@ --- -## Текущий статус (май 2026) +## Текущий статус (июнь 2026) -**Live:** [https://gendsgn.ru/](https://gendsgn.ru/) — HTTP/2+HTTP/3, валидный TLS, [/health](https://gendsgn.ru/health) `{"status":"ok"}`. Полный CI/CD: GitHub Actions → GHCR → SSH-deploy на Beget VPS. Sentry активен (release tracking привязан к git-sha). +**Live:** [https://gendsgn.ru/](https://gendsgn.ru/) — HTTP/2+HTTP/3, валидный TLS, [/health](https://gendsgn.ru/health) `{"status":"ok"}`. Полный CI/CD: **Forgejo Actions** ([git.gendsgn.ru](https://git.gendsgn.ru)) → GHCR → SSH-deploy на Beget VPS (миграция с GitHub Actions 16 мая 2026). Sentry/GlitchTip активен (release tracking привязан к git-sha). | Слой | Статус | |---|---| @@ -25,7 +25,7 @@ | Concept page (`/concept`) — Stage 1 Generative | 🟡 skeleton | | Site Finder (`/site-finder`) — Stage 2 | 🔴 заглушка | -**Backlog:** [GitHub Issues](https://github.com/lekss361/-gendesign/issues) (трекаемые задачи с PR-связью) + [Obsidian vault](https://obsidian.gendsgn.ru) (планы, ADR, research — см. ниже про разделение). +**Backlog:** [Forgejo Issues](https://git.gendsgn.ru/lekss361/gendesign/issues) (трекаемые задачи с PR-связью) + [Obsidian vault](https://obsidian.gendsgn.ru) (планы, ADR, research — см. ниже про разделение). --- @@ -65,8 +65,12 @@ docs/ стратегические снапшоты (акту memory/ legacy JSONL knowledge graph (deprecated, не обновляется — данные в Obsidian) docker-compose.prod.yml main стек (backend, frontend, postgres, redis, worker, beat, caddy) docker-compose.obsidian.yml obsidian-стек (CouchDB) — деплоится отдельно -.github/workflows/ - ├── deploy.yml main → пересборка backend/frontend образов + SSH deploy +.forgejo/workflows/ (Forgejo Actions — основной CI/CD после миграции 16.05.2026) + ├── ci.yml lint (ruff) + mypy + pytest на PR + ├── deploy.yml main → пересборка backend/frontend образов + auto-apply data/sql/*.sql + SSH deploy + ├── deploy-tradein.yml tradein-mvp стек (отдельный пайплайн) + └── stale-claims.yml авто-снятие протухших claim-меток в bot-пайплайне +.github/workflows/ (остаточные — только obsidian-стек на GitHub) └── deploy-obsidian.yml obsidian-стек (CouchDB compose changes + bootstrap) ``` @@ -104,12 +108,18 @@ docker-compose.obsidian.yml obsidian-стек (CouchDB) — деплоится **SSH-only по ключу к non-root пользователю `gendesign`**, fail2ban активен. UFW: 22/80/443. -**GitHub Actions deploys:** +**Forgejo Actions deploys** (self-hosted `git.gendsgn.ru`, мигрировано с GitHub Actions 16.05.2026): -- [`.github/workflows/deploy.yml`](.github/workflows/deploy.yml) — main: триггер на `backend/**`, `frontend/**`, `Caddyfile`, `docker-compose.prod.yml`. Build backend lean + worker-with-chromium + frontend → push в приватный GHCR → SSH `git reset --hard`, sed `SENTRY_RELEASE=$IMAGE_TAG` в `backend/.env.runtime`, `compose pull && up -d`, `caddy reload`, `curl /health`. -- [`.github/workflows/deploy-obsidian.yml`](.github/workflows/deploy-obsidian.yml) — obsidian: триггер на `docker-compose.obsidian.yml`, `scripts/setup-couchdb.sh`, `docs/obsidian-livesync.md`. Без сборки образов (couchdb:3 с DockerHub), SSH `compose up -d` + idempotent bootstrap (CORS, DB, лимиты). +- [`.forgejo/workflows/ci.yml`](.forgejo/workflows/ci.yml) — на PR: ruff lint + mypy (selective strict) + pytest. Блокирует merge при провале. +- [`.forgejo/workflows/deploy.yml`](.forgejo/workflows/deploy.yml) — main: триггер на `backend/**`, `frontend/**`, `Caddyfile`, `docker-compose.prod.yml`, `data/sql/**`. Build backend lean + worker-with-chromium + frontend → push в приватный GHCR → SSH `git reset --hard`, **auto-apply pending `data/sql/NN_*.sql` через `_schema_migrations`** (idempotent, см. ниже про миграции), sed `SENTRY_RELEASE=$IMAGE_TAG` в `backend/.env.runtime`, `compose pull && up -d`, `caddy reload`, `curl /health`. +- [`.forgejo/workflows/deploy-tradein.yml`](.forgejo/workflows/deploy-tradein.yml) — tradein-mvp стек (отдельный пайплайн). +- [`.github/workflows/deploy-obsidian.yml`](.github/workflows/deploy-obsidian.yml) — obsidian (**остался на GitHub**): триггер на `docker-compose.obsidian.yml`, `scripts/setup-couchdb.sh`, `docs/obsidian-livesync.md`. Без сборки образов (couchdb:3 с DockerHub), SSH `compose up -d` + idempotent bootstrap (CORS, DB, лимиты). -**GitHub Secrets:** `DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_SSH_KEY`, `DEPLOY_PORT`. Сервер авторизуется в GHCR однократно через PAT с `read:packages`. `COUCHDB_USER`/`COUCHDB_PASSWORD` — в `backend/.env.runtime` на VPS (не в репе). +**Forgejo Secrets / Variables:** `DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_SSH_KEY`, `DEPLOY_PORT`. Сервер авторизуется в GHCR однократно через PAT с `read:packages`. `COUCHDB_USER`/`COUCHDB_PASSWORD` — в `backend/.env.runtime` на VPS (не в репе). + +### Миграции БД — `_schema_migrations`, не Alembic + +Схема развивается через **идемпотентные `data/sql/NN_*.sql`** (`CREATE … IF NOT EXISTS`, guarded `DO`-блоки). `deploy.yml` перед `compose up` прогоняет файлы в лексикографическом порядке, отмечая применённые в таблице-трекере `_schema_migrations` (применяется ровно один раз каждый). Каталог `backend/alembic/versions/` **пуст** — Alembic в проде не используется. Подробности и граница «raw SQL vs ORM» — [`docs/Schema_Migrations_vs_Alembic.md`](docs/Schema_Migrations_vs_Alembic.md). **Триггеры миграции на Yandex Cloud:** 3-й платящий пилот / Раунд А / госзаказ (нужен реестр ПО) / БД >10 GB / >50 RPS. На YC ~10–15 тыс ₽/мес. @@ -128,14 +138,14 @@ docker-compose.obsidian.yml obsidian-стек (CouchDB) — деплоится |---|---|---|---| | **0** ✅ | нед. 1 | Скелет монорепо, CI, прод | curl /health = ok | | **Sprint 1** ✅ | конец апр 2026 | NSPD-скрейпер + per-bucket эластичность + кадастр cross-check + Sentry release tracking | recommend_mix Tier 3 в проде | -| **1a** | нед. 2–3 | Shapely-сервис, отступы, API-контракт `/concept` ([#5](https://github.com/lekss361/-gendesign/issues/5)) | GeoJSON → 3 заглушки | -| **1b** | нед. 4–5 | Greedy-алгоритм, 3 стратегии, STRtree ([#12](https://github.com/lekss361/-gendesign/issues/12)) | 3 варианта ≤15с на 1 га | -| **1c** | нед. 6–7 | ТЭП + финмодель + PDF/Excel/DXF ([#6](https://github.com/lekss361/-gendesign/issues/6)) | Продаваемый артефакт | -| **2a** | нед. 8–9 | ПКК-парсер + sales_tracker ([#7](https://github.com/lekss361/-gendesign/issues/7)) | ≥1000 участков ЕКБ | -| **2b** | нед. 10–11 | Скоринг + API + UI Site Finder ([#8](https://github.com/lekss361/-gendesign/issues/8)) | Карта с цветовой градацией | -| **2c** | нед. 12 | Site Finder ↔ Generative + реестр ПО ([#9](https://github.com/lekss361/-gendesign/issues/9)) | 3 платящих пилота | +| **1a** | нед. 2–3 | Shapely-сервис, отступы, API-контракт `/concept` ([#5](https://git.gendsgn.ru/lekss361/gendesign/issues/5)) | GeoJSON → 3 заглушки | +| **1b** | нед. 4–5 | Greedy-алгоритм, 3 стратегии, STRtree ([#12](https://git.gendsgn.ru/lekss361/gendesign/issues/12)) | 3 варианта ≤15с на 1 га | +| **1c** | нед. 6–7 | ТЭП + финмодель + PDF/Excel/DXF ([#6](https://git.gendsgn.ru/lekss361/gendesign/issues/6)) | Продаваемый артефакт | +| **2a** | нед. 8–9 | ПКК-парсер + sales_tracker ([#7](https://git.gendsgn.ru/lekss361/gendesign/issues/7)) | ≥1000 участков ЕКБ | +| **2b** | нед. 10–11 | Скоринг + API + UI Site Finder ([#8](https://git.gendsgn.ru/lekss361/gendesign/issues/8)) | Карта с цветовой градацией | +| **2c** | нед. 12 | Site Finder ↔ Generative + реестр ПО ([#9](https://git.gendsgn.ru/lekss361/gendesign/issues/9)) | 3 платящих пилота | -Аналитический backlog: [#17](https://github.com/lekss361/-gendesign/issues/17) velocity-anomaly, [#18](https://github.com/lekss361/-gendesign/issues/18) ekb_districts median refresh, [#19](https://github.com/lekss361/-gendesign/issues/19) competitor-360, [#20](https://github.com/lekss361/-gendesign/issues/20) mortgage-rate sensitivity, [#21](https://github.com/lekss361/-gendesign/issues/21) cad_buildings filters в Site Finder. +Аналитический backlog: [#17](https://git.gendsgn.ru/lekss361/gendesign/issues/17) velocity-anomaly, [#18](https://git.gendsgn.ru/lekss361/gendesign/issues/18) ekb_districts median refresh, [#19](https://git.gendsgn.ru/lekss361/gendesign/issues/19) competitor-360, [#20](https://git.gendsgn.ru/lekss361/gendesign/issues/20) mortgage-rate sensitivity, [#21](https://git.gendsgn.ru/lekss361/gendesign/issues/21) cad_buildings filters в Site Finder. --- @@ -164,28 +174,43 @@ docker-compose.obsidian.yml obsidian-стек (CouchDB) — деплоится **Legacy:** старый knowledge graph JSONL (`memory/memory-gendesign.jsonl`) — снапшот на момент 2026-05-11, не обновляется. Все новые факты пишутся в Obsidian. -### Где вести задачи — GitHub Issues vs Obsidian +### Где вести задачи — Forgejo Issues vs Obsidian -Используем **оба**, но с чёткой ролью: +Используем **оба**, но с чёткой ролью (трекер мигрирован с GitHub на self-hosted Forgejo 16.05.2026): -| | GitHub Issues | Obsidian vault | +| | Forgejo Issues ([git.gendsgn.ru](https://git.gendsgn.ru/lekss361/gendesign/issues)) | Obsidian vault | |---|---|---| | Что сюда | Bug + Feature + Refactor — всё с **связью с кодом / PR** | Knowledge: research, ADR, planning, draft-ideas | | Lifecycle | Open → Assigned → PR → Merged → Closed | Append-only журнал решений и контекста | -| Уведомления | GitHub email/mobile + integrations | Никаких (читаешь когда сам пришёл) | -| Поиск | GitHub search + labels + milestones | BM25 + tags + Graph + MOC | +| Уведомления | Forgejo email/webhook | Никаких (читаешь когда сам пришёл) | +| Поиск | Forgejo search + labels + milestones | BM25 + tags + Graph + MOC | | Видимость | Команде в reviewer'ах | Всем у кого есть vault password | | Сроки | Milestones (12-week roadmap) | Frontmatter `date:`, `status:`, `due:` | -**Правило перехода:** Obsidian `fixes/Bug_X.md` со `status: OPEN` → когда требует PR — создаём **GitHub Issue**, ссылку прописываем во frontmatter `.md` (`github_issue: 42`). Bug-исследование живёт в vault'е, action item — в Issues. +**Правило перехода:** Obsidian `fixes/Bug_X.md` со `status: OPEN` → когда требует PR — создаём **Forgejo Issue**, ссылку прописываем во frontmatter `.md` (`issue: 42`). Bug-исследование живёт в vault'е, action item — в Issues. **Что куда:** -- `Bug_*` (vault) + GitHub Issue с тегом `bug` → обнаружен баг, фикс через PR -- `decisions/*` (vault) — ADR, не нужен GitHub Issue +- `Bug_*` (vault) + Forgejo Issue с тегом `bug` → обнаружен баг, фикс через PR +- `decisions/*` (vault) — ADR, не нужен Issue - `research/*` (vault) — анализ источников, конкуренты, метрики - `sessions/*` (vault) — журналы рабочих сессий -- GitHub Issues `feat/*` — фичи + PR на 1+ files, видимые в milestones +- Forgejo Issues `feat/*` — фичи + PR на 1+ files, видимые в milestones + +### Claude Code subagents (`.claude/agents/`) + +Разработка ведётся через Claude Code с **6 кастомными subagent'ами** (path-scoped, со своими MCP-«учётками»: прод-PostgreSQL, Obsidian vault, Forgejo, GlitchTip). Полные правила — `.claude/rules/` + корневой [`CLAUDE.md`](CLAUDE.md). + +| Agent | Scope / зона ответственности | +|---|---| +| `tech-analyst` | Нечёткая / cross-domain задача → план перед кодом | +| `backend-engineer` | `backend/app/**` — FastAPI, Celery, scrapers, services | +| `frontend-engineer` | `frontend/src/**` — Next.js 15, React 19, hooks | +| `database-expert` | `data/sql/**.sql`, схема, EXPLAIN ANALYZE, прод-БД через MCP | +| `devops-engineer` | `docker-compose*.yml`, `Caddyfile`, `.forgejo/workflows/**` | +| `code-reviewer` | Pre-push lint: security, correctness, conventions | + +**Workflow:** тривиально (typo, 1-line) → main session; single-domain → профильный worker; cross-domain → `tech-analyst` сначала. Worker → `code-reviewer` → коммит → push → PR в Forgejo. Branch + PR обязательны, никаких direct push в main. --- @@ -195,10 +220,11 @@ docker-compose.obsidian.yml obsidian-стек (CouchDB) — деплоится |---|---| | 🌐 Production | https://gendsgn.ru/ | | 🩺 Health | https://gendsgn.ru/health | -| 📚 OpenAPI | https://gendsgn.ru/openapi.json · [Swagger UI](https://gendsgn.ru/docs) | +| 📚 OpenAPI | [Swagger UI](https://gendsgn.ru/docs) · https://gendsgn.ru/openapi.json (за Caddy basic_auth) | | 🗂 Knowledge vault | https://obsidian.gendsgn.ru (требуется Obsidian + Self-hosted LiveSync, см. vault `meta/01_connect_devices.md`) | -| Backlog | [Issues](https://github.com/lekss361/-gendesign/issues) · [Milestones](https://github.com/lekss361/-gendesign/milestones) | -| Деплой | [GitHub Actions](https://github.com/lekss361/-gendesign/actions) · [GHCR пакеты](https://github.com/lekss361?tab=packages) | +| 🧰 Git / CI | [git.gendsgn.ru](https://git.gendsgn.ru/lekss361/gendesign) (Forgejo) · [`CLAUDE.md`](CLAUDE.md) — правила разработки | +| Backlog | [Issues](https://git.gendsgn.ru/lekss361/gendesign/issues) · [Milestones](https://git.gendsgn.ru/lekss361/gendesign/milestones) | +| Деплой | [Forgejo Actions](https://git.gendsgn.ru/lekss361/gendesign/actions) · [GHCR пакеты](https://github.com/lekss361?tab=packages) | | Мониторинг | [Sentry](https://qqq-5y.sentry.io/insights/projects/gendesign-backend/) | | Инфра | Beget VPS [`cp.beget.com/cloud/...`](https://cp.beget.com/cloud/servers/jolly-yakov-3) · [DNS](https://cp.beget.com/dns/14193206) | diff --git a/backend/app/api/v1/admin_scrape.py b/backend/app/api/v1/admin_scrape.py index 70c8b2fb..52307e74 100644 --- a/backend/app/api/v1/admin_scrape.py +++ b/backend/app/api/v1/admin_scrape.py @@ -11,6 +11,7 @@ Returns the Celery task id; poll /api/v1/admin/scrape/runs/{run_id} to track DB- from __future__ import annotations +from datetime import UTC, datetime from typing import Annotated, Any from fastapi import APIRouter, Depends, HTTPException @@ -24,6 +25,12 @@ from app.core.db import get_db router = APIRouter() +def _utcnow() -> datetime: + """Timezone-aware UTC now. timestamptz-колонки приходят aware, поэтому naive + datetime.utcnow() дал бы TypeError при вычитании — всегда aware.""" + return datetime.now(UTC) + + class TriggerKnRequest(BaseModel): region_code: int = Field(..., ge=1, le=99) developers: list[str] | None = Field(default=None, max_length=20) @@ -1166,6 +1173,228 @@ def list_all_logs( ] +# ── Data-freshness monitoring (#73 B5-3) ───────────────────────────────────── +# +# GET /api/v1/admin/scrape/freshness — для каждого источника данных: +# last_success_at / last_attempt_at / last_status / objects_updated_24h|7d / +# age_days / freshness (fresh|stale|critical) / status (ok|stale|failed). +# +# Источник истины — run-таблицы (НЕ контент-таблицы): на проде проверено, что +# идемпотентный re-scrape НЕ бампит complexes.updated_at (newest 2026-05-11 при +# kn-run 3 дня назад), поэтому "objects_updated" считаем по счётчикам прогонов +# (objects_count / quarters_ok / rows_lots / targets_done) за done-прогоны в окне. +# +# Пороги (fresh/stale/critical в днях) выведены из beat-расписания каждого job'а +# (app/workers/beat_schedule.py). Sentry-алёрты по критичным job'ам шлёт отдельный +# beat-task scrape_freshness_check (tasks.scrape_freshness_check) — endpoint только +# читает, не алертит, чтобы UI-поллинг не спамил issue. + + +class FreshnessSource(BaseModel): + """Описание одного источника данных в реестре freshness-мониторинга.""" + + source: str # стабильный ключ (kn / nspd / objective / nspd_geo / cadastre) + label: str # человекочитаемое имя для UI + table: str # run-таблица + # Колонка-счётчик "сколько объектов обновил done-прогон". Агрегируется как + # COALESCE(SUM() FILTER (...), 0) — FILTER применяется к SUM, не к COALESCE. + work_col: str + started_col: str = "started_at" + # Колонка-fallback для last_attempt когда started_at NULL (queued-but-not-run). + attempt_fallback_col: str | None = None + fresh_days: float = 7.0 # < fresh_days → fresh + stale_days: float = 30.0 # < stale_days → stale, иначе critical + critical: bool = False # критичный источник (для Sentry-алёртов в beat-таске) + + +# Реестр источников. Все 5 таблиц проверены на проде (information_schema) — +# статусы done присутствуют у kn/nspd_geo/objective/cadastre; nspd только failed. +# Пороги: +# kn — beat ~ежедневно (scrape_kn cron) → fresh<2d, stale<5d +# objective — beat ~еженедельно (objective_sync) + критичный → fresh<7d, stale<14d +# nspd — harvest_stale_quarters (пн) → fresh<8d, stale<30d +# nspd_geo — ручной bulk geo-job → fresh<7d, stale<30d +# cadastre — ручной cadastre-job → fresh<14d, stale<45d +_FRESHNESS_SOURCES: list[FreshnessSource] = [ + FreshnessSource( + source="kn", + label="DOM.РФ kn-API (ЖК)", + table="kn_scrape_runs", + work_col="objects_count", + fresh_days=2.0, + stale_days=5.0, + critical=True, + ), + FreshnessSource( + source="objective", + label="Объектив (лоты/цены)", + table="objective_scrape_runs", + work_col="rows_lots", + fresh_days=7.0, + stale_days=14.0, + critical=True, + ), + FreshnessSource( + source="nspd", + label="NSPD кварталы/здания (harvest)", + table="nspd_scrape_runs", + work_col="quarters_ok", + fresh_days=8.0, + stale_days=30.0, + ), + FreshnessSource( + source="nspd_geo", + label="NSPD geo bulk-fetcher", + table="nspd_geo_jobs", + work_col="targets_done", + attempt_fallback_col="created_at", + fresh_days=7.0, + stale_days=30.0, + ), + FreshnessSource( + source="cadastre", + label="Кадастровые jobs", + table="cadastre_jobs", + work_col="targets_done", + attempt_fallback_col="created_at", + fresh_days=14.0, + stale_days=45.0, + ), +] + + +def _classify_freshness( + age_days: float | None, fresh_days: float, stale_days: float +) -> str: + """fresh / stale / critical / unknown по возрасту последнего успешного прогона.""" + if age_days is None: + return "unknown" + if age_days < fresh_days: + return "fresh" + if age_days < stale_days: + return "stale" + return "critical" + + +@router.get("/freshness") +def scrape_freshness( + db: Annotated[Session, Depends(get_db)], +) -> dict[str, Any]: + """Свежесть данных по каждому источнику (для dashboard + alerting). + + Для каждого источника один проход по его run-таблице агрегирует: + - last_success_at — max(COALESCE(finished_at, started_at)) WHERE status='done' + - last_attempt_at — max(started_at) (или created_at fallback) по всем прогонам + - last_status — status последнего прогона + - objects_updated_24h / _7d — сумма run-счётчика по done-прогонам в окне + - age_days — возраст last_success в днях (NULL если успехов не было) + - freshness — fresh / stale / critical / unknown (по порогам источника) + - status — ok / stale / failed (для UI-светофора) + + `overall_status` агрегирует худший статус по критичным источникам. + """ + sources_out: list[dict[str, Any]] = [] + worst_critical = "ok" + severity = {"ok": 0, "stale": 1, "failed": 2} + + for src in _FRESHNESS_SOURCES: + started = src.started_col + attempt_expr = ( + f"MAX(COALESCE({started}, {src.attempt_fallback_col}))" + if src.attempt_fallback_col + else f"MAX({started})" + ) + # last_status — отдельным скаляром: status последнего по времени прогона. + order_col = ( + f"COALESCE({started}, {src.attempt_fallback_col})" + if src.attempt_fallback_col + else started + ) + # work_col / table / column-имена — из доверенного серверного реестра + # (НЕ из пользовательского ввода), поэтому f-string в SQL здесь безопасен; + # все временные границы передаются параметрами (:d1/:d7). + # FILTER применяется к SUM(...), а COALESCE(...,0) оборачивает результат — + # COALESCE(...) FILTER(...) — синтаксическая ошибка в PostgreSQL. + row = ( + db.execute( + text( + f""" + SELECT + MAX(COALESCE(finished_at, {started})) + FILTER (WHERE status = 'done') AS last_success_at, + {attempt_expr} AS last_attempt_at, + COALESCE(SUM({src.work_col}) FILTER ( + WHERE status = 'done' + AND COALESCE(finished_at, {started}) + > NOW() - CAST(:d1 AS interval) + ), 0) AS upd_24h, + COALESCE(SUM({src.work_col}) FILTER ( + WHERE status = 'done' + AND COALESCE(finished_at, {started}) + > NOW() - CAST(:d7 AS interval) + ), 0) AS upd_7d, + ( + SELECT status FROM {src.table} + ORDER BY {order_col} DESC NULLS LAST + LIMIT 1 + ) AS last_status + FROM {src.table} + """ + ), + {"d1": "24 hours", "d7": "7 days"}, + ) + .mappings() + .first() + ) + + last_success = row["last_success_at"] if row else None + last_attempt = row["last_attempt_at"] if row else None + last_status = row["last_status"] if row else None + + age_days: float | None = None + if last_success is not None: + age_seconds = (_utcnow() - last_success).total_seconds() + age_days = round(age_seconds / 86400.0, 2) + + freshness = _classify_freshness(age_days, src.fresh_days, src.stale_days) + + # status (UI-светофор): нет успехов вообще → failed; иначе мапим freshness. + if last_success is None: + status = "failed" + elif freshness == "fresh": + status = "ok" + elif freshness == "stale": + status = "stale" + else: # critical + status = "failed" + + if src.critical and severity[status] > severity[worst_critical]: + worst_critical = status + + sources_out.append( + { + "source": src.source, + "label": src.label, + "critical": src.critical, + "last_success_at": last_success.isoformat() if last_success else None, + "last_attempt_at": last_attempt.isoformat() if last_attempt else None, + "last_status": last_status, + "objects_updated_24h": int(row["upd_24h"] or 0) if row else 0, + "objects_updated_7d": int(row["upd_7d"] or 0) if row else 0, + "age_days": age_days, + "thresholds": {"fresh_days": src.fresh_days, "stale_days": src.stale_days}, + "freshness": freshness, + "status": status, + } + ) + + return { + "generated_at": _utcnow().isoformat(), + "overall_status": worst_critical, + "sources": sources_out, + } + + # ── DomRF (kn) — runs list ─────────────────────────────────────────────────── diff --git a/backend/tests/api/v1/test_admin_scrape_freshness.py b/backend/tests/api/v1/test_admin_scrape_freshness.py new file mode 100644 index 00000000..ebc1fe42 --- /dev/null +++ b/backend/tests/api/v1/test_admin_scrape_freshness.py @@ -0,0 +1,197 @@ +"""Тесты для GET /api/v1/admin/scrape/freshness (#73 B5-3). + +Эндпоинт читает 5 run-таблиц (kn / objective / nspd / nspd_geo / cadastre) и +для каждой считает свежесть. Здесь DB замокан: на каждый per-source SELECT +возвращаем заранее заданную строку, чтобы проверить классификацию +fresh/stale/critical, маппинг status (ok/stale/failed) и агрегат overall_status +по критичным источникам. +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import Any +from unittest.mock import MagicMock + +from fastapi.testclient import TestClient + +from app.api.v1 import admin_scrape +from app.core.db import get_db +from app.main import app + +ENDPOINT = "/api/v1/admin/scrape/freshness" + + +def _ago(days: float) -> datetime: + return datetime.now(UTC) - timedelta(days=days) + + +def _make_db(per_source: dict[str, dict[str, Any]]) -> MagicMock: + """Db-mock: каждый execute()'s SELECT относится к одной run-таблице; матчим по + имени таблицы в SQL и возвращаем соответствующую заранее заданную строку. + + per_source: ключ — имя run-таблицы, значение — mapping-строка (как .first()). + """ + db = MagicMock() + + def execute(stmt: Any, params: Any = None) -> Any: + sql = str(stmt) + matched: dict[str, Any] | None = None + # Матчим по FROM — самый длинный матч (nspd_geo_jobs vs nspd_*). + for table in sorted(per_source, key=len, reverse=True): + if f"FROM {table}" in sql: + matched = per_source[table] + break + result = MagicMock() + result.mappings.return_value.first.return_value = matched + return result + + db.execute = execute + return db + + +def _override_db(db: MagicMock) -> None: + def _factory(): + yield db + + app.dependency_overrides[get_db] = _factory + + +def test_freshness_mixed_statuses() -> None: + """kn свежий (ok), objective stale, nspd без успехов (failed), nspd_geo critical, + cadastre fresh. overall_status = failed (по критичному nspd_geo→failed).""" + rows = { + "kn_scrape_runs": { + "last_success_at": _ago(1.0), # < fresh_days=2 → fresh → ok + "last_attempt_at": _ago(1.0), + "upd_24h": 1500, + "upd_7d": 1500, + "last_status": "done", + }, + "objective_scrape_runs": { + "last_success_at": _ago(10.0), # fresh<7, stale<14 → stale + "last_attempt_at": _ago(10.0), + "upd_24h": 0, + "upd_7d": 325000, + "last_status": "done", + }, + "nspd_scrape_runs": { + "last_success_at": None, # нет успехов → failed + "last_attempt_at": _ago(20.0), + "upd_24h": 0, + "upd_7d": 0, + "last_status": "failed", + }, + "nspd_geo_jobs": { + "last_success_at": _ago(40.0), # >= stale_days=30 → critical → failed + "last_attempt_at": _ago(40.0), + "upd_24h": 0, + "upd_7d": 0, + "last_status": "done", + }, + "cadastre_jobs": { + "last_success_at": _ago(3.0), # < fresh_days=14 → fresh → ok + "last_attempt_at": _ago(3.0), + "upd_24h": 5, + "upd_7d": 5, + "last_status": "done", + }, + } + _override_db(_make_db(rows)) + try: + resp = TestClient(app).get(ENDPOINT) + finally: + app.dependency_overrides.clear() + + assert resp.status_code == 200, resp.text + body = resp.json() + assert "generated_at" in body + assert len(body["sources"]) == 5 + by_src = {s["source"]: s for s in body["sources"]} + + assert by_src["kn"]["status"] == "ok" + assert by_src["kn"]["freshness"] == "fresh" + assert by_src["kn"]["objects_updated_7d"] == 1500 + assert by_src["kn"]["critical"] is True + + assert by_src["objective"]["status"] == "stale" + assert by_src["objective"]["freshness"] == "stale" + + assert by_src["nspd"]["status"] == "failed" + assert by_src["nspd"]["last_success_at"] is None + assert by_src["nspd"]["age_days"] is None + + assert by_src["nspd_geo"]["status"] == "failed" + assert by_src["nspd_geo"]["freshness"] == "critical" + + assert by_src["cadastre"]["status"] == "ok" + + # overall_status — худший по критичным (kn=ok, objective=stale) → stale. + # nspd_geo критичным НЕ помечен, поэтому его failed в overall не попадает. + assert by_src["nspd_geo"]["critical"] is False + assert body["overall_status"] == "stale" + + +def test_freshness_all_healthy() -> None: + """Все источники свежие → overall_status=ok.""" + fresh_row = { + "last_success_at": _ago(0.5), + "last_attempt_at": _ago(0.5), + "upd_24h": 10, + "upd_7d": 100, + "last_status": "done", + } + rows = { + s.table: dict(fresh_row) for s in admin_scrape._FRESHNESS_SOURCES + } + _override_db(_make_db(rows)) + try: + resp = TestClient(app).get(ENDPOINT) + finally: + app.dependency_overrides.clear() + + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["overall_status"] == "ok" + assert all(s["status"] == "ok" for s in body["sources"]) + assert all(s["age_days"] is not None for s in body["sources"]) + + +def test_freshness_critical_source_failed_drives_overall() -> None: + """Критичный источник (objective) без успехов → overall_status=failed.""" + base = { + "last_success_at": _ago(0.5), + "last_attempt_at": _ago(0.5), + "upd_24h": 1, + "upd_7d": 1, + "last_status": "done", + } + rows = {s.table: dict(base) for s in admin_scrape._FRESHNESS_SOURCES} + # objective критичный — обнулим успех. + rows["objective_scrape_runs"] = { + "last_success_at": None, + "last_attempt_at": _ago(5.0), + "upd_24h": 0, + "upd_7d": 0, + "last_status": "running", + } + _override_db(_make_db(rows)) + try: + resp = TestClient(app).get(ENDPOINT) + finally: + app.dependency_overrides.clear() + + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["overall_status"] == "failed" + + +def test_classify_freshness_boundaries() -> None: + """Юнит на пороги классификатора: границы fresh/stale/critical, unknown.""" + f = admin_scrape._classify_freshness + assert f(None, 2.0, 5.0) == "unknown" + assert f(1.9, 2.0, 5.0) == "fresh" + assert f(2.0, 2.0, 5.0) == "stale" # ровно fresh_days → уже stale + assert f(4.9, 2.0, 5.0) == "stale" + assert f(5.0, 2.0, 5.0) == "critical" # ровно stale_days → critical + assert f(100.0, 2.0, 5.0) == "critical" diff --git a/docs/Schema_Migrations_vs_Alembic.md b/docs/Schema_Migrations_vs_Alembic.md new file mode 100644 index 00000000..1458c02e --- /dev/null +++ b/docs/Schema_Migrations_vs_Alembic.md @@ -0,0 +1,176 @@ +# Миграции схемы БД: `_schema_migrations`, не Alembic + +> Decision-doc по issue [#72](https://git.gendsgn.ru/lekss361/gendesign/issues/72) +> (B5-2). Дата: 2026-06-13. Статус: **ACCEPTED**. +> +> Снапшот — источник правды по решениям живёт в Obsidian vault +> (`decisions/`). Здесь — версионированная в репозитории копия рядом с кодом, +> на который она ссылается. + +## TL;DR + +- Схема прод-БД развивается через **идемпотентные `data/sql/NN_*.sql`**, которые + `deploy.yml` (Forgejo Actions) прогоняет автоматически и ровно один раз каждый, + отмечая применённые в таблице-трекере **`_schema_migrations`**. +- **Alembic в проде НЕ используется.** `backend/alembic/versions/` содержит только + `.gitkeep` — за всё время не создано ни одной ревизии. `alembic upgrade head` + на проде — no-op (нет ревизий), а `alembic revision --autogenerate` рассинхронизирован + с реальной схемой (модели в `app/models/` описывают лишь крошечную часть из ~50 таблиц). +- **Рекомендация (принята): удалить пустой Alembic-скелет** (`backend/alembic/`, + `backend/alembic.ini`, dep `alembic` в `pyproject.toml`) — он не несёт функции и + вводит в заблуждение («есть Alembic → значит миграции через него»). Если в будущем + понадобится ORM-driven migration tool — переустановить осознанно. +- Формулировка из тела issue про «синхронизацию состояния Alembic с прод» + (`alembic current` / `alembic stamp head` / catch-up migration) **неактуальна**: + синхронизировать нечего — Alembic никогда не был источником схемы. + +## Как реально работают миграции + +### Артефакт: `data/sql/NN_*.sql` + +~118 файлов (на 2026-06-13), нумерованных `NN_описание.sql` (`01_…`, `144_…`). +Каждый — самодостаточный idempotent DDL/ETL-фрагмент: + +```sql +-- 144_audit_log.sql +-- Deploy: auto-applied deploy.yml через _schema_migrations (ровно один раз NN=144). +-- Idempotent: CREATE TABLE/INDEX IF NOT EXISTS + guarded DO-блок для CHECK. +BEGIN; +CREATE TABLE IF NOT EXISTS audit_log ( ... ); +COMMENT ON TABLE audit_log IS '...'; +COMMIT; +``` + +Правила для нового файла: + +1. **Имя** — следующий свободный номер + snake_case описание: `NNN_<что>.sql`. + Порядок применения = лексикографическая сортировка `ls -1 | sort`, поэтому + зануляй ширину при необходимости (`089_…` vs `89_…` сортируются по-разному — + придерживайся существующей ширины в каталоге). +2. **Идемпотентность** — `CREATE … IF NOT EXISTS`, `ADD COLUMN IF NOT EXISTS`, + `CREATE OR REPLACE VIEW`, `ON CONFLICT DO NOTHING`, guarded `DO $$ … $$`-блоки + для того, что не поддерживает `IF NOT EXISTS` (CHECK-констрейнты, ENUM-значения). + Файл может выполниться повторно вручную без вреда (хотя deploy и так пропустит + уже применённый). +3. **psycopg v3 / SQLAlchemy** — в коде приложения только `CAST(:x AS type)`, + никогда `:x::type` (см. `.claude/rules/backend.md`). В самих `.sql`-файлах, + которые гоняет `psql`, `::type`-каст допустим — это чистый psql, не через + SQLAlchemy bind-params. +4. **Транзакционность** — оборачивай связанные изменения в `BEGIN; … COMMIT;`. + `psql -v ON_ERROR_STOP=on`: при ошибке деплой падает (`exit 1`) и контейнеры + НЕ обновляются (схема и образы остаются консистентны). + +### Трекер: таблица `_schema_migrations` + +```sql +CREATE TABLE IF NOT EXISTS _schema_migrations ( + filename TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +`filename` — basename файла (`144_audit_log.sql`). Применённость = наличие строки. + +### Раннер: `.forgejo/workflows/deploy.yml` + +После `git reset --hard origin/main` и `compose pull`, ДО `compose up -d`: + +```bash +# 1) гарантировать трекер +psql … -c "CREATE TABLE IF NOT EXISTS _schema_migrations ( filename TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() );" + +# 2) применить каждый ещё-не-применённый файл в порядке сортировки +for sql_file in $(ls -1 data/sql/*.sql 2>/dev/null | sort); do + fname=$(basename "$sql_file") + applied=$(psql … -tAc "SELECT COUNT(*) FROM _schema_migrations WHERE filename='$fname'") + if [ "$applied" = "0" ]; then + echo "→ Applying migration: $fname" + psql … -v ON_ERROR_STOP=on < "$sql_file" \ + || { echo "FAILED on migration: $fname"; exit 1; } + psql … -c "INSERT INTO _schema_migrations (filename) VALUES ('$fname') ON CONFLICT DO NOTHING;" + else + echo "✓ Already applied: $fname" + fi +done +``` + +Ключевые свойства: + +- **Только `*.sql`** трекаются/применяются. `data/sql/*.py` и `*.sh` (ad-hoc + загрузчики, напр. `02_load_all_quarters.sh`, `40_scrape_history.py`) раннер + игнорирует — они запускаются вручную. +- **Идемпотентность на двух уровнях**: трекер пропускает уже применённое; сам SQL + безопасен к повторному прогону (на случай ручного запуска или восстановления). +- **Fail-fast**: первый упавший файл останавливает деплой, образы не катятся. +- **Триггер деплоя**: путь `data/sql/**` в `on.push.paths` `deploy.yml` (правка + только `docs/**` деплой НЕ триггерит — см. `.claude/rules/deploy.md`). + +### Bootstrap (исторический, разовый) + +`scripts/bootstrap_schema_migrations.sh` сидировал `_schema_migrations` всеми +файлами, уже применёнными на проде ДО введения auto-apply (PR #150) — чтобы первый +auto-deploy не пытался переиграть всю историю. Запускался один раз вручную через +SSH-туннель. Для новых файлов больше не нужен — deploy.yml сам ведёт трекер. + +## Почему raw SQL, а не Alembic + +| | `data/sql/NN_*.sql` (используется) | Alembic (скелет, не используется) | +|---|---|---| +| Что описывает | Реальную прод-схему (~50 таблиц, partitions, GIST, materialized views, PostGIS, FDW, роли, гранты) | Только `Base.metadata` из `app/models/` — крошечная часть (1 модуль `parcel`) | +| PostGIS / big views / partitions | Нативно — пишем DDL как есть | autogenerate их не видит/ломает (geoalchemy типы, `CREATE MATERIALIZED VIEW`, partitioning) | +| Идемпотентность | Встроена (`IF NOT EXISTS`, guarded `DO`) | Линейная цепочка ревизий, down/up | +| Источник истины | Файлы в репе + `_schema_migrations` | `alembic_version` (в проде таблицы нет) | +| Применение | `deploy.yml` авто, fail-fast | Не подключён к деплою | + +Проект на старте — data-heavy: партиционированный `rosreestr_deals` (~7M строк), +PostGIS-геометрии, materialized views, cross-DB FDW в tradein, роли/гранты. Это +**deployment artifacts**, которые естественно описывать прямым DDL. ORM-модели +покрывают лишь endpoints приложения, а не аналитический слой. Alembic autogenerate +на такой схеме генерирует мусор (drop неизвестных ему объектов) — поэтому им и не +пользовались. + +## Граница: что куда + +- **`data/sql/NN_*.sql`** — ВСЁ изменение схемы прод-БД сейчас идёт сюда: + - новые таблицы / колонки / индексы / констрейнты; + - materialized views и обычные views (`v_*`, `mv_*`); + - partitions `rosreestr_deals`; + - роли, гранты, FDW-bootstrap; + - data-backfill, который должен прогоняться ровно один раз. + - Владелец правок: **`database-expert`** subagent (см. `.claude/rules/sql.md`). +- **Alembic** — НЕ используется. (Гипотетически: если бы в будущем понадобилась + чистая ORM-driven эволюция небольшого набора app-таблиц с автогенерацией — это + был бы кандидат. Сейчас такой потребности нет, и смешение двух раннеров на одной + схеме опаснее единого пути.) + +## Рекомендация по пустому Alembic-скелету + +**Удалить**, чтобы убрать ложный сигнал «миграции через Alembic»: + +- `backend/alembic/` (env.py, script.py.mako, versions/.gitkeep) +- `backend/alembic.ini` +- dep `alembic>=1.13.0` из `backend/pyproject.toml` + пересборка лок-файла + (`cd backend && uv lock`) +- таргеты `make migrate` (`alembic upgrade head`) и `make migration NAME=…` + (`alembic revision --autogenerate`) в корневом `Makefile` — оба зовут Alembic + и сейчас вводят в заблуждение (на проде upgrade — no-op, autogenerate + рассинхронизирован со схемой). Заменить на `make migrate-status` поверх + `_schema_migrations` либо удалить. + +> Это правка `pyproject.toml` + удаление файлов под `backend/` — выполняется +> отдельным PR (`chore(backend): drop unused Alembic skeleton`), вне scope чисто +> документационного #72, чтобы не смешивать docs-only изменение с правкой +> зависимостей/Dockerfile-кэша. Здесь — зафиксированное **решение**; реализацию +> сноса делает backend-engineer отдельным PR после approve этого decision-doc. + +Альтернатива (если решат оставить Alembic «на будущее»): держать скелет, но +добавить в `backend/alembic/README` явную пометку «NOT WIRED — схема через +`data/sql` + `_schema_migrations`, см. `docs/Schema_Migrations_vs_Alembic.md`», +чтобы новый разработчик не пытался `alembic upgrade head`. + +## Ссылки + +- Раннер: `.forgejo/workflows/deploy.yml` (шаг "Apply pending SQL migrations") +- Bootstrap: `scripts/bootstrap_schema_migrations.sh` +- Конвенции SQL/деплоя: `.claude/rules/sql.md`, `.claude/rules/deploy.md`, `.claude/rules/backend.md` +- Скелет (к удалению): `backend/alembic/`, `backend/alembic.ini`