feat(N1): Scope-Registry via Contract — workspace_scopes() Deklarationen + /scope-definitions Endpoint (#365)
Check Cross-Plugin Imports / check (push) Has been cancelled
Check Cross-Plugin Imports / check (push) Has been cancelled
- workspace_scopes() Contract-Hook (document_placeholders-Muster): Plugins deklarieren Scope-Dimensionen inkl. Wertequellen
- Deklarationen: contacts (Ordner/Typen/Saved-View), dms (Ordner/Datei-Typen), mail (Postfächer), calendar (Kalender/Standard-Ansicht)
- Pydantic fail-closed (schemas/workspace.py): ScopeOption, ScopeValueSource (nur interne /api/v1-Pfade, SSRF-sicher), WorkspaceScopeDimension, WorkspaceModuleScopes
- Aggregator workspace_scope_service.py: discovered-Plugins, ARCH-014-safe, Crash-sicher, ungültige Deklarationen verworfen
- GET /api/v1/workspaces/scope-definitions (workspaces:configure_modules) vor /{workspace_id} registriert
- Security-Invariante: Scope = reine UND-Einschränkung (Workspace ∧ RLS ∧ ABAC ∧ Permissions)
- Tests: 18/18 neu (TDD rot→grün), Regression 17/17, Checker 0 Verstöße, Ruff clean
- Doku: api-documentation.md Workspaces-Sektion, PROGRESS.md Phase N1
This commit is contained in:
+21
-2
@@ -7,8 +7,8 @@
|
||||
**Kürzlich abgeschlossen:** Phase L vollständig (L1 Block-System, L2 Drag&Drop-Editor, L3 Renderer, L4 KI-Steuerung, L5 XRechnung-Format-Layer) — Details siehe Phase-L-Sections unten.
|
||||
|
||||
**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). **Nächster Schritt:** Phase N Workspace-Scopes (N1-N4) oder Phase P Notizen-App (P1-P5) — beide user-abgestimmt und startklar in der Roadmap.
|
||||
- **Phase N** — Workspace-Scopes (N1-N4). 0 Umbau — Fundament (config JSONB, X-Workspace-ID, /context, Sidebar-Consumer) existiert bereits.
|
||||
- **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: workspace_scopes() für contacts/dms/mail/calendar, Pydantic fail-closed, `/api/v1/workspaces/scope-definitions` — siehe Phase-N1-Section). **Nächster Schritt:** N2 Dynamischer Scope-Editor.
|
||||
- **Phase O** — UI-Overhaul (umbenannt von Doppel-L, Bug-Verifikation steht im Roadmap-Eintrag: 5/7 Bugs bereits erledigt, offen: 1.2 Kontakte-Drag-Drop in Ordner, 1.3 MoveDialog)
|
||||
|
||||
**Vorbestands-Findings (nicht blockierend, dokumentiert):**
|
||||
@@ -104,6 +104,25 @@
|
||||
|
||||
**Offen in Phase M:** — (Phase M abgeschlossen).
|
||||
|
||||
## Phase N1 — Scope-Registry via Contract (2026-08-31) ✅
|
||||
|
||||
**Spec:** [#365](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/365) | **Roadmap:** Phase N, N1 | **Milestone:** Phase N — Workspace-Scopes (#14)
|
||||
|
||||
**Umgesetzt:**
|
||||
- **Contract-Hook `workspace_scopes()`** (document_placeholders-Muster, #359-Philosophie): Plugins deklarieren Scope-Dimensionen ihres Moduls inkl. Wertequellen; der generische Editor bleibt modul-agnostisch.
|
||||
- **Deklarationen (4 N3-Module):** contacts (folder_ids via /api/v1/contact-folders, contact_types Firmen/Personen, default_saved_view_id via /api/v1/saved-views?entity_type=contact), dms (folder_ids via /api/v1/dms/folders, file_types PDF/Bilder/Tabellen/Dokumente/Sonstige), mail (account_ids via /api/v1/mail/accounts), calendar (calendar_ids via /api/v1/calendars, default_view Tag/Woche/Monat/Zeitraum — Frontend-Ansichten live abgeglichen).
|
||||
- **Pydantic fail-closed** (app/schemas/workspace.py): ScopeOption, ScopeValueSource (nur interne /api/v1/-Pfade — SSRF-sicher per Konstruktion; Validator), WorkspaceScopeDimension (multiselect/select ohne options UND value_source → ValidationError), WorkspaceModuleScopes (module_key + min. 1 Dimension).
|
||||
- **Aggregator** (app/services/workspace_scope_service.py): iteriert discovered Plugins, lazy-loadet Contracts, ARCH-014-safe (deaktivierte bleiben weg), Crash-sicher pro Plugin, ungültige Deklarationen verworfen (Warning-Log).
|
||||
- **Endpoint** `GET /api/v1/workspaces/scope-definitions` (workspaces:configure_modules — Admin-Kontext) — VOR /{workspace_id} registriert (Route-Order-Falle, test_plugin_route_order-Klasse).
|
||||
|
||||
**Verifiziert (2026-08-31):**
|
||||
- TDD: Rot (ImportError) → ✅ Grün **18/18** (tests/test_workspace_scopes.py: Pydantic-Unit 4, Contract-Deklarationen 8, Aggregator fail-closed 1, HTTP-Endpoint 2 (Admin bekommt alle 4 Module, Viewer-403), Route-Order 1, Value-Endpoint-Existenz via OpenAPI 1 (431 Pfade, app.routes enthält nur _IncludedRouter-Wrapper — isinstance-Scan versagt, OpenAPI kanonisch), /context-config-Regression 1)
|
||||
- ✅ Regression: test_workspaces.py **17/17**
|
||||
- ✅ Cross-Plugin-Checker: 0 Verstöße (495 Dateien)
|
||||
- ✅ Ruff clean (alle 8 geänderten Dateien; UP037-Quote-Fix)
|
||||
|
||||
**Offen in Phase N:** N2 Dynamischer Scope-Editor (WorkspaceManager rendert Filter-UI aus /scope-definitions, Speicherung in workspace_modules.config), N3 Listen-Filterung via X-Workspace-ID (additive UND-Einschränkung), N4 restliche Module.
|
||||
|
||||
## Phase M6 — Weitere Hosts (2026-08-30) ✅ — PHASE M KOMPLETT
|
||||
|
||||
**Spec:** [#364](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/364) | **Roadmap:** Phase M, M6 (letzter Task)
|
||||
|
||||
@@ -47,6 +47,41 @@ class CalendarContract:
|
||||
]
|
||||
}
|
||||
|
||||
# ─── Workspace Scopes contribution (Phase N1, #359 pattern) ───
|
||||
|
||||
@staticmethod
|
||||
def workspace_scopes() -> list[dict]:
|
||||
"""Scope-Dimensionen des calendar-Moduls für den Workspace-Editor (N1)."""
|
||||
return [
|
||||
{
|
||||
"module_key": "calendar",
|
||||
"dimensions": [
|
||||
{
|
||||
"key": "calendar_ids",
|
||||
"label": "Kalender",
|
||||
"control": "multiselect",
|
||||
"value_source": {
|
||||
"endpoint": "/api/v1/calendars",
|
||||
"items_path": "",
|
||||
"value_key": "id",
|
||||
"label_key": "name",
|
||||
},
|
||||
},
|
||||
{
|
||||
"key": "default_view",
|
||||
"label": "Standard-Ansicht",
|
||||
"control": "select",
|
||||
"options": [
|
||||
{"value": "day", "label": "Tag"},
|
||||
{"value": "week", "label": "Woche"},
|
||||
{"value": "month", "label": "Monat"},
|
||||
{"value": "range", "label": "Zeitraum"},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def get_function(cls, name: str):
|
||||
"""Return a callable exposed by this contract, or None if absent."""
|
||||
|
||||
@@ -349,6 +349,53 @@ class ContactsContract:
|
||||
data[key] = value if value is not None else ""
|
||||
return data
|
||||
|
||||
# ─── Workspace Scopes contribution (Phase N1, #359 pattern) ───
|
||||
# Declares the scope dimensions the contacts module supports; the N2
|
||||
# workspace editor renders its filter UI from these definitions. Scope
|
||||
# VALUES live per workspace in workspace_modules.config (JSONB).
|
||||
|
||||
@staticmethod
|
||||
def workspace_scopes() -> list[dict]:
|
||||
"""Scope-Dimensionen des contacts-Moduls für den Workspace-Editor."""
|
||||
return [
|
||||
{
|
||||
"module_key": "contacts",
|
||||
"dimensions": [
|
||||
{
|
||||
"key": "folder_ids",
|
||||
"label": "Kontakt-Ordner",
|
||||
"control": "multiselect",
|
||||
"value_source": {
|
||||
"endpoint": "/api/v1/contact-folders",
|
||||
"items_path": "items",
|
||||
"value_key": "id",
|
||||
"label_key": "name",
|
||||
},
|
||||
},
|
||||
{
|
||||
"key": "contact_types",
|
||||
"label": "Kontakt-Typen",
|
||||
"control": "multiselect",
|
||||
"options": [
|
||||
{"value": "company", "label": "Firmen"},
|
||||
{"value": "person", "label": "Personen"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"key": "default_saved_view_id",
|
||||
"label": "Standard-Ansicht",
|
||||
"control": "select",
|
||||
"value_source": {
|
||||
"endpoint": "/api/v1/saved-views?entity_type=contact",
|
||||
"items_path": "",
|
||||
"value_key": "id",
|
||||
"label_key": "name",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def get_function(cls, name: str):
|
||||
"""Return a callable exposed by this contract, or None if absent."""
|
||||
|
||||
@@ -15,6 +15,40 @@ class DmsContract:
|
||||
DmsFile = DmsFile
|
||||
Folder = Folder
|
||||
|
||||
@staticmethod
|
||||
def workspace_scopes() -> list[dict]:
|
||||
"""Scope-Dimensionen des dms-Moduls für den Workspace-Editor (N1)."""
|
||||
return [
|
||||
{
|
||||
"module_key": "dms",
|
||||
"dimensions": [
|
||||
{
|
||||
"key": "folder_ids",
|
||||
"label": "DMS-Ordner",
|
||||
"control": "multiselect",
|
||||
"value_source": {
|
||||
"endpoint": "/api/v1/dms/folders",
|
||||
"items_path": "",
|
||||
"value_key": "id",
|
||||
"label_key": "name",
|
||||
},
|
||||
},
|
||||
{
|
||||
"key": "file_types",
|
||||
"label": "Datei-Typen",
|
||||
"control": "multiselect",
|
||||
"options": [
|
||||
{"value": "application/pdf", "label": "PDF"},
|
||||
{"value": "image/", "label": "Bilder"},
|
||||
{"value": "spreadsheet", "label": "Tabellen"},
|
||||
{"value": "word", "label": "Dokumente"},
|
||||
{"value": "other", "label": "Sonstige"},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def get_function(cls, name: str):
|
||||
"""Return a callable exposed by this contract, or None if absent."""
|
||||
|
||||
@@ -64,6 +64,30 @@ class MailContract:
|
||||
]
|
||||
}
|
||||
|
||||
# ─── Workspace Scopes contribution (Phase N1, #359 pattern) ───
|
||||
|
||||
@staticmethod
|
||||
def workspace_scopes() -> list[dict]:
|
||||
"""Scope-Dimensionen des mail-Moduls für den Workspace-Editor (N1)."""
|
||||
return [
|
||||
{
|
||||
"module_key": "mail",
|
||||
"dimensions": [
|
||||
{
|
||||
"key": "account_ids",
|
||||
"label": "Postfächer",
|
||||
"control": "multiselect",
|
||||
"value_source": {
|
||||
"endpoint": "/api/v1/mail/accounts",
|
||||
"items_path": "",
|
||||
"value_key": "id",
|
||||
"label_key": "email",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def get_function(cls, name: str):
|
||||
"""Return a callable exposed by this contract, or None if absent."""
|
||||
|
||||
@@ -126,6 +126,25 @@ async def workspace_context(
|
||||
return ctx
|
||||
|
||||
|
||||
@router.get("/scope-definitions")
|
||||
async def workspace_scope_definitions(
|
||||
current_user: dict = Depends(require_permission("workspaces:configure_modules")),
|
||||
):
|
||||
"""N1: Scope-Dimensionen aller Module für den Workspace-Editor.
|
||||
|
||||
Aggregiert ``workspace_scopes()``-Contract-Beiträge der Plugins
|
||||
(document_placeholders-Muster): pro module_key die filterbaren
|
||||
Dimensionen inkl. Wertequellen. Der N2-Editor rendert daraus das
|
||||
Filter-UI; die Werte landen in ``workspace_modules.config``.
|
||||
|
||||
Admin-only: Scope-Definitionen konfigurieren Module-Teilmengen —
|
||||
das ist Workspace-Admin-Kontext (configure_modules), kein Lesen.
|
||||
"""
|
||||
from app.services.workspace_scope_service import get_scope_definitions
|
||||
|
||||
return {"modules": get_scope_definitions()}
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_workspace(
|
||||
body: WorkspaceCreate,
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
|
||||
class WorkspaceBase(BaseModel):
|
||||
@@ -34,6 +36,72 @@ class WorkspaceResponse(WorkspaceBase):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ─── N1: Workspace-Scope-Registry (Phase N) ───────────────────
|
||||
|
||||
|
||||
class ScopeOption(BaseModel):
|
||||
"""Ein auswählbarer Wert einer Scope-Dimension."""
|
||||
|
||||
value: str
|
||||
label: str
|
||||
|
||||
|
||||
class ScopeValueSource(BaseModel):
|
||||
"""Deklariert, woher der Editor wählbare Werte lädt.
|
||||
|
||||
Security (fail-closed): nur interne ``/api/v1/...``-Pfade sind
|
||||
erlaubt — keine absoluten URLs, keine protokoll-relativen Pfade,
|
||||
keine externen Hosts (SSRF-sicher per Konstruktion).
|
||||
"""
|
||||
|
||||
endpoint: str
|
||||
items_path: str = "items"
|
||||
value_key: str = "id"
|
||||
label_key: str = "name"
|
||||
|
||||
@field_validator("endpoint")
|
||||
@classmethod
|
||||
def _must_be_internal_api_path(cls, v: str) -> str:
|
||||
if not v.startswith("/api/v1/"):
|
||||
raise ValueError(
|
||||
"value_source endpoint must be an internal /api/v1/ path"
|
||||
)
|
||||
return v
|
||||
|
||||
|
||||
class WorkspaceScopeDimension(BaseModel):
|
||||
"""Eine filterbare Dimension eines Moduls im Workspace (N1).
|
||||
|
||||
``multiselect``/``select`` brauchen zwingend ``options`` oder
|
||||
``value_source`` (fail-closed gegen leere Filter-UI); ``toggle``
|
||||
ist ein reiner Schalter mit ``default``.
|
||||
"""
|
||||
|
||||
key: str = Field(..., min_length=1, max_length=100)
|
||||
label: str = Field(..., min_length=1, max_length=200)
|
||||
control: Literal["multiselect", "select", "toggle"]
|
||||
options: list[ScopeOption] = Field(default_factory=list)
|
||||
value_source: ScopeValueSource | None = None
|
||||
default: bool | str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _options_or_source_required(self) -> WorkspaceScopeDimension:
|
||||
if self.control in ("multiselect", "select"):
|
||||
if not self.options and self.value_source is None:
|
||||
raise ValueError(
|
||||
f"scope dimension '{self.key}': control '{self.control}' "
|
||||
"requires options or value_source"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class WorkspaceModuleScopes(BaseModel):
|
||||
"""Scope-Contribution eines Plugins für ein Modul."""
|
||||
|
||||
module_key: str = Field(..., min_length=1, max_length=100)
|
||||
dimensions: list[WorkspaceScopeDimension] = Field(..., min_length=1)
|
||||
|
||||
|
||||
class WorkspaceModuleResponse(BaseModel):
|
||||
id: str
|
||||
workspace_id: str
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Workspace-Scope-Registry aggregation (Phase N1, Roadmap Phase N).
|
||||
|
||||
Plugins declare the scope dimensions their modules support via the contract
|
||||
hook ``workspace_scopes()`` — same aggregation pattern as
|
||||
``document_placeholders()`` (#359 philosophy): the module owns its domain,
|
||||
the generic consumer stays module-agnostic.
|
||||
|
||||
The N2 workspace editor renders filter UI from these definitions; scope
|
||||
VALUES are stored per workspace in ``workspace_modules.config`` (JSONB).
|
||||
N3 list filtering applies them as pure AND-restrictions.
|
||||
|
||||
Security-Invariante (Phase N): a workspace can never GRANT visibility —
|
||||
effective visibility is always Workspace-Scope ∧ RLS ∧ ABAC ∧ Permissions.
|
||||
Without an active workspace there is no filter (backward compatible).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.schemas.workspace import WorkspaceModuleScopes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _parse_contribution(plugin_name: str, contribution: Any) -> WorkspaceModuleScopes | None:
|
||||
"""Validate a single raw contribution; ``None`` when invalid (fail-closed).
|
||||
|
||||
Every declaration must pass the Pydantic schema — invalid dimensions
|
||||
(e.g. a multiselect without options AND value_source) are dropped with a
|
||||
warning instead of breaking the editor endpoint.
|
||||
"""
|
||||
if not isinstance(contribution, dict):
|
||||
return None
|
||||
try:
|
||||
return WorkspaceModuleScopes.model_validate(contribution)
|
||||
except ValidationError as exc:
|
||||
logger.warning(
|
||||
"Invalid workspace_scopes contribution from plugin '%s': %s",
|
||||
plugin_name,
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def get_scope_definitions() -> dict[str, list[dict[str, Any]]]:
|
||||
"""Aggregate ``workspace_scopes()`` contributions from all discovered plugins.
|
||||
|
||||
Returns ``module_key -> [dimension, ...]`` for the N2 editor. Contracts
|
||||
of explicitly deactivated plugins stay gone (ARCH-014 unregister marker);
|
||||
never-loaded contracts lazy-load on first access (document_placeholders
|
||||
precedent). A plugin crash while declaring scopes never fails the
|
||||
endpoint — its contribution is simply skipped.
|
||||
"""
|
||||
from app.plugins.builtins.contracts import get_contract_registry
|
||||
from app.plugins.registry import get_registry
|
||||
|
||||
modules: dict[str, list[dict[str, Any]]] = {}
|
||||
for plugin_name in get_registry().list_discovered():
|
||||
contract = get_contract_registry().get_contract(plugin_name)
|
||||
if contract is None:
|
||||
continue
|
||||
fn = getattr(contract, "workspace_scopes", None)
|
||||
if fn is None:
|
||||
continue
|
||||
try:
|
||||
contributions = fn() or []
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception(
|
||||
"workspace_scopes() raised for plugin '%s' — skipping", plugin_name
|
||||
)
|
||||
continue
|
||||
for contribution in contributions:
|
||||
parsed = _parse_contribution(plugin_name, contribution)
|
||||
if parsed is None:
|
||||
continue
|
||||
modules.setdefault(parsed.module_key, []).extend(
|
||||
dimension.model_dump() for dimension in parsed.dimensions
|
||||
)
|
||||
return modules
|
||||
@@ -296,6 +296,29 @@ Personal per-user dashboards (Phase M2). Owner-only: every query is scoped to te
|
||||
| DELETE | `/api/v1/dashboards/{id}` | Soft-delete (deleting the default promotes the next one). |
|
||||
| POST | `/api/v1/dashboards/{id}/set-default` | Mark as the user's default (exactly one). |
|
||||
|
||||
### workspaces (14 endpoints)
|
||||
|
||||
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.
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v1/workspaces` | List all workspaces for the tenant. |
|
||||
| POST | `/api/v1/workspaces` | Create workspace (creator becomes manager; only one default per tenant). |
|
||||
| GET | `/api/v1/workspaces/my` | Workspaces assigned to the current user. |
|
||||
| GET | `/api/v1/workspaces/context` | Active workspace context (modules + widgets) — X-Workspace-ID header, falls back to user default. |
|
||||
| GET | `/api/v1/workspaces/scope-definitions` | N1: aggregated `workspace_scopes()` contract contributions — per module_key the filterable scope dimensions (control type, options, value_source) for the workspace editor. Admin (`workspaces:configure_modules`). |
|
||||
| GET | `/api/v1/workspaces/{id}` | Get one workspace with modules and user count. |
|
||||
| PUT | `/api/v1/workspaces/{id}` | Update workspace. |
|
||||
| DELETE | `/api/v1/workspaces/{id}` | Soft-delete workspace (is_active=false). |
|
||||
| POST | `/api/v1/workspaces/{id}/modules` | Replace module assignments (module_key, is_visible, menu_order, config — config JSONB stores Phase N scope values). |
|
||||
| POST | `/api/v1/workspaces/{id}/users` | Assign user (member/manager, same-tenant check). |
|
||||
| DELETE | `/api/v1/workspaces/{id}/users/{user_id}` | Remove user assignment. |
|
||||
| GET | `/api/v1/workspaces/{id}/widgets` | List workspace widgets (multiple instances of same key allowed). |
|
||||
| POST | `/api/v1/workspaces/{id}/widgets` | Create widget (position, size, config). |
|
||||
| PUT | `/api/v1/workspaces/{id}/widgets/{widget_id}` | Update widget. |
|
||||
| DELETE | `/api/v1/workspaces/{id}/widgets/{widget_id}` | Delete widget. |
|
||||
| POST | `/api/v1/workspaces/{id}/set-default` | Set as the current user's default workspace. |
|
||||
|
||||
---
|
||||
|
||||
## Plugin Routes
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
"""N1 — Workspace-Scope-Registry (Contract-Muster, Roadmap Phase N).
|
||||
|
||||
Plugins deklarieren ``workspace_scopes()``: verfügbare Scope-Dimensionen
|
||||
pro Modul inkl. Wertequellen (Multiselects für Ordner/Postfächer/Kalender,
|
||||
Toggles, Standard-Ansichten). Der N2-Editor rendert daraus automatisch
|
||||
Filter-UI; Speicherort ist ``workspace_modules.config`` (JSONB, vorhanden
|
||||
und bereits über /context ausgeliefert).
|
||||
|
||||
Security-Invariante (Phase N): Scope = reine UND-Einschränkung.
|
||||
Sichtbarkeit = Workspace-Scope ∧ RLS ∧ ABAC ∧ Permissions — ein Workspace
|
||||
kann NIE mehr sichtbar machen, nur weniger. Ohne aktiven Workspace kein
|
||||
Filter (rückwärtskompatibel, wie Sidebar).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.routing import APIRoute
|
||||
from httpx import AsyncClient
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from tests.conftest import login_client, seed_tenant_and_users
|
||||
|
||||
SCOPE_DEFINITIONS_PATH = "/api/v1/workspaces/scope-definitions"
|
||||
N1_MODULES = ("contacts", "dms", "mail", "calendar")
|
||||
|
||||
|
||||
# ─── Unit: Pydantic-Validierung (fail-closed) ──────────────────
|
||||
|
||||
|
||||
def test_dimension_select_requires_options_or_source():
|
||||
"""multiselect ohne options UND ohne value_source muss abgelehnt werden."""
|
||||
from app.schemas.workspace import WorkspaceScopeDimension
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
WorkspaceScopeDimension(key="folder_ids", label="Ordner", control="multiselect")
|
||||
with pytest.raises(ValidationError):
|
||||
WorkspaceScopeDimension(key="default_view", label="Ansicht", control="select")
|
||||
|
||||
|
||||
def test_dimension_toggle_needs_no_source():
|
||||
"""toggle braucht keine Wertequelle — reiner Schalter."""
|
||||
from app.schemas.workspace import WorkspaceScopeDimension
|
||||
|
||||
dim = WorkspaceScopeDimension(
|
||||
key="only_mine", label="Nur meine", control="toggle", default=False
|
||||
)
|
||||
assert dim.default is False
|
||||
|
||||
|
||||
def test_value_source_must_be_internal_api_path():
|
||||
"""Nur interne /api/v1-Pfade als Wertequelle (kein SSRF, keine externen URLs)."""
|
||||
from app.schemas.workspace import ScopeValueSource
|
||||
|
||||
for bad in (
|
||||
"https://evil.example.com/folders",
|
||||
"http://127.0.0.1:8000/api/v1/x",
|
||||
"//api/v1/contact-folders",
|
||||
"api/v1/contact-folders",
|
||||
"/api/v2/other",
|
||||
):
|
||||
with pytest.raises(ValidationError):
|
||||
ScopeValueSource(endpoint=bad)
|
||||
# interne Pfade inkl. Query sind erlaubt
|
||||
ok = ScopeValueSource(endpoint="/api/v1/saved-views?entity_type=contact")
|
||||
assert ok.endpoint.startswith("/api/v1/")
|
||||
|
||||
|
||||
def test_contribution_requires_module_key_and_dimensions():
|
||||
from app.schemas.workspace import WorkspaceModuleScopes
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
WorkspaceModuleScopes(module_key="contacts")
|
||||
with pytest.raises(ValidationError):
|
||||
WorkspaceModuleScopes(module_key="", dimensions=[])
|
||||
|
||||
|
||||
# ─── Unit: Contract-Deklarationen der 4 N3-Module ──────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("plugin_name", N1_MODULES)
|
||||
def test_contract_declares_valid_scopes(plugin_name: str):
|
||||
"""Jedes N3-Modul deklariert workspace_scopes() für seinen module_key,
|
||||
und jede Deklaration ist Pydantic-validierbar (fail-closed)."""
|
||||
from app.plugins.builtins.contracts import get_contract
|
||||
from app.schemas.workspace import WorkspaceModuleScopes
|
||||
|
||||
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"
|
||||
|
||||
module_keys = set()
|
||||
for contribution in contributions:
|
||||
parsed = WorkspaceModuleScopes.model_validate(contribution)
|
||||
module_keys.add(parsed.module_key)
|
||||
assert plugin_name in module_keys, (
|
||||
f"{plugin_name}: module_key '{plugin_name}' fehlt in {module_keys}"
|
||||
)
|
||||
|
||||
|
||||
def test_contacts_dimensions_cover_folder_types_view():
|
||||
"""Roadmap N3 contacts: Ordner-Teilmengen, Firmen/Personen-Filter,
|
||||
Standard-Saved-View."""
|
||||
from app.plugins.builtins.contracts import get_contract
|
||||
|
||||
dims = {d["key"]: d for c in get_contract("contacts").workspace_scopes() for d in c["dimensions"]}
|
||||
assert "folder_ids" in dims, "Ordner-Teilmenge fehlt"
|
||||
assert dims["folder_ids"]["value_source"]["endpoint"] == "/api/v1/contact-folders"
|
||||
assert "contact_types" in dims, "Firmen/Personen-Filter fehlt"
|
||||
type_values = {o["value"] for o in dims["contact_types"]["options"]}
|
||||
assert {"company", "person"} <= type_values
|
||||
assert "default_saved_view_id" in dims, "Standard-Ansicht fehlt"
|
||||
|
||||
|
||||
def test_dms_dimensions_cover_folders_and_file_types():
|
||||
"""Roadmap N3 dms: Ordner-Teilmengen + Datei-Typ-Filter."""
|
||||
from app.plugins.builtins.contracts import get_contract
|
||||
|
||||
dims = {d["key"]: d for c in get_contract("dms").workspace_scopes() for d in c["dimensions"]}
|
||||
assert "folder_ids" in dims
|
||||
assert dims["folder_ids"]["value_source"]["endpoint"] == "/api/v1/dms/folders"
|
||||
assert "file_types" in dims
|
||||
assert dims["file_types"]["options"], "Datei-Typ-Optionen fehlen"
|
||||
|
||||
|
||||
def test_mail_dimensions_cover_accounts():
|
||||
"""Roadmap N3 mail: Postfach-Teilmengen."""
|
||||
from app.plugins.builtins.contracts import get_contract
|
||||
|
||||
dims = {d["key"]: d for c in get_contract("mail").workspace_scopes() for d in c["dimensions"]}
|
||||
assert "account_ids" in dims
|
||||
assert dims["account_ids"]["value_source"]["endpoint"] == "/api/v1/mail/accounts"
|
||||
|
||||
|
||||
def test_calendar_dimensions_cover_calendars_and_default_view():
|
||||
"""Roadmap N3 calendar: Kalender-Teilmengen + Standard-Ansicht."""
|
||||
from app.plugins.builtins.contracts import get_contract
|
||||
|
||||
dims = {d["key"]: d for c in get_contract("calendar").workspace_scopes() for d in c["dimensions"]}
|
||||
assert "calendar_ids" in dims
|
||||
assert dims["calendar_ids"]["value_source"]["endpoint"] == "/api/v1/calendars"
|
||||
assert "default_view" in dims
|
||||
view_values = {o["value"] for o in dims["default_view"]["options"]}
|
||||
assert {"day", "week", "month", "range"} <= view_values
|
||||
|
||||
|
||||
# ─── Unit: Aggregator fail-closed ──────────────────────────────
|
||||
|
||||
|
||||
def test_invalid_contribution_dropped_by_parser():
|
||||
"""Ungültige Deklarationen werden verworfen (None), gültige geparst."""
|
||||
from app.services.workspace_scope_service import _parse_contribution
|
||||
|
||||
bad = {
|
||||
"module_key": "fake",
|
||||
"dimensions": [
|
||||
{"key": "nope", "label": "Ohne Quelle", "control": "multiselect"},
|
||||
],
|
||||
}
|
||||
assert _parse_contribution("fakeplugin", bad) is None
|
||||
|
||||
good = {
|
||||
"module_key": "fake",
|
||||
"dimensions": [
|
||||
{
|
||||
"key": "ok",
|
||||
"label": "OK",
|
||||
"control": "multiselect",
|
||||
"options": [{"value": "a", "label": "A"}],
|
||||
},
|
||||
],
|
||||
}
|
||||
parsed = _parse_contribution("fakeplugin", good)
|
||||
assert parsed is not None
|
||||
assert parsed.module_key == "fake"
|
||||
|
||||
|
||||
# ─── HTTP: Scope-Definitionen-Endpoint ─────────────────────────
|
||||
|
||||
|
||||
class TestScopeDefinitionsEndpoint:
|
||||
async def test_admin_gets_definitions_for_all_n1_modules(
|
||||
self, client: AsyncClient, db_session: AsyncSession
|
||||
):
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
|
||||
resp = await client.get(SCOPE_DEFINITIONS_PATH)
|
||||
assert resp.status_code == 200, resp.text
|
||||
modules = resp.json()["modules"]
|
||||
for key in N1_MODULES:
|
||||
assert key in modules, f"Modul {key} fehlt in Scope-Definitionen"
|
||||
assert isinstance(modules[key], list) and modules[key], (
|
||||
f"{key}: Dimensionen leer"
|
||||
)
|
||||
|
||||
async def test_viewer_without_workspaces_permission_gets_403(
|
||||
self, client: AsyncClient, db_session: AsyncSession
|
||||
):
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "viewer@tenanta.com")
|
||||
|
||||
resp = await client.get(SCOPE_DEFINITIONS_PATH)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ─── HTTP: Route-Order & Value-Endpoint-Existenz ───────────────
|
||||
|
||||
|
||||
def test_scope_definitions_route_registered_before_dynamic_workspace_id():
|
||||
"""FastAPI matcht in Registrierungsreihenfolge: /scope-definitions muss
|
||||
VOR /{workspace_id} stehen, sonst wird es als workspace_id verschluckt
|
||||
(gleiche Fehlerklasse wie test_plugin_route_order)."""
|
||||
from app.routes.workspaces import router
|
||||
|
||||
paths = [r.path for r in router.routes if isinstance(r, APIRoute)]
|
||||
assert SCOPE_DEFINITIONS_PATH in paths, "/scope-definitions Route fehlt"
|
||||
assert "/api/v1/workspaces/{workspace_id}" in paths
|
||||
assert paths.index(SCOPE_DEFINITIONS_PATH) < paths.index(
|
||||
"/api/v1/workspaces/{workspace_id}"
|
||||
), "/scope-definitions muss vor /{workspace_id} registriert werden"
|
||||
|
||||
|
||||
async def test_declared_value_endpoints_exist_in_app(app):
|
||||
"""Jede deklarierte value_source-Endpoint muss als GET-Route in der
|
||||
FastAPI-App existieren (keine Geister-Quellen im Editor).
|
||||
|
||||
Prüft über das OpenAPI-Schema: ``app.routes`` enthält hier nur
|
||||
``_IncludedRouter``-Wrapper, deren flacher isinstance-Scan keine
|
||||
APIRoute-Objekte mehr liefert — OpenAPI dagegen aggregiert den
|
||||
vollständigen Pfad-/Methoden-Satz kanonisch.
|
||||
"""
|
||||
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 N1_MODULES:
|
||||
contract = get_contract(plugin_name)
|
||||
assert contract is not None
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
# ─── Regression: /context liefert Modul-config mit aus ──────────
|
||||
|
||||
|
||||
class TestContextConfigRegression:
|
||||
"""Roadmap N1: '/context liefert config der Module mit aus' — der
|
||||
Bestand fließt config bereits durch; dieser Test sichert den Contract
|
||||
für den N2-Editor (Speicher = workspace_modules.config)."""
|
||||
|
||||
async def test_context_returns_module_config(
|
||||
self, client: AsyncClient, db_session: AsyncSession
|
||||
):
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
|
||||
ws = await client.post("/api/v1/workspaces", json={"name": "ScopeWS"})
|
||||
assert ws.status_code == 201, ws.text
|
||||
ws_id = ws.json()["id"]
|
||||
|
||||
scope_config = {"folder_ids": ["11111111-1111-1111-1111-111111111111"], "contact_types": ["company"]}
|
||||
mod = await client.post(
|
||||
f"/api/v1/workspaces/{ws_id}/modules",
|
||||
json={
|
||||
"modules": [
|
||||
{
|
||||
"module_key": "contacts",
|
||||
"is_visible": True,
|
||||
"menu_order": 0,
|
||||
"config": scope_config,
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert mod.status_code == 200, mod.text
|
||||
|
||||
ctx = await client.get(
|
||||
"/api/v1/workspaces/context", headers={"X-Workspace-ID": ws_id}
|
||||
)
|
||||
assert ctx.status_code == 200, ctx.text
|
||||
entries = [m for m in ctx.json()["modules"] if m["module_key"] == "contacts"]
|
||||
assert entries, "contacts-Modul fehlt im Context"
|
||||
assert entries[0]["config"] == scope_config, "config wird nicht ausgeliefert"
|
||||
Reference in New Issue
Block a user