805 lines
28 KiB
Python
805 lines
28 KiB
Python
from typing import List
|
|
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Response, status
|
|
from sqlalchemy import text
|
|
from sqlalchemy.engine.url import make_url
|
|
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
|
from sqlalchemy.ext.asyncio import create_async_engine
|
|
|
|
from app.auth.metadata_dependencies import (
|
|
get_current_metadata_admin,
|
|
get_metadata_repository,
|
|
)
|
|
from app.core.audit import AuditAction, log_audit_event
|
|
from app.domain.schemas.admin_metadata import (
|
|
AdminProjectCreateRequest,
|
|
AdminProjectResponse,
|
|
AdminProjectUpdateRequest,
|
|
MetadataUsersBatchSyncRequest,
|
|
MetadataUserResponse,
|
|
MetadataUserSyncRequest,
|
|
MetadataUserSyncResult,
|
|
MetadataUserUpdateRequest,
|
|
ProjectDatabaseHealthResponse,
|
|
ProjectDatabaseHealthRequest,
|
|
ProjectDatabaseResponse,
|
|
ProjectDatabaseUpsertRequest,
|
|
ProjectDbRole,
|
|
ProjectGeoServerConfigResponse,
|
|
ProjectGeoServerConfigUpsertRequest,
|
|
ProjectMemberCreateRequest,
|
|
ProjectMemberResponse,
|
|
ProjectMemberUpdateRequest,
|
|
)
|
|
from app.infra.db.metadb import models
|
|
from app.infra.db.metadb.repositories.metadata_repository import (
|
|
MetadataRepository,
|
|
ProjectDbRouting,
|
|
)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def _project_response(project: models.Project) -> AdminProjectResponse:
|
|
return AdminProjectResponse(
|
|
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,
|
|
created_at=project.created_at,
|
|
updated_at=project.updated_at,
|
|
)
|
|
|
|
|
|
def _project_database_response(
|
|
record: models.ProjectDatabase,
|
|
) -> ProjectDatabaseResponse:
|
|
return ProjectDatabaseResponse(
|
|
id=record.id,
|
|
project_id=record.project_id,
|
|
db_role=record.db_role,
|
|
db_type=record.db_type,
|
|
pool_min_size=record.pool_min_size,
|
|
pool_max_size=record.pool_max_size,
|
|
has_dsn=bool(record.dsn_encrypted),
|
|
)
|
|
|
|
|
|
def _geoserver_config_response(
|
|
record: models.ProjectGeoServerConfig,
|
|
) -> ProjectGeoServerConfigResponse:
|
|
return ProjectGeoServerConfigResponse(
|
|
id=record.id,
|
|
project_id=record.project_id,
|
|
gs_base_url=record.gs_base_url,
|
|
gs_admin_user=record.gs_admin_user,
|
|
gs_datastore_name=record.gs_datastore_name,
|
|
default_extent=record.default_extent,
|
|
srid=record.srid,
|
|
configured=True,
|
|
has_password=bool(record.gs_admin_password_encrypted),
|
|
updated_at=record.updated_at,
|
|
)
|
|
|
|
|
|
def _database_audit_payload(payload: ProjectDatabaseUpsertRequest) -> dict:
|
|
return {
|
|
"db_role": payload.db_role,
|
|
"db_type": _db_type_for_role(payload.db_role),
|
|
"pool_min_size": payload.pool_min_size,
|
|
"pool_max_size": payload.pool_max_size,
|
|
"dsn_updated": payload.dsn is not None,
|
|
}
|
|
|
|
|
|
def _geoserver_audit_payload(
|
|
payload: ProjectGeoServerConfigUpsertRequest,
|
|
) -> dict:
|
|
return {
|
|
"gs_base_url": payload.gs_base_url,
|
|
"gs_admin_user": payload.gs_admin_user,
|
|
"gs_datastore_name": payload.gs_datastore_name,
|
|
"default_extent": payload.default_extent,
|
|
"srid": payload.srid,
|
|
"password_updated": "gs_admin_password" in payload.model_fields_set,
|
|
}
|
|
|
|
|
|
def _to_async_sqlalchemy_url(dsn: str) -> str:
|
|
parsed = make_url(dsn)
|
|
if parsed.drivername in {"postgresql", "postgres"}:
|
|
parsed = parsed.set(drivername="postgresql+psycopg")
|
|
return parsed.render_as_string(hide_password=False)
|
|
|
|
|
|
def _db_type_for_role(db_role: str) -> str:
|
|
if db_role == "iot_data":
|
|
return "timescaledb"
|
|
return "postgresql"
|
|
|
|
|
|
def _status_for_config_value_error(exc: ValueError) -> int:
|
|
if "ENCRYPTION_KEY" in str(exc):
|
|
return status.HTTP_503_SERVICE_UNAVAILABLE
|
|
return status.HTTP_400_BAD_REQUEST
|
|
|
|
|
|
async def _check_database_connection(routing: ProjectDbRouting) -> None:
|
|
engine = create_async_engine(
|
|
_to_async_sqlalchemy_url(routing.dsn),
|
|
pool_size=1,
|
|
max_overflow=0,
|
|
pool_pre_ping=True,
|
|
)
|
|
try:
|
|
async with engine.connect() as conn:
|
|
await conn.execute(text("SELECT 1"))
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
def _database_health_error_detail(exc: Exception) -> str:
|
|
message = str(exc)
|
|
lower_message = message.lower()
|
|
if "password authentication failed" in lower_message:
|
|
return "连通性测试失败:用户名或密码错误,请检查 DSN 中的账号密码。"
|
|
if "connection refused" in lower_message:
|
|
return "连通性测试失败:目标主机或端口拒绝连接,请检查地址、端口和服务状态。"
|
|
if "timeout" in lower_message or "timed out" in lower_message:
|
|
return "连通性测试失败:连接超时,请检查网络、防火墙和数据库服务状态。"
|
|
if "could not translate host name" in lower_message or "name or service not known" in lower_message:
|
|
return "连通性测试失败:数据库主机名无法解析,请检查 DSN 中的主机地址。"
|
|
first_line = message.splitlines()[0] if message else exc.__class__.__name__
|
|
return f"连通性测试失败:{first_line}"
|
|
|
|
|
|
async def _upsert_and_audit_metadata_user(
|
|
payload: MetadataUserSyncRequest,
|
|
*,
|
|
current_user,
|
|
metadata_repo: MetadataRepository,
|
|
response_status: int,
|
|
) -> MetadataUserResponse:
|
|
user = await metadata_repo.upsert_user_from_keycloak(
|
|
keycloak_id=payload.keycloak_id,
|
|
username=payload.username,
|
|
email=str(payload.email),
|
|
role=payload.role,
|
|
is_active=payload.is_active,
|
|
)
|
|
await log_audit_event(
|
|
action=AuditAction.UPDATE,
|
|
user_id=current_user.id,
|
|
resource_type="metadata_user",
|
|
resource_id=str(user.id),
|
|
request_data=payload.model_dump(mode="json"),
|
|
response_status=response_status,
|
|
session=metadata_repo.session,
|
|
)
|
|
return MetadataUserResponse.model_validate(user)
|
|
|
|
|
|
@router.get("/me", response_model=MetadataUserResponse)
|
|
async def get_metadata_admin_me(
|
|
current_user=Depends(get_current_metadata_admin),
|
|
) -> MetadataUserResponse:
|
|
return MetadataUserResponse.model_validate(current_user)
|
|
|
|
|
|
@router.post("/users/sync", response_model=MetadataUserResponse)
|
|
async def sync_metadata_user(
|
|
payload: MetadataUserSyncRequest,
|
|
current_user=Depends(get_current_metadata_admin),
|
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
|
) -> MetadataUserResponse:
|
|
try:
|
|
return await _upsert_and_audit_metadata_user(
|
|
payload,
|
|
current_user=current_user,
|
|
metadata_repo=metadata_repo,
|
|
response_status=status.HTTP_200_OK,
|
|
)
|
|
except IntegrityError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail="User keycloak_id, username, or email conflicts with an existing user",
|
|
) from exc
|
|
except SQLAlchemyError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail=f"Metadata database error: {exc}",
|
|
) from exc
|
|
|
|
|
|
|
|
@router.post("/users/sync/batch", response_model=List[MetadataUserSyncResult])
|
|
async def sync_metadata_users_batch(
|
|
payload: MetadataUsersBatchSyncRequest,
|
|
current_user=Depends(get_current_metadata_admin),
|
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
|
) -> List[MetadataUserSyncResult]:
|
|
results: list[MetadataUserSyncResult] = []
|
|
for item in payload.users:
|
|
try:
|
|
user = await _upsert_and_audit_metadata_user(
|
|
item,
|
|
current_user=current_user,
|
|
metadata_repo=metadata_repo,
|
|
response_status=status.HTTP_200_OK,
|
|
)
|
|
except IntegrityError as exc:
|
|
results.append(
|
|
MetadataUserSyncResult(
|
|
keycloak_id=item.keycloak_id,
|
|
success=False,
|
|
error="User keycloak_id, username, or email conflicts with an existing user",
|
|
)
|
|
)
|
|
await metadata_repo.session.rollback()
|
|
except SQLAlchemyError as exc:
|
|
results.append(
|
|
MetadataUserSyncResult(
|
|
keycloak_id=item.keycloak_id,
|
|
success=False,
|
|
error=f"Metadata database error: {exc}",
|
|
)
|
|
)
|
|
await metadata_repo.session.rollback()
|
|
else:
|
|
results.append(
|
|
MetadataUserSyncResult(
|
|
keycloak_id=item.keycloak_id,
|
|
success=True,
|
|
user=user,
|
|
)
|
|
)
|
|
return results
|
|
|
|
|
|
@router.get("/users", response_model=List[MetadataUserResponse])
|
|
async def list_metadata_users(
|
|
skip: int = Query(0, ge=0),
|
|
limit: int = Query(100, ge=1, le=1000),
|
|
current_user=Depends(get_current_metadata_admin),
|
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
|
) -> List[MetadataUserResponse]:
|
|
users = await metadata_repo.list_users(skip=skip, limit=limit)
|
|
return [MetadataUserResponse.model_validate(user) for user in users]
|
|
|
|
|
|
@router.get("/projects", response_model=List[AdminProjectResponse])
|
|
async def list_admin_projects(
|
|
current_user=Depends(get_current_metadata_admin),
|
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
|
) -> List[AdminProjectResponse]:
|
|
projects = await metadata_repo.list_project_records()
|
|
return [_project_response(project) for project in projects]
|
|
|
|
|
|
@router.post(
|
|
"/projects",
|
|
response_model=AdminProjectResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
async def create_admin_project(
|
|
payload: AdminProjectCreateRequest,
|
|
current_user=Depends(get_current_metadata_admin),
|
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
|
) -> AdminProjectResponse:
|
|
try:
|
|
project = await metadata_repo.create_project(
|
|
name=payload.name,
|
|
code=payload.code,
|
|
description=payload.description,
|
|
gs_workspace=payload.gs_workspace,
|
|
map_extent=payload.map_extent,
|
|
status=payload.status,
|
|
)
|
|
except IntegrityError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail="Project code or GeoServer workspace conflicts with an existing project",
|
|
) from exc
|
|
except SQLAlchemyError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail=f"Metadata database error: {exc}",
|
|
) from exc
|
|
|
|
await log_audit_event(
|
|
action=AuditAction.CREATE,
|
|
user_id=current_user.id,
|
|
project_id=project.id,
|
|
resource_type="project",
|
|
resource_id=str(project.id),
|
|
request_data=payload.model_dump(mode="json"),
|
|
response_status=status.HTTP_201_CREATED,
|
|
session=metadata_repo.session,
|
|
)
|
|
return _project_response(project)
|
|
|
|
|
|
@router.patch(
|
|
"/projects/{project_id}",
|
|
response_model=AdminProjectResponse,
|
|
)
|
|
async def update_admin_project(
|
|
payload: AdminProjectUpdateRequest,
|
|
project_id: UUID = Path(...),
|
|
current_user=Depends(get_current_metadata_admin),
|
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
|
) -> AdminProjectResponse:
|
|
updates = payload.model_dump(mode="json", exclude_unset=True)
|
|
try:
|
|
project = await metadata_repo.update_project(project_id, updates=updates)
|
|
except IntegrityError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail="Project code or GeoServer workspace conflicts with an existing project",
|
|
) from exc
|
|
except SQLAlchemyError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail=f"Metadata database error: {exc}",
|
|
) from exc
|
|
if project is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
|
|
|
await log_audit_event(
|
|
action=AuditAction.UPDATE,
|
|
user_id=current_user.id,
|
|
project_id=project.id,
|
|
resource_type="project",
|
|
resource_id=str(project.id),
|
|
request_data=updates,
|
|
response_status=status.HTTP_200_OK,
|
|
session=metadata_repo.session,
|
|
)
|
|
return _project_response(project)
|
|
|
|
|
|
@router.get(
|
|
"/projects/{project_id}/databases",
|
|
response_model=List[ProjectDatabaseResponse],
|
|
)
|
|
async def list_project_databases(
|
|
project_id: UUID = Path(...),
|
|
current_user=Depends(get_current_metadata_admin),
|
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
|
) -> List[ProjectDatabaseResponse]:
|
|
project = await metadata_repo.get_project_by_id(project_id)
|
|
if project is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
|
records = await metadata_repo.list_project_databases(project_id)
|
|
return [_project_database_response(record) for record in records]
|
|
|
|
|
|
@router.put(
|
|
"/projects/{project_id}/databases",
|
|
response_model=ProjectDatabaseResponse,
|
|
)
|
|
async def upsert_project_database(
|
|
payload: ProjectDatabaseUpsertRequest,
|
|
project_id: UUID = Path(...),
|
|
current_user=Depends(get_current_metadata_admin),
|
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
|
) -> ProjectDatabaseResponse:
|
|
project = await metadata_repo.get_project_by_id(project_id)
|
|
if project is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
|
try:
|
|
routing = (
|
|
ProjectDbRouting(
|
|
project_id=project_id,
|
|
db_role=payload.db_role,
|
|
db_type=_db_type_for_role(payload.db_role),
|
|
dsn=payload.dsn,
|
|
pool_min_size=payload.pool_min_size,
|
|
pool_max_size=payload.pool_max_size,
|
|
)
|
|
if payload.dsn
|
|
else await metadata_repo.get_project_db_routing(project_id, payload.db_role)
|
|
)
|
|
if routing is None:
|
|
raise ValueError("dsn is required when creating project database config")
|
|
await _check_database_connection(routing)
|
|
except ValueError as exc:
|
|
raise HTTPException(
|
|
status_code=_status_for_config_value_error(exc),
|
|
detail=str(exc),
|
|
) from exc
|
|
except Exception as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=_database_health_error_detail(exc),
|
|
) from exc
|
|
|
|
try:
|
|
record = await metadata_repo.upsert_project_database_config(
|
|
project_id,
|
|
db_role=payload.db_role,
|
|
db_type=_db_type_for_role(payload.db_role),
|
|
dsn=payload.dsn,
|
|
pool_min_size=payload.pool_min_size,
|
|
pool_max_size=payload.pool_max_size,
|
|
)
|
|
except IntegrityError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail="Project database role conflicts with an existing config",
|
|
) from exc
|
|
except SQLAlchemyError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail=f"Metadata database error: {exc}",
|
|
) from exc
|
|
|
|
await log_audit_event(
|
|
action=AuditAction.CONFIG_CHANGE,
|
|
user_id=current_user.id,
|
|
project_id=project_id,
|
|
resource_type="project_database",
|
|
resource_id=payload.db_role,
|
|
request_data=_database_audit_payload(payload),
|
|
response_status=status.HTTP_200_OK,
|
|
session=metadata_repo.session,
|
|
)
|
|
return _project_database_response(record)
|
|
|
|
|
|
@router.delete(
|
|
"/projects/{project_id}/databases/{db_role}",
|
|
status_code=status.HTTP_204_NO_CONTENT,
|
|
)
|
|
async def delete_project_database(
|
|
project_id: UUID = Path(...),
|
|
db_role: ProjectDbRole = Path(...),
|
|
current_user=Depends(get_current_metadata_admin),
|
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
|
) -> None:
|
|
removed = await metadata_repo.delete_project_database_config(project_id, db_role)
|
|
if not removed:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Project database config not found",
|
|
)
|
|
await log_audit_event(
|
|
action=AuditAction.CONFIG_CHANGE,
|
|
user_id=current_user.id,
|
|
project_id=project_id,
|
|
resource_type="project_database",
|
|
resource_id=db_role,
|
|
request_data={"deleted": True},
|
|
response_status=status.HTTP_204_NO_CONTENT,
|
|
session=metadata_repo.session,
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/projects/{project_id}/databases/{db_role}/health",
|
|
response_model=ProjectDatabaseHealthResponse,
|
|
)
|
|
async def check_project_database_health(
|
|
response: Response,
|
|
project_id: UUID = Path(...),
|
|
db_role: ProjectDbRole = Path(...),
|
|
payload: ProjectDatabaseHealthRequest | None = None,
|
|
current_user=Depends(get_current_metadata_admin),
|
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
|
) -> ProjectDatabaseHealthResponse:
|
|
dsn_to_test = payload.dsn if payload and payload.dsn else None
|
|
if dsn_to_test:
|
|
routing = ProjectDbRouting(
|
|
project_id=project_id,
|
|
db_role=db_role,
|
|
db_type=_db_type_for_role(db_role),
|
|
dsn=dsn_to_test,
|
|
pool_min_size=1,
|
|
pool_max_size=1,
|
|
)
|
|
else:
|
|
try:
|
|
routing = await metadata_repo.get_project_db_routing(project_id, db_role)
|
|
except ValueError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail=f"Project database routing DSN is invalid: {exc}",
|
|
) from exc
|
|
if routing is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Project database config not found",
|
|
)
|
|
|
|
try:
|
|
await _check_database_connection(routing)
|
|
except Exception as exc: # health endpoint should return diagnostic status
|
|
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
|
return ProjectDatabaseHealthResponse(
|
|
project_id=project_id,
|
|
db_role=db_role,
|
|
db_type=routing.db_type,
|
|
ok=False,
|
|
detail=_database_health_error_detail(exc),
|
|
)
|
|
return ProjectDatabaseHealthResponse(
|
|
project_id=project_id,
|
|
db_role=db_role,
|
|
db_type=routing.db_type,
|
|
ok=True,
|
|
detail="连通性测试通过",
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/projects/{project_id}/geoserver",
|
|
response_model=ProjectGeoServerConfigResponse,
|
|
)
|
|
async def get_project_geoserver_config(
|
|
project_id: UUID = Path(...),
|
|
current_user=Depends(get_current_metadata_admin),
|
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
|
) -> ProjectGeoServerConfigResponse:
|
|
project = await metadata_repo.get_project_by_id(project_id)
|
|
if project is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
|
record = await metadata_repo.get_geoserver_config_record(project_id)
|
|
if record is None:
|
|
return ProjectGeoServerConfigResponse(
|
|
id=None,
|
|
project_id=project_id,
|
|
gs_base_url=None,
|
|
gs_admin_user=None,
|
|
gs_datastore_name="ds_postgis",
|
|
default_extent=None,
|
|
srid=4326,
|
|
configured=False,
|
|
has_password=False,
|
|
updated_at=None,
|
|
)
|
|
return _geoserver_config_response(record)
|
|
|
|
|
|
@router.put(
|
|
"/projects/{project_id}/geoserver",
|
|
response_model=ProjectGeoServerConfigResponse,
|
|
)
|
|
async def upsert_project_geoserver_config(
|
|
payload: ProjectGeoServerConfigUpsertRequest,
|
|
project_id: UUID = Path(...),
|
|
current_user=Depends(get_current_metadata_admin),
|
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
|
) -> ProjectGeoServerConfigResponse:
|
|
project = await metadata_repo.get_project_by_id(project_id)
|
|
if project is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
|
try:
|
|
record = await metadata_repo.upsert_geoserver_config(
|
|
project_id,
|
|
gs_base_url=payload.gs_base_url,
|
|
gs_admin_user=payload.gs_admin_user,
|
|
gs_admin_password=payload.gs_admin_password,
|
|
password_update_requested="gs_admin_password" in payload.model_fields_set,
|
|
gs_datastore_name=payload.gs_datastore_name,
|
|
default_extent=payload.default_extent,
|
|
srid=payload.srid,
|
|
)
|
|
except ValueError as exc:
|
|
raise HTTPException(
|
|
status_code=_status_for_config_value_error(exc),
|
|
detail=str(exc),
|
|
) from exc
|
|
except SQLAlchemyError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail=f"Metadata database error: {exc}",
|
|
) from exc
|
|
|
|
await log_audit_event(
|
|
action=AuditAction.CONFIG_CHANGE,
|
|
user_id=current_user.id,
|
|
project_id=project_id,
|
|
resource_type="project_geoserver",
|
|
resource_id=str(project_id),
|
|
request_data=_geoserver_audit_payload(payload),
|
|
response_status=status.HTTP_200_OK,
|
|
session=metadata_repo.session,
|
|
)
|
|
return _geoserver_config_response(record)
|
|
|
|
|
|
@router.get("/users/{user_id}", response_model=MetadataUserResponse)
|
|
async def get_metadata_user(
|
|
user_id: UUID = Path(...),
|
|
current_user=Depends(get_current_metadata_admin),
|
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
|
) -> MetadataUserResponse:
|
|
user = await metadata_repo.get_user_by_id(user_id)
|
|
if user is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
|
return MetadataUserResponse.model_validate(user)
|
|
|
|
|
|
@router.patch("/users/{user_id}", response_model=MetadataUserResponse)
|
|
async def update_metadata_user(
|
|
payload: MetadataUserUpdateRequest,
|
|
user_id: UUID = Path(...),
|
|
current_user=Depends(get_current_metadata_admin),
|
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
|
) -> MetadataUserResponse:
|
|
updates = payload.model_dump(mode="json", exclude_unset=True)
|
|
if user_id == current_user.id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Users cannot modify themselves",
|
|
)
|
|
user = await metadata_repo.update_user_admin(
|
|
user_id,
|
|
updates=updates,
|
|
)
|
|
if user is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
|
|
|
await log_audit_event(
|
|
action=AuditAction.UPDATE,
|
|
user_id=current_user.id,
|
|
resource_type="metadata_user",
|
|
resource_id=str(user.id),
|
|
request_data=updates,
|
|
response_status=status.HTTP_200_OK,
|
|
session=metadata_repo.session,
|
|
)
|
|
return MetadataUserResponse.model_validate(user)
|
|
|
|
|
|
@router.get(
|
|
"/projects/{project_id}/members",
|
|
response_model=List[ProjectMemberResponse],
|
|
)
|
|
async def list_project_members(
|
|
project_id: UUID = Path(...),
|
|
current_user=Depends(get_current_metadata_admin),
|
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
|
) -> List[ProjectMemberResponse]:
|
|
project = await metadata_repo.get_project_by_id(project_id)
|
|
if project is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Project not found"
|
|
)
|
|
members = await metadata_repo.list_project_members(project_id)
|
|
return [ProjectMemberResponse(**member.__dict__) for member in members]
|
|
|
|
|
|
@router.post(
|
|
"/projects/{project_id}/members",
|
|
response_model=ProjectMemberResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
async def add_project_member(
|
|
payload: ProjectMemberCreateRequest,
|
|
project_id: UUID = Path(...),
|
|
current_user=Depends(get_current_metadata_admin),
|
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
|
) -> ProjectMemberResponse:
|
|
if payload.user_id == current_user.id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Users cannot modify their own project membership",
|
|
)
|
|
project = await metadata_repo.get_project_by_id(project_id)
|
|
if project is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Project not found"
|
|
)
|
|
user = await metadata_repo.get_user_by_id(payload.user_id)
|
|
if user is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
|
existing = await metadata_repo.get_project_membership(project_id, payload.user_id)
|
|
if existing is not None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail="User is already a project member",
|
|
)
|
|
|
|
membership = await metadata_repo.add_project_member(
|
|
project_id, payload.user_id, payload.project_role
|
|
)
|
|
await log_audit_event(
|
|
action=AuditAction.PERMISSION_CHANGE,
|
|
user_id=current_user.id,
|
|
project_id=project_id,
|
|
resource_type="project_member",
|
|
resource_id=str(payload.user_id),
|
|
request_data=payload.model_dump(mode="json"),
|
|
response_status=status.HTTP_201_CREATED,
|
|
session=metadata_repo.session,
|
|
)
|
|
return ProjectMemberResponse(
|
|
id=membership.id,
|
|
user_id=membership.user_id,
|
|
project_id=membership.project_id,
|
|
project_role=membership.project_role,
|
|
username=user.username,
|
|
email=user.email,
|
|
is_active=user.is_active,
|
|
)
|
|
|
|
|
|
@router.patch(
|
|
"/projects/{project_id}/members/{user_id}",
|
|
response_model=ProjectMemberResponse,
|
|
)
|
|
async def update_project_member(
|
|
payload: ProjectMemberUpdateRequest,
|
|
project_id: UUID = Path(...),
|
|
user_id: UUID = Path(...),
|
|
current_user=Depends(get_current_metadata_admin),
|
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
|
) -> ProjectMemberResponse:
|
|
if user_id == current_user.id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Users cannot modify their own project membership",
|
|
)
|
|
user = await metadata_repo.get_user_by_id(user_id)
|
|
if user is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
|
membership = await metadata_repo.update_project_member_role(
|
|
project_id, user_id, payload.project_role
|
|
)
|
|
if membership is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Project member not found"
|
|
)
|
|
await log_audit_event(
|
|
action=AuditAction.PERMISSION_CHANGE,
|
|
user_id=current_user.id,
|
|
project_id=project_id,
|
|
resource_type="project_member",
|
|
resource_id=str(user_id),
|
|
request_data=payload.model_dump(mode="json"),
|
|
response_status=status.HTTP_200_OK,
|
|
session=metadata_repo.session,
|
|
)
|
|
return ProjectMemberResponse(
|
|
id=membership.id,
|
|
user_id=membership.user_id,
|
|
project_id=membership.project_id,
|
|
project_role=membership.project_role,
|
|
username=user.username,
|
|
email=user.email,
|
|
is_active=user.is_active,
|
|
)
|
|
|
|
|
|
@router.delete("/projects/{project_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def remove_project_member(
|
|
project_id: UUID = Path(...),
|
|
user_id: UUID = Path(...),
|
|
current_user=Depends(get_current_metadata_admin),
|
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
|
) -> None:
|
|
if user_id == current_user.id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Users cannot modify their own project membership",
|
|
)
|
|
removed = await metadata_repo.remove_project_member(project_id, user_id)
|
|
if not removed:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Project member not found"
|
|
)
|
|
await log_audit_event(
|
|
action=AuditAction.PERMISSION_CHANGE,
|
|
user_id=current_user.id,
|
|
project_id=project_id,
|
|
resource_type="project_member",
|
|
resource_id=str(user_id),
|
|
response_status=status.HTTP_204_NO_CONTENT,
|
|
session=metadata_repo.session,
|
|
)
|