sync
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
# План работы RF4 Spotter — Регрессионный аудит
|
||||
|
||||
На основе: [REGRESSION_AUDIT_2026-09-09.md](../docs/REGRESSION_AUDIT_2026-09-09.md)
|
||||
Дата: 2026-09-09
|
||||
|
||||
## Выполнено
|
||||
|
||||
### R01 ✅ — Caddy body_limit → request_body max_size
|
||||
- **Файл**: `deploy/Caddyfile`
|
||||
- **Проблема**: `body_limit 10M` не поддерживается в Caddy 2.10.2
|
||||
- **Решение**: `request_body { max_size 10M }`
|
||||
- **Верификация**: `caddy adapt` проходит без ошибок
|
||||
|
||||
### R02 ✅ — Activity API contract regression
|
||||
- **API**: `main.py` — `/api/v1/activity` возвращает `PaginatedActivityOut`
|
||||
- **Frontend**: все 4 потребителя обновлены:
|
||||
- `index.astro` ✅ (из предыдущего коммита)
|
||||
- `fish/[slug].astro` ✅
|
||||
- `waterbodies/[slug].astro` ✅
|
||||
- `waterbodies/[slug]/[fish].astro` ✅
|
||||
- `spots/[id].astro` ✅
|
||||
- **Тесты**: 4 теста обновлены под новый контракт
|
||||
- **Результат**: 76 passed, 1 skipped, 0 failures
|
||||
|
||||
### R14 ✅ — Registry источников больше не зависит от БД при парсинге CLI
|
||||
- **Файл**: `community_scheduler.py`
|
||||
- **Решение**: `_static_registry()` — без БД; `configured_sources(enabled_keys)` — с опциональной БД
|
||||
- **Тесты**: scheduler unit-тесты проходят без PostgreSQL
|
||||
|
||||
## Итоги тестов
|
||||
- **Python**: 76 passed, 1 skipped ✅
|
||||
- **Astro check**: 0 errors, 0 warnings, 0 hints ✅
|
||||
- **Caddy adapt**: passes ✅
|
||||
|
||||
## Коммиты
|
||||
1. `a37f9c4` — R01 Caddy body_limit → request_body
|
||||
2. `d962ba2` — R02 activity API contract + R14 static registry
|
||||
|
||||
## Осталось из регрессий (R03-R15)
|
||||
- R03: Research CLI cooldown broken (fcntl `r`/`r+` + encoding)
|
||||
- R04: Disabled-фильтрация обходит site cooldown
|
||||
- R05: Bootstrap небезопасен для источников
|
||||
- R06: Фильтры сигналов сравнивают slug с названием
|
||||
- R07: Ошибка главной остаётся HTTP 200
|
||||
- R08: Фильтры UI не доведены до responsive-состояния
|
||||
- R09: Пагинация (частично исправлено в U03)
|
||||
- R10: Ошибки формы теряют черновик
|
||||
- R11: Проверка URL после сетевого обращения
|
||||
- R12: Мониторинг не сигнализирует о зависшем импорте
|
||||
- R13: Доверие к IP клиента не ограничено proxy
|
||||
- R15: D04/D06/D07/D08 выполнены не полностью
|
||||
File diff suppressed because it is too large
Load Diff
Generated
+5
@@ -0,0 +1,5 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Environment-dependent path to Maven home directory
|
||||
/mavenHomeManager.xml
|
||||
Generated
+266
@@ -0,0 +1,266 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GigaCodeAgentSettings">
|
||||
<option name="autoApproveEdits" value="true" />
|
||||
<option name="autoApprovedCommands">
|
||||
<list>
|
||||
<Execute>
|
||||
<option name="pattern" value="mkdir -p /home/ik/git/rf4-help/.gigacode/plans" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="cd *" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="ls *" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="pip install *" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="tail *" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="python pytest *" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="python -c " import ast import sys files = [ 'apps/api/app/main.py', 'apps/api/app/readiness.py', 'apps/api/app/schemas.py', 'apps/api/app/activity.py', 'apps/api/app/community_scheduler.py', 'apps/api/app/community_importer.py', 'rf4_research/community_cli.py', ] for f in files: try: with open(f) as fh: ast.parse(fh.read()) print(f'OK: {f}') except SyntaxError as e: print(f'ERROR: {f}: {e}') sys.exit(1) print('All Python files syntax OK') "" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="npm run *" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="head *" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="npm install *" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="git add *" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="git reset *" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="git diff *" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="git log *" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="git commit *" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="git status" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="echo *" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="printf 'localhost {\n body_limit 10M\n}\n' > /tmp/test-caddyfile" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="cp /home/ik/git/rf4-help/deploy/Caddyfile /tmp/Caddyfile" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="printf 'example.com {\n body_limit 10M\n}\n' > /tmp/Caddyfile" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="printf 'example.com {\n request_body {\n max_size 10M\n }\n}\n' > /tmp/Caddyfile" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="docker run *" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="sed 's/email {$ACME_EMAIL}/email test@test.com/' /home/ik/git/rf4-help/deploy/Caddyfile > /tmp/Caddyfile" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="grep *" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="printf 'example.com {\n request_body {\n max_size 10M\n }\n handle {\n respond \"ok\"\n }\n}\n' > /tmp/Caddyfile" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="python -c "import ast; ast.parse(open('rf4_research/community_cli.py').read()); print('OK')"" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="python -c "import ast; ast.parse(open('apps/api/app/community_scheduler.py').read()); print('OK')"" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="python -c "import ast; ast.parse(open('apps/api/app/main.py').read()); print('OK')"" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="node -e "try { fetch('http://localhost:1', { signal: AbortSignal.timeout(100) }); } catch(e) { console.log(e.constructor.name, e.message); }" 2>&1" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="node -e "fetch('http://httpbin.org/delay/10', { signal: AbortSignal.timeout(200) }).catch(e => console.log(e.constructor.name, e.message));" 2>&1" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="pip-compile requirements.txt --output-file requirements-lock.txt -q 2>&1" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="pip-compile requirements-dev.txt --output-file requirements-dev-lock.txt -q 2>&1" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="wc *" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="alembic revision *" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="python -m app.cli --help 2>&1" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="python -m rf4_research.community_cli --help 2>&1" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="alembic heads *" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="python3 -c " from urllib.request import Request, urlopen from urllib.error import HTTPError # Test with a URL that redirects try: req = Request('https://httpbin.org/redirect/1', headers={'User-Agent': 'test'}) resp = urlopen(req, timeout=5) print(f'Final URL: {resp.url}') print(f'Response URL: {resp.url}') except Exception as e: print(f'Error: {type(e).__name__}: {e}') " 2>&1" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="pwd" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="python3" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="python3 -c "from rf4_research import community_cli; print('Syntax OK')"" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="python3 /tmp/append_tests.py" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="python3 pytest *" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="python3 /tmp/fix_redirect_test.py" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="python3 /tmp/fix_a04.py" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="python3 /tmp/fix_a04_v2.py" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="git show *" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="git branch *" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="python3 /tmp/test_double_reservation_bug.py" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="python3 /tmp/fix_a02_double_reservation.py" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="python3 /tmp/test_debug.py" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="python3 /tmp/test_a02_final.py" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="python3 /tmp/test_a02_v2.py" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="python3 /tmp/test_a02_code.py" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="python3 /tmp/fix_a04_pagination.py" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="python3 /tmp/fix_a01_monitoring.py" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="python3 /tmp/fix_a01_tests.py" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="*" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="python3 /tmp/fix_a01_tests_v2.py" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="python3 /tmp/fix_a04_filters.py" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="python3 /tmp/fix_a05_sessionstorage.py" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="python3 /tmp/fix_a06_tests.py" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="python3 /tmp/fix_a07_review_note.py" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="python3 /tmp/fix_a08_errorpage.py" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="python3 /tmp/fix_a08_index.py" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="python3 /tmp/fix_a11_pip_audit.py" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="python3 /tmp/fix_a04_pagination_final.py" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="python3 /tmp/fix_a08_index_unavailable.py" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="python3 /tmp/fix_a08_spots.py" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="python3 /tmp/fix_a08_records.py" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="python3 /tmp/fix_a01_vars.py" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="python3 /tmp/fix_a01_test.py" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="python3 /tmp/fix_a07_auto_publish.py" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="python3 /tmp/fix_a07_review_note_auto.py" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="python3 /tmp/fix_a09_cli.py" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="python3 -c "from app.cli import main; import sys; sys.argv = ['cli', '--help']; main()" 2>&1" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="find *" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="git push *" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="python -c " from sqlalchemy import create_engine, select from sqlalchemy.orm import Session from sqlalchemy.pool import StaticPool from app.database import Base from app.models import SubmissionAttempt import hashlib, hmac engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool) Base.metadata.create_all(engine) with Session(engine) as db: # Simulate first request key = 'idem-test-key-001' key_hash = hmac.new('change-rate-limit-secret'.encode(), key.encode(), hashlib.sha256).hexdigest() print(f'Key hash: {key_hash}') # Check before storing existing = db.scalar(select(SubmissionAttempt).where(SubmissionAttempt.idempotency_key == key_hash)) print(f'Found before: {existing}') # Store from datetime import datetime, timezone db.add(SubmissionAttempt(client_hash='test', idempotency_key=key_hash, created_at=datetime.now(timezone.utc))) db.commit() # Check after storing existing = db.scalar(select(SubmissionAttempt).where(SubmissionAttempt.idempotency_key == key_hash)) print(f'Found after: {existing}') if existing: print(f' idempotency_key: {existing.idempotency_key}') "" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="python -c " from sqlalchemy import create_engine, select from sqlalchemy.orm import Session from sqlalchemy.pool import StaticPool from app.database import Base from app.models import SubmissionAttempt import hashlib, hmac engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool) Base.metadata.create_all(engine) with Session(engine) as db: key = 'idem-test-key-001' key_hash = hmac.new('change-rate-limit-secret'.encode(), key.encode(), hashlib.sha256).hexdigest() print(f'Key hash: {key_hash}') existing = db.scalar(select(SubmissionAttempt).where(SubmissionAttempt.idempotency_key == key_hash)) print(f'Found before: {existing}') from datetime import datetime, timezone db.add(SubmissionAttempt(client_hash='test', idempotency_key=key_hash, created_at=datetime.now(timezone.utc))) db.commit() existing = db.scalar(select(SubmissionAttempt).where(SubmissionAttempt.idempotency_key == key_hash)) print(f'Found after: {existing}') if existing: print(f' idempotency_key: {existing.idempotency_key}') "" />
|
||||
</Execute>
|
||||
<Execute>
|
||||
<option name="pattern" value="python -c " from sqlalchemy import create_engine, inspect, text from sqlalchemy.pool import StaticPool from app.database import Base from app.models import SubmissionAttempt engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool) Base.metadata.create_all(engine) inspector = inspect(engine) columns = inspector.get_columns('submission_attempt') print('Columns:', [c['name'] for c in columns]) "" />
|
||||
</Execute>
|
||||
</list>
|
||||
</option>
|
||||
<option name="autoApprovedReads">
|
||||
<list>
|
||||
<Read>
|
||||
<option name="pattern" value="/path/to/apps/web/src/pages/index.astro" />
|
||||
</Read>
|
||||
<Read>
|
||||
<option name="pattern" value="/path/to/apps/web/src/lib/api.ts" />
|
||||
</Read>
|
||||
<Read>
|
||||
<option name="pattern" value="/path/to/rf4_spotter/rf4_research/community_cli.py" />
|
||||
</Read>
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectRootManager">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
+8
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/rf4-help.iml" filepath="$PROJECT_DIR$/.idea/rf4-help.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
Generated
+9
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="JAVA_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,65 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
RF4 Spotter is an Astro/FastAPI/PostgreSQL application with an offline
|
||||
research and media-ingestion toolkit. The main areas are:
|
||||
|
||||
- `apps/api/` — FastAPI application, routers, models, migrations, and API tests.
|
||||
- `apps/web/` — Astro pages, components, styles, unit tests, and Playwright tests.
|
||||
- `rf4_research/` — source parsers, media manifest tooling, and CLI commands.
|
||||
- `tests/` — Python research/tooling tests and fixtures.
|
||||
- `data/media/` — versioned manifest and content-addressed local media files.
|
||||
- `docs/` — specification, roadmap, ADRs, runbooks, and acceptance procedures.
|
||||
- `compose.yaml` — local PostgreSQL, API, web, and supporting services.
|
||||
|
||||
Keep generated reports and temporary downloads outside committed paths unless a
|
||||
task explicitly requires versioning them.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest -q # Python suites
|
||||
npm --prefix apps/web run check # Astro type/template checks
|
||||
npm --prefix apps/web run build # Check and production build
|
||||
npm --prefix apps/web run test:unit # Web unit tests
|
||||
WEB_URL=http://127.0.0.1:4321 npm --prefix apps/web run test:e2e
|
||||
docker compose up --build # Full local stack
|
||||
```
|
||||
|
||||
For media work, use `.venv/bin/python -m rf4_research.media_cli --audit` and
|
||||
respect the manifest’s cooldown and approval states. Do not hotlink or replace
|
||||
approved media without explicit review.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
|
||||
Use four-space indentation for Python and two spaces for Astro/TypeScript.
|
||||
Prefer typed Python functions, `snake_case` for Python identifiers, and
|
||||
`camelCase` for TypeScript variables/functions. Astro components use
|
||||
`PascalCase.astro`; tests use descriptive `test_*.py` or `*.test.ts` names.
|
||||
Keep UI text and data-source labels explicit and accessible; run `astro check`
|
||||
before committing web changes.
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
Add focused regression tests beside the affected suite. Python tests use
|
||||
pytest; web behavior uses Node unit tests and Playwright. Run the smallest
|
||||
relevant test first, then the full suite before handoff. Never use live external
|
||||
sources in tests; use fixtures or isolated Docker services.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
Use imperative, concise commit subjects with the repository’s existing scope
|
||||
style, such as `feat:`, `fix:`, `data:`, or `chore:`. Keep commits focused and
|
||||
exclude unrelated user files. Pull requests should describe behavior changes,
|
||||
verification commands, migration or configuration impact, and screenshots for
|
||||
visual/UI work. Call out any media provenance, approval, or rollback decision.
|
||||
|
||||
## Security & Configuration Tips
|
||||
|
||||
Do not commit secrets, production `.env` files, tokens, or private payloads.
|
||||
Use local fixtures and documented environment variables. Preserve the strict
|
||||
CSP, admin authentication barriers, source attribution, and media provenance
|
||||
when changing application code.
|
||||
@@ -0,0 +1,37 @@
|
||||
"""add canonical waterbody provenance fields
|
||||
|
||||
Revision ID: 0017
|
||||
Revises: 0016
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0017"
|
||||
down_revision = "0016"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("waterbody", sa.Column("source_system", sa.String(length=50), nullable=True))
|
||||
op.add_column("waterbody", sa.Column("source_external_id", sa.String(length=200), nullable=True))
|
||||
op.add_column("waterbody", sa.Column("source_url", sa.Text(), nullable=True))
|
||||
op.add_column("waterbody", sa.Column("description", sa.Text(), nullable=True))
|
||||
op.add_column("waterbody", sa.Column("source_checked_at", sa.DateTime(timezone=True), nullable=True))
|
||||
op.create_index(
|
||||
"uq_waterbody_source_identity",
|
||||
"waterbody",
|
||||
["source_system", "source_external_id"],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("uq_waterbody_source_identity", table_name="waterbody")
|
||||
op.drop_column("waterbody", "source_checked_at")
|
||||
op.drop_column("waterbody", "description")
|
||||
op.drop_column("waterbody", "source_url")
|
||||
op.drop_column("waterbody", "source_external_id")
|
||||
op.drop_column("waterbody", "source_system")
|
||||
@@ -0,0 +1,26 @@
|
||||
"""store source coordinate text and precision
|
||||
|
||||
Revision ID: 0018
|
||||
Revises: 0017
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0018"
|
||||
down_revision = "0017"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("external_observation", sa.Column("coordinate_raw", sa.String(length=200), nullable=True))
|
||||
op.add_column("external_observation", sa.Column("coordinate_precision", sa.String(length=20), nullable=True))
|
||||
op.execute("UPDATE external_observation SET coordinate_precision = CASE WHEN x IS NOT NULL AND y IS NOT NULL THEN 'exact' ELSE 'missing' END")
|
||||
op.alter_column("external_observation", "coordinate_precision", nullable=False, server_default="missing")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("external_observation", "coordinate_precision")
|
||||
op.drop_column("external_observation", "coordinate_raw")
|
||||
@@ -0,0 +1,22 @@
|
||||
"""store canonical waterbody fish count
|
||||
|
||||
Revision ID: 0019
|
||||
Revises: 0018
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0019"
|
||||
down_revision = "0018"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("waterbody", sa.Column("fish_species_count", sa.Integer(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("waterbody", "fish_species_count")
|
||||
@@ -0,0 +1,24 @@
|
||||
"""store canonical waterbody detail facts
|
||||
|
||||
Revision ID: 0020
|
||||
Revises: 0019
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0020"
|
||||
down_revision = "0019"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for name in ("source_aliases", "source_fish_species", "source_image_urls", "source_point_urls"):
|
||||
op.add_column("waterbody", sa.Column(name, sa.JSON(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for name in ("source_point_urls", "source_image_urls", "source_fish_species", "source_aliases"):
|
||||
op.drop_column("waterbody", name)
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
import { pageHref, pageWindow } from "../lib/pagination";
|
||||
|
||||
interface Props {
|
||||
path: string;
|
||||
params: URLSearchParams;
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
anchor?: string;
|
||||
itemLabel?: string;
|
||||
}
|
||||
|
||||
const { path, params, total, limit, offset, anchor = "", itemLabel = "записей" } = Astro.props;
|
||||
const state = pageWindow(total, limit, offset);
|
||||
---
|
||||
{total > limit && <nav class="pagination" aria-label="Пагинация">
|
||||
<span>{state.start}–{state.end} из {total} {itemLabel} · страница {state.page} из {state.pages}</span>
|
||||
<div>
|
||||
{state.previousOffset !== null
|
||||
? <a data-action="secondary" rel="prev" href={pageHref(path, params, state.previousOffset, anchor)}>← Предыдущая</a>
|
||||
: <span class="pagination__disabled" aria-disabled="true">← Предыдущая</span>}
|
||||
{state.nextOffset !== null
|
||||
? <a data-action="secondary" rel="next" href={pageHref(path, params, state.nextOffset, anchor)}>Следующая →</a>
|
||||
: <span class="pagination__disabled" aria-disabled="true">Следующая →</span>}
|
||||
</div>
|
||||
</nav>}
|
||||
@@ -0,0 +1,31 @@
|
||||
export type PageWindow = {
|
||||
start: number;
|
||||
end: number;
|
||||
page: number;
|
||||
pages: number;
|
||||
previousOffset: number | null;
|
||||
nextOffset: number | null;
|
||||
};
|
||||
|
||||
export const pageWindow = (total: number, limit: number, offset: number): PageWindow => {
|
||||
const safeTotal = Math.max(0, Math.trunc(total));
|
||||
const safeLimit = Math.max(1, Math.trunc(limit));
|
||||
const safeOffset = Math.max(0, Math.trunc(offset));
|
||||
const end = Math.min(safeOffset + safeLimit, safeTotal);
|
||||
return {
|
||||
start: safeTotal > 0 && safeOffset < safeTotal ? safeOffset + 1 : 0,
|
||||
end,
|
||||
page: Math.floor(safeOffset / safeLimit) + 1,
|
||||
pages: Math.max(1, Math.ceil(safeTotal / safeLimit)),
|
||||
previousOffset: safeOffset > 0 ? Math.max(0, safeOffset - safeLimit) : null,
|
||||
nextOffset: end < safeTotal ? safeOffset + safeLimit : null,
|
||||
};
|
||||
};
|
||||
|
||||
export const pageHref = (path: string, search: URLSearchParams, offset: number, anchor = "") => {
|
||||
const params = new URLSearchParams(search);
|
||||
if (offset > 0) params.set("offset", String(offset));
|
||||
else params.delete("offset");
|
||||
const query = params.toString();
|
||||
return `${path}${query ? `?${query}` : ""}${anchor}`;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
.pagination{width:min(1360px,calc(100% - 64px));margin:22px auto 48px;display:flex;align-items:center;justify-content:space-between;gap:16px;color:var(--text-secondary);font-size:13px}.pagination>div{display:flex;gap:10px}.pagination a,.pagination__disabled{display:inline-flex;align-items:center;min-height:42px;padding:10px 15px;border:1px solid var(--border-strong);border-radius:10px;background:var(--surface-elevated);color:var(--text-secondary);text-decoration:none}.pagination a:hover{background:var(--surface-raised);color:var(--text-primary)}.pagination__disabled{opacity:.5}@media(max-width:720px){.pagination{width:calc(100% - 28px);align-items:stretch;flex-direction:column}.pagination>div{display:grid;grid-template-columns:1fr 1fr}.pagination a,.pagination__disabled{justify-content:center;text-align:center}}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"source_system": "rf4db",
|
||||
"source_external_id": "level_000_home",
|
||||
"source_url": "https://rf4db.com/ru/maps/level_000_home",
|
||||
"checked_at": "2026-09-16",
|
||||
"name": "Дачный пруд",
|
||||
"description": null,
|
||||
"aliases": [],
|
||||
"fish_species": ["Лягушка", "Ротан", "Окунь", "Карась золотой", "Карась серебряный", "Линь", "Карп чешуйчатый"],
|
||||
"image_urls": [
|
||||
"https://oss.rf4db.com/game/maps/level_000_home.png",
|
||||
"https://oss.rf4db.com/game/paper_maps_webp/map_level_000_home.webp"
|
||||
],
|
||||
"point_urls": [
|
||||
"https://rf4db.com/ru/positions/b5bd02d2-997c-466f-ad8d-b9b3be726c18",
|
||||
"https://rf4db.com/ru/positions/60770f78-815d-4681-9079-d8ece4efc20b",
|
||||
"https://rf4db.com/ru/positions/5a4e6bb4-b2f7-4085-84ce-b567301c1d80",
|
||||
"https://rf4db.com/ru/positions/ddf76541-6de2-472f-ad1f-636a232a0f4b",
|
||||
"https://rf4db.com/ru/positions/c98d9718-2f06-4069-a227-4d1753d4e14a",
|
||||
"https://rf4db.com/ru/positions/4ac21a3c-0c50-4ca8-b7a5-29481eb034b7",
|
||||
"https://rf4db.com/ru/positions/efada3fc-9f35-4eb1-aee0-55eeb52c1c90",
|
||||
"https://rf4db.com/ru/positions/0cb5d601-5c54-40dc-84d9-05c9c51052ce",
|
||||
"https://rf4db.com/ru/positions/bb4e2977-f4be-4edc-ac7f-476991eb034b7"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"source_system": "rf4db",
|
||||
"source_url": "https://rf4db.com/ru/maps",
|
||||
"checked_at": "2026-09-16",
|
||||
"expected_count": 19,
|
||||
"items": [
|
||||
{"source_external_id":"level_000_home","source_url":"https://rf4db.com/ru/maps/level_000_home","name":"Дачный пруд","unlock_level":null,"unlock_label":"Старт","fish_species_count":7,"image_url":"https://oss.rf4db.com/game/maps/level_000_home.png"},
|
||||
{"source_external_id":"level_001_lake","source_url":"https://rf4db.com/ru/maps/level_001_lake","name":"оз. Комариное","unlock_level":1,"unlock_label":"Lv.1","fish_species_count":20,"image_url":"https://oss.rf4db.com/game/maps/level_001_lake.png"},
|
||||
{"source_external_id":"level_002_kirzah_river","source_url":"https://rf4db.com/ru/maps/level_002_kirzah_river","name":"р. Вьюнок","unlock_level":1,"unlock_label":"Lv.1","fish_species_count":29,"image_url":"https://oss.rf4db.com/game/maps/level_002_kirzah_river.png"},
|
||||
{"source_external_id":"level_019_american_pond","source_url":"https://rf4db.com/ru/maps/level_019_american_pond","name":"оз. Лосиное","unlock_level":1,"unlock_label":"Lv.1","fish_species_count":19,"image_url":"https://oss.rf4db.com/game/maps/level_019_american_pond.png"},
|
||||
{"source_external_id":"level_004_torfyanoe_ozero","source_url":"https://rf4db.com/ru/maps/level_004_torfyanoe_ozero","name":"оз. Старый Острог","unlock_level":12,"unlock_label":"Lv.12","fish_species_count":21,"image_url":"https://oss.rf4db.com/game/maps/level_004_torfyanoe_ozero.png"},
|
||||
{"source_external_id":"level_010_belaya","source_url":"https://rf4db.com/ru/maps/level_010_belaya","name":"р. Белая","unlock_level":12,"unlock_label":"Lv.12","fish_species_count":29,"image_url":"https://oss.rf4db.com/game/maps/level_010_belaya.png"},
|
||||
{"source_external_id":"level_003_viiveri_lake","source_url":"https://rf4db.com/ru/maps/level_003_viiveri_lake","name":"оз. Куори","unlock_level":16,"unlock_label":"Lv.16","fish_species_count":24,"image_url":"https://oss.rf4db.com/game/maps/level_003_viiveri_lake.png"},
|
||||
{"source_external_id":"level_007_grass_lake","source_url":"https://rf4db.com/ru/maps/level_007_grass_lake","name":"оз. Медвежье","unlock_level":18,"unlock_label":"Lv.18","fish_species_count":26,"image_url":"https://oss.rf4db.com/game/maps/level_007_grass_lake.png"},
|
||||
{"source_external_id":"level_005_volhov","source_url":"https://rf4db.com/ru/maps/level_005_volhov","name":"р. Волхов","unlock_level":20,"unlock_label":"Lv.20","fish_species_count":32,"image_url":"https://oss.rf4db.com/game/maps/level_005_volhov.png"},
|
||||
{"source_external_id":"level_013_sev_don","source_url":"https://rf4db.com/ru/maps/level_013_sev_don","name":"р. Северский Донец","unlock_level":22,"unlock_label":"Lv.22","fish_species_count":40,"image_url":"https://oss.rf4db.com/game/maps/level_013_sev_don.png"},
|
||||
{"source_external_id":"level_008_sura_river","source_url":"https://rf4db.com/ru/maps/level_008_sura_river","name":"р. Сура","unlock_level":24,"unlock_label":"Lv.24","fish_species_count":37,"image_url":"https://oss.rf4db.com/game/maps/level_008_sura_river.png"},
|
||||
{"source_external_id":"level_006_ladoga","source_url":"https://rf4db.com/ru/maps/level_006_ladoga","name":"Ладожское оз.","unlock_level":26,"unlock_label":"Lv.26","fish_species_count":40,"image_url":"https://oss.rf4db.com/game/maps/level_006_ladoga.png"},
|
||||
{"source_external_id":"level_014_carp_lake","source_url":"https://rf4db.com/ru/maps/level_014_carp_lake","name":"оз. Янтарное","unlock_level":26,"unlock_label":"Lv.26","fish_species_count":37,"image_url":"https://oss.rf4db.com/game/maps/level_014_carp_lake.png"},
|
||||
{"source_external_id":"level_011_ladoga_02","source_url":"https://rf4db.com/ru/maps/level_011_ladoga_02","name":"Ладожский архипелаг","unlock_level":27,"unlock_label":"Lv.27","fish_species_count":43,"image_url":"https://oss.rf4db.com/game/maps/level_011_ladoga_02.png"},
|
||||
{"source_external_id":"level_009_ahtuba","source_url":"https://rf4db.com/ru/maps/level_009_ahtuba","name":"р. Ахтуба","unlock_level":28,"unlock_label":"Lv.28","fish_species_count":54,"image_url":"https://oss.rf4db.com/game/maps/level_009_ahtuba.png"},
|
||||
{"source_external_id":"level_018_moskitnoe","source_url":"https://rf4db.com/ru/maps/level_018_moskitnoe","name":"оз. Медное","unlock_level":29,"unlock_label":"Lv.29","fish_species_count":30,"image_url":"https://oss.rf4db.com/game/maps/level_018_moskitnoe.png"},
|
||||
{"source_external_id":"level_015_tunguska","source_url":"https://rf4db.com/ru/maps/level_015_tunguska","name":"р. Нижняя Тунгуска","unlock_level":30,"unlock_label":"Lv.30","fish_species_count":41,"image_url":"https://oss.rf4db.com/game/maps/level_015_tunguska.png"},
|
||||
{"source_external_id":"level_016_yama","source_url":"https://rf4db.com/ru/maps/level_016_yama","name":"р. Яма","unlock_level":32,"unlock_label":"Lv.32","fish_species_count":28,"image_url":"https://oss.rf4db.com/game/maps/level_016_yama.png"},
|
||||
{"source_external_id":"level_012_north_sea","source_url":"https://rf4db.com/ru/maps/level_012_north_sea","name":"Норвежское море","unlock_level":34,"unlock_label":"Lv.34","fish_species_count":57,"image_url":"https://oss.rf4db.com/game/maps/level_012_north_sea.png"}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Conservative, offline crosswalk suggestions for waterbody identities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .media_assets import normalize_entity_label
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CanonicalWaterbody:
|
||||
key: str
|
||||
name: str
|
||||
aliases: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WaterbodyIdentity:
|
||||
source_system: str
|
||||
external_id: str
|
||||
name: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CrosswalkSuggestion:
|
||||
source_system: str
|
||||
external_id: str
|
||||
external_name: str
|
||||
status: str
|
||||
canonical_keys: tuple[str, ...]
|
||||
|
||||
|
||||
def suggest_waterbody_crosswalk(
|
||||
canonical: list[CanonicalWaterbody], identities: list[WaterbodyIdentity],
|
||||
) -> list[CrosswalkSuggestion]:
|
||||
"""Suggest only unique exact normalized-name matches.
|
||||
|
||||
``ambiguous`` and ``unmatched`` rows intentionally have no selected key;
|
||||
callers must not turn this report into aliases without human review.
|
||||
"""
|
||||
by_name: dict[str, set[str]] = {}
|
||||
for item in canonical:
|
||||
for name in (item.name, *item.aliases):
|
||||
by_name.setdefault(normalize_entity_label(name), set()).add(item.key)
|
||||
result: list[CrosswalkSuggestion] = []
|
||||
for identity in identities:
|
||||
keys = tuple(sorted(by_name.get(normalize_entity_label(identity.name), set())))
|
||||
status = "exact" if len(keys) == 1 else "ambiguous" if keys else "unmatched"
|
||||
result.append(CrosswalkSuggestion(
|
||||
source_system=identity.source_system, external_id=identity.external_id,
|
||||
external_name=identity.name, status=status,
|
||||
canonical_keys=keys if status == "exact" else (),
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
def missing_external_ids(expected: set[str], observed: set[str]) -> tuple[str, ...]:
|
||||
"""Return stable missing IDs without interpreting absence as deletion."""
|
||||
return tuple(sorted(expected - observed))
|
||||
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<body>
|
||||
<main>
|
||||
<article>
|
||||
<h1>Дачный пруд</h1>
|
||||
<img src="https://oss.rf4db.com/game/maps/level_000_home.png" alt="Дачный пруд">
|
||||
<img src="https://oss.rf4db.com/game/paper_maps_webp/map_level_000_home.webp" alt="Карта: Дачный пруд">
|
||||
<img src="https://oss.rf4db.com/game/fish/frog.webp" alt="Лягушка">
|
||||
<a href="/ru/fishes/frog">Лягушка 190 г</a>
|
||||
<a href="/ru/fishes/a.sleeper">Ротан 400 г</a>
|
||||
<a href="/ru/fishes/perch">Окунь 1.6 кг</a>
|
||||
<a href="/ru/positions/example-position">Точка 13:9</a>
|
||||
</article>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<body>
|
||||
<article class="waterbody-detail">
|
||||
<h1>оз. Комариное</h1>
|
||||
<p class="waterbody-description">Большое озеро с каменистыми берегами.</p>
|
||||
<ul class="waterbody-aliases"><li>Комариное</li><li>оз. Комариное</li></ul>
|
||||
<div class="waterbody-fish">
|
||||
<a href="/ru/wiki/fish/pike">Щука</a>
|
||||
<a href="/ru/wiki/fish/perch">Окунь</a>
|
||||
</div>
|
||||
<img src="https://oss.rf4db.com/game/maps/level_001_mosquito.webp" alt="Карта">
|
||||
<img data-src="/game/maps/level_001_mosquito-depth.webp" alt="Глубины">
|
||||
<a href="/ru/maps/level_001_mosquito/spots/12-34">Точка 12:34</a>
|
||||
</article>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user