Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f98cf9ced8 | ||
|
|
d2e491348d | ||
|
|
562d278f60 | ||
|
|
c6f6faf64f | ||
|
|
1c3ee9e8c7 | ||
|
|
aa91063211 | ||
|
|
fa2d5d40dd | ||
|
|
6ccef162f6 | ||
|
|
48dac139a5 | ||
|
|
a090e5af20 | ||
|
|
0c1358b862 | ||
|
|
a9035c4c3b | ||
|
|
137c7c005f |
@@ -157,6 +157,16 @@ The initial implementation includes provider-neutral orchestration helpers:
|
||||
- `apply_configuration_package(...)`
|
||||
- `export_configuration_package(...)`
|
||||
|
||||
Portable fragments may bind deployment-specific operator input without placing
|
||||
that value in the signed reusable definition. A payload value of
|
||||
`{"$data": "requirement_key"}` references a key declared in the manifest's
|
||||
`data_requirements`. Preflight fails before invoking the owning provider when a
|
||||
reference is malformed, undeclared, or unresolved. Once supplied, Core replaces
|
||||
the reference in memory and passes only the resolved fragment to the provider.
|
||||
This mechanism is for deployment bindings and wording, not plaintext secrets:
|
||||
credential-envelope or environment references remain the normal portable
|
||||
boundary.
|
||||
|
||||
The first concrete provider is `govoplan_access.backend.configuration_provider`.
|
||||
It supports access-owned `roles`, `groups`, and `group_role_assignments`
|
||||
fragments and applies them idempotently. Mail and Files also register providers
|
||||
@@ -212,6 +222,14 @@ The admin wizard backend starts with these routes:
|
||||
10. Store import provenance, package version, supplied non-secret metadata, and
|
||||
audit events.
|
||||
|
||||
Provider applies may commit independently. Core therefore stops at the first
|
||||
apply or health blocker and reports an explicit rollback state. A blocked
|
||||
preflight or a no-op needs no recovery; a successful multi-provider mutation
|
||||
retains the reviewed pre-apply database snapshot as its generic rollback path;
|
||||
a later-provider failure is reported as a partial apply that requires snapshot
|
||||
recovery or an explicitly supported module-owned compensation. The generic
|
||||
wizard never claims atomic cross-module undo.
|
||||
|
||||
The wizard should display everything necessary and nothing unnecessary. Generic
|
||||
sections should cover package trust, dependency plan, required data, conflicts,
|
||||
review, and result. Module-specific fields should appear only when the selected
|
||||
@@ -262,6 +280,11 @@ Exported packages should record provenance: source GovOPlaN version, module
|
||||
versions, exporter identity, timestamp, selected scope, redactions, and
|
||||
validation status.
|
||||
|
||||
The orchestrator emits this provenance independently of provider payloads and
|
||||
lists secret requirement keys as redacted without serializing their supplied
|
||||
values. Providers still own the deeper rule that credentials, tokens, and
|
||||
decrypted envelope contents must never appear in exported fragments.
|
||||
|
||||
## Catalogs And Trust
|
||||
|
||||
Configuration catalogs should follow the existing module package catalog model:
|
||||
|
||||
@@ -58,6 +58,17 @@ than adding custom `F1` listeners:
|
||||
headed pages. `WorkspaceLayout` owns the full-canvas workspace scope and its
|
||||
labelled primary/content panes; pages inside it use `PageLayout` in
|
||||
`workspace` mode and retain their own route-level help identity.
|
||||
- `PasswordField` passes its owner context and module through reveal/generate
|
||||
actions and the shared generator dialog. Credential consumers must supply an
|
||||
exact owner context; the generic component does not own credential policy.
|
||||
|
||||
High-risk controls use one of the source-inventory risk classes (`authority`,
|
||||
`credential`, `disclosure`, `encryption`, `external-effect`, `irreversible`,
|
||||
`policy`, or `retention`) and require exact F1 help. The extractor infers
|
||||
obvious cases conservatively; components may declare `data-help-risk`
|
||||
explicitly or mark a reviewed ordinary control with
|
||||
`data-help-risk-reviewed="standard"`. The strict workspace gate rejects new
|
||||
unresolved high-risk debt.
|
||||
|
||||
Module routes, public routes, settings sections, and administration sections
|
||||
may also declare `helpContextId` and `helpTopicId`. Each module must keep a
|
||||
|
||||
@@ -18,6 +18,7 @@ operator, and roadmap pages.
|
||||
| External references and integration maturity | `EXTERNAL_REFERENCES_AND_INTEGRATION_MATURITY.md` | Stable external identity and cumulative connector maturity; configured source authority is defined by the meta target architecture. |
|
||||
| Institutional context and governed references | `INSTITUTIONAL_CONTEXT_CONTRACT.md` | Shared temporal, actor/representation, institution, mandate, service, party, decision, evidence, legal-basis, information-governance, presentation, and geo DTO/provider contracts. |
|
||||
| Provider-neutral record filing | `RECORDS_FILING_CONTRACT.md` | Exact source-revision identity, current source authorization, idempotent filing, capability discovery, and ownership boundary. |
|
||||
| Ticket routing and Case escalation | `TICKET_INTEGRATION_CONTRACTS.md` | Optional fail-open routing, replay-safe Case handoff, authorization, evidence, and ownership boundaries. |
|
||||
| Temporal data read context | `TEMPORAL_DATA_CONTEXT.md` | Valid-time and recorded-time titlebar selection, HTTP/cache contract, security boundary, and module-adoption rule. |
|
||||
| Cross-module information governance adoption | `INFORMATION_GOVERNANCE_ADOPTION.md` | Manifest evidence and enforcement rules for temporal browsing, purpose-aware access, retention, and institutional context. |
|
||||
| Data-subject access and erasure requests | `DATA_SUBJECT_REQUESTS.md` | Provider-owned search and mutation, explicit coverage, governed export, retained evidence, permissions, and idempotent execution. |
|
||||
|
||||
@@ -18,6 +18,27 @@ The platform inventory recognizes both inline locale objects and generated
|
||||
catalogs declared as `const de` / `const en`. Its strict mode requires both
|
||||
locales and reports `de` explicitly as the reference locale.
|
||||
|
||||
## Structured Documentation Localization
|
||||
|
||||
`DocumentationTopic.translations` continues to own localized title, summary,
|
||||
and body prose. Topics whose metadata contains rendered prose opt into the
|
||||
separate `structured_translation_version="1"` contract and provide a complete
|
||||
same-shape value for each translated metadata key in
|
||||
`structured_translations`. Version 1 covers workflow prerequisites, steps,
|
||||
outcome, result and verification; reference fields; limitations, constraints,
|
||||
consequences and consequence classes; and the other rendered explanation
|
||||
fields declared by Core.
|
||||
|
||||
The registry rejects an unversioned translation, an unsupported contract
|
||||
version, missing structured keys, changed object keys or list lengths, empty
|
||||
translated strings, and changed non-text values. Stable field IDs, routes,
|
||||
permission scopes, and other technical leaves therefore remain structurally
|
||||
bound to the source metadata. The Docs module overlays only a validated locale
|
||||
at response time and reports the selected structured locale separately from the
|
||||
title/body locale. Missing structured translations fall back to source content
|
||||
and remain visible in public coverage until the owning module adopts the
|
||||
contract.
|
||||
|
||||
## Help Resolution
|
||||
|
||||
Every focusable field and action receives a stable derived F1 identity from the
|
||||
@@ -54,6 +75,17 @@ native control nested in `FormField`. Dynamic context expressions remain
|
||||
separate evidence and generic derived fallbacks remain in the richer-help
|
||||
candidate queue.
|
||||
|
||||
The same inventory classifies controls whose labels, identities, component
|
||||
context, or explicit `data-help-risk` indicate authority, credentials,
|
||||
disclosure, encryption, external effects, irreversible changes, policy, or
|
||||
retention. These controls require an exact context rather than relying only on
|
||||
page fallback. Reviewed false positives carry
|
||||
`data-help-risk-reviewed="standard"`. Invalid risk classes and any increase
|
||||
above the versioned `tools/inventory/high-risk-help-baseline.json` ceiling fail
|
||||
strict declaration checks; the ceiling is lowered as the finite queue is
|
||||
resolved. Password fields and their generator dialog propagate the owning
|
||||
field's context so shared credential controls never invent a Core-owned topic.
|
||||
|
||||
The generated `help_review_candidates` list is therefore a content-depth queue,
|
||||
not a list of controls on which F1 cannot work. It should prioritize:
|
||||
|
||||
@@ -88,6 +120,8 @@ The check must report:
|
||||
- no duplicate stable IDs;
|
||||
- no undeclared public WebUI surface;
|
||||
- no stale runtime route or endpoint declaration.
|
||||
- no invalid high-risk help annotation or regression above the recorded
|
||||
exact-context debt ceiling.
|
||||
|
||||
Browser acceptance is part of the focused workspace gate and can be run alone:
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ consistent while each module still owns its domain rules.
|
||||
| Governance defaults | `govoplan-admin` plus `govoplan-access` materializer | admin settings, governance template routes, access materialization capability | System governance can block tenant-local groups, roles, and API keys. |
|
||||
| Delegation and ownership policy | access/campaign/mail/files modules | capability checks and owner-scoped APIs | Source provenance should use this contract when policies become externally explainable. |
|
||||
| Definition governance | `govoplan-policy` | capability `policy.definitionGovernance` | Resolves view, edit, run/start, reuse, derive, and automate for system, tenant, group, and user Dataflow/Workflow definitions. |
|
||||
| Function assignment governance | `govoplan-policy` | capability `policy.functionAssignmentGovernance` | Returns current review steps, delegation depth/validity ceilings, and explicit timed-escalation targets consumed by IDM. |
|
||||
|
||||
## Policy Decision
|
||||
|
||||
@@ -126,6 +127,22 @@ When the capability is absent, modules must not silently emulate cross-scope
|
||||
inheritance. Their conservative fallback is limited to local tenant
|
||||
definitions and disables reuse, derivation, and automation.
|
||||
|
||||
## Function Assignment Delegation And Escalation
|
||||
|
||||
`FunctionAssignmentGovernanceDecision` is the versioned cross-module contract
|
||||
for request/grant review. In addition to the required holder, authority, and
|
||||
recipient steps, it returns `delegation_allowed`,
|
||||
`maximum_delegation_depth`, `maximum_delegated_validity_days`, and typed
|
||||
`FunctionAssignmentEscalationRule` entries. Each escalation entry binds one
|
||||
review step to an exact target function and timeout.
|
||||
|
||||
The decision is a current ceiling, not durable authorization. IDM must recheck
|
||||
the complete assignment-source chain and all recorded decisions before final
|
||||
application. An elapsed timeout creates explicit state and evidence; it must
|
||||
never be interpreted as approval or as permission to silently substitute an
|
||||
approver. Missing providers, malformed rules, invalid chains, or tightened
|
||||
limits fail closed with an explainable reason.
|
||||
|
||||
## Bounded Impact-Subject Providers
|
||||
|
||||
Policy impact previews discover optional subject providers through capability
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# Ticket Integration Capability Contracts
|
||||
|
||||
Core owns two narrow, optional contracts that let the Tickets module compose
|
||||
with policy and formal-procedure modules without importing either one. Tickets
|
||||
remains the authority for operational ticket identity, lifecycle, assignment,
|
||||
comments, links, and immutable history.
|
||||
|
||||
## Capability Names
|
||||
|
||||
- `tickets.routing` optionally supplies a `TicketRoutingProvider`.
|
||||
- `tickets.case_escalation` optionally supplies a
|
||||
`TicketCaseEscalationProvider`.
|
||||
|
||||
Both contracts are version 1 and are defined in
|
||||
`govoplan_core.core.tickets`. Registry helpers return `None` when a capability
|
||||
is absent or has the wrong shape, so optional-module absence is normal runtime
|
||||
state rather than a startup failure.
|
||||
|
||||
## Routing
|
||||
|
||||
Tickets sends a bounded, tenant-scoped `TicketRoutingRequest` containing the
|
||||
ticket reference, type, priority, title, receive time, optional queue hint, and
|
||||
non-secret attributes. The provider returns its identity and may return a queue
|
||||
reference, timezone-aware service target, human-readable explanation, and
|
||||
bounded metadata.
|
||||
|
||||
The provider is advisory. Tickets snapshots any returned queue and target into
|
||||
its own record and history. An absent provider, a no-match plan, or an absent
|
||||
queue must not prevent ticket intake; authorized staff can route manually.
|
||||
Providers must not persist a second ticket lifecycle.
|
||||
|
||||
## Case Escalation
|
||||
|
||||
Tickets sends a `TicketCaseEscalationCommand` with stable tenant, ticket, and
|
||||
display references, the requested Case type, actor-visible handoff note,
|
||||
timezone-aware occurrence time, and an idempotency key. The provider returns a
|
||||
stable Case identifier, number, bounded application-relative URL, replay flag,
|
||||
and bounded metadata.
|
||||
|
||||
Providers must:
|
||||
|
||||
- recheck tenant and Case-creation authorization;
|
||||
- reject an absent or inactive requested Case type;
|
||||
- make identical retries resolve the same Case;
|
||||
- preserve the Ticket reference in governed Case context; and
|
||||
- return only an application-relative path, never an untrusted external URL.
|
||||
|
||||
Tickets records the result and its own escalation evidence. Cases remains the
|
||||
authority for the formal procedure; Tickets remains the authority for the
|
||||
operational request. Creating a Case does not merge or silently close either
|
||||
lifecycle.
|
||||
|
||||
## Failure And Transaction Semantics
|
||||
|
||||
Capability calls receive the caller's active persistence session so a concrete
|
||||
provider can participate in the same unit of work. Authorization and validation
|
||||
errors fail the requested routing/escalation mutation explicitly. The caller
|
||||
must still apply its own permission checks, tenant boundary, replay protection,
|
||||
and immutable evidence rules.
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-core"
|
||||
version = "0.1.26"
|
||||
version = "0.1.39"
|
||||
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from collections.abc import Callable, Iterable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Protocol, runtime_checkable
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
|
||||
CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT = "campaigns.mailPolicyContext"
|
||||
@@ -12,6 +12,20 @@ CAPABILITY_CAMPAIGNS_POLICY_CONTEXT = "campaigns.policyContext"
|
||||
CAPABILITY_CAMPAIGNS_DELIVERY_TASKS = "campaigns.deliveryTasks"
|
||||
CAPABILITY_CAMPAIGNS_SCHEDULES = "campaigns.schedules"
|
||||
CAPABILITY_CAMPAIGNS_RETENTION = "campaigns.retention"
|
||||
CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION = "campaigns.workOrchestration"
|
||||
|
||||
CampaignWorkAssigneeKind = Literal[
|
||||
"account",
|
||||
"group",
|
||||
"organization_function",
|
||||
]
|
||||
CampaignWorkHandoffStatus = Literal[
|
||||
"open",
|
||||
"in_progress",
|
||||
"completed",
|
||||
"rejected",
|
||||
"cancelled",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -32,6 +46,88 @@ class CampaignPolicyContext:
|
||||
settings: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CampaignWorkHandoffRequest:
|
||||
"""Typed request used by Workflow to open accountable Campaign work."""
|
||||
|
||||
tenant_id: str
|
||||
idempotency_key: str
|
||||
purpose: str
|
||||
assignee_kind: CampaignWorkAssigneeKind
|
||||
assignee_id: str
|
||||
campaign_id: str | None = None
|
||||
create_external_id: str | None = None
|
||||
create_name: str | None = None
|
||||
create_description: str | None = None
|
||||
expected_campaign_revision: int | None = None
|
||||
due_at: datetime | None = None
|
||||
mirror_to_tasks: bool = True
|
||||
correlation_id: str | None = None
|
||||
workflow_instance_id: str | None = None
|
||||
workflow_step_id: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for value, label in (
|
||||
(self.tenant_id, "Campaign hand-off tenant"),
|
||||
(self.idempotency_key, "Campaign hand-off idempotency key"),
|
||||
(self.purpose, "Campaign hand-off purpose"),
|
||||
(self.assignee_id, "Campaign hand-off assignee"),
|
||||
):
|
||||
if not value.strip():
|
||||
raise ValueError(f"{label} is required")
|
||||
references_existing = bool(self.campaign_id and self.campaign_id.strip())
|
||||
creates_new = bool(
|
||||
self.create_external_id
|
||||
and self.create_external_id.strip()
|
||||
and self.create_name
|
||||
and self.create_name.strip()
|
||||
)
|
||||
if references_existing == creates_new:
|
||||
raise ValueError(
|
||||
"Campaign hand-offs must either reference one campaign or "
|
||||
"declare one new campaign."
|
||||
)
|
||||
if self.expected_campaign_revision is not None and (
|
||||
self.expected_campaign_revision < 1
|
||||
):
|
||||
raise ValueError("Expected Campaign revisions start at one")
|
||||
if self.due_at is not None and self.due_at.tzinfo is None:
|
||||
raise ValueError("Campaign hand-off due dates require a timezone")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CampaignWorkHandoffRef:
|
||||
"""Stable, revision-bearing reference returned to the Workflow instance."""
|
||||
|
||||
tenant_id: str
|
||||
campaign_id: str
|
||||
campaign_version_id: str
|
||||
campaign_revision: int
|
||||
assignment_id: str
|
||||
assignment_revision: int
|
||||
status: CampaignWorkHandoffStatus
|
||||
action_url: str
|
||||
campaign_ref: str
|
||||
assignment_ref: str
|
||||
event_type: str = "campaign.work.changed"
|
||||
replayed: bool = False
|
||||
optional_capabilities: Mapping[str, bool] = field(default_factory=dict)
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CampaignWorkHandoffInspection:
|
||||
"""Current authorization and revision check before Workflow continuation."""
|
||||
|
||||
allowed: bool
|
||||
status: CampaignWorkHandoffStatus | None = None
|
||||
assignment_revision: int | None = None
|
||||
action_url: str | None = None
|
||||
assignment_ref: str | None = None
|
||||
reason: str | None = None
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CampaignMailPolicyContextProvider(Protocol):
|
||||
def get_campaign_mail_policy_context(
|
||||
@@ -132,3 +228,45 @@ class CampaignRetentionProvider(Protocol):
|
||||
policy_for_campaign_id: Callable[[str | None], object],
|
||||
) -> Mapping[str, Mapping[str, int]]:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CampaignWorkOrchestrationProvider(Protocol):
|
||||
"""Optional Campaign boundary for durable Workflow-owned hand-offs."""
|
||||
|
||||
def prepare_handoff(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: CampaignWorkHandoffRequest,
|
||||
) -> CampaignWorkHandoffRef:
|
||||
...
|
||||
|
||||
def inspect_handoff(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
assignment_id: str,
|
||||
expected_revision: int | None = None,
|
||||
) -> CampaignWorkHandoffInspection:
|
||||
...
|
||||
|
||||
|
||||
def campaign_work_orchestration_provider(
|
||||
registry: object | None,
|
||||
) -> CampaignWorkOrchestrationProvider | None:
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not registry.has_capability(CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION)
|
||||
):
|
||||
return None
|
||||
capability = registry.capability(CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION)
|
||||
return (
|
||||
capability
|
||||
if isinstance(capability, CampaignWorkOrchestrationProvider)
|
||||
else None
|
||||
)
|
||||
|
||||
@@ -3,6 +3,8 @@ from __future__ import annotations
|
||||
import base64
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from importlib.metadata import PackageNotFoundError, version as package_version
|
||||
from pathlib import Path
|
||||
import json
|
||||
import os
|
||||
@@ -38,6 +40,12 @@ CONFIGURATION_PROVIDER_CAPABILITY = "configuration.provider"
|
||||
|
||||
DiagnosticSeverity = Literal["blocker", "warning", "info"]
|
||||
PlanAction = Literal["create", "update", "bind", "skip", "blocked", "noop"]
|
||||
ConfigurationRollbackStatus = Literal[
|
||||
"blocked_before_apply",
|
||||
"not_required",
|
||||
"database_restore_required",
|
||||
"partial_apply_requires_recovery",
|
||||
]
|
||||
ConfigurationPackageClass = Literal[
|
||||
"reference",
|
||||
"product",
|
||||
@@ -461,6 +469,21 @@ class ConfigurationApplyResult:
|
||||
diagnostics: tuple[ConfigurationDiagnostic, ...] = ()
|
||||
created_refs: Mapping[str, str] = field(default_factory=dict)
|
||||
updated_refs: Mapping[str, str] = field(default_factory=dict)
|
||||
rollback: "ConfigurationRollbackState | None" = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigurationRollbackState:
|
||||
status: ConfigurationRollbackStatus
|
||||
summary: str
|
||||
recovery_action: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"status": self.status,
|
||||
"summary": self.summary,
|
||||
"recovery_action": self.recovery_action,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -471,11 +494,40 @@ class ConfigurationExportSelection:
|
||||
object_refs: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigurationExportProvenance:
|
||||
exported_at: str
|
||||
source_core_version: str
|
||||
module_versions: Mapping[str, str]
|
||||
tenant_id: str | None
|
||||
exporter_id: str | None
|
||||
scopes: tuple[str, ...] = ()
|
||||
module_ids: tuple[str, ...] = ()
|
||||
object_refs: tuple[str, ...] = ()
|
||||
redacted_secret_keys: tuple[str, ...] = ()
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"exported_at": self.exported_at,
|
||||
"source_core_version": self.source_core_version,
|
||||
"module_versions": dict(self.module_versions),
|
||||
"tenant_id": self.tenant_id,
|
||||
"exporter_id": self.exporter_id,
|
||||
"selection": {
|
||||
"scopes": list(self.scopes),
|
||||
"module_ids": list(self.module_ids),
|
||||
"object_refs": list(self.object_refs),
|
||||
},
|
||||
"redacted_secret_keys": list(self.redacted_secret_keys),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigurationExportResult:
|
||||
fragments: tuple[ConfigurationPackageFragment, ...] = ()
|
||||
data_requirements: tuple[ConfigurationRequiredData, ...] = ()
|
||||
diagnostics: tuple[ConfigurationDiagnostic, ...] = ()
|
||||
provenance: ConfigurationExportProvenance | None = None
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
@@ -508,6 +560,7 @@ def dry_run_configuration_package(
|
||||
diagnostics: list[ConfigurationDiagnostic] = []
|
||||
required_data: list[ConfigurationRequiredData] = []
|
||||
plan: list[ConfigurationPlanItem] = []
|
||||
declared_data: dict[str, ConfigurationRequiredData] = {}
|
||||
|
||||
diagnostics.extend(_module_requirement_diagnostics(manifest, context))
|
||||
diagnostics.extend(_capability_requirement_diagnostics(manifest, context))
|
||||
@@ -515,6 +568,7 @@ def dry_run_configuration_package(
|
||||
for item in manifest.data_requirements:
|
||||
requirement = ConfigurationRequiredData.from_mapping(item)
|
||||
required_data.append(requirement)
|
||||
declared_data[requirement.key] = requirement
|
||||
if requirement.required and requirement.key not in context.supplied_data:
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
@@ -525,6 +579,25 @@ def dry_run_configuration_package(
|
||||
))
|
||||
|
||||
for fragment in manifest.fragments:
|
||||
data_ref_diagnostics = _fragment_data_reference_diagnostics(
|
||||
fragment,
|
||||
declared_data=declared_data,
|
||||
supplied_data=context.supplied_data,
|
||||
)
|
||||
if data_ref_diagnostics:
|
||||
diagnostics.extend(data_ref_diagnostics)
|
||||
plan.append(ConfigurationPlanItem(
|
||||
action="blocked",
|
||||
module_id=fragment.module_id,
|
||||
fragment_type=fragment.fragment_type,
|
||||
fragment_id=fragment.fragment_id,
|
||||
summary="Fragment needs declared deployment data before provider preflight.",
|
||||
))
|
||||
continue
|
||||
resolved_fragment = _resolve_fragment_data_references(
|
||||
fragment,
|
||||
context.supplied_data,
|
||||
)
|
||||
provider = provider_map.get(fragment.module_id)
|
||||
if provider is None:
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
@@ -550,7 +623,7 @@ def dry_run_configuration_package(
|
||||
plan.append(ConfigurationPlanItem(action="blocked", module_id=fragment.module_id, fragment_type=fragment.fragment_type, fragment_id=fragment.fragment_id, summary="Fragment type is unsupported."))
|
||||
continue
|
||||
try:
|
||||
result = provider.preflight(fragment, context)
|
||||
result = provider.preflight(resolved_fragment, context)
|
||||
except Exception as exc:
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
@@ -605,19 +678,41 @@ def apply_configuration_package(
|
||||
preflight = dry_run_configuration_package(manifest, providers, apply_context)
|
||||
blockers = [item for item in preflight.diagnostics if item.severity == "blocker"]
|
||||
if blockers:
|
||||
return ConfigurationApplyResult(diagnostics=tuple(blockers))
|
||||
return ConfigurationApplyResult(
|
||||
diagnostics=tuple(blockers),
|
||||
rollback=ConfigurationRollbackState(
|
||||
status="blocked_before_apply",
|
||||
summary="No provider changes were attempted because package preflight is blocked.",
|
||||
),
|
||||
)
|
||||
provider_map = _configuration_provider_map(providers)
|
||||
diagnostics: list[ConfigurationDiagnostic] = list(preflight.diagnostics)
|
||||
created_refs: dict[str, str] = {}
|
||||
updated_refs: dict[str, str] = {}
|
||||
stopped_after_blocker = False
|
||||
for fragment in manifest.fragments:
|
||||
provider = provider_map[fragment.module_id]
|
||||
resolved_fragment = _resolve_fragment_data_references(
|
||||
fragment,
|
||||
apply_context.supplied_data,
|
||||
)
|
||||
try:
|
||||
result = provider.apply(fragment, apply_context.supplied_data, apply_context)
|
||||
result = provider.apply(
|
||||
resolved_fragment,
|
||||
apply_context.supplied_data,
|
||||
apply_context,
|
||||
)
|
||||
diagnostics.extend(result.diagnostics)
|
||||
created_refs.update(result.created_refs)
|
||||
updated_refs.update(result.updated_refs)
|
||||
diagnostics.extend(provider.health(result, apply_context))
|
||||
health_diagnostics = provider.health(result, apply_context)
|
||||
diagnostics.extend(health_diagnostics)
|
||||
if any(
|
||||
item.severity == "blocker"
|
||||
for item in (*result.diagnostics, *health_diagnostics)
|
||||
):
|
||||
stopped_after_blocker = True
|
||||
break
|
||||
except Exception as exc:
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
@@ -627,10 +722,36 @@ def apply_configuration_package(
|
||||
object_ref=fragment.fragment_id or fragment.fragment_type,
|
||||
resolution="Stop the import, keep previous configuration, and inspect provider logs.",
|
||||
))
|
||||
stopped_after_blocker = True
|
||||
break
|
||||
changed = bool(created_refs or updated_refs)
|
||||
if stopped_after_blocker and changed:
|
||||
rollback = ConfigurationRollbackState(
|
||||
status="partial_apply_requires_recovery",
|
||||
summary="At least one provider committed changes before a later provider blocked the package.",
|
||||
recovery_action="Restore the reviewed pre-apply database snapshot or use module-owned compensation where explicitly supported.",
|
||||
)
|
||||
elif stopped_after_blocker:
|
||||
rollback = ConfigurationRollbackState(
|
||||
status="blocked_before_apply",
|
||||
summary="The first provider blocked before any configuration reference was created or updated.",
|
||||
)
|
||||
elif changed:
|
||||
rollback = ConfigurationRollbackState(
|
||||
status="database_restore_required",
|
||||
summary="The package changed provider-owned configuration; generic cross-module compensation is not available.",
|
||||
recovery_action="Retain the pre-apply database snapshot until verification is complete; restore it if the package must be rolled back.",
|
||||
)
|
||||
else:
|
||||
rollback = ConfigurationRollbackState(
|
||||
status="not_required",
|
||||
summary="All package fragments were no-ops, so no rollback action is required.",
|
||||
)
|
||||
return ConfigurationApplyResult(
|
||||
diagnostics=tuple(_dedupe_diagnostics(diagnostics)),
|
||||
created_refs=created_refs,
|
||||
updated_refs=updated_refs,
|
||||
rollback=rollback,
|
||||
)
|
||||
|
||||
|
||||
@@ -669,10 +790,29 @@ def export_configuration_package(
|
||||
fragments.extend(result.fragments)
|
||||
data_requirements.extend(result.data_requirements)
|
||||
diagnostics.extend(result.diagnostics)
|
||||
deduped_required_data = tuple(_dedupe_required_data(data_requirements))
|
||||
provenance = ConfigurationExportProvenance(
|
||||
exported_at=datetime.now(UTC).isoformat(),
|
||||
source_core_version=_installed_core_version(),
|
||||
module_versions={
|
||||
module_id: context.installed_modules[module_id]
|
||||
for module_id in sorted(set(module_ids))
|
||||
if module_id in context.installed_modules
|
||||
},
|
||||
tenant_id=selection.tenant_id,
|
||||
exporter_id=context.operator_user_id,
|
||||
scopes=selection.scopes,
|
||||
module_ids=tuple(module_ids),
|
||||
object_refs=selection.object_refs,
|
||||
redacted_secret_keys=tuple(
|
||||
sorted(item.key for item in deduped_required_data if item.secret)
|
||||
),
|
||||
)
|
||||
return ConfigurationExportResult(
|
||||
fragments=tuple(fragments),
|
||||
data_requirements=tuple(_dedupe_required_data(data_requirements)),
|
||||
data_requirements=deduped_required_data,
|
||||
diagnostics=tuple(_dedupe_diagnostics(diagnostics)),
|
||||
provenance=provenance,
|
||||
)
|
||||
|
||||
|
||||
@@ -1283,6 +1423,103 @@ def _dedupe_required_data(items: Sequence[ConfigurationRequiredData]) -> list[Co
|
||||
return result
|
||||
|
||||
|
||||
def _fragment_data_reference_diagnostics(
|
||||
fragment: ConfigurationPackageFragment,
|
||||
*,
|
||||
declared_data: Mapping[str, ConfigurationRequiredData],
|
||||
supplied_data: Mapping[str, Any],
|
||||
) -> list[ConfigurationDiagnostic]:
|
||||
references: set[str] = set()
|
||||
invalid = _collect_fragment_data_references(fragment.payload, references)
|
||||
diagnostics: list[ConfigurationDiagnostic] = []
|
||||
object_ref = fragment.fragment_id or fragment.fragment_type
|
||||
if invalid:
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="fragment_data_reference_invalid",
|
||||
message="Configuration fragment data references must be objects containing only a non-empty $data key.",
|
||||
module_id=fragment.module_id,
|
||||
object_ref=object_ref,
|
||||
resolution="Replace malformed references with {\"$data\": \"declared_requirement_key\"}.",
|
||||
))
|
||||
for key in sorted(references - set(declared_data)):
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="fragment_data_reference_undeclared",
|
||||
message=f"Configuration fragment references undeclared operator data {key!r}.",
|
||||
module_id=fragment.module_id,
|
||||
object_ref=key,
|
||||
resolution="Declare the key in package data_requirements before using it in a fragment.",
|
||||
))
|
||||
for key in sorted(references & set(declared_data)):
|
||||
if key in supplied_data:
|
||||
continue
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="fragment_data_reference_missing",
|
||||
message=f"Configuration fragment needs operator data {declared_data[key].label!r} before provider preflight.",
|
||||
module_id=fragment.module_id,
|
||||
object_ref=key,
|
||||
resolution="Provide the value in the generated configuration package form.",
|
||||
))
|
||||
return diagnostics
|
||||
|
||||
|
||||
def _collect_fragment_data_references(value: object, references: set[str]) -> bool:
|
||||
invalid = False
|
||||
if isinstance(value, Mapping):
|
||||
if "$data" in value:
|
||||
key = value.get("$data")
|
||||
if len(value) != 1 or not isinstance(key, str) or not key.strip():
|
||||
return True
|
||||
references.add(key.strip())
|
||||
return False
|
||||
for item in value.values():
|
||||
invalid = _collect_fragment_data_references(item, references) or invalid
|
||||
elif isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
|
||||
for item in value:
|
||||
invalid = _collect_fragment_data_references(item, references) or invalid
|
||||
return invalid
|
||||
|
||||
|
||||
def _resolve_fragment_data_references(
|
||||
fragment: ConfigurationPackageFragment,
|
||||
supplied_data: Mapping[str, Any],
|
||||
) -> ConfigurationPackageFragment:
|
||||
payload = _resolve_data_reference_value(fragment.payload, supplied_data)
|
||||
if not isinstance(payload, Mapping):
|
||||
raise ValueError("Resolved configuration fragment payload must remain an object.")
|
||||
return ConfigurationPackageFragment(
|
||||
module_id=fragment.module_id,
|
||||
fragment_type=fragment.fragment_type,
|
||||
fragment_id=fragment.fragment_id,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_data_reference_value(value: object, supplied_data: Mapping[str, Any]) -> object:
|
||||
if isinstance(value, Mapping):
|
||||
if set(value) == {"$data"}:
|
||||
key = value.get("$data")
|
||||
if not isinstance(key, str) or key not in supplied_data:
|
||||
raise ValueError("Configuration fragment contains an unresolved $data reference.")
|
||||
return supplied_data[key]
|
||||
return {
|
||||
str(key): _resolve_data_reference_value(item, supplied_data)
|
||||
for key, item in value.items()
|
||||
}
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
|
||||
return [_resolve_data_reference_value(item, supplied_data) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _installed_core_version() -> str:
|
||||
try:
|
||||
return package_version("govoplan-core")
|
||||
except PackageNotFoundError:
|
||||
return "workspace"
|
||||
|
||||
|
||||
def _catalog_source(path: Path | str | None) -> Path | str | None:
|
||||
if path is not None:
|
||||
return path if isinstance(path, str) and _is_http_url(path) else Path(path).expanduser()
|
||||
|
||||
@@ -101,6 +101,8 @@ class DatasourceGovernance:
|
||||
transfer_agreement_ref: str | None = None
|
||||
freshness_policy: Mapping[str, object] = field(default_factory=dict)
|
||||
quality_policy: Mapping[str, object] = field(default_factory=dict)
|
||||
approval_policy: Mapping[str, object] = field(default_factory=dict)
|
||||
retention_policy: Mapping[str, object] = field(default_factory=dict)
|
||||
known_limits: tuple[str, ...] = ()
|
||||
correction_procedure_ref: str | None = None
|
||||
affected_refs: tuple[str, ...] = ()
|
||||
@@ -179,6 +181,8 @@ class DatasourceGovernance:
|
||||
),
|
||||
freshness_policy=_governance_mapping(source.get("freshness_policy")),
|
||||
quality_policy=_governance_mapping(source.get("quality_policy")),
|
||||
approval_policy=_governance_mapping(source.get("approval_policy")),
|
||||
retention_policy=_governance_mapping(source.get("retention_policy")),
|
||||
known_limits=_governance_texts(source.get("known_limits")),
|
||||
correction_procedure_ref=_optional_governance_text(
|
||||
source.get("correction_procedure_ref")
|
||||
@@ -210,6 +214,8 @@ class DatasourceGovernance:
|
||||
"transfer_agreement_ref": self.transfer_agreement_ref,
|
||||
"freshness_policy": dict(self.freshness_policy),
|
||||
"quality_policy": dict(self.quality_policy),
|
||||
"approval_policy": dict(self.approval_policy),
|
||||
"retention_policy": dict(self.retention_policy),
|
||||
"known_limits": list(self.known_limits),
|
||||
"correction_procedure_ref": self.correction_procedure_ref,
|
||||
"affected_refs": list(self.affected_refs),
|
||||
@@ -289,6 +295,8 @@ class DatasourceMaterialization:
|
||||
frozen_label: str | None = None
|
||||
source_timestamp: datetime | None = None
|
||||
created_at: datetime | None = None
|
||||
disposed_at: datetime | None = None
|
||||
disposition: Mapping[str, object] = field(default_factory=dict)
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
governance: DatasourceGovernance = field(default_factory=DatasourceGovernance)
|
||||
@@ -309,6 +317,7 @@ class DatasourceStage:
|
||||
row_count: int | None = None
|
||||
byte_count: int | None = None
|
||||
validation: Mapping[str, object] = field(default_factory=dict)
|
||||
approval: Mapping[str, object] = field(default_factory=dict)
|
||||
created_at: datetime | None = None
|
||||
promoted_at: datetime | None = None
|
||||
promoted_materialization_ref: str | None = None
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterable, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass, field, replace
|
||||
from typing import Any, Literal, Protocol, TYPE_CHECKING
|
||||
|
||||
from govoplan_core.core.information_governance import ModuleInformationGovernance
|
||||
@@ -289,6 +289,30 @@ DocumentationSourceState = Literal["configured", "disabled", "unavailable"]
|
||||
CapabilityStability = Literal["experimental", "stable", "deprecated"]
|
||||
|
||||
|
||||
DOCUMENTATION_STRUCTURED_TRANSLATION_VERSION = "1"
|
||||
DOCUMENTATION_LOCALIZABLE_METADATA_KEYS = frozenset(
|
||||
{
|
||||
"admin_explanation",
|
||||
"consequence_classes",
|
||||
"consequences",
|
||||
"constraints",
|
||||
"current_configuration",
|
||||
"fields",
|
||||
"limitations",
|
||||
"operational_consequences",
|
||||
"outcome",
|
||||
"prerequisites",
|
||||
"privacy_notes",
|
||||
"purpose",
|
||||
"result",
|
||||
"steps",
|
||||
"user_explanation",
|
||||
"verification",
|
||||
"when_used",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DocumentationLink:
|
||||
label: str
|
||||
@@ -324,12 +348,142 @@ class DocumentationTopic:
|
||||
configuration_keys: tuple[str, ...] = ()
|
||||
i18n_key: str | None = None
|
||||
translations: Mapping[str, Mapping[str, str]] = field(default_factory=dict)
|
||||
structured_translation_version: str | None = None
|
||||
structured_translations: Mapping[str, Mapping[str, Any]] = field(
|
||||
default_factory=dict
|
||||
)
|
||||
source_module_id: str | None = None
|
||||
version_min: str | None = None
|
||||
version_max_exclusive: str | None = None
|
||||
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def localizable_documentation_metadata_keys(
|
||||
topic: DocumentationTopic,
|
||||
) -> tuple[str, ...]:
|
||||
"""Return structured metadata keys whose values are public prose."""
|
||||
|
||||
return tuple(
|
||||
sorted(DOCUMENTATION_LOCALIZABLE_METADATA_KEYS.intersection(topic.metadata))
|
||||
)
|
||||
|
||||
|
||||
def localized_documentation_metadata(
|
||||
topic: DocumentationTopic,
|
||||
locale: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Overlay one validated structured translation onto source metadata."""
|
||||
|
||||
localized = dict(topic.metadata)
|
||||
translation = topic.structured_translations.get(locale)
|
||||
if translation:
|
||||
localized.update(translation)
|
||||
return localized
|
||||
|
||||
|
||||
def documentation_structured_translation_issues(
|
||||
topic: DocumentationTopic,
|
||||
) -> tuple[str, ...]:
|
||||
"""Validate the opt-in, versioned structured-documentation translation."""
|
||||
|
||||
version = topic.structured_translation_version
|
||||
translations = topic.structured_translations
|
||||
if version is None:
|
||||
if translations:
|
||||
return (
|
||||
"structured_translations require structured_translation_version",
|
||||
)
|
||||
return ()
|
||||
if version != DOCUMENTATION_STRUCTURED_TRANSLATION_VERSION:
|
||||
return (
|
||||
"unsupported structured_translation_version "
|
||||
f"{version!r}; expected {DOCUMENTATION_STRUCTURED_TRANSLATION_VERSION!r}",
|
||||
)
|
||||
|
||||
localizable_keys = set(localizable_documentation_metadata_keys(topic))
|
||||
issues: list[str] = []
|
||||
for locale, translation in translations.items():
|
||||
if not locale.strip():
|
||||
issues.append("structured translation locale must not be empty")
|
||||
continue
|
||||
translated_keys = set(translation)
|
||||
for key in sorted(translated_keys - localizable_keys):
|
||||
issues.append(
|
||||
f"structured translation {locale!r} contains non-localizable or missing metadata key {key!r}"
|
||||
)
|
||||
for key in sorted(localizable_keys - translated_keys):
|
||||
issues.append(
|
||||
f"structured translation {locale!r} is missing metadata key {key!r}"
|
||||
)
|
||||
for key in sorted(localizable_keys & translated_keys):
|
||||
issues.extend(
|
||||
_structured_translation_shape_issues(
|
||||
topic.metadata[key],
|
||||
translation[key],
|
||||
path=f"{locale}.{key}",
|
||||
)
|
||||
)
|
||||
return tuple(issues)
|
||||
|
||||
|
||||
def _structured_translation_shape_issues(
|
||||
source: object,
|
||||
translated: object,
|
||||
*,
|
||||
path: str,
|
||||
) -> tuple[str, ...]:
|
||||
if isinstance(source, str):
|
||||
if not isinstance(translated, str) or not translated.strip():
|
||||
return (f"structured translation {path} must be a non-empty string",)
|
||||
return ()
|
||||
if isinstance(source, Mapping):
|
||||
if not isinstance(translated, Mapping):
|
||||
return (f"structured translation {path} must preserve object shape",)
|
||||
issues: list[str] = []
|
||||
source_keys = {str(key) for key in source}
|
||||
translated_keys = {str(key) for key in translated}
|
||||
if source_keys != translated_keys:
|
||||
issues.append(
|
||||
f"structured translation {path} must preserve object keys"
|
||||
)
|
||||
return tuple(issues)
|
||||
for key, value in source.items():
|
||||
issues.extend(
|
||||
_structured_translation_shape_issues(
|
||||
value,
|
||||
translated[key],
|
||||
path=f"{path}.{key}",
|
||||
)
|
||||
)
|
||||
return tuple(issues)
|
||||
if isinstance(source, Sequence) and not isinstance(
|
||||
source, (str, bytes, bytearray)
|
||||
):
|
||||
if not isinstance(translated, Sequence) or isinstance(
|
||||
translated, (str, bytes, bytearray)
|
||||
):
|
||||
return (f"structured translation {path} must preserve list shape",)
|
||||
if len(source) != len(translated):
|
||||
return (f"structured translation {path} must preserve list length",)
|
||||
issues: list[str] = []
|
||||
for index, (source_item, translated_item) in enumerate(
|
||||
zip(source, translated, strict=True)
|
||||
):
|
||||
issues.extend(
|
||||
_structured_translation_shape_issues(
|
||||
source_item,
|
||||
translated_item,
|
||||
path=f"{path}[{index}]",
|
||||
)
|
||||
)
|
||||
return tuple(issues)
|
||||
if translated != source:
|
||||
return (
|
||||
f"structured translation {path} must preserve non-text value {source!r}",
|
||||
)
|
||||
return ()
|
||||
|
||||
|
||||
def user_workflow_scope_condition_issues(topic: DocumentationTopic) -> tuple[str, ...]:
|
||||
"""Return fail-closed authoring issues for a user-facing workflow topic.
|
||||
|
||||
@@ -533,3 +687,53 @@ class ModuleManifest:
|
||||
# runtime module ID changes.
|
||||
permission_namespace: str | None = None
|
||||
workflow_definitions: tuple["WorkflowDefinitionContribution", ...] = ()
|
||||
|
||||
|
||||
def with_documentation_structured_translations(
|
||||
manifest: ModuleManifest,
|
||||
*,
|
||||
locale: str,
|
||||
translations: Mapping[str, Mapping[str, Any]],
|
||||
) -> ModuleManifest:
|
||||
"""Merge module-owned structured documentation translations by topic id.
|
||||
|
||||
The helper keeps feature prose in its owning module while giving every
|
||||
manifest the same fail-closed merge behavior. Unknown topic ids and
|
||||
incomplete or shape-changing locale maps are rejected immediately.
|
||||
"""
|
||||
|
||||
locale = locale.strip()
|
||||
if not locale:
|
||||
raise ValueError("structured documentation locale must not be empty")
|
||||
|
||||
topics_by_id = {topic.id: topic for topic in manifest.documentation}
|
||||
unknown_topic_ids = sorted(set(translations) - set(topics_by_id))
|
||||
if unknown_topic_ids:
|
||||
raise ValueError(
|
||||
"structured documentation translations reference unknown topic ids: "
|
||||
+ ", ".join(unknown_topic_ids)
|
||||
)
|
||||
|
||||
localized_topics: list[DocumentationTopic] = []
|
||||
for topic in manifest.documentation:
|
||||
translation = translations.get(topic.id)
|
||||
if translation is None:
|
||||
localized_topics.append(topic)
|
||||
continue
|
||||
|
||||
structured_translations = dict(topic.structured_translations)
|
||||
structured_translations[locale] = translation
|
||||
localized_topic = replace(
|
||||
topic,
|
||||
structured_translation_version=DOCUMENTATION_STRUCTURED_TRANSLATION_VERSION,
|
||||
structured_translations=structured_translations,
|
||||
)
|
||||
issues = documentation_structured_translation_issues(localized_topic)
|
||||
if issues:
|
||||
raise ValueError(
|
||||
f"invalid {locale!r} structured documentation translation for "
|
||||
f"{topic.id!r}: {'; '.join(issues)}"
|
||||
)
|
||||
localized_topics.append(localized_topic)
|
||||
|
||||
return replace(manifest, documentation=tuple(localized_topics))
|
||||
|
||||
@@ -41,10 +41,12 @@ ViewGovernanceAction = Literal[
|
||||
"workflow_activate",
|
||||
]
|
||||
FunctionAssignmentChangeKind = Literal["request", "grant"]
|
||||
FunctionAssignmentReviewStep = Literal["holder", "authority", "recipient"]
|
||||
FunctionAssignmentGovernanceAction = Literal[
|
||||
"submit",
|
||||
"approve_holder",
|
||||
"approve_authority",
|
||||
"approve_escalation",
|
||||
"accept_recipient",
|
||||
"request_changes",
|
||||
"respond",
|
||||
@@ -419,6 +421,20 @@ class FunctionAssignmentGovernanceRequest:
|
||||
context: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FunctionAssignmentEscalationRule:
|
||||
step: FunctionAssignmentReviewStep
|
||||
target_function_id: str
|
||||
timeout_hours: int
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"step": self.step,
|
||||
"target_function_id": self.target_function_id,
|
||||
"timeout_hours": self.timeout_hours,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FunctionAssignmentGovernanceDecision:
|
||||
allowed: bool
|
||||
@@ -431,6 +447,10 @@ class FunctionAssignmentGovernanceDecision:
|
||||
separation_of_duties: bool = True
|
||||
quorum: int = 1
|
||||
maximum_validity_days: int | None = None
|
||||
delegation_allowed: bool = False
|
||||
maximum_delegation_depth: int = 0
|
||||
maximum_delegated_validity_days: int | None = None
|
||||
escalation_rules: tuple[FunctionAssignmentEscalationRule, ...] = ()
|
||||
request_expiry_hours: int = 336
|
||||
source_path: tuple[PolicySourceStep, ...] = ()
|
||||
requirements: tuple[str, ...] = ()
|
||||
@@ -448,12 +468,24 @@ class FunctionAssignmentGovernanceDecision:
|
||||
"separation_of_duties": self.separation_of_duties,
|
||||
"quorum": self.quorum,
|
||||
"maximum_validity_days": self.maximum_validity_days,
|
||||
"delegation_allowed": self.delegation_allowed,
|
||||
"maximum_delegation_depth": self.maximum_delegation_depth,
|
||||
"maximum_delegated_validity_days": (
|
||||
self.maximum_delegated_validity_days
|
||||
),
|
||||
"escalation_rules": [rule.to_dict() for rule in self.escalation_rules],
|
||||
"request_expiry_hours": self.request_expiry_hours,
|
||||
"source_path": [step.to_dict() for step in self.source_path],
|
||||
"requirements": list(self.requirements),
|
||||
"details": dict(self.details),
|
||||
}
|
||||
|
||||
def escalation_rule(
|
||||
self,
|
||||
step: FunctionAssignmentReviewStep,
|
||||
) -> FunctionAssignmentEscalationRule | None:
|
||||
return next((rule for rule in self.escalation_rules if rule.step == step), None)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class FunctionAssignmentGovernancePolicy(Protocol):
|
||||
|
||||
@@ -26,6 +26,7 @@ from govoplan_core.core.modules import (
|
||||
TenantSummaryBatchProvider,
|
||||
TenantSummaryProvider,
|
||||
user_workflow_scope_condition_issues,
|
||||
documentation_structured_translation_issues,
|
||||
)
|
||||
from govoplan_core.core.module_entitlements import (
|
||||
TenantModuleEntitlementResolver,
|
||||
@@ -962,6 +963,10 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} documentation topic {topic.id!r}: {issue}"
|
||||
)
|
||||
for issue in documentation_structured_translation_issues(topic):
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} documentation topic {topic.id!r}: {issue}"
|
||||
)
|
||||
_validate_documentation_extensions(manifest)
|
||||
_validate_architecture_declarations(manifest)
|
||||
_validate_workflow_definition_contributions(manifest)
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Mapping, Protocol, runtime_checkable
|
||||
|
||||
|
||||
TICKET_INTEGRATION_CONTRACT_VERSION = "1"
|
||||
CAPABILITY_TICKET_ROUTING = "tickets.routing"
|
||||
CAPABILITY_TICKET_CASE_ESCALATION = "tickets.case_escalation"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TicketRoutingRequest:
|
||||
tenant_id: str
|
||||
ticket_id: str
|
||||
ticket_type: str
|
||||
priority: str
|
||||
title: str
|
||||
received_at: datetime
|
||||
queue_hint: str | None = None
|
||||
attributes: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_required(self.tenant_id, "Ticket routing tenant", 255)
|
||||
_required(self.ticket_id, "Ticket routing ticket", 255)
|
||||
_required(self.ticket_type, "Ticket routing type", 80)
|
||||
_required(self.priority, "Ticket routing priority", 40)
|
||||
_required(self.title, "Ticket routing title", 500)
|
||||
_aware(self.received_at, "Ticket routing received_at")
|
||||
_optional(self.queue_hint, "Ticket routing queue hint", 255)
|
||||
if len(self.attributes) > 100:
|
||||
raise ValueError("Ticket routing attributes are limited to 100 entries.")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TicketRoutingPlan:
|
||||
provider_id: str
|
||||
queue_ref: str | None = None
|
||||
service_target_at: datetime | None = None
|
||||
explanation: str | None = None
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_required(self.provider_id, "Ticket routing provider", 200)
|
||||
_optional(self.queue_ref, "Ticket routing queue reference", 255)
|
||||
_optional(self.explanation, "Ticket routing explanation", 4_000)
|
||||
_aware(self.service_target_at, "Ticket routing service_target_at")
|
||||
if len(self.metadata) > 100:
|
||||
raise ValueError("Ticket routing metadata is limited to 100 entries.")
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TicketRoutingProvider(Protocol):
|
||||
def route_ticket(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: TicketRoutingRequest,
|
||||
) -> TicketRoutingPlan: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TicketCaseEscalationCommand:
|
||||
tenant_id: str
|
||||
ticket_id: str
|
||||
ticket_number: str
|
||||
title: str
|
||||
case_type_key: str
|
||||
occurred_at: datetime
|
||||
idempotency_key: str
|
||||
handoff_note: str | None = None
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_required(self.tenant_id, "Ticket escalation tenant", 255)
|
||||
_required(self.ticket_id, "Ticket escalation ticket", 255)
|
||||
_required(self.ticket_number, "Ticket escalation number", 255)
|
||||
_required(self.title, "Ticket escalation title", 500)
|
||||
_required(self.case_type_key, "Ticket escalation case type", 120)
|
||||
_required(self.idempotency_key, "Ticket escalation idempotency key", 255)
|
||||
_optional(self.handoff_note, "Ticket escalation handoff note", 10_000)
|
||||
_aware(self.occurred_at, "Ticket escalation occurred_at")
|
||||
if len(self.metadata) > 100:
|
||||
raise ValueError("Ticket escalation metadata is limited to 100 entries.")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TicketCaseEscalationResult:
|
||||
provider_id: str
|
||||
case_id: str
|
||||
case_number: str
|
||||
case_url: str
|
||||
replayed: bool = False
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_required(self.provider_id, "Ticket escalation provider", 200)
|
||||
_required(self.case_id, "Ticket escalation case", 255)
|
||||
_required(self.case_number, "Ticket escalation case number", 255)
|
||||
_relative_url(self.case_url)
|
||||
if len(self.metadata) > 100:
|
||||
raise ValueError("Ticket escalation metadata is limited to 100 entries.")
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TicketCaseEscalationProvider(Protocol):
|
||||
def escalate_ticket(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
command: TicketCaseEscalationCommand,
|
||||
) -> TicketCaseEscalationResult: ...
|
||||
|
||||
|
||||
def ticket_routing_provider(registry: object | None) -> TicketRoutingProvider | None:
|
||||
provider = _capability(registry, CAPABILITY_TICKET_ROUTING)
|
||||
return provider if isinstance(provider, TicketRoutingProvider) else None
|
||||
|
||||
|
||||
def ticket_case_escalation_provider(
|
||||
registry: object | None,
|
||||
) -> TicketCaseEscalationProvider | None:
|
||||
provider = _capability(registry, CAPABILITY_TICKET_CASE_ESCALATION)
|
||||
return provider if isinstance(provider, TicketCaseEscalationProvider) else None
|
||||
|
||||
|
||||
def _capability(registry: object | None, name: str) -> object | None:
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not hasattr(registry, "capability")
|
||||
or not registry.has_capability(name)
|
||||
):
|
||||
return None
|
||||
return registry.capability(name)
|
||||
|
||||
|
||||
def _required(value: str, label: str, maximum: int) -> None:
|
||||
if not value.strip() or len(value) > maximum:
|
||||
raise ValueError(f"{label} must contain 1 to {maximum} characters.")
|
||||
|
||||
|
||||
def _optional(value: str | None, label: str, maximum: int) -> None:
|
||||
if value is not None and (not value.strip() or len(value) > maximum):
|
||||
raise ValueError(f"{label} must contain 1 to {maximum} characters when set.")
|
||||
|
||||
|
||||
def _aware(value: datetime | None, label: str) -> None:
|
||||
if value is not None and (value.tzinfo is None or value.utcoffset() is None):
|
||||
raise ValueError(f"{label} must include a timezone.")
|
||||
|
||||
|
||||
def _relative_url(value: str) -> None:
|
||||
if (
|
||||
not value.startswith("/")
|
||||
or value.startswith("//")
|
||||
or "\\" in value
|
||||
or len(value) > 1_500
|
||||
or any(ord(character) < 32 or ord(character) == 127 for character in value)
|
||||
):
|
||||
raise ValueError("Ticket escalation URLs must be bounded application-relative paths.")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CAPABILITY_TICKET_CASE_ESCALATION",
|
||||
"CAPABILITY_TICKET_ROUTING",
|
||||
"TICKET_INTEGRATION_CONTRACT_VERSION",
|
||||
"TicketCaseEscalationCommand",
|
||||
"TicketCaseEscalationProvider",
|
||||
"TicketCaseEscalationResult",
|
||||
"TicketRoutingPlan",
|
||||
"TicketRoutingProvider",
|
||||
"TicketRoutingRequest",
|
||||
"ticket_case_escalation_provider",
|
||||
"ticket_routing_provider",
|
||||
]
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from typing import Mapping
|
||||
|
||||
@@ -12,6 +13,12 @@ from govoplan_core.security.outbound_http import (
|
||||
)
|
||||
|
||||
|
||||
MAX_OUTBOUND_HTTP_REQUEST_BODY_BYTES = 1_000_000
|
||||
_STANDARD_REDIRECT_SENSITIVE_HEADERS = frozenset(
|
||||
{"authorization", "proxy-authorization", "cookie", "cookie2"}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class HttpFetchResponse:
|
||||
status: int
|
||||
@@ -46,15 +53,27 @@ def fetch_http(
|
||||
label: str = "URL",
|
||||
method: str = "GET",
|
||||
headers: Mapping[str, str] | None = None,
|
||||
body: bytes | None = None,
|
||||
max_bytes: int | None = None,
|
||||
redirect_sensitive_headers: Iterable[str] = (),
|
||||
) -> HttpFetchResponse:
|
||||
if body is not None and len(body) > MAX_OUTBOUND_HTTP_REQUEST_BODY_BYTES:
|
||||
raise ValueError(
|
||||
"Outbound HTTP request body exceeds the 1000000-byte safety limit."
|
||||
)
|
||||
validated_url = validate_outbound_http_url(url, label=label)
|
||||
request = urllib.request.Request( # noqa: S310 - URL is restricted to validated HTTP(S).
|
||||
validated_url,
|
||||
data=body,
|
||||
headers=dict(headers or {}),
|
||||
method=method,
|
||||
)
|
||||
opener = build_outbound_http_opener(_PolicyRedirectHandler(label=label))
|
||||
opener = build_outbound_http_opener(
|
||||
_PolicyRedirectHandler(
|
||||
label=label,
|
||||
sensitive_headers=redirect_sensitive_headers,
|
||||
)
|
||||
)
|
||||
with opener.open(request, timeout=timeout) as response: # noqa: S310 - URL and every redirect are policy-validated. # nosec B310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
|
||||
response_headers = dict(response.headers.items())
|
||||
return HttpFetchResponse(
|
||||
@@ -76,16 +95,35 @@ def fetch_http_text(
|
||||
label: str = "URL",
|
||||
method: str = "GET",
|
||||
headers: Mapping[str, str] | None = None,
|
||||
body: bytes | None = None,
|
||||
encoding: str = "utf-8",
|
||||
max_bytes: int | None = None,
|
||||
redirect_sensitive_headers: Iterable[str] = (),
|
||||
) -> str:
|
||||
return fetch_http(url, timeout=timeout, label=label, method=method, headers=headers, max_bytes=max_bytes).text(encoding)
|
||||
return fetch_http(
|
||||
url,
|
||||
timeout=timeout,
|
||||
label=label,
|
||||
method=method,
|
||||
headers=headers,
|
||||
body=body,
|
||||
max_bytes=max_bytes,
|
||||
redirect_sensitive_headers=redirect_sensitive_headers,
|
||||
).text(encoding)
|
||||
|
||||
|
||||
class _PolicyRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||
def __init__(self, *, label: str) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
label: str,
|
||||
sensitive_headers: Iterable[str] = (),
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._label = label
|
||||
self._sensitive_headers = _STANDARD_REDIRECT_SENSITIVE_HEADERS | {
|
||||
value.strip().lower() for value in sensitive_headers if value.strip()
|
||||
}
|
||||
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
|
||||
candidate = validate_outbound_http_url(newurl, label=f"{self._label} redirect")
|
||||
@@ -95,8 +133,9 @@ class _PolicyRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||
return None
|
||||
new_request = super().redirect_request(req, fp, code, msg, headers, candidate)
|
||||
if new_request is not None and _http_origin(previous) != _http_origin(redirected):
|
||||
for header in ("Authorization", "Proxy-Authorization", "Cookie", "Cookie2"):
|
||||
new_request.remove_header(header)
|
||||
for header in tuple(new_request.headers) + tuple(new_request.unredirected_hdrs):
|
||||
if header.lower() in self._sensitive_headers:
|
||||
new_request.remove_header(header)
|
||||
return new_request
|
||||
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ from govoplan_core.core.campaigns import (
|
||||
CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT,
|
||||
CAPABILITY_CAMPAIGNS_POLICY_CONTEXT,
|
||||
CAPABILITY_CAMPAIGNS_RETENTION,
|
||||
CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION,
|
||||
CampaignAccessProvider,
|
||||
CampaignDeliveryTaskProvider,
|
||||
CampaignMailPolicyContext,
|
||||
@@ -78,6 +79,10 @@ from govoplan_core.core.campaigns import (
|
||||
CampaignPolicyContext,
|
||||
CampaignPolicyContextProvider,
|
||||
CampaignRetentionProvider,
|
||||
CampaignWorkHandoffInspection,
|
||||
CampaignWorkHandoffRef,
|
||||
CampaignWorkHandoffRequest,
|
||||
CampaignWorkOrchestrationProvider,
|
||||
)
|
||||
from govoplan_core.core.files import CAPABILITY_FILES_ACCESS, FileAccessProvider
|
||||
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||
@@ -464,6 +469,40 @@ class _FakeCampaignRetentionProvider:
|
||||
return {"raw_campaign_json": {"eligible": int(dry_run)}}
|
||||
|
||||
|
||||
class _FakeCampaignWorkOrchestrationProvider:
|
||||
def prepare_handoff(self, session: object, principal: object, *, request):
|
||||
del session, principal
|
||||
return CampaignWorkHandoffRef(
|
||||
tenant_id=request.tenant_id,
|
||||
campaign_id=request.campaign_id or "campaign-created",
|
||||
campaign_version_id="campaign-version-1",
|
||||
campaign_revision=1,
|
||||
assignment_id="assignment-1",
|
||||
assignment_revision=1,
|
||||
status="open",
|
||||
action_url="/campaigns/campaign-1/work?assignment=assignment-1",
|
||||
campaign_ref="campaign:campaign-1:version:campaign-version-1:r1",
|
||||
assignment_ref="campaign-work-assignment:assignment-1:r1",
|
||||
)
|
||||
|
||||
def inspect_handoff(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
assignment_id: str,
|
||||
expected_revision: int | None = None,
|
||||
):
|
||||
del session, principal, tenant_id, assignment_id
|
||||
return CampaignWorkHandoffInspection(
|
||||
allowed=expected_revision in {None, 1},
|
||||
status="open",
|
||||
assignment_revision=1,
|
||||
assignment_ref="campaign-work-assignment:assignment-1:r1",
|
||||
)
|
||||
|
||||
|
||||
class _FakeSecretProvider:
|
||||
def __init__(self) -> None:
|
||||
self._values: dict[str, str] = {}
|
||||
@@ -528,6 +567,10 @@ class AccessContractTests(unittest.TestCase):
|
||||
self.assertEqual("campaigns.mailPolicyContext", CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT)
|
||||
self.assertEqual("campaigns.policyContext", CAPABILITY_CAMPAIGNS_POLICY_CONTEXT)
|
||||
self.assertEqual("campaigns.retention", CAPABILITY_CAMPAIGNS_RETENTION)
|
||||
self.assertEqual(
|
||||
"campaigns.workOrchestration",
|
||||
CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION,
|
||||
)
|
||||
self.assertEqual("tenancy.tenantResolver", CAPABILITY_TENANCY_TENANT_RESOLVER)
|
||||
self.assertEqual("security.secretProvider", CAPABILITY_SECURITY_SECRET_PROVIDER)
|
||||
self.assertEqual("audit.sink", CAPABILITY_AUDIT_SINK)
|
||||
@@ -642,6 +685,10 @@ class AccessContractTests(unittest.TestCase):
|
||||
self.assertIsInstance(_FakeCampaignMailPolicyContextProvider(), CampaignMailPolicyContextProvider)
|
||||
self.assertIsInstance(_FakeCampaignPolicyContextProvider(), CampaignPolicyContextProvider)
|
||||
self.assertIsInstance(_FakeCampaignRetentionProvider(), CampaignRetentionProvider)
|
||||
self.assertIsInstance(
|
||||
_FakeCampaignWorkOrchestrationProvider(),
|
||||
CampaignWorkOrchestrationProvider,
|
||||
)
|
||||
self.assertIsInstance(_FakeSecretProvider(), SecretProvider)
|
||||
self.assertIsInstance(_FakeAuditSink(), AuditSink)
|
||||
self.assertIsInstance(_FakeAuditRecorder(), AuditRecorder)
|
||||
@@ -676,6 +723,37 @@ class AccessContractTests(unittest.TestCase):
|
||||
self.assertEqual({"job_id": "job-1", "status": "appended"}, delivery_provider.append_sent_for_job(object(), job_id="job-1"))
|
||||
self.assertEqual({"raw_campaign_json": {"eligible": 1}}, retention_provider.apply_retention(object(), dry_run=True, now=object(), policy_for_campaign_id=lambda campaign_id: object()))
|
||||
|
||||
def test_campaign_work_handoff_contract_requires_one_campaign_source(self) -> None:
|
||||
request = CampaignWorkHandoffRequest(
|
||||
tenant_id="tenant-1",
|
||||
campaign_id="campaign-1",
|
||||
idempotency_key="workflow-step-1",
|
||||
purpose="Review the campaign",
|
||||
assignee_kind="account",
|
||||
assignee_id="account-1",
|
||||
)
|
||||
provider = _FakeCampaignWorkOrchestrationProvider()
|
||||
|
||||
handoff = provider.prepare_handoff(object(), object(), request=request)
|
||||
inspection = provider.inspect_handoff(
|
||||
object(),
|
||||
object(),
|
||||
tenant_id="tenant-1",
|
||||
assignment_id=handoff.assignment_id,
|
||||
expected_revision=handoff.assignment_revision,
|
||||
)
|
||||
|
||||
self.assertEqual("campaign-1", handoff.campaign_id)
|
||||
self.assertTrue(inspection.allowed)
|
||||
with self.assertRaisesRegex(ValueError, "either reference one campaign"):
|
||||
CampaignWorkHandoffRequest(
|
||||
tenant_id="tenant-1",
|
||||
idempotency_key="workflow-step-2",
|
||||
purpose="Review",
|
||||
assignee_kind="account",
|
||||
assignee_id="account-1",
|
||||
)
|
||||
|
||||
def test_access_capabilities_register_and_resolve_through_platform_registry(self) -> None:
|
||||
directory = _FakeAccessDirectory()
|
||||
semantic_directory = _FakeAccessSemanticDirectory()
|
||||
|
||||
@@ -3,13 +3,22 @@ from __future__ import annotations
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.configuration_packages import (
|
||||
ConfigurationApplyResult,
|
||||
ConfigurationExportResult,
|
||||
ConfigurationExportSelection,
|
||||
ConfigurationModuleRequirement,
|
||||
ConfigurationPackageFragment,
|
||||
ConfigurationPackageEvidence,
|
||||
ConfigurationPackageManifest,
|
||||
ConfigurationPackageParent,
|
||||
ConfigurationPlanItem,
|
||||
ConfigurationPreflightContext,
|
||||
ConfigurationPreflightResult,
|
||||
ConfigurationProviderExpectation,
|
||||
ConfigurationRequiredData,
|
||||
apply_configuration_package,
|
||||
dry_run_configuration_package,
|
||||
export_configuration_package,
|
||||
validate_configuration_package_derivation,
|
||||
)
|
||||
|
||||
@@ -27,6 +36,121 @@ def _evidence(*kinds: str) -> tuple[ConfigurationPackageEvidence, ...]:
|
||||
|
||||
|
||||
class ConfigurationPackageArchitectureTests(unittest.TestCase):
|
||||
def test_deployment_data_references_are_declared_resolved_and_never_exported(self) -> None:
|
||||
class Provider:
|
||||
module_id = "forms"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.preflight_payloads: list[dict[str, object]] = []
|
||||
|
||||
def describe(self):
|
||||
from govoplan_core.core.configuration_packages import ConfigurationProviderDescription
|
||||
|
||||
return ConfigurationProviderDescription(
|
||||
module_id=self.module_id,
|
||||
fragment_types=("definition",),
|
||||
)
|
||||
|
||||
def preflight(self, fragment, context):
|
||||
del context
|
||||
self.preflight_payloads.append(dict(fragment.payload))
|
||||
return ConfigurationPreflightResult(plan=(ConfigurationPlanItem(
|
||||
action="create",
|
||||
module_id=self.module_id,
|
||||
fragment_type=fragment.fragment_type,
|
||||
fragment_id=fragment.fragment_id,
|
||||
),))
|
||||
|
||||
def apply(self, fragment, supplied_data, context):
|
||||
del supplied_data, context
|
||||
return ConfigurationApplyResult(
|
||||
created_refs={fragment.fragment_id or "definition": "form:resident-parking"}
|
||||
)
|
||||
|
||||
def export(self, selection, context):
|
||||
del selection, context
|
||||
return ConfigurationExportResult(
|
||||
fragments=(ConfigurationPackageFragment(
|
||||
module_id=self.module_id,
|
||||
fragment_type="definition",
|
||||
payload={"name": "Resident parking permit"},
|
||||
),),
|
||||
data_requirements=(ConfigurationRequiredData(
|
||||
key="payment_credential_ref",
|
||||
label="Payment credential reference",
|
||||
secret=True,
|
||||
),),
|
||||
)
|
||||
|
||||
def health(self, import_result, context):
|
||||
del import_result, context
|
||||
return ()
|
||||
|
||||
provider = Provider()
|
||||
package = ConfigurationPackageManifest(
|
||||
package_id="product.resident-parking",
|
||||
name="Resident parking permit",
|
||||
version="1.0.0",
|
||||
required_modules=(ConfigurationModuleRequirement("forms"),),
|
||||
data_requirements=({
|
||||
"key": "service_name",
|
||||
"label": "Public service name",
|
||||
},),
|
||||
fragments=(ConfigurationPackageFragment(
|
||||
module_id="forms",
|
||||
fragment_type="definition",
|
||||
fragment_id="resident-parking",
|
||||
payload={
|
||||
"definition": {
|
||||
"title": {"$data": "service_name"},
|
||||
}
|
||||
},
|
||||
),),
|
||||
)
|
||||
missing_context = ConfigurationPreflightContext(
|
||||
installed_modules={"forms": "0.1.0"},
|
||||
)
|
||||
|
||||
missing = dry_run_configuration_package(package, (provider,), missing_context)
|
||||
|
||||
self.assertEqual([], provider.preflight_payloads)
|
||||
self.assertIn(
|
||||
"fragment_data_reference_missing",
|
||||
{item.code for item in missing.diagnostics},
|
||||
)
|
||||
|
||||
ready_context = ConfigurationPreflightContext(
|
||||
installed_modules={"forms": "0.1.0"},
|
||||
supplied_data={"service_name": "Anwohnerparkausweis"},
|
||||
operator_user_id="operator-1",
|
||||
)
|
||||
ready = dry_run_configuration_package(package, (provider,), ready_context)
|
||||
applied = apply_configuration_package(package, (provider,), ready_context)
|
||||
exported = export_configuration_package(
|
||||
(provider,),
|
||||
ConfigurationExportSelection(
|
||||
tenant_id="tenant-1",
|
||||
module_ids=("forms",),
|
||||
),
|
||||
ready_context,
|
||||
)
|
||||
|
||||
self.assertFalse(any(item.severity == "blocker" for item in ready.diagnostics))
|
||||
self.assertEqual(
|
||||
"Anwohnerparkausweis",
|
||||
provider.preflight_payloads[-1]["definition"]["title"], # type: ignore[index]
|
||||
)
|
||||
self.assertIsNotNone(applied.rollback)
|
||||
assert applied.rollback is not None
|
||||
self.assertEqual("database_restore_required", applied.rollback.status)
|
||||
self.assertIsNotNone(exported.provenance)
|
||||
assert exported.provenance is not None
|
||||
self.assertEqual("operator-1", exported.provenance.exporter_id)
|
||||
self.assertEqual(
|
||||
("payment_credential_ref",),
|
||||
exported.provenance.redacted_secret_keys,
|
||||
)
|
||||
|
||||
def test_legacy_package_defaults_to_product_and_round_trips(self) -> None:
|
||||
package = ConfigurationPackageManifest.from_mapping(
|
||||
{"package_id": "example", "name": "Example", "version": "1.0.0"}
|
||||
|
||||
@@ -13,6 +13,7 @@ from govoplan_core.core.datasources import (
|
||||
DatasourceArtifactBackendProvider,
|
||||
DatasourceDescriptor,
|
||||
DatasourceField,
|
||||
DatasourceGovernance,
|
||||
DatasourceLifecycleProvider,
|
||||
DatasourceMaterialization,
|
||||
DatasourceOrigin,
|
||||
@@ -212,6 +213,55 @@ class DatasourceContractTests(unittest.TestCase):
|
||||
self.assertEqual("upload", descriptor.kind)
|
||||
self.assertEqual("tabular", descriptor.shape)
|
||||
|
||||
def test_lifecycle_governance_round_trips_without_provider_specific_types(self) -> None:
|
||||
governance = DatasourceGovernance.from_mapping(
|
||||
{
|
||||
"approval_policy": {
|
||||
"version": "approval-v2",
|
||||
"required": True,
|
||||
"required_approvals": 2,
|
||||
},
|
||||
"retention_policy": {
|
||||
"version": "retention-v3",
|
||||
"enabled": True,
|
||||
"stage_days": 30,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual("approval-v2", governance.approval_policy["version"])
|
||||
self.assertEqual(30, governance.retention_policy["stage_days"])
|
||||
self.assertEqual(
|
||||
governance.approval_policy,
|
||||
governance.to_dict()["approval_policy"],
|
||||
)
|
||||
self.assertEqual(
|
||||
governance.retention_policy,
|
||||
governance.to_dict()["retention_policy"],
|
||||
)
|
||||
|
||||
stage = DatasourceStage(
|
||||
ref="stage:governed",
|
||||
name="Governed stage",
|
||||
source_name="governed",
|
||||
kind="upload",
|
||||
mode="static",
|
||||
shape="tabular",
|
||||
state="awaiting_approval",
|
||||
approval={"status": "pending", "policy_version": "approval-v2"},
|
||||
)
|
||||
materialization = DatasourceMaterialization(
|
||||
ref="materialization:disposed",
|
||||
datasource_ref="datasource:governed",
|
||||
revision=1,
|
||||
state="disposed",
|
||||
fingerprint="abc123",
|
||||
disposition={"reason": "retention_policy", "policy_version": "retention-v3"},
|
||||
)
|
||||
|
||||
self.assertEqual("pending", stage.approval["status"])
|
||||
self.assertEqual("retention_policy", materialization.disposition["reason"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -10,7 +10,9 @@ from govoplan_core.core.modules import (
|
||||
DocumentationSourceDefinition,
|
||||
DocumentationTopic,
|
||||
ModuleManifest,
|
||||
localized_documentation_metadata,
|
||||
user_workflow_scope_condition_issues,
|
||||
with_documentation_structured_translations,
|
||||
)
|
||||
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
||||
|
||||
@@ -83,6 +85,97 @@ class DocumentationTopicContractTests(unittest.TestCase):
|
||||
self.assertEqual(user_workflow_scope_condition_issues(user_reference), ())
|
||||
registry_for(scoped, admin_workflow, user_reference).validate()
|
||||
|
||||
def test_versioned_structured_translation_preserves_metadata_shape(self) -> None:
|
||||
topic = DocumentationTopic(
|
||||
id="example.workflow.localized",
|
||||
title="Run task",
|
||||
summary="Run the task.",
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"steps": ["Review", "Execute"],
|
||||
"verification": "Confirm the result.",
|
||||
},
|
||||
structured_translation_version="1",
|
||||
structured_translations={
|
||||
"de": {
|
||||
"steps": ["Prüfen", "Ausführen"],
|
||||
"verification": "Das Ergebnis bestätigen.",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
registry_for(topic).validate()
|
||||
self.assertEqual(
|
||||
["Prüfen", "Ausführen"],
|
||||
localized_documentation_metadata(topic, "de")["steps"],
|
||||
)
|
||||
self.assertEqual(
|
||||
"workflow", localized_documentation_metadata(topic, "de")["kind"]
|
||||
)
|
||||
|
||||
def test_structured_translation_requires_version_and_complete_shape(self) -> None:
|
||||
missing_version = DocumentationTopic(
|
||||
id="example.localized.missing-version",
|
||||
title="Localized",
|
||||
summary="Invalid contract.",
|
||||
metadata={"limitations": ["One", "Two"]},
|
||||
structured_translations={"de": {"limitations": ["Eins", "Zwei"]}},
|
||||
)
|
||||
with self.assertRaisesRegex(
|
||||
RegistryError, "require structured_translation_version"
|
||||
):
|
||||
registry_for(missing_version).validate()
|
||||
|
||||
incomplete_shape = DocumentationTopic(
|
||||
id="example.localized.incomplete",
|
||||
title="Localized",
|
||||
summary="Invalid shape.",
|
||||
metadata={"limitations": ["One", "Two"]},
|
||||
structured_translation_version="1",
|
||||
structured_translations={"de": {"limitations": ["Eins"]}},
|
||||
)
|
||||
with self.assertRaisesRegex(RegistryError, "preserve list length"):
|
||||
registry_for(incomplete_shape).validate()
|
||||
|
||||
def test_manifest_helper_merges_and_validates_owner_translations(self) -> None:
|
||||
topic = DocumentationTopic(
|
||||
id="example.workflow.localized",
|
||||
title="Run task",
|
||||
summary="Run the task.",
|
||||
metadata={"steps": ["Review", "Execute"]},
|
||||
)
|
||||
manifest = ModuleManifest(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="1.0.0",
|
||||
documentation=(topic,),
|
||||
)
|
||||
|
||||
localized = with_documentation_structured_translations(
|
||||
manifest,
|
||||
locale="de",
|
||||
translations={
|
||||
topic.id: {"steps": ["Prüfen", "Ausführen"]},
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
["Prüfen", "Ausführen"],
|
||||
localized.documentation[0].structured_translations["de"]["steps"],
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "unknown topic ids"):
|
||||
with_documentation_structured_translations(
|
||||
manifest,
|
||||
locale="de",
|
||||
translations={"missing.topic": {"steps": ["Prüfen", "Ausführen"]}},
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "preserve list length"):
|
||||
with_documentation_structured_translations(
|
||||
manifest,
|
||||
locale="de",
|
||||
translations={topic.id: {"steps": ["Prüfen"]}},
|
||||
)
|
||||
|
||||
def test_documentation_configuration_and_source_extensions_are_validated(self) -> None:
|
||||
resolver = lambda _context, keys: { # noqa: E731
|
||||
key: DocumentationConfigurationDecision(key=key, state="enabled")
|
||||
|
||||
@@ -2,9 +2,14 @@ from __future__ import annotations
|
||||
|
||||
import io
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from govoplan_core.security.http_fetch import _PolicyRedirectHandler, is_http_url, validate_http_url
|
||||
from govoplan_core.security.http_fetch import (
|
||||
_PolicyRedirectHandler,
|
||||
fetch_http,
|
||||
is_http_url,
|
||||
validate_http_url,
|
||||
)
|
||||
from govoplan_core.security.outbound_http import (
|
||||
DEFAULT_FILE_TRANSFER_BYTES,
|
||||
DEFAULT_STRUCTURED_RESPONSE_BYTES,
|
||||
@@ -21,6 +26,51 @@ from govoplan_core.security.outbound_http import (
|
||||
|
||||
|
||||
class HttpFetchTests(unittest.TestCase):
|
||||
def test_fetch_http_forwards_a_bounded_request_body(self) -> None:
|
||||
class Response(io.BytesIO):
|
||||
status = 200
|
||||
headers = {"Content-Type": "application/json"}
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
opener = Mock()
|
||||
opener.open.return_value = Response(b"{}")
|
||||
with patch(
|
||||
"govoplan_core.security.http_fetch.validate_outbound_http_url",
|
||||
return_value="https://wiki.example.test/api.php",
|
||||
), patch(
|
||||
"govoplan_core.security.http_fetch.build_outbound_http_opener",
|
||||
return_value=opener,
|
||||
):
|
||||
response = fetch_http(
|
||||
"https://wiki.example.test/api.php",
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
body=b"action=edit",
|
||||
max_bytes=1024,
|
||||
)
|
||||
|
||||
request = opener.open.call_args.args[0]
|
||||
self.assertEqual("POST", request.get_method())
|
||||
self.assertEqual(b"action=edit", request.data)
|
||||
self.assertEqual(b"{}", response.body)
|
||||
|
||||
def test_fetch_http_rejects_an_oversized_request_body_before_transport(self) -> None:
|
||||
with patch(
|
||||
"govoplan_core.security.http_fetch.validate_outbound_http_url"
|
||||
) as validate:
|
||||
with self.assertRaisesRegex(ValueError, "request body exceeds"):
|
||||
fetch_http(
|
||||
"https://wiki.example.test/api.php",
|
||||
method="POST",
|
||||
body=b"x" * 1_000_001,
|
||||
)
|
||||
validate.assert_not_called()
|
||||
|
||||
def test_validate_http_url_accepts_absolute_http_urls_without_credentials(self) -> None:
|
||||
self.assertEqual("https://example.test/catalog.json", validate_http_url("https://example.test/catalog.json"))
|
||||
self.assertTrue(is_http_url("http://example.test/catalog.json"))
|
||||
@@ -189,9 +239,17 @@ class HttpFetchTests(unittest.TestCase):
|
||||
|
||||
request = urllib.request.Request(
|
||||
"https://catalog.example.test/releases",
|
||||
headers={"Authorization": "Bearer secret", "X-Request-ID": "request-1"},
|
||||
headers={
|
||||
"Authorization": "Bearer secret",
|
||||
"Cookie": "session=secret",
|
||||
"X-OTRS-Header-Password": "secret",
|
||||
"X-Request-ID": "request-1",
|
||||
},
|
||||
)
|
||||
handler = _PolicyRedirectHandler(
|
||||
label="Catalog URL",
|
||||
sensitive_headers=("X-OTRS-Header-Password",),
|
||||
)
|
||||
handler = _PolicyRedirectHandler(label="Catalog URL")
|
||||
with patch.dict("os.environ", {"APP_ENV": "test"}), patch(
|
||||
"govoplan_core.security.outbound_http.socket.getaddrinfo",
|
||||
return_value=[(2, 1, 6, "", ("127.0.0.1", 443))],
|
||||
@@ -215,9 +273,38 @@ class HttpFetchTests(unittest.TestCase):
|
||||
|
||||
self.assertIsNotNone(redirected)
|
||||
self.assertIsNone(redirected.get_header("Authorization"))
|
||||
self.assertIsNone(redirected.get_header("Cookie"))
|
||||
self.assertIsNone(redirected.get_header("X-otrs-header-password"))
|
||||
self.assertEqual("request-1", redirected.get_header("X-request-id"))
|
||||
self.assertIsNone(downgrade)
|
||||
|
||||
def test_core_redirects_preserve_caller_sensitive_headers_on_the_same_origin(self) -> None:
|
||||
import urllib.request
|
||||
|
||||
request = urllib.request.Request(
|
||||
"https://desk.example.test/original",
|
||||
headers={"X-OTRS-Header-SessionID": "secret"},
|
||||
)
|
||||
handler = _PolicyRedirectHandler(
|
||||
label="Service-desk URL",
|
||||
sensitive_headers=("X-OTRS-Header-SessionID",),
|
||||
)
|
||||
with patch.dict("os.environ", {"APP_ENV": "test"}), patch(
|
||||
"govoplan_core.security.outbound_http.socket.getaddrinfo",
|
||||
return_value=[(2, 1, 6, "", ("127.0.0.1", 443))],
|
||||
):
|
||||
redirected = handler.redirect_request(
|
||||
request,
|
||||
None,
|
||||
302,
|
||||
"Found",
|
||||
{},
|
||||
"https://desk.example.test/final",
|
||||
)
|
||||
|
||||
self.assertIsNotNone(redirected)
|
||||
self.assertEqual("secret", redirected.get_header("X-otrs-header-sessionid"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -296,6 +296,9 @@ class ModuleSystemTests(unittest.TestCase):
|
||||
"approvals",
|
||||
"reporting",
|
||||
"search",
|
||||
"organizations",
|
||||
"idm",
|
||||
"tasks",
|
||||
),
|
||||
)
|
||||
self.assertEqual(manifests["dashboard"].dependencies, ())
|
||||
|
||||
@@ -10,6 +10,8 @@ from govoplan_core.core.configuration_safety import (
|
||||
ui_managed_configuration_fields_requiring_approval,
|
||||
)
|
||||
from govoplan_core.core.policy import (
|
||||
FunctionAssignmentEscalationRule,
|
||||
FunctionAssignmentGovernanceDecision,
|
||||
PolicyDecision,
|
||||
PolicySourceStep,
|
||||
parse_policy_source_path,
|
||||
@@ -19,6 +21,34 @@ from govoplan_core.core.policy import (
|
||||
|
||||
|
||||
class PolicyContractTests(unittest.TestCase):
|
||||
def test_function_assignment_policy_serializes_delegation_and_escalation(self) -> None:
|
||||
decision = FunctionAssignmentGovernanceDecision(
|
||||
allowed=True,
|
||||
delegation_allowed=True,
|
||||
maximum_delegation_depth=2,
|
||||
maximum_delegated_validity_days=30,
|
||||
escalation_rules=(
|
||||
FunctionAssignmentEscalationRule(
|
||||
step="authority",
|
||||
target_function_id="function-escalation",
|
||||
timeout_hours=48,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
payload = decision.to_dict()
|
||||
|
||||
self.assertEqual(2, payload["maximum_delegation_depth"])
|
||||
self.assertEqual(30, payload["maximum_delegated_validity_days"])
|
||||
self.assertEqual(
|
||||
"function-escalation",
|
||||
payload["escalation_rules"][0]["target_function_id"],
|
||||
)
|
||||
self.assertEqual(
|
||||
"function-escalation",
|
||||
decision.escalation_rule("authority").target_function_id,
|
||||
)
|
||||
|
||||
def test_policy_source_paths_are_stable_and_round_trip(self) -> None:
|
||||
self.assertEqual(policy_source_path("system"), "system")
|
||||
self.assertEqual(policy_source_path("tenant", "tenant-1"), "tenant:tenant-1")
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.tickets import (
|
||||
CAPABILITY_TICKET_CASE_ESCALATION,
|
||||
CAPABILITY_TICKET_ROUTING,
|
||||
TicketCaseEscalationCommand,
|
||||
TicketCaseEscalationResult,
|
||||
TicketRoutingPlan,
|
||||
TicketRoutingRequest,
|
||||
ticket_case_escalation_provider,
|
||||
ticket_routing_provider,
|
||||
)
|
||||
|
||||
|
||||
class _Provider:
|
||||
def route_ticket(self, session, principal, *, request):
|
||||
del session, principal, request
|
||||
return TicketRoutingPlan(provider_id="helpdesk", queue_ref="citizen-service")
|
||||
|
||||
def escalate_ticket(self, session, principal, *, command):
|
||||
del session, principal, command
|
||||
return TicketCaseEscalationResult(
|
||||
provider_id="cases",
|
||||
case_id="case-1",
|
||||
case_number="CASE-1",
|
||||
case_url="/cases/case-1",
|
||||
)
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, capabilities):
|
||||
self.capabilities = capabilities
|
||||
|
||||
def has_capability(self, name):
|
||||
return name in self.capabilities
|
||||
|
||||
def capability(self, name):
|
||||
return self.capabilities[name]
|
||||
|
||||
|
||||
class TicketContractTests(unittest.TestCase):
|
||||
def test_optional_providers_fail_open_when_absent(self) -> None:
|
||||
registry = _Registry({})
|
||||
self.assertIsNone(ticket_routing_provider(registry))
|
||||
self.assertIsNone(ticket_case_escalation_provider(registry))
|
||||
|
||||
def test_optional_providers_resolve_structurally(self) -> None:
|
||||
provider = _Provider()
|
||||
registry = _Registry(
|
||||
{
|
||||
CAPABILITY_TICKET_ROUTING: provider,
|
||||
CAPABILITY_TICKET_CASE_ESCALATION: provider,
|
||||
}
|
||||
)
|
||||
self.assertIs(provider, ticket_routing_provider(registry))
|
||||
self.assertIs(provider, ticket_case_escalation_provider(registry))
|
||||
|
||||
def test_commands_validate_tenant_time_and_relative_case_link(self) -> None:
|
||||
instant = datetime(2026, 8, 22, 9, 0, tzinfo=UTC)
|
||||
request = TicketRoutingRequest(
|
||||
tenant_id="tenant-1",
|
||||
ticket_id="ticket-1",
|
||||
ticket_type="request",
|
||||
priority="normal",
|
||||
title="Broken streetlight",
|
||||
received_at=instant,
|
||||
)
|
||||
self.assertEqual("ticket-1", request.ticket_id)
|
||||
|
||||
command = TicketCaseEscalationCommand(
|
||||
tenant_id="tenant-1",
|
||||
ticket_id="ticket-1",
|
||||
ticket_number="TKT-1",
|
||||
title="Broken streetlight",
|
||||
case_type_key="service-request",
|
||||
occurred_at=instant,
|
||||
idempotency_key="escalation-1",
|
||||
)
|
||||
self.assertEqual("service-request", command.case_type_key)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
TicketCaseEscalationResult(
|
||||
provider_id="cases",
|
||||
case_id="case-1",
|
||||
case_number="CASE-1",
|
||||
case_url="https://other.example/cases/1",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Generated
+89
-25
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.26",
|
||||
"version": "0.1.39",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.26",
|
||||
"version": "0.1.39",
|
||||
"dependencies": {
|
||||
"@govoplan/access-webui": "file:../../govoplan-access/webui",
|
||||
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
|
||||
@@ -27,6 +27,7 @@
|
||||
"@govoplan/files-webui": "file:../../govoplan-files/webui",
|
||||
"@govoplan/forms-runtime-webui": "file:../../govoplan-forms-runtime/webui",
|
||||
"@govoplan/forms-webui": "file:../../govoplan-forms/webui",
|
||||
"@govoplan/helpdesk-webui": "file:../../govoplan-helpdesk/webui",
|
||||
"@govoplan/identity-trust-webui": "file:../../govoplan-identity-trust/webui",
|
||||
"@govoplan/identity-webui": "file:../../govoplan-identity/webui",
|
||||
"@govoplan/idm-webui": "file:../../govoplan-idm/webui",
|
||||
@@ -48,8 +49,10 @@
|
||||
"@govoplan/tasks-webui": "file:../../govoplan-tasks/webui",
|
||||
"@govoplan/templates-webui": "file:../../govoplan-templates/webui",
|
||||
"@govoplan/tenancy-webui": "file:../../govoplan-tenancy/webui",
|
||||
"@govoplan/tickets-webui": "file:../../govoplan-tickets/webui",
|
||||
"@govoplan/views-webui": "file:../../govoplan-views/webui",
|
||||
"@govoplan/voting-webui": "file:../../govoplan-voting/webui",
|
||||
"@govoplan/wiki-webui": "file:../../govoplan-wiki/webui",
|
||||
"@govoplan/workflow-webui": "file:../../govoplan-workflow/webui",
|
||||
"@tiptap/core": "^3.29.2",
|
||||
"@tiptap/extension-image": "^3.29.2",
|
||||
@@ -82,7 +85,7 @@
|
||||
},
|
||||
"../../govoplan-access/webui": {
|
||||
"name": "@govoplan/access-webui",
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.20",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
@@ -117,12 +120,12 @@
|
||||
},
|
||||
"../../govoplan-admin/webui": {
|
||||
"name": "@govoplan/admin-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.19",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"@govoplan/core-webui": "^0.1.35",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -151,7 +154,7 @@
|
||||
},
|
||||
"../../govoplan-audit/webui": {
|
||||
"name": "@govoplan/audit-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.19",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"lucide-react": "^1.23.0",
|
||||
@@ -186,7 +189,7 @@
|
||||
},
|
||||
"../../govoplan-campaign/webui": {
|
||||
"name": "@govoplan/campaign-webui",
|
||||
"version": "0.1.22",
|
||||
"version": "0.1.24",
|
||||
"dependencies": {
|
||||
"read-excel-file": "9.2.0"
|
||||
},
|
||||
@@ -208,9 +211,9 @@
|
||||
},
|
||||
"../../govoplan-cases/webui": {
|
||||
"name": "@govoplan/cases-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.20",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"@govoplan/core-webui": "^0.1.30",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -240,7 +243,7 @@
|
||||
},
|
||||
"../../govoplan-connectors/webui": {
|
||||
"name": "@govoplan/connectors-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.22",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"react": ">=19.2.7 <20",
|
||||
@@ -270,7 +273,7 @@
|
||||
},
|
||||
"../../govoplan-dataflow/webui": {
|
||||
"name": "@govoplan/dataflow-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.20",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"@xyflow/react": "^12.11.2",
|
||||
@@ -322,7 +325,7 @@
|
||||
},
|
||||
"../../govoplan-docs/webui": {
|
||||
"name": "@govoplan/docs-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.20",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
@@ -391,12 +394,29 @@
|
||||
},
|
||||
"../../govoplan-forms/webui": {
|
||||
"name": "@govoplan/forms-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.20",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20"
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
"react-router": ">=8.3.0 <9"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"../../govoplan-helpdesk/webui": {
|
||||
"name": "@govoplan/helpdesk-webui",
|
||||
"version": "0.1.20",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.30",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
"react-router": ">=8.3.0 <9"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
@@ -436,7 +456,7 @@
|
||||
},
|
||||
"../../govoplan-idm/webui": {
|
||||
"name": "@govoplan/idm-webui",
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.20",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
@@ -455,7 +475,7 @@
|
||||
},
|
||||
"../../govoplan-mail/webui": {
|
||||
"name": "@govoplan/mail-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.22",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
@@ -493,7 +513,7 @@
|
||||
},
|
||||
"../../govoplan-ops/webui": {
|
||||
"name": "@govoplan/ops-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.19",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
@@ -547,7 +567,7 @@
|
||||
},
|
||||
"../../govoplan-policy/webui": {
|
||||
"name": "@govoplan/policy-webui",
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.20",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"lucide-react": "^1.23.0",
|
||||
@@ -563,7 +583,7 @@
|
||||
},
|
||||
"../../govoplan-portal/webui": {
|
||||
"name": "@govoplan/portal-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.19",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"lucide-react": "^1.23.0",
|
||||
@@ -579,7 +599,7 @@
|
||||
},
|
||||
"../../govoplan-postbox/webui": {
|
||||
"name": "@govoplan/postbox-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.19",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"lucide-react": "^1.23.0",
|
||||
@@ -611,7 +631,7 @@
|
||||
},
|
||||
"../../govoplan-quick-access/webui": {
|
||||
"name": "@govoplan/quick-access-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.19",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"lucide-react": "^1.23.0",
|
||||
@@ -675,7 +695,7 @@
|
||||
},
|
||||
"../../govoplan-scheduling/webui": {
|
||||
"name": "@govoplan/scheduling-webui",
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.18",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
@@ -710,7 +730,7 @@
|
||||
},
|
||||
"../../govoplan-tasks/webui": {
|
||||
"name": "@govoplan/tasks-webui",
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.20",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"lucide-react": "^1.23.0",
|
||||
@@ -756,9 +776,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"../../govoplan-tickets/webui": {
|
||||
"name": "@govoplan/tickets-webui",
|
||||
"version": "0.1.20",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.30",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
"react-router": ">=8.3.0 <9"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"../../govoplan-views/webui": {
|
||||
"name": "@govoplan/views-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.19",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"lucide-react": "^1.23.0",
|
||||
@@ -787,9 +823,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"../../govoplan-wiki/webui": {
|
||||
"name": "@govoplan/wiki-webui",
|
||||
"version": "0.1.20",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.31",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
"react-router": ">=8.3.0 <9"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"../../govoplan-workflow/webui": {
|
||||
"name": "@govoplan/workflow-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.21",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"@xyflow/react": "^12.11.2",
|
||||
@@ -1633,6 +1685,10 @@
|
||||
"resolved": "../../govoplan-forms/webui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@govoplan/helpdesk-webui": {
|
||||
"resolved": "../../govoplan-helpdesk/webui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@govoplan/identity-trust-webui": {
|
||||
"resolved": "../../govoplan-identity-trust/webui",
|
||||
"link": true
|
||||
@@ -1717,6 +1773,10 @@
|
||||
"resolved": "../../govoplan-tenancy/webui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@govoplan/tickets-webui": {
|
||||
"resolved": "../../govoplan-tickets/webui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@govoplan/views-webui": {
|
||||
"resolved": "../../govoplan-views/webui",
|
||||
"link": true
|
||||
@@ -1725,6 +1785,10 @@
|
||||
"resolved": "../../govoplan-voting/webui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@govoplan/wiki-webui": {
|
||||
"resolved": "../../govoplan-wiki/webui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@govoplan/workflow-webui": {
|
||||
"resolved": "../../govoplan-workflow/webui",
|
||||
"link": true
|
||||
|
||||
+499
-431
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.26",
|
||||
"version": "0.1.39",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -77,6 +77,7 @@
|
||||
"@govoplan/files-webui": "file:../../govoplan-files/webui",
|
||||
"@govoplan/forms-runtime-webui": "file:../../govoplan-forms-runtime/webui",
|
||||
"@govoplan/forms-webui": "file:../../govoplan-forms/webui",
|
||||
"@govoplan/helpdesk-webui": "file:../../govoplan-helpdesk/webui",
|
||||
"@govoplan/identity-trust-webui": "file:../../govoplan-identity-trust/webui",
|
||||
"@govoplan/identity-webui": "file:../../govoplan-identity/webui",
|
||||
"@govoplan/idm-webui": "file:../../govoplan-idm/webui",
|
||||
@@ -98,8 +99,10 @@
|
||||
"@govoplan/tasks-webui": "file:../../govoplan-tasks/webui",
|
||||
"@govoplan/templates-webui": "file:../../govoplan-templates/webui",
|
||||
"@govoplan/tenancy-webui": "file:../../govoplan-tenancy/webui",
|
||||
"@govoplan/tickets-webui": "file:../../govoplan-tickets/webui",
|
||||
"@govoplan/views-webui": "file:../../govoplan-views/webui",
|
||||
"@govoplan/voting-webui": "file:../../govoplan-voting/webui",
|
||||
"@govoplan/wiki-webui": "file:../../govoplan-wiki/webui",
|
||||
"@govoplan/workflow-webui": "file:../../govoplan-workflow/webui",
|
||||
"@tiptap/core": "^3.29.2",
|
||||
"@tiptap/extension-image": "^3.29.2",
|
||||
|
||||
+14
-10
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.26",
|
||||
"version": "0.1.39",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -26,19 +26,23 @@
|
||||
"preview": "vite preview --host 127.0.0.1 --port 4173"
|
||||
},
|
||||
"dependencies": {
|
||||
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.19",
|
||||
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.18",
|
||||
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.23",
|
||||
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.22",
|
||||
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git#v0.1.18",
|
||||
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#v0.1.18",
|
||||
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#v0.1.22",
|
||||
"@govoplan/cases-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-cases.git#v0.1.20",
|
||||
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.18",
|
||||
"@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git#v0.1.18",
|
||||
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.20",
|
||||
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#v0.1.19",
|
||||
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.18",
|
||||
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#v0.1.22",
|
||||
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git#v0.1.18",
|
||||
"@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git#v0.1.22",
|
||||
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.23",
|
||||
"@govoplan/helpdesk-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-helpdesk.git#v0.1.20",
|
||||
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#v0.1.24",
|
||||
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.24",
|
||||
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#v0.1.27",
|
||||
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git#v0.1.20",
|
||||
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git#v0.1.18",
|
||||
"@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git#v0.1.18",
|
||||
"@govoplan/tickets-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-tickets.git#v0.1.22",
|
||||
"@govoplan/wiki-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-wiki.git#v0.1.22",
|
||||
"@tiptap/core": "^3.29.2",
|
||||
"@tiptap/extension-image": "^3.29.2",
|
||||
"@tiptap/pm": "^3.29.2",
|
||||
|
||||
@@ -22,6 +22,7 @@ const packageByModule = {
|
||||
files: "@govoplan/files-webui",
|
||||
forms: "@govoplan/forms-webui",
|
||||
forms_runtime: "@govoplan/forms-runtime-webui",
|
||||
helpdesk: "@govoplan/helpdesk-webui",
|
||||
idm: "@govoplan/idm-webui",
|
||||
identity: "@govoplan/identity-webui",
|
||||
mail: "@govoplan/mail-webui",
|
||||
@@ -42,8 +43,10 @@ const packageByModule = {
|
||||
tasks: "@govoplan/tasks-webui",
|
||||
tenancy: "@govoplan/tenancy-webui",
|
||||
templates: "@govoplan/templates-webui",
|
||||
tickets: "@govoplan/tickets-webui",
|
||||
views: "@govoplan/views-webui",
|
||||
voting: "@govoplan/voting-webui",
|
||||
wiki: "@govoplan/wiki-webui",
|
||||
workflow: "@govoplan/workflow-webui"
|
||||
};
|
||||
|
||||
@@ -75,6 +78,10 @@ const cases = [
|
||||
{ name: "files-only", modules: ["files"] },
|
||||
{ name: "forms-only", modules: ["forms"] },
|
||||
{ name: "forms-runtime", modules: ["forms", "forms_runtime"] },
|
||||
{ name: "tickets-only", modules: ["tickets"] },
|
||||
{ name: "tickets-with-helpdesk-and-cases", modules: ["tickets", "helpdesk", "cases"] },
|
||||
{ name: "wiki-only", modules: ["wiki"] },
|
||||
{ name: "wiki-with-files-search", modules: ["wiki", "files", "search"] },
|
||||
{ name: "mail-only", modules: ["mail"] },
|
||||
{ name: "notifications-only", modules: ["notifications"] },
|
||||
{ name: "organizations-only", modules: ["organizations"] },
|
||||
@@ -106,7 +113,7 @@ const cases = [
|
||||
{ name: "tasks-only", modules: ["access", "tasks"] },
|
||||
{ name: "tasks-with-contributors", modules: ["access", "approvals", "postbox", "workflow", "dashboard", "tasks"] },
|
||||
{ name: "voting-only", modules: ["access", "voting"] },
|
||||
{ name: "full-product", modules: ["access", "tenancy", "admin", "addresses", "approvals", "policy", "audit", "dashboard", "datasources", "dataflow", "dist_lists", "templates", "workflow", "views", "organizations", "idm", "identity", "identity_trust", "encryption", "cases", "committee", "connectors", "campaigns", "files", "forms", "forms_runtime", "mail", "notifications", "docs", "ops", "payments", "calendar", "scheduling", "portal", "postbox", "projects", "quick_access", "reporting", "records", "risk_compliance", "search", "tasks", "voting"] }
|
||||
{ name: "full-product", modules: ["access", "tenancy", "admin", "addresses", "approvals", "policy", "audit", "dashboard", "datasources", "dataflow", "dist_lists", "templates", "workflow", "views", "organizations", "idm", "identity", "identity_trust", "encryption", "cases", "committee", "connectors", "campaigns", "files", "forms", "forms_runtime", "helpdesk", "mail", "notifications", "docs", "ops", "payments", "calendar", "scheduling", "portal", "postbox", "projects", "quick_access", "reporting", "records", "risk_compliance", "search", "tasks", "tickets", "voting", "wiki"] }
|
||||
];
|
||||
|
||||
const npmExec = process.env.npm_execpath;
|
||||
|
||||
@@ -35,7 +35,7 @@ export type MailProfilePatternRules = Partial<Record<MailProfilePatternKey, stri
|
||||
|
||||
export type MailConnectionTestResponse = {
|
||||
ok: boolean;
|
||||
protocol: "smtp" | "imap";
|
||||
protocol: "smtp" | "imap" | "jmap" | "pop3";
|
||||
host?: string | null;
|
||||
port?: number | null;
|
||||
security?: MailSecurity | string | null;
|
||||
@@ -52,7 +52,7 @@ export type MailImapFolderResponse = {
|
||||
|
||||
export type MailImapFolderListResponse = {
|
||||
ok: boolean;
|
||||
protocol: "imap";
|
||||
protocol: "imap" | "jmap";
|
||||
host?: string | null;
|
||||
port?: number | null;
|
||||
security?: MailSecurity | string | null;
|
||||
@@ -69,6 +69,7 @@ export type MailImapFolderListResponse = {
|
||||
export const mailProfilePatternKeys = [
|
||||
"smtp_hosts",
|
||||
"imap_hosts",
|
||||
"jmap_hosts",
|
||||
"envelope_senders",
|
||||
"from_headers",
|
||||
"recipient_domains"
|
||||
@@ -82,11 +83,13 @@ export const mailProfilePolicyLimitKeys = [
|
||||
"imap_credentials.inherit",
|
||||
"whitelist.smtp_hosts",
|
||||
"whitelist.imap_hosts",
|
||||
"whitelist.jmap_hosts",
|
||||
"whitelist.envelope_senders",
|
||||
"whitelist.from_headers",
|
||||
"whitelist.recipient_domains",
|
||||
"blacklist.smtp_hosts",
|
||||
"blacklist.imap_hosts",
|
||||
"blacklist.jmap_hosts",
|
||||
"blacklist.envelope_senders",
|
||||
"blacklist.from_headers",
|
||||
"blacklist.recipient_domains"
|
||||
|
||||
@@ -3,8 +3,9 @@ import { Dice5, Eye, EyeOff } from "lucide-react";
|
||||
import PasswordGeneratorDialog from "./PasswordGeneratorDialog";
|
||||
import type { PasswordGeneratorOptions } from "./passwordGenerator";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
import type { PlatformInterfaceIdentityProps } from "../types";
|
||||
|
||||
export type PasswordFieldProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange"> & {
|
||||
export type PasswordFieldProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange"> & PlatformInterfaceIdentityProps & {
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
saved?: boolean;
|
||||
@@ -32,6 +33,10 @@ export default function PasswordField({
|
||||
className = "",
|
||||
inputClassName = "",
|
||||
id,
|
||||
interfaceId,
|
||||
helpContextId,
|
||||
helpModuleId,
|
||||
helpTopicId,
|
||||
...inputProps
|
||||
}: PasswordFieldProps) {
|
||||
const generatedId = useId();
|
||||
@@ -50,10 +55,20 @@ export default function PasswordField({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`password-field ${canReveal || canGenerate ? "has-actions" : ""} ${canGenerate ? "has-generator" : ""} ${canReveal ? "has-reveal" : ""} ${showSavedPlaceholder ? "is-saved-empty" : ""} ${className}`.trim()}>
|
||||
<div
|
||||
className={`password-field ${canReveal || canGenerate ? "has-actions" : ""} ${canGenerate ? "has-generator" : ""} ${canReveal ? "has-reveal" : ""} ${showSavedPlaceholder ? "is-saved-empty" : ""} ${className}`.trim()}
|
||||
data-help-scope="field"
|
||||
data-interface-id={interfaceId}
|
||||
data-help-context-id={helpContextId}
|
||||
data-help-module-id={helpModuleId}
|
||||
data-help-topic-id={helpTopicId}
|
||||
>
|
||||
<input
|
||||
{...inputProps}
|
||||
id={inputId}
|
||||
data-help-context-id={helpContextId}
|
||||
data-help-module-id={helpModuleId}
|
||||
data-help-topic-id={helpTopicId}
|
||||
className={inputClassName}
|
||||
type={inputType}
|
||||
value={value}
|
||||
@@ -72,6 +87,9 @@ export default function PasswordField({
|
||||
className="password-field-action"
|
||||
aria-label={translatedGeneratorLabel}
|
||||
title={translatedGeneratorLabel}
|
||||
data-help-context-id={helpContextId}
|
||||
data-help-module-id={helpModuleId}
|
||||
data-help-topic-id={helpTopicId}
|
||||
onClick={() => setGeneratorOpen(true)}
|
||||
>
|
||||
<Dice5 size={17} aria-hidden="true" />
|
||||
@@ -83,6 +101,9 @@ export default function PasswordField({
|
||||
className="password-field-action"
|
||||
aria-label={visible ? translatedHideLabel : translatedRevealLabel}
|
||||
title={visible ? translatedHideLabel : translatedRevealLabel}
|
||||
data-help-context-id={helpContextId}
|
||||
data-help-module-id={helpModuleId}
|
||||
data-help-topic-id={helpTopicId}
|
||||
onClick={() => setVisible((current) => !current)}
|
||||
>
|
||||
{visible ? <EyeOff size={17} aria-hidden="true" /> : <Eye size={17} aria-hidden="true" />}
|
||||
@@ -95,6 +116,9 @@ export default function PasswordField({
|
||||
<PasswordGeneratorDialog
|
||||
open={generatorOpen}
|
||||
initialOptions={generatorOptions}
|
||||
helpContextId={helpContextId}
|
||||
helpModuleId={helpModuleId}
|
||||
helpTopicId={helpTopicId}
|
||||
onUse={(password) => {
|
||||
onValueChange(password);
|
||||
setVisible(false);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type PasswordGeneratorOptions
|
||||
} from "./passwordGenerator";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
import type { PlatformInterfaceIdentityProps } from "../types";
|
||||
|
||||
const GENERATION_ERROR_LABELS: Record<PasswordGeneratorErrorCode, string> = {
|
||||
"invalid-length": "i18n:govoplan-core.password_length_must_be_between_12_and_128_character.4d147c07",
|
||||
@@ -23,7 +24,7 @@ const GENERATION_ERROR_LABELS: Record<PasswordGeneratorErrorCode, string> = {
|
||||
"secure-random-unavailable": "i18n:govoplan-core.secure_browser_password_generation_is_unavailable.55275f10"
|
||||
};
|
||||
|
||||
export type PasswordGeneratorDialogProps = {
|
||||
export type PasswordGeneratorDialogProps = PlatformInterfaceIdentityProps & {
|
||||
open: boolean;
|
||||
initialOptions?: Partial<PasswordGeneratorOptions>;
|
||||
onUse: (password: string) => void;
|
||||
@@ -33,6 +34,9 @@ export type PasswordGeneratorDialogProps = {
|
||||
export default function PasswordGeneratorDialog({
|
||||
open,
|
||||
initialOptions,
|
||||
helpContextId,
|
||||
helpModuleId,
|
||||
helpTopicId,
|
||||
onUse,
|
||||
onClose
|
||||
}: PasswordGeneratorDialogProps) {
|
||||
@@ -95,6 +99,9 @@ export default function PasswordGeneratorDialog({
|
||||
bodyClassName="password-generator-body"
|
||||
footerClassName="button-row compact-actions"
|
||||
portal
|
||||
helpContextId={helpContextId}
|
||||
helpModuleId={helpModuleId}
|
||||
helpTopicId={helpTopicId}
|
||||
onClose={onClose}
|
||||
footer={(
|
||||
<>
|
||||
@@ -103,6 +110,9 @@ export default function PasswordGeneratorDialog({
|
||||
type="button"
|
||||
variant="primary"
|
||||
disabled={!candidate}
|
||||
helpContextId={helpContextId}
|
||||
helpModuleId={helpModuleId}
|
||||
helpTopicId={helpTopicId}
|
||||
onClick={() => {
|
||||
if (!candidate) return;
|
||||
onUse(candidate);
|
||||
@@ -116,7 +126,7 @@ export default function PasswordGeneratorDialog({
|
||||
>
|
||||
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
|
||||
<div className="password-generator-options">
|
||||
<FormField label="i18n:govoplan-core.length.adc95605">
|
||||
<FormField label="i18n:govoplan-core.length.adc95605" helpContextId={helpContextId} helpModuleId={helpModuleId} helpTopicId={helpTopicId}>
|
||||
<input
|
||||
type="number"
|
||||
min={12}
|
||||
@@ -127,13 +137,13 @@ export default function PasswordGeneratorDialog({
|
||||
/>
|
||||
</FormField>
|
||||
<div className="password-generator-character-sets" aria-label={translateText("i18n:govoplan-core.character_sets.db6efda2")}>
|
||||
<ToggleSwitch label="i18n:govoplan-core.lowercase.3b677a18" checked={options.lowercase} onChange={(checked) => setOption("lowercase", checked)} />
|
||||
<ToggleSwitch label="i18n:govoplan-core.uppercase.b463d690" checked={options.uppercase} onChange={(checked) => setOption("uppercase", checked)} />
|
||||
<ToggleSwitch label="i18n:govoplan-core.digits.9cd500d3" checked={options.digits} onChange={(checked) => setOption("digits", checked)} />
|
||||
<ToggleSwitch label="i18n:govoplan-core.symbols.9491fc41" checked={options.symbols} onChange={(checked) => setOption("symbols", checked)} />
|
||||
<ToggleSwitch label="i18n:govoplan-core.lowercase.3b677a18" checked={options.lowercase} onChange={(checked) => setOption("lowercase", checked)} helpContextId={helpContextId} helpModuleId={helpModuleId} helpTopicId={helpTopicId} />
|
||||
<ToggleSwitch label="i18n:govoplan-core.uppercase.b463d690" checked={options.uppercase} onChange={(checked) => setOption("uppercase", checked)} helpContextId={helpContextId} helpModuleId={helpModuleId} helpTopicId={helpTopicId} />
|
||||
<ToggleSwitch label="i18n:govoplan-core.digits.9cd500d3" checked={options.digits} onChange={(checked) => setOption("digits", checked)} helpContextId={helpContextId} helpModuleId={helpModuleId} helpTopicId={helpTopicId} />
|
||||
<ToggleSwitch label="i18n:govoplan-core.symbols.9491fc41" checked={options.symbols} onChange={(checked) => setOption("symbols", checked)} helpContextId={helpContextId} helpModuleId={helpModuleId} helpTopicId={helpTopicId} />
|
||||
</div>
|
||||
</div>
|
||||
<FormField label="i18n:govoplan-core.generated_password.78461854">
|
||||
<FormField label="i18n:govoplan-core.generated_password.78461854" helpContextId={helpContextId} helpModuleId={helpModuleId} helpTopicId={helpTopicId}>
|
||||
<div className="password-generator-result">
|
||||
<input
|
||||
type="text"
|
||||
@@ -147,11 +157,17 @@ export default function PasswordGeneratorDialog({
|
||||
icon={<Copy size={16} />}
|
||||
onClick={() => void copy()}
|
||||
disabled={!candidate || typeof navigator === "undefined" || !navigator.clipboard?.writeText}
|
||||
helpContextId={helpContextId}
|
||||
helpModuleId={helpModuleId}
|
||||
helpTopicId={helpTopicId}
|
||||
/>
|
||||
<IconButton
|
||||
label="i18n:govoplan-core.generate_another_password.d99fc019"
|
||||
icon={<RefreshCw size={16} />}
|
||||
onClick={generate}
|
||||
helpContextId={helpContextId}
|
||||
helpModuleId={helpModuleId}
|
||||
helpTopicId={helpTopicId}
|
||||
/>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
@@ -57,13 +57,13 @@ export default function LoginModal({
|
||||
<FormLayout columns={1} collapseAt="standard" id={formId} className="" onSubmit={submit}>
|
||||
{message && <DismissibleAlert tone="info" dismissible={false}>{message}</DismissibleAlert>}
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
<FormField label="i18n:govoplan-core.email.84add5b2">
|
||||
<input type="email" value={email} autoComplete="username" onChange={(e) => setEmail(e.target.value)} />
|
||||
<FormField label="i18n:govoplan-core.email.84add5b2" helpContextId="access.authentication.email" helpModuleId="access">
|
||||
<input data-help-context-id="access.authentication.email" data-help-module-id="access" type="email" value={email} autoComplete="username" onChange={(e) => setEmail(e.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="i18n:govoplan-core.password.8be3c943">
|
||||
<PasswordField value={password} autoComplete="current-password" onValueChange={setPassword} />
|
||||
<FormField label="i18n:govoplan-core.password.8be3c943" helpContextId="access.authentication.password" helpModuleId="access">
|
||||
<PasswordField helpContextId="access.authentication.password" helpModuleId="access" value={password} autoComplete="current-password" onValueChange={setPassword} />
|
||||
</FormField>
|
||||
</FormLayout>
|
||||
</Dialog>);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -572,8 +572,10 @@ export default function SettingsPage({
|
||||
<FormField label="i18n:govoplan-core.api_base_url.1358fba4" help="i18n:govoplan-core.leave_empty_to_use_the_same_origin_in_vite_dev_a.9a1c25d7">
|
||||
<input value={settings.apiBaseUrl} onChange={(e) => onSettingsChange({ ...settings, apiBaseUrl: e.target.value })} placeholder="https://example.org or empty" />
|
||||
</FormField>
|
||||
<FormField label="i18n:govoplan-core.automation_api_key.5d4e2e6e" help="i18n:govoplan-core.used_only_when_there_is_no_browser_session_token.9d399e70">
|
||||
<FormField label="i18n:govoplan-core.automation_api_key.5d4e2e6e" help="i18n:govoplan-core.used_only_when_there_is_no_browser_session_token.9d399e70" helpContextId="access.settings.automation-api-key" helpModuleId="access">
|
||||
<PasswordField
|
||||
helpContextId="access.settings.automation-api-key"
|
||||
helpModuleId="access"
|
||||
value={settings.apiKey}
|
||||
autoComplete="off"
|
||||
onValueChange={(apiKey) => onSettingsChange({ ...settings, apiKey })} />
|
||||
|
||||
+13
-3
@@ -847,6 +847,16 @@ export type MailImapTransportSettings = MailTransportSettings & {
|
||||
folder_mappings?: MailImapFolderMappings | null;
|
||||
};
|
||||
|
||||
export type MailJmapTransportSettings = {
|
||||
session_url: string;
|
||||
account_id?: string | null;
|
||||
auth_scheme?: "bearer" | "basic";
|
||||
timeout_seconds?: number | null;
|
||||
max_response_bytes?: number | null;
|
||||
max_body_value_bytes?: number | null;
|
||||
allowed_api_origins?: string[];
|
||||
};
|
||||
|
||||
export type MailServerProfileCredentials = {
|
||||
smtp?: MailTransportCredentials | null;
|
||||
imap?: MailTransportCredentials | null;
|
||||
@@ -883,9 +893,9 @@ export type MailServerEndpoint = {
|
||||
id: string;
|
||||
profile_id: string;
|
||||
tenant_id?: string | null;
|
||||
protocol: "smtp" | "imap";
|
||||
protocol: "smtp" | "imap" | "jmap" | "pop3";
|
||||
name: string;
|
||||
config: MailTransportSettings | MailImapTransportSettings;
|
||||
config: MailTransportSettings | MailImapTransportSettings | MailJmapTransportSettings;
|
||||
scope_type: MailProfileScope;
|
||||
scope_id?: string | null;
|
||||
inherit_to_lower_scopes: boolean;
|
||||
@@ -922,7 +932,7 @@ export type MailCredentialPolicy = {
|
||||
allow_override?: boolean | null;
|
||||
};
|
||||
|
||||
export type MailProfilePatternKey = "smtp_hosts" | "imap_hosts" | "envelope_senders" | "from_headers" | "recipient_domains";
|
||||
export type MailProfilePatternKey = "smtp_hosts" | "imap_hosts" | "jmap_hosts" | "envelope_senders" | "from_headers" | "recipient_domains";
|
||||
|
||||
export type MailProfilePolicy = {
|
||||
allowed_profile_ids?: string[] | null;
|
||||
|
||||
@@ -4,6 +4,7 @@ function assert(condition: unknown, message = "assertion failed"): asserts condi
|
||||
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import PasswordField from "../src/components/PasswordField";
|
||||
import PasswordGeneratorDialog from "../src/components/PasswordGeneratorDialog";
|
||||
import {
|
||||
DEFAULT_PASSWORD_GENERATOR_OPTIONS,
|
||||
generateSecurePassword,
|
||||
@@ -56,11 +57,27 @@ for (const [options, expected] of [
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<PlatformLanguageProvider>
|
||||
<PasswordField value="" onValueChange={() => undefined} generator />
|
||||
<PasswordField value="" onValueChange={() => undefined} generator helpContextId="access.authentication.password" helpModuleId="access" />
|
||||
</PlatformLanguageProvider>
|
||||
);
|
||||
assert(markup.includes('aria-label="Generate password"'), "the opt-in generator action is accessible");
|
||||
assert(markup.includes("lucide-dice-5"), "the familiar generator icon is used");
|
||||
assert(!markup.includes("password-generator-dialog"), "the generator dialog stays closed until explicitly requested");
|
||||
assert(markup.includes('data-help-context-id="access.authentication.password"'), "the owner context reaches the password field and its actions");
|
||||
assert(markup.includes('data-help-module-id="access"'), "the password field retains its documentation owner");
|
||||
|
||||
const dialogMarkup = renderToStaticMarkup(
|
||||
<PlatformLanguageProvider>
|
||||
<PasswordGeneratorDialog
|
||||
open
|
||||
helpContextId="access.authentication.password"
|
||||
helpModuleId="access"
|
||||
onUse={() => undefined}
|
||||
onClose={() => undefined}
|
||||
/>
|
||||
</PlatformLanguageProvider>
|
||||
);
|
||||
assert(dialogMarkup.includes('data-help-context-id="access.authentication.password"'), "the generator dialog inherits the calling credential context");
|
||||
assert(dialogMarkup.includes('data-help-module-id="access"'), "generated-password controls retain the credential owner's module");
|
||||
|
||||
console.log("Password generator contract passed.");
|
||||
|
||||
@@ -32,6 +32,7 @@ const defaultWebModulePackages = [
|
||||
"@govoplan/files-webui",
|
||||
"@govoplan/forms-webui",
|
||||
"@govoplan/forms-runtime-webui",
|
||||
"@govoplan/helpdesk-webui",
|
||||
"@govoplan/idm-webui",
|
||||
"@govoplan/identity-webui",
|
||||
"@govoplan/identity-trust-webui",
|
||||
@@ -52,8 +53,10 @@ const defaultWebModulePackages = [
|
||||
"@govoplan/search-webui",
|
||||
"@govoplan/tenancy-webui",
|
||||
"@govoplan/templates-webui",
|
||||
"@govoplan/tickets-webui",
|
||||
"@govoplan/views-webui",
|
||||
"@govoplan/voting-webui",
|
||||
"@govoplan/wiki-webui",
|
||||
"@govoplan/workflow-webui"
|
||||
];
|
||||
|
||||
@@ -266,6 +269,7 @@ export default defineConfig({
|
||||
fileURLToPath(new URL('../../govoplan-files/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-forms/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-forms-runtime/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-helpdesk/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-idm/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-identity/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-mail/webui', import.meta.url)),
|
||||
@@ -283,8 +287,10 @@ export default defineConfig({
|
||||
fileURLToPath(new URL('../../govoplan-search/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-tenancy/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-templates/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-tickets/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-views/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-voting/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-wiki/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-workflow/webui', import.meta.url))
|
||||
]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user