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