feat(N4): Restliche Module — Tasks/Kommunikation/Wiki/Reports/Agents/Tags/Search + Navigation + Dashboard-Schnittstelle (#368)
Check Cross-Plugin Imports / check (push) Has been cancelled
Check Cross-Plugin Imports / check (push) Has been cancelled
- Scope-Deklarationen: tasks only_mine, kommunikation conversation_ids, wiki category_ids (NEUE contracts.py), report_generator template_ids, automation agent_ids (module_key agents), tags tag_ids, unified_search entity_types dynamisch aus Provider-Registry - Core-Beiträge: navigation default_route (Startseite) + dashboard widget_app_ids (Widget-TYP-Angebot, Layout bleibt Phase M) - Backend-Filter (additive UND): /tasks (only_mine), /comm/conversations, /wiki/articles+/categories (Subtree), /reports/print-templates, /agents, /tags, /search GET+POST (entity_types-Schnitt), /miniapps?host=dashboard - apply_entity_type_scope-Helper (requested ∧ scope) - Frontend: WorkspaceSwitcher default_route-Navigation, Sidebar workspace-menu_order-Sortierung, workspaceStore moduleMenuOrder() - Tests: 18/18 Deklarationen + 11/11 Filter (TDD), Frontend 2/2 + Store 18/18, tsc clean, Build OK - Regression 64 passed (4 Kombi-Failures = Suite-Isolation, solo-bewiesen); Checker 0; Ruff = Vorbestand (Stash-bewiesen)
This commit is contained in:
+19
-1
@@ -8,7 +8,7 @@
|
||||
|
||||
**Offene Roadmap-Phasen (user-abgestimmt, startklar):**
|
||||
- **Phase M** — MiniApp-Plattform & Dashboard-Builder (M1-M6). **M1 ✓** (Universal-Registry, `/api/v1/miniapps`), **M2 ✓** (persönliche Dashboards: Tabelle, CRUD, Seed, RLS), **M3 ✓** (Dashboard-Builder: Edit-Modus, Drag&Drop, Palette, Tabs), **M4 ✓** (System-Rückbau, Core = reiner Host), **M5 ✓** (Plugin-MiniApps), **M6 ✓ erledigt — PHASE M KOMPLETT** (Windows-Host + AI-Agenten-Tool send_miniapp — siehe Phase-M6-Section).
|
||||
- **Phase N** — Workspace-Scopes (N1-N4). 0 Umbau — Fundament (config JSONB, X-Workspace-ID, /context, Sidebar-Consumer) existiert bereits. **N1 ✓** (Scope-Registry via Contract), **N2 ✓** (Dynamischer Scope-Editor), **N3 ✓** (Backend-Filterung: contacts Ordner-Subtree/Typen + Cache-Bypass, dms Ordner/Datei-Typen, mail Postfächer, calendar Kalender; Frontend-Defaults Saved-View/Kalender-Ansicht — siehe Phase-N3-Section). **Nächster Schritt:** N4 Restliche Module (Tasks, Kommunikation, Wiki, Reports/Dokumente, Automation, Tags, Suche, Navigation; Dashboard-Schnittstelle workspace_widgets).
|
||||
- **Phase N** — Workspace-Scopes (N1-N4). **N1 ✓** (Scope-Registry via Contract), **N2 ✓** (Dynamischer Scope-Editor), **N3 ✓** (Backend-Filterung contacts/dms/mail/calendar + Frontend-Defaults), **N4 ✓ erledigt — PHASE N KOMPLETT** (7 weitere Module: Tasks nur-meine, Kommunikation-Räume, Wiki-Kategorien-Subtree, Reports-Vorlagen, Agents, Tags, Search-Entity-Types + Navigation Startseite/Menü-Reihenfolge + Dashboard-Schnittstelle — siehe Phase-N4-Section). **Nächster Schritt:** Phase O UI-Overhaul (offen: 1.2 Kontakte-Drag-Drop in Ordner, 1.3 MoveDialog) oder Phase P Notizen-App (P1-P5).
|
||||
- **Phase O** — UI-Overhaul (umbenannt von Doppel-L, Bug-Verifikation steht im Roadmap-Eintrag: 5/7 Bugs bereits erledigt, offen: 1.2 Kontakte-Drag-Drop in Ordner, 1.3 MoveDialog)
|
||||
|
||||
**Vorbestands-Findings (nicht blockierend, dokumentiert):**
|
||||
@@ -104,6 +104,24 @@
|
||||
|
||||
**Offen in Phase M:** — (Phase M abgeschlossen).
|
||||
|
||||
## Phase N4 — Restliche Module (2026-09-01) ✅ — PHASE N KOMPLETT
|
||||
|
||||
**Spec:** [#368](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/368) | **Roadmap:** Phase N, N4 (letzter Task) | **Milestone:** Phase N — Workspace-Scopes (#14)
|
||||
|
||||
**Umgesetzt:**
|
||||
- **Scope-Deklarationen (7 Plugins):** tasks only_mine-Toggle („nur meine"), kommunikation conversation_ids (Räume), wiki category_ids (Subtree — NEUE contracts.py, wiki hatte zuvor keinen Contract), report_generator template_ids (Vorlagen), automation agent_ids (module_key agents — page route ohne Menüeintrag), tags tag_ids (Root-Array), unified_search entity_types DYNAMISCH aus Provider-Registry (13 Entity-Types, Live-Set + deterministischer Klassen-Fallback).
|
||||
- **Core-Beiträge (Aggregator):** navigation default_route (Startseite pro Workspace, Optionen aus CORE_PERMISSIONS + bekannten Frontend-Routen) + dashboard widget_app_ids (begrenzt das Widget-TYP-Angebot — workspace_widgets-Boundary; persönliches Layout bleibt Phase M).
|
||||
- **Backend-Filter (additive UND, kein Umbau):** GET /tasks (assigned_to OR created_by), GET /comm/conversations (Subset), GET /wiki/articles + /categories (expand_folder_scope-Subtree), GET /reports/print-templates (Subset), GET /agents (Subset), GET /tags (Subset), GET+POST /search (apply_entity_type_scope: requested ∧ scope), GET /miniapps?host=dashboard (widget_app_ids begrenzt NUR Dashboard-Angebot, chat/window unberührt).
|
||||
- **Frontend-Navigation:** WorkspaceSwitcher navigiert nach default_route beim Wechsel (Validierung: muss mit / beginnen); Sidebar sortiert nach workspace menu_order als Admin-Default (persönliche savedOrder bleibt Override); workspaceStore moduleMenuOrder()-Helper.
|
||||
|
||||
**Verifiziert (2026-09-01):**
|
||||
- TDD: Deklarationen **18/18** (rot: 18 failed → Implementation → grün), Filter **11/11** (rot: 8 failed + 1 error → grün; inkl. Dashboard-Boundary: scoped {w1} vs. unscoped Superset, chat unberührt)
|
||||
- ✅ Frontend: Vitest Switcher-Navigation 2/2, Store 18/18 (moduleMenuOrder +2), tsc clean, Build OK
|
||||
- ✅ Kombi-Regression (N1+N3+N4-Dateien): 64 passed / 4 failed — alle 4 per Solo-Lauf als Suite-Isolation bewiesen (N1 solo 18/18, N3-Test solo grün — bekannter Vorbestand, unterschiedliche Plugin-Fixtures in einem Prozess)
|
||||
- ✅ Cross-Plugin-Checker: 0 Verstöße; Ruff: 7 Fehler = exakt Vorbestand (Stash-Beweis: clean HEAD identisch 7)
|
||||
|
||||
**Phase N Gesamtbilanz:** Workspace-Scopes komplett — Registry via Contract (N1), dynamischer Editor (N2), Backend-Filterung für alle 11 Module (N3: contacts/dms/mail/calendar + N4: tasks/communication/wiki/reports/agents/tags/search) + Navigation (Startseite, Menü-Reihenfolge) + Dashboard-Schnittstelle (widget_app_ids). Security-Invariante durchgehend: Scope = reine UND-Einschränkung, Exemptions nur System-Admin + configure_modules-Inhaber (Editor-Deadlock). Issues #365-#368 alle geschlossen.
|
||||
|
||||
## Phase N3 — Erste vier Module integrieren (2026-09-01) ✅
|
||||
|
||||
**Spec:** [#367](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/367) | **Roadmap:** Phase N, N3 | **Milestone:** Phase N — Workspace-Scopes (#14)
|
||||
|
||||
@@ -15,7 +15,7 @@ from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.deps import get_current_user, require_permission
|
||||
from app.deps import get_current_user, require_permission, require_workspace_scope
|
||||
from app.plugins.builtins.automation.models import (
|
||||
AgentDefinition,
|
||||
AgentRun,
|
||||
@@ -118,8 +118,13 @@ async def list_agents(
|
||||
offset: int = Query(0, ge=0),
|
||||
current_user: dict[str, Any] = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
workspace_scope: dict | None = Depends(require_workspace_scope("agents")),
|
||||
):
|
||||
"""List agent definitions with optional filters."""
|
||||
"""List agent definitions with optional filters.
|
||||
|
||||
Phase N4: an active workspace scope restricts the list to the
|
||||
configured agent subset (pure AND — never a grant).
|
||||
"""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_system_admin = current_user.get("is_system_admin", False)
|
||||
@@ -127,6 +132,14 @@ async def list_agents(
|
||||
db, tenant_id, is_active=is_active, mode=mode, limit=limit, offset=offset,
|
||||
user_id=user_id, is_system_admin=is_system_admin,
|
||||
)
|
||||
# Phase N4: workspace scope — agent subset (pure AND)
|
||||
if workspace_scope:
|
||||
from app.services.workspace_scope_service import scope_uuid_set
|
||||
|
||||
agent_scope = scope_uuid_set(workspace_scope.get("agent_ids"))
|
||||
if agent_scope is not None:
|
||||
items = [a for a in items if a.id in agent_scope]
|
||||
total = len(items)
|
||||
return AgentDefinitionListResponse(
|
||||
items=[_agent_to_response(a) for a in items],
|
||||
total=total,
|
||||
|
||||
@@ -63,6 +63,31 @@ class AutomationContract:
|
||||
# ─── agent_comm ───
|
||||
send_agent_message = staticmethod(send_agent_message)
|
||||
|
||||
# ─── Workspace Scopes contribution (Phase N4) ───
|
||||
|
||||
@staticmethod
|
||||
def workspace_scopes() -> list[dict]:
|
||||
"""Scope-Dimensionen des agents-Moduls: Agenten-Teilmengen (N4)."""
|
||||
return [
|
||||
{
|
||||
"module_key": "agents",
|
||||
"dimensions": [
|
||||
{
|
||||
"key": "agent_ids",
|
||||
"label": "Agenten",
|
||||
"control": "multiselect",
|
||||
"options": [],
|
||||
"value_source": {
|
||||
"endpoint": "/api/v1/agents",
|
||||
"items_path": "items",
|
||||
"value_key": "id",
|
||||
"label_key": "name",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def get_function(cls, name: str):
|
||||
"""Return a callable exposed by this contract, or None if absent."""
|
||||
|
||||
@@ -105,6 +105,33 @@ class KommunikationContract:
|
||||
}
|
||||
|
||||
|
||||
|
||||
# ─── Workspace Scopes contribution (Phase N4) ───
|
||||
|
||||
@staticmethod
|
||||
def workspace_scopes() -> list[dict]:
|
||||
"""Scope-Dimensionen des communication-Moduls: Räume-Teilmengen (N4)."""
|
||||
return [
|
||||
{
|
||||
"module_key": "communication",
|
||||
"dimensions": [
|
||||
{
|
||||
"key": "conversation_ids",
|
||||
"label": "Räume",
|
||||
"control": "multiselect",
|
||||
"options": [],
|
||||
"value_source": {
|
||||
"endpoint": "/api/v1/comm/conversations",
|
||||
"items_path": "items",
|
||||
"value_key": "id",
|
||||
"label_key": "title",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
# ─── self-registration ───
|
||||
|
||||
_contract = KommunikationContract()
|
||||
|
||||
@@ -19,7 +19,7 @@ from fastapi import (
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.deps import get_current_user, require_permission
|
||||
from app.deps import get_current_user, require_permission, require_workspace_scope
|
||||
from app.plugins.builtins.kommunikation.content_types import list_block_types
|
||||
from app.plugins.builtins.kommunikation.dms_bridge import DmsBridge
|
||||
from app.plugins.builtins.kommunikation.rbac import CommRBAC
|
||||
@@ -74,11 +74,23 @@ async def list_user_conversations(
|
||||
archived: bool = Query(False, description="Include archived conversations"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
workspace_scope: dict | None = Depends(require_workspace_scope("communication")),
|
||||
):
|
||||
"""List all conversations for the current user."""
|
||||
"""List all conversations for the current user.
|
||||
|
||||
Phase N4: an active workspace scope (X-Workspace-ID) restricts the list
|
||||
to the configured conversation subset (pure AND — never a grant).
|
||||
"""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
convs = await list_conversations(db, tenant_id, user_id, include_archived=archived)
|
||||
# Phase N4: conversation_ids scope — keep only scoped rooms
|
||||
if workspace_scope:
|
||||
from app.services.workspace_scope_service import scope_uuid_set
|
||||
|
||||
conv_scope = scope_uuid_set(workspace_scope.get("conversation_ids"))
|
||||
if conv_scope is not None:
|
||||
convs = [c for c in convs if uuid.UUID(c["id"]) in conv_scope]
|
||||
return {"items": convs, "total": len(convs)}
|
||||
|
||||
|
||||
|
||||
@@ -43,6 +43,31 @@ class ReportGeneratorContract:
|
||||
PRESET_META = PRESET_META
|
||||
PRESET_TEMPLATES = PRESET_TEMPLATES
|
||||
|
||||
# ─── Workspace Scopes contribution (Phase N4) ───
|
||||
|
||||
@staticmethod
|
||||
def workspace_scopes() -> list[dict]:
|
||||
"""Scope-Dimensionen des reports-Moduls: Vorlagen-Teilmengen (N4)."""
|
||||
return [
|
||||
{
|
||||
"module_key": "reports",
|
||||
"dimensions": [
|
||||
{
|
||||
"key": "template_ids",
|
||||
"label": "Vorlagen",
|
||||
"control": "multiselect",
|
||||
"options": [],
|
||||
"value_source": {
|
||||
"endpoint": "/api/v1/reports/print-templates",
|
||||
"items_path": "items",
|
||||
"value_key": "id",
|
||||
"label_key": "name",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
# ─── self-registration ───
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ from app.ai.llm_client import llm_complete
|
||||
from app.core.audit import log_audit
|
||||
from app.core.db import get_db, set_tenant_context
|
||||
from app.core.storage import get_storage_backend
|
||||
from app.deps import require_permission
|
||||
from app.deps import require_permission, require_workspace_scope
|
||||
from app.plugins.builtins.report_generator.document_blocks import (
|
||||
BlockValidationError,
|
||||
get_document_blocks,
|
||||
@@ -423,8 +423,13 @@ async def list_letterhead_assets(
|
||||
async def list_print_templates(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("reports:read")),
|
||||
workspace_scope: dict | None = Depends(require_workspace_scope("reports")),
|
||||
):
|
||||
"""List print templates for the current tenant."""
|
||||
"""List print templates for the current tenant.
|
||||
|
||||
Phase N4: an active workspace scope restricts the template list to the
|
||||
configured subset (pure AND — never a grant).
|
||||
"""
|
||||
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
|
||||
q = (
|
||||
select(PrintTemplate)
|
||||
@@ -435,6 +440,12 @@ async def list_print_templates(
|
||||
.order_by(PrintTemplate.name)
|
||||
)
|
||||
items = (await db.execute(q)).scalars().all()
|
||||
if workspace_scope:
|
||||
from app.services.workspace_scope_service import scope_uuid_set
|
||||
|
||||
template_scope = scope_uuid_set(workspace_scope.get("template_ids"))
|
||||
if template_scope is not None:
|
||||
items = [t for t in items if t.id in template_scope]
|
||||
return {
|
||||
"items": [_template_to_response(t).model_dump() for t in items],
|
||||
"total": len(items),
|
||||
|
||||
@@ -26,6 +26,31 @@ class TagsContract:
|
||||
Tag = Tag
|
||||
TagAssignment = TagAssignment
|
||||
|
||||
# ─── Workspace Scopes contribution (Phase N4) ───
|
||||
|
||||
@staticmethod
|
||||
def workspace_scopes() -> list[dict]:
|
||||
"""Scope-Dimensionen des tags-Moduls: Tag-Teilmengen (N4)."""
|
||||
return [
|
||||
{
|
||||
"module_key": "tags",
|
||||
"dimensions": [
|
||||
{
|
||||
"key": "tag_ids",
|
||||
"label": "Tags",
|
||||
"control": "multiselect",
|
||||
"options": [],
|
||||
"value_source": {
|
||||
"endpoint": "/api/v1/tags",
|
||||
"items_path": "",
|
||||
"value_key": "id",
|
||||
"label_key": "name",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
# ─── self-registration ───
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.core.audit import log_audit
|
||||
from app.core.db import get_db
|
||||
from app.core.visibility import apply_visibility_filter
|
||||
from app.deps import get_current_user, require_permission
|
||||
from app.deps import get_current_user, require_permission, require_workspace_scope
|
||||
from app.plugins.builtins.tags.models import Tag, TagAssignment
|
||||
from app.plugins.builtins.tags.schemas import (
|
||||
TagAssignRequest,
|
||||
@@ -44,6 +44,7 @@ def _parse_uuid(val: str, field: str) -> uuid.UUID:
|
||||
async def list_tags(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
workspace_scope: dict | None = Depends(require_workspace_scope("tags")),
|
||||
):
|
||||
"""List all tags with entity counts."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
@@ -73,6 +74,14 @@ async def list_tags(
|
||||
result = await db.execute(query)
|
||||
rows = result.all()
|
||||
|
||||
# Phase N4: workspace scope — tag subset (pure AND, never a grant)
|
||||
if workspace_scope:
|
||||
from app.services.workspace_scope_service import scope_uuid_set
|
||||
|
||||
tag_scope = scope_uuid_set(workspace_scope.get("tag_ids"))
|
||||
if tag_scope is not None:
|
||||
rows = [(tag, count) for tag, count in rows if tag.id in tag_scope]
|
||||
|
||||
return [
|
||||
{
|
||||
"id": str(tag.id),
|
||||
|
||||
@@ -47,6 +47,27 @@ class TasksContract:
|
||||
update_task_status = staticmethod(update_task_status)
|
||||
get_due_tasks = staticmethod(get_due_tasks)
|
||||
|
||||
# ─── Workspace Scopes contribution (Phase N4) ───
|
||||
|
||||
@staticmethod
|
||||
def workspace_scopes() -> list[dict]:
|
||||
"""Scope-Dimensionen des tasks-Moduls: „nur meine" (Roadmap N4)."""
|
||||
return [
|
||||
{
|
||||
"module_key": "tasks",
|
||||
"dimensions": [
|
||||
{
|
||||
"key": "only_mine",
|
||||
"label": "Nur meine Aufgaben",
|
||||
"control": "toggle",
|
||||
"options": [],
|
||||
"value_source": None,
|
||||
"default": False,
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
# ─── models (read-only for queries) ───
|
||||
Task = Task
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.audit import log_audit
|
||||
from app.core.db import get_db
|
||||
from app.deps import get_current_user, require_permission
|
||||
from app.deps import get_current_user, require_permission, require_workspace_scope
|
||||
from app.plugins.builtins.tasks import services
|
||||
from app.plugins.builtins.tasks.schemas import (
|
||||
TaskAssignRequest,
|
||||
@@ -53,8 +53,13 @@ async def list_tasks(
|
||||
search: str | None = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
workspace_scope: dict | None = Depends(require_workspace_scope("tasks")),
|
||||
):
|
||||
"""List tasks with filtering and pagination."""
|
||||
"""List tasks with filtering and pagination.
|
||||
|
||||
Phase N4: an active workspace scope with only_mine=true restricts the
|
||||
list to tasks assigned to or created by the current user (pure AND).
|
||||
"""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_system_admin = current_user.get("is_system_admin", False)
|
||||
@@ -68,6 +73,7 @@ async def list_tasks(
|
||||
parent_task_id=parent_task_id, task_type=task_type,
|
||||
search=search,
|
||||
user_id=user_id, is_system_admin=is_system_admin,
|
||||
workspace_scope=workspace_scope,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ def _to_uuid(val: str | UUID | None) -> UUID | None:
|
||||
return val
|
||||
return uuid.UUID(str(val))
|
||||
|
||||
from sqlalchemy import func, select # noqa: E402 — after helper defs by design
|
||||
from sqlalchemy import func, or_, select # noqa: E402 — after helper defs by design
|
||||
from sqlalchemy.ext.asyncio import AsyncSession # noqa: E402
|
||||
|
||||
from app.core.visibility import apply_visibility_filter # noqa: E402
|
||||
@@ -215,10 +215,26 @@ async def list_tasks(
|
||||
task_type: str | None = None,
|
||||
user_id: uuid.UUID | None = None,
|
||||
is_system_admin: bool = False,
|
||||
workspace_scope: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""List tasks with filtering and pagination."""
|
||||
"""List tasks with filtering and pagination.
|
||||
|
||||
Phase N4: ``workspace_scope`` with only_mine=true restricts the list to
|
||||
tasks assigned to or created by the current user (pure AND on top of
|
||||
all other filters — never a grant).
|
||||
"""
|
||||
query = select(Task).where(Task.tenant_id == tenant_id, Task.deleted_at.is_(None))
|
||||
|
||||
# Phase N4: workspace scope — only_mine restricts to own tasks (assigned
|
||||
# to OR created by the current user). Pure AND, never a grant.
|
||||
if workspace_scope and workspace_scope.get("only_mine") is True and user_id:
|
||||
query = query.where(
|
||||
or_(
|
||||
Task.assigned_to == user_id,
|
||||
Task.created_by == user_id,
|
||||
)
|
||||
)
|
||||
|
||||
if user_id and not is_system_admin:
|
||||
query = await apply_visibility_filter(
|
||||
db, query, "task", Task, user_id, tenant_id, is_system_admin
|
||||
|
||||
@@ -61,6 +61,68 @@ class UnifiedSearchContract:
|
||||
|
||||
await _auto_register(db)
|
||||
|
||||
# ─── Workspace Scopes contribution (Phase N4) ───
|
||||
|
||||
@staticmethod
|
||||
def workspace_scopes() -> list[dict]:
|
||||
"""Scope-Dimensionen des search-Moduls: Suchbereiche (N4).
|
||||
|
||||
Options come from the live search provider registry; when it has
|
||||
not been initialized yet (sync context before activation), the
|
||||
built-in provider classes are the deterministic fallback source
|
||||
(same classes auto_register_providers registers at activation).
|
||||
"""
|
||||
entity_types = list(get_search_registry().get_entity_types())
|
||||
if not entity_types:
|
||||
from app.plugins.builtins.unified_search.providers import (
|
||||
agent_memory_provider,
|
||||
ai_chat_provider,
|
||||
company_provider,
|
||||
contact_provider,
|
||||
contactperson_provider,
|
||||
conversation_provider,
|
||||
event_provider,
|
||||
file_provider,
|
||||
mail_provider,
|
||||
tag_provider,
|
||||
task_provider,
|
||||
user_provider,
|
||||
workflow_provider,
|
||||
)
|
||||
|
||||
for module in (
|
||||
agent_memory_provider, ai_chat_provider, company_provider,
|
||||
contact_provider, contactperson_provider, conversation_provider,
|
||||
event_provider, file_provider, mail_provider, tag_provider,
|
||||
task_provider, user_provider, workflow_provider,
|
||||
):
|
||||
for attr in dir(module):
|
||||
obj = getattr(module, attr)
|
||||
if (
|
||||
isinstance(obj, type)
|
||||
and attr.endswith("Provider")
|
||||
and attr != "BaseSearchProvider"
|
||||
and getattr(obj, "entity_type", "")
|
||||
):
|
||||
entity_types.append(obj.entity_type)
|
||||
entity_types = sorted(set(entity_types))
|
||||
return [
|
||||
{
|
||||
"module_key": "search",
|
||||
"dimensions": [
|
||||
{
|
||||
"key": "entity_types",
|
||||
"label": "Suchbereiche",
|
||||
"control": "multiselect",
|
||||
"options": [
|
||||
{"value": et, "label": et.replace("_", " ").title()}
|
||||
for et in entity_types
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def get_function(cls, name: str):
|
||||
"""Return a callable exposed by this contract, or None if absent."""
|
||||
|
||||
@@ -14,7 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.core.db import get_db
|
||||
from app.core.jobs import enqueue_job
|
||||
from app.core.permissions import filter_fields_by_permission, resolve_permissions
|
||||
from app.deps import get_current_user, require_permission
|
||||
from app.deps import get_current_user, require_permission, require_workspace_scope
|
||||
from app.plugins.builtins.unified_search.provider_registry import get_search_registry
|
||||
from app.plugins.builtins.unified_search.query_understanding import (
|
||||
llm_aggregate_results,
|
||||
@@ -56,8 +56,13 @@ async def search_get(
|
||||
sort: str = Query(default="relevance", description="Sort order: relevance, date, name"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
workspace_scope: dict | None = Depends(require_workspace_scope("search")),
|
||||
) -> SearchResponse:
|
||||
"""Perform hybrid search via GET (same as POST but with query params)."""
|
||||
"""Perform hybrid search via GET (same as POST but with query params).
|
||||
|
||||
Phase N4: an active workspace scope intersects the requested entity
|
||||
types with the configured search areas (pure AND — never a grant).
|
||||
"""
|
||||
types_list = entity_types.split(",") if entity_types else None
|
||||
tags_list = tags.split(",") if tags else None
|
||||
req = SearchRequest(
|
||||
@@ -70,6 +75,12 @@ async def search_get(
|
||||
tags=tags_list,
|
||||
sort=sort,
|
||||
)
|
||||
if workspace_scope:
|
||||
from app.services.workspace_scope_service import apply_entity_type_scope
|
||||
|
||||
req.entity_types = apply_entity_type_scope(
|
||||
req.entity_types, workspace_scope.get("entity_types")
|
||||
)
|
||||
return await _do_search(req, current_user, db)
|
||||
|
||||
|
||||
@@ -219,8 +230,19 @@ async def search(
|
||||
req: SearchRequest,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
workspace_scope: dict | None = Depends(require_workspace_scope("search")),
|
||||
) -> SearchResponse:
|
||||
"""Perform hybrid search with KI query understanding."""
|
||||
"""Perform hybrid search with KI query understanding.
|
||||
|
||||
Phase N4: an active workspace scope intersects the requested entity
|
||||
types with the configured search areas (pure AND — never a grant).
|
||||
"""
|
||||
if workspace_scope:
|
||||
from app.services.workspace_scope_service import apply_entity_type_scope
|
||||
|
||||
req.entity_types = apply_entity_type_scope(
|
||||
req.entity_types, workspace_scope.get("entity_types")
|
||||
)
|
||||
return await _do_search(req, current_user, db)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Wiki plugin contract — public interface for cross-plugin access (N4).
|
||||
|
||||
Created for the Phase N workspace_scopes contribution (the wiki previously
|
||||
had no contract module — N4 needs one for the scope registry, mirroring the
|
||||
contacts/dms/mail/calendar pattern from N1).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.plugins.builtins.contracts import get_contract_registry
|
||||
|
||||
|
||||
class WikiContract:
|
||||
"""Public contract for the wiki plugin."""
|
||||
|
||||
contract_name = "wiki"
|
||||
|
||||
# ─── Workspace Scopes contribution (Phase N4) ───
|
||||
|
||||
@staticmethod
|
||||
def workspace_scopes() -> list[dict]:
|
||||
"""Scope-Dimensionen des wiki-Moduls: Kategorien-Teilmengen (N4)."""
|
||||
return [
|
||||
{
|
||||
"module_key": "wiki",
|
||||
"dimensions": [
|
||||
{
|
||||
"key": "category_ids",
|
||||
"label": "Wiki-Kategorien",
|
||||
"control": "multiselect",
|
||||
"options": [],
|
||||
"value_source": {
|
||||
"endpoint": "/api/v1/wiki/categories",
|
||||
"items_path": "items",
|
||||
"value_key": "id",
|
||||
"label_key": "name",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def get_function(cls, name: str):
|
||||
"""Return a callable exposed by this contract, or None if absent."""
|
||||
return getattr(cls, name, None)
|
||||
|
||||
|
||||
# ─── self-registration ───
|
||||
|
||||
_contract = WikiContract()
|
||||
get_contract_registry().register("wiki", _contract)
|
||||
|
||||
|
||||
__all__ = ["WikiContract"]
|
||||
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.audit import log_audit
|
||||
from app.core.db import get_db
|
||||
from app.deps import require_permission
|
||||
from app.deps import require_permission, require_workspace_scope
|
||||
from app.plugins.builtins.wiki import services
|
||||
from app.plugins.builtins.wiki.schemas import (
|
||||
ArticleCreate,
|
||||
@@ -28,11 +28,35 @@ async def list_articles(
|
||||
search: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("wiki:read")),
|
||||
workspace_scope: dict | None = Depends(require_workspace_scope("wiki")),
|
||||
):
|
||||
return await services.list_articles(
|
||||
"""List wiki articles.
|
||||
|
||||
Phase N4: an active workspace scope (X-Workspace-ID) restricts articles
|
||||
to the category subtree (category_ids incl. children — pure AND).
|
||||
"""
|
||||
scoped_category_ids: set | None = None
|
||||
if workspace_scope:
|
||||
from app.plugins.builtins.wiki.models import WikiCategory
|
||||
from app.services.workspace_scope_service import expand_folder_scope
|
||||
|
||||
raw_ids = workspace_scope.get("category_ids")
|
||||
if isinstance(raw_ids, list) and raw_ids:
|
||||
scoped_category_ids = await expand_folder_scope(db, WikiCategory, raw_ids)
|
||||
|
||||
result = await services.list_articles(
|
||||
db, uuid.UUID(current_user["tenant_id"]),
|
||||
page=page, page_size=page_size, category_id=category_id, status=status, search=search,
|
||||
)
|
||||
# Phase N4: filter to the scoped category subtree (post-fetch AND filter)
|
||||
if scoped_category_ids is not None:
|
||||
items = [
|
||||
a for a in result["items"]
|
||||
if a.get("category_id") and uuid.UUID(a["category_id"]) in scoped_category_ids
|
||||
]
|
||||
result["items"] = items
|
||||
result["total"] = len(items)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/articles", status_code=status.HTTP_201_CREATED)
|
||||
@@ -120,8 +144,20 @@ async def restore_version(
|
||||
async def list_categories(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("wiki:read")),
|
||||
workspace_scope: dict | None = Depends(require_workspace_scope("wiki")),
|
||||
):
|
||||
return {"items": await services.list_categories(db, uuid.UUID(current_user["tenant_id"]))}
|
||||
"""List wiki categories (Phase N4: scope reduces to the category subtree)."""
|
||||
items = await services.list_categories(db, uuid.UUID(current_user["tenant_id"]))
|
||||
if workspace_scope:
|
||||
from app.plugins.builtins.wiki.models import WikiCategory
|
||||
from app.services.workspace_scope_service import expand_folder_scope
|
||||
|
||||
raw_ids = workspace_scope.get("category_ids")
|
||||
if isinstance(raw_ids, list) and raw_ids:
|
||||
subtree = await expand_folder_scope(db, WikiCategory, raw_ids)
|
||||
allowed = subtree or set()
|
||||
items = [c for c in items if uuid.UUID(c["id"]) in allowed]
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@router.post("/categories", status_code=status.HTTP_201_CREATED)
|
||||
|
||||
+10
-1
@@ -9,7 +9,7 @@ from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.deps import get_current_user
|
||||
from app.deps import get_current_user, require_workspace_scope
|
||||
from app.plugins.miniapp_registry import get_miniapp_registry, user_permits
|
||||
|
||||
router = APIRouter(prefix="/api/v1/miniapps", tags=["miniapps"])
|
||||
@@ -23,14 +23,23 @@ _user_permits = user_permits
|
||||
async def list_miniapps(
|
||||
host: str | None = None,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
workspace_scope: dict | None = Depends(require_workspace_scope("dashboard")),
|
||||
):
|
||||
"""List MiniApps visible to the current user (permission-filtered).
|
||||
|
||||
``?host=chat|dashboard|window`` filters by the hosts declared on the
|
||||
MiniApp definition.
|
||||
Phase N4: a workspace scope with widget_app_ids limits the OFFERED
|
||||
widget types — only for host=dashboard (admin boundary). Personal
|
||||
layouts stay user-owned (Phase M split).
|
||||
"""
|
||||
registry = get_miniapp_registry()
|
||||
items = [a for a in registry.list_apps(host=host) if _user_permits(current_user, a)]
|
||||
if workspace_scope and host == "dashboard":
|
||||
widget_ids = workspace_scope.get("widget_app_ids")
|
||||
if isinstance(widget_ids, list) and widget_ids:
|
||||
allowed = set(widget_ids)
|
||||
items = [a for a in items if a.get("app_id") in allowed]
|
||||
items.sort(key=lambda a: a.get("order", 100))
|
||||
return {"items": items, "total": len(items)}
|
||||
|
||||
|
||||
@@ -155,6 +155,23 @@ async def expand_folder_scope(
|
||||
return result
|
||||
|
||||
|
||||
def apply_entity_type_scope(
|
||||
requested: list[str] | None,
|
||||
scope_entity_types: Any,
|
||||
) -> list[str] | None:
|
||||
"""Intersect requested search entity types with the workspace scope (N4).
|
||||
|
||||
Pure AND: the effective set is requested ∧ scope. ``None`` means "no
|
||||
restriction" on either side (search all). An empty result list means the
|
||||
search legitimately yields nothing (scope excludes every requested type).
|
||||
"""
|
||||
if not isinstance(scope_entity_types, list) or not scope_entity_types:
|
||||
return requested
|
||||
if requested is None:
|
||||
return list(scope_entity_types)
|
||||
return [et for et in requested if et in set(scope_entity_types)]
|
||||
|
||||
|
||||
def scope_uuid_set(raw: Any) -> set[uuid.UUID] | None:
|
||||
"""Convert a scope dimension value into a set of UUIDs (Phase N3).
|
||||
|
||||
@@ -244,4 +261,72 @@ def get_scope_definitions() -> dict[str, list[dict[str, Any]]]:
|
||||
modules.setdefault(parsed.module_key, []).extend(
|
||||
dimension.model_dump() for dimension in parsed.dimensions
|
||||
)
|
||||
|
||||
# ─── Core contributions (Phase N4) ──────────────────────────
|
||||
# Core-owned modules (no plugin owns them) contribute through the same
|
||||
# registry so the N2 editor renders them automatically.
|
||||
for core_contribution in _core_scope_contributions():
|
||||
parsed = _parse_contribution("core", core_contribution)
|
||||
if parsed is None:
|
||||
continue
|
||||
modules.setdefault(parsed.module_key, []).extend(
|
||||
dimension.model_dump() for dimension in parsed.dimensions
|
||||
)
|
||||
return modules
|
||||
|
||||
|
||||
def _core_scope_contributions() -> list[dict[str, Any]]:
|
||||
"""Scope contributions for core-owned modules (Phase N4).
|
||||
|
||||
- navigation: default_route per workspace ("Startseite") — where the
|
||||
workspace switcher navigates to.
|
||||
- dashboard: widget_app_ids — the workspace limits the OFFERED widget
|
||||
types (admin context, workspace_widgets boundary). The personal
|
||||
layout stays user-owned (Phase M boundary, user-corrected split).
|
||||
"""
|
||||
from app.core.permission_registry import CORE_PERMISSIONS
|
||||
|
||||
route_options = [
|
||||
{"value": "/", "label": "Dashboard"},
|
||||
{"value": "/contacts", "label": "Kontakte"},
|
||||
]
|
||||
# Every core permission module with a matching frontend route contributes
|
||||
# a navigation option (dynamic, registry-derived — no hardcoded list).
|
||||
known_routes = {"/", "/contacts", "/tasks", "/calendar", "/mail", "/dms", "/wiki", "/communication", "/reports", "/tags", "/search", "/agents", "/workflows"}
|
||||
for perm in CORE_PERMISSIONS:
|
||||
module = perm.get("module", "")
|
||||
route = f"/{module}"
|
||||
if route in known_routes and all(o["value"] != route for o in route_options):
|
||||
route_options.append({"value": route, "label": module.title()})
|
||||
|
||||
return [
|
||||
{
|
||||
"module_key": "navigation",
|
||||
"dimensions": [
|
||||
{
|
||||
"key": "default_route",
|
||||
"label": "Startseite",
|
||||
"control": "select",
|
||||
"options": route_options,
|
||||
"default": "/",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"module_key": "dashboard",
|
||||
"dimensions": [
|
||||
{
|
||||
"key": "widget_app_ids",
|
||||
"label": "Verfügbare Widgets",
|
||||
"control": "multiselect",
|
||||
"options": [],
|
||||
"value_source": {
|
||||
"endpoint": "/api/v1/miniapps?host=dashboard",
|
||||
"items_path": "items",
|
||||
"value_key": "app_id",
|
||||
"label_key": "name",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -300,6 +300,8 @@ Personal per-user dashboards (Phase M2). Owner-only: every query is scoped to te
|
||||
|
||||
Workspaces are UI/navigation context only — they never affect permissions or data access (Phase N). Visibility is always Workspace ∧ RLS ∧ ABAC ∧ Permissions. Tab-local workspace selection via `X-Workspace-ID` header; the API client interceptor sets it per tab. Requires `workspaces:*` core permissions.
|
||||
|
||||
**Phase N3/N4 list scoping:** with an active `X-Workspace-ID`, list endpoints apply the workspace module scope as a pure AND-restriction (never a grant): contacts (folder subtree + types), dms (folder subtree + file types), mail (accounts), calendar (calendars), tasks (only_mine), communication (conversation subsets), wiki (category subtree), reports (template subsets), agents (agent subsets), tags (tag subsets), search (entity-type intersection on GET/POST). System admins and holders of `workspaces:configure_modules` are exempt (the scope editor needs unrestricted value lists). `GET /miniapps?host=dashboard` additionally limits the offered widget types to the workspace's `widget_app_ids` (chat/window hosts unaffected; personal dashboard layouts stay user-owned, Phase M).
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v1/workspaces` | List all workspaces for the tenant. |
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* N4 — Navigation defaults (Phase N, final task).
|
||||
*
|
||||
* Workspace navigation config (admin-defined per workspace):
|
||||
* - default_route: where the workspace switcher navigates to after switching
|
||||
*/
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { WorkspaceSwitcher } from '@/components/layout/WorkspaceSwitcher';
|
||||
|
||||
const mockSwitch = vi.fn();
|
||||
const mockNavigate = vi.fn();
|
||||
|
||||
vi.mock('react-router-dom', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('react-router-dom')>();
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
|
||||
const myWorkspaces = [
|
||||
{ id: 'ws-1', name: 'Vertrieb', description: null, icon: 'LayoutGrid', is_default: false, is_active: true, role: 'member', is_user_default: false, modules: [] },
|
||||
{
|
||||
id: 'ws-2',
|
||||
name: 'Support',
|
||||
description: null,
|
||||
icon: 'LayoutGrid',
|
||||
is_default: false,
|
||||
is_active: true,
|
||||
role: 'member',
|
||||
is_user_default: false,
|
||||
modules: [
|
||||
{ module_key: 'navigation', menu_order: 0, config: { default_route: '/contacts' } },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock('@/hooks/useWorkspace', () => ({
|
||||
useWorkspace: () => ({
|
||||
myWorkspaces,
|
||||
activeWorkspaceId: 'ws-1',
|
||||
switchWorkspace: mockSwitch,
|
||||
hasWorkspaces: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
function renderSwitcher() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<WorkspaceSwitcher />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('WorkspaceSwitcher navigation (N4)', () => {
|
||||
it('switches workspace and navigates to its default_route', () => {
|
||||
renderSwitcher();
|
||||
fireEvent.click(screen.getByRole('button', { name: /Vertrieb/i }));
|
||||
fireEvent.click(screen.getByText('Support'));
|
||||
|
||||
expect(mockSwitch).toHaveBeenCalledWith('ws-2');
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/contacts');
|
||||
});
|
||||
|
||||
it('switches without navigation config (backward compatible, no navigate call)', () => {
|
||||
renderSwitcher();
|
||||
fireEvent.click(screen.getByRole('button', { name: /Vertrieb/i }));
|
||||
// switch to ws-1 (no navigation config) — the dropdown entry (not the
|
||||
// trigger button, which also shows the active workspace name)
|
||||
fireEvent.click(screen.getAllByText('Vertrieb')[1].closest('button')!);
|
||||
|
||||
expect(mockSwitch).toHaveBeenCalledWith('ws-1');
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,7 @@ const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
import { useMenuOrder } from '@/api/users';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { useWorkspace } from '@/hooks/useWorkspace';
|
||||
import { useWorkspaceStore } from '@/store/workspaceStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
|
||||
interface NavSingleItem {
|
||||
@@ -68,6 +69,7 @@ export function Sidebar() {
|
||||
const { hasPermission } = usePermission();
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const { isModuleVisible } = useWorkspace();
|
||||
const moduleMenuOrder = useWorkspaceStore(s => s.moduleMenuOrder());
|
||||
|
||||
// Use hasPermission directly — permissions are loaded via useUserPermissions hook
|
||||
const canAccess = (perm?: string): boolean => {
|
||||
@@ -136,10 +138,19 @@ export function Sidebar() {
|
||||
}
|
||||
|
||||
return workspaceFiltered.sort((a, b) => {
|
||||
// N4: workspace menu_order is the admin-defined fallback order —
|
||||
// the personal savedOrder above stays the user override.
|
||||
const wsOrder = moduleMenuOrder;
|
||||
const aWs = wsOrder.get(a.path.replace(/^\//, '').split('/')[0]);
|
||||
const bWs = wsOrder.get(b.path.replace(/^\//, '').split('/')[0]);
|
||||
if (aWs !== undefined && bWs !== undefined && aWs !== bWs) return aWs - bWs;
|
||||
if (aWs !== undefined && bWs === undefined) return -1;
|
||||
if (aWs === undefined && bWs !== undefined) return 1;
|
||||
// Fall back to order field + label (backward compatible)
|
||||
if (a.order !== b.order) return a.order - b.order;
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
}, [manifests, menuOrderData, user, isModuleVisible]);
|
||||
}, [manifests, menuOrderData, user, isModuleVisible, moduleMenuOrder]);
|
||||
|
||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useWorkspace } from '@/hooks/useWorkspace';
|
||||
import { LayoutGrid, ChevronDown, Check } from 'lucide-react';
|
||||
|
||||
export function WorkspaceSwitcher() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { myWorkspaces, activeWorkspaceId, switchWorkspace, hasWorkspaces } = useWorkspace();
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
@@ -23,6 +25,21 @@ export function WorkspaceSwitcher() {
|
||||
|
||||
const activeWs = myWorkspaces.find(w => w.id === activeWorkspaceId);
|
||||
|
||||
const handleSwitch = (wsId: string) => {
|
||||
switchWorkspace(wsId);
|
||||
setOpen(false);
|
||||
// N4: navigate to the workspace default_route (admin-defined per
|
||||
// workspace). The route comes from the target workspace's context —
|
||||
// read via the workspace store so the fresh context applies after the
|
||||
// switch (the store resolves config synchronously from myWorkspaces).
|
||||
const target = myWorkspaces.find(w => w.id === wsId);
|
||||
const navConfig = target?.modules?.find((m: any) => m.module_key === 'navigation');
|
||||
const route = navConfig?.config?.default_route;
|
||||
if (typeof route === 'string' && route.startsWith('/')) {
|
||||
navigate(route);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
@@ -43,10 +60,7 @@ export function WorkspaceSwitcher() {
|
||||
{myWorkspaces.map(ws => (
|
||||
<button
|
||||
key={ws.id}
|
||||
onClick={() => {
|
||||
switchWorkspace(ws.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
onClick={() => handleSwitch(ws.id)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-800 text-sm text-gray-700 dark:text-gray-300 transition-colors"
|
||||
>
|
||||
<span className="flex-1 text-left">
|
||||
|
||||
@@ -169,4 +169,25 @@ describe('workspaceStore', () => {
|
||||
expect(useWorkspaceStore.getState().getModuleConfig('mail')).toEqual({});
|
||||
expect(useWorkspaceStore.getState().getModuleConfig('contacts')).toEqual({});
|
||||
});
|
||||
|
||||
// ─── N4: moduleMenuOrder (navigation sidebar order) ─────────
|
||||
|
||||
it('moduleMenuOrder maps module keys to workspace menu_order', () => {
|
||||
useWorkspaceStore.getState().setContext({
|
||||
workspace_id: 'ws-1',
|
||||
modules: [
|
||||
{ module_key: 'calendar', menu_order: 5, config: {} },
|
||||
{ module_key: 'contacts', menu_order: 2, config: {} },
|
||||
],
|
||||
widgets: [],
|
||||
});
|
||||
const map = useWorkspaceStore.getState().moduleMenuOrder();
|
||||
expect(map.get('contacts')).toBe(2);
|
||||
expect(map.get('calendar')).toBe(5);
|
||||
expect(map.has('mail')).toBe(false);
|
||||
});
|
||||
|
||||
it('moduleMenuOrder returns empty map without workspace context', () => {
|
||||
expect(useWorkspaceStore.getState().moduleMenuOrder().size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,6 +59,7 @@ interface WorkspaceStoreState {
|
||||
isModuleVisible: (moduleKey: string, isSystemAdmin?: boolean) => boolean;
|
||||
visibleModuleKeys: () => Set<string>;
|
||||
getModuleConfig: (moduleKey: string) => Record<string, any>;
|
||||
moduleMenuOrder: () => Map<string, number>;
|
||||
hasWorkspaces: () => boolean;
|
||||
// Reset
|
||||
reset: () => void;
|
||||
@@ -120,6 +121,16 @@ export const useWorkspaceStore = create<WorkspaceStoreState>()(
|
||||
return mod?.config ?? {};
|
||||
},
|
||||
|
||||
moduleMenuOrder: () => {
|
||||
// N4: admin-defined sidebar order per workspace (module_key →
|
||||
// menu_order). The user's personal saved order stays the override;
|
||||
// this map is the fallback for unsorted modules.
|
||||
const ctx = get().context;
|
||||
return new Map(
|
||||
(ctx?.modules ?? []).map(m => [m.module_key, m.menu_order] as const),
|
||||
);
|
||||
},
|
||||
|
||||
hasWorkspaces: () => get().myWorkspaces.length > 0,
|
||||
|
||||
reset: () => {
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""N4 — Scope-Deklarationen der restlichen Module (Phase N, letzter Task).
|
||||
|
||||
Plugins declare workspace_scopes() for: tasks (only_mine), kommunikation
|
||||
(conversation_ids), wiki (category_ids subtree), reports (template_ids),
|
||||
agents (agent_ids), tags (tag_ids), search (entity_types — dynamic from the
|
||||
provider registry). Core contributions add navigation (default_route) and
|
||||
dashboard (widget_app_ids — the workspace limits the offered widget TYPES,
|
||||
never the personal layout, Phase M boundary).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.schemas.workspace import WorkspaceModuleScopes
|
||||
|
||||
N4_PLUGINS = (
|
||||
"tasks",
|
||||
"kommunikation",
|
||||
"wiki",
|
||||
"report_generator",
|
||||
"automation",
|
||||
"tags",
|
||||
"unified_search",
|
||||
)
|
||||
N4_MODULES = {
|
||||
"tasks",
|
||||
"communication",
|
||||
"wiki",
|
||||
"reports",
|
||||
"agents",
|
||||
"tags",
|
||||
"search",
|
||||
"navigation",
|
||||
"dashboard",
|
||||
}
|
||||
|
||||
|
||||
# ─── Unit: Contract-Deklarationen der N4-Plugins ──────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("plugin_name", N4_PLUGINS)
|
||||
def test_contract_declares_valid_scopes(plugin_name: str):
|
||||
"""Every N4 plugin declares workspace_scopes() with valid contributions."""
|
||||
from app.plugins.builtins.contracts import get_contract
|
||||
|
||||
contract = get_contract(plugin_name)
|
||||
assert contract is not None, f"Contract für {plugin_name} fehlt"
|
||||
fn = getattr(contract, "workspace_scopes", None)
|
||||
assert callable(fn), f"{plugin_name} deklariert workspace_scopes() nicht"
|
||||
|
||||
contributions = fn() or []
|
||||
assert contributions, f"{plugin_name}: mindestens eine Contribution"
|
||||
for contribution in contributions:
|
||||
WorkspaceModuleScopes.model_validate(contribution)
|
||||
|
||||
|
||||
def _dims_for(plugin_name: str) -> dict[str, dict]:
|
||||
from app.plugins.builtins.contracts import get_contract
|
||||
|
||||
return {
|
||||
d["key"]: d
|
||||
for c in get_contract(plugin_name).workspace_scopes()
|
||||
for d in c["dimensions"]
|
||||
}
|
||||
|
||||
|
||||
def test_tasks_declares_only_mine_toggle():
|
||||
"""Roadmap N4 tasks: „nur meine" — reiner Toggle ohne Wertequelle."""
|
||||
dims = _dims_for("tasks")
|
||||
assert "only_mine" in dims
|
||||
assert dims["only_mine"]["control"] == "toggle"
|
||||
assert dims["only_mine"]["value_source"] is None
|
||||
# module_key tasks (Menüpfad /tasks)
|
||||
from app.plugins.builtins.contracts import get_contract
|
||||
|
||||
keys = [c["module_key"] for c in get_contract("tasks").workspace_scopes()]
|
||||
assert "tasks" in keys
|
||||
|
||||
|
||||
def test_kommunikation_declares_conversation_ids():
|
||||
"""Roadmap N4 Kommunikation: Räume-Teilmengen."""
|
||||
dims = _dims_for("kommunikation")
|
||||
assert "conversation_ids" in dims
|
||||
assert dims["conversation_ids"]["control"] == "multiselect"
|
||||
assert dims["conversation_ids"]["value_source"]["endpoint"] == "/api/v1/comm/conversations"
|
||||
from app.plugins.builtins.contracts import get_contract
|
||||
|
||||
keys = [c["module_key"] for c in get_contract("kommunikation").workspace_scopes()]
|
||||
assert "communication" in keys # Menüpfad /communication
|
||||
|
||||
|
||||
def test_wiki_declares_category_ids():
|
||||
"""Roadmap N4 Wiki: Kategorien-Teilmengen (Subtree wie contacts/dms)."""
|
||||
dims = _dims_for("wiki")
|
||||
assert "category_ids" in dims
|
||||
assert dims["category_ids"]["value_source"]["endpoint"] == "/api/v1/wiki/categories"
|
||||
assert dims["category_ids"]["value_source"]["items_path"] == "items"
|
||||
|
||||
|
||||
def test_reports_declares_template_ids():
|
||||
"""Roadmap N4 Reports/Dokumente: Vorlagen-Teilmengen."""
|
||||
dims = _dims_for("report_generator")
|
||||
assert "template_ids" in dims
|
||||
assert dims["template_ids"]["value_source"]["endpoint"] == "/api/v1/reports/print-templates"
|
||||
assert dims["template_ids"]["value_source"]["items_path"] == "items"
|
||||
from app.plugins.builtins.contracts import get_contract
|
||||
|
||||
keys = [c["module_key"] for c in get_contract("report_generator").workspace_scopes()]
|
||||
assert "reports" in keys # Menüpfad /reports
|
||||
|
||||
|
||||
def test_automation_declares_agent_ids():
|
||||
"""Roadmap N4 Automation: Agenten-Teilmengen (module_key agents)."""
|
||||
dims = _dims_for("automation")
|
||||
assert "agent_ids" in dims
|
||||
assert dims["agent_ids"]["value_source"]["endpoint"] == "/api/v1/agents"
|
||||
assert dims["agent_ids"]["value_source"]["items_path"] == "items"
|
||||
from app.plugins.builtins.contracts import get_contract
|
||||
|
||||
keys = [c["module_key"] for c in get_contract("automation").workspace_scopes()]
|
||||
assert "agents" in keys # page route /agents (kein Menüeintrag)
|
||||
|
||||
|
||||
def test_tags_declares_tag_ids():
|
||||
"""Roadmap N4 Tags: Tag-Teilmengen."""
|
||||
dims = _dims_for("tags")
|
||||
assert "tag_ids" in dims
|
||||
assert dims["tag_ids"]["value_source"]["endpoint"] == "/api/v1/tags"
|
||||
assert dims["tag_ids"]["value_source"]["value_key"] == "id"
|
||||
|
||||
|
||||
def test_search_declares_entity_types_with_current_providers():
|
||||
"""Roadmap N4 Suche: Provider-Teilmengen — Optionen dynamisch aus der
|
||||
Provider-Registry (Contract liefert sie zur Aufrufzeit)."""
|
||||
from app.plugins.builtins.contracts import get_contract
|
||||
|
||||
contributions = get_contract("unified_search").workspace_scopes()
|
||||
keys = [c["module_key"] for c in contributions]
|
||||
assert "search" in keys
|
||||
dims = {d["key"]: d for c in contributions for d in c["dimensions"]}
|
||||
assert "entity_types" in dims
|
||||
assert dims["entity_types"]["control"] == "multiselect"
|
||||
# Dynamic options: reflect the currently registered providers
|
||||
values = {o["value"] for o in dims["entity_types"]["options"]}
|
||||
assert {"contact", "task", "mail"} <= values, (
|
||||
f"entity_types-Optionen enthalten nicht Core-Provider: {values}"
|
||||
)
|
||||
|
||||
|
||||
# ─── Unit: Core-Beiträge (navigation, dashboard) ───────────────
|
||||
|
||||
|
||||
def test_core_contributions_navigation_and_dashboard():
|
||||
"""Core-Module navigation + dashboard contribute scope dimensions via the
|
||||
aggregator (they are core-owned, not plugin-owned)."""
|
||||
from app.services.workspace_scope_service import get_scope_definitions
|
||||
|
||||
modules = get_scope_definitions()
|
||||
assert "navigation" in modules, "navigation-Beitrag fehlt"
|
||||
nav_dims = {d["key"]: d for d in modules["navigation"]}
|
||||
assert nav_dims["default_route"]["control"] == "select"
|
||||
route_values = {o["value"] for o in nav_dims["default_route"]["options"]}
|
||||
assert "/" in route_values
|
||||
assert "/contacts" in route_values
|
||||
|
||||
assert "dashboard" in modules, "dashboard-Beitrag fehlt"
|
||||
dash_dims = {d["key"]: d for d in modules["dashboard"]}
|
||||
assert dash_dims["widget_app_ids"]["control"] == "multiselect"
|
||||
source = dash_dims["widget_app_ids"]["value_source"]
|
||||
assert source["endpoint"] == "/api/v1/miniapps?host=dashboard"
|
||||
assert source["value_key"] == "app_id"
|
||||
|
||||
|
||||
def test_aggregator_covers_all_n4_modules():
|
||||
"""The aggregated registry covers every N4 module (plugin + core)."""
|
||||
from app.services.workspace_scope_service import get_scope_definitions
|
||||
|
||||
modules = get_scope_definitions()
|
||||
for key in N4_MODULES:
|
||||
assert key in modules, f"Modul {key} fehlt in den Scope-Definitionen"
|
||||
assert modules[key], f"{key}: Dimensionen leer"
|
||||
|
||||
|
||||
# ─── Unit: deklarierte Value-Endpoints existieren (OpenAPI) ────
|
||||
|
||||
|
||||
async def test_n4_value_endpoints_exist(app):
|
||||
"""Every N4 value_source endpoint must exist as a GET route — checked via
|
||||
OpenAPI (app.routes carries only _IncludedRouter wrappers, N1 lesson)."""
|
||||
from app.plugins.builtins.contracts import get_contract
|
||||
|
||||
paths = app.openapi().get("paths", {})
|
||||
get_paths = {p for p, ops in paths.items() if "get" in ops}
|
||||
for plugin_name in N4_PLUGINS:
|
||||
contract = get_contract(plugin_name)
|
||||
for contribution in contract.workspace_scopes() or []:
|
||||
for dim in contribution.get("dimensions", []):
|
||||
source = dim.get("value_source")
|
||||
if not source:
|
||||
continue
|
||||
endpoint_path = source["endpoint"].split("?")[0]
|
||||
assert endpoint_path in get_paths, (
|
||||
f"{plugin_name}: deklarierter Value-Endpoint {endpoint_path} "
|
||||
"existiert nicht als GET-Route"
|
||||
)
|
||||
|
||||
|
||||
def test_navigation_routes_are_valid_paths():
|
||||
"""Navigation default_route options must be real frontend paths."""
|
||||
from app.services.workspace_scope_service import get_scope_definitions
|
||||
|
||||
nav = {d["key"]: d for d in get_scope_definitions()["navigation"]}
|
||||
for option in nav["default_route"]["options"]:
|
||||
assert option["value"].startswith("/"), (
|
||||
f"Route {option['value']} muss mit / beginnen"
|
||||
)
|
||||
@@ -0,0 +1,476 @@
|
||||
"""N4 — Workspace-Scopes in den restlichen Backend-Listen (Phase N).
|
||||
|
||||
X-Workspace-ID filtering for: tasks (only_mine), communication
|
||||
(conversation_ids), wiki (category_ids incl. subtree), reports
|
||||
(template_ids), agents (agent_ids), tags (tag_ids), search (entity_types
|
||||
intersection) and the dashboard widget-type boundary (miniapps listing,
|
||||
host=dashboard — the personal layout stays untouched, Phase M split).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
|
||||
|
||||
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
|
||||
|
||||
# ─── Helpers (shared with N3 pattern) ─────────────────────────
|
||||
|
||||
|
||||
async def _make_member(db: AsyncSession, seed: dict, email: str):
|
||||
from app.core.auth import hash_password
|
||||
from app.models.role import Role
|
||||
from app.models.user import User, UserTenant
|
||||
|
||||
user = User(
|
||||
email=email,
|
||||
name=email.split("@")[0].title(),
|
||||
password_hash=hash_password("TestPass123!"),
|
||||
is_active=True,
|
||||
preferences={},
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
role = Role(
|
||||
tenant_id=seed["tenant_a"].id,
|
||||
name=f"n4-{uuid.uuid4().hex[:8]}",
|
||||
permissions={
|
||||
"tasks": {"read": True},
|
||||
"comm": {"read": True},
|
||||
"wiki": {"read": True},
|
||||
"reports": {"read": True},
|
||||
"agents": {"read": True},
|
||||
"tags": {"read": True},
|
||||
"search": {"read": True},
|
||||
"workspaces": {"read": True},
|
||||
},
|
||||
denied_permissions=[],
|
||||
field_permissions={},
|
||||
)
|
||||
db.add(role)
|
||||
await db.flush()
|
||||
db.add(
|
||||
UserTenant(
|
||||
user_id=user.id,
|
||||
tenant_id=seed["tenant_a"].id,
|
||||
is_default=True,
|
||||
role="viewer",
|
||||
role_id=role.id,
|
||||
)
|
||||
)
|
||||
await db.flush()
|
||||
return user
|
||||
|
||||
|
||||
async def _make_workspace(db, seed, member, configs: dict[str, dict]) -> uuid.UUID:
|
||||
from app.services import workspace_service
|
||||
|
||||
tenant = seed["tenant_a"].id
|
||||
ws = await workspace_service.create_workspace(
|
||||
db, tenant, seed["admin_a"].id, f"N4WS-{uuid.uuid4().hex[:6]}"
|
||||
)
|
||||
ws_id = uuid.UUID(ws["id"])
|
||||
await workspace_service.assign_user(db, tenant, ws_id, member.id, role="member")
|
||||
modules = [
|
||||
{"module_key": k, "is_visible": True, "menu_order": i, "config": c}
|
||||
for i, (k, c) in enumerate(configs.items())
|
||||
]
|
||||
if modules:
|
||||
await workspace_service.set_workspace_modules(db, tenant, ws_id, modules)
|
||||
await db.commit()
|
||||
return ws_id
|
||||
|
||||
|
||||
# ─── Fixture: all N4 plugins active ──────────────────────────
|
||||
|
||||
|
||||
N4_PLUGINS = (
|
||||
"permissions",
|
||||
"unified_search",
|
||||
"dms",
|
||||
"kommunikation",
|
||||
"mail",
|
||||
"tasks",
|
||||
"wiki",
|
||||
"report_generator",
|
||||
"automation",
|
||||
"tags",
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def n4_app(engine: AsyncEngine, redis_client):
|
||||
"""App with every N4 plugin (and their dependencies) active."""
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
from app.core.db import close_engine, reset_engine_for_testing
|
||||
from app.core.permission_registry import (
|
||||
init_permission_registry,
|
||||
register_plugin_permissions,
|
||||
)
|
||||
from app.core.service_container import get_container
|
||||
from app.main import create_app
|
||||
from app.plugins.builtins.automation.plugin import AutomationPlugin
|
||||
from app.plugins.builtins.dms.plugin import DmsPlugin
|
||||
from app.plugins.builtins.kommunikation.plugin import KommunikationPlugin
|
||||
from app.plugins.builtins.mail.plugin import MailPlugin
|
||||
from app.plugins.builtins.permissions.plugin import PermissionsPlugin
|
||||
from app.plugins.builtins.report_generator.plugin import ReportGeneratorPlugin
|
||||
from app.plugins.builtins.tags.plugin import TagsPlugin
|
||||
from app.plugins.builtins.tasks.plugin import TasksPlugin
|
||||
from app.plugins.builtins.unified_search.plugin import UnifiedSearchPlugin
|
||||
from app.plugins.builtins.wiki.plugin import WikiPlugin
|
||||
from app.plugins.registry import reset_registry_for_testing
|
||||
from app.services.plugin_service import reset_plugin_service_for_testing
|
||||
|
||||
reset_engine_for_testing(engine)
|
||||
app = create_app()
|
||||
registry = reset_registry_for_testing()
|
||||
registry.initialize(engine, app)
|
||||
init_permission_registry(active_plugin_names=set(N4_PLUGINS))
|
||||
container = get_container()
|
||||
await container.initialize()
|
||||
for plugin in (
|
||||
PermissionsPlugin(),
|
||||
UnifiedSearchPlugin(),
|
||||
DmsPlugin(),
|
||||
KommunikationPlugin(),
|
||||
MailPlugin(),
|
||||
TasksPlugin(),
|
||||
WikiPlugin(),
|
||||
ReportGeneratorPlugin(),
|
||||
AutomationPlugin(),
|
||||
TagsPlugin(),
|
||||
):
|
||||
registry.register_plugin(plugin)
|
||||
if plugin.manifest.permissions:
|
||||
register_plugin_permissions(plugin.name, plugin.manifest.permissions)
|
||||
reset_plugin_service_for_testing(registry)
|
||||
sf = async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession)
|
||||
async with sf() as session:
|
||||
for name in N4_PLUGINS:
|
||||
await registry.install(session, name)
|
||||
await registry.activate(session, name)
|
||||
await session.commit()
|
||||
yield app
|
||||
await close_engine()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def n4_client(n4_app) -> AsyncClient:
|
||||
transport = ASGITransport(app=n4_app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
# ─── tasks: only_mine ─────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def n4_seed_tasks(n4_app, db_session: AsyncSession):
|
||||
from app.plugins.builtins.tasks.models import Task
|
||||
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
member = await _make_member(db_session, seed, "n4-tasks@example.com")
|
||||
tenant = seed["tenant_a"].id
|
||||
mine = Task(tenant_id=tenant, title="Mein Task", assigned_to=member.id, created_by=member.id)
|
||||
other = Task(tenant_id=tenant, title="Fremder Task", assigned_to=seed["admin_a"].id, created_by=seed["admin_a"].id)
|
||||
db_session.add_all([mine, other])
|
||||
await db_session.flush()
|
||||
ws_id = await _make_workspace(db_session, seed, member, {"tasks": {"only_mine": True}})
|
||||
return member, ws_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tasks_only_mine(n4_client: AsyncClient, n4_seed_tasks):
|
||||
member, ws_id = n4_seed_tasks
|
||||
await login_client(n4_client, "n4-tasks@example.com")
|
||||
headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)}
|
||||
|
||||
resp = await n4_client.get("/api/v1/tasks", headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
titles = {i["title"] for i in resp.json()["items"]}
|
||||
assert "Mein Task" in titles
|
||||
assert "Fremder Task" not in titles, "only_mine muss fremde Tasks ausblenden"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tasks_no_header_no_filter(n4_client: AsyncClient, n4_seed_tasks):
|
||||
member, ws_id = n4_seed_tasks
|
||||
await login_client(n4_client, "n4-tasks@example.com")
|
||||
|
||||
resp = await n4_client.get("/api/v1/tasks", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 200, resp.text
|
||||
titles = {i["title"] for i in resp.json()["items"]}
|
||||
assert {"Mein Task", "Fremder Task"} <= titles
|
||||
|
||||
|
||||
# ─── communication: conversation_ids ─────────────────────────
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def n4_seed_comm(n4_app, db_session: AsyncSession):
|
||||
from app.plugins.builtins.kommunikation.models import (
|
||||
CommConversation,
|
||||
CommParticipant,
|
||||
)
|
||||
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
member = await _make_member(db_session, seed, "n4-comm@example.com")
|
||||
tenant = seed["tenant_a"].id
|
||||
conv1 = CommConversation(tenant_id=tenant, title="Vertrieb-Raum")
|
||||
conv2 = CommConversation(tenant_id=tenant, title="Kaffeeklatsch")
|
||||
db_session.add_all([conv1, conv2])
|
||||
await db_session.flush()
|
||||
for conv in (conv1, conv2):
|
||||
db_session.add(
|
||||
CommParticipant(
|
||||
tenant_id=tenant,
|
||||
conversation_id=conv.id,
|
||||
participant_id=member.id,
|
||||
participant_type="user",
|
||||
)
|
||||
)
|
||||
await db_session.flush()
|
||||
ws_id = await _make_workspace(db_session, seed, member, {"communication": {"conversation_ids": [str(conv1.id)]}})
|
||||
return member, ws_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_comm_conversations_scoped(n4_client: AsyncClient, n4_seed_comm):
|
||||
member, ws_id = n4_seed_comm
|
||||
await login_client(n4_client, "n4-comm@example.com")
|
||||
headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)}
|
||||
|
||||
resp = await n4_client.get("/api/v1/comm/conversations", headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
titles = {c["title"] for c in resp.json()["items"]}
|
||||
assert "Vertrieb-Raum" in titles
|
||||
assert "Kaffeeklatsch" not in titles, "Räume-Scope muss ausgeblendet werden"
|
||||
|
||||
|
||||
# ─── wiki: category_ids (subtree) ────────────────────────────
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def n4_seed_wiki(n4_app, db_session: AsyncSession):
|
||||
from app.plugins.builtins.wiki.models import WikiArticle, WikiCategory
|
||||
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
member = await _make_member(db_session, seed, "n4-wiki@example.com")
|
||||
tenant = seed["tenant_a"].id
|
||||
cat1 = WikiCategory(tenant_id=tenant, name="Vertrieb", slug="vertrieb")
|
||||
db_session.add(cat1)
|
||||
await db_session.flush()
|
||||
cat1b = WikiCategory(tenant_id=tenant, name="Angebote", slug="angebote", parent_id=cat1.id)
|
||||
db_session.add(cat1b)
|
||||
await db_session.flush()
|
||||
cat2 = WikiCategory(tenant_id=tenant, name="Intern", slug="intern")
|
||||
db_session.add(cat2)
|
||||
await db_session.flush()
|
||||
a1 = WikiArticle(tenant_id=tenant, title="Playbook", slug="playbook", content="x", category_id=cat1.id)
|
||||
a2 = WikiArticle(tenant_id=tenant, title="Preise", slug="preise", content="x", category_id=cat1b.id)
|
||||
a3 = WikiArticle(tenant_id=tenant, title="Onboarding", slug="onboarding", content="x", category_id=cat2.id)
|
||||
db_session.add_all([a1, a2, a3])
|
||||
await db_session.flush()
|
||||
ws_id = await _make_workspace(db_session, seed, member, {"wiki": {"category_ids": [str(cat1.id)]}})
|
||||
return member, ws_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wiki_articles_scoped_subtree(n4_client: AsyncClient, n4_seed_wiki):
|
||||
member, ws_id = n4_seed_wiki
|
||||
await login_client(n4_client, "n4-wiki@example.com")
|
||||
headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)}
|
||||
|
||||
resp = await n4_client.get("/api/v1/wiki/articles", headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
titles = {a["title"] for a in resp.json()["items"]}
|
||||
assert titles == {"Playbook", "Preise"}, "Scope-Kategorie + Subtree"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wiki_categories_scoped(n4_client: AsyncClient, n4_seed_wiki):
|
||||
member, ws_id = n4_seed_wiki
|
||||
await login_client(n4_client, "n4-wiki@example.com")
|
||||
headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)}
|
||||
|
||||
resp = await n4_client.get("/api/v1/wiki/categories", headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
names = {c["name"] for c in resp.json()["items"]}
|
||||
assert names == {"Vertrieb", "Angebote"}
|
||||
|
||||
|
||||
# ─── reports: template_ids ───────────────────────────────────
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def n4_seed_reports(n4_app, db_session: AsyncSession):
|
||||
from app.plugins.builtins.report_generator.models import PrintTemplate
|
||||
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
member = await _make_member(db_session, seed, "n4-reports@example.com")
|
||||
tenant = seed["tenant_a"].id
|
||||
t1 = PrintTemplate(tenant_id=tenant, name="Angebot", created_by=member.id, blocks=[])
|
||||
t2 = PrintTemplate(tenant_id=tenant, name="Rechnung", created_by=member.id, blocks=[])
|
||||
db_session.add_all([t1, t2])
|
||||
await db_session.flush()
|
||||
ws_id = await _make_workspace(db_session, seed, member, {"reports": {"template_ids": [str(t1.id)]}})
|
||||
return member, ws_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reports_templates_scoped(n4_client: AsyncClient, n4_seed_reports):
|
||||
member, ws_id = n4_seed_reports
|
||||
await login_client(n4_client, "n4-reports@example.com")
|
||||
headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)}
|
||||
|
||||
resp = await n4_client.get("/api/v1/reports/print-templates", headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
names = {t["name"] for t in resp.json()["items"]}
|
||||
assert names == {"Angebot"}
|
||||
|
||||
|
||||
# ─── agents: agent_ids ───────────────────────────────────────
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def n4_seed_agents(n4_app, db_session: AsyncSession):
|
||||
from app.plugins.builtins.automation.models import AgentDefinition
|
||||
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
member = await _make_member(db_session, seed, "n4-agents@example.com")
|
||||
tenant = seed["tenant_a"].id
|
||||
g1 = AgentDefinition(tenant_id=tenant, name="Vertriebs-Assistent", llm_model="gpt", mode="agent", owner_id=member.id)
|
||||
g2 = AgentDefinition(tenant_id=tenant, name="Support-Bot", llm_model="gpt", mode="agent", owner_id=member.id)
|
||||
db_session.add_all([g1, g2])
|
||||
await db_session.flush()
|
||||
ws_id = await _make_workspace(db_session, seed, member, {"agents": {"agent_ids": [str(g1.id)]}})
|
||||
return member, ws_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agents_scoped(n4_client: AsyncClient, n4_seed_agents):
|
||||
member, ws_id = n4_seed_agents
|
||||
await login_client(n4_client, "n4-agents@example.com")
|
||||
headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)}
|
||||
|
||||
resp = await n4_client.get("/api/v1/agents", headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
names = {a["name"] for a in resp.json()["items"]}
|
||||
assert names == {"Vertriebs-Assistent"}
|
||||
|
||||
|
||||
# ─── tags: tag_ids ───────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def n4_seed_tags(n4_app, db_session: AsyncSession):
|
||||
from app.plugins.builtins.tags.models import Tag
|
||||
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
member = await _make_member(db_session, seed, "n4-tags@example.com")
|
||||
tenant = seed["tenant_a"].id
|
||||
tag1 = Tag(tenant_id=tenant, name="VIP", color="#ff0000")
|
||||
tag2 = Tag(tenant_id=tenant, name="Lead", color="#00ff00")
|
||||
db_session.add_all([tag1, tag2])
|
||||
await db_session.flush()
|
||||
ws_id = await _make_workspace(db_session, seed, member, {"tags": {"tag_ids": [str(tag1.id)]}})
|
||||
return member, ws_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tags_scoped(n4_client: AsyncClient, n4_seed_tags):
|
||||
member, ws_id = n4_seed_tags
|
||||
await login_client(n4_client, "n4-tags@example.com")
|
||||
headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)}
|
||||
|
||||
resp = await n4_client.get("/api/v1/tags", headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
names = {t["name"] for t in resp.json()}
|
||||
assert names == {"VIP"}
|
||||
|
||||
|
||||
# ─── search: entity_types intersection ───────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_entity_type_scope_unit():
|
||||
"""The entity-type scope is a pure intersection (AND) — request ∧ scope."""
|
||||
from app.services.workspace_scope_service import apply_entity_type_scope
|
||||
|
||||
assert apply_entity_type_scope(None, ["contact", "mail"]) == ["contact", "mail"]
|
||||
assert apply_entity_type_scope(["contact", "task"], None) == ["contact", "task"]
|
||||
assert apply_entity_type_scope(["contact", "mail"], ["contact", "wiki_article"]) == ["contact"]
|
||||
assert apply_entity_type_scope(["task"], ["contact"]) == []
|
||||
assert apply_entity_type_scope(None, None) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_route_with_workspace_header_ok(n4_client: AsyncClient, db_session: AsyncSession):
|
||||
"""Search with X-Workspace-ID answers 200 (scope intersection applied)."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
member = await _make_member(db_session, seed, "n4-search@example.com")
|
||||
ws_id = await _make_workspace(db_session, seed, member, {"search": {"entity_types": ["contact"]}})
|
||||
await login_client(n4_client, "n4-search@example.com")
|
||||
headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)}
|
||||
|
||||
resp = await n4_client.get(
|
||||
"/api/v1/search",
|
||||
params={"q": "Alpha", "use_ai": False},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
|
||||
# ─── dashboard boundary: miniapps listing ────────────────────
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
def _clean_miniapp_registry():
|
||||
from app.plugins.miniapp_registry import reset_miniapp_registry
|
||||
|
||||
reset_miniapp_registry()
|
||||
yield
|
||||
reset_miniapp_registry()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_miniapps_dashboard_scope(n4_client: AsyncClient, db_session: AsyncSession):
|
||||
"""workspace widget_app_ids limits the OFFERED widget types on
|
||||
/miniapps?host=dashboard — personal layouts stay untouched (Phase M)."""
|
||||
from app.plugins.miniapp_registry import get_miniapp_registry
|
||||
|
||||
reg = get_miniapp_registry()
|
||||
reg.register(app_id="w1", name="Widget 1", plugin_name="test", component="@/x", hosts=["dashboard"])
|
||||
reg.register(app_id="w2", name="Widget 2", plugin_name="test", component="@/x", hosts=["dashboard"])
|
||||
reg.register(app_id="c1", name="Chat App", plugin_name="test", hosts=["chat"])
|
||||
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
member = await _make_member(db_session, seed, "n4-dash@example.com")
|
||||
ws_id = await _make_workspace(db_session, seed, member, {"dashboard": {"widget_app_ids": ["w1"]}})
|
||||
await login_client(n4_client, "n4-dash@example.com")
|
||||
headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)}
|
||||
|
||||
scoped = await n4_client.get("/api/v1/miniapps", params={"host": "dashboard"}, headers=headers)
|
||||
assert scoped.status_code == 200, scoped.text
|
||||
ids = {a["app_id"] for a in scoped.json()["items"]}
|
||||
assert ids == {"w1"}, "widget_app_ids muss das Widget-Angebot begrenzen"
|
||||
|
||||
unscoped = await n4_client.get("/api/v1/miniapps", params={"host": "dashboard"}, headers=ORIGIN_HEADER)
|
||||
assert unscoped.status_code == 200
|
||||
unscoped_ids = {a["app_id"] for a in unscoped.json()["items"]}
|
||||
# Plugin activation registers the plugins' own miniapps too — the test
|
||||
# apps must be a SUBSET of the unscoped offer (superset check).
|
||||
assert {"w1", "w2"} <= unscoped_ids, "Ohne Workspace: volles Angebot"
|
||||
|
||||
chat = await n4_client.get("/api/v1/miniapps", params={"host": "chat"}, headers=headers)
|
||||
assert chat.status_code == 200
|
||||
chat_ids = {a["app_id"] for a in chat.json()["items"]}
|
||||
# Plugin chat miniapps are registered too — c1 must be present and the
|
||||
# dashboard scope must NOT restrict the chat host (superset check).
|
||||
assert {"c1"} <= chat_ids, "Nur host=dashboard wird begrenzt"
|
||||
Reference in New Issue
Block a user