sync
This commit is contained in:
@@ -34,13 +34,13 @@ def activity_rows(
|
||||
CatchReport.reported_at >= now - timedelta(hours=hours),
|
||||
)
|
||||
)
|
||||
reports = list(session.scalars(query))
|
||||
if waterbody:
|
||||
reports = [r for r in reports if r.waterbody.slug == waterbody]
|
||||
query = query.where(CatchReport.waterbody.has(slug=waterbody))
|
||||
if fish:
|
||||
reports = [r for r in reports if r.fish.slug == fish]
|
||||
query = query.where(CatchReport.fish.has(slug=fish))
|
||||
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]] = {}
|
||||
for report in reports:
|
||||
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .community_review import publish_observation
|
||||
from .models import DataSource, ExternalEntityAlias, ExternalObservation
|
||||
from .models import DataSource, ExternalEntityAlias, ExternalObservation, ModerationStatus
|
||||
|
||||
|
||||
SOURCE_DEFAULTS = {
|
||||
@@ -77,6 +77,18 @@ def stage_observations(
|
||||
session.add(observation)
|
||||
created += 1
|
||||
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",
|
||||
)) 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"
|
||||
for key, value in values.items():
|
||||
setattr(observation, key, value)
|
||||
updated += 1
|
||||
@@ -90,6 +102,8 @@ def stage_observations(
|
||||
def _auto_publish(session: Session, observation: ExternalObservation) -> bool:
|
||||
"""Publish only complete observations covered by previously reviewed aliases."""
|
||||
if (
|
||||
observation.catch_report_id is not None
|
||||
or
|
||||
observation.status not in {"staged", "mapped", "ready"}
|
||||
or not observation.source.enabled
|
||||
or observation.fish_external_id is None
|
||||
|
||||
@@ -59,8 +59,10 @@ def reject_observation(session: Session, observation: ExternalObservation, *, re
|
||||
|
||||
|
||||
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
|
||||
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):
|
||||
raise ExternalReviewError("fish, waterbody, coordinates and weight are required for publication")
|
||||
spot = session.scalar(select(Spot).where(
|
||||
@@ -71,7 +73,7 @@ def publish_observation(session: Session, observation: ExternalObservation) -> C
|
||||
session.add(spot)
|
||||
bait = _bait(session, observation.payload.get("bait"))
|
||||
now = datetime.now(timezone.utc)
|
||||
report = CatchReport(
|
||||
values = dict(
|
||||
fish=observation.fish, waterbody=observation.waterbody, spot=spot, bait=bait,
|
||||
weight_g=observation.weight_g,
|
||||
fishing_method=observation.payload.get("fishing_method"),
|
||||
@@ -95,6 +97,12 @@ def publish_observation(session: Session, observation: ExternalObservation) -> C
|
||||
"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.flush()
|
||||
observation.catch_report = report
|
||||
|
||||
@@ -42,13 +42,15 @@ def run_source(source_system: str, *, now: datetime | None = None) -> bool:
|
||||
source = session.get(DataSource, source_system)
|
||||
if source is None or not source.enabled:
|
||||
return False
|
||||
# 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:{source_system}"}):
|
||||
return False
|
||||
recent = list(session.scalars(select(CommunityImportRun).where(CommunityImportRun.source_system == source_system).order_by(CommunityImportRun.started_at.desc()).limit(8)))
|
||||
latest = recent[0].started_at if recent else None
|
||||
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):
|
||||
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")
|
||||
session.add(run); session.commit()
|
||||
try:
|
||||
@@ -57,6 +59,7 @@ def run_source(source_system: str, *, now: datetime | None = None) -> bool:
|
||||
created, updated = stage_observations(session, [asdict(item) for item in records])
|
||||
run.status, run.rows_seen, run.rows_created, run.rows_updated = "success", len(records), created, updated
|
||||
except Exception as exc:
|
||||
session.rollback()
|
||||
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})
|
||||
run.finished_at = datetime.now(timezone.utc); session.commit()
|
||||
|
||||
+32
-2
@@ -109,6 +109,17 @@ def baits(db: Db, limit: int = Query(200, ge=1, le=500), offset: int = Query(0,
|
||||
return list(db.scalars(select(Bait).order_by(Bait.name, Bait.id).offset(offset).limit(limit)))
|
||||
|
||||
|
||||
@app.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}")]
|
||||
|
||||
|
||||
@app.get("/api/v1/activity", response_model=list[ActivityOut])
|
||||
def activity(
|
||||
db: Db, response: Response, hours: int = Query(24),
|
||||
@@ -119,7 +130,8 @@ def activity(
|
||||
) -> 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")
|
||||
response.headers["Cache-Control"] = f"public, max-age={settings.public_cache_seconds}"
|
||||
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:
|
||||
@@ -133,7 +145,7 @@ def activity(
|
||||
}
|
||||
rows.sort(key=keys[sort], reverse=True)
|
||||
response.headers["X-Cache"] = "MISS"
|
||||
return public_cache.set(cache_key, rows[offset:offset + limit])
|
||||
return public_cache.set(cache_key, rows[offset:offset + limit], generation=generation)
|
||||
|
||||
|
||||
def _spot_or_404(db: Session, spot_id: UUID) -> Spot:
|
||||
@@ -174,6 +186,24 @@ def spot_catches(spot_id: UUID, db: Db, limit: int = Query(50, ge=1, le=100), of
|
||||
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]
|
||||
|
||||
|
||||
@app.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
|
||||
|
||||
|
||||
def _report_source(report: CatchReport) -> str:
|
||||
provenance = (report.raw_payload or {}).get("provenance", {})
|
||||
if isinstance(provenance, dict) and provenance.get("source_system"):
|
||||
|
||||
@@ -7,9 +7,15 @@ from typing import Any
|
||||
|
||||
|
||||
class PublicResponseCache:
|
||||
def __init__(self) -> None:
|
||||
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:
|
||||
@@ -19,13 +25,18 @@ class PublicResponseCache:
|
||||
return None
|
||||
return deepcopy(item[1])
|
||||
|
||||
def set(self, key: tuple[Any, ...], value: Any) -> Any:
|
||||
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()
|
||||
|
||||
|
||||
|
||||
@@ -57,6 +57,37 @@ def test_invalid_period_is_rejected() -> None:
|
||||
assert client.get("/api/v1/activity?sort=unknown").status_code == 422
|
||||
|
||||
|
||||
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:
|
||||
assert client.get("/api/v1/fishes?limit=0").status_code == 422
|
||||
assert client.get("/api/v1/fishes?limit=1&offset=0").status_code == 200
|
||||
|
||||
@@ -8,7 +8,7 @@ from sqlalchemy import create_engine, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.community_importer import CommunityImportError, stage_observations
|
||||
from app.community_review import ExternalReviewError, map_observation, suggest_aliases
|
||||
from app.community_review import ExternalReviewError, map_observation, publish_observation, suggest_aliases
|
||||
from app.database import Base
|
||||
from app.models import CatchReport, DataSource, ExternalEntityAlias, ExternalObservation, Fish, Waterbody
|
||||
from rf4_research.community_sources import parse_rf4db_catches, parse_rf4map_point, parse_rf4posts_spot
|
||||
@@ -111,6 +111,33 @@ def test_complete_observation_with_reviewed_aliases_is_published(db: Session) ->
|
||||
assert db.scalar(select(func.count()).select_from(CatchReport)) == 1
|
||||
|
||||
|
||||
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()
|
||||
stage_observations(db, [record() | {"weight_g": 5000}])
|
||||
item = db.scalar(select(ExternalObservation))
|
||||
map_observation(db, item, fish, water)
|
||||
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
|
||||
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"
|
||||
assert db.scalar(select(func.count()).select_from(CatchReport)) == 1
|
||||
|
||||
|
||||
def test_auto_publication_requires_enabled_source(db: Session) -> None:
|
||||
source = DataSource(key="rf4db", name="RF4DB", base_url="https://rf4db.com", default_confidence=70, enabled=False)
|
||||
fish = Fish(slug="pike", name_ru="Щука")
|
||||
|
||||
@@ -9,3 +9,15 @@ def test_cache_copies_values_and_invalidates() -> None:
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user