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):**
|
**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 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)
|
- **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):**
|
**Vorbestands-Findings (nicht blockierend, dokumentiert):**
|
||||||
@@ -104,6 +104,24 @@
|
|||||||
|
|
||||||
**Offen in Phase M:** — (Phase M abgeschlossen).
|
**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) ✅
|
## 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)
|
**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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.db import get_db
|
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 (
|
from app.plugins.builtins.automation.models import (
|
||||||
AgentDefinition,
|
AgentDefinition,
|
||||||
AgentRun,
|
AgentRun,
|
||||||
@@ -118,8 +118,13 @@ async def list_agents(
|
|||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
current_user: dict[str, Any] = Depends(get_current_user),
|
current_user: dict[str, Any] = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
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"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
user_id = uuid.UUID(current_user["user_id"])
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
is_system_admin = current_user.get("is_system_admin", False)
|
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,
|
db, tenant_id, is_active=is_active, mode=mode, limit=limit, offset=offset,
|
||||||
user_id=user_id, is_system_admin=is_system_admin,
|
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(
|
return AgentDefinitionListResponse(
|
||||||
items=[_agent_to_response(a) for a in items],
|
items=[_agent_to_response(a) for a in items],
|
||||||
total=total,
|
total=total,
|
||||||
|
|||||||
@@ -63,6 +63,31 @@ class AutomationContract:
|
|||||||
# ─── agent_comm ───
|
# ─── agent_comm ───
|
||||||
send_agent_message = staticmethod(send_agent_message)
|
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
|
@classmethod
|
||||||
def get_function(cls, name: str):
|
def get_function(cls, name: str):
|
||||||
"""Return a callable exposed by this contract, or None if absent."""
|
"""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 ───
|
# ─── self-registration ───
|
||||||
|
|
||||||
_contract = KommunikationContract()
|
_contract = KommunikationContract()
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from fastapi import (
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.db import get_db
|
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.content_types import list_block_types
|
||||||
from app.plugins.builtins.kommunikation.dms_bridge import DmsBridge
|
from app.plugins.builtins.kommunikation.dms_bridge import DmsBridge
|
||||||
from app.plugins.builtins.kommunikation.rbac import CommRBAC
|
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"),
|
archived: bool = Query(False, description="Include archived conversations"),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
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"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
user_id = uuid.UUID(current_user["user_id"])
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
convs = await list_conversations(db, tenant_id, user_id, include_archived=archived)
|
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)}
|
return {"items": convs, "total": len(convs)}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,31 @@ class ReportGeneratorContract:
|
|||||||
PRESET_META = PRESET_META
|
PRESET_META = PRESET_META
|
||||||
PRESET_TEMPLATES = PRESET_TEMPLATES
|
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 ───
|
# ─── self-registration ───
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from app.ai.llm_client import llm_complete
|
|||||||
from app.core.audit import log_audit
|
from app.core.audit import log_audit
|
||||||
from app.core.db import get_db, set_tenant_context
|
from app.core.db import get_db, set_tenant_context
|
||||||
from app.core.storage import get_storage_backend
|
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 (
|
from app.plugins.builtins.report_generator.document_blocks import (
|
||||||
BlockValidationError,
|
BlockValidationError,
|
||||||
get_document_blocks,
|
get_document_blocks,
|
||||||
@@ -423,8 +423,13 @@ async def list_letterhead_assets(
|
|||||||
async def list_print_templates(
|
async def list_print_templates(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(require_permission("reports:read")),
|
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"])
|
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
|
||||||
q = (
|
q = (
|
||||||
select(PrintTemplate)
|
select(PrintTemplate)
|
||||||
@@ -435,6 +440,12 @@ async def list_print_templates(
|
|||||||
.order_by(PrintTemplate.name)
|
.order_by(PrintTemplate.name)
|
||||||
)
|
)
|
||||||
items = (await db.execute(q)).scalars().all()
|
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 {
|
return {
|
||||||
"items": [_template_to_response(t).model_dump() for t in items],
|
"items": [_template_to_response(t).model_dump() for t in items],
|
||||||
"total": len(items),
|
"total": len(items),
|
||||||
|
|||||||
@@ -26,6 +26,31 @@ class TagsContract:
|
|||||||
Tag = Tag
|
Tag = Tag
|
||||||
TagAssignment = TagAssignment
|
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 ───
|
# ─── self-registration ───
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.core.audit import log_audit
|
from app.core.audit import log_audit
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.core.visibility import apply_visibility_filter
|
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.models import Tag, TagAssignment
|
||||||
from app.plugins.builtins.tags.schemas import (
|
from app.plugins.builtins.tags.schemas import (
|
||||||
TagAssignRequest,
|
TagAssignRequest,
|
||||||
@@ -44,6 +44,7 @@ def _parse_uuid(val: str, field: str) -> uuid.UUID:
|
|||||||
async def list_tags(
|
async def list_tags(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
|
workspace_scope: dict | None = Depends(require_workspace_scope("tags")),
|
||||||
):
|
):
|
||||||
"""List all tags with entity counts."""
|
"""List all tags with entity counts."""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
@@ -73,6 +74,14 @@ async def list_tags(
|
|||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
rows = result.all()
|
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 [
|
return [
|
||||||
{
|
{
|
||||||
"id": str(tag.id),
|
"id": str(tag.id),
|
||||||
|
|||||||
@@ -47,6 +47,27 @@ class TasksContract:
|
|||||||
update_task_status = staticmethod(update_task_status)
|
update_task_status = staticmethod(update_task_status)
|
||||||
get_due_tasks = staticmethod(get_due_tasks)
|
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) ───
|
# ─── models (read-only for queries) ───
|
||||||
Task = Task
|
Task = Task
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.core.audit import log_audit
|
from app.core.audit import log_audit
|
||||||
from app.core.db import get_db
|
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 import services
|
||||||
from app.plugins.builtins.tasks.schemas import (
|
from app.plugins.builtins.tasks.schemas import (
|
||||||
TaskAssignRequest,
|
TaskAssignRequest,
|
||||||
@@ -53,8 +53,13 @@ async def list_tasks(
|
|||||||
search: str | None = Query(None),
|
search: str | None = Query(None),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(get_current_user),
|
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"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
user_id = uuid.UUID(current_user["user_id"])
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
is_system_admin = current_user.get("is_system_admin", False)
|
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,
|
parent_task_id=parent_task_id, task_type=task_type,
|
||||||
search=search,
|
search=search,
|
||||||
user_id=user_id, is_system_admin=is_system_admin,
|
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 val
|
||||||
return uuid.UUID(str(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 sqlalchemy.ext.asyncio import AsyncSession # noqa: E402
|
||||||
|
|
||||||
from app.core.visibility import apply_visibility_filter # 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,
|
task_type: str | None = None,
|
||||||
user_id: uuid.UUID | None = None,
|
user_id: uuid.UUID | None = None,
|
||||||
is_system_admin: bool = False,
|
is_system_admin: bool = False,
|
||||||
|
workspace_scope: dict | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> 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))
|
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:
|
if user_id and not is_system_admin:
|
||||||
query = await apply_visibility_filter(
|
query = await apply_visibility_filter(
|
||||||
db, query, "task", Task, user_id, tenant_id, is_system_admin
|
db, query, "task", Task, user_id, tenant_id, is_system_admin
|
||||||
|
|||||||
@@ -61,6 +61,68 @@ class UnifiedSearchContract:
|
|||||||
|
|
||||||
await _auto_register(db)
|
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
|
@classmethod
|
||||||
def get_function(cls, name: str):
|
def get_function(cls, name: str):
|
||||||
"""Return a callable exposed by this contract, or None if absent."""
|
"""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.db import get_db
|
||||||
from app.core.jobs import enqueue_job
|
from app.core.jobs import enqueue_job
|
||||||
from app.core.permissions import filter_fields_by_permission, resolve_permissions
|
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.provider_registry import get_search_registry
|
||||||
from app.plugins.builtins.unified_search.query_understanding import (
|
from app.plugins.builtins.unified_search.query_understanding import (
|
||||||
llm_aggregate_results,
|
llm_aggregate_results,
|
||||||
@@ -56,8 +56,13 @@ async def search_get(
|
|||||||
sort: str = Query(default="relevance", description="Sort order: relevance, date, name"),
|
sort: str = Query(default="relevance", description="Sort order: relevance, date, name"),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
workspace_scope: dict | None = Depends(require_workspace_scope("search")),
|
||||||
) -> SearchResponse:
|
) -> 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
|
types_list = entity_types.split(",") if entity_types else None
|
||||||
tags_list = tags.split(",") if tags else None
|
tags_list = tags.split(",") if tags else None
|
||||||
req = SearchRequest(
|
req = SearchRequest(
|
||||||
@@ -70,6 +75,12 @@ async def search_get(
|
|||||||
tags=tags_list,
|
tags=tags_list,
|
||||||
sort=sort,
|
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)
|
return await _do_search(req, current_user, db)
|
||||||
|
|
||||||
|
|
||||||
@@ -219,8 +230,19 @@ async def search(
|
|||||||
req: SearchRequest,
|
req: SearchRequest,
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
workspace_scope: dict | None = Depends(require_workspace_scope("search")),
|
||||||
) -> SearchResponse:
|
) -> 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)
|
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.audit import log_audit
|
||||||
from app.core.db import get_db
|
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 import services
|
||||||
from app.plugins.builtins.wiki.schemas import (
|
from app.plugins.builtins.wiki.schemas import (
|
||||||
ArticleCreate,
|
ArticleCreate,
|
||||||
@@ -28,11 +28,35 @@ async def list_articles(
|
|||||||
search: str | None = None,
|
search: str | None = None,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(require_permission("wiki:read")),
|
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"]),
|
db, uuid.UUID(current_user["tenant_id"]),
|
||||||
page=page, page_size=page_size, category_id=category_id, status=status, search=search,
|
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)
|
@router.post("/articles", status_code=status.HTTP_201_CREATED)
|
||||||
@@ -120,8 +144,20 @@ async def restore_version(
|
|||||||
async def list_categories(
|
async def list_categories(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(require_permission("wiki:read")),
|
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)
|
@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 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
|
from app.plugins.miniapp_registry import get_miniapp_registry, user_permits
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/miniapps", tags=["miniapps"])
|
router = APIRouter(prefix="/api/v1/miniapps", tags=["miniapps"])
|
||||||
@@ -23,14 +23,23 @@ _user_permits = user_permits
|
|||||||
async def list_miniapps(
|
async def list_miniapps(
|
||||||
host: str | None = None,
|
host: str | None = None,
|
||||||
current_user: dict = Depends(get_current_user),
|
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).
|
"""List MiniApps visible to the current user (permission-filtered).
|
||||||
|
|
||||||
``?host=chat|dashboard|window`` filters by the hosts declared on the
|
``?host=chat|dashboard|window`` filters by the hosts declared on the
|
||||||
MiniApp definition.
|
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()
|
registry = get_miniapp_registry()
|
||||||
items = [a for a in registry.list_apps(host=host) if _user_permits(current_user, a)]
|
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))
|
items.sort(key=lambda a: a.get("order", 100))
|
||||||
return {"items": items, "total": len(items)}
|
return {"items": items, "total": len(items)}
|
||||||
|
|
||||||
|
|||||||
@@ -155,6 +155,23 @@ async def expand_folder_scope(
|
|||||||
return result
|
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:
|
def scope_uuid_set(raw: Any) -> set[uuid.UUID] | None:
|
||||||
"""Convert a scope dimension value into a set of UUIDs (Phase N3).
|
"""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(
|
modules.setdefault(parsed.module_key, []).extend(
|
||||||
dimension.model_dump() for dimension in parsed.dimensions
|
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
|
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.
|
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 |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| GET | `/api/v1/workspaces` | List all workspaces for the tenant. |
|
| 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 { useMenuOrder } from '@/api/users';
|
||||||
import { usePermission } from '@/hooks/usePermission';
|
import { usePermission } from '@/hooks/usePermission';
|
||||||
import { useWorkspace } from '@/hooks/useWorkspace';
|
import { useWorkspace } from '@/hooks/useWorkspace';
|
||||||
|
import { useWorkspaceStore } from '@/store/workspaceStore';
|
||||||
import { useAuthStore } from '@/store/authStore';
|
import { useAuthStore } from '@/store/authStore';
|
||||||
|
|
||||||
interface NavSingleItem {
|
interface NavSingleItem {
|
||||||
@@ -68,6 +69,7 @@ export function Sidebar() {
|
|||||||
const { hasPermission } = usePermission();
|
const { hasPermission } = usePermission();
|
||||||
const user = useAuthStore((state) => state.user);
|
const user = useAuthStore((state) => state.user);
|
||||||
const { isModuleVisible } = useWorkspace();
|
const { isModuleVisible } = useWorkspace();
|
||||||
|
const moduleMenuOrder = useWorkspaceStore(s => s.moduleMenuOrder());
|
||||||
|
|
||||||
// Use hasPermission directly — permissions are loaded via useUserPermissions hook
|
// Use hasPermission directly — permissions are loaded via useUserPermissions hook
|
||||||
const canAccess = (perm?: string): boolean => {
|
const canAccess = (perm?: string): boolean => {
|
||||||
@@ -136,10 +138,19 @@ export function Sidebar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return workspaceFiltered.sort((a, b) => {
|
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;
|
if (a.order !== b.order) return a.order - b.order;
|
||||||
return a.label.localeCompare(b.label);
|
return a.label.localeCompare(b.label);
|
||||||
});
|
});
|
||||||
}, [manifests, menuOrderData, user, isModuleVisible]);
|
}, [manifests, menuOrderData, user, isModuleVisible, moduleMenuOrder]);
|
||||||
|
|
||||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useWorkspace } from '@/hooks/useWorkspace';
|
import { useWorkspace } from '@/hooks/useWorkspace';
|
||||||
import { LayoutGrid, ChevronDown, Check } from 'lucide-react';
|
import { LayoutGrid, ChevronDown, Check } from 'lucide-react';
|
||||||
|
|
||||||
export function WorkspaceSwitcher() {
|
export function WorkspaceSwitcher() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { myWorkspaces, activeWorkspaceId, switchWorkspace, hasWorkspaces } = useWorkspace();
|
const { myWorkspaces, activeWorkspaceId, switchWorkspace, hasWorkspaces } = useWorkspace();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
@@ -23,6 +25,21 @@ export function WorkspaceSwitcher() {
|
|||||||
|
|
||||||
const activeWs = myWorkspaces.find(w => w.id === activeWorkspaceId);
|
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 (
|
return (
|
||||||
<div className="relative" ref={ref}>
|
<div className="relative" ref={ref}>
|
||||||
<button
|
<button
|
||||||
@@ -43,10 +60,7 @@ export function WorkspaceSwitcher() {
|
|||||||
{myWorkspaces.map(ws => (
|
{myWorkspaces.map(ws => (
|
||||||
<button
|
<button
|
||||||
key={ws.id}
|
key={ws.id}
|
||||||
onClick={() => {
|
onClick={() => handleSwitch(ws.id)}
|
||||||
switchWorkspace(ws.id);
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
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"
|
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">
|
<span className="flex-1 text-left">
|
||||||
|
|||||||
@@ -169,4 +169,25 @@ describe('workspaceStore', () => {
|
|||||||
expect(useWorkspaceStore.getState().getModuleConfig('mail')).toEqual({});
|
expect(useWorkspaceStore.getState().getModuleConfig('mail')).toEqual({});
|
||||||
expect(useWorkspaceStore.getState().getModuleConfig('contacts')).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;
|
isModuleVisible: (moduleKey: string, isSystemAdmin?: boolean) => boolean;
|
||||||
visibleModuleKeys: () => Set<string>;
|
visibleModuleKeys: () => Set<string>;
|
||||||
getModuleConfig: (moduleKey: string) => Record<string, any>;
|
getModuleConfig: (moduleKey: string) => Record<string, any>;
|
||||||
|
moduleMenuOrder: () => Map<string, number>;
|
||||||
hasWorkspaces: () => boolean;
|
hasWorkspaces: () => boolean;
|
||||||
// Reset
|
// Reset
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
@@ -120,6 +121,16 @@ export const useWorkspaceStore = create<WorkspaceStoreState>()(
|
|||||||
return mod?.config ?? {};
|
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,
|
hasWorkspaces: () => get().myWorkspaces.length > 0,
|
||||||
|
|
||||||
reset: () => {
|
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