Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58ebf2c7e6 | ||
|
|
b23e6e4eee | ||
|
|
4408a9486f | ||
|
|
9f17de7a3f | ||
|
|
aa4ed0b7c1 | ||
|
|
d1e3b1cbfd | ||
|
|
f34a6b17a9 | ||
|
|
8b67f28ace | ||
|
|
bc0eb60064 | ||
|
|
5bae5bbc3e | ||
|
|
1a7b9fbf16 | ||
|
|
8a3724bf8b | ||
|
|
30fcdbe832 | ||
|
|
3ebe8c2c99 | ||
|
|
18e4df348a |
@@ -46,13 +46,21 @@ accessibility evidence are recorded in
|
||||
|
||||
## Module Contract
|
||||
|
||||
The module registers two capabilities from
|
||||
`govoplan_core.core.organizations`:
|
||||
The module registers organization-directory capabilities from
|
||||
`govoplan_core.core.organizations` and a privacy capability:
|
||||
|
||||
- `organizations.directory` for backward-compatible direct unit/function
|
||||
lookup;
|
||||
- `organizations.hierarchyDirectory` for typed, tenant-safe, explicitly
|
||||
structure-scoped hierarchy and path resolution.
|
||||
- `privacy.dsar.organizations` for tenant-scoped account attribution on model
|
||||
instantiations and upgrades.
|
||||
|
||||
The DSAR provider does not treat institutional units or functions as personal
|
||||
records. It retains model-change attribution as governance evidence and excludes
|
||||
global templates, opaque definitions, previews, decisions, provenance,
|
||||
idempotency material, and other tenants. Identity-to-function assignments are
|
||||
covered by IDM, which owns that relationship.
|
||||
|
||||
Feature modules should consume the capability instead of importing
|
||||
organization ORM models.
|
||||
|
||||
@@ -38,11 +38,14 @@ units, relations, functions, settings, and their mutation consequences.
|
||||
|
||||
## State And Accessibility Evidence
|
||||
|
||||
The module uses Core page, subnavigation, grid, tree, card, dialog, loading,
|
||||
alert, status, blocker, field-help, and disabled-action controls. Stable table
|
||||
The module uses Core `WorkspaceLayout` and `PageLayout` for both the standalone
|
||||
workspace and embedded administration contribution, plus shared subnavigation,
|
||||
grid, tree, card, dialog, loading, alert, status, blocker, field-help, and
|
||||
disabled-action controls. Pane width, content inset, headings, route actions,
|
||||
notices, scrolling, and responsive navigation are therefore Core-owned rather
|
||||
than repeated in Organizations CSS. Stable table
|
||||
action slots remain keyboard reachable; dialogs retain shared focus containment
|
||||
and return. Responsive behavior stays owned by the existing workspace and admin
|
||||
shell CSS. Labels and accessible properties use the English/German module
|
||||
and return. Labels and accessible properties use the English/German module
|
||||
catalogue, and API errors are bounded to the current tenant operation.
|
||||
|
||||
The manifest contributes stable help contexts for the workspace, sections,
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/organizations-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.21",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "webui/src/index.ts",
|
||||
@@ -19,7 +19,7 @@
|
||||
"LICENSE"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.45",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
|
||||
+3
-3
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-organizations"
|
||||
version = "0.1.15"
|
||||
version = "0.1.21"
|
||||
description = "GovOPlaN organizational model module."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"govoplan-core>=0.1.15",
|
||||
"govoplan-tenancy>=0.1.15",
|
||||
"govoplan-core>=0.1.45",
|
||||
"govoplan-tenancy>=0.1.18",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""GovOPlaN organizations module."""
|
||||
|
||||
__version__ = "0.1.15"
|
||||
__version__ = "0.1.21"
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.dsar import (
|
||||
DsarErasureActionRef,
|
||||
DsarExecutionResultRef,
|
||||
DsarRecordRef,
|
||||
DsarSubjectRef,
|
||||
dsar_capability_name,
|
||||
)
|
||||
from govoplan_organizations.backend.db.models import (
|
||||
OrganizationModelInstantiation,
|
||||
OrganizationModelUpgrade,
|
||||
)
|
||||
|
||||
|
||||
ORGANIZATIONS_DSAR_CAPABILITY = dsar_capability_name("organizations")
|
||||
_MAX_RECORDS = 5_000
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _SubjectSelectors:
|
||||
account_id: str | None
|
||||
instantiation_id: str | None
|
||||
upgrade_id: str | None
|
||||
|
||||
|
||||
class OrganizationsDsarProvider:
|
||||
provider_id = "organizations"
|
||||
module_id = "organizations"
|
||||
|
||||
def search_subject(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
) -> Sequence[DsarRecordRef]:
|
||||
db = _session(session)
|
||||
selectors = _subject_selectors(subject)
|
||||
if selectors is None or not selectors.account_id:
|
||||
return ()
|
||||
|
||||
records: list[DsarRecordRef] = []
|
||||
|
||||
instantiation_query = db.query(OrganizationModelInstantiation).filter(
|
||||
OrganizationModelInstantiation.tenant_id == tenant_id,
|
||||
OrganizationModelInstantiation.instantiated_by_account_id
|
||||
== selectors.account_id,
|
||||
)
|
||||
if selectors.instantiation_id:
|
||||
instantiation_query = instantiation_query.filter(
|
||||
OrganizationModelInstantiation.id == selectors.instantiation_id
|
||||
)
|
||||
for row in _bounded_rows(
|
||||
instantiation_query.order_by(OrganizationModelInstantiation.id)
|
||||
):
|
||||
records.append(
|
||||
_record(
|
||||
"organizations_model_instantiation",
|
||||
row.id,
|
||||
"organization_model_governance_evidence",
|
||||
"Organization model instantiation attribution",
|
||||
{
|
||||
"match_fields": ["instantiated_by_account_id"],
|
||||
"template_id": row.template_id,
|
||||
"template_version_id": row.template_version_id,
|
||||
"source_definition_sha256": row.source_definition_sha256,
|
||||
"status": row.status,
|
||||
},
|
||||
observed_at=row.updated_at,
|
||||
retention_reason=(
|
||||
"Model-instantiation attribution is retained to explain which "
|
||||
"reviewed institutional model became tenant-owned."
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
upgrade_query = db.query(OrganizationModelUpgrade).filter(
|
||||
OrganizationModelUpgrade.tenant_id == tenant_id,
|
||||
or_(
|
||||
OrganizationModelUpgrade.requested_by_account_id
|
||||
== selectors.account_id,
|
||||
OrganizationModelUpgrade.applied_by_account_id == selectors.account_id,
|
||||
OrganizationModelUpgrade.cancelled_by_account_id
|
||||
== selectors.account_id,
|
||||
),
|
||||
)
|
||||
if selectors.upgrade_id:
|
||||
upgrade_query = upgrade_query.filter(
|
||||
OrganizationModelUpgrade.id == selectors.upgrade_id
|
||||
)
|
||||
for row in _bounded_rows(upgrade_query.order_by(OrganizationModelUpgrade.id)):
|
||||
records.append(
|
||||
_record(
|
||||
"organizations_model_upgrade",
|
||||
row.id,
|
||||
"organization_model_governance_evidence",
|
||||
"Organization model upgrade attribution",
|
||||
{
|
||||
"match_fields": _actor_match_fields(
|
||||
row,
|
||||
selectors.account_id,
|
||||
(
|
||||
"requested_by_account_id",
|
||||
"applied_by_account_id",
|
||||
"cancelled_by_account_id",
|
||||
),
|
||||
),
|
||||
"template_id": row.template_id,
|
||||
"source_instantiation_id": row.source_instantiation_id,
|
||||
"source_template_version_id": (row.source_template_version_id),
|
||||
"target_template_version_id": (row.target_template_version_id),
|
||||
"status": row.status,
|
||||
"revision": row.revision,
|
||||
"base_definition_sha256": row.base_definition_sha256,
|
||||
"local_definition_sha256": row.local_definition_sha256,
|
||||
"target_definition_sha256": row.target_definition_sha256,
|
||||
"applied_at": _iso(row.applied_at),
|
||||
"cancelled_at": _iso(row.cancelled_at),
|
||||
},
|
||||
observed_at=row.updated_at,
|
||||
retention_reason=(
|
||||
"Upgrade request, application, and cancellation attribution "
|
||||
"is retained as organization-model change evidence."
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
if len(records) > _MAX_RECORDS:
|
||||
raise ValueError(
|
||||
"Organizations DSAR result limit exceeded; narrow the subject selectors."
|
||||
)
|
||||
return tuple(records)
|
||||
|
||||
def plan_erasure(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
records: Sequence[DsarRecordRef],
|
||||
) -> Sequence[DsarErasureActionRef]:
|
||||
del tenant_id
|
||||
_session(session)
|
||||
if _subject_selectors(subject) is None:
|
||||
raise ValueError("Organizations DSAR subject selectors conflict.")
|
||||
actions: list[DsarErasureActionRef] = []
|
||||
for record in records:
|
||||
_validate_record(record)
|
||||
actions.append(
|
||||
DsarErasureActionRef(
|
||||
action_id=(
|
||||
f"organizations:retain:{record.resource_type}:"
|
||||
f"{record.resource_id}"
|
||||
),
|
||||
provider_id=self.provider_id,
|
||||
module_id=self.module_id,
|
||||
kind="retain",
|
||||
resource_type=record.resource_type,
|
||||
resource_id=record.resource_id,
|
||||
title=f"Retain {record.title}",
|
||||
rationale=record.retention_reason
|
||||
or "Organization-model governance evidence must be retained.",
|
||||
executable=False,
|
||||
)
|
||||
)
|
||||
return tuple(actions)
|
||||
|
||||
def execute_erasure(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
actions: Sequence[DsarErasureActionRef],
|
||||
request_id: str,
|
||||
) -> Sequence[DsarExecutionResultRef]:
|
||||
del tenant_id
|
||||
_session(session)
|
||||
if _subject_selectors(subject) is None:
|
||||
raise ValueError("Organizations DSAR subject selectors conflict.")
|
||||
results: list[DsarExecutionResultRef] = []
|
||||
for action in actions:
|
||||
_validate_action(action)
|
||||
if action.executable:
|
||||
raise ValueError(
|
||||
"Organizations DSAR does not publish executable erasure actions."
|
||||
)
|
||||
results.append(
|
||||
DsarExecutionResultRef(
|
||||
action_id=action.action_id,
|
||||
status="blocked",
|
||||
summary=(
|
||||
"Organization-model change attribution is retained as "
|
||||
"institutional governance evidence."
|
||||
),
|
||||
evidence={"request_id": request_id},
|
||||
)
|
||||
)
|
||||
return tuple(results)
|
||||
|
||||
|
||||
def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None:
|
||||
account_ids = {
|
||||
value
|
||||
for item in (
|
||||
subject.account_id,
|
||||
subject.external_references.get("organizations.account"),
|
||||
subject.external_references.get("access.account"),
|
||||
)
|
||||
if (value := _normalized_id(item))
|
||||
}
|
||||
if len(account_ids) > 1:
|
||||
return None
|
||||
return _SubjectSelectors(
|
||||
account_id=next(iter(account_ids), None),
|
||||
instantiation_id=_normalized_id(
|
||||
subject.external_references.get("organizations.model_instantiation")
|
||||
),
|
||||
upgrade_id=_normalized_id(
|
||||
subject.external_references.get("organizations.model_upgrade")
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _actor_match_fields(
|
||||
row: object,
|
||||
account_id: str,
|
||||
fields: Sequence[str],
|
||||
) -> list[str]:
|
||||
return [field for field in fields if getattr(row, field, None) == account_id]
|
||||
|
||||
|
||||
def _validate_record(record: DsarRecordRef) -> None:
|
||||
if record.provider_id != "organizations" or record.module_id != "organizations":
|
||||
raise ValueError("Organizations DSAR received a foreign provider record.")
|
||||
|
||||
|
||||
def _validate_action(action: DsarErasureActionRef) -> None:
|
||||
if action.provider_id != "organizations" or action.module_id != "organizations":
|
||||
raise ValueError("Organizations DSAR received a foreign provider action.")
|
||||
|
||||
|
||||
def _record(
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
category: str,
|
||||
title: str,
|
||||
data: Mapping[str, object],
|
||||
*,
|
||||
observed_at: datetime | None,
|
||||
retention_reason: str,
|
||||
) -> DsarRecordRef:
|
||||
return DsarRecordRef(
|
||||
provider_id="organizations",
|
||||
module_id="organizations",
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
category=category,
|
||||
title=title,
|
||||
data=data,
|
||||
observed_at=observed_at,
|
||||
immutable_evidence=True,
|
||||
retention_reason=retention_reason,
|
||||
source_path="/admin?section=tenant-organization-settings",
|
||||
)
|
||||
|
||||
|
||||
def _session(value: object) -> Session:
|
||||
if not isinstance(value, Session):
|
||||
raise TypeError("Organizations DSAR provider requires a SQLAlchemy session.")
|
||||
return value
|
||||
|
||||
|
||||
def _bounded_rows(query: object) -> list[object]:
|
||||
rows = query.limit(_MAX_RECORDS + 1).all() # type: ignore[attr-defined]
|
||||
if len(rows) > _MAX_RECORDS:
|
||||
raise ValueError(
|
||||
"Organizations DSAR match limit exceeded; narrow the subject selectors."
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _normalized_id(value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
value = str(value).strip()
|
||||
return value or None
|
||||
|
||||
|
||||
def _iso(value: datetime | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=timezone.utc)
|
||||
return value.isoformat()
|
||||
|
||||
|
||||
__all__ = ["ORGANIZATIONS_DSAR_CAPABILITY", "OrganizationsDsarProvider"]
|
||||
@@ -8,6 +8,8 @@ from govoplan_core.core.access import (
|
||||
)
|
||||
from govoplan_core.core.module_guards import persistent_table_uninstall_guard
|
||||
from govoplan_core.core.modules import (
|
||||
CapabilityDocumentation,
|
||||
DocumentationCondition,
|
||||
DocumentationLink,
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
@@ -18,6 +20,7 @@ from govoplan_core.core.modules import (
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
ProductAreaContribution,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||
@@ -28,6 +31,7 @@ from govoplan_core.core.organizations import (
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_organizations.backend.db import models as organization_models # noqa: F401 - populate metadata
|
||||
from govoplan_organizations.backend.dsar_provider import ORGANIZATIONS_DSAR_CAPABILITY
|
||||
|
||||
|
||||
ORGANIZATIONS_READ_SCOPES = (
|
||||
@@ -130,10 +134,17 @@ def _organization_directory(context: ModuleContext) -> object:
|
||||
return SqlOrganizationDirectory()
|
||||
|
||||
|
||||
def _organizations_dsar_provider(context: ModuleContext) -> object:
|
||||
del context
|
||||
from govoplan_organizations.backend.dsar_provider import OrganizationsDsarProvider
|
||||
|
||||
return OrganizationsDsarProvider()
|
||||
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id="organizations",
|
||||
name="Organizations",
|
||||
version="0.1.15",
|
||||
version="0.1.21",
|
||||
required_capabilities=(
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
@@ -148,6 +159,10 @@ manifest = ModuleManifest(
|
||||
name="organizations.hierarchy_directory",
|
||||
version="0.1.0",
|
||||
),
|
||||
ModuleInterfaceProvider(
|
||||
name=ORGANIZATIONS_DSAR_CAPABILITY,
|
||||
version="0.1.0",
|
||||
),
|
||||
),
|
||||
permissions=PERMISSIONS,
|
||||
role_templates=ROLE_TEMPLATES,
|
||||
@@ -181,6 +196,17 @@ manifest = ModuleManifest(
|
||||
order=70,
|
||||
),
|
||||
),
|
||||
product_areas=(
|
||||
ProductAreaContribution(
|
||||
id="people-responsibility",
|
||||
module_id="organizations",
|
||||
label="i18n:govoplan-core.product_area.people_responsibility",
|
||||
icon="users",
|
||||
description="i18n:govoplan-core.product_area.people_responsibility_description",
|
||||
surface_ids=("organizations.nav.organizations", "organizations.route.organizations"),
|
||||
order=70,
|
||||
),
|
||||
),
|
||||
view_surfaces=(
|
||||
ViewSurface(
|
||||
id="organizations.admin.tenant",
|
||||
@@ -224,8 +250,106 @@ manifest = ModuleManifest(
|
||||
capability_factories={
|
||||
CAPABILITY_ORGANIZATION_DIRECTORY: _organization_directory,
|
||||
CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY: (_organization_directory),
|
||||
ORGANIZATIONS_DSAR_CAPABILITY: _organizations_dsar_provider,
|
||||
},
|
||||
capability_documentation={
|
||||
ORGANIZATIONS_DSAR_CAPABILITY: CapabilityDocumentation(
|
||||
label="Organizations data-subject request provider",
|
||||
summary=(
|
||||
"Finds tenant-scoped account attribution on organization-model "
|
||||
"instantiations and upgrades without exporting opaque model payloads."
|
||||
),
|
||||
contract_version="0.1.0",
|
||||
documentation_types=("admin",),
|
||||
audience=("privacy_officer", "organization_admin", "records_manager"),
|
||||
),
|
||||
},
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="organizations.workspace-layout",
|
||||
title="Organizations workspace layout",
|
||||
summary="Find workspace actions and read consistently arranged content.",
|
||||
body="Organization model tables use the complete card width; headings and creation actions stay in the card header, while the shared DataGrid owns filtering, sizing, pagination, and row actions. Unit types, structures, relation types, function types, units, relations, and functions follow the same layout. Model upgrades keep their meaningful version and decision context separate from the result table. Administrators and configurators must use Core's table-card layout instead of local padding or width overrides. The change does not modify model permissions, effective dates, or template-copy semantics.",
|
||||
layer="static",
|
||||
documentation_types=("user", "admin"),
|
||||
audience=("user", "module_admin", "operator"),
|
||||
order=5,
|
||||
translations={"de": {
|
||||
"title": "Organisationen: Aufbau des Arbeitsbereichs",
|
||||
"summary": "Arbeitsbereichsaktionen finden und einheitlich angeordnete Inhalte lesen.",
|
||||
"body": "Tabellen des Organisationsmodells nutzen die gesamte Kartenbreite. Überschrift und Anlegen-Aktionen bleiben im Kartenkopf; das gemeinsame DataGrid übernimmt Filter, Größe, Seitennavigation und Zeilenaktionen. Einheitentypen, Strukturen, Beziehungstypen, Funktionstypen, Einheiten, Beziehungen und Funktionen folgen demselben Layout. Modell-Upgrades behalten ihren relevanten Versions- und Entscheidungskontext getrennt von der Ergebnistabelle. Administratoren und Konfiguratoren verwenden Cores Tabellenkartenlayout statt lokaler Innenabstands- oder Breitenregeln. Rechte, Gültigkeitsdaten und das Kopierprinzip der Vorlagen ändern sich dadurch nicht.",
|
||||
}},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="organizations.privacy.data-subject-requests",
|
||||
title="Review Organizations data in a data-subject request",
|
||||
summary=(
|
||||
"Collect tenant-scoped organization-model change attribution while "
|
||||
"preserving institutional governance evidence."
|
||||
),
|
||||
body=(
|
||||
"Organizations stores institutional units, structures, relations, and "
|
||||
"functions rather than personal incumbency. IDM owns the links between "
|
||||
"identities and functions and must answer for those assignments. The "
|
||||
"Organizations DSAR provider therefore searches only corroborated account "
|
||||
"attribution on tenant model instantiations and upgrade requests, "
|
||||
"applications, or cancellations. It exports the affected template and "
|
||||
"version references, status, revision, timestamps, and integrity hashes. "
|
||||
"Global template authorship, unrelated institutional model objects, "
|
||||
"template definitions, upgrade previews and decisions, provenance, "
|
||||
"idempotency keys, request digests, opaque settings, and other tenants are "
|
||||
"excluded. The attribution is retained as institutional model-change "
|
||||
"evidence and the provider publishes no automatic erasure action."
|
||||
),
|
||||
layer="static",
|
||||
documentation_types=("admin",),
|
||||
audience=(
|
||||
"privacy_officer",
|
||||
"organization_admin",
|
||||
"records_manager",
|
||||
"operator",
|
||||
),
|
||||
related_modules=("access", "audit", "idm", "records"),
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="Data-subject requests",
|
||||
href="/admin?section=tenant-data-subject-requests",
|
||||
kind="runtime",
|
||||
),
|
||||
DocumentationLink(
|
||||
label="Organizations administration",
|
||||
href="/admin?section=tenant-organization-settings",
|
||||
kind="runtime",
|
||||
),
|
||||
),
|
||||
metadata={
|
||||
"kind": "guide",
|
||||
"help_contexts": [
|
||||
"organizations.admin.tenant",
|
||||
"organizations.admin.template-upgrades",
|
||||
],
|
||||
},
|
||||
order=24,
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Organizations-Daten in einer Datenschutzanfrage prüfen",
|
||||
"summary": (
|
||||
"Mandantenbezogene Zuordnungen von Änderungen am Organisationsmodell sammeln und institutionelle Governance-Nachweise bewahren."
|
||||
),
|
||||
"body": (
|
||||
"Organizations speichert institutionelle Einheiten, Strukturen, Beziehungen und Funktionen statt persönlicher "
|
||||
"Funktionsbesetzungen. IDM führt die Verknüpfungen zwischen Identitäten und Funktionen und beantwortet Anfragen "
|
||||
"zu diesen Zuordnungen. Der DSAR-Anbieter von Organizations sucht deshalb nur bestätigte Kontozuordnungen an "
|
||||
"Mandantenmodellinstanzen sowie Anträgen, Ausführungen oder Abbrüchen von Upgrades. Er gibt betroffene Vorlagen- "
|
||||
"und Versionsverweise, Status, Revision, Zeitangaben und Integritätsprüfsummen aus. Globale Vorlagenurheberschaft, "
|
||||
"fremde institutionelle Modellobjekte, Vorlagendefinitionen, Upgrade-Vorschauen und -Entscheidungen, Provenienz, "
|
||||
"Idempotenzschlüssel, Anfrageprüfsummen, undurchsichtige Einstellungen und andere Mandanten bleiben ausgeschlossen. "
|
||||
"Die Zuordnung bleibt als institutioneller Nachweis einer Modelländerung erhalten; der Anbieter veröffentlicht "
|
||||
"keine automatische Löschaktion."
|
||||
),
|
||||
}
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="organizations.template-upgrades",
|
||||
title="Upgrade a tenant organization model",
|
||||
@@ -272,6 +396,24 @@ manifest = ModuleManifest(
|
||||
],
|
||||
},
|
||||
order=27,
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Organisationsmodell eines Mandanten aktualisieren",
|
||||
"summary": (
|
||||
"Eine unveränderliche Systemvorlagenversion mit dem aktuellen mandanteneigenen Modell vergleichen, bevor ein Upgrade ausdrücklich angewendet wird."
|
||||
),
|
||||
"body": (
|
||||
"In der Organizations-Administration wird eine Vorschau für eine neuere veröffentlichte Version der vom "
|
||||
"Mandanten verwendeten Vorlage erstellt. Der gespeicherte Drei-Wege-Vergleich trennt kompatible Ergänzungen, "
|
||||
"kompatible Änderungen, reine Mandantenabweichungen, destruktive Neuzuordnungen und ungültige Verweise. Reine "
|
||||
"Mandantenänderungen bleiben erhalten. Konfliktbehaftete oder destruktive Einträge erfordern eine ausdrückliche "
|
||||
"Entscheidung zum Behalten, Ersetzen oder zu einer begrenzten Zuordnung. Das Anwenden der geprüften Vorschau "
|
||||
"erzeugt eine neue mandanteneigene Instanz und ersetzt die frühere Provenienz; eine laufende Vererbung entsteht "
|
||||
"nie. Eine veraltete Vorschau wird abgewiesen, wenn sich Mandantenmodell oder eine der Vorlagenversionen geändert "
|
||||
"hat. Ein Abbruch bewahrt den Prüfdatensatz, ohne Organisationsdaten zu verändern."
|
||||
),
|
||||
}
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="organizations.model",
|
||||
@@ -286,6 +428,9 @@ manifest = ModuleManifest(
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("tenant_admin", "access_admin", "operator"),
|
||||
conditions=(
|
||||
DocumentationCondition(any_scopes=ORGANIZATIONS_READ_SCOPES),
|
||||
),
|
||||
related_modules=("tenancy", "access", "idm", "policy", "audit"),
|
||||
links=(
|
||||
DocumentationLink(
|
||||
@@ -300,7 +445,7 @@ manifest = ModuleManifest(
|
||||
),
|
||||
),
|
||||
metadata={
|
||||
"kind": "guide",
|
||||
"kind": "workflow",
|
||||
"help_contexts": [
|
||||
"organizations.workspace",
|
||||
"organizations.model",
|
||||
@@ -312,6 +457,22 @@ manifest = ModuleManifest(
|
||||
],
|
||||
},
|
||||
order=25,
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Organisationsmodell",
|
||||
"summary": (
|
||||
"Organizations führt Einheiten, Hierarchie und Funktionen. IDM verknüpft Identitäten mit Funktionen; Access bildet angenommene Fakten auf Rollen und Rechte ab."
|
||||
),
|
||||
"body": (
|
||||
"Organisationseinheitsarten, Strukturen und Beziehungsarten modellieren die Selbstbeschreibung der Institution. "
|
||||
"Eine konkrete Organisationseinheit kann gleichzeitig an mehreren Strukturen teilnehmen, etwa einer "
|
||||
"Arbeitgeberhierarchie und einer akademischen Struktur. Funktionen beschreiben Verantwortlichkeiten in "
|
||||
"Organisationseinheiten. IDM verknüpft Identitäten mit diesen Funktionen; Access bildet angenommene Fakten auf "
|
||||
"Rollen und Rechte ab. Eine Funktion beweist für sich weder Mandat, Zuständigkeit, Entscheidungsbefugnis noch "
|
||||
"Unterschriftsbefugnis; diese wirksamen institutionellen Fakten gehören in einen getrennten Anbietervertrag."
|
||||
),
|
||||
}
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="organizations.reference.fields-and-consequences",
|
||||
@@ -328,7 +489,11 @@ manifest = ModuleManifest(
|
||||
"downstream routing. Deactivation retains the record and evidence but "
|
||||
"removes it from active selection. Delegation and act-in-place flags only "
|
||||
"describe permitted organizational semantics; Access and governed workflows "
|
||||
"still decide effective authority. When configured, a recorded change-request "
|
||||
"still decide effective authority. A delegable function permits a bounded "
|
||||
"substitute to act as themself; act-in-place permits an explicitly selected "
|
||||
"representation context that retains the real and represented accounts. Both "
|
||||
"remain source-linked, time-bounded, revocable facts in IDM rather than inferred "
|
||||
"group membership. When configured, a recorded change-request "
|
||||
"ID is required before model mutations. Organization settings are tenant-owned "
|
||||
"and do not inherit a live global hierarchy."
|
||||
),
|
||||
@@ -368,6 +533,38 @@ manifest = ModuleManifest(
|
||||
},
|
||||
},
|
||||
order=26,
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Felder und Folgen des Organisationsmodells",
|
||||
"summary": (
|
||||
"Mandanteneigene Modelldefinitionen, konkrete Einheiten, Beziehungen, Funktionen, Governance-Verweise und Lebenszykluszustände unterscheiden."
|
||||
),
|
||||
"body": (
|
||||
"Einheitsarten, Strukturen, Beziehungsarten und Funktionsarten definieren das mandanteneigene Modell. Einheiten, "
|
||||
"Beziehungen und Funktionen sind konkrete institutionelle Fakten darin. Slugs sind stabile Integrationsverweise; "
|
||||
"Änderungen an Eltern und Beziehungen wirken auf Hierarchietraversierung und nachgelagerte Weiterleitung. "
|
||||
"Deaktivierung bewahrt Datensatz und Nachweise, entfernt ihn aber aus der aktiven Auswahl. Kennzeichen für "
|
||||
"Delegation und Handeln-an-Stelle beschreiben nur zulässige Organisationssemantik; Access und gesteuerte Workflows "
|
||||
"entscheiden weiterhin über wirksame Befugnis. Eine delegierbare Funktion erlaubt einer begrenzten Vertretung, als "
|
||||
"sie selbst zu handeln; Handeln-an-Stelle erlaubt einen ausdrücklich gewählten Vertretungskontext, der reales und "
|
||||
"vertretenes Konto bewahrt. Beides bleibt in IDM quellverknüpft, zeitlich begrenzt und widerrufbar, statt aus "
|
||||
"Gruppenmitgliedschaft abgeleitet zu werden. Wenn konfiguriert, ist vor Modelländerungen eine erfasste "
|
||||
"Änderungsantragskennung erforderlich. Organisationseinstellungen gehören dem Mandanten und erben keine laufende "
|
||||
"globale Hierarchie."
|
||||
),
|
||||
}
|
||||
},
|
||||
structured_translation_version="1",
|
||||
structured_translations={
|
||||
"de": {
|
||||
"consequence_classes": {
|
||||
"model_change": "Ändert zulässiges Vokabular und Einschränkungen mandanteneigener Organisationsfakten.",
|
||||
"hierarchy_change": "Ändert Traversierung, Weiterleitung und geerbten institutionellen Kontext nachgelagerter Module.",
|
||||
"deactivate": "Bewahrt Fakt und Nachweis und entfernt sie aus der aktiven Auswahl.",
|
||||
"settings": "Ändert mandanteneigene Governance, Prüftiefe und Aufbewahrungsverhalten.",
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
architecture=declared_module_architecture(
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from govoplan_core.core.dsar import (
|
||||
DsarErasureActionRef,
|
||||
DsarProvider,
|
||||
DsarSubjectRef,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.privacy.dsar_workflow import (
|
||||
create_data_subject_request,
|
||||
search_data_subject_request,
|
||||
)
|
||||
from govoplan_organizations.backend.db.models import (
|
||||
OrganizationModelInstantiation,
|
||||
OrganizationModelTemplate,
|
||||
OrganizationModelTemplateVersion,
|
||||
OrganizationModelUpgrade,
|
||||
)
|
||||
from govoplan_organizations.backend.dsar_provider import (
|
||||
ORGANIZATIONS_DSAR_CAPABILITY,
|
||||
OrganizationsDsarProvider,
|
||||
)
|
||||
from govoplan_organizations.backend.manifest import manifest
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(
|
||||
self,
|
||||
provider: OrganizationsDsarProvider,
|
||||
*,
|
||||
organizations_active: bool = True,
|
||||
) -> None:
|
||||
self.provider = provider
|
||||
self.organizations_active = organizations_active
|
||||
|
||||
def capability_names(self):
|
||||
return (ORGANIZATIONS_DSAR_CAPABILITY,)
|
||||
|
||||
def capability_owner(self, name):
|
||||
self._assert_capability(name)
|
||||
return "organizations"
|
||||
|
||||
def tenant_entitlement_resolver(self):
|
||||
organizations_active = self.organizations_active
|
||||
|
||||
class _Resolver:
|
||||
@staticmethod
|
||||
def resolve(session, tenant_id):
|
||||
del session, tenant_id
|
||||
return type(
|
||||
"State",
|
||||
(),
|
||||
{
|
||||
"effective_modules": (
|
||||
("organizations",) if organizations_active else ()
|
||||
)
|
||||
},
|
||||
)()
|
||||
|
||||
return _Resolver()
|
||||
|
||||
def require_tenant_capability(self, name, session, **kwargs):
|
||||
del session, kwargs
|
||||
self._assert_capability(name)
|
||||
return self.provider
|
||||
|
||||
def manifests(self):
|
||||
return (type("Manifest", (), {"id": "organizations"})(),)
|
||||
|
||||
@staticmethod
|
||||
def _assert_capability(name: str) -> None:
|
||||
if name != ORGANIZATIONS_DSAR_CAPABILITY:
|
||||
raise KeyError(name)
|
||||
|
||||
|
||||
class OrganizationsDsarProviderTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:", future=True)
|
||||
Base.metadata.create_all(bind=self.engine)
|
||||
self.session = sessionmaker(bind=self.engine, future=True)()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
self.template = OrganizationModelTemplate(
|
||||
id="template-1",
|
||||
slug="municipality",
|
||||
name="Municipality",
|
||||
description="Global institutional template",
|
||||
created_by_account_id="account-1",
|
||||
settings={"secret": "global-template-settings-do-not-export"},
|
||||
)
|
||||
self.version_one = OrganizationModelTemplateVersion(
|
||||
id="version-1",
|
||||
template_id=self.template.id,
|
||||
version="1.0.0",
|
||||
status="published",
|
||||
definition={"secret": "definition-one-do-not-export"},
|
||||
definition_sha256="a" * 64,
|
||||
published_at=now,
|
||||
published_by_account_id="account-1",
|
||||
)
|
||||
self.version_two = OrganizationModelTemplateVersion(
|
||||
id="version-2",
|
||||
template_id=self.template.id,
|
||||
version="2.0.0",
|
||||
status="published",
|
||||
definition={"secret": "definition-two-do-not-export"},
|
||||
definition_sha256="b" * 64,
|
||||
published_at=now,
|
||||
published_by_account_id="account-1",
|
||||
)
|
||||
self.version_three = OrganizationModelTemplateVersion(
|
||||
id="version-3",
|
||||
template_id=self.template.id,
|
||||
version="3.0.0",
|
||||
status="published",
|
||||
definition={"secret": "definition-three-do-not-export"},
|
||||
definition_sha256="c" * 64,
|
||||
)
|
||||
self.instantiation = OrganizationModelInstantiation(
|
||||
id="instantiation-1",
|
||||
tenant_id="tenant-1",
|
||||
template_id=self.template.id,
|
||||
template_version_id=self.version_one.id,
|
||||
source_definition_sha256=self.version_one.definition_sha256,
|
||||
status="superseded",
|
||||
instantiated_by_account_id="account-1",
|
||||
object_counts={"units": 5},
|
||||
provenance={"secret": "instantiation-provenance-do-not-export"},
|
||||
)
|
||||
unrelated_instantiation = OrganizationModelInstantiation(
|
||||
id="instantiation-unrelated",
|
||||
tenant_id="tenant-1",
|
||||
template_id=self.template.id,
|
||||
template_version_id=self.version_three.id,
|
||||
source_definition_sha256=self.version_three.definition_sha256,
|
||||
status="applied",
|
||||
instantiated_by_account_id="account-other",
|
||||
object_counts={"units": 9},
|
||||
provenance={"secret": "unrelated-provenance-do-not-export"},
|
||||
)
|
||||
tenant_two_instantiation = OrganizationModelInstantiation(
|
||||
id="instantiation-tenant-2",
|
||||
tenant_id="tenant-2",
|
||||
template_id=self.template.id,
|
||||
template_version_id=self.version_one.id,
|
||||
source_definition_sha256=self.version_one.definition_sha256,
|
||||
status="applied",
|
||||
instantiated_by_account_id="account-1",
|
||||
object_counts={"units": 99},
|
||||
provenance={"secret": "other-tenant-provenance-do-not-export"},
|
||||
)
|
||||
self.upgrade = OrganizationModelUpgrade(
|
||||
id="upgrade-1",
|
||||
tenant_id="tenant-1",
|
||||
template_id=self.template.id,
|
||||
source_instantiation_id=self.instantiation.id,
|
||||
source_template_version_id=self.version_one.id,
|
||||
target_template_version_id=self.version_two.id,
|
||||
status="applied",
|
||||
revision=2,
|
||||
base_definition_sha256="d" * 64,
|
||||
local_definition_sha256="e" * 64,
|
||||
target_definition_sha256="f" * 64,
|
||||
preview={"secret": "upgrade-preview-do-not-export"},
|
||||
decisions={"secret": "upgrade-decisions-do-not-export"},
|
||||
idempotency_key="idempotency-key-do-not-export",
|
||||
request_digest="1" * 64,
|
||||
requested_by_account_id="account-1",
|
||||
applied_by_account_id="account-other",
|
||||
applied_at=now,
|
||||
provenance={"secret": "upgrade-provenance-do-not-export"},
|
||||
)
|
||||
unrelated_upgrade = OrganizationModelUpgrade(
|
||||
id="upgrade-unrelated",
|
||||
tenant_id="tenant-1",
|
||||
template_id=self.template.id,
|
||||
source_instantiation_id=unrelated_instantiation.id,
|
||||
source_template_version_id=self.version_three.id,
|
||||
target_template_version_id=self.version_two.id,
|
||||
status="cancelled",
|
||||
base_definition_sha256="2" * 64,
|
||||
local_definition_sha256="3" * 64,
|
||||
target_definition_sha256="4" * 64,
|
||||
idempotency_key="unrelated-key",
|
||||
request_digest="5" * 64,
|
||||
requested_by_account_id="account-other",
|
||||
cancelled_by_account_id="account-other",
|
||||
)
|
||||
tenant_two_upgrade = OrganizationModelUpgrade(
|
||||
id="upgrade-tenant-2",
|
||||
tenant_id="tenant-2",
|
||||
template_id=self.template.id,
|
||||
source_instantiation_id=tenant_two_instantiation.id,
|
||||
source_template_version_id=self.version_one.id,
|
||||
target_template_version_id=self.version_two.id,
|
||||
status="previewed",
|
||||
base_definition_sha256="6" * 64,
|
||||
local_definition_sha256="7" * 64,
|
||||
target_definition_sha256="8" * 64,
|
||||
idempotency_key="tenant-two-key",
|
||||
request_digest="9" * 64,
|
||||
requested_by_account_id="account-1",
|
||||
)
|
||||
self.session.add_all(
|
||||
[
|
||||
self.template,
|
||||
self.version_one,
|
||||
self.version_two,
|
||||
self.version_three,
|
||||
self.instantiation,
|
||||
unrelated_instantiation,
|
||||
tenant_two_instantiation,
|
||||
self.upgrade,
|
||||
unrelated_upgrade,
|
||||
tenant_two_upgrade,
|
||||
]
|
||||
)
|
||||
self.session.commit()
|
||||
self.provider = OrganizationsDsarProvider()
|
||||
self.subject = DsarSubjectRef(account_id="account-1")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def test_manifest_publishes_protocol_conforming_provider(self) -> None:
|
||||
provided = {item.name for item in manifest.provides_interfaces}
|
||||
self.assertIn(ORGANIZATIONS_DSAR_CAPABILITY, provided)
|
||||
provider = manifest.capability_factories[ORGANIZATIONS_DSAR_CAPABILITY](None)
|
||||
self.assertIsInstance(provider, DsarProvider)
|
||||
|
||||
def test_search_is_tenant_scoped_narrow_and_minimized(self) -> None:
|
||||
records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=self.subject,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
{
|
||||
"organizations_model_instantiation",
|
||||
"organizations_model_upgrade",
|
||||
},
|
||||
{record.resource_type for record in records},
|
||||
)
|
||||
serialized = repr([record.to_dict() for record in records])
|
||||
self.assertIn("instantiation-1", serialized)
|
||||
self.assertIn("upgrade-1", serialized)
|
||||
excluded = (
|
||||
"global-template-settings-do-not-export",
|
||||
"definition-one-do-not-export",
|
||||
"instantiation-provenance-do-not-export",
|
||||
"upgrade-preview-do-not-export",
|
||||
"upgrade-decisions-do-not-export",
|
||||
"idempotency-key-do-not-export",
|
||||
"upgrade-provenance-do-not-export",
|
||||
"instantiation-unrelated",
|
||||
"upgrade-unrelated",
|
||||
"instantiation-tenant-2",
|
||||
"upgrade-tenant-2",
|
||||
"other-tenant-provenance-do-not-export",
|
||||
)
|
||||
for value in excluded:
|
||||
self.assertNotIn(value, serialized)
|
||||
|
||||
def test_conflicting_account_selectors_fail_closed(self) -> None:
|
||||
records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(
|
||||
account_id="account-1",
|
||||
external_references={"organizations.account": "account-other"},
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual((), records)
|
||||
|
||||
def test_plan_retains_governance_evidence_and_execution_is_non_mutating(
|
||||
self,
|
||||
) -> None:
|
||||
records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=self.subject,
|
||||
)
|
||||
actions = self.provider.plan_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=self.subject,
|
||||
records=records,
|
||||
)
|
||||
|
||||
self.assertEqual({"retain"}, {action.kind for action in actions})
|
||||
self.assertFalse(any(action.executable for action in actions))
|
||||
results = self.provider.execute_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=self.subject,
|
||||
actions=actions,
|
||||
request_id="dsar-organizations-1",
|
||||
)
|
||||
self.assertEqual({"blocked"}, {result.status for result in results})
|
||||
self.assertIsNotNone(
|
||||
self.session.get(OrganizationModelUpgrade, self.upgrade.id)
|
||||
)
|
||||
|
||||
def test_execution_rejects_foreign_and_forged_executable_actions(self) -> None:
|
||||
actions = (
|
||||
DsarErasureActionRef(
|
||||
action_id="idm:delete:upgrade:upgrade-1",
|
||||
provider_id="idm",
|
||||
module_id="idm",
|
||||
kind="delete",
|
||||
resource_type="organizations_model_upgrade",
|
||||
resource_id=self.upgrade.id,
|
||||
title="Foreign action",
|
||||
rationale="Must be rejected",
|
||||
executable=True,
|
||||
),
|
||||
DsarErasureActionRef(
|
||||
action_id="organizations:delete:upgrade:upgrade-1",
|
||||
provider_id="organizations",
|
||||
module_id="organizations",
|
||||
kind="delete",
|
||||
resource_type="organizations_model_upgrade",
|
||||
resource_id=self.upgrade.id,
|
||||
title="Forged action",
|
||||
rationale="Must be rejected",
|
||||
executable=True,
|
||||
),
|
||||
)
|
||||
for action in actions:
|
||||
with self.assertRaises(ValueError):
|
||||
self.provider.execute_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=self.subject,
|
||||
actions=(action,),
|
||||
request_id="dsar-organizations-2",
|
||||
)
|
||||
|
||||
def test_core_workflow_discovers_active_and_inactive_provider(self) -> None:
|
||||
request = create_data_subject_request(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
reference="DSAR-ORGANIZATIONS-1",
|
||||
request_kind="access",
|
||||
subject=self.subject,
|
||||
purpose="Respond to an authorized privacy request.",
|
||||
legal_basis="Article 15 GDPR",
|
||||
due_at=None,
|
||||
requested_by_account_id="privacy-officer",
|
||||
)
|
||||
self.session.commit()
|
||||
search_data_subject_request(
|
||||
self.session,
|
||||
registry=_Registry(self.provider),
|
||||
row=request,
|
||||
expected_revision=1,
|
||||
)
|
||||
self.assertEqual(["organizations"], request.coverage["covered_modules"])
|
||||
|
||||
disabled = create_data_subject_request(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
reference="DSAR-ORGANIZATIONS-DISABLED",
|
||||
request_kind="access",
|
||||
subject=self.subject,
|
||||
purpose="Verify disabled-module coverage.",
|
||||
legal_basis="Article 15 GDPR",
|
||||
due_at=None,
|
||||
requested_by_account_id="privacy-officer",
|
||||
)
|
||||
search_data_subject_request(
|
||||
self.session,
|
||||
registry=_Registry(self.provider, organizations_active=False),
|
||||
row=disabled,
|
||||
expected_revision=1,
|
||||
)
|
||||
self.assertEqual(0, disabled.search_result["record_count"])
|
||||
self.assertEqual(
|
||||
[ORGANIZATIONS_DSAR_CAPABILITY],
|
||||
disabled.coverage["inactive_provider_capabilities"],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -2,6 +2,10 @@ from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.modules import (
|
||||
documentation_structured_translation_issues,
|
||||
localizable_documentation_metadata_keys,
|
||||
)
|
||||
from govoplan_organizations.backend.manifest import manifest
|
||||
|
||||
|
||||
@@ -39,6 +43,25 @@ class OrganizationsInterfaceDocumentationContractTests(unittest.TestCase):
|
||||
)
|
||||
self.assertIn("hierarchy_change", reference.metadata["consequence_classes"])
|
||||
|
||||
def test_german_reference_documentation_is_complete(self) -> None:
|
||||
topics = manifest.documentation
|
||||
self.assertEqual(4, len(topics))
|
||||
for topic in topics:
|
||||
translation = topic.translations.get("de", {})
|
||||
self.assertTrue(translation.get("title"), topic.id)
|
||||
self.assertTrue(translation.get("summary"), topic.id)
|
||||
self.assertTrue(translation.get("body"), topic.id)
|
||||
if localizable_documentation_metadata_keys(topic):
|
||||
self.assertEqual("1", topic.structured_translation_version, topic.id)
|
||||
self.assertIn("de", topic.structured_translations, topic.id)
|
||||
self.assertEqual((), documentation_structured_translation_issues(topic))
|
||||
|
||||
kinds = {topic.metadata.get("kind") for topic in topics}
|
||||
self.assertIn("workflow", kinds)
|
||||
self.assertIn("reference", kinds)
|
||||
workflow = next(topic for topic in topics if topic.metadata.get("kind") == "workflow")
|
||||
self.assertTrue(workflow.conditions)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/organizations-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.21",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -19,7 +19,7 @@
|
||||
"./styles/organizations.css": "./src/styles/organizations.css"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.45",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
|
||||
@@ -17,7 +17,8 @@ const translations = source("../src/i18n/generatedTranslations.ts");
|
||||
assert(page.includes("DocumentationHelpLink") && settings.includes("DocumentationHelpLink"), "Workspace and tenant settings expose contextual documentation");
|
||||
assert(page.includes("ActionBlockerHint") && settings.includes("ActionBlockerHint"), "Read-only states identify action, actor, and destination");
|
||||
assert(page.includes("disabledReason") && settings.includes("disabledReason"), "Unavailable organization actions explain their state");
|
||||
assert(page.includes("requestDiscard(() => void loadModel())") && settings.includes("requestDiscard(() => void load())"), "Reload preserves dirty organization drafts and settings");
|
||||
assert(page.includes("<PageActionBar") && page.includes("reloadAction={{") && page.includes("onReload: () => void loadModel()") && settings.includes("requestDiscard(() => void load())"), "Reload uses the guarded semantic page action or the settings draft guard");
|
||||
assert((page.match(/<Card bodyLayout="table"/g) ?? []).length === 7, "All seven organization collection tables use the shared edge-to-edge card body");
|
||||
assert(page.includes("hasDirtyDraft ? requestDiscard(discardDrafts)"), "Editor close uses the shared unsaved-change guard");
|
||||
assert(page.includes("ORGANIZATIONS_FIELD_DOCUMENTATION"), "Model fields link to stable consequence documentation");
|
||||
assert(patterns.includes('topicId: "organizations.model"') && patterns.includes('topicId: "organizations.reference.fields-and-consequences"'), "Organizations uses manifest-backed help references");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MetricGrid } from "@govoplan/core-webui";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Eye, GitCompareArrows, Play, RefreshCw, X } from "lucide-react";
|
||||
import {
|
||||
import { ActionToolbar,
|
||||
Button,
|
||||
Card,
|
||||
ConfirmDialog,
|
||||
@@ -177,18 +178,20 @@ export default function OrganizationTemplateUpgradePanel({ settings, auth }: { s
|
||||
return <>
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
{success && <DismissibleAlert tone="success" resetKey={success}>{success}</DismissibleAlert>}
|
||||
<Card title="Organization template upgrades" actions={<Button onClick={() => void load()} disabled={loading || busy}><RefreshCw aria-hidden="true" /> Reload</Button>}>
|
||||
<Card bodyLayout="table" title="Organization template upgrades" actions={<Button onClick={() => void load()} disabled={loading || busy}><RefreshCw aria-hidden="true" /> Reload</Button>}>
|
||||
<LoadingFrame loading={loading} label="Loading organization template upgrade state">
|
||||
<div className="metric-grid compact">
|
||||
<div className="content-pad">
|
||||
<MetricGrid density="compact">
|
||||
<MetricCard label="Applied version" value={currentVersion?.version ?? "Custom model"} tone="info" />
|
||||
<MetricCard label="Available upgrades" value={targets.length} tone={targets.length ? "info" : "good"} />
|
||||
<MetricCard label="Open previews" value={pending.length} tone={pending.length ? "warning" : "good"} />
|
||||
<MetricCard label="Copy semantics" value="Tenant-owned" tone="good" />
|
||||
</div>
|
||||
</MetricGrid>
|
||||
<p className="muted small-note">Template versions are immutable sources. A tenant model never live-inherits changes: every upgrade is a recorded three-way comparison, explicit decision set, and confirmed new instantiation.</p>
|
||||
<div className="organization-upgrade-toolbar">
|
||||
<ActionToolbar className="organization-upgrade-toolbar">
|
||||
<FormField label="Published target version"><select value={targetVersionId} disabled={!canWrite || busy || !targets.length} onChange={(event) => setTargetVersionId(event.target.value)}>{targets.length ? targets.map((version) => <option key={version.id} value={version.id}>{templateVersionLabel(templates, version)}</option>) : <option value="">No newer published version</option>}</select></FormField>
|
||||
<Button variant="primary" onClick={() => void createPreview()} disabled={!canWrite || busy || !targetVersionId}><GitCompareArrows aria-hidden="true" /> Create preview</Button>
|
||||
</ActionToolbar>
|
||||
</div>
|
||||
<div className="organization-upgrade-table"><DataGrid id="organization-model-upgrades" rows={upgrades} columns={upgradeColumns} initialFit="container" getRowKey={(row) => row.id} emptyText={current ? "No organization template upgrades have been previewed." : "This tenant model was not instantiated from a system template."} /></div>
|
||||
</LoadingFrame>
|
||||
@@ -196,12 +199,12 @@ export default function OrganizationTemplateUpgradePanel({ settings, auth }: { s
|
||||
|
||||
<Dialog open={Boolean(selected)} title="Organization model upgrade preview" className="organization-upgrade-dialog" onClose={() => !busy && setSelected(null)} closeDisabled={busy} footer={<><Button onClick={() => setSelected(null)} disabled={busy}>Close</Button>{selected?.status === "previewed" && <><Button variant="danger" onClick={() => setCancelTarget(selected)} disabled={!canWrite || busy}>Cancel preview</Button><Button variant="primary" onClick={() => setApplyConfirmation(true)} disabled={applyDisabled}><Play aria-hidden="true" /> Review and apply</Button></>}</>}>
|
||||
{selected && <>
|
||||
<div className="metric-grid compact">
|
||||
<MetricGrid density="compact">
|
||||
<MetricCard label="Changes" value={selected.preview.entries.length} tone="info" />
|
||||
<MetricCard label="Required decisions" value={selected.preview.requires_decisions} tone={selected.preview.requires_decisions ? "warning" : "good"} />
|
||||
<MetricCard label="Invalid references" value={selected.preview.blocking_invalid_references} tone={selected.preview.blocking_invalid_references ? "danger" : "good"} />
|
||||
<MetricCard label="Status" value={humanize(selected.status)} tone="info" />
|
||||
</div>
|
||||
</MetricGrid>
|
||||
<p className="muted small-note">Compatible additions and non-conflicting changes apply automatically. Local-only divergence is preserved. Destructive remapping and competing edits require an explicit bounded decision.</p>
|
||||
<div className="organization-upgrade-diff"><DataGrid id={`organization-model-upgrade-diff-${selected.id}`} rows={selected.preview.entries} columns={diffColumns} initialFit="container" getRowKey={(row) => row.id} emptyText="The versions and tenant model are equivalent." /></div>
|
||||
<FormField label="Approved change request (when required by tenant policy)"><input value={changeRequestId} disabled={!canWrite || busy || selected.status !== "previewed"} onChange={(event) => setChangeRequestId(event.target.value)} /></FormField>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
import { ContentGrid, FormGrid,
|
||||
ActionBlockerHint,
|
||||
AdminPageLayout,
|
||||
Button,
|
||||
@@ -154,7 +154,7 @@ export default function OrganizationsAdminPanel({ settings, auth }: { settings:
|
||||
documentation={ORGANIZATIONS_DOCUMENTATION}
|
||||
/>
|
||||
)}
|
||||
<div className="organizations-settings-grid">
|
||||
<ContentGrid columns={1}>
|
||||
<Card title="i18n:govoplan-organizations.model_governance.6aa18fd0">
|
||||
<div className="settings-list">
|
||||
<ToggleSwitch
|
||||
@@ -176,7 +176,7 @@ export default function OrganizationsAdminPanel({ settings, auth }: { settings:
|
||||
</Card>
|
||||
|
||||
<Card title="i18n:govoplan-organizations.audit_and_retention.3ba1d2fc">
|
||||
<div className="admin-form-grid two-columns">
|
||||
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||
<FormField
|
||||
label="i18n:govoplan-organizations.audit_detail_level.7397355d"
|
||||
help={organizationWriteReason(canWrite, busy, "settings")}
|
||||
@@ -190,6 +190,8 @@ export default function OrganizationsAdminPanel({ settings, auth }: { settings:
|
||||
label="i18n:govoplan-organizations.change_retention_days.71bbd140"
|
||||
help={organizationWriteReason(canWrite, busy, "settings")}
|
||||
documentation={ORGANIZATIONS_FIELD_DOCUMENTATION}
|
||||
helpContextId="organizations.field.audit-retention"
|
||||
helpModuleId="organizations"
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
@@ -200,10 +202,10 @@ export default function OrganizationsAdminPanel({ settings, auth }: { settings:
|
||||
onChange={(event) => setDraft({ ...draft, change_retention_days: event.target.value === "" ? null : Math.max(0, Number(event.target.value)) })}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</FormGrid>
|
||||
<p className="muted small-note">i18n:govoplan-organizations.audit_retention_help.42dec57d</p>
|
||||
</Card>
|
||||
</div>
|
||||
</ContentGrid>
|
||||
<OrganizationTemplateUpgradePanel settings={settings} auth={auth} />
|
||||
</AdminPageLayout>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
||||
import { Edit3, Plus, RefreshCw } from "lucide-react";
|
||||
import {
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent, type JSX } from "react";
|
||||
import { Edit3, Plus } from "lucide-react";
|
||||
import { FormLayout, ActionToolbar,
|
||||
ActionBlockerHint,
|
||||
AdminIconButton,
|
||||
ApiError,
|
||||
@@ -8,14 +8,14 @@ import {
|
||||
Card,
|
||||
DataGrid,
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
DocumentationHelpLink,
|
||||
ExplorerTree,
|
||||
FormField,
|
||||
IconButton,
|
||||
LoadingFrame,
|
||||
ModuleSubnav,
|
||||
PageTitle,
|
||||
PageActionBar,
|
||||
PageLayout,
|
||||
StatusBadge,
|
||||
TableActionGroup,
|
||||
ToggleSwitch,
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
useUnsavedDraftGuard,
|
||||
usePlatformUiCapabilities,
|
||||
useViewSurfaces,
|
||||
WorkspaceLayout,
|
||||
type ApiSettings,
|
||||
type AuthInfo,
|
||||
type DataGridColumn,
|
||||
@@ -281,7 +282,7 @@ function mapById<T extends { id: string }>(items: T[]): Map<string, T> {
|
||||
function sectionGroupsFor(sections: ReadonlySet<OrganizationSection>): ModuleSubnavGroup<OrganizationSection>[] {
|
||||
return SECTION_GROUPS.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter((item) => sections.has(item.id))
|
||||
items: group.items.filter((item) => "id" in item && sections.has(item.id))
|
||||
})).filter((group) => group.items.length > 0);
|
||||
}
|
||||
|
||||
@@ -308,14 +309,14 @@ function activeStatus(active: boolean): JSX.Element {
|
||||
return <StatusBadge status={active ? "success" : "inactive"} label={active ? "i18n:govoplan-organizations.active.a733b809" : "i18n:govoplan-organizations.inactive.09af574c"} />;
|
||||
}
|
||||
|
||||
function SluggedFields({
|
||||
function SluggedFields<T extends SluggedDraft>({
|
||||
draft,
|
||||
onChange,
|
||||
disabled,
|
||||
disabledReason
|
||||
}: {
|
||||
draft: SluggedDraft;
|
||||
onChange: (next: SluggedDraft) => void;
|
||||
draft: T;
|
||||
onChange: (next: T) => void;
|
||||
disabled: boolean;
|
||||
disabledReason?: string;
|
||||
}) {
|
||||
@@ -998,28 +999,29 @@ export default function OrganizationsPage({
|
||||
: undefined;
|
||||
|
||||
const content = (
|
||||
<div className={`${mode === "workspace" ? "content-pad " : ""}organizations-page ${mode === "admin" ? "organizations-admin-page" : ""}`.trim()}>
|
||||
<div className="page-heading split organizations-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>{title}</PageTitle>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<div className="organizations-toolbar">
|
||||
<DocumentationHelpLink reference={ORGANIZATIONS_DOCUMENTATION} />
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => requestDiscard(() => void loadModel())}
|
||||
disabled={Boolean(reloadDisabledReason)}
|
||||
disabledReason={reloadDisabledReason}
|
||||
title="i18n:govoplan-organizations.reload.cce71553"
|
||||
>
|
||||
<RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-organizations.reload.cce71553
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
{success && !error && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
|
||||
<>
|
||||
<PageLayout
|
||||
archetype="workspace"
|
||||
title={title}
|
||||
description={description}
|
||||
actions={<PageActionBar
|
||||
variant="workspace"
|
||||
refreshable
|
||||
reloadAction={{
|
||||
onReload: () => void loadModel(),
|
||||
disabled: Boolean(reloadDisabledReason),
|
||||
disabledReason: reloadDisabledReason,
|
||||
title: "i18n:govoplan-organizations.reload.cce71553"
|
||||
}}
|
||||
helpAction={<DocumentationHelpLink reference={ORGANIZATIONS_DOCUMENTATION} />}
|
||||
/>}
|
||||
mode={mode === "workspace" ? "workspace" : "embedded"}
|
||||
headerLoading={loading}
|
||||
error={error}
|
||||
success={error ? "" : success}
|
||||
className={`organizations-page ${mode === "admin" ? "organizations-admin-page" : ""}`.trim()}
|
||||
headerClassName="organizations-heading"
|
||||
>
|
||||
{!canWriteActive && (
|
||||
<ActionBlockerHint
|
||||
reason={{
|
||||
@@ -1044,38 +1046,45 @@ export default function OrganizationsPage({
|
||||
{active === "relations" && renderRelationsSection()}
|
||||
{active === "functions" && renderFunctionsSection()}
|
||||
</LoadingFrame>
|
||||
</PageLayout>
|
||||
{renderEditorDialog()}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (mode === "admin") return content;
|
||||
|
||||
return (
|
||||
<div className="workspace organizations-workspace">
|
||||
<ModuleSubnav
|
||||
active={active}
|
||||
groups={visibleSectionGroups}
|
||||
onSelect={(section) => requestDiscard(() => setActive(section))}
|
||||
/>
|
||||
<main className="workspace-content">
|
||||
{content}
|
||||
</main>
|
||||
</div>
|
||||
<WorkspaceLayout
|
||||
className="organizations-workspace"
|
||||
primarySize="wide"
|
||||
primary={(
|
||||
<ModuleSubnav
|
||||
active={active}
|
||||
groups={visibleSectionGroups}
|
||||
onSelect={(section) => requestDiscard(() => setActive(section))}
|
||||
/>
|
||||
)}
|
||||
primaryLabel="i18n:govoplan-organizations.organization_model.5945c48a"
|
||||
contentLabel={title}
|
||||
documentationType="user"
|
||||
>
|
||||
{content}
|
||||
</WorkspaceLayout>
|
||||
);
|
||||
|
||||
function renderModelSection() {
|
||||
return (
|
||||
<div className="organizations-table-stack">
|
||||
<Card title="i18n:govoplan-organizations.unit_types.c7afc174" actions={<AdminIconButton label="i18n:govoplan-organizations.add_unit_type.58f2c05a" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteModel || busy} disabledReason={organizationWriteReason(canWriteModel, busy, "model")} onClick={openUnitTypeCreate} />}>
|
||||
<Card bodyLayout="table" title="i18n:govoplan-organizations.unit_types.c7afc174" actions={<AdminIconButton label="i18n:govoplan-organizations.add_unit_type.58f2c05a" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteModel || busy} disabledReason={organizationWriteReason(canWriteModel, busy, "model")} onClick={openUnitTypeCreate} />}>
|
||||
<DataGrid id="organizations-unit-types" rows={model.unit_types} columns={unitTypeColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_unit_types_found.c81fb2a7" initialFit="container" />
|
||||
</Card>
|
||||
<Card title="i18n:govoplan-organizations.structures.f9b7f3b4" actions={<AdminIconButton label="i18n:govoplan-organizations.add_structure.b722042a" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteModel || busy} disabledReason={organizationWriteReason(canWriteModel, busy, "model")} onClick={openStructureCreate} />}>
|
||||
<Card bodyLayout="table" title="i18n:govoplan-organizations.structures.f9b7f3b4" actions={<AdminIconButton label="i18n:govoplan-organizations.add_structure.b722042a" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteModel || busy} disabledReason={organizationWriteReason(canWriteModel, busy, "model")} onClick={openStructureCreate} />}>
|
||||
<DataGrid id="organizations-structures" rows={model.structures} columns={structureColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_structures_found.20b382d0" initialFit="container" />
|
||||
</Card>
|
||||
<Card title="i18n:govoplan-organizations.relation_types.e5890528" actions={<AdminIconButton label="i18n:govoplan-organizations.add_relation_type.2ad19d03" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteModel || busy} disabledReason={organizationWriteReason(canWriteModel, busy, "model")} onClick={openRelationTypeCreate} />}>
|
||||
<Card bodyLayout="table" title="i18n:govoplan-organizations.relation_types.e5890528" actions={<AdminIconButton label="i18n:govoplan-organizations.add_relation_type.2ad19d03" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteModel || busy} disabledReason={organizationWriteReason(canWriteModel, busy, "model")} onClick={openRelationTypeCreate} />}>
|
||||
<DataGrid id="organizations-relation-types" rows={model.relation_types} columns={relationTypeColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_relation_types_found.6f90bb81" initialFit="container" />
|
||||
</Card>
|
||||
<Card title="i18n:govoplan-organizations.function_types.172c01fe" actions={<AdminIconButton label="i18n:govoplan-organizations.add_function_type.90d793f1" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteModel || busy} disabledReason={organizationWriteReason(canWriteModel, busy, "model")} onClick={openFunctionTypeCreate} />}>
|
||||
<Card bodyLayout="table" title="i18n:govoplan-organizations.function_types.172c01fe" actions={<AdminIconButton label="i18n:govoplan-organizations.add_function_type.90d793f1" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteModel || busy} disabledReason={organizationWriteReason(canWriteModel, busy, "model")} onClick={openFunctionTypeCreate} />}>
|
||||
<DataGrid id="organizations-function-types" rows={model.function_types} columns={functionTypeColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_function_types_found.5eef31b1" initialFit="container" />
|
||||
</Card>
|
||||
</div>
|
||||
@@ -1086,9 +1095,9 @@ export default function OrganizationsPage({
|
||||
return (
|
||||
<div className="organizations-table-stack">
|
||||
<Card title="i18n:govoplan-organizations.organization_tree.e5bfb195" actions={<AdminIconButton label="i18n:govoplan-organizations.add_root_unit.1ef8a9f5" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteUnits || busy} disabledReason={organizationWriteReason(canWriteUnits, busy, "unit")} onClick={() => openUnitCreate()} />}>
|
||||
<div className="explorer-tree-toolbar">
|
||||
<ActionToolbar className="explorer-tree-toolbar">
|
||||
{selectedUnitId && <Button type="button" variant="ghost" disabled={busy} disabledReason={busy ? ORGANIZATIONS_INTERFACE_I18N.busy : undefined} onClick={() => setSelectedUnitId("")}>i18n:govoplan-organizations.none.334c4a4c</Button>}
|
||||
</div>
|
||||
</ActionToolbar>
|
||||
{model.units.length ? (
|
||||
<ExplorerTree
|
||||
nodes={unitsByParentId.get("") ?? []}
|
||||
@@ -1120,7 +1129,7 @@ export default function OrganizationsPage({
|
||||
/>
|
||||
) : <p className="muted small-note">i18n:govoplan-organizations.no_units_found.eea2dd0c</p>}
|
||||
</Card>
|
||||
<Card title="i18n:govoplan-organizations.units.e14d0d92" actions={<AdminIconButton label="i18n:govoplan-organizations.add_unit.8fa12fb1" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteUnits || busy} disabledReason={organizationWriteReason(canWriteUnits, busy, "unit")} onClick={() => openUnitCreate()} />}>
|
||||
<Card bodyLayout="table" title="i18n:govoplan-organizations.units.e14d0d92" actions={<AdminIconButton label="i18n:govoplan-organizations.add_unit.8fa12fb1" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteUnits || busy} disabledReason={organizationWriteReason(canWriteUnits, busy, "unit")} onClick={() => openUnitCreate()} />}>
|
||||
<DataGrid id="organizations-units" rows={model.units} columns={unitColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_units_found.eea2dd0c" initialFit="container" />
|
||||
</Card>
|
||||
</div>
|
||||
@@ -1130,7 +1139,7 @@ export default function OrganizationsPage({
|
||||
function renderRelationsSection() {
|
||||
return (
|
||||
<div className="organizations-table-stack">
|
||||
<Card title="i18n:govoplan-organizations.relations.1c796711" actions={<AdminIconButton label="i18n:govoplan-organizations.add_relation.6b6e67ea" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteUnits || busy} disabledReason={organizationWriteReason(canWriteUnits, busy, "unit")} onClick={openRelationCreate} />}>
|
||||
<Card bodyLayout="table" title="i18n:govoplan-organizations.relations.1c796711" actions={<AdminIconButton label="i18n:govoplan-organizations.add_relation.6b6e67ea" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteUnits || busy} disabledReason={organizationWriteReason(canWriteUnits, busy, "unit")} onClick={openRelationCreate} />}>
|
||||
<DataGrid id="organizations-relations" rows={model.relations} columns={relationColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_relations_found.4e75b11d" initialFit="container" />
|
||||
</Card>
|
||||
</div>
|
||||
@@ -1140,7 +1149,7 @@ export default function OrganizationsPage({
|
||||
function renderFunctionsSection() {
|
||||
return (
|
||||
<div className="organizations-table-stack">
|
||||
<Card title="i18n:govoplan-organizations.functions.805dc49b" actions={<AdminIconButton label="i18n:govoplan-organizations.add_function.6abafee0" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteFunctions || busy} disabledReason={organizationWriteReason(canWriteFunctions, busy, "function")} onClick={() => openFunctionCreate()} />}>
|
||||
<Card bodyLayout="table" title="i18n:govoplan-organizations.functions.805dc49b" actions={<AdminIconButton label="i18n:govoplan-organizations.add_function.6abafee0" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteFunctions || busy} disabledReason={organizationWriteReason(canWriteFunctions, busy, "function")} onClick={() => openFunctionCreate()} />}>
|
||||
<DataGrid id="organizations-functions" rows={model.functions} columns={functionColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_functions_found.54e759a0" initialFit="container" />
|
||||
</Card>
|
||||
</div>
|
||||
@@ -1215,7 +1224,7 @@ export default function OrganizationsPage({
|
||||
activeEditor === "functionType" ? submitFunctionType :
|
||||
submitFunction;
|
||||
return (
|
||||
<Dialog
|
||||
<Dialog variant="administration" size="wide"
|
||||
open={Boolean(activeEditor)}
|
||||
title={editorTitle()}
|
||||
onClose={() => {
|
||||
@@ -1224,7 +1233,7 @@ export default function OrganizationsPage({
|
||||
else discardDrafts();
|
||||
}}
|
||||
closeDisabled={busy}
|
||||
className="admin-dialog admin-dialog-wide organizations-editor-dialog"
|
||||
className="organizations-editor-dialog"
|
||||
footer={(
|
||||
<>
|
||||
<Button
|
||||
@@ -1250,9 +1259,9 @@ export default function OrganizationsPage({
|
||||
<div className="button-row organizations-dialog-help">
|
||||
<DocumentationHelpLink reference={ORGANIZATIONS_FIELD_DOCUMENTATION} />
|
||||
</div>
|
||||
<form id={formId} className="admin-form-grid two-columns" onSubmit={(event) => void submit(event)}>
|
||||
<FormLayout columns={2} gap="small" collapseAt="workspace" id={formId} className="" onSubmit={(event) => void submit(event)}>
|
||||
{renderEditorFields()}
|
||||
</form>
|
||||
</FormLayout>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-organizations.no_units_found.eea2dd0c": "No units found.",
|
||||
"i18n:govoplan-organizations.none.334c4a4c": "None",
|
||||
"i18n:govoplan-organizations.organization_model.4f924c0e": "Organization model",
|
||||
"i18n:govoplan-organizations.organization_model.5945c48a": "Organization model",
|
||||
"i18n:govoplan-organizations.organization_model_admin_description.35dc9f10": "Configure tenant-local unit types, parallel structures, relation types, and function types used by the organization module.",
|
||||
"i18n:govoplan-organizations.organization_settings.c9ab9829": "Organizations",
|
||||
"i18n:govoplan-organizations.organization_settings_description.35dc9f10": "Configure tenant-level governance, audit, and retention behavior for organization changes.",
|
||||
@@ -205,6 +206,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-organizations.no_units_found.eea2dd0c": "Keine Einheiten gefunden.",
|
||||
"i18n:govoplan-organizations.none.334c4a4c": "Keine",
|
||||
"i18n:govoplan-organizations.organization_model.4f924c0e": "Organisationsmodell",
|
||||
"i18n:govoplan-organizations.organization_model.5945c48a": "Organisationsmodell",
|
||||
"i18n:govoplan-organizations.organization_model_admin_description.35dc9f10": "Konfiguriere mandantenbezogene Einheitstypen, parallele Strukturen, Beziehungstypen und Funktionstypen des Organisationsmoduls.",
|
||||
"i18n:govoplan-organizations.organization_settings.c9ab9829": "Organisationen",
|
||||
"i18n:govoplan-organizations.organization_settings_description.35dc9f10": "Konfiguriere Governance, Audit und Aufbewahrung für Organisationsänderungen auf Mandantenebene.",
|
||||
|
||||
@@ -1,35 +1,3 @@
|
||||
.organizations-workspace {
|
||||
grid-template-columns: 230px minmax(0, 1fr);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.organizations-page {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.organizations-admin-page {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.organizations-settings-grid {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.organizations-heading {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.organizations-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.organizations-check-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -72,11 +40,6 @@
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.organizations-workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.organization-upgrade-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(16rem, 1fr) auto;
|
||||
|
||||
Reference in New Issue
Block a user