feat: implement workspace management and member invitations
- Added tenant API for managing workspaces, including listing, creating, and inviting members. - Introduced new models for organizations, workspaces, and workspace memberships in the backend. - Created frontend components for workspace administration, allowing users to manage workspaces and invite members. - Updated document listing to support workspace-specific filtering. - Implemented account settings page for user profile management and password changes. - Added end-to-end tests for workspace administration and dashboard functionalities.
This commit is contained in:
parent
809235e962
commit
bf5cd44dc1
20 changed files with 928 additions and 27 deletions
|
|
@ -9,7 +9,7 @@ from sqlalchemy.exc import OperationalError
|
|||
from app.config import settings
|
||||
from app.database import Base, engine
|
||||
from app.mongo import close_mongo, connect_mongo
|
||||
from app.routers import auth, collab, documents, health, templates
|
||||
from app.routers import auth, collab, documents, health, templates, tenant
|
||||
from app.services.collaboration_service import ensure_collaboration_indexes
|
||||
from app.services.document_service import ensure_indexes
|
||||
from app.services.template_service import ensure_template_indexes
|
||||
|
|
@ -57,6 +57,7 @@ app.include_router(auth.router, prefix="/api/v1")
|
|||
app.include_router(documents.router, prefix="/api/v1")
|
||||
app.include_router(templates.router, prefix="/api/v1")
|
||||
app.include_router(collab.router, prefix="/api/v1")
|
||||
app.include_router(tenant.router, prefix="/api/v1")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
|
|
|||
64
backend/app/models/tenant.py
Normal file
64
backend/app/models/tenant.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Organization(Base):
|
||||
__tablename__ = "organizations"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||
slug: Mapped[str] = mapped_column(String(180), unique=True, index=True, nullable=False)
|
||||
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
|
||||
|
||||
class Workspace(Base):
|
||||
__tablename__ = "workspaces"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
organization_id: Mapped[int] = mapped_column(ForeignKey("organizations.id"), index=True, nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||
slug: Mapped[str] = mapped_column(String(180), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
__table_args__ = (UniqueConstraint("organization_id", "slug", name="uq_workspace_org_slug"),)
|
||||
|
||||
|
||||
class WorkspaceMembership(Base):
|
||||
__tablename__ = "workspace_memberships"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
workspace_id: Mapped[int] = mapped_column(ForeignKey("workspaces.id"), index=True, nullable=False)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True, nullable=False)
|
||||
role: Mapped[str] = mapped_column(String(30), default="member", nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
__table_args__ = (UniqueConstraint("workspace_id", "user_id", name="uq_workspace_member"),)
|
||||
|
||||
|
||||
class WorkspaceInvitation(Base):
|
||||
__tablename__ = "workspace_invitations"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
workspace_id: Mapped[int] = mapped_column(ForeignKey("workspaces.id"), index=True, nullable=False)
|
||||
inviter_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||
email: Mapped[str] = mapped_column(String(255), index=True, nullable=False)
|
||||
role: Mapped[str] = mapped_column(String(30), default="member", nullable=False)
|
||||
token: Mapped[str] = mapped_column(String(128), unique=True, index=True, nullable=False)
|
||||
accepted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
|
||||
|
||||
class TenantAuditEvent(Base):
|
||||
__tablename__ = "tenant_audit_events"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
organization_id: Mapped[int] = mapped_column(ForeignKey("organizations.id"), index=True, nullable=False)
|
||||
workspace_id: Mapped[int | None] = mapped_column(ForeignKey("workspaces.id"), index=True)
|
||||
actor_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||
action: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
target: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
|
|
@ -2,8 +2,9 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
|||
from motor.motor_asyncio import AsyncIOMotorDatabase
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user, get_mongo_db_dep
|
||||
from app.database import get_db
|
||||
from app.services.tenant_service import get_workspace
|
||||
from app.models.user import User
|
||||
from app.schemas.document import DocumentCreate, DocumentResponse, DocumentUpdate, DocumentVersionResponse, ShareRequest
|
||||
from app.schemas.collaboration import (
|
||||
|
|
@ -199,10 +200,19 @@ async def list_docs(
|
|||
stead_id: str | None = Query(None, description="Filter by parent stead"),
|
||||
parent_page_id: str | None = Query(None, description="Filter by parent page"),
|
||||
starred: bool | None = Query(None, description="Filter starred projects"),
|
||||
workspace_id: int | None = Query(None, description="Filter by workspace"),
|
||||
sort: str = Query("updated_at", description="Sort field: updated_at or last_visited_at"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
|
||||
auth_db: Session = Depends(get_db),
|
||||
) -> list[DocumentResponse]:
|
||||
if workspace_id is not None:
|
||||
if get_workspace(auth_db, workspace_id, current_user.id) is None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Workspace access denied")
|
||||
await db["documents"].update_many(
|
||||
{"owner_id": current_user.id, "workspace_id": {"$exists": False}},
|
||||
{"$set": {"workspace_id": workspace_id}},
|
||||
)
|
||||
sort_field = "last_visited_at" if sort == "last_visited_at" else "updated_at"
|
||||
docs = await list_documents(
|
||||
db,
|
||||
|
|
@ -212,6 +222,7 @@ async def list_docs(
|
|||
parent_page_id=parent_page_id,
|
||||
starred=starred,
|
||||
sort=sort_field,
|
||||
workspace_id=workspace_id,
|
||||
)
|
||||
return [DocumentResponse(**doc) for doc in docs]
|
||||
|
||||
|
|
@ -243,7 +254,10 @@ async def create_doc(
|
|||
data: DocumentCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
|
||||
auth_db: Session = Depends(get_db),
|
||||
) -> DocumentResponse:
|
||||
if data.workspace_id is not None and get_workspace(auth_db, data.workspace_id, current_user.id) is None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Workspace access denied")
|
||||
created_by = current_user.full_name or current_user.username
|
||||
try:
|
||||
doc = await create_document(db, current_user.id, created_by, data)
|
||||
|
|
|
|||
150
backend/app/routers/tenant.py
Normal file
150
backend/app/routers/tenant.py
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.tenant import WorkspaceInvitation
|
||||
from app.schemas.tenant import (
|
||||
InvitationCreate,
|
||||
InvitationResponse,
|
||||
MemberResponse,
|
||||
RoleUpdate,
|
||||
AuditEventResponse,
|
||||
WorkspaceCreate,
|
||||
WorkspaceResponse,
|
||||
)
|
||||
from app.config import settings
|
||||
from app.services.tenant_service import (
|
||||
accept_invitation,
|
||||
can_manage,
|
||||
create_workspace,
|
||||
get_membership,
|
||||
get_workspace,
|
||||
invite_member,
|
||||
list_audit_events,
|
||||
revoke_invitation,
|
||||
transfer_ownership,
|
||||
list_members,
|
||||
list_workspaces,
|
||||
update_member_role,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/tenants", tags=["tenants"])
|
||||
|
||||
|
||||
def workspace_response(row) -> WorkspaceResponse:
|
||||
workspace, organization, role = row
|
||||
return WorkspaceResponse(
|
||||
id=workspace.id,
|
||||
organization_id=organization.id,
|
||||
organization_name=organization.name,
|
||||
name=workspace.name,
|
||||
slug=workspace.slug,
|
||||
role=role,
|
||||
created_at=workspace.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/workspaces", response_model=list[WorkspaceResponse])
|
||||
def get_workspaces(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
return [workspace_response(row) for row in list_workspaces(db, current_user.id)]
|
||||
|
||||
|
||||
@router.post("/workspaces", response_model=WorkspaceResponse, status_code=status.HTTP_201_CREATED)
|
||||
def post_workspace(data: WorkspaceCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
workspace = create_workspace(db, current_user, data)
|
||||
row = get_workspace(db, workspace.id, current_user.id)
|
||||
return workspace_response(row)
|
||||
|
||||
|
||||
@router.get("/workspaces/{workspace_id}/members", response_model=list[MemberResponse])
|
||||
def get_workspace_members(workspace_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
if get_workspace(db, workspace_id, current_user.id) is None:
|
||||
raise HTTPException(status_code=403, detail="Workspace not found or access denied")
|
||||
return [
|
||||
MemberResponse(user_id=user.id, email=user.email, full_name=user.full_name, username=user.username, role=membership.role, joined_at=membership.created_at)
|
||||
for user, membership in list_members(db, workspace_id)
|
||||
]
|
||||
|
||||
|
||||
@router.post("/workspaces/{workspace_id}/invitations", response_model=InvitationResponse, status_code=201)
|
||||
def post_invitation(workspace_id: int, data: InvitationCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
row = get_workspace(db, workspace_id, current_user.id)
|
||||
if row is None or not can_manage(row[2]):
|
||||
raise HTTPException(status_code=403, detail="Only workspace owners and admins can invite members")
|
||||
invitation = invite_member(db, workspace_id, current_user.id, str(data.email), data.role)
|
||||
return InvitationResponse(
|
||||
id=invitation.id,
|
||||
workspace_id=invitation.workspace_id,
|
||||
email=invitation.email,
|
||||
role=invitation.role,
|
||||
expires_at=invitation.expires_at,
|
||||
accepted=invitation.accepted,
|
||||
token=invitation.token,
|
||||
invite_url=f"{settings.frontend_url}/invite/{invitation.token}",
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/workspaces/{workspace_id}/invitations/{invitation_id}", status_code=204)
|
||||
def delete_invitation(workspace_id: int, invitation_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
row = get_workspace(db, workspace_id, current_user.id)
|
||||
if row is None or not can_manage(row[2]) or not revoke_invitation(db, workspace_id, invitation_id, current_user.id):
|
||||
raise HTTPException(status_code=404, detail="Invitation not found")
|
||||
|
||||
|
||||
@router.post("/workspaces/{workspace_id}/invitations/{invitation_id}/resend", response_model=InvitationResponse)
|
||||
def resend_invitation(workspace_id: int, invitation_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
row = get_workspace(db, workspace_id, current_user.id)
|
||||
invitation = db.query(WorkspaceInvitation).filter_by(id=invitation_id, workspace_id=workspace_id, accepted=False).first()
|
||||
if row is None or not can_manage(row[2]) or invitation is None:
|
||||
raise HTTPException(status_code=404, detail="Invitation not found")
|
||||
invitation.accepted = True
|
||||
db.commit()
|
||||
replacement = invite_member(db, workspace_id, current_user.id, invitation.email, invitation.role)
|
||||
return InvitationResponse(
|
||||
id=replacement.id,
|
||||
workspace_id=replacement.workspace_id,
|
||||
email=replacement.email,
|
||||
role=replacement.role,
|
||||
expires_at=replacement.expires_at,
|
||||
accepted=replacement.accepted,
|
||||
token=replacement.token,
|
||||
invite_url=f"{settings.frontend_url}/invite/{replacement.token}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/workspaces/{workspace_id}/ownership/{user_id}", response_model=WorkspaceResponse)
|
||||
def post_transfer_ownership(workspace_id: int, user_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
row = get_workspace(db, workspace_id, current_user.id)
|
||||
if row is None or row[2] != "owner" or not transfer_ownership(db, workspace_id, current_user.id, user_id):
|
||||
raise HTTPException(status_code=403, detail="Only the workspace owner can transfer ownership")
|
||||
return workspace_response(get_workspace(db, workspace_id, user_id))
|
||||
|
||||
|
||||
@router.get("/workspaces/{workspace_id}/audit", response_model=list[AuditEventResponse])
|
||||
def get_audit(workspace_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
row = get_workspace(db, workspace_id, current_user.id)
|
||||
if row is None or not can_manage(row[2]):
|
||||
raise HTTPException(status_code=403, detail="Only workspace admins can view audit history")
|
||||
return [AuditEventResponse.model_validate(event, from_attributes=True) for event in list_audit_events(db, workspace_id)]
|
||||
|
||||
|
||||
@router.post("/invitations/{token}/accept", response_model=MemberResponse)
|
||||
def post_accept_invitation(token: str, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
membership = accept_invitation(db, token, current_user)
|
||||
if membership is None:
|
||||
raise HTTPException(status_code=400, detail="Invitation is invalid, expired, or not addressed to this account")
|
||||
return MemberResponse(user_id=current_user.id, email=current_user.email, full_name=current_user.full_name, username=current_user.username, role=membership.role, joined_at=membership.created_at)
|
||||
|
||||
|
||||
@router.patch("/workspaces/{workspace_id}/members/{user_id}", response_model=MemberResponse)
|
||||
def patch_member_role(workspace_id: int, user_id: int, data: RoleUpdate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
row = get_workspace(db, workspace_id, current_user.id)
|
||||
if row is None or not can_manage(row[2]):
|
||||
raise HTTPException(status_code=403, detail="Only workspace owners and admins can manage roles")
|
||||
membership = update_member_role(db, workspace_id, user_id, data.role)
|
||||
target = db.query(User).filter(User.id == user_id).first()
|
||||
if membership is None or target is None:
|
||||
raise HTTPException(status_code=400, detail="Member role cannot be changed")
|
||||
return MemberResponse(user_id=target.id, email=target.email, full_name=target.full_name, username=target.username, role=membership.role, joined_at=membership.created_at)
|
||||
|
|
@ -22,6 +22,7 @@ class DocumentCreate(BaseModel):
|
|||
template_id: str | None = None
|
||||
stead_id: str | None = None
|
||||
parent_page_id: str | None = None
|
||||
workspace_id: int | None = None
|
||||
|
||||
|
||||
class DocumentUpdate(BaseModel):
|
||||
|
|
@ -30,6 +31,7 @@ class DocumentUpdate(BaseModel):
|
|||
starred: bool | None = None
|
||||
stead_id: str | None = None
|
||||
parent_page_id: str | None = None
|
||||
workspace_id: int | None = None
|
||||
|
||||
|
||||
class ShareRequest(BaseModel):
|
||||
|
|
@ -53,6 +55,7 @@ class DocumentResponse(BaseModel):
|
|||
owner_id: int | None = None
|
||||
visibility: Visibility = "private"
|
||||
members: list[DocumentMember] = Field(default_factory=list)
|
||||
workspace_id: int | None = None
|
||||
|
||||
|
||||
class DocumentVersionResponse(BaseModel):
|
||||
|
|
|
|||
58
backend/app/schemas/tenant.py
Normal file
58
backend/app/schemas/tenant.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
TenantRole = Literal["owner", "admin", "member", "viewer"]
|
||||
|
||||
|
||||
class WorkspaceCreate(BaseModel):
|
||||
organization_name: str = Field(min_length=2, max_length=160)
|
||||
workspace_name: str = Field(min_length=2, max_length=160)
|
||||
|
||||
|
||||
class WorkspaceResponse(BaseModel):
|
||||
id: int
|
||||
organization_id: int
|
||||
organization_name: str
|
||||
name: str
|
||||
slug: str
|
||||
role: TenantRole
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class MemberResponse(BaseModel):
|
||||
user_id: int
|
||||
email: EmailStr
|
||||
full_name: str
|
||||
username: str
|
||||
role: TenantRole
|
||||
joined_at: datetime
|
||||
|
||||
|
||||
class InvitationCreate(BaseModel):
|
||||
email: EmailStr
|
||||
role: Literal["admin", "member", "viewer"] = "member"
|
||||
|
||||
|
||||
class InvitationResponse(BaseModel):
|
||||
id: int
|
||||
workspace_id: int
|
||||
email: EmailStr
|
||||
role: TenantRole
|
||||
expires_at: datetime
|
||||
accepted: bool
|
||||
token: str | None = None
|
||||
invite_url: str | None = None
|
||||
|
||||
|
||||
class AuditEventResponse(BaseModel):
|
||||
id: int
|
||||
action: str
|
||||
target: str
|
||||
actor_id: int
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class RoleUpdate(BaseModel):
|
||||
role: Literal["admin", "member", "viewer"]
|
||||
|
|
@ -40,6 +40,7 @@ def _serialize(doc: dict[str, Any]) -> dict[str, Any]:
|
|||
"owner_id": doc.get("owner_id"),
|
||||
"visibility": doc.get("visibility", "private"),
|
||||
"members": doc.get("members", []),
|
||||
"workspace_id": doc.get("workspace_id"),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -84,6 +85,7 @@ async def create_document(
|
|||
"last_visited_at": now,
|
||||
"visibility": "private",
|
||||
"members": [],
|
||||
"workspace_id": data.workspace_id,
|
||||
}
|
||||
await db[COLLECTION].insert_one(doc)
|
||||
return _serialize(doc)
|
||||
|
|
@ -104,6 +106,7 @@ async def list_documents(
|
|||
doc_type: CreateType | None = None,
|
||||
stead_id: str | None = None,
|
||||
parent_page_id: str | None = None,
|
||||
workspace_id: int | None = None,
|
||||
starred: bool | None = None,
|
||||
sort: SortField = "updated_at",
|
||||
) -> list[dict[str, Any]]:
|
||||
|
|
@ -117,6 +120,8 @@ async def list_documents(
|
|||
extras["parent_page_id"] = parent_page_id
|
||||
if starred is not None:
|
||||
extras["starred"] = starred
|
||||
if workspace_id is not None:
|
||||
extras["workspace_id"] = workspace_id
|
||||
if extras:
|
||||
query = {"$and": [query, extras]}
|
||||
|
||||
|
|
@ -192,6 +197,8 @@ async def update_document(
|
|||
updates["starred"] = data.starred
|
||||
if data.stead_id is not None:
|
||||
updates["stead_id"] = data.stead_id
|
||||
if data.workspace_id is not None:
|
||||
updates["workspace_id"] = data.workspace_id
|
||||
if "parent_page_id" in data.model_fields_set:
|
||||
updates["parent_page_id"] = data.parent_page_id
|
||||
|
||||
|
|
|
|||
159
backend/app/services/tenant_service.py
Normal file
159
backend/app/services/tenant_service.py
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import re
|
||||
import secrets
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.tenant import Organization, TenantAuditEvent, Workspace, WorkspaceInvitation, WorkspaceMembership
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
def slugify(value: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")[:170] or "workspace"
|
||||
|
||||
|
||||
def get_membership(db: Session, workspace_id: int, user_id: int) -> WorkspaceMembership | None:
|
||||
return db.query(WorkspaceMembership).filter_by(workspace_id=workspace_id, user_id=user_id).first()
|
||||
|
||||
|
||||
def can_manage(role: str | None) -> bool:
|
||||
return role in {"owner", "admin"}
|
||||
|
||||
|
||||
def audit(db: Session, organization_id: int, workspace_id: int | None, actor_id: int, action: str, target: str) -> None:
|
||||
db.add(TenantAuditEvent(
|
||||
organization_id=organization_id,
|
||||
workspace_id=workspace_id,
|
||||
actor_id=actor_id,
|
||||
action=action,
|
||||
target=target,
|
||||
))
|
||||
|
||||
|
||||
def create_workspace(db: Session, owner: User, data) -> Workspace:
|
||||
org_slug = slugify(data.organization_name)
|
||||
if db.query(Organization).filter_by(slug=org_slug).first():
|
||||
org_slug = f"{org_slug}-{secrets.token_hex(3)}"
|
||||
organization = Organization(name=data.organization_name.strip(), slug=org_slug, owner_id=owner.id)
|
||||
db.add(organization)
|
||||
db.flush()
|
||||
workspace_slug = slugify(data.workspace_name)
|
||||
workspace = Workspace(
|
||||
organization_id=organization.id,
|
||||
name=data.workspace_name.strip(),
|
||||
slug=workspace_slug,
|
||||
)
|
||||
db.add(workspace)
|
||||
db.flush()
|
||||
db.add(WorkspaceMembership(workspace_id=workspace.id, user_id=owner.id, role="owner"))
|
||||
audit(db, organization.id, workspace.id, owner.id, "workspace.created", workspace.name)
|
||||
db.commit()
|
||||
db.refresh(workspace)
|
||||
return workspace
|
||||
|
||||
|
||||
def list_workspaces(db: Session, user_id: int):
|
||||
return (
|
||||
db.query(Workspace, Organization, WorkspaceMembership.role)
|
||||
.join(Organization, Organization.id == Workspace.organization_id)
|
||||
.join(WorkspaceMembership, WorkspaceMembership.workspace_id == Workspace.id)
|
||||
.filter(WorkspaceMembership.user_id == user_id)
|
||||
.order_by(Workspace.created_at.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def get_workspace(db: Session, workspace_id: int, user_id: int):
|
||||
return (
|
||||
db.query(Workspace, Organization, WorkspaceMembership.role)
|
||||
.join(Organization, Organization.id == Workspace.organization_id)
|
||||
.join(WorkspaceMembership, WorkspaceMembership.workspace_id == Workspace.id)
|
||||
.filter(Workspace.id == workspace_id, WorkspaceMembership.user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def list_members(db: Session, workspace_id: int):
|
||||
return (
|
||||
db.query(User, WorkspaceMembership)
|
||||
.join(WorkspaceMembership, WorkspaceMembership.user_id == User.id)
|
||||
.filter(WorkspaceMembership.workspace_id == workspace_id)
|
||||
.order_by(User.full_name.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def invite_member(db: Session, workspace_id: int, inviter_id: int, email: str, role: str):
|
||||
token = secrets.token_urlsafe(32)
|
||||
invitation = WorkspaceInvitation(
|
||||
workspace_id=workspace_id,
|
||||
inviter_id=inviter_id,
|
||||
email=email.lower(),
|
||||
role=role,
|
||||
token=token,
|
||||
expires_at=datetime.now(UTC) + timedelta(days=7),
|
||||
)
|
||||
db.add(invitation)
|
||||
workspace = db.query(Workspace).filter_by(id=workspace_id).first()
|
||||
if workspace:
|
||||
audit(db, workspace.organization_id, workspace_id, inviter_id, "invitation.created", email.lower())
|
||||
db.commit()
|
||||
db.refresh(invitation)
|
||||
return invitation
|
||||
|
||||
|
||||
def revoke_invitation(db: Session, workspace_id: int, invitation_id: int, actor_id: int) -> bool:
|
||||
invitation = db.query(WorkspaceInvitation).filter_by(id=invitation_id, workspace_id=workspace_id, accepted=False).first()
|
||||
if invitation is None:
|
||||
return False
|
||||
invitation.expires_at = datetime.now(UTC)
|
||||
workspace = db.query(Workspace).filter_by(id=workspace_id).first()
|
||||
if workspace:
|
||||
audit(db, workspace.organization_id, workspace_id, actor_id, "invitation.revoked", invitation.email)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
|
||||
def transfer_ownership(db: Session, workspace_id: int, current_owner_id: int, next_owner_id: int) -> bool:
|
||||
current = get_membership(db, workspace_id, current_owner_id)
|
||||
target = get_membership(db, workspace_id, next_owner_id)
|
||||
workspace = db.query(Workspace).filter_by(id=workspace_id).first()
|
||||
if not current or current.role != "owner" or not target or not workspace:
|
||||
return False
|
||||
current.role = "admin"
|
||||
target.role = "owner"
|
||||
organization = db.query(Organization).filter_by(id=workspace.organization_id).first()
|
||||
if organization and organization.owner_id == current_owner_id:
|
||||
organization.owner_id = next_owner_id
|
||||
audit(db, workspace.organization_id, workspace_id, current_owner_id, "workspace.ownership_transferred", str(next_owner_id))
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
|
||||
def list_audit_events(db: Session, workspace_id: int, limit: int = 100):
|
||||
return db.query(TenantAuditEvent).filter_by(workspace_id=workspace_id).order_by(TenantAuditEvent.created_at.desc()).limit(limit).all()
|
||||
|
||||
|
||||
def accept_invitation(db: Session, token: str, user: User) -> WorkspaceMembership | None:
|
||||
invitation = db.query(WorkspaceInvitation).filter_by(token=token, accepted=False).first()
|
||||
if invitation is None or invitation.expires_at < datetime.now(UTC) or invitation.email != user.email.lower():
|
||||
return None
|
||||
membership = get_membership(db, invitation.workspace_id, user.id)
|
||||
if membership is None:
|
||||
membership = WorkspaceMembership(workspace_id=invitation.workspace_id, user_id=user.id, role=invitation.role)
|
||||
db.add(membership)
|
||||
invitation.accepted = True
|
||||
db.commit()
|
||||
db.refresh(membership)
|
||||
return membership
|
||||
|
||||
|
||||
def update_member_role(db: Session, workspace_id: int, target_id: int, role: str) -> WorkspaceMembership | None:
|
||||
membership = get_membership(db, workspace_id, target_id)
|
||||
if membership is None or membership.role == "owner":
|
||||
return None
|
||||
membership.role = role
|
||||
db.commit()
|
||||
db.refresh(membership)
|
||||
return membership
|
||||
Loading…
Add table
Add a link
Reference in a new issue