426 lines
12 KiB
Python
426 lines
12 KiB
Python
"""Workspace service — CRUD, module config, user assignment, widgets.
|
|
|
|
Workspaces are UI/navigation context only. They never affect permissions.
|
|
See: docs/security_kernel.md
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from typing import Any
|
|
|
|
from sqlalchemy import select, update, func
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.models.workspace import Workspace, WorkspaceModule, WorkspaceUser, WorkspaceWidget
|
|
|
|
|
|
def _workspace_to_dict(ws: Workspace, modules: list[WorkspaceModule] | None = None, user_count: int = 0) -> dict[str, Any]:
|
|
return {
|
|
"id": str(ws.id),
|
|
"name": ws.name,
|
|
"icon": ws.icon,
|
|
"description": ws.description,
|
|
"is_default": ws.is_default,
|
|
"is_active": ws.is_active,
|
|
"created_by": str(ws.created_by) if ws.created_by else None,
|
|
"created_at": ws.created_at.isoformat() if ws.created_at else None,
|
|
"updated_at": ws.updated_at.isoformat() if ws.updated_at else None,
|
|
"modules": [
|
|
{
|
|
"id": str(m.id),
|
|
"module_key": m.module_key,
|
|
"is_visible": m.is_visible,
|
|
"menu_order": m.menu_order,
|
|
"config": m.config or {},
|
|
}
|
|
for m in (modules or [])
|
|
],
|
|
"user_count": user_count,
|
|
}
|
|
|
|
|
|
async def list_workspaces(
|
|
db: AsyncSession, tenant_id: uuid.UUID
|
|
) -> dict[str, Any]:
|
|
"""List all workspaces for a tenant."""
|
|
q = select(Workspace).where(
|
|
Workspace.tenant_id == tenant_id,
|
|
Workspace.is_active == True, # noqa: E712
|
|
).order_by(Workspace.name)
|
|
result = await db.execute(q)
|
|
workspaces = result.scalars().all()
|
|
|
|
items = []
|
|
for ws in workspaces:
|
|
# Count users
|
|
count_q = select(func.count()).select_from(WorkspaceUser).where(
|
|
WorkspaceUser.workspace_id == ws.id,
|
|
WorkspaceUser.tenant_id == tenant_id,
|
|
)
|
|
count_result = await db.execute(count_q)
|
|
user_count = count_result.scalar() or 0
|
|
items.append(_workspace_to_dict(ws, user_count=user_count))
|
|
|
|
return {"items": items, "total": len(items)}
|
|
|
|
|
|
async def get_workspace(
|
|
db: AsyncSession, tenant_id: uuid.UUID, workspace_id: uuid.UUID
|
|
) -> dict[str, Any] | None:
|
|
"""Get a single workspace with modules and user count."""
|
|
q = select(Workspace).where(
|
|
Workspace.id == workspace_id,
|
|
Workspace.tenant_id == tenant_id,
|
|
)
|
|
result = await db.execute(q)
|
|
ws = result.scalar_one_or_none()
|
|
if ws is None:
|
|
return None
|
|
|
|
# Get modules
|
|
mod_q = select(WorkspaceModule).where(
|
|
WorkspaceModule.workspace_id == workspace_id,
|
|
WorkspaceModule.tenant_id == tenant_id,
|
|
).order_by(WorkspaceModule.menu_order)
|
|
mod_result = await db.execute(mod_q)
|
|
modules = mod_result.scalars().all()
|
|
|
|
# Count users
|
|
count_q = select(func.count()).select_from(WorkspaceUser).where(
|
|
WorkspaceUser.workspace_id == workspace_id,
|
|
WorkspaceUser.tenant_id == tenant_id,
|
|
)
|
|
count_result = await db.execute(count_q)
|
|
user_count = count_result.scalar() or 0
|
|
|
|
return _workspace_to_dict(ws, modules=modules, user_count=user_count)
|
|
|
|
|
|
async def create_workspace(
|
|
db: AsyncSession,
|
|
tenant_id: uuid.UUID,
|
|
user_id: uuid.UUID,
|
|
name: str,
|
|
icon: str = "LayoutGrid",
|
|
description: str | None = None,
|
|
is_default: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""Create a new workspace."""
|
|
ws = Workspace(
|
|
tenant_id=tenant_id,
|
|
name=name,
|
|
icon=icon,
|
|
description=description,
|
|
is_default=is_default,
|
|
is_active=True,
|
|
created_by=user_id,
|
|
)
|
|
db.add(ws)
|
|
await db.flush()
|
|
await db.refresh(ws)
|
|
|
|
# If this is the default workspace, unset others
|
|
if is_default:
|
|
await db.execute(
|
|
update(Workspace)
|
|
.where(
|
|
Workspace.tenant_id == tenant_id,
|
|
Workspace.id != ws.id,
|
|
)
|
|
.values(is_default=False)
|
|
)
|
|
|
|
# Auto-assign creator as manager
|
|
wu = WorkspaceUser(
|
|
tenant_id=tenant_id,
|
|
workspace_id=ws.id,
|
|
user_id=user_id,
|
|
role="manager",
|
|
is_default=is_default,
|
|
assigned_by=user_id,
|
|
)
|
|
db.add(wu)
|
|
await db.flush()
|
|
|
|
return _workspace_to_dict(ws, user_count=1)
|
|
|
|
|
|
async def update_workspace(
|
|
db: AsyncSession,
|
|
tenant_id: uuid.UUID,
|
|
workspace_id: uuid.UUID,
|
|
name: str | None = None,
|
|
icon: str | None = None,
|
|
description: str | None = None,
|
|
is_default: bool | None = None,
|
|
is_active: bool | None = None,
|
|
) -> dict[str, Any] | None:
|
|
"""Update a workspace."""
|
|
q = select(Workspace).where(
|
|
Workspace.id == workspace_id,
|
|
Workspace.tenant_id == tenant_id,
|
|
)
|
|
result = await db.execute(q)
|
|
ws = result.scalar_one_or_none()
|
|
if ws is None:
|
|
return None
|
|
|
|
if name is not None:
|
|
ws.name = name
|
|
if icon is not None:
|
|
ws.icon = icon
|
|
if description is not None:
|
|
ws.description = description
|
|
if is_active is not None:
|
|
ws.is_active = is_active
|
|
if is_default is True:
|
|
# Unset other defaults
|
|
await db.execute(
|
|
update(Workspace)
|
|
.where(
|
|
Workspace.tenant_id == tenant_id,
|
|
Workspace.id != ws.id,
|
|
)
|
|
.values(is_default=False)
|
|
)
|
|
ws.is_default = True
|
|
elif is_default is False:
|
|
ws.is_default = False
|
|
|
|
await db.flush()
|
|
await db.refresh(ws)
|
|
return _workspace_to_dict(ws)
|
|
|
|
|
|
async def delete_workspace(
|
|
db: AsyncSession, tenant_id: uuid.UUID, workspace_id: uuid.UUID
|
|
) -> bool:
|
|
"""Delete a workspace (soft delete by setting is_active=False)."""
|
|
q = select(Workspace).where(
|
|
Workspace.id == workspace_id,
|
|
Workspace.tenant_id == tenant_id,
|
|
)
|
|
result = await db.execute(q)
|
|
ws = result.scalar_one_or_none()
|
|
if ws is None:
|
|
return False
|
|
ws.is_active = False
|
|
await db.flush()
|
|
return True
|
|
|
|
|
|
async def set_workspace_modules(
|
|
db: AsyncSession,
|
|
tenant_id: uuid.UUID,
|
|
workspace_id: uuid.UUID,
|
|
modules: list[dict[str, Any]],
|
|
) -> list[dict[str, Any]]:
|
|
"""Set the modules for a workspace. Replaces all existing modules."""
|
|
# Delete existing modules
|
|
existing_q = select(WorkspaceModule).where(
|
|
WorkspaceModule.workspace_id == workspace_id,
|
|
WorkspaceModule.tenant_id == tenant_id,
|
|
)
|
|
existing = await db.execute(existing_q)
|
|
for m in existing.scalars().all():
|
|
await db.delete(m)
|
|
|
|
# Insert new modules
|
|
result = []
|
|
for mod in modules:
|
|
wm = WorkspaceModule(
|
|
tenant_id=tenant_id,
|
|
workspace_id=workspace_id,
|
|
module_key=mod["module_key"],
|
|
is_visible=mod.get("is_visible", True),
|
|
menu_order=mod.get("menu_order", 0),
|
|
config=mod.get("config", {}),
|
|
)
|
|
db.add(wm)
|
|
await db.flush()
|
|
await db.refresh(wm)
|
|
result.append({
|
|
"id": str(wm.id),
|
|
"module_key": wm.module_key,
|
|
"is_visible": wm.is_visible,
|
|
"menu_order": wm.menu_order,
|
|
"config": wm.config or {},
|
|
})
|
|
|
|
return result
|
|
|
|
|
|
async def assign_user(
|
|
db: AsyncSession,
|
|
tenant_id: uuid.UUID,
|
|
workspace_id: uuid.UUID,
|
|
user_id: uuid.UUID,
|
|
role: str = "member",
|
|
assigned_by: uuid.UUID | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Assign a user to a workspace."""
|
|
wu = WorkspaceUser(
|
|
tenant_id=tenant_id,
|
|
workspace_id=workspace_id,
|
|
user_id=user_id,
|
|
role=role,
|
|
is_default=False,
|
|
assigned_by=assigned_by,
|
|
)
|
|
db.add(wu)
|
|
await db.flush()
|
|
await db.refresh(wu)
|
|
return {
|
|
"id": str(wu.id),
|
|
"workspace_id": str(wu.workspace_id),
|
|
"user_id": str(wu.user_id),
|
|
"role": wu.role,
|
|
"is_default": wu.is_default,
|
|
"assigned_at": wu.assigned_at.isoformat() if wu.assigned_at else None,
|
|
}
|
|
|
|
|
|
async def remove_user(
|
|
db: AsyncSession, tenant_id: uuid.UUID, workspace_id: uuid.UUID, user_id: uuid.UUID
|
|
) -> bool:
|
|
"""Remove a user from a workspace."""
|
|
q = select(WorkspaceUser).where(
|
|
WorkspaceUser.workspace_id == workspace_id,
|
|
WorkspaceUser.user_id == user_id,
|
|
WorkspaceUser.tenant_id == tenant_id,
|
|
)
|
|
result = await db.execute(q)
|
|
wu = result.scalar_one_or_none()
|
|
if wu is None:
|
|
return False
|
|
await db.delete(wu)
|
|
await db.flush()
|
|
return True
|
|
|
|
|
|
async def get_my_workspaces(
|
|
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID
|
|
) -> dict[str, Any]:
|
|
"""Get workspaces assigned to the current user."""
|
|
q = (
|
|
select(Workspace, WorkspaceUser)
|
|
.join(WorkspaceUser, WorkspaceUser.workspace_id == Workspace.id)
|
|
.where(
|
|
WorkspaceUser.user_id == user_id,
|
|
Workspace.tenant_id == tenant_id,
|
|
Workspace.is_active == True, # noqa: E712
|
|
)
|
|
.order_by(Workspace.name)
|
|
)
|
|
result = await db.execute(q)
|
|
rows = result.all()
|
|
|
|
items = []
|
|
for ws, wu in rows:
|
|
# Get modules for this workspace
|
|
mod_q = select(WorkspaceModule).where(
|
|
WorkspaceModule.workspace_id == ws.id,
|
|
WorkspaceModule.tenant_id == tenant_id,
|
|
WorkspaceModule.is_visible == True, # noqa: E712
|
|
).order_by(WorkspaceModule.menu_order)
|
|
mod_result = await db.execute(mod_q)
|
|
modules = mod_result.scalars().all()
|
|
|
|
items.append({
|
|
"id": str(ws.id),
|
|
"name": ws.name,
|
|
"icon": ws.icon,
|
|
"description": ws.description,
|
|
"is_default": ws.is_default,
|
|
"role": wu.role,
|
|
"is_user_default": wu.is_default,
|
|
"modules": [
|
|
{
|
|
"module_key": m.module_key,
|
|
"menu_order": m.menu_order,
|
|
"config": m.config or {},
|
|
}
|
|
for m in modules
|
|
],
|
|
})
|
|
|
|
return {"items": items, "total": len(items)}
|
|
|
|
|
|
async def get_workspace_context(
|
|
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, workspace_id: uuid.UUID
|
|
) -> dict[str, Any] | None:
|
|
"""Get workspace context for a user — modules, widgets, config.
|
|
|
|
Validates:
|
|
- Workspace belongs to tenant
|
|
- User is assigned or is system admin / tenant admin
|
|
- Workspace is active
|
|
"""
|
|
# Check workspace exists and is active
|
|
ws_q = select(Workspace).where(
|
|
Workspace.id == workspace_id,
|
|
Workspace.tenant_id == tenant_id,
|
|
Workspace.is_active == True, # noqa: E712
|
|
)
|
|
ws_result = await db.execute(ws_q)
|
|
ws = ws_result.scalar_one_or_none()
|
|
if ws is None:
|
|
return None
|
|
|
|
# Check user is assigned
|
|
wu_q = select(WorkspaceUser).where(
|
|
WorkspaceUser.workspace_id == workspace_id,
|
|
WorkspaceUser.user_id == user_id,
|
|
WorkspaceUser.tenant_id == tenant_id,
|
|
)
|
|
wu_result = await db.execute(wu_q)
|
|
wu = wu_result.scalar_one_or_none()
|
|
if wu is None:
|
|
return None # User not assigned — caller can check is_system_admin
|
|
|
|
# Get visible modules
|
|
mod_q = select(WorkspaceModule).where(
|
|
WorkspaceModule.workspace_id == workspace_id,
|
|
WorkspaceModule.tenant_id == tenant_id,
|
|
WorkspaceModule.is_visible == True, # noqa: E712
|
|
).order_by(WorkspaceModule.menu_order)
|
|
mod_result = await db.execute(mod_q)
|
|
modules = mod_result.scalars().all()
|
|
|
|
# Get widgets
|
|
widget_q = select(WorkspaceWidget).where(
|
|
WorkspaceWidget.workspace_id == workspace_id,
|
|
WorkspaceWidget.tenant_id == tenant_id,
|
|
).order_by(WorkspaceWidget.position_y, WorkspaceWidget.position_x)
|
|
widget_result = await db.execute(widget_q)
|
|
widgets = widget_result.scalars().all()
|
|
|
|
return {
|
|
"workspace_id": str(ws.id),
|
|
"name": ws.name,
|
|
"icon": ws.icon,
|
|
"role": wu.role,
|
|
"modules": [
|
|
{
|
|
"module_key": m.module_key,
|
|
"menu_order": m.menu_order,
|
|
"config": m.config or {},
|
|
}
|
|
for m in modules
|
|
],
|
|
"widgets": [
|
|
{
|
|
"id": str(w.id),
|
|
"widget_key": w.widget_key,
|
|
"position_x": w.position_x,
|
|
"position_y": w.position_y,
|
|
"width": w.width,
|
|
"height": w.height,
|
|
"config": w.config or {},
|
|
}
|
|
for w in widgets
|
|
],
|
|
}
|