Project selection previously relied only on membership, so inactive projects remained visible. Filter at the repository boundary and add regression coverage.
506 lines
16 KiB
Python
506 lines
16 KiB
Python
from dataclasses import dataclass
|
||
from datetime import datetime, timezone
|
||
from typing import Optional, List
|
||
from uuid import UUID, uuid4
|
||
|
||
from cryptography.fernet import InvalidToken
|
||
from sqlalchemy import delete, select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.core.encryption import (
|
||
get_database_encryptor,
|
||
is_database_encryption_configured,
|
||
)
|
||
from app.infra.db.metadb import models
|
||
|
||
|
||
def _normalize_postgres_dsn(dsn: str) -> str:
|
||
if not dsn or "://" not in dsn:
|
||
return dsn
|
||
scheme, rest = dsn.split("://", 1)
|
||
if scheme not in ("postgresql", "postgres", "postgresql+psycopg"):
|
||
return dsn
|
||
if scheme == "postgresql+psycopg":
|
||
scheme = "postgresql"
|
||
normalized_dsn = f"{scheme}://{rest}"
|
||
if "@" not in rest:
|
||
return normalized_dsn
|
||
userinfo, hostinfo = rest.rsplit("@", 1)
|
||
if ":" not in userinfo:
|
||
return normalized_dsn
|
||
username, password = userinfo.split(":", 1)
|
||
if "@" not in password:
|
||
return normalized_dsn
|
||
password = password.replace("@", "%40")
|
||
return f"{scheme}://{username}:{password}@{hostinfo}"
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ProjectDbRouting:
|
||
project_id: UUID
|
||
db_role: str
|
||
db_type: str
|
||
dsn: str
|
||
pool_min_size: int
|
||
pool_max_size: int
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ProjectSummary:
|
||
project_id: UUID
|
||
name: str
|
||
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
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ProjectMemberSummary:
|
||
id: UUID
|
||
user_id: UUID
|
||
project_id: UUID
|
||
project_role: str
|
||
username: str
|
||
email: str
|
||
is_active: bool
|
||
|
||
|
||
def _utcnow() -> datetime:
|
||
return datetime.now(timezone.utc)
|
||
|
||
|
||
def _encrypt_database_secret(value: str) -> str:
|
||
if not is_database_encryption_configured():
|
||
raise ValueError("DATABASE_ENCRYPTION_KEY is not configured")
|
||
return get_database_encryptor().encrypt(value)
|
||
|
||
|
||
class MetadataRepository:
|
||
"""元数据访问层(system_hub)"""
|
||
|
||
def __init__(self, session: AsyncSession):
|
||
self.session = session
|
||
|
||
async def get_user_by_keycloak_id(self, keycloak_id: UUID) -> Optional[models.User]:
|
||
result = await self.session.execute(
|
||
select(models.User).where(models.User.keycloak_id == keycloak_id)
|
||
)
|
||
return result.scalar_one_or_none()
|
||
|
||
async def get_user_by_username(self, username: str) -> Optional[models.User]:
|
||
result = await self.session.execute(
|
||
select(models.User).where(models.User.username == username)
|
||
)
|
||
return result.scalar_one_or_none()
|
||
|
||
async def get_user_by_id(self, user_id: UUID) -> Optional[models.User]:
|
||
result = await self.session.execute(
|
||
select(models.User).where(models.User.id == user_id)
|
||
)
|
||
return result.scalar_one_or_none()
|
||
|
||
async def list_users(self, skip: int = 0, limit: int = 100) -> List[models.User]:
|
||
result = await self.session.execute(
|
||
select(models.User)
|
||
.order_by(models.User.created_at.desc())
|
||
.offset(skip)
|
||
.limit(limit)
|
||
)
|
||
return list(result.scalars().all())
|
||
|
||
async def upsert_user_from_keycloak(
|
||
self,
|
||
*,
|
||
keycloak_id: UUID,
|
||
username: str,
|
||
email: str,
|
||
role: str,
|
||
is_active: bool,
|
||
) -> models.User:
|
||
user = await self.get_user_by_keycloak_id(keycloak_id)
|
||
if user is None:
|
||
user = models.User(
|
||
id=uuid4(),
|
||
keycloak_id=keycloak_id,
|
||
username=username,
|
||
email=email,
|
||
role=role,
|
||
is_active=is_active,
|
||
is_superuser=False,
|
||
)
|
||
self.session.add(user)
|
||
else:
|
||
user.username = username
|
||
user.email = email
|
||
user.role = role
|
||
user.is_active = is_active
|
||
await self.session.commit()
|
||
await self.session.refresh(user)
|
||
return user
|
||
|
||
async def refresh_user_keycloak_snapshot(
|
||
self,
|
||
user: models.User,
|
||
*,
|
||
username: str | None,
|
||
email: str | None,
|
||
last_login_at: datetime | None = None,
|
||
) -> models.User:
|
||
if username:
|
||
user.username = username
|
||
if email:
|
||
user.email = email
|
||
user.last_login_at = last_login_at or _utcnow()
|
||
user.updated_at = _utcnow()
|
||
await self.session.commit()
|
||
await self.session.refresh(user)
|
||
return user
|
||
|
||
async def update_user_admin(
|
||
self,
|
||
user_id: UUID,
|
||
*,
|
||
updates: dict,
|
||
) -> Optional[models.User]:
|
||
user = await self.get_user_by_id(user_id)
|
||
if user is None:
|
||
return None
|
||
if "role" in updates:
|
||
user.role = updates["role"]
|
||
if "is_active" in updates:
|
||
user.is_active = updates["is_active"]
|
||
await self.session.commit()
|
||
await self.session.refresh(user)
|
||
return user
|
||
|
||
async def get_project_by_id(self, project_id: UUID) -> Optional[models.Project]:
|
||
result = await self.session.execute(
|
||
select(models.Project).where(models.Project.id == project_id)
|
||
)
|
||
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 list_project_records(self) -> List[models.Project]:
|
||
result = await self.session.execute(
|
||
select(models.Project).order_by(models.Project.name)
|
||
)
|
||
return list(result.scalars().all())
|
||
|
||
async def create_project(
|
||
self,
|
||
*,
|
||
name: str,
|
||
code: str,
|
||
description: str | None,
|
||
gs_workspace: str,
|
||
map_extent: dict | None,
|
||
status: str,
|
||
creator_user_id: UUID | None = None,
|
||
) -> models.Project:
|
||
project = models.Project(
|
||
id=uuid4(),
|
||
name=name,
|
||
code=code,
|
||
description=description,
|
||
gs_workspace=gs_workspace,
|
||
map_extent=map_extent,
|
||
status=status,
|
||
created_at=_utcnow(),
|
||
updated_at=_utcnow(),
|
||
)
|
||
self.session.add(project)
|
||
if creator_user_id is not None:
|
||
self.session.add(
|
||
models.UserProjectMembership(
|
||
id=uuid4(),
|
||
user_id=creator_user_id,
|
||
project_id=project.id,
|
||
project_role="member",
|
||
)
|
||
)
|
||
await self.session.commit()
|
||
await self.session.refresh(project)
|
||
return project
|
||
|
||
async def update_project(
|
||
self,
|
||
project_id: UUID,
|
||
*,
|
||
updates: dict,
|
||
) -> Optional[models.Project]:
|
||
project = await self.get_project_by_id(project_id)
|
||
if project is None:
|
||
return None
|
||
for field in (
|
||
"name",
|
||
"code",
|
||
"description",
|
||
"gs_workspace",
|
||
"map_extent",
|
||
"status",
|
||
):
|
||
if field in updates:
|
||
setattr(project, field, updates[field])
|
||
project.updated_at = _utcnow()
|
||
await self.session.commit()
|
||
await self.session.refresh(project)
|
||
return project
|
||
|
||
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
|
||
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,
|
||
)
|
||
|
||
async def get_membership_role(
|
||
self, project_id: UUID, user_id: UUID
|
||
) -> Optional[str]:
|
||
result = await self.session.execute(
|
||
select(models.UserProjectMembership.project_role).where(
|
||
models.UserProjectMembership.project_id == project_id,
|
||
models.UserProjectMembership.user_id == user_id,
|
||
)
|
||
)
|
||
return result.scalar_one_or_none()
|
||
|
||
async def list_project_members(
|
||
self, project_id: UUID
|
||
) -> List[ProjectMemberSummary]:
|
||
stmt = (
|
||
select(models.UserProjectMembership, models.User)
|
||
.join(models.User, models.User.id == models.UserProjectMembership.user_id)
|
||
.where(models.UserProjectMembership.project_id == project_id)
|
||
.order_by(models.User.username)
|
||
)
|
||
result = await self.session.execute(stmt)
|
||
return [
|
||
ProjectMemberSummary(
|
||
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,
|
||
)
|
||
for membership, user in result.all()
|
||
]
|
||
|
||
async def get_project_membership(
|
||
self, project_id: UUID, user_id: UUID
|
||
) -> Optional[models.UserProjectMembership]:
|
||
result = await self.session.execute(
|
||
select(models.UserProjectMembership).where(
|
||
models.UserProjectMembership.project_id == project_id,
|
||
models.UserProjectMembership.user_id == user_id,
|
||
)
|
||
)
|
||
return result.scalar_one_or_none()
|
||
|
||
async def add_project_member(
|
||
self, project_id: UUID, user_id: UUID, project_role: str
|
||
) -> models.UserProjectMembership:
|
||
membership = models.UserProjectMembership(
|
||
id=uuid4(),
|
||
user_id=user_id,
|
||
project_id=project_id,
|
||
project_role=project_role,
|
||
)
|
||
self.session.add(membership)
|
||
await self.session.commit()
|
||
await self.session.refresh(membership)
|
||
return membership
|
||
|
||
async def update_project_member_role(
|
||
self, project_id: UUID, user_id: UUID, project_role: str
|
||
) -> Optional[models.UserProjectMembership]:
|
||
membership = await self.get_project_membership(project_id, user_id)
|
||
if membership is None:
|
||
return None
|
||
membership.project_role = project_role
|
||
await self.session.commit()
|
||
await self.session.refresh(membership)
|
||
return membership
|
||
|
||
async def remove_project_member(self, project_id: UUID, user_id: UUID) -> bool:
|
||
result = await self.session.execute(
|
||
delete(models.UserProjectMembership).where(
|
||
models.UserProjectMembership.project_id == project_id,
|
||
models.UserProjectMembership.user_id == user_id,
|
||
)
|
||
)
|
||
await self.session.commit()
|
||
return bool(result.rowcount)
|
||
|
||
async def list_project_databases(
|
||
self, project_id: UUID
|
||
) -> List[models.ProjectDatabase]:
|
||
result = await self.session.execute(
|
||
select(models.ProjectDatabase)
|
||
.where(models.ProjectDatabase.project_id == project_id)
|
||
.order_by(models.ProjectDatabase.db_role)
|
||
)
|
||
return list(result.scalars().all())
|
||
|
||
async def get_project_database_config(
|
||
self, project_id: UUID, db_role: str
|
||
) -> Optional[models.ProjectDatabase]:
|
||
result = await self.session.execute(
|
||
select(models.ProjectDatabase).where(
|
||
models.ProjectDatabase.project_id == project_id,
|
||
models.ProjectDatabase.db_role == db_role,
|
||
)
|
||
)
|
||
return result.scalar_one_or_none()
|
||
|
||
async def upsert_project_database_config(
|
||
self,
|
||
project_id: UUID,
|
||
*,
|
||
db_role: str,
|
||
db_type: str,
|
||
dsn: str | None,
|
||
pool_min_size: int,
|
||
pool_max_size: int,
|
||
) -> models.ProjectDatabase:
|
||
record = await self.get_project_database_config(project_id, db_role)
|
||
if record is None:
|
||
if dsn is None:
|
||
raise ValueError("dsn is required when creating project database config")
|
||
record = models.ProjectDatabase(
|
||
id=uuid4(),
|
||
project_id=project_id,
|
||
db_role=db_role,
|
||
db_type=db_type,
|
||
dsn_encrypted=_encrypt_database_secret(dsn),
|
||
pool_min_size=pool_min_size,
|
||
pool_max_size=pool_max_size,
|
||
)
|
||
self.session.add(record)
|
||
else:
|
||
record.db_type = db_type
|
||
if dsn is not None:
|
||
record.dsn_encrypted = _encrypt_database_secret(dsn)
|
||
record.pool_min_size = pool_min_size
|
||
record.pool_max_size = pool_max_size
|
||
await self.session.commit()
|
||
await self.session.refresh(record)
|
||
return record
|
||
|
||
async def delete_project_database_config(
|
||
self, project_id: UUID, db_role: str
|
||
) -> bool:
|
||
result = await self.session.execute(
|
||
delete(models.ProjectDatabase).where(
|
||
models.ProjectDatabase.project_id == project_id,
|
||
models.ProjectDatabase.db_role == db_role,
|
||
)
|
||
)
|
||
await self.session.commit()
|
||
return bool(result.rowcount)
|
||
|
||
async def get_project_db_routing(
|
||
self, project_id: UUID, db_role: str
|
||
) -> Optional[ProjectDbRouting]:
|
||
result = await self.session.execute(
|
||
select(models.ProjectDatabase).where(
|
||
models.ProjectDatabase.project_id == project_id,
|
||
models.ProjectDatabase.db_role == db_role,
|
||
)
|
||
)
|
||
record = result.scalar_one_or_none()
|
||
if not record:
|
||
return None
|
||
if not is_database_encryption_configured():
|
||
raise ValueError("DATABASE_ENCRYPTION_KEY is not configured")
|
||
encryptor = get_database_encryptor()
|
||
try:
|
||
dsn = encryptor.decrypt(record.dsn_encrypted)
|
||
except InvalidToken:
|
||
raise ValueError(
|
||
"Failed to decrypt project DB DSN: DATABASE_ENCRYPTION_KEY mismatch "
|
||
"or invalid dsn_encrypted value"
|
||
)
|
||
dsn = _normalize_postgres_dsn(dsn)
|
||
return ProjectDbRouting(
|
||
project_id=record.project_id,
|
||
db_role=record.db_role,
|
||
db_type=record.db_type,
|
||
dsn=dsn,
|
||
pool_min_size=record.pool_min_size,
|
||
pool_max_size=record.pool_max_size,
|
||
)
|
||
|
||
async def list_projects_for_user(self, user_id: UUID) -> List[ProjectSummary]:
|
||
stmt = (
|
||
select(models.Project, models.UserProjectMembership.project_role)
|
||
.join(
|
||
models.UserProjectMembership,
|
||
models.UserProjectMembership.project_id == models.Project.id,
|
||
)
|
||
.where(
|
||
models.UserProjectMembership.user_id == user_id,
|
||
models.Project.status == "active",
|
||
)
|
||
.order_by(models.Project.name)
|
||
)
|
||
result = await self.session.execute(stmt)
|
||
return [
|
||
ProjectSummary(
|
||
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,
|
||
project_role=role,
|
||
)
|
||
for project, role in result.all()
|
||
]
|
||
|
||
async def list_all_projects(self) -> List[ProjectSummary]:
|
||
result = await self.session.execute(
|
||
select(models.Project).order_by(models.Project.name)
|
||
)
|
||
return [
|
||
ProjectSummary(
|
||
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,
|
||
project_role="member",
|
||
)
|
||
for project in result.scalars().all()
|
||
]
|