"""Workspace API routes. Workspaces are UI/navigation context only — they never affect permissions. The X-Workspace-ID header is used for workspace context (per-tab, not session). """ from __future__ import annotations import uuid from typing import Any from fastapi import APIRouter, Depends, Header, HTTPException, status from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession from app.core.db import get_db from app.deps import require_permission from app.services import workspace_service router = APIRouter(prefix="/api/v1/workspaces", tags=["Workspaces"]) class WorkspaceCreate(BaseModel): name: str icon: str = "LayoutGrid" description: str | None = None is_default: bool = False class WorkspaceUpdate(BaseModel): name: str | None = None icon: str | None = None description: str | None = None is_default: bool | None = None is_active: bool | None = None class ModuleAssignment(BaseModel): module_key: str is_visible: bool = True menu_order: int = 0 config: dict[str, Any] = {} class SetModules(BaseModel): modules: list[ModuleAssignment] class UserAssignment(BaseModel): user_id: str role: str = "member" @router.get("") async def list_workspaces( db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("workspaces:read")), ): """List all workspaces for the tenant.""" tenant_id = uuid.UUID(current_user["tenant_id"]) return await workspace_service.list_workspaces(db, tenant_id) @router.get("/my") async def my_workspaces( db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("workspaces:read")), ): """Get workspaces assigned to the current user.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) return await workspace_service.get_my_workspaces(db, tenant_id, user_id) @router.get("/context") async def workspace_context( db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("workspaces:read")), x_workspace_id: str | None = Header(None, alias="X-Workspace-ID"), ): """Get workspace context for the current user (modules, widgets). Uses X-Workspace-ID header for tab-local workspace selection. Falls back to user's default workspace if no header. """ tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) is_admin = current_user.get("is_system_admin", False) workspace_id = None if x_workspace_id: try: workspace_id = uuid.UUID(x_workspace_id) except ValueError: raise HTTPException(400, detail={"detail": "Invalid X-Workspace-ID", "code": "invalid_id"}) from None else: # Find user's default workspace my = await workspace_service.get_my_workspaces(db, tenant_id, user_id) for ws in my["items"]: if ws.get("is_user_default"): workspace_id = uuid.UUID(ws["id"]) break if workspace_id is None and my["items"]: workspace_id = uuid.UUID(my["items"][0]["id"]) if workspace_id is None: return {"workspace_id": None, "modules": [], "widgets": []} ctx = await workspace_service.get_workspace_context(db, tenant_id, user_id, workspace_id) if ctx is None and not is_admin: # User not assigned — return empty context return {"workspace_id": str(workspace_id), "modules": [], "widgets": [], "error": "not_assigned"} elif ctx is None and is_admin: # Admin can see any workspace — get without user check ws_data = await workspace_service.get_workspace(db, tenant_id, workspace_id) if ws_data is None: return {"workspace_id": None, "modules": [], "widgets": []} return { "workspace_id": ws_data["id"], "name": ws_data["name"], "icon": ws_data["icon"], "role": "admin", "modules": ws_data.get("modules", []), "widgets": [], } return ctx @router.get("/scope-definitions") async def workspace_scope_definitions( current_user: dict = Depends(require_permission("workspaces:configure_modules")), ): """N1: Scope-Dimensionen aller Module für den Workspace-Editor. Aggregiert ``workspace_scopes()``-Contract-Beiträge der Plugins (document_placeholders-Muster): pro module_key die filterbaren Dimensionen inkl. Wertequellen. Der N2-Editor rendert daraus das Filter-UI; die Werte landen in ``workspace_modules.config``. Admin-only: Scope-Definitionen konfigurieren Module-Teilmengen — das ist Workspace-Admin-Kontext (configure_modules), kein Lesen. """ from app.services.workspace_scope_service import get_scope_definitions return {"modules": get_scope_definitions()} @router.post("", status_code=status.HTTP_201_CREATED) async def create_workspace( body: WorkspaceCreate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("workspaces:create")), ): """Create a new workspace (Tenant Admin or System Admin).""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) return await workspace_service.create_workspace( db, tenant_id, user_id, body.name, body.icon, body.description, body.is_default ) @router.get("/{workspace_id}") async def get_workspace( workspace_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("workspaces:read")), ): """Get a single workspace with modules and user count.""" tenant_id = uuid.UUID(current_user["tenant_id"]) try: wid = uuid.UUID(workspace_id) except ValueError: raise HTTPException(400, detail={"detail": "Invalid workspace_id", "code": "invalid_id"}) from None result = await workspace_service.get_workspace(db, tenant_id, wid) if result is None: raise HTTPException(404, detail={"detail": "Workspace not found", "code": "not_found"}) return result @router.put("/{workspace_id}") async def update_workspace( workspace_id: str, body: WorkspaceUpdate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("workspaces:update")), ): """Update a workspace.""" tenant_id = uuid.UUID(current_user["tenant_id"]) try: wid = uuid.UUID(workspace_id) except ValueError: raise HTTPException(400, detail={"detail": "Invalid workspace_id", "code": "invalid_id"}) from None result = await workspace_service.update_workspace( db, tenant_id, wid, body.name, body.icon, body.description, body.is_default, body.is_active ) if result is None: raise HTTPException(404, detail={"detail": "Workspace not found", "code": "not_found"}) return result @router.delete("/{workspace_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_workspace( workspace_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("workspaces:delete")), ): """Delete a workspace (soft delete).""" tenant_id = uuid.UUID(current_user["tenant_id"]) try: wid = uuid.UUID(workspace_id) except ValueError: raise HTTPException(400, detail={"detail": "Invalid workspace_id", "code": "invalid_id"}) from None deleted = await workspace_service.delete_workspace(db, tenant_id, wid) if not deleted: raise HTTPException(404, detail={"detail": "Workspace not found", "code": "not_found"}) @router.post("/{workspace_id}/modules") async def set_modules( workspace_id: str, body: SetModules, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("workspaces:configure_modules")), ): """Set modules for a workspace (replaces all existing).""" tenant_id = uuid.UUID(current_user["tenant_id"]) try: wid = uuid.UUID(workspace_id) except ValueError: raise HTTPException(400, detail={"detail": "Invalid workspace_id", "code": "invalid_id"}) from None modules = [{"module_key": m.module_key, "is_visible": m.is_visible, "menu_order": m.menu_order, "config": m.config} for m in body.modules] return await workspace_service.set_workspace_modules(db, tenant_id, wid, modules) @router.post("/{workspace_id}/users", status_code=status.HTTP_201_CREATED) async def assign_user( workspace_id: str, body: UserAssignment, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("workspaces:assign_users")), ): """Assign a user to a workspace.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) try: wid = uuid.UUID(workspace_id) target_uid = uuid.UUID(body.user_id) except ValueError: raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"}) from None # Cross-tenant validation: target user must belong to same tenant if not await workspace_service.verify_user_same_tenant(db, tenant_id, target_uid): raise HTTPException(403, detail={"detail": "Cannot assign user from different tenant", "code": "cross_tenant"}) return await workspace_service.assign_user(db, tenant_id, wid, target_uid, body.role, assigned_by=user_id) @router.delete("/{workspace_id}/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT) async def remove_user( workspace_id: str, user_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("workspaces:assign_users")), ): """Remove a user from a workspace.""" tenant_id = uuid.UUID(current_user["tenant_id"]) try: wid = uuid.UUID(workspace_id) uid = uuid.UUID(user_id) except ValueError: raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"}) from None removed = await workspace_service.remove_user(db, tenant_id, wid, uid) if not removed: raise HTTPException(404, detail={"detail": "User not assigned to this workspace", "code": "not_found"}) # ─── Widget CRUD ───────────────────────────────────────────── class WidgetCreate(BaseModel): widget_key: str position_x: int = 0 position_y: int = 0 width: int = 1 height: int = 1 config: dict[str, Any] = {} class WidgetUpdate(BaseModel): position_x: int | None = None position_y: int | None = None width: int | None = None height: int | None = None config: dict[str, Any] | None = None @router.get("/{workspace_id}/widgets") async def list_widgets( workspace_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("workspaces:read")), ): """List all widgets for a workspace.""" tenant_id = uuid.UUID(current_user["tenant_id"]) try: wid = uuid.UUID(workspace_id) except ValueError: raise HTTPException(400, detail={"detail": "Invalid workspace_id", "code": "invalid_id"}) from None widgets = await workspace_service.get_widgets(db, tenant_id, wid) return {"items": widgets, "total": len(widgets)} @router.post("/{workspace_id}/widgets", status_code=status.HTTP_201_CREATED) async def create_widget( workspace_id: str, body: WidgetCreate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("workspaces:configure_modules")), ): """Create a widget in a workspace. Multiple instances of the same widget_key allowed.""" tenant_id = uuid.UUID(current_user["tenant_id"]) try: wid = uuid.UUID(workspace_id) except ValueError: raise HTTPException(400, detail={"detail": "Invalid workspace_id", "code": "invalid_id"}) from None return await workspace_service.create_widget( db, tenant_id, wid, body.widget_key, body.position_x, body.position_y, body.width, body.height, body.config, ) @router.put("/{workspace_id}/widgets/{widget_id}") async def update_widget( workspace_id: str, widget_id: str, body: WidgetUpdate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("workspaces:configure_modules")), ): """Update a widget.""" tenant_id = uuid.UUID(current_user["tenant_id"]) try: ws_id = uuid.UUID(workspace_id) wid = uuid.UUID(widget_id) except ValueError: raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"}) from None result = await workspace_service.update_widget( db, tenant_id, ws_id, wid, body.position_x, body.position_y, body.width, body.height, body.config, ) if result is None: raise HTTPException(404, detail={"detail": "Widget not found", "code": "not_found"}) return result @router.delete("/{workspace_id}/widgets/{widget_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_widget( workspace_id: str, widget_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("workspaces:configure_modules")), ): """Delete a widget.""" tenant_id = uuid.UUID(current_user["tenant_id"]) try: ws_id = uuid.UUID(workspace_id) wid = uuid.UUID(widget_id) except ValueError: raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"}) from None deleted = await workspace_service.delete_widget(db, tenant_id, ws_id, wid) if not deleted: raise HTTPException(404, detail={"detail": "Widget not found", "code": "not_found"}) # ─── Set User Default Workspace ─────────────────────────────── @router.post("/{workspace_id}/set-default", status_code=status.HTTP_200_OK) async def set_default_workspace( workspace_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("workspaces:read")), ): """Set a workspace as the current user's default.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) try: wid = uuid.UUID(workspace_id) except ValueError: raise HTTPException(400, detail={"detail": "Invalid workspace_id", "code": "invalid_id"}) from None # Verify user is assigned to this workspace ctx = await workspace_service.get_workspace_context(db, tenant_id, user_id, wid) if ctx is None and not current_user.get("is_system_admin"): raise HTTPException(403, detail={"detail": "Not assigned to this workspace", "code": "not_assigned"}) await workspace_service.set_user_default_workspace(db, tenant_id, user_id, wid) return {"status": "ok", "workspace_id": workspace_id}