release: bump version to 0.47.0

This commit is contained in:
linkong
2026-04-30 16:56:37 +08:00
parent f22079d33a
commit 421234301a
42 changed files with 2501 additions and 454 deletions

View File

@@ -5,7 +5,7 @@ from datetime import datetime
import base64
import json
import re
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel, Field
@@ -318,7 +318,7 @@ async def list_configs(
"""List all user-defined data source configurations"""
query = select(DataSourceConfig)
if active_only:
query = query.where(DataSourceConfig.is_active == True)
query = query.where(DataSourceConfig.is_active)
query = query.order_by(DataSourceConfig.created_at.desc())
result = await db.execute(query)
@@ -374,6 +374,11 @@ async def list_all_datasources(
"is_active": db_config.is_active if db_config else True,
"source_type": db_config.source_type if db_config else "http",
"auth_type": db_config.auth_type if db_config else "none",
"auth_configured": {
"api_key": bool((db_config.auth_config or {}).get("api_key"))
if db_config
else False,
},
"headers": db_config.headers if db_config else {},
"config": strip_connectivity_validation(db_config.config if db_config else {}),
"config_id": db_config.id if db_config else None,
@@ -464,6 +469,8 @@ async def update_config(
for field, value in update_data.items():
if field == "config":
value = strip_connectivity_validation(value)
if field == "auth_config" and value == {} and (config.auth_config or {}):
continue
setattr(config, field, value)
await db.commit()
@@ -601,6 +608,7 @@ async def connect_builtin_config(
config_data.headers,
config_data.config,
db,
config_data.auth_config,
)
if result.get("success") and result.get("checksum"):
validation = await save_connectivity_success(

View File

@@ -17,6 +17,7 @@ from app.models.datasource import DataSource
from app.models.datasource_config import DataSourceConfig
from app.models.system_setting import SystemSetting
from app.models.user import User
from app.models.vessel import AISSourceHealth
from app.services.barentswatch import (
BarentsWatchConfig,
check_barentswatch_config,
@@ -368,7 +369,12 @@ def format_frequency_label(minutes: int) -> str:
return f"{minutes}m"
def serialize_collector(datasource: DataSource) -> dict:
async def get_ais_source_health_by_source(db: AsyncSession) -> dict[str, dict]:
result = await db.execute(select(AISSourceHealth))
return {item.source: item.to_dict() for item in result.scalars().all()}
def serialize_collector(datasource: DataSource, ais_health_by_source: dict[str, dict] | None = None) -> dict:
defaults = DEFAULT_DATASOURCES.get(datasource.source, {})
return {
"id": datasource.id,
@@ -387,6 +393,7 @@ def serialize_collector(datasource: DataSource) -> dict:
"requires_credentials": bool(defaults.get("requires_credentials", False)),
"credential_provider": defaults.get("credential_provider"),
"credential_status": defaults.get("credential_status", "none"),
"ais_health": (ais_health_by_source or {}).get(datasource.source),
}
@@ -599,7 +606,8 @@ async def get_collector_settings(
):
result = await db.execute(select(DataSource).order_by(DataSource.module, DataSource.id))
datasources = result.scalars().all()
return {"collectors": [serialize_collector(datasource) for datasource in datasources]}
ais_health_by_source = await get_ais_source_health_by_source(db)
return {"collectors": [serialize_collector(datasource, ais_health_by_source) for datasource in datasources]}
@router.put("/collectors/{datasource_id}")
@@ -619,7 +627,8 @@ async def update_collector_settings(
await db.commit()
await db.refresh(datasource)
await sync_datasource_job(datasource.id)
return {"status": "updated", "collector": serialize_collector(datasource)}
ais_health_by_source = await get_ais_source_health_by_source(db)
return {"status": "updated", "collector": serialize_collector(datasource, ais_health_by_source)}
@router.get("")
@@ -633,12 +642,13 @@ async def get_all_settings(
db,
["system", "notifications", "security"],
)
ais_health_by_source = await get_ais_source_health_by_source(db)
return {
"system": setting_payloads["system"],
"notifications": setting_payloads["notifications"],
"security": setting_payloads["security"],
"tv": await get_tv_settings_payload(db),
"integrations": await serialize_external_integrations(db),
"collectors": [serialize_collector(datasource) for datasource in datasources],
"collectors": [serialize_collector(datasource, ais_health_by_source) for datasource in datasources],
"generated_at": to_iso8601_utc(datetime.now(UTC)),
}

View File

@@ -25,6 +25,14 @@ from app.services.bgp_collectors import build_bgp_collector_coverage
from app.services.cable_graph import build_graph_from_data, CableGraph, haversine_distance
from app.services.collectors.bgp_common import RIPE_RIS_COLLECTOR_COORDS
from app.services.persistent_logs import record_system_log
from app.services.vessel_ais_aggregation import (
build_field_conflict_candidates,
get_aggregated_vessel,
get_aggregated_vessel_track,
get_aggregated_vessels,
get_vessel_conflict_records,
get_vessel_raw_observations,
)
from app.core.logging import get_logger
router = APIRouter()
@@ -664,6 +672,54 @@ def convert_vessels_to_geojson(rows: List[Any]) -> Dict[str, Any]:
return {"type": "FeatureCollection", "features": features}
def convert_aggregated_vessels_to_geojson(vessels: List[dict[str, Any]]) -> Dict[str, Any]:
features = []
for vessel in vessels:
if vessel.get("lat") is None or vessel.get("lon") is None:
continue
source_summary = {}
for source, summary in (vessel.get("source_summary") or {}).items():
source_summary[source] = {
**summary,
"latest_observed_at": to_iso8601_utc(summary.get("latest_observed_at")),
}
props = {
"mmsi": vessel["mmsi"],
"name": vessel.get("name") or f"MMSI {vessel['mmsi']}",
"callsign": vessel.get("callsign"),
"imo": vessel.get("imo"),
"vessel_type": vessel.get("vessel_type"),
"vessel_type_name": vessel.get("vessel_type_name") or "Other",
"flag": vessel.get("flag"),
"length": vessel.get("length"),
"width": vessel.get("width"),
"draught": vessel.get("draught"),
"sog": vessel.get("sog"),
"cog": vessel.get("cog"),
"heading": vessel.get("heading"),
"nav_status": vessel.get("nav_status"),
"received_at": to_iso8601_utc(vessel.get("received_at")),
"field_sources": vessel.get("field_sources") or {},
"selected_reasons": vessel.get("selected_reasons") or {},
"source_summary": source_summary,
"quality_flags": vessel.get("quality_flags") or [],
"conflict_count": vessel.get("conflict_count", 0),
"data_type": "vessel",
}
features.append(
{
"type": "Feature",
"id": vessel["mmsi"],
"geometry": {
"type": "Point",
"coordinates": [vessel["lon"], vessel["lat"]],
},
"properties": props,
}
)
return {"type": "FeatureCollection", "features": features}
def _parse_bbox(value: Optional[str]) -> tuple[float, float, float, float] | None:
if not value:
return None
@@ -1411,10 +1467,37 @@ async def get_vessels_geojson(
None,
description="Comma-separated vessel types: cargo,tanker,passenger,fishing,military,other",
),
limit: int = Query(5000, ge=1, le=50000),
limit: Optional[int] = Query(
None,
ge=0,
description="Maximum vessel features to return. Omit or pass 0 for no limit.",
),
db: AsyncSession = Depends(get_db),
):
"""Return latest vessel positions as GeoJSON points."""
parsed_bbox = _parse_bbox(bbox)
aggregated_vessels = await get_aggregated_vessels(db, bbox=parsed_bbox, limit=limit)
if aggregated_vessels:
geojson = convert_aggregated_vessels_to_geojson(aggregated_vessels)
requested_types = {
item.strip().lower()
for item in (type or "").split(",")
if item.strip()
}
if requested_types:
geojson["features"] = [
feature
for feature in geojson.get("features", [])
if _matches_vessel_type(feature.get("properties", {}), requested_types)
]
features = geojson.get("features", [])
return {
**geojson,
"count": len(features),
"stats": _build_vessel_stats(features),
}
latest_times = (
select(
VesselPosition.mmsi.label("mmsi"),
@@ -1432,10 +1515,10 @@ async def get_vessels_geojson(
)
.outerjoin(VesselStatic, VesselStatic.mmsi == VesselPosition.mmsi)
.order_by(VesselPosition.received_at.desc())
.limit(limit)
)
if limit and limit > 0:
stmt = stmt.limit(limit)
parsed_bbox = _parse_bbox(bbox)
if parsed_bbox is not None:
lon_min, lat_min, lon_max, lat_max = parsed_bbox
stmt = stmt.where(
@@ -1470,6 +1553,15 @@ async def get_vessels_geojson(
@router.get("/vessels/{mmsi}")
async def get_vessel_detail(mmsi: int, db: AsyncSession = Depends(get_db)):
aggregated = await get_aggregated_vessel(db, mmsi)
if aggregated is not None:
return {
**aggregated,
"received_at": to_iso8601_utc(aggregated.get("received_at")),
"latitude": aggregated["lat"],
"longitude": aggregated["lon"],
}
latest_position_stmt = (
select(VesselPosition)
.where(VesselPosition.mmsi == mmsi)
@@ -1496,6 +1588,30 @@ async def get_vessel_track(
db: AsyncSession = Depends(get_db),
):
cutoff = datetime.now(UTC) - timedelta(hours=hours)
aggregated_points = await get_aggregated_vessel_track(db, mmsi, cutoff=cutoff)
if aggregated_points:
return {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
"type": "LineString",
"coordinates": [[point["lon"], point["lat"]] for point in aggregated_points],
},
"properties": {
"mmsi": mmsi,
"hours": hours,
"point_count": len(aggregated_points),
"start_at": to_iso8601_utc(aggregated_points[0]["observed_at"]),
"end_at": to_iso8601_utc(aggregated_points[-1]["observed_at"]),
"point_sources": [point["source"] for point in aggregated_points],
},
}
],
"count": 1,
}
result = await db.execute(
select(VesselPosition)
.where(VesselPosition.mmsi == mmsi)
@@ -1532,6 +1648,37 @@ async def get_vessel_track(
}
@router.get("/vessels/{mmsi}/observations")
async def get_vessel_observations(
mmsi: int,
limit: int = Query(100, ge=1, le=500),
db: AsyncSession = Depends(get_db),
):
"""Return raw AIS observations for debugging source-level collector facts."""
observations = await get_vessel_raw_observations(db, mmsi, limit=limit)
return {
"mmsi": mmsi,
"count": len(observations),
"observations": [item.to_dict() for item in observations],
"conflict_candidates": build_field_conflict_candidates(observations),
}
@router.get("/vessels/{mmsi}/conflicts")
async def get_vessel_conflicts(mmsi: int, db: AsyncSession = Depends(get_db)):
"""Return recorded AIS conflicts plus current raw-observation candidates."""
records = await get_vessel_conflict_records(db, mmsi)
observations = await get_vessel_raw_observations(db, mmsi, limit=500)
return {
"mmsi": mmsi,
"count": len(records),
"conflicts": [item.to_dict() for item in records],
"candidates": build_field_conflict_candidates(observations),
}
@router.get("/geo/bgp-anomalies")
async def get_bgp_anomalies_geojson(
severity: Optional[str] = Query(None),