Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d438c40542 | ||
|
|
722c88d436 | ||
|
|
5221442aeb | ||
|
|
85d81aa996 | ||
|
|
53e7b0e4ae | ||
|
|
d63eedf41b | ||
|
|
28fffb3acb | ||
|
|
4303b145b0 | ||
|
|
137aa806c0 | ||
|
|
e012b84969 | ||
|
|
727a87b73b | ||
|
|
d3ee8ebbd7 | ||
|
|
c216229c61 | ||
|
|
efd5f7b172 | ||
|
|
c14ae0250e | ||
|
|
2e34fb20b5 | ||
|
|
74ff49062f | ||
|
|
4c7051bc97 | ||
|
|
d9f58fb90e | ||
|
|
eb4d6ccdb0 | ||
|
|
9bcb019d3a | ||
|
|
4c75db1f74 | ||
|
|
75820125aa | ||
|
|
6c67e5042f | ||
|
|
a624066aab | ||
|
|
f6ff400344 | ||
|
|
5be5964098 | ||
|
|
399afe5ea5 | ||
|
|
a8c0da837a | ||
|
|
7d009c932f | ||
|
|
b47a6cc366 | ||
|
|
6146ea9eb0 | ||
|
|
61c7ac7d51 | ||
|
|
0eca44c11a | ||
|
|
79daea4ae1 | ||
|
|
7d94fbadb5 | ||
|
|
22485efe10 | ||
|
|
6c9bbd7fd7 | ||
|
|
3682f3ad61 | ||
|
|
2dd5f97143 | ||
|
|
064e731d25 | ||
|
|
e08bfaffba | ||
|
|
1d27fbde30 | ||
|
|
0152593815 | ||
|
|
8207d3ae02 | ||
|
|
787a5065bc | ||
|
|
f243807fc8 | ||
|
|
73feb75767 | ||
|
|
abe51b38d0 | ||
|
|
86ed3a966a | ||
|
|
1305cccfa5 | ||
|
|
bc3ceb5dfe | ||
|
|
bb8040d6fb | ||
|
|
aed541c70c | ||
|
|
54a1e6c042 | ||
|
|
d8b0c08aaa | ||
|
|
99c1498810 | ||
|
|
7567ac9191 | ||
|
|
4f5fb6d7c2 |
@@ -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 выполнены не полностью
|
||||
@@ -2,7 +2,6 @@ __pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.cache/
|
||||
data/media/files/
|
||||
.env.production
|
||||
.maintenance.lock
|
||||
.venv/
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Environment-dependent path to Maven home directory
|
||||
/mavenHomeManager.xml
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
@@ -6,7 +6,7 @@ RF4 Spotter — неофициальный сервис свежих точек
|
||||
|
||||
## Статус разработки
|
||||
|
||||
**Проверка 11 сентября 2026 (`907ad53`): локальный контур готов к развёртыванию открытой альфы, внешний запуск ждёт сервер и его настройки.** Пакет восстановления A01–A13 закрыт. Python: **130 passed, 1 skipped**; Astro check/build и API-тесты проходят. Чистый production bootstrap подтвердил Caddy, scheduler validation, миграцию `20260910_recovery` и браузерный сценарий отправки/модерации. Реальные источники во время приёмки не опрашивались.
|
||||
**Проверка 15 сентября 2026: локальный контур готов к развёртыванию открытой альфы, внешний запуск ждёт сервер и его настройки.** Пакет восстановления A01–A13 закрыт. Python: **178 passed, 1 skipped**; Astro check/build, web unit и API-тесты проходят. Граф миграций имеет единственную голову `0018`; production bootstrap и импорт реальных источников по-прежнему требуют отдельной инфраструктурной/разрешённой приёмки.
|
||||
|
||||
Актуальные следующие задачи находятся только в [ROADMAP](docs/ROADMAP.md). Старые планы и аудиты сохранены как история и больше не задают порядок работ. До внешнего запуска нужны сервер, DNS/TLS, production-секреты, публичные контакты, внешний backup и канал уведомлений.
|
||||
|
||||
@@ -34,22 +34,34 @@ Web Docker-образ устанавливает зависимости чере
|
||||
|
||||
RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изолированный staging. Полные записи с ранее подтверждёнными алиасами источника публикуются автоматически; новые соответствия и неполные записи остаются на ручной проверке. Admin API предлагает точные ранее подтверждённые алиасы отдельно от mapping-действия и запрещает молча переназначать alias другой сущности. Для разрешённых community-источников действует интервал не менее 30 минут на сайт, общий для всех его endpoint. Открытая альфа не использует продуктовый allowlist: интерфейс показывает весь корректно загруженный разрешённый каталог, сохраняя требования полноты и модерации.
|
||||
|
||||
Жизненный цикл первоисточника учитывается консервативно: изменённая опубликованная запись снимается с активности до повторной ручной проверки, а исчезнувшая — только после подтверждённого ответа `missing` во время разрешённого планового обращения. Временные ошибки и блокировка доступа не удаляют данные. Admin provenance показывает результат и время последней проверки; повторно появившаяся запись также требует подтверждения модератором.
|
||||
|
||||
На сайте у каждой записи отображается источник, а у агрегированной активности — все вошедшие в расчёт источники. Неполные community-наблюдения публикуются сразу в отдельной ленте «Полевые сигналы» с предупреждением и перечнем отсутствующих полей; до подтверждения полноты они не влияют на индекс клёва. Лента раскрывается серверной кнопкой «Показать ещё», сохраняет выбранные фильтры и ограничена 48 сигналами на страницу. Визуально объединяются только повторы одного ID источника; похожие записи разных площадок остаются самостоятельными наблюдениями. Sidebar лидера скрывается при единственном результате, чтобы не повторять ту же карточку.
|
||||
|
||||
Базовый SEO-контур готов для `rf4spotter.ru`: страницы имеют уникальные метаданные, canonical, Open Graph/Twitter Card, фирменное изображение 1200×630 и JSON-LD; доступны динамические `/robots.txt` и `/sitemap.xml`, административные и ошибочные страницы закрыты от индексации, добавлена собственная страница 404. Индексируемые каталоги рыб и водоёмов, detail-страницы и сочетания водоём + рыба строятся из актуального разрешённого справочника и включаются в sitemap. Подключены резкие favicon/app icons из SVG-мастера, отдельные полнофоновые maskable-иконки, web manifest и production-кэширование статических ресурсов. Карточки активности показывают единый паспорт данных: источники, свежесть, полноту и уровень доверия. На `/status` опубликована легенда цветов всех источников и статусов качества.
|
||||
|
||||
Публичные точки используют постоянные читаемые адреса вида `/spots/kuori-85x92`; старые UUID-адреса остаются совместимыми и перенаправляются на канонический URL. На странице точки координаты дополнительно показаны фирменным радаром, который не имитирует отсутствующую географию водоёма, а уловы за 72 часа — шкалой-леской с 12-часовым шагом. Каждый улов показывает источник, относительную свежесть и точное время UTC; время получения явно отделено от времени улова. Каталоги оформлены как полевой атлас: тёмный seal показывает объём справочника, карточки рыб используют смысловые SVG-силуэты, а каждый водоём — собственный детерминированный абстрактный отпечаток берега, волн, точки и индекса. На странице сочетания оба знака собираются в единую атласную эмблему, detail-иерархию связывает breadcrumb-леска с текстовыми узлами, а боковые переходы повторяют знаки связанных сущностей. Это не карта и не игровая география. Пустые состояния используют статичную CSS-иллюстрацию поплавка; смысловые анимации полностью учитывают системное ограничение движения.
|
||||
|
||||
Все пять community-парсеров подключены к отдельному scheduler-процессу. Попытка резервируется в PostgreSQL до HTTP-запроса, поэтому ошибки тоже расходуют cooldown. Блокировка и минимальный интервал 1800 секунд действуют на весь домен; endpoint одного сайта выбираются по самому давнему запуску и не голодают. Ручной production-запуск использует тот же журнал: `docker compose exec api python -m app.cli fetch-community rf4stat-fishing`. Локально scheduler включается профилем `docker compose --profile scheduler up -d`; detail-URL RF4MAP/RF4 Posts задаются переменными окружения.
|
||||
Все пять community-парсеров подключены к отдельному scheduler-процессу. Попытка резервируется в PostgreSQL до HTTP-запроса, поэтому ошибки тоже расходуют cooldown. Блокировка и минимальный интервал 1800 секунд действуют на весь домен; endpoint одного сайта выбираются по самому давнему запуску и не голодают. Тот же запрос служит проверкой точной исходной ссылки: `404/410` означает `missing`, `401/403/429` — `blocked`, остальные сбои — `temporary_error`; отдельного link-checker и дополнительных обращений нет. Пропажа элемента из агрегатного списка сама по себе удалением не считается. Ручной production-запуск использует тот же журнал: `docker compose exec api python -m app.cli fetch-community rf4stat-fishing`. Локально scheduler включается профилем `docker compose --profile scheduler up -d`; detail-URL RF4MAP/RF4 Posts задаются переменными окружения.
|
||||
|
||||
Медиасборщик индексирует разрешённые изображения отдельно от публичного каталога: manifest хранит исходную страницу, URL, предполагаемый тип сущности и время обнаружения, а оригиналы сохраняются по SHA-256 без hotlink. Индексация страницы и загрузка каждого файла используют общий 30-минутный cooldown домена; непроверенный asset не публикуется автоматически. Локальный `media_cli --audit` без сетевых запросов проверяет хэши, файлы, MIME, размеры, approved-сопоставления и отсутствие бесхозных оригиналов.
|
||||
Медиасборщик индексирует разрешённые изображения отдельно от публичного каталога: manifest хранит исходную страницу, URL, предполагаемый тип сущности и время обнаружения, а оригиналы сохраняются по SHA-256 без hotlink. После явного разрешения владельца от 14 сентября все скачанные и целостные материалы опубликованы в `/media`; публичный API отдаёт Git-копии по content-addressed URL, а каждая карточка показывает плашку и прямую ссылку на источник. Будущие загрузки по-прежнему не одобряются автоматически. Локальный `media_cli --audit` без сетевых запросов проверяет хэши, файлы, MIME, размеры, approved-сопоставления и отсутствие бесхозных оригиналов.
|
||||
|
||||
`python -m rf4_research.media_cli --coverage` сравнивает manifest с датированным `data/media/catalog-baseline.json`: отдельно считает файлы, уникальные нормализованные подписи и кандидатов без подписи, поэтому дубли и общие учебные схемы не завышают покрытие. Сейчас не покрыты минимум 24 рыбы и все 19 водоёмов, а до ручного review не подтверждены 251 рыба и все 19 водоёмов. Общий target снастей остаётся `null`, пока разрешённый источник не отдаст проверяемый полный счётчик.
|
||||
Актуальный offline-срез от 16.09.2026: 704 записи manifest, 466 `approved`, 226 `superseded`, 1 `duplicate` и 11 `invalid`; audit проходит без ошибок и orphan-файлов. У 252 из 253 рыбных изображений есть 1024×1024 WebP, один 48×48 fallback сохранён из-за отсутствия проверенной альтернативы. Производные WebP/AVIF и provenance хранятся в Git; визуальная browser-приёмка остаётся отдельным пунктом B25.
|
||||
|
||||
Актуальный внешний ориентир — 19 водоёмов и 252 вида рыб; локальная альфа пока содержит 2+2 сущности. Media-manifest включает 452 кандидата: 228 изображений рыб, 149 приманок и 75 справочных изображений; подтверждённых entity-карт водоёмов пока нет. Вручную проверены 20 ассетов: 1 рыба, 9 приманок/наживок и 10 справочных схем; 425 записей остаются в очереди, 1 файл ожидает ревью, 6 URL признаны невалидными. Полное число «снастей» пока не заявляется: приманки — лишь одна часть каталога наряду с удилищами, катушками, лесками, крючками и оснастками.
|
||||
`python -m rf4_research.media_cli --coverage` сравнивает manifest с датированным `data/media/catalog-baseline.json`: отдельно считает уникальные нормализованные подписи и кандидатов без подписи, поэтому альтернативные URL не завышают покрытие. На 14.09.2026 manifest содержит 704 записи: 456 approved, 227 duplicate, 10 queued и 11 invalid. Опубликованы 243 уникально подписанные рыбы из 252, все 149 найденных изображений снастей/приманок и 64 справочных материала; 10 оставшихся RF4DB URL будут загружены после cooldown. Карты 19 водоёмов ещё предстоит проиндексировать, а общий target снастей остаётся `null` до проверяемого полного счётчика.
|
||||
|
||||
`python -m rf4_research.media_cli --quality-report` выполняет offline-проверку разрешения опубликованных рыб. Текущий отчёт выявляет 227 PNG RF4MAP размером 48×48, которые увеличиваются в карточках до 180 px, и 16 WebP 1024×1024; для всех низких версий уже известны альтернативные RF4DB URL. `--queue-quality-upgrades` переводит только прямые альтернативы низкоразрешённых published-файлов в безопасную очередь, не снимая текущую версию с публикации. На 15.09 первая партия из 40 замен уже сохранена в карантине для сравнения и review; дальнейшие этапы ведутся в B21–B25 ROADMAP.
|
||||
|
||||
`python -m rf4_research.media_cli --queue-plan` без сетевых запросов объединяет manifest с общим cooldown-state: показывает queued-состав каждого домена, оставшееся время и наиболее полезный следующий asset с приоритетом водоёмов и рыб. Разрешённое media-окно загружается командой `--download-batch --batch-limit 40`: до 40 assets на домен под одной резервацией, затем 30 минут до нового batch. Блокировка/rate limit/сетевая ошибка останавливает домен сразу, три последовательных невалидных ответа — досрочно. Все файлы остаются в карантине до ручного review. Точный поимённый список отсутствующих сущностей появится только после получения канонического перечня; разница между двумя несогласованными каталогами не выдаётся за доказанный gap.
|
||||
|
||||
Актуальный внешний ориентир — 19 водоёмов и 252 вида рыб; локальная альфа пока содержит 2+2 справочные сущности. Media-manifest включает 704 записи из RF4MAP, RF4DB и официального руководства: 456 опубликованы, 227 являются альтернативными дубликатами, 10 ожидают загрузки и 11 URL невалидны. Все 456 оригиналов находятся в Git; подтверждённых entity-карт водоёмов пока нет. Полное число «снастей» не заявляется: приманки — лишь одна часть каталога наряду с удилищами, катушками, лесками, крючками и оснастками.
|
||||
|
||||
Исследовательские RF4-ассеты в `data/media/files/` версионируются обычным Git вместе с `data/media/manifest.json`, чтобы клон репозитория был самодостаточным и не зависел от локального кэша. Это не относится к пользовательским скриншотам: они по-прежнему хранятся в MinIO/S3 и не попадают в Git.
|
||||
|
||||
Для измерений на собственном сервере подготовлен read-only `deploy/load-smoke.py`: он считает p50/p95/max и HTTP-коды для activity/records, а при наличии `ADMIN_TOKEN` — staging/moderation. Методика и безопасные ступени нагрузки описаны в [docs/load-testing.md](docs/load-testing.md); локальные цифры не выдаются за production baseline.
|
||||
|
||||
До сервера запросы проверяются командой `./deploy/test-query-plans.sh`: session-local TEMP-fixture на 100 000 уловов не меняет рабочую БД и требует индексные планы для activity, records и spot detail, а также выполнение пяти публичных планов быстрее 250 мс. Методика и последний локальный результат находятся в [docs/query-performance.md](docs/query-performance.md); новые индексы по текущему измерению не требуются.
|
||||
|
||||
В production MinIO root credentials доступны только одноразовому init-контейнеру. API использует отдельного пользователя с доступом исключительно к `S3_BUCKET`: просмотр bucket, чтение, запись и удаление его объектов без глобального списка bucket и без права создавать новые.
|
||||
|
||||
Production release отделяет Alembic от runtime: одноразовый `migrate` должен успешно завершиться до запуска новой версии API. Перед изменением схемы создаётся backup; совместимый rollback возвращает предыдущие images, несовместимый — восстанавливает предрелизную копию данных вместо непроверенного `alembic downgrade`.
|
||||
@@ -91,9 +103,9 @@ FastAPI ─ PostgreSQL 17
|
||||
|
||||
Наружу production-профиль публикует только Caddy. PostgreSQL, API, Astro и MinIO находятся в Docker-сетях. Caddy завершает TLS и защищает административные страницы Basic Auth; административный API отдельно проверяет Bearer-токен в FastAPI. Basic не накладывается на API-запросы.
|
||||
|
||||
Production CSP ограничивает browser-запросы текущим доменом и отдельным files-доменом для изображений, запрещает plugins, frames, inline handlers и attributes, eval, wildcard и HTTP. Page scripts и scoped styles принудительно выпускаются отдельными same-origin `_astro`-ассетами; единственное временное inline-исключение остаётся для динамического JSON-LD и описано в [CSP inventory](docs/csp-inventory.md).
|
||||
Production CSP ограничивает browser-запросы текущим доменом и отдельным files-доменом для изображений, запрещает plugins, frames, inline handlers и attributes, `unsafe-inline`, eval, wildcard и HTTP. Page scripts и scoped styles выпускаются отдельными same-origin `_astro`-ассетами; динамический JSON-LD получает новый криптографический nonce на каждый SSR-ответ. Caddy сохраняет эту policy и задаёт строгий fallback для служебных ответов. Локальный Compose отдельно разрешает только loopback API/MinIO; детали и проверка описаны в [CSP inventory](docs/csp-inventory.md).
|
||||
|
||||
Тема по умолчанию следует системному `prefers-color-scheme`, а переключатель в header позволяет выбрать системную, светлую или тёмную палитру. Выбор сохраняется в cookie и применяется Astro при SSR без localStorage-only flash и ослабления CSP. Семантические роли и ограничения дальнейшей миграции компонентов описаны в [dark-theme.md](docs/dark-theme.md).
|
||||
Тема по умолчанию следует системному `prefers-color-scheme`, а переключатель в header позволяет выбрать системную, светлую или тёмную палитру. Выбор сохраняется в cookie и применяется Astro при SSR без localStorage-only flash и ослабления CSP; browser chrome синхронизируется парными `theme-color`. Публичные и административные поверхности, формы, таблицы, provenance/status-плашки и фирменная SVG/CSS-графика используют семантические light/dark-токены и базовый forced-colors layer; ограничения и оставшаяся визуальная приёмка описаны в [dark-theme.md](docs/dark-theme.md).
|
||||
|
||||
Gitea Actions workflow `.gitea/workflows/ci.yml` на каждый push и pull request проверяет Python, миграции на чистой PostgreSQL, Astro build и полный Compose/Playwright-сценарий. При падении E2E сохраняются логи контейнеров и Playwright-артефакты.
|
||||
|
||||
@@ -136,7 +148,7 @@ docker compose up --build
|
||||
- readiness PostgreSQL, MinIO и импорта с версией/revision сборки: <http://localhost:8000/ready>;
|
||||
- консоль MinIO: <http://localhost:9001>.
|
||||
|
||||
Контейнер API сам выполняет `alembic upgrade head`, затем идемпотентный seed. PostgreSQL хранит данные в именованном volume `postgres_data`, а MinIO — в `minio_data`. Compose ожидает readiness PostgreSQL и MinIO перед API, а API-контейнер проверяет `/ready`. Версия и commit SHA задаются через `APP_VERSION`/`APP_REVISION`; те же значения доступны администратору в `/api/v1/admin/diagnostics`. Официальный импорт по умолчанию необязателен; при включённом scheduler установите `OFFICIAL_IMPORT_REQUIRED=true`, тогда отсутствующий, неуспешный или просроченный запуск сделает readiness отрицательным.
|
||||
Одноразовые контейнеры `migrate` и `minio-init` перед запуском API соответственно применяют `alembic upgrade head` и идемпотентно создают локальный `S3_BUCKET`; приложение само не создаёт схему или bucket. PostgreSQL хранит данные в именованном volume `postgres_data`, а MinIO — в `minio_data`. Compose ожидает readiness PostgreSQL, MinIO и успешное завершение обоих init-контейнеров перед API, а API-контейнер проверяет `/ready`. Версия и commit SHA задаются через `APP_VERSION`/`APP_REVISION`; те же значения доступны администратору в `/api/v1/admin/diagnostics`. Здоровье импортов диагностическое и не мешает API или scheduler восстановиться после сбоя.
|
||||
|
||||
API и scheduler пишут по одной JSON-записи на событие. HTTP-лог содержит только сгенерированный `request_id`, метод, путь без query string, статус и длительность; IP, заголовок авторизации и пользовательский payload не журналируются. `X-Request-ID` возвращается клиенту. Стандартный access-log Uvicorn отключён. Уровень управляется `LOG_LEVEL`. Публичный агрегат активности кэшируется в памяти процесса на 20 секунд (до 128 ключей) и очищается после публикации, модерации или удаления через этот процесс API; изменения scheduler видны после TTL; `X-Cache` показывает `HIT`/`MISS`. Защищённый `/api/v1/admin/diagnostics` скачивает JSON только с идентификатором сборки и агрегированными счётчиками, без имён игроков, исходных URL, payload и ошибок парсеров.
|
||||
|
||||
@@ -172,7 +184,7 @@ docker compose up --build
|
||||
## Что реализовано
|
||||
|
||||
- FastAPI и SQLAlchemy 2;
|
||||
- PostgreSQL 17 и миграции Alembic до `20260910_recovery`;
|
||||
- PostgreSQL 17 и линейные миграции Alembic до `0018`;
|
||||
- идемпотентный seed с двумя точками и свежими демо-уловами;
|
||||
- `GET /api/v1/activity` с фильтрами периода, водоёма, рыбы, способа и сортировки;
|
||||
- `GET /api/v1/spots/{id}` и `/catches`;
|
||||
|
||||
@@ -6,6 +6,7 @@ RUN pip install --no-cache-dir -r requirements-lock.txt
|
||||
RUN useradd --create-home --uid 10001 rf4
|
||||
COPY --chown=rf4:rf4 apps/api .
|
||||
COPY --chown=rf4:rf4 rf4_research ./rf4_research
|
||||
COPY --chown=rf4:rf4 data/media ./data/media
|
||||
USER rf4
|
||||
EXPOSE 8000
|
||||
CMD ["sh", "-c", "python -m app.seed && uvicorn app.main:app --host 0.0.0.0 --port 8000 --no-access-log"]
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""track source record lifecycle checks
|
||||
|
||||
Revision ID: 0016
|
||||
Revises: 0015
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0016"
|
||||
down_revision = "0015"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("external_observation", sa.Column("source_check_status", sa.String(length=30)))
|
||||
op.add_column("external_observation", sa.Column("source_checked_at", sa.DateTime(timezone=True)))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("external_observation", "source_checked_at")
|
||||
op.drop_column("external_observation", "source_check_status")
|
||||
@@ -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)
|
||||
@@ -61,6 +61,9 @@ def activity_rows(
|
||||
confidence = min(confidence, 50)
|
||||
elif len(players) == 2:
|
||||
confidence = min(confidence, 65)
|
||||
coordinate_precisions = {_coordinate_precision(item) for item in items}
|
||||
coordinate_precision = max(coordinate_precisions, key=_precision_rank)
|
||||
coordinate_sources = sorted({_source_system(item) for item in items})
|
||||
latest = max(_aware(r.reported_at) for r in items)
|
||||
baits = Counter(r.bait.name for r in items if r.bait)
|
||||
freshness_text = _freshness_text(now - latest)
|
||||
@@ -74,7 +77,9 @@ def activity_rows(
|
||||
max_weight_g=max(r.weight_g for r in items), last_confirmed_at=latest,
|
||||
activity_score=activity, confidence_score=confidence,
|
||||
explanation=_explanation(len(items), len(players), freshness_text, activity, confidence),
|
||||
sources=sorted({_source_system(item) for item in items}),
|
||||
sources=coordinate_sources,
|
||||
coordinate_precision=coordinate_precision,
|
||||
coordinate_sources=coordinate_sources,
|
||||
))
|
||||
return sorted(result, key=lambda row: (row.activity_score, row.last_confirmed_at), reverse=True)
|
||||
|
||||
@@ -90,6 +95,16 @@ def _source_system(report: CatchReport) -> str:
|
||||
return "manual-import"
|
||||
|
||||
|
||||
def _coordinate_precision(report: CatchReport) -> str:
|
||||
provenance = (report.raw_payload or {}).get("provenance", {})
|
||||
value = provenance.get("coordinate_precision") if isinstance(provenance, dict) else None
|
||||
return value if value in {"exact", "approximate", "area", "missing"} else "exact"
|
||||
|
||||
|
||||
def _precision_rank(value: str) -> int:
|
||||
return {"exact": 0, "approximate": 1, "area": 2, "missing": 3}[value]
|
||||
|
||||
|
||||
def _aware(value: datetime) -> datetime:
|
||||
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
@@ -15,3 +15,28 @@ def audit_catalog(db: Session) -> dict[str, int]:
|
||||
"incomplete_published_staging": db.scalar(select(func.count()).select_from(ExternalObservation).where(ExternalObservation.status == "published", or_(ExternalObservation.fish_id.is_(None), ExternalObservation.waterbody_id.is_(None), ExternalObservation.x.is_(None), ExternalObservation.y.is_(None), ExternalObservation.weight_g.is_(None), ExternalObservation.catch_report_id.is_(None)))) or 0,
|
||||
}
|
||||
return {"fishes": count(Fish), "waterbodies": count(Waterbody), "reports": count(CatchReport), "staging": count(ExternalObservation), **failures, "failures": sum(failures.values())}
|
||||
|
||||
|
||||
def audit_waterbody_catalog(db: Session, expected_ids: set[str]) -> dict:
|
||||
"""Check a verified RF4DB snapshot without withdrawing legacy rows."""
|
||||
rows = list(db.scalars(select(Waterbody).where(Waterbody.source_system == "rf4db")))
|
||||
observed_ids = [str(row.source_external_id) for row in rows if row.source_external_id]
|
||||
observed = set(observed_ids)
|
||||
duplicate_ids = sorted({item for item in observed_ids if observed_ids.count(item) > 1})
|
||||
missing = sorted(expected_ids - observed)
|
||||
unexpected = sorted(observed - expected_ids)
|
||||
provenance_issues = sorted(
|
||||
str(row.source_external_id)
|
||||
for row in rows
|
||||
if not row.source_external_id or not row.source_url or not row.source_checked_at
|
||||
)
|
||||
failures = len(missing) + len(duplicate_ids) + len(provenance_issues)
|
||||
return {
|
||||
"expected": len(expected_ids),
|
||||
"observed": len(observed),
|
||||
"missing_source_external_ids": missing,
|
||||
"unexpected_source_external_ids": unexpected,
|
||||
"duplicate_source_external_ids": duplicate_ids,
|
||||
"provenance_issues": provenance_issues,
|
||||
"failures": failures,
|
||||
}
|
||||
|
||||
@@ -8,10 +8,10 @@ from dataclasses import asdict
|
||||
from .config import settings
|
||||
from .database import SessionLocal
|
||||
from .importer import import_records
|
||||
from .community_importer import stage_observations
|
||||
from .community_importer import stage_observations, update_waterbody_detail, update_waterbody_details, upsert_waterbody_catalog
|
||||
from .retention import RetentionPolicy, apply_retention
|
||||
from .storage import delete_screenshot
|
||||
from .catalog_audit import audit_catalog
|
||||
from .catalog_audit import audit_catalog, audit_waterbody_catalog
|
||||
from .community_scheduler import run_source, configured_sources
|
||||
|
||||
# Static registry for argparse choices — no DB required for --help
|
||||
@@ -34,11 +34,20 @@ def main() -> int:
|
||||
community = sub.add_parser("stage-community-json")
|
||||
community.add_argument("--input", default="-", help="JSON array path or - for stdin")
|
||||
community.add_argument("--limit", type=int, default=500)
|
||||
waterbodies = sub.add_parser("import-waterbody-catalog")
|
||||
waterbodies.add_argument("--input", required=True, help="JSON snapshot path or - for stdin")
|
||||
waterbodies.add_argument("--limit", type=int, default=100)
|
||||
detail = sub.add_parser("import-waterbody-detail")
|
||||
detail.add_argument("--input", required=True, help="JSON detail snapshot path")
|
||||
details = sub.add_parser("import-waterbody-details")
|
||||
details.add_argument("--input", required=True, help="JSON array of detail snapshots")
|
||||
fetch_community = sub.add_parser("fetch-community")
|
||||
fetch_community.add_argument("source", choices=STATIC_SOURCE_CHOICES)
|
||||
cleanup = sub.add_parser("cleanup-retention")
|
||||
cleanup.add_argument("--apply", action="store_true", help="apply changes; default is dry-run")
|
||||
sub.add_parser("audit-catalog")
|
||||
waterbody_audit = sub.add_parser("audit-waterbody-catalog")
|
||||
waterbody_audit.add_argument("--input", required=True, help="JSON snapshot path")
|
||||
args = parser.parse_args()
|
||||
with SessionLocal() as session:
|
||||
if args.command == "import-records":
|
||||
@@ -57,6 +66,43 @@ def main() -> int:
|
||||
parser.error("input must be a JSON array")
|
||||
created, updated = stage_observations(session, payload[:args.limit])
|
||||
print(f"staged: created={created} updated={updated}")
|
||||
elif args.command == "import-waterbody-catalog":
|
||||
if not 1 <= args.limit <= 500:
|
||||
parser.error("--limit must be between 1 and 500")
|
||||
stream = sys.stdin if args.input == "-" else open(args.input, encoding="utf-8")
|
||||
try:
|
||||
snapshot = json.load(stream)
|
||||
finally:
|
||||
if stream is not sys.stdin:
|
||||
stream.close()
|
||||
if isinstance(snapshot, dict):
|
||||
payload = snapshot.get("items")
|
||||
source_system = snapshot.get("source_system")
|
||||
if isinstance(payload, list) and isinstance(source_system, str):
|
||||
payload = [
|
||||
{"source_system": source_system, **item}
|
||||
for item in payload if isinstance(item, dict)
|
||||
]
|
||||
else:
|
||||
payload = snapshot
|
||||
if not isinstance(payload, list):
|
||||
parser.error("input must be a JSON array or an object with an items array")
|
||||
created, updated = upsert_waterbody_catalog(session, payload[:args.limit])
|
||||
print(f"waterbodies: created={created} updated={updated}")
|
||||
elif args.command == "import-waterbody-detail":
|
||||
with open(args.input, encoding="utf-8") as stream:
|
||||
payload = json.load(stream)
|
||||
if not isinstance(payload, dict):
|
||||
parser.error("input must be a JSON object")
|
||||
update_waterbody_detail(session, payload)
|
||||
print(f"waterbody detail: updated={payload.get('source_external_id', 'unknown')}")
|
||||
elif args.command == "import-waterbody-details":
|
||||
with open(args.input, encoding="utf-8") as stream:
|
||||
payload = json.load(stream)
|
||||
if not isinstance(payload, list):
|
||||
parser.error("input must be a JSON array")
|
||||
created, updated = update_waterbody_details(session, payload)
|
||||
print(f"waterbody details: created={created} updated={updated}")
|
||||
elif args.command == "fetch-community":
|
||||
# A09: Verify source is enabled at runtime (not just in static choices)
|
||||
enabled = configured_sources()
|
||||
@@ -76,6 +122,24 @@ def main() -> int:
|
||||
)
|
||||
counts = apply_retention(session, policy=policy, dry_run=not args.apply, delete_object=delete_screenshot)
|
||||
print(json.dumps({"mode": "apply" if args.apply else "dry-run", "policy": asdict(policy), "counts": counts}, ensure_ascii=False))
|
||||
elif args.command == "audit-waterbody-catalog":
|
||||
stream = sys.stdin if args.input == "-" else open(args.input, encoding="utf-8")
|
||||
try:
|
||||
snapshot = json.load(stream)
|
||||
finally:
|
||||
if stream is not sys.stdin:
|
||||
stream.close()
|
||||
items = snapshot.get("items") if isinstance(snapshot, dict) else snapshot
|
||||
if not isinstance(items, list):
|
||||
parser.error("input must be a JSON array or an object with an items array")
|
||||
expected_ids = {
|
||||
str(item["source_external_id"])
|
||||
for item in items
|
||||
if isinstance(item, dict) and item.get("source_external_id")
|
||||
}
|
||||
result = audit_waterbody_catalog(session, expected_ids)
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
return 1 if result["failures"] else 0
|
||||
else:
|
||||
result = audit_catalog(session)
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Iterable
|
||||
from urllib.parse import urlparse
|
||||
@@ -26,12 +28,153 @@ SOURCE_HOSTS = {
|
||||
"rf4map": {"rf4map.ru"},
|
||||
"rf4posts-spot": {"rf4-posts.com"},
|
||||
}
|
||||
COORDINATE_PRECISIONS = frozenset({"exact", "approximate", "area", "missing"})
|
||||
|
||||
|
||||
class CommunityImportError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def upsert_waterbody_catalog(
|
||||
session: Session, rows: Iterable[dict[str, Any]], *, fetched_at: datetime | None = None,
|
||||
) -> tuple[int, int]:
|
||||
"""Apply a validated canonical waterbody snapshot without destructive sync.
|
||||
|
||||
Rows are matched by the RF4DB source identity first and by an exact existing
|
||||
name second. Missing rows are deliberately left untouched: an incomplete
|
||||
response must never withdraw a previously known waterbody.
|
||||
"""
|
||||
fetched_at = fetched_at or datetime.now(timezone.utc)
|
||||
created = updated = 0
|
||||
for raw in rows:
|
||||
payload = _json_payload(raw)
|
||||
if payload.get("source_system") != "rf4db":
|
||||
raise CommunityImportError("waterbody catalog requires source_system=rf4db")
|
||||
external_id = _required(payload, "source_external_id", 200)
|
||||
name = _required(payload, "name", 200)
|
||||
source_url = _required(payload, "source_url", 2000)
|
||||
parsed_url = urlparse(source_url)
|
||||
if parsed_url.scheme != "https" or parsed_url.hostname not in {"rf4db.com", "www.rf4db.com"}:
|
||||
raise CommunityImportError("waterbody source_url does not match rf4db")
|
||||
unlock_level = _integer(payload.get("unlock_level"), minimum=0, maximum=1_000)
|
||||
unlock_label = _required(payload, "unlock_label", 50)
|
||||
fish_species_count = _integer(payload.get("fish_species_count"), minimum=0, maximum=10_000)
|
||||
if fish_species_count is None:
|
||||
raise CommunityImportError("invalid fish_species_count")
|
||||
|
||||
item = session.scalar(select(Waterbody).where(
|
||||
Waterbody.source_system == "rf4db",
|
||||
Waterbody.source_external_id == external_id,
|
||||
))
|
||||
if item is None:
|
||||
item = session.scalar(select(Waterbody).where(Waterbody.name_ru == name))
|
||||
if item is None:
|
||||
item = Waterbody(
|
||||
slug=_catalog_slug(session, name, external_id),
|
||||
name_ru=name,
|
||||
unlock_level=unlock_level,
|
||||
)
|
||||
session.add(item)
|
||||
created += 1
|
||||
else:
|
||||
updated += 1
|
||||
item.name_ru = name
|
||||
item.unlock_level = unlock_level
|
||||
item.fish_species_count = fish_species_count
|
||||
item.source_system = "rf4db"
|
||||
item.source_external_id = external_id
|
||||
item.source_url = source_url
|
||||
item.source_checked_at = fetched_at
|
||||
session.commit()
|
||||
return created, updated
|
||||
|
||||
|
||||
def update_waterbody_detail(
|
||||
session: Session, detail: dict[str, Any], *, fetched_at: datetime | None = None,
|
||||
) -> bool:
|
||||
"""Persist one complete RF4DB detail snapshot without assigning media roles."""
|
||||
fetched_at = fetched_at or datetime.now(timezone.utc)
|
||||
payload = _validate_waterbody_detail(detail)
|
||||
_apply_waterbody_detail(session, payload, fetched_at=fetched_at)
|
||||
session.commit()
|
||||
return True
|
||||
|
||||
|
||||
def update_waterbody_details(
|
||||
session: Session, details: Iterable[dict[str, Any]], *, fetched_at: datetime | None = None,
|
||||
) -> tuple[int, int]:
|
||||
"""Validate and apply a detail batch in one transaction."""
|
||||
fetched_at = fetched_at or datetime.now(timezone.utc)
|
||||
payloads = [_validate_waterbody_detail(detail) for detail in details]
|
||||
external_ids = [str(payload["source_external_id"]) for payload in payloads]
|
||||
if len(external_ids) != len(set(external_ids)):
|
||||
raise CommunityImportError("waterbody detail batch contains duplicate source identities")
|
||||
updated = 0
|
||||
for payload in payloads:
|
||||
_apply_waterbody_detail(session, payload, fetched_at=fetched_at)
|
||||
updated += 1
|
||||
session.commit()
|
||||
return 0, updated
|
||||
|
||||
|
||||
def _validate_waterbody_detail(detail: dict[str, Any]) -> dict[str, Any]:
|
||||
payload = _json_payload(detail)
|
||||
if payload.get("source_system") != "rf4db":
|
||||
raise CommunityImportError("waterbody detail requires source_system=rf4db")
|
||||
external_id = _required(payload, "source_external_id", 200)
|
||||
source_url = _required(payload, "source_url", 2000)
|
||||
parsed_url = urlparse(source_url)
|
||||
if parsed_url.scheme != "https" or parsed_url.hostname not in {"rf4db.com", "www.rf4db.com"}:
|
||||
raise CommunityImportError("waterbody detail source_url does not match rf4db")
|
||||
_required(payload, "name", 200)
|
||||
_optional(payload, "description", 20_000)
|
||||
_string_list(payload, "aliases", 100, 200)
|
||||
_string_list(payload, "fish_species", 10_000, 200)
|
||||
_string_list(payload, "image_urls", 100, 2_000)
|
||||
_string_list(payload, "point_urls", 10_000, 2_000)
|
||||
return payload
|
||||
|
||||
|
||||
def _apply_waterbody_detail(session: Session, payload: dict[str, Any], *, fetched_at: datetime) -> None:
|
||||
external_id = str(payload["source_external_id"])
|
||||
source_url = str(payload["source_url"])
|
||||
item = session.scalar(select(Waterbody).where(
|
||||
Waterbody.source_system == "rf4db", Waterbody.source_external_id == external_id,
|
||||
))
|
||||
if item is None:
|
||||
raise CommunityImportError("waterbody detail has no imported catalog identity")
|
||||
item.description = _optional(payload, "description", 20_000)
|
||||
item.source_aliases = _string_list(payload, "aliases", 100, 200)
|
||||
item.source_fish_species = _string_list(payload, "fish_species", 10_000, 200)
|
||||
item.source_image_urls = _string_list(payload, "image_urls", 100, 2_000)
|
||||
item.source_point_urls = _string_list(payload, "point_urls", 10_000, 2_000)
|
||||
item.source_url = source_url
|
||||
item.source_checked_at = fetched_at
|
||||
|
||||
|
||||
def _catalog_slug(session: Session, name: str, external_id: str) -> str:
|
||||
base = re.sub(r"[^a-z0-9а-яё]+", "-", name.casefold(), flags=re.IGNORECASE).strip("-")
|
||||
base = base or "waterbody"
|
||||
candidate = base[:100]
|
||||
if session.scalar(select(Waterbody.id).where(Waterbody.slug == candidate)) is None:
|
||||
return candidate
|
||||
suffix = hashlib.sha256(external_id.encode()).hexdigest()[:10]
|
||||
return f"{base[:89]}-{suffix}"
|
||||
|
||||
|
||||
def _string_list(payload: dict[str, Any], key: str, max_items: int, max_length: int) -> list[str]:
|
||||
value = payload.get(key)
|
||||
if not isinstance(value, list) or len(value) > max_items:
|
||||
raise CommunityImportError(f"invalid {key}")
|
||||
result = []
|
||||
for item in value:
|
||||
text = str(item).strip()
|
||||
if not text or len(text) > max_length:
|
||||
raise CommunityImportError(f"invalid {key}")
|
||||
result.append(text)
|
||||
return list(dict.fromkeys(result))
|
||||
|
||||
|
||||
def stage_observations(
|
||||
session: Session, records: Iterable[dict[str, Any]], *, fetched_at: datetime | None = None,
|
||||
) -> tuple[int, int]:
|
||||
@@ -65,9 +208,12 @@ def stage_observations(
|
||||
"waterbody_external_id": _optional(payload, "waterbody_external_id", 200),
|
||||
"x": _integer(payload.get("x"), maximum=10_000),
|
||||
"y": _integer(payload.get("y"), maximum=10_000),
|
||||
"coordinate_raw": _coordinate_raw(payload),
|
||||
"coordinate_precision": _coordinate_precision(payload),
|
||||
"weight_g": _integer(payload.get("weight_g"), minimum=1, maximum=3_000_000),
|
||||
"published_at": _datetime(payload.get("published_at")),
|
||||
"last_seen_at": fetched_at, "payload": payload,
|
||||
"source_check_status": "available", "source_checked_at": fetched_at,
|
||||
}
|
||||
if observation is None:
|
||||
observation = ExternalObservation(
|
||||
@@ -82,6 +228,7 @@ def stage_observations(
|
||||
changed = any(getattr(observation, key) != values[key] for key in (
|
||||
"source_url", "fish_name", "fish_external_id", "waterbody_name",
|
||||
"waterbody_external_id", "x", "y", "weight_g",
|
||||
"coordinate_raw", "coordinate_precision",
|
||||
)) or observation.payload != payload
|
||||
if observation.status != "rejected" and changed and observation.catch_report is not None:
|
||||
observation.catch_report.moderation_status = ModerationStatus.pending
|
||||
@@ -89,8 +236,17 @@ def stage_observations(
|
||||
observation.fish = None
|
||||
observation.waterbody = None
|
||||
observation.review_note = "Source record changed; manual mapping and publication required"
|
||||
observation.reviewed_at = fetched_at
|
||||
observation.moderation_version += 1
|
||||
for key, value in values.items():
|
||||
setattr(observation, key, value)
|
||||
if observation.status == "withdrawn":
|
||||
observation.status = "staged"
|
||||
observation.fish = None
|
||||
observation.waterbody = None
|
||||
observation.review_note = "Source record reappeared; manual confirmation required"
|
||||
observation.reviewed_at = fetched_at
|
||||
observation.moderation_version += 1
|
||||
updated += 1
|
||||
touched.append(observation)
|
||||
session.commit()
|
||||
@@ -176,6 +332,27 @@ def _optional(payload: dict[str, Any], key: str, limit: int) -> str | None:
|
||||
return value or None
|
||||
|
||||
|
||||
def _coordinate_raw(payload: dict[str, Any]) -> str | None:
|
||||
value = str(payload.get("coordinate_raw") or "").strip()
|
||||
if len(value) > 200:
|
||||
raise CommunityImportError("invalid coordinate_raw")
|
||||
if value:
|
||||
return value
|
||||
x, y = payload.get("x"), payload.get("y")
|
||||
return f"{x}:{y}" if isinstance(x, int) and isinstance(y, int) else None
|
||||
|
||||
|
||||
def _coordinate_precision(payload: dict[str, Any]) -> str:
|
||||
value = str(payload.get("coordinate_precision") or "").strip().casefold()
|
||||
if not value:
|
||||
return "exact" if isinstance(payload.get("x"), int) and isinstance(payload.get("y"), int) else "missing"
|
||||
if value not in COORDINATE_PRECISIONS:
|
||||
raise CommunityImportError("invalid coordinate_precision")
|
||||
if value == "exact" and (not isinstance(payload.get("x"), int) or not isinstance(payload.get("y"), int)):
|
||||
raise CommunityImportError("exact coordinates require x and y")
|
||||
return value
|
||||
|
||||
|
||||
def _integer(value: Any, *, minimum: int = -10_000, maximum: int) -> int | None:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
@@ -104,6 +104,8 @@ def publish_observation(session: Session, observation: ExternalObservation) -> C
|
||||
"external_observation_id": str(observation.id),
|
||||
"source_system": observation.source_system,
|
||||
"source_external_id": observation.source_external_id,
|
||||
"coordinate_raw": observation.coordinate_raw,
|
||||
"coordinate_precision": observation.coordinate_precision,
|
||||
},
|
||||
"original": observation.payload,
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@ from .config import settings
|
||||
from .database import SessionLocal
|
||||
from .logging_config import configure_logging
|
||||
from .models import CommunityImportRun, DataSource
|
||||
from .source_lifecycle import classify_source_failure, record_scheduled_source_check
|
||||
|
||||
logger = logging.getLogger("rf4.community_scheduler")
|
||||
MAX_BACKOFF_SECONDS = 24 * 60 * 60
|
||||
@@ -102,11 +103,27 @@ def run_source(source_system: str, *, now: datetime | None = None) -> bool:
|
||||
html = fetch_html(url)
|
||||
records = parser(html, source_url=url) if source_system in {"rf4map", "rf4posts-spot"} else parser(html)
|
||||
created, updated = stage_observations(session, [asdict(item) for item in records])
|
||||
record_scheduled_source_check(
|
||||
session, source_system=source_system, source_url=url,
|
||||
status="available", checked_at=current,
|
||||
)
|
||||
run.status, run.rows_seen, run.rows_created, run.rows_updated = "success", len(records), created, updated
|
||||
except Exception as exc:
|
||||
session.rollback()
|
||||
source_status = classify_source_failure(exc)
|
||||
checked = datetime.now(timezone.utc)
|
||||
affected = record_scheduled_source_check(
|
||||
session,
|
||||
source_system=source_system,
|
||||
source_url=url,
|
||||
status=source_status,
|
||||
checked_at=checked,
|
||||
)
|
||||
run.status, run.error_summary = "failed", f"{type(exc).__name__}: {str(exc)[:500]}"
|
||||
logger.exception("community import failed", extra={"event":"community_import_failed", "source_system":source_system})
|
||||
logger.exception("community import failed", extra={
|
||||
"event":"community_import_failed", "source_system":source_system,
|
||||
"source_check_status": source_status, "affected_observations": affected,
|
||||
})
|
||||
run.finished_at = datetime.now(timezone.utc); session.commit()
|
||||
return True
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from .readiness import readiness_report
|
||||
from .routers.activity import router as activity_router
|
||||
from .routers.admin import router as admin_router
|
||||
from .routers.catalog import router as catalog_router
|
||||
from .routers.media import router as media_router
|
||||
from .routers.public_data import router as public_data_router
|
||||
from .routers.submissions import router as submissions_router
|
||||
from .storage import client as storage_client
|
||||
@@ -87,6 +88,7 @@ def ready(db: Db) -> JSONResponse:
|
||||
|
||||
|
||||
app.include_router(catalog_router)
|
||||
app.include_router(media_router)
|
||||
app.include_router(activity_router)
|
||||
app.include_router(public_data_router)
|
||||
app.include_router(admin_router)
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MEDIA_ROOT = Path(os.environ.get("MEDIA_ROOT", "data/media")).resolve()
|
||||
|
||||
|
||||
def published_assets(entity_type: str | None = None) -> list[dict]:
|
||||
manifest = json.loads((MEDIA_ROOT / "manifest.json").read_text(encoding="utf-8"))
|
||||
result = []
|
||||
for item in manifest.get("assets", []):
|
||||
if item.get("status") != "approved" or not item.get("sha256") or not item.get("local_path"):
|
||||
continue
|
||||
if entity_type and item.get("entity_type") != entity_type:
|
||||
continue
|
||||
source_page = str(item.get("source_page") or "")
|
||||
source = "rf4db" if "rf4db.com" in source_page else "rf4map" if "rf4map.ru" in source_page else "rf4-official"
|
||||
result.append({
|
||||
"id": item["sha256"],
|
||||
"entity_type": item.get("entity_type"),
|
||||
"entity_key": item.get("entity_key"),
|
||||
"label": item.get("label"),
|
||||
"width": item.get("width"),
|
||||
"height": item.get("height"),
|
||||
"content_type": item.get("content_type"),
|
||||
"image_url": f"/api/v1/media/assets/{item['sha256']}",
|
||||
"source_system": source,
|
||||
"source_url": source_page,
|
||||
"variants": [
|
||||
{
|
||||
"role": variant.get("role"),
|
||||
"format": variant.get("format"),
|
||||
"width": variant.get("width"),
|
||||
"height": variant.get("height"),
|
||||
"url": f"/api/v1/media/assets/{variant['sha256']}",
|
||||
}
|
||||
for variant in item.get("derivatives", [])
|
||||
if variant.get("sha256") and variant.get("local_path")
|
||||
],
|
||||
})
|
||||
return sorted(result, key=lambda item: (str(item["entity_type"]), str(item["label"] or "").casefold(), item["id"]))
|
||||
|
||||
|
||||
def published_file(digest: str) -> tuple[Path, str] | None:
|
||||
if len(digest) != 64 or any(char not in "0123456789abcdef" for char in digest):
|
||||
return None
|
||||
manifest = json.loads((MEDIA_ROOT / "manifest.json").read_text(encoding="utf-8"))
|
||||
item = next((row for row in manifest.get("assets", []) if row.get("status") == "approved" and row.get("sha256") == digest), None)
|
||||
if not item:
|
||||
return None
|
||||
target = (MEDIA_ROOT / item["local_path"]).resolve()
|
||||
if not target.is_relative_to(MEDIA_ROOT.resolve()) or not target.is_file():
|
||||
return None
|
||||
return target, str(item["content_type"])
|
||||
|
||||
|
||||
def review_assets(entity_type: str | None = None, status: str | None = None) -> list[dict]:
|
||||
manifest = json.loads((MEDIA_ROOT / "manifest.json").read_text(encoding="utf-8"))
|
||||
result = []
|
||||
for item in manifest.get("assets", []):
|
||||
item_status = str(item.get("status") or "")
|
||||
if item_status not in {"approved", "upgrade_queued", "upgrade_stored"} or (status and item_status != status):
|
||||
continue
|
||||
if entity_type and item.get("entity_type") != entity_type:
|
||||
continue
|
||||
digest = str(item.get("sha256") or "")
|
||||
if len(digest) != 64 or not item.get("local_path"):
|
||||
continue
|
||||
source_page = str(item.get("source_page") or "")
|
||||
source = "rf4db" if "rf4db.com" in source_page else "rf4map" if "rf4map.ru" in source_page else "rf4-official"
|
||||
result.append({
|
||||
"id": digest,
|
||||
"status": item_status,
|
||||
"entity_type": item.get("entity_type"),
|
||||
"entity_key": item.get("entity_key"),
|
||||
"label": item.get("label"),
|
||||
"width": item.get("width"),
|
||||
"height": item.get("height"),
|
||||
"content_type": item.get("content_type"),
|
||||
"image_url": f"/api/v1/admin/media/assets/{digest}",
|
||||
"asset_url": item.get("asset_url", ""),
|
||||
"source_system": source,
|
||||
"source_url": source_page,
|
||||
"duplicate_of": item.get("duplicate_of"),
|
||||
"supersedes": item.get("supersedes"),
|
||||
"derivatives": [{
|
||||
"role": variant.get("role"), "format": variant.get("format"),
|
||||
"width": variant.get("width"), "height": variant.get("height"),
|
||||
} for variant in item.get("derivatives", [])],
|
||||
})
|
||||
return sorted(result, key=lambda item: (str(item["status"]), str(item["entity_type"]), str(item["label"] or "").casefold(), item["id"]))
|
||||
|
||||
|
||||
def review_file(digest: str) -> tuple[Path, str] | None:
|
||||
if len(digest) != 64 or any(char not in "0123456789abcdef" for char in digest):
|
||||
return None
|
||||
manifest = json.loads((MEDIA_ROOT / "manifest.json").read_text(encoding="utf-8"))
|
||||
item = next((row for row in manifest.get("assets", []) if row.get("sha256") == digest and row.get("status") in {"approved", "upgrade_queued", "upgrade_stored"}), None)
|
||||
if not item or not item.get("local_path"):
|
||||
return None
|
||||
target = (MEDIA_ROOT / item["local_path"]).resolve()
|
||||
if not target.is_relative_to(MEDIA_ROOT.resolve()) or not target.is_file():
|
||||
return None
|
||||
return target, str(item.get("content_type") or "application/octet-stream")
|
||||
@@ -49,6 +49,16 @@ class Waterbody(Base):
|
||||
slug: Mapped[str] = mapped_column(String(100), unique=True)
|
||||
name_ru: Mapped[str] = mapped_column(String(200), unique=True)
|
||||
unlock_level: Mapped[int | None]
|
||||
fish_species_count: Mapped[int | None]
|
||||
source_system: Mapped[str | None] = mapped_column(String(50))
|
||||
source_external_id: Mapped[str | None] = mapped_column(String(200))
|
||||
source_url: Mapped[str | None] = mapped_column(Text)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
source_aliases: Mapped[list[str] | None] = mapped_column(JSON)
|
||||
source_fish_species: Mapped[list[str] | None] = mapped_column(JSON)
|
||||
source_image_urls: Mapped[list[str] | None] = mapped_column(JSON)
|
||||
source_point_urls: Mapped[list[str] | None] = mapped_column(JSON)
|
||||
source_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class Bait(Base):
|
||||
@@ -187,6 +197,8 @@ class ExternalObservation(Base):
|
||||
waterbody_external_id: Mapped[str | None] = mapped_column(String(200))
|
||||
x: Mapped[int | None]
|
||||
y: Mapped[int | None]
|
||||
coordinate_raw: Mapped[str | None] = mapped_column(String(200))
|
||||
coordinate_precision: Mapped[str] = mapped_column(String(20), default="missing")
|
||||
weight_g: Mapped[int | None]
|
||||
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
@@ -199,6 +211,8 @@ class ExternalObservation(Base):
|
||||
review_note: Mapped[str | None] = mapped_column(Text)
|
||||
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
moderation_version: Mapped[int] = mapped_column(default=0)
|
||||
source_check_status: Mapped[str | None] = mapped_column(String(30))
|
||||
source_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
source: Mapped[DataSource] = relationship()
|
||||
fish: Mapped[Fish | None] = relationship()
|
||||
waterbody: Mapped[Waterbody | None] = relationship()
|
||||
|
||||
@@ -68,7 +68,15 @@ def spot_detail(spot_id: UUID, db: Db) -> SpotOut:
|
||||
def count_since(delta: timedelta) -> int:
|
||||
return sum(aware(report.reported_at) >= now - delta for report in reports)
|
||||
|
||||
return SpotOut(id=spot.id, waterbody_slug=spot.waterbody.slug, waterbody=spot.waterbody.name_ru, x=spot.x, y=spot.y, description=spot.description, catches_24h=count_since(timedelta(hours=24)), catches_3d=count_since(timedelta(days=3)), catches_7d=count_since(timedelta(days=7)), top_baits=[name for name, _ in bait_counts.most_common(5)])
|
||||
provenance = [
|
||||
(report.raw_payload or {}).get("provenance", {})
|
||||
for report in reports
|
||||
if isinstance((report.raw_payload or {}).get("provenance", {}), dict)
|
||||
]
|
||||
precisions = [item.get("coordinate_precision") for item in provenance]
|
||||
precision = max((value for value in precisions if value in {"exact", "approximate", "area", "missing"}), key={"exact": 0, "approximate": 1, "area": 2, "missing": 3}.get, default="exact")
|
||||
sources = sorted({str(item.get("source_system")) for item in provenance if item.get("source_system")}) or ["players"]
|
||||
return SpotOut(id=spot.id, waterbody_slug=spot.waterbody.slug, waterbody=spot.waterbody.name_ru, x=spot.x, y=spot.y, description=spot.description, catches_24h=count_since(timedelta(hours=24)), catches_3d=count_since(timedelta(days=3)), catches_7d=count_since(timedelta(days=7)), top_baits=[name for name, _ in bait_counts.most_common(5)], coordinate_precision=precision, coordinate_sources=sources)
|
||||
|
||||
|
||||
def _report_source(report: CatchReport) -> str:
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Annotated, Literal
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from sqlalchemy import case, func, or_, select
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
@@ -15,9 +15,12 @@ from ..community_review import ExternalReviewError, map_observation, publish_obs
|
||||
from ..config import settings
|
||||
from ..dependencies import Db
|
||||
from ..importer import ImportAlreadyRunning, ImportSourceError, import_records
|
||||
from rf4_research.media_assets import publish_quality_upgrades, rollback_quality_upgrade
|
||||
|
||||
from ..media_catalog import MEDIA_ROOT, review_assets, review_file
|
||||
from ..models import CatchReport, CommunityImportRun, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Waterbody
|
||||
from ..public_cache import public_cache
|
||||
from ..schemas import AdminCatchReportOut, AdminModerationHistoryOut, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationAction, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, ImportRunOut, ModerationUpdate
|
||||
from ..schemas import AdminCatchReportOut, AdminMediaDecision, AdminMediaReviewOut, AdminMediaRollback, AdminModerationHistoryOut, AdminSourceStatusOut, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationAction, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, ImportRunOut, ModerationUpdate
|
||||
from ..storage import delete_screenshot, signed_screenshot_url
|
||||
from ..time_utils import aware
|
||||
|
||||
@@ -29,6 +32,52 @@ def _admin(request: Request, db: Db, authorization: Annotated[str | None, Header
|
||||
return verify_admin(request, db, authorization, settings)
|
||||
|
||||
|
||||
@router.get("/api/v1/admin/media/catalog", response_model=list[AdminMediaReviewOut])
|
||||
def admin_media_catalog(
|
||||
_: Annotated[str, Depends(_admin)],
|
||||
entity_type: str | None = Query(None, pattern="^(fish|waterbody|tackle|reference)$"),
|
||||
status: str | None = Query(None, pattern="^(approved|upgrade_queued|upgrade_stored)$"),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> list[AdminMediaReviewOut]:
|
||||
return review_assets(entity_type, status)[offset:offset + limit]
|
||||
|
||||
|
||||
@router.get("/api/v1/admin/media/assets/{digest}", response_class=FileResponse)
|
||||
def admin_media_asset(digest: str, _: Annotated[str, Depends(_admin)]) -> FileResponse:
|
||||
item = review_file(digest)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Media review asset not found")
|
||||
path, media_type = item
|
||||
return FileResponse(path, media_type=media_type, headers={"Cache-Control": "private, no-store"})
|
||||
|
||||
|
||||
@router.post("/api/v1/admin/media/upgrades/publish")
|
||||
def admin_publish_media_upgrades(
|
||||
payload: AdminMediaDecision,
|
||||
_: Annotated[str, Depends(_admin)],
|
||||
) -> dict[str, int]:
|
||||
"""Atomically publish all stored quality upgrades after an explicit decision."""
|
||||
try:
|
||||
return publish_quality_upgrades(MEDIA_ROOT / "manifest.json", note=payload.note)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/api/v1/admin/media/upgrades/rollback")
|
||||
def admin_rollback_media_upgrade(
|
||||
payload: AdminMediaRollback,
|
||||
_: Annotated[str, Depends(_admin)],
|
||||
) -> dict[str, str]:
|
||||
"""Restore one superseded fallback while retaining the reviewed candidate."""
|
||||
try:
|
||||
return rollback_quality_upgrade(
|
||||
MEDIA_ROOT / "manifest.json", asset_url=payload.asset_url, note=payload.note,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/api/v1/admin/diagnostics")
|
||||
def admin_diagnostics(db: Db, _: Annotated[str, Depends(_admin)]) -> JSONResponse:
|
||||
report_counts = {status.value: count for status, count in db.execute(
|
||||
@@ -131,6 +180,52 @@ def admin_start_official_import(db: Db, _: Annotated[str, Depends(_admin)]) -> O
|
||||
raise HTTPException(status_code=502, detail=f"official records import failed: {exc}") from exc
|
||||
|
||||
|
||||
@router.get("/api/v1/admin/source-status", response_model=list[AdminSourceStatusOut])
|
||||
def admin_source_status(db: Db, _: Annotated[str, Depends(_admin)]) -> list[AdminSourceStatusOut]:
|
||||
"""Return safe operational details needed by the owner dashboard."""
|
||||
now = datetime.now(timezone.utc)
|
||||
result: list[AdminSourceStatusOut] = []
|
||||
for source in db.scalars(select(DataSource).order_by(DataSource.name)):
|
||||
runs = list(db.scalars(
|
||||
select(CommunityImportRun)
|
||||
.where(CommunityImportRun.source_system == source.key)
|
||||
.order_by(CommunityImportRun.started_at.desc()).limit(20)
|
||||
))
|
||||
latest = runs[0] if runs else None
|
||||
success = next((run for run in runs if run.status == "success"), None)
|
||||
recent_failures = sum(
|
||||
1 for run in runs
|
||||
if run.status == "failed" and aware(run.started_at) >= now - timedelta(hours=24)
|
||||
)
|
||||
next_allowed = (
|
||||
aware(latest.started_at) + timedelta(seconds=settings.community_import_interval_seconds)
|
||||
if latest else None
|
||||
)
|
||||
cooldown_seconds = max(0, int((next_allowed - now).total_seconds())) if next_allowed else 0
|
||||
if not source.enabled:
|
||||
state = "disabled"
|
||||
elif latest is None:
|
||||
state = "waiting"
|
||||
elif latest.status == "failed":
|
||||
state = "source_changed" if "CommunityParseError" in (latest.error_summary or "") else "temporarily_limited"
|
||||
elif aware(latest.started_at) < now - timedelta(seconds=settings.community_import_interval_seconds * 2):
|
||||
state = "stale"
|
||||
else:
|
||||
state = "healthy"
|
||||
result.append(AdminSourceStatusOut(
|
||||
source_system=source.key,
|
||||
name=source.name,
|
||||
status=state,
|
||||
last_started_at=latest.started_at if latest else None,
|
||||
last_success_at=success.started_at if success else None,
|
||||
next_allowed_at=next_allowed,
|
||||
cooldown_seconds=cooldown_seconds,
|
||||
recent_failures_24h=recent_failures,
|
||||
backoff_recommended=recent_failures >= 5,
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
def _external_out(item: ExternalObservation) -> ExternalObservationOut:
|
||||
allowed_payload = {
|
||||
key: value for key, value in (item.payload or {}).items()
|
||||
@@ -156,13 +251,14 @@ def _external_out(item: ExternalObservation) -> ExternalObservationOut:
|
||||
catch_report_id=item.catch_report_id, review_note=item.review_note,
|
||||
missing_fields=missing_fields, source_payload=allowed_payload,
|
||||
moderation_version=item.moderation_version,
|
||||
source_check_status=item.source_check_status, source_checked_at=item.source_checked_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/v1/admin/external-observations", response_model=list[ExternalObservationOut])
|
||||
def admin_external_observations(
|
||||
db: Db, _: Annotated[str, Depends(_admin)],
|
||||
status: Literal["staged", "mapped", "ready", "published", "rejected", "review"] | None = None,
|
||||
status: Literal["staged", "mapped", "ready", "published", "rejected", "withdrawn", "review"] | None = None,
|
||||
source_system: str | None = None,
|
||||
completeness: Literal["all", "complete", "incomplete"] = "all",
|
||||
order: Literal["newest", "oldest", "risk"] = "newest",
|
||||
@@ -328,4 +424,3 @@ def delete_report(report_id: UUID, db: Db, moderator: Annotated[str, Depends(_ad
|
||||
db.commit()
|
||||
public_cache.invalidate()
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from ..media_catalog import published_assets, published_file
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/v1/media/catalog")
|
||||
def media_catalog(entity_type: str | None = Query(None, pattern="^(fish|waterbody|tackle|reference)$")) -> list[dict]:
|
||||
return published_assets(entity_type)
|
||||
|
||||
|
||||
@router.get("/api/v1/media/assets/{digest}", response_class=FileResponse)
|
||||
def media_asset(digest: str) -> FileResponse:
|
||||
item = published_file(digest)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Media asset not found")
|
||||
path, media_type = item
|
||||
return FileResponse(path, media_type=media_type, headers={
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
"ETag": f'"{digest}"',
|
||||
})
|
||||
@@ -40,7 +40,7 @@ def community_observations(
|
||||
joinedload(ExternalObservation.source)
|
||||
).where(
|
||||
ExternalObservation.catch_report_id.is_(None),
|
||||
ExternalObservation.status != "rejected",
|
||||
ExternalObservation.status.not_in(["rejected", "withdrawn"]),
|
||||
DataSource.enabled.is_(True),
|
||||
)
|
||||
if waterbody:
|
||||
|
||||
@@ -20,6 +20,16 @@ class WaterbodyOut(BaseModel):
|
||||
slug: str
|
||||
name_ru: str
|
||||
unlock_level: int | None
|
||||
fish_species_count: int | None
|
||||
source_system: str | None
|
||||
source_external_id: str | None
|
||||
source_url: str | None
|
||||
description: str | None
|
||||
source_aliases: list[str] | None
|
||||
source_fish_species: list[str] | None
|
||||
source_image_urls: list[str] | None
|
||||
source_point_urls: list[str] | None
|
||||
source_checked_at: datetime | None
|
||||
|
||||
|
||||
class BaitOut(BaseModel):
|
||||
@@ -48,6 +58,8 @@ class ActivityOut(BaseModel):
|
||||
confidence_score: int
|
||||
explanation: str
|
||||
sources: list[str]
|
||||
coordinate_precision: str
|
||||
coordinate_sources: list[str]
|
||||
|
||||
|
||||
class PaginatedActivityOut(BaseModel):
|
||||
@@ -82,6 +94,8 @@ class SpotOut(BaseModel):
|
||||
catches_3d: int
|
||||
catches_7d: int
|
||||
top_baits: list[str]
|
||||
coordinate_precision: str
|
||||
coordinate_sources: list[str]
|
||||
|
||||
|
||||
class OfficialRecordOut(BaseModel):
|
||||
@@ -239,6 +253,8 @@ class ExternalObservationOut(BaseModel):
|
||||
missing_fields: list[str]
|
||||
source_payload: dict[str, str | int | float | bool | None]
|
||||
moderation_version: int
|
||||
source_check_status: str | None
|
||||
source_checked_at: datetime | None
|
||||
|
||||
|
||||
class ExternalObservationMapping(BaseModel):
|
||||
@@ -285,3 +301,48 @@ class SourceStatusOut(BaseModel):
|
||||
last_started_at: datetime | None
|
||||
last_success_at: datetime | None
|
||||
observations: int
|
||||
|
||||
|
||||
class AdminSourceStatusOut(BaseModel):
|
||||
source_system: str
|
||||
name: str
|
||||
status: str
|
||||
last_started_at: datetime | None
|
||||
last_success_at: datetime | None
|
||||
next_allowed_at: datetime | None
|
||||
cooldown_seconds: int
|
||||
recent_failures_24h: int
|
||||
backoff_recommended: bool
|
||||
|
||||
|
||||
class AdminMediaDerivativeOut(BaseModel):
|
||||
role: str | None
|
||||
format: str | None
|
||||
width: int | None
|
||||
height: int | None
|
||||
|
||||
|
||||
class AdminMediaReviewOut(BaseModel):
|
||||
id: str
|
||||
status: str
|
||||
entity_type: str | None
|
||||
entity_key: str | None
|
||||
label: str | None
|
||||
width: int | None
|
||||
height: int | None
|
||||
content_type: str | None
|
||||
image_url: str
|
||||
asset_url: str
|
||||
source_system: str
|
||||
source_url: str
|
||||
duplicate_of: str | None
|
||||
supersedes: str | None
|
||||
derivatives: list[AdminMediaDerivativeOut]
|
||||
|
||||
|
||||
class AdminMediaDecision(BaseModel):
|
||||
note: str = Field(min_length=1, max_length=1000)
|
||||
|
||||
|
||||
class AdminMediaRollback(AdminMediaDecision):
|
||||
asset_url: str = Field(min_length=1, max_length=2000)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Literal
|
||||
from urllib.error import HTTPError
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .models import ExternalObservation, ModerationStatus
|
||||
|
||||
|
||||
SourceCheckStatus = Literal["available", "missing", "temporary_error", "blocked"]
|
||||
|
||||
|
||||
def classify_source_failure(exc: Exception) -> SourceCheckStatus:
|
||||
"""Classify the result of the scheduled request without retrying it."""
|
||||
if isinstance(exc, HTTPError):
|
||||
if exc.code in {404, 410}:
|
||||
return "missing"
|
||||
if exc.code in {401, 403, 429}:
|
||||
return "blocked"
|
||||
return "temporary_error"
|
||||
|
||||
|
||||
def record_source_check(
|
||||
session: Session,
|
||||
observation: ExternalObservation,
|
||||
status: SourceCheckStatus,
|
||||
*,
|
||||
checked_at: datetime | None = None,
|
||||
) -> ExternalObservation:
|
||||
"""Persist a check performed during an already scheduled source request.
|
||||
|
||||
Only an authoritative 404/410-style ``missing`` result withdraws published
|
||||
data. Transient errors and access blocks remain diagnostic and never remove
|
||||
an observation from activity.
|
||||
"""
|
||||
_apply_source_check(observation, status, checked_at or datetime.now(timezone.utc))
|
||||
session.commit()
|
||||
return observation
|
||||
|
||||
|
||||
def _apply_source_check(
|
||||
observation: ExternalObservation,
|
||||
status: SourceCheckStatus,
|
||||
checked_at: datetime,
|
||||
) -> None:
|
||||
"""Mutate one observation; the caller owns the transaction boundary."""
|
||||
current = checked_at
|
||||
observation.source_check_status = status
|
||||
observation.source_checked_at = current
|
||||
if status == "missing" and observation.status != "withdrawn":
|
||||
if observation.catch_report is not None:
|
||||
observation.catch_report.moderation_status = ModerationStatus.pending
|
||||
observation.status = "withdrawn"
|
||||
observation.review_note = "Source record missing; withdrawn pending moderator review"
|
||||
observation.reviewed_at = current
|
||||
observation.moderation_version += 1
|
||||
|
||||
|
||||
def record_scheduled_source_check(
|
||||
session: Session,
|
||||
*,
|
||||
source_system: str,
|
||||
source_url: str,
|
||||
status: SourceCheckStatus,
|
||||
checked_at: datetime | None = None,
|
||||
) -> int:
|
||||
"""Apply one scheduled request result only to observations with that exact URL.
|
||||
|
||||
Aggregate pages cannot prove that an omitted record was deleted, so absence
|
||||
from a parsed listing is deliberately ignored.
|
||||
"""
|
||||
observations = list(session.scalars(select(ExternalObservation).where(
|
||||
ExternalObservation.source_system == source_system,
|
||||
ExternalObservation.source_url == source_url,
|
||||
)))
|
||||
current = checked_at or datetime.now(timezone.utc)
|
||||
for observation in observations:
|
||||
_apply_source_check(observation, status, current)
|
||||
session.commit()
|
||||
return len(observations)
|
||||
@@ -30,6 +30,17 @@
|
||||
"title": "Confidence Score",
|
||||
"type": "integer"
|
||||
},
|
||||
"coordinate_precision": {
|
||||
"title": "Coordinate Precision",
|
||||
"type": "string"
|
||||
},
|
||||
"coordinate_sources": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Coordinate Sources",
|
||||
"type": "array"
|
||||
},
|
||||
"explanation": {
|
||||
"title": "Explanation",
|
||||
"type": "string"
|
||||
@@ -101,7 +112,9 @@
|
||||
"activity_score",
|
||||
"confidence_score",
|
||||
"explanation",
|
||||
"sources"
|
||||
"sources",
|
||||
"coordinate_precision",
|
||||
"coordinate_sources"
|
||||
],
|
||||
"title": "ActivityOut",
|
||||
"type": "object"
|
||||
@@ -204,6 +217,187 @@
|
||||
"title": "AdminCatchReportOut",
|
||||
"type": "object"
|
||||
},
|
||||
"AdminMediaDerivativeOut": {
|
||||
"properties": {
|
||||
"format": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Format"
|
||||
},
|
||||
"height": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Height"
|
||||
},
|
||||
"role": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Role"
|
||||
},
|
||||
"width": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Width"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"role",
|
||||
"format",
|
||||
"width",
|
||||
"height"
|
||||
],
|
||||
"title": "AdminMediaDerivativeOut",
|
||||
"type": "object"
|
||||
},
|
||||
"AdminMediaReviewOut": {
|
||||
"properties": {
|
||||
"content_type": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Content Type"
|
||||
},
|
||||
"derivatives": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/AdminMediaDerivativeOut"
|
||||
},
|
||||
"title": "Derivatives",
|
||||
"type": "array"
|
||||
},
|
||||
"duplicate_of": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Duplicate Of"
|
||||
},
|
||||
"entity_key": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Entity Key"
|
||||
},
|
||||
"entity_type": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Entity Type"
|
||||
},
|
||||
"height": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Height"
|
||||
},
|
||||
"id": {
|
||||
"title": "Id",
|
||||
"type": "string"
|
||||
},
|
||||
"image_url": {
|
||||
"title": "Image Url",
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Label"
|
||||
},
|
||||
"source_system": {
|
||||
"title": "Source System",
|
||||
"type": "string"
|
||||
},
|
||||
"source_url": {
|
||||
"title": "Source Url",
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"title": "Status",
|
||||
"type": "string"
|
||||
},
|
||||
"width": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Width"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"status",
|
||||
"entity_type",
|
||||
"entity_key",
|
||||
"label",
|
||||
"width",
|
||||
"height",
|
||||
"content_type",
|
||||
"image_url",
|
||||
"source_system",
|
||||
"source_url",
|
||||
"duplicate_of",
|
||||
"derivatives"
|
||||
],
|
||||
"title": "AdminMediaReviewOut",
|
||||
"type": "object"
|
||||
},
|
||||
"AdminModerationHistoryOut": {
|
||||
"properties": {
|
||||
"action": {
|
||||
@@ -263,6 +457,83 @@
|
||||
"title": "AdminModerationHistoryOut",
|
||||
"type": "object"
|
||||
},
|
||||
"AdminSourceStatusOut": {
|
||||
"properties": {
|
||||
"backoff_recommended": {
|
||||
"title": "Backoff Recommended",
|
||||
"type": "boolean"
|
||||
},
|
||||
"cooldown_seconds": {
|
||||
"title": "Cooldown Seconds",
|
||||
"type": "integer"
|
||||
},
|
||||
"last_started_at": {
|
||||
"anyOf": [
|
||||
{
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Last Started At"
|
||||
},
|
||||
"last_success_at": {
|
||||
"anyOf": [
|
||||
{
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Last Success At"
|
||||
},
|
||||
"name": {
|
||||
"title": "Name",
|
||||
"type": "string"
|
||||
},
|
||||
"next_allowed_at": {
|
||||
"anyOf": [
|
||||
{
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Next Allowed At"
|
||||
},
|
||||
"recent_failures_24h": {
|
||||
"title": "Recent Failures 24H",
|
||||
"type": "integer"
|
||||
},
|
||||
"source_system": {
|
||||
"title": "Source System",
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"title": "Status",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"source_system",
|
||||
"name",
|
||||
"status",
|
||||
"last_started_at",
|
||||
"last_success_at",
|
||||
"next_allowed_at",
|
||||
"cooldown_seconds",
|
||||
"recent_failures_24h",
|
||||
"backoff_recommended"
|
||||
],
|
||||
"title": "AdminSourceStatusOut",
|
||||
"type": "object"
|
||||
},
|
||||
"BaitOut": {
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -820,6 +1091,29 @@
|
||||
],
|
||||
"title": "Reviewed At"
|
||||
},
|
||||
"source_check_status": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Source Check Status"
|
||||
},
|
||||
"source_checked_at": {
|
||||
"anyOf": [
|
||||
{
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Source Checked At"
|
||||
},
|
||||
"source_external_id": {
|
||||
"title": "Source External Id",
|
||||
"type": "string"
|
||||
@@ -942,7 +1236,9 @@
|
||||
"review_note",
|
||||
"missing_fields",
|
||||
"source_payload",
|
||||
"moderation_version"
|
||||
"moderation_version",
|
||||
"source_check_status",
|
||||
"source_checked_at"
|
||||
],
|
||||
"title": "ExternalObservationOut",
|
||||
"type": "object"
|
||||
@@ -1585,6 +1881,17 @@
|
||||
"title": "Catches 7D",
|
||||
"type": "integer"
|
||||
},
|
||||
"coordinate_precision": {
|
||||
"title": "Coordinate Precision",
|
||||
"type": "string"
|
||||
},
|
||||
"coordinate_sources": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Coordinate Sources",
|
||||
"type": "array"
|
||||
},
|
||||
"description": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -1635,7 +1942,9 @@
|
||||
"catches_24h",
|
||||
"catches_3d",
|
||||
"catches_7d",
|
||||
"top_baits"
|
||||
"top_baits",
|
||||
"coordinate_precision",
|
||||
"coordinate_sources"
|
||||
],
|
||||
"title": "SpotOut",
|
||||
"type": "object"
|
||||
@@ -1675,6 +1984,28 @@
|
||||
},
|
||||
"WaterbodyOut": {
|
||||
"properties": {
|
||||
"description": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Description"
|
||||
},
|
||||
"fish_species_count": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Fish Species Count"
|
||||
},
|
||||
"id": {
|
||||
"format": "uuid",
|
||||
"title": "Id",
|
||||
@@ -1688,6 +2019,107 @@
|
||||
"title": "Slug",
|
||||
"type": "string"
|
||||
},
|
||||
"source_aliases": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Source Aliases"
|
||||
},
|
||||
"source_checked_at": {
|
||||
"anyOf": [
|
||||
{
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Source Checked At"
|
||||
},
|
||||
"source_external_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Source External Id"
|
||||
},
|
||||
"source_fish_species": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Source Fish Species"
|
||||
},
|
||||
"source_image_urls": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Source Image Urls"
|
||||
},
|
||||
"source_point_urls": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Source Point Urls"
|
||||
},
|
||||
"source_system": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Source System"
|
||||
},
|
||||
"source_url": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Source Url"
|
||||
},
|
||||
"unlock_level": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -1704,7 +2136,17 @@
|
||||
"id",
|
||||
"slug",
|
||||
"name_ru",
|
||||
"unlock_level"
|
||||
"unlock_level",
|
||||
"fish_species_count",
|
||||
"source_system",
|
||||
"source_external_id",
|
||||
"source_url",
|
||||
"description",
|
||||
"source_aliases",
|
||||
"source_fish_species",
|
||||
"source_image_urls",
|
||||
"source_point_urls",
|
||||
"source_checked_at"
|
||||
],
|
||||
"title": "WaterbodyOut",
|
||||
"type": "object"
|
||||
@@ -2109,6 +2551,7 @@
|
||||
"ready",
|
||||
"published",
|
||||
"rejected",
|
||||
"withdrawn",
|
||||
"review"
|
||||
],
|
||||
"type": "string"
|
||||
@@ -2625,6 +3068,161 @@
|
||||
"summary": "Admin Start Official Import"
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/media/assets/{digest}": {
|
||||
"get": {
|
||||
"operationId": "admin_media_asset_api_v1_admin_media_assets__digest__get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "digest",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "Digest",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "header",
|
||||
"name": "authorization",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Authorization"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"summary": "Admin Media Asset"
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/media/catalog": {
|
||||
"get": {
|
||||
"operationId": "admin_media_catalog_api_v1_admin_media_catalog_get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "entity_type",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"pattern": "^(fish|waterbody|tackle|reference)$",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Entity Type"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "status",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"pattern": "^(approved|upgrade_queued|upgrade_stored)$",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Status"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "limit",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"default": 50,
|
||||
"maximum": 100,
|
||||
"minimum": 1,
|
||||
"title": "Limit",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "offset",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"default": 0,
|
||||
"minimum": 0,
|
||||
"title": "Offset",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "header",
|
||||
"name": "authorization",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Authorization"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/AdminMediaReviewOut"
|
||||
},
|
||||
"title": "Response Admin Media Catalog Api V1 Admin Media Catalog Get",
|
||||
"type": "array"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"summary": "Admin Media Catalog"
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/moderation-history": {
|
||||
"get": {
|
||||
"operationId": "admin_moderation_history_api_v1_admin_moderation_history_get",
|
||||
@@ -2755,6 +3353,57 @@
|
||||
"summary": "Admin Moderation History Export"
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/source-status": {
|
||||
"get": {
|
||||
"description": "Return safe operational details needed by the owner dashboard.",
|
||||
"operationId": "admin_source_status_api_v1_admin_source_status_get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "header",
|
||||
"name": "authorization",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Authorization"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/AdminSourceStatusOut"
|
||||
},
|
||||
"title": "Response Admin Source Status Api V1 Admin Source Status Get",
|
||||
"type": "array"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"summary": "Admin Source Status"
|
||||
}
|
||||
},
|
||||
"/api/v1/baits": {
|
||||
"get": {
|
||||
"operationId": "baits_api_v1_baits_get",
|
||||
@@ -3130,6 +3779,90 @@
|
||||
"summary": "Imports"
|
||||
}
|
||||
},
|
||||
"/api/v1/media/assets/{digest}": {
|
||||
"get": {
|
||||
"operationId": "media_asset_api_v1_media_assets__digest__get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "digest",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "Digest",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"summary": "Media Asset"
|
||||
}
|
||||
},
|
||||
"/api/v1/media/catalog": {
|
||||
"get": {
|
||||
"operationId": "media_catalog_api_v1_media_catalog_get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "entity_type",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"pattern": "^(fish|waterbody|tackle|reference)$",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Entity Type"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"items": {
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
"title": "Response Media Catalog Api V1 Media Catalog Get",
|
||||
"type": "array"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"summary": "Media Catalog"
|
||||
}
|
||||
},
|
||||
"/api/v1/public-spot-pages": {
|
||||
"get": {
|
||||
"operationId": "public_spot_pages_api_v1_public_spot_pages_get",
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.community_importer import stage_observations
|
||||
from app.importer import ImportAlreadyRunning
|
||||
from app.main import app
|
||||
from app.models import Bait, BaitKind, CatchReport, DataSource, ExternalEntityAlias, ExternalObservation, Fish, ImportStatus, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody
|
||||
from app.routers import admin as admin_router
|
||||
|
||||
|
||||
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
@@ -56,11 +57,34 @@ def test_activity_filters_and_explains_score() -> None:
|
||||
assert payload["items"][0]["sources"] == ["manual-import"]
|
||||
|
||||
|
||||
def test_waterbody_catalog_exposes_nullable_source_provenance() -> None:
|
||||
response = client.get("/api/v1/waterbodies")
|
||||
assert response.status_code == 200
|
||||
item = next(row for row in response.json() if row["slug"] == "test-lake")
|
||||
assert item["source_system"] is None
|
||||
assert item["source_external_id"] is None
|
||||
assert item["source_url"] is None
|
||||
assert item["description"] is None
|
||||
assert item["source_checked_at"] is None
|
||||
|
||||
|
||||
def test_invalid_period_is_rejected() -> None:
|
||||
assert client.get("/api/v1/activity?hours=13").status_code == 422
|
||||
assert client.get("/api/v1/activity?sort=unknown").status_code == 422
|
||||
|
||||
|
||||
def test_published_media_catalog_and_content_addressed_file() -> None:
|
||||
catalog = client.get("/api/v1/media/catalog?entity_type=fish")
|
||||
assert catalog.status_code == 200
|
||||
assert catalog.json()
|
||||
item = catalog.json()[0]
|
||||
image = client.get(item["image_url"])
|
||||
assert image.status_code == 200
|
||||
assert image.headers["content-type"].startswith("image/")
|
||||
assert image.headers["cache-control"] == "public, max-age=31536000, immutable"
|
||||
assert client.get("/api/v1/media/assets/not-a-hash").status_code == 404
|
||||
|
||||
|
||||
def test_review_queue_filters_before_pagination() -> None:
|
||||
with Session(engine) as db:
|
||||
stage_observations(db, [{
|
||||
@@ -139,6 +163,54 @@ def test_public_source_status_hides_internal_details() -> None:
|
||||
assert all("error_summary" not in item and "source_url" not in item for item in response.json())
|
||||
|
||||
|
||||
def test_admin_source_status_requires_auth_and_exposes_safe_cooldown_fields() -> None:
|
||||
assert client.get("/api/v1/admin/source-status").status_code == 401
|
||||
response = client.get("/api/v1/admin/source-status", headers={"Authorization": "Bearer change-me-in-production"})
|
||||
assert response.status_code == 200
|
||||
assert response.json()
|
||||
assert all({"status", "cooldown_seconds", "recent_failures_24h", "backoff_recommended"} <= set(item) for item in response.json())
|
||||
assert all({"source_system", "name", "last_started_at", "last_success_at", "next_allowed_at"} <= set(item) for item in response.json())
|
||||
assert all("error_summary" not in item and "base_url" not in item for item in response.json())
|
||||
|
||||
|
||||
def test_admin_media_review_requires_auth() -> None:
|
||||
assert client.get("/api/v1/admin/media/catalog").status_code == 401
|
||||
response = client.get("/api/v1/admin/media/catalog?status=approved&limit=2", headers={"Authorization": "Bearer change-me-in-production"})
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()) <= 2
|
||||
if response.json():
|
||||
assert {"status", "width", "height", "source_system", "source_url", "derivatives"} <= set(response.json()[0])
|
||||
assert all({"role", "format", "width", "height"} <= set(derivative) for derivative in response.json()[0]["derivatives"])
|
||||
|
||||
|
||||
def test_admin_media_decisions_require_auth_and_note(monkeypatch) -> None:
|
||||
assert client.post("/api/v1/admin/media/upgrades/publish", json={"note": "publish"}).status_code == 401
|
||||
assert client.post("/api/v1/admin/media/upgrades/rollback", json={"asset_url": "https://example.test/a", "note": "rollback"}).status_code == 401
|
||||
|
||||
monkeypatch.setattr(admin_router, "publish_quality_upgrades", lambda path, note: {"published": 2, "retained_fallbacks": 2})
|
||||
publish = client.post(
|
||||
"/api/v1/admin/media/upgrades/publish",
|
||||
json={"note": "visual review complete"},
|
||||
headers={"Authorization": "Bearer change-me-in-production"},
|
||||
)
|
||||
assert publish.status_code == 200
|
||||
assert publish.json() == {"published": 2, "retained_fallbacks": 2}
|
||||
|
||||
monkeypatch.setattr(admin_router, "rollback_quality_upgrade", lambda path, asset_url, note: {"rolled_back": asset_url, "restored": "https://example.test/fallback"})
|
||||
rollback = client.post(
|
||||
"/api/v1/admin/media/upgrades/rollback",
|
||||
json={"asset_url": "https://example.test/a", "note": "fallback is preferred"},
|
||||
headers={"Authorization": "Bearer change-me-in-production"},
|
||||
)
|
||||
assert rollback.status_code == 200
|
||||
assert rollback.json()["rolled_back"] == "https://example.test/a"
|
||||
assert client.post(
|
||||
"/api/v1/admin/media/upgrades/publish",
|
||||
json={"note": ""},
|
||||
headers={"Authorization": "Bearer change-me-in-production"},
|
||||
).status_code == 422
|
||||
|
||||
|
||||
def test_liveness_does_not_probe_dependencies() -> None:
|
||||
response = client.get("/health?token=must-not-be-logged")
|
||||
assert response.json() == {"status": "ok"}
|
||||
|
||||
@@ -3,7 +3,7 @@ from datetime import datetime, timezone
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.catalog_audit import audit_catalog
|
||||
from app.catalog_audit import audit_catalog, audit_waterbody_catalog
|
||||
from app.database import Base
|
||||
from app.models import CatchReport, Fish, ModerationStatus, SourceType, Spot, Waterbody
|
||||
|
||||
@@ -24,3 +24,24 @@ def test_catalog_audit_checks_the_whole_catalog() -> None:
|
||||
assert result["reports"] == 1
|
||||
assert result["invalid_coordinates"] == 1
|
||||
assert result["failures"] == 1
|
||||
|
||||
|
||||
def test_waterbody_catalog_audit_reports_snapshot_gaps_without_legacy_rows() -> None:
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as db:
|
||||
now = datetime.now(timezone.utc)
|
||||
db.add_all([
|
||||
Waterbody(
|
||||
slug="lake", name_ru="Озеро", source_system="rf4db",
|
||||
source_external_id="level_001_lake", source_url="https://rf4db.com/ru/maps/level_001_lake",
|
||||
source_checked_at=now,
|
||||
),
|
||||
Waterbody(slug="legacy", name_ru="Старое озеро"),
|
||||
])
|
||||
db.commit()
|
||||
result = audit_waterbody_catalog(db, {"level_001_lake", "level_002_river"})
|
||||
assert result["expected"] == 2
|
||||
assert result["observed"] == 1
|
||||
assert result["missing_source_external_ids"] == ["level_002_river"]
|
||||
assert result["failures"] == 1
|
||||
|
||||
@@ -7,8 +7,9 @@ import pytest
|
||||
from sqlalchemy import create_engine, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.community_importer import CommunityImportError, stage_observations
|
||||
from app.community_importer import CommunityImportError, stage_observations, update_waterbody_detail, update_waterbody_details, upsert_waterbody_catalog
|
||||
from app.community_review import ExternalReviewError, map_observation, publish_observation, suggest_aliases
|
||||
from app.source_lifecycle import record_scheduled_source_check, record_source_check
|
||||
from app.database import Base
|
||||
from app.models import CatchReport, DataSource, ExternalEntityAlias, ExternalObservation, Fish, Waterbody
|
||||
from rf4_research.community_sources import parse_rf4db_catches, parse_rf4map_point, parse_rf4posts_spot
|
||||
@@ -41,6 +42,20 @@ def record(source: str = "rf4db", external_id: str = "catch-1") -> dict[str, obj
|
||||
}
|
||||
|
||||
|
||||
def waterbody_row(**overrides: object) -> dict[str, object]:
|
||||
row: dict[str, object] = {
|
||||
"source_system": "rf4db",
|
||||
"source_external_id": "level_001_mosquito",
|
||||
"source_url": "https://rf4db.com/ru/maps/level_001_mosquito",
|
||||
"name": "оз. Комариное",
|
||||
"unlock_level": 1,
|
||||
"unlock_label": "1",
|
||||
"fish_species_count": 20,
|
||||
}
|
||||
row.update(overrides)
|
||||
return row
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db() -> Session:
|
||||
engine = create_engine("sqlite://")
|
||||
@@ -68,6 +83,62 @@ def test_staging_is_idempotent_and_preserves_first_seen(db: Session) -> None:
|
||||
assert source is not None and source.enabled is True
|
||||
|
||||
|
||||
def test_waterbody_catalog_upsert_is_idempotent_and_non_destructive(db: Session) -> None:
|
||||
first = datetime(2026, 9, 16, 10, tzinfo=timezone.utc)
|
||||
assert upsert_waterbody_catalog(db, [waterbody_row()], fetched_at=first) == (1, 0)
|
||||
item = db.scalar(select(Waterbody).where(Waterbody.source_external_id == "level_001_mosquito"))
|
||||
assert item is not None
|
||||
assert item.slug == "оз-комариное"
|
||||
assert item.source_checked_at.replace(tzinfo=timezone.utc) == first
|
||||
assert item.fish_species_count == 20
|
||||
|
||||
assert upsert_waterbody_catalog(db, [waterbody_row(name="Озеро Комариное", unlock_level=2)], fetched_at=first) == (0, 1)
|
||||
item = db.scalar(select(Waterbody).where(Waterbody.source_external_id == "level_001_mosquito"))
|
||||
assert item is not None
|
||||
assert (item.name_ru, item.unlock_level, item.fish_species_count) == ("Озеро Комариное", 2, 20)
|
||||
assert db.scalar(select(Waterbody).where(Waterbody.name_ru == "оз. Комариное")) is None
|
||||
|
||||
|
||||
def test_waterbody_catalog_rejects_untrusted_source(db: Session) -> None:
|
||||
with pytest.raises(CommunityImportError, match="source_url"):
|
||||
upsert_waterbody_catalog(db, [waterbody_row(source_url="https://example.test/map")])
|
||||
|
||||
|
||||
def test_waterbody_detail_updates_only_imported_identity_without_media_roles(db: Session) -> None:
|
||||
upsert_waterbody_catalog(db, [waterbody_row()])
|
||||
assert update_waterbody_detail(db, {
|
||||
"source_system": "rf4db",
|
||||
"source_external_id": "level_001_mosquito",
|
||||
"source_url": "https://rf4db.com/ru/maps/level_001_mosquito",
|
||||
"name": "оз. Комариное",
|
||||
"description": "Каменистые берега.",
|
||||
"aliases": ["Комариное", "Комариное"],
|
||||
"fish_species": ["Щука", "Окунь"],
|
||||
"image_urls": ["https://oss.rf4db.com/map.webp"],
|
||||
"point_urls": ["https://rf4db.com/ru/maps/level_001_mosquito/spots/12-34"],
|
||||
}) is True
|
||||
item = db.scalar(select(Waterbody).where(Waterbody.source_external_id == "level_001_mosquito"))
|
||||
assert item is not None
|
||||
assert item.source_aliases == ["Комариное"]
|
||||
assert item.source_fish_species == ["Щука", "Окунь"]
|
||||
assert item.source_image_urls == ["https://oss.rf4db.com/map.webp"]
|
||||
|
||||
|
||||
def test_waterbody_detail_batch_validates_before_writing(db: Session) -> None:
|
||||
upsert_waterbody_catalog(db, [waterbody_row()])
|
||||
valid = {
|
||||
"source_system": "rf4db", "source_external_id": "level_001_mosquito",
|
||||
"source_url": "https://rf4db.com/ru/maps/level_001_mosquito", "name": "оз. Комариное",
|
||||
"description": "Описание", "aliases": [], "fish_species": ["Щука"],
|
||||
"image_urls": [], "point_urls": [],
|
||||
}
|
||||
invalid = valid | {"source_external_id": "unknown", "source_url": "https://example.test/map"}
|
||||
with pytest.raises(CommunityImportError, match="source_url"):
|
||||
update_waterbody_details(db, [valid, invalid])
|
||||
item = db.scalar(select(Waterbody).where(Waterbody.source_external_id == "level_001_mosquito"))
|
||||
assert item is not None and item.description is None
|
||||
|
||||
|
||||
def test_external_ids_are_isolated_by_source(db: Session) -> None:
|
||||
created, updated = stage_observations(db, [record("rf4db"), record("rf4stat-fishing")])
|
||||
|
||||
@@ -103,14 +174,28 @@ def test_complete_observation_with_reviewed_aliases_is_published(db: Session) ->
|
||||
assert item.catch_report is not None
|
||||
assert item.catch_report.fish_id == fish.id
|
||||
assert item.catch_report.waterbody_id == waterbody.id
|
||||
assert db.scalar(select(func.count()).select_from(CatchReport)) == 1
|
||||
|
||||
assert stage_observations(db, [record() | {"weight_g": 5_000}]) == (0, 1)
|
||||
db.refresh(item)
|
||||
assert item.status == "published"
|
||||
assert db.scalar(select(func.count()).select_from(CatchReport)) == 1
|
||||
|
||||
|
||||
def test_observation_preserves_coordinate_text_and_precision(db: Session) -> None:
|
||||
stage_observations(db, [record() | {
|
||||
"source_external_id": "coordinate-area",
|
||||
"x": None, "y": None, "coordinate_raw": "северная бухта",
|
||||
"coordinate_precision": "area", "weight_g": None,
|
||||
}])
|
||||
item = db.scalar(select(ExternalObservation).where(ExternalObservation.source_external_id == "coordinate-area"))
|
||||
assert item is not None
|
||||
assert (item.coordinate_raw, item.coordinate_precision, item.x, item.y) == ("северная бухта", "area", None, None)
|
||||
|
||||
|
||||
def test_coordinate_precision_rejects_unknown_value(db: Session) -> None:
|
||||
with pytest.raises(CommunityImportError, match="invalid coordinate_precision"):
|
||||
stage_observations(db, [record() | {"coordinate_precision": "guess"}])
|
||||
|
||||
|
||||
def test_changed_published_record_requires_review_and_reuses_report(db: Session) -> None:
|
||||
fish = Fish(slug="pike", name_ru="Щука")
|
||||
water = Waterbody(slug="test-lake", name_ru="Тестовое озеро")
|
||||
@@ -128,6 +213,8 @@ def test_changed_published_record_requires_review_and_reuses_report(db: Session)
|
||||
assert report.moderation_status.value == "pending"
|
||||
assert report.weight_g == 5000
|
||||
assert item.weight_g == 6000
|
||||
assert item.moderation_version == 1
|
||||
assert item.reviewed_at is not None
|
||||
stage_observations(db, [record() | {"weight_g": 6000}])
|
||||
assert item.status == "staged"
|
||||
with pytest.raises(ExternalReviewError):
|
||||
@@ -140,6 +227,78 @@ def test_changed_published_record_requires_review_and_reuses_report(db: Session)
|
||||
assert db.scalar(select(func.count()).select_from(CatchReport)) == 1
|
||||
|
||||
|
||||
def test_missing_source_withdraws_published_record_until_manual_review(db: Session) -> None:
|
||||
fish = Fish(slug="pike", name_ru="Щука")
|
||||
water = Waterbody(slug="test-lake", name_ru="Тестовое озеро")
|
||||
db.add_all([fish, water])
|
||||
db.commit()
|
||||
seen = datetime(2026, 9, 13, 8, tzinfo=timezone.utc)
|
||||
checked = datetime(2026, 9, 13, 9, tzinfo=timezone.utc)
|
||||
stage_observations(db, [record() | {"weight_g": 5000}], fetched_at=seen)
|
||||
item = db.scalar(select(ExternalObservation))
|
||||
assert item is not None and item.catch_report is not None
|
||||
|
||||
record_source_check(db, item, "missing", checked_at=checked)
|
||||
|
||||
assert item.status == "withdrawn"
|
||||
assert item.source_check_status == "missing"
|
||||
assert item.source_checked_at.replace(tzinfo=timezone.utc) == checked
|
||||
assert item.catch_report.moderation_status.value == "pending"
|
||||
assert item.moderation_version == 1
|
||||
|
||||
stage_observations(db, [record() | {"weight_g": 5000}], fetched_at=checked)
|
||||
assert item.status == "staged"
|
||||
assert item.source_check_status == "available"
|
||||
assert item.catch_report.moderation_status.value == "pending"
|
||||
assert "reappeared" in (item.review_note or "")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", ["temporary_error", "blocked"])
|
||||
def test_non_authoritative_source_failures_do_not_withdraw(db: Session, status: str) -> None:
|
||||
fish = Fish(slug="pike", name_ru="Щука")
|
||||
water = Waterbody(slug="test-lake", name_ru="Тестовое озеро")
|
||||
db.add_all([fish, water])
|
||||
db.commit()
|
||||
stage_observations(db, [record() | {"weight_g": 5000}])
|
||||
item = db.scalar(select(ExternalObservation))
|
||||
assert item is not None and item.catch_report is not None
|
||||
|
||||
record_source_check(db, item, status) # type: ignore[arg-type]
|
||||
|
||||
assert item.status == "published"
|
||||
assert item.catch_report.moderation_status.value == "approved"
|
||||
assert item.source_check_status == status
|
||||
|
||||
|
||||
def test_scheduled_failure_only_affects_exact_source_url(db: Session) -> None:
|
||||
stage_observations(db, [
|
||||
record(external_id="matching"),
|
||||
record(external_id="other") | {"source_url": "https://rf4db.com/catches/other"},
|
||||
])
|
||||
|
||||
affected = record_scheduled_source_check(
|
||||
db,
|
||||
source_system="rf4db",
|
||||
source_url="https://rf4db.com/ru/catches/matching",
|
||||
status="missing",
|
||||
)
|
||||
items = {item.source_external_id: item for item in db.scalars(select(ExternalObservation))}
|
||||
|
||||
assert affected == 1
|
||||
assert items["matching"].status == "withdrawn"
|
||||
assert items["other"].status != "withdrawn"
|
||||
assert items["other"].source_check_status == "available"
|
||||
|
||||
record_scheduled_source_check(
|
||||
db,
|
||||
source_system="rf4db",
|
||||
source_url="https://rf4db.com/ru/catches/matching",
|
||||
status="available",
|
||||
)
|
||||
assert items["matching"].source_check_status == "available"
|
||||
assert items["matching"].status == "withdrawn"
|
||||
|
||||
|
||||
def test_auto_publication_requires_enabled_source(db: Session) -> None:
|
||||
source = DataSource(key="rf4db", name="RF4DB", base_url="https://rf4db.com", default_confidence=70, enabled=False)
|
||||
fish = Fish(slug="pike", name_ru="Щука")
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from urllib.error import HTTPError
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.community_scheduler import MAX_BACKOFF_SECONDS, configured_sources, _static_registry, oldest_site_source, retry_delay
|
||||
from app.config import Settings
|
||||
from app.source_lifecycle import classify_source_failure
|
||||
|
||||
|
||||
def test_all_authorized_sources_are_scheduled() -> None:
|
||||
@@ -23,6 +25,16 @@ def test_failed_runs_back_off_but_success_resets_delay() -> None:
|
||||
assert retry_delay(["success", "failed"]) == 1800
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("code", "expected"), [(404, "missing"), (410, "missing"), (403, "blocked"), (429, "blocked"), (500, "temporary_error")])
|
||||
def test_source_http_failure_classification(code: int, expected: str) -> None:
|
||||
error = HTTPError("https://rf4.example/source", code, "failure", {}, None)
|
||||
assert classify_source_failure(error) == expected
|
||||
|
||||
|
||||
def test_non_http_source_failure_is_temporary() -> None:
|
||||
assert classify_source_failure(TimeoutError("timeout")) == "temporary_error"
|
||||
|
||||
|
||||
def test_same_site_endpoints_rotate_by_oldest_attempt() -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
all_keys = {"rf4db", "rf4stat-fishing", "rf4stat-post", "rf4map", "rf4posts-spot"}
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f2f5ee",
|
||||
"theme_color": "#082226",
|
||||
"background_color": "#071719",
|
||||
"theme_color": "#071719",
|
||||
"icons": [
|
||||
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
|
||||
@@ -8,6 +8,7 @@ import TackleGlyph from "./TackleGlyph.astro";
|
||||
const { item } = Astro.props as { item: Activity };
|
||||
const level = activityLevel(item.activity_score);
|
||||
const limited = item.catches < 3;
|
||||
const precision = { exact: "точные", approximate: "приблизительные", area: "район", missing: "не указаны" }[item.coordinate_precision];
|
||||
---
|
||||
<a class="spot-card" data-testid={`spot-${item.x}-${item.y}`} href={spotPath(item)}>
|
||||
<span class="spot-rank">{String(item.activity_score).padStart(2,"0")}</span>
|
||||
@@ -15,7 +16,7 @@ const limited = item.catches < 3;
|
||||
<div class="spot-topline"><span>{item.waterbody}</span><span class="activity-pill" data-activity-level={level.short}><i></i>{level.short}</span>{limited && <span class="data-quality">Данных мало</span>}</div>
|
||||
<h3>{item.fish}</h3>
|
||||
<FishSilhouette name={item.fish}/>
|
||||
<div class="spot-meta"><span><FishingIcon name="pin" size={14}/> {item.x}:{item.y}</span><span><FishingIcon name="clock" size={14}/> {ago(item.last_confirmed_at)}</span></div>
|
||||
<div class="spot-meta"><span><FishingIcon name="pin" size={14}/> {item.x}:{item.y} · {precision}</span><span><FishingIcon name="clock" size={14}/> {ago(item.last_confirmed_at)}</span></div>
|
||||
<p class="data-note">{item.explanation}</p>
|
||||
<DataPassport sources={item.sources} observedAt={item.last_confirmed_at} confidence={item.confidence_score}/>
|
||||
<div class="bait-line"><TackleGlyph name={item.best_bait}/><div><span>Работает сейчас</span><strong>{item.best_bait ?? "не указана"}</strong></div></div>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
const path = Astro.url.pathname;
|
||||
const links = [
|
||||
["/admin", "Обзор"],
|
||||
["/admin/moderation", "Уловы"],
|
||||
["/admin/external-sources", "Источники"],
|
||||
["/admin/media", "Медиа"],
|
||||
] as const;
|
||||
---
|
||||
<nav class="admin-nav" aria-label="Разделы админ-панели">
|
||||
{links.map(([href, label]) => <a class={path === href ? "active" : undefined} aria-current={path === href ? "page" : undefined} href={href}>{label}</a>)}
|
||||
</nav>
|
||||
@@ -19,10 +19,10 @@ const { items } = Astro.props as { items: Item[] };
|
||||
ol::-webkit-scrollbar { display: none; }
|
||||
li { display: flex; align-items: center; flex: 0 0 auto; color: var(--text-muted); font-size: 12px; }
|
||||
a, li > span[aria-current] { padding: 8px 9px; color: inherit; text-decoration: none; white-space: nowrap; }
|
||||
a:hover { color: var(--deep); text-decoration: underline; text-underline-offset: 4px; }
|
||||
li > span[aria-current] { color: var(--deep); font-weight: 750; }
|
||||
.atlas-breadcrumbs__knot { width: 7px; height: 7px; flex: 0 0 auto; border: 1px solid #78918a; border-radius: 50%; background: var(--paper); }
|
||||
li:last-child .atlas-breadcrumbs__knot { border-color: #6f922d; background: var(--lime); box-shadow: 0 0 0 4px #c9f45b26; }
|
||||
.atlas-breadcrumbs__line { width: clamp(18px, 3vw, 42px); height: 1px; background: repeating-linear-gradient(90deg,#9aaca5 0 5px,transparent 5px 8px); }
|
||||
a:hover { color: var(--text-primary); text-decoration: underline; text-underline-offset: 4px; }
|
||||
li > span[aria-current] { color: var(--text-primary); font-weight: 750; }
|
||||
.atlas-breadcrumbs__knot { width: 7px; height: 7px; flex: 0 0 auto; border: 1px solid var(--border-strong); border-radius: 50%; background: var(--paper); }
|
||||
li:last-child .atlas-breadcrumbs__knot { border-color: var(--accent-muted); background: var(--lime); box-shadow: 0 0 0 4px color-mix(in srgb,var(--lime) 18%,transparent); }
|
||||
.atlas-breadcrumbs__line { width: clamp(18px, 3vw, 42px); height: 1px; background: repeating-linear-gradient(90deg,var(--border-strong) 0 5px,transparent 5px 8px); }
|
||||
@media (max-width: 720px) { .atlas-breadcrumbs { width: calc(100% - 28px); padding-top: 22px; } a, li > span[aria-current] { padding-inline: 7px; } }
|
||||
</style>
|
||||
|
||||
@@ -9,8 +9,8 @@ const { href, label, kind, identity } = Astro.props as { href: string; label: st
|
||||
</a>
|
||||
<style>
|
||||
.atlas-entity-link { display:grid;grid-template-columns:52px minmax(0,1fr) auto;align-items:center;gap:9px;min-height:54px;padding:5px 2px;border-bottom:1px solid var(--border-soft);color:var(--text-muted);font-size:13px;text-decoration:none }
|
||||
.atlas-entity-link__mark { width:48px;height:38px;display:grid;place-items:center;color:#315f63 }
|
||||
.atlas-entity-link__mark { width:48px;height:38px;display:grid;place-items:center;color:var(--decorative-water) }
|
||||
.atlas-entity-link__mark :global(.fish-silhouette),.atlas-entity-link__mark :global(.waterbody-mark) { position:static;opacity:.68 }
|
||||
.atlas-entity-link__mark :global(.waterbody-mark) { stroke:currentColor }.atlas-entity-link__mark :global(.waterbody-mark text) { fill:currentColor }
|
||||
b { color:#6d8d2a;font-size:15px;transition:transform var(--motion-fast) var(--ease-out) }.atlas-entity-link:hover { color:var(--deep) }.atlas-entity-link:hover b { transform:translateX(2px) }
|
||||
b { color:var(--accent-muted);font-size:15px;transition:transform var(--motion-fast) var(--ease-out) }.atlas-entity-link:hover { color:var(--text-primary) }.atlas-entity-link:hover b { transform:translateX(2px) }
|
||||
</style>
|
||||
|
||||
@@ -14,5 +14,5 @@ const { catches } = Astro.props as { catches: Catch[] };
|
||||
})}
|
||||
</div>
|
||||
<style>
|
||||
time{margin-top:7px;color:#496357;font-size:10px;font-weight:750}
|
||||
time{margin-top:7px;color:var(--text-secondary);font-size:10px;font-weight:750}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
import SourceBadge from "./SourceBadge.astro";
|
||||
import type { MediaAsset } from "../lib/api";
|
||||
const { asset, compact = false, sourceLink = false } = Astro.props as { asset: MediaAsset; compact?: boolean; sourceLink?: boolean };
|
||||
---
|
||||
<figure class:list={["entity-media", { "entity-media--compact": compact }]}>
|
||||
<span class="entity-media__frame"><picture>
|
||||
{(["avif", "webp"] as const).map(format => {
|
||||
const variants = (asset.variants ?? []).filter(item => item.format === format);
|
||||
return variants.length ? <source type={`image/${format}`} srcset={variants.map(item => `${item.url} ${item.width}w`).join(", ")} sizes={compact ? "180px" : "(max-width: 720px) 100vw, 720px"} /> : null;
|
||||
})}
|
||||
<img src={asset.image_url} alt={asset.label ?? "Иллюстрация RF4"} width={asset.width} height={asset.height} loading="lazy" decoding="async" />
|
||||
</picture></span>
|
||||
<figcaption><SourceBadge source={asset.source_system} href={sourceLink ? asset.source_url : undefined} /><span>{asset.label ?? "Справочный материал"}</span></figcaption>
|
||||
</figure>
|
||||
@@ -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>}
|
||||
@@ -31,6 +31,6 @@ const groups = [...signals.reduce((map, signal) => {
|
||||
})}</div>
|
||||
</section>
|
||||
<style>
|
||||
.repeat-note{position:relative;z-index:1;margin:-8px 0 10px!important;padding:6px 9px;border-left:2px solid #6b8d74;color:#496357!important;font-size:10px!important}
|
||||
.repeat-note{position:relative;z-index:1;margin:-8px 0 10px!important;padding:6px 9px;border-left:2px solid var(--border-strong);color:var(--text-secondary)!important;font-size:10px!important}
|
||||
.signal-card__top .source-strip{margin:0}
|
||||
</style>
|
||||
|
||||
@@ -11,9 +11,11 @@ import "../styles/tackle-glyph.css";
|
||||
import "../styles/empty-states.css";
|
||||
import "../styles/alpha-banner.css";
|
||||
import "../styles/signal-pagination.css";
|
||||
import "../styles/pagination.css";
|
||||
import "../styles/dashboard-polish.css";
|
||||
import "../styles/loading-states.css";
|
||||
import "../styles/theme.css";
|
||||
import "../styles/media-catalog.css";
|
||||
import FishingIcon from "../components/FishingIcon.astro";
|
||||
import AlphaBanner from "../components/AlphaBanner.astro";
|
||||
const {
|
||||
@@ -30,7 +32,12 @@ const theme = storedTheme === "light" || storedTheme === "dark" ? storedTheme :
|
||||
const siteUrl = import.meta.env.PUBLIC_SITE_URL || "https://rf4spotter.ru";
|
||||
const canonical = new URL(path, siteUrl).toString();
|
||||
const socialImage = new URL(image, siteUrl).toString();
|
||||
const preventIndexing = noindex || path.startsWith("/admin/");
|
||||
const isAdminPath = path === "/admin" || path.startsWith("/admin/");
|
||||
const preventIndexing = noindex || isAdminPath;
|
||||
if (isAdminPath) {
|
||||
Astro.response.headers.set("Cache-Control", "private, no-store");
|
||||
Astro.response.headers.set("X-Robots-Tag", "noindex, nofollow");
|
||||
}
|
||||
const websiteJsonLd = { "@type": "WebSite", name: "RF4 Spotter", url: siteUrl, inLanguage: "ru" };
|
||||
// A08: Skip structuredData on error pages (explicit errorPage prop)
|
||||
// Don't infer error from noindex alone — main page can have noindex on 422
|
||||
@@ -39,6 +46,43 @@ const jsonLd = JSON.stringify({
|
||||
"@context": "https://schema.org",
|
||||
"@graph": jsonLdGraph,
|
||||
}).replaceAll("<", "\\u003c");
|
||||
const configuredFilesDomain = process.env.FILES_DOMAIN || "files.rf4spotter.ru";
|
||||
const filesDomain = /^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/i.test(configuredFilesDomain)
|
||||
? configuredFilesDomain
|
||||
: "files.rf4spotter.ru";
|
||||
const isLoopback = (hostname: string) => hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
|
||||
const safeOrigin = (value: string | undefined, fallback: string) => {
|
||||
try {
|
||||
const url = new URL(value || fallback);
|
||||
return url.protocol === "https:" || (url.protocol === "http:" && isLoopback(url.hostname)) ? url.origin : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
const apiOrigin = safeOrigin(import.meta.env.PUBLIC_API_URL, siteUrl);
|
||||
const filesFallback = `https://${filesDomain}`;
|
||||
const filesOrigin = (() => {
|
||||
try {
|
||||
const url = new URL(process.env.FILES_ORIGIN || filesFallback);
|
||||
if ((url.protocol === "https:" && url.hostname === filesDomain) || (url.protocol === "http:" && isLoopback(url.hostname))) {
|
||||
return url.origin;
|
||||
}
|
||||
} catch {
|
||||
// Invalid deployment input falls back to the validated production hostname.
|
||||
}
|
||||
return filesFallback;
|
||||
})();
|
||||
const localOrigins = [apiOrigin, filesOrigin].filter((origin) => origin.startsWith("http://"));
|
||||
const cspNonce = crypto.randomUUID().replaceAll("-", "");
|
||||
Astro.response.headers.set("Content-Security-Policy", [
|
||||
"default-src 'self'", "base-uri 'self'", "object-src 'none'", "frame-ancestors 'none'",
|
||||
"form-action 'self'", `connect-src 'self'${apiOrigin === siteUrl ? "" : ` ${apiOrigin}`}`,
|
||||
`img-src 'self' data: ${filesOrigin}`,
|
||||
"font-src 'self'", "media-src 'self'", "manifest-src 'self'",
|
||||
`script-src 'self' 'nonce-${cspNonce}'`, "script-src-attr 'none'",
|
||||
"style-src 'self'", "style-src-attr 'none'",
|
||||
...(localOrigins.length ? [] : ["upgrade-insecure-requests"]),
|
||||
].join("; "));
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="ru" data-theme={theme === "system" ? undefined : theme}>
|
||||
@@ -52,7 +96,8 @@ const jsonLd = JSON.stringify({
|
||||
<link rel="icon" href="/favicon-32.png" sizes="32x32" type="image/png" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<meta name="theme-color" content="#082226" />
|
||||
<meta name="theme-color" content="#f2f5ee" media={theme === "system" ? "(prefers-color-scheme: light)" : theme === "light" ? "all" : "not all"} data-theme-color="light" />
|
||||
<meta name="theme-color" content="#071719" media={theme === "system" ? "(prefers-color-scheme: dark)" : theme === "dark" ? "all" : "not all"} data-theme-color="dark" />
|
||||
<meta property="og:locale" content="ru_RU" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="RF4 Spotter" />
|
||||
@@ -69,14 +114,14 @@ const jsonLd = JSON.stringify({
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={socialImage} />
|
||||
<meta name="twitter:image:alt" content="Лаймовый поплавок на тёмном озере с координатной сеткой" />
|
||||
<script type="application/ld+json" set:html={jsonLd} is:inline />
|
||||
<script type="application/ld+json" nonce={cspNonce} set:html={jsonLd} is:inline />
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main-content">Перейти к содержимому</a>
|
||||
<header class="topbar">
|
||||
<a href="/" class="brand"><span class="brand-mark" aria-hidden="true"><FishingIcon name="hook" size={24}/></span><span class="brand-name"><strong>RF4 Spotter</strong><span>Ни хвоста, ни чешуи</span></span></a>
|
||||
<nav aria-label="Разделы сайта"><a class:list={{active:path === "/"}} aria-current={path === "/" ? "page" : undefined} href="/"><FishingIcon name="float"/> <span>Сейчас клюёт</span></a><a class:list={{active:path.startsWith("/waterbodies") || path.startsWith("/fish")}} aria-current={path.startsWith("/waterbodies") || path.startsWith("/fish") ? "page" : undefined} href="/waterbodies"><FishingIcon name="ripple"/> <span>Каталог</span></a><a class:list={{active:path.startsWith("/records")}} aria-current={path.startsWith("/records") ? "page" : undefined} href="/records"><FishingIcon name="trophy"/> <span>Рекорды</span></a><a class:list={{active:path.startsWith("/report")}} aria-current={path.startsWith("/report") ? "page" : undefined} href="/report"><FishingIcon name="plus"/> <span>Добавить улов</span></a></nav>
|
||||
<nav aria-label="Разделы сайта"><a class:list={{active:path === "/"}} aria-current={path === "/" ? "page" : undefined} href="/"><FishingIcon name="float"/> <span>Сейчас клюёт</span></a><a class:list={{active:path.startsWith("/waterbodies") || path.startsWith("/fish")}} aria-current={path.startsWith("/waterbodies") || path.startsWith("/fish") ? "page" : undefined} href="/waterbodies"><FishingIcon name="ripple"/> <span>Каталог</span></a><a class:list={{active:path.startsWith("/media")}} aria-current={path.startsWith("/media") ? "page" : undefined} href="/media"><FishingIcon name="lure"/> <span>Медиатека</span></a><a class:list={{active:path.startsWith("/records")}} aria-current={path.startsWith("/records") ? "page" : undefined} href="/records"><FishingIcon name="trophy"/> <span>Рекорды</span></a><a class:list={{active:path.startsWith("/report")}} aria-current={path.startsWith("/report") ? "page" : undefined} href="/report"><FishingIcon name="plus"/> <span>Добавить улов</span></a></nav>
|
||||
<div class="header-tools">
|
||||
<p class="live-badge"><span></span> Свежие данные и честная оценка</p>
|
||||
<div class="theme-switcher" role="group" aria-label="Цветовая тема">
|
||||
@@ -94,6 +139,7 @@ const jsonLd = JSON.stringify({
|
||||
<script>
|
||||
const root = document.documentElement;
|
||||
const themeButtons = document.querySelectorAll<HTMLButtonElement>("[data-theme-option]");
|
||||
const themeColors = document.querySelectorAll<HTMLMetaElement>("[data-theme-color]");
|
||||
const allowedThemes = new Set(["system", "light", "dark"]);
|
||||
|
||||
const applyTheme = (value: string) => {
|
||||
@@ -103,6 +149,10 @@ const jsonLd = JSON.stringify({
|
||||
themeButtons.forEach((button) => {
|
||||
button.setAttribute("aria-pressed", String(button.dataset.themeOption === theme));
|
||||
});
|
||||
themeColors.forEach((meta) => {
|
||||
const color = meta.dataset.themeColor;
|
||||
meta.media = theme === "system" ? `(prefers-color-scheme: ${color})` : color === theme ? "all" : "not all";
|
||||
});
|
||||
const secure = location.protocol === "https:" ? "; Secure" : "";
|
||||
document.cookie = `rf4-theme=${theme}; Max-Age=31536000; Path=/; SameSite=Lax${secure}`;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export function adminErrorMessage(status: number, fallback: string): string {
|
||||
if (status === 401) return "Неверный или истёкший административный токен.";
|
||||
if (status === 409) return "Операция конфликтует с изменением в другой вкладке.";
|
||||
if (status === 429) return "Слишком много попыток. Повторите позже.";
|
||||
if (status >= 500) return "Сервис временно недоступен. Проверьте состояние и повторите позже.";
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function adminEndsSession(status: number): boolean {
|
||||
return status === 401 || status === 429;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export type Activity = {
|
||||
unique_players: number; average_weight_g: number; max_weight_g: number;
|
||||
last_confirmed_at: string; activity_score: number; confidence_score: number;
|
||||
explanation: string; sources: string[];
|
||||
coordinate_precision: "exact" | "approximate" | "area" | "missing"; coordinate_sources: string[];
|
||||
};
|
||||
|
||||
export type PaginatedActivity = {
|
||||
@@ -13,9 +14,9 @@ export type PaginatedActivity = {
|
||||
offset: number;
|
||||
};
|
||||
|
||||
export type Spot = { id: string; waterbody_slug: string; waterbody: string; x: number; y: number; description: string | null; catches_24h: number; catches_3d: number; catches_7d: number; top_baits: string[] };
|
||||
export type Spot = { id: string; waterbody_slug: string; waterbody: string; x: number; y: number; description: string | null; catches_24h: number; catches_3d: number; catches_7d: number; top_baits: string[]; coordinate_precision: "exact" | "approximate" | "area" | "missing"; coordinate_sources: string[] };
|
||||
export type Catch = { id: string; fish: string; weight_g: number; bait: string | null; player_name: string | null; caught_at: string | null; reported_at: string; retrieve_method: string | null; retrieve_speed: number | null; source_system: string; source_url: string | null };
|
||||
export type DictionaryItem = { id: string; slug: string; name_ru: string };
|
||||
export type DictionaryItem = { id: string; slug: string; name_ru: string; unlock_level?: number | null; fish_species_count?: number | null; source_system?: string | null; source_external_id?: string | null; source_url?: string | null; description?: string | null; source_aliases?: string[] | null; source_fish_species?: string[] | null; source_image_urls?: string[] | null; source_point_urls?: string[] | null; source_checked_at?: string | null };
|
||||
export type OfficialRecord = { id: string; fish: string; weight_g: number; waterbody: string; bait: string | null; player_name: string | null; record_date: string | null; category: string | null; region: string | null; source_url: string | null; source_system: string };
|
||||
export type PaginatedOfficialRecord = {
|
||||
items: OfficialRecord[];
|
||||
@@ -26,6 +27,8 @@ export type PaginatedOfficialRecord = {
|
||||
export type PublicObservation = { id: string; source_system: string; source_name: string; source_url: string; fish_name: string; waterbody_name: string; x: number | null; y: number | null; weight_g: number | null; last_seen_at: string; missing_fields: string[]; quality: "incomplete" | "unverified" };
|
||||
export type ImportRun = { id: string; started_at: string; finished_at: string | null; status: string; source_url: string; rows_seen: number; rows_created: number; rows_updated: number; error_summary: string | null };
|
||||
export type SourceStatus = { source_system: string; name: string; status: "healthy" | "stale" | "temporarily_limited" | "source_changed" | "waiting" | "disabled"; last_started_at: string | null; last_success_at: string | null; observations: number };
|
||||
export type MediaVariant = { role: "card" | "detail"; format: "webp" | "avif"; width: number; height: number; url: string };
|
||||
export type MediaAsset = { id: string; entity_type: "fish" | "waterbody" | "tackle" | "reference"; entity_key: string; label: string | null; width: number; height: number; content_type: string; image_url: string; source_system: string; source_url: string; variants?: MediaVariant[] };
|
||||
|
||||
export const spotPath = (item: Pick<Activity, "waterbody_slug" | "x" | "y">) => `/spots/${item.waterbody_slug}-${item.x}x${item.y}`;
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { MediaAsset } from "./api";
|
||||
|
||||
const tokens = (value: string) => value
|
||||
.toLocaleLowerCase("ru")
|
||||
.replaceAll("ё", "е")
|
||||
.replace(/^(оз\.|р\.)\s*/, "")
|
||||
.replace(/[^a-zа-я0-9]+/giu, " ")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
|
||||
export const findMediaByLabel = (assets: MediaAsset[], label: string): MediaAsset | undefined => {
|
||||
const wanted = tokens(label);
|
||||
const exact = assets.filter((asset) => tokens(asset.label ?? "").join(" ") === wanted.join(" "));
|
||||
if (exact.length === 1) return exact[0];
|
||||
|
||||
const wantedSet = new Set(wanted);
|
||||
const candidates = assets.filter((asset) => {
|
||||
const available = new Set(tokens(asset.label ?? ""));
|
||||
return wanted.every((token) => available.has(token)) || [...available].every((token) => wantedSet.has(token));
|
||||
});
|
||||
return candidates.length === 1 ? candidates[0] : undefined;
|
||||
};
|
||||
@@ -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}`;
|
||||
};
|
||||
@@ -1,10 +1,12 @@
|
||||
---
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import AdminNav from "../../components/AdminNav.astro";
|
||||
const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
||||
---
|
||||
<Layout title="Внешние источники — RF4 Spotter">
|
||||
<section class="form-hero"><div><span class="eyebrow"><b>ADMIN</b> Staging</span><h1>Внешние<br/><em>наблюдения</em></h1></div><p>Сопоставьте названия с каноническим каталогом. Опубликовать можно только запись с координатами и весом.</p></section>
|
||||
<section class="moderation-app" data-api-url={apiUrl}>
|
||||
<AdminNav />
|
||||
<form class="admin-login" autocomplete="off"><label>Административный токен<input name="token" type="password" required autocomplete="off" /></label><button data-action="primary" type="submit">Открыть очередь</button></form>
|
||||
<p class="privacy">Токен остаётся только в памяти страницы. Исходная ссылка и происхождение сохраняются при публикации.</p>
|
||||
<div class="admin-session-bar" hidden><span>Административная сессия активна</span><button data-action="secondary" type="button" data-admin-logout>Выйти</button></div>
|
||||
@@ -19,11 +21,12 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
||||
<nav data-pages aria-label="Страницы очереди" hidden>
|
||||
<button data-action="secondary" type="button" data-previous>Предыдущая</button>
|
||||
<span data-page-number aria-live="polite"></span>
|
||||
<button data-action="secondary" type="button" data-next>Следующая</button>
|
||||
<button data-action="secondary" type="button" data-next>Следующая</button><button data-action="secondary" type="button" data-refresh>Обновить</button>
|
||||
</nav>
|
||||
<p class="admin-shortcuts"><kbd>S</kbd> подсказать · <kbd>M</kbd> сопоставить · <kbd>P</kbd> опубликовать карточку с фокусом</p>
|
||||
</section>
|
||||
<script>
|
||||
import { adminEndsSession, adminErrorMessage } from "../../lib/admin-errors";
|
||||
const root = document.querySelector<HTMLElement>(".moderation-app");
|
||||
const login = document.querySelector<HTMLFormElement>(".admin-login");
|
||||
const list = document.querySelector<HTMLElement>("[data-external-list]");
|
||||
@@ -32,6 +35,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
||||
const pages = document.querySelector<HTMLElement>("[data-pages]");
|
||||
const previous = document.querySelector<HTMLButtonElement>("[data-previous]");
|
||||
const next = document.querySelector<HTMLButtonElement>("[data-next]");
|
||||
const refresh = document.querySelector<HTMLButtonElement>("[data-refresh]");
|
||||
const pageNumber = document.querySelector<HTMLElement>("[data-page-number]");
|
||||
const sessionBar = document.querySelector<HTMLElement>(".admin-session-bar");
|
||||
const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]");
|
||||
@@ -48,7 +52,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
||||
const endSession = (message?: string) => { token = ""; if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = undefined; if (login) { login.hidden = false; login.reset(); } if (sessionBar) sessionBar.hidden = true; if (filters) filters.hidden = true; if (list) list.innerHTML = ""; pages?.setAttribute("hidden", ""); if (message) fail(message); };
|
||||
const keepSession = () => { if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); };
|
||||
const options = (items: Record<string, string>[], selected?: unknown) => items.map(item => `<option value="${esc(item.slug)}" ${item.slug === selected ? "selected" : ""}>${esc(item.name_ru)}</option>`).join("");
|
||||
async function json(url: string, init: RequestInit = {}) { const response = await fetch(url, init); if (response.status === 401) { endSession(); throw new Error("Неверный или истёкший административный токен."); } if (response.status === 409) { await loadQueue(); throw new Error("Запись уже изменена в другой вкладке. Очередь обновлена."); } if (!response.ok) throw new Error(`Запрос завершился ошибкой ${response.status}.`); if ((init.headers as Record<string, string> | undefined)?.Authorization) keepSession(); return response.json(); }
|
||||
async function json(url: string, init: RequestInit = {}) { const response = await fetch(url, init); if (response.status === 409) { await loadQueue(); throw new Error("Запись уже изменена в другой вкладке. Очередь обновлена."); } if (!response.ok) { if (adminEndsSession(response.status)) endSession(); throw new Error(adminErrorMessage(response.status, `Запрос завершился ошибкой ${response.status}.`)); } if ((init.headers as Record<string, string> | undefined)?.Authorization) keepSession(); return response.json(); }
|
||||
async function loadQueue() {
|
||||
if (!root || !list) return; error?.setAttribute("hidden", ""); setLoading(true); list.innerHTML = loadingCards();
|
||||
if (!fishes.length || !waters.length) { const [fishRows, waterRows, sources] = await Promise.all([json(`${root.dataset.apiUrl}/api/v1/fishes`), json(`${root.dataset.apiUrl}/api/v1/waterbodies`), json(`${root.dataset.apiUrl}/api/v1/source-status`)]); fishes = fishRows; waters = waterRows; const sourceSelect = filters?.querySelector<HTMLSelectElement>('[name="source"]'); if (sourceSelect) sourceSelect.innerHTML = '<option value="">Все источники</option>' + (sources as Record<string, unknown>[]).map(source => `<option value="${esc(source.source_system)}">${esc(source.name)}</option>`).join(""); }
|
||||
@@ -74,7 +78,8 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
||||
const missing = ((row.missing_fields as string[]) ?? []).map(key => missingLabels[key] ?? key);
|
||||
const details = document.createElement("details");
|
||||
details.className = "moderation-provenance";
|
||||
details.innerHTML = `<summary>Происхождение и исходные поля</summary><dl><div><dt>Впервые замечено</dt><dd>${esc(new Date(String(row.first_seen_at)).toLocaleString("ru-RU"))}</dd></div><div><dt>Последний раз</dt><dd>${esc(new Date(String(row.last_seen_at)).toLocaleString("ru-RU"))}</dd></div><div><dt>Проверено</dt><dd>${row.reviewed_at ? esc(new Date(String(row.reviewed_at)).toLocaleString("ru-RU")) : "ещё нет"}</dd></div><div><dt>Не хватает</dt><dd>${missing.length ? esc(missing.join(", ")) : "ничего"}</dd></div>${payload.map(([key,value]) => `<div><dt>${esc(payloadLabels[key] ?? key)}</dt><dd>${esc(value)}</dd></div>`).join("")}</dl><p>Показаны только разрешённые поля, сохранённые парсером. Перед публикацией сверьте их с первоисточником.</p>`;
|
||||
const sourceCheckLabels: Record<string, string> = {available:"доступен",missing:"не найден",temporary_error:"временная ошибка",blocked:"доступ ограничен"};
|
||||
details.innerHTML = `<summary>Происхождение и исходные поля</summary><dl><div><dt>Впервые замечено</dt><dd>${esc(new Date(String(row.first_seen_at)).toLocaleString("ru-RU"))}</dd></div><div><dt>Последний раз</dt><dd>${esc(new Date(String(row.last_seen_at)).toLocaleString("ru-RU"))}</dd></div><div><dt>Решение модератора</dt><dd>${row.reviewed_at ? esc(new Date(String(row.reviewed_at)).toLocaleString("ru-RU")) : "ещё нет"}</dd></div><div><dt>Проверка источника</dt><dd>${row.source_check_status ? esc(sourceCheckLabels[String(row.source_check_status)] ?? row.source_check_status) : "ещё не выполнялась"}${row.source_checked_at ? ` · ${esc(new Date(String(row.source_checked_at)).toLocaleString("ru-RU"))}` : ""}</dd></div><div><dt>Не хватает</dt><dd>${missing.length ? esc(missing.join(", ")) : "ничего"}</dd></div>${payload.map(([key,value]) => `<div><dt>${esc(payloadLabels[key] ?? key)}</dt><dd>${esc(value)}</dd></div>`).join("")}</dl><p>Показаны только разрешённые поля, сохранённые парсером. Перед публикацией сверьте их с первоисточником.</p>`;
|
||||
summary.append(details);
|
||||
});
|
||||
}
|
||||
@@ -88,6 +93,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
||||
}
|
||||
previous?.addEventListener("click", () => changePage(-50));
|
||||
next?.addEventListener("click", () => changePage(50));
|
||||
refresh?.addEventListener("click", async () => { refresh.disabled = true; try { await loadQueue(); succeed("Очередь обновлена."); } catch (cause) { fail(cause instanceof Error ? cause.message : "Не удалось обновить очередь."); } finally { refresh.disabled = false; } });
|
||||
login?.addEventListener("submit", async event => { event.preventDefault(); offset = 0; token = String(new FormData(login).get("token") || ""); try { await loadQueue(); login.hidden = true; if (sessionBar) sessionBar.hidden = false; if (filters) filters.hidden = false; } catch (cause) { setLoading(false); if (list) list.innerHTML = ""; fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
|
||||
filters?.addEventListener("submit", async event => { event.preventDefault(); offset = 0; try { await loadQueue(); } catch (cause) { setLoading(false); fail(cause instanceof Error ? cause.message : "Ошибка фильтрации."); } });
|
||||
logout?.addEventListener("click", () => endSession("Вы вышли из административной панели."));
|
||||
|
||||
@@ -1,52 +1,58 @@
|
||||
---
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import AdminNav from "../../components/AdminNav.astro";
|
||||
const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
||||
---
|
||||
<Layout title="Административная панель — RF4 Spotter">
|
||||
<section class="form-hero"><div><span class="eyebrow"><b>ADMIN</b> Центр управления</span><h1>Панель<br/><em>модератора</em></h1></div><p>Очереди, состояние источников и последние импорты в одном безопасном обзоре.</p></section>
|
||||
<main class="admin-dashboard" data-api-url={apiUrl}>
|
||||
<section class="admin-dashboard" data-api-url={apiUrl}>
|
||||
<AdminNav />
|
||||
<form class="admin-login" autocomplete="off"><label>Административный токен<input name="token" type="password" required autocomplete="off" /></label><button data-action="primary" type="submit">Открыть панель</button></form>
|
||||
<p class="privacy">Токен существует только в памяти вкладки. Сессия завершится после 15 минут бездействия.</p>
|
||||
<div class="admin-session-bar" hidden><span>Административная сессия активна</span><button data-action="secondary" type="button" data-admin-logout>Выйти</button></div>
|
||||
<div class="notice error" data-admin-error role="alert" hidden></div>
|
||||
<div class="notice success" data-admin-status role="status" hidden></div>
|
||||
<section class="admin-dashboard-content" aria-live="polite"></section>
|
||||
</main>
|
||||
</section>
|
||||
<script>
|
||||
import { adminEndsSession, adminErrorMessage } from "../../lib/admin-errors";
|
||||
const root = document.querySelector<HTMLElement>(".admin-dashboard");
|
||||
const login = document.querySelector<HTMLFormElement>(".admin-login");
|
||||
const content = document.querySelector<HTMLElement>(".admin-dashboard-content");
|
||||
const error = document.querySelector<HTMLElement>("[data-admin-error]");
|
||||
const status = document.querySelector<HTMLElement>("[data-admin-status]");
|
||||
const sessionBar = document.querySelector<HTMLElement>(".admin-session-bar");
|
||||
const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]");
|
||||
let token = "";
|
||||
let sessionTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&","<":"<",">":">","'":"'",'"':"""}[char] ?? char));
|
||||
const fail = (message: string) => { if (error) { error.textContent = message; error.hidden = false; } };
|
||||
const succeed = (message: string) => { if (error) error.hidden = true; if (status) { status.textContent = message; status.hidden = false; } };
|
||||
const endSession = (message?: string) => { token = ""; if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = undefined; if (login) { login.hidden = false; login.reset(); } if (sessionBar) sessionBar.hidden = true; if (content) content.innerHTML = ""; if (message) fail(message); };
|
||||
const keepSession = () => { if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); };
|
||||
async function authorizedJson(path: string) { const response = await fetch(`${root?.dataset.apiUrl}${path}`, {headers:{Authorization:`Bearer ${token}`}}); if (response.status === 401 || response.status === 429) { endSession(); throw new Error(response.status === 429 ? "Слишком много попыток входа. Повторите позже." : "Неверный или истёкший административный токен."); } if (!response.ok) throw new Error("Не удалось загрузить административные данные."); keepSession(); return response.json(); }
|
||||
async function publicJson(path: string) { const response = await fetch(`${root?.dataset.apiUrl}${path}`); return response.ok ? response.json() : []; }
|
||||
async function authorizedJson(path: string) { const response = await fetch(`${root?.dataset.apiUrl}${path}`, {headers:{Authorization:`Bearer ${token}`}}); if (!response.ok) { if (adminEndsSession(response.status)) endSession(); throw new Error(adminErrorMessage(response.status, "Не удалось загрузить административные данные.")); } keepSession(); return response.json(); }
|
||||
async function loadDashboard() {
|
||||
if (!content) return;
|
||||
error?.setAttribute("hidden", ""); content.setAttribute("aria-busy", "true"); content.innerHTML = '<div class="loading-grid" aria-hidden="true"><div class="loading-card"></div><div class="loading-card"></div></div>';
|
||||
error?.setAttribute("hidden", ""); status?.setAttribute("hidden", ""); content.setAttribute("aria-busy", "true"); content.innerHTML = '<div class="loading-grid" aria-hidden="true"><div class="loading-card"></div><div class="loading-card"></div></div>';
|
||||
const diagnostics = await authorizedJson("/api/v1/admin/diagnostics");
|
||||
const [imports, sources, history] = await Promise.all([authorizedJson("/api/v1/admin/imports?limit=5"), publicJson("/api/v1/source-status"), authorizedJson("/api/v1/admin/moderation-history?limit=8")]);
|
||||
const [imports, sources, history] = await Promise.all([authorizedJson("/api/v1/admin/imports?limit=5"), authorizedJson("/api/v1/admin/source-status"), authorizedJson("/api/v1/admin/moderation-history?limit=8")]);
|
||||
const reports = diagnostics.counts?.catch_reports ?? {}; const observations = diagnostics.counts?.external_observations ?? {};
|
||||
const sourceLabels: Record<string, string> = {healthy:"Работает",waiting:"Ожидает",stale:"Устарел",disabled:"Отключён",source_changed:"Изменился",temporarily_limited:"Временно недоступен"};
|
||||
const sourceRows = (sources as Record<string, unknown>[]).map(source => { const state = String(source.status); const safeState = Object.hasOwn(sourceLabels, state) ? state : "waiting"; return `<li><span><i class="status-dot ${safeState}"></i>${esc(source.name)}</span><strong>${sourceLabels[safeState]}</strong></li>`; }).join("");
|
||||
const importRows = (imports as Record<string, unknown>[]).map(run => `<li><span>${esc(run.status)}</span><time>${esc(new Date(String(run.started_at)).toLocaleString("ru-RU"))}</time></li>`).join("");
|
||||
const sourceRows = (sources as Record<string, unknown>[]).map(source => { const state = String(source.status); const safeState = Object.hasOwn(sourceLabels, state) ? state : "waiting"; const cooldown = Number(source.cooldown_seconds ?? 0); const detail = cooldown > 0 ? ` · cooldown ${Math.ceil(cooldown / 60)} мин` : source.backoff_recommended ? " · backoff рекомендован" : ""; const success = source.last_success_at ? ` · успех ${new Date(String(source.last_success_at)).toLocaleString("ru-RU")}` : " · успешных запусков нет"; return `<li><span><i class="status-dot ${safeState}"></i>${esc(source.name)}<small>${esc(detail + success)}</small></span><strong>${sourceLabels[safeState]}</strong></li>`; }).join("");
|
||||
const importLabels: Record<string, string> = {running:"Выполняется",success:"Успешно",partial:"Частично",failed:"Ошибка"};
|
||||
const importRows = (imports as Record<string, unknown>[]).map(run => { const status = String(run.status); const rows = Number(run.rows_seen ?? 0); const created = Number(run.rows_created ?? 0); const updated = Number(run.rows_updated ?? 0); const result = rows ? ` · ${rows} строк · +${created}/↻${updated}` : ""; const finished = run.finished_at ? ` · завершён ${new Date(String(run.finished_at)).toLocaleString("ru-RU")}` : ""; return `<li><span>${esc(importLabels[status] ?? status)}<small>${esc(result)}</small></span><time>${esc(new Date(String(run.started_at)).toLocaleString("ru-RU"))}<small>${esc(finished)}</small></time></li>`; }).join("");
|
||||
const actionLabels: Record<string, string> = {approved:"Одобрено",rejected:"Отклонено",pending:"Возвращено на проверку",published:"Опубликовано",mapped:"Сопоставлено",ready:"Готово"};
|
||||
const typeLabels: Record<string, string> = {catch_report:"Улов",external_observation:"Внешнее наблюдение"};
|
||||
const historyRows = (history as Record<string, unknown>[]).map(event => { const action = String(event.action); const type = String(event.entity_type); return `<li><span><b>${esc(typeLabels[type] ?? "Запись")}</b> · ${esc(actionLabels[action] ?? action)}${event.reason ? `<small>${esc(event.reason)}</small>` : ""}</span><time>${esc(new Date(String(event.decided_at)).toLocaleString("ru-RU"))}</time></li>`; }).join("");
|
||||
content.removeAttribute("aria-busy"); content.innerHTML = `<div class="admin-kpis"><a href="/admin/moderation"><span>Уловы на проверке</span><strong>${esc(reports.pending ?? 0)}</strong><small>Открыть очередь →</small></a><a href="/admin/external-sources"><span>Наблюдения в staging</span><strong>${esc((observations.staged ?? 0) + (observations.mapped ?? 0) + (observations.ready ?? 0))}</strong><small>Проверить источники →</small></a><article><span>Одобрено уловов</span><strong>${esc(reports.approved ?? 0)}</strong><small>Участвуют в статистике</small></article><article><span>Источников включено</span><strong>${esc(diagnostics.counts?.enabled_data_sources ?? 0)}</strong><small>из ${esc(diagnostics.counts?.data_sources ?? 0)}</small></article></div><div class="admin-dashboard-grid"><section><h2>Состояние источников</h2><ul>${sourceRows || "<li>Нет данных</li>"}</ul><a href="/status">Публичная страница состояния →</a></section><section><h2>Последние импорты</h2><ul>${importRows || "<li>Запусков пока нет</li>"}</ul></section><section class="admin-history"><div class="admin-section-head"><h2>Последние решения</h2><button type="button" data-action="secondary" data-history-export>Экспорт JSON</button></div><ul>${historyRows || "<li>Решений пока нет</li>"}</ul><p class="privacy">Экспорт обезличен: без UUID, модератора, причин и исходных данных.</p></section></div>`;
|
||||
content.removeAttribute("aria-busy"); content.innerHTML = `<div class="admin-kpis"><a href="/admin/moderation"><span>Уловы на проверке</span><strong>${esc(reports.pending ?? 0)}</strong><small>Открыть очередь →</small></a><a href="/admin/external-sources"><span>Наблюдения в staging</span><strong>${esc((observations.staged ?? 0) + (observations.mapped ?? 0) + (observations.ready ?? 0))}</strong><small>Проверить источники →</small></a><article><span>Одобрено уловов</span><strong>${esc(reports.approved ?? 0)}</strong><small>Участвуют в статистике</small></article><article><span>Источников включено</span><strong>${esc(diagnostics.counts?.enabled_data_sources ?? 0)}</strong><small>из ${esc(diagnostics.counts?.data_sources ?? 0)}</small></article></div><div class="admin-dashboard-grid"><section><div class="admin-section-head"><h2>Состояние источников</h2><button type="button" data-action="secondary" data-refresh>Обновить</button></div><ul>${sourceRows || "<li>Нет данных</li>"}</ul><a href="/status">Публичная страница состояния →</a><br /><a href="/admin/media">Проверить медиа →</a></section><section><div class="admin-section-head"><h2>Последние импорты</h2><button type="button" data-action="secondary" data-official-import>Запустить импорт</button></div><ul>${importRows || "<li>Запусков пока нет</li>"}</ul><p class="privacy">Импорт обращается к официальному источнику и соблюдает cooldown.</p></section><section class="admin-history"><div class="admin-section-head"><h2>Последние решения</h2><button type="button" data-action="secondary" data-history-export>Экспорт JSON</button></div><ul>${historyRows || "<li>Решений пока нет</li>"}</ul><p class="privacy">Экспорт обезличен: без UUID, модератора, причин и исходных данных.</p></section></div>`;
|
||||
}
|
||||
login?.addEventListener("submit", async event => { event.preventDefault(); token = String(new FormData(login).get("token") || ""); try { await loadDashboard(); login.hidden = true; if (sessionBar) sessionBar.hidden = false; } catch (cause) { if (content) { content.removeAttribute("aria-busy"); content.innerHTML = ""; } fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
|
||||
logout?.addEventListener("click", () => endSession("Вы вышли из административной панели."));
|
||||
content?.addEventListener("click", async event => {
|
||||
const button = (event.target as HTMLElement).closest<HTMLButtonElement>("[data-history-export]"); if (!button) return;
|
||||
const button = (event.target as HTMLElement).closest<HTMLButtonElement>("[data-history-export],[data-official-import],[data-refresh]"); if (!button) return;
|
||||
button.disabled = true;
|
||||
try { const response = await fetch(`${root?.dataset.apiUrl}/api/v1/admin/moderation-history-export`, {headers:{Authorization:`Bearer ${token}`}}); if (!response.ok) throw new Error(); const blob = await response.blob(); const link = document.createElement("a"); link.href = URL.createObjectURL(blob); link.download = "rf4spotter-moderation-history.json"; link.click(); URL.revokeObjectURL(link.href); keepSession(); }
|
||||
catch { fail("Не удалось выгрузить журнал решений."); }
|
||||
try { if (button.hasAttribute("data-refresh")) { await loadDashboard(); succeed("Данные обновлены."); } else if (button.hasAttribute("data-official-import")) { const response = await fetch(`${root?.dataset.apiUrl}/api/v1/admin/imports/official-records`, {method:"POST",headers:{Authorization:`Bearer ${token}`}}); if (!response.ok) { if (adminEndsSession(response.status)) endSession(); throw new Error(response.status === 502 ? "Официальный источник временно недоступен. Старые данные сохранены." : adminErrorMessage(response.status, "Не удалось запустить импорт.")); } keepSession(); await loadDashboard(); succeed("Импорт запущен. Список запусков обновлён."); } else { const response = await fetch(`${root?.dataset.apiUrl}/api/v1/admin/moderation-history-export`, {headers:{Authorization:`Bearer ${token}`}}); if (!response.ok) { if (adminEndsSession(response.status)) endSession(); throw new Error(adminErrorMessage(response.status, "Не удалось выгрузить журнал решений.")); } const blob = await response.blob(); const link = document.createElement("a"); link.href = URL.createObjectURL(blob); link.download = "rf4spotter-moderation-history.json"; link.click(); URL.revokeObjectURL(link.href); keepSession(); } }
|
||||
catch (cause) { fail(cause instanceof Error ? cause.message : "Операция не выполнена."); }
|
||||
finally { button.disabled = false; }
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import AdminNav from "../../components/AdminNav.astro";
|
||||
const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
||||
---
|
||||
<Layout title="Проверка медиа — RF4 Spotter" noindex>
|
||||
<section class="form-hero"><div><span class="eyebrow"><b>ADMIN</b> Media review</span><h1>Проверка<br/><em>медиа</em></h1></div><p>Сравнение approved и upgrade_queued файлов перед отдельным редакционным решением.</p></section>
|
||||
<section class="moderation-app" data-api-url={apiUrl}>
|
||||
<AdminNav />
|
||||
<form class="admin-login" autocomplete="off"><label>Административный токен<input name="token" type="password" required autocomplete="off" /></label><button data-action="primary" type="submit">Открыть медиатеку</button></form>
|
||||
<p class="privacy">Публикация требует явной причины и атомарно сохраняет fallback для rollback. Токен хранится только в памяти страницы.</p>
|
||||
<div class="admin-session-bar" hidden><span>Административная сессия активна</span><button data-action="secondary" type="button" data-admin-logout>Выйти</button></div>
|
||||
<form class="admin-queue-filters" hidden><label>Тип<select name="entity_type"><option value="">Все типы</option><option value="fish">Рыбы</option><option value="waterbody">Водоёмы</option><option value="tackle">Снасти</option><option value="reference">Справка</option></select></label><label>Состояние<select name="status"><option value="">Все состояния</option><option value="approved">Approved</option><option value="upgrade_queued">Upgrade queued</option><option value="upgrade_stored">Upgrade stored</option></select></label><button data-action="primary" type="submit">Применить</button></form>
|
||||
<div class="notice error" data-admin-error role="alert" hidden></div><div class="notice success" data-admin-status role="status" hidden></div><section class="media-library__grid" data-media-list aria-live="polite"></section>
|
||||
<nav data-pages aria-label="Страницы медиатеки" hidden><button data-action="secondary" type="button" data-previous>Предыдущая</button><span data-page-number aria-live="polite"></span><button data-action="secondary" type="button" data-next>Следующая</button><button data-action="secondary" type="button" data-refresh>Обновить</button></nav>
|
||||
</section>
|
||||
<script>
|
||||
import { adminEndsSession, adminErrorMessage } from "../../lib/admin-errors";
|
||||
const root = document.querySelector<HTMLElement>("[data-api-url]");
|
||||
const login = document.querySelector<HTMLFormElement>(".admin-login");
|
||||
const filters = document.querySelector<HTMLFormElement>(".admin-queue-filters");
|
||||
const list = document.querySelector<HTMLElement>("[data-media-list]");
|
||||
const error = document.querySelector<HTMLElement>("[data-admin-error]");
|
||||
const status = document.querySelector<HTMLElement>("[data-admin-status]");
|
||||
const sessionBar = document.querySelector<HTMLElement>(".admin-session-bar");
|
||||
const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]");
|
||||
const pages = document.querySelector<HTMLElement>("[data-pages]");
|
||||
const previous = document.querySelector<HTMLButtonElement>("[data-previous]");
|
||||
const next = document.querySelector<HTMLButtonElement>("[data-next]");
|
||||
const refresh = document.querySelector<HTMLButtonElement>("[data-refresh]");
|
||||
const pageNumber = document.querySelector<HTMLElement>("[data-page-number]");
|
||||
let token = ""; let offset = 0; let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&","<":"<",">":">","'":"'",'"':"""}[char] ?? char));
|
||||
const url = (value: unknown) => { try { const parsed = new URL(String(value), root?.dataset.apiUrl); return parsed.protocol === "http:" || parsed.protocol === "https:" ? esc(parsed.href) : ""; } catch { return ""; } };
|
||||
const fail = (message: string) => { if (status) status.hidden = true; if (error) { error.textContent = message; error.hidden = false; } };
|
||||
const succeed = (message: string) => { if (error) error.hidden = true; if (status) { status.textContent = message; status.hidden = false; } };
|
||||
const endSession = (message?: string) => { token = ""; if (timer) clearTimeout(timer); timer = undefined; if (login) { login.hidden = false; login.reset(); } if (filters) filters.hidden = true; if (sessionBar) sessionBar.hidden = true; if (list) list.innerHTML = ""; pages?.setAttribute("hidden", ""); if (message) fail(message); };
|
||||
const keepSession = () => { if (timer) clearTimeout(timer); timer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); };
|
||||
const loading = () => '<div class="loading-card" aria-hidden="true"></div><span class="sr-only">Загружаем медиа</span>';
|
||||
async function load() {
|
||||
if (!root || !list || !filters) return;
|
||||
error?.setAttribute("hidden", ""); list.innerHTML = loading(); list.setAttribute("aria-busy", "true");
|
||||
const values = new FormData(filters); const params = new URLSearchParams({limit:"51", offset:String(offset)}); for (const key of ["entity_type", "status"]) { const value = String(values.get(key) || ""); if (value) params.set(key, value); }
|
||||
const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/media/catalog?${params}`, {headers:{Authorization:`Bearer ${token}`} });
|
||||
if (!response.ok) { if (adminEndsSession(response.status)) endSession(); throw new Error(adminErrorMessage(response.status, "Не удалось загрузить медиатеку.")); }
|
||||
const rows: Record<string, unknown>[] = await response.json(); keepSession(); list.removeAttribute("aria-busy");
|
||||
if (!rows.length && offset > 0) { offset = 0; return load(); }
|
||||
const assets = rows.slice(0, 50); if (pages) pages.hidden = !assets.length; if (previous) previous.disabled = offset === 0; if (next) next.disabled = rows.length <= 50; if (pageNumber) pageNumber.textContent = `Страница ${offset / 50 + 1}`;
|
||||
if (!assets.length) { list.innerHTML = '<div class="state"><h2>Кандидатов нет</h2><p>Для выбранных фильтров нет approved или upgrade_queued файлов.</p></div>'; return; }
|
||||
list.innerHTML = assets.map(asset => { const image = url(asset.image_url); const source = url(asset.source_url); const variants = (asset.derivatives as Record<string, unknown>[] ?? []).map(item => `${esc(item.format)} ${esc(item.width)}×${esc(item.height)}`).join(", "); const rollback = asset.status === "approved" && asset.supersedes ? `<button data-media-rollback="${esc(asset.asset_url)}" type="button" data-action="secondary">Откатить замену</button>` : ""; return `<article class="media-library__card"><a href="${image}" target="_blank" rel="noreferrer">${image ? `<img src="${image}" alt="${esc(asset.label)}" loading="lazy" />` : ""}</a><h2>${esc(asset.label)}</h2><span>${esc(asset.status)} · ${esc(asset.entity_type)} · ${esc(asset.width)}×${esc(asset.height)}</span><p>${esc(asset.source_system)}${asset.duplicate_of ? ` · duplicate_of ${esc(asset.duplicate_of)}` : ""}</p>${variants ? `<small>Производные: ${variants}</small>` : "<small>Производных нет</small>"}${source ? `<a href="${source}" target="_blank" rel="noreferrer">Первоисточник →</a>` : ""}${rollback}</article>`; }).join("");
|
||||
list.querySelectorAll<HTMLButtonElement>("[data-media-rollback]").forEach(button => button.addEventListener("click", async () => { const note = window.prompt("Причина отката:"); if (!note?.trim()) return; button.disabled = true; try { const response = await fetch(`${root?.dataset.apiUrl}/api/v1/admin/media/upgrades/rollback`, {method:"POST", headers:{Authorization:`Bearer ${token}`, "Content-Type":"application/json"}, body:JSON.stringify({asset_url:button.dataset.mediaRollback, note:note.trim()})}); if (!response.ok) throw new Error(adminErrorMessage(response.status, "Не удалось выполнить откат.")); succeed("Fallback восстановлен."); await load(); } catch (cause) { fail(cause instanceof Error ? cause.message : "Не удалось выполнить откат."); } finally { button.disabled = false; } }));
|
||||
if (assets.some(asset => asset.status === "upgrade_stored")) { const publish = document.createElement("button"); publish.type = "button"; publish.dataset.action = "primary"; publish.textContent = "Опубликовать сохранённые замены"; publish.addEventListener("click", async () => { const note = window.prompt("Причина публикации замен:"); if (!note?.trim()) return; publish.disabled = true; try { const response = await fetch(`${root?.dataset.apiUrl}/api/v1/admin/media/upgrades/publish`, {method:"POST", headers:{Authorization:`Bearer ${token}`, "Content-Type":"application/json"}, body:JSON.stringify({note:note.trim()})}); if (!response.ok) throw new Error(adminErrorMessage(response.status, "Не удалось опубликовать замены.")); succeed("Замены опубликованы атомарно, fallback сохранены."); await load(); } catch (cause) { fail(cause instanceof Error ? cause.message : "Не удалось опубликовать замены."); } finally { publish.disabled = false; } }); list.prepend(publish); }
|
||||
}
|
||||
login?.addEventListener("submit", async event => { event.preventDefault(); token = String(new FormData(login).get("token") || ""); offset = 0; try { await load(); login.hidden = true; if (sessionBar) sessionBar.hidden = false; if (filters) filters.hidden = false; } catch (cause) { list && (list.innerHTML = ""); fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
|
||||
filters?.addEventListener("submit", async event => { event.preventDefault(); offset = 0; try { await load(); } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка фильтрации."); } });
|
||||
const move = async (delta: number) => { offset = Math.max(0, offset + delta); try { await load(); } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка загрузки страницы."); } };
|
||||
previous?.addEventListener("click", () => move(-50)); next?.addEventListener("click", () => move(50)); refresh?.addEventListener("click", async () => { refresh.disabled = true; try { await load(); succeed("Медиатека обновлена."); } catch (cause) { fail(cause instanceof Error ? cause.message : "Не удалось обновить медиатеку."); } finally { refresh.disabled = false; } });
|
||||
logout?.addEventListener("click", () => endSession("Вы вышли из административной панели."));
|
||||
</script>
|
||||
</Layout>
|
||||
@@ -1,25 +1,35 @@
|
||||
---
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import AdminNav from "../../components/AdminNav.astro";
|
||||
const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
||||
---
|
||||
<Layout title="Модерация уловов — RF4 Spotter">
|
||||
<section class="form-hero"><div><span class="eyebrow"><b>ADMIN</b> Очередь проверки</span><h1>Модерация<br/><em>уловов</em></h1></div><p>Проверьте данные и скриншот до того, как запись повлияет на публичную статистику.</p></section>
|
||||
<section class="moderation-app" data-api-url={apiUrl}>
|
||||
<AdminNav />
|
||||
<form class="admin-login" autocomplete="off"><label>Административный токен<input name="token" type="password" required autocomplete="off" /></label><button data-action="primary" type="submit">Открыть очередь</button></form>
|
||||
<p class="privacy">Токен хранится только в памяти страницы и не записывается в URL или localStorage.</p>
|
||||
<div class="admin-session-bar" hidden><span>Административная сессия активна</span><button data-action="secondary" type="button" data-admin-logout>Выйти</button></div>
|
||||
<div class="notice error" data-admin-error role="alert" hidden></div><div class="notice success" data-admin-status role="status" hidden></div><div class="moderation-list" data-moderation-list aria-live="polite"></div>
|
||||
<nav data-pages aria-label="Страницы очереди" hidden><button data-action="secondary" type="button" data-previous>Предыдущая</button><span data-page-number aria-live="polite"></span><button data-action="secondary" type="button" data-next>Следующая</button><button data-action="secondary" type="button" data-refresh>Обновить</button></nav>
|
||||
<p class="admin-shortcuts"><kbd>A</kbd> одобрить карточку с фокусом · отклонение и удаление — только кнопками</p>
|
||||
</section>
|
||||
<script>
|
||||
import { adminEndsSession, adminErrorMessage } from "../../lib/admin-errors";
|
||||
const root = document.querySelector<HTMLElement>(".moderation-app");
|
||||
const login = document.querySelector<HTMLFormElement>(".admin-login");
|
||||
const list = document.querySelector<HTMLElement>("[data-moderation-list]");
|
||||
const error = document.querySelector<HTMLElement>("[data-admin-error]");
|
||||
const status = document.querySelector<HTMLElement>("[data-admin-status]");
|
||||
const pages = document.querySelector<HTMLElement>("[data-pages]");
|
||||
const previous = document.querySelector<HTMLButtonElement>("[data-previous]");
|
||||
const next = document.querySelector<HTMLButtonElement>("[data-next]");
|
||||
const refresh = document.querySelector<HTMLButtonElement>("[data-refresh]");
|
||||
const pageNumber = document.querySelector<HTMLElement>("[data-page-number]");
|
||||
const sessionBar = document.querySelector<HTMLElement>(".admin-session-bar");
|
||||
const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]");
|
||||
let token = "";
|
||||
let offset = 0;
|
||||
let sessionTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const loadingCards = () => `<div class="loading-grid" aria-hidden="true">${Array.from({length:2}, () => '<div class="loading-card"><span class="loading-line loading-line--label"></span><span class="loading-line loading-line--title"></span><span class="loading-line"></span><span class="loading-line loading-line--short"></span></div>').join("")}</div><span class="sr-only">Загружаем очередь модерации</span>`;
|
||||
const setLoading = (loading: boolean) => list?.setAttribute("aria-busy", String(loading));
|
||||
@@ -32,16 +42,25 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
||||
async function loadQueue() {
|
||||
if (!root || !list) return;
|
||||
error?.setAttribute("hidden", ""); setLoading(true); list.innerHTML = loadingCards();
|
||||
const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/catch-reports?status=pending`, {headers:{Authorization:`Bearer ${token}`}});
|
||||
if (response.status === 401) { endSession(); throw new Error("Неверный или истёкший административный токен."); }
|
||||
if (!response.ok) throw new Error("Не удалось загрузить очередь.");
|
||||
const reports: Record<string, unknown>[] = await response.json();
|
||||
const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/catch-reports?status=pending&limit=51&offset=${offset}`, {headers:{Authorization:`Bearer ${token}`} });
|
||||
if (!response.ok) { if (adminEndsSession(response.status)) endSession(); throw new Error(adminErrorMessage(response.status, "Не удалось загрузить очередь.")); }
|
||||
const rows: Record<string, unknown>[] = await response.json();
|
||||
keepSession();
|
||||
setLoading(false);
|
||||
if (!rows.length && offset > 0) { offset = 0; return loadQueue(); }
|
||||
const reports = rows.slice(0, 50);
|
||||
if (pages) pages.hidden = !reports.length;
|
||||
if (previous) previous.disabled = offset === 0;
|
||||
if (next) next.disabled = rows.length <= 50;
|
||||
if (pageNumber) pageNumber.textContent = `Страница ${offset / 50 + 1}`;
|
||||
if (!reports.length) { list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Новых уловов для проверки нет.</p></div>'; return; }
|
||||
list.innerHTML = reports.map(report => { const screenshotUrl = safeHttpUrl(report.screenshot_url); return `<article class="moderation-card" data-report-id="${esc(report.id)}" data-version="${esc(report.moderation_version)}"><div class="moderation-summary"><span class="activity-pill"><i></i>На проверке</span><h2>${esc(report.fish)}</h2><p>${esc(report.waterbody)} · ${esc(report.coordinates)}</p><dl><div><dt>Вес</dt><dd>${esc(report.weight_g)} г</dd></div><div><dt>Приманка</dt><dd>${esc(report.bait)}</dd></div><div><dt>Игрок</dt><dd>${esc(report.player_name)}</dd></div><div><dt>Отправлено</dt><dd>${esc(new Date(String(report.reported_at)).toLocaleString("ru-RU"))}</dd></div></dl>${report.comment ? `<blockquote>${esc(report.comment)}</blockquote>` : ""}</div><div class="moderation-proof">${screenshotUrl ? `<a href="${screenshotUrl}" target="_blank" rel="noreferrer"><img src="${screenshotUrl}" alt="Скриншот улова ${esc(report.fish)}" /></a>` : '<div class="no-proof">Скриншот не приложен</div>'}</div><div class="moderation-actions"><label>Причина решения<textarea rows="2" maxlength="1000"></textarea></label><div><button data-action="primary" type="button" data-decision="approved">Одобрить</button><button data-action="danger" type="button" data-decision="rejected">Отклонить</button><button data-action="quiet-danger" type="button" data-delete>Удалить</button></div></div></article>`; }).join("");
|
||||
}
|
||||
login?.addEventListener("submit", async event => { event.preventDefault(); token = String(new FormData(login).get("token") || ""); try { await loadQueue(); login.hidden = true; if (sessionBar) sessionBar.hidden = false; } catch (cause) { setLoading(false); if (list) list.innerHTML = ""; fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
|
||||
async function changePage(delta: number) { const oldOffset = offset; offset = Math.max(0, offset + delta); if (previous) previous.disabled = true; if (next) next.disabled = true; try { await loadQueue(); } catch { offset = oldOffset; setLoading(false); fail("Не удалось загрузить страницу очереди."); } }
|
||||
previous?.addEventListener("click", () => changePage(-50));
|
||||
next?.addEventListener("click", () => changePage(50));
|
||||
refresh?.addEventListener("click", async () => { refresh.disabled = true; try { await loadQueue(); succeed("Очередь обновлена."); } catch (cause) { fail(cause instanceof Error ? cause.message : "Не удалось обновить очередь."); } finally { refresh.disabled = false; } });
|
||||
login?.addEventListener("submit", async event => { event.preventDefault(); offset = 0; token = String(new FormData(login).get("token") || ""); try { await loadQueue(); login.hidden = true; if (sessionBar) sessionBar.hidden = false; } catch (cause) { setLoading(false); if (list) list.innerHTML = ""; fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
|
||||
logout?.addEventListener("click", () => endSession("Вы вышли из административной панели."));
|
||||
list?.addEventListener("click", async event => {
|
||||
const button = (event.target as HTMLElement).closest<HTMLButtonElement>("button[data-decision],button[data-delete]"); const card = button?.closest<HTMLElement>("[data-report-id]"); if (!button || !card || !root) return;
|
||||
@@ -49,7 +68,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
||||
if (button.dataset.decision === "rejected" && !reason) { fail("Укажите причину отклонения."); card.querySelector("textarea")?.focus(); return; }
|
||||
if (button.hasAttribute("data-delete") && !window.confirm("Удалить и обезличить эту заявку? Действие нельзя отменить.")) return;
|
||||
const cardButtons = card.querySelectorAll<HTMLButtonElement>("button"); cardButtons.forEach(item => item.disabled = true);
|
||||
try { const deleting = button.hasAttribute("data-delete"); const decision = deleting ? "Заявка удалена и обезличена." : button.dataset.decision === "approved" ? "Улов одобрен и опубликован." : "Улов отклонён."; const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/catch-reports/${card.dataset.reportId}${deleting ? `?expected_version=${card.dataset.version}` : ""}`, {method:deleting ? "DELETE" : "PATCH",headers:{Authorization:`Bearer ${token}`,"Content-Type":"application/json"},body:deleting ? undefined : JSON.stringify({status:button.dataset.decision,reason,expected_version:Number(card.dataset.version)})}); if (response.status === 401) { endSession(); throw new Error("Сессия истекла. Введите токен снова."); } if (response.status === 409) { await loadQueue(); throw new Error("Запись уже изменена в другой вкладке. Очередь обновлена."); } if (!response.ok) throw new Error("Не удалось сохранить решение."); keepSession(); card.remove(); succeed(decision); const nextAction = list.querySelector<HTMLButtonElement>("button[data-decision]"); if (nextAction) nextAction.focus(); else list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Все записи обработаны.</p></div>'; } catch (cause) { cardButtons.forEach(item => item.disabled = false); fail(cause instanceof Error ? cause.message : "Ошибка сохранения."); }
|
||||
try { const deleting = button.hasAttribute("data-delete"); const decision = deleting ? "Заявка удалена и обезличена." : button.dataset.decision === "approved" ? "Улов одобрен и опубликован." : "Улов отклонён."; const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/catch-reports/${card.dataset.reportId}${deleting ? `?expected_version=${card.dataset.version}` : ""}`, {method:deleting ? "DELETE" : "PATCH",headers:{Authorization:`Bearer ${token}`,"Content-Type":"application/json"},body:deleting ? undefined : JSON.stringify({status:button.dataset.decision,reason,expected_version:Number(card.dataset.version)})}); if (response.status === 409) { await loadQueue(); throw new Error("Запись уже изменена в другой вкладке. Очередь обновлена."); } if (!response.ok) { if (adminEndsSession(response.status)) endSession(); throw new Error(adminErrorMessage(response.status, "Не удалось сохранить решение.")); } keepSession(); card.remove(); succeed(decision); const nextAction = list.querySelector<HTMLButtonElement>("button[data-decision]"); if (nextAction) nextAction.focus(); else list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Все записи обработаны.</p></div>'; } catch (cause) { cardButtons.forEach(item => item.disabled = false); fail(cause instanceof Error ? cause.message : "Ошибка сохранения."); }
|
||||
});
|
||||
document.addEventListener("keydown", event => { const target = event.target as HTMLElement; if (event.key.toLowerCase() !== "a" || target.matches("input,textarea,select") || event.ctrlKey || event.metaKey || event.altKey) return; const card = target.closest<HTMLElement>("[data-report-id]"); const approve = card?.querySelector<HTMLButtonElement>('[data-decision="approved"]'); if (approve && !approve.disabled) { event.preventDefault(); approve.click(); } });
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { APIRoute } from "astro";
|
||||
|
||||
const apiBase = process.env.API_INTERNAL_URL || import.meta.env.API_INTERNAL_URL || "http://localhost:8000";
|
||||
|
||||
export const GET: APIRoute = async ({ params }) => {
|
||||
const digest = params.digest ?? "";
|
||||
if (!/^[a-f0-9]{64}$/.test(digest)) return new Response("Not found", { status: 404 });
|
||||
|
||||
try {
|
||||
const upstream = await fetch(`${apiBase}/api/v1/media/assets/${digest}`);
|
||||
if (!upstream.ok || !upstream.body) return new Response("Not found", { status: upstream.status === 404 ? 404 : 502 });
|
||||
const headers = new Headers();
|
||||
for (const name of ["content-type", "content-length", "cache-control", "etag"]) {
|
||||
const value = upstream.headers.get(name);
|
||||
if (value) headers.set(name, value);
|
||||
}
|
||||
return new Response(upstream.body, { status: 200, headers });
|
||||
} catch {
|
||||
return new Response("Media service unavailable", { status: 502 });
|
||||
}
|
||||
};
|
||||
@@ -1,15 +1,18 @@
|
||||
---
|
||||
import ActivityCard from "../../components/ActivityCard.astro";
|
||||
import EntityMedia from "../../components/EntityMedia.astro";
|
||||
import AtlasBreadcrumbs from "../../components/AtlasBreadcrumbs.astro";
|
||||
import AtlasEntityLink from "../../components/AtlasEntityLink.astro";
|
||||
import PageHero from "../../components/PageHero.astro";
|
||||
import StatePanel from "../../components/StatePanel.astro";
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import { api, plural, type Activity, type DictionaryItem, type PaginatedActivity } from "../../lib/api";
|
||||
import { api, plural, type Activity, type DictionaryItem, type MediaAsset, type PaginatedActivity } from "../../lib/api";
|
||||
import { findMediaByLabel } from "../../lib/media";
|
||||
const { slug } = Astro.params;
|
||||
let fish: DictionaryItem | undefined, items: Activity[] = [], unavailable = false;
|
||||
let fish: DictionaryItem | undefined, items: Activity[] = [], media: MediaAsset[] = [], unavailable = false;
|
||||
try {
|
||||
const fishes = await api<DictionaryItem[]>("/api/v1/fishes?limit=500");
|
||||
const [fishes, fishMedia] = await Promise.all([api<DictionaryItem[]>("/api/v1/fishes?limit=500"), api<MediaAsset[]>("/api/v1/media/catalog?entity_type=fish")]);
|
||||
media = fishMedia;
|
||||
fish = fishes.find(item => item.slug === slug);
|
||||
if (fish) { const paginated = await api<PaginatedActivity>(`/api/v1/activity?hours=72&fish=${encodeURIComponent(fish.slug)}&limit=100`); items = paginated.items; }
|
||||
} catch { unavailable = true; }
|
||||
@@ -20,10 +23,12 @@ if (unavailable) {
|
||||
}
|
||||
if (!fish && !unavailable) Astro.response.status = 404;
|
||||
const waters = [...new Map(items.map(item => [item.waterbody_slug, item.waterbody])).entries()];
|
||||
const image = fish ? findMediaByLabel(media, fish.name_ru) : undefined;
|
||||
const schema = fish ? { "@context":"https://schema.org", "@type":"CollectionPage", name:`Где ловить ${fish.name_ru} в RF4`, url:`https://rf4spotter.ru/fish/${fish.slug}` } : null;
|
||||
---
|
||||
<Layout title={fish ? `Где ловить ${fish.name_ru} в RF4 — свежие точки` : "Рыба не найдена — RF4 Spotter"} description={fish ? `Свежие точки ловли ${fish.name_ru} в Russian Fishing 4: водоёмы, координаты, приманки, активность и источники наблюдений.` : "Такого вида рыбы нет в каталоге RF4 Spotter."} noindex={!fish || unavailable} structuredData={schema} errorPage={!fish || unavailable}>
|
||||
<AtlasBreadcrumbs items={[{ label: "Рыбы", href: "/fish" }, { label: fish?.name_ru ?? "Не найдено" }]} />
|
||||
<PageHero eyebrow="Свежие данные за 72 часа" title={fish?.name_ru ?? "Рыба не найдена"} description={fish ? `${items.length} ${plural(items.length,["активная точка","активные точки","активных точек"])} на ${waters.length} ${plural(waters.length,["водоёме","водоёмах","водоёмах"])}.` : undefined} variant={fish ? "fish" : undefined} identity={fish?.name_ru} />
|
||||
{image && <section class="entity-feature content-grid" aria-label={`Изображение: ${fish!.name_ru}`}><EntityMedia asset={image} sourceLink /><div><span class="overline">Визуальный справочник</span><h2>{fish!.name_ru}</h2><p>Изображение опубликовано с прямой ссылкой на источник. Оно помогает отличить вид, а актуальные точки ниже остаются отдельными наблюдениями игроков.</p></div></section>}
|
||||
{unavailable ? <StatePanel tone="unavailable" title="Данные временно недоступны" description="Каталог сохранён, но свежие наблюдения сейчас не получены." /> : !fish ? <StatePanel tone="error" title="Такой рыбы нет в справочнике" actionHref="/fish" actionLabel="Открыть каталог" /> : <section class="catalog-results content-grid"><aside><span class="overline">Водоёмы</span>{waters.length ? <nav>{waters.map(([waterSlug,name]) => <AtlasEntityLink href={`/waterbodies/${waterSlug}/${fish!.slug}`} label={name} kind="water" identity={waterSlug} />)}</nav> : <p>Свежих подтверждённых водоёмов пока нет.</p>}</aside><div>{items.length ? items.map(item => <ActivityCard item={item}/>) : <StatePanel contained={false} title="Свежих точек пока нет" description="Проверьте позже или посмотрите полевые сигналы на главной." />}</div></section>}
|
||||
</Layout>
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
---
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import FishSilhouette from "../../components/FishSilhouette.astro";
|
||||
import EntityMedia from "../../components/EntityMedia.astro";
|
||||
import PageHero from "../../components/PageHero.astro";
|
||||
import StatePanel from "../../components/StatePanel.astro";
|
||||
import { api, type DictionaryItem } from "../../lib/api";
|
||||
let fishes: DictionaryItem[] = [], unavailable = false;
|
||||
try { fishes = await api<DictionaryItem[]>("/api/v1/fishes?limit=500"); } catch { unavailable = true; }
|
||||
import Pagination from "../../components/Pagination.astro";
|
||||
import { api, type DictionaryItem, type MediaAsset } from "../../lib/api";
|
||||
import { findMediaByLabel } from "../../lib/media";
|
||||
const params = Astro.url.searchParams;
|
||||
const pageLimit = 48;
|
||||
const requestedOffset = Number(params.get("offset") ?? 0);
|
||||
let offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0;
|
||||
let fishes: DictionaryItem[] = [], allFishes: DictionaryItem[] = [], media: MediaAsset[] = [], unavailable = false;
|
||||
try {
|
||||
[allFishes, media] = await Promise.all([api<DictionaryItem[]>("/api/v1/fishes?limit=500"), api<MediaAsset[]>("/api/v1/media/catalog?entity_type=fish")]);
|
||||
if (allFishes.length > 0 && offset >= allFishes.length) offset = Math.floor((allFishes.length - 1) / pageLimit) * pageLimit;
|
||||
fishes = allFishes.slice(offset, offset + pageLimit);
|
||||
} catch { unavailable = true; }
|
||||
if (unavailable) {
|
||||
Astro.response.status = 503;
|
||||
Astro.response.headers.set("Retry-After", "60");
|
||||
@@ -13,6 +24,7 @@ if (unavailable) {
|
||||
}
|
||||
---
|
||||
<Layout title="Все виды рыб Russian Fishing 4 — RF4 Spotter" description="Каталог рыб RF4 со свежими точками, уловами, приманками и прозрачными источниками данных.">
|
||||
<PageHero eyebrow="Справочник RF4" title="Рыбы" description="Выберите вид, чтобы увидеть свежие подтверждённые точки и полевые сигналы." variant="fish" count={fishes.length} />
|
||||
{unavailable ? <StatePanel tone="unavailable" title="Каталог временно недоступен" description="Не показываем непроверенный список. Попробуйте обновить страницу позже." /> : <nav class="catalog-grid content-grid" aria-label="Виды рыб">{fishes.map((fish,index) => <a href={`/fish/${fish.slug}`}><span>Вид рыбы · {String(index + 1).padStart(2,"0")}</span><strong>{fish.name_ru}</strong><i>Открыть <b>→</b></i><FishSilhouette name={fish.name_ru} size={92}/></a>)}</nav>}
|
||||
<PageHero eyebrow="Справочник RF4" title="Рыбы" description="Выберите вид, чтобы увидеть свежие подтверждённые точки и полевые сигналы." variant="fish" count={allFishes.length} />
|
||||
{unavailable ? <StatePanel tone="unavailable" title="Каталог временно недоступен" description="Не показываем непроверенный список. Попробуйте обновить страницу позже." /> : <nav class="catalog-grid content-grid" aria-label="Виды рыб">{fishes.map((fish,index) => { const asset = findMediaByLabel(media, fish.name_ru); return <a href={`/fish/${fish.slug}`}><span>Вид рыбы · {String(offset + index + 1).padStart(2,"0")}</span><strong>{fish.name_ru}</strong><i>Открыть <b>→</b></i>{asset ? <EntityMedia asset={asset} compact /> : <FishSilhouette name={fish.name_ru} size={92}/>}</a>; })}</nav>}
|
||||
{!unavailable && <Pagination path="/fish" params={params} total={allFishes.length} limit={pageLimit} offset={offset} itemLabel="видов" />}
|
||||
</Layout>
|
||||
|
||||
@@ -6,6 +6,7 @@ import SourceBadge from "../components/SourceBadge.astro";
|
||||
import TackleGlyph from "../components/TackleGlyph.astro";
|
||||
import SignalFeed from "../components/SignalFeed.astro";
|
||||
import StatePanel from "../components/StatePanel.astro";
|
||||
import Pagination from "../components/Pagination.astro";
|
||||
import { activityLevel, ago, api, kg, plural, type Activity, type DictionaryItem, type PaginatedActivity, type PublicObservation } from "../lib/api";
|
||||
|
||||
const params = Astro.url.searchParams;
|
||||
@@ -13,8 +14,9 @@ const hours = params.get("hours") ?? "24";
|
||||
const waterbody = params.get("waterbody") ?? "";
|
||||
const fish = params.get("fish") ?? "";
|
||||
const sort = params.get("sort") ?? "activity";
|
||||
const activityLimit = 20;
|
||||
const requestedOffset = Number(params.get("offset") ?? 0);
|
||||
const offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0;
|
||||
let offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0;
|
||||
const requestedSignalLimit = Number(params.get("signals") ?? 12);
|
||||
const signalLimit = Number.isInteger(requestedSignalLimit) ? Math.min(48, Math.max(12, requestedSignalLimit)) : 12;
|
||||
let items: Activity[] = [], signals: PublicObservation[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [];
|
||||
@@ -38,11 +40,16 @@ if (!signalsUnavailable) {
|
||||
signals = signalRows.slice(0, signalLimit);
|
||||
}
|
||||
if (!filterError) {
|
||||
const query = new URLSearchParams({ hours, waterbody, fish, sort, limit: "20", offset: String(offset) });
|
||||
const query = new URLSearchParams({ hours, waterbody, fish, sort, limit: String(activityLimit), offset: String(offset) });
|
||||
try {
|
||||
const paginated = await api<PaginatedActivity>(`/api/v1/activity?${query}`);
|
||||
items = offset > 0 ? [...items, ...paginated.items] : paginated.items;
|
||||
items = paginated.items;
|
||||
totalItems = paginated.total;
|
||||
if (totalItems > 0 && offset >= totalItems) {
|
||||
offset = Math.floor((totalItems - 1) / activityLimit) * activityLimit;
|
||||
query.set("offset", String(offset));
|
||||
items = (await api<PaginatedActivity>(`/api/v1/activity?${query}`)).items;
|
||||
}
|
||||
} catch { activityUnavailable = true; }
|
||||
}
|
||||
const catalogUnavailable = fishCatalogUnavailable || waterCatalogUnavailable;
|
||||
@@ -86,9 +93,10 @@ const datasetJsonLd = {
|
||||
<button data-action="primary">⌕ Найти клёв</button>
|
||||
</form></section>
|
||||
<div class="active-filters content-grid" aria-label="Применённые фильтры"><span>{selectedWaterbody}</span><span>{selectedFish}</span><span>{periodLabel}</span><span>{sortLabel}</span>{filtersChanged && <a href="/#results">Сбросить</a>}</div>
|
||||
<section class="dashboard content-grid" id="results"><div class="results-column"><div class="section-heading"><div><span class="overline">За выбранный период</span><h2>Горячие точки</h2></div><span class="result-count">{items.length} из {totalItems} {plural(totalItems, ["точка", "точки", "точек"])}</span></div>{filterError ? <div class="state error-state"><h2>Некорректные фильтры</h2><p>Выберите период и сортировку из предложенных значений.</p><a data-action="secondary" href="/">Сбросить фильтры</a></div> : activityUnavailable ? <StatePanel contained={false} tone="unavailable" title="Горячие точки временно недоступны" description="Полевые сигналы и справочники продолжают работать независимо." /> : items.length ? <><div class="spot-list">{items.map(item => <ActivityCard item={item} />)}</div>{offset + items.length < totalItems && <a class="load-more" data-action="secondary" href={`/?${(() => { const p = new URLSearchParams(params); p.delete("offset"); p.set("offset", String(offset + items.length)); return p.toString(); })()}#results`}>Показать ещё <span>{offset + items.length} из {totalItems}</span> ↓</a>}</> : <div class="state"><h2>Пока нет свежих данных</h2><p>Для выбранных фильтров нет одобренных наблюдений. Расширьте период или выберите другой водоём.</p></div>}</div>
|
||||
<section class="dashboard content-grid" id="results"><div class="results-column"><div class="section-heading"><div><span class="overline">За выбранный период</span><h2>Горячие точки</h2></div><span class="result-count">{items.length} на странице · всего {totalItems}</span></div>{filterError ? <div class="state error-state"><h2>Некорректные фильтры</h2><p>Выберите период и сортировку из предложенных значений.</p><a data-action="secondary" href="/">Сбросить фильтры</a></div> : activityUnavailable ? <StatePanel contained={false} tone="unavailable" title="Горячие точки временно недоступны" description="Полевые сигналы и справочники продолжают работать независимо." /> : items.length ? <div class="spot-list">{items.map(item => <ActivityCard item={item} />)}</div> : <div class="state"><h2>Пока нет свежих данных</h2><p>Для выбранных фильтров нет одобренных наблюдений. Расширьте период или выберите другой водоём.</p></div>}</div>
|
||||
{items[0] && leaderLevel && <aside class="detail-card"><div class="detail-head"><div><span class="overline">Лидер активности</span><h2>{items[0].waterbody} <em>{items[0].x}:{items[0].y}</em></h2></div><a href={`/spots/${items[0].spot_id}`} aria-label="Открыть точку"><FishingIcon name="arrow"/></a></div><div class="source-strip">{items[0].sources.map(source => <SourceBadge source={source}/>)}</div><div class="detail-score"><div class:list={["float-gauge", `meter-level-${leaderMeter}`]} aria-label={`Индекс активности: ${items[0].activity_score} из 100`}><span class="float-gauge__line"></span><span class="float-gauge__water"></span><span class="float-gauge__bob"><i></i></span><strong>{items[0].activity_score}</strong><small>из 100</small></div><div><span>Индекс активности</span><strong data-activity-level={leaderLevel.short}>{leaderLevel.description}</strong><p>{items[0].explanation}</p></div></div><div class="metric-grid"><div><span><FishingIcon name="ripple"/></span><small>Уверенность</small><strong>{items[0].confidence_score}%</strong></div><div><span><FishingIcon name="angler"/></span><small>{plural(items[0].unique_players, ["Игрок", "Игрока", "Игроков"])}</small><strong>{items[0].unique_players}</strong></div><div><span><FishingIcon name="clock"/></span><small>Последний</small><strong>{ago(items[0].last_confirmed_at)}</strong></div><div><span><FishingIcon name="scale"/></span><small>Средний вес</small><strong>{kg(items[0].average_weight_g)}</strong></div></div><div class="best-lure"><span class="overline">Лучшая связка</span><div><TackleGlyph name={items[0].best_bait}/><strong>{items[0].best_bait ?? "Не указана"}</strong><span>{items[0].catches} {plural(items[0].catches, ["улов", "улова", "уловов"])}</span></div></div><p class="confidence-note"><span>✓</span><span><strong>Оценка объяснима.</strong> Один игрок не может искусственно поднять уверенность.</span></p></aside>}
|
||||
</section>
|
||||
{!filterError && !activityUnavailable && <Pagination path="/" params={params} total={totalItems} limit={activityLimit} offset={offset} anchor="#results" itemLabel="точек" />}
|
||||
{signals.length > 0 && <SignalFeed signals={signals}/>}
|
||||
{signalsUnavailable && <div class="content-grid"><StatePanel tone="unavailable" title="Полевые сигналы временно недоступны" description="Горячие точки и справочники продолжают работать независимо." /></div>}
|
||||
{hasMoreSignals && signalLimit < 48 && <a class="signal-more" data-action="secondary" href={moreSignalsHref}>Показать ещё <span>{signalLimit} из доступных</span> ↓</a>}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
import EntityMedia from "../components/EntityMedia.astro";
|
||||
import PageHero from "../components/PageHero.astro";
|
||||
import StatePanel from "../components/StatePanel.astro";
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import { api, type MediaAsset } from "../lib/api";
|
||||
const allowed = new Set(["fish", "waterbody", "tackle", "reference"]);
|
||||
const requested = Astro.url.searchParams.get("type") ?? "fish";
|
||||
const type = allowed.has(requested) ? requested : "fish";
|
||||
let assets: MediaAsset[] = [], unavailable = false;
|
||||
try { assets = await api<MediaAsset[]>(`/api/v1/media/catalog?entity_type=${type}`); } catch { unavailable = true; }
|
||||
const labels: Record<string,string> = {fish:"Рыбы",waterbody:"Водоёмы",tackle:"Снасти и приманки",reference:"Справочные материалы"};
|
||||
---
|
||||
<Layout title={`${labels[type]} RF4 — медиатека RF4 Spotter`} description="Изображения рыб, водоёмов и снастей Russian Fishing 4 с обязательной атрибуцией каждого источника.">
|
||||
<PageHero eyebrow="Визуальный справочник" title="Медиатека" description="Собранные материалы RF4 с источником у каждого изображения. Каталог пополняется по мере импорта и проверки." variant="fish" count={assets.length} />
|
||||
<section class="media-library content-grid">
|
||||
<nav class="media-library__intro" aria-label="Разделы медиатеки"><strong>{labels[type]} · {assets.length}</strong><span><a href="/media?type=fish">Рыбы</a> · <a href="/media?type=waterbody">Водоёмы</a> · <a href="/media?type=tackle">Снасти</a> · <a href="/media?type=reference">Справка</a></span></nav>
|
||||
{unavailable ? <StatePanel tone="unavailable" title="Медиатека временно недоступна" /> : assets.length ? <div class="media-library__grid">{assets.map(asset => <article class="media-library__card"><EntityMedia asset={asset} sourceLink /><h2>{asset.label ?? "Без подписи"}</h2><span>{labels[asset.entity_type]}</span></article>)}</div> : <StatePanel title="В этом разделе пока нет изображений" description="Материалы появятся после следующего импорта." />}
|
||||
</section>
|
||||
</Layout>
|
||||
@@ -4,19 +4,26 @@ import SourceBadge from "../components/SourceBadge.astro";
|
||||
import SectionHeading from "../components/SectionHeading.astro";
|
||||
import StatePanel from "../components/StatePanel.astro";
|
||||
import TackleGlyph from "../components/TackleGlyph.astro";
|
||||
import Pagination from "../components/Pagination.astro";
|
||||
import { api, kg, type DictionaryItem, type ImportRun, type OfficialRecord, type PaginatedOfficialRecord } from "../lib/api";
|
||||
const params = Astro.url.searchParams;
|
||||
const fish = params.get("fish") ?? "";
|
||||
const waterbody = params.get("waterbody") ?? "";
|
||||
const recordsLimit = 50;
|
||||
const requestedOffset = Number(params.get("offset") ?? 0);
|
||||
const offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0;
|
||||
let offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0;
|
||||
let items: OfficialRecord[] = [], runs: ImportRun[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [];
|
||||
let unavailable = false, showNoIndex = false, totalRecords = 0;
|
||||
try {
|
||||
const query = new URLSearchParams({ fish, waterbody, limit: "50", offset: String(offset) });
|
||||
const query = new URLSearchParams({ fish, waterbody, limit: String(recordsLimit), offset: String(offset) });
|
||||
const paginated = await api<PaginatedOfficialRecord>(`/api/v1/records?${query}`);
|
||||
items = offset > 0 ? [...items, ...paginated.items] : paginated.items;
|
||||
items = paginated.items;
|
||||
totalRecords = paginated.total;
|
||||
if (totalRecords > 0 && offset >= totalRecords) {
|
||||
offset = Math.floor((totalRecords - 1) / recordsLimit) * recordsLimit;
|
||||
query.set("offset", String(offset));
|
||||
items = (await api<PaginatedOfficialRecord>(`/api/v1/records?${query}`)).items;
|
||||
}
|
||||
[runs, fishes, waterbodies] = await Promise.all([api<ImportRun[]>("/api/v1/imports?limit=1"), api<DictionaryItem[]>("/api/v1/fishes"), api<DictionaryItem[]>("/api/v1/waterbodies")]);
|
||||
} catch { unavailable = true; showNoIndex = true; Astro.response.status = 503; Astro.response.headers.set("Retry-After", "60"); }
|
||||
const last = runs[0];
|
||||
@@ -24,10 +31,10 @@ const last = runs[0];
|
||||
<Layout title="Официальные рекорды Russian Fishing 4 — RF4 Spotter" description="Последние официальные рекорды RF4 по рыбам и водоёмам: вес, приманка, игрок, дата и прямая ссылка на источник." noindex={showNoIndex} errorPage={unavailable}>
|
||||
<section class="records-hero"><div><span class="eyebrow">Публичные данные RF4</span><h1>Официальные<br/><em>рекорды</em></h1></div><div class="source-status"><span class:list={["status-dot", last?.status]}></span><strong>{last ? `Импорт: ${last.status}` : "Импорт ещё не запускался"}</strong>{last?.finished_at && <small>{new Date(last.finished_at).toLocaleString("ru-RU")} · {last.rows_seen} строк</small>}</div></section>
|
||||
<form class="record-filters" method="get"><label>Рыба<select name="fish"><option value="">Любая рыба</option>{fishes.map(item => <option value={item.slug} selected={fish === item.slug}>{item.name_ru}</option>)}</select></label><label>Водоём<select name="waterbody"><option value="">Все водоёмы</option>{waterbodies.map(item => <option value={item.slug} selected={waterbody === item.slug}>{item.name_ru}</option>)}</select></label><button data-action="primary">Фильтровать</button>{(fish || waterbody) && <a data-action="quiet" href="/records">Сбросить</a>}</form>
|
||||
<div class="content-grid"><SectionHeading eyebrow="Официальный источник" title="Последние записи" count={`${items.length} из ${totalRecords} записей`} /></div>
|
||||
<div class="content-grid"><SectionHeading eyebrow="Официальный источник" title="Последние записи" count={`${items.length} на странице · всего ${totalRecords}`} /></div>
|
||||
{unavailable && <StatePanel tone="unavailable" title="Источник временно недоступен" description="Сохраняем ранее импортированные данные, но не показываем их как свежее обновление." />}
|
||||
{!unavailable && items.length && <div class="record-table"><div class="record-row record-head"><span>Рыба</span><span>Вес</span><span>Водоём</span><span>Приманка</span><span>Игрок</span><span>Дата и источник</span></div>{items.map(record => <article class="record-row"><strong data-label="Рыба">{record.fish}</strong><strong data-label="Вес">{kg(record.weight_g)}</strong><span data-label="Водоём">{record.waterbody}</span><span class="tackle-label" data-label="Приманка"><TackleGlyph name={record.bait} size={24}/><span>{record.bait ?? "—"}</span></span><span data-label="Игрок">{record.player_name ?? "—"}</span><span data-label="Дата и источник" class="record-provenance"><time>{record.record_date ? new Date(record.record_date).toLocaleDateString("ru-RU") : "—"}</time><SourceBadge source={record.source_system} href={record.source_url}/></span></article>)}</div>}
|
||||
{!unavailable && items.length && offset + items.length < totalRecords && <a class="load-more" data-action="secondary" href={`/records?${(() => { const p = new URLSearchParams(params); p.delete("offset"); p.set("offset", String(offset + items.length)); return p.toString(); })()}`}>Показать ещё <span>{offset + items.length} из {totalRecords}</span> ↓</a>}
|
||||
{!unavailable && <Pagination path="/records" params={params} total={totalRecords} limit={recordsLimit} offset={offset} itemLabel="записей" />}
|
||||
{!unavailable && !items.length && <StatePanel title="Рекорды ещё не импортированы" description="Для выбранных условий записей пока нет." actionHref="/records" actionLabel="Сбросить фильтры" />}
|
||||
<p class="official-note">Источник: <a href="https://rf4game.de/records/region/RU/" rel="noreferrer">официальный сайт Russian Fishing 4</a>. Координаты в официальных таблицах отсутствуют.</p>
|
||||
</Layout>
|
||||
|
||||
@@ -9,7 +9,7 @@ export const GET: APIRoute = async ({ site }) => {
|
||||
const output = (xml: string) => new Response(xml, { headers: { "Content-Type": "application/xml; charset=utf-8", "Cache-Control": "public, max-age=1800" } });
|
||||
if (lastGood?.origin === origin && Date.now() - lastGood.at < 1800000) return output(lastGood.xml);
|
||||
try {
|
||||
const paths = new Set(["/", "/records", "/report", "/status", "/rules", "/privacy", "/fish", "/waterbodies"]);
|
||||
const paths = new Set(["/", "/records", "/report", "/status", "/rules", "/privacy", "/fish", "/waterbodies", "/media"]);
|
||||
for (const [endpoint, prefix] of [["fishes", "fish"], ["waterbodies", "waterbodies"]]) {
|
||||
for (let offset = 0; ; offset += 500) {
|
||||
const rows = await api<DictionaryItem[]>(`/api/v1/${endpoint}?limit=500&offset=${offset}`);
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
---
|
||||
import ActivityCard from "../../components/ActivityCard.astro";
|
||||
import EntityMedia from "../../components/EntityMedia.astro";
|
||||
import AtlasBreadcrumbs from "../../components/AtlasBreadcrumbs.astro";
|
||||
import AtlasEntityLink from "../../components/AtlasEntityLink.astro";
|
||||
import PageHero from "../../components/PageHero.astro";
|
||||
import StatePanel from "../../components/StatePanel.astro";
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import { api, plural, type Activity, type DictionaryItem, type PaginatedActivity } from "../../lib/api";
|
||||
import { api, plural, type Activity, type DictionaryItem, type MediaAsset, type PaginatedActivity } from "../../lib/api";
|
||||
import { findMediaByLabel } from "../../lib/media";
|
||||
const { slug } = Astro.params;
|
||||
let water: DictionaryItem | undefined, items: Activity[] = [], unavailable = false;
|
||||
let water: DictionaryItem | undefined, items: Activity[] = [], media: MediaAsset[] = [], unavailable = false;
|
||||
try {
|
||||
const waters = await api<DictionaryItem[]>("/api/v1/waterbodies?limit=500");
|
||||
const [waters, waterMedia] = await Promise.all([api<DictionaryItem[]>("/api/v1/waterbodies?limit=500"), api<MediaAsset[]>("/api/v1/media/catalog?entity_type=waterbody")]);
|
||||
media = waterMedia;
|
||||
water = waters.find(item => item.slug === slug);
|
||||
if (water) { const paginated = await api<PaginatedActivity>(`/api/v1/activity?hours=72&waterbody=${encodeURIComponent(water.slug)}&limit=100`); items = paginated.items; }
|
||||
} catch { unavailable = true; }
|
||||
@@ -20,10 +23,13 @@ if (unavailable) {
|
||||
}
|
||||
if (!water && !unavailable) Astro.response.status = 404;
|
||||
const fishes = [...new Map(items.map(item => [item.fish_slug, item.fish])).entries()];
|
||||
const image = water ? findMediaByLabel(media, water.name_ru) : undefined;
|
||||
const schema = water ? { "@context":"https://schema.org", "@type":"CollectionPage", name:`Что ловить на ${water.name_ru} в RF4`, url:`https://rf4spotter.ru/waterbodies/${water.slug}` } : null;
|
||||
---
|
||||
<Layout title={water ? `${water.name_ru} в RF4 — рыба и свежие точки` : "Водоём не найден — RF4 Spotter"} description={water ? `${water.name_ru} в Russian Fishing 4: свежие координаты, активные виды рыб, приманки и источники наблюдений.` : "Такого водоёма нет в каталоге RF4 Spotter."} noindex={!water || unavailable} structuredData={schema} errorPage={!water || unavailable}>
|
||||
<AtlasBreadcrumbs items={[{ label: "Водоёмы", href: "/waterbodies" }, { label: water?.name_ru ?? "Не найдено" }]} />
|
||||
<PageHero eyebrow="Свежие данные за 72 часа" title={water?.name_ru ?? "Водоём не найден"} description={water ? `${items.length} ${plural(items.length,["активная точка","активные точки","активных точек"])} для ${fishes.length} ${plural(fishes.length,["вида рыбы","видов рыб","видов рыб"])}.` : undefined} variant={water ? "water" : undefined} identity={water?.slug} />
|
||||
{image && <section class="entity-feature content-grid" aria-label={`Изображение: ${water!.name_ru}`}><EntityMedia asset={image} sourceLink /><div><span class="overline">Карта и образ водоёма</span><h2>{water!.name_ru}</h2><p>Материал показан с прямой атрибуцией. Координаты активных точек ниже относятся к данным наблюдений, а не к геометрии изображения.</p></div></section>}
|
||||
{water && (water.description || water.unlock_level !== undefined || water.source_url || water.source_fish_species) && <section class="data-passport content-grid" aria-label="Паспорт водоёма"><div><span class="overline">Канонические сведения</span><h2>{water.name_ru}</h2>{water.description && <p>{water.description}</p>}{water.unlock_level !== undefined && water.unlock_level !== null && <p>Открывается с уровня: <strong>{water.unlock_level}</strong></p>}{water.source_fish_species?.length && <p>В карточке источника указано видов рыб: <strong>{water.source_fish_species.length}</strong>.</p>}{water.source_aliases?.length && <p>Алиасы источника: {water.source_aliases.join(", ")}.</p>}</div><div><span class="overline">Источник</span>{water.source_url ? <p><a href={water.source_url} rel="noreferrer">{water.source_system ?? "Внешний источник"} · исходная карточка</a></p> : <p>Источник для описания ещё не подтверждён.</p>}{water.source_point_urls?.length && <p>Сохранено ссылок на точки: <strong>{water.source_point_urls.length}</strong>.</p>}{water.source_image_urls?.length && <p>Изображений-кандидатов: <strong>{water.source_image_urls.length}</strong>; публикация требует отдельной проверки.</p>}{water.source_checked_at && <p>Проверено: {new Date(water.source_checked_at).toLocaleDateString("ru-RU")}</p>}</div></section>}
|
||||
{unavailable ? <StatePanel tone="unavailable" title="Данные временно недоступны" description="Каталог сохранён, но свежие наблюдения сейчас не получены." /> : !water ? <StatePanel tone="error" title="Такого водоёма нет в справочнике" actionHref="/waterbodies" actionLabel="Открыть каталог" /> : <section class="catalog-results content-grid"><aside><span class="overline">Рыбы</span>{fishes.length ? <nav>{fishes.map(([fishSlug,name]) => <AtlasEntityLink href={`/waterbodies/${water!.slug}/${fishSlug}`} label={name} kind="fish" identity={name} />)}</nav> : <p>Свежих подтверждённых видов пока нет.</p>}</aside><div>{items.length ? items.map(item => <ActivityCard item={item}/>) : <StatePanel contained={false} title="Свежих точек пока нет" description="Проверьте позже или посмотрите полевые сигналы на главной." />}</div></section>}
|
||||
</Layout>
|
||||
|
||||
@@ -3,9 +3,18 @@ import Layout from "../../layouts/Layout.astro";
|
||||
import PageHero from "../../components/PageHero.astro";
|
||||
import StatePanel from "../../components/StatePanel.astro";
|
||||
import WaterbodyMark from "../../components/WaterbodyMark.astro";
|
||||
import Pagination from "../../components/Pagination.astro";
|
||||
import { api, type DictionaryItem } from "../../lib/api";
|
||||
let waters: DictionaryItem[] = [], unavailable = false;
|
||||
try { waters = await api<DictionaryItem[]>("/api/v1/waterbodies?limit=500"); } catch { unavailable = true; }
|
||||
const params = Astro.url.searchParams;
|
||||
const pageLimit = 48;
|
||||
const requestedOffset = Number(params.get("offset") ?? 0);
|
||||
let offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0;
|
||||
let waters: DictionaryItem[] = [], allWaters: DictionaryItem[] = [], unavailable = false;
|
||||
try {
|
||||
allWaters = await api<DictionaryItem[]>("/api/v1/waterbodies?limit=500");
|
||||
if (allWaters.length > 0 && offset >= allWaters.length) offset = Math.floor((allWaters.length - 1) / pageLimit) * pageLimit;
|
||||
waters = allWaters.slice(offset, offset + pageLimit);
|
||||
} catch { unavailable = true; }
|
||||
if (unavailable) {
|
||||
Astro.response.status = 503;
|
||||
Astro.response.headers.set("Retry-After", "60");
|
||||
@@ -13,6 +22,7 @@ if (unavailable) {
|
||||
}
|
||||
---
|
||||
<Layout title="Все водоёмы Russian Fishing 4 — RF4 Spotter" description="Каталог водоёмов RF4 со свежими точками, рыбами, приманками и прозрачными источниками данных.">
|
||||
<PageHero eyebrow="Карта водоёмов RF4" title="Водоёмы" description="Откройте водоём, чтобы увидеть активные виды рыб и последние подтверждённые точки." variant="water" count={waters.length} />
|
||||
{unavailable ? <StatePanel tone="unavailable" title="Каталог временно недоступен" description="Не показываем непроверенный список. Попробуйте обновить страницу позже." /> : <nav class="catalog-grid catalog-grid--waters content-grid" aria-label="Водоёмы">{waters.map((water,index) => <a href={`/waterbodies/${water.slug}`}><span>Водоём · {String(index + 1).padStart(2,"0")}</span><strong>{water.name_ru}</strong><i>Открыть <b>→</b></i><WaterbodyMark identity={water.slug}/></a>)}</nav>}
|
||||
<PageHero eyebrow="Карта водоёмов RF4" title="Водоёмы" description="Откройте водоём, чтобы увидеть активные виды рыб и последние подтверждённые точки." variant="water" count={allWaters.length} />
|
||||
{unavailable ? <StatePanel tone="unavailable" title="Каталог временно недоступен" description="Не показываем непроверенный список. Попробуйте обновить страницу позже." /> : <nav class="catalog-grid catalog-grid--waters content-grid" aria-label="Водоёмы">{waters.map((water,index) => <a href={`/waterbodies/${water.slug}`}><span>Водоём · {String(offset + index + 1).padStart(2,"0")}</span><strong>{water.name_ru}</strong><i>Открыть <b>→</b></i><WaterbodyMark identity={water.slug}/></a>)}</nav>}
|
||||
{!unavailable && <Pagination path="/waterbodies" params={params} total={allWaters.length} limit={pageLimit} offset={offset} itemLabel="водоёмов" />}
|
||||
</Layout>
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
.activity-timeline{width:min(1180px,calc(100% - 64px));margin:20px auto 0;padding:25px 28px;border-radius:14px;background:#fff;border:1px solid #d9e1da}.activity-timeline header{display:flex;align-items:end;justify-content:space-between;gap:20px}.activity-timeline h2{font:400 29px Georgia,serif;margin:5px 0 0}.activity-timeline header p{margin:0;color:#71817e;font-size:10px}
|
||||
.timeline-chart{height:145px;display:grid;grid-template-columns:repeat(6,1fr);gap:8px;margin-top:18px;padding-top:8px;border-bottom:1px solid #a9bbb1;background:repeating-linear-gradient(to top,transparent 0,transparent 35px,#dce5de 36px)}.timeline-slot{position:relative;display:grid;grid-template-rows:1fr auto;place-items:center}.timeline-line{position:relative;align-self:end;width:2px;height:var(--meter-level);min-height:10px;background:#3f7779}.timeline-line i{position:absolute;left:0;top:0;width:12px;height:18px;margin:-4px 0 0 -5px;border-radius:7px 7px 9px 9px;background:linear-gradient(#ff785a 0 35%,var(--lime) 35%);box-shadow:0 0 0 4px #c9f45b18}.timeline-slot strong{position:absolute;top:2px;font:400 14px Georgia,serif;color:#365c5e}.timeline-slot small{position:absolute;bottom:-20px;color:#71817e;font-size:8px;white-space:nowrap}.activity-timeline+*{margin-top:42px}
|
||||
.activity-timeline{width:min(1180px,calc(100% - 64px));margin:20px auto 0;padding:25px 28px;border-radius:14px;background:var(--surface);border:1px solid var(--border-soft)}.activity-timeline header{display:flex;align-items:end;justify-content:space-between;gap:20px}.activity-timeline h2{font:400 29px Georgia,serif;margin:5px 0 0}.activity-timeline header p{margin:0;color:var(--text-muted);font-size:10px}
|
||||
.timeline-chart{height:145px;display:grid;grid-template-columns:repeat(6,1fr);gap:8px;margin-top:18px;padding-top:8px;border-bottom:1px solid var(--border);background:repeating-linear-gradient(to top,transparent 0,transparent 35px,var(--graphic-grid) 36px)}.timeline-slot{position:relative;display:grid;grid-template-rows:1fr auto;place-items:center}.timeline-line{position:relative;align-self:end;width:2px;height:var(--meter-level);min-height:10px;background:var(--graphic-line)}.timeline-line i{position:absolute;left:0;top:0;width:12px;height:18px;margin:-4px 0 0 -5px;border-radius:7px 7px 9px 9px;background:linear-gradient(#ff785a 0 35%,var(--lime) 35%);box-shadow:0 0 0 4px color-mix(in srgb,var(--lime) 12%,transparent)}.timeline-slot strong{position:absolute;top:2px;font:400 14px Georgia,serif;color:var(--text)}.timeline-slot small{position:absolute;bottom:-20px;color:var(--text-muted);font-size:8px;white-space:nowrap}.activity-timeline+*{margin-top:42px}
|
||||
@media(max-width:720px){.activity-timeline{width:calc(100% - 28px);padding:20px 15px}.activity-timeline header{display:block}.activity-timeline header p{margin-top:6px}.timeline-slot small{font-size:7px}}
|
||||
|
||||
@@ -1 +1 @@
|
||||
.coordinate-radar{position:relative;flex:0 0 180px;width:180px;height:180px;display:grid;place-items:center;color:var(--lime)}.coordinate-radar svg{position:absolute;inset:0;width:100%;height:100%;fill:none;stroke:#ffffff20;stroke-width:1}.coordinate-radar .radar-sweep{fill:#c9f45b0d;stroke:#c9f45b38}.coordinate-radar .radar-mark{fill:var(--lime);stroke:none}.coordinate-radar .radar-pulse{stroke:var(--lime);stroke-width:2;opacity:.55}.coordinate-radar strong{position:relative;display:grid;grid-template-columns:auto auto 1px auto auto;align-items:baseline;gap:4px;padding:7px 10px;border-radius:7px;background:#082226d9;font:400 20px Georgia,serif}.coordinate-radar strong small{font:700 8px Inter,sans-serif;color:#9eb0ad}.coordinate-radar strong i{width:1px;height:14px;background:#ffffff35}.radar-north{position:absolute;top:3px;font-size:8px;letter-spacing:.15em}.radar-caption{position:absolute;bottom:1px;color:#91a5a1;font-size:8px;text-transform:uppercase;letter-spacing:.12em}@media(max-width:720px){.coordinate-radar{display:none}}
|
||||
.coordinate-radar{position:relative;flex:0 0 180px;width:180px;height:180px;display:grid;place-items:center;color:var(--lime)}.coordinate-radar svg{position:absolute;inset:0;width:100%;height:100%;fill:none;stroke:color-mix(in srgb,var(--text-on-dark) 13%,transparent);stroke-width:1}.coordinate-radar .radar-sweep{fill:color-mix(in srgb,var(--lime) 5%,transparent);stroke:color-mix(in srgb,var(--lime) 22%,transparent)}.coordinate-radar .radar-mark{fill:var(--lime);stroke:none}.coordinate-radar .radar-pulse{stroke:var(--lime);stroke-width:2;opacity:.55}.coordinate-radar strong{position:relative;display:grid;grid-template-columns:auto auto 1px auto auto;align-items:baseline;gap:4px;padding:7px 10px;border-radius:7px;background:color-mix(in srgb,var(--deep) 85%,transparent);font:400 20px Georgia,serif}.coordinate-radar strong small{font:700 8px Inter,sans-serif;color:color-mix(in srgb,var(--text-on-dark) 65%,transparent)}.coordinate-radar strong i{width:1px;height:14px;background:color-mix(in srgb,var(--text-on-dark) 22%,transparent)}.radar-north{position:absolute;top:3px;font-size:8px;letter-spacing:.15em}.radar-caption{position:absolute;bottom:1px;color:color-mix(in srgb,var(--text-on-dark) 60%,transparent);font-size:8px;text-transform:uppercase;letter-spacing:.12em}@media(max-width:720px){.coordinate-radar{display:none}}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.data-passport{margin-top:18px;padding:14px;border:1px solid var(--border);border-radius:13px;background:var(--surface-soft);color:var(--deep)}
|
||||
.data-passport header{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:11px}.data-passport header>span{font:italic 15px Georgia,serif}.data-passport header strong{padding:4px 8px;border-radius:999px;background:var(--success-soft);color:var(--success);font-size:9px;text-transform:uppercase;letter-spacing:.06em}.data-passport header strong[data-passport-status="unverified"]{background:#e8e2f4;color:#59438d}.data-passport header strong[data-passport-status="incomplete"]{background:var(--warning-soft);color:var(--warning)}
|
||||
.data-passport header{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:11px}.data-passport header>span{font:italic 15px Georgia,serif}.data-passport header strong{padding:4px 8px;border-radius:999px;background:var(--success-soft);color:var(--success);font-size:9px;text-transform:uppercase;letter-spacing:.06em}.data-passport header strong[data-passport-status="unverified"]{background:var(--info-soft);color:var(--info)}.data-passport header strong[data-passport-status="incomplete"]{background:var(--warning-soft);color:var(--warning)}
|
||||
.data-passport__sources{display:flex;flex-wrap:wrap;gap:5px;margin-bottom:12px}.data-passport dl{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:0}.data-passport dl div{padding-top:8px;border-top:1px solid var(--border-soft)}.data-passport dt{font-size:8px;text-transform:uppercase;letter-spacing:.08em;color:var(--text-subtle)}.data-passport dd{margin:3px 0 0;font:400 12px Georgia,serif;color:var(--deep)}
|
||||
.detail-card .data-passport,.detail-grid aside .data-passport{border-color:#ffffff20;background:#ffffff08;color:#fff}.detail-card .data-passport dd,.detail-grid aside .data-passport dd{color:#fff}.detail-card .data-passport dl div,.detail-grid aside .data-passport dl div{border-color:#ffffff1c}.detail-card .data-passport dt,.detail-grid aside .data-passport dt{color:#a9b8b5}.signal-card .data-passport{position:relative;z-index:1;margin-top:15px;background:#fffdf7;border-color:#ddd3af}
|
||||
@media(max-width:420px){.data-passport dl{grid-template-columns:1fr}.data-passport dl div{display:flex;justify-content:space-between;gap:10px}}
|
||||
|
||||
@@ -1 +1 @@
|
||||
.fish-silhouette{fill:currentColor;color:#315f63;pointer-events:none}.fish-silhouette .fish-eye{fill:var(--paper)}.fish-silhouette .fish-detail{fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round}.spot-main{position:relative}.spot-main>.fish-silhouette{position:absolute;right:2px;top:38px;opacity:.075}.catalog-grid>a{position:relative;overflow:hidden}.catalog-grid>a>.fish-silhouette{position:absolute;right:20px;bottom:12px;opacity:.12;transform:rotate(-4deg);transition:opacity var(--motion-base) var(--ease-out),transform var(--motion-base) var(--ease-out)}.catalog-grid>a:hover>.fish-silhouette{opacity:.2;transform:rotate(-2deg) translateX(-2px)}@media(max-width:720px){.spot-main>.fish-silhouette{width:68px;right:0;opacity:.055}}
|
||||
.fish-silhouette{fill:currentColor;color:var(--decorative-water);pointer-events:none}.fish-silhouette .fish-eye{fill:var(--paper)}.fish-silhouette .fish-detail{fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round}.spot-main{position:relative}.spot-main>.fish-silhouette{position:absolute;right:2px;top:38px;opacity:.075}.catalog-grid>a{position:relative;overflow:hidden}.catalog-grid>a>.fish-silhouette{position:absolute;right:20px;bottom:12px;opacity:.12;transform:rotate(-4deg);transition:opacity var(--motion-base) var(--ease-out),transform var(--motion-base) var(--ease-out)}.catalog-grid>a:hover>.fish-silhouette{opacity:.2;transform:rotate(-2deg) translateX(-2px)}@media(max-width:720px){.spot-main>.fish-silhouette{width:68px;right:0;opacity:.055}}
|
||||
|
||||
@@ -59,6 +59,9 @@ footer{min-height:118px;background:var(--deep);color:#dbe4df;padding:28px max(32
|
||||
/* One semantic treatment for empty, unavailable and invalid public states. */
|
||||
.state.unavailable-state{border-style:solid;border-color:color-mix(in srgb,var(--warning) 35%,var(--border));background:color-mix(in srgb,var(--warning-soft) 35%,var(--surface))}.state.error-state{background:color-mix(in srgb,var(--danger-soft) 28%,var(--surface))}.state>a{margin-top:8px;font-weight:750;text-underline-offset:4px}
|
||||
|
||||
/* Admin navigation keeps all operational surfaces reachable by keyboard. */
|
||||
.admin-nav{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 18px}.admin-nav a{padding:9px 13px;border:1px solid var(--line);border-radius:999px;color:var(--text-muted);font-size:12px;text-decoration:none}.admin-nav a:hover,.admin-nav a.active{border-color:var(--focus);color:var(--deep);background:var(--lime)}
|
||||
|
||||
/* Action hierarchy stays semantic across public and admin surfaces. */
|
||||
[data-action]{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:42px;padding:0 18px;border:1px solid transparent;border-radius:10px;font-weight:750;text-decoration:none;transition:background-color var(--motion-fast) var(--ease-out),border-color var(--motion-fast) var(--ease-out),color var(--motion-fast) var(--ease-out),transform var(--motion-fast) var(--ease-out)}[data-action]:hover:not(:disabled){transform:translateY(-1px)}[data-action]:active:not(:disabled){transform:translateY(0)}[data-action="primary"]{background:var(--lime);color:var(--deep)}[data-action="secondary"]{border-color:var(--border);background:var(--surface-soft);color:var(--text-muted)}[data-action="inverse"]{border-color:#ffffff38;background:var(--deep);color:var(--white)}[data-action="danger"]{background:var(--danger-soft);color:var(--danger)}[data-action="quiet-danger"]{border-color:color-mix(in srgb,var(--danger) 35%,transparent);background:transparent;color:var(--danger)}[data-action="quiet"]{background:transparent;color:#d5dfdc;text-decoration:underline;text-underline-offset:4px}[data-action]:disabled{opacity:.55;cursor:not-allowed}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
.loading-grid{display:grid;gap:18px}.loading-card{position:relative;overflow:hidden;min-height:210px;padding:25px;border:1px solid var(--border-soft);border-radius:16px;background:var(--surface)}.loading-card:after{content:"";position:absolute;inset:0;transform:translateX(-100%);background:linear-gradient(100deg,transparent 20%,var(--surface-soft) 48%,transparent 76%);animation:loading-sweep calc(var(--motion-signal) - 1000ms) ease-in-out infinite}.loading-line{display:block;width:100%;height:13px;margin-top:18px;border-radius:999px;background:#e7ede7}.loading-line--label{width:22%;height:9px;margin-top:0;background:#d8e2d8}.loading-line--title{width:48%;height:30px;margin-top:24px}.loading-line--short{width:64%}.moderation-list[aria-busy="true"]{cursor:progress}.sr-only{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}@keyframes loading-sweep{to{transform:translateX(100%)}}@media(prefers-reduced-motion:reduce){.loading-card:after{animation:none;background:#f7faf533}}
|
||||
.loading-grid{display:grid;gap:18px}.loading-card{position:relative;overflow:hidden;min-height:210px;padding:25px;border:1px solid var(--border-soft);border-radius:16px;background:var(--surface)}.loading-card:after{content:"";position:absolute;inset:0;transform:translateX(-100%);background:linear-gradient(100deg,transparent 20%,var(--surface-soft) 48%,transparent 76%);animation:loading-sweep calc(var(--motion-signal) - 1000ms) ease-in-out infinite}.loading-line{display:block;width:100%;height:13px;margin-top:18px;border-radius:999px;background:var(--skeleton)}.loading-line--label{width:22%;height:9px;margin-top:0;background:var(--skeleton-strong)}.loading-line--title{width:48%;height:30px;margin-top:24px}.loading-line--short{width:64%}.moderation-list[aria-busy="true"]{cursor:progress}.sr-only{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}@keyframes loading-sweep{to{transform:translateX(100%)}}@media(prefers-reduced-motion:reduce){.loading-card:after{animation:none;background:color-mix(in srgb,var(--surface-soft) 35%,transparent)}}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
.entity-media{margin:0;min-width:0}.entity-media__frame{position:relative;display:grid;place-items:center;overflow:hidden;min-height:190px;padding:18px;border:1px solid var(--border-soft);border-radius:16px;background:radial-gradient(circle at 50% 46%,color-mix(in srgb,var(--lime) 15%,var(--surface)) 0 18%,var(--surface-soft) 62%)}.entity-media__frame:after{content:"";position:absolute;inset:12px;border:1px solid color-mix(in srgb,var(--border) 55%,transparent);border-radius:11px;pointer-events:none}.entity-media img{position:relative;z-index:1;display:block;width:100%;height:180px;object-fit:contain;filter:drop-shadow(0 12px 18px #08222624);image-rendering:auto}.entity-media figcaption{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:9px;color:var(--text-muted);font-size:11px}.entity-media figcaption>span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.entity-media--compact{grid-column:2;grid-row:1/4;width:118px}.entity-media--compact .entity-media__frame{min-height:88px;height:88px;padding:10px;border:0;background:color-mix(in srgb,var(--lime) 8%,var(--surface-soft))}.entity-media--compact img{height:72px}.entity-media--compact figcaption{justify-content:flex-end}.entity-media--compact figcaption>span:last-child{display:none}.entity-media--compact .source-chip{transform:scale(.9);transform-origin:right center}.media-library{padding:34px 0 100px}.media-library__intro{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-bottom:24px;color:var(--text-muted)}.media-library__grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px}.media-library__card{padding:12px;border:1px solid var(--border-soft);border-radius:18px;background:var(--surface)}.media-library__card .entity-media__frame{min-height:170px}.media-library__card h2{margin:12px 3px 3px;font:400 19px Georgia,serif}.media-library__card>span{margin-left:3px;color:var(--text-subtle);font-size:10px;text-transform:uppercase;letter-spacing:.09em}@media(max-width:1050px){.media-library__grid{grid-template-columns:repeat(3,1fr)}}@media(max-width:720px){.media-library__grid{grid-template-columns:repeat(2,1fr)}.entity-media--compact{width:90px}.entity-media--compact .entity-media__frame{height:76px}.media-library__intro{display:block}}@media(max-width:440px){.media-library__grid{grid-template-columns:1fr}}
|
||||
.entity-feature{display:grid;grid-template-columns:minmax(280px,420px) 1fr;align-items:center;gap:48px;padding-block:36px;border-bottom:1px solid var(--border)}.entity-feature .entity-media__frame{min-height:260px}.entity-feature .entity-media img{height:230px}.entity-feature h2{margin:10px 0 12px;font:400 clamp(34px,4vw,58px)/.95 Georgia,serif}.entity-feature p{max-width:620px;color:var(--text-muted);line-height:1.6}@media(max-width:720px){.entity-feature{grid-template-columns:1fr;gap:24px;padding-block:26px}}
|
||||
@@ -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}}
|
||||
@@ -1 +1 @@
|
||||
.tackle-glyph{--tackle-accent:#dd8b42;flex:none;color:var(--tackle-accent);stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}.tackle-glyph[data-tone="1"]{--tackle-accent:#759d35}.tackle-glyph[data-tone="2"]{--tackle-accent:#3f8588}.tackle-glyph[data-tone="3"]{--tackle-accent:#9a6a9d}.tackle-glyph[data-tone="4"]{--tackle-accent:#bd6250}.tackle-glyph__fill{fill:color-mix(in srgb,currentColor 78%,var(--surface));stroke:currentColor}.tackle-glyph__stroke{stroke-width:3}.tackle-label{display:inline-flex;align-items:center;gap:8px;min-width:0}.tackle-label>span{min-width:0}.best-lure .tackle-glyph{color:var(--lime)}.bait-line>.tackle-glyph{width:25px;height:25px}.catch-list .tackle-label{margin-top:3px;color:var(--text-muted);font-size:12px}.detail-grid aside li .tackle-label{width:100%}
|
||||
.tackle-glyph{--tackle-accent:var(--tackle-orange);flex:none;color:var(--tackle-accent);stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}.tackle-glyph[data-tone="1"]{--tackle-accent:var(--tackle-green)}.tackle-glyph[data-tone="2"]{--tackle-accent:var(--tackle-teal)}.tackle-glyph[data-tone="3"]{--tackle-accent:var(--tackle-violet)}.tackle-glyph[data-tone="4"]{--tackle-accent:var(--tackle-red)}.tackle-glyph__fill{fill:color-mix(in srgb,currentColor 78%,var(--surface));stroke:currentColor}.tackle-glyph__stroke{stroke-width:3}.tackle-label{display:inline-flex;align-items:center;gap:8px;min-width:0}.tackle-label>span{min-width:0}.best-lure .tackle-glyph{color:var(--lime)}.bait-line>.tackle-glyph{width:25px;height:25px}.catch-list .tackle-label{margin-top:3px;color:var(--text-muted);font-size:12px}.detail-grid aside li .tackle-label{width:100%}
|
||||
|
||||
@@ -29,6 +29,29 @@
|
||||
--warning-soft: light-dark(#fff0c7, #46391d);
|
||||
--danger: light-dark(#842f25, #ffafa3);
|
||||
--danger-soft: light-dark(#f5d8d2, #4b2928);
|
||||
--info: light-dark(#59438d, #c9baf2);
|
||||
--info-soft: light-dark(#e8e2f4, #302844);
|
||||
--accent-muted: light-dark(#6d8d2a, #c0e562);
|
||||
--decorative-water: light-dark(#315f63, #72a9ad);
|
||||
--skeleton: light-dark(#e7ede7, #1c3537);
|
||||
--skeleton-strong: light-dark(#d8e2d8, #294548);
|
||||
--status-healthy: light-dark(#5f8c26, #a8d85e);
|
||||
--status-warning: light-dark(#9a741d, #e8c768);
|
||||
--status-changed: light-dark(#a44236, #ff9587);
|
||||
--source-official: light-dark(#815713, #e3b65b);
|
||||
--source-rf4db: light-dark(#286581, #74bee3);
|
||||
--source-rf4stat: light-dark(#60499a, #b7a2ed);
|
||||
--source-rf4map: light-dark(#2f704f, #79c79e);
|
||||
--source-rf4posts: light-dark(#984a33, #ee9a7e);
|
||||
--source-players: light-dark(#526b1c, #add066);
|
||||
--graphic-line: light-dark(#3f7779, #79b5b8);
|
||||
--graphic-grid: light-dark(#dce5de, #294244);
|
||||
--tackle-orange: light-dark(#b96b27, #f0a55f);
|
||||
--tackle-green: light-dark(#648b2b, #a8cf62);
|
||||
--tackle-teal: light-dark(#34777a, #78bec1);
|
||||
--tackle-violet: light-dark(#845787, #c795ca);
|
||||
--tackle-red: light-dark(#a95041, #ed8e7c);
|
||||
--hero-image-filter: light-dark(saturate(.92) contrast(1.02), brightness(.78) saturate(.82) contrast(1.08));
|
||||
}
|
||||
|
||||
:root[data-theme="light"] { color-scheme: light; }
|
||||
@@ -63,7 +86,7 @@
|
||||
.alpha-banner strong { color: var(--text-primary); }
|
||||
.alpha-banner__cta { background: light-dark(var(--deep), var(--lime)); color: light-dark(#fff, #082226); }
|
||||
|
||||
.spot-card:hover { background: var(--surface-raised); border-color: #6f8988; box-shadow: 0 18px 40px var(--shadow-color); }
|
||||
.spot-card:hover { background: var(--surface-raised); border-color: var(--border-strong); box-shadow: 0 18px 40px var(--shadow-color); }
|
||||
.report-form input, .report-form textarea, .report-form select,
|
||||
.moderation-actions textarea { background: var(--surface-control); color: var(--text-primary); border-color: var(--border-strong); }
|
||||
.record-head, .no-proof { background: var(--surface-raised); }
|
||||
@@ -78,7 +101,13 @@
|
||||
.signal-card dt { color: light-dark(#776f56, #b9ae82); }
|
||||
.signal-card .missing-note { background: light-dark(#efe5bd, #453c20); color: light-dark(#6b550f, #f0d889); }
|
||||
.signal-card .data-passport { background: light-dark(#fffdf7, #172628); border-color: light-dark(#ddd3af, #59605a); }
|
||||
.source-chip { background: color-mix(in srgb, var(--chip) 24%, var(--surface-elevated)); }
|
||||
.source-chip { background: color-mix(in srgb, var(--chip) 24%, var(--surface-elevated)); box-shadow: 0 5px 14px var(--shadow-color); }
|
||||
.source-chip[data-source="rf4-official"] { --chip: var(--source-official); }
|
||||
.source-chip[data-source="rf4db"] { --chip: var(--source-rf4db); }
|
||||
.source-chip[data-source^="rf4stat"] { --chip: var(--source-rf4stat); }
|
||||
.source-chip[data-source="rf4map"] { --chip: var(--source-rf4map); }
|
||||
.source-chip[data-source="rf4posts-spot"] { --chip: var(--source-rf4posts); }
|
||||
.source-chip[data-source="players"] { --chip: var(--source-players); }
|
||||
.notice.success { background: var(--success-soft); color: var(--success); }
|
||||
.notice.warning, .data-quality { background: var(--warning-soft); color: var(--warning); }
|
||||
.notice.error { background: var(--danger-soft); color: var(--danger); }
|
||||
@@ -98,6 +127,67 @@
|
||||
.timeline-chart { border-color: var(--border-strong); background: repeating-linear-gradient(to top, transparent 0, transparent 35px, var(--border-subtle) 36px); }
|
||||
.timeline-slot strong { color: var(--text-primary); }
|
||||
.timeline-slot small, .activity-legend { color: var(--text-tertiary); }
|
||||
|
||||
.catalog-hero { border-color: var(--border-strong); }
|
||||
.catalog-hero p, .catalog-results aside a, .catalog-grid span { color: var(--text-secondary); }
|
||||
.catalog-results aside { background: var(--surface-elevated); border-color: var(--border-subtle); }
|
||||
.catalog-results aside a { border-color: var(--border-subtle); }
|
||||
.catalog-grid > a { background: linear-gradient(145deg,var(--surface-elevated),var(--surface-raised)); border-color: var(--border-subtle); }
|
||||
.catalog-grid > a:hover { border-color: var(--border-strong); box-shadow: 0 14px 30px var(--shadow-color); }
|
||||
.catalog-grid i, .catalog-grid > a > i b { color: var(--accent-muted); }
|
||||
.waterbody-mark, .fish-silhouette { color: var(--decorative-water); stroke: currentColor; }
|
||||
.signal-more, .load-more { color: var(--text-secondary); background: var(--surface-elevated); border-color: var(--border-strong); box-shadow: 0 8px 22px var(--shadow-color); }
|
||||
.signal-more span, .load-more span { color: var(--text-secondary); }
|
||||
.signal-more:hover, .load-more:hover { background: var(--surface-raised); border-color: var(--border-strong); }
|
||||
.state { color: var(--text-secondary); border-color: var(--border-strong); }
|
||||
.state::before { border-color: var(--border-strong); }
|
||||
.state::after { border-color: var(--decorative-water); box-shadow: 0 -7px 0 -6px var(--decorative-water),0 7px 0 -6px var(--decorative-water); }
|
||||
.data-passport header strong[data-passport-status="unverified"] { background: var(--info-soft); color: var(--info); }
|
||||
|
||||
.record-table, .report-form, .moderation-card { background: var(--surface-elevated); border-color: var(--border-subtle); box-shadow: 0 30px 80px var(--shadow-color); }
|
||||
.record-row, .optional-fields, .moderation-summary dl div, .moderation-actions { border-color: var(--border-subtle); }
|
||||
.record-head, .no-proof { background: var(--surface-raised); }
|
||||
.record-head span, .record-row > span, .record-row > time, .report-form label,
|
||||
.field-help, .privacy, .moderation-summary > p, .moderation-summary dt,
|
||||
.moderation-actions label { color: var(--text-secondary); }
|
||||
.report-form input, .report-form textarea, .report-form select,
|
||||
.moderation-actions textarea { background: var(--surface-control); color: var(--text-primary); border-color: var(--border-strong); }
|
||||
.report-form input:focus, .report-form textarea:focus, .report-form select:focus { border-color: var(--focus); box-shadow: 0 0 0 3px color-mix(in srgb,var(--focus) 28%,transparent); }
|
||||
.report-form input:user-invalid, .report-form select:user-invalid { border-color: var(--danger); }
|
||||
.moderation-summary blockquote { background: var(--surface-raised); color: var(--text-secondary); }
|
||||
|
||||
.data-legend { border-color: var(--border-strong); }
|
||||
.data-legend__intro p, .legend-sources > div > span, .legend-quality span { color: var(--text-secondary); }
|
||||
.legend-sources > div { border-color: var(--border-subtle); }
|
||||
.legend-quality > div { background: var(--surface-elevated); }
|
||||
.legend-quality i { background: var(--success-soft); color: var(--success); }
|
||||
.legend-quality i[data-quality="unverified"] { background: var(--info-soft); color: var(--info); }
|
||||
.legend-quality i[data-quality="incomplete"] { background: var(--warning-soft); color: var(--warning); }
|
||||
.health-state i { background: var(--status-warning); }
|
||||
.source-health-grid [data-status="healthy"] .health-state i { background: var(--status-healthy); }
|
||||
.source-health-grid [data-status="source_changed"] .health-state i { background: var(--status-changed); }
|
||||
.activity-timeline header p { color: var(--text-secondary); }
|
||||
.timeline-line { background: var(--graphic-line); }
|
||||
.timeline-line i { box-shadow: 0 0 0 4px color-mix(in srgb,var(--lime) 12%,transparent); }
|
||||
.coordinate-radar svg { stroke: color-mix(in srgb,var(--text-on-dark) 13%,transparent); }
|
||||
.coordinate-radar .radar-sweep { fill: color-mix(in srgb,var(--lime) 5%,transparent); stroke: color-mix(in srgb,var(--lime) 22%,transparent); }
|
||||
.admin-dashboard-grid > section, .admin-kpis > a, .admin-kpis > article,
|
||||
.source-health-grid article { background: var(--surface-elevated); border-color: var(--border-subtle); }
|
||||
.admin-history li small, .source-health-grid p, .health-state { color: var(--text-secondary); }
|
||||
.lake-card { background: var(--surface-raised); box-shadow: 0 24px 70px var(--shadow-color); }
|
||||
.lake-card img { filter: var(--hero-image-filter); }
|
||||
.moderation-proof { border-radius: 11px; background: var(--surface-raised); }
|
||||
.moderation-proof img { background: var(--surface-raised); }
|
||||
@media print {
|
||||
:root { color-scheme: light; }
|
||||
.lake-card img { filter: none; }
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
.theme-switcher, .theme-switcher button, .spot-card, .record-table,
|
||||
.report-form, .moderation-card, .catalog-grid > a, .data-passport,
|
||||
.source-chip, .notice, .state { border: 1px solid CanvasText; }
|
||||
.theme-switcher button[aria-pressed="true"] { background: Highlight; color: HighlightText; }
|
||||
.source-chip i, .health-state i, .quality-chip i, .activity-pill i { forced-color-adjust: none; }
|
||||
.fish-silhouette, .waterbody-mark, .tackle-glyph, .coordinate-radar { color: CanvasText; }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
for (const path of ["/", "/records", "/report", "/rules", "/privacy"]) {
|
||||
for (const path of [
|
||||
"/", "/records", "/report", "/rules", "/privacy",
|
||||
"/admin", "/admin/moderation", "/admin/external-sources", "/admin/media",
|
||||
]) {
|
||||
test(`${path} has no serious accessibility violations`, async ({ page }) => {
|
||||
await page.goto(path);
|
||||
const results = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21aa"]).analyze();
|
||||
|
||||
@@ -23,3 +23,20 @@ test("production bootstrap supports submission and moderation", async ({ page, r
|
||||
expect(response.ok()).toBeTruthy();
|
||||
expect(await response.text()).toContain(player);
|
||||
});
|
||||
|
||||
test("SSR pages use a per-response CSP nonce for JSON-LD", async ({ page, request }) => {
|
||||
for (const path of ["/", "/report", "/admin/"]) {
|
||||
const response = await page.goto(path);
|
||||
const policy = response?.headers()["content-security-policy"] ?? "";
|
||||
expect(policy).toContain("script-src 'self' 'nonce-");
|
||||
expect(policy).not.toContain("'unsafe-inline'");
|
||||
expect(policy).toContain("style-src-attr 'none'");
|
||||
const nonce = await page.locator('script[type="application/ld+json"]').evaluate((node: HTMLScriptElement) => node.nonce);
|
||||
expect(nonce).toMatch(/^[a-f0-9]{32}$/);
|
||||
expect(policy).toContain(`'nonce-${nonce}'`);
|
||||
}
|
||||
|
||||
const image = await request.get("/og-rf4spotter.png");
|
||||
expect(image.ok()).toBeTruthy();
|
||||
expect(image.headers()["content-type"]).toContain("image/png");
|
||||
});
|
||||
|
||||
@@ -76,6 +76,7 @@ for (const viewport of [{ name: "desktop", width: 1280, height: 900 }, { name: "
|
||||
const path = new URL(request.url()).pathname;
|
||||
if (path.endsWith("/fishes")) return route.fulfill({ json: [{ slug: "pike", name_ru: "Щука" }] });
|
||||
if (path.endsWith("/waterbodies")) return route.fulfill({ json: [{ slug: "kuori", name_ru: "Куори" }] });
|
||||
if (path.endsWith("/source-status")) return route.fulfill({ json: [{ source_system: "rf4db", name: "RF4DB", status: "healthy", last_started_at: null, last_success_at: null, observations: 1 }] });
|
||||
if (path.endsWith("/admin/external-observations")) return route.fulfill({ json: [{ id: "10000000-0000-0000-0000-000000000001", source_system: "rf4db", source_external_id: "fixture-1", source_url: "https://rf4db.com/fixture-1", fish_name: "Pike", waterbody_name: "Kuori", x: 72, y: 84, weight_g: null, status: "staged", fish_slug: null, waterbody_slug: null, review_note: null }] });
|
||||
return route.fulfill({ status: 404 });
|
||||
});
|
||||
@@ -90,6 +91,28 @@ for (const viewport of [{ name: "desktop", width: 1280, height: 900 }, { name: "
|
||||
const dimensions = await page.evaluate(() => ({ width: document.documentElement.clientWidth, scroll: document.documentElement.scrollWidth }));
|
||||
expect(dimensions.scroll).toBeLessThanOrEqual(dimensions.width);
|
||||
});
|
||||
|
||||
test(`admin media review is read-only on ${viewport.name}`, async ({ page }) => {
|
||||
await page.setViewportSize(viewport);
|
||||
let mutations = 0;
|
||||
await page.route("**/api/v1/**", async route => {
|
||||
const request = route.request();
|
||||
if (request.method() !== "GET") mutations += 1;
|
||||
const path = new URL(request.url()).pathname;
|
||||
if (path.endsWith("/admin/media/catalog")) return route.fulfill({ json: [{ id: "a".repeat(64), status: "upgrade_stored", entity_type: "fish", entity_key: "fish:pike", label: "Щука", width: 1024, height: 1024, content_type: "image/webp", image_url: "/api/v1/admin/media/assets/" + "a".repeat(64), source_system: "rf4db", source_url: "https://rf4db.com/fish/pike", derivatives: [{ role: "card", format: "webp", width: 256, height: 256 }] }] });
|
||||
return route.fulfill({ status: 404 });
|
||||
});
|
||||
await page.goto("/admin/media");
|
||||
await page.getByLabel("Административный токен").fill("test-token-not-sent");
|
||||
await page.getByRole("button", { name: "Открыть медиатеку" }).click();
|
||||
await expect(page.locator(".media-library__card")).toContainText("Щука");
|
||||
await expect(page.locator(".media-library__card")).toContainText("upgrade_stored");
|
||||
await expect(page.getByRole("navigation", { name: "Разделы админ-панели" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Медиатека" })).toHaveAttribute("aria-current", "page");
|
||||
expect(mutations).toBe(0);
|
||||
const dimensions = await page.evaluate(() => ({ width: document.documentElement.clientWidth, scroll: document.documentElement.scrollWidth }));
|
||||
expect(dimensions.scroll).toBeLessThanOrEqual(dimensions.width);
|
||||
});
|
||||
}
|
||||
|
||||
test("public pages fit the narrow viewport and expose the skip link", async ({ page }) => {
|
||||
|
||||
@@ -5,6 +5,8 @@ import { activityLevel, plural } from "../../src/lib/presentation.ts";
|
||||
import { fishVisualFamily } from "../../src/lib/fish-visuals.ts";
|
||||
import { tackleVisualKind, tackleVisualTone } from "../../src/lib/tackle-visuals.ts";
|
||||
import { waterbodyVisual } from "../../src/lib/waterbody-visuals.ts";
|
||||
import { pageHref, pageWindow } from "../../src/lib/pagination.ts";
|
||||
import { adminEndsSession, adminErrorMessage } from "../../src/lib/admin-errors.ts";
|
||||
|
||||
test("activity levels share one complete 0-100 scale", () => {
|
||||
assert.deepEqual(
|
||||
@@ -46,3 +48,31 @@ test("waterbody fingerprints are stable, bounded and entity-specific", () => {
|
||||
assert.ok(kuori.markerY >= 18 && kuori.markerY <= 40);
|
||||
assert.match(kuori.code, /^[0-9A-Z]{2}$/);
|
||||
});
|
||||
|
||||
test("45 records are reachable across three stable pages", () => {
|
||||
const pages = [pageWindow(45, 20, 0), pageWindow(45, 20, 20), pageWindow(45, 20, 40)];
|
||||
assert.deepEqual(pages.map(page => [page.start, page.end]), [[1, 20], [21, 40], [41, 45]]);
|
||||
assert.deepEqual(pages.map(page => [page.previousOffset, page.nextOffset]), [[null, 20], [0, 40], [20, null]]);
|
||||
assert.deepEqual(pages.map(page => page.page), [1, 2, 3]);
|
||||
const reached = pages.flatMap(page => Array.from({ length: page.end - page.start + 1 }, (_, index) => page.start + index));
|
||||
assert.deepEqual(reached, Array.from({ length: 45 }, (_, index) => index + 1));
|
||||
});
|
||||
|
||||
test("pagination links preserve filters and replace offset", () => {
|
||||
const params = new URLSearchParams("fish=pike&waterbody=kuori&offset=20&offset=999");
|
||||
const next = pageHref("/records", params, 40);
|
||||
const first = pageHref("/records", params, 0, "#results");
|
||||
assert.equal(next, "/records?fish=pike&waterbody=kuori&offset=40");
|
||||
assert.equal(first, "/records?fish=pike&waterbody=kuori#results");
|
||||
});
|
||||
|
||||
test("admin errors keep auth and operational responses consistent", () => {
|
||||
assert.equal(adminErrorMessage(401, "fallback"), "Неверный или истёкший административный токен.");
|
||||
assert.equal(adminErrorMessage(409, "fallback"), "Операция конфликтует с изменением в другой вкладке.");
|
||||
assert.equal(adminErrorMessage(429, "fallback"), "Слишком много попыток. Повторите позже.");
|
||||
assert.equal(adminErrorMessage(503, "fallback"), "Сервис временно недоступен. Проверьте состояние и повторите позже.");
|
||||
assert.equal(adminErrorMessage(422, "fallback"), "fallback");
|
||||
assert.equal(adminEndsSession(401), true);
|
||||
assert.equal(adminEndsSession(429), true);
|
||||
assert.equal(adminEndsSession(409), false);
|
||||
});
|
||||
|
||||
@@ -182,6 +182,8 @@ services:
|
||||
environment:
|
||||
PUBLIC_API_URL: https://${SITE_DOMAIN:?Set SITE_DOMAIN}
|
||||
API_INTERNAL_URL: http://api:8000
|
||||
FILES_DOMAIN: ${FILES_DOMAIN:?Set FILES_DOMAIN}
|
||||
FILES_ORIGIN: https://${FILES_DOMAIN:?Set FILES_DOMAIN}
|
||||
depends_on:
|
||||
api: {condition: service_healthy}
|
||||
healthcheck:
|
||||
|
||||
@@ -30,6 +30,23 @@ services:
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
|
||||
minio-init:
|
||||
image: minio/mc:RELEASE.2025-07-21T05-28-08Z
|
||||
restart: "no"
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command:
|
||||
- >-
|
||||
mc alias set local http://minio:9000 "$$S3_ACCESS_KEY" "$$S3_SECRET_KEY" >/dev/null &&
|
||||
mc mb --ignore-existing "local/$$S3_BUCKET" >/dev/null &&
|
||||
mc stat "local/$$S3_BUCKET" >/dev/null
|
||||
environment:
|
||||
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-rf4-local}
|
||||
S3_SECRET_KEY: ${S3_SECRET_KEY:-rf4-local-secret}
|
||||
S3_BUCKET: ${S3_BUCKET:-catch-screenshots}
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
@@ -55,6 +72,8 @@ services:
|
||||
condition: service_completed_successfully
|
||||
minio:
|
||||
condition: service_healthy
|
||||
minio-init:
|
||||
condition: service_completed_successfully
|
||||
ports:
|
||||
- "8000:8000"
|
||||
healthcheck:
|
||||
@@ -80,6 +99,7 @@ services:
|
||||
environment:
|
||||
PUBLIC_API_URL: http://localhost:8000
|
||||
API_INTERNAL_URL: http://api:8000
|
||||
FILES_ORIGIN: http://localhost:9000
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
|
||||
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 22 KiB |