fix(metadata): sync project metadata endpoints

This commit is contained in:
2026-06-11 11:15:59 +08:00
parent 78af7ecfb3
commit f61be3685f
8 changed files with 357 additions and 12 deletions
+21 -3
View File
@@ -1,5 +1,6 @@
import logging
from fastapi import APIRouter, Depends, HTTPException, status, Query, Path
import psycopg
from psycopg import AsyncConnection
from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError
@@ -58,6 +59,7 @@ async def get_project_metadata(
code=project.code,
description=project.description,
gs_workspace=project.gs_workspace,
map_extent=project.map_extent,
status=project.status,
project_role=ctx.project_role,
geoserver=geoserver_payload,
@@ -110,7 +112,23 @@ async def project_db_health(
检查PostgreSQL和TimescaleDB数据库的连接状态
"""
await pg_session.execute(text("SELECT 1"))
async with ts_conn.cursor() as cur:
await cur.execute("SELECT 1")
try:
await pg_session.execute(text("SELECT 1"))
except SQLAlchemyError as exc:
logger.error("Project PostgreSQL health check failed", exc_info=True)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Project PostgreSQL health check failed: {exc}",
) from exc
try:
async with ts_conn.cursor() as cur:
await cur.execute("SELECT 1")
except psycopg.Error as exc:
logger.error("Project TimescaleDB health check failed", exc_info=True)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Project TimescaleDB health check failed: {exc}",
) from exc
return {"postgres": "ok", "timescale": "ok"}
+40 -1
View File
@@ -1,7 +1,10 @@
import json
from fastapi import APIRouter, Request, HTTPException, Query, Path, Body
from fastapi import APIRouter, Request, HTTPException, Query, Path, Body, Depends
from fastapi.responses import PlainTextResponse
from typing import Any, Dict, List
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
from app.auth.project_dependencies import get_metadata_repository
from app.domain.schemas.metadata import ProjectMetaResponse, GeoServerConfigResponse
import app.services.project_info as project_info
from app.infra.db.postgresql.database import get_database_instance as get_pg_db
from app.infra.db.timescaledb.database import get_database_instance as get_ts_db
@@ -39,6 +42,42 @@ inpDir = "data/" # Assuming data directory exists or is defined somewhere.
router = APIRouter()
lockedPrjs: Dict[str, str] = {}
@router.get("/project_info/", summary="获取项目信息", description="从数据库获取项目的详细信息,包括地图范围等。", response_model=ProjectMetaResponse)
async def get_project_info_endpoint(
network: str = Query(..., description="管网名称(或项目代码)"),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
):
"""
获取项目信息
- **network**: 管网名称(或项目代码)
"""
project_detail = await metadata_repo.get_project_detail_by_code(network)
if not project_detail:
raise HTTPException(status_code=404, detail=f"Project {network} not found")
geoserver_payload = None
if project_detail.geoserver:
geoserver_payload = GeoServerConfigResponse(
gs_base_url=project_detail.geoserver.gs_base_url,
gs_admin_user=project_detail.geoserver.gs_admin_user,
gs_datastore_name=project_detail.geoserver.gs_datastore_name,
default_extent=project_detail.geoserver.default_extent,
srid=project_detail.geoserver.srid,
)
return ProjectMetaResponse(
project_id=project_detail.project_id,
name=project_detail.name,
code=project_detail.code,
description=project_detail.description,
gs_workspace=project_detail.gs_workspace,
map_extent=project_detail.map_extent,
status=project_detail.status,
project_role="viewer",
geoserver=geoserver_payload,
)
@router.get("/listprojects/", summary="获取项目列表", description="获取服务器上所有可用的供水管网项目名称列表。")
async def list_projects_endpoint() -> list[str]:
"""
+7 -6
View File
@@ -5,10 +5,10 @@ from pydantic import BaseModel
class GeoServerConfigResponse(BaseModel):
gs_base_url: Optional[str]
gs_admin_user: Optional[str]
gs_base_url: Optional[str] = None
gs_admin_user: Optional[str] = None
gs_datastore_name: str
default_extent: Optional[dict]
default_extent: Optional[dict] = None
srid: int
@@ -16,18 +16,19 @@ class ProjectMetaResponse(BaseModel):
project_id: UUID
name: str
code: str
description: Optional[str]
description: Optional[str] = None
gs_workspace: str
map_extent: Optional[dict] = None
status: str
project_role: str
geoserver: Optional[GeoServerConfigResponse]
geoserver: Optional[GeoServerConfigResponse] = None
class ProjectSummaryResponse(BaseModel):
project_id: UUID
name: str
code: str
description: Optional[str]
description: Optional[str] = None
gs_workspace: str
status: str
project_role: str
+1
View File
@@ -42,6 +42,7 @@ class Project(Base):
code: Mapped[str] = mapped_column(String(50), unique=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
gs_workspace: Mapped[str] = mapped_column(String(100), unique=True)
map_extent: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
status: Mapped[str] = mapped_column(String(20), default="active")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.utcnow
@@ -61,10 +61,23 @@ class ProjectSummary:
code: str
description: Optional[str]
gs_workspace: str
map_extent: Optional[dict]
status: str
project_role: str
@dataclass(frozen=True)
class ProjectDetail:
project_id: UUID
name: str
code: str
description: Optional[str]
gs_workspace: str
map_extent: Optional[dict]
status: str
geoserver: Optional[ProjectGeoServerInfo]
class MetadataRepository:
"""元数据访问层(system_hub"""
@@ -89,6 +102,30 @@ class MetadataRepository:
)
return result.scalar_one_or_none()
async def get_project_by_code(self, code: str) -> Optional[models.Project]:
result = await self.session.execute(
select(models.Project).where(models.Project.code == code)
)
return result.scalar_one_or_none()
async def get_project_detail_by_code(self, code: str) -> Optional[ProjectDetail]:
project = await self.get_project_by_code(code)
if not project:
return None
geoserver = await self.get_geoserver_config(project.id)
return ProjectDetail(
project_id=project.id,
name=project.name,
code=project.code,
description=project.description,
gs_workspace=project.gs_workspace,
map_extent=project.map_extent,
status=project.status,
geoserver=geoserver,
)
async def get_membership_role(
self, project_id: UUID, user_id: UUID
) -> Optional[str]:
@@ -179,6 +216,7 @@ class MetadataRepository:
code=project.code,
description=project.description,
gs_workspace=project.gs_workspace,
map_extent=project.map_extent,
status=project.status,
project_role=role,
)
@@ -196,6 +234,7 @@ class MetadataRepository:
code=project.code,
description=project.description,
gs_workspace=project.gs_workspace,
map_extent=project.map_extent,
status=project.status,
project_role="owner",
)
+92
View File
@@ -0,0 +1,92 @@
from types import SimpleNamespace
from uuid import uuid4
import pytest
from fastapi import HTTPException
from sqlalchemy.exc import SQLAlchemyError
from tests.conftest import install_stub, load_module_from_path
def _load_meta_module(monkeypatch):
install_stub(monkeypatch, "app.auth", package=True)
install_stub(
monkeypatch,
"app.auth.project_dependencies",
{
"ProjectContext": object,
"get_project_context": lambda: None,
"get_project_pg_session": lambda: None,
"get_project_timescale_connection": lambda: None,
"get_metadata_repository": lambda: None,
},
)
install_stub(
monkeypatch,
"app.auth.metadata_dependencies",
{"get_current_metadata_user": lambda: None},
)
return load_module_from_path(
"tests_meta_endpoints_module",
"app/api/v1/endpoints/meta.py",
)
@pytest.mark.anyio
async def test_meta_project_returns_map_extent(monkeypatch):
module = _load_meta_module(monkeypatch)
project_id = uuid4()
repo = SimpleNamespace(
get_project_by_id=lambda _project_id: None,
get_geoserver_config=lambda _project_id: None,
)
async def get_project_by_id(_project_id):
return SimpleNamespace(
id=project_id,
name="Demo Project",
code="demo",
description="desc",
gs_workspace="workspace",
map_extent={"xmin": 1, "ymin": 2, "xmax": 3, "ymax": 4},
status="active",
)
async def get_geoserver_config(_project_id):
return None
repo.get_project_by_id = get_project_by_id
repo.get_geoserver_config = get_geoserver_config
response = await module.get_project_metadata(
ctx=SimpleNamespace(
project_id=project_id,
project_role="editor",
),
metadata_repo=repo,
)
assert response.map_extent == {"xmin": 1, "ymin": 2, "xmax": 3, "ymax": 4}
assert response.project_id == project_id
@pytest.mark.anyio
async def test_meta_db_health_returns_503_for_postgres_errors(monkeypatch):
module = _load_meta_module(monkeypatch)
class BrokenSession:
async def execute(self, _query):
raise SQLAlchemyError("pg unavailable")
class DummyTimescaleConnection:
def cursor(self):
raise AssertionError("timescale should not be queried after postgres failure")
with pytest.raises(HTTPException) as exc_info:
await module.project_db_health(
pg_session=BrokenSession(),
ts_conn=DummyTimescaleConnection(),
)
assert exc_info.value.status_code == 503
assert exc_info.value.detail == "Project PostgreSQL health check failed: pg unavailable"
+108
View File
@@ -0,0 +1,108 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
from fastapi import HTTPException
from tests.conftest import install_stub, load_module_from_path
class DummyChangeSet:
def __init__(self, operations=None):
if operations is None:
self.operations = []
elif isinstance(operations, dict):
self.operations = [operations]
else:
self.operations = operations
def _load_project_module(monkeypatch):
install_stub(monkeypatch, "app.services", package=True)
install_stub(monkeypatch, "app.services.project_info", {})
install_stub(
monkeypatch,
"app.services.tjnetwork",
{
"ChangeSet": DummyChangeSet,
"list_project": lambda: ["demo"],
"have_project": lambda network: network == "demo",
"create_project": lambda network: None,
"delete_project": lambda network: None,
"is_project_open": lambda network: False,
"open_project": lambda network: None,
"close_project": lambda network: None,
"copy_project": lambda source, target: None,
"import_inp": lambda network, cs: {"ok": True},
"export_inp": lambda network, version: DummyChangeSet({"kind": "export"}),
"read_inp": lambda network, inp: True,
"dump_inp": lambda network, inp: True,
"get_all_vertices": lambda network: [],
"get_all_scada_elements": lambda network: [],
"get_all_district_metering_areas": lambda network: [],
"get_all_service_areas": lambda network: [],
"get_all_virtual_districts": lambda network: [],
"get_extension_data": lambda network, key: None,
"convert_inp_v3_to_v2": lambda inp: DummyChangeSet({"inp": inp}),
},
)
install_stub(
monkeypatch,
"app.auth.project_dependencies",
{"get_metadata_repository": lambda: None},
)
install_stub(
monkeypatch,
"app.infra.db.postgresql.database",
{"get_database_instance": lambda network: None},
)
install_stub(
monkeypatch,
"app.infra.db.timescaledb.database",
{"get_database_instance": lambda network: None},
)
return load_module_from_path(
"tests_project_endpoints_module",
"app/api/v1/endpoints/project.py",
)
@pytest.mark.anyio
async def test_project_info_returns_404_when_missing(monkeypatch):
module = _load_project_module(monkeypatch)
repo = SimpleNamespace(get_project_detail_by_code=AsyncMock(return_value=None))
with pytest.raises(HTTPException) as exc_info:
await module.get_project_info_endpoint(network="missing", metadata_repo=repo)
assert exc_info.value.status_code == 404
assert exc_info.value.detail == "Project missing not found"
@pytest.mark.anyio
async def test_project_info_returns_geoserver_payload(monkeypatch):
module = _load_project_module(monkeypatch)
detail = SimpleNamespace(
project_id=uuid4(),
name="Demo Project",
code="demo",
description="desc",
gs_workspace="ws",
map_extent={"xmin": 1, "ymin": 2, "xmax": 3, "ymax": 4},
status="active",
geoserver=SimpleNamespace(
gs_base_url="http://gs",
gs_admin_user="admin",
gs_datastore_name="store",
default_extent={"xmin": 1, "ymin": 2, "xmax": 3, "ymax": 4},
srid=4326,
),
)
repo = SimpleNamespace(get_project_detail_by_code=AsyncMock(return_value=detail))
payload = await module.get_project_info_endpoint(network="demo", metadata_repo=repo)
assert payload.code == "demo"
assert payload.map_extent == {"xmin": 1, "ymin": 2, "xmax": 3, "ymax": 4}
assert payload.geoserver.gs_base_url == "http://gs"
+49 -2
View File
@@ -1,9 +1,16 @@
import pytest
import importlib
import importlib.util
import sys
import os
import types
from pathlib import Path
import pytest
from fastapi import FastAPI
# 自动添加项目根目录到路径(处理项目结构)
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
PROJECT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT_ROOT))
def run_this_test(test_file):
@@ -12,3 +19,43 @@ def run_this_test(test_file):
test_name = os.path.splitext(os.path.basename(test_file))[0]
# 使用pytest运行(自动处理导入)
pytest.main([test_file, "-v"])
def build_test_app(router, prefix: str = "") -> FastAPI:
app = FastAPI()
app.include_router(router, prefix=prefix)
return app
def load_module_from_path(module_name: str, relative_path: str):
module_path = PROJECT_ROOT / relative_path
spec = importlib.util.spec_from_file_location(module_name, module_path)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module)
return module
def install_stub(monkeypatch, name: str, attrs: dict | None = None, package: bool = False):
module = types.ModuleType(name)
if package:
module.__path__ = []
if attrs:
for key, value in attrs.items():
setattr(module, key, value)
monkeypatch.setitem(sys.modules, name, module)
parent_name, _, child_name = name.rpartition(".")
if parent_name:
parent = sys.modules.get(parent_name)
if parent is None:
try:
parent = importlib.import_module(parent_name)
except Exception:
parent = types.ModuleType(parent_name)
parent.__path__ = []
monkeypatch.setitem(sys.modules, parent_name, parent)
setattr(parent, child_name, module)
return module