Compare commits

..
68 Commits
Author SHA1 Message Date
ik 3f0a02a9b9 feat: save local fishing plans
CI / backend-and-migrations (push) Waiting to run
CI / astro-build (push) Waiting to run
CI / dependency-audit (push) Waiting to run
CI / compose-e2e (push) Waiting to run
2026-09-20 20:05:50 +07:00
ik e31fbe996d feat: standardize catalog evidence 2026-09-20 20:03:34 +07:00
ik 3a8d3565a1 feat: extend evidence passport to spots 2026-09-20 19:58:39 +07:00
ik 7efdb1a16a feat: clarify home query context 2026-09-20 19:55:33 +07:00
ik d1a5ad1b22 feat: add activity evidence card
CI / backend-and-migrations (push) Waiting to run
CI / astro-build (push) Waiting to run
CI / dependency-audit (push) Waiting to run
CI / compose-e2e (push) Waiting to run
2026-09-20 19:50:19 +07:00
ik 0ac9830c84 docs: add competitive ux roadmap 2026-09-20 19:46:44 +07:00
ik 4bcf301289 test: add media acceptance coverage 2026-09-20 19:42:46 +07:00
ik 72ae157ec3 fix: improve domain accessibility contrast
CI / backend-and-migrations (push) Waiting to run
CI / astro-build (push) Waiting to run
CI / dependency-audit (push) Waiting to run
CI / compose-e2e (push) Waiting to run
2026-09-20 19:25:18 +07:00
ik 25514d9377 test: cover waterbody error states 2026-09-20 19:18:51 +07:00
ik f02cb247a3 feat: add offline waterbody source crosswalk 2026-09-20 19:16:46 +07:00
ik d541443a1a test: cover tackle browser acceptance 2026-09-20 18:42:33 +07:00
ik 46251c635f test: add tackle query acceptance gate
CI / backend-and-migrations (push) Waiting to run
CI / astro-build (push) Waiting to run
CI / dependency-audit (push) Waiting to run
CI / compose-e2e (push) Waiting to run
2026-09-20 18:36:37 +07:00
ik a547d09fcd feat: extend media roles and acceptance coverage
CI / backend-and-migrations (push) Waiting to run
CI / astro-build (push) Waiting to run
CI / dependency-audit (push) Waiting to run
CI / compose-e2e (push) Waiting to run
2026-09-20 18:30:16 +07:00
ik e94d247096 feat: add tackle catalog and recommendation analytics 2026-09-20 18:26:53 +07:00
ik 4f0c2d23de feat: preserve gear components through catch imports
CI / backend-and-migrations (push) Waiting to run
CI / astro-build (push) Waiting to run
CI / dependency-audit (push) Waiting to run
CI / compose-e2e (push) Waiting to run
2026-09-20 18:10:41 +07:00
ik 4e9895fbf4 feat: add gear provenance models and browser fetcher 2026-09-20 15:50:43 +07:00
ik b0edae98c6 feat: continue roadmap acceptance work 2026-09-20 15:17:21 +07:00
ik d438c40542 sync
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-17 07:35:23 +07:00
ik 722c88d436 sync
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-17 07:35:04 +07:00
ik 5221442aeb docs: close admin media review milestone
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-16 20:29:10 +07:00
ik 85d81aa996 feat: add admin media upgrade decisions 2026-09-16 20:28:45 +07:00
ik 53e7b0e4ae docs: refresh media catalog snapshot 2026-09-16 20:26:30 +07:00
ik d63eedf41b test: gate admin cache headers in bootstrap 2026-09-16 20:23:37 +07:00
ik 28fffb3acb docs: record media visual review 2026-09-16 20:22:32 +07:00
ik 4303b145b0 docs: close verified media roadmap items
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-16 20:20:55 +07:00
ik 137aa806c0 test: cover admin media proxy routes
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-16 20:19:35 +07:00
ik e012b84969 feat: add safe media upgrade rollback 2026-09-16 20:16:54 +07:00
ik 727a87b73b data: accept verified media upgrades
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-16 20:12:38 +07:00
ik d3ee8ebbd7 test: extend admin accessibility coverage 2026-09-16 20:11:18 +07:00
ik c216229c61 fix: type admin API contracts
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-16 20:09:41 +07:00
ik efd5f7b172 test: cover admin media review smoke 2026-09-16 20:02:47 +07:00
ik c14ae0250e fix: avoid nested admin main landmarks 2026-09-16 20:00:59 +07:00
ik 2e34fb20b5 feat: show source freshness in admin
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-16 20:00:30 +07:00
ik 74ff49062f feat: show admin import results 2026-09-16 20:00:01 +07:00
ik 4c7051bc97 feat: add admin workspace navigation 2026-09-16 19:59:15 +07:00
ik d9f58fb90e fix: harden admin page cache headers 2026-09-16 19:57:05 +07:00
ik eb4d6ccdb0 fix: unify admin error handling 2026-09-16 19:54:18 +07:00
ik 9bcb019d3a feat: extend admin operations and media review 2026-09-16 19:50:01 +07:00
ik 4c75db1f74 feat: parse RF4DB waterbody catalog
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-15 17:19:15 +07:00
ik 75820125aa data: publish collected RF4 assets 2026-09-15 17:14:26 +07:00
ik 6c67e5042f data: store fifth RF4 fish quality batch 2026-09-15 17:09:29 +07:00
ik a624066aab data: store fourth RF4 fish quality batch 2026-09-15 15:15:57 +07:00
ik f6ff400344 data: store third RF4 fish quality batch 2026-09-15 14:08:34 +07:00
ik 5be5964098 data: complete second RF4 fish quality batch 2026-09-15 13:10:03 +07:00
ik 399afe5ea5 data: store second RF4 fish quality batch 2026-09-15 13:08:42 +07:00
ik a8c0da837a Publish first RF4 fish quality upgrades
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-15 11:45:54 +07:00
ik 7d009c932f chore: audit RF4 media quality
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-14 22:25:45 +07:00
ik b47a6cc366 feat: publish approved RF4 media catalog
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-14 22:20:46 +07:00
ik 6146ea9eb0 data: store RF4DB fish gap batch 2026-09-14 21:45:55 +07:00
ik 61c7ac7d51 feat: reconcile RF4DB media catalog
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-14 07:45:29 +07:00
ik 0eca44c11a data: complete media download queue
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-14 07:39:21 +07:00
ik 79daea4ae1 data: version media assets in git
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-14 07:22:00 +07:00
ik 7d94fbadb5 data: store penultimate media queue batch 2026-09-14 06:58:54 +07:00
ik 22485efe10 data: store penultimate RF4MAP media batch 2026-09-14 06:28:10 +07:00
ik 6c9bbd7fd7 data: store next tackle media batch 2026-09-14 05:57:53 +07:00
ik 3682f3ad61 data: store mixed RF4MAP media batch 2026-09-14 05:27:09 +07:00
ik 2dd5f97143 data: store next RF4MAP media batch 2026-09-14 04:56:56 +07:00
ik 064e731d25 data: store next fish icon batch 2026-09-14 04:26:35 +07:00
ik e08bfaffba data: store next verified media batch 2026-09-14 03:56:56 +07:00
ik 1d27fbde30 data: store expanded media batch 2026-09-14 03:27:17 +07:00
ik 0152593815 data: store next media batch
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-13 17:31:20 +07:00
ik 8207d3ae02 feat: download media in bounded domain batches 2026-09-13 16:58:45 +07:00
ik 787a5065bc data: review next prioritized media assets
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-13 16:53:19 +07:00
ik f243807fc8 feat: plan media queue without network access 2026-09-13 16:50:27 +07:00
ik 73feb75767 perf: add reproducible query plan gate 2026-09-13 16:46:41 +07:00
ik abe51b38d0 docs: reconcile roadmap with current project state 2026-09-13 16:40:47 +07:00
ik 86ed3a966a security: enforce nonce based content policy 2026-09-13 16:38:47 +07:00
ik 1305cccfa5 feat: check source links during scheduled fetches 2026-09-13 16:32:10 +07:00
2388 changed files with 40176 additions and 1025 deletions
@@ -0,0 +1,51 @@
# План работы RF4 Spotter — Регрессионный аудит
На основе: [REGRESSION_AUDIT_2026-09-09.md](../docs/REGRESSION_AUDIT_2026-09-09.md)
Дата: 2026-09-09
## Выполнено
### R01 ✅ — Caddy body_limit → request_body max_size
- **Файл**: `deploy/Caddyfile`
- **Проблема**: `body_limit 10M` не поддерживается в Caddy 2.10.2
- **Решение**: `request_body { max_size 10M }`
- **Верификация**: `caddy adapt` проходит без ошибок
### R02 ✅ — Activity API contract regression
- **API**: `main.py``/api/v1/activity` возвращает `PaginatedActivityOut`
- **Frontend**: все 4 потребителя обновлены:
- `index.astro` ✅ (из предыдущего коммита)
- `fish/[slug].astro`
- `waterbodies/[slug].astro`
- `waterbodies/[slug]/[fish].astro`
- `spots/[id].astro`
- **Тесты**: 4 теста обновлены под новый контракт
- **Результат**: 76 passed, 1 skipped, 0 failures
### R14 ✅ — Registry источников больше не зависит от БД при парсинге CLI
- **Файл**: `community_scheduler.py`
- **Решение**: `_static_registry()` — без БД; `configured_sources(enabled_keys)` — с опциональной БД
- **Тесты**: scheduler unit-тесты проходят без PostgreSQL
## Итоги тестов
- **Python**: 76 passed, 1 skipped ✅
- **Astro check**: 0 errors, 0 warnings, 0 hints ✅
- **Caddy adapt**: passes ✅
## Коммиты
1. `a37f9c4` — R01 Caddy body_limit → request_body
2. `d962ba2` — R02 activity API contract + R14 static registry
## Осталось из регрессий (R03-R15)
- R03: Research CLI cooldown broken (fcntl `r`/`r+` + encoding)
- R04: Disabled-фильтрация обходит site cooldown
- R05: Bootstrap небезопасен для источников
- R06: Фильтры сигналов сравнивают slug с названием
- R07: Ошибка главной остаётся HTTP 200
- R08: Фильтры UI не доведены до responsive-состояния
- R09: Пагинация (частично исправлено в U03)
- R10: Ошибки формы теряют черновик
- R11: Проверка URL после сетевого обращения
- R12: Мониторинг не сигнализирует о зависшем импорте
- R13: Доверие к IP клиента не ограничено proxy
- R15: D04/D06/D07/D08 выполнены не полностью
File diff suppressed because it is too large Load Diff
-1
View File
@@ -2,7 +2,6 @@ __pycache__/
*.py[cod] *.py[cod]
.pytest_cache/ .pytest_cache/
.cache/ .cache/
data/media/files/
.env.production .env.production
.maintenance.lock .maintenance.lock
.venv/ .venv/
+5
View File
@@ -0,0 +1,5 @@
# Default ignored files
/shelf/
/workspace.xml
# Environment-dependent path to Maven home directory
/mavenHomeManager.xml
+266
View File
@@ -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 &quot;&#10;import ast&#10;import sys&#10;&#10;files = [&#10; 'apps/api/app/main.py',&#10; 'apps/api/app/readiness.py',&#10; 'apps/api/app/schemas.py',&#10; 'apps/api/app/activity.py',&#10; 'apps/api/app/community_scheduler.py',&#10; 'apps/api/app/community_importer.py',&#10; 'rf4_research/community_cli.py',&#10;]&#10;&#10;for f in files:&#10; try:&#10; with open(f) as fh:&#10; ast.parse(fh.read())&#10; print(f'OK: {f}')&#10; except SyntaxError as e:&#10; print(f'ERROR: {f}: {e}')&#10; sys.exit(1)&#10;&#10;print('All Python files syntax OK')&#10;&quot;" />
</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' &gt; /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' &gt; /tmp/Caddyfile" />
</Execute>
<Execute>
<option name="pattern" value="printf 'example.com {\n request_body {\n max_size 10M\n }\n}\n' &gt; /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 &gt; /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 \&quot;ok\&quot;\n }\n}\n' &gt; /tmp/Caddyfile" />
</Execute>
<Execute>
<option name="pattern" value="python -c &quot;import ast; ast.parse(open('rf4_research/community_cli.py').read()); print('OK')&quot;" />
</Execute>
<Execute>
<option name="pattern" value="python -c &quot;import ast; ast.parse(open('apps/api/app/community_scheduler.py').read()); print('OK')&quot;" />
</Execute>
<Execute>
<option name="pattern" value="python -c &quot;import ast; ast.parse(open('apps/api/app/main.py').read()); print('OK')&quot;" />
</Execute>
<Execute>
<option name="pattern" value="node -e &quot;try { fetch('http://localhost:1', { signal: AbortSignal.timeout(100) }); } catch(e) { console.log(e.constructor.name, e.message); }&quot; 2&gt;&amp;1" />
</Execute>
<Execute>
<option name="pattern" value="node -e &quot;fetch('http://httpbin.org/delay/10', { signal: AbortSignal.timeout(200) }).catch(e =&gt; console.log(e.constructor.name, e.message));&quot; 2&gt;&amp;1" />
</Execute>
<Execute>
<option name="pattern" value="pip-compile requirements.txt --output-file requirements-lock.txt -q 2&gt;&amp;1" />
</Execute>
<Execute>
<option name="pattern" value="pip-compile requirements-dev.txt --output-file requirements-dev-lock.txt -q 2&gt;&amp;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&gt;&amp;1" />
</Execute>
<Execute>
<option name="pattern" value="python -m rf4_research.community_cli --help 2&gt;&amp;1" />
</Execute>
<Execute>
<option name="pattern" value="alembic heads *" />
</Execute>
<Execute>
<option name="pattern" value="python3 -c &quot;&#10;from urllib.request import Request, urlopen&#10;from urllib.error import HTTPError&#10;# Test with a URL that redirects&#10;try:&#10; req = Request('https://httpbin.org/redirect/1', headers={'User-Agent': 'test'})&#10; resp = urlopen(req, timeout=5)&#10; print(f'Final URL: {resp.url}')&#10; print(f'Response URL: {resp.url}')&#10;except Exception as e:&#10; print(f'Error: {type(e).__name__}: {e}')&#10;&quot; 2&gt;&amp;1" />
</Execute>
<Execute>
<option name="pattern" value="pwd" />
</Execute>
<Execute>
<option name="pattern" value="python3" />
</Execute>
<Execute>
<option name="pattern" value="python3 -c &quot;from rf4_research import community_cli; print('Syntax OK')&quot;" />
</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 &quot;from app.cli import main; import sys; sys.argv = ['cli', '--help']; main()&quot; 2&gt;&amp;1" />
</Execute>
<Execute>
<option name="pattern" value="find *" />
</Execute>
<Execute>
<option name="pattern" value="git push *" />
</Execute>
<Execute>
<option name="pattern" value="python -c &quot;&#10;from sqlalchemy import create_engine, select&#10;from sqlalchemy.orm import Session&#10;from sqlalchemy.pool import StaticPool&#10;from app.database import Base&#10;from app.models import SubmissionAttempt&#10;import hashlib, hmac&#10;&#10;engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool)&#10;Base.metadata.create_all(engine)&#10;&#10;with Session(engine) as db:&#10; # Simulate first request&#10; key = 'idem-test-key-001'&#10; key_hash = hmac.new('change-rate-limit-secret'.encode(), key.encode(), hashlib.sha256).hexdigest()&#10; print(f'Key hash: {key_hash}')&#10; &#10; # Check before storing&#10; existing = db.scalar(select(SubmissionAttempt).where(SubmissionAttempt.idempotency_key == key_hash))&#10; print(f'Found before: {existing}')&#10; &#10; # Store&#10; from datetime import datetime, timezone&#10; db.add(SubmissionAttempt(client_hash='test', idempotency_key=key_hash, created_at=datetime.now(timezone.utc)))&#10; db.commit()&#10; &#10; # Check after storing&#10; existing = db.scalar(select(SubmissionAttempt).where(SubmissionAttempt.idempotency_key == key_hash))&#10; print(f'Found after: {existing}')&#10; if existing:&#10; print(f' idempotency_key: {existing.idempotency_key}')&#10;&quot;" />
</Execute>
<Execute>
<option name="pattern" value="python -c &quot;&#10;from sqlalchemy import create_engine, select&#10;from sqlalchemy.orm import Session&#10;from sqlalchemy.pool import StaticPool&#10;from app.database import Base&#10;from app.models import SubmissionAttempt&#10;import hashlib, hmac&#10;&#10;engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool)&#10;Base.metadata.create_all(engine)&#10;&#10;with Session(engine) as db:&#10; key = 'idem-test-key-001'&#10; key_hash = hmac.new('change-rate-limit-secret'.encode(), key.encode(), hashlib.sha256).hexdigest()&#10; print(f'Key hash: {key_hash}')&#10; &#10; existing = db.scalar(select(SubmissionAttempt).where(SubmissionAttempt.idempotency_key == key_hash))&#10; print(f'Found before: {existing}')&#10; &#10; from datetime import datetime, timezone&#10; db.add(SubmissionAttempt(client_hash='test', idempotency_key=key_hash, created_at=datetime.now(timezone.utc)))&#10; db.commit()&#10; &#10; existing = db.scalar(select(SubmissionAttempt).where(SubmissionAttempt.idempotency_key == key_hash))&#10; print(f'Found after: {existing}')&#10; if existing:&#10; print(f' idempotency_key: {existing.idempotency_key}')&#10;&quot;" />
</Execute>
<Execute>
<option name="pattern" value="python -c &quot;&#10;from sqlalchemy import create_engine, inspect, text&#10;from sqlalchemy.pool import StaticPool&#10;from app.database import Base&#10;from app.models import SubmissionAttempt&#10;&#10;engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool)&#10;Base.metadata.create_all(engine)&#10;&#10;inspector = inspect(engine)&#10;columns = inspector.get_columns('submission_attempt')&#10;print('Columns:', [c['name'] for c in columns])&#10;&quot;" />
</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>
+6
View File
@@ -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>
+8
View File
@@ -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>
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+65
View File
@@ -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 manifests 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 repositorys 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.
+18 -8
View File
@@ -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` и браузерный сценарий отправки/модерации. Реальные источники во время приёмки не опрашивались. **Проверка 20 сентября 2026: локальный кодовый контур проходит сборочные и тестовые gates, внешний запуск ждёт сервер и его настройки.** Пакет восстановления A01–A13 закрыт. Python: **191 passed, 1 skipped**; Astro check/build и web unit проходят. Граф миграций имеет единственную голову `0020`; OpenAPI artifact синхронизирован с FastAPI (`36 paths`). Изолированный production bootstrap с чистыми томами повторно пройден; живая БД, реальные секреты и импорт внешних источников по-прежнему требуют отдельной инфраструктурной/разрешённой приёмки.
Актуальные следующие задачи находятся только в [ROADMAP](docs/ROADMAP.md). Старые планы и аудиты сохранены как история и больше не задают порядок работ. До внешнего запуска нужны сервер, DNS/TLS, production-секреты, публичные контакты, внешний backup и канал уведомлений. Актуальные следующие задачи находятся только в [ROADMAP](docs/ROADMAP.md). Старые планы и аудиты сохранены как история и больше не задают порядок работ. До внешнего запуска нужны сервер, DNS/TLS, production-секреты, публичные контакты, внешний backup и канал уведомлений.
@@ -42,16 +42,26 @@ RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изо
Публичные точки используют постоянные читаемые адреса вида `/spots/kuori-85x92`; старые UUID-адреса остаются совместимыми и перенаправляются на канонический URL. На странице точки координаты дополнительно показаны фирменным радаром, который не имитирует отсутствующую географию водоёма, а уловы за 72 часа — шкалой-леской с 12-часовым шагом. Каждый улов показывает источник, относительную свежесть и точное время UTC; время получения явно отделено от времени улова. Каталоги оформлены как полевой атлас: тёмный seal показывает объём справочника, карточки рыб используют смысловые SVG-силуэты, а каждый водоём — собственный детерминированный абстрактный отпечаток берега, волн, точки и индекса. На странице сочетания оба знака собираются в единую атласную эмблему, detail-иерархию связывает breadcrumb-леска с текстовыми узлами, а боковые переходы повторяют знаки связанных сущностей. Это не карта и не игровая география. Пустые состояния используют статичную CSS-иллюстрацию поплавка; смысловые анимации полностью учитывают системное ограничение движения. Публичные точки используют постоянные читаемые адреса вида `/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-срез от 20.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 не завышают покрытие. Актуальный manifest содержит 704 записи: 466 approved, 226 superseded, 1 duplicate и 11 invalid; queue-представления больше нет. Опубликованы 253 fish-файла, все 149 найденных изображений снастей/приманок и 64 справочных материала; общий target снастей остаётся `null` до проверяемого полного счётчика. Водоёмы имеют отдельный canonical index из 19 карточек; detail-наполнение остаётся W02–W08.
`python -m rf4_research.media_cli --quality-report` выполняет offline-проверку разрешения опубликованных рыб. Контур quality-upgrade обработал 226 прямых RF4DB-альтернатив: 252 из 253 рыбных изображений теперь имеют 1024×1024 WebP, один 48×48 fallback сохранён из-за отсутствия проверенной альтернативы. `--queue-quality-upgrades` по-прежнему переводит только прямые альтернативы низкоразрешённых published-файлов в безопасную очередь, не снимая текущую версию с публикации; B21/B22 завершены, а browser-приёмка остаётся отдельным пунктом B25.
`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. Для измерений на собственном сервере подготовлен 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 MinIO root credentials доступны только одноразовому init-контейнеру. API использует отдельного пользователя с доступом исключительно к `S3_BUCKET`: просмотр bucket, чтение, запись и удаление его объектов без глобального списка bucket и без права создавать новые.
Production release отделяет Alembic от runtime: одноразовый `migrate` должен успешно завершиться до запуска новой версии API. Перед изменением схемы создаётся backup; совместимый rollback возвращает предыдущие images, несовместимый — восстанавливает предрелизную копию данных вместо непроверенного `alembic downgrade`. Production release отделяет Alembic от runtime: одноразовый `migrate` должен успешно завершиться до запуска новой версии API. Перед изменением схемы создаётся backup; совместимый rollback возвращает предыдущие images, несовместимый — восстанавливает предрелизную копию данных вместо непроверенного `alembic downgrade`.
@@ -93,7 +103,7 @@ FastAPI ─ PostgreSQL 17
Наружу production-профиль публикует только Caddy. PostgreSQL, API, Astro и MinIO находятся в Docker-сетях. Caddy завершает TLS и защищает административные страницы Basic Auth; административный API отдельно проверяет Bearer-токен в FastAPI. Basic не накладывается на API-запросы. Наружу 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; browser chrome синхронизируется парными `theme-color`. Публичные и административные поверхности, формы, таблицы, provenance/status-плашки и фирменная SVG/CSS-графика используют семантические light/dark-токены и базовый forced-colors layer; ограничения и оставшаяся визуальная приёмка описаны в [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).
@@ -138,7 +148,7 @@ docker compose up --build
- readiness PostgreSQL, MinIO и импорта с версией/revision сборки: <http://localhost:8000/ready>; - readiness PostgreSQL, MinIO и импорта с версией/revision сборки: <http://localhost:8000/ready>;
- консоль MinIO: <http://localhost:9001>. - консоль 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 и ошибок парсеров. 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 и ошибок парсеров.
@@ -174,7 +184,7 @@ docker compose up --build
## Что реализовано ## Что реализовано
- FastAPI и SQLAlchemy 2; - FastAPI и SQLAlchemy 2;
- PostgreSQL 17 и миграции Alembic до `20260910_recovery`; - PostgreSQL 17 и линейные миграции Alembic до `0020`;
- идемпотентный seed с двумя точками и свежими демо-уловами; - идемпотентный seed с двумя точками и свежими демо-уловами;
- `GET /api/v1/activity` с фильтрами периода, водоёма, рыбы, способа и сортировки; - `GET /api/v1/activity` с фильтрами периода, водоёма, рыбы, способа и сортировки;
- `GET /api/v1/spots/{id}` и `/catches`; - `GET /api/v1/spots/{id}` и `/catches`;
+1
View File
@@ -6,6 +6,7 @@ RUN pip install --no-cache-dir -r requirements-lock.txt
RUN useradd --create-home --uid 10001 rf4 RUN useradd --create-home --uid 10001 rf4
COPY --chown=rf4:rf4 apps/api . COPY --chown=rf4:rf4 apps/api .
COPY --chown=rf4:rf4 rf4_research ./rf4_research COPY --chown=rf4:rf4 rf4_research ./rf4_research
COPY --chown=rf4:rf4 data/media ./data/media
USER rf4 USER rf4
EXPOSE 8000 EXPOSE 8000
CMD ["sh", "-c", "python -m app.seed && uvicorn app.main:app --host 0.0.0.0 --port 8000 --no-access-log"] CMD ["sh", "-c", "python -m app.seed && uvicorn app.main:app --host 0.0.0.0 --port 8000 --no-access-log"]
@@ -0,0 +1,37 @@
"""add canonical waterbody provenance fields
Revision ID: 0017
Revises: 0016
"""
from alembic import op
import sqlalchemy as sa
revision = "0017"
down_revision = "0016"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("waterbody", sa.Column("source_system", sa.String(length=50), nullable=True))
op.add_column("waterbody", sa.Column("source_external_id", sa.String(length=200), nullable=True))
op.add_column("waterbody", sa.Column("source_url", sa.Text(), nullable=True))
op.add_column("waterbody", sa.Column("description", sa.Text(), nullable=True))
op.add_column("waterbody", sa.Column("source_checked_at", sa.DateTime(timezone=True), nullable=True))
op.create_index(
"uq_waterbody_source_identity",
"waterbody",
["source_system", "source_external_id"],
unique=True,
)
def downgrade() -> None:
op.drop_index("uq_waterbody_source_identity", table_name="waterbody")
op.drop_column("waterbody", "source_checked_at")
op.drop_column("waterbody", "description")
op.drop_column("waterbody", "source_url")
op.drop_column("waterbody", "source_external_id")
op.drop_column("waterbody", "source_system")
@@ -0,0 +1,26 @@
"""store source coordinate text and precision
Revision ID: 0018
Revises: 0017
"""
from alembic import op
import sqlalchemy as sa
revision = "0018"
down_revision = "0017"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("external_observation", sa.Column("coordinate_raw", sa.String(length=200), nullable=True))
op.add_column("external_observation", sa.Column("coordinate_precision", sa.String(length=20), nullable=True))
op.execute("UPDATE external_observation SET coordinate_precision = CASE WHEN x IS NOT NULL AND y IS NOT NULL THEN 'exact' ELSE 'missing' END")
op.alter_column("external_observation", "coordinate_precision", nullable=False, server_default="missing")
def downgrade() -> None:
op.drop_column("external_observation", "coordinate_precision")
op.drop_column("external_observation", "coordinate_raw")
@@ -0,0 +1,22 @@
"""store canonical waterbody fish count
Revision ID: 0019
Revises: 0018
"""
from alembic import op
import sqlalchemy as sa
revision = "0019"
down_revision = "0018"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("waterbody", sa.Column("fish_species_count", sa.Integer(), nullable=True))
def downgrade() -> None:
op.drop_column("waterbody", "fish_species_count")
@@ -0,0 +1,24 @@
"""store canonical waterbody detail facts
Revision ID: 0020
Revises: 0019
"""
from alembic import op
import sqlalchemy as sa
revision = "0020"
down_revision = "0019"
branch_labels = None
depends_on = None
def upgrade() -> None:
for name in ("source_aliases", "source_fish_species", "source_image_urls", "source_point_urls"):
op.add_column("waterbody", sa.Column(name, sa.JSON(), nullable=True))
def downgrade() -> None:
for name in ("source_point_urls", "source_image_urls", "source_fish_species", "source_aliases"):
op.drop_column("waterbody", name)
@@ -0,0 +1,65 @@
"""add canonical tackle items and rig components
Revision ID: 0021
Revises: 0020
"""
from alembic import op
import sqlalchemy as sa
revision = "0021"
down_revision = "0020"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"tackle_item",
sa.Column("id", sa.Uuid(), primary_key=True),
sa.Column("name", sa.String(200), nullable=False),
sa.Column("normalized_name", sa.String(200), nullable=False, unique=True),
sa.Column("category", sa.String(20), nullable=False),
sa.Column("subcategory", sa.String(100)),
sa.Column("brand", sa.String(100)),
sa.Column("family", sa.String(100)),
sa.Column("unlock_level", sa.Integer()),
sa.Column("source_system", sa.String(50)),
sa.Column("source_external_id", sa.String(200)),
sa.Column("source_url", sa.Text()),
sa.Column("source_checked_at", sa.DateTime(timezone=True)),
sa.Column("raw_payload", sa.JSON()),
sa.UniqueConstraint("source_system", "source_external_id"),
sa.CheckConstraint(
"category IN ('bait', 'lure', 'rod', 'reel', 'line', 'hook', 'rig', 'float', 'sinker', 'other')",
name="ck_tackle_item_category",
),
)
op.create_table(
"rig",
sa.Column("id", sa.Uuid(), primary_key=True),
sa.Column("name", sa.String(200), nullable=False),
sa.Column("normalized_name", sa.String(200), nullable=False, unique=True),
sa.Column("source_system", sa.String(50)),
sa.Column("source_external_id", sa.String(200)),
sa.Column("source_url", sa.Text()),
sa.Column("source_checked_at", sa.DateTime(timezone=True)),
sa.Column("raw_payload", sa.JSON()),
)
op.create_table(
"rig_component",
sa.Column("id", sa.Uuid(), primary_key=True),
sa.Column("rig_id", sa.Uuid(), sa.ForeignKey("rig.id"), nullable=False),
sa.Column("tackle_item_id", sa.Uuid(), sa.ForeignKey("tackle_item.id")),
sa.Column("role", sa.String(50), nullable=False),
sa.Column("position", sa.Integer(), nullable=False),
sa.Column("raw_value", sa.String(200)),
sa.UniqueConstraint("rig_id", "position"),
)
def downgrade() -> None:
op.drop_table("rig_component")
op.drop_table("rig")
op.drop_table("tackle_item")
@@ -0,0 +1,40 @@
"""preserve ordered gear evidence on catches
Revision ID: 0022
Revises: 0021
"""
from alembic import op
import sqlalchemy as sa
revision = "0022"
down_revision = "0021"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"catch_tackle_component",
sa.Column("id", sa.Uuid(), primary_key=True),
sa.Column("catch_report_id", sa.Uuid(), sa.ForeignKey("catch_report.id"), nullable=False),
sa.Column("tackle_item_id", sa.Uuid(), sa.ForeignKey("tackle_item.id")),
sa.Column("rig_id", sa.Uuid(), sa.ForeignKey("rig.id")),
sa.Column("role", sa.String(50), nullable=False),
sa.Column("position", sa.Integer(), nullable=False),
sa.Column("raw_value", sa.String(200), nullable=False),
sa.Column("source_system", sa.String(50)),
sa.Column("source_external_id", sa.String(200)),
sa.Column("source_url", sa.Text()),
sa.Column("raw_payload", sa.JSON()),
sa.UniqueConstraint("catch_report_id", "position"),
sa.CheckConstraint(
"NOT (tackle_item_id IS NOT NULL AND rig_id IS NOT NULL)",
name="ck_catch_tackle_one_canonical_target",
),
)
def downgrade() -> None:
op.drop_table("catch_tackle_component")
+16 -1
View File
@@ -61,6 +61,9 @@ def activity_rows(
confidence = min(confidence, 50) confidence = min(confidence, 50)
elif len(players) == 2: elif len(players) == 2:
confidence = min(confidence, 65) 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) latest = max(_aware(r.reported_at) for r in items)
baits = Counter(r.bait.name for r in items if r.bait) baits = Counter(r.bait.name for r in items if r.bait)
freshness_text = _freshness_text(now - latest) 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, max_weight_g=max(r.weight_g for r in items), last_confirmed_at=latest,
activity_score=activity, confidence_score=confidence, activity_score=activity, confidence_score=confidence,
explanation=_explanation(len(items), len(players), freshness_text, activity, 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) 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" 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: def _aware(value: datetime) -> datetime:
return value if value.tzinfo else value.replace(tzinfo=timezone.utc) return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
+25
View File
@@ -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, "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())} 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,
}
+66 -2
View File
@@ -8,10 +8,10 @@ from dataclasses import asdict
from .config import settings from .config import settings
from .database import SessionLocal from .database import SessionLocal
from .importer import import_records 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 .retention import RetentionPolicy, apply_retention
from .storage import delete_screenshot 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 from .community_scheduler import run_source, configured_sources
# Static registry for argparse choices — no DB required for --help # 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 = sub.add_parser("stage-community-json")
community.add_argument("--input", default="-", help="JSON array path or - for stdin") community.add_argument("--input", default="-", help="JSON array path or - for stdin")
community.add_argument("--limit", type=int, default=500) 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 = sub.add_parser("fetch-community")
fetch_community.add_argument("source", choices=STATIC_SOURCE_CHOICES) fetch_community.add_argument("source", choices=STATIC_SOURCE_CHOICES)
cleanup = sub.add_parser("cleanup-retention") cleanup = sub.add_parser("cleanup-retention")
cleanup.add_argument("--apply", action="store_true", help="apply changes; default is dry-run") cleanup.add_argument("--apply", action="store_true", help="apply changes; default is dry-run")
sub.add_parser("audit-catalog") 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() args = parser.parse_args()
with SessionLocal() as session: with SessionLocal() as session:
if args.command == "import-records": if args.command == "import-records":
@@ -57,6 +66,43 @@ def main() -> int:
parser.error("input must be a JSON array") parser.error("input must be a JSON array")
created, updated = stage_observations(session, payload[:args.limit]) created, updated = stage_observations(session, payload[:args.limit])
print(f"staged: created={created} updated={updated}") 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": elif args.command == "fetch-community":
# A09: Verify source is enabled at runtime (not just in static choices) # A09: Verify source is enabled at runtime (not just in static choices)
enabled = configured_sources() 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) 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)) 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: else:
result = audit_catalog(session) result = audit_catalog(session)
print(json.dumps(result, ensure_ascii=False)) print(json.dumps(result, ensure_ascii=False))
+167
View File
@@ -1,6 +1,8 @@
from __future__ import annotations from __future__ import annotations
import json import json
import hashlib
import re
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Iterable from typing import Any, Iterable
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -26,12 +28,153 @@ SOURCE_HOSTS = {
"rf4map": {"rf4map.ru"}, "rf4map": {"rf4map.ru"},
"rf4posts-spot": {"rf4-posts.com"}, "rf4posts-spot": {"rf4-posts.com"},
} }
COORDINATE_PRECISIONS = frozenset({"exact", "approximate", "area", "missing"})
class CommunityImportError(ValueError): class CommunityImportError(ValueError):
pass 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", "download.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( def stage_observations(
session: Session, records: Iterable[dict[str, Any]], *, fetched_at: datetime | None = None, session: Session, records: Iterable[dict[str, Any]], *, fetched_at: datetime | None = None,
) -> tuple[int, int]: ) -> tuple[int, int]:
@@ -65,6 +208,8 @@ def stage_observations(
"waterbody_external_id": _optional(payload, "waterbody_external_id", 200), "waterbody_external_id": _optional(payload, "waterbody_external_id", 200),
"x": _integer(payload.get("x"), maximum=10_000), "x": _integer(payload.get("x"), maximum=10_000),
"y": _integer(payload.get("y"), 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), "weight_g": _integer(payload.get("weight_g"), minimum=1, maximum=3_000_000),
"published_at": _datetime(payload.get("published_at")), "published_at": _datetime(payload.get("published_at")),
"last_seen_at": fetched_at, "payload": payload, "last_seen_at": fetched_at, "payload": payload,
@@ -83,6 +228,7 @@ def stage_observations(
changed = any(getattr(observation, key) != values[key] for key in ( changed = any(getattr(observation, key) != values[key] for key in (
"source_url", "fish_name", "fish_external_id", "waterbody_name", "source_url", "fish_name", "fish_external_id", "waterbody_name",
"waterbody_external_id", "x", "y", "weight_g", "waterbody_external_id", "x", "y", "weight_g",
"coordinate_raw", "coordinate_precision",
)) or observation.payload != payload )) or observation.payload != payload
if observation.status != "rejected" and changed and observation.catch_report is not None: if observation.status != "rejected" and changed and observation.catch_report is not None:
observation.catch_report.moderation_status = ModerationStatus.pending observation.catch_report.moderation_status = ModerationStatus.pending
@@ -186,6 +332,27 @@ def _optional(payload: dict[str, Any], key: str, limit: int) -> str | None:
return value or 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: def _integer(value: Any, *, minimum: int = -10_000, maximum: int) -> int | None:
if value is None: if value is None:
return None return None
+15
View File
@@ -11,6 +11,8 @@ from .models import (
Bait, BaitKind, CatchReport, ExternalEntityAlias, ExternalObservation, Bait, BaitKind, CatchReport, ExternalEntityAlias, ExternalObservation,
Fish, ModerationStatus, SourceType, Spot, Waterbody, Fish, ModerationStatus, SourceType, Spot, Waterbody,
) )
from .tackle_components import replace_tackle_components
from rf4_research.gear_components import from_catch_fields
class ExternalReviewError(ValueError): class ExternalReviewError(ValueError):
@@ -104,6 +106,8 @@ def publish_observation(session: Session, observation: ExternalObservation) -> C
"external_observation_id": str(observation.id), "external_observation_id": str(observation.id),
"source_system": observation.source_system, "source_system": observation.source_system,
"source_external_id": observation.source_external_id, "source_external_id": observation.source_external_id,
"coordinate_raw": observation.coordinate_raw,
"coordinate_precision": observation.coordinate_precision,
}, },
"original": observation.payload, "original": observation.payload,
}, },
@@ -116,6 +120,17 @@ def publish_observation(session: Session, observation: ExternalObservation) -> C
setattr(report, key, value) setattr(report, key, value)
session.add(report) session.add(report)
session.flush() session.flush()
replace_tackle_components(
session,
report,
from_catch_fields(
bait=observation.payload.get("bait"),
rig_type=observation.payload.get("rig_type"),
),
source_system=observation.source_system,
source_url=observation.source_url,
raw_payload={"origin": "community_observation", "observation_id": str(observation.id)},
)
observation.catch_report = report observation.catch_report = report
observation.status = "published" observation.status = "published"
observation.reviewed_at = now observation.reviewed_at = now
+18 -1
View File
@@ -14,6 +14,7 @@ from .config import settings
from .database import SessionLocal from .database import SessionLocal
from .logging_config import configure_logging from .logging_config import configure_logging
from .models import CommunityImportRun, DataSource from .models import CommunityImportRun, DataSource
from .source_lifecycle import classify_source_failure, record_scheduled_source_check
logger = logging.getLogger("rf4.community_scheduler") logger = logging.getLogger("rf4.community_scheduler")
MAX_BACKOFF_SECONDS = 24 * 60 * 60 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) html = fetch_html(url)
records = parser(html, source_url=url) if source_system in {"rf4map", "rf4posts-spot"} else parser(html) 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]) 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 run.status, run.rows_seen, run.rows_created, run.rows_updated = "success", len(records), created, updated
except Exception as exc: except Exception as exc:
session.rollback() 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]}" 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() run.finished_at = datetime.now(timezone.utc); session.commit()
return True return True
+10
View File
@@ -16,6 +16,8 @@ from .models import (
Bait, BaitKind, CatchReport, Fish, ImportRecordEvent, ImportStatus, ModerationStatus, Bait, BaitKind, CatchReport, Fish, ImportRecordEvent, ImportStatus, ModerationStatus,
OfficialRecordImport, SourceType, Waterbody, OfficialRecordImport, SourceType, Waterbody,
) )
from .tackle_components import replace_tackle_components
from rf4_research.gear_components import from_catch_fields
USER_AGENT = "RF4-Spotter/0.1 (public records importer)" USER_AGENT = "RF4-Spotter/0.1 (public records importer)"
@@ -222,6 +224,14 @@ def _import_records_locked(session: Session, *, url: str, region: str, category:
provenance={"source_system": "rf4-official", "source_url": url, "source_external_id": key}, provenance={"source_system": "rf4-official", "source_url": url, "source_external_id": key},
)) ))
run.rows_updated += 1 run.rows_updated += 1
replace_tackle_components(
session,
report,
from_catch_fields(bait=raw.bait, rig_type=None),
source_system="rf4-official",
source_url=url,
raw_payload={"origin": "official_record", "source_external_id": key},
)
run.status = ImportStatus.success run.status = ImportStatus.success
run.finished_at = datetime.now(timezone.utc) run.finished_at = datetime.now(timezone.utc)
session.commit() session.commit()
+4
View File
@@ -14,8 +14,10 @@ from .dependencies import Db
from .logging_config import configure_logging from .logging_config import configure_logging
from .readiness import readiness_report from .readiness import readiness_report
from .routers.activity import router as activity_router from .routers.activity import router as activity_router
from .routers.analytics import router as analytics_router
from .routers.admin import router as admin_router from .routers.admin import router as admin_router
from .routers.catalog import router as catalog_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.public_data import router as public_data_router
from .routers.submissions import router as submissions_router from .routers.submissions import router as submissions_router
from .storage import client as storage_client from .storage import client as storage_client
@@ -87,7 +89,9 @@ def ready(db: Db) -> JSONResponse:
app.include_router(catalog_router) app.include_router(catalog_router)
app.include_router(media_router)
app.include_router(activity_router) app.include_router(activity_router)
app.include_router(analytics_router)
app.include_router(public_data_router) app.include_router(public_data_router)
app.include_router(admin_router) app.include_router(admin_router)
app.include_router(submissions_router) app.include_router(submissions_router)
+121
View File
@@ -0,0 +1,121 @@
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)
media_type = None
local_path = None
if item:
media_type = item.get("content_type")
local_path = item.get("local_path")
else:
for row in manifest.get("assets", []):
if row.get("status") != "approved":
continue
variant = next((candidate for candidate in row.get("derivatives", []) if candidate.get("sha256") == digest), None)
if variant:
media_type = variant.get("content_type")
local_path = variant.get("local_path")
break
if not local_path:
return None
target = (MEDIA_ROOT / local_path).resolve()
if not target.is_relative_to(MEDIA_ROOT.resolve()) or not target.is_file():
return None
return target, str(media_type or "application/octet-stream")
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")
+95
View File
@@ -49,6 +49,16 @@ class Waterbody(Base):
slug: Mapped[str] = mapped_column(String(100), unique=True) slug: Mapped[str] = mapped_column(String(100), unique=True)
name_ru: Mapped[str] = mapped_column(String(200), unique=True) name_ru: Mapped[str] = mapped_column(String(200), unique=True)
unlock_level: Mapped[int | None] 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): class Bait(Base):
@@ -59,6 +69,61 @@ class Bait(Base):
kind: Mapped[BaitKind] = mapped_column(Enum(BaitKind)) kind: Mapped[BaitKind] = mapped_column(Enum(BaitKind))
class TackleItem(Base):
"""Canonical gear item; legacy Bait rows remain source-compatible."""
__tablename__ = "tackle_item"
__table_args__ = (
UniqueConstraint("source_system", "source_external_id"),
CheckConstraint(
"category IN ('bait', 'lure', 'rod', 'reel', 'line', 'hook', 'rig', 'float', 'sinker', 'other')",
name="ck_tackle_item_category",
),
)
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(200))
normalized_name: Mapped[str] = mapped_column(String(200), unique=True)
category: Mapped[str] = mapped_column(String(20))
subcategory: Mapped[str | None] = mapped_column(String(100))
brand: Mapped[str | None] = mapped_column(String(100))
family: Mapped[str | None] = mapped_column(String(100))
unlock_level: 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)
source_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
raw_payload: Mapped[dict | None] = mapped_column(JSON)
rig_components: Mapped[list["RigComponent"]] = relationship(back_populates="tackle_item")
class Rig(Base):
"""A named rig/setup kept separate from individual tackle items."""
__tablename__ = "rig"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(200))
normalized_name: Mapped[str] = mapped_column(String(200), unique=True)
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)
source_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
raw_payload: Mapped[dict | None] = mapped_column(JSON)
components: Mapped[list["RigComponent"]] = relationship(back_populates="rig")
class RigComponent(Base):
__tablename__ = "rig_component"
__table_args__ = (UniqueConstraint("rig_id", "position"),)
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
rig_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("rig.id"))
tackle_item_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("tackle_item.id"))
role: Mapped[str] = mapped_column(String(50))
position: Mapped[int] = mapped_column(Integer)
raw_value: Mapped[str | None] = mapped_column(String(200))
rig: Mapped[Rig] = relationship(back_populates="components")
tackle_item: Mapped[TackleItem | None] = relationship(back_populates="rig_components")
class Spot(Base): class Spot(Base):
__tablename__ = "spot" __tablename__ = "spot"
__table_args__ = (UniqueConstraint("waterbody_id", "x", "y"),) __table_args__ = (UniqueConstraint("waterbody_id", "x", "y"),)
@@ -100,6 +165,34 @@ class CatchReport(Base):
spot: Mapped[Spot | None] = relationship() spot: Mapped[Spot | None] = relationship()
waterbody: Mapped[Waterbody] = relationship() waterbody: Mapped[Waterbody] = relationship()
bait: Mapped[Bait | None] = relationship() bait: Mapped[Bait | None] = relationship()
tackle_components: Mapped[list["CatchTackleComponent"]] = relationship(back_populates="catch_report")
class CatchTackleComponent(Base):
"""Ordered gear evidence; unresolved raw values are valid and preserved."""
__tablename__ = "catch_tackle_component"
__table_args__ = (
UniqueConstraint("catch_report_id", "position"),
CheckConstraint(
"NOT (tackle_item_id IS NOT NULL AND rig_id IS NOT NULL)",
name="ck_catch_tackle_one_canonical_target",
),
)
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
catch_report_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("catch_report.id"))
tackle_item_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("tackle_item.id"))
rig_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("rig.id"))
role: Mapped[str] = mapped_column(String(50))
position: Mapped[int] = mapped_column(Integer)
raw_value: Mapped[str] = mapped_column(String(200))
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)
raw_payload: Mapped[dict | None] = mapped_column(JSON)
catch_report: Mapped[CatchReport] = relationship(back_populates="tackle_components")
tackle_item: Mapped[TackleItem | None] = relationship()
rig: Mapped[Rig | None] = relationship()
class OfficialRecordImport(Base): class OfficialRecordImport(Base):
@@ -187,6 +280,8 @@ class ExternalObservation(Base):
waterbody_external_id: Mapped[str | None] = mapped_column(String(200)) waterbody_external_id: Mapped[str | None] = mapped_column(String(200))
x: Mapped[int | None] x: Mapped[int | None]
y: 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] weight_g: Mapped[int | None]
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
+17 -4
View File
@@ -5,7 +5,7 @@ from uuid import UUID
from fastapi import APIRouter, HTTPException, Query, Response from fastapi import APIRouter, HTTPException, Query, Response
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import Session, joinedload, selectinload
from ..activity import activity_rows from ..activity import activity_rows
from ..config import settings from ..config import settings
@@ -68,7 +68,15 @@ def spot_detail(spot_id: UUID, db: Db) -> SpotOut:
def count_since(delta: timedelta) -> int: def count_since(delta: timedelta) -> int:
return sum(aware(report.reported_at) >= now - delta for report in reports) 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: def _report_source(report: CatchReport) -> str:
@@ -83,8 +91,13 @@ def _report_source(report: CatchReport) -> str:
@router.get("/api/v1/spots/{spot_id}/catches", response_model=list[CatchOut]) @router.get("/api/v1/spots/{spot_id}/catches", response_model=list[CatchOut])
def spot_catches(spot_id: UUID, db: Db, limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0)) -> list[CatchOut]: def spot_catches(spot_id: UUID, db: Db, limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0)) -> list[CatchOut]:
_spot_or_404(db, spot_id) _spot_or_404(db, spot_id)
reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.bait)).where(CatchReport.spot_id == spot_id, CatchReport.moderation_status == ModerationStatus.approved, CatchReport.deleted_at.is_(None)).order_by(CatchReport.reported_at.desc(), CatchReport.id.desc()).offset(offset).limit(limit))) reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.bait), selectinload(CatchReport.tackle_components)).where(CatchReport.spot_id == spot_id, CatchReport.moderation_status == ModerationStatus.approved, CatchReport.deleted_at.is_(None)).order_by(CatchReport.reported_at.desc(), CatchReport.id.desc()).offset(offset).limit(limit)))
return [CatchOut(id=report.id, fish=report.fish.name_ru, weight_g=report.weight_g, bait=report.bait.name if report.bait else None, player_name=report.player_name, caught_at=report.caught_at, reported_at=report.reported_at, retrieve_method=report.retrieve_method, retrieve_speed=report.retrieve_speed, source_system=_report_source(report), source_url=report.source_url) for report in reports] return [CatchOut(id=report.id, fish=report.fish.name_ru, weight_g=report.weight_g, bait=report.bait.name if report.bait else None, player_name=report.player_name, caught_at=report.caught_at, reported_at=report.reported_at, retrieve_method=report.retrieve_method, retrieve_speed=report.retrieve_speed, source_system=_report_source(report), source_url=report.source_url, tackle_components=[{
"id": component.id, "role": component.role, "position": component.position,
"raw_value": component.raw_value, "tackle_item_id": component.tackle_item_id,
"rig_id": component.rig_id, "source_system": component.source_system,
"source_url": component.source_url,
} for component in sorted(report.tackle_components, key=lambda value: value.position)]) for report in reports]
@router.get("/api/v1/spots/{spot_id}/timeline") @router.get("/api/v1/spots/{spot_id}/timeline")
+98 -3
View File
@@ -1,12 +1,12 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from typing import Annotated, Literal from typing import Annotated, Literal
from uuid import UUID from uuid import UUID
import httpx import httpx
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response 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 import case, func, or_, select
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
@@ -15,9 +15,12 @@ from ..community_review import ExternalReviewError, map_observation, publish_obs
from ..config import settings from ..config import settings
from ..dependencies import Db from ..dependencies import Db
from ..importer import ImportAlreadyRunning, ImportSourceError, import_records 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 ..models import CatchReport, CommunityImportRun, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Waterbody
from ..public_cache import public_cache 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 ..storage import delete_screenshot, signed_screenshot_url
from ..time_utils import aware 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) 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") @router.get("/api/v1/admin/diagnostics")
def admin_diagnostics(db: Db, _: Annotated[str, Depends(_admin)]) -> JSONResponse: def admin_diagnostics(db: Db, _: Annotated[str, Depends(_admin)]) -> JSONResponse:
report_counts = {status.value: count for status, count in db.execute( 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 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: def _external_out(item: ExternalObservation) -> ExternalObservationOut:
allowed_payload = { allowed_payload = {
key: value for key, value in (item.payload or {}).items() key: value for key, value in (item.payload or {}).items()
+66
View File
@@ -0,0 +1,66 @@
from __future__ import annotations
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Query
from sqlalchemy import select
from sqlalchemy.orm import Session, joinedload, selectinload
from ..dependencies import Db
from ..models import CatchReport, Fish, ModerationStatus, Spot, Waterbody
from ..schemas import TackleCombinationOut
router = APIRouter()
@router.get("/api/v1/analytics/tackle", response_model=list[TackleCombinationOut])
def tackle_combinations(
db: Db,
waterbody: str | None = None,
fish: str | None = None,
method: str | None = None,
hours: int = Query(72, ge=24, le=168),
min_samples: int = Query(3, ge=1, le=100),
min_players: int = Query(2, ge=1, le=100),
) -> list[TackleCombinationOut]:
now = datetime.now(timezone.utc)
query = select(CatchReport).options(
joinedload(CatchReport.fish), joinedload(CatchReport.waterbody),
selectinload(CatchReport.tackle_components),
).where(
CatchReport.moderation_status == ModerationStatus.approved,
CatchReport.deleted_at.is_(None),
CatchReport.reported_at >= now - timedelta(hours=hours),
)
if waterbody:
query = query.join(Waterbody, CatchReport.waterbody_id == Waterbody.id).where(Waterbody.slug == waterbody)
if fish:
query = query.join(Fish, CatchReport.fish_id == Fish.id).where(Fish.slug == fish)
if method:
query = query.where(CatchReport.fishing_method == method)
groups: dict[tuple[str, str], list[CatchReport]] = defaultdict(list)
for report in db.scalars(query):
for component in report.tackle_components:
if component.raw_value.strip():
groups[(component.role, component.raw_value.strip())].append(report)
result = []
for (role, value), reports in groups.items():
unique_reports = {report.id: report for report in reports}
players = {report.player_name.strip().casefold() for report in unique_reports.values() if report.player_name and report.player_name.strip()}
catches = len(unique_reports)
unique_players = len(players)
last_seen = max(report.reported_at for report in unique_reports.values())
enough = catches >= min_samples and unique_players >= min_players
result.append(TackleCombinationOut(
role=role, value=value, catches=catches, unique_players=unique_players,
last_seen_at=last_seen, status="recommendation" if enough else "insufficient_data",
explanation=(
"Достаточно независимых наблюдений для рекомендации."
if enough else
f"Данных мало: нужно минимум {min_samples} наблюдения и {min_players} независимых игрока."
),
))
return sorted(result, key=lambda item: (item.status != "recommendation", -item.catches, -item.unique_players, item.role, item.value))
+69 -4
View File
@@ -1,9 +1,12 @@
from fastapi import APIRouter, Query from uuid import UUID
from sqlalchemy import select
from fastapi import APIRouter, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.orm import selectinload
from ..dependencies import Db from ..dependencies import Db
from ..models import Bait, CatchReport, Fish, ModerationStatus, Spot, Waterbody from ..models import Bait, CatchReport, Fish, ModerationStatus, Rig, Spot, TackleItem, Waterbody
from ..schemas import BaitOut, FishOut, WaterbodyOut from ..schemas import BaitOut, FishOut, PaginatedTackleItemOut, RigOut, TackleItemOut, WaterbodyOut
router = APIRouter() router = APIRouter()
@@ -23,6 +26,68 @@ def baits(db: Db, limit: int = Query(200, ge=1, le=500), offset: int = Query(0,
return list(db.scalars(select(Bait).order_by(Bait.name, Bait.id).offset(offset).limit(limit))) return list(db.scalars(select(Bait).order_by(Bait.name, Bait.id).offset(offset).limit(limit)))
def _item_missing_fields(item: TackleItem) -> list[str]:
return [field for field, value in (
("subcategory", item.subcategory), ("brand", item.brand),
("family", item.family), ("unlock_level", item.unlock_level),
("source_url", item.source_url), ("source_checked_at", item.source_checked_at),
) if value is None]
@router.get("/api/v1/tackle/items", response_model=PaginatedTackleItemOut)
def tackle_items(
db: Db,
category: str | None = Query(None, pattern="^(bait|lure|rod|reel|line|hook|rig|float|sinker|other)$"),
brand: str | None = None,
family: str | None = None,
unlock_level: int | None = Query(None, ge=0),
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
) -> PaginatedTackleItemOut:
query = select(TackleItem)
if category:
query = query.where(TackleItem.category == category)
if brand:
query = query.where(TackleItem.brand == brand)
if family:
query = query.where(TackleItem.family == family)
if unlock_level is not None:
query = query.where(TackleItem.unlock_level == unlock_level)
total = db.scalar(query.with_only_columns(func.count(TackleItem.id), maintain_column_froms=True).order_by(None)) or 0
items = list(db.scalars(query.order_by(TackleItem.name, TackleItem.id).offset(offset).limit(limit)))
return PaginatedTackleItemOut(
items=[TackleItemOut.model_validate(item).model_copy(update={"missing_fields": _item_missing_fields(item)}) for item in items],
total=total, limit=limit, offset=offset,
)
@router.get("/api/v1/tackle/items/{item_id}", response_model=TackleItemOut)
def tackle_item(item_id: UUID, db: Db) -> TackleItemOut:
item = db.get(TackleItem, item_id)
if item is None:
raise HTTPException(status_code=404, detail="tackle item not found")
return TackleItemOut.model_validate(item).model_copy(update={"missing_fields": _item_missing_fields(item)})
@router.get("/api/v1/tackle/rigs/{rig_id}", response_model=RigOut)
def rig_detail(rig_id: UUID, db: Db) -> RigOut:
rig = db.scalar(select(Rig).options(selectinload(Rig.components)).where(Rig.id == rig_id))
if rig is None:
raise HTTPException(status_code=404, detail="rig not found")
missing = [field for field, value in (
("source_url", rig.source_url), ("source_checked_at", rig.source_checked_at),
) if value is None]
return RigOut(
id=rig.id, name=rig.name, source_system=rig.source_system,
source_external_id=rig.source_external_id, source_url=rig.source_url,
source_checked_at=rig.source_checked_at, missing_fields=missing,
components=[{
"id": component.id, "role": component.role, "position": component.position,
"raw_value": component.raw_value, "tackle_item_id": component.tackle_item_id,
} for component in sorted(rig.components, key=lambda value: value.position)],
)
@router.get("/api/v1/public-spot-pages") @router.get("/api/v1/public-spot-pages")
def public_spot_pages(db: Db, limit: int = Query(500, ge=1, le=500), offset: int = Query(0, ge=0)) -> list[str]: def public_spot_pages(db: Db, limit: int = Query(500, ge=1, le=500), offset: int = Query(0, ge=0)) -> list[str]:
rows = db.execute(select(Waterbody.slug, Spot.x, Spot.y, Fish.slug) rows = db.execute(select(Waterbody.slug, Spot.x, Spot.y, Fish.slug)
+24
View File
@@ -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}"',
})
+10
View File
@@ -21,6 +21,8 @@ from ..models import Bait, BaitKind, CatchReport, Fish, ModerationStatus, Source
from ..schemas import CatchReportAccepted, CatchReportCreate from ..schemas import CatchReportAccepted, CatchReportCreate
from ..storage import ScreenshotError, upload_screenshot from ..storage import ScreenshotError, upload_screenshot
from ..submission_security import check_rate_limit from ..submission_security import check_rate_limit
from ..tackle_components import replace_tackle_components
from rf4_research.gear_components import from_catch_fields
router = APIRouter() router = APIRouter()
logger = logging.getLogger("rf4.api.submissions") logger = logging.getLogger("rf4.api.submissions")
@@ -60,6 +62,14 @@ def create_catch_report(payload: CatchReportCreate, request: Request, db: Db, id
upload_token = _replay_token(key_hash) if key_hash else secrets.token_urlsafe(32) upload_token = _replay_token(key_hash) if key_hash else secrets.token_urlsafe(32)
report = CatchReport(fish=fish, spot=spot, waterbody=waterbody, bait=bait, weight_g=payload.weight_g, fishing_method=payload.fishing_method, rig_type=payload.rig_type, retrieve_method=payload.retrieve_method, retrieve_speed=payload.retrieve_speed, caught_at=payload.caught_at, reported_at=datetime.now(timezone.utc), player_name=payload.player_name, source_type=SourceType.user, source_url=payload.source_url, source_confidence=60, moderation_status=ModerationStatus.pending, raw_payload={"comment": payload.comment} if payload.comment else None, screenshot_upload_token_hash=hashlib.sha256(upload_token.encode()).hexdigest()) report = CatchReport(fish=fish, spot=spot, waterbody=waterbody, bait=bait, weight_g=payload.weight_g, fishing_method=payload.fishing_method, rig_type=payload.rig_type, retrieve_method=payload.retrieve_method, retrieve_speed=payload.retrieve_speed, caught_at=payload.caught_at, reported_at=datetime.now(timezone.utc), player_name=payload.player_name, source_type=SourceType.user, source_url=payload.source_url, source_confidence=60, moderation_status=ModerationStatus.pending, raw_payload={"comment": payload.comment} if payload.comment else None, screenshot_upload_token_hash=hashlib.sha256(upload_token.encode()).hexdigest())
db.add(report) db.add(report)
replace_tackle_components(
db,
report,
from_catch_fields(bait=payload.bait_name, rig_type=payload.rig_type),
source_system="user",
source_url=payload.source_url,
raw_payload={"origin": "user_submission"},
)
if key_hash: if key_hash:
db.add(SubmissionAttempt(client_hash="", idempotency_key=key_hash, catch_report=report, payload_hash=payload_hash, created_at=datetime.now(timezone.utc))) db.add(SubmissionAttempt(client_hash="", idempotency_key=key_hash, catch_report=report, payload_hash=payload_hash, created_at=datetime.now(timezone.utc)))
try: try:
+123
View File
@@ -20,6 +20,16 @@ class WaterbodyOut(BaseModel):
slug: str slug: str
name_ru: str name_ru: str
unlock_level: int | None 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): class BaitOut(BaseModel):
@@ -30,6 +40,48 @@ class BaitOut(BaseModel):
kind: str kind: str
class TackleItemOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
name: str
category: str
subcategory: str | None
brand: str | None
family: str | None
unlock_level: int | None
source_system: str | None
source_external_id: str | None
source_url: str | None
source_checked_at: datetime | None
missing_fields: list[str] = Field(default_factory=list)
class PaginatedTackleItemOut(BaseModel):
items: list[TackleItemOut]
total: int
limit: int
offset: int
class RigComponentOut(BaseModel):
id: UUID
role: str
position: int
raw_value: str | None
tackle_item_id: UUID | None
class RigOut(BaseModel):
id: UUID
name: str
source_system: str | None
source_external_id: str | None
source_url: str | None
source_checked_at: datetime | None
missing_fields: list[str] = Field(default_factory=list)
components: list[RigComponentOut]
class ActivityOut(BaseModel): class ActivityOut(BaseModel):
spot_id: UUID spot_id: UUID
waterbody_slug: str waterbody_slug: str
@@ -48,6 +100,8 @@ class ActivityOut(BaseModel):
confidence_score: int confidence_score: int
explanation: str explanation: str
sources: list[str] sources: list[str]
coordinate_precision: str
coordinate_sources: list[str]
class PaginatedActivityOut(BaseModel): class PaginatedActivityOut(BaseModel):
@@ -57,6 +111,16 @@ class PaginatedActivityOut(BaseModel):
offset: int offset: int
class TackleCombinationOut(BaseModel):
role: str
value: str
catches: int
unique_players: int
last_seen_at: datetime
status: str
explanation: str
class CatchOut(BaseModel): class CatchOut(BaseModel):
id: UUID id: UUID
fish: str fish: str
@@ -69,6 +133,18 @@ class CatchOut(BaseModel):
retrieve_speed: int | None retrieve_speed: int | None
source_system: str source_system: str
source_url: str | None source_url: str | None
tackle_components: list["CatchTackleComponentOut"]
class CatchTackleComponentOut(BaseModel):
id: UUID
role: str
position: int
raw_value: str
tackle_item_id: UUID | None
rig_id: UUID | None
source_system: str | None
source_url: str | None
class SpotOut(BaseModel): class SpotOut(BaseModel):
@@ -82,6 +158,8 @@ class SpotOut(BaseModel):
catches_3d: int catches_3d: int
catches_7d: int catches_7d: int
top_baits: list[str] top_baits: list[str]
coordinate_precision: str
coordinate_sources: list[str]
class OfficialRecordOut(BaseModel): class OfficialRecordOut(BaseModel):
@@ -287,3 +365,48 @@ class SourceStatusOut(BaseModel):
last_started_at: datetime | None last_started_at: datetime | None
last_success_at: datetime | None last_success_at: datetime | None
observations: int 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)
+47 -2
View File
@@ -2,7 +2,9 @@ from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Literal from typing import Literal
from urllib.error import HTTPError
from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .models import ExternalObservation, ModerationStatus from .models import ExternalObservation, ModerationStatus
@@ -11,6 +13,16 @@ from .models import ExternalObservation, ModerationStatus
SourceCheckStatus = Literal["available", "missing", "temporary_error", "blocked"] 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( def record_source_check(
session: Session, session: Session,
observation: ExternalObservation, observation: ExternalObservation,
@@ -24,7 +36,18 @@ def record_source_check(
data. Transient errors and access blocks remain diagnostic and never remove data. Transient errors and access blocks remain diagnostic and never remove
an observation from activity. an observation from activity.
""" """
current = checked_at or datetime.now(timezone.utc) _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_check_status = status
observation.source_checked_at = current observation.source_checked_at = current
if status == "missing" and observation.status != "withdrawn": if status == "missing" and observation.status != "withdrawn":
@@ -34,5 +57,27 @@ def record_source_check(
observation.review_note = "Source record missing; withdrawn pending moderator review" observation.review_note = "Source record missing; withdrawn pending moderator review"
observation.reviewed_at = current observation.reviewed_at = current
observation.moderation_version += 1 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() session.commit()
return observation return len(observations)
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
from collections.abc import Iterable
from sqlalchemy import delete
from sqlalchemy.orm import Session
from rf4_research.gear_components import GearComponentIdentity
from .models import CatchReport, CatchTackleComponent
def replace_tackle_components(
session: Session,
report: CatchReport,
components: Iterable[GearComponentIdentity],
*,
source_system: str | None,
source_url: str | None = None,
raw_payload: dict | None = None,
) -> None:
"""Replace the ordered evidence for a report while keeping imports idempotent."""
session.flush()
session.execute(
delete(CatchTackleComponent).where(CatchTackleComponent.catch_report_id == report.id)
)
session.add_all(
CatchTackleComponent(
catch_report_id=report.id,
role=component.role,
position=component.position,
raw_value=component.raw_value,
source_system=source_system,
source_external_id=component.source_external_id,
source_url=source_url,
raw_payload=raw_payload,
)
for component in components
)
+886 -3
View File
@@ -30,6 +30,17 @@
"title": "Confidence Score", "title": "Confidence Score",
"type": "integer" "type": "integer"
}, },
"coordinate_precision": {
"title": "Coordinate Precision",
"type": "string"
},
"coordinate_sources": {
"items": {
"type": "string"
},
"title": "Coordinate Sources",
"type": "array"
},
"explanation": { "explanation": {
"title": "Explanation", "title": "Explanation",
"type": "string" "type": "string"
@@ -101,7 +112,9 @@
"activity_score", "activity_score",
"confidence_score", "confidence_score",
"explanation", "explanation",
"sources" "sources",
"coordinate_precision",
"coordinate_sources"
], ],
"title": "ActivityOut", "title": "ActivityOut",
"type": "object" "type": "object"
@@ -204,6 +217,241 @@
"title": "AdminCatchReportOut", "title": "AdminCatchReportOut",
"type": "object" "type": "object"
}, },
"AdminMediaDecision": {
"properties": {
"note": {
"maxLength": 1000,
"minLength": 1,
"title": "Note",
"type": "string"
}
},
"required": [
"note"
],
"title": "AdminMediaDecision",
"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": {
"asset_url": {
"title": "Asset Url",
"type": "string"
},
"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"
},
"supersedes": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Supersedes"
},
"width": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Width"
}
},
"required": [
"id",
"status",
"entity_type",
"entity_key",
"label",
"width",
"height",
"content_type",
"image_url",
"asset_url",
"source_system",
"source_url",
"duplicate_of",
"supersedes",
"derivatives"
],
"title": "AdminMediaReviewOut",
"type": "object"
},
"AdminMediaRollback": {
"properties": {
"asset_url": {
"maxLength": 2000,
"minLength": 1,
"title": "Asset Url",
"type": "string"
},
"note": {
"maxLength": 1000,
"minLength": 1,
"title": "Note",
"type": "string"
}
},
"required": [
"note",
"asset_url"
],
"title": "AdminMediaRollback",
"type": "object"
},
"AdminModerationHistoryOut": { "AdminModerationHistoryOut": {
"properties": { "properties": {
"action": { "action": {
@@ -263,6 +511,83 @@
"title": "AdminModerationHistoryOut", "title": "AdminModerationHistoryOut",
"type": "object" "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": { "BaitOut": {
"properties": { "properties": {
"id": { "id": {
@@ -1610,6 +1935,17 @@
"title": "Catches 7D", "title": "Catches 7D",
"type": "integer" "type": "integer"
}, },
"coordinate_precision": {
"title": "Coordinate Precision",
"type": "string"
},
"coordinate_sources": {
"items": {
"type": "string"
},
"title": "Coordinate Sources",
"type": "array"
},
"description": { "description": {
"anyOf": [ "anyOf": [
{ {
@@ -1660,7 +1996,9 @@
"catches_24h", "catches_24h",
"catches_3d", "catches_3d",
"catches_7d", "catches_7d",
"top_baits" "top_baits",
"coordinate_precision",
"coordinate_sources"
], ],
"title": "SpotOut", "title": "SpotOut",
"type": "object" "type": "object"
@@ -1700,6 +2038,28 @@
}, },
"WaterbodyOut": { "WaterbodyOut": {
"properties": { "properties": {
"description": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Description"
},
"fish_species_count": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Fish Species Count"
},
"id": { "id": {
"format": "uuid", "format": "uuid",
"title": "Id", "title": "Id",
@@ -1713,6 +2073,107 @@
"title": "Slug", "title": "Slug",
"type": "string" "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": { "unlock_level": {
"anyOf": [ "anyOf": [
{ {
@@ -1729,7 +2190,17 @@
"id", "id",
"slug", "slug",
"name_ru", "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", "title": "WaterbodyOut",
"type": "object" "type": "object"
@@ -2651,6 +3122,283 @@
"summary": "Admin Start Official Import" "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/media/upgrades/publish": {
"post": {
"description": "Atomically publish all stored quality upgrades after an explicit decision.",
"operationId": "admin_publish_media_upgrades_api_v1_admin_media_upgrades_publish_post",
"parameters": [
{
"in": "header",
"name": "authorization",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AdminMediaDecision"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"additionalProperties": {
"type": "integer"
},
"title": "Response Admin Publish Media Upgrades Api V1 Admin Media Upgrades Publish Post",
"type": "object"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Admin Publish Media Upgrades"
}
},
"/api/v1/admin/media/upgrades/rollback": {
"post": {
"description": "Restore one superseded fallback while retaining the reviewed candidate.",
"operationId": "admin_rollback_media_upgrade_api_v1_admin_media_upgrades_rollback_post",
"parameters": [
{
"in": "header",
"name": "authorization",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AdminMediaRollback"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"additionalProperties": {
"type": "string"
},
"title": "Response Admin Rollback Media Upgrade Api V1 Admin Media Upgrades Rollback Post",
"type": "object"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Admin Rollback Media Upgrade"
}
},
"/api/v1/admin/moderation-history": { "/api/v1/admin/moderation-history": {
"get": { "get": {
"operationId": "admin_moderation_history_api_v1_admin_moderation_history_get", "operationId": "admin_moderation_history_api_v1_admin_moderation_history_get",
@@ -2781,6 +3529,57 @@
"summary": "Admin Moderation History Export" "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": { "/api/v1/baits": {
"get": { "get": {
"operationId": "baits_api_v1_baits_get", "operationId": "baits_api_v1_baits_get",
@@ -3156,6 +3955,90 @@
"summary": "Imports" "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": { "/api/v1/public-spot-pages": {
"get": { "get": {
"operationId": "public_spot_pages_api_v1_public_spot_pages_get", "operationId": "public_spot_pages_api_v1_public_spot_pages_get",
+71
View File
@@ -0,0 +1,71 @@
from datetime import datetime, timedelta, timezone
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.database import Base
from app.models import CatchReport, CatchTackleComponent, Fish, ModerationStatus, SourceType, Spot, Waterbody
from app.routers.analytics import tackle_combinations
def test_tackle_recommendation_requires_samples_and_independent_players() -> None:
now = datetime.now(timezone.utc)
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
with Session(engine) as db:
waterbody = Waterbody(slug="lake", name_ru="Озеро", unlock_level=1)
fish = Fish(slug="pike", name_ru="Щука", trophy_weight_g=10_000)
spot = Spot(waterbody=waterbody, x=10, y=20)
db.add_all([waterbody, fish, spot])
db.flush()
for index, player in enumerate(("One", "One", "Two")):
report = CatchReport(
fish=fish, waterbody=waterbody, spot=spot, weight_g=1000,
caught_at=now - timedelta(hours=1), reported_at=now - timedelta(hours=1),
player_name=player, source_type=SourceType.user, source_confidence=80,
moderation_status=ModerationStatus.approved,
)
report.tackle_components.append(CatchTackleComponent(role="lure", position=0, raw_value="Spinner #1"))
db.add(report)
db.commit()
rows = tackle_combinations(db, waterbody="lake", fish="pike", method=None, hours=72, min_samples=3, min_players=2)
assert len(rows) == 1
assert (rows[0].status, rows[0].catches, rows[0].unique_players) == ("recommendation", 3, 2)
rows = tackle_combinations(db, waterbody="lake", fish="pike", method=None, hours=72, min_samples=3, min_players=3)
assert rows[0].status == "insufficient_data"
engine.dispose()
def test_tackle_analytics_handles_empty_and_multicomponent_observations() -> None:
now = datetime.now(timezone.utc)
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
with Session(engine) as db:
waterbody = Waterbody(slug="empty-check", name_ru="Проверка", unlock_level=1)
fish = Fish(slug="perch", name_ru="Окунь", trophy_weight_g=5_000)
spot = Spot(waterbody=waterbody, x=1, y=2)
report = CatchReport(
fish=fish, waterbody=waterbody, spot=spot, weight_g=500,
caught_at=now, reported_at=now, player_name="Player",
source_type=SourceType.user, source_confidence=80,
moderation_status=ModerationStatus.approved,
)
report.tackle_components.extend([
CatchTackleComponent(role="lure", position=0, raw_value="Spinner #1"),
CatchTackleComponent(role="rig", position=1, raw_value="Rig #1"),
CatchTackleComponent(role="lure", position=2, raw_value="Spinner #1"),
CatchTackleComponent(role="lure", position=3, raw_value=" "),
])
db.add(report)
db.commit()
rows = tackle_combinations(db, waterbody="empty-check", fish="perch", method=None, hours=72, min_samples=1, min_players=1)
assert {(row.role, row.value, row.catches) for row in rows} == {
("lure", "Spinner #1", 1), ("rig", "Rig #1", 1),
}
assert tackle_combinations(db, waterbody="missing", fish=None, method=None, hours=72) == []
engine.dispose()
+80 -2
View File
@@ -12,7 +12,8 @@ from app.database import Base, get_session
from app.community_importer import stage_observations from app.community_importer import stage_observations
from app.importer import ImportAlreadyRunning from app.importer import ImportAlreadyRunning
from app.main import app 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.models import Bait, BaitKind, CatchReport, CatchTackleComponent, 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) engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
@@ -56,11 +57,37 @@ def test_activity_filters_and_explains_score() -> None:
assert payload["items"][0]["sources"] == ["manual-import"] 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: def test_invalid_period_is_rejected() -> None:
assert client.get("/api/v1/activity?hours=13").status_code == 422 assert client.get("/api/v1/activity?hours=13").status_code == 422
assert client.get("/api/v1/activity?sort=unknown").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"
variant = client.get(item["variants"][0]["url"])
assert variant.status_code == 200
assert variant.headers["content-type"].startswith("image/")
assert client.get("/api/v1/media/assets/not-a-hash").status_code == 404
def test_review_queue_filters_before_pagination() -> None: def test_review_queue_filters_before_pagination() -> None:
with Session(engine) as db: with Session(engine) as db:
stage_observations(db, [{ stage_observations(db, [{
@@ -139,6 +166,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()) 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: def test_liveness_does_not_probe_dependencies() -> None:
response = client.get("/health?token=must-not-be-logged") response = client.get("/health?token=must-not-be-logged")
assert response.json() == {"status": "ok"} assert response.json() == {"status": "ok"}
@@ -276,11 +351,14 @@ def test_records_pagination_returns_correct_total_and_offset() -> None:
def test_user_report_requires_moderation_before_activity() -> None: def test_user_report_requires_moderation_before_activity() -> None:
created = client.post("/api/v1/catch-reports", json={"fish_slug": "pike", "waterbody_slug": "test-lake", "x": 77, "y": 88, "weight_g": 5500, "bait_name": "Новая приманка", "player_name": "Reporter"}) created = client.post("/api/v1/catch-reports", json={"fish_slug": "pike", "waterbody_slug": "test-lake", "x": 77, "y": 88, "weight_g": 5500, "bait_name": "Новая приманка", "rig_type": "Спиннинг", "player_name": "Reporter"})
assert created.status_code == 201 assert created.status_code == 201
assert created.headers["Cache-Control"] == "no-store" assert created.headers["Cache-Control"] == "no-store"
assert created.json()["moderation_status"] == "pending" assert created.json()["moderation_status"] == "pending"
report_id = created.json()["id"] report_id = created.json()["id"]
with Session(engine) as db:
components = db.scalars(select(CatchTackleComponent).where(CatchTackleComponent.catch_report_id == UUID(report_id)).order_by(CatchTackleComponent.position)).all()
assert [(component.role, component.raw_value) for component in components] == [("lure", "Новая приманка"), ("rig", "Спиннинг")]
headers = {"Authorization": "Bearer change-me-in-production"} headers = {"Authorization": "Bearer change-me-in-production"}
pending = client.get("/api/v1/admin/catch-reports", headers=headers) pending = client.get("/api/v1/admin/catch-reports", headers=headers)
assert pending.status_code == 200 assert pending.status_code == 200
+22 -1
View File
@@ -3,7 +3,7 @@ from datetime import datetime, timezone
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import Session 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.database import Base
from app.models import CatchReport, Fish, ModerationStatus, SourceType, Spot, Waterbody 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["reports"] == 1
assert result["invalid_coordinates"] == 1 assert result["invalid_coordinates"] == 1
assert result["failures"] == 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
@@ -0,0 +1,31 @@
from datetime import datetime, timezone
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.database import Base
from app.models import CatchReport, CatchTackleComponent, Fish, ModerationStatus, SourceType, Waterbody
def test_catch_keeps_ordered_unresolved_gear_evidence() -> None:
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
with Session(engine) as session:
fish = Fish(slug="pike", name_ru="Щука")
waterbody = Waterbody(slug="lake", name_ru="Озеро")
report = CatchReport(
fish=fish, waterbody=waterbody, weight_g=1000,
reported_at=datetime(2026, 9, 20, tzinfo=timezone.utc), source_type=SourceType.manual_import,
source_confidence=50, moderation_status=ModerationStatus.pending,
)
report.tackle_components.extend([
CatchTackleComponent(role="lure", position=0, raw_value="Spiker #2"),
CatchTackleComponent(role="rig", position=1, raw_value="Method Popup"),
])
session.add(report)
session.commit()
saved = session.get(CatchReport, report.id)
assert saved is not None
assert [(row.position, row.role, row.raw_value) for row in saved.tackle_components] == [
(0, "lure", "Spiker #2"), (1, "rig", "Method Popup"),
]
+135 -5
View File
@@ -7,11 +7,11 @@ import pytest
from sqlalchemy import create_engine, func, select from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session 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.community_review import ExternalReviewError, map_observation, publish_observation, suggest_aliases
from app.source_lifecycle import record_source_check from app.source_lifecycle import record_scheduled_source_check, record_source_check
from app.database import Base from app.database import Base
from app.models import CatchReport, DataSource, ExternalEntityAlias, ExternalObservation, Fish, Waterbody from app.models import CatchReport, CatchTackleComponent, DataSource, ExternalEntityAlias, ExternalObservation, Fish, Waterbody
from rf4_research.community_sources import parse_rf4db_catches, parse_rf4map_point, parse_rf4posts_spot from rf4_research.community_sources import parse_rf4db_catches, parse_rf4map_point, parse_rf4posts_spot
@@ -42,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 @pytest.fixture
def db() -> Session: def db() -> Session:
engine = create_engine("sqlite://") engine = create_engine("sqlite://")
@@ -69,6 +83,77 @@ def test_staging_is_idempotent_and_preserves_first_seen(db: Session) -> None:
assert source is not None and source.enabled is True 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_accepts_authorized_download_subdomain(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://download.rf4db.com/ru/maps/level_001_mosquito",
"name": "оз. Комариное",
"description": None,
"aliases": [],
"fish_species": ["Щука"],
"image_urls": [],
"point_urls": [],
}) is True
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: def test_external_ids_are_isolated_by_source(db: Session) -> None:
created, updated = stage_observations(db, [record("rf4db"), record("rf4stat-fishing")]) created, updated = stage_observations(db, [record("rf4db"), record("rf4stat-fishing")])
@@ -104,14 +189,28 @@ def test_complete_observation_with_reviewed_aliases_is_published(db: Session) ->
assert item.catch_report is not None assert item.catch_report is not None
assert item.catch_report.fish_id == fish.id assert item.catch_report.fish_id == fish.id
assert item.catch_report.waterbody_id == waterbody.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) assert stage_observations(db, [record() | {"weight_g": 5_000}]) == (0, 1)
db.refresh(item) db.refresh(item)
assert item.status == "published" assert item.status == "published"
assert db.scalar(select(func.count()).select_from(CatchReport)) == 1 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: def test_changed_published_record_requires_review_and_reuses_report(db: Session) -> None:
fish = Fish(slug="pike", name_ru="Щука") fish = Fish(slug="pike", name_ru="Щука")
water = Waterbody(slug="test-lake", name_ru="Тестовое озеро") water = Waterbody(slug="test-lake", name_ru="Тестовое озеро")
@@ -140,6 +239,8 @@ def test_changed_published_record_requires_review_and_reuses_report(db: Session)
assert updated.id == report_id assert updated.id == report_id
assert updated.weight_g == 6000 assert updated.weight_g == 6000
assert updated.moderation_status.value == "approved" assert updated.moderation_status.value == "approved"
components = db.scalars(select(CatchTackleComponent).order_by(CatchTackleComponent.position)).all()
assert [(component.position, component.raw_value) for component in components] == [(0, "Приманка")]
assert db.scalar(select(func.count()).select_from(CatchReport)) == 1 assert db.scalar(select(func.count()).select_from(CatchReport)) == 1
@@ -186,6 +287,35 @@ def test_non_authoritative_source_failures_do_not_withdraw(db: Session, status:
assert item.source_check_status == status 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: 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) source = DataSource(key="rf4db", name="RF4DB", base_url="https://rf4db.com", default_confidence=70, enabled=False)
fish = Fish(slug="pike", name_ru="Щука") fish = Fish(slug="pike", name_ru="Щука")
@@ -1,10 +1,12 @@
import pytest import pytest
from pydantic import ValidationError from pydantic import ValidationError
from urllib.error import HTTPError
from datetime import datetime, timedelta, timezone 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.community_scheduler import MAX_BACKOFF_SECONDS, configured_sources, _static_registry, oldest_site_source, retry_delay
from app.config import Settings from app.config import Settings
from app.source_lifecycle import classify_source_failure
def test_all_authorized_sources_are_scheduled() -> None: 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 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: def test_same_site_endpoints_rotate_by_oldest_attempt() -> None:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
all_keys = {"rf4db", "rf4stat-fishing", "rf4stat-post", "rf4map", "rf4posts-spot"} all_keys = {"rf4db", "rf4stat-fishing", "rf4stat-post", "rf4map", "rf4posts-spot"}
+2 -1
View File
@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
from app.database import Base from app.database import Base
from app.importer import FetchResult, ImportAlreadyRunning, ImportSourceError, _lock_key, _official_import_lock, import_records, parse_html from app.importer import FetchResult, ImportAlreadyRunning, ImportSourceError, _lock_key, _official_import_lock, import_records, parse_html
from app.models import CatchReport, ImportStatus, OfficialRecordImport, SourceType from app.models import CatchReport, CatchTackleComponent, ImportStatus, OfficialRecordImport, SourceType
FIXTURE = Path(__file__).parents[3] / "tests" / "fixtures" / "records_ru_sample.html" FIXTURE = Path(__file__).parents[3] / "tests" / "fixtures" / "records_ru_sample.html"
@@ -50,6 +50,7 @@ def test_parser_and_import_are_idempotent() -> None:
# A12: Second import of identical data creates no events (no fields changed) # A12: Second import of identical data creates no events (no fields changed)
assert (second.rows_created, second.rows_updated) == (0, 0) assert (second.rows_created, second.rows_updated) == (0, 0)
assert db.scalar(select(func.count()).select_from(CatchReport).where(CatchReport.source_type == SourceType.official_record)) == 2 assert db.scalar(select(func.count()).select_from(CatchReport).where(CatchReport.source_type == SourceType.official_record)) == 2
assert db.scalar(select(func.count()).select_from(CatchTackleComponent)) == 2
assert db.scalar(select(func.count()).select_from(OfficialRecordImport)) == 2 assert db.scalar(select(func.count()).select_from(OfficialRecordImport)) == 2
+32
View File
@@ -0,0 +1,32 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.database import Base
from app.models import Rig, RigComponent, TackleItem
from app.routers.catalog import rig_detail, tackle_item, tackle_items
def test_tackle_catalog_filters_details_and_missing_fields() -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
with Session(engine) as db:
item = TackleItem(
name="API тестовая блесна", normalized_name="api тестовая блесна",
category="lure", subcategory="spinner", brand="RF4", family=None,
unlock_level=0, source_system="fixture", source_external_id="api-lure-1",
source_url="https://example.test/lure/api-lure-1",
)
rig = Rig(name="API тестовый монтаж", normalized_name="api тестовый монтаж", source_system="fixture")
rig.components.append(RigComponent(role="lure", position=0, tackle_item=item, raw_value=item.name))
db.add(rig)
db.commit()
page = tackle_items(db, category="lure", brand="RF4", family=None, unlock_level=None, limit=10, offset=0)
assert page.total == 1
assert page.items[0].missing_fields == ["family", "source_checked_at"]
assert tackle_item(item.id, db).name == item.name
details = rig_detail(rig.id, db)
assert details.components[0].raw_value == item.name
assert details.missing_fields == ["source_url", "source_checked_at"]
engine.dispose()
+35
View File
@@ -0,0 +1,35 @@
import uuid
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.database import Base
from app.models import Rig, RigComponent, TackleItem
def test_tackle_item_and_rig_components_keep_provenance_and_legacy_independence() -> None:
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
item_id = uuid.uuid4()
rig_id = uuid.uuid4()
with Session(engine) as session:
item = TackleItem(
id=item_id, name="Spiker #2", normalized_name="spiker #2",
category="lure", subcategory="spinner", brand="RF4", family="spoon",
unlock_level=0, source_system="rf4db", source_external_id="spiker-2",
source_url="https://rf4db.com/ru/wiki/lures/spiker-2",
raw_payload={"weight": {"state": "value", "value": 0}},
)
rig = Rig(
id=rig_id, name="Method Popup", normalized_name="method popup",
source_system="rf4db", source_external_id="method-popup",
)
rig.components.append(RigComponent(role="lure", position=0, tackle_item=item, raw_value="Spiker #2"))
session.add(rig)
session.commit()
saved = session.get(TackleItem, item_id)
assert saved is not None
assert saved.unlock_level == 0
assert saved.raw_payload == {"weight": {"state": "value", "value": 0}}
assert saved.rig_components[0].rig_id == rig_id
+3 -2
View File
@@ -8,6 +8,7 @@ import TackleGlyph from "./TackleGlyph.astro";
const { item } = Astro.props as { item: Activity }; const { item } = Astro.props as { item: Activity };
const level = activityLevel(item.activity_score); const level = activityLevel(item.activity_score);
const limited = item.catches < 3; 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)}> <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> <span class="spot-rank">{String(item.activity_score).padStart(2,"0")}</span>
@@ -15,9 +16,9 @@ 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> <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> <h3>{item.fish}</h3>
<FishSilhouette name={item.fish}/> <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> <p class="data-note">{item.explanation}</p>
<DataPassport sources={item.sources} observedAt={item.last_confirmed_at} confidence={item.confidence_score}/> <DataPassport sources={item.sources} observedAt={item.last_confirmed_at} confidence={item.confidence_score} sampleSize={item.catches} independentPlayers={item.unique_players} coordinatePrecision={item.coordinate_precision} status={limited ? "incomplete" : "verified"}/>
<div class="bait-line"><TackleGlyph name={item.best_bait}/><div><span>Работает сейчас</span><strong>{item.best_bait ?? "не указана"}</strong></div></div> <div class="bait-line"><TackleGlyph name={item.best_bait}/><div><span>Работает сейчас</span><strong>{item.best_bait ?? "не указана"}</strong></div></div>
</div> </div>
<div class="spot-stats"><div><strong>{item.catches}</strong><span>{plural(item.catches, ["улов", "улова", "уловов"])}</span></div><div><strong>{item.unique_players}</strong><span>{plural(item.unique_players, ["игрок", "игрока", "игроков"])}</span></div><div><strong>{kg(item.average_weight_g)}</strong><span>средний вес</span></div><div><strong>{item.confidence_score}%</strong><span>уверенность</span></div></div><span class="card-arrow"><FishingIcon name="arrow" size={22}/></span> <div class="spot-stats"><div><strong>{item.catches}</strong><span>{plural(item.catches, ["улов", "улова", "уловов"])}</span></div><div><strong>{item.unique_players}</strong><span>{plural(item.unique_players, ["игрок", "игрока", "игроков"])}</span></div><div><strong>{kg(item.average_weight_g)}</strong><span>средний вес</span></div><div><strong>{item.confidence_score}%</strong><span>уверенность</span></div></div><span class="card-arrow"><FishingIcon name="arrow" size={22}/></span>
+12
View File
@@ -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>
+4 -3
View File
@@ -1,13 +1,14 @@
--- ---
import SourceBadge from "./SourceBadge.astro"; import SourceBadge from "./SourceBadge.astro";
import { ago } from "../lib/api"; import { ago } from "../lib/api";
type Props = { sources: string[]; sourceUrl?: string | null; observedAt: string; completeness?: number | null; confidence?: number | null; status?: "verified" | "unverified" | "incomplete" }; type Props = { sources: string[]; sourceUrl?: string | null; observedAt?: string | null; completeness?: number | null; confidence?: number | null; sampleSize?: number | null; independentPlayers?: number | null; coordinatePrecision?: "exact" | "approximate" | "area" | "missing" | null; status?: "verified" | "unverified" | "incomplete" };
const { sources, sourceUrl, observedAt, completeness = null, confidence = null, status = "verified" } = Astro.props; const { sources, sourceUrl, observedAt, completeness = null, confidence = null, sampleSize = null, independentPlayers = null, coordinatePrecision = null, status = "verified" } = Astro.props as Props;
const statusLabels = { verified: "Учтено", unverified: "Ждёт проверки", incomplete: "Неполные данные" }; const statusLabels = { verified: "Учтено", unverified: "Ждёт проверки", incomplete: "Неполные данные" };
const completenessLabel = completeness == null ? "Не рассчитана" : `${Math.min(100, Math.max(0, completeness))}% полей`; const completenessLabel = completeness == null ? "Не рассчитана" : `${Math.min(100, Math.max(0, completeness))}% полей`;
const precisionLabels = { exact: "точные", approximate: "приблизительные", area: "район", missing: "не указаны" };
--- ---
<section class="data-passport" aria-label="Паспорт данных"> <section class="data-passport" aria-label="Паспорт данных">
<header><span>Паспорт данных</span><strong data-passport-status={status}>{statusLabels[status]}</strong></header> <header><span>Паспорт данных</span><strong data-passport-status={status}>{statusLabels[status]}</strong></header>
<div class="data-passport__sources">{sources.map(source => <SourceBadge source={source} href={sources.length === 1 ? sourceUrl : null}/>)}</div> <div class="data-passport__sources">{sources.map(source => <SourceBadge source={source} href={sources.length === 1 ? sourceUrl : null}/>)}</div>
<dl><div><dt>Свежесть</dt><dd>{ago(observedAt)}</dd></div><div><dt>Полнота</dt><dd>{completenessLabel}</dd></div><div><dt>Доверие</dt><dd>{confidence == null ? "После проверки" : `${confidence}%`}</dd></div></dl> <dl><div><dt>Свежесть</dt><dd>{observedAt ? ago(observedAt) : "Не указана"}</dd></div><div><dt>Полнота</dt><dd>{completenessLabel}</dd></div><div><dt>Доверие</dt><dd>{confidence == null ? "После проверки" : `${confidence}%`}</dd></div>{sampleSize != null && <div><dt>Наблюдения</dt><dd>{sampleSize}</dd></div>}{independentPlayers != null && <div><dt>Игроки</dt><dd>{independentPlayers}</dd></div>}{coordinatePrecision && <div><dt>Координаты</dt><dd>{precisionLabels[coordinatePrecision]}</dd></div>}</dl>
</section> </section>
+15
View File
@@ -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>
+27
View File
@@ -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>}
+48 -3
View File
@@ -11,9 +11,12 @@ import "../styles/tackle-glyph.css";
import "../styles/empty-states.css"; import "../styles/empty-states.css";
import "../styles/alpha-banner.css"; import "../styles/alpha-banner.css";
import "../styles/signal-pagination.css"; import "../styles/signal-pagination.css";
import "../styles/pagination.css";
import "../styles/dashboard-polish.css"; import "../styles/dashboard-polish.css";
import "../styles/loading-states.css"; import "../styles/loading-states.css";
import "../styles/query-context.css";
import "../styles/theme.css"; import "../styles/theme.css";
import "../styles/media-catalog.css";
import FishingIcon from "../components/FishingIcon.astro"; import FishingIcon from "../components/FishingIcon.astro";
import AlphaBanner from "../components/AlphaBanner.astro"; import AlphaBanner from "../components/AlphaBanner.astro";
const { const {
@@ -30,7 +33,12 @@ const theme = storedTheme === "light" || storedTheme === "dark" ? storedTheme :
const siteUrl = import.meta.env.PUBLIC_SITE_URL || "https://rf4spotter.ru"; const siteUrl = import.meta.env.PUBLIC_SITE_URL || "https://rf4spotter.ru";
const canonical = new URL(path, siteUrl).toString(); const canonical = new URL(path, siteUrl).toString();
const socialImage = new URL(image, 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" }; const websiteJsonLd = { "@type": "WebSite", name: "RF4 Spotter", url: siteUrl, inLanguage: "ru" };
// A08: Skip structuredData on error pages (explicit errorPage prop) // A08: Skip structuredData on error pages (explicit errorPage prop)
// Don't infer error from noindex alone — main page can have noindex on 422 // Don't infer error from noindex alone — main page can have noindex on 422
@@ -39,6 +47,43 @@ const jsonLd = JSON.stringify({
"@context": "https://schema.org", "@context": "https://schema.org",
"@graph": jsonLdGraph, "@graph": jsonLdGraph,
}).replaceAll("<", "\\u003c"); }).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> <!doctype html>
<html lang="ru" data-theme={theme === "system" ? undefined : theme}> <html lang="ru" data-theme={theme === "system" ? undefined : theme}>
@@ -70,14 +115,14 @@ const jsonLd = JSON.stringify({
<meta name="twitter:description" content={description} /> <meta name="twitter:description" content={description} />
<meta name="twitter:image" content={socialImage} /> <meta name="twitter:image" content={socialImage} />
<meta name="twitter:image:alt" content="Лаймовый поплавок на тёмном озере с координатной сеткой" /> <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> <title>{title}</title>
</head> </head>
<body> <body>
<a class="skip-link" href="#main-content">Перейти к содержимому</a> <a class="skip-link" href="#main-content">Перейти к содержимому</a>
<header class="topbar"> <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> <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") || path.startsWith("/admin/media")}} aria-current={path.startsWith("/media") || path.startsWith("/admin/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"> <div class="header-tools">
<p class="live-badge"><span></span> Свежие данные и честная оценка</p> <p class="live-badge"><span></span> Свежие данные и честная оценка</p>
<div class="theme-switcher" role="group" aria-label="Цветовая тема"> <div class="theme-switcher" role="group" aria-label="Цветовая тема">
+11
View File
@@ -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;
}
+7 -2
View File
@@ -4,6 +4,7 @@ export type Activity = {
unique_players: number; average_weight_g: number; max_weight_g: number; unique_players: number; average_weight_g: number; max_weight_g: number;
last_confirmed_at: string; activity_score: number; confidence_score: number; last_confirmed_at: string; activity_score: number; confidence_score: number;
explanation: string; sources: string[]; explanation: string; sources: string[];
coordinate_precision: "exact" | "approximate" | "area" | "missing"; coordinate_sources: string[];
}; };
export type PaginatedActivity = { export type PaginatedActivity = {
@@ -13,9 +14,11 @@ export type PaginatedActivity = {
offset: number; 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 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 TackleItem = { id: string; name: string; category: string; subcategory: string | null; brand: string | null; family: string | null; unlock_level: number | null; source_system: string | null; source_external_id: string | null; source_url: string | null; source_checked_at: string | null; missing_fields: string[] };
export type PaginatedTackleItems = { items: TackleItem[]; total: number; limit: number; offset: number };
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 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 = { export type PaginatedOfficialRecord = {
items: OfficialRecord[]; items: OfficialRecord[];
@@ -26,6 +29,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 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 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 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}`; export const spotPath = (item: Pick<Activity, "waterbody_slug" | "x" | "y">) => `/spots/${item.waterbody_slug}-${item.x}x${item.y}`;
+23
View File
@@ -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;
};
+31
View File
@@ -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 Layout from "../../layouts/Layout.astro";
import AdminNav from "../../components/AdminNav.astro";
const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000"; const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
--- ---
<Layout title="Внешние источники — RF4 Spotter"> <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="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}> <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> <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> <p class="privacy">Токен остаётся только в памяти страницы. Исходная ссылка и происхождение сохраняются при публикации.</p>
<div class="admin-session-bar" hidden><span>Административная сессия активна</span><button data-action="secondary" type="button" data-admin-logout>Выйти</button></div> <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> <nav data-pages aria-label="Страницы очереди" hidden>
<button data-action="secondary" type="button" data-previous>Предыдущая</button> <button data-action="secondary" type="button" data-previous>Предыдущая</button>
<span data-page-number aria-live="polite"></span> <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> </nav>
<p class="admin-shortcuts"><kbd>S</kbd> подсказать · <kbd>M</kbd> сопоставить · <kbd>P</kbd> опубликовать карточку с фокусом</p> <p class="admin-shortcuts"><kbd>S</kbd> подсказать · <kbd>M</kbd> сопоставить · <kbd>P</kbd> опубликовать карточку с фокусом</p>
</section> </section>
<script> <script>
import { adminEndsSession, adminErrorMessage } from "../../lib/admin-errors";
const root = document.querySelector<HTMLElement>(".moderation-app"); const root = document.querySelector<HTMLElement>(".moderation-app");
const login = document.querySelector<HTMLFormElement>(".admin-login"); const login = document.querySelector<HTMLFormElement>(".admin-login");
const list = document.querySelector<HTMLElement>("[data-external-list]"); 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 pages = document.querySelector<HTMLElement>("[data-pages]");
const previous = document.querySelector<HTMLButtonElement>("[data-previous]"); const previous = document.querySelector<HTMLButtonElement>("[data-previous]");
const next = document.querySelector<HTMLButtonElement>("[data-next]"); const next = document.querySelector<HTMLButtonElement>("[data-next]");
const refresh = document.querySelector<HTMLButtonElement>("[data-refresh]");
const pageNumber = document.querySelector<HTMLElement>("[data-page-number]"); const pageNumber = document.querySelector<HTMLElement>("[data-page-number]");
const sessionBar = document.querySelector<HTMLElement>(".admin-session-bar"); const sessionBar = document.querySelector<HTMLElement>(".admin-session-bar");
const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]"); 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 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 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(""); 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() { async function loadQueue() {
if (!root || !list) return; error?.setAttribute("hidden", ""); setLoading(true); list.innerHTML = loadingCards(); 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(""); } 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(""); }
@@ -89,6 +93,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
} }
previous?.addEventListener("click", () => changePage(-50)); previous?.addEventListener("click", () => changePage(-50));
next?.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 : "Ошибка загрузки."); } }); 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 : "Ошибка фильтрации."); } }); 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("Вы вышли из административной панели.")); logout?.addEventListener("click", () => endSession("Вы вышли из административной панели."));
+18 -12
View File
@@ -1,52 +1,58 @@
--- ---
import Layout from "../../layouts/Layout.astro"; import Layout from "../../layouts/Layout.astro";
import AdminNav from "../../components/AdminNav.astro";
const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000"; const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
--- ---
<Layout title="Административная панель — RF4 Spotter"> <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="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> <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> <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="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 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> <section class="admin-dashboard-content" aria-live="polite"></section>
</main> </section>
<script> <script>
import { adminEndsSession, adminErrorMessage } from "../../lib/admin-errors";
const root = document.querySelector<HTMLElement>(".admin-dashboard"); const root = document.querySelector<HTMLElement>(".admin-dashboard");
const login = document.querySelector<HTMLFormElement>(".admin-login"); const login = document.querySelector<HTMLFormElement>(".admin-login");
const content = document.querySelector<HTMLElement>(".admin-dashboard-content"); const content = document.querySelector<HTMLElement>(".admin-dashboard-content");
const error = document.querySelector<HTMLElement>("[data-admin-error]"); 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 sessionBar = document.querySelector<HTMLElement>(".admin-session-bar");
const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]"); const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]");
let token = ""; let token = "";
let sessionTimer: ReturnType<typeof setTimeout> | undefined; let sessionTimer: ReturnType<typeof setTimeout> | undefined;
const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&amp;","<":"&lt;",">":"&gt;","'":"&#39;",'"':"&quot;"}[char] ?? char)); const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&amp;","<":"&lt;",">":"&gt;","'":"&#39;",'"':"&quot;"}[char] ?? char));
const fail = (message: string) => { if (error) { error.textContent = message; error.hidden = false; } }; 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 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); }; 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 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 publicJson(path: string) { const response = await fetch(`${root?.dataset.apiUrl}${path}`); return response.ok ? response.json() : []; }
async function loadDashboard() { async function loadDashboard() {
if (!content) return; 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 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 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 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 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 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 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 actionLabels: Record<string, string> = {approved:"Одобрено",rejected:"Отклонено",pending:"Возвращено на проверку",published:"Опубликовано",mapped:"Сопоставлено",ready:"Готово"};
const typeLabels: Record<string, string> = {catch_report:"Улов",external_observation:"Внешнее наблюдение"}; 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(""); 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 : "Ошибка загрузки."); } }); 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("Вы вышли из административной панели.")); logout?.addEventListener("click", () => endSession("Вы вышли из административной панели."));
content?.addEventListener("click", async event => { 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; 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(); } 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 { fail("Не удалось выгрузить журнал решений."); } catch (cause) { fail(cause instanceof Error ? cause.message : "Операция не выполнена."); }
finally { button.disabled = false; } finally { button.disabled = false; }
}); });
</script> </script>
+60
View File
@@ -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 => ({"&":"&amp;","<":"&lt;",">":"&gt;","'":"&#39;",'"':"&quot;"}[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>
+25 -6
View File
@@ -1,25 +1,35 @@
--- ---
import Layout from "../../layouts/Layout.astro"; import Layout from "../../layouts/Layout.astro";
import AdminNav from "../../components/AdminNav.astro";
const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000"; const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
--- ---
<Layout title="Модерация уловов — RF4 Spotter"> <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="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}> <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> <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> <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="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> <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> <p class="admin-shortcuts"><kbd>A</kbd> одобрить карточку с фокусом · отклонение и удаление — только кнопками</p>
</section> </section>
<script> <script>
import { adminEndsSession, adminErrorMessage } from "../../lib/admin-errors";
const root = document.querySelector<HTMLElement>(".moderation-app"); const root = document.querySelector<HTMLElement>(".moderation-app");
const login = document.querySelector<HTMLFormElement>(".admin-login"); const login = document.querySelector<HTMLFormElement>(".admin-login");
const list = document.querySelector<HTMLElement>("[data-moderation-list]"); const list = document.querySelector<HTMLElement>("[data-moderation-list]");
const error = document.querySelector<HTMLElement>("[data-admin-error]"); const error = document.querySelector<HTMLElement>("[data-admin-error]");
const status = document.querySelector<HTMLElement>("[data-admin-status]"); 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 sessionBar = document.querySelector<HTMLElement>(".admin-session-bar");
const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]"); const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]");
let token = ""; let token = "";
let offset = 0;
let sessionTimer: ReturnType<typeof setTimeout> | undefined; 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 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)); 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() { async function loadQueue() {
if (!root || !list) return; if (!root || !list) return;
error?.setAttribute("hidden", ""); setLoading(true); list.innerHTML = loadingCards(); 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}`}}); const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/catch-reports?status=pending&limit=51&offset=${offset}`, {headers:{Authorization:`Bearer ${token}`} });
if (response.status === 401) { endSession(); throw new Error("Неверный или истёкший административный токен."); } if (!response.ok) { if (adminEndsSession(response.status)) endSession(); throw new Error(adminErrorMessage(response.status, "Не удалось загрузить очередь.")); }
if (!response.ok) throw new Error("Не удалось загрузить очередь."); const rows: Record<string, unknown>[] = await response.json();
const reports: Record<string, unknown>[] = await response.json();
keepSession(); keepSession();
setLoading(false); 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; } 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(""); 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("Вы вышли из административной панели.")); logout?.addEventListener("click", () => endSession("Вы вышли из административной панели."));
list?.addEventListener("click", async event => { 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; 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.dataset.decision === "rejected" && !reason) { fail("Укажите причину отклонения."); card.querySelector("textarea")?.focus(); return; }
if (button.hasAttribute("data-delete") && !window.confirm("Удалить и обезличить эту заявку? Действие нельзя отменить.")) return; if (button.hasAttribute("data-delete") && !window.confirm("Удалить и обезличить эту заявку? Действие нельзя отменить.")) return;
const cardButtons = card.querySelectorAll<HTMLButtonElement>("button"); cardButtons.forEach(item => item.disabled = true); 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(); } }); 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> </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 });
}
};
+8 -3
View File
@@ -1,15 +1,18 @@
--- ---
import ActivityCard from "../../components/ActivityCard.astro"; import ActivityCard from "../../components/ActivityCard.astro";
import EntityMedia from "../../components/EntityMedia.astro";
import AtlasBreadcrumbs from "../../components/AtlasBreadcrumbs.astro"; import AtlasBreadcrumbs from "../../components/AtlasBreadcrumbs.astro";
import AtlasEntityLink from "../../components/AtlasEntityLink.astro"; import AtlasEntityLink from "../../components/AtlasEntityLink.astro";
import PageHero from "../../components/PageHero.astro"; import PageHero from "../../components/PageHero.astro";
import StatePanel from "../../components/StatePanel.astro"; import StatePanel from "../../components/StatePanel.astro";
import Layout from "../../layouts/Layout.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; const { slug } = Astro.params;
let fish: DictionaryItem | undefined, items: Activity[] = [], unavailable = false; let fish: DictionaryItem | undefined, items: Activity[] = [], media: MediaAsset[] = [], unavailable = false;
try { 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); 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; } if (fish) { const paginated = await api<PaginatedActivity>(`/api/v1/activity?hours=72&fish=${encodeURIComponent(fish.slug)}&limit=100`); items = paginated.items; }
} catch { unavailable = true; } } catch { unavailable = true; }
@@ -20,10 +23,12 @@ if (unavailable) {
} }
if (!fish && !unavailable) Astro.response.status = 404; if (!fish && !unavailable) Astro.response.status = 404;
const waters = [...new Map(items.map(item => [item.waterbody_slug, item.waterbody])).entries()]; 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; 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}> <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 ?? "Не найдено" }]} /> <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} /> <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>} {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> </Layout>
+17 -5
View File
@@ -1,11 +1,22 @@
--- ---
import Layout from "../../layouts/Layout.astro"; import Layout from "../../layouts/Layout.astro";
import FishSilhouette from "../../components/FishSilhouette.astro"; import FishSilhouette from "../../components/FishSilhouette.astro";
import EntityMedia from "../../components/EntityMedia.astro";
import PageHero from "../../components/PageHero.astro"; import PageHero from "../../components/PageHero.astro";
import StatePanel from "../../components/StatePanel.astro"; import StatePanel from "../../components/StatePanel.astro";
import { api, type DictionaryItem } from "../../lib/api"; import Pagination from "../../components/Pagination.astro";
let fishes: DictionaryItem[] = [], unavailable = false; import { api, type DictionaryItem, type MediaAsset } from "../../lib/api";
try { fishes = await api<DictionaryItem[]>("/api/v1/fishes?limit=500"); } catch { unavailable = true; } 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) { if (unavailable) {
Astro.response.status = 503; Astro.response.status = 503;
Astro.response.headers.set("Retry-After", "60"); Astro.response.headers.set("Retry-After", "60");
@@ -13,6 +24,7 @@ if (unavailable) {
} }
--- ---
<Layout title="Все виды рыб Russian Fishing 4 — RF4 Spotter" description="Каталог рыб RF4 со свежими точками, уловами, приманками и прозрачными источниками данных."> <Layout title="Все виды рыб Russian Fishing 4 — RF4 Spotter" description="Каталог рыб RF4 со свежими точками, уловами, приманками и прозрачными источниками данных.">
<PageHero eyebrow="Справочник RF4" title="Рыбы" description="Выберите вид, чтобы увидеть свежие подтверждённые точки и полевые сигналы." variant="fish" count={fishes.length} /> <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) => <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>} {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> </Layout>
+14 -5
View File
@@ -6,6 +6,7 @@ import SourceBadge from "../components/SourceBadge.astro";
import TackleGlyph from "../components/TackleGlyph.astro"; import TackleGlyph from "../components/TackleGlyph.astro";
import SignalFeed from "../components/SignalFeed.astro"; import SignalFeed from "../components/SignalFeed.astro";
import StatePanel from "../components/StatePanel.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"; import { activityLevel, ago, api, kg, plural, type Activity, type DictionaryItem, type PaginatedActivity, type PublicObservation } from "../lib/api";
const params = Astro.url.searchParams; const params = Astro.url.searchParams;
@@ -13,8 +14,9 @@ const hours = params.get("hours") ?? "24";
const waterbody = params.get("waterbody") ?? ""; const waterbody = params.get("waterbody") ?? "";
const fish = params.get("fish") ?? ""; const fish = params.get("fish") ?? "";
const sort = params.get("sort") ?? "activity"; const sort = params.get("sort") ?? "activity";
const activityLimit = 20;
const requestedOffset = Number(params.get("offset") ?? 0); 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 requestedSignalLimit = Number(params.get("signals") ?? 12);
const signalLimit = Number.isInteger(requestedSignalLimit) ? Math.min(48, Math.max(12, requestedSignalLimit)) : 12; const signalLimit = Number.isInteger(requestedSignalLimit) ? Math.min(48, Math.max(12, requestedSignalLimit)) : 12;
let items: Activity[] = [], signals: PublicObservation[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = []; let items: Activity[] = [], signals: PublicObservation[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [];
@@ -38,11 +40,16 @@ if (!signalsUnavailable) {
signals = signalRows.slice(0, signalLimit); signals = signalRows.slice(0, signalLimit);
} }
if (!filterError) { 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 { try {
const paginated = await api<PaginatedActivity>(`/api/v1/activity?${query}`); const paginated = await api<PaginatedActivity>(`/api/v1/activity?${query}`);
items = offset > 0 ? [...items, ...paginated.items] : paginated.items; items = paginated.items;
totalItems = paginated.total; 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; } } catch { activityUnavailable = true; }
} }
const catalogUnavailable = fishCatalogUnavailable || waterCatalogUnavailable; const catalogUnavailable = fishCatalogUnavailable || waterCatalogUnavailable;
@@ -62,6 +69,7 @@ const selectedFish = fishes.find(item => item.slug === fish)?.name_ru ?? "Люб
const periodLabel = { "6": "6 часов", "12": "12 часов", "24": "24 часа", "72": "72 часа" }[hours] ?? hours; const periodLabel = { "6": "6 часов", "12": "12 часов", "24": "24 часа", "72": "72 часа" }[hours] ?? hours;
const sortLabel = { activity: "Сначала активные", confidence: "Сначала надёжные", freshness: "Сначала свежие" }[sort] ?? sort; const sortLabel = { activity: "Сначала активные", confidence: "Сначала надёжные", freshness: "Сначала свежие" }[sort] ?? sort;
const filtersChanged = Boolean(waterbody || fish || hours !== "24" || sort !== "activity"); const filtersChanged = Boolean(waterbody || fish || hours !== "24" || sort !== "activity");
const queryTitle = `${selectedFish} · ${selectedWaterbody}`;
const moreSignalParams = new URLSearchParams(params); const moreSignalParams = new URLSearchParams(params);
moreSignalParams.set("signals", String(Math.min(48, signalLimit + 12))); moreSignalParams.set("signals", String(Math.min(48, signalLimit + 12)));
const moreSignalsHref = `/?${moreSignalParams.toString()}#signals-title`; const moreSignalsHref = `/?${moreSignalParams.toString()}#signals-title`;
@@ -85,10 +93,11 @@ const datasetJsonLd = {
</div></details> </div></details>
<button data-action="primary">⌕ Найти клёв</button> <button data-action="primary">⌕ Найти клёв</button>
</form></section> </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="query-summary content-grid" aria-label="Контекст запроса" aria-live="polite"><div><span class="overline">Ваш запрос</span><h2 id="query-summary-title">{queryTitle}</h2><p>{periodLabel} · {sortLabel}</p></div><div class="query-summary__filters" aria-label="Применённые фильтры"><span>{selectedWaterbody}</span><span>{selectedFish}</span><span>{periodLabel}</span><span>{sortLabel}</span>{filtersChanged && <a href="/#results">Сбросить запрос</a>}</div></section>
<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>{filtersChanged && <a data-action="secondary" href="/#results">Показать все данные</a>}</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>} {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> </section>
{!filterError && !activityUnavailable && <Pagination path="/" params={params} total={totalItems} limit={activityLimit} offset={offset} anchor="#results" itemLabel="точек" />}
{signals.length > 0 && <SignalFeed signals={signals}/>} {signals.length > 0 && <SignalFeed signals={signals}/>}
{signalsUnavailable && <div class="content-grid"><StatePanel tone="unavailable" title="Полевые сигналы временно недоступны" description="Горячие точки и справочники продолжают работать независимо." /></div>} {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>} {hasMoreSignals && signalLimit < 48 && <a class="signal-more" data-action="secondary" href={moreSignalsHref}>Показать ещё <span>{signalLimit} из доступных</span> ↓</a>}
+20
View File
@@ -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>
+12 -5
View File
@@ -4,19 +4,26 @@ import SourceBadge from "../components/SourceBadge.astro";
import SectionHeading from "../components/SectionHeading.astro"; import SectionHeading from "../components/SectionHeading.astro";
import StatePanel from "../components/StatePanel.astro"; import StatePanel from "../components/StatePanel.astro";
import TackleGlyph from "../components/TackleGlyph.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"; import { api, kg, type DictionaryItem, type ImportRun, type OfficialRecord, type PaginatedOfficialRecord } from "../lib/api";
const params = Astro.url.searchParams; const params = Astro.url.searchParams;
const fish = params.get("fish") ?? ""; const fish = params.get("fish") ?? "";
const waterbody = params.get("waterbody") ?? ""; const waterbody = params.get("waterbody") ?? "";
const recordsLimit = 50;
const requestedOffset = Number(params.get("offset") ?? 0); 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 items: OfficialRecord[] = [], runs: ImportRun[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [];
let unavailable = false, showNoIndex = false, totalRecords = 0; let unavailable = false, showNoIndex = false, totalRecords = 0;
try { 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}`); const paginated = await api<PaginatedOfficialRecord>(`/api/v1/records?${query}`);
items = offset > 0 ? [...items, ...paginated.items] : paginated.items; items = paginated.items;
totalRecords = paginated.total; 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")]); [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"); } } catch { unavailable = true; showNoIndex = true; Astro.response.status = 503; Astro.response.headers.set("Retry-After", "60"); }
const last = runs[0]; 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}> <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> <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> <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 && <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 && <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="Сбросить фильтры" />} {!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> <p class="official-note">Источник: <a href="https://rf4game.de/records/region/RU/" rel="noreferrer">официальный сайт Russian Fishing 4</a>. Координаты в официальных таблицах отсутствуют.</p>
</Layout> </Layout>
+1 -1
View File
@@ -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" } }); 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); if (lastGood?.origin === origin && Date.now() - lastGood.at < 1800000) return output(lastGood.xml);
try { 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 (const [endpoint, prefix] of [["fishes", "fish"], ["waterbodies", "waterbodies"]]) {
for (let offset = 0; ; offset += 500) { for (let offset = 0; ; offset += 500) {
const rows = await api<DictionaryItem[]>(`/api/v1/${endpoint}?limit=500&offset=${offset}`); const rows = await api<DictionaryItem[]>(`/api/v1/${endpoint}?limit=500&offset=${offset}`);
+29 -4
View File
@@ -1,12 +1,12 @@
--- ---
import Layout from "../../layouts/Layout.astro"; import Layout from "../../layouts/Layout.astro";
import AtlasBreadcrumbs from "../../components/AtlasBreadcrumbs.astro"; import AtlasBreadcrumbs from "../../components/AtlasBreadcrumbs.astro";
import SourceBadge from "../../components/SourceBadge.astro";
import CoordinateRadar from "../../components/CoordinateRadar.astro"; import CoordinateRadar from "../../components/CoordinateRadar.astro";
import TackleGlyph from "../../components/TackleGlyph.astro"; import TackleGlyph from "../../components/TackleGlyph.astro";
import ActivityTimeline from "../../components/ActivityTimeline.astro"; import ActivityTimeline from "../../components/ActivityTimeline.astro";
import CatchList from "../../components/CatchList.astro"; import CatchList from "../../components/CatchList.astro";
import { activityLevel, api, ApiError, plural, type Activity, type Catch, type PaginatedActivity, type Spot } from "../../lib/api"; import DataPassport from "../../components/DataPassport.astro";
import { activityLevel, api, ApiError, type Activity, type Catch, type PaginatedActivity, type Spot } from "../../lib/api";
const { id } = Astro.params; const { id } = Astro.params;
let spot: Spot | null = null, catches: Catch[] = [], activity: Activity | null = null, unavailable = false; let spot: Spot | null = null, catches: Catch[] = [], activity: Activity | null = null, unavailable = false;
let timeline: { start: string; end: string; count: number }[] = []; let timeline: { start: string; end: string; count: number }[] = [];
@@ -25,6 +25,7 @@ try {
if (Astro.response.status === 503) Astro.response.headers.set("Retry-After", "60"); if (Astro.response.status === 503) Astro.response.headers.set("Retry-After", "60");
} }
const level = activity ? activityLevel(activity.activity_score) : null; const level = activity ? activityLevel(activity.activity_score) : null;
const coordinatePrecision = { exact: "точные", approximate: "приблизительные", area: "район", missing: "не указаны" } as const;
const spotDescription = spot ? `Свежие уловы и активность на точке ${spot.x}:${spot.y}, ${spot.waterbody}: рыба, вес, приманки и источники данных.` : "Данные точки ловли Russian Fishing 4."; const spotDescription = spot ? `Свежие уловы и активность на точке ${spot.x}:${spot.y}, ${spot.waterbody}: рыба, вес, приманки и источники данных.` : "Данные точки ловли Russian Fishing 4.";
const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "BreadcrumbList", itemListElement: [ const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "BreadcrumbList", itemListElement: [
{ "@type": "ListItem", position: 1, name: "Сейчас клюёт", item: "https://rf4spotter.ru/" }, { "@type": "ListItem", position: 1, name: "Сейчас клюёт", item: "https://rf4spotter.ru/" },
@@ -34,7 +35,7 @@ const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "Breadcr
<Layout title={spot ? `Точка ${spot.x}:${spot.y}, ${spot.waterbody} — RF4 Spotter` : "Точка не найдена — RF4 Spotter"} description={spotDescription} noindex={!spot} structuredData={breadcrumbs} errorPage={!spot || unavailable}> <Layout title={spot ? `Точка ${spot.x}:${spot.y}, ${spot.waterbody} — RF4 Spotter` : "Точка не найдена — RF4 Spotter"} description={spotDescription} noindex={!spot} structuredData={breadcrumbs} errorPage={!spot || unavailable}>
<AtlasBreadcrumbs items={[{ label: "Сейчас клюёт", href: "/" }, ...(spot ? [{ label: spot.waterbody, href: `/waterbodies/${spot.waterbody_slug}` }, { label: `Точка ${spot.x}:${spot.y}` }] : [{ label: "Точка недоступна" }])]} /> <AtlasBreadcrumbs items={[{ label: "Сейчас клюёт", href: "/" }, ...(spot ? [{ label: spot.waterbody, href: `/waterbodies/${spot.waterbody_slug}` }, { label: `Точка ${spot.x}:${spot.y}` }] : [{ label: "Точка недоступна" }])]} />
{unavailable || !spot ? <div class="state"><h1>Точка недоступна</h1><p>API не ответил или такой точки нет.</p></div> : <> {unavailable || !spot ? <div class="state"><h1>Точка недоступна</h1><p>API не ответил или такой точки нет.</p></div> : <>
<section class="spot-hero"><div><span class="eyebrow">{spot.waterbody}</span><h1>Точка {spot.x}:{spot.y}</h1><p>{spot.description}</p><button class="coordinate-copy" data-action="inverse" type="button" data-copy-coordinates={`${spot.x}:${spot.y}`}>Скопировать координаты</button><small class="copy-status" aria-live="polite"></small></div><CoordinateRadar x={spot.x} y={spot.y}/></section> <section class="spot-hero"><div><span class="eyebrow">{spot.waterbody}</span><h1>Точка {spot.x}:{spot.y}</h1><p>{spot.description}</p><p class="coordinate-precision">Точность координат: <strong>{coordinatePrecision[spot.coordinate_precision as keyof typeof coordinatePrecision] ?? "не указаны"}</strong></p><div class="spot-hero__actions"><button class="coordinate-copy" data-action="inverse" type="button" data-copy-coordinates={`${spot.x}:${spot.y}`}>Скопировать координаты</button><button class="plan-save" data-action="inverse" type="button" data-plan-save data-plan-key={Astro.url.pathname} data-plan-waterbody={spot.waterbody} data-plan-coordinates={`${spot.x}:${spot.y}`} data-plan-baits={spot.top_baits.join("|")} data-plan-freshness={activity?.last_confirmed_at ?? ""} data-plan-confidence={activity?.confidence_score ?? ""} aria-pressed="false">Сохранить в план</button><small class="copy-status" aria-live="polite"></small><small class="plan-status" aria-live="polite"></small></div></div><CoordinateRadar x={spot.x} y={spot.y}/></section>
<div class="periods"><div><strong>{spot.catches_24h}</strong><span>за 24 часа</span></div><div><strong>{spot.catches_3d}</strong><span>за 3 дня</span></div><div><strong>{spot.catches_7d}</strong><span>за 7 дней</span></div></div> <div class="periods"><div><strong>{spot.catches_24h}</strong><span>за 24 часа</span></div><div><strong>{spot.catches_3d}</strong><span>за 3 дня</span></div><div><strong>{spot.catches_7d}</strong><span>за 7 дней</span></div></div>
<ActivityTimeline buckets={timeline}/> <ActivityTimeline buckets={timeline}/>
<div class="activity-legend" aria-label="Уровни активности"><span>Тихо</span><span>Есть сигналы</span><span>Горячо</span></div> <div class="activity-legend" aria-label="Уровни активности"><span>Тихо</span><span>Есть сигналы</span><span>Горячо</span></div>
@@ -43,7 +44,7 @@ const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "Breadcr
<div class="section-heading"><h2>Последние уловы</h2></div> <div class="section-heading"><h2>Последние уловы</h2></div>
{catches.length ? <CatchList catches={catches}/> : <div class="state compact-state"><h2>Уловов пока нет</h2><p>Для этой точки нет одобренных наблюдений.</p></div>} {catches.length ? <CatchList catches={catches}/> : <div class="state compact-state"><h2>Уловов пока нет</h2><p>Для этой точки нет одобренных наблюдений.</p></div>}
</div> </div>
<aside><span class="eyebrow">Оценка за 24 часа</span>{activity && level ? <><h2>{activity.activity_score} / 100</h2><strong data-activity-level={level.short}>{level.description}</strong><div class="source-strip">{activity.sources.map(source => <SourceBadge source={source}/>)}</div><p>{activity.explanation}</p><p class="note">Основано на {activity.catches} {plural(activity.catches, ["наблюдении", "наблюдениях", "наблюдениях"])} от {activity.unique_players} {plural(activity.unique_players, ["игрока", "игроков", "игроков"])}. Уверенность: {activity.confidence_score}%.</p></> : <p>За последние 24 часа данных для расчёта нет.</p>}<span class="eyebrow">Лучшие приманки</span>{spot.top_baits.length ? <ol>{spot.top_baits.map(name => <li><span class="tackle-label"><TackleGlyph name={name} size={24}/><span>{name}</span></span></li>)}</ol> : <p>Недостаточно данных.</p>}<p class="note">Учитываются только одобренные наблюдения.</p></aside> <aside><span class="eyebrow">Оценка за 24 часа</span>{activity && level ? <><h2>{activity.activity_score} / 100</h2><strong data-activity-level={level.short}>{level.description}</strong><p>{activity.explanation}</p><DataPassport sources={activity.sources} observedAt={activity.last_confirmed_at} confidence={activity.confidence_score} sampleSize={activity.catches} independentPlayers={activity.unique_players} coordinatePrecision={activity.coordinate_precision} status={activity.catches < 3 ? "incomplete" : "verified"}/></> : <p>За последние 24 часа данных для расчёта нет.</p>}<h3 class="detail-action-heading">Что взять</h3>{spot.top_baits.length ? <ol>{spot.top_baits.map(name => <li><span class="tackle-label"><TackleGlyph name={name} size={24}/><span>{name}</span></span></li>)}</ol> : <p>Недостаточно данных.</p>}<p class="note">Учитываются только одобренные наблюдения.</p></aside>
</section> </section>
</>} </>}
</Layout> </Layout>
@@ -56,4 +57,28 @@ const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "Breadcr
catch { if (status) status.textContent = value; } catch { if (status) status.textContent = value; }
}); });
}); });
const planStorageKey = "rf4spotter:fishing-plan";
const readPlan = (): Array<Record<string, string>> => {
try { const value = JSON.parse(localStorage.getItem(planStorageKey) || "[]"); return Array.isArray(value) ? value : []; }
catch { return []; }
};
document.querySelectorAll<HTMLButtonElement>("[data-plan-save]").forEach((button) => {
const key = button.dataset.planKey || "";
const status = button.parentElement?.querySelector<HTMLElement>(".plan-status");
const sync = () => {
const saved = readPlan().some(item => item.key === key);
button.textContent = saved ? "В плане" : "Сохранить в план";
button.setAttribute("aria-pressed", String(saved));
button.dataset.saved = String(saved);
};
sync();
button.addEventListener("click", () => {
const plan = readPlan();
const index = plan.findIndex(item => item.key === key);
if (index >= 0) { plan.splice(index, 1); if (status) status.textContent = "Удалено из плана"; }
else { plan.unshift({ key, waterbody: button.dataset.planWaterbody || "", coordinates: button.dataset.planCoordinates || "", baits: button.dataset.planBaits || "", freshness: button.dataset.planFreshness || "", confidence: button.dataset.planConfidence || "" }); if (status) status.textContent = "Добавлено в план"; }
localStorage.setItem(planStorageKey, JSON.stringify(plan.slice(0, 5)));
sync();
});
});
</script> </script>
+41
View File
@@ -0,0 +1,41 @@
---
import Layout from "../../layouts/Layout.astro";
import PageHero from "../../components/PageHero.astro";
import StatePanel from "../../components/StatePanel.astro";
import Pagination from "../../components/Pagination.astro";
import TackleGlyph from "../../components/TackleGlyph.astro";
import DataPassport from "../../components/DataPassport.astro";
import { api, type PaginatedTackleItems } from "../../lib/api";
const params = Astro.url.searchParams;
const category = params.get("category") ?? "";
const brand = params.get("brand") ?? "";
const family = params.get("family") ?? "";
const requestedOffset = Number(params.get("offset") ?? 0);
const limit = 48;
const offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0;
const query = new URLSearchParams({ limit: String(limit), offset: String(offset) });
if (category) query.set("category", category);
if (brand) query.set("brand", brand);
if (family) query.set("family", family);
let result: PaginatedTackleItems = { items: [], total: 0, limit, offset }, unavailable = false;
try { result = await api<PaginatedTackleItems>(`/api/v1/tackle/items?${query}`); } catch { unavailable = true; }
if (unavailable) { Astro.response.status = 503; Astro.response.headers.set("Retry-After", "60"); Astro.response.headers.set("Cache-Control", "no-store"); }
const categoryLabels: Record<string, string> = { bait: "Наживка", lure: "Приманка", rod: "Удилище", reel: "Катушка", line: "Леска", hook: "Крючок", rig: "Монтаж", float: "Поплавок", sinker: "Груз", other: "Другое" };
const filterParams = new URLSearchParams();
if (category) filterParams.set("category", category);
if (brand) filterParams.set("brand", brand);
if (family) filterParams.set("family", family);
---
<Layout title="Снасти и приманки RF4 — RF4 Spotter" description="Канонический каталог снастей, приманок и монтажей Russian Fishing 4 с источниками и отметками неполноты.">
<PageHero eyebrow="Канонический справочник" title="Снасти и приманки" description="Показываем только подтверждённые карточки. Пустые поля явно отмечены и не заменяются догадками." variant="fish" count={result.total} />
<form class="record-filters" method="get" aria-label="Фильтры каталога снастей">
<label>Категория<select name="category"><option value="">Все категории</option>{Object.entries(categoryLabels).map(([value, label]) => <option value={value} selected={category === value}>{label}</option>)}</select></label>
<label>Бренд<input name="brand" value={brand} maxlength="100" placeholder="Например, RF4" /></label>
<label>Семейство<input name="family" value={family} maxlength="100" placeholder="Название семейства" /></label>
<button data-action="primary">Фильтровать</button>
{(category || brand || family) && <a data-action="quiet" href="/tackle">Сбросить</a>}
</form>
{unavailable ? <StatePanel tone="unavailable" title="Каталог временно недоступен" description="Не показываем непроверенные карточки. Попробуйте обновить страницу позже." /> : result.items.length ? <section class="catalog-grid content-grid" aria-label="Карточки снастей">{result.items.map(item => <a href={`/tackle/items/${item.id}`}><TackleGlyph name={item.name} size={32} /><span>{categoryLabels[item.category] ?? item.category}</span><strong>{item.name}</strong><small>{[item.brand, item.family].filter(Boolean).join(" · ") || "Характеристики уточняются"}</small><div class="tackle-card-passport"><DataPassport sources={item.source_system ? [item.source_system] : []} sourceUrl={item.source_url} observedAt={item.source_checked_at} completeness={item.missing_fields.length ? null : 100} status={item.missing_fields.length ? "incomplete" : item.source_checked_at ? "verified" : "unverified"}/></div></a>)}</section> : <StatePanel title="Подтверждённых карточек пока нет" description="Каталог заполнится после разрешённой загрузки и ручной проверки источников." />}
{!unavailable && <Pagination path="/tackle" params={filterParams} total={result.total} limit={limit} offset={offset} itemLabel="карточек" />}
</Layout>
+11 -3
View File
@@ -1,15 +1,19 @@
--- ---
import ActivityCard from "../../components/ActivityCard.astro"; import ActivityCard from "../../components/ActivityCard.astro";
import EntityMedia from "../../components/EntityMedia.astro";
import AtlasBreadcrumbs from "../../components/AtlasBreadcrumbs.astro"; import AtlasBreadcrumbs from "../../components/AtlasBreadcrumbs.astro";
import AtlasEntityLink from "../../components/AtlasEntityLink.astro"; import AtlasEntityLink from "../../components/AtlasEntityLink.astro";
import PageHero from "../../components/PageHero.astro"; import PageHero from "../../components/PageHero.astro";
import StatePanel from "../../components/StatePanel.astro"; import StatePanel from "../../components/StatePanel.astro";
import DataPassport from "../../components/DataPassport.astro";
import Layout from "../../layouts/Layout.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; const { slug } = Astro.params;
let water: DictionaryItem | undefined, items: Activity[] = [], unavailable = false; let water: DictionaryItem | undefined, items: Activity[] = [], media: MediaAsset[] = [], unavailable = false;
try { 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); 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; } if (water) { const paginated = await api<PaginatedActivity>(`/api/v1/activity?hours=72&waterbody=${encodeURIComponent(water.slug)}&limit=100`); items = paginated.items; }
} catch { unavailable = true; } } catch { unavailable = true; }
@@ -20,10 +24,14 @@ if (unavailable) {
} }
if (!water && !unavailable) Astro.response.status = 404; if (!water && !unavailable) Astro.response.status = 404;
const fishes = [...new Map(items.map(item => [item.fish_slug, item.fish])).entries()]; 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; 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}> <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 ?? "Не найдено" }]} /> <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} /> <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>}
{water && <DataPassport sources={water.source_system ? [water.source_system] : []} sourceUrl={water.source_url} observedAt={water.source_checked_at} status={water.source_checked_at ? "verified" : "unverified"}/>}
{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>} {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> </Layout>
+14 -4
View File
@@ -3,9 +3,18 @@ import Layout from "../../layouts/Layout.astro";
import PageHero from "../../components/PageHero.astro"; import PageHero from "../../components/PageHero.astro";
import StatePanel from "../../components/StatePanel.astro"; import StatePanel from "../../components/StatePanel.astro";
import WaterbodyMark from "../../components/WaterbodyMark.astro"; import WaterbodyMark from "../../components/WaterbodyMark.astro";
import Pagination from "../../components/Pagination.astro";
import { api, type DictionaryItem } from "../../lib/api"; import { api, type DictionaryItem } from "../../lib/api";
let waters: DictionaryItem[] = [], unavailable = false; const params = Astro.url.searchParams;
try { waters = await api<DictionaryItem[]>("/api/v1/waterbodies?limit=500"); } catch { unavailable = true; } 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) { if (unavailable) {
Astro.response.status = 503; Astro.response.status = 503;
Astro.response.headers.set("Retry-After", "60"); Astro.response.headers.set("Retry-After", "60");
@@ -13,6 +22,7 @@ if (unavailable) {
} }
--- ---
<Layout title="Все водоёмы Russian Fishing 4 — RF4 Spotter" description="Каталог водоёмов RF4 со свежими точками, рыбами, приманками и прозрачными источниками данных."> <Layout title="Все водоёмы Russian Fishing 4 — RF4 Spotter" description="Каталог водоёмов RF4 со свежими точками, рыбами, приманками и прозрачными источниками данных.">
<PageHero eyebrow="Карта водоёмов RF4" title="Водоёмы" description="Откройте водоём, чтобы увидеть активные виды рыб и последние подтверждённые точки." variant="water" count={waters.length} /> <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(index + 1).padStart(2,"0")}</span><strong>{water.name_ru}</strong><i>Открыть <b>→</b></i><WaterbodyMark identity={water.slug}/></a>)}</nav>} {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> </Layout>
+2 -2
View File
@@ -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{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:var(--info-soft);color:var(--info)}.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)} .data-passport__sources{display:flex;flex-wrap:wrap;gap:5px;margin-bottom:12px}.data-passport dl{display:grid;grid-template-columns:repeat(3,minmax(0,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} .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}} @media(max-width:720px){.data-passport dl{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:420px){.data-passport dl{grid-template-columns:1fr}.data-passport dl div{display:flex;justify-content:space-between;gap:10px}}
+12
View File
@@ -23,6 +23,15 @@
footer{min-height:118px;background:var(--deep);color:#dbe4df;padding:28px max(32px,calc((100vw - 1360px)/2));display:grid;grid-template-columns:1fr 1fr auto;align-items:center;gap:28px}footer .brand-mark{border-color:#ffffff32}footer .brand-name span{color:#9dafaa}footer p{font-size:12px;color:#92a4a0}footer>span{font:italic 16px Georgia;color:var(--lime)} footer{min-height:118px;background:var(--deep);color:#dbe4df;padding:28px max(32px,calc((100vw - 1360px)/2));display:grid;grid-template-columns:1fr 1fr auto;align-items:center;gap:28px}footer .brand-mark{border-color:#ffffff32}footer .brand-name span{color:#9dafaa}footer p{font-size:12px;color:#92a4a0}footer>span{font:italic 16px Georgia;color:var(--lime)}
@media(max-width:1040px){.content-grid{width:min(100% - 36px,900px)}.topbar{width:calc(100% - 36px);grid-template-columns:1fr auto}.live-badge{display:none}.intro{grid-template-columns:1fr;gap:34px;padding-top:54px}.lake-card{height:280px}.dashboard{grid-template-columns:1fr}.detail-card{position:relative;top:auto}.how-it-works{grid-template-columns:1fr}.records-hero,.form-hero{display:block}.records-hero>div:last-child,.form-hero>p{margin-top:25px}} @media(max-width:1040px){.content-grid{width:min(100% - 36px,900px)}.topbar{width:calc(100% - 36px);grid-template-columns:1fr auto}.live-badge{display:none}.intro{grid-template-columns:1fr;gap:34px;padding-top:54px}.lake-card{height:280px}.dashboard{grid-template-columns:1fr}.detail-card{position:relative;top:auto}.how-it-works{grid-template-columns:1fr}.records-hero,.form-hero{display:block}.records-hero>div:last-child,.form-hero>p{margin-top:25px}}
@media(max-width:720px){.content-grid,.records-hero,.form-hero,.record-filters,.record-table,.official-note,.report-form,.spot-hero,.periods,.detail-grid{width:calc(100% - 28px)}.topbar{width:100%;padding:13px 14px 0;display:flex;flex-wrap:wrap;height:auto}.topbar .brand{flex:1}.topbar nav{order:2;width:100%;height:46px;overflow-x:auto}.topbar nav a{flex:0 0 auto;font-size:13px}.intro h1,.records-hero h1,.form-hero h1{font-size:55px}.intro{padding:30px 0 25px}.intro h1{font-size:47px;margin:13px 0 12px}.intro-copy>p{font-size:15px;margin:0}.lake-card{display:none}.filters-wrap{position:relative;padding:17px 0}.filters{grid-template-columns:1fr 1fr}.filter-advanced-field{display:none}.filter-compact-hidden{display:none!important}.filter-advanced-fallback{display:block;grid-column:1/-1;border-top:1px solid #ffffff1d;padding-top:10px}.filter-advanced-fallback summary{display:flex;justify-content:space-between;color:#d5dfdc;font-size:12px;cursor:pointer;list-style:none}.filter-advanced-fallback summary::-webkit-details-marker{display:none}.filter-advanced-fallback summary:before{content:"";margin-right:7px;color:var(--lime)}.filter-advanced-fallback[open] summary:before{content:""}.filter-advanced-fallback summary span{margin-left:auto;color:#9fb0ad}.filter-advanced-fallback .advanced-fields{display:grid!important;grid-template-columns:1fr 1fr;gap:12px;padding-top:12px}.filter-advanced-fallback:not([open]) .advanced-fields{display:none!important}.filters button{grid-column:1/-1}.active-filters{min-height:0;overflow-x:auto;padding:10px 14px 0;width:100%;scrollbar-width:none}.active-filters span{flex:0 0 auto}.active-filters a{position:sticky;right:0;padding:6px 10px;background:var(--paper)}.dashboard{padding:28px 0 74px;scroll-margin-top:10px}.spot-card{grid-template-columns:34px 1fr;padding:18px 18px 18px 14px;gap:10px}.spot-stats{grid-column:2;border:0;border-top:1px solid #e2e8e2;padding:13px 0 0;grid-template-columns:repeat(4,1fr)}.card-arrow{display:none}.detail-score{grid-template-columns:105px 1fr}.score-ring{width:100px;height:100px}.principles{grid-template-columns:1fr}.record-filters{display:grid}.record-row{grid-template-columns:1fr 1fr}.record-head{display:none}.record-row>*:nth-child(even){text-align:right}.form-grid,.detail-grid{grid-template-columns:1fr}.spot-hero{padding:28px}.spot-hero h1{font-size:48px}.pin{display:none}footer{grid-template-columns:1fr auto;padding:30px 20px}footer p{grid-column:1/-1;order:3}} @media(max-width:720px){.content-grid,.records-hero,.form-hero,.record-filters,.record-table,.official-note,.report-form,.spot-hero,.periods,.detail-grid{width:calc(100% - 28px)}.topbar{width:100%;padding:13px 14px 0;display:flex;flex-wrap:wrap;height:auto}.topbar .brand{flex:1}.topbar nav{order:2;width:100%;height:46px;overflow-x:auto}.topbar nav a{flex:0 0 auto;font-size:13px}.intro h1,.records-hero h1,.form-hero h1{font-size:55px}.intro{padding:30px 0 25px}.intro h1{font-size:47px;margin:13px 0 12px}.intro-copy>p{font-size:15px;margin:0}.lake-card{display:none}.filters-wrap{position:relative;padding:17px 0}.filters{grid-template-columns:1fr 1fr}.filter-advanced-field{display:none}.filter-compact-hidden{display:none!important}.filter-advanced-fallback{display:block;grid-column:1/-1;border-top:1px solid #ffffff1d;padding-top:10px}.filter-advanced-fallback summary{display:flex;justify-content:space-between;color:#d5dfdc;font-size:12px;cursor:pointer;list-style:none}.filter-advanced-fallback summary::-webkit-details-marker{display:none}.filter-advanced-fallback summary:before{content:"";margin-right:7px;color:var(--lime)}.filter-advanced-fallback[open] summary:before{content:""}.filter-advanced-fallback summary span{margin-left:auto;color:#9fb0ad}.filter-advanced-fallback .advanced-fields{display:grid!important;grid-template-columns:1fr 1fr;gap:12px;padding-top:12px}.filter-advanced-fallback:not([open]) .advanced-fields{display:none!important}.filters button{grid-column:1/-1}.active-filters{min-height:0;overflow-x:auto;padding:10px 14px 0;width:100%;scrollbar-width:none}.active-filters span{flex:0 0 auto}.active-filters a{position:sticky;right:0;padding:6px 10px;background:var(--paper)}.dashboard{padding:28px 0 74px;scroll-margin-top:10px}.spot-card{grid-template-columns:34px 1fr;padding:18px 18px 18px 14px;gap:10px}.spot-stats{grid-column:2;border:0;border-top:1px solid #e2e8e2;padding:13px 0 0;grid-template-columns:repeat(4,1fr)}.card-arrow{display:none}.detail-score{grid-template-columns:105px 1fr}.score-ring{width:100px;height:100px}.principles{grid-template-columns:1fr}.record-filters{display:grid}.record-row{grid-template-columns:1fr 1fr}.record-head{display:none}.record-row>*:nth-child(even){text-align:right}.form-grid,.detail-grid{grid-template-columns:1fr}.spot-hero{padding:28px}.spot-hero h1{font-size:48px}.pin{display:none}footer{grid-template-columns:1fr auto;padding:30px 20px}footer p{grid-column:1/-1;order:3}}
/* The tablet header needs the same two-row layout as mobile: the full theme
switcher and horizontal navigation cannot share one 720px row. */
@media (min-width:721px) and (max-width:900px){
.topbar{width:100%;padding:13px 24px 0;display:flex;flex-wrap:wrap;height:auto}
.topbar .brand{flex:1;min-width:0}
.topbar nav{order:2;width:100%;height:46px;overflow-x:auto}
.topbar nav a{flex:0 0 auto;font-size:13px}
}
@media(max-width:720px){.moderation-app{width:calc(100% - 28px)}.moderation-card{grid-template-columns:1fr}.moderation-proof{grid-row:2}.moderation-actions{grid-column:1;display:block}.moderation-actions>div{margin-top:12px}.admin-login{display:block}.admin-login button{width:100%;margin-top:12px}} @media(max-width:720px){.moderation-app{width:calc(100% - 28px)}.moderation-card{grid-template-columns:1fr}.moderation-proof{grid-row:2}.moderation-actions{grid-column:1;display:block}.moderation-actions>div{margin-top:12px}.admin-login{display:block}.admin-login button{width:100%;margin-top:12px}}
@media(max-width:480px){.filters{grid-template-columns:1fr 1fr}.spot-stats strong{font-size:16px}.topbar nav{gap:4px}.topbar nav a{padding:0 5px}.brand-name strong{font-size:14px}.brand-name span{font-size:11px}.moderation-actions>div{display:grid}.moderation-actions button{width:100%}} @media(max-width:480px){.filters{grid-template-columns:1fr 1fr}.spot-stats strong{font-size:16px}.topbar nav{gap:4px}.topbar nav a{padding:0 5px}.brand-name strong{font-size:14px}.brand-name span{font-size:11px}.moderation-actions>div{display:grid}.moderation-actions button{width:100%}}
@media(max-width:720px){.filter-advanced-fallback .filter-advanced-field{display:flex!important;align-items:center;gap:6px}.filter-advanced-fallback .filter-compact-hidden{display:flex!important}} @media(max-width:720px){.filter-advanced-fallback .filter-advanced-field{display:flex!important;align-items:center;gap:6px}.filter-advanced-fallback .filter-compact-hidden{display:flex!important}}
@@ -59,6 +68,9 @@ footer{min-height:118px;background:var(--deep);color:#dbe4df;padding:28px max(32
/* One semantic treatment for empty, unavailable and invalid public states. */ /* 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} .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. */ /* 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} [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}
+4
View File
@@ -0,0 +1,4 @@
.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}}
@media (min-width:721px) and (max-width:1050px){.media-library__grid{grid-template-columns:repeat(3,minmax(0,1fr))}}
+1
View File
@@ -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}}
+11
View File
@@ -0,0 +1,11 @@
.query-summary{display:flex;justify-content:space-between;align-items:end;gap:24px;padding-top:20px}
.query-summary h2{margin:5px 0 4px;font:400 27px Georgia,serif;letter-spacing:-.035em}
.query-summary p{margin:0;color:var(--text-muted);font-size:13px}
.query-summary__filters{display:flex;align-items:center;justify-content:flex-end;flex-wrap:wrap;gap:8px}
.query-summary__filters span{padding:6px 10px;border:1px solid #c8d3cb;border-radius:20px;background:#f8faf5;color:#526662;font-size:12px}
.query-summary__filters a{margin-left:4px;color:#4d625e;font-size:13px;text-underline-offset:3px}
.detail-action-heading{margin:27px 0 8px;font:400 24px Georgia,serif;color:var(--lime)}
.tackle-card-passport{grid-column:1/-1;min-width:0}.tackle-card-passport .data-passport{margin-top:14px}
.spot-hero__actions{display:flex;align-items:center;flex-wrap:wrap;gap:8px}.spot-hero__actions small{color:#a9b8b5;font-size:11px}.plan-save[data-saved="true"]{background:var(--lime);color:var(--deep)}
@media(max-width:720px){.spot-hero__actions{align-items:flex-start;flex-direction:column}.spot-hero__actions small{min-height:15px}}
@media(max-width:720px){.query-summary{display:block;padding:16px 14px 0;width:100%}.query-summary h2{font-size:23px}.query-summary__filters{justify-content:flex-start;flex-wrap:nowrap;overflow-x:auto;padding-top:11px;scroll-snap-type:x proximity;scrollbar-width:none}.query-summary__filters::-webkit-scrollbar{display:none}.query-summary__filters span{flex:0 0 auto;scroll-snap-align:start}.query-summary__filters a{position:sticky;right:0;flex:0 0 auto;padding:6px 10px;background:var(--paper)}}
+9 -4
View File
@@ -6,7 +6,7 @@
--surface-control: light-dark(#f3f6f1, #0a1d1f); --surface-control: light-dark(#f3f6f1, #0a1d1f);
--text-primary: light-dark(#092226, #edf5ef); --text-primary: light-dark(#092226, #edf5ef);
--text-secondary: light-dark(#526662, #b8c7c3); --text-secondary: light-dark(#526662, #b8c7c3);
--text-tertiary: light-dark(#647572, #93a7a2); --text-tertiary: light-dark(#5b6b68, #93a7a2);
--text-on-dark: #f5f8f3; --text-on-dark: #f5f8f3;
--border-strong: light-dark(#cbd6ce, #3a5658); --border-strong: light-dark(#cbd6ce, #3a5658);
--border-subtle: light-dark(#d6dfd7, #294244); --border-subtle: light-dark(#d6dfd7, #294244);
@@ -39,11 +39,11 @@
--status-warning: light-dark(#9a741d, #e8c768); --status-warning: light-dark(#9a741d, #e8c768);
--status-changed: light-dark(#a44236, #ff9587); --status-changed: light-dark(#a44236, #ff9587);
--source-official: light-dark(#815713, #e3b65b); --source-official: light-dark(#815713, #e3b65b);
--source-rf4db: light-dark(#286581, #74bee3); --source-rf4db: light-dark(#245d78, #74bee3);
--source-rf4stat: light-dark(#60499a, #b7a2ed); --source-rf4stat: light-dark(#60499a, #b7a2ed);
--source-rf4map: light-dark(#2f704f, #79c79e); --source-rf4map: light-dark(#276044, #79c79e);
--source-rf4posts: light-dark(#984a33, #ee9a7e); --source-rf4posts: light-dark(#984a33, #ee9a7e);
--source-players: light-dark(#526b1c, #add066); --source-players: light-dark(#4b6419, #add066);
--graphic-line: light-dark(#3f7779, #79b5b8); --graphic-line: light-dark(#3f7779, #79b5b8);
--graphic-grid: light-dark(#dce5de, #294244); --graphic-grid: light-dark(#dce5de, #294244);
--tackle-orange: light-dark(#b96b27, #f0a55f); --tackle-orange: light-dark(#b96b27, #f0a55f);
@@ -127,6 +127,11 @@
.timeline-chart { border-color: var(--border-strong); background: repeating-linear-gradient(to top, transparent 0, transparent 35px, var(--border-subtle) 36px); } .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 strong { color: var(--text-primary); }
.timeline-slot small, .activity-legend { color: var(--text-tertiary); } .timeline-slot small, .activity-legend { color: var(--text-tertiary); }
.spot-hero .eyebrow { color: var(--text-on-dark); }
.activity-legend { color: var(--text-secondary); }
.catch-list .tackle-label > span { color: var(--text-secondary); }
.catch-list span { color: var(--text-secondary); }
.catch-list .source-chip > span { color: var(--chip) !important; }
.catalog-hero { border-color: var(--border-strong); } .catalog-hero { border-color: var(--border-strong); }
.catalog-hero p, .catalog-results aside a, .catalog-grid span { color: var(--text-secondary); } .catalog-hero p, .catalog-results aside a, .catalog-grid span { color: var(--text-secondary); }
+54 -1
View File
@@ -1,10 +1,63 @@
import AxeBuilder from "@axe-core/playwright"; import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test"; 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 }) => { test(`${path} has no serious accessibility violations`, async ({ page }) => {
await page.goto(path); await page.goto(path);
const results = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21aa"]).analyze(); const results = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21aa"]).analyze();
expect(results.violations.filter(item => ["critical", "serious"].includes(item.impact ?? ""))).toEqual([]); expect(results.violations.filter(item => ["critical", "serious"].includes(item.impact ?? ""))).toEqual([]);
}); });
} }
test("admin keyboard navigation keeps focus out of the document body", async ({ page }) => {
await page.goto("/admin");
await page.keyboard.press("Tab");
await expect(page.locator(".skip-link")).toBeFocused();
const focusedLabels: string[] = [];
for (let index = 0; index < 20; index += 1) {
await page.keyboard.press("Tab");
focusedLabels.push(await page.evaluate(() => {
const active = document.activeElement;
if (active?.tagName === "BODY") return "BODY";
if (active?.matches(".admin-login input")) return "ADMIN_TOKEN_INPUT";
if (active?.matches(".admin-login button")) return "ADMIN_PANEL_BUTTON";
return (active?.getAttribute("aria-label") || active?.textContent || active?.tagName || "").trim().slice(0, 80);
}));
}
expect(focusedLabels).not.toContain("BODY");
expect(focusedLabels).toContain("ADMIN_TOKEN_INPUT");
expect(focusedLabels).toContain("ADMIN_PANEL_BUTTON");
});
test("print media keeps a light color scheme and removes hero image filters", async ({ page }) => {
await page.emulateMedia({ media: "print" });
await page.goto("/");
const printState = await page.evaluate(() => ({
colorScheme: getComputedStyle(document.documentElement).colorScheme,
heroFilter: document.querySelector(".lake-card img") ? getComputedStyle(document.querySelector(".lake-card img")!).filter : "none",
}));
expect(printState.colorScheme).toBe("light");
expect(printState.heroFilter).toBe("none");
});
test("forced colors keeps theme controls visibly bordered", async ({ page }) => {
await page.emulateMedia({ forcedColors: "active" });
await page.goto("/admin");
const forcedColorsState = await page.evaluate(() => {
const control = document.querySelector(".theme-switcher button");
const pressed = document.querySelector(".theme-switcher button[aria-pressed='true']");
return {
borderStyle: control ? getComputedStyle(control).borderStyle : "",
borderWidth: control ? getComputedStyle(control).borderWidth : "",
activeBackground: pressed ? getComputedStyle(pressed).backgroundColor : "",
};
});
expect(forcedColorsState.borderStyle).toBe("solid");
expect(forcedColorsState.borderWidth).toBe("1px");
expect(forcedColorsState.activeBackground).not.toBe("transparent");
});
+12
View File
@@ -0,0 +1,12 @@
import { expect, test } from "@playwright/test";
test("waterbody passport and honest empty tackle catalog are explicit", async ({ page }) => {
await page.goto("/waterbodies/р-вьюнок");
const waterPassport = page.locator('.data-passport[aria-label="Паспорт данных"]');
await expect(waterPassport).toBeVisible();
await expect(waterPassport.getByText("Паспорт данных")).toBeVisible();
await expect(waterPassport.getByText("Свежесть")).toBeVisible();
await page.goto("/tackle");
await expect(page.getByText("Подтверждённых карточек пока нет")).toBeVisible();
});
@@ -0,0 +1,15 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
for (const path of [
"/waterbodies",
"/waterbodies/р-вьюнок",
"/spots/vyunok-321x654",
"/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();
expect(results.violations.filter(item => ["critical", "serious"].includes(item.impact ?? ""))).toEqual([]);
});
}
+14
View File
@@ -0,0 +1,14 @@
import { expect, test } from "@playwright/test";
test("activity cards explain freshness, sample size and coordinate precision", async ({ page }) => {
await page.goto("/");
const passport = page.locator(".spot-card .data-passport");
const count = await passport.count();
expect(count).toBeGreaterThan(0);
const first = passport.first();
await expect(first).toContainText("Свежесть");
await expect(first).toContainText("Наблюдения");
await expect(first).toContainText("Игроки");
await expect(first).toContainText("Координаты");
});
+46
View File
@@ -0,0 +1,46 @@
import { expect, test } from "@playwright/test";
test("media catalog keeps image provenance and loaded assets healthy", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto("/media");
await page.waitForLoadState("networkidle");
const state = await page.evaluate(() => ({
images: Array.from(document.images).map(image => ({
alt: image.alt,
complete: image.complete,
naturalWidth: image.naturalWidth,
})),
width: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth,
}));
expect(state.images.length).toBeGreaterThan(0);
expect(state.images.every(image => image.alt.trim().length > 0)).toBe(true);
expect(state.images.filter(image => image.complete && image.naturalWidth === 0)).toEqual([]);
expect(state.scrollWidth).toBeLessThanOrEqual(state.width);
});
for (const theme of ["light", "dark"] as const) {
test(`media catalog respects ${theme} theme on a narrow viewport`, async ({ page, context }) => {
await context.addCookies([{ name: "rf4-theme", value: theme, url: "http://127.0.0.1:4321" }]);
await page.setViewportSize({ width: 320, height: 800 });
await page.goto("/media");
const state = await page.evaluate(() => ({
theme: document.documentElement.dataset.theme,
width: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth,
}));
expect(state.theme).toBe(theme);
expect(state.scrollWidth).toBeLessThanOrEqual(state.width);
});
}
for (const type of ["waterbody", "tackle", "reference"]) {
test(`media ${type} section has a stable page state`, async ({ page }) => {
await page.goto(`/media?type=${type}`);
await expect(page.getByRole("heading", { name: "Медиатека" })).toBeVisible();
await expect(page.locator("main")).toBeVisible();
});
}
@@ -1,6 +1,10 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
test("production bootstrap supports submission and moderation", async ({ page, request }) => { test("production bootstrap supports submission and moderation", async ({ page, request }) => {
test.skip(
!process.env.BOOTSTRAP_API_URL || !process.env.BOOTSTRAP_ADMIN_TOKEN,
"requires BOOTSTRAP_API_URL and BOOTSTRAP_ADMIN_TOKEN; run deploy/test-production-bootstrap.sh",
);
const player = `Bootstrap Player ${Date.now()}`; const player = `Bootstrap Player ${Date.now()}`;
const x = 7000 + Date.now() % 1000; const x = 7000 + Date.now() % 1000;
const y = x + 1; const y = x + 1;
@@ -23,3 +27,20 @@ test("production bootstrap supports submission and moderation", async ({ page, r
expect(response.ok()).toBeTruthy(); expect(response.ok()).toBeTruthy();
expect(await response.text()).toContain(player); 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");
});
+26
View File
@@ -0,0 +1,26 @@
import { expect, test } from "@playwright/test";
test("home makes the selected query context explicit and shareable", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/?waterbody=kuori&fish=lake-trout&hours=72&sort=freshness");
await expect(page.getByRole("heading", { name: "Озёрная форель · Куори" })).toBeVisible();
await expect(page.getByText("72 часа · Сначала свежие")).toBeVisible();
await expect(page.getByRole("link", { name: "Сбросить запрос" })).toHaveAttribute("href", "/#results");
await expect(page.getByLabel("Водоём")).toHaveValue("kuori");
await expect(page.getByLabel("Рыба")).toHaveValue("lake-trout");
await expect(page.getByLabel("Период")).toHaveValue("72");
await expect(page.getByLabel("Сначала")).toHaveValue("freshness");
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
expect(overflow).toBeLessThanOrEqual(0);
});
test("empty filtered result offers a clear way back to the full dataset", async ({ page }) => {
await page.goto("/?waterbody=kuori&fish=lake-trout&hours=6&sort=freshness");
const emptyState = page.getByRole("heading", { name: "Пока нет свежих данных" });
if (await emptyState.isVisible()) {
await expect(page.getByRole("link", { name: "Показать все данные" })).toHaveAttribute("href", "/#results");
}
});
+18
View File
@@ -0,0 +1,18 @@
import { expect, test } from "@playwright/test";
test("spot can be saved to a five-item local fishing plan and survives reload", async ({ page }) => {
await page.goto("/spots/vyunok-6331x6332");
await page.evaluate(() => localStorage.removeItem("rf4spotter:fishing-plan"));
await page.reload();
const save = page.getByRole("button", { name: "Сохранить в план" });
await expect(save).toHaveAttribute("aria-pressed", "false");
await save.click();
await expect(page.getByRole("button", { name: "В плане" })).toHaveAttribute("aria-pressed", "true");
await expect(page.getByText("Добавлено в план")).toBeVisible();
await page.reload();
await expect(page.getByRole("button", { name: "В плане" })).toHaveAttribute("aria-pressed", "true");
await page.getByRole("button", { name: "В плане" }).click();
await expect(page.getByRole("button", { name: "Сохранить в план" })).toHaveAttribute("aria-pressed", "false");
});
+25 -11
View File
@@ -1,19 +1,10 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
test("player can open an active spot", async ({ page }) => { test("player can open an active spot", async ({ page }) => {
await page.goto("/"); await page.goto("/spots/vyunok-321x654");
await expect(page.getByRole("heading", { name: "Выбирай место, пока клюёт." })).toBeVisible();
const activeSpots = page.locator(".spot-card[data-testid]");
await expect(activeSpots).not.toHaveCount(0);
const activeSpot = activeSpots.first();
await expect(activeSpot.locator(".source-chip")).not.toHaveCount(0);
const level = await activeSpot.locator("[data-activity-level]").getAttribute("data-activity-level");
await expect(page.locator(".detail-score [data-activity-level]")).toHaveAttribute("data-activity-level", level ?? "");
await activeSpot.click();
await expect(page.getByRole("heading", { name: /^Точка / })).toBeVisible(); await expect(page.getByRole("heading", { name: /^Точка / })).toBeVisible();
await expect(page.getByText("Точность координат:", { exact: false })).toBeVisible();
await expect(page.getByRole("heading", { name: "Последние уловы" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Последние уловы" })).toBeVisible();
await expect(page.locator(".catch-list .source-chip")).not.toHaveCount(0);
await expect(page.locator("[data-activity-level]")).toHaveAttribute("data-activity-level", level ?? "");
}); });
test("submitted catch appears publicly only after moderation", async ({ page }) => { test("submitted catch appears publicly only after moderation", async ({ page }) => {
@@ -76,6 +67,7 @@ for (const viewport of [{ name: "desktop", width: 1280, height: 900 }, { name: "
const path = new URL(request.url()).pathname; const path = new URL(request.url()).pathname;
if (path.endsWith("/fishes")) return route.fulfill({ json: [{ slug: "pike", name_ru: "Щука" }] }); 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("/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 }] }); 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 }); return route.fulfill({ status: 404 });
}); });
@@ -90,6 +82,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 })); const dimensions = await page.evaluate(() => ({ width: document.documentElement.clientWidth, scroll: document.documentElement.scrollWidth }));
expect(dimensions.scroll).toBeLessThanOrEqual(dimensions.width); 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 }) => { test("public pages fit the narrow viewport and expose the skip link", async ({ page }) => {
+13
View File
@@ -0,0 +1,13 @@
import { expect, test } from "@playwright/test";
test("spot detail exposes the same evidence passport as activity cards", async ({ page }) => {
await page.goto("/spots/vyunok-6331x6332");
const passport = page.locator(".detail-grid aside .data-passport");
await expect(passport).toBeVisible();
await expect(passport).toContainText("Свежесть");
await expect(passport).toContainText("Наблюдения");
await expect(passport).toContainText("Игроки");
await expect(passport).toContainText("Координаты");
await expect(page.getByRole("heading", { name: "Что взять" })).toBeVisible();
});
+10
View File
@@ -0,0 +1,10 @@
import { expect, test } from "@playwright/test";
test("unknown spot exposes an unavailable state without a misleading coordinate", async ({ page }) => {
await page.goto("/spots/not-a-real-spot");
await expect(page.getByRole("heading", { name: "Точка недоступна" })).toBeVisible();
await expect(page.getByText("API не ответил или такой точки нет.")).toBeVisible();
await expect(page.getByRole("navigation", { name: "Навигационная цепочка" })).toContainText("Точка недоступна");
await expect(page.getByText(/Точность координат:/)).toHaveCount(0);
});
+18
View File
@@ -0,0 +1,18 @@
import { expect, test } from "@playwright/test";
test("tackle catalog exposes an explicit empty state for unmatched filters", async ({ page }) => {
await page.setViewportSize({ width: 320, height: 800 });
await page.goto("/tackle?category=lure&brand=NoSuchBrand&family=NoSuchFamily");
await expect(page.getByRole("heading", { name: "Подтверждённых карточек пока нет" })).toBeVisible();
await expect(page.getByRole("link", { name: "Сбросить" })).toHaveAttribute("href", "/tackle");
await expect(page.getByLabel("Категория")).toHaveValue("lure");
await expect(page.getByLabel("Бренд")).toHaveValue("NoSuchBrand");
await expect(page.getByLabel("Семейство")).toHaveValue("NoSuchFamily");
const dimensions = await page.evaluate(() => ({
clientWidth: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth,
}));
expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth);
});
+30
View File
@@ -5,6 +5,8 @@ import { activityLevel, plural } from "../../src/lib/presentation.ts";
import { fishVisualFamily } from "../../src/lib/fish-visuals.ts"; import { fishVisualFamily } from "../../src/lib/fish-visuals.ts";
import { tackleVisualKind, tackleVisualTone } from "../../src/lib/tackle-visuals.ts"; import { tackleVisualKind, tackleVisualTone } from "../../src/lib/tackle-visuals.ts";
import { waterbodyVisual } from "../../src/lib/waterbody-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", () => { test("activity levels share one complete 0-100 scale", () => {
assert.deepEqual( assert.deepEqual(
@@ -46,3 +48,31 @@ test("waterbody fingerprints are stable, bounded and entity-specific", () => {
assert.ok(kuori.markerY >= 18 && kuori.markerY <= 40); assert.ok(kuori.markerY >= 18 && kuori.markerY <= 40);
assert.match(kuori.code, /^[0-9A-Z]{2}$/); 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);
});
+41
View File
@@ -0,0 +1,41 @@
import { expect, test } from "@playwright/test";
const routes = [
"/", "/waterbodies", "/tackle", "/records", "/report", "/status", "/media", "/rules",
"/waterbodies/р-вьюнок", "/fish/pike", "/admin", "/admin/moderation",
"/admin/external-sources", "/admin/media",
];
const viewports = [
{ width: 320, height: 800 },
{ width: 390, height: 844 },
{ width: 768, height: 900 },
{ width: 1280, height: 900 },
];
const themes = ["system", "light", "dark"] as const;
test("ROADMAP visual matrix keeps routes within the viewport", async ({ page, context }) => {
for (const theme of themes) {
await context.clearCookies();
if (theme !== "system") {
await context.addCookies([{ name: "rf4-theme", value: theme, url: "http://127.0.0.1:4321" }]);
}
await page.emulateMedia({ colorScheme: theme === "system" ? "light" : theme });
for (const viewport of viewports) {
await page.setViewportSize(viewport);
for (const route of routes) {
await page.goto(route);
const state = await page.evaluate(() => ({
clientWidth: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth,
hasMain: Boolean(document.querySelector("main")),
hasHeading: Boolean(document.querySelector("h1")),
theme: document.documentElement.dataset.theme || "system",
}));
expect(state.scrollWidth, `${theme} ${viewport.width}px ${route} overflows`).toBeLessThanOrEqual(state.clientWidth);
expect(state.hasMain, `${route} has no main landmark`).toBe(true);
expect(state.hasHeading, `${route} has no h1`).toBe(true);
expect(state.theme, `${theme} SSR theme mismatch on ${route}`).toBe(theme);
}
}
}
});
+27
View File
@@ -0,0 +1,27 @@
import { expect, test } from "@playwright/test";
test("waterbody detail preserves confirmed facts and empty activity state", async ({ page }) => {
await page.goto("/waterbodies/р-вьюнок");
await expect(page.locator("h1")).toHaveText("р. Вьюнок");
await expect(page.getByText("В карточке источника указано видов рыб: 29.")).toBeVisible();
await expect(page.getByText("Изображений-кандидатов: 2; публикация требует отдельной проверки.")).toBeVisible();
await expect(page.getByText("Свежих подтверждённых видов пока нет.")).toBeVisible();
await expect(page.getByText("Свежих точек пока нет")).toBeVisible();
});
test("unknown waterbody slug has a navigable not-found state", async ({ page }) => {
await page.goto("/waterbodies/not-a-real-waterbody");
await expect(page.getByRole("heading", { name: "Водоём не найден" })).toBeVisible();
await expect(page.getByRole("alert")).toContainText("Такого водоёма нет в справочнике");
await expect(page.getByRole("link", { name: "Открыть каталог" })).toHaveAttribute("href", "/waterbodies");
});
test("unknown waterbody/fish pair keeps a clear not-found state", async ({ page }) => {
await page.goto("/waterbodies/р-вьюнок/not-a-real-fish");
await expect(page.getByRole("heading", { name: "Данные не найдены" })).toBeVisible();
await expect(page.getByRole("alert")).toContainText("Такого сочетания нет в справочнике");
await expect(page.getByRole("link", { name: "Вернуться на главную" })).toHaveAttribute("href", "/");
});
+4 -2
View File
@@ -84,7 +84,7 @@ services:
- >- - >-
test "$$MINIO_ROOT_USER" != "$$S3_ACCESS_KEY" && test "$$MINIO_ROOT_USER" != "$$S3_ACCESS_KEY" &&
test "$$MINIO_ROOT_PASSWORD" != "$$S3_SECRET_KEY" && test "$$MINIO_ROOT_PASSWORD" != "$$S3_SECRET_KEY" &&
mc alias set local http://minio:9000 "$$MINIO_ROOT_USER" "$$MINIO_ROOT_PASSWORD" >/dev/null && attempt=0; until mc alias set local http://minio:9000 "$$MINIO_ROOT_USER" "$$MINIO_ROOT_PASSWORD" >/dev/null 2>&1; do attempt=$$((attempt + 1)); test "$$attempt" -lt 30 || exit 1; sleep 1; done &&
mc mb --ignore-existing "local/$$S3_BUCKET" >/dev/null && mc mb --ignore-existing "local/$$S3_BUCKET" >/dev/null &&
printf '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetBucketLocation","s3:ListBucket"],"Resource":["arn:aws:s3:::%s"]},{"Effect":"Allow","Action":["s3:GetObject","s3:PutObject","s3:DeleteObject"],"Resource":["arn:aws:s3:::%s/*"]}]}' "$$S3_BUCKET" "$$S3_BUCKET" > /tmp/rf4-app-policy.json && printf '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetBucketLocation","s3:ListBucket"],"Resource":["arn:aws:s3:::%s"]},{"Effect":"Allow","Action":["s3:GetObject","s3:PutObject","s3:DeleteObject"],"Resource":["arn:aws:s3:::%s/*"]}]}' "$$S3_BUCKET" "$$S3_BUCKET" > /tmp/rf4-app-policy.json &&
mc admin policy create local rf4-app-bucket /tmp/rf4-app-policy.json >/dev/null && mc admin policy create local rf4-app-bucket /tmp/rf4-app-policy.json >/dev/null &&
@@ -93,7 +93,7 @@ services:
mc admin policy attach local rf4-app-bucket --user "$$S3_ACCESS_KEY" >/dev/null && mc admin policy attach local rf4-app-bucket --user "$$S3_ACCESS_KEY" >/dev/null &&
mc alias set app http://minio:9000 "$$S3_ACCESS_KEY" "$$S3_SECRET_KEY" >/dev/null && mc alias set app http://minio:9000 "$$S3_ACCESS_KEY" "$$S3_SECRET_KEY" >/dev/null &&
mc stat "app/$$S3_BUCKET" >/dev/null && mc stat "app/$$S3_BUCKET" >/dev/null &&
! mc ls app >/dev/null 2>&1 ! mc admin info app >/dev/null 2>&1
environment: environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:?Set MINIO_ROOT_USER} MINIO_ROOT_USER: ${MINIO_ROOT_USER:?Set MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?Set MINIO_ROOT_PASSWORD} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?Set MINIO_ROOT_PASSWORD}
@@ -182,6 +182,8 @@ services:
environment: environment:
PUBLIC_API_URL: https://${SITE_DOMAIN:?Set SITE_DOMAIN} PUBLIC_API_URL: https://${SITE_DOMAIN:?Set SITE_DOMAIN}
API_INTERNAL_URL: http://api:8000 API_INTERNAL_URL: http://api:8000
FILES_DOMAIN: ${FILES_DOMAIN:?Set FILES_DOMAIN}
FILES_ORIGIN: https://${FILES_DOMAIN:?Set FILES_DOMAIN}
depends_on: depends_on:
api: {condition: service_healthy} api: {condition: service_healthy}
healthcheck: healthcheck:
+20
View File
@@ -30,6 +30,23 @@ services:
volumes: volumes:
- minio_data:/data - 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: api:
build: build:
context: . context: .
@@ -55,6 +72,8 @@ services:
condition: service_completed_successfully condition: service_completed_successfully
minio: minio:
condition: service_healthy condition: service_healthy
minio-init:
condition: service_completed_successfully
ports: ports:
- "8000:8000" - "8000:8000"
healthcheck: healthcheck:
@@ -80,6 +99,7 @@ services:
environment: environment:
PUBLIC_API_URL: http://localhost:8000 PUBLIC_API_URL: http://localhost:8000
API_INTERNAL_URL: http://api:8000 API_INTERNAL_URL: http://api:8000
FILES_ORIGIN: http://localhost:9000
depends_on: depends_on:
api: api:
condition: service_healthy condition: service_healthy
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Some files were not shown because too many files have changed in this diff Show More