Files
leocrm/app/services/workspace_service.py
T

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
],
}