"""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 sqlalchemy.orm import selectinload 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 ], }