190 lines
7.3 KiB
Python
190 lines
7.3 KiB
Python
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 ensure_personal_workspace(db: Session, owner: User) -> Workspace:
|
|
existing = (
|
|
db.query(Workspace)
|
|
.join(WorkspaceMembership, WorkspaceMembership.workspace_id == Workspace.id)
|
|
.filter(WorkspaceMembership.user_id == owner.id)
|
|
.order_by(Workspace.created_at.asc())
|
|
.first()
|
|
)
|
|
if existing is not None:
|
|
return existing
|
|
label = (owner.full_name or owner.username or "Personal").strip()
|
|
org_slug = f"{slugify(owner.username)}-personal"
|
|
if db.query(Organization).filter_by(slug=org_slug).first():
|
|
org_slug = f"{org_slug}-{secrets.token_hex(3)}"
|
|
organization = Organization(name=f"{label}'s organization", slug=org_slug, owner_id=owner.id)
|
|
db.add(organization)
|
|
db.flush()
|
|
workspace = Workspace(
|
|
organization_id=organization.id,
|
|
name="Personal workspace",
|
|
slug="personal-workspace",
|
|
)
|
|
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.bootstrapped", 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
|