Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3260f91495 | ||
|
|
346ecd2dbe | ||
|
|
196185dc97 | ||
|
|
7a8e49dde3 | ||
|
|
5a8174b11e | ||
|
|
1dfe1e8ba4 | ||
|
|
9569f4768a | ||
|
|
5335255c5c | ||
|
|
3f0a02a9b9 | ||
|
|
e31fbe996d | ||
|
|
3a8d3565a1 | ||
|
|
7efdb1a16a | ||
|
|
d1a5ad1b22 | ||
|
|
0ac9830c84 | ||
|
|
4bcf301289 | ||
|
|
72ae157ec3 | ||
|
|
25514d9377 | ||
|
|
f02cb247a3 | ||
|
|
d541443a1a | ||
|
|
46251c635f | ||
|
|
a547d09fcd | ||
|
|
e94d247096 | ||
|
|
4f0c2d23de | ||
|
|
4e9895fbf4 | ||
|
|
b0edae98c6 | ||
|
|
d438c40542 | ||
|
|
722c88d436 | ||
|
|
5221442aeb | ||
|
|
85d81aa996 | ||
|
|
53e7b0e4ae | ||
|
|
d63eedf41b | ||
|
|
28fffb3acb | ||
|
|
4303b145b0 | ||
|
|
137aa806c0 | ||
|
|
e012b84969 | ||
|
|
727a87b73b | ||
|
|
d3ee8ebbd7 | ||
|
|
c216229c61 | ||
|
|
efd5f7b172 | ||
|
|
c14ae0250e | ||
|
|
2e34fb20b5 | ||
|
|
74ff49062f | ||
|
|
4c7051bc97 | ||
|
|
d9f58fb90e | ||
|
|
eb4d6ccdb0 | ||
|
|
9bcb019d3a | ||
|
|
4c75db1f74 | ||
|
|
75820125aa | ||
|
|
6c67e5042f | ||
|
|
a624066aab | ||
|
|
f6ff400344 | ||
|
|
5be5964098 | ||
|
|
399afe5ea5 | ||
|
|
a8c0da837a | ||
|
|
7d009c932f | ||
|
|
b47a6cc366 | ||
|
|
6146ea9eb0 | ||
|
|
61c7ac7d51 | ||
|
|
0eca44c11a | ||
|
|
79daea4ae1 | ||
|
|
7d94fbadb5 | ||
|
|
22485efe10 | ||
|
|
6c9bbd7fd7 | ||
|
|
3682f3ad61 | ||
|
|
2dd5f97143 | ||
|
|
064e731d25 | ||
|
|
e08bfaffba | ||
|
|
1d27fbde30 | ||
|
|
0152593815 | ||
|
|
8207d3ae02 | ||
|
|
787a5065bc | ||
|
|
f243807fc8 | ||
|
|
73feb75767 | ||
|
|
abe51b38d0 | ||
|
|
86ed3a966a | ||
|
|
1305cccfa5 | ||
|
|
bc3ceb5dfe | ||
|
|
bb8040d6fb | ||
|
|
aed541c70c | ||
|
|
54a1e6c042 | ||
|
|
d8b0c08aaa | ||
|
|
99c1498810 | ||
|
|
7567ac9191 | ||
|
|
4f5fb6d7c2 | ||
|
|
703a0ba32f | ||
|
|
35b00d7fd6 | ||
|
|
8ffbd7f9ec | ||
|
|
b7b49a3a63 | ||
|
|
fe9367b5e9 | ||
|
|
14958be302 | ||
|
|
cd319a32c2 | ||
|
|
3886c4f167 | ||
|
|
8cf453f86b | ||
|
|
48cd217479 | ||
|
|
04ac25c818 | ||
|
|
976b386e6b | ||
|
|
bb6fc6cd53 | ||
|
|
1adeff5e94 | ||
|
|
9c44cfc08f | ||
|
|
b76da2edd0 | ||
|
|
30bfccd0e7 | ||
|
|
c93c6adc7a | ||
|
|
aae5e0926a | ||
|
|
016d459861 | ||
|
|
0abe1a2bb4 | ||
|
|
cbd854d933 | ||
|
|
6aaee107a9 | ||
|
|
c6ffded8d7 | ||
|
|
392e1e534a | ||
|
|
3547598d37 | ||
|
|
0b7df7f721 | ||
|
|
7e464ef598 | ||
|
|
674bc5627f | ||
|
|
4042c80f00 | ||
|
|
fcc26a5b34 | ||
|
|
9681bdbd36 | ||
|
|
def5c39aa1 | ||
|
|
699a7c3857 | ||
|
|
0f078ab482 | ||
|
|
ba4c42f8f6 | ||
|
|
8e419c1832 | ||
|
|
6e18e8e565 | ||
|
|
7c28a0c36a | ||
|
|
ff8ea40d40 | ||
|
|
ca22042d18 | ||
|
|
9158ae3d8a | ||
|
|
1776283a95 | ||
|
|
31bbc15535 | ||
|
|
e48b9ef876 | ||
|
|
6974ae690d | ||
|
|
b84f1fe815 | ||
|
|
8d7fab97b9 | ||
|
|
d4ded77355 | ||
|
|
f6e6211871 | ||
|
|
049f1ef30a | ||
|
|
868278fdba | ||
|
|
926182a6cf | ||
|
|
883ad2c73b | ||
|
|
215af73388 | ||
|
|
26724c7675 | ||
|
|
bda0a0ef5e | ||
|
|
52ff29668e | ||
|
|
45c7e65bc4 | ||
|
|
cadd5607b8 | ||
|
|
2ce1e20af2 | ||
|
|
80d534d549 | ||
|
|
c3e847c249 | ||
|
|
b71e4a22a2 | ||
|
|
966000b0b0 | ||
|
|
a8b1775169 | ||
|
|
0c25cf020f | ||
|
|
2477543029 | ||
|
|
c944db54af | ||
|
|
9647667ae2 | ||
|
|
fc22795b63 | ||
|
|
1fabf21935 | ||
|
|
b0dd703f72 | ||
|
|
987d9ea109 | ||
|
|
e7f3de1ddc | ||
|
|
4fef0e8b74 | ||
|
|
45b73ec54a | ||
|
|
907ad537e2 | ||
|
|
51728bf3f2 | ||
|
|
8f76f70409 | ||
|
|
8c94d40766 | ||
|
|
1ffaf7478d | ||
|
|
2ad2bdcdff | ||
|
|
6664f24f20 | ||
|
|
0b7bbc78a6 | ||
|
|
fc963101b3 | ||
|
|
d28ad31aca | ||
|
|
67d81f81ff | ||
|
|
d6bc5ce85c | ||
|
|
5b2db1cf48 | ||
|
|
42bdec6c2f | ||
|
|
531f1aa82f | ||
|
|
486e4c4673 | ||
|
|
bea3b9ccbb | ||
|
|
13e04e6c66 | ||
|
|
5107a7c467 | ||
|
|
2ba4f6027d | ||
|
|
641374ddbc | ||
|
|
57aa3ffafb | ||
|
|
f29ec706fd | ||
|
|
7e08ecbfc6 | ||
|
|
ec3a1ca516 | ||
|
|
e2223c6f24 | ||
|
|
883e63aa8b | ||
|
|
e996c6da41 | ||
|
|
2a0c7b2fa5 | ||
|
|
69f052810c | ||
|
|
ed78a17109 | ||
|
|
0e592ea404 | ||
|
|
56ce498eac | ||
|
|
05d1f1616f | ||
|
|
1d403883a0 | ||
|
|
6183fb3417 | ||
|
|
f2ad5ecfa3 | ||
|
|
7386e7bea8 | ||
|
|
5de8ea939a | ||
|
|
97660833ab | ||
|
|
8f888671b0 | ||
|
|
b7c00dca8a | ||
|
|
d0d208ebd7 | ||
|
|
4ac50db1db | ||
|
|
4189199120 | ||
|
|
49027306d9 | ||
|
|
745a5ff9fd | ||
|
|
9e4d7aefba | ||
|
|
2ccca7350f | ||
|
|
4974f362ac | ||
|
|
79245965ec | ||
|
|
779d554057 | ||
|
|
98e7649f9d | ||
|
|
4f68d6b004 | ||
|
|
3ea08fa706 | ||
|
|
2c7dd27b4f | ||
|
|
f550639456 | ||
|
|
e2bed0db89 | ||
|
|
cc3b42eaf6 | ||
|
|
39f66481be | ||
|
|
6e0077bd4d | ||
|
|
67681ee039 | ||
|
|
51b3eb5e17 | ||
|
|
38bb871638 | ||
|
|
0b3a5ece4b | ||
|
|
fc878c81d4 | ||
|
|
ff0bc08222 | ||
|
|
d962ba2f90 | ||
|
|
a37f9c4696 | ||
|
|
cefb4494a5 | ||
|
|
9ae05ef6f0 | ||
|
|
b31042337b | ||
|
|
63e33e1861 | ||
|
|
c6fdc969c2 | ||
|
|
eb501affb8 | ||
|
|
bb61d21dd6 | ||
|
|
3e70b0f387 | ||
|
|
486b4e9645 | ||
|
|
731eade7b3 | ||
|
|
6e21bb774d | ||
|
|
18855644a4 | ||
|
|
25114d18c4 | ||
|
|
1f63f71600 | ||
|
|
ddb6909c4d | ||
|
|
8b2d7e2e1c | ||
|
|
214b328cd2 | ||
|
|
24ee01b9c3 | ||
|
|
f04dbabb3c | ||
|
|
fa3b24fb1f | ||
|
|
6477681e41 | ||
|
|
0f9c255596 | ||
|
|
02b67741f8 | ||
|
|
ef8355904c | ||
|
|
37aa8ec60d | ||
|
|
dbe01359de |
@@ -1,4 +1,6 @@
|
|||||||
DATABASE_URL=postgresql+psycopg://rf4:rf4_local@localhost:5432/rf4_spotter
|
DATABASE_URL=postgresql+psycopg://rf4:rf4_local@localhost:5432/rf4_spotter
|
||||||
|
APP_VERSION=0.1.0
|
||||||
|
APP_REVISION=dev
|
||||||
PUBLIC_API_URL=http://localhost:8000
|
PUBLIC_API_URL=http://localhost:8000
|
||||||
API_INTERNAL_URL=http://api:8000
|
API_INTERNAL_URL=http://api:8000
|
||||||
ADMIN_TOKEN=change-me-in-production
|
ADMIN_TOKEN=change-me-in-production
|
||||||
@@ -14,6 +16,7 @@ OFFICIAL_RECORDS_CATEGORY=records
|
|||||||
OFFICIAL_IMPORT_REQUIRED=false
|
OFFICIAL_IMPORT_REQUIRED=false
|
||||||
IMPORT_INTERVAL_SECONDS=3600
|
IMPORT_INTERVAL_SECONDS=3600
|
||||||
COMMUNITY_IMPORT_INTERVAL_SECONDS=1800
|
COMMUNITY_IMPORT_INTERVAL_SECONDS=1800
|
||||||
|
PUBLIC_CACHE_SECONDS=20
|
||||||
RF4MAP_POINT_URL=https://rf4map.ru/points/275
|
RF4MAP_POINT_URL=https://rf4map.ru/points/275
|
||||||
RF4POSTS_SPOT_URL=https://rf4-posts.com/ru/spots/d0c6d9c6-4ebf-49a7-98a8-9a562553a8ee
|
RF4POSTS_SPOT_URL=https://rf4-posts.com/ru/spots/d0c6d9c6-4ebf-49a7-98a8-9a562553a8ee
|
||||||
RATE_LIMIT_SECRET=change-rate-limit-secret
|
RATE_LIMIT_SECRET=change-rate-limit-secret
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ POSTGRES_USER=rf4
|
|||||||
POSTGRES_PASSWORD=replace-with-long-random-value
|
POSTGRES_PASSWORD=replace-with-long-random-value
|
||||||
# URL-encode special characters from POSTGRES_PASSWORD in this URL.
|
# URL-encode special characters from POSTGRES_PASSWORD in this URL.
|
||||||
DATABASE_URL=postgresql+psycopg://rf4:replace-with-url-encoded-password@db:5432/rf4_spotter
|
DATABASE_URL=postgresql+psycopg://rf4:replace-with-url-encoded-password@db:5432/rf4_spotter
|
||||||
|
APP_VERSION=0.1.0
|
||||||
|
APP_REVISION=replace-with-git-commit-sha
|
||||||
|
PUBLIC_CACHE_SECONDS=20
|
||||||
|
|
||||||
ADMIN_TOKEN=replace-with-at-least-32-random-characters
|
ADMIN_TOKEN=replace-with-at-least-32-random-characters
|
||||||
RATE_LIMIT_SECRET=replace-with-at-least-32-random-characters
|
RATE_LIMIT_SECRET=replace-with-at-least-32-random-characters
|
||||||
@@ -40,4 +43,6 @@ BACKUP_ROOT=/srv/rf4-backups
|
|||||||
MONITOR_DISK_MAX_PERCENT=85
|
MONITOR_DISK_MAX_PERCENT=85
|
||||||
MONITOR_TLS_MIN_DAYS=14
|
MONITOR_TLS_MIN_DAYS=14
|
||||||
MONITOR_BACKUP_MAX_HOURS=26
|
MONITOR_BACKUP_MAX_HOURS=26
|
||||||
|
MONITOR_DB_MAX_MB=2048
|
||||||
|
MONITOR_MINIO_MAX_MB=10240
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|||||||
@@ -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 выполнены не полностью
|
||||||
@@ -29,10 +29,10 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
cache: pip
|
cache: pip
|
||||||
- name: Install Python dependencies
|
- name: Install Python dependencies (locked)
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install -r apps/api/requirements.txt
|
pip install -r apps/api/requirements-dev-lock.txt
|
||||||
pip install -e .
|
pip install -e .
|
||||||
- name: Apply migrations to clean PostgreSQL
|
- name: Apply migrations to clean PostgreSQL
|
||||||
working-directory: apps/api
|
working-directory: apps/api
|
||||||
@@ -41,6 +41,8 @@ jobs:
|
|||||||
alembic current
|
alembic current
|
||||||
- name: Run Python tests
|
- name: Run Python tests
|
||||||
run: pytest -q
|
run: pytest -q
|
||||||
|
- name: Verify generated OpenAPI contract
|
||||||
|
run: python apps/api/export_openapi.py --check
|
||||||
|
|
||||||
astro-build:
|
astro-build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -54,9 +56,28 @@ jobs:
|
|||||||
- name: Install web dependencies
|
- name: Install web dependencies
|
||||||
working-directory: apps/web
|
working-directory: apps/web
|
||||||
run: npm ci
|
run: npm ci
|
||||||
- name: Check and build Astro
|
- name: Astro check, build and unit tests
|
||||||
working-directory: apps/web
|
working-directory: apps/web
|
||||||
run: npm run build
|
run: |
|
||||||
|
npm run check
|
||||||
|
npm run build
|
||||||
|
npm run test:unit
|
||||||
|
|
||||||
|
dependency-audit:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
cache: pip
|
||||||
|
- name: Install pip-audit and check dependencies
|
||||||
|
run: |
|
||||||
|
pip install --upgrade pip
|
||||||
|
pip install pip-audit
|
||||||
|
pip-audit --requirement apps/api/requirements-lock.txt
|
||||||
|
# Also check dev dependencies
|
||||||
|
pip-audit --requirement apps/api/requirements-dev-lock.txt
|
||||||
|
|
||||||
compose-e2e:
|
compose-e2e:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
name: Production bootstrap drill
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
schedule:
|
||||||
|
- cron: "17 3 * * 6"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
isolated-production-bootstrap:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: apps/web/package-lock.json
|
||||||
|
- name: Install web dependencies
|
||||||
|
run: npm --prefix apps/web ci
|
||||||
|
- name: Run isolated production bootstrap
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -o pipefail
|
||||||
|
mkdir -p artifacts
|
||||||
|
./deploy/test-production-bootstrap.sh 2>&1 | tee artifacts/production-bootstrap.log
|
||||||
|
- name: Collect failure diagnostics
|
||||||
|
if: failure()
|
||||||
|
run: |
|
||||||
|
docker compose --env-file .env.production.example -f compose.production.yaml ps -a > artifacts/compose-ps.txt 2>&1 || true
|
||||||
|
cp -R apps/web/test-results artifacts/test-results 2>/dev/null || true
|
||||||
|
cp -R apps/web/playwright-report artifacts/playwright-report 2>/dev/null || true
|
||||||
|
- name: Upload drill diagnostics
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: production-bootstrap-${{ github.run_id }}
|
||||||
|
path: artifacts
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Environment-dependent path to Maven home directory
|
||||||
|
/mavenHomeManager.xml
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="GigaCodeAgentSettings">
|
||||||
|
<option name="autoApproveEdits" value="true" />
|
||||||
|
<option name="autoApprovedCommands">
|
||||||
|
<list>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="mkdir -p /home/ik/git/rf4-help/.gigacode/plans" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="cd *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="ls *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="pip install *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="tail *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python pytest *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python -c " import ast import sys files = [ 'apps/api/app/main.py', 'apps/api/app/readiness.py', 'apps/api/app/schemas.py', 'apps/api/app/activity.py', 'apps/api/app/community_scheduler.py', 'apps/api/app/community_importer.py', 'rf4_research/community_cli.py', ] for f in files: try: with open(f) as fh: ast.parse(fh.read()) print(f'OK: {f}') except SyntaxError as e: print(f'ERROR: {f}: {e}') sys.exit(1) print('All Python files syntax OK') "" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="npm run *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="head *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="npm install *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="git add *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="git reset *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="git diff *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="git log *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="git commit *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="git status" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="echo *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="printf 'localhost {\n body_limit 10M\n}\n' > /tmp/test-caddyfile" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="cp /home/ik/git/rf4-help/deploy/Caddyfile /tmp/Caddyfile" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="printf 'example.com {\n body_limit 10M\n}\n' > /tmp/Caddyfile" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="printf 'example.com {\n request_body {\n max_size 10M\n }\n}\n' > /tmp/Caddyfile" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="docker run *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="sed 's/email {$ACME_EMAIL}/email test@test.com/' /home/ik/git/rf4-help/deploy/Caddyfile > /tmp/Caddyfile" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="grep *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="printf 'example.com {\n request_body {\n max_size 10M\n }\n handle {\n respond \"ok\"\n }\n}\n' > /tmp/Caddyfile" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python -c "import ast; ast.parse(open('rf4_research/community_cli.py').read()); print('OK')"" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python -c "import ast; ast.parse(open('apps/api/app/community_scheduler.py').read()); print('OK')"" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python -c "import ast; ast.parse(open('apps/api/app/main.py').read()); print('OK')"" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="node -e "try { fetch('http://localhost:1', { signal: AbortSignal.timeout(100) }); } catch(e) { console.log(e.constructor.name, e.message); }" 2>&1" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="node -e "fetch('http://httpbin.org/delay/10', { signal: AbortSignal.timeout(200) }).catch(e => console.log(e.constructor.name, e.message));" 2>&1" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="pip-compile requirements.txt --output-file requirements-lock.txt -q 2>&1" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="pip-compile requirements-dev.txt --output-file requirements-dev-lock.txt -q 2>&1" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="wc *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="alembic revision *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python -m app.cli --help 2>&1" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python -m rf4_research.community_cli --help 2>&1" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="alembic heads *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 -c " from urllib.request import Request, urlopen from urllib.error import HTTPError # Test with a URL that redirects try: req = Request('https://httpbin.org/redirect/1', headers={'User-Agent': 'test'}) resp = urlopen(req, timeout=5) print(f'Final URL: {resp.url}') print(f'Response URL: {resp.url}') except Exception as e: print(f'Error: {type(e).__name__}: {e}') " 2>&1" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="pwd" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 -c "from rf4_research import community_cli; print('Syntax OK')"" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/append_tests.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 pytest *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_redirect_test.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a04.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a04_v2.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="git show *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="git branch *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/test_double_reservation_bug.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a02_double_reservation.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/test_debug.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/test_a02_final.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/test_a02_v2.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/test_a02_code.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a04_pagination.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a01_monitoring.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a01_tests.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="*" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a01_tests_v2.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a04_filters.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a05_sessionstorage.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a06_tests.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a07_review_note.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a08_errorpage.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a08_index.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a11_pip_audit.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a04_pagination_final.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a08_index_unavailable.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a08_spots.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a08_records.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a01_vars.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a01_test.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a07_auto_publish.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a07_review_note_auto.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a09_cli.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 -c "from app.cli import main; import sys; sys.argv = ['cli', '--help']; main()" 2>&1" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="find *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="git push *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python -c " from sqlalchemy import create_engine, select from sqlalchemy.orm import Session from sqlalchemy.pool import StaticPool from app.database import Base from app.models import SubmissionAttempt import hashlib, hmac engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool) Base.metadata.create_all(engine) with Session(engine) as db: # Simulate first request key = 'idem-test-key-001' key_hash = hmac.new('change-rate-limit-secret'.encode(), key.encode(), hashlib.sha256).hexdigest() print(f'Key hash: {key_hash}') # Check before storing existing = db.scalar(select(SubmissionAttempt).where(SubmissionAttempt.idempotency_key == key_hash)) print(f'Found before: {existing}') # Store from datetime import datetime, timezone db.add(SubmissionAttempt(client_hash='test', idempotency_key=key_hash, created_at=datetime.now(timezone.utc))) db.commit() # Check after storing existing = db.scalar(select(SubmissionAttempt).where(SubmissionAttempt.idempotency_key == key_hash)) print(f'Found after: {existing}') if existing: print(f' idempotency_key: {existing.idempotency_key}') "" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python -c " from sqlalchemy import create_engine, select from sqlalchemy.orm import Session from sqlalchemy.pool import StaticPool from app.database import Base from app.models import SubmissionAttempt import hashlib, hmac engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool) Base.metadata.create_all(engine) with Session(engine) as db: key = 'idem-test-key-001' key_hash = hmac.new('change-rate-limit-secret'.encode(), key.encode(), hashlib.sha256).hexdigest() print(f'Key hash: {key_hash}') existing = db.scalar(select(SubmissionAttempt).where(SubmissionAttempt.idempotency_key == key_hash)) print(f'Found before: {existing}') from datetime import datetime, timezone db.add(SubmissionAttempt(client_hash='test', idempotency_key=key_hash, created_at=datetime.now(timezone.utc))) db.commit() existing = db.scalar(select(SubmissionAttempt).where(SubmissionAttempt.idempotency_key == key_hash)) print(f'Found after: {existing}') if existing: print(f' idempotency_key: {existing.idempotency_key}') "" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python -c " from sqlalchemy import create_engine, inspect, text from sqlalchemy.pool import StaticPool from app.database import Base from app.models import SubmissionAttempt engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool) Base.metadata.create_all(engine) inspector = inspect(engine) columns = inspector.get_columns('submission_attempt') print('Columns:', [c['name'] for c in columns]) "" />
|
||||||
|
</Execute>
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
<option name="autoApprovedReads">
|
||||||
|
<list>
|
||||||
|
<Read>
|
||||||
|
<option name="pattern" value="/path/to/apps/web/src/pages/index.astro" />
|
||||||
|
</Read>
|
||||||
|
<Read>
|
||||||
|
<option name="pattern" value="/path/to/apps/web/src/lib/api.ts" />
|
||||||
|
</Read>
|
||||||
|
<Read>
|
||||||
|
<option name="pattern" value="/path/to/rf4_spotter/rf4_research/community_cli.py" />
|
||||||
|
</Read>
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectRootManager">
|
||||||
|
<output url="file://$PROJECT_DIR$/out" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/rf4-help.iml" filepath="$PROJECT_DIR$/.idea/rf4-help.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="JAVA_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||||
|
<exclude-output />
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
## Project Structure & Module Organization
|
||||||
|
|
||||||
|
RF4 Spotter is an Astro/FastAPI/PostgreSQL application with an offline
|
||||||
|
research and media-ingestion toolkit. The main areas are:
|
||||||
|
|
||||||
|
- `apps/api/` — FastAPI application, routers, models, migrations, and API tests.
|
||||||
|
- `apps/web/` — Astro pages, components, styles, unit tests, and Playwright tests.
|
||||||
|
- `rf4_research/` — source parsers, media manifest tooling, and CLI commands.
|
||||||
|
- `tests/` — Python research/tooling tests and fixtures.
|
||||||
|
- `data/media/` — versioned manifest and content-addressed local media files.
|
||||||
|
- `docs/` — specification, roadmap, ADRs, runbooks, and acceptance procedures.
|
||||||
|
- `compose.yaml` — local PostgreSQL, API, web, and supporting services.
|
||||||
|
|
||||||
|
Keep generated reports and temporary downloads outside committed paths unless a
|
||||||
|
task explicitly requires versioning them.
|
||||||
|
|
||||||
|
## Build, Test, and Development Commands
|
||||||
|
|
||||||
|
From the repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/pytest -q # Python suites
|
||||||
|
npm --prefix apps/web run check # Astro type/template checks
|
||||||
|
npm --prefix apps/web run build # Check and production build
|
||||||
|
npm --prefix apps/web run test:unit # Web unit tests
|
||||||
|
WEB_URL=http://127.0.0.1:4321 npm --prefix apps/web run test:e2e
|
||||||
|
docker compose up --build # Full local stack
|
||||||
|
```
|
||||||
|
|
||||||
|
For media work, use `.venv/bin/python -m rf4_research.media_cli --audit` and
|
||||||
|
respect the manifest’s cooldown and approval states. Do not hotlink or replace
|
||||||
|
approved media without explicit review.
|
||||||
|
|
||||||
|
## Coding Style & Naming Conventions
|
||||||
|
|
||||||
|
Use four-space indentation for Python and two spaces for Astro/TypeScript.
|
||||||
|
Prefer typed Python functions, `snake_case` for Python identifiers, and
|
||||||
|
`camelCase` for TypeScript variables/functions. Astro components use
|
||||||
|
`PascalCase.astro`; tests use descriptive `test_*.py` or `*.test.ts` names.
|
||||||
|
Keep UI text and data-source labels explicit and accessible; run `astro check`
|
||||||
|
before committing web changes.
|
||||||
|
|
||||||
|
## Testing Guidelines
|
||||||
|
|
||||||
|
Add focused regression tests beside the affected suite. Python tests use
|
||||||
|
pytest; web behavior uses Node unit tests and Playwright. Run the smallest
|
||||||
|
relevant test first, then the full suite before handoff. Never use live external
|
||||||
|
sources in tests; use fixtures or isolated Docker services.
|
||||||
|
|
||||||
|
## Commit & Pull Request Guidelines
|
||||||
|
|
||||||
|
Use imperative, concise commit subjects with the repository’s existing scope
|
||||||
|
style, such as `feat:`, `fix:`, `data:`, or `chore:`. Keep commits focused and
|
||||||
|
exclude unrelated user files. Pull requests should describe behavior changes,
|
||||||
|
verification commands, migration or configuration impact, and screenshots for
|
||||||
|
visual/UI work. Call out any media provenance, approval, or rollback decision.
|
||||||
|
|
||||||
|
## Security & Configuration Tips
|
||||||
|
|
||||||
|
Do not commit secrets, production `.env` files, tokens, or private payloads.
|
||||||
|
Use local fixtures and documented environment variables. Preserve the strict
|
||||||
|
CSP, admin authentication barriers, source attribution, and media provenance
|
||||||
|
when changing application code.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Regenerate Python dependency lock files
|
||||||
|
|
||||||
|
.PHONY: lock lock-dev
|
||||||
|
|
||||||
|
lock:
|
||||||
|
cd apps/api && pip-compile requirements.txt --output-file requirements-lock.txt
|
||||||
|
|
||||||
|
lock-dev:
|
||||||
|
cd apps/api && pip-compile requirements-dev.txt --output-file requirements-dev-lock.txt
|
||||||
@@ -6,17 +6,71 @@ RF4 Spotter — неофициальный сервис свежих точек
|
|||||||
|
|
||||||
## Статус разработки
|
## Статус разработки
|
||||||
|
|
||||||
Функциональный MVP и локальный production-контур готовятся к открытой альфе: официальный импорт, пользовательские заявки, модерация, объяснимый индекс, staging внешних источников, адаптивный Astro UI, миграции, резервное копирование, retention, мониторинг и security/accessibility-проверки реализованы. Автоматические импорты внешних источников выключены. Публичный запуск блокируют покупка и настройка сервера, DNS/TLS, реальные секреты, внешний backup, канал уведомлений и публичные страницы правил/privacy.
|
**Проверка 20 сентября 2026: локальный кодовый контур проходит сборочные и тестовые gates, внешний запуск ждёт сервер и его настройки.** Пакет восстановления A01–A13 закрыт. Python: **191 passed, 1 skipped**; Astro check/build и web unit проходят. Граф миграций имеет единственную голову `0020`; OpenAPI artifact синхронизирован с FastAPI (`36 paths`). Изолированный production bootstrap с чистыми томами повторно пройден; живая БД, реальные секреты и импорт внешних источников по-прежнему требуют отдельной инфраструктурной/разрешённой приёмки.
|
||||||
|
|
||||||
RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изолированный staging. Полные записи с ранее подтверждёнными алиасами источника публикуются автоматически; новые соответствия и неполные записи остаются на ручной проверке. Для разрешённых community-источников действует интервал не менее 30 минут на источник. Открытая альфа не использует продуктовый allowlist: интерфейс показывает весь корректно загруженный разрешённый каталог, сохраняя требования полноты и модерации.
|
Актуальные следующие задачи находятся только в [ROADMAP](docs/ROADMAP.md). Старые планы и аудиты сохранены как история и больше не задают порядок работ. До внешнего запуска нужны сервер, DNS/TLS, production-секреты, публичные контакты, внешний backup и канал уведомлений.
|
||||||
|
|
||||||
На сайте у каждой записи отображается источник, а у агрегированной активности — все вошедшие в расчёт источники. Неполные community-наблюдения публикуются сразу в отдельной ленте «Полевые сигналы» с предупреждением и перечнем отсутствующих полей; до подтверждения полноты они не влияют на индекс клёва.
|
Разрешённые интеграции и недостающие первичные подтверждения сведены в [реестр разрешений](docs/source-permissions.md). Перед открытой публикацией пустые поля реестра являются блокером конкретного источника, особенно для изображений.
|
||||||
|
|
||||||
Базовый SEO-контур готов для `rf4spotter.ru`: страницы имеют уникальные метаданные, canonical, Open Graph/Twitter Card, фирменное изображение 1200×630 и JSON-LD; доступны динамические `/robots.txt` и `/sitemap.xml`, административные и ошибочные страницы закрыты от индексации, добавлена собственная страница 404. Индексируемые каталоги рыб и водоёмов, detail-страницы и сочетания водоём + рыба строятся из актуального разрешённого справочника и включаются в sitemap. Подключены фирменные favicon/app icons, web manifest и production-кэширование статических ресурсов. Карточки активности показывают единый паспорт данных: источники, свежесть, полноту и уровень доверия. На `/status` опубликована легенда цветов всех источников и статусов качества.
|
Web Docker-образ устанавливает зависимости через `npm ci` по lock-файлу и удаляет devDependencies после сборки. Локальные `.env` исключены из web build context.
|
||||||
|
|
||||||
Публичные точки используют постоянные читаемые адреса вида `/spots/kuori-85x92`; старые UUID-адреса остаются совместимыми и перенаправляются на канонический URL. На странице точки координаты дополнительно показаны фирменным радаром, который не имитирует отсутствующую географию водоёма, а уловы за 72 часа — шкалой-леской с 12-часовым шагом. Карточки активности и каталог дополнены лёгкими SVG-силуэтами рыб без внешних графических зависимостей.
|
Очередь внешних наблюдений выбирает только ожидающие проверки записи на сервере и показывает их страницами по 50. Обработанные записи не скрывают более старые необработанные наблюдения.
|
||||||
|
|
||||||
Все пять community-парсеров подключены к отдельному scheduler-процессу. Состояние запусков и ошибок хранится в PostgreSQL, параллельный запуск одного источника блокируется, минимальный интервал жёстко ограничен 1800 секундами. Локально процесс включается профилем `docker compose --profile scheduler up -d`; detail-URL RF4MAP/RF4 Posts задаются переменными окружения.
|
Запросы Astro к API ограничены таймаутом 8 секунд на запрос. При недоступности API каталоги рыб/водоёмов возвращают HTTP 503 с Retry-After, вместо успешного ответа со страницей ошибки.
|
||||||
|
|
||||||
|
В очереди внешних наблюдений доступна кнопка «Подсказать соответствия»: она показывает ранее подтверждённые рыбу и водоём. Значения формы не меняются автоматически; сопоставление и публикация подтверждаются отдельно.
|
||||||
|
|
||||||
|
Повторный импорт изменённой опубликованной записи переводит её на ручную проверку и снимает прежний улов с активности (с учётом TTL кэша). После сопоставления и подтверждения обновляется тот же улов; дубликат не создаётся. Автоматическое обнаружение удалённых оригиналов пока не реализовано.
|
||||||
|
|
||||||
|
История исправлений аудита сохранена в [AUDIT_FIXES.md](docs/AUDIT_FIXES.md) и [RECOVERY_FIXES_REPORT.md](docs/RECOVERY_FIXES_REPORT.md). Production Compose включает community scheduler; страницы rules/privacy реализованы. Для запуска остаются сервер, DNS/TLS, секреты, внешний backup и контакты. Шкала 72 часов использует полную выборку по времени поступления; одинаковые поля разных источников больше не считаются доказательством одного события. Фоновая публикация обновляет кэш API в пределах TTL, не мгновенно.
|
||||||
|
|
||||||
|
Предыдущие аудиты и план восстановления доступны в `docs/` как исторические материалы. Их незакрытые на момент составления чекбоксы не являются текущим backlog; статусы сведены в [итоговый отчёт](docs/RECOVERY_FIXES_REPORT.md).
|
||||||
|
|
||||||
|
На ширинах 320, 390, 768 и 1280 px проверено отсутствие горизонтального переполнения основных страниц; исправлена desktop-компоновка фильтров. Наполненные карточки, длинные названия, клавиатура и zoom остаются постоянной частью визуальной приёмки каждого крупного UI-пакета.
|
||||||
|
|
||||||
|
Функциональный MVP и локальный production-контур готовятся к открытой альфе: официальный импорт, пользовательские заявки, модерация, объяснимый индекс, staging внешних источников, адаптивный Astro UI, миграции, резервное копирование, retention, мониторинг и security/accessibility-проверки реализованы. На всех страницах подключён компактный баннер открытой альфы со ссылками на статус, правила и отправку улова. В production Compose включён community scheduler; локально он запускается отдельным профилем. Публичный запуск блокируют покупка и настройка сервера, DNS/TLS, реальные секреты, внешний backup, канал уведомлений; публичный адрес обратной связи ещё не задан.
|
||||||
|
|
||||||
|
Главная деградирует по секциям: activity, community signals и справочники загружаются независимо. Частичный отказ сохраняет доступные данные и возвращает HTTP 200 с `X-RF4-Partial` и запретом кэширования; общий 503 возникает только при отказе всех частей.
|
||||||
|
|
||||||
|
RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изолированный staging. Полные записи с ранее подтверждёнными алиасами источника публикуются автоматически; новые соответствия и неполные записи остаются на ручной проверке. Admin API предлагает точные ранее подтверждённые алиасы отдельно от mapping-действия и запрещает молча переназначать alias другой сущности. Для разрешённых community-источников действует интервал не менее 30 минут на сайт, общий для всех его endpoint. Открытая альфа не использует продуктовый allowlist: интерфейс показывает весь корректно загруженный разрешённый каталог, сохраняя требования полноты и модерации.
|
||||||
|
|
||||||
|
Жизненный цикл первоисточника учитывается консервативно: изменённая опубликованная запись снимается с активности до повторной ручной проверки, а исчезнувшая — только после подтверждённого ответа `missing` во время разрешённого планового обращения. Временные ошибки и блокировка доступа не удаляют данные. Admin provenance показывает результат и время последней проверки; повторно появившаяся запись также требует подтверждения модератором.
|
||||||
|
|
||||||
|
На сайте у каждой записи отображается источник, а у агрегированной активности — все вошедшие в расчёт источники. Неполные community-наблюдения публикуются сразу в отдельной ленте «Полевые сигналы» с предупреждением и перечнем отсутствующих полей; до подтверждения полноты они не влияют на индекс клёва. Лента раскрывается серверной кнопкой «Показать ещё», сохраняет выбранные фильтры и ограничена 48 сигналами на страницу. Визуально объединяются только повторы одного ID источника; похожие записи разных площадок остаются самостоятельными наблюдениями. Sidebar лидера скрывается при единственном результате, чтобы не повторять ту же карточку.
|
||||||
|
|
||||||
|
Базовый SEO-контур готов для `rf4spotter.ru`: страницы имеют уникальные метаданные, canonical, Open Graph/Twitter Card, фирменное изображение 1200×630 и JSON-LD; доступны динамические `/robots.txt` и `/sitemap.xml`, административные и ошибочные страницы закрыты от индексации, добавлена собственная страница 404. Индексируемые каталоги рыб и водоёмов, detail-страницы и сочетания водоём + рыба строятся из актуального разрешённого справочника и включаются в sitemap. Подключены резкие favicon/app icons из SVG-мастера, отдельные полнофоновые maskable-иконки, web manifest и production-кэширование статических ресурсов. Карточки активности показывают единый паспорт данных: источники, свежесть, полноту и уровень доверия. На `/status` опубликована легенда цветов всех источников и статусов качества.
|
||||||
|
|
||||||
|
Публичные точки используют постоянные читаемые адреса вида `/spots/kuori-85x92`; старые UUID-адреса остаются совместимыми и перенаправляются на канонический URL. На странице точки координаты дополнительно показаны фирменным радаром, который не имитирует отсутствующую географию водоёма, а уловы за 72 часа — шкалой-леской с 12-часовым шагом. Каждый улов показывает источник, относительную свежесть и точное время UTC; время получения явно отделено от времени улова. Каталоги оформлены как полевой атлас: тёмный seal показывает объём справочника, карточки рыб используют смысловые SVG-силуэты, а каждый водоём — собственный детерминированный абстрактный отпечаток берега, волн, точки и индекса. На странице сочетания оба знака собираются в единую атласную эмблему, detail-иерархию связывает breadcrumb-леска с текстовыми узлами, а боковые переходы повторяют знаки связанных сущностей. Это не карта и не игровая география. Пустые состояния используют статичную CSS-иллюстрацию поплавка; смысловые анимации полностью учитывают системное ограничение движения.
|
||||||
|
|
||||||
|
Все пять community-парсеров подключены к отдельному scheduler-процессу. Попытка резервируется в PostgreSQL до HTTP-запроса, поэтому ошибки тоже расходуют cooldown. Блокировка и минимальный интервал 1800 секунд действуют на весь домен; endpoint одного сайта выбираются по самому давнему запуску и не голодают. Тот же запрос служит проверкой точной исходной ссылки: `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. После явного разрешения владельца от 14 сентября все скачанные и целостные материалы опубликованы в `/media`; публичный API отдаёт Git-копии по content-addressed URL, а каждая карточка показывает плашку и прямую ссылку на источник. Будущие загрузки по-прежнему не одобряются автоматически. Локальный `media_cli --audit` без сетевых запросов проверяет хэши, файлы, MIME, размеры, approved-сопоставления и отсутствие бесхозных оригиналов.
|
||||||
|
|
||||||
|
Актуальный 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.
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
||||||
|
До сервера запросы проверяются командой `./deploy/test-query-plans.sh`: session-local TEMP-fixture на 100 000 уловов не меняет рабочую БД и требует индексные планы для activity, records и spot detail, а также выполнение пяти публичных планов быстрее 250 мс. Методика и последний локальный результат находятся в [docs/query-performance.md](docs/query-performance.md); новые индексы по текущему измерению не требуются.
|
||||||
|
|
||||||
|
В production MinIO root credentials доступны только одноразовому init-контейнеру. API использует отдельного пользователя с доступом исключительно к `S3_BUCKET`: просмотр bucket, чтение, запись и удаление его объектов без глобального списка bucket и без права создавать новые.
|
||||||
|
|
||||||
|
Production release отделяет Alembic от runtime: одноразовый `migrate` должен успешно завершиться до запуска новой версии API. Перед изменением схемы создаётся backup; совместимый rollback возвращает предыдущие images, несовместимый — восстанавливает предрелизную копию данных вместо непроверенного `alembic downgrade`.
|
||||||
|
|
||||||
|
Путь обновления схемы проверяется изолированным `deploy/test-release-upgrade.sh`: предыдущая ревизия получает контрольную запись, обновляется до head, после чего проверяются версия, сохранность записи и новые колонки.
|
||||||
|
|
||||||
|
Тяжёлый production bootstrap вынесен в отдельный ручной/еженедельный CI workflow с 30-минутным timeout и сохраняемыми diagnostics; обычный push по-прежнему использует быстрый Compose E2E.
|
||||||
|
|
||||||
|
Публичный API зафиксирован генерируемым [OpenAPI-контрактом](docs/api-contract.md): CI сравнивает `apps/api/openapi.json` с фактической схемой FastAPI, поэтому рефакторинг routers не может незаметно изменить URL, параметры или response models. Catalog, activity/spots, public data, submissions и admin API принадлежат отдельным `APIRouter`; `main.py` служит компактной точкой сборки приложения, а проверка доверенных proxy и persistent rate limit изолированы в `submission_security`.
|
||||||
|
|
||||||
После повторных ошибок scheduler увеличивает паузу экспоненциально до 24 часов и возвращается к 30 минутам после успеха. Публичная страница `/status` показывает свежесть и состояние источников без URL запросов, внутренних ошибок и другой диагностической информации.
|
После повторных ошибок scheduler увеличивает паузу экспоненциально до 24 часов и возвращается к 30 минутам после успеха. Публичная страница `/status` показывает свежесть и состояние источников без URL запросов, внутренних ошибок и другой диагностической информации.
|
||||||
|
|
||||||
@@ -26,7 +80,11 @@ Production-контур для домена `rf4spotter.ru`, TLS, секреты
|
|||||||
|
|
||||||
Политика минимизации данных и ежедневная dry-run-first очистка описаны в [`docs/data-retention.md`](docs/data-retention.md).
|
Политика минимизации данных и ежедневная dry-run-first очистка описаны в [`docs/data-retention.md`](docs/data-retention.md).
|
||||||
|
|
||||||
Host-side мониторинг контейнеров, readiness, диска, резервных копий и TLS описан в [`docs/production-monitoring.md`](docs/production-monitoring.md).
|
Host-side мониторинг контейнеров, readiness, диска, объёма PostgreSQL/MinIO, резервных копий и TLS описан в [`docs/production-monitoring.md`](docs/production-monitoring.md).
|
||||||
|
|
||||||
|
Минимальные SLI открытой альфы, стартовые пороги и правила безопасной телеметрии собраны в [observability plan](docs/observability.md). До выбора сервера используются существующие JSON-логи, readiness, diagnostics и host monitor; отдельный metrics-стек заранее не добавляется.
|
||||||
|
|
||||||
|
Принятые границы стека, memory-cache, scheduler и хранилищ зафиксированы в [архитектурных решениях](docs/architecture-decisions.md). Порядок действий при заполнении диска, отказах PostgreSQL/MinIO, зависшем импорте, ошибке миграции и утечке секрета находится в [incident runbook](docs/incident-runbook.md).
|
||||||
Ежедневный systemd timer создаёт проверяемую копию до retention-очистки, а production Compose ограничивает рост JSON-логов контейнеров.
|
Ежедневный systemd timer создаёт проверяемую копию до retention-очистки, а production Compose ограничивает рост JSON-логов контейнеров.
|
||||||
|
|
||||||
Фактическое состояние DNS/TLS домена и серверный чек-лист ведутся в [`docs/deployment-status.md`](docs/deployment-status.md).
|
Фактическое состояние DNS/TLS домена и серверный чек-лист ведутся в [`docs/deployment-status.md`](docs/deployment-status.md).
|
||||||
@@ -43,11 +101,15 @@ FastAPI ─ PostgreSQL 17
|
|||||||
└ MinIO/S3
|
└ MinIO/S3
|
||||||
```
|
```
|
||||||
|
|
||||||
Наружу production-профиль публикует только Caddy. PostgreSQL, API, Astro и MinIO находятся во внутренних Docker-сетях. Caddy завершает TLS и защищает административные маршруты Basic Auth; административный API дополнительно требует bearer token.
|
Наружу 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, `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).
|
||||||
|
|
||||||
Gitea Actions workflow `.gitea/workflows/ci.yml` на каждый push и pull request проверяет Python, миграции на чистой PostgreSQL, Astro build и полный Compose/Playwright-сценарий. При падении E2E сохраняются логи контейнеров и Playwright-артефакты.
|
Gitea Actions workflow `.gitea/workflows/ci.yml` на каждый push и pull request проверяет Python, миграции на чистой PostgreSQL, Astro build и полный Compose/Playwright-сценарий. При падении E2E сохраняются логи контейнеров и Playwright-артефакты.
|
||||||
|
|
||||||
Актуальная инвентаризация источников и правила подключения адаптеров находятся в [`docs/data-source-audit.md`](docs/data-source-audit.md). Разрешённый технический пилот RF4DB/RF4-STAT описан в [`docs/community-source-pilot.md`](docs/community-source-pilot.md), а статус разрешений и лимитов — в [`docs/data-permissions.md`](docs/data-permissions.md). Данные сохраняются только в промежуточный staging и не влияют на индекс без явной проверки и публикации администратором.
|
Актуальная инвентаризация источников и правила подключения адаптеров находятся в [`docs/data-source-audit.md`](docs/data-source-audit.md). Разрешённый технический пилот RF4DB/RF4-STAT описан в [`docs/community-source-pilot.md`](docs/community-source-pilot.md), а статус разрешений и лимитов — в [`docs/data-permissions.md`](docs/data-permissions.md). Все наблюдения сначала попадают в staging. Полные записи с уже подтверждёнными алиасами могут публиковаться автоматически; новые соответствия требуют модерации, а неполные записи показываются отдельно и не влияют на индекс.
|
||||||
|
|
||||||
Один ограниченный снимок публичных карточек можно получить исследовательским CLI:
|
Один ограниченный снимок публичных карточек можно получить исследовательским CLI:
|
||||||
|
|
||||||
@@ -59,7 +121,7 @@ python -m rf4_research.community_cli rf4map-point --url https://rf4map.ru/points
|
|||||||
python -m rf4_research.community_cli rf4posts-spot --url https://rf4-posts.com/ru/spots/UUID --limit 25
|
python -m rf4_research.community_cli rf4posts-spot --url https://rf4-posts.com/ru/spots/UUID --limit 25
|
||||||
```
|
```
|
||||||
|
|
||||||
Команды печатают нормализованный JSON в stdout и ничего не записывают в базу. Detail-команды требуют явный публичный URL и не обходят запрещённые `/api/`. Для RF4-STAT действует пауза не менее пяти секунд между разными страницами; для RF4MAP/RF4 Posts CLI хранит состояние в `.cache/community-fetch-state.json` и блокирует повтор того же источника раньше 30 минут.
|
Команды печатают нормализованный JSON в stdout и ничего не записывают в базу. Detail-команды требуют явный публичный URL и не обходят запрещённые `/api/`. CLI резервирует домен в `.cache/community-fetch-state.json` до HTTP-запроса и блокирует любой его endpoint на 30 минут даже после ошибки. Это автономный исследовательский режим: не запускайте его одновременно с production scheduler; для ручного production-запуска используйте `app.cli fetch-community`, который разделяет PostgreSQL-cooldown с scheduler.
|
||||||
|
|
||||||
Проверенный JSON можно идемпотентно загрузить в изолированный staging, не влияющий на публичную статистику:
|
Проверенный JSON можно идемпотентно загрузить в изолированный staging, не влияющий на публичную статистику:
|
||||||
|
|
||||||
@@ -68,7 +130,7 @@ python -m rf4_research.community_cli rf4db --limit 25 \
|
|||||||
| docker compose exec -T api python -m app.cli stage-community-json --input -
|
| docker compose exec -T api python -m app.cli stage-community-json --input -
|
||||||
```
|
```
|
||||||
|
|
||||||
Staging проверяет происхождение URL и диапазоны значений. Источники по умолчанию выключены; автоматического преобразования в одобренные уловы нет. Ручная очередь доступна по адресу <http://localhost:4321/admin/external-sources>. Публикация разрешена только после сопоставления канонических рыбы и водоёма и при наличии координат и веса.
|
Staging проверяет происхождение URL и диапазоны значений. Источники локально включаются явно; production-профиль запускает разрешённый scheduler. Ручная очередь доступна по адресу <http://localhost:4321/admin/external-sources>. Автопубликация разрешена только для полных наблюдений с ранее подтверждёнными каноническими соответствиями рыбы и водоёма; остальные записи не обходят модерацию.
|
||||||
|
|
||||||
## Запуск через Docker
|
## Запуск через Docker
|
||||||
|
|
||||||
@@ -83,12 +145,12 @@ docker compose up --build
|
|||||||
- сайт: <http://localhost:4321>;
|
- сайт: <http://localhost:4321>;
|
||||||
- OpenAPI: <http://localhost:8000/docs>;
|
- OpenAPI: <http://localhost:8000/docs>;
|
||||||
- liveness API: <http://localhost:8000/health>;
|
- liveness API: <http://localhost:8000/health>;
|
||||||
- readiness PostgreSQL, MinIO и импорта: <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`. Официальный импорт по умолчанию необязателен; при включённом 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`.
|
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 и ошибок парсеров.
|
||||||
|
|
||||||
Остановка:
|
Остановка:
|
||||||
|
|
||||||
@@ -122,7 +184,7 @@ docker compose up --build
|
|||||||
## Что реализовано
|
## Что реализовано
|
||||||
|
|
||||||
- FastAPI и SQLAlchemy 2;
|
- FastAPI и SQLAlchemy 2;
|
||||||
- PostgreSQL 17 и миграции Alembic до `0013`;
|
- 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`;
|
||||||
@@ -147,13 +209,15 @@ Backend и исследовательский парсер:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m venv .venv
|
python3 -m venv .venv
|
||||||
.venv/bin/pip install -r apps/api/requirements.txt
|
.venv/bin/pip install -r apps/api/requirements-dev.txt
|
||||||
.venv/bin/pip install -e .
|
.venv/bin/pip install -e .
|
||||||
.venv/bin/pytest -q
|
.venv/bin/pytest -q
|
||||||
```
|
```
|
||||||
|
|
||||||
Актуальное число тестов выводит команда `pytest`; набор включает backend, импорт, расчёт активности и исследовательский парсер.
|
Актуальное число тестов выводит команда `pytest`; набор включает backend, импорт, расчёт активности и исследовательский парсер.
|
||||||
|
|
||||||
|
Production-образ API устанавливает только `requirements.txt`; `pytest` подключается отдельно через `requirements-dev.txt` в локальной среде и CI.
|
||||||
|
|
||||||
Frontend:
|
Frontend:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -206,9 +270,21 @@ curl -H "Authorization: Bearer change-me-in-production" \
|
|||||||
|
|
||||||
Если создание записи прошло успешно, а загрузка скриншота завершилась ошибкой, форма сохраняет на один час ID заявки и одноразовый секрет в защищённой `HttpOnly` cookie и предлагает повторить только загрузку изображения. Повторно отправлять сам улов не требуется; один UUID заявки не даёт права изменить чужую запись.
|
Если создание записи прошло успешно, а загрузка скриншота завершилась ошибкой, форма сохраняет на один час ID заявки и одноразовый секрет в защищённой `HttpOnly` cookie и предлагает повторить только загрузку изображения. Повторно отправлять сам улов не требуется; один UUID заявки не даёт права изменить чужую запись.
|
||||||
|
|
||||||
Очередь модерации доступна по адресу <http://localhost:4321/admin/moderation>. Администратор вводит `ADMIN_TOKEN`; интерфейс держит его только в памяти открытой страницы и не сохраняет в URL или браузерном хранилище.
|
Единый dashboard доступен по адресу <http://localhost:4321/admin>, очереди — `/admin/moderation` и `/admin/external-sources`. Dashboard показывает объём очередей, состояние источников и последние импорты без внутренних URL и текстов ошибок. Администратор вводит `ADMIN_TOKEN`; интерфейс держит его только в памяти открытой страницы и не сохраняет в URL или браузерном хранилище. После 15 минут бездействия или ответа API `401` сессия очищается; также доступен явный выход.
|
||||||
|
|
||||||
Администратор может одобрить, отклонить или удалить сообщение. Удаление очищает ник, комментарий, исходную ссылку и объект скриншота, исключает запись из статистики, но сохраняет обезличенный факт действия в журнале аудита.
|
В production HTML административных страниц дополнительно закрыт Caddy Basic Auth, а API независимо проверяет Bearer-токен. Неуспешные попытки API-входа считаются в БД по HMAC-идентификатору адреса и временно блокируются после десяти ошибок за десять минут; успешная авторизация очищает ошибки клиента. Интерфейс открывает только ссылки со схемой `http` или `https`; данные источника не могут подставить исполняемую URL-схему в ссылку или превью.
|
||||||
|
|
||||||
|
Администратор может одобрить, отклонить или удалить сообщение. Очередь внешних наблюдений фильтруется на сервере по источнику и полноте, ищет рыбу/водоём и сортируется по свежести либо риску до применения пагинации; риск поднимает неполные и несопоставленные записи. Во время решения вся карточка блокируется; при ошибке введённая причина остаётся на месте, а после успеха интерфейс сообщает результат и переводит фокус к следующей записи. Удаление очищает ник, комментарий, исходную ссылку и объект скриншота, исключает запись из статистики, но сохраняет обезличенный факт действия в журнале аудита.
|
||||||
|
|
||||||
|
Для карточки с клавиатурным фокусом доступны подсказанные в интерфейсе быстрые клавиши одобрения, сопоставления и публикации. Они не срабатывают в полях ввода; отклонение и удаление требуют явного нажатия кнопки.
|
||||||
|
|
||||||
|
Административный API внешней очереди возвращает безопасный provenance: время первого и последнего обнаружения, время проверки, отсутствующие поля и только разрешённые скалярные поля исходной записи. Неизвестные ключи и вложенные служебные структуры в ответ не попадают.
|
||||||
|
|
||||||
|
В карточке внешнего наблюдения provenance доступен в отдельном раскрываемом блоке: временная линия, отсутствующие и исходные разрешённые поля видны до сопоставления и публикации.
|
||||||
|
|
||||||
|
`GET /api/v1/admin/moderation-history` объединяет историю решений по пользовательским уловам и внешним наблюдениям; последние события видны на dashboard. Ответ содержит только тип и UUID сущности, время, действие, оператора и причину — без ников, исходных URL и parser payload. Dashboard выгружает отдельный `moderation-history-export`: в нём дополнительно исключены UUID, оператор и свободный текст причины, остаются только время, тип, действие и признак необходимости подтверждения.
|
||||||
|
|
||||||
|
Решения в обеих очередях используют optimistic locking: API возвращает `moderation_version`, а изменяющий запрос обязан прислать увиденное значение. Проверка выполняется под блокировкой строки; если другая вкладка уже решила запись, сервер отвечает `409`, UI обновляет очередь и не перезаписывает более новое решение.
|
||||||
|
|
||||||
## Эксплуатация production
|
## Эксплуатация production
|
||||||
|
|
||||||
@@ -223,9 +299,9 @@ Production-логи структурированы в JSON и не содерж
|
|||||||
## Известные ограничения альфы
|
## Известные ограничения альфы
|
||||||
|
|
||||||
- нет пользовательских аккаунтов, OCR, Telegram-бота и уведомлений о клёве;
|
- нет пользовательских аккаунтов, OCR, Telegram-бота и уведомлений о клёве;
|
||||||
- community-источники автоматически публикуют только полные наблюдения с подтверждёнными алиасами; новые соответствия требуют ручной проверки, неполные наблюдения не публикуются;
|
- community-источники автоматически включают в активность только полные наблюдения с подтверждёнными external-ID алиасами; fallback alias по имени ещё не используется автопубликацией. Неполные наблюдения видны в «Полевых сигналах», но не влияют на индекс;
|
||||||
- offset pagination рассчитана на пилотные объёмы, не на бесконечную ленту;
|
- offset pagination рассчитана на пилотные объёмы, не на бесконечную ленту;
|
||||||
- Lighthouse LCP локального прогона — 9,3 с; оптимизация изображения/CDN остаётся после размещения;
|
- локальный Lighthouse production-сборки 12 сентября показал performance 100, LCP 1,66 с, CLS 0,023 и TBT 9 мс; это лабораторный baseline, полевой INP и серверные метрики появятся после размещения;
|
||||||
- один сервер остаётся точкой отказа, поэтому обязательны внешний backup и мониторинг;
|
- один сервер остаётся точкой отказа, поэтому обязательны внешний backup и мониторинг;
|
||||||
- текущий публичный DNS/TLS не подтверждён, см. [статус развёртывания](docs/deployment-status.md).
|
- текущий публичный DNS/TLS не подтверждён, см. [статус развёртывания](docs/deployment-status.md).
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||||
COPY apps/api/requirements.txt .
|
COPY apps/api/requirements-lock.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
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", "alembic upgrade head && 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,27 @@
|
|||||||
|
"""Add persistent administrative authentication rate limit."""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0014"
|
||||||
|
down_revision = "20260910_recovery"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"admin_auth_attempt",
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("client_hash", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_admin_auth_attempt_client_hash", "admin_auth_attempt", ["client_hash"])
|
||||||
|
op.create_index("ix_admin_auth_attempt_created_at", "admin_auth_attempt", ["created_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_admin_auth_attempt_created_at", table_name="admin_auth_attempt")
|
||||||
|
op.drop_index("ix_admin_auth_attempt_client_hash", table_name="admin_auth_attempt")
|
||||||
|
op.drop_table("admin_auth_attempt")
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""add optimistic moderation versions
|
||||||
|
|
||||||
|
Revision ID: 0015
|
||||||
|
Revises: 0014
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0015"
|
||||||
|
down_revision = "0014"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("catch_report", sa.Column("moderation_version", sa.Integer(), nullable=False, server_default="0"))
|
||||||
|
op.add_column("external_observation", sa.Column("moderation_version", sa.Integer(), nullable=False, server_default="0"))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("external_observation", "moderation_version")
|
||||||
|
op.drop_column("catch_report", "moderation_version")
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""track source record lifecycle checks
|
||||||
|
|
||||||
|
Revision ID: 0016
|
||||||
|
Revises: 0015
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0016"
|
||||||
|
down_revision = "0015"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("external_observation", sa.Column("source_check_status", sa.String(length=30)))
|
||||||
|
op.add_column("external_observation", sa.Column("source_checked_at", sa.DateTime(timezone=True)))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("external_observation", "source_checked_at")
|
||||||
|
op.drop_column("external_observation", "source_check_status")
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""add canonical waterbody provenance fields
|
||||||
|
|
||||||
|
Revision ID: 0017
|
||||||
|
Revises: 0016
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0017"
|
||||||
|
down_revision = "0016"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("waterbody", sa.Column("source_system", sa.String(length=50), nullable=True))
|
||||||
|
op.add_column("waterbody", sa.Column("source_external_id", sa.String(length=200), nullable=True))
|
||||||
|
op.add_column("waterbody", sa.Column("source_url", sa.Text(), nullable=True))
|
||||||
|
op.add_column("waterbody", sa.Column("description", sa.Text(), nullable=True))
|
||||||
|
op.add_column("waterbody", sa.Column("source_checked_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
op.create_index(
|
||||||
|
"uq_waterbody_source_identity",
|
||||||
|
"waterbody",
|
||||||
|
["source_system", "source_external_id"],
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("uq_waterbody_source_identity", table_name="waterbody")
|
||||||
|
op.drop_column("waterbody", "source_checked_at")
|
||||||
|
op.drop_column("waterbody", "description")
|
||||||
|
op.drop_column("waterbody", "source_url")
|
||||||
|
op.drop_column("waterbody", "source_external_id")
|
||||||
|
op.drop_column("waterbody", "source_system")
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""store source coordinate text and precision
|
||||||
|
|
||||||
|
Revision ID: 0018
|
||||||
|
Revises: 0017
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0018"
|
||||||
|
down_revision = "0017"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("external_observation", sa.Column("coordinate_raw", sa.String(length=200), nullable=True))
|
||||||
|
op.add_column("external_observation", sa.Column("coordinate_precision", sa.String(length=20), nullable=True))
|
||||||
|
op.execute("UPDATE external_observation SET coordinate_precision = CASE WHEN x IS NOT NULL AND y IS NOT NULL THEN 'exact' ELSE 'missing' END")
|
||||||
|
op.alter_column("external_observation", "coordinate_precision", nullable=False, server_default="missing")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("external_observation", "coordinate_precision")
|
||||||
|
op.drop_column("external_observation", "coordinate_raw")
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""store canonical waterbody fish count
|
||||||
|
|
||||||
|
Revision ID: 0019
|
||||||
|
Revises: 0018
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0019"
|
||||||
|
down_revision = "0018"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("waterbody", sa.Column("fish_species_count", sa.Integer(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("waterbody", "fish_species_count")
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""store canonical waterbody detail facts
|
||||||
|
|
||||||
|
Revision ID: 0020
|
||||||
|
Revises: 0019
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0020"
|
||||||
|
down_revision = "0019"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
for name in ("source_aliases", "source_fish_species", "source_image_urls", "source_point_urls"):
|
||||||
|
op.add_column("waterbody", sa.Column(name, sa.JSON(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
for name in ("source_point_urls", "source_image_urls", "source_fish_species", "source_aliases"):
|
||||||
|
op.drop_column("waterbody", name)
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""add recovery idempotency and import history columns
|
||||||
|
|
||||||
|
Revision ID: 20260910_recovery
|
||||||
|
Revises: 48094a7d1b92
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "20260910_recovery"
|
||||||
|
down_revision = "48094a7d1b92"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("submission_attempt", sa.Column("idempotency_key", sa.String(128), nullable=True))
|
||||||
|
op.add_column("submission_attempt", sa.Column("catch_report_id", sa.Uuid(), nullable=True))
|
||||||
|
op.add_column("submission_attempt", sa.Column("payload_hash", sa.String(64), nullable=True))
|
||||||
|
op.create_foreign_key("fk_submission_attempt_report", "submission_attempt", "catch_report", ["catch_report_id"], ["id"])
|
||||||
|
op.create_index("ix_submission_attempt_idempotency_key", "submission_attempt", ["idempotency_key"], unique=True)
|
||||||
|
op.add_column("import_record_event", sa.Column("changes", sa.JSON(), nullable=True))
|
||||||
|
op.add_column("import_record_event", sa.Column("provenance", sa.JSON(), nullable=True))
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("import_record_event", "provenance")
|
||||||
|
op.drop_column("import_record_event", "changes")
|
||||||
|
op.drop_index("ix_submission_attempt_idempotency_key", table_name="submission_attempt")
|
||||||
|
op.drop_constraint("fk_submission_attempt_report", "submission_attempt", type_="foreignkey")
|
||||||
|
op.drop_column("submission_attempt", "catch_report_id")
|
||||||
|
op.drop_column("submission_attempt", "payload_hash")
|
||||||
|
op.drop_column("submission_attempt", "idempotency_key")
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""add_import_record_event_table
|
||||||
|
|
||||||
|
Revision ID: 48094a7d1b92
|
||||||
|
Revises: 0013
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = '48094a7d1b92'
|
||||||
|
down_revision: Union[str, None] = '0013'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"import_record_event",
|
||||||
|
sa.Column("id", sa.Uuid(), server_default=sa.func.gen_random_uuid(), primary_key=True),
|
||||||
|
sa.Column("catch_report_id", sa.Uuid(), sa.ForeignKey("catch_report.id"), nullable=False, index=True),
|
||||||
|
sa.Column("import_run_id", sa.Uuid(), sa.ForeignKey("official_record_import.id"), nullable=False, index=True),
|
||||||
|
sa.Column("event_type", sa.String(20), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("import_record_event")
|
||||||
@@ -34,13 +34,13 @@ def activity_rows(
|
|||||||
CatchReport.reported_at >= now - timedelta(hours=hours),
|
CatchReport.reported_at >= now - timedelta(hours=hours),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
reports = list(session.scalars(query))
|
|
||||||
if waterbody:
|
if waterbody:
|
||||||
reports = [r for r in reports if r.waterbody.slug == waterbody]
|
query = query.where(CatchReport.waterbody.has(slug=waterbody))
|
||||||
if fish:
|
if fish:
|
||||||
reports = [r for r in reports if r.fish.slug == fish]
|
query = query.where(CatchReport.fish.has(slug=fish))
|
||||||
if method:
|
if method:
|
||||||
reports = [r for r in reports if r.fishing_method == method]
|
query = query.where(CatchReport.fishing_method == method)
|
||||||
|
reports = list(session.scalars(query))
|
||||||
|
|
||||||
groups: dict[tuple[object, object], list[CatchReport]] = {}
|
groups: dict[tuple[object, object], list[CatchReport]] = {}
|
||||||
for report in reports:
|
for report in reports:
|
||||||
@@ -56,7 +56,15 @@ def activity_rows(
|
|||||||
activity = round(55 * min(1, weighted / 12) + 25 * min(1, len(players) / 6) + 20 * min(1, trophies / 3))
|
activity = round(55 * min(1, weighted / 12) + 25 * min(1, len(players) / 6) + 20 * min(1, trophies / 3))
|
||||||
average_confidence = sum(r.source_confidence for r in items) / len(items)
|
average_confidence = sum(r.source_confidence for r in items) / len(items)
|
||||||
confidence = round(45 * min(1, len(items) / 10) + 35 * min(1, len(players) / 5) + 20 * average_confidence / 100)
|
confidence = round(45 * min(1, len(items) / 10) + 35 * min(1, len(players) / 5) + 20 * average_confidence / 100)
|
||||||
latest = max(_aware(r.caught_at or r.reported_at) for r in items)
|
# Cap confidence: 1 player → max 50%, 2 players → max 65%
|
||||||
|
if len(players) == 1:
|
||||||
|
confidence = min(confidence, 50)
|
||||||
|
elif len(players) == 2:
|
||||||
|
confidence = min(confidence, 65)
|
||||||
|
coordinate_precisions = {_coordinate_precision(item) for item in items}
|
||||||
|
coordinate_precision = max(coordinate_precisions, key=_precision_rank)
|
||||||
|
coordinate_sources = sorted({_source_system(item) for item in items})
|
||||||
|
latest = max(_aware(r.reported_at) for r in items)
|
||||||
baits = Counter(r.bait.name for r in items if r.bait)
|
baits = Counter(r.bait.name for r in items if r.bait)
|
||||||
freshness_text = _freshness_text(now - latest)
|
freshness_text = _freshness_text(now - latest)
|
||||||
result.append(ActivityOut(
|
result.append(ActivityOut(
|
||||||
@@ -69,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)
|
||||||
|
|
||||||
@@ -85,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)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
|
||||||
|
from fastapi import HTTPException, Request
|
||||||
|
from sqlalchemy import delete, func, select, text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .models import AdminAuthAttempt
|
||||||
|
from .submission_security import RateLimitConfig, client_address
|
||||||
|
|
||||||
|
|
||||||
|
class AdminAuthConfig(RateLimitConfig):
|
||||||
|
admin_token: str
|
||||||
|
admin_auth_attempt_limit: int
|
||||||
|
admin_auth_window_seconds: int
|
||||||
|
|
||||||
|
|
||||||
|
def verify_admin(
|
||||||
|
request: Request,
|
||||||
|
db: Session,
|
||||||
|
authorization: str | None,
|
||||||
|
config: AdminAuthConfig,
|
||||||
|
) -> str:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
cutoff = now - timedelta(seconds=config.admin_auth_window_seconds)
|
||||||
|
client = client_address(request, config.trusted_proxy_cidrs)
|
||||||
|
client_hash = hmac.new(
|
||||||
|
config.rate_limit_secret.encode(), f"admin:{client}".encode(), hashlib.sha256
|
||||||
|
).hexdigest()
|
||||||
|
if db.get_bind().dialect.name == "postgresql":
|
||||||
|
lock_key = int(client_hash[:16], 16) & 0x7FFF_FFFF_FFFF_FFFF
|
||||||
|
db.execute(text("SELECT pg_advisory_xact_lock(:lock_key)"), {"lock_key": lock_key})
|
||||||
|
db.execute(delete(AdminAuthAttempt).where(AdminAuthAttempt.created_at < now - timedelta(days=1)))
|
||||||
|
failures = db.scalar(
|
||||||
|
select(func.count()).select_from(AdminAuthAttempt).where(
|
||||||
|
AdminAuthAttempt.client_hash == client_hash,
|
||||||
|
AdminAuthAttempt.created_at >= cutoff,
|
||||||
|
)
|
||||||
|
) or 0
|
||||||
|
if failures >= config.admin_auth_attempt_limit:
|
||||||
|
db.commit()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail="too many admin authentication attempts",
|
||||||
|
headers={"Retry-After": str(config.admin_auth_window_seconds)},
|
||||||
|
)
|
||||||
|
expected = f"Bearer {config.admin_token}"
|
||||||
|
if not authorization or not hmac.compare_digest(authorization, expected):
|
||||||
|
db.add(AdminAuthAttempt(client_hash=client_hash, created_at=now))
|
||||||
|
db.commit()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="invalid admin token",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
db.execute(delete(AdminAuthAttempt).where(AdminAuthAttempt.client_hash == client_hash))
|
||||||
|
db.commit()
|
||||||
|
return "admin"
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,10 +8,20 @@ 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
|
||||||
|
|
||||||
|
# Static registry for argparse choices — no DB required for --help
|
||||||
|
STATIC_SOURCE_CHOICES = [
|
||||||
|
"rf4db",
|
||||||
|
"rf4stat-fishing",
|
||||||
|
"rf4stat-post",
|
||||||
|
"rf4map",
|
||||||
|
"rf4posts-spot",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
@@ -24,9 +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.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":
|
||||||
@@ -45,6 +66,51 @@ 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":
|
||||||
|
# A09: Verify source is enabled at runtime (not just in static choices)
|
||||||
|
enabled = configured_sources()
|
||||||
|
if args.source not in enabled:
|
||||||
|
print(f"source {args.source!r} is disabled or not configured", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
started = run_source(args.source)
|
||||||
|
print("community fetch started" if started else "community fetch skipped: locked or cooling down")
|
||||||
elif args.command == "cleanup-retention":
|
elif args.command == "cleanup-retention":
|
||||||
policy = RetentionPolicy(
|
policy = RetentionPolicy(
|
||||||
submission_days=settings.retention_submission_days,
|
submission_days=settings.retention_submission_days,
|
||||||
@@ -56,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))
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -9,7 +11,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .community_review import publish_observation
|
from .community_review import publish_observation
|
||||||
from .models import DataSource, ExternalEntityAlias, ExternalObservation
|
from .models import DataSource, ExternalEntityAlias, ExternalObservation, Fish, ModerationStatus, Waterbody
|
||||||
|
|
||||||
|
|
||||||
SOURCE_DEFAULTS = {
|
SOURCE_DEFAULTS = {
|
||||||
@@ -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,9 +208,12 @@ 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,
|
||||||
|
"source_check_status": "available", "source_checked_at": fetched_at,
|
||||||
}
|
}
|
||||||
if observation is None:
|
if observation is None:
|
||||||
observation = ExternalObservation(
|
observation = ExternalObservation(
|
||||||
@@ -77,8 +223,30 @@ def stage_observations(
|
|||||||
session.add(observation)
|
session.add(observation)
|
||||||
created += 1
|
created += 1
|
||||||
else:
|
else:
|
||||||
|
# Preserve the published snapshot, but withdraw it from activity until
|
||||||
|
# a moderator confirms the changed source record.
|
||||||
|
changed = any(getattr(observation, key) != values[key] for key in (
|
||||||
|
"source_url", "fish_name", "fish_external_id", "waterbody_name",
|
||||||
|
"waterbody_external_id", "x", "y", "weight_g",
|
||||||
|
"coordinate_raw", "coordinate_precision",
|
||||||
|
)) or observation.payload != payload
|
||||||
|
if observation.status != "rejected" and changed and observation.catch_report is not None:
|
||||||
|
observation.catch_report.moderation_status = ModerationStatus.pending
|
||||||
|
observation.status = "staged"
|
||||||
|
observation.fish = None
|
||||||
|
observation.waterbody = None
|
||||||
|
observation.review_note = "Source record changed; manual mapping and publication required"
|
||||||
|
observation.reviewed_at = fetched_at
|
||||||
|
observation.moderation_version += 1
|
||||||
for key, value in values.items():
|
for key, value in values.items():
|
||||||
setattr(observation, key, value)
|
setattr(observation, key, value)
|
||||||
|
if observation.status == "withdrawn":
|
||||||
|
observation.status = "staged"
|
||||||
|
observation.fish = None
|
||||||
|
observation.waterbody = None
|
||||||
|
observation.review_note = "Source record reappeared; manual confirmation required"
|
||||||
|
observation.reviewed_at = fetched_at
|
||||||
|
observation.moderation_version += 1
|
||||||
updated += 1
|
updated += 1
|
||||||
touched.append(observation)
|
touched.append(observation)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -90,31 +258,56 @@ def stage_observations(
|
|||||||
def _auto_publish(session: Session, observation: ExternalObservation) -> bool:
|
def _auto_publish(session: Session, observation: ExternalObservation) -> bool:
|
||||||
"""Publish only complete observations covered by previously reviewed aliases."""
|
"""Publish only complete observations covered by previously reviewed aliases."""
|
||||||
if (
|
if (
|
||||||
|
observation.catch_report_id is not None
|
||||||
|
or
|
||||||
observation.status not in {"staged", "mapped", "ready"}
|
observation.status not in {"staged", "mapped", "ready"}
|
||||||
or not observation.source.enabled
|
or not observation.source.enabled
|
||||||
or observation.fish_external_id is None
|
|
||||||
or observation.waterbody_external_id is None
|
|
||||||
or observation.x is None
|
or observation.x is None
|
||||||
or observation.y is None
|
or observation.y is None
|
||||||
or observation.weight_g is None
|
or observation.weight_g is None
|
||||||
):
|
):
|
||||||
return False
|
return False
|
||||||
fish_alias = session.scalar(select(ExternalEntityAlias).where(
|
# Fish: prefer external alias, fall back to exact name match
|
||||||
ExternalEntityAlias.source_system == observation.source_system,
|
fish = None
|
||||||
ExternalEntityAlias.entity_type == "fish",
|
if observation.fish_external_id is not None:
|
||||||
ExternalEntityAlias.external_id == observation.fish_external_id,
|
fish_alias = session.scalar(select(ExternalEntityAlias).where(
|
||||||
))
|
ExternalEntityAlias.source_system == observation.source_system,
|
||||||
waterbody_alias = session.scalar(select(ExternalEntityAlias).where(
|
ExternalEntityAlias.entity_type == "fish",
|
||||||
ExternalEntityAlias.source_system == observation.source_system,
|
ExternalEntityAlias.external_id == observation.fish_external_id,
|
||||||
ExternalEntityAlias.entity_type == "waterbody",
|
))
|
||||||
ExternalEntityAlias.external_id == observation.waterbody_external_id,
|
if fish_alias and fish_alias.fish:
|
||||||
))
|
fish = fish_alias.fish
|
||||||
if fish_alias is None or fish_alias.fish is None or waterbody_alias is None or waterbody_alias.waterbody is None:
|
if fish is None:
|
||||||
|
# Fallback: exact name match
|
||||||
|
fish = session.scalar(
|
||||||
|
select(Fish).where(Fish.name_ru == observation.fish_name)
|
||||||
|
)
|
||||||
|
if fish is None:
|
||||||
return False
|
return False
|
||||||
observation.fish = fish_alias.fish
|
# Waterbody: prefer external alias, fall back to exact name match
|
||||||
observation.waterbody = waterbody_alias.waterbody
|
waterbody = None
|
||||||
|
if observation.waterbody_external_id is not None:
|
||||||
|
waterbody_alias = session.scalar(select(ExternalEntityAlias).where(
|
||||||
|
ExternalEntityAlias.source_system == observation.source_system,
|
||||||
|
ExternalEntityAlias.entity_type == "waterbody",
|
||||||
|
ExternalEntityAlias.external_id == observation.waterbody_external_id,
|
||||||
|
))
|
||||||
|
if waterbody_alias and waterbody_alias.waterbody:
|
||||||
|
waterbody = waterbody_alias.waterbody
|
||||||
|
if waterbody is None:
|
||||||
|
# Fallback: exact name match
|
||||||
|
waterbody = session.scalar(
|
||||||
|
select(Waterbody).where(Waterbody.name_ru == observation.waterbody_name)
|
||||||
|
)
|
||||||
|
if waterbody is None:
|
||||||
|
return False
|
||||||
|
observation.fish = fish
|
||||||
|
observation.waterbody = waterbody
|
||||||
observation.status = "ready"
|
observation.status = "ready"
|
||||||
observation.review_note = "Automatically matched by previously reviewed source aliases"
|
# A07: Describe actual matching method used
|
||||||
|
fish_method = "external_id" if observation.fish_external_id else "name"
|
||||||
|
wb_method = "external_id" if observation.waterbody_external_id else "name"
|
||||||
|
observation.review_note = f"Auto-matched: fish via {fish_method}, waterbody via {wb_method}"
|
||||||
publish_observation(session, observation)
|
publish_observation(session, observation)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -139,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
|
||||||
|
|||||||
@@ -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):
|
||||||
@@ -25,7 +27,18 @@ def map_observation(
|
|||||||
raise ExternalReviewError("published observation cannot be remapped")
|
raise ExternalReviewError("published observation cannot be remapped")
|
||||||
observation.fish = fish
|
observation.fish = fish
|
||||||
observation.waterbody = waterbody
|
observation.waterbody = waterbody
|
||||||
observation.review_note = note
|
# A07: Explain the matching method in review_note
|
||||||
|
match_method = []
|
||||||
|
if observation.fish_external_id:
|
||||||
|
match_method.append(f"external_id={observation.fish_external_id}")
|
||||||
|
elif observation.fish_name:
|
||||||
|
match_method.append(f"name={observation.fish_name}")
|
||||||
|
if observation.waterbody_external_id:
|
||||||
|
match_method.append(f"wb_external_id={observation.waterbody_external_id}")
|
||||||
|
elif observation.waterbody_name:
|
||||||
|
match_method.append(f"wb_name={observation.waterbody_name}")
|
||||||
|
method_explanation = f"matched via {', '.join(match_method)}"
|
||||||
|
observation.review_note = f"{method_explanation}" + (f"; {note}" if note else "")
|
||||||
observation.reviewed_at = datetime.now(timezone.utc)
|
observation.reviewed_at = datetime.now(timezone.utc)
|
||||||
observation.status = "ready" if _complete(observation) else "mapped"
|
observation.status = "ready" if _complete(observation) else "mapped"
|
||||||
_save_alias(session, observation, "fish", observation.fish_external_id or observation.fish_name, fish=fish)
|
_save_alias(session, observation, "fish", observation.fish_external_id or observation.fish_name, fish=fish)
|
||||||
@@ -34,6 +47,20 @@ def map_observation(
|
|||||||
return observation
|
return observation
|
||||||
|
|
||||||
|
|
||||||
|
def suggest_aliases(session: Session, observation: ExternalObservation) -> tuple[Fish | None, Waterbody | None]:
|
||||||
|
fish_alias = session.scalar(select(ExternalEntityAlias).where(
|
||||||
|
ExternalEntityAlias.source_system == observation.source_system,
|
||||||
|
ExternalEntityAlias.entity_type == "fish",
|
||||||
|
ExternalEntityAlias.external_id == (observation.fish_external_id or observation.fish_name),
|
||||||
|
))
|
||||||
|
waterbody_alias = session.scalar(select(ExternalEntityAlias).where(
|
||||||
|
ExternalEntityAlias.source_system == observation.source_system,
|
||||||
|
ExternalEntityAlias.entity_type == "waterbody",
|
||||||
|
ExternalEntityAlias.external_id == (observation.waterbody_external_id or observation.waterbody_name),
|
||||||
|
))
|
||||||
|
return (fish_alias.fish if fish_alias else None, waterbody_alias.waterbody if waterbody_alias else None)
|
||||||
|
|
||||||
|
|
||||||
def reject_observation(session: Session, observation: ExternalObservation, *, reason: str) -> ExternalObservation:
|
def reject_observation(session: Session, observation: ExternalObservation, *, reason: str) -> ExternalObservation:
|
||||||
if observation.status == "published":
|
if observation.status == "published":
|
||||||
raise ExternalReviewError("published observation cannot be rejected")
|
raise ExternalReviewError("published observation cannot be rejected")
|
||||||
@@ -45,8 +72,10 @@ def reject_observation(session: Session, observation: ExternalObservation, *, re
|
|||||||
|
|
||||||
|
|
||||||
def publish_observation(session: Session, observation: ExternalObservation) -> CatchReport:
|
def publish_observation(session: Session, observation: ExternalObservation) -> CatchReport:
|
||||||
if observation.catch_report is not None:
|
if observation.catch_report is not None and observation.status == "published":
|
||||||
return observation.catch_report
|
return observation.catch_report
|
||||||
|
if observation.status == "rejected":
|
||||||
|
raise ExternalReviewError("rejected observation must be mapped again before publication")
|
||||||
if observation.fish is None or observation.waterbody is None or not _complete(observation):
|
if observation.fish is None or observation.waterbody is None or not _complete(observation):
|
||||||
raise ExternalReviewError("fish, waterbody, coordinates and weight are required for publication")
|
raise ExternalReviewError("fish, waterbody, coordinates and weight are required for publication")
|
||||||
spot = session.scalar(select(Spot).where(
|
spot = session.scalar(select(Spot).where(
|
||||||
@@ -57,14 +86,14 @@ def publish_observation(session: Session, observation: ExternalObservation) -> C
|
|||||||
session.add(spot)
|
session.add(spot)
|
||||||
bait = _bait(session, observation.payload.get("bait"))
|
bait = _bait(session, observation.payload.get("bait"))
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
report = CatchReport(
|
values = dict(
|
||||||
fish=observation.fish, waterbody=observation.waterbody, spot=spot, bait=bait,
|
fish=observation.fish, waterbody=observation.waterbody, spot=spot, bait=bait,
|
||||||
weight_g=observation.weight_g,
|
weight_g=observation.weight_g,
|
||||||
fishing_method=observation.payload.get("fishing_method"),
|
fishing_method=observation.payload.get("fishing_method"),
|
||||||
rig_type=observation.payload.get("rig_type"),
|
rig_type=observation.payload.get("rig_type"),
|
||||||
retrieve_method=observation.payload.get("retrieve_method"),
|
retrieve_method=observation.payload.get("retrieve_method"),
|
||||||
retrieve_speed=observation.payload.get("retrieve_speed"),
|
retrieve_speed=observation.payload.get("retrieve_speed"),
|
||||||
caught_at=observation.published_at,
|
caught_at=None,
|
||||||
reported_at=observation.published_at or observation.first_seen_at,
|
reported_at=observation.published_at or observation.first_seen_at,
|
||||||
player_name=observation.payload.get("player_name"),
|
player_name=observation.payload.get("player_name"),
|
||||||
source_type=SourceType.manual_import,
|
source_type=SourceType.manual_import,
|
||||||
@@ -77,12 +106,31 @@ 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,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
report = observation.catch_report
|
||||||
|
if report is None:
|
||||||
|
report = CatchReport(**values)
|
||||||
|
else:
|
||||||
|
for key, value in values.items():
|
||||||
|
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
|
||||||
@@ -109,6 +157,8 @@ def _save_alias(
|
|||||||
external_id=external_id, external_name=observation.fish_name if fish else observation.waterbody_name,
|
external_id=external_id, external_name=observation.fish_name if fish else observation.waterbody_name,
|
||||||
)
|
)
|
||||||
session.add(alias)
|
session.add(alias)
|
||||||
|
elif (fish is not None and alias.fish_id != fish.id) or (waterbody is not None and alias.waterbody_id != waterbody.id):
|
||||||
|
raise ExternalReviewError(f"confirmed {entity_type} alias points to another entity")
|
||||||
alias.fish = fish
|
alias.fish = fish
|
||||||
alias.waterbody = waterbody
|
alias.waterbody = waterbody
|
||||||
alias.updated_at = datetime.now(timezone.utc)
|
alias.updated_at = datetime.now(timezone.utc)
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ from datetime import datetime, timedelta, timezone
|
|||||||
|
|
||||||
from sqlalchemy import select, text
|
from sqlalchemy import select, text
|
||||||
|
|
||||||
from rf4_research.community_cli import SOURCES, fetch_html
|
from rf4_research.community_cli import SOURCES, fetch_html, fetch_site_key
|
||||||
from rf4_research.community_sources import parse_rf4map_point, parse_rf4posts_spot
|
from rf4_research.community_sources import parse_rf4map_point, parse_rf4posts_spot
|
||||||
from .community_importer import stage_observations
|
from .community_importer import stage_observations
|
||||||
from .config import settings
|
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
|
||||||
@@ -26,7 +27,9 @@ def retry_delay(statuses: list[str]) -> int:
|
|||||||
failures += 1
|
failures += 1
|
||||||
return min(settings.community_import_interval_seconds * (2 ** max(0, failures - 1)), MAX_BACKOFF_SECONDS)
|
return min(settings.community_import_interval_seconds * (2 ** max(0, failures - 1)), MAX_BACKOFF_SECONDS)
|
||||||
|
|
||||||
def configured_sources():
|
|
||||||
|
def _static_registry() -> dict[str, tuple[str, callable]]:
|
||||||
|
"""Return the full static registry without DB access (for unit tests)."""
|
||||||
return {
|
return {
|
||||||
"rf4db": SOURCES["rf4db"],
|
"rf4db": SOURCES["rf4db"],
|
||||||
"rf4stat-fishing": SOURCES["rf4stat-fishing"],
|
"rf4stat-fishing": SOURCES["rf4stat-fishing"],
|
||||||
@@ -35,30 +38,92 @@ def configured_sources():
|
|||||||
"rf4posts-spot": (settings.rf4posts_spot_url, parse_rf4posts_spot),
|
"rf4posts-spot": (settings.rf4posts_spot_url, parse_rf4posts_spot),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def configured_sources(enabled_keys: set[str] | None = None) -> dict[str, tuple[str, callable]]:
|
||||||
|
"""Return enabled sources. When enabled_keys is None, query the DB."""
|
||||||
|
registry = _static_registry()
|
||||||
|
if enabled_keys is None:
|
||||||
|
with SessionLocal() as session:
|
||||||
|
enabled_keys = {
|
||||||
|
s.key for s in session.scalars(select(DataSource).where(DataSource.enabled.is_(True)))
|
||||||
|
}
|
||||||
|
return {k: v for k, v in registry.items() if k in enabled_keys}
|
||||||
|
|
||||||
|
|
||||||
|
def oldest_site_source(source_system: str, latest_by_source: dict[str, datetime], enabled_keys: set[str] | None = None) -> str:
|
||||||
|
"""Return the oldest candidate for the same site.
|
||||||
|
|
||||||
|
Uses the full registry (not just enabled) for cooldown history so that
|
||||||
|
disabling an endpoint does not reset the site-wide cooldown for its
|
||||||
|
neighbours. enabled_keys is used to filter candidates after the oldest
|
||||||
|
is found — if the oldest is disabled, the next oldest enabled is picked.
|
||||||
|
"""
|
||||||
|
registry = _static_registry()
|
||||||
|
site = fetch_site_key(registry[source_system][0])
|
||||||
|
candidates = [key for key, (url, _) in registry.items() if fetch_site_key(url) == site]
|
||||||
|
order = {key: index for index, key in enumerate(candidates)}
|
||||||
|
# Sort by (last_run, order) — pick oldest
|
||||||
|
sorted_candidates = sorted(candidates, key=lambda key: (latest_by_source.get(key, datetime.min.replace(tzinfo=timezone.utc)), order[key]))
|
||||||
|
# If enabled_keys is provided, prefer enabled; otherwise return oldest regardless
|
||||||
|
if enabled_keys:
|
||||||
|
enabled = [k for k in sorted_candidates if k in enabled_keys]
|
||||||
|
if enabled:
|
||||||
|
return enabled[0]
|
||||||
|
return sorted_candidates[0]
|
||||||
|
|
||||||
def run_source(source_system: str, *, now: datetime | None = None) -> bool:
|
def run_source(source_system: str, *, now: datetime | None = None) -> bool:
|
||||||
current = now or datetime.now(timezone.utc)
|
current = now or datetime.now(timezone.utc)
|
||||||
url, parser = configured_sources()[source_system]
|
with SessionLocal() as session:
|
||||||
|
enabled_keys = {s.key for s in session.scalars(select(DataSource).where(DataSource.enabled.is_(True)))}
|
||||||
|
registry = _static_registry()
|
||||||
|
url, parser = registry[source_system]
|
||||||
|
site_key = fetch_site_key(url)
|
||||||
|
site_sources = [key for key, (candidate_url, _) in registry.items() if fetch_site_key(candidate_url) == site_key]
|
||||||
with SessionLocal() as session:
|
with SessionLocal() as session:
|
||||||
source = session.get(DataSource, source_system)
|
source = session.get(DataSource, source_system)
|
||||||
if source is None or not source.enabled:
|
if source is None or not source.enabled:
|
||||||
return False
|
return False
|
||||||
recent = list(session.scalars(select(CommunityImportRun).where(CommunityImportRun.source_system == source_system).order_by(CommunityImportRun.started_at.desc()).limit(8)))
|
# Lock before reading cooldown: committing the reservation makes it visible
|
||||||
|
# to the next contender before releasing this transaction lock.
|
||||||
|
if session.bind and session.bind.dialect.name == "postgresql" and not session.scalar(text("select pg_try_advisory_xact_lock(hashtext(:key))"), {"key": f"community-site:{site_key}"}):
|
||||||
|
return False
|
||||||
|
recent = list(session.scalars(select(CommunityImportRun).where(CommunityImportRun.source_system.in_(site_sources)).order_by(CommunityImportRun.started_at.desc()).limit(32)))
|
||||||
|
latest_by_source: dict[str, datetime] = {}
|
||||||
|
for previous in recent:
|
||||||
|
latest_by_source.setdefault(previous.source_system, previous.started_at if previous.started_at.tzinfo else previous.started_at.replace(tzinfo=timezone.utc))
|
||||||
|
if oldest_site_source(source_system, latest_by_source, enabled_keys) != source_system:
|
||||||
|
return False
|
||||||
latest = recent[0].started_at if recent else None
|
latest = recent[0].started_at if recent else None
|
||||||
delay = retry_delay([run.status for run in recent])
|
delay = retry_delay([run.status for run in recent])
|
||||||
if latest and (latest if latest.tzinfo else latest.replace(tzinfo=timezone.utc)) > current - timedelta(seconds=delay):
|
if latest and (latest if latest.tzinfo else latest.replace(tzinfo=timezone.utc)) > current - timedelta(seconds=delay):
|
||||||
return False
|
return False
|
||||||
if session.bind and session.bind.dialect.name == "postgresql" and not session.scalar(text("select pg_try_advisory_xact_lock(hashtext(:key))"), {"key": f"community:{source_system}"}):
|
|
||||||
return False
|
|
||||||
run = CommunityImportRun(source_system=source_system, source_url=url, started_at=current, status="running")
|
run = CommunityImportRun(source_system=source_system, source_url=url, started_at=current, status="running")
|
||||||
session.add(run); session.commit()
|
session.add(run); session.commit()
|
||||||
try:
|
try:
|
||||||
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()
|
||||||
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
|||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
deployment_environment: str = "development"
|
deployment_environment: str = "development"
|
||||||
|
app_version: str = "0.1.0"
|
||||||
|
app_revision: str = "dev"
|
||||||
database_url: str = "postgresql+psycopg://rf4:rf4_local@localhost:5432/rf4_spotter"
|
database_url: str = "postgresql+psycopg://rf4:rf4_local@localhost:5432/rf4_spotter"
|
||||||
admin_token: str = "change-me-in-production"
|
admin_token: str = "change-me-in-production"
|
||||||
s3_endpoint_url: str = "http://localhost:9000"
|
s3_endpoint_url: str = "http://localhost:9000"
|
||||||
@@ -25,11 +27,26 @@ class Settings(BaseSettings):
|
|||||||
retention_published_payload_days: int = Field(default=365, ge=90)
|
retention_published_payload_days: int = Field(default=365, ge=90)
|
||||||
import_interval_seconds: int = Field(default=3600, ge=3600)
|
import_interval_seconds: int = Field(default=3600, ge=3600)
|
||||||
community_import_interval_seconds: int = Field(default=1800, ge=1800)
|
community_import_interval_seconds: int = Field(default=1800, ge=1800)
|
||||||
|
public_cache_seconds: int = Field(default=20, ge=1, le=300)
|
||||||
rf4map_point_url: str = "https://rf4map.ru/points/275"
|
rf4map_point_url: str = "https://rf4map.ru/points/275"
|
||||||
rf4posts_spot_url: str = "https://rf4-posts.com/ru/spots/d0c6d9c6-4ebf-49a7-98a8-9a562553a8ee"
|
rf4posts_spot_url: str = "https://rf4-posts.com/ru/spots/d0c6d9c6-4ebf-49a7-98a8-9a562553a8ee"
|
||||||
rate_limit_secret: str = "change-rate-limit-secret"
|
rate_limit_secret: str = "change-rate-limit-secret"
|
||||||
|
admin_auth_attempt_limit: int = Field(default=10, ge=3, le=100)
|
||||||
|
admin_auth_window_seconds: int = Field(default=600, ge=60, le=3600)
|
||||||
log_level: str = "INFO"
|
log_level: str = "INFO"
|
||||||
cors_origins: list[str] = Field(default_factory=lambda: ["http://localhost:4321", "http://127.0.0.1:4321"])
|
cors_origins: list[str] = Field(default_factory=lambda: ["http://localhost:4321", "http://127.0.0.1:4321"])
|
||||||
|
trusted_proxy_cidrs: list[str] = Field(default_factory=lambda: ["127.0.0.1/32", "::1/128"])
|
||||||
|
|
||||||
|
@model_validator(mode="before")
|
||||||
|
@classmethod
|
||||||
|
def parse_comma_separated_lists(cls, data: dict) -> dict:
|
||||||
|
"""Parse comma-separated string values into lists."""
|
||||||
|
if isinstance(data, dict):
|
||||||
|
for field_name in ["cors_origins", "trusted_proxy_cidrs"]:
|
||||||
|
value = data.get(field_name)
|
||||||
|
if isinstance(value, str) and value:
|
||||||
|
data[field_name] = [item.strip() for item in value.split(",") if item.strip()]
|
||||||
|
return data
|
||||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .database import get_session
|
||||||
|
|
||||||
|
Db = Annotated[Session, Depends(get_session)]
|
||||||
@@ -13,9 +13,11 @@ from sqlalchemy.orm import Session
|
|||||||
from rf4_research.official_parser import RecordsContractError, parse_official_records
|
from rf4_research.official_parser import RecordsContractError, parse_official_records
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
Bait, BaitKind, CatchReport, Fish, 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)"
|
||||||
@@ -185,13 +187,51 @@ def _import_records_locked(session: Session, *, url: str, region: str, category:
|
|||||||
bait = _bait(session, raw.bait) if raw.bait else None
|
bait = _bait(session, raw.bait) if raw.bait else None
|
||||||
payload = asdict(raw) | {"record_date": raw.record_date.isoformat()}
|
payload = asdict(raw) | {"record_date": raw.record_date.isoformat()}
|
||||||
caught = datetime.combine(raw.record_date, time(), tzinfo=timezone.utc)
|
caught = datetime.combine(raw.record_date, time(), tzinfo=timezone.utc)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
if report is None:
|
if report is None:
|
||||||
session.add(CatchReport(fish=fish, waterbody=waterbody, bait=bait, spot=None, weight_g=raw.weight_g, caught_at=caught, reported_at=datetime.now(timezone.utc), player_name=raw.player, source_type=SourceType.official_record, source_url=url, source_external_id=key, source_confidence=100, moderation_status=ModerationStatus.approved, raw_payload=payload))
|
report = CatchReport(fish=fish, waterbody=waterbody, bait=bait, spot=None, weight_g=raw.weight_g, caught_at=caught, reported_at=now, player_name=raw.player, source_type=SourceType.official_record, source_url=url, source_external_id=key, source_confidence=100, moderation_status=ModerationStatus.approved, raw_payload=payload)
|
||||||
|
session.add(report)
|
||||||
|
session.add(ImportRecordEvent(
|
||||||
|
catch_report=report, import_run=run, event_type="created", created_at=now,
|
||||||
|
changes={"weight_g": raw.weight_g, "player": raw.player, "record_date": raw.record_date.isoformat()},
|
||||||
|
provenance={"source_system": "rf4-official", "source_url": url, "source_external_id": key},
|
||||||
|
))
|
||||||
run.rows_created += 1
|
run.rows_created += 1
|
||||||
else:
|
else:
|
||||||
report.raw_payload = payload
|
# A12: Only create event if actual values changed
|
||||||
report.source_url = url
|
old_payload = (report.raw_payload or {})
|
||||||
run.rows_updated += 1
|
new_payload = asdict(raw) | {"record_date": raw.record_date.isoformat()}
|
||||||
|
changed_fields = {}
|
||||||
|
for field in ("weight_g", "player", "waterbody", "bait", "record_date"):
|
||||||
|
old_val = old_payload.get(field)
|
||||||
|
new_val = new_payload.get(field)
|
||||||
|
if old_val != new_val:
|
||||||
|
changed_fields[field] = {"old": old_val, "new": new_val}
|
||||||
|
if changed_fields:
|
||||||
|
# Keep the queryable normalized record in sync with its
|
||||||
|
# versioned source payload.
|
||||||
|
report.fish = fish
|
||||||
|
report.waterbody = waterbody
|
||||||
|
report.bait = bait
|
||||||
|
report.weight_g = raw.weight_g
|
||||||
|
report.player_name = raw.player
|
||||||
|
report.caught_at = caught
|
||||||
|
report.raw_payload = payload
|
||||||
|
report.source_url = url
|
||||||
|
session.add(ImportRecordEvent(
|
||||||
|
catch_report=report, import_run=run, event_type="updated", created_at=now,
|
||||||
|
changes=changed_fields,
|
||||||
|
provenance={"source_system": "rf4-official", "source_url": url, "source_external_id": key},
|
||||||
|
))
|
||||||
|
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()
|
||||||
|
|||||||
@@ -1,33 +1,28 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections import Counter
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import logging
|
import logging
|
||||||
import secrets
|
|
||||||
import time as time_module
|
import time as time_module
|
||||||
from typing import Annotated, Literal
|
|
||||||
from uuid import UUID
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import httpx
|
from fastapi import FastAPI, Request
|
||||||
from fastapi import Depends, FastAPI, File, Header, HTTPException, Query, Request, Response, UploadFile
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from sqlalchemy import delete, func, select, text
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy.orm import Session, joinedload
|
|
||||||
|
|
||||||
from .activity import activity_rows
|
|
||||||
from .database import get_session
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .community_review import ExternalReviewError, map_observation, publish_observation, reject_observation
|
from .dependencies import Db
|
||||||
from .importer import ImportAlreadyRunning, ImportSourceError, import_records, normalize
|
|
||||||
from .logging_config import configure_logging
|
from .logging_config import configure_logging
|
||||||
from .models import Bait, BaitKind, CatchReport, CommunityImportRun, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody
|
|
||||||
from .readiness import readiness_report
|
from .readiness import readiness_report
|
||||||
from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, FishOut, ImportRunOut, ModerationUpdate, OfficialRecordOut, PublicObservationOut, SourceStatusOut, SpotOut, WaterbodyOut
|
from .routers.activity import router as activity_router
|
||||||
from .storage import ScreenshotError, client as storage_client, delete_screenshot, signed_screenshot_url, upload_screenshot
|
from .routers.analytics import router as analytics_router
|
||||||
|
from .routers.admin import router as admin_router
|
||||||
|
from .routers.catalog import router as catalog_router
|
||||||
|
from .routers.media import router as media_router
|
||||||
|
from .routers.public_data import router as public_data_router
|
||||||
|
from .routers.submissions import router as submissions_router
|
||||||
|
from .storage import client as storage_client
|
||||||
|
from .submission_security import check_rate_limit
|
||||||
|
from .submission_security import is_trusted_proxy as _is_trusted_proxy
|
||||||
|
|
||||||
|
|
||||||
configure_logging(settings.log_level)
|
configure_logging(settings.log_level)
|
||||||
@@ -39,7 +34,6 @@ app.add_middleware(
|
|||||||
allow_methods=["GET", "POST", "PATCH", "DELETE"],
|
allow_methods=["GET", "POST", "PATCH", "DELETE"],
|
||||||
allow_headers=["Authorization", "Content-Type"],
|
allow_headers=["Authorization", "Content-Type"],
|
||||||
)
|
)
|
||||||
Db = Annotated[Session, Depends(get_session)]
|
|
||||||
|
|
||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
@@ -86,391 +80,22 @@ def ready(db: Db) -> JSONResponse:
|
|||||||
is_ready, components = readiness_report(
|
is_ready, components = readiness_report(
|
||||||
db, storage_client(), import_required=settings.official_import_required,
|
db, storage_client(), import_required=settings.official_import_required,
|
||||||
import_interval_seconds=settings.import_interval_seconds,
|
import_interval_seconds=settings.import_interval_seconds,
|
||||||
|
community_import_interval_seconds=settings.community_import_interval_seconds,
|
||||||
)
|
)
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=200 if is_ready else 503,
|
status_code=200 if is_ready else 503,
|
||||||
content={"status": "ready" if is_ready else "not_ready", "components": components},
|
content={"status": "ready" if is_ready else "not_ready", "version": settings.app_version, "revision": settings.app_revision, "components": components},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/fishes", response_model=list[FishOut])
|
app.include_router(catalog_router)
|
||||||
def fishes(db: Db, limit: int = Query(200, ge=1, le=500), offset: int = Query(0, ge=0)) -> list[Fish]:
|
app.include_router(media_router)
|
||||||
return list(db.scalars(select(Fish).order_by(Fish.name_ru, Fish.id).offset(offset).limit(limit)))
|
app.include_router(activity_router)
|
||||||
|
app.include_router(analytics_router)
|
||||||
|
app.include_router(public_data_router)
|
||||||
|
app.include_router(admin_router)
|
||||||
|
app.include_router(submissions_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/waterbodies", response_model=list[WaterbodyOut])
|
def _check_rate_limit(request: Request, db: Session) -> None:
|
||||||
def waterbodies(db: Db, limit: int = Query(200, ge=1, le=500), offset: int = Query(0, ge=0)) -> list[Waterbody]:
|
check_rate_limit(request, db, settings)
|
||||||
return list(db.scalars(select(Waterbody).order_by(Waterbody.name_ru, Waterbody.id).offset(offset).limit(limit)))
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/baits", response_model=list[BaitOut])
|
|
||||||
def baits(db: Db, limit: int = Query(200, ge=1, le=500), offset: int = Query(0, ge=0)) -> list[Bait]:
|
|
||||||
return list(db.scalars(select(Bait).order_by(Bait.name, Bait.id).offset(offset).limit(limit)))
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/activity", response_model=list[ActivityOut])
|
|
||||||
def activity(
|
|
||||||
db: Db, hours: int = Query(24),
|
|
||||||
waterbody: str | None = None, fish: str | None = None,
|
|
||||||
method: str | None = None,
|
|
||||||
sort: Literal["activity", "confidence", "freshness"] = "activity",
|
|
||||||
limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0),
|
|
||||||
) -> list[ActivityOut]:
|
|
||||||
if hours not in {6, 12, 24, 72}:
|
|
||||||
raise HTTPException(status_code=422, detail="hours must be one of: 6, 12, 24, 72")
|
|
||||||
rows = activity_rows(db, hours=hours, waterbody=waterbody, fish=fish, method=method)
|
|
||||||
keys = {
|
|
||||||
"activity": lambda r: (r.activity_score, r.confidence_score, r.last_confirmed_at, str(r.spot_id)),
|
|
||||||
"confidence": lambda r: (r.confidence_score, r.activity_score, r.last_confirmed_at, str(r.spot_id)),
|
|
||||||
"freshness": lambda r: (r.last_confirmed_at, r.activity_score, r.confidence_score, str(r.spot_id)),
|
|
||||||
}
|
|
||||||
rows.sort(key=keys[sort], reverse=True)
|
|
||||||
return rows[offset:offset + limit]
|
|
||||||
|
|
||||||
|
|
||||||
def _spot_or_404(db: Session, spot_id: UUID) -> Spot:
|
|
||||||
spot = db.scalar(select(Spot).options(joinedload(Spot.waterbody)).where(Spot.id == spot_id))
|
|
||||||
if spot is None:
|
|
||||||
raise HTTPException(status_code=404, detail="spot not found")
|
|
||||||
return spot
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/spots/resolve", response_model=SpotOut)
|
|
||||||
def resolve_spot(
|
|
||||||
db: Db, waterbody: str, x: int = Query(ge=-10_000, le=10_000),
|
|
||||||
y: int = Query(ge=-10_000, le=10_000),
|
|
||||||
) -> SpotOut:
|
|
||||||
spot = db.scalar(select(Spot).options(joinedload(Spot.waterbody)).join(Spot.waterbody).where(
|
|
||||||
Waterbody.slug == waterbody, Spot.x == x, Spot.y == y,
|
|
||||||
))
|
|
||||||
if spot is None:
|
|
||||||
raise HTTPException(status_code=404, detail="spot not found")
|
|
||||||
return spot_detail(spot.id, db)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/spots/{spot_id}", response_model=SpotOut)
|
|
||||||
def spot_detail(spot_id: UUID, db: Db) -> SpotOut:
|
|
||||||
spot = _spot_or_404(db, spot_id)
|
|
||||||
reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.bait)).where(CatchReport.spot_id == spot.id, CatchReport.moderation_status == ModerationStatus.approved, CatchReport.deleted_at.is_(None))))
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
def count_since(delta: timedelta) -> int:
|
|
||||||
return sum(_aware(r.reported_at) >= now - delta for r in reports)
|
|
||||||
bait_counts = Counter(r.bait.name for r in reports if r.bait)
|
|
||||||
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)])
|
|
||||||
|
|
||||||
|
|
||||||
@app.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]:
|
|
||||||
_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)))
|
|
||||||
return [CatchOut(id=r.id, fish=r.fish.name_ru, weight_g=r.weight_g, bait=r.bait.name if r.bait else None, player_name=r.player_name, caught_at=r.caught_at, reported_at=r.reported_at, retrieve_method=r.retrieve_method, retrieve_speed=r.retrieve_speed, source_system=_report_source(r), source_url=r.source_url) for r in reports]
|
|
||||||
|
|
||||||
|
|
||||||
def _report_source(report: CatchReport) -> str:
|
|
||||||
provenance = (report.raw_payload or {}).get("provenance", {})
|
|
||||||
if isinstance(provenance, dict) and provenance.get("source_system"):
|
|
||||||
return str(provenance["source_system"])
|
|
||||||
if report.source_type == SourceType.official_record:
|
|
||||||
return "rf4-official"
|
|
||||||
if report.source_type == SourceType.user:
|
|
||||||
return "players"
|
|
||||||
return "manual-import"
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/community-observations", response_model=list[PublicObservationOut])
|
|
||||||
def community_observations(
|
|
||||||
db: Db, limit: int = Query(12, ge=1, le=50), offset: int = Query(0, ge=0),
|
|
||||||
) -> list[PublicObservationOut]:
|
|
||||||
items = list(db.scalars(
|
|
||||||
select(ExternalObservation)
|
|
||||||
.join(ExternalObservation.source)
|
|
||||||
.options(joinedload(ExternalObservation.source))
|
|
||||||
.where(
|
|
||||||
ExternalObservation.catch_report_id.is_(None),
|
|
||||||
ExternalObservation.status != "rejected",
|
|
||||||
DataSource.enabled.is_(True),
|
|
||||||
)
|
|
||||||
.order_by(ExternalObservation.last_seen_at.desc(), ExternalObservation.id.desc())
|
|
||||||
.offset(offset).limit(limit)
|
|
||||||
))
|
|
||||||
result: list[PublicObservationOut] = []
|
|
||||||
for item in items:
|
|
||||||
missing = []
|
|
||||||
if item.x is None or item.y is None:
|
|
||||||
missing.append("координаты")
|
|
||||||
if item.weight_g is None:
|
|
||||||
missing.append("вес")
|
|
||||||
result.append(PublicObservationOut(
|
|
||||||
id=item.id, source_system=item.source_system, source_name=item.source.name,
|
|
||||||
source_url=item.source_url, fish_name=item.fish_name, waterbody_name=item.waterbody_name,
|
|
||||||
x=item.x, y=item.y, weight_g=item.weight_g, last_seen_at=item.last_seen_at,
|
|
||||||
missing_fields=missing, quality="incomplete" if missing else "unverified",
|
|
||||||
))
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/source-status", response_model=list[SourceStatusOut])
|
|
||||||
def source_status(db: Db) -> list[SourceStatusOut]:
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
result = []
|
|
||||||
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)
|
|
||||||
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(SourceStatusOut(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,
|
|
||||||
observations=db.scalar(select(func.count()).select_from(ExternalObservation).where(ExternalObservation.source_system == source.key)) or 0))
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/records", response_model=list[OfficialRecordOut])
|
|
||||||
def records(
|
|
||||||
db: Db, fish: str | None = None, waterbody: str | None = None,
|
|
||||||
category: str | None = None, limit: int = Query(50, ge=1, le=100),
|
|
||||||
offset: int = Query(0, ge=0),
|
|
||||||
) -> list[OfficialRecordOut]:
|
|
||||||
query = select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.waterbody), joinedload(CatchReport.bait)).where(CatchReport.source_type == SourceType.official_record)
|
|
||||||
if fish:
|
|
||||||
query = query.join(CatchReport.fish).where(Fish.slug == fish)
|
|
||||||
if waterbody:
|
|
||||||
query = query.join(CatchReport.waterbody).where(Waterbody.slug == waterbody)
|
|
||||||
if category:
|
|
||||||
query = query.where(CatchReport.raw_payload["category"].as_string() == category)
|
|
||||||
items = list(db.scalars(query.order_by(CatchReport.caught_at.desc(), CatchReport.weight_g.desc(), CatchReport.id.desc()).offset(offset).limit(limit)))
|
|
||||||
return [OfficialRecordOut(id=r.id, fish=r.fish.name_ru, weight_g=r.weight_g, waterbody=r.waterbody.name_ru, bait=r.bait.name if r.bait else None, player_name=r.player_name, record_date=r.caught_at, category=(r.raw_payload or {}).get("category"), region=(r.raw_payload or {}).get("region"), source_url=r.source_url) for r in items]
|
|
||||||
|
|
||||||
|
|
||||||
def _admin(authorization: Annotated[str | None, Header()] = None) -> str:
|
|
||||||
expected = f"Bearer {settings.admin_token}"
|
|
||||||
if not authorization or not hmac.compare_digest(authorization, expected):
|
|
||||||
raise HTTPException(status_code=401, detail="invalid admin token", headers={"WWW-Authenticate": "Bearer"})
|
|
||||||
return "admin"
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/imports", response_model=list[ImportRunOut])
|
|
||||||
def imports(db: Db, limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0)) -> list[OfficialRecordImport]:
|
|
||||||
return list(db.scalars(select(OfficialRecordImport).order_by(OfficialRecordImport.started_at.desc(), OfficialRecordImport.id.desc()).offset(offset).limit(limit)))
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/admin/imports", response_model=list[ImportRunOut])
|
|
||||||
def admin_imports(
|
|
||||||
db: Db,
|
|
||||||
_: Annotated[str, Depends(_admin)],
|
|
||||||
limit: int = Query(20, ge=1, le=100),
|
|
||||||
offset: int = Query(0, ge=0),
|
|
||||||
) -> list[OfficialRecordImport]:
|
|
||||||
query = select(OfficialRecordImport).order_by(
|
|
||||||
OfficialRecordImport.started_at.desc(), OfficialRecordImport.id.desc()
|
|
||||||
).offset(offset).limit(limit)
|
|
||||||
return list(db.scalars(query))
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/v1/admin/imports/official-records", response_model=ImportRunOut, status_code=201)
|
|
||||||
def admin_start_official_import(db: Db, _: Annotated[str, Depends(_admin)]) -> OfficialRecordImport:
|
|
||||||
try:
|
|
||||||
return import_records(
|
|
||||||
db,
|
|
||||||
url=settings.official_records_url,
|
|
||||||
region=settings.official_records_region,
|
|
||||||
category=settings.official_records_category,
|
|
||||||
)
|
|
||||||
except ImportAlreadyRunning as exc:
|
|
||||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
||||||
except (ImportSourceError, httpx.HTTPError) as exc:
|
|
||||||
raise HTTPException(status_code=502, detail=f"official records import failed: {exc}") from exc
|
|
||||||
|
|
||||||
|
|
||||||
def _external_out(item: ExternalObservation) -> ExternalObservationOut:
|
|
||||||
return ExternalObservationOut(
|
|
||||||
id=item.id, source_system=item.source_system, source_external_id=item.source_external_id,
|
|
||||||
source_url=item.source_url, fish_name=item.fish_name, fish_external_id=item.fish_external_id,
|
|
||||||
waterbody_name=item.waterbody_name, waterbody_external_id=item.waterbody_external_id,
|
|
||||||
x=item.x, y=item.y, weight_g=item.weight_g, published_at=item.published_at,
|
|
||||||
last_seen_at=item.last_seen_at, status=item.status,
|
|
||||||
fish_slug=item.fish.slug if item.fish else None,
|
|
||||||
waterbody_slug=item.waterbody.slug if item.waterbody else None,
|
|
||||||
catch_report_id=item.catch_report_id, review_note=item.review_note,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/admin/external-observations", response_model=list[ExternalObservationOut])
|
|
||||||
def admin_external_observations(
|
|
||||||
db: Db, _: Annotated[str, Depends(_admin)],
|
|
||||||
status: Literal["staged", "mapped", "ready", "published", "rejected"] | None = None,
|
|
||||||
source_system: str | None = None, limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0),
|
|
||||||
) -> list[ExternalObservationOut]:
|
|
||||||
query = select(ExternalObservation).options(
|
|
||||||
joinedload(ExternalObservation.fish), joinedload(ExternalObservation.waterbody),
|
|
||||||
)
|
|
||||||
if status:
|
|
||||||
query = query.where(ExternalObservation.status == status)
|
|
||||||
if source_system:
|
|
||||||
query = query.where(ExternalObservation.source_system == source_system)
|
|
||||||
items = db.scalars(query.order_by(ExternalObservation.last_seen_at.desc(), ExternalObservation.id.desc()).offset(offset).limit(limit))
|
|
||||||
return [_external_out(item) for item in items]
|
|
||||||
|
|
||||||
|
|
||||||
@app.patch("/api/v1/admin/external-observations/{observation_id}/mapping", response_model=ExternalObservationOut)
|
|
||||||
def admin_map_external_observation(
|
|
||||||
observation_id: UUID, payload: ExternalObservationMapping, db: Db,
|
|
||||||
_: Annotated[str, Depends(_admin)],
|
|
||||||
) -> ExternalObservationOut:
|
|
||||||
observation = db.get(ExternalObservation, observation_id)
|
|
||||||
fish = db.scalar(select(Fish).where(Fish.slug == payload.fish_slug))
|
|
||||||
waterbody = db.scalar(select(Waterbody).where(Waterbody.slug == payload.waterbody_slug))
|
|
||||||
if observation is None:
|
|
||||||
raise HTTPException(status_code=404, detail="external observation not found")
|
|
||||||
if fish is None or waterbody is None:
|
|
||||||
raise HTTPException(status_code=422, detail="unknown fish or waterbody")
|
|
||||||
try:
|
|
||||||
return _external_out(map_observation(db, observation, fish, waterbody, note=payload.note))
|
|
||||||
except ExternalReviewError as exc:
|
|
||||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/v1/admin/external-observations/{observation_id}/publish", response_model=ExternalObservationPublished)
|
|
||||||
def admin_publish_external_observation(
|
|
||||||
observation_id: UUID, db: Db, _: Annotated[str, Depends(_admin)],
|
|
||||||
) -> ExternalObservationPublished:
|
|
||||||
observation = db.get(ExternalObservation, observation_id)
|
|
||||||
if observation is None:
|
|
||||||
raise HTTPException(status_code=404, detail="external observation not found")
|
|
||||||
try:
|
|
||||||
report = publish_observation(db, observation)
|
|
||||||
except ExternalReviewError as exc:
|
|
||||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
||||||
return ExternalObservationPublished(observation_id=observation.id, catch_report_id=report.id, status=observation.status)
|
|
||||||
|
|
||||||
|
|
||||||
@app.patch("/api/v1/admin/external-observations/{observation_id}/reject", response_model=ExternalObservationOut)
|
|
||||||
def admin_reject_external_observation(
|
|
||||||
observation_id: UUID, payload: ExternalObservationDecision, db: Db,
|
|
||||||
_: Annotated[str, Depends(_admin)],
|
|
||||||
) -> ExternalObservationOut:
|
|
||||||
observation = db.get(ExternalObservation, observation_id)
|
|
||||||
if observation is None:
|
|
||||||
raise HTTPException(status_code=404, detail="external observation not found")
|
|
||||||
try:
|
|
||||||
return _external_out(reject_observation(db, observation, reason=payload.reason))
|
|
||||||
except ExternalReviewError as exc:
|
|
||||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/v1/catch-reports", response_model=CatchReportAccepted, status_code=201)
|
|
||||||
def create_catch_report(payload: CatchReportCreate, request: Request, db: Db) -> CatchReportAccepted:
|
|
||||||
if payload.website:
|
|
||||||
raise HTTPException(status_code=400, detail="invalid submission")
|
|
||||||
_check_rate_limit(request.client.host if request.client else "unknown", db)
|
|
||||||
fish = db.scalar(select(Fish).where(Fish.slug == payload.fish_slug))
|
|
||||||
waterbody = db.scalar(select(Waterbody).where(Waterbody.slug == payload.waterbody_slug))
|
|
||||||
if fish is None or waterbody is None:
|
|
||||||
raise HTTPException(status_code=422, detail="unknown fish or waterbody")
|
|
||||||
spot = db.scalar(select(Spot).where(Spot.waterbody_id == waterbody.id, Spot.x == payload.x, Spot.y == payload.y))
|
|
||||||
if spot is None:
|
|
||||||
spot = Spot(waterbody=waterbody, x=payload.x, y=payload.y)
|
|
||||||
db.add(spot)
|
|
||||||
bait = None
|
|
||||||
if payload.bait_name and payload.bait_name.strip():
|
|
||||||
key = normalize(payload.bait_name)
|
|
||||||
bait = db.scalar(select(Bait).where(Bait.normalized_name == key))
|
|
||||||
if bait is None:
|
|
||||||
bait = Bait(name=payload.bait_name.strip(), normalized_name=key, kind=BaitKind.unknown)
|
|
||||||
db.add(bait)
|
|
||||||
upload_token = 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())
|
|
||||||
db.add(report)
|
|
||||||
db.commit()
|
|
||||||
return CatchReportAccepted(id=report.id, moderation_status=report.moderation_status.value, screenshot_upload_token=upload_token)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/v1/catch-reports/{report_id}/screenshot", status_code=204, response_class=Response)
|
|
||||||
def add_screenshot(
|
|
||||||
report_id: UUID, db: Db, screenshot: UploadFile = File(),
|
|
||||||
upload_token: Annotated[str | None, Header(alias="X-Upload-Token")] = None,
|
|
||||||
) -> Response:
|
|
||||||
report = db.get(CatchReport, report_id)
|
|
||||||
if report is None or report.source_type != SourceType.user or report.moderation_status != ModerationStatus.pending:
|
|
||||||
raise HTTPException(status_code=404, detail="pending catch report not found")
|
|
||||||
supplied_hash = hashlib.sha256((upload_token or "").encode()).hexdigest()
|
|
||||||
if not report.screenshot_upload_token_hash or not hmac.compare_digest(report.screenshot_upload_token_hash, supplied_hash):
|
|
||||||
raise HTTPException(status_code=401, detail="invalid screenshot upload token")
|
|
||||||
if report.screenshot_key:
|
|
||||||
raise HTTPException(status_code=409, detail="screenshot already uploaded")
|
|
||||||
raw = screenshot.file.read(settings.screenshot_max_bytes + 1)
|
|
||||||
try:
|
|
||||||
report.screenshot_key = upload_screenshot(raw, filename=screenshot.filename, content_type=screenshot.content_type)
|
|
||||||
except ScreenshotError as exc:
|
|
||||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
||||||
report.screenshot_upload_token_hash = None
|
|
||||||
db.commit()
|
|
||||||
return Response(status_code=204)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/admin/catch-reports", response_model=list[AdminCatchReportOut])
|
|
||||||
def admin_reports(db: Db, _: Annotated[str, Depends(_admin)], status: ModerationStatus = ModerationStatus.pending, limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0)) -> list[AdminCatchReportOut]:
|
|
||||||
reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.waterbody), joinedload(CatchReport.spot), joinedload(CatchReport.bait)).where(CatchReport.source_type == SourceType.user, CatchReport.moderation_status == status, CatchReport.deleted_at.is_(None)).order_by(CatchReport.reported_at, CatchReport.id).offset(offset).limit(limit)))
|
|
||||||
return [AdminCatchReportOut(id=r.id, fish=r.fish.name_ru, waterbody=r.waterbody.name_ru, coordinates=f"{r.spot.x}:{r.spot.y}" if r.spot else "—", weight_g=r.weight_g, bait=r.bait.name if r.bait else None, player_name=r.player_name, reported_at=r.reported_at, moderation_status=r.moderation_status.value, comment=(r.raw_payload or {}).get("comment"), screenshot_url=signed_screenshot_url(r.screenshot_key) if r.screenshot_key else None) for r in reports]
|
|
||||||
|
|
||||||
|
|
||||||
@app.patch("/api/v1/admin/catch-reports/{report_id}", response_model=CatchReportCreated)
|
|
||||||
def moderate_report(report_id: UUID, payload: ModerationUpdate, db: Db, moderator: Annotated[str, Depends(_admin)]) -> CatchReportCreated:
|
|
||||||
report = db.get(CatchReport, report_id)
|
|
||||||
if report is None or report.source_type != SourceType.user or report.deleted_at is not None:
|
|
||||||
raise HTTPException(status_code=404, detail="catch report not found")
|
|
||||||
previous = report.moderation_status
|
|
||||||
report.moderation_status = ModerationStatus(payload.status)
|
|
||||||
db.add(ModerationEvent(catch_report=report, created_at=datetime.now(timezone.utc), previous_status=previous, new_status=report.moderation_status, moderator=moderator, reason=payload.reason))
|
|
||||||
db.commit()
|
|
||||||
return CatchReportCreated(id=report.id, moderation_status=report.moderation_status.value)
|
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/v1/admin/catch-reports/{report_id}", status_code=204, response_class=Response)
|
|
||||||
def delete_report(report_id: UUID, db: Db, moderator: Annotated[str, Depends(_admin)]) -> Response:
|
|
||||||
report = db.get(CatchReport, report_id)
|
|
||||||
if report is None or report.source_type != SourceType.user or report.deleted_at is not None:
|
|
||||||
raise HTTPException(status_code=404, detail="catch report not found")
|
|
||||||
previous = report.moderation_status
|
|
||||||
if report.screenshot_key:
|
|
||||||
try:
|
|
||||||
delete_screenshot(report.screenshot_key)
|
|
||||||
except Exception as exc:
|
|
||||||
raise HTTPException(status_code=502, detail="screenshot deletion failed") from exc
|
|
||||||
report.moderation_status = ModerationStatus.rejected
|
|
||||||
report.deleted_at = datetime.now(timezone.utc)
|
|
||||||
report.player_name = None
|
|
||||||
report.source_url = None
|
|
||||||
report.screenshot_key = None
|
|
||||||
report.raw_payload = None
|
|
||||||
db.add(ModerationEvent(catch_report=report, created_at=report.deleted_at, previous_status=previous, new_status=ModerationStatus.rejected, moderator=moderator, reason="user report deleted and anonymized"))
|
|
||||||
db.commit()
|
|
||||||
return Response(status_code=204)
|
|
||||||
|
|
||||||
|
|
||||||
def _check_rate_limit(client: str, db: Session) -> None:
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
cutoff = now - timedelta(minutes=10)
|
|
||||||
client_hash = hmac.new(settings.rate_limit_secret.encode(), client.encode(), hashlib.sha256).hexdigest()
|
|
||||||
if db.get_bind().dialect.name == "postgresql":
|
|
||||||
lock_key = int(client_hash[:16], 16) & 0x7FFF_FFFF_FFFF_FFFF
|
|
||||||
db.execute(text("SELECT pg_advisory_xact_lock(:lock_key)"), {"lock_key": lock_key})
|
|
||||||
db.execute(delete(SubmissionAttempt).where(SubmissionAttempt.created_at < now - timedelta(days=1)))
|
|
||||||
recent = db.scalar(select(func.count()).select_from(SubmissionAttempt).where(SubmissionAttempt.client_hash == client_hash, SubmissionAttempt.created_at >= cutoff)) or 0
|
|
||||||
if recent >= 5:
|
|
||||||
db.commit()
|
|
||||||
raise HTTPException(status_code=429, detail="too many submissions")
|
|
||||||
db.add(SubmissionAttempt(client_hash=client_hash, created_at=now))
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def _aware(value: datetime) -> datetime:
|
|
||||||
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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"),)
|
||||||
@@ -95,10 +160,39 @@ class CatchReport(Base):
|
|||||||
screenshot_upload_token_hash: Mapped[str | None] = mapped_column(String(64))
|
screenshot_upload_token_hash: Mapped[str | None] = mapped_column(String(64))
|
||||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
raw_payload: Mapped[dict | None] = mapped_column(JSON)
|
raw_payload: Mapped[dict | None] = mapped_column(JSON)
|
||||||
|
moderation_version: Mapped[int] = mapped_column(default=0)
|
||||||
fish: Mapped[Fish] = relationship()
|
fish: Mapped[Fish] = relationship()
|
||||||
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):
|
||||||
@@ -136,6 +230,17 @@ class SubmissionAttempt(Base):
|
|||||||
__tablename__ = "submission_attempt"
|
__tablename__ = "submission_attempt"
|
||||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
client_hash: Mapped[str] = mapped_column(String(64), index=True)
|
client_hash: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
|
idempotency_key: Mapped[str | None] = mapped_column(String(128), unique=True, index=True)
|
||||||
|
catch_report_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("catch_report.id"), nullable=True)
|
||||||
|
payload_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
|
catch_report: Mapped[CatchReport | None] = relationship()
|
||||||
|
|
||||||
|
|
||||||
|
class AdminAuthAttempt(Base):
|
||||||
|
__tablename__ = "admin_auth_attempt"
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
client_hash: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
|
|
||||||
|
|
||||||
@@ -175,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))
|
||||||
@@ -186,6 +293,9 @@ class ExternalObservation(Base):
|
|||||||
catch_report_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("catch_report.id"), unique=True)
|
catch_report_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("catch_report.id"), unique=True)
|
||||||
review_note: Mapped[str | None] = mapped_column(Text)
|
review_note: Mapped[str | None] = mapped_column(Text)
|
||||||
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
moderation_version: Mapped[int] = mapped_column(default=0)
|
||||||
|
source_check_status: Mapped[str | None] = mapped_column(String(30))
|
||||||
|
source_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
source: Mapped[DataSource] = relationship()
|
source: Mapped[DataSource] = relationship()
|
||||||
fish: Mapped[Fish | None] = relationship()
|
fish: Mapped[Fish | None] = relationship()
|
||||||
waterbody: Mapped[Waterbody | None] = relationship()
|
waterbody: Mapped[Waterbody | None] = relationship()
|
||||||
@@ -212,3 +322,17 @@ class ExternalEntityAlias(Base):
|
|||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
fish: Mapped[Fish | None] = relationship()
|
fish: Mapped[Fish | None] = relationship()
|
||||||
waterbody: Mapped[Waterbody | None] = relationship()
|
waterbody: Mapped[Waterbody | None] = relationship()
|
||||||
|
|
||||||
|
|
||||||
|
class ImportRecordEvent(Base):
|
||||||
|
"""Track per-record import events for D09 revision history with provenance."""
|
||||||
|
__tablename__ = "import_record_event"
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
catch_report_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("catch_report.id"), index=True)
|
||||||
|
import_run_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("official_record_import.id"), index=True)
|
||||||
|
event_type: Mapped[str] = mapped_column(String(20)) # created/updated/deleted
|
||||||
|
changes: Mapped[dict | None] = mapped_column(JSON, default=None) # what fields changed
|
||||||
|
provenance: Mapped[dict | None] = mapped_column(JSON, default=None) # source system, external_id
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
|
catch_report: Mapped[CatchReport] = relationship()
|
||||||
|
import_run: Mapped[OfficialRecordImport] = relationship()
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from copy import deepcopy
|
||||||
|
from threading import Lock
|
||||||
|
from time import monotonic
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class PublicResponseCache:
|
||||||
|
def __init__(self, max_entries: int = 128) -> None:
|
||||||
|
self._items: dict[tuple[Any, ...], tuple[float, Any]] = {}
|
||||||
|
self._lock = Lock()
|
||||||
|
self._max_entries = max_entries
|
||||||
|
self._generation = 0
|
||||||
|
|
||||||
|
def generation(self) -> int:
|
||||||
|
with self._lock:
|
||||||
|
return self._generation
|
||||||
|
|
||||||
|
def get(self, key: tuple[Any, ...], ttl_seconds: int) -> Any | None:
|
||||||
|
with self._lock:
|
||||||
|
item = self._items.get(key)
|
||||||
|
if item is None or monotonic() - item[0] >= ttl_seconds:
|
||||||
|
self._items.pop(key, None)
|
||||||
|
return None
|
||||||
|
return deepcopy(item[1])
|
||||||
|
|
||||||
|
def set(self, key: tuple[Any, ...], value: Any, *, generation: int | None = None) -> Any:
|
||||||
|
with self._lock:
|
||||||
|
if generation is not None and generation != self._generation:
|
||||||
|
return value
|
||||||
|
if key not in self._items and len(self._items) >= self._max_entries:
|
||||||
|
self._items.pop(next(iter(self._items)))
|
||||||
|
self._items[key] = (monotonic(), deepcopy(value))
|
||||||
|
return value
|
||||||
|
|
||||||
|
def invalidate(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._generation += 1
|
||||||
|
self._items.clear()
|
||||||
|
|
||||||
|
|
||||||
|
public_cache = PublicResponseCache()
|
||||||
@@ -3,16 +3,24 @@ from __future__ import annotations
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import select, text
|
from sqlalchemy import func, select, text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .models import ImportStatus, OfficialRecordImport
|
from .config import settings
|
||||||
|
from .models import CommunityImportRun, DataSource, ImportStatus, OfficialRecordImport
|
||||||
|
|
||||||
|
|
||||||
def readiness_report(
|
def readiness_report(
|
||||||
session: Session, s3: Any, *, import_required: bool,
|
session: Session, s3: Any, *, import_required: bool,
|
||||||
import_interval_seconds: int, now: datetime | None = None,
|
import_interval_seconds: int, community_import_interval_seconds: int = 1800,
|
||||||
|
now: datetime | None = None,
|
||||||
) -> tuple[bool, dict[str, dict[str, object]]]:
|
) -> tuple[bool, dict[str, dict[str, object]]]:
|
||||||
|
"""A01: Separate infrastructure readiness from import health diagnostics.
|
||||||
|
|
||||||
|
Infrastructure (DB, MinIO) blocks readiness. Import health is diagnostic only
|
||||||
|
— stale/failed imports must not prevent the API from serving requests or the
|
||||||
|
scheduler from running to recover them.
|
||||||
|
"""
|
||||||
current = now or datetime.now(timezone.utc)
|
current = now or datetime.now(timezone.utc)
|
||||||
components: dict[str, dict[str, object]] = {}
|
components: dict[str, dict[str, object]] = {}
|
||||||
ready = True
|
ready = True
|
||||||
@@ -25,12 +33,13 @@ def readiness_report(
|
|||||||
ready = False
|
ready = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
s3.list_buckets()
|
s3.head_bucket(Bucket=settings.s3_bucket)
|
||||||
components["minio"] = {"status": "ready"}
|
components["minio"] = {"status": "ready"}
|
||||||
except Exception:
|
except Exception:
|
||||||
components["minio"] = {"status": "unavailable"}
|
components["minio"] = {"status": "unavailable"}
|
||||||
ready = False
|
ready = False
|
||||||
|
|
||||||
|
# Official import health — diagnostic only, never blocks readiness (A01)
|
||||||
try:
|
try:
|
||||||
latest = session.scalar(select(OfficialRecordImport).order_by(
|
latest = session.scalar(select(OfficialRecordImport).order_by(
|
||||||
OfficialRecordImport.started_at.desc(), OfficialRecordImport.id.desc(),
|
OfficialRecordImport.started_at.desc(), OfficialRecordImport.id.desc(),
|
||||||
@@ -39,10 +48,13 @@ def readiness_report(
|
|||||||
components["official_import"] = {
|
components["official_import"] = {
|
||||||
"status": "optional",
|
"status": "optional",
|
||||||
"last_run_status": latest.status.value if latest else None,
|
"last_run_status": latest.status.value if latest else None,
|
||||||
|
"blocking": False,
|
||||||
}
|
}
|
||||||
elif latest is None:
|
elif latest is None:
|
||||||
components["official_import"] = {"status": "not_run"}
|
components["official_import"] = {
|
||||||
ready = False
|
"status": "not_run",
|
||||||
|
"blocking": False,
|
||||||
|
}
|
||||||
else:
|
else:
|
||||||
started = latest.started_at if latest.started_at.tzinfo else latest.started_at.replace(tzinfo=timezone.utc)
|
started = latest.started_at if latest.started_at.tzinfo else latest.started_at.replace(tzinfo=timezone.utc)
|
||||||
stale = started < current - timedelta(seconds=import_interval_seconds * 2)
|
stale = started < current - timedelta(seconds=import_interval_seconds * 2)
|
||||||
@@ -51,11 +63,77 @@ def readiness_report(
|
|||||||
"status": "ready" if healthy else ("stale" if stale else latest.status.value),
|
"status": "ready" if healthy else ("stale" if stale else latest.status.value),
|
||||||
"last_run_status": latest.status.value,
|
"last_run_status": latest.status.value,
|
||||||
"last_started_at": started.isoformat(),
|
"last_started_at": started.isoformat(),
|
||||||
|
"blocking": False,
|
||||||
}
|
}
|
||||||
ready = ready and healthy
|
|
||||||
except Exception:
|
except Exception:
|
||||||
components["official_import"] = {"status": "unknown"}
|
components["official_import"] = {
|
||||||
if import_required:
|
"status": "unknown",
|
||||||
ready = False
|
"blocking": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Community scheduler health — diagnostic only, never blocks readiness (A01)
|
||||||
|
# Track per-source health with rotation, backoff, last success, and stalled attempts
|
||||||
|
# Overall status reflects worst-case source health (success of one does not mask failure of another)
|
||||||
|
try:
|
||||||
|
enabled_sources = list(session.scalars(
|
||||||
|
select(DataSource).where(DataSource.enabled.is_(True)).order_by(DataSource.key)
|
||||||
|
))
|
||||||
|
source_health: dict[str, dict[str, object]] = {}
|
||||||
|
has_any_failure = False
|
||||||
|
has_any_success = False
|
||||||
|
has_any_stale = False
|
||||||
|
has_any_running = False
|
||||||
|
for source in enabled_sources:
|
||||||
|
latest_run = session.scalar(
|
||||||
|
select(CommunityImportRun)
|
||||||
|
.where(CommunityImportRun.source_system == source.key)
|
||||||
|
.order_by(CommunityImportRun.started_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
if latest_run is None:
|
||||||
|
source_health[source.key] = {"status": "not_started", "blocking": False}
|
||||||
|
continue
|
||||||
|
started = latest_run.started_at
|
||||||
|
if started.tzinfo is None:
|
||||||
|
started = started.replace(tzinfo=timezone.utc)
|
||||||
|
stale = started < current - timedelta(seconds=community_import_interval_seconds * 2)
|
||||||
|
healthy = latest_run.status == "success" and not stale
|
||||||
|
# Count recent failures for backoff detection
|
||||||
|
recent_failures = session.scalar(
|
||||||
|
select(func.count()).select_from(CommunityImportRun)
|
||||||
|
.where(
|
||||||
|
CommunityImportRun.source_system == source.key,
|
||||||
|
CommunityImportRun.status == "failed",
|
||||||
|
CommunityImportRun.started_at >= current - timedelta(hours=24),
|
||||||
|
)
|
||||||
|
) or 0
|
||||||
|
source_health[source.key] = {
|
||||||
|
"status": "ready" if healthy else ("stale" if stale else latest_run.status),
|
||||||
|
"last_started_at": started.isoformat(),
|
||||||
|
"recent_failures_24h": recent_failures,
|
||||||
|
"backoff_recommended": recent_failures >= 5,
|
||||||
|
"blocking": False,
|
||||||
|
}
|
||||||
|
if healthy:
|
||||||
|
has_any_success = True
|
||||||
|
elif latest_run.status == "failed":
|
||||||
|
has_any_failure = True
|
||||||
|
elif stale:
|
||||||
|
has_any_stale = True
|
||||||
|
elif latest_run.status == "running":
|
||||||
|
has_any_running = True
|
||||||
|
# Overall status: never mask failures with success of another source
|
||||||
|
# "degraded" if any source failed/stale/running
|
||||||
|
# "ready" only when ALL enabled sources are healthy
|
||||||
|
# "not_started" when no sources are enabled
|
||||||
|
if has_any_failure or has_any_stale or has_any_running:
|
||||||
|
scheduler_status = "degraded"
|
||||||
|
elif has_any_success and len(source_health) > 0:
|
||||||
|
scheduler_status = "ready"
|
||||||
|
else:
|
||||||
|
scheduler_status = "not_started"
|
||||||
|
components["community_scheduler"] = {"status": scheduler_status, "sources": source_health}
|
||||||
|
except Exception:
|
||||||
|
components["community_scheduler"] = {"status": "unknown", "sources": {}}
|
||||||
|
|
||||||
return ready, components
|
return ready, components
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""HTTP route groups for the RF4 Spotter API."""
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
from collections import Counter
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Literal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query, Response
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session, joinedload, selectinload
|
||||||
|
|
||||||
|
from ..activity import activity_rows
|
||||||
|
from ..config import settings
|
||||||
|
from ..dependencies import Db
|
||||||
|
from ..models import CatchReport, ModerationStatus, SourceType, Spot, Waterbody
|
||||||
|
from ..public_cache import public_cache
|
||||||
|
from ..schemas import CatchOut, PaginatedActivityOut, SpotOut
|
||||||
|
from ..time_utils import aware
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/activity", response_model=PaginatedActivityOut)
|
||||||
|
def activity(db: Db, response: Response, hours: int = Query(24), waterbody: str | None = None,
|
||||||
|
fish: str | None = None, method: str | None = None,
|
||||||
|
sort: Literal["activity", "confidence", "freshness"] = "activity",
|
||||||
|
limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0)) -> PaginatedActivityOut:
|
||||||
|
if hours not in {6, 12, 24, 72}:
|
||||||
|
raise HTTPException(status_code=422, detail="hours must be one of: 6, 12, 24, 72")
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
generation = public_cache.generation()
|
||||||
|
cache_key = ("activity", hours, waterbody, fish, method, sort, limit, offset)
|
||||||
|
cached = public_cache.get(cache_key, settings.public_cache_seconds)
|
||||||
|
if cached is not None:
|
||||||
|
response.headers["X-Cache"] = "HIT"
|
||||||
|
return cached
|
||||||
|
rows = activity_rows(db, hours=hours, waterbody=waterbody, fish=fish, method=method)
|
||||||
|
total = len(rows)
|
||||||
|
keys = {
|
||||||
|
"activity": lambda row: (row.activity_score, row.confidence_score, row.last_confirmed_at, str(row.spot_id)),
|
||||||
|
"confidence": lambda row: (row.confidence_score, row.activity_score, row.last_confirmed_at, str(row.spot_id)),
|
||||||
|
"freshness": lambda row: (row.last_confirmed_at, row.activity_score, row.confidence_score, str(row.spot_id)),
|
||||||
|
}
|
||||||
|
rows.sort(key=keys[sort], reverse=True)
|
||||||
|
response.headers["X-Cache"] = "MISS"
|
||||||
|
return public_cache.set(cache_key, PaginatedActivityOut(items=rows[offset:offset + limit], total=total, limit=limit, offset=offset), generation=generation)
|
||||||
|
|
||||||
|
|
||||||
|
def _spot_or_404(db: Session, spot_id: UUID) -> Spot:
|
||||||
|
spot = db.scalar(select(Spot).options(joinedload(Spot.waterbody)).where(Spot.id == spot_id))
|
||||||
|
if spot is None:
|
||||||
|
raise HTTPException(status_code=404, detail="spot not found")
|
||||||
|
return spot
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/spots/resolve", response_model=SpotOut)
|
||||||
|
def resolve_spot(db: Db, waterbody: str, x: int = Query(ge=-10_000, le=10_000), y: int = Query(ge=-10_000, le=10_000)) -> SpotOut:
|
||||||
|
spot = db.scalar(select(Spot).options(joinedload(Spot.waterbody)).join(Spot.waterbody).where(Waterbody.slug == waterbody, Spot.x == x, Spot.y == y))
|
||||||
|
if spot is None:
|
||||||
|
raise HTTPException(status_code=404, detail="spot not found")
|
||||||
|
return spot_detail(spot.id, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/spots/{spot_id}", response_model=SpotOut)
|
||||||
|
def spot_detail(spot_id: UUID, db: Db) -> SpotOut:
|
||||||
|
spot = _spot_or_404(db, spot_id)
|
||||||
|
reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.bait)).where(CatchReport.spot_id == spot.id, CatchReport.moderation_status == ModerationStatus.approved, CatchReport.deleted_at.is_(None))))
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
bait_counts = Counter(report.bait.name for report in reports if report.bait)
|
||||||
|
def count_since(delta: timedelta) -> int:
|
||||||
|
return sum(aware(report.reported_at) >= now - delta for report in reports)
|
||||||
|
|
||||||
|
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:
|
||||||
|
provenance = (report.raw_payload or {}).get("provenance", {})
|
||||||
|
if isinstance(provenance, dict) and provenance.get("source_system"):
|
||||||
|
return str(provenance["source_system"])
|
||||||
|
if report.source_type == SourceType.official_record:
|
||||||
|
return "rf4-official"
|
||||||
|
return "players" if report.source_type == SourceType.user else "manual-import"
|
||||||
|
|
||||||
|
|
||||||
|
@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]:
|
||||||
|
_spot_or_404(db, spot_id)
|
||||||
|
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, 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")
|
||||||
|
def spot_timeline(spot_id: UUID, db: Db) -> list[dict]:
|
||||||
|
_spot_or_404(db, spot_id)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
buckets = []
|
||||||
|
for index in range(6):
|
||||||
|
start = now - timedelta(hours=(6 - index) * 12)
|
||||||
|
end = start + timedelta(hours=12)
|
||||||
|
count = db.scalar(select(func.count()).select_from(CatchReport).where(CatchReport.spot_id == spot_id, CatchReport.moderation_status == ModerationStatus.approved, CatchReport.deleted_at.is_(None), CatchReport.reported_at >= start, CatchReport.reported_at < end)) or 0
|
||||||
|
buckets.append({"start": start.isoformat(), "end": end.isoformat(), "count": count})
|
||||||
|
return buckets
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Annotated, Literal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response
|
||||||
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
|
from sqlalchemy import case, func, or_, select
|
||||||
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
|
from ..admin_security import verify_admin
|
||||||
|
from ..community_review import ExternalReviewError, map_observation, publish_observation, reject_observation, suggest_aliases
|
||||||
|
from ..config import settings
|
||||||
|
from ..dependencies import Db
|
||||||
|
from ..importer import ImportAlreadyRunning, ImportSourceError, import_records
|
||||||
|
from rf4_research.media_assets import publish_quality_upgrades, rollback_quality_upgrade
|
||||||
|
|
||||||
|
from ..media_catalog import MEDIA_ROOT, review_assets, review_file
|
||||||
|
from ..models import CatchReport, CommunityImportRun, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Waterbody
|
||||||
|
from ..public_cache import public_cache
|
||||||
|
from ..schemas import AdminCatchReportOut, AdminMediaDecision, AdminMediaReviewOut, AdminMediaRollback, AdminModerationHistoryOut, AdminSourceStatusOut, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationAction, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, ImportRunOut, ModerationUpdate
|
||||||
|
from ..storage import delete_screenshot, signed_screenshot_url
|
||||||
|
from ..time_utils import aware
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _admin(request: Request, db: Db, authorization: Annotated[str | None, Header()] = None) -> str:
|
||||||
|
return verify_admin(request, db, authorization, settings)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/admin/media/catalog", response_model=list[AdminMediaReviewOut])
|
||||||
|
def admin_media_catalog(
|
||||||
|
_: Annotated[str, Depends(_admin)],
|
||||||
|
entity_type: str | None = Query(None, pattern="^(fish|waterbody|tackle|reference)$"),
|
||||||
|
status: str | None = Query(None, pattern="^(approved|upgrade_queued|upgrade_stored)$"),
|
||||||
|
limit: int = Query(50, ge=1, le=100),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
) -> list[AdminMediaReviewOut]:
|
||||||
|
return review_assets(entity_type, status)[offset:offset + limit]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/admin/media/assets/{digest}", response_class=FileResponse)
|
||||||
|
def admin_media_asset(digest: str, _: Annotated[str, Depends(_admin)]) -> FileResponse:
|
||||||
|
item = review_file(digest)
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="Media review asset not found")
|
||||||
|
path, media_type = item
|
||||||
|
return FileResponse(path, media_type=media_type, headers={"Cache-Control": "private, no-store"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/admin/media/upgrades/publish")
|
||||||
|
def admin_publish_media_upgrades(
|
||||||
|
payload: AdminMediaDecision,
|
||||||
|
_: Annotated[str, Depends(_admin)],
|
||||||
|
) -> dict[str, int]:
|
||||||
|
"""Atomically publish all stored quality upgrades after an explicit decision."""
|
||||||
|
try:
|
||||||
|
return publish_quality_upgrades(MEDIA_ROOT / "manifest.json", note=payload.note)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/admin/media/upgrades/rollback")
|
||||||
|
def admin_rollback_media_upgrade(
|
||||||
|
payload: AdminMediaRollback,
|
||||||
|
_: Annotated[str, Depends(_admin)],
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Restore one superseded fallback while retaining the reviewed candidate."""
|
||||||
|
try:
|
||||||
|
return rollback_quality_upgrade(
|
||||||
|
MEDIA_ROOT / "manifest.json", asset_url=payload.asset_url, note=payload.note,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/admin/diagnostics")
|
||||||
|
def admin_diagnostics(db: Db, _: Annotated[str, Depends(_admin)]) -> JSONResponse:
|
||||||
|
report_counts = {status.value: count for status, count in db.execute(
|
||||||
|
select(CatchReport.moderation_status, func.count()).group_by(CatchReport.moderation_status)
|
||||||
|
)}
|
||||||
|
observation_counts = {status: count for status, count in db.execute(
|
||||||
|
select(ExternalObservation.status, func.count()).group_by(ExternalObservation.status)
|
||||||
|
)}
|
||||||
|
payload = {
|
||||||
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"build": {"version": settings.app_version, "revision": settings.app_revision, "environment": settings.deployment_environment},
|
||||||
|
"counts": {
|
||||||
|
"catch_reports": report_counts,
|
||||||
|
"external_observations": observation_counts,
|
||||||
|
"data_sources": db.scalar(select(func.count()).select_from(DataSource)) or 0,
|
||||||
|
"enabled_data_sources": db.scalar(select(func.count()).select_from(DataSource).where(DataSource.enabled.is_(True))) or 0,
|
||||||
|
"official_import_runs": db.scalar(select(func.count()).select_from(OfficialRecordImport)) or 0,
|
||||||
|
"community_import_runs": db.scalar(select(func.count()).select_from(CommunityImportRun)) or 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return JSONResponse(payload, headers={"Content-Disposition": "attachment; filename=rf4spotter-diagnostics.json"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/admin/moderation-history", response_model=list[AdminModerationHistoryOut])
|
||||||
|
def admin_moderation_history(
|
||||||
|
db: Db,
|
||||||
|
_: Annotated[str, Depends(_admin)],
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
) -> list[AdminModerationHistoryOut]:
|
||||||
|
report_events = list(db.scalars(
|
||||||
|
select(ModerationEvent).order_by(ModerationEvent.created_at.desc()).limit(limit + offset)
|
||||||
|
))
|
||||||
|
external_events = list(db.scalars(
|
||||||
|
select(ExternalObservation).where(ExternalObservation.reviewed_at.is_not(None))
|
||||||
|
.order_by(ExternalObservation.reviewed_at.desc()).limit(limit + offset)
|
||||||
|
))
|
||||||
|
history = [AdminModerationHistoryOut(
|
||||||
|
entity_type="catch_report", entity_id=event.catch_report_id,
|
||||||
|
decided_at=event.created_at, action=event.new_status.value,
|
||||||
|
moderator=event.moderator, reason=event.reason,
|
||||||
|
) for event in report_events]
|
||||||
|
history.extend(AdminModerationHistoryOut(
|
||||||
|
entity_type="external_observation", entity_id=observation.id,
|
||||||
|
decided_at=observation.reviewed_at, action=observation.status,
|
||||||
|
moderator=None, reason=observation.review_note,
|
||||||
|
) for observation in external_events if observation.reviewed_at is not None)
|
||||||
|
history.sort(key=lambda event: aware(event.decided_at), reverse=True)
|
||||||
|
return history[offset:offset + limit]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/admin/moderation-history-export")
|
||||||
|
def admin_moderation_history_export(
|
||||||
|
db: Db,
|
||||||
|
_: Annotated[str, Depends(_admin)],
|
||||||
|
limit: int = Query(1000, ge=1, le=5000),
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""Return an anonymized, analysis-safe decision export."""
|
||||||
|
events = admin_moderation_history(db, _, limit=limit, offset=0)
|
||||||
|
payload = {
|
||||||
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"count": len(events),
|
||||||
|
"events": [{
|
||||||
|
"entity_type": event.entity_type,
|
||||||
|
"decided_at": event.decided_at.isoformat(),
|
||||||
|
"action": event.action,
|
||||||
|
"requires_confirmation": event.requires_confirmation,
|
||||||
|
} for event in events],
|
||||||
|
}
|
||||||
|
return JSONResponse(payload, headers={
|
||||||
|
"Content-Disposition": "attachment; filename=rf4spotter-moderation-history.json",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/admin/imports", response_model=list[ImportRunOut])
|
||||||
|
def admin_imports(
|
||||||
|
db: Db,
|
||||||
|
_: Annotated[str, Depends(_admin)],
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
) -> list[OfficialRecordImport]:
|
||||||
|
query = select(OfficialRecordImport).order_by(
|
||||||
|
OfficialRecordImport.started_at.desc(), OfficialRecordImport.id.desc()
|
||||||
|
).offset(offset).limit(limit)
|
||||||
|
return list(db.scalars(query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/admin/imports/official-records", response_model=ImportRunOut, status_code=201)
|
||||||
|
def admin_start_official_import(db: Db, _: Annotated[str, Depends(_admin)]) -> OfficialRecordImport:
|
||||||
|
try:
|
||||||
|
return import_records(
|
||||||
|
db,
|
||||||
|
url=settings.official_records_url,
|
||||||
|
region=settings.official_records_region,
|
||||||
|
category=settings.official_records_category,
|
||||||
|
)
|
||||||
|
except ImportAlreadyRunning as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
except (ImportSourceError, httpx.HTTPError) as 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:
|
||||||
|
allowed_payload = {
|
||||||
|
key: value for key, value in (item.payload or {}).items()
|
||||||
|
if key in {
|
||||||
|
"bait", "fishing_method", "rig_type", "retrieve_method", "retrieve_speed",
|
||||||
|
"player_name", "published_at", "region", "category",
|
||||||
|
} and (value is None or isinstance(value, (str, int, float, bool)))
|
||||||
|
}
|
||||||
|
missing_fields = []
|
||||||
|
if item.x is None or item.y is None:
|
||||||
|
missing_fields.append("coordinates")
|
||||||
|
if item.weight_g is None:
|
||||||
|
missing_fields.append("weight_g")
|
||||||
|
return ExternalObservationOut(
|
||||||
|
id=item.id, source_system=item.source_system, source_external_id=item.source_external_id,
|
||||||
|
source_url=item.source_url, fish_name=item.fish_name, fish_external_id=item.fish_external_id,
|
||||||
|
waterbody_name=item.waterbody_name, waterbody_external_id=item.waterbody_external_id,
|
||||||
|
x=item.x, y=item.y, weight_g=item.weight_g, published_at=item.published_at,
|
||||||
|
first_seen_at=item.first_seen_at, last_seen_at=item.last_seen_at, reviewed_at=item.reviewed_at,
|
||||||
|
status=item.status,
|
||||||
|
fish_slug=item.fish.slug if item.fish else None,
|
||||||
|
waterbody_slug=item.waterbody.slug if item.waterbody else None,
|
||||||
|
catch_report_id=item.catch_report_id, review_note=item.review_note,
|
||||||
|
missing_fields=missing_fields, source_payload=allowed_payload,
|
||||||
|
moderation_version=item.moderation_version,
|
||||||
|
source_check_status=item.source_check_status, source_checked_at=item.source_checked_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/admin/external-observations", response_model=list[ExternalObservationOut])
|
||||||
|
def admin_external_observations(
|
||||||
|
db: Db, _: Annotated[str, Depends(_admin)],
|
||||||
|
status: Literal["staged", "mapped", "ready", "published", "rejected", "withdrawn", "review"] | None = None,
|
||||||
|
source_system: str | None = None,
|
||||||
|
completeness: Literal["all", "complete", "incomplete"] = "all",
|
||||||
|
order: Literal["newest", "oldest", "risk"] = "newest",
|
||||||
|
q: str | None = Query(None, max_length=100),
|
||||||
|
limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0),
|
||||||
|
) -> list[ExternalObservationOut]:
|
||||||
|
query = select(ExternalObservation).options(
|
||||||
|
joinedload(ExternalObservation.fish), joinedload(ExternalObservation.waterbody),
|
||||||
|
)
|
||||||
|
if status == "review":
|
||||||
|
query = query.where(ExternalObservation.status.in_(["staged", "mapped", "ready"]))
|
||||||
|
elif status:
|
||||||
|
query = query.where(ExternalObservation.status == status)
|
||||||
|
if source_system:
|
||||||
|
query = query.where(ExternalObservation.source_system == source_system)
|
||||||
|
if completeness == "complete":
|
||||||
|
query = query.where(
|
||||||
|
ExternalObservation.x.is_not(None), ExternalObservation.y.is_not(None),
|
||||||
|
ExternalObservation.weight_g.is_not(None),
|
||||||
|
)
|
||||||
|
elif completeness == "incomplete":
|
||||||
|
query = query.where(or_(
|
||||||
|
ExternalObservation.x.is_(None), ExternalObservation.y.is_(None),
|
||||||
|
ExternalObservation.weight_g.is_(None),
|
||||||
|
))
|
||||||
|
if q and q.strip():
|
||||||
|
term = q.strip()
|
||||||
|
query = query.where(or_(
|
||||||
|
ExternalObservation.fish_name.icontains(term, autoescape=True),
|
||||||
|
ExternalObservation.waterbody_name.icontains(term, autoescape=True),
|
||||||
|
))
|
||||||
|
if order == "risk":
|
||||||
|
incomplete = case(
|
||||||
|
(or_(ExternalObservation.x.is_(None), ExternalObservation.y.is_(None), ExternalObservation.weight_g.is_(None)), 0),
|
||||||
|
else_=1,
|
||||||
|
)
|
||||||
|
workflow = case(
|
||||||
|
(ExternalObservation.status == "staged", 0),
|
||||||
|
(ExternalObservation.status == "mapped", 1),
|
||||||
|
else_=2,
|
||||||
|
)
|
||||||
|
ordering = (incomplete, workflow, ExternalObservation.last_seen_at.asc(), ExternalObservation.id.desc())
|
||||||
|
else:
|
||||||
|
direction = ExternalObservation.last_seen_at.asc() if order == "oldest" else ExternalObservation.last_seen_at.desc()
|
||||||
|
ordering = (direction, ExternalObservation.id.desc())
|
||||||
|
items = db.scalars(query.order_by(*ordering).offset(offset).limit(limit))
|
||||||
|
return [_external_out(item) for item in items]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/admin/external-observations/{observation_id}/alias-suggestions", response_model=ExternalAliasSuggestionOut)
|
||||||
|
def admin_external_alias_suggestions(
|
||||||
|
observation_id: UUID, db: Db, _: Annotated[str, Depends(_admin)],
|
||||||
|
) -> ExternalAliasSuggestionOut:
|
||||||
|
observation = db.get(ExternalObservation, observation_id)
|
||||||
|
if observation is None:
|
||||||
|
raise HTTPException(status_code=404, detail="external observation not found")
|
||||||
|
fish, waterbody = suggest_aliases(db, observation)
|
||||||
|
return ExternalAliasSuggestionOut(
|
||||||
|
fish_slug=fish.slug if fish else None,
|
||||||
|
waterbody_slug=waterbody.slug if waterbody else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/api/v1/admin/external-observations/{observation_id}/mapping", response_model=ExternalObservationOut)
|
||||||
|
def admin_map_external_observation(
|
||||||
|
observation_id: UUID, payload: ExternalObservationMapping, db: Db,
|
||||||
|
_: Annotated[str, Depends(_admin)],
|
||||||
|
) -> ExternalObservationOut:
|
||||||
|
observation = db.scalar(select(ExternalObservation).where(ExternalObservation.id == observation_id).with_for_update())
|
||||||
|
fish = db.scalar(select(Fish).where(Fish.slug == payload.fish_slug))
|
||||||
|
waterbody = db.scalar(select(Waterbody).where(Waterbody.slug == payload.waterbody_slug))
|
||||||
|
if observation is None:
|
||||||
|
raise HTTPException(status_code=404, detail="external observation not found")
|
||||||
|
if fish is None or waterbody is None:
|
||||||
|
raise HTTPException(status_code=422, detail="unknown fish or waterbody")
|
||||||
|
if observation.moderation_version != payload.expected_version:
|
||||||
|
raise HTTPException(status_code=409, detail="observation changed; reload the queue")
|
||||||
|
observation.moderation_version += 1
|
||||||
|
try:
|
||||||
|
return _external_out(map_observation(db, observation, fish, waterbody, note=payload.note))
|
||||||
|
except ExternalReviewError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/admin/external-observations/{observation_id}/publish", response_model=ExternalObservationPublished)
|
||||||
|
def admin_publish_external_observation(
|
||||||
|
observation_id: UUID, payload: ExternalObservationAction, db: Db, _: Annotated[str, Depends(_admin)],
|
||||||
|
) -> ExternalObservationPublished:
|
||||||
|
observation = db.scalar(select(ExternalObservation).where(ExternalObservation.id == observation_id).with_for_update())
|
||||||
|
if observation is None:
|
||||||
|
raise HTTPException(status_code=404, detail="external observation not found")
|
||||||
|
if observation.moderation_version != payload.expected_version:
|
||||||
|
raise HTTPException(status_code=409, detail="observation changed; reload the queue")
|
||||||
|
observation.moderation_version += 1
|
||||||
|
try:
|
||||||
|
report = publish_observation(db, observation)
|
||||||
|
except ExternalReviewError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
public_cache.invalidate()
|
||||||
|
return ExternalObservationPublished(observation_id=observation.id, catch_report_id=report.id, status=observation.status)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/api/v1/admin/external-observations/{observation_id}/reject", response_model=ExternalObservationOut)
|
||||||
|
def admin_reject_external_observation(
|
||||||
|
observation_id: UUID, payload: ExternalObservationDecision, db: Db,
|
||||||
|
_: Annotated[str, Depends(_admin)],
|
||||||
|
) -> ExternalObservationOut:
|
||||||
|
observation = db.scalar(select(ExternalObservation).where(ExternalObservation.id == observation_id).with_for_update())
|
||||||
|
if observation is None:
|
||||||
|
raise HTTPException(status_code=404, detail="external observation not found")
|
||||||
|
if observation.moderation_version != payload.expected_version:
|
||||||
|
raise HTTPException(status_code=409, detail="observation changed; reload the queue")
|
||||||
|
observation.moderation_version += 1
|
||||||
|
try:
|
||||||
|
return _external_out(reject_observation(db, observation, reason=payload.reason))
|
||||||
|
except ExternalReviewError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/admin/catch-reports", response_model=list[AdminCatchReportOut])
|
||||||
|
def admin_reports(db: Db, _: Annotated[str, Depends(_admin)], status: ModerationStatus = ModerationStatus.pending, limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0)) -> list[AdminCatchReportOut]:
|
||||||
|
reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.waterbody), joinedload(CatchReport.spot), joinedload(CatchReport.bait)).where(CatchReport.source_type == SourceType.user, CatchReport.moderation_status == status, CatchReport.deleted_at.is_(None)).order_by(CatchReport.reported_at, CatchReport.id).offset(offset).limit(limit)))
|
||||||
|
return [AdminCatchReportOut(id=r.id, fish=r.fish.name_ru, waterbody=r.waterbody.name_ru, coordinates=f"{r.spot.x}:{r.spot.y}" if r.spot else "—", weight_g=r.weight_g, bait=r.bait.name if r.bait else None, player_name=r.player_name, reported_at=r.reported_at, moderation_status=r.moderation_status.value, comment=(r.raw_payload or {}).get("comment"), screenshot_url=signed_screenshot_url(r.screenshot_key) if r.screenshot_key else None, moderation_version=r.moderation_version) for r in reports]
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/api/v1/admin/catch-reports/{report_id}", response_model=CatchReportCreated)
|
||||||
|
def moderate_report(report_id: UUID, payload: ModerationUpdate, db: Db, moderator: Annotated[str, Depends(_admin)]) -> CatchReportCreated:
|
||||||
|
report = db.scalar(select(CatchReport).where(CatchReport.id == report_id).with_for_update())
|
||||||
|
if report is None or report.source_type != SourceType.user or report.deleted_at is not None:
|
||||||
|
raise HTTPException(status_code=404, detail="catch report not found")
|
||||||
|
if report.moderation_version != payload.expected_version:
|
||||||
|
raise HTTPException(status_code=409, detail="report changed; reload the queue")
|
||||||
|
previous = report.moderation_status
|
||||||
|
report.moderation_status = ModerationStatus(payload.status)
|
||||||
|
report.moderation_version += 1
|
||||||
|
db.add(ModerationEvent(catch_report=report, created_at=datetime.now(timezone.utc), previous_status=previous, new_status=report.moderation_status, moderator=moderator, reason=payload.reason))
|
||||||
|
db.commit()
|
||||||
|
public_cache.invalidate()
|
||||||
|
return CatchReportCreated(id=report.id, moderation_status=report.moderation_status.value)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/v1/admin/catch-reports/{report_id}", status_code=204, response_class=Response)
|
||||||
|
def delete_report(report_id: UUID, db: Db, moderator: Annotated[str, Depends(_admin)], expected_version: int = Query(ge=0)) -> Response:
|
||||||
|
report = db.scalar(select(CatchReport).where(CatchReport.id == report_id).with_for_update())
|
||||||
|
if report is None or report.source_type != SourceType.user or report.deleted_at is not None:
|
||||||
|
raise HTTPException(status_code=404, detail="catch report not found")
|
||||||
|
if report.moderation_version != expected_version:
|
||||||
|
raise HTTPException(status_code=409, detail="report changed; reload the queue")
|
||||||
|
previous = report.moderation_status
|
||||||
|
if report.screenshot_key:
|
||||||
|
try:
|
||||||
|
delete_screenshot(report.screenshot_key)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=502, detail="screenshot deletion failed") from exc
|
||||||
|
report.moderation_status = ModerationStatus.rejected
|
||||||
|
report.moderation_version += 1
|
||||||
|
report.deleted_at = datetime.now(timezone.utc)
|
||||||
|
report.player_name = None
|
||||||
|
report.source_url = None
|
||||||
|
report.screenshot_key = None
|
||||||
|
report.raw_payload = None
|
||||||
|
db.add(ModerationEvent(catch_report=report, created_at=report.deleted_at, previous_status=previous, new_status=ModerationStatus.rejected, moderator=moderator, reason="user report deleted and anonymized"))
|
||||||
|
db.commit()
|
||||||
|
public_cache.invalidate()
|
||||||
|
return Response(status_code=204)
|
||||||
@@ -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))
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from ..dependencies import Db
|
||||||
|
from ..models import Bait, CatchReport, Fish, ModerationStatus, Rig, Spot, TackleItem, Waterbody
|
||||||
|
from ..schemas import BaitOut, FishOut, PaginatedTackleItemOut, RigOut, TackleItemOut, WaterbodyOut
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/fishes", response_model=list[FishOut])
|
||||||
|
def fishes(db: Db, limit: int = Query(200, ge=1, le=500), offset: int = Query(0, ge=0)) -> list[Fish]:
|
||||||
|
return list(db.scalars(select(Fish).order_by(Fish.name_ru, Fish.id).offset(offset).limit(limit)))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/waterbodies", response_model=list[WaterbodyOut])
|
||||||
|
def waterbodies(db: Db, limit: int = Query(200, ge=1, le=500), offset: int = Query(0, ge=0)) -> list[Waterbody]:
|
||||||
|
return list(db.scalars(select(Waterbody).order_by(Waterbody.name_ru, Waterbody.id).offset(offset).limit(limit)))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/baits", response_model=list[BaitOut])
|
||||||
|
def baits(db: Db, limit: int = Query(200, ge=1, le=500), offset: int = Query(0, ge=0)) -> list[Bait]:
|
||||||
|
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")
|
||||||
|
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)
|
||||||
|
.select_from(CatchReport).join(Spot, CatchReport.spot_id == Spot.id)
|
||||||
|
.join(Waterbody, Spot.waterbody_id == Waterbody.id).join(Fish, CatchReport.fish_id == Fish.id)
|
||||||
|
.where(CatchReport.moderation_status == ModerationStatus.approved, CatchReport.deleted_at.is_(None))
|
||||||
|
.distinct().order_by(Waterbody.slug, Spot.x, Spot.y, Fish.slug).offset(offset).limit(limit))
|
||||||
|
return [path for water, x, y, fish in rows for path in
|
||||||
|
(f"/spots/{water}-{x}x{y}", f"/waterbodies/{water}/{fish}")]
|
||||||
@@ -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}"',
|
||||||
|
})
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Query
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
|
from ..config import settings
|
||||||
|
from ..dependencies import Db
|
||||||
|
from ..models import (
|
||||||
|
CatchReport,
|
||||||
|
CommunityImportRun,
|
||||||
|
DataSource,
|
||||||
|
ExternalObservation,
|
||||||
|
Fish,
|
||||||
|
OfficialRecordImport,
|
||||||
|
SourceType,
|
||||||
|
Waterbody,
|
||||||
|
)
|
||||||
|
from ..schemas import (
|
||||||
|
ImportRunPublicOut,
|
||||||
|
OfficialRecordOut,
|
||||||
|
PaginatedOfficialRecordOut,
|
||||||
|
PublicObservationOut,
|
||||||
|
SourceStatusOut,
|
||||||
|
)
|
||||||
|
from ..time_utils import aware
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/community-observations", response_model=list[PublicObservationOut])
|
||||||
|
def community_observations(
|
||||||
|
db: Db,
|
||||||
|
limit: int = Query(12, ge=1, le=50),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
waterbody: str | None = None,
|
||||||
|
fish: str | None = None,
|
||||||
|
) -> list[PublicObservationOut]:
|
||||||
|
query = select(ExternalObservation).join(ExternalObservation.source).options(
|
||||||
|
joinedload(ExternalObservation.source)
|
||||||
|
).where(
|
||||||
|
ExternalObservation.catch_report_id.is_(None),
|
||||||
|
ExternalObservation.status.not_in(["rejected", "withdrawn"]),
|
||||||
|
DataSource.enabled.is_(True),
|
||||||
|
)
|
||||||
|
if waterbody:
|
||||||
|
query = query.join(ExternalObservation.waterbody).where(Waterbody.slug == waterbody)
|
||||||
|
if fish:
|
||||||
|
query = query.join(ExternalObservation.fish).where(Fish.slug == fish)
|
||||||
|
items = list(db.scalars(
|
||||||
|
query.order_by(ExternalObservation.last_seen_at.desc(), ExternalObservation.id.desc())
|
||||||
|
.offset(offset).limit(limit)
|
||||||
|
))
|
||||||
|
result: list[PublicObservationOut] = []
|
||||||
|
for item in items:
|
||||||
|
missing = []
|
||||||
|
if item.x is None or item.y is None:
|
||||||
|
missing.append("координаты")
|
||||||
|
if item.weight_g is None:
|
||||||
|
missing.append("вес")
|
||||||
|
result.append(PublicObservationOut(
|
||||||
|
id=item.id,
|
||||||
|
source_system=item.source_system,
|
||||||
|
source_name=item.source.name,
|
||||||
|
source_url=item.source_url,
|
||||||
|
fish_name=item.fish_name,
|
||||||
|
waterbody_name=item.waterbody_name,
|
||||||
|
x=item.x,
|
||||||
|
y=item.y,
|
||||||
|
weight_g=item.weight_g,
|
||||||
|
last_seen_at=item.last_seen_at,
|
||||||
|
missing_fields=missing,
|
||||||
|
quality="incomplete" if missing else "unverified",
|
||||||
|
))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/source-status", response_model=list[SourceStatusOut])
|
||||||
|
def source_status(db: Db) -> list[SourceStatusOut]:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
result = []
|
||||||
|
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)
|
||||||
|
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(SourceStatusOut(
|
||||||
|
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,
|
||||||
|
observations=db.scalar(
|
||||||
|
select(func.count()).select_from(ExternalObservation)
|
||||||
|
.where(ExternalObservation.source_system == source.key)
|
||||||
|
) or 0,
|
||||||
|
))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/records", response_model=PaginatedOfficialRecordOut)
|
||||||
|
def records(
|
||||||
|
db: Db,
|
||||||
|
fish: str | None = None,
|
||||||
|
waterbody: str | None = None,
|
||||||
|
category: str | None = None,
|
||||||
|
limit: int = Query(50, ge=1, le=100),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
) -> PaginatedOfficialRecordOut:
|
||||||
|
query = select(CatchReport).options(
|
||||||
|
joinedload(CatchReport.fish),
|
||||||
|
joinedload(CatchReport.waterbody),
|
||||||
|
joinedload(CatchReport.bait),
|
||||||
|
).where(CatchReport.source_type == SourceType.official_record)
|
||||||
|
if fish:
|
||||||
|
query = query.join(CatchReport.fish).where(Fish.slug == fish)
|
||||||
|
if waterbody:
|
||||||
|
query = query.join(CatchReport.waterbody).where(Waterbody.slug == waterbody)
|
||||||
|
if category:
|
||||||
|
query = query.where(CatchReport.raw_payload["category"].as_string() == category)
|
||||||
|
total = db.scalar(
|
||||||
|
query.with_only_columns(func.count(CatchReport.id), maintain_column_froms=True).order_by(None)
|
||||||
|
) or 0
|
||||||
|
items = list(db.scalars(
|
||||||
|
query.order_by(CatchReport.caught_at.desc(), CatchReport.weight_g.desc(), CatchReport.id.desc())
|
||||||
|
.offset(offset).limit(limit)
|
||||||
|
))
|
||||||
|
return PaginatedOfficialRecordOut(
|
||||||
|
items=[OfficialRecordOut(
|
||||||
|
id=item.id,
|
||||||
|
fish=item.fish.name_ru,
|
||||||
|
weight_g=item.weight_g,
|
||||||
|
waterbody=item.waterbody.name_ru,
|
||||||
|
bait=item.bait.name if item.bait else None,
|
||||||
|
player_name=item.player_name,
|
||||||
|
record_date=item.caught_at,
|
||||||
|
category=(item.raw_payload or {}).get("category"),
|
||||||
|
region=(item.raw_payload or {}).get("region"),
|
||||||
|
source_url=item.source_url,
|
||||||
|
) for item in items],
|
||||||
|
total=total,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/imports", response_model=list[ImportRunPublicOut])
|
||||||
|
def imports(
|
||||||
|
db: Db,
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
) -> list[OfficialRecordImport]:
|
||||||
|
return list(db.scalars(
|
||||||
|
select(OfficialRecordImport)
|
||||||
|
.order_by(OfficialRecordImport.started_at.desc(), OfficialRecordImport.id.desc())
|
||||||
|
.offset(offset).limit(limit)
|
||||||
|
))
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
from typing import Annotated
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, File, Header, HTTPException, Request, Response, UploadFile
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
from ..config import settings
|
||||||
|
from ..dependencies import Db
|
||||||
|
from ..importer import normalize
|
||||||
|
from ..models import Bait, BaitKind, CatchReport, Fish, ModerationStatus, SourceType, Spot, SubmissionAttempt, Waterbody
|
||||||
|
from ..schemas import CatchReportAccepted, CatchReportCreate
|
||||||
|
from ..storage import ScreenshotError, upload_screenshot
|
||||||
|
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()
|
||||||
|
logger = logging.getLogger("rf4.api.submissions")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/catch-reports", response_model=CatchReportAccepted, status_code=201)
|
||||||
|
def create_catch_report(payload: CatchReportCreate, request: Request, db: Db, idempotency_key: Annotated[str | None, Header()] = None) -> CatchReportAccepted:
|
||||||
|
if payload.website:
|
||||||
|
raise HTTPException(status_code=400, detail="invalid submission")
|
||||||
|
payload_hash = hashlib.sha256(json.dumps(payload.model_dump(mode="json"), sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
||||||
|
key_hash = hmac.new(settings.rate_limit_secret.encode(), idempotency_key.encode(), hashlib.sha256).hexdigest() if idempotency_key else None
|
||||||
|
if key_hash:
|
||||||
|
cutoff = datetime.now(timezone.utc) - timedelta(minutes=5)
|
||||||
|
db.expire_all()
|
||||||
|
existing = db.scalar(select(SubmissionAttempt).where(SubmissionAttempt.idempotency_key == key_hash, SubmissionAttempt.created_at >= cutoff))
|
||||||
|
if existing is not None:
|
||||||
|
logger.info("idempotent hit", extra={"idempotency_key": idempotency_key[:8]})
|
||||||
|
if existing.payload_hash and not hmac.compare_digest(existing.payload_hash, payload_hash):
|
||||||
|
raise HTTPException(status_code=409, detail="Idempotency-Key was already used with different payload")
|
||||||
|
if existing.catch_report is None:
|
||||||
|
raise HTTPException(status_code=409, detail="idempotency record is incomplete; retry with a new key")
|
||||||
|
replay_token = _replay_token(key_hash)
|
||||||
|
if not hmac.compare_digest(hashlib.sha256(replay_token.encode()).hexdigest(), existing.catch_report.screenshot_upload_token_hash or ""):
|
||||||
|
raise HTTPException(status_code=409, detail="idempotency record token mismatch; retry with a new key")
|
||||||
|
return JSONResponse(status_code=200, content=_accepted(existing.catch_report, replay_token, True))
|
||||||
|
logger.info("idempotency check miss", extra={"idempotency_key": idempotency_key[:8]})
|
||||||
|
check_rate_limit(request, db, settings)
|
||||||
|
fish = db.scalar(select(Fish).where(Fish.slug == payload.fish_slug))
|
||||||
|
waterbody = db.scalar(select(Waterbody).where(Waterbody.slug == payload.waterbody_slug))
|
||||||
|
if fish is None or waterbody is None:
|
||||||
|
raise HTTPException(status_code=422, detail="unknown fish or waterbody")
|
||||||
|
spot = db.scalar(select(Spot).where(Spot.waterbody_id == waterbody.id, Spot.x == payload.x, Spot.y == payload.y))
|
||||||
|
if spot is None:
|
||||||
|
spot = Spot(waterbody=waterbody, x=payload.x, y=payload.y)
|
||||||
|
db.add(spot)
|
||||||
|
bait = _bait(db, payload.bait_name)
|
||||||
|
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())
|
||||||
|
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:
|
||||||
|
db.add(SubmissionAttempt(client_hash="", idempotency_key=key_hash, catch_report=report, payload_hash=payload_hash, created_at=datetime.now(timezone.utc)))
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
db.rollback()
|
||||||
|
winner = db.scalar(select(SubmissionAttempt).where(SubmissionAttempt.idempotency_key == key_hash))
|
||||||
|
if winner and winner.catch_report:
|
||||||
|
return JSONResponse(status_code=200, content=_accepted(winner.catch_report, _replay_token(key_hash), True))
|
||||||
|
raise
|
||||||
|
logger.info("idempotency key stored", extra={"idempotency_key": idempotency_key[:8]})
|
||||||
|
else:
|
||||||
|
db.commit()
|
||||||
|
return CatchReportAccepted(id=report.id, moderation_status=report.moderation_status.value, screenshot_upload_token=upload_token, idempotent=False)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/catch-reports/{report_id}/screenshot", status_code=204, response_class=Response)
|
||||||
|
def add_screenshot(report_id: UUID, db: Db, screenshot: UploadFile = File(), upload_token: Annotated[str | None, Header(alias="X-Upload-Token")] = None) -> Response:
|
||||||
|
report = db.get(CatchReport, report_id)
|
||||||
|
if report is None or report.source_type != SourceType.user or report.moderation_status != ModerationStatus.pending:
|
||||||
|
raise HTTPException(status_code=404, detail="pending catch report not found")
|
||||||
|
supplied_hash = hashlib.sha256((upload_token or "").encode()).hexdigest()
|
||||||
|
if not report.screenshot_upload_token_hash or not hmac.compare_digest(report.screenshot_upload_token_hash, supplied_hash):
|
||||||
|
raise HTTPException(status_code=401, detail="invalid screenshot upload token")
|
||||||
|
if report.screenshot_key:
|
||||||
|
raise HTTPException(status_code=409, detail="screenshot already uploaded")
|
||||||
|
raw = screenshot.file.read(settings.screenshot_max_bytes + 1)
|
||||||
|
try:
|
||||||
|
report.screenshot_key = upload_screenshot(raw, filename=screenshot.filename, content_type=screenshot.content_type)
|
||||||
|
except ScreenshotError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
report.screenshot_upload_token_hash = None
|
||||||
|
db.commit()
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
|
||||||
|
def _replay_token(key_hash: str) -> str:
|
||||||
|
return hmac.new(settings.rate_limit_secret.encode(), (key_hash + ":upload").encode(), hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _accepted(report: CatchReport, token: str, idempotent: bool) -> dict[str, object]:
|
||||||
|
return {"id": str(report.id), "moderation_status": report.moderation_status.value, "screenshot_upload_token": token, "idempotent": idempotent}
|
||||||
|
|
||||||
|
|
||||||
|
def _bait(db: Db, value: str | None) -> Bait | None:
|
||||||
|
if not value or not value.strip():
|
||||||
|
return None
|
||||||
|
key = normalize(value)
|
||||||
|
bait = db.scalar(select(Bait).where(Bait.normalized_name == key))
|
||||||
|
if bait is None:
|
||||||
|
bait = Bait(name=value.strip(), normalized_name=key, kind=BaitKind.unknown)
|
||||||
|
db.add(bait)
|
||||||
|
return bait
|
||||||
@@ -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,25 @@ 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):
|
||||||
|
items: list[ActivityOut]
|
||||||
|
total: int
|
||||||
|
limit: 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):
|
||||||
@@ -62,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):
|
||||||
@@ -75,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):
|
||||||
@@ -91,6 +176,13 @@ class OfficialRecordOut(BaseModel):
|
|||||||
source_system: str = "rf4-official"
|
source_system: str = "rf4-official"
|
||||||
|
|
||||||
|
|
||||||
|
class PaginatedOfficialRecordOut(BaseModel):
|
||||||
|
items: list[OfficialRecordOut]
|
||||||
|
total: int
|
||||||
|
limit: int
|
||||||
|
offset: int
|
||||||
|
|
||||||
|
|
||||||
class PublicObservationOut(BaseModel):
|
class PublicObservationOut(BaseModel):
|
||||||
id: UUID
|
id: UUID
|
||||||
source_system: str
|
source_system: str
|
||||||
@@ -125,6 +217,18 @@ class ImportRunOut(BaseModel):
|
|||||||
not_modified: bool
|
not_modified: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ImportRunPublicOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: UUID
|
||||||
|
started_at: datetime
|
||||||
|
finished_at: datetime | None
|
||||||
|
status: str
|
||||||
|
rows_seen: int
|
||||||
|
rows_created: int
|
||||||
|
rows_updated: int
|
||||||
|
not_modified: bool
|
||||||
|
|
||||||
|
|
||||||
class CatchReportCreate(BaseModel):
|
class CatchReportCreate(BaseModel):
|
||||||
fish_slug: str
|
fish_slug: str
|
||||||
waterbody_slug: str
|
waterbody_slug: str
|
||||||
@@ -158,6 +262,7 @@ class CatchReportCreated(BaseModel):
|
|||||||
|
|
||||||
class CatchReportAccepted(CatchReportCreated):
|
class CatchReportAccepted(CatchReportCreated):
|
||||||
screenshot_upload_token: str
|
screenshot_upload_token: str
|
||||||
|
idempotent: bool = False
|
||||||
|
|
||||||
|
|
||||||
class AdminCatchReportOut(BaseModel):
|
class AdminCatchReportOut(BaseModel):
|
||||||
@@ -172,11 +277,13 @@ class AdminCatchReportOut(BaseModel):
|
|||||||
moderation_status: str
|
moderation_status: str
|
||||||
comment: str | None
|
comment: str | None
|
||||||
screenshot_url: str | None
|
screenshot_url: str | None
|
||||||
|
moderation_version: int
|
||||||
|
|
||||||
|
|
||||||
class ModerationUpdate(BaseModel):
|
class ModerationUpdate(BaseModel):
|
||||||
status: str
|
status: str
|
||||||
reason: str | None = Field(default=None, max_length=1000)
|
reason: str | None = Field(default=None, max_length=1000)
|
||||||
|
expected_version: int = Field(ge=0)
|
||||||
|
|
||||||
@field_validator("status")
|
@field_validator("status")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -199,22 +306,50 @@ class ExternalObservationOut(BaseModel):
|
|||||||
y: int | None
|
y: int | None
|
||||||
weight_g: int | None
|
weight_g: int | None
|
||||||
published_at: datetime | None
|
published_at: datetime | None
|
||||||
|
first_seen_at: datetime
|
||||||
last_seen_at: datetime
|
last_seen_at: datetime
|
||||||
|
reviewed_at: datetime | None
|
||||||
status: str
|
status: str
|
||||||
fish_slug: str | None
|
fish_slug: str | None
|
||||||
waterbody_slug: str | None
|
waterbody_slug: str | None
|
||||||
catch_report_id: UUID | None
|
catch_report_id: UUID | None
|
||||||
review_note: str | None
|
review_note: str | None
|
||||||
|
missing_fields: list[str]
|
||||||
|
source_payload: dict[str, str | int | float | bool | None]
|
||||||
|
moderation_version: int
|
||||||
|
source_check_status: str | None
|
||||||
|
source_checked_at: datetime | None
|
||||||
|
|
||||||
|
|
||||||
class ExternalObservationMapping(BaseModel):
|
class ExternalObservationMapping(BaseModel):
|
||||||
fish_slug: str
|
fish_slug: str
|
||||||
waterbody_slug: str
|
waterbody_slug: str
|
||||||
note: str | None = Field(default=None, max_length=1000)
|
note: str | None = Field(default=None, max_length=1000)
|
||||||
|
expected_version: int = Field(ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalAliasSuggestionOut(BaseModel):
|
||||||
|
fish_slug: str | None
|
||||||
|
waterbody_slug: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class AdminModerationHistoryOut(BaseModel):
|
||||||
|
entity_type: str
|
||||||
|
entity_id: UUID
|
||||||
|
decided_at: datetime
|
||||||
|
action: str
|
||||||
|
moderator: str | None
|
||||||
|
reason: str | None
|
||||||
|
requires_confirmation: bool = True
|
||||||
|
|
||||||
|
|
||||||
class ExternalObservationDecision(BaseModel):
|
class ExternalObservationDecision(BaseModel):
|
||||||
reason: str = Field(min_length=1, max_length=1000)
|
reason: str = Field(min_length=1, max_length=1000)
|
||||||
|
expected_version: int = Field(ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalObservationAction(BaseModel):
|
||||||
|
expected_version: int = Field(ge=0)
|
||||||
|
|
||||||
|
|
||||||
class ExternalObservationPublished(BaseModel):
|
class ExternalObservationPublished(BaseModel):
|
||||||
@@ -230,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)
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Literal
|
||||||
|
from urllib.error import HTTPError
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .models import ExternalObservation, ModerationStatus
|
||||||
|
|
||||||
|
|
||||||
|
SourceCheckStatus = Literal["available", "missing", "temporary_error", "blocked"]
|
||||||
|
|
||||||
|
|
||||||
|
def classify_source_failure(exc: Exception) -> SourceCheckStatus:
|
||||||
|
"""Classify the result of the scheduled request without retrying it."""
|
||||||
|
if isinstance(exc, HTTPError):
|
||||||
|
if exc.code in {404, 410}:
|
||||||
|
return "missing"
|
||||||
|
if exc.code in {401, 403, 429}:
|
||||||
|
return "blocked"
|
||||||
|
return "temporary_error"
|
||||||
|
|
||||||
|
|
||||||
|
def record_source_check(
|
||||||
|
session: Session,
|
||||||
|
observation: ExternalObservation,
|
||||||
|
status: SourceCheckStatus,
|
||||||
|
*,
|
||||||
|
checked_at: datetime | None = None,
|
||||||
|
) -> ExternalObservation:
|
||||||
|
"""Persist a check performed during an already scheduled source request.
|
||||||
|
|
||||||
|
Only an authoritative 404/410-style ``missing`` result withdraws published
|
||||||
|
data. Transient errors and access blocks remain diagnostic and never remove
|
||||||
|
an observation from activity.
|
||||||
|
"""
|
||||||
|
_apply_source_check(observation, status, checked_at or datetime.now(timezone.utc))
|
||||||
|
session.commit()
|
||||||
|
return observation
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_source_check(
|
||||||
|
observation: ExternalObservation,
|
||||||
|
status: SourceCheckStatus,
|
||||||
|
checked_at: datetime,
|
||||||
|
) -> None:
|
||||||
|
"""Mutate one observation; the caller owns the transaction boundary."""
|
||||||
|
current = checked_at
|
||||||
|
observation.source_check_status = status
|
||||||
|
observation.source_checked_at = current
|
||||||
|
if status == "missing" and observation.status != "withdrawn":
|
||||||
|
if observation.catch_report is not None:
|
||||||
|
observation.catch_report.moderation_status = ModerationStatus.pending
|
||||||
|
observation.status = "withdrawn"
|
||||||
|
observation.review_note = "Source record missing; withdrawn pending moderator review"
|
||||||
|
observation.reviewed_at = current
|
||||||
|
observation.moderation_version += 1
|
||||||
|
|
||||||
|
|
||||||
|
def record_scheduled_source_check(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
source_system: str,
|
||||||
|
source_url: str,
|
||||||
|
status: SourceCheckStatus,
|
||||||
|
checked_at: datetime | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Apply one scheduled request result only to observations with that exact URL.
|
||||||
|
|
||||||
|
Aggregate pages cannot prove that an omitted record was deleted, so absence
|
||||||
|
from a parsed listing is deliberately ignored.
|
||||||
|
"""
|
||||||
|
observations = list(session.scalars(select(ExternalObservation).where(
|
||||||
|
ExternalObservation.source_system == source_system,
|
||||||
|
ExternalObservation.source_url == source_url,
|
||||||
|
)))
|
||||||
|
current = checked_at or datetime.now(timezone.utc)
|
||||||
|
for observation in observations:
|
||||||
|
_apply_source_check(observation, status, current)
|
||||||
|
session.commit()
|
||||||
|
return len(observations)
|
||||||
@@ -6,7 +6,6 @@ from functools import lru_cache
|
|||||||
|
|
||||||
import boto3
|
import boto3
|
||||||
from botocore.client import BaseClient
|
from botocore.client import BaseClient
|
||||||
from botocore.exceptions import ClientError
|
|
||||||
from PIL import Image, UnidentifiedImageError
|
from PIL import Image, UnidentifiedImageError
|
||||||
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
@@ -63,10 +62,7 @@ def upload_screenshot(raw: bytes, *, filename: str | None = None, content_type:
|
|||||||
body, extension, mime = prepare_image(raw)
|
body, extension, mime = prepare_image(raw)
|
||||||
key = f"reports/{uuid.uuid4()}.{extension}"
|
key = f"reports/{uuid.uuid4()}.{extension}"
|
||||||
s3 = client()
|
s3 = client()
|
||||||
try:
|
s3.head_bucket(Bucket=settings.s3_bucket)
|
||||||
s3.head_bucket(Bucket=settings.s3_bucket)
|
|
||||||
except ClientError:
|
|
||||||
s3.create_bucket(Bucket=settings.s3_bucket)
|
|
||||||
s3.put_object(Bucket=settings.s3_bucket, Key=key, Body=body, ContentType=mime)
|
s3.put_object(Bucket=settings.s3_bucket, Key=key, Body=body, ContentType=mime)
|
||||||
return key
|
return key
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network, ip_address
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from fastapi import HTTPException, Request
|
||||||
|
from sqlalchemy import delete, func, select, text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .models import SubmissionAttempt
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitConfig(Protocol):
|
||||||
|
rate_limit_secret: str
|
||||||
|
trusted_proxy_cidrs: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
def is_trusted_proxy(address: str, trusted_cidrs: list[str]) -> bool:
|
||||||
|
try:
|
||||||
|
addr = IPv4Address(address) if ":" not in address else IPv6Address(address)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
for cidr in trusted_cidrs:
|
||||||
|
try:
|
||||||
|
network = IPv4Network(cidr) if ":" not in cidr else IPv6Network(cidr)
|
||||||
|
if addr in network:
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def client_address(request: Request, trusted_cidrs: list[str]) -> str:
|
||||||
|
client = request.client.host if request.client else "unknown"
|
||||||
|
forwarded = request.headers.get("x-forwarded-for")
|
||||||
|
if forwarded and request.client and is_trusted_proxy(request.client.host, trusted_cidrs):
|
||||||
|
candidate = forwarded.split(",")[0].strip()
|
||||||
|
try:
|
||||||
|
return str(ip_address(candidate))
|
||||||
|
except ValueError:
|
||||||
|
return client
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def check_rate_limit(request: Request, db: Session, config: RateLimitConfig) -> None:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
cutoff = now - timedelta(minutes=10)
|
||||||
|
client = client_address(request, config.trusted_proxy_cidrs)
|
||||||
|
client_hash = hmac.new(config.rate_limit_secret.encode(), client.encode(), hashlib.sha256).hexdigest()
|
||||||
|
if db.get_bind().dialect.name == "postgresql":
|
||||||
|
lock_key = int(client_hash[:16], 16) & 0x7FFF_FFFF_FFFF_FFFF
|
||||||
|
db.execute(text("SELECT pg_advisory_xact_lock(:lock_key)"), {"lock_key": lock_key})
|
||||||
|
db.execute(delete(SubmissionAttempt).where(SubmissionAttempt.created_at < now - timedelta(days=1)))
|
||||||
|
recent = db.scalar(
|
||||||
|
select(func.count()).select_from(SubmissionAttempt).where(
|
||||||
|
SubmissionAttempt.client_hash == client_hash,
|
||||||
|
SubmissionAttempt.created_at >= cutoff,
|
||||||
|
)
|
||||||
|
) or 0
|
||||||
|
if recent >= 5:
|
||||||
|
db.commit()
|
||||||
|
raise HTTPException(status_code=429, detail="too many submissions")
|
||||||
|
db.add(SubmissionAttempt(client_hash=client_hash, created_at=now))
|
||||||
|
db.commit()
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
|
def aware(value: datetime) -> datetime:
|
||||||
|
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
|
||||||
|
TARGET = Path(__file__).with_name("openapi.json")
|
||||||
|
|
||||||
|
|
||||||
|
def rendered_contract() -> str:
|
||||||
|
return json.dumps(app.openapi(), ensure_ascii=False, indent=2, sort_keys=True) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Generate or verify the RF4 Spotter OpenAPI contract")
|
||||||
|
parser.add_argument("--check", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
rendered = rendered_contract()
|
||||||
|
if args.check:
|
||||||
|
if not TARGET.exists() or TARGET.read_text(encoding="utf-8") != rendered:
|
||||||
|
parser.exit(1, "OpenAPI contract is stale; run apps/api/export_openapi.py\n")
|
||||||
|
print(f"OpenAPI contract is current: {len(app.openapi()['paths'])} paths")
|
||||||
|
return 0
|
||||||
|
TARGET.write_text(rendered, encoding="utf-8")
|
||||||
|
print(f"Wrote {TARGET}: {len(app.openapi()['paths'])} paths")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
#
|
||||||
|
# This file is autogenerated by pip-compile with Python 3.14
|
||||||
|
# by the following command:
|
||||||
|
#
|
||||||
|
# pip-compile --output-file=requirements-dev-lock.txt requirements-dev.txt
|
||||||
|
#
|
||||||
|
alembic==1.16.5
|
||||||
|
# via -r requirements.txt
|
||||||
|
annotated-types==0.8.0
|
||||||
|
# via pydantic
|
||||||
|
anyio==4.15.1
|
||||||
|
# via
|
||||||
|
# httpx
|
||||||
|
# starlette
|
||||||
|
# watchfiles
|
||||||
|
beautifulsoup4==4.15.0
|
||||||
|
# via -r requirements.txt
|
||||||
|
boto3==1.40.35
|
||||||
|
# via -r requirements.txt
|
||||||
|
botocore==1.40.76
|
||||||
|
# via
|
||||||
|
# boto3
|
||||||
|
# s3transfer
|
||||||
|
certifi==2026.7.22
|
||||||
|
# via
|
||||||
|
# httpcore
|
||||||
|
# httpx
|
||||||
|
click==8.5.0
|
||||||
|
# via uvicorn
|
||||||
|
fastapi==0.116.1
|
||||||
|
# via -r requirements.txt
|
||||||
|
h11==0.16.0
|
||||||
|
# via
|
||||||
|
# httpcore
|
||||||
|
# uvicorn
|
||||||
|
httpcore==1.0.9
|
||||||
|
# via httpx
|
||||||
|
httptools==0.8.0
|
||||||
|
# via uvicorn
|
||||||
|
httpx==0.28.1
|
||||||
|
# via -r requirements.txt
|
||||||
|
idna==3.19
|
||||||
|
# via
|
||||||
|
# anyio
|
||||||
|
# httpx
|
||||||
|
iniconfig==2.3.0
|
||||||
|
# via pytest
|
||||||
|
jmespath==1.1.0
|
||||||
|
# via
|
||||||
|
# boto3
|
||||||
|
# botocore
|
||||||
|
mako==1.4.1
|
||||||
|
# via alembic
|
||||||
|
markupsafe==3.0.3
|
||||||
|
# via mako
|
||||||
|
packaging==26.3
|
||||||
|
# via pytest
|
||||||
|
pillow==11.3.0
|
||||||
|
# via -r requirements.txt
|
||||||
|
pluggy==1.6.0
|
||||||
|
# via pytest
|
||||||
|
psycopg[binary]==3.2.10
|
||||||
|
# via -r requirements.txt
|
||||||
|
psycopg-binary==3.2.10
|
||||||
|
# via psycopg
|
||||||
|
pydantic==2.13.5
|
||||||
|
# via
|
||||||
|
# fastapi
|
||||||
|
# pydantic-settings
|
||||||
|
pydantic-core==2.46.5
|
||||||
|
# via pydantic
|
||||||
|
pydantic-settings==2.10.1
|
||||||
|
# via -r requirements.txt
|
||||||
|
pygments==2.21.0
|
||||||
|
# via pytest
|
||||||
|
pytest==8.4.2
|
||||||
|
# via -r requirements-dev.txt
|
||||||
|
python-dateutil==2.9.0.post0
|
||||||
|
# via botocore
|
||||||
|
python-dotenv==1.2.3
|
||||||
|
# via
|
||||||
|
# pydantic-settings
|
||||||
|
# uvicorn
|
||||||
|
python-multipart==0.0.20
|
||||||
|
# via -r requirements.txt
|
||||||
|
pyyaml==6.0.3
|
||||||
|
# via uvicorn
|
||||||
|
s3transfer==0.14.0
|
||||||
|
# via boto3
|
||||||
|
six==1.17.0
|
||||||
|
# via python-dateutil
|
||||||
|
soupsieve==2.9.2
|
||||||
|
# via beautifulsoup4
|
||||||
|
sqlalchemy==2.0.43
|
||||||
|
# via
|
||||||
|
# -r requirements.txt
|
||||||
|
# alembic
|
||||||
|
starlette==0.47.3
|
||||||
|
# via fastapi
|
||||||
|
typing-extensions==4.16.0
|
||||||
|
# via
|
||||||
|
# alembic
|
||||||
|
# anyio
|
||||||
|
# beautifulsoup4
|
||||||
|
# fastapi
|
||||||
|
# pydantic
|
||||||
|
# pydantic-core
|
||||||
|
# sqlalchemy
|
||||||
|
# typing-inspection
|
||||||
|
typing-inspection==0.4.4
|
||||||
|
# via
|
||||||
|
# pydantic
|
||||||
|
# pydantic-settings
|
||||||
|
urllib3==2.7.0
|
||||||
|
# via botocore
|
||||||
|
uvicorn[standard]==0.35.0
|
||||||
|
# via -r requirements.txt
|
||||||
|
uvloop==0.22.1
|
||||||
|
# via uvicorn
|
||||||
|
watchfiles==1.2.0
|
||||||
|
# via uvicorn
|
||||||
|
websockets==17.1
|
||||||
|
# via uvicorn
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
pytest==8.4.2
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
#
|
||||||
|
# This file is autogenerated by pip-compile with Python 3.14
|
||||||
|
# by the following command:
|
||||||
|
#
|
||||||
|
# pip-compile --output-file=requirements-lock.txt requirements.txt
|
||||||
|
#
|
||||||
|
alembic==1.16.5
|
||||||
|
# via -r requirements.txt
|
||||||
|
annotated-types==0.8.0
|
||||||
|
# via pydantic
|
||||||
|
anyio==4.15.1
|
||||||
|
# via
|
||||||
|
# httpx
|
||||||
|
# starlette
|
||||||
|
# watchfiles
|
||||||
|
beautifulsoup4==4.15.0
|
||||||
|
# via -r requirements.txt
|
||||||
|
boto3==1.40.35
|
||||||
|
# via -r requirements.txt
|
||||||
|
botocore==1.40.76
|
||||||
|
# via
|
||||||
|
# boto3
|
||||||
|
# s3transfer
|
||||||
|
certifi==2026.7.22
|
||||||
|
# via
|
||||||
|
# httpcore
|
||||||
|
# httpx
|
||||||
|
click==8.5.0
|
||||||
|
# via uvicorn
|
||||||
|
fastapi==0.116.1
|
||||||
|
# via -r requirements.txt
|
||||||
|
h11==0.16.0
|
||||||
|
# via
|
||||||
|
# httpcore
|
||||||
|
# uvicorn
|
||||||
|
httpcore==1.0.9
|
||||||
|
# via httpx
|
||||||
|
httptools==0.8.0
|
||||||
|
# via uvicorn
|
||||||
|
httpx==0.28.1
|
||||||
|
# via -r requirements.txt
|
||||||
|
idna==3.19
|
||||||
|
# via
|
||||||
|
# anyio
|
||||||
|
# httpx
|
||||||
|
jmespath==1.1.0
|
||||||
|
# via
|
||||||
|
# boto3
|
||||||
|
# botocore
|
||||||
|
mako==1.4.1
|
||||||
|
# via alembic
|
||||||
|
markupsafe==3.0.3
|
||||||
|
# via mako
|
||||||
|
pillow==11.3.0
|
||||||
|
# via -r requirements.txt
|
||||||
|
psycopg[binary]==3.2.10
|
||||||
|
# via -r requirements.txt
|
||||||
|
psycopg-binary==3.2.10
|
||||||
|
# via psycopg
|
||||||
|
pydantic==2.13.5
|
||||||
|
# via
|
||||||
|
# fastapi
|
||||||
|
# pydantic-settings
|
||||||
|
pydantic-core==2.46.5
|
||||||
|
# via pydantic
|
||||||
|
pydantic-settings==2.10.1
|
||||||
|
# via -r requirements.txt
|
||||||
|
python-dateutil==2.9.0.post0
|
||||||
|
# via botocore
|
||||||
|
python-dotenv==1.2.3
|
||||||
|
# via
|
||||||
|
# pydantic-settings
|
||||||
|
# uvicorn
|
||||||
|
python-multipart==0.0.20
|
||||||
|
# via -r requirements.txt
|
||||||
|
pyyaml==6.0.3
|
||||||
|
# via uvicorn
|
||||||
|
s3transfer==0.14.0
|
||||||
|
# via boto3
|
||||||
|
six==1.17.0
|
||||||
|
# via python-dateutil
|
||||||
|
soupsieve==2.9.2
|
||||||
|
# via beautifulsoup4
|
||||||
|
sqlalchemy==2.0.43
|
||||||
|
# via
|
||||||
|
# -r requirements.txt
|
||||||
|
# alembic
|
||||||
|
starlette==0.47.3
|
||||||
|
# via fastapi
|
||||||
|
typing-extensions==4.16.0
|
||||||
|
# via
|
||||||
|
# alembic
|
||||||
|
# anyio
|
||||||
|
# beautifulsoup4
|
||||||
|
# fastapi
|
||||||
|
# pydantic
|
||||||
|
# pydantic-core
|
||||||
|
# sqlalchemy
|
||||||
|
# typing-inspection
|
||||||
|
typing-inspection==0.4.4
|
||||||
|
# via
|
||||||
|
# pydantic
|
||||||
|
# pydantic-settings
|
||||||
|
urllib3==2.7.0
|
||||||
|
# via botocore
|
||||||
|
uvicorn[standard]==0.35.0
|
||||||
|
# via -r requirements.txt
|
||||||
|
uvloop==0.22.1
|
||||||
|
# via uvicorn
|
||||||
|
watchfiles==1.2.0
|
||||||
|
# via uvicorn
|
||||||
|
websockets==17.1
|
||||||
|
# via uvicorn
|
||||||
@@ -7,6 +7,5 @@ psycopg[binary]==3.2.10
|
|||||||
pydantic-settings==2.10.1
|
pydantic-settings==2.10.1
|
||||||
pillow==11.3.0
|
pillow==11.3.0
|
||||||
python-multipart==0.0.20
|
python-multipart==0.0.20
|
||||||
pytest==8.4.2
|
|
||||||
sqlalchemy==2.0.43
|
sqlalchemy==2.0.43
|
||||||
uvicorn[standard]==0.35.0
|
uvicorn[standard]==0.35.0
|
||||||
|
|||||||
@@ -129,3 +129,55 @@ def test_reports_without_coordinates_do_not_create_activity_group(db: Session) -
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
assert activity_rows(db, hours=72, now=NOW) == []
|
assert activity_rows(db, hours=72, now=NOW) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_confidence_capped_at_50_with_single_player() -> None:
|
||||||
|
"""D06: One player cannot artificially inflate confidence above 50%."""
|
||||||
|
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
with Session(engine) as session:
|
||||||
|
waterbody = Waterbody(slug="test-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)
|
||||||
|
session.add_all([waterbody, fish, spot])
|
||||||
|
session.flush()
|
||||||
|
# 10 reports from 1 player, all max confidence
|
||||||
|
for _ in range(10):
|
||||||
|
report = CatchReport(
|
||||||
|
fish=fish, spot=spot, waterbody=waterbody, bait=None,
|
||||||
|
weight_g=5_000, fishing_method="spinning",
|
||||||
|
caught_at=NOW - timedelta(hours=1), reported_at=NOW - timedelta(hours=1),
|
||||||
|
player_name="Single Player", source_type=SourceType.user,
|
||||||
|
source_confidence=100, moderation_status=ModerationStatus.approved,
|
||||||
|
)
|
||||||
|
session.add(report)
|
||||||
|
session.commit()
|
||||||
|
row = activity_rows(session, hours=24, now=NOW)[0]
|
||||||
|
assert row.unique_players == 1
|
||||||
|
assert row.confidence_score <= 50, f"Expected max 50 with 1 player, got {row.confidence_score}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_confidence_capped_at_65_with_two_players() -> None:
|
||||||
|
"""D06: Two players cannot get confidence above 65%."""
|
||||||
|
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
with Session(engine) as session:
|
||||||
|
waterbody = Waterbody(slug="test-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)
|
||||||
|
session.add_all([waterbody, fish, spot])
|
||||||
|
session.flush()
|
||||||
|
# 10 reports from 2 players, all max confidence
|
||||||
|
for i in range(10):
|
||||||
|
report = CatchReport(
|
||||||
|
fish=fish, spot=spot, waterbody=waterbody, bait=None,
|
||||||
|
weight_g=5_000, fishing_method="spinning",
|
||||||
|
caught_at=NOW - timedelta(hours=1), reported_at=NOW - timedelta(hours=1),
|
||||||
|
player_name=f"Player {i % 2}", source_type=SourceType.user,
|
||||||
|
source_confidence=100, moderation_status=ModerationStatus.approved,
|
||||||
|
)
|
||||||
|
session.add(report)
|
||||||
|
session.commit()
|
||||||
|
row = activity_rows(session, hours=24, now=NOW)[0]
|
||||||
|
assert row.unique_players == 2
|
||||||
|
assert row.confidence_score <= 65, f"Expected max 65 with 2 players, got {row.confidence_score}"
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -4,7 +4,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import create_engine, select
|
from sqlalchemy import create_engine, delete, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy.pool import StaticPool
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
@@ -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, 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)
|
||||||
@@ -45,11 +46,26 @@ def test_activity_filters_and_explains_score() -> None:
|
|||||||
response = client.get("/api/v1/activity?waterbody=test-lake&fish=pike&hours=24")
|
response = client.get("/api/v1/activity?waterbody=test-lake&fish=pike&hours=24")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
payload = response.json()
|
payload = response.json()
|
||||||
assert len(payload) == 1
|
assert "items" in payload
|
||||||
assert payload[0]["catches"] == 3
|
assert payload["total"] == 1
|
||||||
assert payload[0]["unique_players"] == 3
|
assert payload["limit"] == 20
|
||||||
assert "3 свежих улова" in payload[0]["explanation"]
|
assert payload["offset"] == 0
|
||||||
assert payload[0]["sources"] == ["manual-import"]
|
assert len(payload["items"]) == 1
|
||||||
|
assert payload["items"][0]["catches"] == 3
|
||||||
|
assert payload["items"][0]["unique_players"] == 3
|
||||||
|
assert "3 свежих улова" in payload["items"][0]["explanation"]
|
||||||
|
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:
|
||||||
@@ -57,6 +73,78 @@ def test_invalid_period_is_rejected() -> None:
|
|||||||
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:
|
||||||
|
with Session(engine) as db:
|
||||||
|
stage_observations(db, [{
|
||||||
|
"source_system": "rf4map", "source_external_id": f"queue-{i}",
|
||||||
|
"source_url": f"https://rf4map.ru/points/queue-{i}",
|
||||||
|
"fish": "Queue fish", "waterbody": "Queue water",
|
||||||
|
} for i in range(3)])
|
||||||
|
rows = list(db.scalars(select(ExternalObservation).where(ExternalObservation.source_system == "rf4map")))
|
||||||
|
for i, row in enumerate(rows):
|
||||||
|
row.status = "published" if i == 0 else "ready"
|
||||||
|
row.last_seen_at = datetime.now(timezone.utc) - timedelta(minutes=i)
|
||||||
|
db.commit()
|
||||||
|
try:
|
||||||
|
headers = {"Authorization": "Bearer change-me-in-production"}
|
||||||
|
url = "/api/v1/admin/external-observations?status=review&source_system=rf4map&limit=1"
|
||||||
|
first = client.get(url, headers=headers).json()
|
||||||
|
second = client.get(url + "&offset=1", headers=headers).json()
|
||||||
|
assert len(first) == len(second) == 1
|
||||||
|
assert first[0]["status"] == second[0]["status"] == "ready"
|
||||||
|
assert first[0]["id"] != second[0]["id"]
|
||||||
|
finally:
|
||||||
|
for row in rows:
|
||||||
|
db.delete(row)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_timeline_includes_more_than_catch_page_and_sitemap_includes_old_spots() -> None:
|
||||||
|
with Session(engine) as db:
|
||||||
|
fish = db.scalar(select(Fish).where(Fish.slug == "pike"))
|
||||||
|
water = db.scalar(select(Waterbody).where(Waterbody.slug == "test-lake"))
|
||||||
|
spot = Spot(waterbody=water, x=901, y=902)
|
||||||
|
db.add(spot)
|
||||||
|
db.flush()
|
||||||
|
spot_id = spot.id
|
||||||
|
reports = [CatchReport(fish=fish, waterbody=water, spot=spot, weight_g=1000,
|
||||||
|
reported_at=datetime.now(timezone.utc) - timedelta(hours=1 if i < 60 else 100),
|
||||||
|
source_type=SourceType.manual_import, source_confidence=70,
|
||||||
|
moderation_status=ModerationStatus.approved) for i in range(61)]
|
||||||
|
db.add_all(reports)
|
||||||
|
db.commit()
|
||||||
|
try:
|
||||||
|
result = client.get(f"/api/v1/spots/{spot_id}/timeline")
|
||||||
|
assert result.status_code == 200
|
||||||
|
assert sum(row["count"] for row in result.json()) == 60
|
||||||
|
assert len(client.get(f"/api/v1/spots/{spot_id}/catches").json()) == 50
|
||||||
|
for report in reports:
|
||||||
|
report.reported_at = datetime.now(timezone.utc) - timedelta(days=10)
|
||||||
|
db.commit()
|
||||||
|
assert "/spots/test-lake-901x902" in client.get("/api/v1/public-spot-pages").json()
|
||||||
|
finally:
|
||||||
|
for report in reports:
|
||||||
|
db.delete(report)
|
||||||
|
db.flush()
|
||||||
|
db.delete(spot)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
def test_list_pagination_and_filter_validation() -> None:
|
def test_list_pagination_and_filter_validation() -> None:
|
||||||
assert client.get("/api/v1/fishes?limit=0").status_code == 422
|
assert client.get("/api/v1/fishes?limit=0").status_code == 422
|
||||||
assert client.get("/api/v1/fishes?limit=1&offset=0").status_code == 200
|
assert client.get("/api/v1/fishes?limit=1&offset=0").status_code == 200
|
||||||
@@ -64,6 +152,7 @@ def test_list_pagination_and_filter_validation() -> None:
|
|||||||
assert client.get("/api/v1/admin/external-observations?status=unknown", headers=headers).status_code == 422
|
assert client.get("/api/v1/admin/external-observations?status=unknown", headers=headers).status_code == 422
|
||||||
assert client.get("/api/v1/admin/catch-reports?offset=-1", headers=headers).status_code == 422
|
assert client.get("/api/v1/admin/catch-reports?offset=-1", headers=headers).status_code == 422
|
||||||
assert client.get("/api/v1/admin/catch-reports?limit=101", headers=headers).status_code == 422
|
assert client.get("/api/v1/admin/catch-reports?limit=101", headers=headers).status_code == 422
|
||||||
|
assert client.get("/api/v1/admin/external-observations/00000000-0000-0000-0000-000000000000/alias-suggestions").status_code == 401
|
||||||
|
|
||||||
|
|
||||||
def test_public_source_status_hides_internal_details() -> None:
|
def test_public_source_status_hides_internal_details() -> None:
|
||||||
@@ -77,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"}
|
||||||
@@ -85,8 +222,32 @@ def test_liveness_does_not_probe_dependencies() -> None:
|
|||||||
assert response.headers["Cross-Origin-Opener-Policy"] == "same-origin"
|
assert response.headers["Cross-Origin-Opener-Policy"] == "same-origin"
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_diagnostics_exposes_build_identity_only_to_admin() -> None:
|
||||||
|
assert client.get("/api/v1/admin/diagnostics").status_code == 401
|
||||||
|
response = client.get("/api/v1/admin/diagnostics", headers={"Authorization": "Bearer change-me-in-production"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["build"] == {"version": "0.1.0", "revision": "dev", "environment": "development"}
|
||||||
|
assert set(payload) == {"generated_at", "build", "counts"}
|
||||||
|
assert response.headers["Content-Disposition"] == "attachment; filename=rf4spotter-diagnostics.json"
|
||||||
|
serialized = response.text.lower()
|
||||||
|
for forbidden in ("player_name", "source_url", "error_summary", "raw_payload", "admin_token", "s3_"):
|
||||||
|
assert forbidden not in serialized
|
||||||
|
assert client.get("/api/v1/admin/moderation-history").status_code == 401
|
||||||
|
history = client.get("/api/v1/admin/moderation-history", headers={"Authorization": "Bearer change-me-in-production"})
|
||||||
|
assert history.status_code == 200
|
||||||
|
for forbidden in ("player_name", "source_url", "raw_payload", "screenshot"):
|
||||||
|
assert forbidden not in history.text.lower()
|
||||||
|
export = client.get("/api/v1/admin/moderation-history-export", headers={"Authorization": "Bearer change-me-in-production"})
|
||||||
|
assert export.status_code == 200
|
||||||
|
assert export.headers["Content-Disposition"] == "attachment; filename=rf4spotter-moderation-history.json"
|
||||||
|
assert set(export.json()) == {"generated_at", "count", "events"}
|
||||||
|
for forbidden in ("entity_id", "moderator", "reason", "player_name", "source_url", "raw_payload"):
|
||||||
|
assert forbidden not in export.text.lower()
|
||||||
|
|
||||||
|
|
||||||
def test_spot_detail_and_catches() -> None:
|
def test_spot_detail_and_catches() -> None:
|
||||||
spot_id = client.get("/api/v1/activity").json()[0]["spot_id"]
|
spot_id = client.get("/api/v1/activity").json()["items"][0]["spot_id"]
|
||||||
detail = client.get(f"/api/v1/spots/{spot_id}")
|
detail = client.get(f"/api/v1/spots/{spot_id}")
|
||||||
catches = client.get(f"/api/v1/spots/{spot_id}/catches")
|
catches = client.get(f"/api/v1/spots/{spot_id}/catches")
|
||||||
assert detail.status_code == 200
|
assert detail.status_code == 200
|
||||||
@@ -102,7 +263,12 @@ def test_spot_detail_and_catches() -> None:
|
|||||||
def test_records_list_is_empty_before_import() -> None:
|
def test_records_list_is_empty_before_import() -> None:
|
||||||
response = client.get("/api/v1/records")
|
response = client.get("/api/v1/records")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == []
|
payload = response.json()
|
||||||
|
assert "items" in payload
|
||||||
|
assert payload["total"] == 0
|
||||||
|
assert payload["limit"] == 50
|
||||||
|
assert payload["offset"] == 0
|
||||||
|
assert payload["items"] == []
|
||||||
|
|
||||||
|
|
||||||
def test_record_category_filter_is_applied_before_pagination() -> None:
|
def test_record_category_filter_is_applied_before_pagination() -> None:
|
||||||
@@ -115,27 +281,96 @@ def test_record_category_filter_is_applied_before_pagination() -> None:
|
|||||||
CatchReport(fish=fish, waterbody=waterbody, weight_g=8000, caught_at=now - timedelta(days=1), reported_at=now, source_type=SourceType.official_record, source_confidence=100, moderation_status=ModerationStatus.approved, raw_payload={"category": "wanted"}),
|
CatchReport(fish=fish, waterbody=waterbody, weight_g=8000, caught_at=now - timedelta(days=1), reported_at=now, source_type=SourceType.official_record, source_confidence=100, moderation_status=ModerationStatus.approved, raw_payload={"category": "wanted"}),
|
||||||
])
|
])
|
||||||
db.commit()
|
db.commit()
|
||||||
response = client.get("/api/v1/records?category=wanted&limit=1")
|
try:
|
||||||
assert response.status_code == 200
|
response = client.get("/api/v1/records?category=wanted&limit=1")
|
||||||
assert len(response.json()) == 1
|
assert response.status_code == 200
|
||||||
assert response.json()[0]["category"] == "wanted"
|
payload = response.json()
|
||||||
|
assert payload["total"] == 1 # only "wanted" matches
|
||||||
|
assert payload["limit"] == 1
|
||||||
|
assert payload["offset"] == 0
|
||||||
|
assert len(payload["items"]) == 1
|
||||||
|
assert payload["items"][0]["category"] == "wanted"
|
||||||
|
finally:
|
||||||
|
# Cleanup added records
|
||||||
|
db.execute(delete(CatchReport).where(
|
||||||
|
CatchReport.source_type == SourceType.official_record,
|
||||||
|
CatchReport.raw_payload["category"].as_string().in_(["other", "wanted"]),
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_records_pagination_returns_correct_total_and_offset() -> None:
|
||||||
|
with Session(engine) as db:
|
||||||
|
fish = db.scalar(select(Fish).where(Fish.slug == "pike"))
|
||||||
|
waterbody = db.scalar(select(Waterbody).where(Waterbody.slug == "test-lake"))
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
# Add exactly 5 official records with unique weights
|
||||||
|
for index in range(5):
|
||||||
|
db.add(CatchReport(fish=fish, waterbody=waterbody, weight_g=70000 + index * 100, caught_at=now - timedelta(days=index), reported_at=now, source_type=SourceType.official_record, source_confidence=100, moderation_status=ModerationStatus.approved))
|
||||||
|
db.commit()
|
||||||
|
try:
|
||||||
|
# Page 1: limit=2, offset=0
|
||||||
|
response1 = client.get("/api/v1/records?limit=2&offset=0")
|
||||||
|
assert response1.status_code == 200
|
||||||
|
p1 = response1.json()
|
||||||
|
assert p1["total"] >= 5
|
||||||
|
assert p1["limit"] == 2
|
||||||
|
assert p1["offset"] == 0
|
||||||
|
assert len(p1["items"]) == 2
|
||||||
|
# Verify first item has our newest caught_at (index=0, weight=70000)
|
||||||
|
assert p1["items"][0]["weight_g"] == 70000
|
||||||
|
# Page 2: limit=2, offset=2
|
||||||
|
response2 = client.get("/api/v1/records?limit=2&offset=2")
|
||||||
|
assert response2.status_code == 200
|
||||||
|
p2 = response2.json()
|
||||||
|
assert p2["total"] == p1["total"] # total must be consistent
|
||||||
|
assert p2["limit"] == 2
|
||||||
|
assert p2["offset"] == 2
|
||||||
|
assert len(p2["items"]) == 2
|
||||||
|
# Page 3: limit=2, offset=4
|
||||||
|
response3 = client.get("/api/v1/records?limit=2&offset=4")
|
||||||
|
assert response3.status_code == 200
|
||||||
|
p3 = response3.json()
|
||||||
|
assert p3["total"] == p1["total"]
|
||||||
|
assert p3["offset"] == 4
|
||||||
|
# Last page should have remaining items
|
||||||
|
assert len(p3["items"]) <= 2
|
||||||
|
# Page 4: offset=total — past total, empty
|
||||||
|
response4 = client.get(f"/api/v1/records?limit=2&offset={p1['total']}")
|
||||||
|
assert response4.status_code == 200
|
||||||
|
p4 = response4.json()
|
||||||
|
assert p4["total"] == p1["total"]
|
||||||
|
assert p4["items"] == []
|
||||||
|
finally:
|
||||||
|
# Cleanup added records
|
||||||
|
db.execute(delete(CatchReport).where(
|
||||||
|
CatchReport.source_type == SourceType.official_record,
|
||||||
|
CatchReport.weight_g >= 70000,
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
assert pending.headers["Cache-Control"] == "no-store"
|
assert pending.headers["Cache-Control"] == "no-store"
|
||||||
assert any(item["id"] == report_id for item in pending.json())
|
assert any(item["id"] == report_id for item in pending.json())
|
||||||
approved = client.patch(f"/api/v1/admin/catch-reports/{report_id}", headers=headers, json={"status": "approved", "reason": "fixture verified"})
|
approved = client.patch(f"/api/v1/admin/catch-reports/{report_id}", headers=headers, json={"status": "approved", "reason": "fixture verified", "expected_version": 0})
|
||||||
assert approved.status_code == 200
|
assert approved.status_code == 200
|
||||||
|
stale = client.patch(f"/api/v1/admin/catch-reports/{report_id}", headers=headers, json={"status": "rejected", "reason": "stale tab", "expected_version": 0})
|
||||||
|
assert stale.status_code == 409
|
||||||
|
assert "reload" in stale.json()["detail"]
|
||||||
activity = client.get("/api/v1/activity?waterbody=test-lake&fish=pike&hours=24").json()
|
activity = client.get("/api/v1/activity?waterbody=test-lake&fish=pike&hours=24").json()
|
||||||
assert any(item["x"] == 77 and item["catches"] == 1 for item in activity)
|
assert any(item["x"] == 77 and item["catches"] == 1 for item in activity["items"])
|
||||||
|
|
||||||
|
|
||||||
def test_admin_requires_token() -> None:
|
def test_admin_requires_token() -> None:
|
||||||
@@ -158,19 +393,19 @@ def test_external_observation_requires_mapping_and_complete_data_before_publicat
|
|||||||
ExternalObservation.source_external_id == "review-complete"
|
ExternalObservation.source_external_id == "review-complete"
|
||||||
))
|
))
|
||||||
headers = {"Authorization": "Bearer change-me-in-production"}
|
headers = {"Authorization": "Bearer change-me-in-production"}
|
||||||
premature = client.post(f"/api/v1/admin/external-observations/{observation_id}/publish", headers=headers)
|
premature = client.post(f"/api/v1/admin/external-observations/{observation_id}/publish", headers=headers, json={"expected_version": 0})
|
||||||
assert premature.status_code == 409
|
assert premature.status_code == 409
|
||||||
mapped = client.patch(
|
mapped = client.patch(
|
||||||
f"/api/v1/admin/external-observations/{observation_id}/mapping", headers=headers,
|
f"/api/v1/admin/external-observations/{observation_id}/mapping", headers=headers,
|
||||||
json={"fish_slug": "pike", "waterbody_slug": "test-lake", "note": "verified fixture"},
|
json={"fish_slug": "pike", "waterbody_slug": "test-lake", "note": "verified fixture", "expected_version": 0},
|
||||||
)
|
)
|
||||||
assert mapped.status_code == 200
|
assert mapped.status_code == 200
|
||||||
assert mapped.json()["status"] == "ready"
|
assert mapped.json()["status"] == "ready"
|
||||||
published = client.post(f"/api/v1/admin/external-observations/{observation_id}/publish", headers=headers)
|
published = client.post(f"/api/v1/admin/external-observations/{observation_id}/publish", headers=headers, json={"expected_version": 1})
|
||||||
assert published.status_code == 200
|
assert published.status_code == 200
|
||||||
assert published.json()["status"] == "published"
|
assert published.json()["status"] == "published"
|
||||||
repeated = client.post(f"/api/v1/admin/external-observations/{observation_id}/publish", headers=headers)
|
repeated = client.post(f"/api/v1/admin/external-observations/{observation_id}/publish", headers=headers, json={"expected_version": 1})
|
||||||
assert repeated.json()["catch_report_id"] == published.json()["catch_report_id"]
|
assert repeated.status_code == 409
|
||||||
with Session(engine) as db:
|
with Session(engine) as db:
|
||||||
observation = db.get(ExternalObservation, observation_id)
|
observation = db.get(ExternalObservation, observation_id)
|
||||||
report = db.get(CatchReport, observation.catch_report_id)
|
report = db.get(CatchReport, observation.catch_report_id)
|
||||||
@@ -196,17 +431,28 @@ def test_incomplete_external_observation_is_publicly_labelled_but_not_counted()
|
|||||||
assert signal["source_system"] == "rf4db"
|
assert signal["source_system"] == "rf4db"
|
||||||
assert signal["quality"] == "incomplete"
|
assert signal["quality"] == "incomplete"
|
||||||
assert signal["missing_fields"] == ["вес"]
|
assert signal["missing_fields"] == ["вес"]
|
||||||
assert all(item["x"] != 32 or item["y"] != 42 for item in client.get("/api/v1/activity").json())
|
|
||||||
headers = {"Authorization": "Bearer change-me-in-production"}
|
headers = {"Authorization": "Bearer change-me-in-production"}
|
||||||
|
incomplete = client.get("/api/v1/admin/external-observations?status=review&completeness=incomplete&q=Pike", headers=headers)
|
||||||
|
assert any(item["id"] == str(observation_id) for item in incomplete.json())
|
||||||
|
provenance = next(item for item in incomplete.json() if item["id"] == str(observation_id))
|
||||||
|
assert provenance["missing_fields"] == ["weight_g"]
|
||||||
|
assert provenance["first_seen_at"] and provenance["last_seen_at"]
|
||||||
|
assert set(provenance["source_payload"]) <= {"bait", "fishing_method", "rig_type", "retrieve_method", "retrieve_speed", "player_name", "published_at", "region", "category"}
|
||||||
|
complete = client.get("/api/v1/admin/external-observations?status=review&completeness=complete&q=Pike", headers=headers)
|
||||||
|
assert all(item["id"] != str(observation_id) for item in complete.json())
|
||||||
|
prioritized = client.get("/api/v1/admin/external-observations?status=review&order=risk", headers=headers)
|
||||||
|
assert prioritized.status_code == 200
|
||||||
|
assert prioritized.json()[0]["weight_g"] is None
|
||||||
|
assert all(item["x"] != 32 or item["y"] != 42 for item in client.get("/api/v1/activity").json()["items"])
|
||||||
mapped = client.patch(
|
mapped = client.patch(
|
||||||
f"/api/v1/admin/external-observations/{observation_id}/mapping", headers=headers,
|
f"/api/v1/admin/external-observations/{observation_id}/mapping", headers=headers,
|
||||||
json={"fish_slug": "pike", "waterbody_slug": "test-lake"},
|
json={"fish_slug": "pike", "waterbody_slug": "test-lake", "expected_version": 0},
|
||||||
)
|
)
|
||||||
assert mapped.json()["status"] == "mapped"
|
assert mapped.json()["status"] == "mapped"
|
||||||
assert client.post(f"/api/v1/admin/external-observations/{observation_id}/publish", headers=headers).status_code == 409
|
assert client.post(f"/api/v1/admin/external-observations/{observation_id}/publish", headers=headers, json={"expected_version": 1}).status_code == 409
|
||||||
rejected = client.patch(
|
rejected = client.patch(
|
||||||
f"/api/v1/admin/external-observations/{observation_id}/reject", headers=headers,
|
f"/api/v1/admin/external-observations/{observation_id}/reject", headers=headers,
|
||||||
json={"reason": "weight is absent"},
|
json={"reason": "weight is absent", "expected_version": 1},
|
||||||
)
|
)
|
||||||
assert rejected.json()["status"] == "rejected"
|
assert rejected.json()["status"] == "rejected"
|
||||||
assert all(item["id"] != str(observation_id) for item in client.get("/api/v1/community-observations").json())
|
assert all(item["id"] != str(observation_id) for item in client.get("/api/v1/community-observations").json())
|
||||||
@@ -228,7 +474,7 @@ def test_admin_can_start_and_list_official_import(monkeypatch) -> None:
|
|||||||
db.refresh(run)
|
db.refresh(run)
|
||||||
return run
|
return run
|
||||||
|
|
||||||
monkeypatch.setattr("app.main.import_records", fake_import)
|
monkeypatch.setattr("app.routers.admin.import_records", fake_import)
|
||||||
headers = {"Authorization": "Bearer change-me-in-production"}
|
headers = {"Authorization": "Bearer change-me-in-production"}
|
||||||
started = client.post("/api/v1/admin/imports/official-records", headers=headers)
|
started = client.post("/api/v1/admin/imports/official-records", headers=headers)
|
||||||
assert started.status_code == 201
|
assert started.status_code == 201
|
||||||
@@ -239,14 +485,14 @@ def test_admin_can_start_and_list_official_import(monkeypatch) -> None:
|
|||||||
def busy_import(*args, **kwargs):
|
def busy_import(*args, **kwargs):
|
||||||
raise ImportAlreadyRunning("official import is already running")
|
raise ImportAlreadyRunning("official import is already running")
|
||||||
|
|
||||||
monkeypatch.setattr("app.main.import_records", busy_import)
|
monkeypatch.setattr("app.routers.admin.import_records", busy_import)
|
||||||
conflict = client.post("/api/v1/admin/imports/official-records", headers=headers)
|
conflict = client.post("/api/v1/admin/imports/official-records", headers=headers)
|
||||||
assert conflict.status_code == 409
|
assert conflict.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
def test_pending_report_accepts_one_validated_screenshot(monkeypatch) -> None:
|
def test_pending_report_accepts_one_validated_screenshot(monkeypatch) -> None:
|
||||||
created = client.post("/api/v1/catch-reports", json={"fish_slug": "pike", "waterbody_slug": "test-lake", "x": 91, "y": 92, "weight_g": 4200}).json()
|
created = client.post("/api/v1/catch-reports", json={"fish_slug": "pike", "waterbody_slug": "test-lake", "x": 91, "y": 92, "weight_g": 4200}).json()
|
||||||
monkeypatch.setattr("app.main.upload_screenshot", lambda raw, **metadata: "reports/test.jpg" if raw == b"image-bytes" and metadata == {"filename": "catch.jpg", "content_type": "image/jpeg"} else "unexpected")
|
monkeypatch.setattr("app.routers.submissions.upload_screenshot", lambda raw, **metadata: "reports/test.jpg" if raw == b"image-bytes" and metadata == {"filename": "catch.jpg", "content_type": "image/jpeg"} else "unexpected")
|
||||||
upload_url = f"/api/v1/catch-reports/{created['id']}/screenshot"
|
upload_url = f"/api/v1/catch-reports/{created['id']}/screenshot"
|
||||||
assert client.post(upload_url, files={"screenshot": ("catch.jpg", b"image-bytes", "image/jpeg")}).status_code == 401
|
assert client.post(upload_url, files={"screenshot": ("catch.jpg", b"image-bytes", "image/jpeg")}).status_code == 401
|
||||||
assert client.post(upload_url, headers={"X-Upload-Token": "wrong"}, files={"screenshot": ("catch.jpg", b"image-bytes", "image/jpeg")}).status_code == 401
|
assert client.post(upload_url, headers={"X-Upload-Token": "wrong"}, files={"screenshot": ("catch.jpg", b"image-bytes", "image/jpeg")}).status_code == 401
|
||||||
@@ -263,9 +509,9 @@ def test_admin_delete_anonymizes_report_removes_screenshot_and_keeps_audit(monke
|
|||||||
report.screenshot_key = "reports/private.jpg"
|
report.screenshot_key = "reports/private.jpg"
|
||||||
db.commit()
|
db.commit()
|
||||||
deleted_keys: list[str] = []
|
deleted_keys: list[str] = []
|
||||||
monkeypatch.setattr("app.main.delete_screenshot", deleted_keys.append)
|
monkeypatch.setattr("app.routers.admin.delete_screenshot", deleted_keys.append)
|
||||||
headers = {"Authorization": "Bearer change-me-in-production"}
|
headers = {"Authorization": "Bearer change-me-in-production"}
|
||||||
response = client.delete(f"/api/v1/admin/catch-reports/{created['id']}", headers=headers)
|
response = client.delete(f"/api/v1/admin/catch-reports/{created['id']}?expected_version=0", headers=headers)
|
||||||
assert response.status_code == 204
|
assert response.status_code == 204
|
||||||
assert deleted_keys == ["reports/private.jpg"]
|
assert deleted_keys == ["reports/private.jpg"]
|
||||||
with Session(engine) as db:
|
with Session(engine) as db:
|
||||||
@@ -277,4 +523,27 @@ def test_admin_delete_anonymizes_report_removes_screenshot_and_keeps_audit(monke
|
|||||||
event = db.query(ModerationEvent).filter_by(catch_report_id=report.id).order_by(ModerationEvent.created_at.desc()).first()
|
event = db.query(ModerationEvent).filter_by(catch_report_id=report.id).order_by(ModerationEvent.created_at.desc()).first()
|
||||||
assert event is not None
|
assert event is not None
|
||||||
assert event.reason == "user report deleted and anonymized"
|
assert event.reason == "user report deleted and anonymized"
|
||||||
assert client.delete(f"/api/v1/admin/catch-reports/{created['id']}", headers=headers).status_code == 404
|
assert client.delete(f"/api/v1/admin/catch-reports/{created['id']}?expected_version=0", headers=headers).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_catch_report_idempotency_key_prevents_duplicates(monkeypatch) -> None:
|
||||||
|
"""A05: Server-side idempotency — same key within 5 min returns 200 with idempotent=True."""
|
||||||
|
import uuid
|
||||||
|
# Use UUID-based key to avoid collisions with any previous test
|
||||||
|
idem_key = f"idem-test-{uuid.uuid4().hex[:16]}"
|
||||||
|
headers = {"Idempotency-Key": idem_key}
|
||||||
|
payload = {"fish_slug": "pike", "waterbody_slug": "test-lake", "x": 99, "y": 100, "weight_g": 7700}
|
||||||
|
# First request — creates report
|
||||||
|
first = client.post("/api/v1/catch-reports", json=payload, headers=headers)
|
||||||
|
assert first.status_code == 201
|
||||||
|
assert first.json()["idempotent"] is False
|
||||||
|
report_id = first.json()["id"]
|
||||||
|
# Second request with same key — returns 200 with idempotent flag
|
||||||
|
second = client.post("/api/v1/catch-reports", json=payload, headers=headers)
|
||||||
|
assert second.status_code == 200, f"Expected 200, got {second.status_code}. Response: {second.json()}"
|
||||||
|
assert second.json()["idempotent"] is True
|
||||||
|
assert second.json()["id"] == report_id
|
||||||
|
assert second.json()["screenshot_upload_token"] == first.json()["screenshot_upload_token"]
|
||||||
|
changed = dict(payload, weight_g=7800)
|
||||||
|
conflict = client.post("/api/v1/catch-reports", json=changed, headers=headers)
|
||||||
|
assert conflict.status_code == 409
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
]
|
||||||
@@ -7,9 +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.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
|
||||||
|
|
||||||
|
|
||||||
@@ -40,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://")
|
||||||
@@ -67,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")])
|
||||||
|
|
||||||
@@ -102,14 +189,133 @@ 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:
|
||||||
|
fish = Fish(slug="pike", name_ru="Щука")
|
||||||
|
water = Waterbody(slug="test-lake", name_ru="Тестовое озеро")
|
||||||
|
db.add_all([fish, water])
|
||||||
|
db.commit()
|
||||||
|
# D04: auto-publish now works with name match fallback
|
||||||
|
stage_observations(db, [record() | {"weight_g": 5000}])
|
||||||
|
item = db.scalar(select(ExternalObservation))
|
||||||
|
# Item auto-published via name match fallback (D04)
|
||||||
|
assert item.status == "published"
|
||||||
|
report = publish_observation(db, item)
|
||||||
|
report_id = report.id
|
||||||
|
stage_observations(db, [record() | {"weight_g": 6000}])
|
||||||
|
assert item.status == "staged"
|
||||||
|
assert report.moderation_status.value == "pending"
|
||||||
|
assert report.weight_g == 5000
|
||||||
|
assert item.weight_g == 6000
|
||||||
|
assert item.moderation_version == 1
|
||||||
|
assert item.reviewed_at is not None
|
||||||
|
stage_observations(db, [record() | {"weight_g": 6000}])
|
||||||
|
assert item.status == "staged"
|
||||||
|
with pytest.raises(ExternalReviewError):
|
||||||
|
publish_observation(db, item)
|
||||||
|
map_observation(db, item, fish, water)
|
||||||
|
updated = publish_observation(db, item)
|
||||||
|
assert updated.id == report_id
|
||||||
|
assert updated.weight_g == 6000
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_source_withdraws_published_record_until_manual_review(db: Session) -> None:
|
||||||
|
fish = Fish(slug="pike", name_ru="Щука")
|
||||||
|
water = Waterbody(slug="test-lake", name_ru="Тестовое озеро")
|
||||||
|
db.add_all([fish, water])
|
||||||
|
db.commit()
|
||||||
|
seen = datetime(2026, 9, 13, 8, tzinfo=timezone.utc)
|
||||||
|
checked = datetime(2026, 9, 13, 9, tzinfo=timezone.utc)
|
||||||
|
stage_observations(db, [record() | {"weight_g": 5000}], fetched_at=seen)
|
||||||
|
item = db.scalar(select(ExternalObservation))
|
||||||
|
assert item is not None and item.catch_report is not None
|
||||||
|
|
||||||
|
record_source_check(db, item, "missing", checked_at=checked)
|
||||||
|
|
||||||
|
assert item.status == "withdrawn"
|
||||||
|
assert item.source_check_status == "missing"
|
||||||
|
assert item.source_checked_at.replace(tzinfo=timezone.utc) == checked
|
||||||
|
assert item.catch_report.moderation_status.value == "pending"
|
||||||
|
assert item.moderation_version == 1
|
||||||
|
|
||||||
|
stage_observations(db, [record() | {"weight_g": 5000}], fetched_at=checked)
|
||||||
|
assert item.status == "staged"
|
||||||
|
assert item.source_check_status == "available"
|
||||||
|
assert item.catch_report.moderation_status.value == "pending"
|
||||||
|
assert "reappeared" in (item.review_note or "")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status", ["temporary_error", "blocked"])
|
||||||
|
def test_non_authoritative_source_failures_do_not_withdraw(db: Session, status: str) -> None:
|
||||||
|
fish = Fish(slug="pike", name_ru="Щука")
|
||||||
|
water = Waterbody(slug="test-lake", name_ru="Тестовое озеро")
|
||||||
|
db.add_all([fish, water])
|
||||||
|
db.commit()
|
||||||
|
stage_observations(db, [record() | {"weight_g": 5000}])
|
||||||
|
item = db.scalar(select(ExternalObservation))
|
||||||
|
assert item is not None and item.catch_report is not None
|
||||||
|
|
||||||
|
record_source_check(db, item, status) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert item.status == "published"
|
||||||
|
assert item.catch_report.moderation_status.value == "approved"
|
||||||
|
assert item.source_check_status == status
|
||||||
|
|
||||||
|
|
||||||
|
def test_scheduled_failure_only_affects_exact_source_url(db: Session) -> None:
|
||||||
|
stage_observations(db, [
|
||||||
|
record(external_id="matching"),
|
||||||
|
record(external_id="other") | {"source_url": "https://rf4db.com/catches/other"},
|
||||||
|
])
|
||||||
|
|
||||||
|
affected = record_scheduled_source_check(
|
||||||
|
db,
|
||||||
|
source_system="rf4db",
|
||||||
|
source_url="https://rf4db.com/ru/catches/matching",
|
||||||
|
status="missing",
|
||||||
|
)
|
||||||
|
items = {item.source_external_id: item for item in db.scalars(select(ExternalObservation))}
|
||||||
|
|
||||||
|
assert affected == 1
|
||||||
|
assert items["matching"].status == "withdrawn"
|
||||||
|
assert items["other"].status != "withdrawn"
|
||||||
|
assert items["other"].source_check_status == "available"
|
||||||
|
|
||||||
|
record_scheduled_source_check(
|
||||||
|
db,
|
||||||
|
source_system="rf4db",
|
||||||
|
source_url="https://rf4db.com/ru/catches/matching",
|
||||||
|
status="available",
|
||||||
|
)
|
||||||
|
assert items["matching"].source_check_status == "available"
|
||||||
|
assert items["matching"].status == "withdrawn"
|
||||||
|
|
||||||
|
|
||||||
def test_auto_publication_requires_enabled_source(db: Session) -> None:
|
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="Щука")
|
||||||
@@ -129,6 +335,31 @@ def test_auto_publication_requires_enabled_source(db: Session) -> None:
|
|||||||
assert item is not None and item.status == "staged" and item.catch_report is None
|
assert item is not None and item.status == "staged" and item.catch_report is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_confirmed_aliases_are_suggestions_and_cannot_be_retargeted(db: Session) -> None:
|
||||||
|
source = DataSource(key="rf4db", name="RF4DB", base_url="https://rf4db.com", default_confidence=70, enabled=True)
|
||||||
|
pike = Fish(slug="pike", name_ru="Щука")
|
||||||
|
perch = Fish(slug="perch", name_ru="Окунь")
|
||||||
|
waterbody = Waterbody(slug="test-lake", name_ru="Тестовое озеро")
|
||||||
|
db.add_all([source, pike, perch, waterbody])
|
||||||
|
db.commit()
|
||||||
|
stage_observations(db, [record(external_id="first")])
|
||||||
|
first = db.scalar(select(ExternalObservation).where(ExternalObservation.source_external_id == "first"))
|
||||||
|
assert first is not None
|
||||||
|
map_observation(db, first, pike, waterbody)
|
||||||
|
|
||||||
|
stage_observations(db, [record(external_id="second")])
|
||||||
|
second = db.scalar(select(ExternalObservation).where(ExternalObservation.source_external_id == "second"))
|
||||||
|
assert second is not None
|
||||||
|
suggested_fish, suggested_waterbody = suggest_aliases(db, second)
|
||||||
|
assert (suggested_fish.slug, suggested_waterbody.slug) == ("pike", "test-lake")
|
||||||
|
|
||||||
|
with pytest.raises(ExternalReviewError, match="confirmed fish alias"):
|
||||||
|
map_observation(db, second, perch, waterbody)
|
||||||
|
db.rollback()
|
||||||
|
alias = db.scalar(select(ExternalEntityAlias).where(ExternalEntityAlias.entity_type == "fish"))
|
||||||
|
assert alias is not None and alias.fish_id == pike.id
|
||||||
|
|
||||||
|
|
||||||
def test_parser_json_can_be_staged_without_losing_provenance(db: Session) -> None:
|
def test_parser_json_can_be_staged_without_losing_provenance(db: Session) -> None:
|
||||||
parsed = parse_rf4db_catches(FIXTURE.read_text(encoding="utf-8"))
|
parsed = parse_rf4db_catches(FIXTURE.read_text(encoding="utf-8"))
|
||||||
payload = json.loads(json.dumps([asdict(item) for item in parsed], default=str))
|
payload = json.loads(json.dumps([asdict(item) for item in parsed], default=str))
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
from urllib.error import HTTPError
|
||||||
|
|
||||||
from app.community_scheduler import MAX_BACKOFF_SECONDS, configured_sources, retry_delay
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from app.community_scheduler import MAX_BACKOFF_SECONDS, configured_sources, _static_registry, oldest_site_source, retry_delay
|
||||||
from app.config import Settings
|
from app.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:
|
||||||
assert set(configured_sources()) == {"rf4db", "rf4stat-fishing", "rf4stat-post", "rf4map", "rf4posts-spot"}
|
assert set(_static_registry()) == {"rf4db", "rf4stat-fishing", "rf4stat-post", "rf4map", "rf4posts-spot"}
|
||||||
|
|
||||||
|
|
||||||
def test_community_interval_cannot_be_less_than_30_minutes() -> None:
|
def test_community_interval_cannot_be_less_than_30_minutes() -> None:
|
||||||
@@ -19,3 +23,21 @@ def test_failed_runs_back_off_but_success_resets_delay() -> None:
|
|||||||
assert retry_delay(["failed", "failed", "failed"]) == 7200
|
assert retry_delay(["failed", "failed", "failed"]) == 7200
|
||||||
assert retry_delay(["failed"] * 20) == MAX_BACKOFF_SECONDS
|
assert retry_delay(["failed"] * 20) == MAX_BACKOFF_SECONDS
|
||||||
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:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
all_keys = {"rf4db", "rf4stat-fishing", "rf4stat-post", "rf4map", "rf4posts-spot"}
|
||||||
|
assert oldest_site_source("rf4stat-fishing", {}, all_keys) == "rf4stat-fishing"
|
||||||
|
latest = {"rf4stat-fishing": now, "rf4stat-post": now - timedelta(hours=1)}
|
||||||
|
assert oldest_site_source("rf4stat-fishing", latest, all_keys) == "rf4stat-post"
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -47,8 +47,10 @@ def test_parser_and_import_are_idempotent() -> None:
|
|||||||
first = import_records(db, url="fixture://records", region="RU", category="records", html=html)
|
first = import_records(db, url="fixture://records", region="RU", category="records", html=html)
|
||||||
second = import_records(db, url="fixture://records", region="RU", category="records", html=html)
|
second = import_records(db, url="fixture://records", region="RU", category="records", html=html)
|
||||||
assert (first.rows_created, first.rows_updated) == (2, 0)
|
assert (first.rows_created, first.rows_updated) == (2, 0)
|
||||||
assert (second.rows_created, second.rows_updated) == (0, 2)
|
# A12: Second import of identical data creates no events (no fields changed)
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from app.public_cache import PublicResponseCache
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_copies_values_and_invalidates() -> None:
|
||||||
|
cache = PublicResponseCache()
|
||||||
|
original = [{"score": 10}]
|
||||||
|
cache.set(("activity",), original)
|
||||||
|
original[0]["score"] = 99
|
||||||
|
assert cache.get(("activity",), 20) == [{"score": 10}]
|
||||||
|
cache.invalidate()
|
||||||
|
assert cache.get(("activity",), 20) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_bounds_memory_and_rejects_result_started_before_invalidation() -> None:
|
||||||
|
cache = PublicResponseCache(max_entries=2)
|
||||||
|
generation = cache.generation()
|
||||||
|
cache.set((1,), [1])
|
||||||
|
cache.set((2,), [2])
|
||||||
|
cache.set((3,), [3])
|
||||||
|
assert cache.get((1,), 20) is None
|
||||||
|
cache.invalidate()
|
||||||
|
cache.set((4,), [4], generation=generation)
|
||||||
|
assert cache.get((4,), 20) is None
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
@@ -8,8 +9,51 @@ from sqlalchemy import create_engine, select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
from app.main import _check_rate_limit
|
from app.admin_security import verify_admin
|
||||||
from app.models import SubmissionAttempt
|
from app.main import _check_rate_limit, _is_trusted_proxy
|
||||||
|
from app.models import AdminAuthAttempt, SubmissionAttempt
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_config() -> MagicMock:
|
||||||
|
config = MagicMock()
|
||||||
|
config.admin_token = "correct-token"
|
||||||
|
config.admin_auth_attempt_limit = 3
|
||||||
|
config.admin_auth_window_seconds = 600
|
||||||
|
config.rate_limit_secret = "test-secret-for-testing"
|
||||||
|
config.trusted_proxy_cidrs = ["127.0.0.1/32"]
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_auth_failures_are_hashed_and_limited() -> None:
|
||||||
|
engine = create_engine("sqlite://")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
request = MagicMock()
|
||||||
|
request.client.host = "203.0.113.42"
|
||||||
|
request.headers.get.return_value = None
|
||||||
|
with Session(engine) as db:
|
||||||
|
for _ in range(3):
|
||||||
|
with pytest.raises(HTTPException) as denied:
|
||||||
|
verify_admin(request, db, "Bearer wrong", _admin_config())
|
||||||
|
assert denied.value.status_code == 401
|
||||||
|
with pytest.raises(HTTPException) as limited:
|
||||||
|
verify_admin(request, db, "Bearer correct-token", _admin_config())
|
||||||
|
assert limited.value.status_code == 429
|
||||||
|
attempts = list(db.scalars(select(AdminAuthAttempt)))
|
||||||
|
assert len(attempts) == 3
|
||||||
|
assert all(item.client_hash != "203.0.113.42" and len(item.client_hash) == 64 for item in attempts)
|
||||||
|
|
||||||
|
|
||||||
|
def test_successful_admin_auth_clears_previous_failures() -> None:
|
||||||
|
engine = create_engine("sqlite://")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
request = MagicMock()
|
||||||
|
request.client.host = "203.0.113.43"
|
||||||
|
request.headers.get.return_value = None
|
||||||
|
with Session(engine) as db:
|
||||||
|
with pytest.raises(HTTPException):
|
||||||
|
verify_admin(request, db, "Bearer wrong", _admin_config())
|
||||||
|
assert verify_admin(request, db, "Bearer correct-token", _admin_config()) == "admin"
|
||||||
|
assert list(db.scalars(select(AdminAuthAttempt))) == []
|
||||||
|
|
||||||
|
|
||||||
def test_rate_limit_is_persistent_and_does_not_store_raw_client() -> None:
|
def test_rate_limit_is_persistent_and_does_not_store_raw_client() -> None:
|
||||||
@@ -17,11 +61,168 @@ def test_rate_limit_is_persistent_and_does_not_store_raw_client() -> None:
|
|||||||
Base.metadata.create_all(engine)
|
Base.metadata.create_all(engine)
|
||||||
with Session(engine) as db:
|
with Session(engine) as db:
|
||||||
for _ in range(5):
|
for _ in range(5):
|
||||||
_check_rate_limit("203.0.113.42", db)
|
mock_request = MagicMock()
|
||||||
|
mock_request.client.host = "203.0.113.42"
|
||||||
|
mock_request.headers.get.return_value = None
|
||||||
|
_check_rate_limit(mock_request, db)
|
||||||
with pytest.raises(HTTPException) as blocked:
|
with pytest.raises(HTTPException) as blocked:
|
||||||
_check_rate_limit("203.0.113.42", db)
|
mock_request = MagicMock()
|
||||||
|
mock_request.client.host = "203.0.113.42"
|
||||||
|
mock_request.headers.get.return_value = None
|
||||||
|
_check_rate_limit(mock_request, db)
|
||||||
assert blocked.value.status_code == 429
|
assert blocked.value.status_code == 429
|
||||||
attempts = list(db.scalars(select(SubmissionAttempt)))
|
attempts = list(db.scalars(select(SubmissionAttempt)))
|
||||||
assert len(attempts) == 5
|
assert len(attempts) == 5
|
||||||
assert all(item.client_hash != "203.0.113.42" and len(item.client_hash) == 64 for item in attempts)
|
assert all(item.client_hash != "203.0.113.42" and len(item.client_hash) == 64 for item in attempts)
|
||||||
assert all(item.created_at.replace(tzinfo=timezone.utc) <= datetime.now(timezone.utc) for item in attempts)
|
assert all(item.created_at.replace(tzinfo=timezone.utc) <= datetime.now(timezone.utc) for item in attempts)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rate_limit_uses_forwarded_for_header_from_trusted_proxy() -> None:
|
||||||
|
"""X-Forwarded-For should be used when client is trusted proxy."""
|
||||||
|
engine = create_engine("sqlite://")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
with Session(engine) as db:
|
||||||
|
with patch("app.main.settings") as mock_settings:
|
||||||
|
mock_settings.rate_limit_secret = "test-secret-for-testing"
|
||||||
|
mock_settings.trusted_proxy_cidrs = ["127.0.0.1/32"]
|
||||||
|
mock_proxy = MagicMock()
|
||||||
|
mock_proxy.client.host = "127.0.0.1"
|
||||||
|
mock_proxy.headers.get.return_value = "198.51.100.10"
|
||||||
|
for _ in range(5):
|
||||||
|
_check_rate_limit(mock_proxy, db)
|
||||||
|
with pytest.raises(HTTPException) as blocked:
|
||||||
|
mock_other = MagicMock()
|
||||||
|
mock_other.client.host = "127.0.0.1"
|
||||||
|
mock_other.headers.get.return_value = "198.51.100.10"
|
||||||
|
_check_rate_limit(mock_other, db)
|
||||||
|
assert blocked.value.status_code == 429
|
||||||
|
|
||||||
|
|
||||||
|
def test_trusted_proxy_checks_cidrs() -> None:
|
||||||
|
assert _is_trusted_proxy("127.0.0.1", ["127.0.0.1/32"]) is True
|
||||||
|
assert _is_trusted_proxy("10.0.0.1", ["10.0.0.0/8"]) is True
|
||||||
|
assert _is_trusted_proxy("192.168.1.1", ["192.168.1.0/24"]) is True
|
||||||
|
assert _is_trusted_proxy("::1", ["::1/128"]) is True
|
||||||
|
assert _is_trusted_proxy("203.0.113.5", ["127.0.0.1/32"]) is False
|
||||||
|
assert _is_trusted_proxy("invalid", []) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_rate_limit_ignores_forwarded_for_from_untrusted_client() -> None:
|
||||||
|
"""X-Forwarded-For should be ignored when client is not in trusted CIDRs."""
|
||||||
|
engine = create_engine("sqlite://")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
with Session(engine) as db:
|
||||||
|
# Client 203.0.113.42 is NOT trusted by default
|
||||||
|
mock_untrusted = MagicMock()
|
||||||
|
mock_untrusted.client.host = "203.0.113.42"
|
||||||
|
mock_untrusted.headers.get.return_value = "10.0.0.99"
|
||||||
|
for i in range(3):
|
||||||
|
_check_rate_limit(mock_untrusted, db)
|
||||||
|
# Should use real client 203.0.113.42, not forwarded 10.0.0.99
|
||||||
|
# So 3 attempts from 203.0.113.42 should be allowed (limit is 5)
|
||||||
|
mock_different = MagicMock()
|
||||||
|
mock_different.client.host = "203.0.113.42"
|
||||||
|
mock_different.headers.get.return_value = "10.0.0.88"
|
||||||
|
_check_rate_limit(mock_different, db) # Should succeed, not blocked
|
||||||
|
|
||||||
|
|
||||||
|
def test_rate_limit_uses_forwarded_for_from_trusted_proxy() -> None:
|
||||||
|
"""X-Forwarded-For should be used when client IS in trusted CIDRs."""
|
||||||
|
engine = create_engine("sqlite://")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
with Session(engine) as db:
|
||||||
|
# 127.0.0.1 IS trusted by default
|
||||||
|
with patch("app.main.settings") as mock_settings:
|
||||||
|
mock_settings.rate_limit_secret = "test-secret-for-testing"
|
||||||
|
mock_settings.trusted_proxy_cidrs = ["127.0.0.1/32", "::1/128"]
|
||||||
|
mock_proxy = MagicMock()
|
||||||
|
mock_proxy.client.host = "127.0.0.1"
|
||||||
|
mock_proxy.headers.get.return_value = "203.0.113.100"
|
||||||
|
for _ in range(5):
|
||||||
|
_check_rate_limit(mock_proxy, db)
|
||||||
|
# Should use forwarded IP 203.0.113.100, so a different forwarded IP should be allowed
|
||||||
|
mock_other_forwarded = MagicMock()
|
||||||
|
mock_other_forwarded.client.host = "127.0.0.1"
|
||||||
|
mock_other_forwarded.headers.get.return_value = "198.51.100.50"
|
||||||
|
_check_rate_limit(mock_other_forwarded, db) # Should succeed
|
||||||
|
|
||||||
|
def test_rate_limit_independent_limits_for_two_clients_through_proxy() -> None:
|
||||||
|
"""Two clients behind trusted proxy should have independent rate limits."""
|
||||||
|
engine = create_engine("sqlite://")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
with Session(engine) as db:
|
||||||
|
with patch("app.main.settings") as mock_settings:
|
||||||
|
mock_settings.rate_limit_secret = "test-secret-for-testing"
|
||||||
|
# Trusted proxy is the Astro container IP
|
||||||
|
mock_settings.trusted_proxy_cidrs = ["172.17.0.0/16"] # Docker network
|
||||||
|
|
||||||
|
# Client 1: 198.51.100.10
|
||||||
|
mock_client1 = MagicMock()
|
||||||
|
mock_client1.client.host = "172.17.0.3" # Astro container
|
||||||
|
mock_client1.headers.get.return_value = "198.51.100.10"
|
||||||
|
|
||||||
|
# Client 2: 198.51.100.20
|
||||||
|
mock_client2 = MagicMock()
|
||||||
|
mock_client2.client.host = "172.17.0.3" # Same Astro container
|
||||||
|
mock_client2.headers.get.return_value = "198.51.100.20"
|
||||||
|
|
||||||
|
# Client 1 makes 5 requests
|
||||||
|
for _ in range(5):
|
||||||
|
_check_rate_limit(mock_client1, db)
|
||||||
|
|
||||||
|
# Client 1 should be blocked
|
||||||
|
with pytest.raises(HTTPException) as blocked:
|
||||||
|
_check_rate_limit(mock_client1, db)
|
||||||
|
assert blocked.value.status_code == 429
|
||||||
|
|
||||||
|
# Client 2 should still be allowed (independent limit)
|
||||||
|
_check_rate_limit(mock_client2, db) # Should succeed
|
||||||
|
|
||||||
|
|
||||||
|
def test_forged_xff_rejected_on_untrusted_port() -> None:
|
||||||
|
"""XFF should be rejected when connection is not from trusted proxy."""
|
||||||
|
engine = create_engine("sqlite://")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
with Session(engine) as db:
|
||||||
|
with patch("app.main.settings") as mock_settings:
|
||||||
|
mock_settings.rate_limit_secret = "test-secret-for-testing"
|
||||||
|
# Only trust Docker network, NOT direct connections
|
||||||
|
mock_settings.trusted_proxy_cidrs = ["172.17.0.0/16"]
|
||||||
|
|
||||||
|
# Direct connection with forged XFF
|
||||||
|
mock_direct = MagicMock()
|
||||||
|
mock_direct.client.host = "203.0.113.50" # Not in trusted CIDR
|
||||||
|
mock_direct.headers.get.return_value = "10.0.0.1" # Forged XFF
|
||||||
|
|
||||||
|
# Should use real client 203.0.113.50, not forged 10.0.0.1
|
||||||
|
for i in range(3):
|
||||||
|
_check_rate_limit(mock_direct, db)
|
||||||
|
|
||||||
|
# Another request from same real client should count
|
||||||
|
mock_direct2 = MagicMock()
|
||||||
|
mock_direct2.client.host = "203.0.113.50"
|
||||||
|
mock_direct2.headers.get.return_value = "10.0.0.2" # Different forged XFF
|
||||||
|
_check_rate_limit(mock_direct2, db) # Should succeed (4th request from 203.0.113.50)
|
||||||
|
|
||||||
|
|
||||||
|
def test_direct_access_without_xff_header() -> None:
|
||||||
|
"""Direct access without X-Forwarded-For should use real client IP."""
|
||||||
|
engine = create_engine("sqlite://")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
with Session(engine) as db:
|
||||||
|
with patch("app.main.settings") as mock_settings:
|
||||||
|
mock_settings.rate_limit_secret = "test-secret-for-testing"
|
||||||
|
mock_settings.trusted_proxy_cidrs = ["127.0.0.1/32"]
|
||||||
|
|
||||||
|
# Direct connection without XFF
|
||||||
|
mock_direct = MagicMock()
|
||||||
|
mock_direct.client.host = "192.168.1.100"
|
||||||
|
mock_direct.headers.get.return_value = None # No XFF
|
||||||
|
|
||||||
|
# Should use real client 192.168.1.100
|
||||||
|
_check_rate_limit(mock_direct, db)
|
||||||
|
|
||||||
|
attempts = list(db.scalars(select(SubmissionAttempt)))
|
||||||
|
assert len(attempts) == 1
|
||||||
|
# Hash should be of the real IP, not empty
|
||||||
|
assert len(attempts[0].client_hash) == 64
|
||||||
|
|||||||
@@ -6,17 +6,18 @@ from sqlalchemy import create_engine
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
from app.models import ImportStatus, OfficialRecordImport
|
from app.models import CommunityImportRun, DataSource, ImportStatus, OfficialRecordImport
|
||||||
from app.readiness import readiness_report
|
from app.readiness import readiness_report
|
||||||
|
|
||||||
|
|
||||||
class AvailableStorage:
|
class AvailableStorage:
|
||||||
def list_buckets(self) -> dict[str, list[object]]:
|
def head_bucket(self, *, Bucket: str) -> dict[str, object]:
|
||||||
return {"Buckets": []}
|
assert Bucket
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
class UnavailableStorage:
|
class UnavailableStorage:
|
||||||
def list_buckets(self) -> None:
|
def head_bucket(self, *, Bucket: str) -> None:
|
||||||
raise ConnectionError("fixture unavailable")
|
raise ConnectionError("fixture unavailable")
|
||||||
|
|
||||||
|
|
||||||
@@ -28,14 +29,15 @@ def test_optional_import_does_not_block_dependencies() -> None:
|
|||||||
session, AvailableStorage(), import_required=False, import_interval_seconds=3600,
|
session, AvailableStorage(), import_required=False, import_interval_seconds=3600,
|
||||||
)
|
)
|
||||||
assert ready is True
|
assert ready is True
|
||||||
assert components == {
|
assert components["postgresql"]["status"] == "ready"
|
||||||
"postgresql": {"status": "ready"},
|
assert components["minio"]["status"] == "ready"
|
||||||
"minio": {"status": "ready"},
|
assert components["official_import"]["status"] == "optional"
|
||||||
"official_import": {"status": "optional", "last_run_status": None},
|
assert components["official_import"]["last_run_status"] is None
|
||||||
}
|
assert "community_scheduler" in components
|
||||||
|
|
||||||
|
|
||||||
def test_required_import_must_be_recent_and_successful() -> None:
|
def test_required_import_success_shows_ready_status() -> None:
|
||||||
|
"""A01: Successful import is diagnostic, not blocking."""
|
||||||
engine = create_engine("sqlite://")
|
engine = create_engine("sqlite://")
|
||||||
Base.metadata.create_all(engine)
|
Base.metadata.create_all(engine)
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
@@ -52,9 +54,11 @@ def test_required_import_must_be_recent_and_successful() -> None:
|
|||||||
)
|
)
|
||||||
assert ready is True
|
assert ready is True
|
||||||
assert components["official_import"]["status"] == "ready"
|
assert components["official_import"]["status"] == "ready"
|
||||||
|
assert components["official_import"]["blocking"] is False
|
||||||
|
|
||||||
|
|
||||||
def test_unavailable_storage_and_stale_import_fail_readiness() -> None:
|
def test_unavailable_storage_blocks_readiness_but_stale_import_does_not() -> None:
|
||||||
|
"""A01: Infrastructure failures block, stale imports are diagnostic only."""
|
||||||
engine = create_engine("sqlite://")
|
engine = create_engine("sqlite://")
|
||||||
Base.metadata.create_all(engine)
|
Base.metadata.create_all(engine)
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
@@ -72,3 +76,121 @@ def test_unavailable_storage_and_stale_import_fail_readiness() -> None:
|
|||||||
assert ready is False
|
assert ready is False
|
||||||
assert components["minio"]["status"] == "unavailable"
|
assert components["minio"]["status"] == "unavailable"
|
||||||
assert components["official_import"]["status"] == "stale"
|
assert components["official_import"]["status"] == "stale"
|
||||||
|
assert components["official_import"]["blocking"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_community_scheduler_success_shows_ready_status() -> None:
|
||||||
|
"""A01: Successful scheduler is diagnostic, not blocking."""
|
||||||
|
engine = create_engine("sqlite://")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
with Session(engine) as session:
|
||||||
|
session.add(DataSource(key="rf4db", name="RF4DB", base_url="https://rf4db.com", default_confidence=70, enabled=True))
|
||||||
|
session.add(CommunityImportRun(
|
||||||
|
source_system="rf4db",
|
||||||
|
started_at=now - timedelta(minutes=30),
|
||||||
|
status="success",
|
||||||
|
source_url="fixture://rf4db",
|
||||||
|
rows_seen=5, rows_created=5, rows_updated=0, error_summary=None,
|
||||||
|
))
|
||||||
|
session.commit()
|
||||||
|
ready, components = readiness_report(
|
||||||
|
session, AvailableStorage(), import_required=False,
|
||||||
|
import_interval_seconds=3600, community_import_interval_seconds=1800, now=now,
|
||||||
|
)
|
||||||
|
assert ready is True
|
||||||
|
assert components["community_scheduler"]["status"] == "ready"
|
||||||
|
assert "rf4db" in components["community_scheduler"]["sources"]
|
||||||
|
assert components["community_scheduler"]["sources"]["rf4db"]["blocking"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_community_scheduler_stale_does_not_block_readiness() -> None:
|
||||||
|
"""A01: Stale scheduler is diagnostic, never blocks readiness."""
|
||||||
|
engine = create_engine("sqlite://")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
with Session(engine) as session:
|
||||||
|
session.add(DataSource(key="rf4db", name="RF4DB", base_url="https://rf4db.com", default_confidence=70, enabled=True))
|
||||||
|
session.add(CommunityImportRun(
|
||||||
|
source_system="rf4db",
|
||||||
|
started_at=now - timedelta(hours=2),
|
||||||
|
status="success",
|
||||||
|
source_url="fixture://rf4db",
|
||||||
|
rows_seen=5, rows_created=5, rows_updated=0, error_summary=None,
|
||||||
|
))
|
||||||
|
session.commit()
|
||||||
|
ready, components = readiness_report(
|
||||||
|
session, AvailableStorage(), import_required=False,
|
||||||
|
import_interval_seconds=3600, community_import_interval_seconds=1800, now=now,
|
||||||
|
)
|
||||||
|
assert ready is True # A01: stale does NOT block readiness
|
||||||
|
assert components["community_scheduler"]["status"] == "degraded" # Stale source shows as degraded
|
||||||
|
assert components["community_scheduler"]["sources"]["rf4db"]["status"] == "stale"
|
||||||
|
assert components["community_scheduler"]["sources"]["rf4db"]["blocking"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_community_scheduler_failed_does_not_block_readiness() -> None:
|
||||||
|
"""A01: Failed scheduler is diagnostic, never blocks readiness."""
|
||||||
|
engine = create_engine("sqlite://")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
with Session(engine) as session:
|
||||||
|
session.add(DataSource(key="rf4db", name="RF4DB", base_url="https://rf4db.com", default_confidence=70, enabled=True))
|
||||||
|
session.add(CommunityImportRun(
|
||||||
|
source_system="rf4db",
|
||||||
|
started_at=now - timedelta(minutes=30),
|
||||||
|
status="failed",
|
||||||
|
source_url="fixture://rf4db",
|
||||||
|
rows_seen=0, rows_created=0, rows_updated=0,
|
||||||
|
error_summary="ConnectionError",
|
||||||
|
))
|
||||||
|
session.commit()
|
||||||
|
ready, components = readiness_report(
|
||||||
|
session, AvailableStorage(), import_required=False,
|
||||||
|
import_interval_seconds=3600, community_import_interval_seconds=1800, now=now,
|
||||||
|
)
|
||||||
|
assert ready is True # A01: failed does NOT block readiness
|
||||||
|
assert components["community_scheduler"]["status"] == "degraded" # Overall reflects failed source
|
||||||
|
assert components["community_scheduler"]["sources"]["rf4db"]["status"] == "failed"
|
||||||
|
assert components["community_scheduler"]["sources"]["rf4db"]["blocking"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_community_scheduler_tracked_per_source_with_backoff() -> None:
|
||||||
|
"""A01: Per-source health tracking with backoff detection."""
|
||||||
|
engine = create_engine("sqlite://")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
with Session(engine) as session:
|
||||||
|
session.add(DataSource(key="rf4db", name="RF4DB", base_url="https://rf4db.com", default_confidence=70, enabled=True))
|
||||||
|
session.add(DataSource(key="rf4stat-fishing", name="RF4-STAT", base_url="https://rf4-stat.ru", default_confidence=65, enabled=True))
|
||||||
|
# rf4db: healthy
|
||||||
|
session.add(CommunityImportRun(
|
||||||
|
source_system="rf4db",
|
||||||
|
started_at=now - timedelta(minutes=30),
|
||||||
|
status="success",
|
||||||
|
source_url="fixture://rf4db",
|
||||||
|
rows_seen=5, rows_created=5, rows_updated=0, error_summary=None,
|
||||||
|
))
|
||||||
|
# rf4stat-fishing: multiple recent failures → backoff recommended
|
||||||
|
for i in range(6):
|
||||||
|
session.add(CommunityImportRun(
|
||||||
|
source_system="rf4stat-fishing",
|
||||||
|
started_at=now - timedelta(hours=i),
|
||||||
|
status="failed",
|
||||||
|
source_url="fixture://rf4stat",
|
||||||
|
rows_seen=0, rows_created=0, rows_updated=0,
|
||||||
|
error_summary="TimeoutError",
|
||||||
|
))
|
||||||
|
session.commit()
|
||||||
|
ready, components = readiness_report(
|
||||||
|
session, AvailableStorage(), import_required=False,
|
||||||
|
import_interval_seconds=3600, community_import_interval_seconds=1800, now=now,
|
||||||
|
)
|
||||||
|
assert ready is True
|
||||||
|
sources = components["community_scheduler"]["sources"]
|
||||||
|
assert sources["rf4db"]["status"] == "ready"
|
||||||
|
assert sources["rf4db"]["recent_failures_24h"] == 0
|
||||||
|
assert sources["rf4db"]["backoff_recommended"] is False
|
||||||
|
assert sources["rf4stat-fishing"]["status"] == "failed"
|
||||||
|
assert sources["rf4stat-fishing"]["recent_failures_24h"] == 6
|
||||||
|
assert sources["rf4stat-fishing"]["backoff_recommended"] is True
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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,3 +3,6 @@ dist
|
|||||||
test-results
|
test-results
|
||||||
playwright-report
|
playwright-report
|
||||||
.astro
|
.astro
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
lighthouse-report.json
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ WORKDIR /app
|
|||||||
ARG PUBLIC_API_URL=http://localhost:8000
|
ARG PUBLIC_API_URL=http://localhost:8000
|
||||||
ENV PUBLIC_API_URL=$PUBLIC_API_URL
|
ENV PUBLIC_API_URL=$PUBLIC_API_URL
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm install
|
RUN npm ci
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
RUN npm prune --omit=dev --ignore-scripts
|
||||||
|
|
||||||
FROM node:22-alpine
|
FROM node:22-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ import node from "@astrojs/node";
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
site: process.env.PUBLIC_SITE_URL || "https://rf4spotter.ru",
|
site: process.env.PUBLIC_SITE_URL || "https://rf4spotter.ru",
|
||||||
|
trailingSlash: "never",
|
||||||
output: "server",
|
output: "server",
|
||||||
adapter: node({ mode: "standalone" }),
|
adapter: node({ mode: "standalone" }),
|
||||||
server: { host: true, port: 4321 },
|
server: { host: true, port: 4321 },
|
||||||
|
vite: {
|
||||||
|
// Keep executable scripts and compiled component styles in same-origin
|
||||||
|
// assets so production CSP does not need broad inline allowances.
|
||||||
|
build: { assetsInlineLimit: 0 },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,22 +8,23 @@
|
|||||||
"name": "rf4-spotter-web",
|
"name": "rf4-spotter-web",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/check": "^0.9.10",
|
|
||||||
"@astrojs/node": "^11.1.5",
|
"@astrojs/node": "^11.1.5",
|
||||||
"@playwright/test": "^1.55.0",
|
"astro": "^7.2.10"
|
||||||
"astro": "^7.2.10",
|
|
||||||
"typescript": "^6.0.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@astrojs/check": "^0.9.10",
|
||||||
"@axe-core/playwright": "^4.13.0",
|
"@axe-core/playwright": "^4.13.0",
|
||||||
|
"@playwright/test": "^1.55.0",
|
||||||
"@types/node": "^26.4.1",
|
"@types/node": "^26.4.1",
|
||||||
"lighthouse": "^13.4.1"
|
"lighthouse": "^13.4.1",
|
||||||
|
"typescript": "^6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@astrojs/check": {
|
"node_modules/@astrojs/check": {
|
||||||
"version": "0.9.10",
|
"version": "0.9.10",
|
||||||
"resolved": "https://registry.npmjs.org/@astrojs/check/-/check-0.9.10.tgz",
|
"resolved": "https://registry.npmjs.org/@astrojs/check/-/check-0.9.10.tgz",
|
||||||
"integrity": "sha512-zgx/UQMozdjOa3bOxjgeCFdtpE3c9rRX6xHwa+2QXvy8z8Akifu2AtubHyv/zzC2znO8dl8fFWL4K+Ba9kS8HQ==",
|
"integrity": "sha512-zgx/UQMozdjOa3bOxjgeCFdtpE3c9rRX6xHwa+2QXvy8z8Akifu2AtubHyv/zzC2znO8dl8fFWL4K+Ba9kS8HQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/language-server": "^2.16.7",
|
"@astrojs/language-server": "^2.16.7",
|
||||||
@@ -42,6 +43,7 @@
|
|||||||
"version": "2.13.1",
|
"version": "2.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.13.1.tgz",
|
||||||
"integrity": "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg==",
|
"integrity": "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@astrojs/compiler-binding": {
|
"node_modules/@astrojs/compiler-binding": {
|
||||||
@@ -240,6 +242,7 @@
|
|||||||
"version": "2.16.16",
|
"version": "2.16.16",
|
||||||
"resolved": "https://registry.npmjs.org/@astrojs/language-server/-/language-server-2.16.16.tgz",
|
"resolved": "https://registry.npmjs.org/@astrojs/language-server/-/language-server-2.16.16.tgz",
|
||||||
"integrity": "sha512-KuS17AOOqH/M5mHXPmKvtp8L+NNteARC7Xjf395+9R9dtJOBndqdStwU4V+OKzYZpgZ+hliV13knzx/oMtFl7Q==",
|
"integrity": "sha512-KuS17AOOqH/M5mHXPmKvtp8L+NNteARC7Xjf395+9R9dtJOBndqdStwU4V+OKzYZpgZ+hliV13knzx/oMtFl7Q==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/compiler": "^2.13.1",
|
"@astrojs/compiler": "^2.13.1",
|
||||||
@@ -334,6 +337,7 @@
|
|||||||
"version": "0.2.4",
|
"version": "0.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@astrojs/yaml2ts/-/yaml2ts-0.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@astrojs/yaml2ts/-/yaml2ts-0.2.4.tgz",
|
||||||
"integrity": "sha512-8oddpOae35pJsXPQXhTkM0ypfKPskVsh2bCxRtbf7e+/Epw2nReakFYpLKjZMEr75CsoF203PMnCocpfz0s69A==",
|
"integrity": "sha512-8oddpOae35pJsXPQXhTkM0ypfKPskVsh2bCxRtbf7e+/Epw2nReakFYpLKjZMEr75CsoF203PMnCocpfz0s69A==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"yaml": "^2.8.3"
|
"yaml": "^2.8.3"
|
||||||
@@ -595,6 +599,7 @@
|
|||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@emmetio/abbreviation/-/abbreviation-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@emmetio/abbreviation/-/abbreviation-2.3.3.tgz",
|
||||||
"integrity": "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA==",
|
"integrity": "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emmetio/scanner": "^1.0.4"
|
"@emmetio/scanner": "^1.0.4"
|
||||||
@@ -604,6 +609,7 @@
|
|||||||
"version": "2.1.8",
|
"version": "2.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/@emmetio/css-abbreviation/-/css-abbreviation-2.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/@emmetio/css-abbreviation/-/css-abbreviation-2.1.8.tgz",
|
||||||
"integrity": "sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw==",
|
"integrity": "sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emmetio/scanner": "^1.0.4"
|
"@emmetio/scanner": "^1.0.4"
|
||||||
@@ -613,6 +619,7 @@
|
|||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emmetio/css-parser/-/css-parser-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emmetio/css-parser/-/css-parser-0.4.1.tgz",
|
||||||
"integrity": "sha512-2bC6m0MV/voF4CTZiAbG5MWKbq5EBmDPKu9Sb7s7nVcEzNQlrZP6mFFFlIaISM8X6514H9shWMme1fCm8cWAfQ==",
|
"integrity": "sha512-2bC6m0MV/voF4CTZiAbG5MWKbq5EBmDPKu9Sb7s7nVcEzNQlrZP6mFFFlIaISM8X6514H9shWMme1fCm8cWAfQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emmetio/stream-reader": "^2.2.0",
|
"@emmetio/stream-reader": "^2.2.0",
|
||||||
@@ -623,6 +630,7 @@
|
|||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emmetio/html-matcher/-/html-matcher-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emmetio/html-matcher/-/html-matcher-1.3.0.tgz",
|
||||||
"integrity": "sha512-NTbsvppE5eVyBMuyGfVu2CRrLvo7J4YHb6t9sBFLyY03WYhXET37qA4zOYUjBWFCRHO7pS1B9khERtY0f5JXPQ==",
|
"integrity": "sha512-NTbsvppE5eVyBMuyGfVu2CRrLvo7J4YHb6t9sBFLyY03WYhXET37qA4zOYUjBWFCRHO7pS1B9khERtY0f5JXPQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emmetio/scanner": "^1.0.0"
|
"@emmetio/scanner": "^1.0.0"
|
||||||
@@ -632,18 +640,21 @@
|
|||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@emmetio/scanner/-/scanner-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@emmetio/scanner/-/scanner-1.0.4.tgz",
|
||||||
"integrity": "sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA==",
|
"integrity": "sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@emmetio/stream-reader": {
|
"node_modules/@emmetio/stream-reader": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emmetio/stream-reader/-/stream-reader-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emmetio/stream-reader/-/stream-reader-2.2.0.tgz",
|
||||||
"integrity": "sha512-fXVXEyFA5Yv3M3n8sUGT7+fvecGrZP4k6FnWWMSZVQf69kAq0LLpaBQLGcPR30m3zMmKYhECP4k/ZkzvhEW5kw==",
|
"integrity": "sha512-fXVXEyFA5Yv3M3n8sUGT7+fvecGrZP4k6FnWWMSZVQf69kAq0LLpaBQLGcPR30m3zMmKYhECP4k/ZkzvhEW5kw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@emmetio/stream-reader-utils": {
|
"node_modules/@emmetio/stream-reader-utils": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emmetio/stream-reader-utils/-/stream-reader-utils-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emmetio/stream-reader-utils/-/stream-reader-utils-0.1.0.tgz",
|
||||||
"integrity": "sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A==",
|
"integrity": "sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
@@ -1830,6 +1841,7 @@
|
|||||||
"version": "1.62.1",
|
"version": "1.62.1",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
||||||
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
|
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "1.62.1"
|
"playwright": "1.62.1"
|
||||||
@@ -2411,6 +2423,7 @@
|
|||||||
"version": "2.4.28",
|
"version": "2.4.28",
|
||||||
"resolved": "https://registry.npmjs.org/@volar/kit/-/kit-2.4.28.tgz",
|
"resolved": "https://registry.npmjs.org/@volar/kit/-/kit-2.4.28.tgz",
|
||||||
"integrity": "sha512-cKX4vK9dtZvDRaAzeoUdaAJEew6IdxHNCRrdp5Kvcl6zZOqb6jTOfk3kXkIkG3T7oTFXguEMt5+9ptyqYR84Pg==",
|
"integrity": "sha512-cKX4vK9dtZvDRaAzeoUdaAJEew6IdxHNCRrdp5Kvcl6zZOqb6jTOfk3kXkIkG3T7oTFXguEMt5+9ptyqYR84Pg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@volar/language-service": "2.4.28",
|
"@volar/language-service": "2.4.28",
|
||||||
@@ -2427,6 +2440,7 @@
|
|||||||
"version": "2.4.28",
|
"version": "2.4.28",
|
||||||
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz",
|
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz",
|
||||||
"integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==",
|
"integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@volar/source-map": "2.4.28"
|
"@volar/source-map": "2.4.28"
|
||||||
@@ -2436,6 +2450,7 @@
|
|||||||
"version": "2.4.28",
|
"version": "2.4.28",
|
||||||
"resolved": "https://registry.npmjs.org/@volar/language-server/-/language-server-2.4.28.tgz",
|
"resolved": "https://registry.npmjs.org/@volar/language-server/-/language-server-2.4.28.tgz",
|
||||||
"integrity": "sha512-NqcLnE5gERKuS4PUFwlhMxf6vqYo7hXtbMFbViXcbVkbZ905AIVWhnSo0ZNBC2V127H1/2zP7RvVOVnyITFfBw==",
|
"integrity": "sha512-NqcLnE5gERKuS4PUFwlhMxf6vqYo7hXtbMFbViXcbVkbZ905AIVWhnSo0ZNBC2V127H1/2zP7RvVOVnyITFfBw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@volar/language-core": "2.4.28",
|
"@volar/language-core": "2.4.28",
|
||||||
@@ -2453,6 +2468,7 @@
|
|||||||
"version": "2.4.28",
|
"version": "2.4.28",
|
||||||
"resolved": "https://registry.npmjs.org/@volar/language-service/-/language-service-2.4.28.tgz",
|
"resolved": "https://registry.npmjs.org/@volar/language-service/-/language-service-2.4.28.tgz",
|
||||||
"integrity": "sha512-Rh/wYCZJrI5vCwMk9xyw/Z+MsWxlJY1rmMZPsxUoJKfzIRjS/NF1NmnuEcrMbEVGja00aVpCsInJfixQTMdvLw==",
|
"integrity": "sha512-Rh/wYCZJrI5vCwMk9xyw/Z+MsWxlJY1rmMZPsxUoJKfzIRjS/NF1NmnuEcrMbEVGja00aVpCsInJfixQTMdvLw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@volar/language-core": "2.4.28",
|
"@volar/language-core": "2.4.28",
|
||||||
@@ -2465,12 +2481,14 @@
|
|||||||
"version": "2.4.28",
|
"version": "2.4.28",
|
||||||
"resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz",
|
"resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz",
|
||||||
"integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==",
|
"integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@volar/typescript": {
|
"node_modules/@volar/typescript": {
|
||||||
"version": "2.4.28",
|
"version": "2.4.28",
|
||||||
"resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz",
|
"resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz",
|
||||||
"integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==",
|
"integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@volar/language-core": "2.4.28",
|
"@volar/language-core": "2.4.28",
|
||||||
@@ -2482,6 +2500,7 @@
|
|||||||
"version": "2.11.0",
|
"version": "2.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vscode/emmet-helper/-/emmet-helper-2.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vscode/emmet-helper/-/emmet-helper-2.11.0.tgz",
|
||||||
"integrity": "sha512-QLxjQR3imPZPQltfbWRnHU6JecWTF1QSWhx3GAKQpslx7y3Dp6sIIXhKjiUJ/BR9FX8PVthjr9PD6pNwOJfAzw==",
|
"integrity": "sha512-QLxjQR3imPZPQltfbWRnHU6JecWTF1QSWhx3GAKQpslx7y3Dp6sIIXhKjiUJ/BR9FX8PVthjr9PD6pNwOJfAzw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"emmet": "^2.4.3",
|
"emmet": "^2.4.3",
|
||||||
@@ -2495,12 +2514,14 @@
|
|||||||
"version": "0.0.18",
|
"version": "0.0.18",
|
||||||
"resolved": "https://registry.npmjs.org/@vscode/l10n/-/l10n-0.0.18.tgz",
|
"resolved": "https://registry.npmjs.org/@vscode/l10n/-/l10n-0.0.18.tgz",
|
||||||
"integrity": "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==",
|
"integrity": "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "8.20.0",
|
"version": "8.20.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
@@ -2517,6 +2538,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz",
|
||||||
"integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==",
|
"integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"ajv": "^8.5.0"
|
"ajv": "^8.5.0"
|
||||||
@@ -2531,6 +2553,7 @@
|
|||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv-i18n/-/ajv-i18n-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv-i18n/-/ajv-i18n-4.2.0.tgz",
|
||||||
"integrity": "sha512-v/ei2UkCEeuKNXh8RToiFsUclmU+G57LO1Oo22OagNMENIw+Yb8eMwvHu7Vn9fmkjJyv6XclhJ8TbuigSglPkg==",
|
"integrity": "sha512-v/ei2UkCEeuKNXh8RToiFsUclmU+G57LO1Oo22OagNMENIw+Yb8eMwvHu7Vn9fmkjJyv6XclhJ8TbuigSglPkg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"ajv": "^8.0.0-beta.0"
|
"ajv": "^8.0.0-beta.0"
|
||||||
@@ -2562,6 +2585,7 @@
|
|||||||
"version": "6.3.0",
|
"version": "6.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz",
|
||||||
"integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==",
|
"integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -2574,6 +2598,7 @@
|
|||||||
"version": "6.2.3",
|
"version": "6.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||||
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -2792,6 +2817,7 @@
|
|||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"readdirp": "^4.0.1"
|
"readdirp": "^4.0.1"
|
||||||
@@ -2875,6 +2901,7 @@
|
|||||||
"version": "9.0.1",
|
"version": "9.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
|
||||||
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
|
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
|
||||||
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"string-width": "^7.2.0",
|
"string-width": "^7.2.0",
|
||||||
@@ -3268,6 +3295,7 @@
|
|||||||
"version": "2.4.11",
|
"version": "2.4.11",
|
||||||
"resolved": "https://registry.npmjs.org/emmet/-/emmet-2.4.11.tgz",
|
"resolved": "https://registry.npmjs.org/emmet/-/emmet-2.4.11.tgz",
|
||||||
"integrity": "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==",
|
"integrity": "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"./packages/scanner",
|
"./packages/scanner",
|
||||||
@@ -3284,6 +3312,7 @@
|
|||||||
"version": "10.6.0",
|
"version": "10.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
|
||||||
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
|
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/encodeurl": {
|
"node_modules/encodeurl": {
|
||||||
@@ -3395,6 +3424,7 @@
|
|||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@@ -3444,6 +3474,7 @@
|
|||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-string-truncated-width": {
|
"node_modules/fast-string-truncated-width": {
|
||||||
@@ -3465,6 +3496,7 @@
|
|||||||
"version": "3.1.7",
|
"version": "3.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
|
||||||
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
|
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
|
||||||
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -3569,6 +3601,7 @@
|
|||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "6.* || 8.* || >= 10.*"
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
@@ -3578,6 +3611,7 @@
|
|||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
|
||||||
"integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
|
"integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
@@ -3878,18 +3912,21 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/jsonc-parser": {
|
"node_modules/jsonc-parser": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-2.3.1.tgz",
|
||||||
"integrity": "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==",
|
"integrity": "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/kleur": {
|
"node_modules/kleur": {
|
||||||
"version": "4.1.5",
|
"version": "4.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
|
||||||
"integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
|
"integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@@ -4569,6 +4606,7 @@
|
|||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
|
||||||
"integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
|
"integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
@@ -4790,6 +4828,7 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||||
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
|
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/piccolore": {
|
"node_modules/piccolore": {
|
||||||
@@ -4820,6 +4859,7 @@
|
|||||||
"version": "1.62.1",
|
"version": "1.62.1",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||||
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.62.1"
|
"playwright-core": "1.62.1"
|
||||||
@@ -4838,6 +4878,7 @@
|
|||||||
"version": "1.62.1",
|
"version": "1.62.1",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||||
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright-core": "cli.js"
|
"playwright-core": "cli.js"
|
||||||
@@ -4850,6 +4891,7 @@
|
|||||||
"version": "2.3.2",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -4892,6 +4934,7 @@
|
|||||||
"version": "3.9.6",
|
"version": "3.9.6",
|
||||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
|
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
|
||||||
"integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
|
"integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"prettier": "bin/prettier.cjs"
|
"prettier": "bin/prettier.cjs"
|
||||||
@@ -5001,6 +5044,7 @@
|
|||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||||
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 14.18.0"
|
"node": ">= 14.18.0"
|
||||||
@@ -5038,6 +5082,7 @@
|
|||||||
"version": "0.7.0",
|
"version": "0.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/request-light/-/request-light-0.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/request-light/-/request-light-0.7.0.tgz",
|
||||||
"integrity": "sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q==",
|
"integrity": "sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/require-directory": {
|
"node_modules/require-directory": {
|
||||||
@@ -5054,6 +5099,7 @@
|
|||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -5356,6 +5402,7 @@
|
|||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
||||||
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
|
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"emoji-regex": "^10.3.0",
|
"emoji-regex": "^10.3.0",
|
||||||
@@ -5387,6 +5434,7 @@
|
|||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||||
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-regex": "^6.2.2"
|
"ansi-regex": "^6.2.2"
|
||||||
@@ -5564,12 +5612,14 @@
|
|||||||
"version": "0.2.2",
|
"version": "0.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/typesafe-path/-/typesafe-path-0.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/typesafe-path/-/typesafe-path-0.2.2.tgz",
|
||||||
"integrity": "sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA==",
|
"integrity": "sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
@@ -5583,6 +5633,7 @@
|
|||||||
"version": "0.3.6",
|
"version": "0.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/typescript-auto-import-cache/-/typescript-auto-import-cache-0.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/typescript-auto-import-cache/-/typescript-auto-import-cache-0.3.6.tgz",
|
||||||
"integrity": "sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ==",
|
"integrity": "sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"semver": "^7.3.8"
|
"semver": "^7.3.8"
|
||||||
@@ -5972,6 +6023,7 @@
|
|||||||
"version": "0.0.71",
|
"version": "0.0.71",
|
||||||
"resolved": "https://registry.npmjs.org/volar-service-css/-/volar-service-css-0.0.71.tgz",
|
"resolved": "https://registry.npmjs.org/volar-service-css/-/volar-service-css-0.0.71.tgz",
|
||||||
"integrity": "sha512-wRRFt9BpjMKCazcgOh67MSjUjiWUCAh99DyYSDIOTuxaRjEtDC7PpB0k1Y1wbJIW/pVtMUSVbpPo3UGSm0Byxw==",
|
"integrity": "sha512-wRRFt9BpjMKCazcgOh67MSjUjiWUCAh99DyYSDIOTuxaRjEtDC7PpB0k1Y1wbJIW/pVtMUSVbpPo3UGSm0Byxw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vscode-css-languageservice": "^6.3.0",
|
"vscode-css-languageservice": "^6.3.0",
|
||||||
@@ -5991,6 +6043,7 @@
|
|||||||
"version": "0.0.71",
|
"version": "0.0.71",
|
||||||
"resolved": "https://registry.npmjs.org/volar-service-emmet/-/volar-service-emmet-0.0.71.tgz",
|
"resolved": "https://registry.npmjs.org/volar-service-emmet/-/volar-service-emmet-0.0.71.tgz",
|
||||||
"integrity": "sha512-zqjzt6bN95e3CUstBm0PBFAJnrfz0ZAARka87fart46/gNCLLuP3Vujy8V/J8HEziTFLnfkgIASLFYPUhonJcA==",
|
"integrity": "sha512-zqjzt6bN95e3CUstBm0PBFAJnrfz0ZAARka87fart46/gNCLLuP3Vujy8V/J8HEziTFLnfkgIASLFYPUhonJcA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emmetio/css-parser": "^0.4.1",
|
"@emmetio/css-parser": "^0.4.1",
|
||||||
@@ -6011,6 +6064,7 @@
|
|||||||
"version": "0.0.71",
|
"version": "0.0.71",
|
||||||
"resolved": "https://registry.npmjs.org/volar-service-html/-/volar-service-html-0.0.71.tgz",
|
"resolved": "https://registry.npmjs.org/volar-service-html/-/volar-service-html-0.0.71.tgz",
|
||||||
"integrity": "sha512-e8tHPhgQ7ooLfudAEIku+kgd9pWkq3SSz8RbnQDI1+Eb8wbenkLGHqoirLqz5ORLV6wIMr2Iv08RWBG5eOcgpw==",
|
"integrity": "sha512-e8tHPhgQ7ooLfudAEIku+kgd9pWkq3SSz8RbnQDI1+Eb8wbenkLGHqoirLqz5ORLV6wIMr2Iv08RWBG5eOcgpw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vscode-html-languageservice": "^5.3.0",
|
"vscode-html-languageservice": "^5.3.0",
|
||||||
@@ -6030,6 +6084,7 @@
|
|||||||
"version": "0.0.71",
|
"version": "0.0.71",
|
||||||
"resolved": "https://registry.npmjs.org/volar-service-prettier/-/volar-service-prettier-0.0.71.tgz",
|
"resolved": "https://registry.npmjs.org/volar-service-prettier/-/volar-service-prettier-0.0.71.tgz",
|
||||||
"integrity": "sha512-Rz7JVH3qD108UCdmIEiZvOBNljMt2nLFdbN8AXcDfn7xD9F5I2aCIsDVqBbXw21PsnxG0b7MfwtNF+zPS/NKUg==",
|
"integrity": "sha512-Rz7JVH3qD108UCdmIEiZvOBNljMt2nLFdbN8AXcDfn7xD9F5I2aCIsDVqBbXw21PsnxG0b7MfwtNF+zPS/NKUg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vscode-uri": "^3.0.8"
|
"vscode-uri": "^3.0.8"
|
||||||
@@ -6051,6 +6106,7 @@
|
|||||||
"version": "0.0.71",
|
"version": "0.0.71",
|
||||||
"resolved": "https://registry.npmjs.org/volar-service-typescript/-/volar-service-typescript-0.0.71.tgz",
|
"resolved": "https://registry.npmjs.org/volar-service-typescript/-/volar-service-typescript-0.0.71.tgz",
|
||||||
"integrity": "sha512-yTtM/BVT6hoyEYnDtaCyAtNhdNeS/mhTTABlBOdw3NNiRBUin3IznFJpgfjer4c6RYopiPjjQjc9VFhxVl1mLw==",
|
"integrity": "sha512-yTtM/BVT6hoyEYnDtaCyAtNhdNeS/mhTTABlBOdw3NNiRBUin3IznFJpgfjer4c6RYopiPjjQjc9VFhxVl1mLw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"path-browserify": "^1.0.1",
|
"path-browserify": "^1.0.1",
|
||||||
@@ -6073,6 +6129,7 @@
|
|||||||
"version": "0.0.71",
|
"version": "0.0.71",
|
||||||
"resolved": "https://registry.npmjs.org/volar-service-typescript-twoslash-queries/-/volar-service-typescript-twoslash-queries-0.0.71.tgz",
|
"resolved": "https://registry.npmjs.org/volar-service-typescript-twoslash-queries/-/volar-service-typescript-twoslash-queries-0.0.71.tgz",
|
||||||
"integrity": "sha512-9K2k72s4n7rV9s4bX0MyjbX9iBribvKZbBJKuEmTCZfeWJXs6Yh7bGpY4eoc7UufAjvpheBqwyZCOIPBvxCv0A==",
|
"integrity": "sha512-9K2k72s4n7rV9s4bX0MyjbX9iBribvKZbBJKuEmTCZfeWJXs6Yh7bGpY4eoc7UufAjvpheBqwyZCOIPBvxCv0A==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vscode-uri": "^3.0.8"
|
"vscode-uri": "^3.0.8"
|
||||||
@@ -6090,6 +6147,7 @@
|
|||||||
"version": "0.0.71",
|
"version": "0.0.71",
|
||||||
"resolved": "https://registry.npmjs.org/volar-service-yaml/-/volar-service-yaml-0.0.71.tgz",
|
"resolved": "https://registry.npmjs.org/volar-service-yaml/-/volar-service-yaml-0.0.71.tgz",
|
||||||
"integrity": "sha512-qYGWGuVpUTnZGu5P/CR4KLK4aIR8RrcVnmfZ2eRcj9q/I8VZCoC5yy9FtEvfNvnDp4MU17yhdJcvpQPIqhJS2Q==",
|
"integrity": "sha512-qYGWGuVpUTnZGu5P/CR4KLK4aIR8RrcVnmfZ2eRcj9q/I8VZCoC5yy9FtEvfNvnDp4MU17yhdJcvpQPIqhJS2Q==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vscode-uri": "^3.0.8",
|
"vscode-uri": "^3.0.8",
|
||||||
@@ -6108,6 +6166,7 @@
|
|||||||
"version": "6.3.10",
|
"version": "6.3.10",
|
||||||
"resolved": "https://registry.npmjs.org/vscode-css-languageservice/-/vscode-css-languageservice-6.3.10.tgz",
|
"resolved": "https://registry.npmjs.org/vscode-css-languageservice/-/vscode-css-languageservice-6.3.10.tgz",
|
||||||
"integrity": "sha512-eq5N9Er3fC4vA9zd9EFhyBG90wtCCuXgRSpAndaOgXMh1Wgep5lBgRIeDgjZBW9pa+332yC9+49cZMW8jcL3MA==",
|
"integrity": "sha512-eq5N9Er3fC4vA9zd9EFhyBG90wtCCuXgRSpAndaOgXMh1Wgep5lBgRIeDgjZBW9pa+332yC9+49cZMW8jcL3MA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vscode/l10n": "^0.0.18",
|
"@vscode/l10n": "^0.0.18",
|
||||||
@@ -6120,12 +6179,14 @@
|
|||||||
"version": "3.17.5",
|
"version": "3.17.5",
|
||||||
"resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
|
"resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
|
||||||
"integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
|
"integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/vscode-html-languageservice": {
|
"node_modules/vscode-html-languageservice": {
|
||||||
"version": "5.6.2",
|
"version": "5.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/vscode-html-languageservice/-/vscode-html-languageservice-5.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/vscode-html-languageservice/-/vscode-html-languageservice-5.6.2.tgz",
|
||||||
"integrity": "sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg==",
|
"integrity": "sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vscode/l10n": "^0.0.18",
|
"@vscode/l10n": "^0.0.18",
|
||||||
@@ -6138,6 +6199,7 @@
|
|||||||
"version": "4.1.8",
|
"version": "4.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/vscode-json-languageservice/-/vscode-json-languageservice-4.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/vscode-json-languageservice/-/vscode-json-languageservice-4.1.8.tgz",
|
||||||
"integrity": "sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg==",
|
"integrity": "sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"jsonc-parser": "^3.0.0",
|
"jsonc-parser": "^3.0.0",
|
||||||
@@ -6154,12 +6216,14 @@
|
|||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
|
||||||
"integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
|
"integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/vscode-jsonrpc": {
|
"node_modules/vscode-jsonrpc": {
|
||||||
"version": "9.0.2",
|
"version": "9.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.2.tgz",
|
||||||
"integrity": "sha512-SbQSV9yRemARxeXw6LU5sS6Zq0e9/DgCCX5yelH263ZQWukbTk8EF8fjTrr1dziasf4GwlJbvTwFnTrnQFWZXQ==",
|
"integrity": "sha512-SbQSV9yRemARxeXw6LU5sS6Zq0e9/DgCCX5yelH263ZQWukbTk8EF8fjTrr1dziasf4GwlJbvTwFnTrnQFWZXQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
@@ -6169,6 +6233,7 @@
|
|||||||
"version": "9.0.1",
|
"version": "9.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz",
|
||||||
"integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==",
|
"integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vscode-languageserver-protocol": "3.17.5"
|
"vscode-languageserver-protocol": "3.17.5"
|
||||||
@@ -6181,6 +6246,7 @@
|
|||||||
"version": "3.18.3",
|
"version": "3.18.3",
|
||||||
"resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.18.3.tgz",
|
"resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.18.3.tgz",
|
||||||
"integrity": "sha512-DF49+WeV5py4zO5hhobp60jjsDSK0lAqA0OuKBLBvp423HPWQcCbhZz3JgyfIewsEz2f8U+X75xNIFHdiXZm2w==",
|
"integrity": "sha512-DF49+WeV5py4zO5hhobp60jjsDSK0lAqA0OuKBLBvp423HPWQcCbhZz3JgyfIewsEz2f8U+X75xNIFHdiXZm2w==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vscode-jsonrpc": "9.0.2",
|
"vscode-jsonrpc": "9.0.2",
|
||||||
@@ -6191,18 +6257,21 @@
|
|||||||
"version": "1.0.14",
|
"version": "1.0.14",
|
||||||
"resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.14.tgz",
|
"resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.14.tgz",
|
||||||
"integrity": "sha512-EQyqJMi552E4ZTf46izQ4Fj6XquqxCySR3J5ZSD1SisMf6RfpeOWHxGBE8Gr6V0/3GHIGdAzDn8F8+1nTGCnoQ==",
|
"integrity": "sha512-EQyqJMi552E4ZTf46izQ4Fj6XquqxCySR3J5ZSD1SisMf6RfpeOWHxGBE8Gr6V0/3GHIGdAzDn8F8+1nTGCnoQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/vscode-languageserver-types": {
|
"node_modules/vscode-languageserver-types": {
|
||||||
"version": "3.18.3",
|
"version": "3.18.3",
|
||||||
"resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.18.3.tgz",
|
"resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.18.3.tgz",
|
||||||
"integrity": "sha512-XIlzJ7Qp/jzSI1ds7/FwPAWrPeTZA7pAtlW4hdJ1J6xXWJL6dR9QYnDhJOdLzdKhUQ5Mm6mvUMw+3DcOQQasPw==",
|
"integrity": "sha512-XIlzJ7Qp/jzSI1ds7/FwPAWrPeTZA7pAtlW4hdJ1J6xXWJL6dR9QYnDhJOdLzdKhUQ5Mm6mvUMw+3DcOQQasPw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/vscode-languageserver/node_modules/vscode-jsonrpc": {
|
"node_modules/vscode-languageserver/node_modules/vscode-jsonrpc": {
|
||||||
"version": "8.2.0",
|
"version": "8.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
|
||||||
"integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==",
|
"integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
@@ -6212,6 +6281,7 @@
|
|||||||
"version": "3.17.5",
|
"version": "3.17.5",
|
||||||
"resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz",
|
"resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz",
|
||||||
"integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==",
|
"integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vscode-jsonrpc": "8.2.0",
|
"vscode-jsonrpc": "8.2.0",
|
||||||
@@ -6222,18 +6292,21 @@
|
|||||||
"version": "3.17.5",
|
"version": "3.17.5",
|
||||||
"resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
|
"resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
|
||||||
"integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
|
"integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/vscode-nls": {
|
"node_modules/vscode-nls": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/vscode-nls/-/vscode-nls-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/vscode-nls/-/vscode-nls-5.2.0.tgz",
|
||||||
"integrity": "sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==",
|
"integrity": "sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/vscode-uri": {
|
"node_modules/vscode-uri": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.2.0.tgz",
|
||||||
"integrity": "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==",
|
"integrity": "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/web-features": {
|
"node_modules/web-features": {
|
||||||
@@ -6261,6 +6334,7 @@
|
|||||||
"version": "9.0.2",
|
"version": "9.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
|
||||||
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
|
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-styles": "^6.2.1",
|
"ansi-styles": "^6.2.1",
|
||||||
@@ -6319,6 +6393,7 @@
|
|||||||
"version": "5.0.8",
|
"version": "5.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||||
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
@@ -6328,6 +6403,7 @@
|
|||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
||||||
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||||
|
"devOptional": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
"yaml": "bin.mjs"
|
"yaml": "bin.mjs"
|
||||||
@@ -6343,6 +6419,7 @@
|
|||||||
"version": "1.23.0",
|
"version": "1.23.0",
|
||||||
"resolved": "https://registry.npmjs.org/yaml-language-server/-/yaml-language-server-1.23.0.tgz",
|
"resolved": "https://registry.npmjs.org/yaml-language-server/-/yaml-language-server-1.23.0.tgz",
|
||||||
"integrity": "sha512-3qVyCOexLCWw06PQa5kRPwvMWMZ/eZeCRWUvgD6a0OkqL/4iCnxy2WumbWifa937Uo5xhyWJ0uxlU39ljhNh7A==",
|
"integrity": "sha512-3qVyCOexLCWw06PQa5kRPwvMWMZ/eZeCRWUvgD6a0OkqL/4iCnxy2WumbWifa937Uo5xhyWJ0uxlU39ljhNh7A==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vscode/l10n": "^0.0.18",
|
"@vscode/l10n": "^0.0.18",
|
||||||
@@ -6366,12 +6443,14 @@
|
|||||||
"version": "0.5.8",
|
"version": "0.5.8",
|
||||||
"resolved": "https://registry.npmjs.org/request-light/-/request-light-0.5.8.tgz",
|
"resolved": "https://registry.npmjs.org/request-light/-/request-light-0.5.8.tgz",
|
||||||
"integrity": "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg==",
|
"integrity": "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/yaml-language-server/node_modules/yaml": {
|
"node_modules/yaml-language-server/node_modules/yaml": {
|
||||||
"version": "2.8.3",
|
"version": "2.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
||||||
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
||||||
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
"yaml": "bin.mjs"
|
"yaml": "bin.mjs"
|
||||||
@@ -6387,6 +6466,7 @@
|
|||||||
"version": "18.1.0",
|
"version": "18.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz",
|
||||||
"integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==",
|
"integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cliui": "^9.0.1",
|
"cliui": "^9.0.1",
|
||||||
@@ -6413,6 +6493,7 @@
|
|||||||
"version": "8.2.2",
|
"version": "8.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz",
|
||||||
"integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==",
|
"integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"get-east-asian-width": "^1.5.0",
|
"get-east-asian-width": "^1.5.0",
|
||||||
|
|||||||
@@ -15,13 +15,13 @@
|
|||||||
"audit:lighthouse": "lighthouse http://127.0.0.1:4321 --quiet --chrome-flags='--headless --no-sandbox' --only-categories=performance,accessibility --output=json --output-path=./lighthouse-report.json"
|
"audit:lighthouse": "lighthouse http://127.0.0.1:4321 --quiet --chrome-flags='--headless --no-sandbox' --only-categories=performance,accessibility --output=json --output-path=./lighthouse-report.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/check": "^0.9.10",
|
|
||||||
"@astrojs/node": "^11.1.5",
|
"@astrojs/node": "^11.1.5",
|
||||||
"@playwright/test": "^1.55.0",
|
"astro": "^7.2.10"
|
||||||
"astro": "^7.2.10",
|
|
||||||
"typescript": "^6.0.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@astrojs/check": "^0.9.10",
|
||||||
|
"@playwright/test": "^1.55.0",
|
||||||
|
"typescript": "^6.0.0",
|
||||||
"@axe-core/playwright": "^4.13.0",
|
"@axe-core/playwright": "^4.13.0",
|
||||||
"@types/node": "^26.4.1",
|
"@types/node": "^26.4.1",
|
||||||
"lighthouse": "^13.4.1"
|
"lighthouse": "^13.4.1"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 368 KiB After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,11 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<rect width="64" height="64" fill="#082226"/>
|
||||||
|
<g stroke="#295158" stroke-width="1" opacity=".7">
|
||||||
|
<path d="M16 8v48M32 8v48M48 8v48M8 16h48M8 32h48M8 48h48"/>
|
||||||
|
</g>
|
||||||
|
<path d="M11 46c8-5 14 5 22 0s14 5 21 0" fill="none" stroke="#6fc3c4" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
<path d="M37 8v21" stroke="#f4efe2" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
<path d="M32 17h10l-2 12h-6Z" fill="#c9f45b"/>
|
||||||
|
<path d="M37 29v13c0 9-13 11-15 2-1-5 3-8 7-7" fill="none" stroke="#c9f45b" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<circle cx="37" cy="13" r="2.5" fill="#ff785a"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 657 B |
|
Before Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 70 KiB |
@@ -6,10 +6,12 @@
|
|||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"scope": "/",
|
"scope": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#f2f5ee",
|
"background_color": "#071719",
|
||||||
"theme_color": "#082226",
|
"theme_color": "#071719",
|
||||||
"icons": [
|
"icons": [
|
||||||
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
|
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
|
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||||
|
{ "src": "/icon-maskable-192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" },
|
||||||
|
{ "src": "/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
---
|
---
|
||||||
import type { Activity } from "../lib/api";
|
import type { Activity } from "../lib/api";
|
||||||
import { activityLevel, ago, kg, plural, spotPath } from "../lib/api";
|
import { activityLevel, ago, kg, spotPath } from "../lib/api";
|
||||||
import FishingIcon from "./FishingIcon.astro";
|
import FishingIcon from "./FishingIcon.astro";
|
||||||
import DataPassport from "./DataPassport.astro";
|
import DataPassport from "./DataPassport.astro";
|
||||||
import FishSilhouette from "./FishSilhouette.astro";
|
import FishSilhouette from "./FishSilhouette.astro";
|
||||||
const { item } = Astro.props as { item: Activity };
|
import TackleGlyph from "./TackleGlyph.astro";
|
||||||
|
const { item, periodLabel = null } = Astro.props as { item: Activity; periodLabel?: string | null };
|
||||||
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>
|
||||||
@@ -14,10 +16,11 @@ 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} completeness={limited ? 75 : 100} confidence={item.confidence_score}/>
|
<DataPassport sources={item.sources} observedAt={item.last_confirmed_at} periodLabel={periodLabel} confidence={item.confidence_score} sampleSize={item.catches} independentPlayers={item.unique_players} coordinatePrecision={item.coordinate_precision} status={limited ? "incomplete" : "verified"}/>
|
||||||
<div class="bait-line"><FishingIcon name="lure" size={25}/><div><span>Работает сейчас</span><strong>{item.best_bait ?? "не указана"}</strong></div></div>
|
<div class="bait-line"><TackleGlyph name={item.best_bait}/><div><span>{limited ? "Нужно ещё подтверждений" : item.best_bait ? "Работает сейчас" : "Наживка не указана"}</span><strong>{item.best_bait ?? "не указана"}</strong></div></div>
|
||||||
|
<span class="card-cta">Открыть точку <span aria-hidden="true">→</span></span>
|
||||||
</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>{kg(item.average_weight_g)}</strong><span>средний вес</span></div><div><strong>{kg(item.max_weight_g)}</strong><span>макс. вес</span></div></div><span class="card-arrow"><FishingIcon name="arrow" size={22}/></span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,19 +1,11 @@
|
|||||||
---
|
---
|
||||||
import type { Catch } from "../lib/api";
|
const { buckets } = Astro.props as { buckets: { start: string; end: string; count: number }[] };
|
||||||
const { catches } = Astro.props as { catches: Catch[] };
|
|
||||||
const now = Date.now();
|
|
||||||
const step = 12 * 60 * 60 * 1000;
|
|
||||||
const buckets = Array.from({ length: 6 }, (_, index) => ({ index, count: 0 }));
|
|
||||||
for (const item of catches) {
|
|
||||||
const timestamp = new Date(item.caught_at ?? item.reported_at).getTime();
|
|
||||||
const age = Math.floor((now - timestamp) / step);
|
|
||||||
if (age >= 0 && age < buckets.length) buckets[buckets.length - 1 - age].count += 1;
|
|
||||||
}
|
|
||||||
const max = Math.max(1, ...buckets.map(bucket => bucket.count));
|
const max = Math.max(1, ...buckets.map(bucket => bucket.count));
|
||||||
|
const meterLevel = (count: number) => Math.round((count / max * 100) / 5) * 5;
|
||||||
---
|
---
|
||||||
<section class="activity-timeline" aria-labelledby="timeline-title">
|
<section class="activity-timeline" aria-labelledby="timeline-title">
|
||||||
<header><div><span class="overline">Последние 72 часа</span><h2 id="timeline-title">Леска активности</h2></div><p>По показанным ниже уловам · шаг 12 часов</p></header>
|
<header><div><span class="overline">Последние 72 часа</span><h2 id="timeline-title">Леска активности</h2></div><p>Все одобренные записи · по времени поступления · шаг 12 часов</p></header>
|
||||||
<div class="timeline-chart" role="img" aria-label={`Распределение ${catches.length} показанных уловов за последние 72 часа`}>
|
<div class="timeline-chart">
|
||||||
{buckets.map((bucket, index) => <div class="timeline-slot"><span class="timeline-line" style={`--height:${Math.max(8, bucket.count / max * 100)}%`}><i></i></span><strong>{bucket.count}</strong><small>{index === 0 ? "−72 ч" : index === 5 ? "сейчас" : `−${(5-index)*12} ч`}</small></div>)}
|
{buckets.map((bucket, index) => <div class="timeline-slot"><span aria-hidden="true" class:list={["timeline-line", `meter-level-${meterLevel(bucket.count)}`]}>{bucket.count > 0 && <i></i>}</span><strong>{bucket.count}</strong><small>{`${(6-index)*12}–${(5-index)*12} ч назад`}</small></div>)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<aside class="alpha-banner" aria-label="Информация об открытой альфе">
|
||||||
|
<div><strong><i></i>Открытая альфа</strong><span>Данные могут быть неполными — источник и качество всегда указаны.</span></div>
|
||||||
|
<nav aria-label="Ссылки открытой альфы"><a href="/status">Статус данных</a><a href="/rules">Как это работает</a><a class="alpha-banner__cta" data-action="inverse" href="/report">Добавить улов</a></nav>
|
||||||
|
</aside>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
type Item = { label: string; href?: string };
|
||||||
|
const { items } = Astro.props as { items: Item[] };
|
||||||
|
---
|
||||||
|
|
||||||
|
<nav class="atlas-breadcrumbs content-grid" aria-label="Навигационная цепочка">
|
||||||
|
<ol>
|
||||||
|
{items.map((item, index) => <li>
|
||||||
|
<span class="atlas-breadcrumbs__knot" aria-hidden="true"></span>
|
||||||
|
{item.href ? <a href={item.href}>{item.label}</a> : <span aria-current="page">{item.label}</span>}
|
||||||
|
{index < items.length - 1 && <span class="atlas-breadcrumbs__line" aria-hidden="true"></span>}
|
||||||
|
</li>)}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.atlas-breadcrumbs { padding-top: 34px; }
|
||||||
|
ol { display: flex; align-items: center; gap: 0; margin: 0; padding: 0; list-style: none; overflow-x: auto; scrollbar-width: none; }
|
||||||
|
ol::-webkit-scrollbar { display: none; }
|
||||||
|
li { display: flex; align-items: center; flex: 0 0 auto; color: var(--text-muted); font-size: 12px; }
|
||||||
|
a, li > span[aria-current] { padding: 8px 9px; color: inherit; text-decoration: none; white-space: nowrap; }
|
||||||
|
a:hover { color: var(--text-primary); text-decoration: underline; text-underline-offset: 4px; }
|
||||||
|
li > span[aria-current] { color: var(--text-primary); font-weight: 750; }
|
||||||
|
.atlas-breadcrumbs__knot { width: 7px; height: 7px; flex: 0 0 auto; border: 1px solid var(--border-strong); border-radius: 50%; background: var(--paper); }
|
||||||
|
li:last-child .atlas-breadcrumbs__knot { border-color: var(--accent-muted); background: var(--lime); box-shadow: 0 0 0 4px color-mix(in srgb,var(--lime) 18%,transparent); }
|
||||||
|
.atlas-breadcrumbs__line { width: clamp(18px, 3vw, 42px); height: 1px; background: repeating-linear-gradient(90deg,var(--border-strong) 0 5px,transparent 5px 8px); }
|
||||||
|
@media (max-width: 720px) { .atlas-breadcrumbs { width: calc(100% - 28px); padding-top: 22px; } a, li > span[aria-current] { padding-inline: 7px; } }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
import FishSilhouette from "./FishSilhouette.astro";
|
||||||
|
import WaterbodyMark from "./WaterbodyMark.astro";
|
||||||
|
const { href, label, kind, identity } = Astro.props as { href: string; label: string; kind: "fish" | "water"; identity: string };
|
||||||
|
---
|
||||||
|
<a class="atlas-entity-link" href={href}>
|
||||||
|
<span class="atlas-entity-link__mark" aria-hidden="true">{kind === "fish" ? <FishSilhouette name={identity} size={45} /> : <WaterbodyMark identity={identity} size={48} />}</span>
|
||||||
|
<span>{label}</span><b aria-hidden="true">→</b>
|
||||||
|
</a>
|
||||||
|
<style>
|
||||||
|
.atlas-entity-link { display:grid;grid-template-columns:52px minmax(0,1fr) auto;align-items:center;gap:9px;min-height:54px;padding:5px 2px;border-bottom:1px solid var(--border-soft);color:var(--text-muted);font-size:13px;text-decoration:none }
|
||||||
|
.atlas-entity-link__mark { width:48px;height:38px;display:grid;place-items:center;color:var(--decorative-water) }
|
||||||
|
.atlas-entity-link__mark :global(.fish-silhouette),.atlas-entity-link__mark :global(.waterbody-mark) { position:static;opacity:.68 }
|
||||||
|
.atlas-entity-link__mark :global(.waterbody-mark) { stroke:currentColor }.atlas-entity-link__mark :global(.waterbody-mark text) { fill:currentColor }
|
||||||
|
b { color:var(--accent-muted);font-size:15px;transition:transform var(--motion-fast) var(--ease-out) }.atlas-entity-link:hover { color:var(--text-primary) }.atlas-entity-link:hover b { transform:translateX(2px) }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
import FishSilhouette from "./FishSilhouette.astro";
|
||||||
|
import WaterbodyMark from "./WaterbodyMark.astro";
|
||||||
|
|
||||||
|
const { waterbody, fish } = Astro.props as { waterbody: string; fish: string };
|
||||||
|
---
|
||||||
|
|
||||||
|
<span class="atlas-pair-mark" aria-hidden="true">
|
||||||
|
<span class="atlas-pair-mark__water"><WaterbodyMark identity={waterbody} size={104} /></span>
|
||||||
|
<span class="atlas-pair-mark__fish"><FishSilhouette name={fish} size={78} /></span>
|
||||||
|
<i>×</i>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.atlas-pair-mark { position: relative; display: block; width: 128px; height: 74px; }
|
||||||
|
.atlas-pair-mark__water { position: absolute; inset: 15px auto auto 0; color: var(--lime); }
|
||||||
|
.atlas-pair-mark__water :global(.waterbody-mark) { position: static; opacity: .72; stroke: currentColor; }
|
||||||
|
.atlas-pair-mark__water :global(.waterbody-mark text) { fill: var(--white); opacity: .8; }
|
||||||
|
.atlas-pair-mark__fish { position: absolute; right: -2px; top: -6px; color: var(--white); filter: drop-shadow(0 3px 7px #06191c); }
|
||||||
|
.atlas-pair-mark__fish :global(.fish-silhouette) { position: static; opacity: .92; }
|
||||||
|
i { position: absolute; right: 2px; bottom: 0; width: 18px; height: 18px; display: grid; place-items: center; border-radius: 50%; background: var(--lime); color: var(--deep); font: 800 11px/1 Inter, sans-serif; }
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.atlas-pair-mark { width: 94px; height: 55px; transform: scale(.73); transform-origin: center; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
import SourceBadge from "./SourceBadge.astro";
|
||||||
|
import { ago, kg, type Catch } from "../lib/api";
|
||||||
|
import TackleGlyph from "./TackleGlyph.astro";
|
||||||
|
const { catches } = Astro.props as { catches: Catch[] };
|
||||||
|
---
|
||||||
|
<div class="catch-list">
|
||||||
|
{catches.map(item => {
|
||||||
|
const timestamp = item.caught_at ?? item.reported_at;
|
||||||
|
return <article>
|
||||||
|
<div><strong>{item.fish}</strong><span class="tackle-label"><TackleGlyph name={item.bait} size={24}/><span>{item.bait ?? "Приманка не указана"}</span></span><SourceBadge source={item.source_system} href={item.source_url}/></div>
|
||||||
|
<div><strong>{kg(item.weight_g)}</strong><span>{item.player_name ?? "Анонимно"}</span><span>{item.caught_at ? "Время улова" : "Получено · время улова неизвестно"}</span><time datetime={timestamp}>{ago(timestamp)} · {new Date(timestamp).toLocaleString("ru-RU", { timeZone: "UTC" })} UTC</time></div>
|
||||||
|
</article>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<style>
|
||||||
|
time{margin-top:7px;color:var(--text-secondary);font-size:10px;font-weight:750}
|
||||||
|
</style>
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
---
|
---
|
||||||
import SourceBadge from "./SourceBadge.astro";
|
import SourceBadge from "./SourceBadge.astro";
|
||||||
import { ago } from "../lib/api";
|
import { ago, freshnessStatus } from "../lib/api";
|
||||||
type Props = { sources: string[]; sourceUrl?: string | null; observedAt: string; completeness: number; confidence?: number | null; status?: "verified" | "unverified" | "incomplete" };
|
type Props = { sources: string[]; sourceUrl?: string | null; observedAt?: string | null; periodLabel?: 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, confidence = null, status = "verified" } = Astro.props;
|
const { sources, sourceUrl, observedAt, periodLabel = null, completeness = null, confidence = null, sampleSize = null, independentPlayers = null, coordinatePrecision = null, status = "verified" } = Astro.props as Props;
|
||||||
const statusLabels = { verified: "Учтено", unverified: "Ждёт проверки", incomplete: "Неполные данные" };
|
const freshness = freshnessStatus(observedAt);
|
||||||
const completenessLabel = completeness >= 100 ? "Полные" : `${Math.max(0, completeness)}% полей`;
|
const displayStatus = status === "verified" && freshness === "stale" ? "stale" : status;
|
||||||
|
const statusLabels = { verified: "Учтено", unverified: "Ждёт проверки", incomplete: "Неполные данные", stale: "Данные устарели" };
|
||||||
|
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={displayStatus}>{statusLabels[displayStatus]}</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 data-freshness={freshness}>{freshness === "stale" ? "Устарело" : freshness === "fresh" ? "Свежо" : "Не указана"}{observedAt && ` · ${ago(observedAt)}`}</dd></div>{periodLabel && <div><dt>Период</dt><dd>{periodLabel}</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>
|
||||||
|
{sampleSize != null && sampleSize < 3 && <p class="data-passport__minimum">Минимум для рекомендации: 3 наблюдения.</p>}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -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>
|
||||||