fix: R02 activity API contract — PaginatedActivity + all consumers (#1788956171115)
This commit is contained in:
@@ -26,23 +26,31 @@ 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():
|
|
||||||
with SessionLocal() as session:
|
def _static_registry() -> dict[str, tuple[str, callable]]:
|
||||||
enabled_keys = {
|
"""Return the full static registry without DB access (for unit tests)."""
|
||||||
s.key for s in session.scalars(select(DataSource).where(DataSource.enabled.is_(True)))
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
k: v for k, v in {
|
"rf4db": SOURCES["rf4db"],
|
||||||
"rf4db": SOURCES["rf4db"],
|
"rf4stat-fishing": SOURCES["rf4stat-fishing"],
|
||||||
"rf4stat-fishing": SOURCES["rf4stat-fishing"],
|
"rf4stat-post": (SOURCES["rf4stat-posts"][0], SOURCES["rf4stat-posts"][1]),
|
||||||
"rf4stat-post": (SOURCES["rf4stat-posts"][0], SOURCES["rf4stat-posts"][1]),
|
"rf4map": (settings.rf4map_point_url, parse_rf4map_point),
|
||||||
"rf4map": (settings.rf4map_point_url, parse_rf4map_point),
|
"rf4posts-spot": (settings.rf4posts_spot_url, parse_rf4posts_spot),
|
||||||
"rf4posts-spot": (settings.rf4posts_spot_url, parse_rf4posts_spot),
|
|
||||||
}.items() if k in enabled_keys
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def oldest_site_source(source_system: str, latest_by_source: dict[str, datetime]) -> str:
|
|
||||||
sources = configured_sources()
|
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:
|
||||||
|
sources = configured_sources(enabled_keys)
|
||||||
site = fetch_site_key(sources[source_system][0])
|
site = fetch_site_key(sources[source_system][0])
|
||||||
candidates = [key for key, (url, _) in sources.items() if fetch_site_key(url) == site]
|
candidates = [key for key, (url, _) in sources.items() if fetch_site_key(url) == site]
|
||||||
order = {key: index for index, key in enumerate(candidates)}
|
order = {key: index for index, key in enumerate(candidates)}
|
||||||
@@ -50,9 +58,12 @@ def oldest_site_source(source_system: str, latest_by_source: dict[str, datetime]
|
|||||||
|
|
||||||
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_key = fetch_site_key(url)
|
||||||
site_sources = [key for key, (candidate_url, _) in configured_sources().items() if fetch_site_key(candidate_url) == site_key]
|
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:
|
||||||
@@ -65,7 +76,7 @@ def run_source(source_system: str, *, now: datetime | None = None) -> bool:
|
|||||||
latest_by_source: dict[str, datetime] = {}
|
latest_by_source: dict[str, datetime] = {}
|
||||||
for previous in recent:
|
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))
|
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) != source_system:
|
if oldest_site_source(source_system, latest_by_source, enabled_keys) != source_system:
|
||||||
return False
|
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])
|
||||||
|
|||||||
@@ -45,11 +45,15 @@ 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_invalid_period_is_rejected() -> None:
|
def test_invalid_period_is_rejected() -> None:
|
||||||
@@ -157,7 +161,7 @@ def test_admin_diagnostics_exposes_build_identity_only_to_admin() -> None:
|
|||||||
|
|
||||||
|
|
||||||
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
|
||||||
@@ -206,7 +210,7 @@ def test_user_report_requires_moderation_before_activity() -> None:
|
|||||||
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"})
|
||||||
assert approved.status_code == 200
|
assert approved.status_code == 200
|
||||||
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:
|
||||||
@@ -267,7 +271,7 @@ 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())
|
assert all(item["x"] != 32 or item["y"] != 42 for item in client.get("/api/v1/activity").json()["items"])
|
||||||
headers = {"Authorization": "Bearer change-me-in-production"}
|
headers = {"Authorization": "Bearer change-me-in-production"}
|
||||||
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,
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ from pydantic import ValidationError
|
|||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from app.community_scheduler import MAX_BACKOFF_SECONDS, configured_sources, oldest_site_source, retry_delay
|
from app.community_scheduler import MAX_BACKOFF_SECONDS, configured_sources, _static_registry, oldest_site_source, retry_delay
|
||||||
from app.config import Settings
|
from app.config import Settings
|
||||||
|
|
||||||
|
|
||||||
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:
|
||||||
@@ -25,6 +25,7 @@ def test_failed_runs_back_off_but_success_resets_delay() -> None:
|
|||||||
|
|
||||||
def test_same_site_endpoints_rotate_by_oldest_attempt() -> None:
|
def test_same_site_endpoints_rotate_by_oldest_attempt() -> None:
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
assert oldest_site_source("rf4stat-fishing", {}) == "rf4stat-fishing"
|
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)}
|
latest = {"rf4stat-fishing": now, "rf4stat-post": now - timedelta(hours=1)}
|
||||||
assert oldest_site_source("rf4stat-fishing", latest) == "rf4stat-post"
|
assert oldest_site_source("rf4stat-fishing", latest, all_keys) == "rf4stat-post"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
@@ -17,11 +18,34 @@ 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() -> None:
|
||||||
|
engine = create_engine("sqlite://")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
with Session(engine) as db:
|
||||||
|
mock_real = MagicMock()
|
||||||
|
mock_real.client.host = "10.0.0.1"
|
||||||
|
mock_real.headers.get.return_value = "198.51.100.10"
|
||||||
|
for _ in range(5):
|
||||||
|
_check_rate_limit(mock_real, db)
|
||||||
|
with pytest.raises(HTTPException) as blocked:
|
||||||
|
mock_other = MagicMock()
|
||||||
|
mock_other.client.host = "10.0.0.2"
|
||||||
|
mock_other.headers.get.return_value = "198.51.100.10"
|
||||||
|
_check_rate_limit(mock_other, db)
|
||||||
|
assert blocked.value.status_code == 429
|
||||||
|
|||||||
@@ -28,11 +28,11 @@ 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_must_be_recent_and_successful() -> None:
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
---
|
---
|
||||||
import ActivityCard from "../../components/ActivityCard.astro";
|
import ActivityCard from "../../components/ActivityCard.astro";
|
||||||
import Layout from "../../layouts/Layout.astro";
|
import Layout from "../../layouts/Layout.astro";
|
||||||
import { api, plural, type Activity, type DictionaryItem } from "../../lib/api";
|
import { api, plural, type Activity, type DictionaryItem, type PaginatedActivity } from "../../lib/api";
|
||||||
const { slug } = Astro.params;
|
const { slug } = Astro.params;
|
||||||
let fish: DictionaryItem | undefined, items: Activity[] = [], unavailable = false;
|
let fish: DictionaryItem | undefined, items: Activity[] = [], unavailable = false;
|
||||||
try {
|
try {
|
||||||
const fishes = await api<DictionaryItem[]>("/api/v1/fishes?limit=500");
|
const fishes = await api<DictionaryItem[]>("/api/v1/fishes?limit=500");
|
||||||
fish = fishes.find(item => item.slug === slug);
|
fish = fishes.find(item => item.slug === slug);
|
||||||
if (fish) items = await api<Activity[]>(`/api/v1/activity?hours=72&fish=${encodeURIComponent(fish.slug)}&limit=100`);
|
if (fish) { const paginated = await api<PaginatedActivity>(`/api/v1/activity?hours=72&fish=${encodeURIComponent(fish.slug)}&limit=100`); items = paginated.items; }
|
||||||
} catch { unavailable = true; }
|
} catch { unavailable = true; }
|
||||||
if (unavailable) {
|
if (unavailable) {
|
||||||
Astro.response.status = 503;
|
Astro.response.status = 503;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import SourceBadge from "../../components/SourceBadge.astro";
|
|||||||
import CoordinateRadar from "../../components/CoordinateRadar.astro";
|
import CoordinateRadar from "../../components/CoordinateRadar.astro";
|
||||||
import ActivityTimeline from "../../components/ActivityTimeline.astro";
|
import ActivityTimeline from "../../components/ActivityTimeline.astro";
|
||||||
import CatchList from "../../components/CatchList.astro";
|
import CatchList from "../../components/CatchList.astro";
|
||||||
import { activityLevel, api, ApiError, plural, type Activity, type Catch, type Spot } from "../../lib/api";
|
import { activityLevel, api, ApiError, plural, type Activity, type Catch, type PaginatedActivity, type Spot } from "../../lib/api";
|
||||||
const { id } = Astro.params;
|
const { id } = Astro.params;
|
||||||
let spot: Spot | null = null, catches: Catch[] = [], activity: Activity | null = null, unavailable = false;
|
let spot: Spot | null = null, catches: Catch[] = [], activity: Activity | null = null, unavailable = false;
|
||||||
let timeline: { start: string; end: string; count: number }[] = [];
|
let timeline: { start: string; end: string; count: number }[] = [];
|
||||||
@@ -14,8 +14,8 @@ try {
|
|||||||
? await api<Spot>(`/api/v1/spots/resolve?waterbody=${encodeURIComponent(readable[1])}&x=${readable[2]}&y=${readable[3]}`)
|
? await api<Spot>(`/api/v1/spots/resolve?waterbody=${encodeURIComponent(readable[1])}&x=${readable[2]}&y=${readable[3]}`)
|
||||||
: await api<Spot>(`/api/v1/spots/${id}`);
|
: await api<Spot>(`/api/v1/spots/${id}`);
|
||||||
if (!readable) return Astro.redirect(`/spots/${spotResult.waterbody_slug}-${spotResult.x}x${spotResult.y}`, 301);
|
if (!readable) return Astro.redirect(`/spots/${spotResult.waterbody_slug}-${spotResult.x}x${spotResult.y}`, 301);
|
||||||
const [catchResult, activityRows] = await Promise.all([api<Catch[]>(`/api/v1/spots/${spotResult.id}/catches`), api<Activity[]>(`/api/v1/activity?hours=24&waterbody=${encodeURIComponent(spotResult.waterbody_slug)}&limit=100`)]);
|
const [catchResult, activityPaginated] = await Promise.all([api<Catch[]>(`/api/v1/spots/${spotResult.id}/catches`), api<PaginatedActivity>(`/api/v1/activity?hours=24&waterbody=${encodeURIComponent(spotResult.waterbody_slug)}&limit=100`)]);
|
||||||
spot = spotResult; catches = catchResult; activity = activityRows.find(item => item.spot_id === spotResult.id) ?? null;
|
spot = spotResult; catches = catchResult; activity = activityPaginated.items.find(item => item.spot_id === spotResult.id) ?? null;
|
||||||
timeline = await api<typeof timeline>(`/api/v1/spots/${spotResult.id}/timeline`);
|
timeline = await api<typeof timeline>(`/api/v1/spots/${spotResult.id}/timeline`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
unavailable = true;
|
unavailable = true;
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
---
|
---
|
||||||
import ActivityCard from "../../components/ActivityCard.astro";
|
import ActivityCard from "../../components/ActivityCard.astro";
|
||||||
import Layout from "../../layouts/Layout.astro";
|
import Layout from "../../layouts/Layout.astro";
|
||||||
import { api, plural, type Activity, type DictionaryItem } from "../../lib/api";
|
import { api, plural, type Activity, type DictionaryItem, type PaginatedActivity } from "../../lib/api";
|
||||||
const { slug } = Astro.params;
|
const { slug } = Astro.params;
|
||||||
let water: DictionaryItem | undefined, items: Activity[] = [], unavailable = false;
|
let water: DictionaryItem | undefined, items: Activity[] = [], unavailable = false;
|
||||||
try {
|
try {
|
||||||
const waters = await api<DictionaryItem[]>("/api/v1/waterbodies?limit=500");
|
const waters = await api<DictionaryItem[]>("/api/v1/waterbodies?limit=500");
|
||||||
water = waters.find(item => item.slug === slug);
|
water = waters.find(item => item.slug === slug);
|
||||||
if (water) items = await api<Activity[]>(`/api/v1/activity?hours=72&waterbody=${encodeURIComponent(water.slug)}&limit=100`);
|
if (water) { const paginated = await api<PaginatedActivity>(`/api/v1/activity?hours=72&waterbody=${encodeURIComponent(water.slug)}&limit=100`); items = paginated.items; }
|
||||||
} catch { unavailable = true; }
|
} catch { unavailable = true; }
|
||||||
if (unavailable) {
|
if (unavailable) {
|
||||||
Astro.response.status = 503;
|
Astro.response.status = 503;
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
---
|
---
|
||||||
import ActivityCard from "../../../components/ActivityCard.astro";
|
import ActivityCard from "../../../components/ActivityCard.astro";
|
||||||
import Layout from "../../../layouts/Layout.astro";
|
import Layout from "../../../layouts/Layout.astro";
|
||||||
import { api, plural, type Activity, type DictionaryItem } from "../../../lib/api";
|
import { api, plural, type Activity, type DictionaryItem, type PaginatedActivity } from "../../../lib/api";
|
||||||
const { slug, fish: fishSlug } = Astro.params;
|
const { slug, fish: fishSlug } = Astro.params;
|
||||||
let water: DictionaryItem | undefined, fish: DictionaryItem | undefined, items: Activity[] = [], unavailable = false;
|
let water: DictionaryItem | undefined, fish: DictionaryItem | undefined, items: Activity[] = [], unavailable = false;
|
||||||
try {
|
try {
|
||||||
const [waters, fishes] = await Promise.all([api<DictionaryItem[]>("/api/v1/waterbodies?limit=500"), api<DictionaryItem[]>("/api/v1/fishes?limit=500")]);
|
const [waters, fishes] = await Promise.all([api<DictionaryItem[]>("/api/v1/waterbodies?limit=500"), api<DictionaryItem[]>("/api/v1/fishes?limit=500")]);
|
||||||
water = waters.find(item => item.slug === slug); fish = fishes.find(item => item.slug === fishSlug);
|
water = waters.find(item => item.slug === slug); fish = fishes.find(item => item.slug === fishSlug);
|
||||||
if (water && fish) items = await api<Activity[]>(`/api/v1/activity?hours=72&waterbody=${encodeURIComponent(water.slug)}&fish=${encodeURIComponent(fish.slug)}&limit=100`);
|
if (water && fish) { const paginated = await api<PaginatedActivity>(`/api/v1/activity?hours=72&waterbody=${encodeURIComponent(water.slug)}&fish=${encodeURIComponent(fish.slug)}&limit=100`); items = paginated.items; }
|
||||||
} catch { unavailable = true; }
|
} catch { unavailable = true; }
|
||||||
if (unavailable) {
|
if (unavailable) {
|
||||||
Astro.response.status = 503;
|
Astro.response.status = 503;
|
||||||
|
|||||||
Reference in New Issue
Block a user