From f61be3685f125035a5c1243892ce72e55ccd006e Mon Sep 17 00:00:00 2001 From: Huarch Date: Thu, 11 Jun 2026 11:15:59 +0800 Subject: [PATCH] fix(metadata): sync project metadata endpoints --- app/api/v1/endpoints/meta.py | 24 +++- app/api/v1/endpoints/project.py | 41 ++++++- app/domain/schemas/metadata.py | 13 ++- app/infra/db/metadb/models.py | 1 + .../repositories/metadata_repository.py | 39 +++++++ tests/api/test_meta_endpoints.py | 92 +++++++++++++++ tests/api/test_project_endpoints.py | 108 ++++++++++++++++++ tests/conftest.py | 51 ++++++++- 8 files changed, 357 insertions(+), 12 deletions(-) create mode 100644 tests/api/test_meta_endpoints.py create mode 100644 tests/api/test_project_endpoints.py diff --git a/app/api/v1/endpoints/meta.py b/app/api/v1/endpoints/meta.py index 455189e..c6a6f45 100644 --- a/app/api/v1/endpoints/meta.py +++ b/app/api/v1/endpoints/meta.py @@ -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"} diff --git a/app/api/v1/endpoints/project.py b/app/api/v1/endpoints/project.py index 3c7e898..b2ad5dd 100644 --- a/app/api/v1/endpoints/project.py +++ b/app/api/v1/endpoints/project.py @@ -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]: """ diff --git a/app/domain/schemas/metadata.py b/app/domain/schemas/metadata.py index b3f4952..db3220a 100644 --- a/app/domain/schemas/metadata.py +++ b/app/domain/schemas/metadata.py @@ -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 diff --git a/app/infra/db/metadb/models.py b/app/infra/db/metadb/models.py index 7c48643..6236080 100644 --- a/app/infra/db/metadb/models.py +++ b/app/infra/db/metadb/models.py @@ -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 diff --git a/app/infra/db/metadb/repositories/metadata_repository.py b/app/infra/db/metadb/repositories/metadata_repository.py index 943097f..89b2ae3 100644 --- a/app/infra/db/metadb/repositories/metadata_repository.py +++ b/app/infra/db/metadb/repositories/metadata_repository.py @@ -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", ) diff --git a/tests/api/test_meta_endpoints.py b/tests/api/test_meta_endpoints.py new file mode 100644 index 0000000..4b1c89b --- /dev/null +++ b/tests/api/test_meta_endpoints.py @@ -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" diff --git a/tests/api/test_project_endpoints.py b/tests/api/test_project_endpoints.py new file mode 100644 index 0000000..f7b1059 --- /dev/null +++ b/tests/api/test_project_endpoints.py @@ -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" diff --git a/tests/conftest.py b/tests/conftest.py index b1c7e7b..98b717c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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