sync
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s

This commit is contained in:
ik
2026-09-08 09:31:41 +07:00
parent 8b2d7e2e1c
commit ddb6909c4d
21 changed files with 255 additions and 58 deletions
+4 -4
View File
@@ -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:
+15 -1
View File
@@ -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
+10 -2
View File
@@ -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
+5 -2
View File
@@ -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
View File
@@ -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"):
+13 -2
View File
@@ -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()