phase5: workspace backend — models, service, routes, migration 0072
This commit is contained in:
@@ -0,0 +1,104 @@
|
|||||||
|
"""Migration: Create workspace tables.
|
||||||
|
|
||||||
|
Revision ID: 0072
|
||||||
|
Revises: 0071
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID, JSONB
|
||||||
|
|
||||||
|
revision = "0072"
|
||||||
|
down_revision = "0071"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# workspaces
|
||||||
|
op.create_table(
|
||||||
|
"workspaces",
|
||||||
|
sa.Column("id", PGUUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||||
|
sa.Column("tenant_id", PGUUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("name", sa.String(100), nullable=False),
|
||||||
|
sa.Column("icon", sa.String(50), nullable=False, server_default="LayoutGrid"),
|
||||||
|
sa.Column("description", sa.String(500), nullable=True),
|
||||||
|
sa.Column("is_default", sa.Boolean, nullable=False, server_default=sa.text("false")),
|
||||||
|
sa.Column("is_active", sa.Boolean, nullable=False, server_default=sa.text("true")),
|
||||||
|
sa.Column("created_by", PGUUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
sa.UniqueConstraint("tenant_id", "name", name="uq_workspaces_tenant_name"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_workspaces_tenant", "workspaces", ["tenant_id"])
|
||||||
|
op.execute(
|
||||||
|
"CREATE UNIQUE INDEX uq_workspace_default_per_tenant "
|
||||||
|
"ON workspaces (tenant_id) WHERE is_default = true"
|
||||||
|
)
|
||||||
|
|
||||||
|
# workspace_modules
|
||||||
|
op.create_table(
|
||||||
|
"workspace_modules",
|
||||||
|
sa.Column("id", PGUUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||||
|
sa.Column("tenant_id", PGUUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("workspace_id", PGUUID(as_uuid=True), sa.ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("module_key", sa.String(100), nullable=False),
|
||||||
|
sa.Column("is_visible", sa.Boolean, nullable=False, server_default=sa.text("true")),
|
||||||
|
sa.Column("menu_order", sa.Integer, nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("config", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
sa.UniqueConstraint("tenant_id", "workspace_id", "module_key", name="uq_wm_tenant_workspace_module"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_wm_workspace", "workspace_modules", ["tenant_id", "workspace_id", "menu_order"])
|
||||||
|
|
||||||
|
# workspace_users
|
||||||
|
op.create_table(
|
||||||
|
"workspace_users",
|
||||||
|
sa.Column("id", PGUUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||||
|
sa.Column("tenant_id", PGUUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("workspace_id", PGUUID(as_uuid=True), sa.ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("user_id", PGUUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("role", sa.String(20), nullable=False, server_default="member"),
|
||||||
|
sa.Column("is_default", sa.Boolean, nullable=False, server_default=sa.text("false")),
|
||||||
|
sa.Column("assigned_by", PGUUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("assigned_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
sa.UniqueConstraint("tenant_id", "workspace_id", "user_id", name="uq_wu_tenant_workspace_user"),
|
||||||
|
sa.CheckConstraint("role IN ('member', 'manager')", name="ck_wu_role"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_wu_workspace", "workspace_users", ["tenant_id", "workspace_id"])
|
||||||
|
op.create_index("ix_wu_user", "workspace_users", ["tenant_id", "user_id"])
|
||||||
|
|
||||||
|
# workspace_widgets
|
||||||
|
op.create_table(
|
||||||
|
"workspace_widgets",
|
||||||
|
sa.Column("id", PGUUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||||
|
sa.Column("tenant_id", PGUUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("workspace_id", PGUUID(as_uuid=True), sa.ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("widget_key", sa.String(100), nullable=False),
|
||||||
|
sa.Column("position_x", sa.Integer, nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("position_y", sa.Integer, nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("width", sa.Integer, nullable=False, server_default=sa.text("1")),
|
||||||
|
sa.Column("height", sa.Integer, nullable=False, server_default=sa.text("1")),
|
||||||
|
sa.Column("config", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_ww_workspace", "workspace_widgets", ["tenant_id", "workspace_id"])
|
||||||
|
|
||||||
|
# RLS on all workspace tables
|
||||||
|
for table in ["workspaces", "workspace_modules", "workspace_users", "workspace_widgets"]:
|
||||||
|
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
|
||||||
|
op.execute(
|
||||||
|
f"CREATE POLICY {table}_tenant_isolation ON {table} "
|
||||||
|
"FOR ALL "
|
||||||
|
"USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid) "
|
||||||
|
"WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::uuid)"
|
||||||
|
)
|
||||||
|
op.execute(f"GRANT SELECT, INSERT, UPDATE, DELETE ON {table} TO crm_api, crm_worker")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
for table in ["workspace_widgets", "workspace_users", "workspace_modules", "workspaces"]:
|
||||||
|
op.execute(f"DROP POLICY IF EXISTS {table}_tenant_isolation ON {table}")
|
||||||
|
op.drop_table(table)
|
||||||
@@ -58,6 +58,7 @@ from app.routes import (
|
|||||||
custom_field_definitions,
|
custom_field_definitions,
|
||||||
custom_fields,
|
custom_fields,
|
||||||
saved_filters,
|
saved_filters,
|
||||||
|
workspaces,
|
||||||
saved_views,
|
saved_views,
|
||||||
webhooks,
|
webhooks,
|
||||||
backups,
|
backups,
|
||||||
@@ -414,6 +415,7 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(errors.router)
|
app.include_router(errors.router)
|
||||||
app.include_router(guest_auth.router)
|
app.include_router(guest_auth.router)
|
||||||
app.include_router(guests.router)
|
app.include_router(guests.router)
|
||||||
|
app.include_router(workspaces.router)
|
||||||
|
|
||||||
# ── Register plugin routes for all built-in plugins ──
|
# ── Register plugin routes for all built-in plugins ──
|
||||||
# Routes are registered at app creation time so OpenAPI docs are complete.
|
# Routes are registered at app creation time so OpenAPI docs are complete.
|
||||||
|
|||||||
@@ -85,3 +85,4 @@ __all__ = [
|
|||||||
"SavedView",
|
"SavedView",
|
||||||
]
|
]
|
||||||
from app.models.entity_attachment import EntityAttachment # noqa: F401
|
from app.models.entity_attachment import EntityAttachment # noqa: F401
|
||||||
|
from app.models.workspace import Workspace, WorkspaceModule, WorkspaceUser, WorkspaceWidget # noqa: F401
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"""Workspace models — UI/navigation context only.
|
||||||
|
|
||||||
|
Workspaces control which modules, menu items, calendar views, contact folders,
|
||||||
|
saved views, and dashboard widgets are visible to a user. They NEVER affect
|
||||||
|
RBAC, ABAC, entity permissions, owner/sharing rights, tenant memberships,
|
||||||
|
RLS policies, or actual data access rights.
|
||||||
|
|
||||||
|
See: docs/security_kernel.md for the permission intersection rule.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
CheckConstraint,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
UniqueConstraint,
|
||||||
|
func,
|
||||||
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.db import Base, TenantMixin
|
||||||
|
|
||||||
|
|
||||||
|
class Workspace(Base, TenantMixin):
|
||||||
|
"""A workspace is a UI/navigation context for a user.
|
||||||
|
|
||||||
|
It defines which modules are visible, which dashboard widgets appear,
|
||||||
|
and how the sidebar is configured. It does NOT affect data access rights.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "workspaces"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "name", name="uq_workspaces_tenant_name"),
|
||||||
|
# Only one default workspace per tenant
|
||||||
|
Index(
|
||||||
|
"uq_workspace_default_per_tenant",
|
||||||
|
"tenant_id",
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=func.text("is_default = true"),
|
||||||
|
),
|
||||||
|
Index("ix_workspaces_tenant", "tenant_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
icon: Mapped[str] = mapped_column(String(50), nullable=False, default="LayoutGrid")
|
||||||
|
description: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceModule(Base, TenantMixin):
|
||||||
|
"""Which modules are visible in a workspace and their configuration."""
|
||||||
|
|
||||||
|
__tablename__ = "workspace_modules"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id", "workspace_id", "module_key", name="uq_wm_tenant_workspace_module"
|
||||||
|
),
|
||||||
|
Index("ix_wm_workspace", "tenant_id", "workspace_id", "menu_order"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
workspace_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True),
|
||||||
|
ForeignKey("workspaces.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
module_key: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
is_visible: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
menu_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
config: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceUser(Base, TenantMixin):
|
||||||
|
"""User assignment to a workspace with role (member or manager)."""
|
||||||
|
|
||||||
|
__tablename__ = "workspace_users"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id", "workspace_id", "user_id", name="uq_wu_tenant_workspace_user"
|
||||||
|
),
|
||||||
|
CheckConstraint("role IN ('member', 'manager')", name="ck_wu_role"),
|
||||||
|
Index("ix_wu_workspace", "tenant_id", "workspace_id"),
|
||||||
|
Index("ix_wu_user", "tenant_id", "user_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
workspace_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True),
|
||||||
|
ForeignKey("workspaces.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True),
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
role: Mapped[str] = mapped_column(String(20), nullable=False, default="member")
|
||||||
|
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
assigned_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
assigned_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceWidget(Base, TenantMixin):
|
||||||
|
"""Dashboard widget configuration per workspace.
|
||||||
|
|
||||||
|
Multiple instances of the same widget type can exist in the same workspace.
|
||||||
|
No UNIQUE constraint on (workspace_id, widget_key) — allows duplicates.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "workspace_widgets"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_ww_workspace", "tenant_id", "workspace_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
workspace_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True),
|
||||||
|
ForeignKey("workspaces.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
widget_key: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
position_x: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
position_y: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
width: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||||
|
height: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||||
|
config: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
)
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/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"})
|
||||||
|
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"})
|
||||||
|
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"})
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
"""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
|
||||||
|
],
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user