612 lines
22 KiB
Python
612 lines
22 KiB
Python
"""Workspace lifecycle and membership service.
|
|
|
|
This module keeps the current single-tenant implementation compatible while
|
|
introducing the product model: every account owns a personal workspace and may
|
|
join team workspaces through memberships.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta
|
|
import hashlib
|
|
import secrets
|
|
from typing import Any
|
|
|
|
from fastapi import HTTPException, status
|
|
from sqlalchemy import and_, func, select
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from ..models import Project, Tenant, TenantInvitation, TenantMembership, User, UserProject, UserStatus
|
|
from ..services.auth import AuthService, has_any_role
|
|
from ..utils.timezone import now_cn_naive
|
|
|
|
INVITATION_TOKEN_BYTES = 32
|
|
INVITATION_EXPIRE_DAYS = 7
|
|
ACTIVE_MEMBERSHIP_STATUS = "active"
|
|
PERSONAL_WORKSPACE_TYPE = "personal"
|
|
TEAM_WORKSPACE_TYPE = "team"
|
|
VALID_WORKSPACE_ROLES = {"OWNER", "REVIEWER", "BUILDER", "OPERATOR"}
|
|
TEAM_INVITABLE_ROLES = {"REVIEWER", "BUILDER", "OPERATOR"}
|
|
|
|
|
|
def hash_invitation_token(token: str) -> str:
|
|
return hashlib.sha256(str(token or "").encode("utf-8")).hexdigest()
|
|
|
|
|
|
def _normalize_email(email: str) -> str:
|
|
normalized = str(email or "").strip().lower()
|
|
if not normalized or "@" not in normalized:
|
|
raise HTTPException(status_code=400, detail="有效邮箱不能为空")
|
|
return normalized
|
|
|
|
|
|
def _phone_invitation_email(phone_e164: str) -> str:
|
|
safe = "".join(ch for ch in str(phone_e164 or "") if ch.isdigit())
|
|
return f"{safe}@phone.pmflow.local"
|
|
|
|
|
|
def _normalize_role(role: str) -> str:
|
|
normalized = str(role or "").strip().upper()
|
|
if normalized not in VALID_WORKSPACE_ROLES:
|
|
raise HTTPException(status_code=400, detail=f"无效空间角色:{role}")
|
|
return normalized
|
|
|
|
|
|
def _workspace_payload(tenant: Tenant, membership: TenantMembership | None = None) -> dict[str, Any]:
|
|
return {
|
|
"id": int(tenant.id),
|
|
"name": tenant.name,
|
|
"domain": tenant.domain,
|
|
"workspace_type": tenant.workspace_type,
|
|
"slug": tenant.slug,
|
|
"owner_user_id": int(tenant.owner_user_id) if tenant.owner_user_id is not None else None,
|
|
"status": tenant.status,
|
|
"role": membership.role if membership else None,
|
|
"membership_status": membership.status if membership else None,
|
|
"is_personal_owner": bool(membership.is_personal_owner) if membership else False,
|
|
}
|
|
|
|
|
|
async def ensure_personal_workspace(
|
|
db: AsyncSession,
|
|
*,
|
|
user: User,
|
|
commit: bool = False,
|
|
) -> Tenant:
|
|
"""Ensure a user has a personal workspace and membership."""
|
|
row = await db.execute(
|
|
select(Tenant)
|
|
.join(TenantMembership, TenantMembership.tenant_id == Tenant.id)
|
|
.where(
|
|
and_(
|
|
TenantMembership.user_id == int(user.id),
|
|
TenantMembership.status == ACTIVE_MEMBERSHIP_STATUS,
|
|
TenantMembership.is_personal_owner.is_(True),
|
|
Tenant.workspace_type == PERSONAL_WORKSPACE_TYPE,
|
|
)
|
|
)
|
|
.order_by(Tenant.id.asc())
|
|
)
|
|
existing = row.scalars().first()
|
|
if existing:
|
|
return existing
|
|
|
|
display_name = str(user.name or user.username or "我的").strip()
|
|
tenant = Tenant(
|
|
name=f"{display_name} 的个人空间",
|
|
domain=None,
|
|
workspace_type=PERSONAL_WORKSPACE_TYPE,
|
|
slug=f"personal-{int(user.id)}",
|
|
owner_user_id=int(user.id),
|
|
status="active",
|
|
)
|
|
db.add(tenant)
|
|
await db.flush()
|
|
db.add(
|
|
TenantMembership(
|
|
tenant_id=int(tenant.id),
|
|
user_id=int(user.id),
|
|
role="OWNER",
|
|
status=ACTIVE_MEMBERSHIP_STATUS,
|
|
is_personal_owner=True,
|
|
)
|
|
)
|
|
if commit:
|
|
await db.commit()
|
|
await db.refresh(tenant)
|
|
return tenant
|
|
|
|
|
|
async def ensure_active_workspace_membership(
|
|
db: AsyncSession,
|
|
*,
|
|
user: User,
|
|
commit: bool = False,
|
|
) -> TenantMembership:
|
|
"""Backfill and return membership for the user's current compatibility tenant."""
|
|
tenant_id = int(getattr(user, "tenant_id", 0) or 0)
|
|
if tenant_id <= 0:
|
|
personal = await ensure_personal_workspace(db, user=user, commit=False)
|
|
user.tenant_id = int(personal.id)
|
|
user.role = "OWNER"
|
|
tenant_id = int(personal.id)
|
|
|
|
row = await db.execute(
|
|
select(TenantMembership).where(
|
|
and_(
|
|
TenantMembership.tenant_id == tenant_id,
|
|
TenantMembership.user_id == int(user.id),
|
|
)
|
|
)
|
|
)
|
|
membership = row.scalar_one_or_none()
|
|
if not membership:
|
|
membership = TenantMembership(
|
|
tenant_id=tenant_id,
|
|
user_id=int(user.id),
|
|
role=str(user.role or "BUILDER").upper(),
|
|
status=ACTIVE_MEMBERSHIP_STATUS,
|
|
is_personal_owner=False,
|
|
)
|
|
db.add(membership)
|
|
await db.flush()
|
|
if commit:
|
|
await db.commit()
|
|
await db.refresh(membership)
|
|
return membership
|
|
|
|
|
|
async def register_user_with_personal_workspace(
|
|
db: AsyncSession,
|
|
*,
|
|
username: str,
|
|
password: str,
|
|
name: str,
|
|
email: str,
|
|
) -> User:
|
|
normalized_email = _normalize_email(email)
|
|
username_value = str(username or "").strip()
|
|
if not username_value:
|
|
raise HTTPException(status_code=400, detail="用户名不能为空")
|
|
|
|
existing = (
|
|
await db.execute(
|
|
select(User).where((func.lower(User.username) == username_value.lower()) | (func.lower(User.email) == normalized_email))
|
|
)
|
|
).scalar_one_or_none()
|
|
if existing:
|
|
raise HTTPException(status_code=409, detail="用户名或邮箱已存在")
|
|
|
|
tenant = Tenant(
|
|
name=f"{(name or username_value).strip()} 的个人空间",
|
|
workspace_type=PERSONAL_WORKSPACE_TYPE,
|
|
slug=None,
|
|
owner_user_id=None,
|
|
status="active",
|
|
)
|
|
db.add(tenant)
|
|
await db.flush()
|
|
user = User(
|
|
tenant_id=int(tenant.id),
|
|
username=username_value,
|
|
password_hash=AuthService.hash_password(password),
|
|
name=(name or username_value).strip(),
|
|
email=normalized_email,
|
|
role="OWNER",
|
|
assigned_domains=[],
|
|
workload=0,
|
|
status=UserStatus.ACTIVE.value,
|
|
is_active=True,
|
|
)
|
|
db.add(user)
|
|
await db.flush()
|
|
tenant.owner_user_id = int(user.id)
|
|
tenant.slug = f"personal-{int(user.id)}"
|
|
db.add(
|
|
TenantMembership(
|
|
tenant_id=int(tenant.id),
|
|
user_id=int(user.id),
|
|
role="OWNER",
|
|
status=ACTIVE_MEMBERSHIP_STATUS,
|
|
is_personal_owner=True,
|
|
)
|
|
)
|
|
await db.commit()
|
|
await db.refresh(user)
|
|
return user
|
|
|
|
|
|
async def list_user_workspaces(db: AsyncSession, *, user: User) -> dict[str, Any]:
|
|
await ensure_personal_workspace(db, user=user, commit=False)
|
|
await ensure_active_workspace_membership(db, user=user, commit=False)
|
|
await db.commit()
|
|
rows = (
|
|
await db.execute(
|
|
select(Tenant, TenantMembership)
|
|
.join(TenantMembership, TenantMembership.tenant_id == Tenant.id)
|
|
.where(
|
|
and_(
|
|
TenantMembership.user_id == int(user.id),
|
|
TenantMembership.status == ACTIVE_MEMBERSHIP_STATUS,
|
|
Tenant.status == "active",
|
|
)
|
|
)
|
|
.order_by(Tenant.workspace_type.desc(), Tenant.id.asc())
|
|
)
|
|
).all()
|
|
workspaces = [_workspace_payload(tenant, membership) for tenant, membership in rows]
|
|
active_workspace = next((item for item in workspaces if int(item["id"]) == int(user.tenant_id)), None)
|
|
return {"active_workspace": active_workspace, "workspaces": workspaces}
|
|
|
|
|
|
async def create_team_workspace(db: AsyncSession, *, current_user: User, name: str) -> dict[str, Any]:
|
|
team_name = str(name or "").strip()
|
|
if not team_name:
|
|
raise HTTPException(status_code=400, detail="团队空间名称不能为空")
|
|
tenant = Tenant(
|
|
name=team_name,
|
|
workspace_type=TEAM_WORKSPACE_TYPE,
|
|
owner_user_id=int(current_user.id),
|
|
status="active",
|
|
)
|
|
db.add(tenant)
|
|
await db.flush()
|
|
membership = TenantMembership(
|
|
tenant_id=int(tenant.id),
|
|
user_id=int(current_user.id),
|
|
role="OWNER",
|
|
status=ACTIVE_MEMBERSHIP_STATUS,
|
|
is_personal_owner=False,
|
|
)
|
|
db.add(membership)
|
|
current_user.tenant_id = int(tenant.id)
|
|
current_user.role = "OWNER"
|
|
await db.commit()
|
|
await db.refresh(tenant)
|
|
return _workspace_payload(tenant, membership)
|
|
|
|
|
|
async def switch_workspace(db: AsyncSession, *, current_user: User, tenant_id: int) -> dict[str, Any]:
|
|
row = (
|
|
await db.execute(
|
|
select(Tenant, TenantMembership)
|
|
.join(TenantMembership, TenantMembership.tenant_id == Tenant.id)
|
|
.where(
|
|
and_(
|
|
Tenant.id == int(tenant_id),
|
|
Tenant.status == "active",
|
|
TenantMembership.user_id == int(current_user.id),
|
|
TenantMembership.status == ACTIVE_MEMBERSHIP_STATUS,
|
|
)
|
|
)
|
|
)
|
|
).one_or_none()
|
|
if not row:
|
|
raise HTTPException(status_code=403, detail="你不是该空间成员")
|
|
tenant, membership = row
|
|
current_user.tenant_id = int(tenant.id)
|
|
current_user.role = str(membership.role or current_user.role)
|
|
await db.commit()
|
|
await db.refresh(current_user)
|
|
return _workspace_payload(tenant, membership)
|
|
|
|
|
|
async def create_invitation(
|
|
db: AsyncSession,
|
|
*,
|
|
current_user: User,
|
|
email: str | None = None,
|
|
phone_country_code: str | None = None,
|
|
phone_number: str | None = None,
|
|
role: str,
|
|
project_ids: list[int] | None = None,
|
|
) -> dict[str, Any]:
|
|
workspace_role = str(current_user.role or "").upper()
|
|
invite_role = _normalize_role(role)
|
|
if invite_role not in TEAM_INVITABLE_ROLES:
|
|
raise HTTPException(status_code=400, detail="邀请成员角色只能是 REVIEWER、BUILDER 或 OPERATOR")
|
|
if not has_any_role(workspace_role, ["OWNER", "REVIEWER"]):
|
|
raise HTTPException(status_code=403, detail="只有空间负责人可以邀请成员")
|
|
if invite_role == "REVIEWER" and not has_any_role(workspace_role, ["OWNER"]):
|
|
raise HTTPException(status_code=403, detail="只有 OWNER 可以邀请 REVIEWER")
|
|
|
|
from .phone_auth_service import mask_phone, normalize_phone
|
|
|
|
normalized_phone = normalize_phone(phone_country_code, phone_number)
|
|
normalized_email = _normalize_email(email or _phone_invitation_email(normalized_phone["phone_e164"]))
|
|
tenant_id = int(current_user.tenant_id)
|
|
project_id_list = sorted({int(pid) for pid in (project_ids or []) if int(pid) > 0})
|
|
if project_id_list:
|
|
existing_projects = (
|
|
await db.execute(
|
|
select(Project.id).where(and_(Project.tenant_id == tenant_id, Project.id.in_(project_id_list)))
|
|
)
|
|
).scalars().all()
|
|
if len(set(existing_projects)) != len(project_id_list):
|
|
raise HTTPException(status_code=400, detail="项目不属于当前空间")
|
|
|
|
existing_invitation = (
|
|
await db.execute(
|
|
select(TenantInvitation).where(
|
|
and_(
|
|
TenantInvitation.tenant_id == tenant_id,
|
|
TenantInvitation.phone_e164 == normalized_phone["phone_e164"],
|
|
TenantInvitation.status == "pending",
|
|
)
|
|
)
|
|
)
|
|
).scalar_one_or_none()
|
|
|
|
token = secrets.token_urlsafe(INVITATION_TOKEN_BYTES)
|
|
if existing_invitation:
|
|
invitation = existing_invitation
|
|
invitation.email = normalized_email
|
|
invitation.phone_country_code = normalized_phone["phone_country_code"]
|
|
invitation.phone_number = normalized_phone["phone_number"]
|
|
invitation.phone_e164 = normalized_phone["phone_e164"]
|
|
invitation.role = invite_role
|
|
invitation.project_ids = project_id_list
|
|
invitation.token_hash = hash_invitation_token(token)
|
|
invitation.expires_at = now_cn_naive() + timedelta(days=INVITATION_EXPIRE_DAYS)
|
|
invitation.invited_by_user_id = int(current_user.id)
|
|
else:
|
|
invitation = TenantInvitation(
|
|
tenant_id=tenant_id,
|
|
email=normalized_email,
|
|
phone_country_code=normalized_phone["phone_country_code"],
|
|
phone_number=normalized_phone["phone_number"],
|
|
phone_e164=normalized_phone["phone_e164"],
|
|
role=invite_role,
|
|
project_ids=project_id_list,
|
|
token_hash=hash_invitation_token(token),
|
|
status="pending",
|
|
expires_at=now_cn_naive() + timedelta(days=INVITATION_EXPIRE_DAYS),
|
|
invited_by_user_id=int(current_user.id),
|
|
)
|
|
db.add(invitation)
|
|
try:
|
|
await db.commit()
|
|
await db.refresh(invitation)
|
|
except IntegrityError as exc:
|
|
await db.rollback()
|
|
raise HTTPException(status_code=409, detail="邀请链接生成冲突,请重试") from exc
|
|
return {
|
|
"id": int(invitation.id),
|
|
"email": invitation.email,
|
|
"phone_country_code": invitation.phone_country_code,
|
|
"phone_number": invitation.phone_number,
|
|
"phone_e164": invitation.phone_e164,
|
|
"masked_phone": mask_phone(invitation.phone_e164 or ""),
|
|
"role": invitation.role,
|
|
"project_ids": invitation.project_ids or [],
|
|
"status": invitation.status,
|
|
"expires_at": invitation.expires_at.isoformat(),
|
|
"token": token,
|
|
}
|
|
|
|
|
|
async def _apply_invitation_to_user(
|
|
db: AsyncSession,
|
|
*,
|
|
invitation: TenantInvitation,
|
|
user: User,
|
|
switch_to_workspace: bool,
|
|
) -> dict[str, Any]:
|
|
membership = (
|
|
await db.execute(
|
|
select(TenantMembership).where(
|
|
and_(
|
|
TenantMembership.tenant_id == int(invitation.tenant_id),
|
|
TenantMembership.user_id == int(user.id),
|
|
)
|
|
)
|
|
)
|
|
).scalar_one_or_none()
|
|
if not membership:
|
|
membership = TenantMembership(
|
|
tenant_id=int(invitation.tenant_id),
|
|
user_id=int(user.id),
|
|
role=invitation.role,
|
|
status=ACTIVE_MEMBERSHIP_STATUS,
|
|
is_personal_owner=False,
|
|
)
|
|
db.add(membership)
|
|
else:
|
|
membership.role = invitation.role
|
|
membership.status = ACTIVE_MEMBERSHIP_STATUS
|
|
|
|
for project_id in sorted({int(pid) for pid in (invitation.project_ids or [])}):
|
|
exists = (
|
|
await db.execute(
|
|
select(UserProject.id).where(
|
|
and_(
|
|
UserProject.tenant_id == int(invitation.tenant_id),
|
|
UserProject.user_id == int(user.id),
|
|
UserProject.project_id == project_id,
|
|
)
|
|
)
|
|
)
|
|
).scalar_one_or_none()
|
|
if exists is None:
|
|
db.add(UserProject(tenant_id=int(invitation.tenant_id), user_id=int(user.id), project_id=project_id))
|
|
|
|
invitation.status = "accepted"
|
|
invitation.accepted_by_user_id = int(user.id)
|
|
if switch_to_workspace:
|
|
user.tenant_id = int(invitation.tenant_id)
|
|
user.role = invitation.role
|
|
tenant = (await db.execute(select(Tenant).where(Tenant.id == int(invitation.tenant_id)))).scalar_one_or_none()
|
|
return {
|
|
"id": int(invitation.id),
|
|
"workspace_id": int(invitation.tenant_id),
|
|
"workspace_name": tenant.name if tenant else None,
|
|
"role": invitation.role,
|
|
"project_ids": invitation.project_ids or [],
|
|
"status": invitation.status,
|
|
}
|
|
|
|
|
|
async def accept_invitation_by_verified_phone(
|
|
db: AsyncSession,
|
|
*,
|
|
token: str,
|
|
user: User,
|
|
phone_e164: str,
|
|
switch_to_workspace: bool = True,
|
|
) -> dict[str, Any]:
|
|
invitation = (
|
|
await db.execute(
|
|
select(TenantInvitation).where(TenantInvitation.token_hash == hash_invitation_token(token))
|
|
)
|
|
).scalar_one_or_none()
|
|
if not invitation or invitation.status != "pending":
|
|
raise HTTPException(status_code=404, detail="邀请不存在或已失效")
|
|
if invitation.expires_at < now_cn_naive():
|
|
invitation.status = "expired"
|
|
await db.flush()
|
|
raise HTTPException(status_code=410, detail="邀请已过期")
|
|
if str(invitation.phone_e164 or "") != str(phone_e164 or ""):
|
|
raise HTTPException(status_code=403, detail="当前验证手机号与邀请手机号不一致")
|
|
return await _apply_invitation_to_user(db, invitation=invitation, user=user, switch_to_workspace=switch_to_workspace)
|
|
|
|
|
|
async def accept_pending_invitations_for_phone(
|
|
db: AsyncSession,
|
|
*,
|
|
user: User,
|
|
phone_e164: str,
|
|
auto_accept: bool = False,
|
|
) -> list[dict[str, Any]]:
|
|
rows = (
|
|
await db.execute(
|
|
select(TenantInvitation)
|
|
.where(
|
|
and_(
|
|
TenantInvitation.phone_e164 == phone_e164,
|
|
TenantInvitation.status == "pending",
|
|
TenantInvitation.expires_at >= now_cn_naive(),
|
|
)
|
|
)
|
|
.order_by(TenantInvitation.created_at.asc(), TenantInvitation.id.asc())
|
|
)
|
|
).scalars().all()
|
|
if not auto_accept:
|
|
payload: list[dict[str, Any]] = []
|
|
for invitation in rows:
|
|
tenant = (await db.execute(select(Tenant).where(Tenant.id == int(invitation.tenant_id)))).scalar_one_or_none()
|
|
payload.append(
|
|
{
|
|
"id": int(invitation.id),
|
|
"workspace_id": int(invitation.tenant_id),
|
|
"workspace_name": tenant.name if tenant else None,
|
|
"role": invitation.role,
|
|
"project_ids": invitation.project_ids or [],
|
|
"expires_at": invitation.expires_at.isoformat(),
|
|
}
|
|
)
|
|
return payload
|
|
accepted: list[dict[str, Any]] = []
|
|
for invitation in rows:
|
|
accepted.append(await _apply_invitation_to_user(db, invitation=invitation, user=user, switch_to_workspace=False))
|
|
return accepted
|
|
|
|
|
|
async def accept_invitation(
|
|
db: AsyncSession,
|
|
*,
|
|
token: str,
|
|
current_user: User | None = None,
|
|
new_user: Any | None = None,
|
|
) -> dict[str, Any]:
|
|
invitation = (
|
|
await db.execute(
|
|
select(TenantInvitation).where(TenantInvitation.token_hash == hash_invitation_token(token))
|
|
)
|
|
).scalar_one_or_none()
|
|
if not invitation or invitation.status != "pending":
|
|
raise HTTPException(status_code=404, detail="邀请不存在或已失效")
|
|
if invitation.expires_at < now_cn_naive():
|
|
invitation.status = "expired"
|
|
await db.commit()
|
|
raise HTTPException(status_code=410, detail="邀请已过期")
|
|
|
|
user = current_user
|
|
if user is None:
|
|
if new_user is None:
|
|
raise HTTPException(status_code=401, detail="接受邀请前需要登录或创建账号")
|
|
if _normalize_email(new_user.email or "") != _normalize_email(invitation.email):
|
|
raise HTTPException(status_code=400, detail="注册邮箱必须与邀请邮箱一致")
|
|
user = await register_user_with_personal_workspace(
|
|
db,
|
|
username=new_user.username,
|
|
password=new_user.password,
|
|
name=new_user.name,
|
|
email=new_user.email or invitation.email,
|
|
)
|
|
else:
|
|
if invitation.phone_e164:
|
|
if str(getattr(user, "phone_e164", "") or "") != str(invitation.phone_e164):
|
|
raise HTTPException(status_code=403, detail="当前账号手机号与邀请手机号不一致")
|
|
elif _normalize_email(user.email) != _normalize_email(invitation.email):
|
|
raise HTTPException(status_code=403, detail="当前账号邮箱与邀请邮箱不一致")
|
|
await ensure_personal_workspace(db, user=user, commit=False)
|
|
await _apply_invitation_to_user(db, invitation=invitation, user=user, switch_to_workspace=True)
|
|
await db.commit()
|
|
await db.refresh(user)
|
|
return {"user": user.to_dict(), "workspace_id": int(invitation.tenant_id), "role": invitation.role}
|
|
|
|
|
|
async def leave_workspace(db: AsyncSession, *, current_user: User, tenant_id: int) -> dict[str, Any]:
|
|
membership = (
|
|
await db.execute(
|
|
select(TenantMembership).where(
|
|
and_(
|
|
TenantMembership.tenant_id == int(tenant_id),
|
|
TenantMembership.user_id == int(current_user.id),
|
|
TenantMembership.status == ACTIVE_MEMBERSHIP_STATUS,
|
|
)
|
|
)
|
|
)
|
|
).scalar_one_or_none()
|
|
if not membership:
|
|
raise HTTPException(status_code=404, detail="你不是该空间成员")
|
|
|
|
tenant = (await db.execute(select(Tenant).where(Tenant.id == int(tenant_id)))).scalar_one_or_none()
|
|
if tenant and tenant.workspace_type == PERSONAL_WORKSPACE_TYPE:
|
|
raise HTTPException(status_code=400, detail="个人空间不能退出")
|
|
|
|
if membership.role == "OWNER":
|
|
owner_count = int(
|
|
(
|
|
await db.execute(
|
|
select(func.count(TenantMembership.id)).where(
|
|
and_(
|
|
TenantMembership.tenant_id == int(tenant_id),
|
|
TenantMembership.status == ACTIVE_MEMBERSHIP_STATUS,
|
|
TenantMembership.role == "OWNER",
|
|
)
|
|
)
|
|
)
|
|
).scalar()
|
|
or 0
|
|
)
|
|
if owner_count <= 1:
|
|
raise HTTPException(status_code=409, detail="退出前需要先转让 OWNER")
|
|
|
|
membership.status = "removed"
|
|
await db.execute(
|
|
UserProject.__table__.delete().where(
|
|
and_(
|
|
UserProject.tenant_id == int(tenant_id),
|
|
UserProject.user_id == int(current_user.id),
|
|
)
|
|
)
|
|
)
|
|
if int(current_user.tenant_id) == int(tenant_id):
|
|
personal = await ensure_personal_workspace(db, user=current_user, commit=False)
|
|
current_user.tenant_id = int(personal.id)
|
|
current_user.role = "OWNER"
|
|
await db.commit()
|
|
return {"left_workspace_id": int(tenant_id), "active_workspace_id": int(current_user.tenant_id)}
|