2026-07-29 18:32:35 +02:00
|
|
|
"""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, HTTPException, Header, 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
|
|
|
|
|
|
2026-07-29 18:36:40 +02:00
|
|
|
router = APIRouter(prefix="/api/v1/workspaces", tags=["Workspaces"])
|
2026-07-29 18:32:35 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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"})
|
|
|
|
|
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.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"})
|
|
|
|
|
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"})
|
|
|
|
|
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"})
|
|
|
|
|
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"})
|
|
|
|
|
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"})
|
2026-08-03 03:39:27 +02:00
|
|
|
# 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"})
|
2026-07-29 18:32:35 +02:00
|
|
|
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"})
|
|
|
|
|
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"})
|
2026-08-03 03:39:27 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── 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"})
|
2026-08-03 12:44:02 +02:00
|
|
|
widgets = await workspace_service.get_widgets(db, tenant_id, wid)
|
|
|
|
|
return {"items": widgets, "total": len(widgets)}
|
2026-08-03 03:39:27 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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"})
|
|
|
|
|
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:
|
2026-08-03 12:44:02 +02:00
|
|
|
ws_id = uuid.UUID(workspace_id)
|
2026-08-03 03:39:27 +02:00
|
|
|
wid = uuid.UUID(widget_id)
|
|
|
|
|
except ValueError:
|
2026-08-03 12:44:02 +02:00
|
|
|
raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"})
|
2026-08-03 03:39:27 +02:00
|
|
|
result = await workspace_service.update_widget(
|
2026-08-03 12:44:02 +02:00
|
|
|
db, tenant_id, ws_id, wid,
|
2026-08-03 03:39:27 +02:00
|
|
|
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:
|
2026-08-03 12:44:02 +02:00
|
|
|
ws_id = uuid.UUID(workspace_id)
|
2026-08-03 03:39:27 +02:00
|
|
|
wid = uuid.UUID(widget_id)
|
|
|
|
|
except ValueError:
|
2026-08-03 12:44:02 +02:00
|
|
|
raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"})
|
|
|
|
|
deleted = await workspace_service.delete_widget(db, tenant_id, ws_id, wid)
|
2026-08-03 03:39:27 +02:00
|
|
|
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"})
|
|
|
|
|
# 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}
|