Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
562d278f60 | ||
|
|
c6f6faf64f | ||
|
|
1c3ee9e8c7 | ||
|
|
aa91063211 | ||
|
|
fa2d5d40dd | ||
|
|
6ccef162f6 | ||
|
|
48dac139a5 | ||
|
|
a090e5af20 | ||
|
|
0c1358b862 | ||
|
|
a9035c4c3b | ||
|
|
137c7c005f | ||
|
|
8eeea968f2 | ||
|
|
0ca6568005 | ||
|
|
af90db44c9 | ||
|
|
5de46e9c0e | ||
|
|
1d9b677c1b | ||
|
|
54178ee56c | ||
|
|
10e7597612 | ||
|
|
142ccbc587 | ||
|
|
f75ad48d78 | ||
|
|
5a9e8f79f9 | ||
|
|
fbea74a74b | ||
|
|
925dc33696 | ||
|
|
4b0737e1cd | ||
|
|
4f4007aff1 | ||
|
|
8e687c4420 | ||
|
|
604f20eed7 | ||
|
|
6a2da94e47 | ||
|
|
e121ca900e | ||
|
|
79629c5a2c | ||
|
|
026e451aa4 | ||
|
|
f11c675d11 |
@@ -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:
|
||||
|
||||
@@ -18,10 +18,12 @@ 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. |
|
||||
| Context-sensitive F1 help | `CONTEXTUAL_HELP_CONTRACT.md` | Focus, route, module-manifest documentation contexts, Docs projection, and hosted fallback. |
|
||||
| Semantic documentation subjects | `SEMANTIC_DOCUMENTATION_SUBJECTS.md` | Stable configured-artifact identity, safe provider discovery, revision review, authorization, and lifecycle semantics. |
|
||||
| German localization and help quality gate | `LOCALIZATION_AND_HELP_QUALITY.md` | German reference locale, new-installation default, catalog completeness, automatic page associations, and explicit-help review priorities. |
|
||||
| Postbox E2EE target architecture | `POSTBOX_E2EE_ARCHITECTURE.md` | Strategic encrypted postbox/mailbox model, key ownership, role mailbox semantics, and retraction limits. |
|
||||
| Shared state, runtime coordination, and recovery | `STATE_AND_RECOVERY_CONTRACT.md` | State profiles, object storage, node registration/drain, fenced leases, migration ordering, and recovery evidence. |
|
||||
|
||||
@@ -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
|
||||
@@ -48,6 +69,12 @@ module and server restrictions, lower-scope visibility, activation, save, and
|
||||
irreversible deletion. This ensures F1 explains secret custody and the effect on
|
||||
dependent connections from system, tenant, group, user, and personal surfaces.
|
||||
|
||||
The source inventory treats literal `helpContextId` and
|
||||
`data-help-context-id` declarations as authored help associations, including a
|
||||
native control nested in `FormField`. Dynamic context expressions remain
|
||||
separate evidence and generic derived fallbacks remain in the richer-help
|
||||
candidate queue.
|
||||
|
||||
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:
|
||||
|
||||
@@ -57,6 +84,14 @@ not a list of controls on which F1 cannot work. It should prioritize:
|
||||
4. provider authority, synchronization, conflict, and outcome unknown;
|
||||
5. fields whose consequences are not evident from their label.
|
||||
|
||||
The shared browser conformance journey mounts the production Help menu and
|
||||
resolver. It proves that F1 uses the focused control rather than only the page,
|
||||
maps an exact retention action to Policy-owned administrator documentation,
|
||||
retains the page context as fallback for derived actions, exposes an accessible
|
||||
modal at narrow widths, closes with Escape, and restores focus to the triggering
|
||||
control. Module journeys should add their own exact high-risk mappings; they do
|
||||
not need to reimplement the keyboard or dialog mechanics.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
@@ -74,3 +109,10 @@ The check must report:
|
||||
- no duplicate stable IDs;
|
||||
- no undeclared public WebUI surface;
|
||||
- no stale runtime route or endpoint declaration.
|
||||
|
||||
Browser acceptance is part of the focused workspace gate and can be run alone:
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan-core/webui
|
||||
npm run test:conformance
|
||||
```
|
||||
|
||||
@@ -1621,6 +1621,17 @@ URLs never contain credentials; only credential-envelope references cross the
|
||||
contract. Provider-specific details belong in sanitized provenance rather than
|
||||
in a shared domain schema.
|
||||
|
||||
## Semantic Documentation Subject Contract
|
||||
|
||||
Optional modules expose configured artifacts that can be documented through
|
||||
the module-scoped `documentation.semantic_subjects.<module_id>` capability.
|
||||
Core supplies stable tenant-scoped references, typed nested anchors, safe
|
||||
localized descriptors, revision/fingerprint review signals, and explicit
|
||||
availability states. Providers remain responsible for authorization and do not
|
||||
expose configuration payloads or credentials. Docs discovers the capability
|
||||
and owns authored content; it does not import feature internals. See
|
||||
`SEMANTIC_DOCUMENTATION_SUBJECTS.md` for the contract and adoption rules.
|
||||
|
||||
## Build And Verification
|
||||
|
||||
Backend verification from core:
|
||||
|
||||
@@ -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,43 @@ 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
|
||||
names beginning with `policy.impactSubjects.`. The suffix is the stable
|
||||
provider ID; for example, Views contributes `policy.impactSubjects.views`.
|
||||
Providers implement `PolicyImpactSubjectProvider` and receive a
|
||||
`PolicyImpactPopulationRequest` containing the active tenant, policy family,
|
||||
an explicit selector, actor scopes, detail-disclosure decision, and a limit of
|
||||
at most 500. They return `PolicyImpactSubjectBatch` with unique opaque subject
|
||||
references and an explicit `complete`, `sampled`, `truncated`, or `unavailable`
|
||||
state. An unavailable batch must explain the gap, and a total may never be
|
||||
smaller than the returned subject count.
|
||||
|
||||
Core does not scan module data or evaluate domain policy. The owning module
|
||||
selects and permission-filters its candidates; Policy compares the current and
|
||||
proposed decisions and controls response disclosure. A caller must select one
|
||||
or more provider populations explicitly. This preserves optional-module
|
||||
boundaries and prevents a seemingly harmless preview from becoming an
|
||||
unbounded platform query. Providers must not include credentials, secrets, or
|
||||
unfiltered cross-tenant labels in subject attributes.
|
||||
|
||||
## Frontend Contract
|
||||
|
||||
Policy UIs must:
|
||||
@@ -138,6 +176,9 @@ Policy UIs must:
|
||||
lower-level limit to `false`
|
||||
- avoid sending locked fields or re-enable attempts in save payloads
|
||||
- show inherited values separately from local overrides
|
||||
- require a current impact preview before enabling a governed high-impact save,
|
||||
preserve its proposal hash on commit, and explain incomplete population
|
||||
coverage rather than presenting unavailable providers as zero impact
|
||||
|
||||
The core WebUI helper `privacyRetentionParentAllowsField()` centralizes the
|
||||
field-lock decision used by the retention editor and its lightweight module
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
# Semantic Documentation Subjects
|
||||
|
||||
## Purpose And Ownership
|
||||
|
||||
The semantic-documentation subject contract lets an optional module expose the
|
||||
configured artifacts that administrators may document: for example a form, a
|
||||
form field, a workflow, or a workflow state. It is a discovery and resolution
|
||||
contract, not a second configuration API.
|
||||
|
||||
The module that owns an artifact also owns its subject provider, authorization,
|
||||
identity, revision, route, and lifecycle semantics. Docs may discover those
|
||||
providers through Core and attach authored documentation to their stable
|
||||
references. Docs must not import the feature module, read its tables, or copy
|
||||
configuration content into a generic index.
|
||||
|
||||
This contract is additive to manifest `DocumentationTopic` contributions and
|
||||
configured-state `documentation_providers`. Every providing module must retain
|
||||
static user and administrator documentation baselines. The baselines explain
|
||||
the feature even when the provider is disabled, unavailable, or has no
|
||||
configured subjects.
|
||||
|
||||
## Identity And Versioning
|
||||
|
||||
`SemanticDocumentationSubjectReference` identifies a subject with:
|
||||
|
||||
- owning module and tenant;
|
||||
- a module-defined subject kind and stable identifier;
|
||||
- an optional typed nested anchor, such as `field/registration-number`;
|
||||
- the revision and canonical fingerprint observed when documentation was
|
||||
authored or reviewed.
|
||||
|
||||
The `stable_key` derives only from identity. A rename or configuration revision
|
||||
therefore does not detach existing documentation. A nested anchor has its own
|
||||
identity so a field can be documented independently from its form.
|
||||
|
||||
Providers must resolve an old reference as one of:
|
||||
|
||||
- `available`: the observed revision/fingerprint is still current;
|
||||
- `changed`: the same stable subject has changed and may need review;
|
||||
- `superseded`: another stable reference replaced it;
|
||||
- `missing`: the subject was removed or is no longer resolvable;
|
||||
- `temporarily_unavailable`: the provider cannot currently determine state.
|
||||
|
||||
Absence is not authorization. A provider returns `None` when the principal may
|
||||
not learn whether a subject exists. Core also rejects cross-tenant list and
|
||||
resolution requests before calling a provider.
|
||||
|
||||
## Safe Projection
|
||||
|
||||
Descriptors contain only bounded, explicit presentation fields: localized
|
||||
labels and descriptions, breadcrumbs, a local route, audience,
|
||||
classification, and required scopes. They must not contain credentials,
|
||||
personal data, arbitrary provider metadata, configuration payloads, or the
|
||||
authored documentation itself. Routes are application-local and are still
|
||||
subject to normal route authorization.
|
||||
|
||||
The fingerprint is a review signal, not a concurrency token or a content hash
|
||||
that callers may use to reconstruct configuration. Providers should calculate
|
||||
it from the smallest canonical JSON projection whose semantic changes require
|
||||
documentation review. Volatile timestamps and secrets must be excluded.
|
||||
|
||||
## Provider Registration
|
||||
|
||||
A provider is registered under its exact module-scoped capability name:
|
||||
|
||||
```python
|
||||
from govoplan_core.core.modules import CapabilityDocumentation
|
||||
from govoplan_core.core.semantic_documentation import (
|
||||
SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
|
||||
semantic_documentation_subject_capability,
|
||||
)
|
||||
|
||||
capability = semantic_documentation_subject_capability("forms")
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id="forms",
|
||||
# ...
|
||||
capability_factories={capability: build_semantic_subject_provider},
|
||||
capability_documentation={
|
||||
capability: CapabilityDocumentation(
|
||||
label="Form semantic subjects",
|
||||
summary="Lists authorized configured forms and fields for Docs.",
|
||||
contract_version=SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
|
||||
documentation_types=("admin", "user"),
|
||||
)
|
||||
},
|
||||
documentation=(admin_baseline, user_baseline),
|
||||
)
|
||||
```
|
||||
|
||||
The capability is `documentation.semantic_subjects.<module_id>`. Registry
|
||||
validation rejects a mismatched owner, missing capability documentation, a
|
||||
wrong contract version, or missing static baselines.
|
||||
|
||||
`list_semantic_documentation_subjects` performs authorized, paginated discovery
|
||||
across installed providers. `resolve_semantic_documentation_subject` targets
|
||||
one owner without loading another feature module. Providers must apply the
|
||||
current tenant and principal on every call and must not infer visibility from a
|
||||
previous list result.
|
||||
|
||||
## Lifecycle And Integration Rules
|
||||
|
||||
- Keep subject and anchor identifiers stable across display-name and route
|
||||
changes.
|
||||
- Return `superseded` only with the replacement reference; do not silently
|
||||
rewrite stored references.
|
||||
- Return a reason code for missing or temporarily unavailable subjects without
|
||||
exposing sensitive detail.
|
||||
- Reauthorize both discovery and resolution. Stored documentation references
|
||||
confer no access to a live artifact.
|
||||
- Treat a changed fingerprint as a request for editorial review. It does not
|
||||
automatically invalidate or publish authored documentation.
|
||||
- Removing a feature module leaves references resolvable as provider
|
||||
unavailable. Docs can preserve history without importing the module.
|
||||
|
||||
Forms, Workflow, and later modules should implement their subject providers in
|
||||
their own repositories. Docs owns the authored semantic-documentation records,
|
||||
review workflow, and projection UI.
|
||||
+19
-6
@@ -22,22 +22,35 @@ inherits the result through semantic tokens without module-specific CSS.
|
||||
child scope; otherwise a tenant lock suppresses a personal override. The
|
||||
authenticated profile reports the effective palette, source, inherited
|
||||
palette, and lock state.
|
||||
- Tenant branding is a separate policy surface and must preserve contrast and
|
||||
status semantics in both modes.
|
||||
- Advanced personal overrides are a separately governed surface. The system
|
||||
must opt in, a tenant may inherit or block that decision, and palette locks
|
||||
always suppress overrides. Changing either policy requires
|
||||
`admin:policies:write` in addition to the owning settings permission.
|
||||
|
||||
## Palette safety and scope
|
||||
|
||||
The Settings preview shows the chosen or inherited accent in every applicable
|
||||
light/dark preview before Save. Presets are checked for WCAG AA contrast in the
|
||||
theme contract. Arbitrary token overrides and branding import/export are not
|
||||
inferred from this preference and require their own governed follow-up.
|
||||
theme contract. When policy permits, the shared advanced editor can atomically
|
||||
override accent, surface, and semantic status pairs for both modes. Every
|
||||
foreground/background pair must meet WCAG AA contrast, and success,
|
||||
information, warning, and danger colors must remain distinct. Invalid stored
|
||||
documents fail closed and are not partially applied.
|
||||
|
||||
Import and export use the exact versioned JSON schema `schema_version: "1"`.
|
||||
Both `light` and `dark` must contain every supported token exactly once as a
|
||||
six-digit hex value. Import changes only the local draft; Save persists the
|
||||
whole document. Removing overrides returns to palette and policy inheritance.
|
||||
The system default is disabled so upgrades do not unexpectedly admit arbitrary
|
||||
branding. Tenant `null` means inherit, `false` blocks, and `true` is accepted
|
||||
only while the system permits overrides.
|
||||
|
||||
Do not introduce fixed foreground/background colors in a module merely to make
|
||||
one mode look correct. Add or reuse a semantic Core token, then define both
|
||||
light and dark values. Bitmap content and externally authored HTML are exempt,
|
||||
but their surrounding controls must still use the shared tokens.
|
||||
|
||||
`npm run test:theme-contract` verifies root mode/palette behavior, preset
|
||||
contrast, and representative
|
||||
`npm run test:theme-contract` verifies root mode/palette behavior, preset and
|
||||
custom-override validation/application, and representative
|
||||
Campaign, Calendar, Files, and Mail token consumption. The check runs before a
|
||||
production WebUI build.
|
||||
|
||||
@@ -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.
|
||||
@@ -173,10 +173,11 @@ shared CSS tokens and persisted user preference selection.
|
||||
- Modules must style new UI with these tokens and shared controls. Module-local
|
||||
CSS may tune layout and spacing, but it must not introduce a separate
|
||||
appearance system.
|
||||
- Appearance controls live in user settings first. The user preference wins
|
||||
over future tenant and system defaults unless a separately documented policy
|
||||
lock is introduced. Tenant defaults and policy
|
||||
enforcement can be added later without changing the token contract.
|
||||
- Appearance controls live in user settings. A personal palette wins over
|
||||
unlocked tenant and system defaults; system and tenant locks take precedence.
|
||||
Advanced personal token overrides additionally require system opt-in and may
|
||||
be narrowed by tenant policy. Their versioned import/export document is
|
||||
validated and applied all-or-nothing in both light and dark modes.
|
||||
- Visual preview in settings is illustrative; it must reflect token families,
|
||||
not become a second theme implementation.
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-core"
|
||||
version = "0.1.18"
|
||||
version = "0.1.37"
|
||||
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -3,7 +3,9 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from govoplan_core.core.appearance import normalize_appearance_overrides
|
||||
|
||||
|
||||
class AuditLogItemResponse(BaseModel):
|
||||
@@ -102,6 +104,36 @@ class NavigationPreferencesPayload(BaseModel):
|
||||
locked: list[str] = Field(default_factory=list, max_length=256)
|
||||
|
||||
|
||||
class AppearanceModeOverrides(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
accent: str
|
||||
accent_foreground: str
|
||||
surface: str
|
||||
surface_foreground: str
|
||||
success: str
|
||||
success_foreground: str
|
||||
info: str
|
||||
info_foreground: str
|
||||
warning: str
|
||||
warning_foreground: str
|
||||
danger: str
|
||||
danger_foreground: str
|
||||
|
||||
|
||||
class AppearanceOverridesDocument(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
schema_version: Literal["1"] = "1"
|
||||
light: AppearanceModeOverrides
|
||||
dark: AppearanceModeOverrides
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_accessibility(self) -> "AppearanceOverridesDocument":
|
||||
normalize_appearance_overrides(self.model_dump(mode="json"))
|
||||
return self
|
||||
|
||||
|
||||
class UserUiPreferences(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
@@ -111,6 +143,7 @@ class UserUiPreferences(BaseModel):
|
||||
sticky_section_sidebars: bool = True
|
||||
theme: Literal["system", "light", "dark"] = "system"
|
||||
palette: Literal["default", "civic_blue", "forest", "plum"] | None = None
|
||||
appearance_overrides: AppearanceOverridesDocument | None = None
|
||||
navigation: NavigationPreferencesPayload | None = None
|
||||
|
||||
|
||||
@@ -121,6 +154,8 @@ class EffectiveAppearanceInfo(BaseModel):
|
||||
system_default_palette: Literal["default", "civic_blue", "forest", "plum"] = "default"
|
||||
tenant_default_palette: Literal["default", "civic_blue", "forest", "plum"] | None = None
|
||||
inherited_palette: Literal["default", "civic_blue", "forest", "plum"] = "default"
|
||||
custom_overrides: AppearanceOverridesDocument | None = None
|
||||
custom_overrides_allowed: bool = False
|
||||
|
||||
|
||||
class UserInfo(BaseModel):
|
||||
|
||||
@@ -729,7 +729,7 @@ def dispatch_campaign_schedules(
|
||||
tenant_id: str | None = None,
|
||||
limit: int = 50,
|
||||
):
|
||||
"""Prepare due campaign drafts; delivery always remains a separate action."""
|
||||
"""Prepare manual drafts or governed autonomous Mail commands for due schedules."""
|
||||
|
||||
from govoplan_core.db.session import get_database
|
||||
|
||||
@@ -738,11 +738,21 @@ def dispatch_campaign_schedules(
|
||||
defaults = {
|
||||
"selected": 0,
|
||||
"prepared": 0,
|
||||
"autonomous_prepared": 0,
|
||||
"failed": 0,
|
||||
"completed": 0,
|
||||
"coalesced": 0,
|
||||
"duplicates": 0,
|
||||
"deferred": 0,
|
||||
"campaign_ids": [],
|
||||
"operator_actions": [],
|
||||
"refreshed": {
|
||||
"checked": 0,
|
||||
"accepted": 0,
|
||||
"uncertain": 0,
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
},
|
||||
}
|
||||
if not registry.has_capability(CAPABILITY_CAMPAIGNS_SCHEDULES):
|
||||
return defaults
|
||||
|
||||
@@ -24,6 +24,7 @@ CAPABILITY_ACCESS_TENANT_PROVISIONER = "access.tenantProvisioner"
|
||||
CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER = "access.firstAdminProvisioner"
|
||||
CAPABILITY_ACCESS_ADMINISTRATION = "access.administration"
|
||||
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER = "access.governanceMaterializer"
|
||||
CAPABILITY_ACCESS_GOVERNANCE_PROJECTION_V1 = "access.governanceProjection.v1"
|
||||
CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS = (
|
||||
"policy.access_explanation_subjects"
|
||||
)
|
||||
@@ -52,6 +53,7 @@ ACCESS_CAPABILITY_NAMES = frozenset(
|
||||
CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER,
|
||||
CAPABILITY_ACCESS_ADMINISTRATION,
|
||||
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER,
|
||||
CAPABILITY_ACCESS_GOVERNANCE_PROJECTION_V1,
|
||||
CAPABILITY_TENANCY_TENANT_RESOLVER,
|
||||
CAPABILITY_AUDIT_SINK,
|
||||
CAPABILITY_AUDIT_RECORDER,
|
||||
@@ -390,6 +392,82 @@ class GovernanceTemplateMaterialization:
|
||||
required: bool = False
|
||||
|
||||
|
||||
GovernanceProjectionOperation = Literal["upsert", "remove"]
|
||||
GovernanceProjectionStatus = Literal[
|
||||
"created",
|
||||
"updated",
|
||||
"unchanged",
|
||||
"removed",
|
||||
"absent",
|
||||
"blocked",
|
||||
"failed",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GovernanceProjectionCommand:
|
||||
"""Stable Access-owned input for one governance assignment projection."""
|
||||
|
||||
assignment_id: str
|
||||
operation: GovernanceProjectionOperation
|
||||
template: GovernanceTemplateMaterialization
|
||||
provenance: Mapping[str, str] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.assignment_id or len(self.assignment_id) > 255:
|
||||
raise ValueError("Governance projection assignment ids must contain at most 255 characters.")
|
||||
if len(self.provenance) > 20:
|
||||
raise ValueError("Governance projection provenance supports at most 20 entries.")
|
||||
for key, value in self.provenance.items():
|
||||
if not key or len(key) > 100 or len(value) > 500:
|
||||
raise ValueError("Governance projection provenance entries exceed their bounds.")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GovernanceProjectionBatch:
|
||||
"""Versioned, bounded reconciliation request independent of Admin internals."""
|
||||
|
||||
operation_id: str
|
||||
commands: tuple[GovernanceProjectionCommand, ...]
|
||||
version: Literal["1"] = "1"
|
||||
dry_run: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.operation_id or len(self.operation_id) > 255:
|
||||
raise ValueError("Governance projection operation ids must contain at most 255 characters.")
|
||||
if not self.commands or len(self.commands) > 500:
|
||||
raise ValueError("Governance projection batches must contain between 1 and 500 commands.")
|
||||
assignment_ids = [command.assignment_id for command in self.commands]
|
||||
if len(assignment_ids) != len(set(assignment_ids)):
|
||||
raise ValueError("Governance projection assignment ids must be unique within a batch.")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GovernanceProjectionOutcome:
|
||||
assignment_id: str
|
||||
template_id: str
|
||||
tenant_id: str
|
||||
kind: Literal["group", "role"]
|
||||
operation: GovernanceProjectionOperation
|
||||
status: GovernanceProjectionStatus
|
||||
resource_id: str | None = None
|
||||
blocker_codes: tuple[str, ...] = ()
|
||||
message: str | None = None
|
||||
provenance: Mapping[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GovernanceProjectionResult:
|
||||
operation_id: str
|
||||
outcomes: tuple[GovernanceProjectionOutcome, ...]
|
||||
version: Literal["1"] = "1"
|
||||
dry_run: bool = False
|
||||
|
||||
@property
|
||||
def blocked(self) -> tuple[GovernanceProjectionOutcome, ...]:
|
||||
return tuple(item for item in self.outcomes if item.status in {"blocked", "failed"})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AuditEvent:
|
||||
event_type: str
|
||||
@@ -693,6 +771,18 @@ class AccessGovernanceMaterializer(Protocol):
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AccessGovernanceProjectionV1(Protocol):
|
||||
"""Bulk reconciliation boundary for Admin-owned governance assignments."""
|
||||
|
||||
def reconcile(
|
||||
self,
|
||||
session: object,
|
||||
batch: GovernanceProjectionBatch,
|
||||
) -> GovernanceProjectionResult:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AuditSink(Protocol):
|
||||
def record(self, event: AuditEvent) -> None:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
from typing import Any, Literal, Mapping
|
||||
|
||||
|
||||
@@ -9,6 +10,14 @@ AppearanceSource = Literal["user", "tenant", "system", "tenant_lock", "system_lo
|
||||
|
||||
APPEARANCE_PALETTES: tuple[AppearancePalette, ...] = ("default", "civic_blue", "forest", "plum")
|
||||
APPEARANCE_SETTINGS_KEY = "appearance"
|
||||
APPEARANCE_OVERRIDE_SCHEMA_VERSION = "1"
|
||||
APPEARANCE_OVERRIDE_TOKENS: tuple[str, ...] = (
|
||||
"accent", "accent_foreground", "surface", "surface_foreground",
|
||||
"success", "success_foreground", "info", "info_foreground",
|
||||
"warning", "warning_foreground", "danger", "danger_foreground",
|
||||
)
|
||||
_STATUS_TOKENS = ("success", "info", "warning", "danger")
|
||||
_HEX_COLOR = re.compile(r"^#[0-9a-fA-F]{6}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -19,6 +28,8 @@ class EffectiveAppearance:
|
||||
system_default_palette: AppearancePalette
|
||||
tenant_default_palette: AppearancePalette | None
|
||||
inherited_palette: AppearancePalette
|
||||
custom_overrides: dict[str, object] | None = None
|
||||
custom_overrides_allowed: bool = False
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
@@ -28,6 +39,8 @@ class EffectiveAppearance:
|
||||
"system_default_palette": self.system_default_palette,
|
||||
"tenant_default_palette": self.tenant_default_palette,
|
||||
"inherited_palette": self.inherited_palette,
|
||||
"custom_overrides": self.custom_overrides,
|
||||
"custom_overrides_allowed": self.custom_overrides_allowed,
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +56,91 @@ def appearance_settings(settings: Mapping[str, Any] | None) -> tuple[AppearanceP
|
||||
return normalize_appearance_palette(raw.get("default_palette")), raw.get("palette_locked") is True
|
||||
|
||||
|
||||
def appearance_custom_overrides_policy(settings: Mapping[str, Any] | None) -> bool | None:
|
||||
raw = settings.get(APPEARANCE_SETTINGS_KEY) if isinstance(settings, Mapping) else None
|
||||
if not isinstance(raw, Mapping) or "allow_custom_overrides" not in raw:
|
||||
return None
|
||||
return raw.get("allow_custom_overrides") is True
|
||||
|
||||
|
||||
def update_appearance_custom_overrides_policy(
|
||||
settings: Mapping[str, Any] | None,
|
||||
*,
|
||||
allowed: bool | None,
|
||||
) -> dict[str, Any]:
|
||||
updated = dict(settings or {})
|
||||
appearance = dict(updated.get(APPEARANCE_SETTINGS_KEY) or {}) if isinstance(updated.get(APPEARANCE_SETTINGS_KEY), Mapping) else {}
|
||||
if allowed is None:
|
||||
appearance.pop("allow_custom_overrides", None)
|
||||
else:
|
||||
appearance["allow_custom_overrides"] = allowed
|
||||
if appearance:
|
||||
updated[APPEARANCE_SETTINGS_KEY] = appearance
|
||||
else:
|
||||
updated.pop(APPEARANCE_SETTINGS_KEY, None)
|
||||
return updated
|
||||
|
||||
|
||||
def normalize_appearance_overrides(value: object) -> dict[str, object] | None:
|
||||
"""Validate and canonicalize the versioned, all-or-nothing color contract."""
|
||||
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, Mapping):
|
||||
raise ValueError("Appearance overrides must be an object.")
|
||||
if set(value) != {"schema_version", "light", "dark"}:
|
||||
raise ValueError("Appearance overrides must contain only schema_version, light, and dark.")
|
||||
if str(value.get("schema_version")) != APPEARANCE_OVERRIDE_SCHEMA_VERSION:
|
||||
raise ValueError("Unsupported appearance override schema version.")
|
||||
normalized: dict[str, object] = {"schema_version": APPEARANCE_OVERRIDE_SCHEMA_VERSION}
|
||||
for mode in ("light", "dark"):
|
||||
raw_mode = value.get(mode)
|
||||
if not isinstance(raw_mode, Mapping) or set(raw_mode) != set(APPEARANCE_OVERRIDE_TOKENS):
|
||||
raise ValueError(f"Appearance override mode {mode} must define every supported token exactly once.")
|
||||
colors: dict[str, str] = {}
|
||||
for token in APPEARANCE_OVERRIDE_TOKENS:
|
||||
color = str(raw_mode.get(token) or "").strip().lower()
|
||||
if not _HEX_COLOR.fullmatch(color):
|
||||
raise ValueError(f"Appearance override {mode}.{token} must be a six-digit hexadecimal color.")
|
||||
colors[token] = color
|
||||
_validate_mode_accessibility(mode, colors)
|
||||
normalized[mode] = colors
|
||||
return normalized
|
||||
|
||||
|
||||
def _validate_mode_accessibility(mode: str, colors: Mapping[str, str]) -> None:
|
||||
pairs = (
|
||||
("accent", "accent_foreground"), ("surface", "surface_foreground"),
|
||||
("success", "success_foreground"), ("info", "info_foreground"),
|
||||
("warning", "warning_foreground"), ("danger", "danger_foreground"),
|
||||
)
|
||||
for background, foreground in pairs:
|
||||
if _contrast_ratio(colors[background], colors[foreground]) < 4.5:
|
||||
raise ValueError(f"Appearance override {mode}.{foreground} must have WCAG AA contrast against {mode}.{background}.")
|
||||
status_colors = [colors[token] for token in _STATUS_TOKENS]
|
||||
for index, first in enumerate(status_colors):
|
||||
for second in status_colors[index + 1:]:
|
||||
if _rgb_distance(first, second) < 12:
|
||||
raise ValueError(f"Appearance override status colors in {mode} must remain visibly distinct.")
|
||||
|
||||
|
||||
def _relative_luminance(color: str) -> float:
|
||||
channels = [int(color[index:index + 2], 16) / 255 for index in (1, 3, 5)]
|
||||
linear = [channel / 12.92 if channel <= 0.04045 else ((channel + 0.055) / 1.055) ** 2.4 for channel in channels]
|
||||
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
|
||||
|
||||
|
||||
def _contrast_ratio(first: str, second: str) -> float:
|
||||
high, low = sorted((_relative_luminance(first), _relative_luminance(second)), reverse=True)
|
||||
return (high + 0.05) / (low + 0.05)
|
||||
|
||||
|
||||
def _rgb_distance(first: str, second: str) -> float:
|
||||
first_channels = [int(first[index:index + 2], 16) for index in (1, 3, 5)]
|
||||
second_channels = [int(second[index:index + 2], 16) for index in (1, 3, 5)]
|
||||
return sum((left - right) ** 2 for left, right in zip(first_channels, second_channels, strict=True)) ** 0.5
|
||||
|
||||
|
||||
def update_appearance_settings(
|
||||
settings: Mapping[str, Any] | None,
|
||||
*,
|
||||
@@ -81,6 +179,15 @@ def resolve_effective_appearance(
|
||||
inherited_palette = tenant_palette or system_palette
|
||||
raw_ui = user_settings.get("ui") if isinstance(user_settings, Mapping) else None
|
||||
user_palette = normalize_appearance_palette(raw_ui.get("palette")) if isinstance(raw_ui, Mapping) else None
|
||||
system_custom_policy = appearance_custom_overrides_policy(system_settings) is True
|
||||
tenant_custom_policy = appearance_custom_overrides_policy(tenant_settings)
|
||||
custom_overrides_allowed = system_custom_policy and tenant_custom_policy is not False and not system_locked and not tenant_locked
|
||||
try:
|
||||
custom_overrides = normalize_appearance_overrides(raw_ui.get("appearance_overrides")) if isinstance(raw_ui, Mapping) else None
|
||||
except ValueError:
|
||||
custom_overrides = None
|
||||
if not custom_overrides_allowed:
|
||||
custom_overrides = None
|
||||
|
||||
if system_locked:
|
||||
return EffectiveAppearance(system_palette, "system_lock", True, system_palette, tenant_palette, system_palette)
|
||||
@@ -93,17 +200,24 @@ def resolve_effective_appearance(
|
||||
system_palette,
|
||||
tenant_palette,
|
||||
inherited_palette,
|
||||
custom_overrides,
|
||||
custom_overrides_allowed,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"APPEARANCE_PALETTES",
|
||||
"APPEARANCE_SETTINGS_KEY",
|
||||
"APPEARANCE_OVERRIDE_SCHEMA_VERSION",
|
||||
"APPEARANCE_OVERRIDE_TOKENS",
|
||||
"AppearancePalette",
|
||||
"AppearanceSource",
|
||||
"EffectiveAppearance",
|
||||
"appearance_settings",
|
||||
"appearance_custom_overrides_policy",
|
||||
"normalize_appearance_overrides",
|
||||
"normalize_appearance_palette",
|
||||
"resolve_effective_appearance",
|
||||
"update_appearance_settings",
|
||||
"update_appearance_custom_overrides_policy",
|
||||
]
|
||||
|
||||
@@ -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(
|
||||
@@ -108,7 +204,7 @@ class CampaignDeliveryTaskProvider(Protocol):
|
||||
|
||||
@runtime_checkable
|
||||
class CampaignScheduleProvider(Protocol):
|
||||
"""Durable boundary for preparing due recurring Campaign drafts."""
|
||||
"""Durable boundary for due manual drafts and governed autonomous occurrences."""
|
||||
|
||||
def dispatch_due(
|
||||
self,
|
||||
@@ -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()
|
||||
|
||||
@@ -5,6 +5,7 @@ from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.external_references import (
|
||||
SOURCE_AUTHORITY_MODES,
|
||||
SourceAuthorityMode,
|
||||
@@ -23,6 +24,8 @@ CAPABILITY_DATASOURCE_CATALOGUE = "datasources.catalogue"
|
||||
CAPABILITY_DATASOURCE_LIFECYCLE = "datasources.lifecycle"
|
||||
CAPABILITY_DATASOURCE_PUBLICATION = "datasources.publication"
|
||||
CAPABILITY_DATASOURCE_ORIGINS = "connectors.datasourceOrigins"
|
||||
CAPABILITY_DATASOURCE_ARTIFACT_BACKENDS = "datasources.artifactBackends"
|
||||
CAPABILITY_POLICY_DATASOURCE_VISIBILITY = "policy.datasourceVisibility"
|
||||
|
||||
DatasourceMode = Literal["live", "cached", "static"]
|
||||
DatasourceKind = Literal[
|
||||
@@ -37,6 +40,12 @@ DatasourceKind = Literal[
|
||||
]
|
||||
DatasourceShape = Literal["tabular", "document", "binary", "directory", "stream"]
|
||||
DatasourceConsistency = Literal["current", "live", "frozen"]
|
||||
DatasourceVisibilityAction = Literal["discover", "read"]
|
||||
DatasourcePublicationStatus = Literal[
|
||||
"published",
|
||||
"published_with_warnings",
|
||||
"review_required",
|
||||
]
|
||||
|
||||
|
||||
class DatasourceError(ValueError):
|
||||
@@ -64,6 +73,7 @@ class DatasourceField:
|
||||
name: str
|
||||
data_type: str
|
||||
nullable: bool = True
|
||||
classification: str = "internal"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -84,11 +94,15 @@ class DatasourceGovernance:
|
||||
classification: str = "internal"
|
||||
privacy_profile_ref: str | None = None
|
||||
retention_policy_ref: str | None = None
|
||||
access_policy_ref: str | None = None
|
||||
visibility_policy: Mapping[str, object] = field(default_factory=dict)
|
||||
hold_refs: tuple[str, ...] = ()
|
||||
publication_state: str = "draft"
|
||||
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, ...] = ()
|
||||
@@ -154,6 +168,12 @@ class DatasourceGovernance:
|
||||
retention_policy_ref=_optional_governance_text(
|
||||
source.get("retention_policy_ref")
|
||||
),
|
||||
access_policy_ref=_optional_governance_text(
|
||||
source.get("access_policy_ref")
|
||||
),
|
||||
visibility_policy=_governance_mapping(
|
||||
source.get("visibility_policy")
|
||||
),
|
||||
hold_refs=_governance_texts(source.get("hold_refs")),
|
||||
publication_state=str(source.get("publication_state") or "draft"),
|
||||
transfer_agreement_ref=_optional_governance_text(
|
||||
@@ -161,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")
|
||||
@@ -185,11 +207,15 @@ class DatasourceGovernance:
|
||||
"classification": self.classification,
|
||||
"privacy_profile_ref": self.privacy_profile_ref,
|
||||
"retention_policy_ref": self.retention_policy_ref,
|
||||
"access_policy_ref": self.access_policy_ref,
|
||||
"visibility_policy": dict(self.visibility_policy),
|
||||
"hold_refs": list(self.hold_refs),
|
||||
"publication_state": self.publication_state,
|
||||
"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),
|
||||
@@ -197,6 +223,39 @@ class DatasourceGovernance:
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DatasourceVisibilityPolicyRequest:
|
||||
tenant_id: str
|
||||
datasource_ref: str
|
||||
principal: PrincipalRef
|
||||
action: DatasourceVisibilityAction
|
||||
classification: str = "internal"
|
||||
policy_ref: str | None = None
|
||||
consistency: DatasourceConsistency = "current"
|
||||
materialization_ref: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DatasourceVisibilityPolicyDecision:
|
||||
allowed: bool
|
||||
reason: str | None = None
|
||||
policies: tuple[Mapping[str, object], ...] = ()
|
||||
decision_ref: str | None = None
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DatasourceVisibilityPolicyProvider(Protocol):
|
||||
"""Optionally tighten Datasources-owned local visibility policy."""
|
||||
|
||||
def decide_datasource_visibility(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
request: DatasourceVisibilityPolicyRequest,
|
||||
) -> DatasourceVisibilityPolicyDecision: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DatasourceDescriptor:
|
||||
ref: str
|
||||
@@ -236,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)
|
||||
@@ -256,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
|
||||
@@ -309,12 +371,73 @@ class DatasourceStageInput:
|
||||
governance: DatasourceGovernance | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DatasourceArtifactReference:
|
||||
"""Immutable provider-neutral reference to a durable tabular payload.
|
||||
|
||||
The producer owns creation of the payload. Datasources pins its locator,
|
||||
checksum and declared shape without importing the artifact-owning module;
|
||||
a configured payload backend verifies integrity and provides bounded reads.
|
||||
"""
|
||||
|
||||
backend: str
|
||||
locator: str
|
||||
checksum: str
|
||||
row_count: int
|
||||
byte_count: int
|
||||
schema: tuple[DatasourceField, ...]
|
||||
fingerprint: str
|
||||
media_type: str = "application/x-ndjson"
|
||||
checkpoint: Mapping[str, object] = field(default_factory=dict)
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
validation: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DatasourceArtifactBackend(Protocol):
|
||||
"""Storage-module boundary for immutable artifact-backed tabular data."""
|
||||
|
||||
backend: str
|
||||
|
||||
def verify(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
artifact: DatasourceArtifactReference,
|
||||
) -> None: ...
|
||||
|
||||
def read_rows(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
artifact: DatasourceArtifactReference,
|
||||
offset: int,
|
||||
limit: int,
|
||||
) -> Sequence[Mapping[str, object]]: ...
|
||||
|
||||
def delete(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
artifact: DatasourceArtifactReference,
|
||||
) -> None: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DatasourceArtifactBackendProvider(Protocol):
|
||||
def artifact_backends(self) -> Sequence[DatasourceArtifactBackend]: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DatasourcePublicationRequest:
|
||||
producer_module: str
|
||||
producer_run_ref: str
|
||||
idempotency_key: str
|
||||
rows: tuple[Mapping[str, object], ...]
|
||||
rows: tuple[Mapping[str, object], ...] | None = None
|
||||
artifact: DatasourceArtifactReference | None = None
|
||||
target_datasource_ref: str | None = None
|
||||
name: str | None = None
|
||||
source_name: str | None = None
|
||||
@@ -331,7 +454,7 @@ class DatasourcePublicationRequest:
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DatasourcePublicationResult:
|
||||
ref: str
|
||||
status: str
|
||||
status: DatasourcePublicationStatus
|
||||
datasource: DatasourceDescriptor
|
||||
materialization: DatasourceMaterialization
|
||||
replayed: bool = False
|
||||
@@ -564,6 +687,17 @@ def datasource_catalogue(registry: object | None) -> DatasourceCatalogueProvider
|
||||
return capability if isinstance(capability, DatasourceCatalogueProvider) else None
|
||||
|
||||
|
||||
def datasource_artifact_backend_provider(
|
||||
registry: object | None,
|
||||
) -> DatasourceArtifactBackendProvider | None:
|
||||
capability = _capability(registry, CAPABILITY_DATASOURCE_ARTIFACT_BACKENDS)
|
||||
return (
|
||||
capability
|
||||
if isinstance(capability, DatasourceArtifactBackendProvider)
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
def datasource_lifecycle(registry: object | None) -> DatasourceLifecycleProvider | None:
|
||||
capability = _capability(registry, CAPABILITY_DATASOURCE_LIFECYCLE)
|
||||
return capability if isinstance(capability, DatasourceLifecycleProvider) else None
|
||||
@@ -581,6 +715,13 @@ def datasource_origins(registry: object | None) -> DatasourceOriginProvider | No
|
||||
return capability if isinstance(capability, DatasourceOriginProvider) else None
|
||||
|
||||
|
||||
def datasource_visibility_policy_provider(
|
||||
registry: object | None,
|
||||
) -> DatasourceVisibilityPolicyProvider | None:
|
||||
capability = _capability(registry, CAPABILITY_POLICY_DATASOURCE_VISIBILITY)
|
||||
return capability if isinstance(capability, DatasourceVisibilityPolicyProvider) else None
|
||||
|
||||
|
||||
def _capability(registry: object | None, name: str) -> object | None:
|
||||
if (
|
||||
registry is None
|
||||
@@ -613,9 +754,15 @@ def _governance_mapping(value: object) -> Mapping[str, object]:
|
||||
|
||||
__all__ = [
|
||||
"CAPABILITY_DATASOURCE_CATALOGUE",
|
||||
"CAPABILITY_DATASOURCE_ARTIFACT_BACKENDS",
|
||||
"CAPABILITY_DATASOURCE_LIFECYCLE",
|
||||
"CAPABILITY_DATASOURCE_ORIGINS",
|
||||
"CAPABILITY_DATASOURCE_PUBLICATION",
|
||||
"CAPABILITY_POLICY_DATASOURCE_VISIBILITY",
|
||||
"DatasourceAccessError",
|
||||
"DatasourceArtifactReference",
|
||||
"DatasourceArtifactBackend",
|
||||
"DatasourceArtifactBackendProvider",
|
||||
"DatasourceCatalogueProvider",
|
||||
"DatasourceConsistency",
|
||||
"DatasourceDescriptor",
|
||||
@@ -631,6 +778,10 @@ __all__ = [
|
||||
"DatasourceOriginProvider",
|
||||
"DatasourceOriginReadRequest",
|
||||
"DatasourceOriginReadResult",
|
||||
"DatasourcePublicationProvider",
|
||||
"DatasourcePublicationRequest",
|
||||
"DatasourcePublicationResult",
|
||||
"DatasourcePublicationStatus",
|
||||
"DatasourceReadRequest",
|
||||
"DatasourceReadResult",
|
||||
"DatasourceShape",
|
||||
@@ -638,7 +789,14 @@ __all__ = [
|
||||
"DatasourceStageInput",
|
||||
"DatasourceUnavailableError",
|
||||
"DatasourceValidationError",
|
||||
"DatasourceVisibilityAction",
|
||||
"DatasourceVisibilityPolicyDecision",
|
||||
"DatasourceVisibilityPolicyProvider",
|
||||
"DatasourceVisibilityPolicyRequest",
|
||||
"datasource_catalogue",
|
||||
"datasource_artifact_backend_provider",
|
||||
"datasource_lifecycle",
|
||||
"datasource_origins",
|
||||
"datasource_publication",
|
||||
"datasource_visibility_policy_provider",
|
||||
]
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from govoplan_core.core.access import ResourceAccessExplanationProvider
|
||||
@@ -10,6 +11,48 @@ from govoplan_core.core.access import ResourceAccessExplanationProvider
|
||||
CAPABILITY_FILES_ACCESS = "files.access"
|
||||
CAPABILITY_FILES_ARTIFACT_STORE = "files.artifact_store"
|
||||
CAPABILITY_FILES_POSTBOX_REFERENCES = "files.postbox_references"
|
||||
CAPABILITY_FILES_TABULAR_CONTENT = "files.tabular_content"
|
||||
|
||||
|
||||
class ManagedTabularFileError(ValueError):
|
||||
"""Stable base error for exact-version managed tabular file access."""
|
||||
|
||||
|
||||
class ManagedTabularFileNotFoundError(ManagedTabularFileError):
|
||||
pass
|
||||
|
||||
|
||||
class ManagedTabularFileAccessError(ManagedTabularFileError):
|
||||
pass
|
||||
|
||||
|
||||
class ManagedTabularFileUnavailableError(ManagedTabularFileError):
|
||||
pass
|
||||
|
||||
|
||||
class ManagedTabularFileValidationError(ManagedTabularFileError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ManagedTabularFile:
|
||||
"""Authorized metadata for one immutable managed file version."""
|
||||
|
||||
file_asset_id: str
|
||||
file_version_id: str
|
||||
filename: str
|
||||
display_path: str
|
||||
content_type: str | None
|
||||
size_bytes: int
|
||||
sha256: str
|
||||
updated_at: datetime | None = None
|
||||
current_version: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ManagedTabularFileContent:
|
||||
file: ManagedTabularFile
|
||||
payload: bytes
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -91,6 +134,39 @@ class PostboxFileReferenceProvider(Protocol):
|
||||
) -> tuple[PostboxFileReferenceRef, ...]: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ManagedTabularFileProvider(Protocol):
|
||||
"""List and open authorized CSV/XLSX content without exposing Files internals."""
|
||||
|
||||
def list_tabular_files(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
query: str = "",
|
||||
limit: int = 100,
|
||||
) -> tuple[ManagedTabularFile, ...]: ...
|
||||
|
||||
def get_tabular_file(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
file_asset_id: str,
|
||||
file_version_id: str | None = None,
|
||||
) -> ManagedTabularFile | None: ...
|
||||
|
||||
def read_tabular_file(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
file_asset_id: str,
|
||||
file_version_id: str,
|
||||
max_bytes: int,
|
||||
) -> ManagedTabularFileContent: ...
|
||||
|
||||
|
||||
def postbox_file_reference_provider(
|
||||
registry: object | None,
|
||||
) -> PostboxFileReferenceProvider | None:
|
||||
@@ -107,3 +183,46 @@ def postbox_file_reference_provider(
|
||||
"PostboxFileReferenceProvider"
|
||||
)
|
||||
return provider
|
||||
|
||||
|
||||
def managed_tabular_file_provider(
|
||||
registry: object | None,
|
||||
) -> ManagedTabularFileProvider | None:
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not registry.has_capability(CAPABILITY_FILES_TABULAR_CONTENT)
|
||||
):
|
||||
return None
|
||||
provider = registry.require_capability(CAPABILITY_FILES_TABULAR_CONTENT)
|
||||
if not isinstance(provider, ManagedTabularFileProvider):
|
||||
raise TypeError(
|
||||
"files.tabular_content provider does not implement "
|
||||
"ManagedTabularFileProvider"
|
||||
)
|
||||
return provider
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CAPABILITY_FILES_ACCESS",
|
||||
"CAPABILITY_FILES_ARTIFACT_STORE",
|
||||
"CAPABILITY_FILES_POSTBOX_REFERENCES",
|
||||
"CAPABILITY_FILES_TABULAR_CONTENT",
|
||||
"FileAccessProvider",
|
||||
"ManagedArtifactRef",
|
||||
"ManagedArtifactStore",
|
||||
"ManagedArtifactWriteRequest",
|
||||
"ManagedTabularFile",
|
||||
"ManagedTabularFileAccessError",
|
||||
"ManagedTabularFileContent",
|
||||
"ManagedTabularFileError",
|
||||
"ManagedTabularFileNotFoundError",
|
||||
"ManagedTabularFileProvider",
|
||||
"ManagedTabularFileUnavailableError",
|
||||
"ManagedTabularFileValidationError",
|
||||
"PostboxFileReferenceProvider",
|
||||
"PostboxFileReferenceRef",
|
||||
"PostboxFileReferenceRequest",
|
||||
"managed_tabular_file_provider",
|
||||
"postbox_file_reference_provider",
|
||||
]
|
||||
|
||||
@@ -737,6 +737,8 @@ CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||
PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS=8
|
||||
PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||
SCHEDULING_CANCELLATION_NOTICE_DAYS=30
|
||||
SCHEDULING_PUBLIC_SELF_ENROLLMENT_ENABLED=true
|
||||
SCHEDULING_PUBLIC_SELF_ENROLLMENT_MAX_CAPACITY=10000
|
||||
|
||||
# Deployment-wide connector egress policy. Enable private networks only when
|
||||
# this installation intentionally integrates with internal services.
|
||||
@@ -818,6 +820,8 @@ CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||
PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS=8
|
||||
PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||
SCHEDULING_CANCELLATION_NOTICE_DAYS=30
|
||||
SCHEDULING_PUBLIC_SELF_ENROLLMENT_ENABLED=true
|
||||
SCHEDULING_PUBLIC_SELF_ENROLLMENT_MAX_CAPACITY=10000
|
||||
|
||||
GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true
|
||||
GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES=16777216
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -7,6 +7,20 @@ from urllib.parse import quote, unquote
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
|
||||
PolicyScopeType = Literal["system", "tenant", "user", "group", "campaign"]
|
||||
PolicyImpactPopulationState = Literal[
|
||||
"complete",
|
||||
"sampled",
|
||||
"truncated",
|
||||
"unavailable",
|
||||
]
|
||||
CampaignArchiveEncryptionMethod = Literal["aes", "zip_standard"]
|
||||
CampaignArchivePasswordDeliveryChannel = Literal[
|
||||
"separate_mail",
|
||||
"sms",
|
||||
"letter",
|
||||
"phone",
|
||||
"in_person",
|
||||
]
|
||||
SchedulingParticipantVisibility = Literal["aggregates_only", "names_and_statuses"]
|
||||
DefinitionScopeType = Literal["system", "tenant", "group", "user"]
|
||||
DefinitionKind = Literal["flow", "template"]
|
||||
@@ -27,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",
|
||||
@@ -45,6 +61,8 @@ CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY = "policy.schedulingParticipant
|
||||
CAPABILITY_POLICY_DEFINITION_GOVERNANCE = "policy.definitionGovernance"
|
||||
CAPABILITY_POLICY_VIEW_GOVERNANCE = "policy.viewGovernance"
|
||||
CAPABILITY_POLICY_FUNCTION_ASSIGNMENT_GOVERNANCE = "policy.functionAssignmentGovernance"
|
||||
CAPABILITY_POLICY_CAMPAIGN_ARCHIVE_ENCRYPTION = "policy.campaignArchiveEncryption"
|
||||
CAPABILITY_POLICY_IMPACT_SUBJECT_PREFIX = "policy.impactSubjects."
|
||||
|
||||
POLICY_SCOPE_TYPES: tuple[PolicyScopeType, ...] = (
|
||||
"system",
|
||||
@@ -182,6 +200,213 @@ class PolicyDecision:
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PolicyImpactPopulationRequest:
|
||||
"""One explicit, bounded request to an optional impact-subject provider."""
|
||||
|
||||
tenant_id: str
|
||||
policy_family: str
|
||||
selector: Mapping[str, Any] = field(default_factory=dict)
|
||||
limit: int = 200
|
||||
actor_scopes: tuple[str, ...] = ()
|
||||
allow_sensitive_details: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.tenant_id.strip():
|
||||
raise ValueError("Policy impact population requires a tenant ID")
|
||||
if not self.policy_family.strip() or len(self.policy_family) > 120:
|
||||
raise ValueError(
|
||||
"Policy impact population family must contain 1 to 120 characters"
|
||||
)
|
||||
if self.limit < 1 or self.limit > 500:
|
||||
raise ValueError("Policy impact population limit must be between 1 and 500")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PolicyImpactSubject:
|
||||
"""Provider-owned reference safe for Policy to compare without domain imports."""
|
||||
|
||||
module_id: str
|
||||
resource_type: str
|
||||
resource_id: str
|
||||
action: str
|
||||
label: str | None = None
|
||||
scope_type: PolicyScopeType | None = None
|
||||
scope_id: str | None = None
|
||||
attributes: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for label, value, maximum in (
|
||||
("module ID", self.module_id, 80),
|
||||
("resource type", self.resource_type, 80),
|
||||
("resource ID", self.resource_id, 240),
|
||||
("action", self.action, 120),
|
||||
):
|
||||
if not value.strip() or len(value) > maximum:
|
||||
raise ValueError(
|
||||
f"Policy impact subject {label} must contain 1 to {maximum} characters"
|
||||
)
|
||||
|
||||
@property
|
||||
def key(self) -> tuple[str, str, str, str]:
|
||||
return (
|
||||
self.module_id,
|
||||
self.resource_type,
|
||||
self.resource_id,
|
||||
self.action,
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"module_id": self.module_id,
|
||||
"resource_type": self.resource_type,
|
||||
"resource_id": self.resource_id,
|
||||
"action": self.action,
|
||||
"label": self.label,
|
||||
"scope_type": self.scope_type,
|
||||
"scope_id": self.scope_id,
|
||||
"attributes": dict(self.attributes),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PolicyImpactSubjectBatch:
|
||||
provider_id: str
|
||||
subjects: tuple[PolicyImpactSubject, ...] = ()
|
||||
state: PolicyImpactPopulationState = "complete"
|
||||
total_available: int | None = None
|
||||
explanation: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.provider_id.strip() or len(self.provider_id) > 120:
|
||||
raise ValueError(
|
||||
"Policy impact provider ID must contain 1 to 120 characters"
|
||||
)
|
||||
if len(self.subjects) > 500:
|
||||
raise ValueError("Policy impact providers may return at most 500 subjects")
|
||||
if len({subject.key for subject in self.subjects}) != len(self.subjects):
|
||||
raise ValueError("Policy impact provider returned duplicate subjects")
|
||||
if self.total_available is not None and self.total_available < len(self.subjects):
|
||||
raise ValueError(
|
||||
"Policy impact population total cannot be smaller than its subjects"
|
||||
)
|
||||
if self.state == "unavailable" and not self.explanation:
|
||||
raise ValueError("Unavailable policy impact populations need an explanation")
|
||||
|
||||
def to_dict(self, *, include_subjects: bool = True) -> dict[str, Any]:
|
||||
return {
|
||||
"provider_id": self.provider_id,
|
||||
"state": self.state,
|
||||
"returned": len(self.subjects),
|
||||
"total_available": self.total_available,
|
||||
"explanation": self.explanation,
|
||||
"subjects": (
|
||||
[subject.to_dict() for subject in self.subjects]
|
||||
if include_subjects
|
||||
else []
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PolicyImpactSubjectProvider(Protocol):
|
||||
provider_id: str
|
||||
supported_policy_families: tuple[str, ...]
|
||||
|
||||
def collect_policy_impact_subjects(
|
||||
self,
|
||||
session: object | None = None,
|
||||
*,
|
||||
request: PolicyImpactPopulationRequest,
|
||||
) -> PolicyImpactSubjectBatch: ...
|
||||
|
||||
|
||||
def policy_impact_subject_provider(
|
||||
registry: object | None,
|
||||
provider_id: str,
|
||||
) -> PolicyImpactSubjectProvider | None:
|
||||
clean_provider_id = provider_id.strip()
|
||||
if not clean_provider_id or registry is None:
|
||||
return None
|
||||
capability_name = f"{CAPABILITY_POLICY_IMPACT_SUBJECT_PREFIX}{clean_provider_id}"
|
||||
if (
|
||||
not hasattr(registry, "has_capability")
|
||||
or not registry.has_capability(capability_name)
|
||||
):
|
||||
return None
|
||||
capability = registry.capability(capability_name)
|
||||
if not isinstance(capability, PolicyImpactSubjectProvider):
|
||||
return None
|
||||
return capability
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CampaignArchiveEncryptionRequest:
|
||||
"""Context required to resolve one Campaign archive-encryption ceiling.
|
||||
|
||||
The owning module supplies the stable Campaign and owner references. Policy
|
||||
owns hierarchy evaluation; Campaign owns archive configuration and evidence.
|
||||
"""
|
||||
|
||||
tenant_id: str
|
||||
campaign_id: str
|
||||
owner_type: Literal["user", "group"] | None = None
|
||||
owner_id: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CampaignArchiveEncryptionDecision:
|
||||
allowed_password_encryption_methods: frozenset[CampaignArchiveEncryptionMethod]
|
||||
allowed_password_delivery_channels: frozenset[
|
||||
CampaignArchivePasswordDeliveryChannel
|
||||
]
|
||||
policy_hash: str
|
||||
source_path: tuple[PolicySourceStep, ...] = ()
|
||||
reason: str | None = None
|
||||
diagnostics: tuple[Mapping[str, Any], ...] = ()
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"allowed_password_encryption_methods": sorted(
|
||||
self.allowed_password_encryption_methods
|
||||
),
|
||||
"allowed_password_delivery_channels": sorted(
|
||||
self.allowed_password_delivery_channels
|
||||
),
|
||||
"policy_hash": self.policy_hash,
|
||||
"source_path": [step.to_dict() for step in self.source_path],
|
||||
"reason": self.reason,
|
||||
"diagnostics": [dict(item) for item in self.diagnostics],
|
||||
}
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CampaignArchiveEncryptionPolicy(Protocol):
|
||||
def resolve_campaign_archive_encryption(
|
||||
self,
|
||||
session: object | None = None,
|
||||
*,
|
||||
request: CampaignArchiveEncryptionRequest,
|
||||
) -> CampaignArchiveEncryptionDecision: ...
|
||||
|
||||
|
||||
def campaign_archive_encryption_policy(
|
||||
registry: object | None,
|
||||
) -> CampaignArchiveEncryptionPolicy | None:
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not registry.has_capability(CAPABILITY_POLICY_CAMPAIGN_ARCHIVE_ENCRYPTION)
|
||||
):
|
||||
return None
|
||||
capability = registry.capability(CAPABILITY_POLICY_CAMPAIGN_ARCHIVE_ENCRYPTION)
|
||||
return (
|
||||
capability
|
||||
if isinstance(capability, CampaignArchiveEncryptionPolicy)
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FunctionAssignmentGovernanceRequest:
|
||||
tenant_id: str
|
||||
@@ -196,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
|
||||
@@ -208,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, ...] = ()
|
||||
@@ -225,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,
|
||||
@@ -61,6 +62,11 @@ from govoplan_core.core.search import (
|
||||
SearchProvider,
|
||||
SearchSourceProvider,
|
||||
)
|
||||
from govoplan_core.core.semantic_documentation import (
|
||||
SEMANTIC_DOCUMENTATION_SUBJECT_CAPABILITY_PREFIX,
|
||||
SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
|
||||
semantic_documentation_subject_capability,
|
||||
)
|
||||
from govoplan_core.core.tasks import (
|
||||
RegisteredWorkItemProvider,
|
||||
WorkItemProvider,
|
||||
@@ -957,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)
|
||||
@@ -1215,6 +1225,48 @@ def _validate_documentation_extensions(manifest: ModuleManifest) -> None:
|
||||
"documentation contract version must not be empty"
|
||||
)
|
||||
|
||||
semantic_capabilities = tuple(
|
||||
capability
|
||||
for capability in manifest.capability_factories
|
||||
if capability.startswith(
|
||||
SEMANTIC_DOCUMENTATION_SUBJECT_CAPABILITY_PREFIX
|
||||
)
|
||||
)
|
||||
for capability in semantic_capabilities:
|
||||
expected = semantic_documentation_subject_capability(manifest.id)
|
||||
if capability != expected:
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} semantic-documentation capability "
|
||||
f"must be {expected!r}, not {capability!r}"
|
||||
)
|
||||
metadata = manifest.capability_documentation.get(capability)
|
||||
if metadata is None:
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} semantic-documentation capability "
|
||||
"must declare capability documentation"
|
||||
)
|
||||
if (
|
||||
metadata.contract_version
|
||||
!= SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION
|
||||
):
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} semantic-documentation capability "
|
||||
f"must declare contract version "
|
||||
f"{SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION!r}"
|
||||
)
|
||||
static_types = {
|
||||
documentation_type
|
||||
for topic in manifest.documentation
|
||||
for documentation_type in topic.documentation_types
|
||||
}
|
||||
missing_types = {"admin", "user"} - static_types
|
||||
if missing_types:
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} semantic-documentation provider must "
|
||||
"retain static user and administrator documentation baselines; "
|
||||
f"missing: {', '.join(sorted(missing_types))}"
|
||||
)
|
||||
|
||||
provider_keys: set[str] = set()
|
||||
for registration in manifest.documentation_configuration_providers:
|
||||
if not registration.keys:
|
||||
|
||||
@@ -0,0 +1,621 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
|
||||
SEMANTIC_DOCUMENTATION_SUBJECT_CAPABILITY_PREFIX = (
|
||||
"documentation.semantic_subjects."
|
||||
)
|
||||
SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION = "1"
|
||||
|
||||
SemanticDocumentationSubjectAvailability = Literal[
|
||||
"available",
|
||||
"changed",
|
||||
"superseded",
|
||||
"missing",
|
||||
"temporarily_unavailable",
|
||||
]
|
||||
|
||||
_MODULE_ID_RE = re.compile(r"^[a-z][a-z0-9_]{0,79}$")
|
||||
_KIND_RE = re.compile(r"^[a-z][a-z0-9_.-]{0,119}$")
|
||||
_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:@-]{0,254}$")
|
||||
_LOCALE_RE = re.compile(r"^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$")
|
||||
_REASON_CODE_RE = re.compile(r"^[a-z][a-z0-9_]{0,79}$")
|
||||
_SHA256_RE = re.compile(r"^(?:sha256:)?[0-9a-fA-F]{64}$")
|
||||
|
||||
|
||||
class SemanticDocumentationContractError(ValueError):
|
||||
"""Raised when a semantic-documentation subject violates the Core contract."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SemanticDocumentationSubjectAnchor:
|
||||
kind: str
|
||||
id: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_require_match(self.kind, _KIND_RE, "Semantic subject anchor kind")
|
||||
_require_match(self.id, _IDENTIFIER_RE, "Semantic subject anchor id")
|
||||
|
||||
def to_dict(self) -> dict[str, str]:
|
||||
return {"kind": self.kind, "id": self.id}
|
||||
|
||||
@classmethod
|
||||
def from_mapping(
|
||||
cls, value: Mapping[str, object]
|
||||
) -> SemanticDocumentationSubjectAnchor:
|
||||
_require_keys(value, {"kind", "id"}, "Semantic subject anchor")
|
||||
return cls(kind=_required_text(value, "kind"), id=_required_text(value, "id"))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SemanticDocumentationSubjectReference:
|
||||
module_id: str
|
||||
tenant_id: str
|
||||
subject_kind: str
|
||||
subject_id: str
|
||||
anchor: SemanticDocumentationSubjectAnchor | None = None
|
||||
observed_revision: str | None = None
|
||||
observed_fingerprint: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_require_match(self.module_id, _MODULE_ID_RE, "Semantic subject module id")
|
||||
_require_match(self.tenant_id, _IDENTIFIER_RE, "Semantic subject tenant id")
|
||||
_require_match(self.subject_kind, _KIND_RE, "Semantic subject kind")
|
||||
_require_match(self.subject_id, _IDENTIFIER_RE, "Semantic subject id")
|
||||
_optional_text(self.observed_revision, "Semantic subject observed revision", 255)
|
||||
if self.observed_fingerprint is not None and not _SHA256_RE.fullmatch(
|
||||
self.observed_fingerprint
|
||||
):
|
||||
raise SemanticDocumentationContractError(
|
||||
"Semantic subject observed fingerprint must be a SHA-256 digest."
|
||||
)
|
||||
|
||||
@property
|
||||
def stable_key(self) -> str:
|
||||
identity = {
|
||||
"anchor": self.anchor.to_dict() if self.anchor else None,
|
||||
"module_id": self.module_id,
|
||||
"subject_id": self.subject_id,
|
||||
"subject_kind": self.subject_kind,
|
||||
"tenant_id": self.tenant_id,
|
||||
}
|
||||
encoded = json.dumps(
|
||||
identity, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||
).encode("utf-8")
|
||||
return f"sha256:{hashlib.sha256(encoded).hexdigest()}"
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"module_id": self.module_id,
|
||||
"tenant_id": self.tenant_id,
|
||||
"subject_kind": self.subject_kind,
|
||||
"subject_id": self.subject_id,
|
||||
"anchor": self.anchor.to_dict() if self.anchor else None,
|
||||
"observed_revision": self.observed_revision,
|
||||
"observed_fingerprint": self.observed_fingerprint,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_mapping(
|
||||
cls, value: Mapping[str, object]
|
||||
) -> SemanticDocumentationSubjectReference:
|
||||
_require_keys(
|
||||
value,
|
||||
{
|
||||
"module_id",
|
||||
"tenant_id",
|
||||
"subject_kind",
|
||||
"subject_id",
|
||||
"anchor",
|
||||
"observed_revision",
|
||||
"observed_fingerprint",
|
||||
},
|
||||
"Semantic subject reference",
|
||||
)
|
||||
raw_anchor = value.get("anchor")
|
||||
if raw_anchor is not None and not isinstance(raw_anchor, Mapping):
|
||||
raise SemanticDocumentationContractError(
|
||||
"Semantic subject anchor must be an object."
|
||||
)
|
||||
return cls(
|
||||
module_id=_required_text(value, "module_id"),
|
||||
tenant_id=_required_text(value, "tenant_id"),
|
||||
subject_kind=_required_text(value, "subject_kind"),
|
||||
subject_id=_required_text(value, "subject_id"),
|
||||
anchor=(
|
||||
SemanticDocumentationSubjectAnchor.from_mapping(raw_anchor)
|
||||
if isinstance(raw_anchor, Mapping)
|
||||
else None
|
||||
),
|
||||
observed_revision=_mapping_optional_text(value, "observed_revision"),
|
||||
observed_fingerprint=_mapping_optional_text(
|
||||
value, "observed_fingerprint"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SemanticDocumentationBreadcrumb:
|
||||
label: str
|
||||
subject_kind: str
|
||||
subject_id: str
|
||||
anchor: SemanticDocumentationSubjectAnchor | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_required_bounded_text(self.label, "Semantic subject breadcrumb label", 300)
|
||||
_require_match(self.subject_kind, _KIND_RE, "Semantic breadcrumb kind")
|
||||
_require_match(self.subject_id, _IDENTIFIER_RE, "Semantic breadcrumb id")
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"label": self.label,
|
||||
"subject_kind": self.subject_kind,
|
||||
"subject_id": self.subject_id,
|
||||
"anchor": self.anchor.to_dict() if self.anchor else None,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SemanticDocumentationSubjectDescriptor:
|
||||
reference: SemanticDocumentationSubjectReference
|
||||
labels: Mapping[str, str]
|
||||
descriptions: Mapping[str, str] = field(default_factory=dict)
|
||||
breadcrumbs: tuple[SemanticDocumentationBreadcrumb, ...] = ()
|
||||
route: str | None = None
|
||||
route_anchor: str | None = None
|
||||
audience: tuple[str, ...] = ()
|
||||
classification: str = "internal"
|
||||
required_scopes: tuple[str, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.reference.observed_revision:
|
||||
raise SemanticDocumentationContractError(
|
||||
"Semantic subject descriptors require a current revision."
|
||||
)
|
||||
if not self.reference.observed_fingerprint:
|
||||
raise SemanticDocumentationContractError(
|
||||
"Semantic subject descriptors require a current fingerprint."
|
||||
)
|
||||
_localized_text(self.labels, "Semantic subject labels", required=True, limit=300)
|
||||
_localized_text(
|
||||
self.descriptions,
|
||||
"Semantic subject descriptions",
|
||||
required=False,
|
||||
limit=2_000,
|
||||
)
|
||||
if len(self.breadcrumbs) > 32:
|
||||
raise SemanticDocumentationContractError(
|
||||
"Semantic subject breadcrumbs are limited to 32 items."
|
||||
)
|
||||
if self.route is not None:
|
||||
_optional_text(self.route, "Semantic subject route", 2_000)
|
||||
if not self.route.startswith("/") or self.route.startswith("//"):
|
||||
raise SemanticDocumentationContractError(
|
||||
"Semantic subject routes must be local absolute paths."
|
||||
)
|
||||
if self.route_anchor is not None:
|
||||
_require_match(
|
||||
self.route_anchor,
|
||||
_IDENTIFIER_RE,
|
||||
"Semantic subject route anchor",
|
||||
)
|
||||
_text_tuple(self.audience, "Semantic subject audience", maximum=32)
|
||||
_required_bounded_text(
|
||||
self.classification, "Semantic subject classification", 120
|
||||
)
|
||||
_text_tuple(
|
||||
self.required_scopes, "Semantic subject required scopes", maximum=64
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"reference": self.reference.to_dict(),
|
||||
"labels": dict(self.labels),
|
||||
"descriptions": dict(self.descriptions),
|
||||
"breadcrumbs": [item.to_dict() for item in self.breadcrumbs],
|
||||
"route": self.route,
|
||||
"route_anchor": self.route_anchor,
|
||||
"audience": list(self.audience),
|
||||
"classification": self.classification,
|
||||
"required_scopes": list(self.required_scopes),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SemanticDocumentationSubjectResolution:
|
||||
requested_reference: SemanticDocumentationSubjectReference
|
||||
availability: SemanticDocumentationSubjectAvailability
|
||||
subject: SemanticDocumentationSubjectDescriptor | None = None
|
||||
superseded_by: SemanticDocumentationSubjectReference | None = None
|
||||
reason_code: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.reason_code is not None:
|
||||
_require_match(
|
||||
self.reason_code, _REASON_CODE_RE, "Semantic resolution reason code"
|
||||
)
|
||||
if self.availability in {"available", "changed"}:
|
||||
if self.subject is None:
|
||||
raise SemanticDocumentationContractError(
|
||||
f"Semantic subject {self.availability} resolutions require a descriptor."
|
||||
)
|
||||
if (
|
||||
self.subject.reference.stable_key
|
||||
!= self.requested_reference.stable_key
|
||||
):
|
||||
raise SemanticDocumentationContractError(
|
||||
"Semantic subject resolution changed the requested identity."
|
||||
)
|
||||
changed = _reference_changed(
|
||||
self.requested_reference, self.subject.reference
|
||||
)
|
||||
if self.availability == "available" and changed:
|
||||
raise SemanticDocumentationContractError(
|
||||
"Changed semantic subjects must use the changed availability."
|
||||
)
|
||||
if self.availability == "changed" and not changed:
|
||||
raise SemanticDocumentationContractError(
|
||||
"Changed semantic subject resolutions require a revision or fingerprint change."
|
||||
)
|
||||
elif self.subject is not None:
|
||||
raise SemanticDocumentationContractError(
|
||||
f"Semantic subject {self.availability} resolutions cannot include a descriptor."
|
||||
)
|
||||
if self.availability == "superseded":
|
||||
if self.superseded_by is None:
|
||||
raise SemanticDocumentationContractError(
|
||||
"Superseded semantic subjects require a replacement reference."
|
||||
)
|
||||
elif self.superseded_by is not None:
|
||||
raise SemanticDocumentationContractError(
|
||||
"Only superseded semantic subjects may declare a replacement."
|
||||
)
|
||||
if self.availability in {"missing", "temporarily_unavailable"} and not self.reason_code:
|
||||
raise SemanticDocumentationContractError(
|
||||
f"Semantic subject {self.availability} resolutions require a reason code."
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"requested_reference": self.requested_reference.to_dict(),
|
||||
"availability": self.availability,
|
||||
"subject": self.subject.to_dict() if self.subject else None,
|
||||
"superseded_by": (
|
||||
self.superseded_by.to_dict() if self.superseded_by else None
|
||||
),
|
||||
"reason_code": self.reason_code,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SemanticDocumentationSubjectQuery:
|
||||
tenant_id: str
|
||||
query: str = ""
|
||||
subject_kinds: tuple[str, ...] = ()
|
||||
limit: int = 50
|
||||
cursor: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_require_match(self.tenant_id, _IDENTIFIER_RE, "Semantic query tenant id")
|
||||
if not isinstance(self.query, str) or len(self.query) > 300:
|
||||
raise SemanticDocumentationContractError(
|
||||
"Semantic subject query must be text of at most 300 characters."
|
||||
)
|
||||
if self.query:
|
||||
_required_bounded_text(self.query, "Semantic subject query", 300)
|
||||
if not 1 <= self.limit <= 200:
|
||||
raise SemanticDocumentationContractError(
|
||||
"Semantic subject query limit must be between 1 and 200."
|
||||
)
|
||||
_text_tuple(self.subject_kinds, "Semantic query subject kinds", maximum=100)
|
||||
for kind in self.subject_kinds:
|
||||
_require_match(kind, _KIND_RE, "Semantic query subject kind")
|
||||
_optional_text(self.cursor, "Semantic query cursor", 1_000)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SemanticDocumentationSubjectPage:
|
||||
subjects: tuple[SemanticDocumentationSubjectDescriptor, ...] = ()
|
||||
next_cursor: str | None = None
|
||||
has_more: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
keys = tuple(item.reference.stable_key for item in self.subjects)
|
||||
if len(keys) != len(set(keys)):
|
||||
raise SemanticDocumentationContractError(
|
||||
"Semantic subject pages cannot contain duplicate identities."
|
||||
)
|
||||
_optional_text(self.next_cursor, "Semantic subject page cursor", 1_000)
|
||||
if self.has_more and not self.next_cursor:
|
||||
raise SemanticDocumentationContractError(
|
||||
"Semantic subject pages with more results require a cursor."
|
||||
)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SemanticDocumentationSubjectProvider(Protocol):
|
||||
provider_id: str
|
||||
module_id: str
|
||||
contract_version: str
|
||||
|
||||
def list_subjects(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: SemanticDocumentationSubjectQuery,
|
||||
) -> SemanticDocumentationSubjectPage: ...
|
||||
|
||||
def resolve_subject(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
reference: SemanticDocumentationSubjectReference,
|
||||
) -> SemanticDocumentationSubjectResolution | None:
|
||||
"""Return None when the principal may not know whether a subject exists."""
|
||||
|
||||
|
||||
def semantic_documentation_subject_capability(module_id: str) -> str:
|
||||
_require_match(module_id, _MODULE_ID_RE, "Semantic subject module id")
|
||||
return f"{SEMANTIC_DOCUMENTATION_SUBJECT_CAPABILITY_PREFIX}{module_id}"
|
||||
|
||||
|
||||
def semantic_documentation_subject_provider_names(
|
||||
registry: object | None,
|
||||
) -> tuple[str, ...]:
|
||||
if registry is None or not hasattr(registry, "capability_names"):
|
||||
return ()
|
||||
return tuple(
|
||||
str(name)
|
||||
for name in registry.capability_names()
|
||||
if str(name).startswith(SEMANTIC_DOCUMENTATION_SUBJECT_CAPABILITY_PREFIX)
|
||||
)
|
||||
|
||||
|
||||
def semantic_documentation_subject_providers(
|
||||
registry: object | None,
|
||||
) -> tuple[tuple[str, SemanticDocumentationSubjectProvider], ...]:
|
||||
if registry is None or not hasattr(registry, "capability"):
|
||||
return ()
|
||||
providers: list[tuple[str, SemanticDocumentationSubjectProvider]] = []
|
||||
for capability_name in semantic_documentation_subject_provider_names(registry):
|
||||
module_id = capability_name.removeprefix(
|
||||
SEMANTIC_DOCUMENTATION_SUBJECT_CAPABILITY_PREFIX
|
||||
)
|
||||
provider = registry.capability(capability_name)
|
||||
if not isinstance(provider, SemanticDocumentationSubjectProvider):
|
||||
raise TypeError(
|
||||
f"Invalid semantic-documentation provider capability: {capability_name}"
|
||||
)
|
||||
if provider.module_id != module_id:
|
||||
raise SemanticDocumentationContractError(
|
||||
f"Semantic provider module {provider.module_id!r} does not match "
|
||||
f"capability {capability_name!r}."
|
||||
)
|
||||
if (
|
||||
provider.contract_version
|
||||
!= SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION
|
||||
):
|
||||
raise SemanticDocumentationContractError(
|
||||
f"Unsupported semantic-documentation provider contract: "
|
||||
f"{provider.contract_version!r}."
|
||||
)
|
||||
providers.append((module_id, provider))
|
||||
return tuple(providers)
|
||||
|
||||
|
||||
def list_semantic_documentation_subjects(
|
||||
registry: object | None,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: SemanticDocumentationSubjectQuery,
|
||||
) -> tuple[tuple[str, SemanticDocumentationSubjectPage], ...]:
|
||||
if _principal_tenant_id(principal) != request.tenant_id:
|
||||
return ()
|
||||
pages: list[tuple[str, SemanticDocumentationSubjectPage]] = []
|
||||
for module_id, provider in semantic_documentation_subject_providers(registry):
|
||||
page = provider.list_subjects(
|
||||
session,
|
||||
principal,
|
||||
request=request,
|
||||
)
|
||||
if any(
|
||||
subject.reference.module_id != module_id
|
||||
or subject.reference.tenant_id != request.tenant_id
|
||||
for subject in page.subjects
|
||||
):
|
||||
raise SemanticDocumentationContractError(
|
||||
f"Semantic provider {module_id!r} returned a foreign subject."
|
||||
)
|
||||
pages.append((module_id, page))
|
||||
return tuple(pages)
|
||||
|
||||
|
||||
def resolve_semantic_documentation_subject(
|
||||
registry: object | None,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
reference: SemanticDocumentationSubjectReference,
|
||||
) -> SemanticDocumentationSubjectResolution | None:
|
||||
if _principal_tenant_id(principal) != reference.tenant_id:
|
||||
return None
|
||||
capability_name = semantic_documentation_subject_capability(reference.module_id)
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not registry.has_capability(capability_name)
|
||||
):
|
||||
return SemanticDocumentationSubjectResolution(
|
||||
requested_reference=reference,
|
||||
availability="temporarily_unavailable",
|
||||
reason_code="provider_unavailable",
|
||||
)
|
||||
provider = registry.capability(capability_name)
|
||||
if not isinstance(provider, SemanticDocumentationSubjectProvider):
|
||||
raise TypeError(
|
||||
f"Invalid semantic-documentation provider capability: {capability_name}"
|
||||
)
|
||||
if (
|
||||
provider.module_id != reference.module_id
|
||||
or provider.contract_version
|
||||
!= SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION
|
||||
):
|
||||
raise SemanticDocumentationContractError(
|
||||
f"Semantic provider {capability_name!r} does not match the Core contract."
|
||||
)
|
||||
result = provider.resolve_subject(
|
||||
session,
|
||||
principal,
|
||||
reference=reference,
|
||||
)
|
||||
if result is None:
|
||||
return None
|
||||
if result.requested_reference != reference:
|
||||
raise SemanticDocumentationContractError(
|
||||
"Semantic provider returned a resolution for another reference."
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def semantic_documentation_fingerprint(value: object) -> str:
|
||||
try:
|
||||
encoded = json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise SemanticDocumentationContractError(
|
||||
"Semantic fingerprint input must be canonical JSON data."
|
||||
) from exc
|
||||
return f"sha256:{hashlib.sha256(encoded).hexdigest()}"
|
||||
|
||||
|
||||
def _reference_changed(
|
||||
requested: SemanticDocumentationSubjectReference,
|
||||
current: SemanticDocumentationSubjectReference,
|
||||
) -> bool:
|
||||
comparisons = (
|
||||
(requested.observed_revision, current.observed_revision),
|
||||
(requested.observed_fingerprint, current.observed_fingerprint),
|
||||
)
|
||||
return any(expected is not None and expected != actual for expected, actual in comparisons)
|
||||
|
||||
|
||||
def _principal_tenant_id(principal: object) -> str:
|
||||
return str(getattr(principal, "tenant_id", "") or "")
|
||||
|
||||
|
||||
def _localized_text(
|
||||
values: Mapping[str, str],
|
||||
label: str,
|
||||
*,
|
||||
required: bool,
|
||||
limit: int,
|
||||
) -> None:
|
||||
if required and not values:
|
||||
raise SemanticDocumentationContractError(f"{label} are required.")
|
||||
if len(values) > 20:
|
||||
raise SemanticDocumentationContractError(f"{label} are limited to 20 locales.")
|
||||
for locale, value in values.items():
|
||||
if not _LOCALE_RE.fullmatch(str(locale)):
|
||||
raise SemanticDocumentationContractError(
|
||||
f"{label} contain an invalid locale: {locale!r}."
|
||||
)
|
||||
_required_bounded_text(value, f"{label} value", limit)
|
||||
|
||||
|
||||
def _text_tuple(values: Sequence[str], label: str, *, maximum: int) -> None:
|
||||
if len(values) > maximum:
|
||||
raise SemanticDocumentationContractError(
|
||||
f"{label} are limited to {maximum} items."
|
||||
)
|
||||
normalized = tuple(str(value).strip() for value in values)
|
||||
if any(not value or len(value) > 255 for value in normalized):
|
||||
raise SemanticDocumentationContractError(
|
||||
f"{label} must contain non-empty bounded text."
|
||||
)
|
||||
if len(normalized) != len(set(normalized)):
|
||||
raise SemanticDocumentationContractError(f"{label} must be unique.")
|
||||
|
||||
|
||||
def _require_match(value: str, pattern: re.Pattern[str], label: str) -> None:
|
||||
if not isinstance(value, str) or not pattern.fullmatch(value):
|
||||
raise SemanticDocumentationContractError(f"{label} is invalid.")
|
||||
|
||||
|
||||
def _required_bounded_text(value: str, label: str, limit: int) -> None:
|
||||
if not isinstance(value, str) or not value.strip() or len(value) > limit:
|
||||
raise SemanticDocumentationContractError(
|
||||
f"{label} must be non-empty and at most {limit} characters."
|
||||
)
|
||||
if any(ord(character) < 32 and character not in "\n\t" for character in value):
|
||||
raise SemanticDocumentationContractError(f"{label} contains control characters.")
|
||||
|
||||
|
||||
def _optional_text(value: str | None, label: str, limit: int) -> None:
|
||||
if value is not None:
|
||||
_required_bounded_text(value, label, limit)
|
||||
|
||||
|
||||
def _require_keys(
|
||||
value: Mapping[str, object], allowed: set[str], label: str
|
||||
) -> None:
|
||||
unexpected = sorted(str(key) for key in value if str(key) not in allowed)
|
||||
if unexpected:
|
||||
raise SemanticDocumentationContractError(
|
||||
f"{label} contains unsupported fields: {', '.join(unexpected)}."
|
||||
)
|
||||
|
||||
|
||||
def _required_text(value: Mapping[str, object], key: str) -> str:
|
||||
result = value.get(key)
|
||||
if not isinstance(result, str) or not result.strip():
|
||||
raise SemanticDocumentationContractError(
|
||||
f"Semantic subject field {key} is required."
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _mapping_optional_text(value: Mapping[str, object], key: str) -> str | None:
|
||||
result = value.get(key)
|
||||
if result is None:
|
||||
return None
|
||||
if not isinstance(result, str):
|
||||
raise SemanticDocumentationContractError(
|
||||
f"Semantic subject field {key} must be text."
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SEMANTIC_DOCUMENTATION_SUBJECT_CAPABILITY_PREFIX",
|
||||
"SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION",
|
||||
"SemanticDocumentationBreadcrumb",
|
||||
"SemanticDocumentationContractError",
|
||||
"SemanticDocumentationSubjectAnchor",
|
||||
"SemanticDocumentationSubjectAvailability",
|
||||
"SemanticDocumentationSubjectDescriptor",
|
||||
"SemanticDocumentationSubjectPage",
|
||||
"SemanticDocumentationSubjectProvider",
|
||||
"SemanticDocumentationSubjectQuery",
|
||||
"SemanticDocumentationSubjectReference",
|
||||
"SemanticDocumentationSubjectResolution",
|
||||
"list_semantic_documentation_subjects",
|
||||
"resolve_semantic_documentation_subject",
|
||||
"semantic_documentation_fingerprint",
|
||||
"semantic_documentation_subject_capability",
|
||||
"semantic_documentation_subject_provider_names",
|
||||
"semantic_documentation_subject_providers",
|
||||
]
|
||||
@@ -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,7 +133,8 @@ 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"):
|
||||
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
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ LEGACY_TO_MODULE_SCOPES: dict[str, str] = {
|
||||
"system:access:read": "access:system_role:read",
|
||||
"system:access:assign": "access:system_role:assign",
|
||||
"system:audit:read": "access:audit:read",
|
||||
"system:audit:evidence:export": "audit:system_evidence:export",
|
||||
"system:settings:read": "access:system_setting:read",
|
||||
"system:settings:write": "access:system_setting:write",
|
||||
"system:maintenance:access": "access:maintenance:access",
|
||||
|
||||
@@ -262,6 +262,16 @@ class Settings(BaseSettings):
|
||||
le=90,
|
||||
alias="SCHEDULING_CANCELLATION_NOTICE_DAYS",
|
||||
)
|
||||
scheduling_public_self_enrollment_enabled: bool = Field(
|
||||
default=True,
|
||||
alias="SCHEDULING_PUBLIC_SELF_ENROLLMENT_ENABLED",
|
||||
)
|
||||
scheduling_public_self_enrollment_max_capacity: int = Field(
|
||||
default=10_000,
|
||||
ge=1,
|
||||
le=10_000,
|
||||
alias="SCHEDULING_PUBLIC_SELF_ENROLLMENT_MAX_CAPACITY",
|
||||
)
|
||||
mock_mailbox_dir: str = Field(default="runtime/mock-mailbox", alias="MOCK_MAILBOX_DIR")
|
||||
|
||||
# Development bootstrap only. Do not use this in production.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -5506,6 +5506,67 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
self.assertEqual(deleted_delta.status_code, 200, deleted_delta.text)
|
||||
self.assertTrue(any(item["id"] == template_id and item["resource_type"] == "governance_template" for item in deleted_delta.json()["deleted"]))
|
||||
|
||||
def test_governance_template_bulk_synchronization_previews_and_applies(self) -> None:
|
||||
headers, login = self._login()
|
||||
tenant_id = login["tenant"]["id"]
|
||||
approver_headers = self._create_system_approver(
|
||||
headers,
|
||||
tenant_id=tenant_id,
|
||||
email="governance-sync-approver@example.local",
|
||||
)
|
||||
create_payload = {
|
||||
"kind": "role",
|
||||
"slug": "governance-sync",
|
||||
"name": "Governance Sync",
|
||||
"description": "Bulk reconciliation contract",
|
||||
"permissions": ["admin:roles:read"],
|
||||
"is_active": True,
|
||||
"assignments": [{"tenant_id": tenant_id, "mode": "required"}],
|
||||
}
|
||||
change_request_id = self._approved_configuration_change(
|
||||
headers,
|
||||
approver_headers,
|
||||
key="governance_templates",
|
||||
value=create_payload,
|
||||
target={"kind": "role", "slug": "governance-sync"},
|
||||
)
|
||||
created = self.client.post(
|
||||
"/api/v1/admin/system/governance-templates",
|
||||
headers=headers,
|
||||
json={**create_payload, "change_request_id": change_request_id},
|
||||
)
|
||||
self.assertEqual(201, created.status_code, created.text)
|
||||
template_id = created.json()["id"]
|
||||
|
||||
preview = self.client.post(
|
||||
"/api/v1/admin/system/governance-templates/synchronize",
|
||||
headers=headers,
|
||||
json={"template_ids": [template_id], "dry_run": True},
|
||||
)
|
||||
self.assertEqual(200, preview.status_code, preview.text)
|
||||
self.assertTrue(preview.json()["dry_run"])
|
||||
self.assertEqual({"unchanged": 1}, preview.json()["counts"])
|
||||
self.assertEqual("1", preview.json()["version"])
|
||||
self.assertEqual("admin.bulk-synchronization", preview.json()["outcomes"][0]["provenance"]["source"])
|
||||
|
||||
applied = self.client.post(
|
||||
"/api/v1/admin/system/governance-templates/synchronize",
|
||||
headers=headers,
|
||||
json={"template_ids": [template_id], "dry_run": False},
|
||||
)
|
||||
self.assertEqual(200, applied.status_code, applied.text)
|
||||
self.assertFalse(applied.json()["dry_run"])
|
||||
self.assertEqual({"unchanged": 1}, applied.json()["counts"])
|
||||
|
||||
audit = self.client.get(
|
||||
"/api/v1/admin/audit",
|
||||
headers=headers,
|
||||
params={"all_tenants": True, "limit": 500},
|
||||
)
|
||||
actions = {item["action"] for item in audit.json()["items"]}
|
||||
self.assertIn("governance_template.synchronization_previewed", actions)
|
||||
self.assertIn("governance_template.synchronized", actions)
|
||||
|
||||
def test_module_installer_history_supports_cursor_windows(self) -> None:
|
||||
from govoplan_core.core.module_installer import default_installer_runtime_dir
|
||||
|
||||
@@ -6158,6 +6219,121 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
self.assertEqual(campaign_source["path"], f"campaign:{campaign_id}")
|
||||
self.assertIn("allow_campaign_profiles", campaign_source["applied_fields"])
|
||||
|
||||
def test_policy_impact_preview_is_bounded_audited_and_linked_to_commit(self) -> None:
|
||||
headers, _ = self._login()
|
||||
proposed_policy = {"visible_surface_ids": []}
|
||||
|
||||
preview = self.client.post(
|
||||
"/api/v1/admin/policy-impact/preview",
|
||||
headers=headers,
|
||||
json={
|
||||
"policy_family": "view",
|
||||
"scope_type": "tenant",
|
||||
"proposed_policy": proposed_policy,
|
||||
"populations": [
|
||||
{
|
||||
"provider_id": "views",
|
||||
"selector": {
|
||||
"include_views": False,
|
||||
"include_surfaces": True,
|
||||
},
|
||||
"limit": 10,
|
||||
}
|
||||
],
|
||||
"include_details": True,
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, preview.status_code, preview.text)
|
||||
preview_payload = preview.json()
|
||||
self.assertEqual(10, preview_payload["counts"]["newly_denied"])
|
||||
self.assertEqual("truncated", preview_payload["populations"][0]["state"])
|
||||
self.assertEqual(10, len(preview_payload["effects"]))
|
||||
|
||||
unchanged = self.client.get(
|
||||
"/api/v1/admin/view-policies/tenant",
|
||||
headers=headers,
|
||||
)
|
||||
self.assertEqual(200, unchanged.status_code, unchanged.text)
|
||||
self.assertEqual({}, unchanged.json()["policy"])
|
||||
|
||||
committed = self.client.put(
|
||||
"/api/v1/admin/view-policies/tenant",
|
||||
headers=headers,
|
||||
json={
|
||||
"policy": proposed_policy,
|
||||
"impact_preview_id": preview_payload["preview_id"],
|
||||
"impact_proposal_hash": preview_payload["proposal_hash"],
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, committed.status_code, committed.text)
|
||||
self.assertEqual([], committed.json()["policy"]["visible_surface_ids"])
|
||||
|
||||
from govoplan_audit.backend.db.models import AuditLog
|
||||
|
||||
with SessionLocal() as session:
|
||||
audit_rows = (
|
||||
session.query(AuditLog)
|
||||
.filter(
|
||||
AuditLog.action.in_(
|
||||
("policy.impact_previewed", "view_policy.updated")
|
||||
)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
by_action = {row.action: row for row in audit_rows}
|
||||
self.assertEqual(
|
||||
preview_payload["proposal_hash"],
|
||||
by_action["policy.impact_previewed"].details["proposal_hash"],
|
||||
)
|
||||
self.assertEqual(
|
||||
preview_payload["preview_id"],
|
||||
by_action["view_policy.updated"].details["impact_preview_id"],
|
||||
)
|
||||
|
||||
stale = self.client.put(
|
||||
"/api/v1/admin/view-policies/tenant",
|
||||
headers=headers,
|
||||
json={
|
||||
"policy": {"allow_edit": False},
|
||||
"impact_preview_id": preview_payload["preview_id"],
|
||||
"impact_proposal_hash": preview_payload["proposal_hash"],
|
||||
},
|
||||
)
|
||||
self.assertEqual(409, stale.status_code, stale.text)
|
||||
self.assertEqual("policy_impact_preview_stale", stale.json()["detail"]["code"])
|
||||
|
||||
inherited_preview = self.client.post(
|
||||
"/api/v1/admin/policy-impact/preview",
|
||||
headers=headers,
|
||||
json={
|
||||
"policy_family": "view",
|
||||
"scope_type": "tenant",
|
||||
"proposed_policy": {},
|
||||
"populations": [
|
||||
{
|
||||
"provider_id": "views",
|
||||
"selector": {
|
||||
"include_views": False,
|
||||
"include_surfaces": True,
|
||||
},
|
||||
"limit": 10,
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, inherited_preview.status_code, inherited_preview.text)
|
||||
inherited_payload = inherited_preview.json()
|
||||
removed = self.client.delete(
|
||||
"/api/v1/admin/view-policies/tenant",
|
||||
headers=headers,
|
||||
params={
|
||||
"impact_preview_id": inherited_payload["preview_id"],
|
||||
"impact_proposal_hash": inherited_payload["proposal_hash"],
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, removed.status_code, removed.text)
|
||||
self.assertEqual({}, removed.json()["policy"])
|
||||
|
||||
def test_campaign_scoped_mail_profile_policy_is_enforced(self) -> None:
|
||||
headers, _ = self._login()
|
||||
created = self.client.post(
|
||||
@@ -6556,6 +6732,93 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
self.assertEqual(final_profile.json()["user"]["appearance"]["palette"], "civic_blue")
|
||||
self.assertEqual(final_profile.json()["user"]["appearance"]["source"], "system_lock")
|
||||
|
||||
def test_governed_custom_appearance_overrides_are_atomic_and_removable(self) -> None:
|
||||
headers, _ = self._login()
|
||||
document = {
|
||||
"schema_version": "1",
|
||||
"light": {
|
||||
"accent": "#245f91", "accent_foreground": "#ffffff",
|
||||
"surface": "#ffffff", "surface_foreground": "#303135",
|
||||
"success": "#d8eee8", "success_foreground": "#315f55",
|
||||
"info": "#dce9f3", "info_foreground": "#294a61",
|
||||
"warning": "#ffe1a3", "warning_foreground": "#593700",
|
||||
"danger": "#f8d1cc", "danger_foreground": "#873c35",
|
||||
},
|
||||
"dark": {
|
||||
"accent": "#7ea6c5", "accent_foreground": "#242424",
|
||||
"surface": "#262724", "surface_foreground": "#f1f1f1",
|
||||
"success": "#24473f", "success_foreground": "#d8eee8",
|
||||
"info": "#243d4e", "info_foreground": "#dce9f3",
|
||||
"warning": "#5a431f", "warning_foreground": "#ffe1a3",
|
||||
"danger": "#4f2d2a", "danger_foreground": "#f8d1cc",
|
||||
},
|
||||
}
|
||||
initially_denied = self.client.patch(
|
||||
"/api/v1/auth/profile",
|
||||
headers=headers,
|
||||
json={"ui_preferences": {"appearance_overrides": document}},
|
||||
)
|
||||
self.assertEqual(initially_denied.status_code, 422, initially_denied.text)
|
||||
|
||||
system = self.client.get("/api/v1/admin/system/settings", headers=headers).json()
|
||||
enabled = self.client.patch(
|
||||
"/api/v1/admin/system/settings",
|
||||
headers=headers,
|
||||
json={
|
||||
"default_locale": system["default_locale"],
|
||||
"allow_tenant_custom_groups": system["allow_tenant_custom_groups"],
|
||||
"allow_tenant_custom_roles": system["allow_tenant_custom_roles"],
|
||||
"allow_tenant_api_keys": system["allow_tenant_api_keys"],
|
||||
"appearance_custom_overrides_allowed": True,
|
||||
},
|
||||
)
|
||||
self.assertEqual(enabled.status_code, 200, enabled.text)
|
||||
self.assertTrue(enabled.json()["appearance_custom_overrides_allowed"])
|
||||
|
||||
tenant = self.client.get("/api/v1/admin/tenant/settings", headers=headers).json()
|
||||
self.assertIsNone(tenant["appearance_custom_overrides_allowed"])
|
||||
self.assertTrue(tenant["effective_appearance_custom_overrides_allowed"])
|
||||
saved = self.client.patch(
|
||||
"/api/v1/auth/profile",
|
||||
headers=headers,
|
||||
json={"ui_preferences": {"appearance_overrides": document}},
|
||||
)
|
||||
self.assertEqual(saved.status_code, 200, saved.text)
|
||||
self.assertEqual(saved.json()["user"]["appearance"]["custom_overrides"], document)
|
||||
|
||||
invalid = {
|
||||
**document,
|
||||
"light": {**document["light"], "accent_foreground": document["light"]["accent"]},
|
||||
}
|
||||
rejected = self.client.patch(
|
||||
"/api/v1/auth/profile",
|
||||
headers=headers,
|
||||
json={"ui_preferences": {"appearance_overrides": invalid}},
|
||||
)
|
||||
self.assertEqual(rejected.status_code, 422, rejected.text)
|
||||
unchanged = self.client.get("/api/v1/auth/profile", headers=headers).json()
|
||||
self.assertEqual(unchanged["user"]["appearance"]["custom_overrides"], document)
|
||||
|
||||
blocked = self.client.patch(
|
||||
"/api/v1/admin/tenant/settings",
|
||||
headers=headers,
|
||||
json={
|
||||
"default_locale": tenant["default_locale"],
|
||||
"appearance_custom_overrides_allowed": False,
|
||||
},
|
||||
)
|
||||
self.assertEqual(blocked.status_code, 200, blocked.text)
|
||||
self.assertFalse(blocked.json()["effective_appearance_custom_overrides_allowed"])
|
||||
inactive = self.client.get("/api/v1/auth/profile", headers=headers).json()
|
||||
self.assertIsNone(inactive["user"]["appearance"]["custom_overrides"])
|
||||
removed = self.client.patch(
|
||||
"/api/v1/auth/profile",
|
||||
headers=headers,
|
||||
json={"ui_preferences": {"appearance_overrides": None}},
|
||||
)
|
||||
self.assertEqual(removed.status_code, 200, removed.text)
|
||||
self.assertIsNone(removed.json()["user"]["ui_preferences"]["appearance_overrides"])
|
||||
|
||||
def test_profile_refresh_and_system_role_protection_model(self) -> None:
|
||||
headers, _ = self._login()
|
||||
profile = self.client.patch(
|
||||
@@ -6591,6 +6854,7 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
"sticky_section_sidebars": False,
|
||||
"theme": "dark",
|
||||
"palette": "civic_blue",
|
||||
"appearance_overrides": None,
|
||||
"navigation": {
|
||||
"contract_version": "1",
|
||||
"order": ["files.navigation.files", "mail.navigation.mail"],
|
||||
|
||||
@@ -1,6 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.appearance import resolve_effective_appearance, update_appearance_settings
|
||||
import pytest
|
||||
|
||||
from govoplan_core.core.appearance import (
|
||||
normalize_appearance_overrides,
|
||||
resolve_effective_appearance,
|
||||
update_appearance_custom_overrides_policy,
|
||||
update_appearance_settings,
|
||||
)
|
||||
|
||||
|
||||
def _overrides() -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": "1",
|
||||
"light": {
|
||||
"accent": "#245f91", "accent_foreground": "#ffffff",
|
||||
"surface": "#ffffff", "surface_foreground": "#303135",
|
||||
"success": "#d8eee8", "success_foreground": "#315f55",
|
||||
"info": "#dce9f3", "info_foreground": "#294a61",
|
||||
"warning": "#ffe1a3", "warning_foreground": "#593700",
|
||||
"danger": "#f8d1cc", "danger_foreground": "#873c35",
|
||||
},
|
||||
"dark": {
|
||||
"accent": "#7ea6c5", "accent_foreground": "#242424",
|
||||
"surface": "#262724", "surface_foreground": "#f1f1f1",
|
||||
"success": "#24473f", "success_foreground": "#d8eee8",
|
||||
"info": "#243d4e", "info_foreground": "#dce9f3",
|
||||
"warning": "#5a431f", "warning_foreground": "#ffe1a3",
|
||||
"danger": "#4f2d2a", "danger_foreground": "#f8d1cc",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_appearance_precedence_and_inheritance() -> None:
|
||||
@@ -32,6 +61,8 @@ def test_system_lock_wins_and_invalid_values_fail_safe() -> None:
|
||||
"system_default_palette": "civic_blue",
|
||||
"tenant_default_palette": "forest",
|
||||
"inherited_palette": "civic_blue",
|
||||
"custom_overrides": None,
|
||||
"custom_overrides_allowed": False,
|
||||
}
|
||||
fallback = resolve_effective_appearance(
|
||||
system_settings={"appearance": {"default_palette": "unsafe"}},
|
||||
@@ -52,3 +83,45 @@ def test_appearance_settings_reset_without_touching_neighbors() -> None:
|
||||
assert update_appearance_settings(configured, default_palette=None, palette_locked=False) == {
|
||||
"neighbor": {"kept": True}
|
||||
}
|
||||
|
||||
|
||||
def test_custom_overrides_require_system_and_tenant_policy_and_validate_both_modes() -> None:
|
||||
document = _overrides()
|
||||
normalized = normalize_appearance_overrides(document)
|
||||
assert normalized == document
|
||||
decision = resolve_effective_appearance(
|
||||
system_settings={"appearance": {"allow_custom_overrides": True}},
|
||||
tenant_settings={"appearance": {"allow_custom_overrides": True}},
|
||||
user_settings={"ui": {"appearance_overrides": document}},
|
||||
)
|
||||
assert decision.custom_overrides_allowed is True
|
||||
assert decision.custom_overrides == document
|
||||
|
||||
blocked = resolve_effective_appearance(
|
||||
system_settings={"appearance": {"allow_custom_overrides": True}},
|
||||
tenant_settings={"appearance": {"allow_custom_overrides": False}},
|
||||
user_settings={"ui": {"appearance_overrides": document}},
|
||||
)
|
||||
assert blocked.custom_overrides_allowed is False
|
||||
assert blocked.custom_overrides is None
|
||||
|
||||
invalid = _overrides()
|
||||
invalid["dark"]["danger"] = invalid["dark"]["warning"] # type: ignore[index]
|
||||
with pytest.raises(ValueError, match="visibly distinct"):
|
||||
normalize_appearance_overrides(invalid)
|
||||
|
||||
low_contrast = _overrides()
|
||||
low_contrast["light"]["accent_foreground"] = "#245f91" # type: ignore[index]
|
||||
with pytest.raises(ValueError, match="WCAG AA"):
|
||||
normalize_appearance_overrides(low_contrast)
|
||||
|
||||
|
||||
def test_custom_override_policy_update_preserves_neighboring_appearance_settings() -> None:
|
||||
configured = update_appearance_custom_overrides_policy(
|
||||
{"appearance": {"default_palette": "forest"}},
|
||||
allowed=True,
|
||||
)
|
||||
assert configured == {"appearance": {"default_palette": "forest", "allow_custom_overrides": True}}
|
||||
assert update_appearance_custom_overrides_policy(configured, allowed=None) == {
|
||||
"appearance": {"default_palette": "forest"}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from contextlib import contextmanager
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from govoplan_core.celery_app import celery, dispatch_campaign_schedules
|
||||
from tests.worker_test_support import allowed_worker_admissions
|
||||
|
||||
|
||||
class CampaignScheduleWorkerTests(unittest.TestCase):
|
||||
def test_dispatch_preserves_autonomous_recovery_result(self) -> None:
|
||||
session = MagicMock()
|
||||
|
||||
@contextmanager
|
||||
def session_scope():
|
||||
yield session
|
||||
|
||||
provider = MagicMock()
|
||||
provider.dispatch_due.return_value = {
|
||||
"selected": 1,
|
||||
"prepared": 0,
|
||||
"autonomous_prepared": 1,
|
||||
"failed": 0,
|
||||
"completed": 0,
|
||||
"coalesced": 0,
|
||||
"duplicates": 0,
|
||||
"deferred": 0,
|
||||
"campaign_ids": ["campaign-1"],
|
||||
"operator_actions": [],
|
||||
"refreshed": {
|
||||
"checked": 1,
|
||||
"accepted": 1,
|
||||
"uncertain": 0,
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
},
|
||||
}
|
||||
registry = MagicMock()
|
||||
registry.has_capability.return_value = True
|
||||
database = SimpleNamespace(SessionLocal=session_scope)
|
||||
|
||||
with (
|
||||
patch("govoplan_core.celery_app._platform_registry", return_value=registry),
|
||||
patch("govoplan_core.celery_app._campaign_schedules", return_value=provider),
|
||||
patch(
|
||||
"govoplan_core.celery_app._worker_admissions",
|
||||
side_effect=allowed_worker_admissions,
|
||||
),
|
||||
patch("govoplan_core.db.session.get_database", return_value=database),
|
||||
):
|
||||
result = dispatch_campaign_schedules.run("tenant-1", 17)
|
||||
|
||||
provider.dispatch_due.assert_called_once_with(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
limit=17,
|
||||
)
|
||||
session.commit.assert_called_once_with()
|
||||
self.assertEqual(result["autonomous_prepared"], 1)
|
||||
self.assertEqual(result["refreshed"]["accepted"], 1)
|
||||
|
||||
def test_missing_provider_returns_complete_autonomous_defaults(self) -> None:
|
||||
session = MagicMock()
|
||||
|
||||
@contextmanager
|
||||
def session_scope():
|
||||
yield session
|
||||
|
||||
registry = MagicMock()
|
||||
registry.has_capability.return_value = False
|
||||
database = SimpleNamespace(SessionLocal=session_scope)
|
||||
|
||||
with (
|
||||
patch("govoplan_core.celery_app._platform_registry", return_value=registry),
|
||||
patch("govoplan_core.db.session.get_database", return_value=database),
|
||||
):
|
||||
result = dispatch_campaign_schedules.run("tenant-1", 17)
|
||||
|
||||
self.assertEqual(result["autonomous_prepared"], 0)
|
||||
self.assertEqual(result["duplicates"], 0)
|
||||
self.assertEqual(result["deferred"], 0)
|
||||
self.assertEqual(
|
||||
result["refreshed"],
|
||||
{
|
||||
"checked": 0,
|
||||
"accepted": 0,
|
||||
"uncertain": 0,
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
},
|
||||
)
|
||||
|
||||
def test_worker_route_and_periodic_dispatch_are_registered(self) -> None:
|
||||
self.assertEqual(
|
||||
celery.conf.task_routes["govoplan.campaigns.dispatch_schedules"],
|
||||
{"queue": "default"},
|
||||
)
|
||||
schedule = celery.conf.beat_schedule["campaign-schedules-every-minute"]
|
||||
self.assertEqual(schedule["task"], "govoplan.campaigns.dispatch_schedules")
|
||||
self.assertEqual(schedule["schedule"], 60.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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"}
|
||||
|
||||
@@ -3,13 +3,17 @@ from __future__ import annotations
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.datasources import (
|
||||
CAPABILITY_DATASOURCE_ARTIFACT_BACKENDS,
|
||||
CAPABILITY_DATASOURCE_CATALOGUE,
|
||||
CAPABILITY_DATASOURCE_LIFECYCLE,
|
||||
CAPABILITY_DATASOURCE_ORIGINS,
|
||||
CAPABILITY_DATASOURCE_PUBLICATION,
|
||||
CAPABILITY_POLICY_DATASOURCE_VISIBILITY,
|
||||
DatasourceCatalogueProvider,
|
||||
DatasourceArtifactBackendProvider,
|
||||
DatasourceDescriptor,
|
||||
DatasourceField,
|
||||
DatasourceGovernance,
|
||||
DatasourceLifecycleProvider,
|
||||
DatasourceMaterialization,
|
||||
DatasourceOrigin,
|
||||
@@ -19,10 +23,14 @@ from govoplan_core.core.datasources import (
|
||||
DatasourcePublicationResult,
|
||||
DatasourceReadResult,
|
||||
DatasourceStage,
|
||||
DatasourceVisibilityPolicyDecision,
|
||||
DatasourceVisibilityPolicyProvider,
|
||||
datasource_catalogue,
|
||||
datasource_artifact_backend_provider,
|
||||
datasource_lifecycle,
|
||||
datasource_origins,
|
||||
datasource_publication,
|
||||
datasource_visibility_policy_provider,
|
||||
)
|
||||
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
@@ -155,6 +163,13 @@ class _Provider:
|
||||
truncated=False,
|
||||
)
|
||||
|
||||
def artifact_backends(self):
|
||||
return ()
|
||||
|
||||
def decide_datasource_visibility(self, session, *, request):
|
||||
del session, request
|
||||
return DatasourceVisibilityPolicyDecision(allowed=True)
|
||||
|
||||
|
||||
class DatasourceContractTests(unittest.TestCase):
|
||||
def test_capabilities_are_runtime_checkable_and_resolved_without_modules(self) -> None:
|
||||
@@ -163,6 +178,8 @@ class DatasourceContractTests(unittest.TestCase):
|
||||
self.assertIsInstance(provider, DatasourceLifecycleProvider)
|
||||
self.assertIsInstance(provider, DatasourcePublicationProvider)
|
||||
self.assertIsInstance(provider, DatasourceOriginProvider)
|
||||
self.assertIsInstance(provider, DatasourceArtifactBackendProvider)
|
||||
self.assertIsInstance(provider, DatasourceVisibilityPolicyProvider)
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
@@ -174,6 +191,8 @@ class DatasourceContractTests(unittest.TestCase):
|
||||
CAPABILITY_DATASOURCE_LIFECYCLE: lambda context: provider,
|
||||
CAPABILITY_DATASOURCE_PUBLICATION: lambda context: provider,
|
||||
CAPABILITY_DATASOURCE_ORIGINS: lambda context: provider,
|
||||
CAPABILITY_DATASOURCE_ARTIFACT_BACKENDS: lambda context: provider,
|
||||
CAPABILITY_POLICY_DATASOURCE_VISIBILITY: lambda context: provider,
|
||||
},
|
||||
)
|
||||
)
|
||||
@@ -183,6 +202,8 @@ class DatasourceContractTests(unittest.TestCase):
|
||||
self.assertIs(provider, datasource_lifecycle(registry))
|
||||
self.assertIs(provider, datasource_publication(registry))
|
||||
self.assertIs(provider, datasource_origins(registry))
|
||||
self.assertIs(provider, datasource_artifact_backend_provider(registry))
|
||||
self.assertIs(provider, datasource_visibility_policy_provider(registry))
|
||||
self.assertIsNone(datasource_catalogue(PlatformRegistry()))
|
||||
|
||||
def test_descriptor_distinguishes_mode_kind_shape_and_materialization(self) -> None:
|
||||
@@ -192,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,6 +10,7 @@ from govoplan_core.core.modules import (
|
||||
DocumentationSourceDefinition,
|
||||
DocumentationTopic,
|
||||
ModuleManifest,
|
||||
localized_documentation_metadata,
|
||||
user_workflow_scope_condition_issues,
|
||||
)
|
||||
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
||||
@@ -83,6 +84,58 @@ 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_documentation_configuration_and_source_extensions_are_validated(self) -> None:
|
||||
resolver = lambda _context, keys: { # noqa: E731
|
||||
key: DocumentationConfigurationDecision(key=key, state="enabled")
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from govoplan_core.core.files import (
|
||||
CAPABILITY_FILES_TABULAR_CONTENT,
|
||||
ManagedTabularFile,
|
||||
ManagedTabularFileContent,
|
||||
ManagedTabularFileProvider,
|
||||
managed_tabular_file_provider,
|
||||
)
|
||||
|
||||
|
||||
class _Provider:
|
||||
def list_tabular_files(self, session, principal, *, query="", limit=100):
|
||||
del session, principal, query, limit
|
||||
return ()
|
||||
|
||||
def get_tabular_file(
|
||||
self,
|
||||
session,
|
||||
principal,
|
||||
*,
|
||||
file_asset_id,
|
||||
file_version_id=None,
|
||||
):
|
||||
del session, principal, file_asset_id, file_version_id
|
||||
return None
|
||||
|
||||
def read_tabular_file(
|
||||
self,
|
||||
session,
|
||||
principal,
|
||||
*,
|
||||
file_asset_id,
|
||||
file_version_id,
|
||||
max_bytes,
|
||||
):
|
||||
del session, principal, file_asset_id, file_version_id, max_bytes
|
||||
file = ManagedTabularFile(
|
||||
file_asset_id="asset-1",
|
||||
file_version_id="version-1",
|
||||
filename="source.csv",
|
||||
display_path="Imports/source.csv",
|
||||
content_type="text/csv",
|
||||
size_bytes=8,
|
||||
sha256="a" * 64,
|
||||
updated_at=datetime(2026, 8, 21, tzinfo=UTC),
|
||||
)
|
||||
return ManagedTabularFileContent(file=file, payload=b"id\n1\n")
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, provider):
|
||||
self.provider = provider
|
||||
|
||||
def has_capability(self, name):
|
||||
return name == CAPABILITY_FILES_TABULAR_CONTENT
|
||||
|
||||
def require_capability(self, name):
|
||||
if not self.has_capability(name):
|
||||
raise KeyError(name)
|
||||
return self.provider
|
||||
|
||||
|
||||
class ManagedTabularFileContractTests(unittest.TestCase):
|
||||
def test_runtime_protocol_and_registry_resolution(self) -> None:
|
||||
provider = _Provider()
|
||||
|
||||
self.assertIsInstance(provider, ManagedTabularFileProvider)
|
||||
self.assertIs(provider, managed_tabular_file_provider(_Registry(provider)))
|
||||
self.assertIsNone(managed_tabular_file_provider(None))
|
||||
|
||||
def test_exact_version_content_retains_safe_metadata(self) -> None:
|
||||
result = _Provider().read_tabular_file(
|
||||
object(),
|
||||
object(),
|
||||
file_asset_id="asset-1",
|
||||
file_version_id="version-1",
|
||||
max_bytes=100,
|
||||
)
|
||||
|
||||
self.assertEqual("version-1", result.file.file_version_id)
|
||||
self.assertEqual("a" * 64, result.file.sha256)
|
||||
self.assertEqual(b"id\n1\n", result.payload)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.access import (
|
||||
AccessGovernanceProjectionV1,
|
||||
GovernanceProjectionBatch,
|
||||
GovernanceProjectionCommand,
|
||||
GovernanceProjectionResult,
|
||||
GovernanceTemplateMaterialization,
|
||||
)
|
||||
|
||||
|
||||
def _command(assignment_id: str) -> GovernanceProjectionCommand:
|
||||
return GovernanceProjectionCommand(
|
||||
assignment_id=assignment_id,
|
||||
operation="upsert",
|
||||
template=GovernanceTemplateMaterialization(
|
||||
template_id="template-1",
|
||||
kind="role",
|
||||
tenant_id=f"tenant-{assignment_id}",
|
||||
slug="reader",
|
||||
name="Reader",
|
||||
),
|
||||
provenance={"source": "contract-test"},
|
||||
)
|
||||
|
||||
|
||||
class _Projection:
|
||||
def reconcile(self, session: object, batch: GovernanceProjectionBatch) -> GovernanceProjectionResult:
|
||||
del session
|
||||
return GovernanceProjectionResult(
|
||||
operation_id=batch.operation_id,
|
||||
outcomes=(),
|
||||
dry_run=batch.dry_run,
|
||||
)
|
||||
|
||||
|
||||
class GovernanceProjectionContractTests(unittest.TestCase):
|
||||
def test_protocol_is_runtime_checkable(self) -> None:
|
||||
self.assertIsInstance(_Projection(), AccessGovernanceProjectionV1)
|
||||
|
||||
def test_batch_rejects_duplicate_assignment_ids(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "unique"):
|
||||
GovernanceProjectionBatch(
|
||||
operation_id="duplicate",
|
||||
commands=(_command("same"), _command("same")),
|
||||
)
|
||||
|
||||
def test_batch_enforces_bounds(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "between 1 and 500"):
|
||||
GovernanceProjectionBatch(operation_id="empty", commands=())
|
||||
with self.assertRaisesRegex(ValueError, "between 1 and 500"):
|
||||
GovernanceProjectionBatch(
|
||||
operation_id="large",
|
||||
commands=tuple(_command(str(index)) for index in range(501)),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
|
||||
@@ -76,6 +76,26 @@ class InstallConfigTests(unittest.TestCase):
|
||||
with self.subTest(invalid=invalid), self.assertRaises(ValidationError):
|
||||
Settings(SCHEDULING_CANCELLATION_NOTICE_DAYS=invalid)
|
||||
|
||||
def test_scheduling_public_self_enrollment_policy_is_configurable(self) -> None:
|
||||
defaults = Settings()
|
||||
self.assertTrue(defaults.scheduling_public_self_enrollment_enabled)
|
||||
self.assertEqual(
|
||||
defaults.scheduling_public_self_enrollment_max_capacity,
|
||||
10_000,
|
||||
)
|
||||
configured = Settings(
|
||||
SCHEDULING_PUBLIC_SELF_ENROLLMENT_ENABLED="false",
|
||||
SCHEDULING_PUBLIC_SELF_ENROLLMENT_MAX_CAPACITY="250",
|
||||
)
|
||||
self.assertFalse(configured.scheduling_public_self_enrollment_enabled)
|
||||
self.assertEqual(
|
||||
configured.scheduling_public_self_enrollment_max_capacity,
|
||||
250,
|
||||
)
|
||||
for invalid in ("0", "10001"):
|
||||
with self.subTest(invalid=invalid), self.assertRaises(ValidationError):
|
||||
Settings(SCHEDULING_PUBLIC_SELF_ENROLLMENT_MAX_CAPACITY=invalid)
|
||||
|
||||
def test_self_hosted_validation_reports_actionable_missing_settings(self) -> None:
|
||||
result = validate_runtime_configuration({}, profile="self-hosted")
|
||||
|
||||
@@ -246,6 +266,8 @@ class InstallConfigTests(unittest.TestCase):
|
||||
self.assertIn("MASTER_KEY_B64=<generate", self_hosted)
|
||||
self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", self_hosted)
|
||||
self.assertIn("SCHEDULING_CANCELLATION_NOTICE_DAYS=30", self_hosted)
|
||||
self.assertIn("SCHEDULING_PUBLIC_SELF_ENROLLMENT_ENABLED=true", self_hosted)
|
||||
self.assertIn("SCHEDULING_PUBLIC_SELF_ENROLLMENT_MAX_CAPACITY=10000", self_hosted)
|
||||
self.assertIn("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=false", self_hosted)
|
||||
self.assertIn("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=", self_hosted)
|
||||
self.assertIn("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=", self_hosted)
|
||||
@@ -258,6 +280,8 @@ class InstallConfigTests(unittest.TestCase):
|
||||
self.assertNotIn("MASTER_KEY_B64=<generate", production_like)
|
||||
self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", production_like)
|
||||
self.assertIn("SCHEDULING_CANCELLATION_NOTICE_DAYS=30", production_like)
|
||||
self.assertIn("SCHEDULING_PUBLIC_SELF_ENROLLMENT_ENABLED=true", production_like)
|
||||
self.assertIn("SCHEDULING_PUBLIC_SELF_ENROLLMENT_MAX_CAPACITY=10000", production_like)
|
||||
self.assertIn("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true", production_like)
|
||||
self.assertIn("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=", production_like)
|
||||
self.assertIn("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=", production_like)
|
||||
|
||||
@@ -10,6 +10,7 @@ from fastapi import APIRouter, Depends, FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal
|
||||
from govoplan_core.admin.models import SystemSettings
|
||||
from govoplan_core.celery_app import _run_tenant_worker_batches
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.lifecycle import require_module_active
|
||||
@@ -26,6 +27,7 @@ from govoplan_core.core.module_entitlements import (
|
||||
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.db.session import configure_database, get_database
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.server.platform import create_platform_router
|
||||
from govoplan_core.tenancy.scope import Tenant, create_scope_tables
|
||||
|
||||
@@ -257,6 +259,10 @@ class TenantModuleEntitlementRouteTests(unittest.TestCase):
|
||||
root = Path(tempfile.mkdtemp(prefix="govoplan-entitlement-test-"))
|
||||
configure_database(f"sqlite:///{root / 'test.db'}")
|
||||
create_scope_tables(get_database().engine)
|
||||
Base.metadata.create_all(
|
||||
bind=get_database().engine,
|
||||
tables=[SystemSettings.__table__],
|
||||
)
|
||||
self.manifests = (
|
||||
ModuleManifest(id="access", name="Access", version="test"),
|
||||
ModuleManifest(
|
||||
|
||||
@@ -296,6 +296,9 @@ class ModuleSystemTests(unittest.TestCase):
|
||||
"approvals",
|
||||
"reporting",
|
||||
"search",
|
||||
"organizations",
|
||||
"idm",
|
||||
"tasks",
|
||||
),
|
||||
)
|
||||
self.assertEqual(manifests["dashboard"].dependencies, ())
|
||||
@@ -342,7 +345,25 @@ class ModuleSystemTests(unittest.TestCase):
|
||||
self.assertTrue(scopes_grant_compatible(["admin:users:read"], "access:membership:read"))
|
||||
self.assertTrue(scopes_grant_compatible(["access:tenant:read"], "system:tenants:read"))
|
||||
self.assertTrue(scopes_grant_compatible(["system:*"], "access:tenant:read"))
|
||||
self.assertTrue(
|
||||
scopes_grant_compatible(
|
||||
["system:*"],
|
||||
"audit:system_evidence:export",
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
scopes_grant_compatible(
|
||||
["system:audit:evidence:export"],
|
||||
"audit:system_evidence:export",
|
||||
)
|
||||
)
|
||||
self.assertFalse(scopes_grant_compatible(["tenant:*"], "system:tenants:read"))
|
||||
self.assertFalse(
|
||||
scopes_grant_compatible(
|
||||
["tenant:*"],
|
||||
"audit:system_evidence:export",
|
||||
)
|
||||
)
|
||||
self.assertFalse(scopes_grant_compatible(["tenant:*"], "access:tenant:read"))
|
||||
|
||||
def test_core_webui_retired_legacy_admin_api_surface(self) -> None:
|
||||
|
||||
@@ -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,91 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.policy import (
|
||||
CAPABILITY_POLICY_IMPACT_SUBJECT_PREFIX,
|
||||
PolicyImpactPopulationRequest,
|
||||
PolicyImpactSubject,
|
||||
PolicyImpactSubjectBatch,
|
||||
PolicyImpactSubjectProvider,
|
||||
policy_impact_subject_provider,
|
||||
)
|
||||
|
||||
|
||||
class _Provider:
|
||||
provider_id = "example"
|
||||
supported_policy_families = ("view",)
|
||||
|
||||
def collect_policy_impact_subjects(
|
||||
self,
|
||||
session: object | None = None,
|
||||
*,
|
||||
request: PolicyImpactPopulationRequest,
|
||||
) -> PolicyImpactSubjectBatch:
|
||||
del session, request
|
||||
return PolicyImpactSubjectBatch(
|
||||
provider_id=self.provider_id,
|
||||
subjects=(
|
||||
PolicyImpactSubject(
|
||||
module_id="example",
|
||||
resource_type="record",
|
||||
resource_id="record-1",
|
||||
action="view",
|
||||
),
|
||||
),
|
||||
total_available=1,
|
||||
)
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, capability: object) -> None:
|
||||
self._capability = capability
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return name == f"{CAPABILITY_POLICY_IMPACT_SUBJECT_PREFIX}example"
|
||||
|
||||
def capability(self, name: str) -> object:
|
||||
del name
|
||||
return self._capability
|
||||
|
||||
|
||||
class PolicyImpactContractTests(unittest.TestCase):
|
||||
def test_provider_contract_is_runtime_checkable_and_discoverable(self) -> None:
|
||||
provider = _Provider()
|
||||
self.assertIsInstance(provider, PolicyImpactSubjectProvider)
|
||||
self.assertIs(
|
||||
policy_impact_subject_provider(_Registry(provider), "example"),
|
||||
provider,
|
||||
)
|
||||
|
||||
def test_population_and_provider_batches_are_bounded(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "between 1 and 500"):
|
||||
PolicyImpactPopulationRequest(
|
||||
tenant_id="tenant-1",
|
||||
policy_family="view",
|
||||
limit=501,
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "at most 500"):
|
||||
PolicyImpactSubjectBatch(
|
||||
provider_id="example",
|
||||
subjects=tuple(
|
||||
PolicyImpactSubject(
|
||||
module_id="example",
|
||||
resource_type="record",
|
||||
resource_id=str(index),
|
||||
action="view",
|
||||
)
|
||||
for index in range(501)
|
||||
),
|
||||
)
|
||||
|
||||
def test_unavailable_batches_explain_the_gap(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "need an explanation"):
|
||||
PolicyImpactSubjectBatch(
|
||||
provider_id="example",
|
||||
state="unavailable",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,378 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
|
||||
from govoplan_core.core.modules import (
|
||||
CapabilityDocumentation,
|
||||
DocumentationTopic,
|
||||
ModuleContext,
|
||||
ModuleManifest,
|
||||
)
|
||||
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
||||
from govoplan_core.core.semantic_documentation import (
|
||||
SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
|
||||
SemanticDocumentationBreadcrumb,
|
||||
SemanticDocumentationContractError,
|
||||
SemanticDocumentationSubjectAnchor,
|
||||
SemanticDocumentationSubjectDescriptor,
|
||||
SemanticDocumentationSubjectPage,
|
||||
SemanticDocumentationSubjectQuery,
|
||||
SemanticDocumentationSubjectReference,
|
||||
SemanticDocumentationSubjectResolution,
|
||||
list_semantic_documentation_subjects,
|
||||
resolve_semantic_documentation_subject,
|
||||
semantic_documentation_fingerprint,
|
||||
semantic_documentation_subject_capability,
|
||||
semantic_documentation_subject_provider_names,
|
||||
semantic_documentation_subject_providers,
|
||||
)
|
||||
|
||||
|
||||
def reference(
|
||||
*,
|
||||
tenant_id: str = "tenant-1",
|
||||
revision: str | None = "4",
|
||||
fingerprint: str | None = None,
|
||||
anchor: SemanticDocumentationSubjectAnchor | None = None,
|
||||
) -> SemanticDocumentationSubjectReference:
|
||||
return SemanticDocumentationSubjectReference(
|
||||
module_id="forms",
|
||||
tenant_id=tenant_id,
|
||||
subject_kind="form",
|
||||
subject_id="permit-application",
|
||||
anchor=anchor,
|
||||
observed_revision=revision,
|
||||
observed_fingerprint=fingerprint
|
||||
or semantic_documentation_fingerprint({"revision": revision}),
|
||||
)
|
||||
|
||||
|
||||
def descriptor(
|
||||
*,
|
||||
tenant_id: str = "tenant-1",
|
||||
revision: str = "4",
|
||||
fingerprint: str | None = None,
|
||||
label: str = "Permit application",
|
||||
anchor: SemanticDocumentationSubjectAnchor | None = None,
|
||||
) -> SemanticDocumentationSubjectDescriptor:
|
||||
return SemanticDocumentationSubjectDescriptor(
|
||||
reference=reference(
|
||||
tenant_id=tenant_id,
|
||||
revision=revision,
|
||||
fingerprint=fingerprint,
|
||||
anchor=anchor,
|
||||
),
|
||||
labels={"de": "Antrag auf Parkerlaubnis", "en": label},
|
||||
descriptions={"de": "Konfiguriertes Antragsformular."},
|
||||
breadcrumbs=(
|
||||
SemanticDocumentationBreadcrumb(
|
||||
label="Forms",
|
||||
subject_kind="form",
|
||||
subject_id="permit-application",
|
||||
),
|
||||
),
|
||||
route="/forms/permit-application",
|
||||
route_anchor=anchor.id if anchor else None,
|
||||
audience=("case_worker",),
|
||||
classification="internal",
|
||||
required_scopes=("forms:definition:read",),
|
||||
)
|
||||
|
||||
|
||||
class Provider:
|
||||
provider_id = "forms.semantic_subjects"
|
||||
module_id = "forms"
|
||||
contract_version = SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.list_calls = 0
|
||||
self.resolve_calls = 0
|
||||
|
||||
def list_subjects(self, _session, principal, *, request):
|
||||
self.list_calls += 1
|
||||
if getattr(principal, "account_id", "") == "denied":
|
||||
return SemanticDocumentationSubjectPage()
|
||||
return SemanticDocumentationSubjectPage(subjects=(descriptor(),))
|
||||
|
||||
def resolve_subject(self, _session, principal, *, reference):
|
||||
self.resolve_calls += 1
|
||||
if getattr(principal, "account_id", "") == "denied":
|
||||
return None
|
||||
current = descriptor()
|
||||
availability = (
|
||||
"changed"
|
||||
if reference.observed_fingerprint
|
||||
!= current.reference.observed_fingerprint
|
||||
else "available"
|
||||
)
|
||||
return SemanticDocumentationSubjectResolution(
|
||||
requested_reference=reference,
|
||||
availability=availability,
|
||||
subject=current,
|
||||
)
|
||||
|
||||
|
||||
class SemanticDocumentationContractTests(unittest.TestCase):
|
||||
def test_reference_identity_is_stable_across_labels_and_revisions(self) -> None:
|
||||
original = descriptor(label="Old label")
|
||||
renamed = descriptor(revision="5", label="New label")
|
||||
|
||||
self.assertEqual(
|
||||
original.reference.stable_key,
|
||||
renamed.reference.stable_key,
|
||||
)
|
||||
self.assertNotEqual(
|
||||
original.reference.observed_fingerprint,
|
||||
renamed.reference.observed_fingerprint,
|
||||
)
|
||||
|
||||
def test_nested_anchor_is_typed_and_changes_subject_identity(self) -> None:
|
||||
form = reference()
|
||||
field = reference(
|
||||
anchor=SemanticDocumentationSubjectAnchor(
|
||||
kind="field",
|
||||
id="vehicle-registration",
|
||||
)
|
||||
)
|
||||
|
||||
self.assertNotEqual(form.stable_key, field.stable_key)
|
||||
restored = SemanticDocumentationSubjectReference.from_mapping(
|
||||
field.to_dict()
|
||||
)
|
||||
self.assertEqual(restored, field)
|
||||
|
||||
def test_reference_mapping_rejects_unbounded_provider_metadata(self) -> None:
|
||||
payload = reference().to_dict()
|
||||
payload["credentials"] = {"token": "must-not-cross"}
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
SemanticDocumentationContractError,
|
||||
"unsupported fields: credentials",
|
||||
):
|
||||
SemanticDocumentationSubjectReference.from_mapping(payload)
|
||||
|
||||
def test_descriptors_reject_remote_routes_and_missing_localization(self) -> None:
|
||||
with self.assertRaisesRegex(
|
||||
SemanticDocumentationContractError,
|
||||
"local absolute paths",
|
||||
):
|
||||
SemanticDocumentationSubjectDescriptor(
|
||||
reference=reference(),
|
||||
labels={"de": "Formular"},
|
||||
route="https://provider.example/form",
|
||||
)
|
||||
with self.assertRaisesRegex(
|
||||
SemanticDocumentationContractError,
|
||||
"labels are required",
|
||||
):
|
||||
SemanticDocumentationSubjectDescriptor(
|
||||
reference=reference(),
|
||||
labels={},
|
||||
)
|
||||
|
||||
def test_resolution_distinguishes_change_supersession_and_absence(self) -> None:
|
||||
old = reference(
|
||||
revision="3",
|
||||
fingerprint=semantic_documentation_fingerprint({"revision": "3"}),
|
||||
)
|
||||
current = descriptor()
|
||||
changed = SemanticDocumentationSubjectResolution(
|
||||
requested_reference=old,
|
||||
availability="changed",
|
||||
subject=current,
|
||||
)
|
||||
superseded = SemanticDocumentationSubjectResolution(
|
||||
requested_reference=old,
|
||||
availability="superseded",
|
||||
superseded_by=SemanticDocumentationSubjectReference(
|
||||
module_id="forms",
|
||||
tenant_id="tenant-1",
|
||||
subject_kind="form",
|
||||
subject_id="permit-application-v2",
|
||||
),
|
||||
)
|
||||
missing = SemanticDocumentationSubjectResolution(
|
||||
requested_reference=old,
|
||||
availability="missing",
|
||||
reason_code="subject_removed",
|
||||
)
|
||||
|
||||
self.assertEqual(changed.availability, "changed")
|
||||
self.assertEqual(superseded.superseded_by.subject_id, "permit-application-v2")
|
||||
self.assertEqual(missing.reason_code, "subject_removed")
|
||||
with self.assertRaisesRegex(
|
||||
SemanticDocumentationContractError,
|
||||
"require a revision or fingerprint change",
|
||||
):
|
||||
SemanticDocumentationSubjectResolution(
|
||||
requested_reference=current.reference,
|
||||
availability="changed",
|
||||
subject=current,
|
||||
)
|
||||
|
||||
def test_subject_pages_reject_duplicate_stable_identities(self) -> None:
|
||||
with self.assertRaisesRegex(
|
||||
SemanticDocumentationContractError,
|
||||
"duplicate identities",
|
||||
):
|
||||
SemanticDocumentationSubjectPage(
|
||||
subjects=(descriptor(), descriptor(label="Renamed")),
|
||||
)
|
||||
|
||||
def test_registry_discovers_optional_providers_without_direct_imports(self) -> None:
|
||||
provider = Provider()
|
||||
registry = provider_registry(provider)
|
||||
|
||||
self.assertEqual(
|
||||
semantic_documentation_subject_provider_names(registry),
|
||||
("documentation.semantic_subjects.forms",),
|
||||
)
|
||||
self.assertEqual(
|
||||
semantic_documentation_subject_providers(registry),
|
||||
(("forms", provider),),
|
||||
)
|
||||
|
||||
def test_listing_and_resolution_are_tenant_and_principal_aware(self) -> None:
|
||||
provider = Provider()
|
||||
registry = provider_registry(provider)
|
||||
allowed = SimpleNamespace(tenant_id="tenant-1", account_id="account-1")
|
||||
wrong_tenant = SimpleNamespace(
|
||||
tenant_id="tenant-2", account_id="account-1"
|
||||
)
|
||||
denied = SimpleNamespace(tenant_id="tenant-1", account_id="denied")
|
||||
|
||||
pages = list_semantic_documentation_subjects(
|
||||
registry,
|
||||
object(),
|
||||
allowed,
|
||||
request=SemanticDocumentationSubjectQuery(tenant_id="tenant-1"),
|
||||
)
|
||||
self.assertEqual(len(pages), 1)
|
||||
self.assertEqual(pages[0][1].subjects[0].reference.tenant_id, "tenant-1")
|
||||
self.assertEqual(
|
||||
list_semantic_documentation_subjects(
|
||||
registry,
|
||||
object(),
|
||||
wrong_tenant,
|
||||
request=SemanticDocumentationSubjectQuery(tenant_id="tenant-1"),
|
||||
),
|
||||
(),
|
||||
)
|
||||
self.assertIsNone(
|
||||
resolve_semantic_documentation_subject(
|
||||
registry,
|
||||
object(),
|
||||
denied,
|
||||
reference=reference(),
|
||||
)
|
||||
)
|
||||
self.assertEqual(provider.list_calls, 1)
|
||||
self.assertEqual(provider.resolve_calls, 1)
|
||||
|
||||
def test_missing_optional_module_resolves_without_loading_feature_code(self) -> None:
|
||||
principal = SimpleNamespace(tenant_id="tenant-1", account_id="account-1")
|
||||
result = resolve_semantic_documentation_subject(
|
||||
PlatformRegistry(),
|
||||
object(),
|
||||
principal,
|
||||
reference=reference(),
|
||||
)
|
||||
|
||||
self.assertEqual(result.availability, "temporarily_unavailable")
|
||||
self.assertEqual(result.reason_code, "provider_unavailable")
|
||||
|
||||
def test_manifest_requires_exact_capability_docs_and_static_baselines(self) -> None:
|
||||
provider = Provider()
|
||||
capability = semantic_documentation_subject_capability("forms")
|
||||
missing_baseline = PlatformRegistry()
|
||||
missing_baseline.register(
|
||||
ModuleManifest(
|
||||
id="forms",
|
||||
name="Forms",
|
||||
version="1.0.0",
|
||||
capability_factories={capability: lambda _context: provider},
|
||||
capability_documentation={
|
||||
capability: CapabilityDocumentation(
|
||||
label="Form semantics",
|
||||
summary="Lists documentation-safe configured form subjects.",
|
||||
contract_version=(
|
||||
SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION
|
||||
),
|
||||
)
|
||||
},
|
||||
documentation=(static_topic("admin"),),
|
||||
)
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(RegistryError, "static user and administrator"):
|
||||
missing_baseline.validate()
|
||||
|
||||
wrong_capability = PlatformRegistry()
|
||||
wrong_capability.register(
|
||||
ModuleManifest(
|
||||
id="forms",
|
||||
name="Forms",
|
||||
version="1.0.0",
|
||||
capability_factories={
|
||||
semantic_documentation_subject_capability("workflow"): (
|
||||
lambda _context: provider
|
||||
)
|
||||
},
|
||||
)
|
||||
)
|
||||
with self.assertRaisesRegex(RegistryError, "must be"):
|
||||
wrong_capability.validate()
|
||||
|
||||
def test_fingerprint_is_canonical_and_rejects_non_json_values(self) -> None:
|
||||
self.assertEqual(
|
||||
semantic_documentation_fingerprint({"b": 2, "a": 1}),
|
||||
semantic_documentation_fingerprint({"a": 1, "b": 2}),
|
||||
)
|
||||
with self.assertRaisesRegex(
|
||||
SemanticDocumentationContractError,
|
||||
"canonical JSON",
|
||||
):
|
||||
semantic_documentation_fingerprint({"unsafe": object()})
|
||||
|
||||
|
||||
def static_topic(documentation_type: str) -> DocumentationTopic:
|
||||
return DocumentationTopic(
|
||||
id=f"forms.semantic.{documentation_type}",
|
||||
title="Form semantics",
|
||||
summary="Static form semantics baseline.",
|
||||
documentation_types=(documentation_type,), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def provider_registry(provider: Provider) -> PlatformRegistry:
|
||||
capability = semantic_documentation_subject_capability("forms")
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id="forms",
|
||||
name="Forms",
|
||||
version="1.0.0",
|
||||
capability_factories={capability: lambda _context: provider},
|
||||
capability_documentation={
|
||||
capability: CapabilityDocumentation(
|
||||
label="Form semantic subjects",
|
||||
summary="Lists safe configured forms and fields for Docs.",
|
||||
contract_version=(
|
||||
SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION
|
||||
),
|
||||
documentation_types=("admin", "user"),
|
||||
)
|
||||
},
|
||||
documentation=(static_topic("admin"), static_topic("user")),
|
||||
)
|
||||
)
|
||||
registry.validate()
|
||||
registry.configure_capability_context(
|
||||
ModuleContext(registry=registry, settings=object())
|
||||
)
|
||||
return registry
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
@@ -26,6 +26,7 @@ import WorkspaceFrame from "../src/components/WorkspaceFrame";
|
||||
import WorkspaceLayout from "../src/components/WorkspaceLayout";
|
||||
import WorkspaceActionBar from "../src/components/WorkspaceActionBar";
|
||||
import BreadcrumbBar from "../src/layout/BreadcrumbBar";
|
||||
import HelpMenu from "../src/layout/HelpMenu";
|
||||
import { useGuardedNavigate } from "../src/components/UnsavedChangesGuard";
|
||||
import {
|
||||
createQuickAccessLaunchContext,
|
||||
@@ -149,6 +150,7 @@ export default function ConformanceApp() {
|
||||
</section>
|
||||
|
||||
<LaunchContextScenario />
|
||||
{new URLSearchParams(location.search).has("help") ? <HelpConformanceScenario /> : null}
|
||||
</PageLayout>
|
||||
|
||||
<Dialog
|
||||
@@ -170,6 +172,32 @@ export default function ConformanceApp() {
|
||||
);
|
||||
}
|
||||
|
||||
function HelpConformanceScenario() {
|
||||
return (
|
||||
<section className="conformance-section" aria-labelledby="help-heading">
|
||||
<h2 id="help-heading">Kontextsensitive Hilfe</h2>
|
||||
<p>F1 löst den Hilfekontext des fokussierten Bedienelements auf und kehrt nach dem Schließen dorthin zurück.</p>
|
||||
<ActionToolbar surface="subtle">
|
||||
<Button
|
||||
variant="danger"
|
||||
data-testid="f1-retention-action"
|
||||
helpContextId="policy.retention.action.apply"
|
||||
helpModuleId="policy"
|
||||
>
|
||||
Aufbewahrungsregeln anwenden
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="f1-fallback-action"
|
||||
interfaceId="core.conformance.action.review"
|
||||
>
|
||||
Allgemeine Aktion prüfen
|
||||
</Button>
|
||||
<HelpMenu auth={null} />
|
||||
</ActionToolbar>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickAccessScenario() {
|
||||
const location = useLocation();
|
||||
const mode = new URLSearchParams(location.search).get("quick-access");
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
const installedModules: Array<never> = [];
|
||||
|
||||
export default installedModules;
|
||||
@@ -3,6 +3,9 @@ import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router";
|
||||
import ConformanceApp from "./ConformanceApp";
|
||||
import { UnsavedChangesProvider } from "../src/components/UnsavedChangesGuard";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
|
||||
import type { PlatformWebModule } from "../src/types";
|
||||
import "../src/styles/tokens.css";
|
||||
import "../src/styles/layout.css";
|
||||
import "../src/styles/forms.css";
|
||||
@@ -16,12 +19,28 @@ import "./conformance.css";
|
||||
const theme = new URLSearchParams(window.location.search).get("theme");
|
||||
if (theme === "dark" || theme === "light") document.documentElement.dataset.theme = theme;
|
||||
|
||||
const CONFORMANCE_MODULES: PlatformWebModule[] = [{
|
||||
id: "policy",
|
||||
label: "Richtlinien",
|
||||
version: "1",
|
||||
helpContexts: [{
|
||||
id: "policy.retention.action.apply",
|
||||
topic_id: "policy.admin.retention-and-disposition",
|
||||
title: "Aufbewahrungsregeln sicher anwenden",
|
||||
documentation_types: ["admin"]
|
||||
}]
|
||||
}];
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<PlatformModulesProvider modules={CONFORMANCE_MODULES}>
|
||||
<PlatformLanguageProvider preferredLanguageCode="de">
|
||||
<UnsavedChangesProvider>
|
||||
<ConformanceApp />
|
||||
</UnsavedChangesProvider>
|
||||
</PlatformLanguageProvider>
|
||||
</PlatformModulesProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@@ -110,6 +110,44 @@ test("Quick Access preserves focus and adapts to a narrow viewport", async ({ pa
|
||||
await expect(filesTrigger).toBeFocused();
|
||||
});
|
||||
|
||||
test("F1 resolves focused high-risk help and restores focus accessibly", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/?theme=light&help=1");
|
||||
|
||||
const retentionAction = page.getByTestId("f1-retention-action");
|
||||
await retentionAction.focus();
|
||||
await page.keyboard.press("F1");
|
||||
|
||||
const dialog = page.getByRole("dialog", { name: "Kontexthilfe" });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.locator("[data-help-context]"))
|
||||
.toHaveAttribute("data-help-context", "policy.retention.action.apply");
|
||||
await expect(dialog.getByRole("heading", { level: 3 }))
|
||||
.toHaveText("Aufbewahrungsregeln anwenden");
|
||||
await expect(dialog).toContainText("topic=policy.admin.retention-and-disposition");
|
||||
await expect(dialog.getByRole("button", { name: "Administrator-Dokumentation öffnen" }))
|
||||
.toBeVisible();
|
||||
await expectNoAccessibilityViolations(page);
|
||||
|
||||
const bounds = await dialog.boundingBox();
|
||||
expect(bounds?.x).toBeGreaterThanOrEqual(0);
|
||||
expect((bounds?.x ?? 0) + (bounds?.width ?? 0)).toBeLessThanOrEqual(390);
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(retentionAction).toBeFocused();
|
||||
|
||||
const fallbackAction = page.getByTestId("f1-fallback-action");
|
||||
await fallbackAction.focus();
|
||||
await page.keyboard.press("F1");
|
||||
const fallbackDialog = page.getByRole("dialog", { name: "Kontexthilfe" });
|
||||
await expect(fallbackDialog.locator("[data-help-context]"))
|
||||
.toHaveAttribute("data-help-context", "core.conformance.action.review");
|
||||
await expect(fallbackDialog).toContainText("fallback_context=campaigns.list");
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(fallbackAction).toBeFocused();
|
||||
});
|
||||
|
||||
test("View focus has a deliberate permission-derived all-tools escape", async ({ page }) => {
|
||||
await page.route("**/api/v1/quick-access/effective*", async (route) => {
|
||||
await route.fulfill({
|
||||
|
||||
Generated
+128
-25
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.37",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.37",
|
||||
"dependencies": {
|
||||
"@govoplan/access-webui": "file:../../govoplan-access/webui",
|
||||
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
|
||||
@@ -17,6 +17,7 @@
|
||||
"@govoplan/campaign-webui": "file:../../govoplan-campaign/webui",
|
||||
"@govoplan/cases-webui": "file:../../govoplan-cases/webui",
|
||||
"@govoplan/committee-webui": "file:../../govoplan-committee/webui",
|
||||
"@govoplan/connectors-webui": "file:../../govoplan-connectors/webui",
|
||||
"@govoplan/dashboard-webui": "file:../../govoplan-dashboard/webui",
|
||||
"@govoplan/dataflow-webui": "file:../../govoplan-dataflow/webui",
|
||||
"@govoplan/datasources-webui": "file:../../govoplan-datasources/webui",
|
||||
@@ -26,7 +27,9 @@
|
||||
"@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",
|
||||
"@govoplan/mail-webui": "file:../../govoplan-mail/webui",
|
||||
"@govoplan/notifications-webui": "file:../../govoplan-notifications/webui",
|
||||
@@ -46,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",
|
||||
@@ -80,7 +85,7 @@
|
||||
},
|
||||
"../../govoplan-access/webui": {
|
||||
"name": "@govoplan/access-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.20",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
@@ -115,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",
|
||||
@@ -149,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",
|
||||
@@ -184,7 +189,7 @@
|
||||
},
|
||||
"../../govoplan-campaign/webui": {
|
||||
"name": "@govoplan/campaign-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.24",
|
||||
"dependencies": {
|
||||
"read-excel-file": "9.2.0"
|
||||
},
|
||||
@@ -206,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",
|
||||
@@ -236,6 +241,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"../../govoplan-connectors/webui": {
|
||||
"name": "@govoplan/connectors-webui",
|
||||
"version": "0.1.22",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"../../govoplan-dashboard/webui": {
|
||||
"name": "@govoplan/dashboard-webui",
|
||||
"version": "0.1.18",
|
||||
@@ -254,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",
|
||||
@@ -272,7 +291,7 @@
|
||||
},
|
||||
"../../govoplan-datasources/webui": {
|
||||
"name": "@govoplan/datasources-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.20",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"lucide-react": "^1.23.0",
|
||||
@@ -306,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",
|
||||
@@ -340,7 +359,7 @@
|
||||
},
|
||||
"../../govoplan-files/webui": {
|
||||
"name": "@govoplan/files-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.20",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
@@ -375,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": {
|
||||
@@ -403,9 +439,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"../../govoplan-identity/webui": {
|
||||
"name": "@govoplan/identity-webui",
|
||||
"version": "0.1.18",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"../../govoplan-idm/webui": {
|
||||
"name": "@govoplan/idm-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.20",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
@@ -424,7 +475,7 @@
|
||||
},
|
||||
"../../govoplan-mail/webui": {
|
||||
"name": "@govoplan/mail-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.22",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
@@ -462,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",
|
||||
@@ -516,7 +567,7 @@
|
||||
},
|
||||
"../../govoplan-policy/webui": {
|
||||
"name": "@govoplan/policy-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.20",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"lucide-react": "^1.23.0",
|
||||
@@ -532,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",
|
||||
@@ -548,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",
|
||||
@@ -580,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",
|
||||
@@ -679,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",
|
||||
@@ -725,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",
|
||||
@@ -756,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",
|
||||
@@ -1562,6 +1645,10 @@
|
||||
"resolved": "../../govoplan-committee/webui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@govoplan/connectors-webui": {
|
||||
"resolved": "../../govoplan-connectors/webui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@govoplan/dashboard-webui": {
|
||||
"resolved": "../../govoplan-dashboard/webui",
|
||||
"link": true
|
||||
@@ -1598,10 +1685,18 @@
|
||||
"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
|
||||
},
|
||||
"node_modules/@govoplan/identity-webui": {
|
||||
"resolved": "../../govoplan-identity/webui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@govoplan/idm-webui": {
|
||||
"resolved": "../../govoplan-idm/webui",
|
||||
"link": true
|
||||
@@ -1678,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
|
||||
@@ -1686,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
|
||||
|
||||
+482
-414
File diff suppressed because it is too large
Load Diff
+6
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.37",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -67,6 +67,7 @@
|
||||
"@govoplan/campaign-webui": "file:../../govoplan-campaign/webui",
|
||||
"@govoplan/cases-webui": "file:../../govoplan-cases/webui",
|
||||
"@govoplan/committee-webui": "file:../../govoplan-committee/webui",
|
||||
"@govoplan/connectors-webui": "file:../../govoplan-connectors/webui",
|
||||
"@govoplan/dashboard-webui": "file:../../govoplan-dashboard/webui",
|
||||
"@govoplan/dataflow-webui": "file:../../govoplan-dataflow/webui",
|
||||
"@govoplan/datasources-webui": "file:../../govoplan-datasources/webui",
|
||||
@@ -76,7 +77,9 @@
|
||||
"@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",
|
||||
"@govoplan/mail-webui": "file:../../govoplan-mail/webui",
|
||||
"@govoplan/notifications-webui": "file:../../govoplan-notifications/webui",
|
||||
@@ -96,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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.37",
|
||||
"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.18",
|
||||
"@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.20",
|
||||
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.19",
|
||||
"@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/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.18",
|
||||
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#v0.1.18",
|
||||
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.20",
|
||||
"@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.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.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/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.20",
|
||||
"@govoplan/wiki-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-wiki.git#v0.1.20",
|
||||
"@tiptap/core": "^3.29.2",
|
||||
"@tiptap/extension-image": "^3.29.2",
|
||||
"@tiptap/pm": "^3.29.2",
|
||||
|
||||
@@ -12,6 +12,7 @@ const packageByModule = {
|
||||
cases: "@govoplan/cases-webui",
|
||||
campaigns: "@govoplan/campaign-webui",
|
||||
committee: "@govoplan/committee-webui",
|
||||
connectors: "@govoplan/connectors-webui",
|
||||
dashboard: "@govoplan/dashboard-webui",
|
||||
dataflow: "@govoplan/dataflow-webui",
|
||||
datasources: "@govoplan/datasources-webui",
|
||||
@@ -21,7 +22,9 @@ 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",
|
||||
notifications: "@govoplan/notifications-webui",
|
||||
organizations: "@govoplan/organizations-webui",
|
||||
@@ -40,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"
|
||||
};
|
||||
|
||||
@@ -69,15 +74,21 @@ const cases = [
|
||||
{ name: "calendar-only", modules: ["calendar"] },
|
||||
{ name: "cases-only", modules: ["cases"] },
|
||||
{ name: "committee-only", modules: ["committee"] },
|
||||
{ name: "connectors-only", modules: ["connectors"] },
|
||||
{ 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"] },
|
||||
{ name: "payments-only", modules: ["payments"] },
|
||||
{ name: "idm-with-organizations", modules: ["organizations", "idm"] },
|
||||
{ name: "identity-trust-only", modules: ["identity_trust"] },
|
||||
{ name: "identity-only", modules: ["identity"] },
|
||||
{ name: "identity-trust-with-access", modules: ["access", "identity_trust"] },
|
||||
{ name: "encryption-only", modules: ["encryption"] },
|
||||
{ name: "encryption-with-trust", modules: ["access", "identity_trust", "encryption"] },
|
||||
@@ -102,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_trust", "encryption", "cases", "committee", "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;
|
||||
|
||||
@@ -8,6 +8,7 @@ const tokens = readFileSync(resolve(webuiRoot, "src/styles/tokens.css"), "utf8")
|
||||
const app = readFileSync(resolve(webuiRoot, "src/App.tsx"), "utf8");
|
||||
const settings = readFileSync(resolve(webuiRoot, "src/features/settings/SettingsPage.tsx"), "utf8");
|
||||
const paletteControl = readFileSync(resolve(webuiRoot, "src/components/AppearancePaletteControl.tsx"), "utf8");
|
||||
const overridesEditor = readFileSync(resolve(webuiRoot, "src/components/AppearanceOverridesEditor.tsx"), "utf8");
|
||||
|
||||
assert.match(tokens, /:root\[data-theme="dark"\]/, "dark token overrides are required");
|
||||
assert.match(tokens, /color-scheme:\s*dark/, "native controls must receive the dark color scheme");
|
||||
@@ -21,6 +22,14 @@ for (const palette of ["default", "civic_blue", "forest", "plum"]) {
|
||||
assert.match(paletteControl, new RegExp(`value:\\s*"${palette}"`), `Shared appearance control must expose ${palette}`);
|
||||
}
|
||||
assert.match(settings, /AppearancePaletteSelect/, "personal settings must use the shared palette control");
|
||||
assert.match(settings, /AppearanceOverridesEditor/, "personal settings must use the shared override editor");
|
||||
assert.match(app, /applyAppearanceOverrides/, "the shell must apply validated overrides centrally");
|
||||
assert.match(overridesEditor, /schema_version:\s*"1"/, "override exchange must use an explicit versioned schema");
|
||||
assert.match(overridesEditor, /contrastRatio[\s\S]*?<\s*4\.5/, "custom pairs must enforce WCAG AA contrast");
|
||||
assert.match(overridesEditor, /rgbDistance[\s\S]*?<\s*12/, "custom status colors must enforce differentiation");
|
||||
for (const token of ["accent", "surface", "success", "info", "warning", "danger"]) {
|
||||
assert.match(overridesEditor, new RegExp(`"--${token}`), `runtime overrides must map ${token} into shared tokens`);
|
||||
}
|
||||
for (const relativePath of [
|
||||
"govoplan-admin/webui/src/features/admin/SystemSettingsPanel.tsx",
|
||||
"govoplan-tenancy/webui/src/features/admin/TenantSettingsPanel.tsx"
|
||||
|
||||
+8
-3
@@ -26,6 +26,7 @@ import ViewSurfaceRouteBoundary from "./components/ViewSurfaceRouteBoundary";
|
||||
import ModuleLoadBoundary from "./components/ModuleLoadBoundary";
|
||||
import { DocumentationHelpProvider } from "./components/help/DocumentationHelpLink";
|
||||
import { hasAnyScope } from "./utils/permissions";
|
||||
import { applyAppearanceOverrides } from "./components/AppearanceOverridesEditor";
|
||||
|
||||
const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage"));
|
||||
const SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
|
||||
@@ -36,7 +37,8 @@ const DEFAULT_UI_PREFERENCES: UserUiPreferences = {
|
||||
reduce_motion: false,
|
||||
sticky_section_sidebars: true,
|
||||
theme: "system",
|
||||
palette: null
|
||||
palette: null,
|
||||
appearance_overrides: null
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
@@ -399,6 +401,7 @@ export default function App() {
|
||||
preferences.theme;
|
||||
root.dataset.theme = resolvedTheme;
|
||||
root.dataset.themePreference = preferences.theme;
|
||||
applyAppearanceOverrides(root, auth?.user.appearance?.custom_overrides ?? null, resolvedTheme);
|
||||
};
|
||||
|
||||
applyTheme();
|
||||
@@ -417,7 +420,8 @@ export default function App() {
|
||||
auth?.user.ui_preferences?.sticky_section_sidebars,
|
||||
auth?.user.ui_preferences?.theme,
|
||||
auth?.user.ui_preferences?.palette,
|
||||
auth?.user.appearance?.palette
|
||||
auth?.user.appearance?.palette,
|
||||
auth?.user.appearance?.custom_overrides
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -721,7 +725,8 @@ function normalizeUiPreferences(value: Partial<UserUiPreferences> | null | undef
|
||||
reduce_motion: Boolean(value?.reduce_motion ?? DEFAULT_UI_PREFERENCES.reduce_motion),
|
||||
sticky_section_sidebars: Boolean(value?.sticky_section_sidebars ?? DEFAULT_UI_PREFERENCES.sticky_section_sidebars),
|
||||
theme,
|
||||
palette: normalizeOptionalUiPalette(value?.palette)
|
||||
palette: normalizeOptionalUiPalette(value?.palette),
|
||||
appearance_overrides: value?.appearance_overrides ?? null
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import { useRef, useState, type CSSProperties, type ChangeEvent } from "react";
|
||||
import type {
|
||||
AppearanceModeOverrides,
|
||||
AppearanceOverridesDocument,
|
||||
AppearanceOverrideToken,
|
||||
UserUiTheme
|
||||
} from "../types";
|
||||
import Button from "./Button";
|
||||
import ColorPickerField from "./ColorPickerField";
|
||||
import ContentGrid from "./ContentGrid";
|
||||
import DismissibleAlert from "./DismissibleAlert";
|
||||
import FormField from "./FormField";
|
||||
import SegmentedControl from "./SegmentedControl";
|
||||
|
||||
export const APPEARANCE_OVERRIDE_TOKENS: readonly AppearanceOverrideToken[] = [
|
||||
"accent", "accent_foreground", "surface", "surface_foreground",
|
||||
"success", "success_foreground", "info", "info_foreground",
|
||||
"warning", "warning_foreground", "danger", "danger_foreground"
|
||||
];
|
||||
|
||||
const TOKEN_LABELS: Record<AppearanceOverrideToken, string> = {
|
||||
accent: "i18n:govoplan-core.override_accent",
|
||||
accent_foreground: "i18n:govoplan-core.override_accent_foreground",
|
||||
surface: "i18n:govoplan-core.override_surface",
|
||||
surface_foreground: "i18n:govoplan-core.override_surface_foreground",
|
||||
success: "i18n:govoplan-core.override_success",
|
||||
success_foreground: "i18n:govoplan-core.override_success_foreground",
|
||||
info: "i18n:govoplan-core.override_info",
|
||||
info_foreground: "i18n:govoplan-core.override_info_foreground",
|
||||
warning: "i18n:govoplan-core.override_warning",
|
||||
warning_foreground: "i18n:govoplan-core.override_warning_foreground",
|
||||
danger: "i18n:govoplan-core.override_danger",
|
||||
danger_foreground: "i18n:govoplan-core.override_danger_foreground"
|
||||
};
|
||||
|
||||
const STATUS_TOKENS: readonly AppearanceOverrideToken[] = ["success", "info", "warning", "danger"];
|
||||
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
|
||||
const RUNTIME_PROPERTIES = new Set<string>();
|
||||
|
||||
const RUNTIME_TOKEN_PROPERTIES: Record<AppearanceOverrideToken, readonly string[]> = {
|
||||
accent: ["--accent", "--action-primary-bg"],
|
||||
accent_foreground: ["--on-accent", "--badge-accent-text", "--action-primary-text"],
|
||||
surface: ["--surface", "--panel-soft"],
|
||||
surface_foreground: ["--text", "--text-strong"],
|
||||
success: ["--success-bg", "--success-soft"],
|
||||
success_foreground: ["--success-text", "--success-text-strong"],
|
||||
info: ["--info-bg", "--info-soft"],
|
||||
info_foreground: ["--info-text", "--info-text-strong", "--info-text-deep"],
|
||||
warning: ["--warning-bg", "--warning-soft"],
|
||||
warning_foreground: ["--warning-text", "--warning-text-strong"],
|
||||
danger: ["--danger-bg", "--danger-soft"],
|
||||
danger_foreground: ["--danger-text", "--danger-text-strong", "--danger-text-deep"]
|
||||
};
|
||||
|
||||
for (const properties of Object.values(RUNTIME_TOKEN_PROPERTIES)) {
|
||||
for (const property of properties) RUNTIME_PROPERTIES.add(property);
|
||||
}
|
||||
|
||||
export const DEFAULT_APPEARANCE_OVERRIDES: AppearanceOverridesDocument = {
|
||||
schema_version: "1",
|
||||
light: {
|
||||
accent: "#245f91", accent_foreground: "#ffffff",
|
||||
surface: "#ffffff", surface_foreground: "#303135",
|
||||
success: "#d8eee8", success_foreground: "#315f55",
|
||||
info: "#dce9f3", info_foreground: "#294a61",
|
||||
warning: "#ffe1a3", warning_foreground: "#593700",
|
||||
danger: "#f8d1cc", danger_foreground: "#873c35"
|
||||
},
|
||||
dark: {
|
||||
accent: "#7ea6c5", accent_foreground: "#242424",
|
||||
surface: "#262724", surface_foreground: "#f1f1f1",
|
||||
success: "#24473f", success_foreground: "#d8eee8",
|
||||
info: "#243d4e", info_foreground: "#dce9f3",
|
||||
warning: "#5a431f", warning_foreground: "#ffe1a3",
|
||||
danger: "#4f2d2a", danger_foreground: "#f8d1cc"
|
||||
}
|
||||
};
|
||||
|
||||
export function cloneDefaultAppearanceOverrides(): AppearanceOverridesDocument {
|
||||
return JSON.parse(JSON.stringify(DEFAULT_APPEARANCE_OVERRIDES)) as AppearanceOverridesDocument;
|
||||
}
|
||||
|
||||
export function validateAppearanceOverrides(value: unknown): AppearanceOverridesDocument {
|
||||
if (!isRecord(value) || value.schema_version !== "1" || !isRecord(value.light) || !isRecord(value.dark)) {
|
||||
throw new Error("i18n:govoplan-core.appearance_override_invalid_schema");
|
||||
}
|
||||
if (!hasExactKeys(value, ["schema_version", "light", "dark"])) {
|
||||
throw new Error("i18n:govoplan-core.appearance_override_invalid_schema");
|
||||
}
|
||||
const document = value as unknown as AppearanceOverridesDocument;
|
||||
for (const modeName of ["light", "dark"] as const) {
|
||||
const mode = document[modeName];
|
||||
if (!hasExactKeys(mode, APPEARANCE_OVERRIDE_TOKENS)) {
|
||||
throw new Error("i18n:govoplan-core.appearance_override_all_tokens_required");
|
||||
}
|
||||
for (const token of APPEARANCE_OVERRIDE_TOKENS) {
|
||||
if (typeof mode[token] !== "string" || !HEX_COLOR.test(mode[token])) {
|
||||
throw new Error("i18n:govoplan-core.appearance_override_hex_required");
|
||||
}
|
||||
}
|
||||
for (const [background, foreground] of [
|
||||
["accent", "accent_foreground"], ["surface", "surface_foreground"],
|
||||
["success", "success_foreground"], ["info", "info_foreground"],
|
||||
["warning", "warning_foreground"], ["danger", "danger_foreground"]
|
||||
] as const) {
|
||||
if (contrastRatio(mode[background], mode[foreground]) < 4.5) {
|
||||
throw new Error("i18n:govoplan-core.appearance_override_contrast_error");
|
||||
}
|
||||
}
|
||||
for (let first = 0; first < STATUS_TOKENS.length; first += 1) {
|
||||
for (let second = first + 1; second < STATUS_TOKENS.length; second += 1) {
|
||||
if (rgbDistance(mode[STATUS_TOKENS[first]], mode[STATUS_TOKENS[second]]) < 12) {
|
||||
throw new Error("i18n:govoplan-core.appearance_override_status_error");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
export function applyAppearanceOverrides(
|
||||
root: HTMLElement,
|
||||
document: AppearanceOverridesDocument | null,
|
||||
theme: "light" | "dark"
|
||||
) {
|
||||
for (const property of RUNTIME_PROPERTIES) root.style.removeProperty(property);
|
||||
if (!document) return;
|
||||
let validated: AppearanceOverridesDocument;
|
||||
try {
|
||||
validated = validateAppearanceOverrides(document);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const colors = validated[theme];
|
||||
for (const token of APPEARANCE_OVERRIDE_TOKENS) {
|
||||
for (const property of RUNTIME_TOKEN_PROPERTIES[token]) {
|
||||
root.style.setProperty(property, colors[token]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function AppearanceOverridesEditor({
|
||||
value,
|
||||
onChange,
|
||||
theme = "system",
|
||||
disabled = false
|
||||
}: {
|
||||
value: AppearanceOverridesDocument | null;
|
||||
onChange: (value: AppearanceOverridesDocument | null) => void;
|
||||
theme?: UserUiTheme;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [mode, setMode] = useState<"light" | "dark">(theme === "dark" ? "dark" : "light");
|
||||
const [message, setMessage] = useState("");
|
||||
const fileInput = useRef<HTMLInputElement | null>(null);
|
||||
const validationMessage = appearanceOverridesValidationMessage(value);
|
||||
|
||||
function updateToken(token: AppearanceOverrideToken, color: string) {
|
||||
const document = value ?? cloneDefaultAppearanceOverrides();
|
||||
onChange({
|
||||
...document,
|
||||
[mode]: { ...document[mode], [token]: color }
|
||||
});
|
||||
}
|
||||
|
||||
function exportDocument() {
|
||||
if (!value) return;
|
||||
const blob = new Blob([`${JSON.stringify(value, null, 2)}\n`], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = window.document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = "govoplan-appearance-overrides-v1.json";
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function importDocument(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
try {
|
||||
const document = validateAppearanceOverrides(JSON.parse(await file.text()));
|
||||
onChange(document);
|
||||
setMessage("i18n:govoplan-core.appearance_override_imported");
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : "i18n:govoplan-core.appearance_override_invalid_schema");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="appearance-overrides-editor" aria-label="i18n:govoplan-core.advanced_theme_overrides">
|
||||
<div className="appearance-overrides-actions">
|
||||
<Button onClick={() => onChange(cloneDefaultAppearanceOverrides())} disabled={disabled}>
|
||||
{value ? "i18n:govoplan-core.restore_safe_defaults" : "i18n:govoplan-core.configure_overrides"}
|
||||
</Button>
|
||||
<Button onClick={() => fileInput.current?.click()} disabled={disabled}>i18n:govoplan-core.import_overrides</Button>
|
||||
<Button onClick={exportDocument} disabled={!value}>i18n:govoplan-core.export_overrides</Button>
|
||||
<Button onClick={() => onChange(null)} disabled={!value}>i18n:govoplan-core.remove_overrides</Button>
|
||||
<input ref={fileInput} type="file" accept="application/json,.json" hidden onChange={(event) => void importDocument(event)} />
|
||||
</div>
|
||||
{message && <DismissibleAlert tone={message.endsWith("imported") ? "success" : "warning"} resetKey={message}>{message}</DismissibleAlert>}
|
||||
{!value && <p className="muted small-note">i18n:govoplan-core.appearance_override_not_configured_help</p>}
|
||||
{value && <>
|
||||
<SegmentedControl
|
||||
options={[
|
||||
{ id: "light" as const, label: "i18n:govoplan-core.light_theme.7878f1fa" },
|
||||
{ id: "dark" as const, label: "i18n:govoplan-core.dark_theme.164a90d9" }
|
||||
]}
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
ariaLabel="i18n:govoplan-core.appearance_override_mode"
|
||||
/>
|
||||
<ContentGrid columns={3} collapseAt="workspace" className="appearance-overrides-fields">
|
||||
{APPEARANCE_OVERRIDE_TOKENS.map((token) => (
|
||||
<FormField key={token} label={TOKEN_LABELS[token]}>
|
||||
<ColorPickerField value={value[mode][token]} onChange={(color) => updateToken(token, color)} disabled={disabled} />
|
||||
</FormField>
|
||||
))}
|
||||
</ContentGrid>
|
||||
<AppearanceOverridesPreview mode={mode} colors={value[mode]} />
|
||||
<p className={validationMessage ? "form-error" : "muted small-note"}>
|
||||
{validationMessage || "i18n:govoplan-core.appearance_override_validation_ok"}
|
||||
</p>
|
||||
</>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AppearanceOverridesPreview({ mode, colors }: { mode: "light" | "dark"; colors: AppearanceModeOverrides }) {
|
||||
const style = {
|
||||
"--appearance-preview-accent": colors.accent,
|
||||
"--appearance-preview-accent-foreground": colors.accent_foreground,
|
||||
"--appearance-preview-surface": colors.surface,
|
||||
"--appearance-preview-surface-foreground": colors.surface_foreground
|
||||
} as CSSProperties;
|
||||
return (
|
||||
<div className="appearance-overrides-preview" data-mode={mode} style={style}>
|
||||
<strong>GovOPlaN</strong>
|
||||
<button type="button">i18n:govoplan-core.preview_action</button>
|
||||
<div className="appearance-overrides-statuses">
|
||||
{STATUS_TOKENS.map((token) => (
|
||||
<span key={token} style={{ backgroundColor: colors[token], color: colors[`${token}_foreground` as AppearanceOverrideToken] }}>
|
||||
{TOKEN_LABELS[token]}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function appearanceOverridesValidationMessage(value: AppearanceOverridesDocument | null): string {
|
||||
if (!value) return "";
|
||||
try {
|
||||
validateAppearanceOverrides(value);
|
||||
return "";
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : "i18n:govoplan-core.appearance_override_invalid_schema";
|
||||
}
|
||||
}
|
||||
|
||||
function hasExactKeys(value: object, keys: readonly string[]): boolean {
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function relativeLuminance(color: string): number {
|
||||
const channels = [1, 3, 5].map((index) => Number.parseInt(color.slice(index, index + 2), 16) / 255);
|
||||
const linear = channels.map((channel) => channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4);
|
||||
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
|
||||
}
|
||||
|
||||
function contrastRatio(first: string, second: string): number {
|
||||
const luminances = [relativeLuminance(first), relativeLuminance(second)].sort((left, right) => right - left);
|
||||
return (luminances[0] + 0.05) / (luminances[1] + 0.05);
|
||||
}
|
||||
|
||||
function rgbDistance(first: string, second: string): number {
|
||||
return Math.sqrt([1, 3, 5].reduce((total, index) => {
|
||||
const delta = Number.parseInt(first.slice(index, index + 2), 16) - Number.parseInt(second.slice(index, index + 2), 16);
|
||||
return total + delta * delta;
|
||||
}, 0));
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import DescriptionList from "../../components/DescriptionList";
|
||||
import ContentGrid, { FormGrid } from "../../components/ContentGrid";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import type { ApiSettings, AuthInfo, AuthUpdate, FilesConnectorsUiCapability, MailProfilesUiCapability, NavigationPreferences, SettingsSectionContribution, SettingsSectionsUiCapability, UserUiPalette, UserUiPreferences, UserUiTheme } from "../../types";
|
||||
import type { AppearanceOverridesDocument, ApiSettings, AuthInfo, AuthUpdate, FilesConnectorsUiCapability, MailProfilesUiCapability, NavigationPreferences, SettingsSectionContribution, SettingsSectionsUiCapability, UserUiPalette, UserUiPreferences, UserUiTheme } from "../../types";
|
||||
import Card from "../../components/Card";
|
||||
import FormField from "../../components/FormField";
|
||||
import PasswordField from "../../components/PasswordField";
|
||||
@@ -28,6 +28,7 @@ import CredentialEnvelopeManager from "../../components/CredentialEnvelopeManage
|
||||
import DocumentationHelpLink from "../../components/help/DocumentationHelpLink";
|
||||
import WorkspaceLayout from "../../components/WorkspaceLayout";
|
||||
import { AppearancePalettePreview, AppearancePaletteSelect, APPEARANCE_PALETTE_OPTIONS, appearancePaletteLabel } from "../../components/AppearancePaletteControl";
|
||||
import AppearanceOverridesEditor from "../../components/AppearanceOverridesEditor";
|
||||
|
||||
type SettingsSection = "profile" | "mail-profiles" | "file-connectors" | "interface" | "workspace" | "local-connection" | string;
|
||||
|
||||
@@ -37,7 +38,8 @@ const DEFAULT_UI_PREFERENCES: UserUiPreferences = {
|
||||
reduce_motion: false,
|
||||
sticky_section_sidebars: true,
|
||||
theme: "system",
|
||||
palette: null
|
||||
palette: null,
|
||||
appearance_overrides: null
|
||||
};
|
||||
|
||||
const UI_THEME_OPTIONS: Array<{ value: UserUiTheme; label: string }> = [
|
||||
@@ -158,6 +160,7 @@ export default function SettingsPage({
|
||||
const [stickySections, setStickySections] = useState(currentUiPreferences.sticky_section_sidebars);
|
||||
const [theme, setTheme] = useState<UserUiTheme>(currentUiPreferences.theme);
|
||||
const [palette, setPalette] = useState<UserUiPalette | null>(currentUiPreferences.palette);
|
||||
const [appearanceOverrides, setAppearanceOverrides] = useState<AppearanceOverridesDocument | null>(currentUiPreferences.appearance_overrides ?? null);
|
||||
const [navigation, setNavigation] = useState<NavigationPreferences | null>(currentUiPreferences.navigation ?? null);
|
||||
const [uiBusy, setUiBusy] = useState(false);
|
||||
const [uiResult, setUiResult] = useState("");
|
||||
@@ -175,6 +178,7 @@ export default function SettingsPage({
|
||||
stickySections !== currentUiPreferences.sticky_section_sidebars ||
|
||||
theme !== currentUiPreferences.theme ||
|
||||
palette !== currentUiPreferences.palette ||
|
||||
JSON.stringify(appearanceOverrides) !== JSON.stringify(currentUiPreferences.appearance_overrides ?? null) ||
|
||||
JSON.stringify(navigation) !== JSON.stringify(currentUiPreferences.navigation ?? null);
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
@@ -230,6 +234,7 @@ export default function SettingsPage({
|
||||
setStickySections(currentUiPreferences.sticky_section_sidebars);
|
||||
setTheme(currentUiPreferences.theme);
|
||||
setPalette(currentUiPreferences.palette);
|
||||
setAppearanceOverrides(currentUiPreferences.appearance_overrides ?? null);
|
||||
setNavigation(currentUiPreferences.navigation ?? null);
|
||||
}, [
|
||||
currentUiPreferences.compact_tables,
|
||||
@@ -238,6 +243,7 @@ export default function SettingsPage({
|
||||
currentUiPreferences.sticky_section_sidebars,
|
||||
currentUiPreferences.theme,
|
||||
currentUiPreferences.palette,
|
||||
currentUiPreferences.appearance_overrides,
|
||||
currentUiPreferences.navigation
|
||||
]);
|
||||
|
||||
@@ -278,6 +284,7 @@ export default function SettingsPage({
|
||||
setStickySections(currentUiPreferences.sticky_section_sidebars);
|
||||
setTheme(currentUiPreferences.theme);
|
||||
setPalette(currentUiPreferences.palette);
|
||||
setAppearanceOverrides(currentUiPreferences.appearance_overrides ?? null);
|
||||
setNavigation(currentUiPreferences.navigation ?? null);
|
||||
}
|
||||
|
||||
@@ -289,6 +296,7 @@ export default function SettingsPage({
|
||||
sticky_section_sidebars: stickySections,
|
||||
theme,
|
||||
palette,
|
||||
appearance_overrides: appearanceOverrides,
|
||||
navigation
|
||||
};
|
||||
}
|
||||
@@ -498,13 +506,20 @@ export default function SettingsPage({
|
||||
<div><dt>i18n:govoplan-core.accent_color.e49578ed</dt><dd>{paletteLabel(palette ?? auth.user.appearance?.inherited_palette ?? "default")}</dd></div>
|
||||
<div><dt>i18n:govoplan-core.effective_source</dt><dd>{appearanceSourceLabel(auth.user.appearance?.source)}</dd></div>
|
||||
<div><dt>i18n:govoplan-core.accessibility</dt><dd>i18n:govoplan-core.palette_contrast_validated</dd></div>
|
||||
<div><dt>i18n:govoplan-core.advanced_theme_overrides</dt><dd>i18n:govoplan-core.not_configured</dd></div>
|
||||
<div><dt>i18n:govoplan-core.advanced_theme_overrides</dt><dd>{appearanceOverrides ? "i18n:govoplan-core.configured" : "i18n:govoplan-core.not_configured"}</dd></div>
|
||||
<div><dt>i18n:govoplan-core.language.89b86ab0</dt><dd>{languageLabel}</dd></div>
|
||||
<div><dt>i18n:govoplan-core.enabled.df174a3f</dt><dd>{enabledLanguages.map((item) => item.code.toUpperCase()).join(", ")}</dd></div>
|
||||
<div><dt>i18n:govoplan-core.available.7c62a142</dt><dd>{availableLanguages.map((item) => item.code.toUpperCase()).join(", ")}</dd></div>
|
||||
<div><dt>i18n:govoplan-core.density.f9160c22</dt><dd>{compactTables ? "i18n:govoplan-core.compact_preview.3e06901d" : "i18n:govoplan-core.comfortable.2313707a"}</dd></div>
|
||||
</DescriptionList>
|
||||
<p className="muted small-note">i18n:govoplan-core.advanced_theme_overrides_follow_up</p>
|
||||
<AppearanceOverridesEditor
|
||||
value={appearanceOverrides}
|
||||
onChange={setAppearanceOverrides}
|
||||
theme={theme}
|
||||
disabled={auth.user.appearance?.custom_overrides_allowed !== true}
|
||||
/>
|
||||
{auth.user.appearance?.custom_overrides_allowed !== true &&
|
||||
<p className="muted small-note">i18n:govoplan-core.appearance_override_policy_disabled</p>}
|
||||
</FormGrid>
|
||||
</Card>
|
||||
</ContentGrid>
|
||||
@@ -640,6 +655,7 @@ function normalizeUiPreferences(value: Partial<UserUiPreferences> | null | undef
|
||||
sticky_section_sidebars: Boolean(value?.sticky_section_sidebars ?? DEFAULT_UI_PREFERENCES.sticky_section_sidebars),
|
||||
theme,
|
||||
palette: normalizeOptionalUiPalette(value?.palette),
|
||||
appearance_overrides: value?.appearance_overrides ?? null,
|
||||
navigation: value?.navigation ?? null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,6 +21,36 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-core.advanced_theme_overrides": "Advanced custom overrides",
|
||||
"i18n:govoplan-core.not_configured": "Not configured",
|
||||
"i18n:govoplan-core.advanced_theme_overrides_follow_up": "Arbitrary token overrides require separate tenant branding policy and accessibility safeguards.",
|
||||
"i18n:govoplan-core.configured": "Configured",
|
||||
"i18n:govoplan-core.configure_overrides": "Configure safe defaults",
|
||||
"i18n:govoplan-core.restore_safe_defaults": "Restore safe defaults",
|
||||
"i18n:govoplan-core.import_overrides": "Import JSON",
|
||||
"i18n:govoplan-core.export_overrides": "Export JSON",
|
||||
"i18n:govoplan-core.explanation_subject.6ff46d22": "Explanation subject",
|
||||
"i18n:govoplan-core.remove_overrides": "Remove overrides",
|
||||
"i18n:govoplan-core.appearance_override_not_configured_help": "No personal token overrides are stored. Preset and policy inheritance remain active.",
|
||||
"i18n:govoplan-core.appearance_override_policy_disabled": "The governing appearance policy currently blocks creating or editing personal overrides. Existing overrides may still be exported or removed.",
|
||||
"i18n:govoplan-core.appearance_override_mode": "Color mode",
|
||||
"i18n:govoplan-core.appearance_override_imported": "The validated appearance document was imported into this draft.",
|
||||
"i18n:govoplan-core.appearance_override_invalid_schema": "Use the supported version 1 appearance document with exactly light and dark modes.",
|
||||
"i18n:govoplan-core.appearance_override_all_tokens_required": "Every supported token must be present exactly once in both modes.",
|
||||
"i18n:govoplan-core.appearance_override_hex_required": "Every token must use a six-digit hexadecimal color.",
|
||||
"i18n:govoplan-core.appearance_override_contrast_error": "Each foreground must meet WCAG AA contrast against its paired color.",
|
||||
"i18n:govoplan-core.appearance_override_status_error": "Success, information, warning, and danger colors must remain visibly distinct.",
|
||||
"i18n:govoplan-core.appearance_override_validation_ok": "Both modes pass contrast and semantic status differentiation checks. Saving applies the document atomically.",
|
||||
"i18n:govoplan-core.override_accent": "Accent",
|
||||
"i18n:govoplan-core.override_accent_foreground": "Accent foreground",
|
||||
"i18n:govoplan-core.override_surface": "Surface",
|
||||
"i18n:govoplan-core.override_surface_foreground": "Surface foreground",
|
||||
"i18n:govoplan-core.override_success": "Success",
|
||||
"i18n:govoplan-core.override_success_foreground": "Success foreground",
|
||||
"i18n:govoplan-core.override_info": "Information",
|
||||
"i18n:govoplan-core.override_info_foreground": "Information foreground",
|
||||
"i18n:govoplan-core.override_warning": "Warning",
|
||||
"i18n:govoplan-core.override_warning_foreground": "Warning foreground",
|
||||
"i18n:govoplan-core.override_danger": "Danger",
|
||||
"i18n:govoplan-core.override_danger_foreground": "Danger foreground",
|
||||
"i18n:govoplan-core.preview_action": "Primary action",
|
||||
"i18n:govoplan-core.standard_folder_mappings": "Standard folder mappings",
|
||||
"i18n:govoplan-core.standard_folder_mappings_help": "Map each standard mailbox role to a folder exposed by this IMAP account. Leave a field empty to use automatic detection.",
|
||||
"i18n:govoplan-core.inbox_folder": "Inbox folder",
|
||||
@@ -727,6 +757,36 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-core.advanced_theme_overrides": "Erweiterte benutzerdefinierte Anpassungen",
|
||||
"i18n:govoplan-core.not_configured": "Nicht konfiguriert",
|
||||
"i18n:govoplan-core.advanced_theme_overrides_follow_up": "Beliebige Token-Anpassungen erfordern eine getrennte Mandanten-Branding-Richtlinie und Barrierefreiheitsprüfungen.",
|
||||
"i18n:govoplan-core.configured": "Konfiguriert",
|
||||
"i18n:govoplan-core.configure_overrides": "Sichere Standardwerte einrichten",
|
||||
"i18n:govoplan-core.restore_safe_defaults": "Sichere Standardwerte wiederherstellen",
|
||||
"i18n:govoplan-core.import_overrides": "JSON importieren",
|
||||
"i18n:govoplan-core.export_overrides": "JSON exportieren",
|
||||
"i18n:govoplan-core.explanation_subject.6ff46d22": "Person für die Zugriffserklärung",
|
||||
"i18n:govoplan-core.remove_overrides": "Anpassungen entfernen",
|
||||
"i18n:govoplan-core.appearance_override_not_configured_help": "Es sind keine persönlichen Token-Anpassungen gespeichert. Vorgaben und Richtlinienvererbung bleiben aktiv.",
|
||||
"i18n:govoplan-core.appearance_override_policy_disabled": "Die geltende Darstellungsrichtlinie sperrt derzeit das Anlegen oder Bearbeiten persönlicher Anpassungen. Bestehende Anpassungen können weiterhin exportiert oder entfernt werden.",
|
||||
"i18n:govoplan-core.appearance_override_mode": "Farbmodus",
|
||||
"i18n:govoplan-core.appearance_override_imported": "Das geprüfte Darstellungsdokument wurde in diesen Entwurf importiert.",
|
||||
"i18n:govoplan-core.appearance_override_invalid_schema": "Verwenden Sie das unterstützte Darstellungsdokument der Version 1 mit genau einem hellen und einem dunklen Modus.",
|
||||
"i18n:govoplan-core.appearance_override_all_tokens_required": "Jedes unterstützte Token muss in beiden Modi genau einmal vorhanden sein.",
|
||||
"i18n:govoplan-core.appearance_override_hex_required": "Jedes Token muss eine sechsstellige hexadezimale Farbe verwenden.",
|
||||
"i18n:govoplan-core.appearance_override_contrast_error": "Jede Vordergrundfarbe muss gegenüber der zugehörigen Farbe den WCAG-AA-Kontrast erfüllen.",
|
||||
"i18n:govoplan-core.appearance_override_status_error": "Erfolg, Information, Warnung und Gefahr müssen visuell unterscheidbar bleiben.",
|
||||
"i18n:govoplan-core.appearance_override_validation_ok": "Beide Modi erfüllen die Prüfungen für Kontrast und semantische Statusunterscheidung. Beim Speichern wird das Dokument atomar angewendet.",
|
||||
"i18n:govoplan-core.override_accent": "Akzent",
|
||||
"i18n:govoplan-core.override_accent_foreground": "Akzent-Vordergrund",
|
||||
"i18n:govoplan-core.override_surface": "Oberfläche",
|
||||
"i18n:govoplan-core.override_surface_foreground": "Oberflächen-Vordergrund",
|
||||
"i18n:govoplan-core.override_success": "Erfolg",
|
||||
"i18n:govoplan-core.override_success_foreground": "Erfolg-Vordergrund",
|
||||
"i18n:govoplan-core.override_info": "Information",
|
||||
"i18n:govoplan-core.override_info_foreground": "Information-Vordergrund",
|
||||
"i18n:govoplan-core.override_warning": "Warnung",
|
||||
"i18n:govoplan-core.override_warning_foreground": "Warnungs-Vordergrund",
|
||||
"i18n:govoplan-core.override_danger": "Gefahr",
|
||||
"i18n:govoplan-core.override_danger_foreground": "Gefahren-Vordergrund",
|
||||
"i18n:govoplan-core.preview_action": "Primäraktion",
|
||||
"i18n:govoplan-core.standard_folder_mappings": "Zuordnung der Standardordner",
|
||||
"i18n:govoplan-core.standard_folder_mappings_help": "Ordnen Sie jede Standardfunktion einem Ordner dieses IMAP-Kontos zu. Lassen Sie ein Feld leer, um die automatische Erkennung zu verwenden.",
|
||||
"i18n:govoplan-core.inbox_folder": "Posteingang",
|
||||
@@ -878,7 +938,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-core.choose_date.e7877fd9": "Choose date",
|
||||
"i18n:govoplan-core.clear_filter.b667d6f9": "Clear filter",
|
||||
"i18n:govoplan-core.close_filter.3a281c3f": "Close filter",
|
||||
"i18n:govoplan-core.close.bbfa773e": "Close",
|
||||
"i18n:govoplan-core.close.bbfa773e": "Schließen",
|
||||
"i18n:govoplan-core.collapse.9cf188d3": "Collapse",
|
||||
"i18n:govoplan-core.color_picker.91dee962": "Color picker",
|
||||
"i18n:govoplan-core.comfortable.2313707a": "Comfortable",
|
||||
@@ -892,7 +952,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-core.connection_security_mode_expected_by_the_server_.4348e12e": "Connection security mode expected by the server: plain, TLS or STARTTLS.",
|
||||
"i18n:govoplan-core.connection_successful_the_backend_health_endpoin.14eb9295": "Connection successful. The backend health endpoint responded.",
|
||||
"i18n:govoplan-core.contains.33e15d00": "Contains",
|
||||
"i18n:govoplan-core.context_help.7fc44ea1": "Context help",
|
||||
"i18n:govoplan-core.context_help.7fc44ea1": "Kontexthilfe",
|
||||
"i18n:govoplan-core.controls_contextual_ui_help_markers_once_persist.0eaef9c4": "Controls contextual UI help markers once persisted user preferences are available.",
|
||||
"i18n:govoplan-core.core_only.d46ce7d9": "Core only",
|
||||
"i18n:govoplan-core.core_shell_only.4949b5d5": "Core shell only",
|
||||
@@ -989,9 +1049,9 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-core.group.171a0606": "Group",
|
||||
"i18n:govoplan-core.groups": "Gruppen",
|
||||
"i18n:govoplan-core.headers.520de744": "Headers",
|
||||
"i18n:govoplan-core.help_context.61aed3b9": "Help context:",
|
||||
"i18n:govoplan-core.help_context.61aed3b9": "Hilfekontext:",
|
||||
"i18n:govoplan-core.help_f1": "Hilfe (F1)",
|
||||
"i18n:govoplan-core.help.c47ae153": "Help",
|
||||
"i18n:govoplan-core.help.c47ae153": "Hilfe",
|
||||
"i18n:govoplan-core.hh_mm.a4c7ee9b": "HH:MM",
|
||||
"i18n:govoplan-core.hide_password.e40123b4": "Hide password",
|
||||
"i18n:govoplan-core.high_level_campaign_pattern_used_by_guided_setup.c5ec6652": "High-level campaign pattern used by guided setup to prefill sensible defaults.",
|
||||
@@ -1116,9 +1176,9 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-core.on.e0049a66": "On",
|
||||
"i18n:govoplan-core.only_users_with_maintenance_access_can_use_the_s.5566264f": "Only users with maintenance access can use the system right now.",
|
||||
"i18n:govoplan-core.open_address_form.f8ee560f": "Open address form",
|
||||
"i18n:govoplan-core.open_admin_documentation.6adbdae3": "Open admin documentation",
|
||||
"i18n:govoplan-core.open_admin_documentation.6adbdae3": "Administrator-Dokumentation öffnen",
|
||||
"i18n:govoplan-core.open_maintenance_mode_settings.99b41249": "Open maintenance mode settings",
|
||||
"i18n:govoplan-core.open_user_documentation.084af515": "Open user documentation",
|
||||
"i18n:govoplan-core.open_user_documentation.084af515": "Benutzerdokumentation öffnen",
|
||||
"i18n:govoplan-core.optional_backend_url_leave_empty_to_use_the_curr.52bd2bb4": "Optional backend URL. Leave empty to use the current origin and the configured Vite/backend proxy.",
|
||||
"i18n:govoplan-core.optional_html_version_of_the_email_body_for_rich.3f01db97": "Optional HTML version of the email body for richer formatting.",
|
||||
"i18n:govoplan-core.optional_local_alias_for_the_active_tenant_it_do.edbb7b14": "Optional local alias for the active tenant. It does not replace the global account name in the title bar.",
|
||||
@@ -1126,7 +1186,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-core.owner.89ff3122": "Eigentümer",
|
||||
"i18n:govoplan-core.owner_policy.1e8df143": "Owner policy",
|
||||
"i18n:govoplan-core.p_no_html_body_content_p.c305bfc6": "<p>No HTML body content.</p>",
|
||||
"i18n:govoplan-core.page.fb06270f": "Page",
|
||||
"i18n:govoplan-core.page.fb06270f": "Seite",
|
||||
"i18n:govoplan-core.password_for_the_selected_account_or_mail_server.d9681f16": "Password for the selected account or mail server. Stored and transmitted according to the configured backend flow.",
|
||||
"i18n:govoplan-core.password_protected.09d9e174": "Password protected",
|
||||
"i18n:govoplan-core.password.8be3c943": "Passwort",
|
||||
@@ -1288,12 +1348,12 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-core.theme_and_language.60a8cf59": "Design und Sprache",
|
||||
"i18n:govoplan-core.theme.a797e309": "Theme",
|
||||
"i18n:govoplan-core.this_address_is_already_listed.0f6d0ff2": "This address is already listed.",
|
||||
"i18n:govoplan-core.this_area_is_prepared_for_context_sensitive_help.57665877": "This area is prepared for context-sensitive help. Future help content can use this context identifier, or the equivalent help query parameter",
|
||||
"i18n:govoplan-core.this_area_is_prepared_for_context_sensitive_help.57665877": "Das fokussierte Bedienelement verweist auf den folgenden dokumentierten Hilfekontext:",
|
||||
"i18n:govoplan-core.this_global_name_is_shown_in_the_title_bar_and_f.1d46240e": "This global name is shown in the title bar and follows you across tenant memberships.",
|
||||
"i18n:govoplan-core.this_module_is_prepared_but_not_implemented_yet.6e6449ee": "Dieses Modul ist vorbereitet, aber noch nicht umgesetzt.",
|
||||
"i18n:govoplan-core.this_page_has_unsaved_changes_save_them_before_l.419a9d8b": "This page has unsaved changes. Save them before leaving, or discard the changes and continue.",
|
||||
"i18n:govoplan-core.timeout_seconds.0bfc6553": "Timeout seconds",
|
||||
"i18n:govoplan-core.to_open_the_right_page_or_section.5ecf4fd2": "to open the right page or section.",
|
||||
"i18n:govoplan-core.to_open_the_right_page_or_section.5ecf4fd2": "Öffnen Sie die verknüpfte Dokumentation für Berechtigungen, Folgen und Hinweise zur Wiederherstellung.",
|
||||
"i18n:govoplan-core.to.ae79ea1e": "To",
|
||||
"i18n:govoplan-core.tu.b44892b7": "Tu",
|
||||
"i18n:govoplan-core.type_a_name_and_email_address_then_press_enter.7c8d43f0": "Type a name and email address, then press Enter",
|
||||
|
||||
@@ -62,6 +62,14 @@ export { default as AdminSelectionList } from "./components/admin/AdminSelection
|
||||
export { adminErrorMessage, formatAdminDateTime, joinLabels } from "./components/admin/adminUtils";
|
||||
export { default as Button } from "./components/Button";
|
||||
export { AppearancePalettePreview, AppearancePaletteSelect, APPEARANCE_PALETTE_OPTIONS, appearancePaletteLabel } from "./components/AppearancePaletteControl";
|
||||
export {
|
||||
default as AppearanceOverridesEditor,
|
||||
APPEARANCE_OVERRIDE_TOKENS,
|
||||
DEFAULT_APPEARANCE_OVERRIDES,
|
||||
applyAppearanceOverrides,
|
||||
cloneDefaultAppearanceOverrides,
|
||||
validateAppearanceOverrides
|
||||
} from "./components/AppearanceOverridesEditor";
|
||||
export type { ButtonProps } from "./components/Button";
|
||||
export { default as Card } from "./components/Card";
|
||||
export type { CardProps } from "./components/Card";
|
||||
|
||||
@@ -3224,6 +3224,63 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.appearance-overrides-editor {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: var(--border-line);
|
||||
}
|
||||
|
||||
.appearance-overrides-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.appearance-overrides-fields {
|
||||
padding: 12px;
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.appearance-overrides-preview {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 82px;
|
||||
padding: 14px;
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--appearance-preview-surface);
|
||||
color: var(--appearance-preview-surface-foreground);
|
||||
}
|
||||
|
||||
.appearance-overrides-preview > button {
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--appearance-preview-accent);
|
||||
color: var(--appearance-preview-accent-foreground);
|
||||
padding: 7px 12px;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.appearance-overrides-statuses {
|
||||
display: flex;
|
||||
flex: 1 1 100%;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.appearance-overrides-statuses > span {
|
||||
border-radius: var(--radius-pill);
|
||||
padding: 4px 9px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.theme-preview {
|
||||
--preview-bg: var(--theme-preview-light-bg);
|
||||
--preview-bar: var(--theme-preview-light-bar);
|
||||
|
||||
+26
-3
@@ -43,6 +43,16 @@ export type AuthUser = {
|
||||
|
||||
export type UserUiTheme = "system" | "light" | "dark";
|
||||
export type UserUiPalette = "default" | "civic_blue" | "forest" | "plum";
|
||||
export type AppearanceOverrideToken =
|
||||
| "accent" | "accent_foreground" | "surface" | "surface_foreground"
|
||||
| "success" | "success_foreground" | "info" | "info_foreground"
|
||||
| "warning" | "warning_foreground" | "danger" | "danger_foreground";
|
||||
export type AppearanceModeOverrides = Record<AppearanceOverrideToken, string>;
|
||||
export type AppearanceOverridesDocument = {
|
||||
schema_version: "1";
|
||||
light: AppearanceModeOverrides;
|
||||
dark: AppearanceModeOverrides;
|
||||
};
|
||||
|
||||
export type UserUiPreferences = {
|
||||
compact_tables: boolean;
|
||||
@@ -51,6 +61,7 @@ export type UserUiPreferences = {
|
||||
sticky_section_sidebars: boolean;
|
||||
theme: UserUiTheme;
|
||||
palette: UserUiPalette | null;
|
||||
appearance_overrides?: AppearanceOverridesDocument | null;
|
||||
navigation?: NavigationPreferences | null;
|
||||
};
|
||||
|
||||
@@ -61,6 +72,8 @@ export type EffectiveAppearance = {
|
||||
system_default_palette: UserUiPalette;
|
||||
tenant_default_palette?: UserUiPalette | null;
|
||||
inherited_palette: UserUiPalette;
|
||||
custom_overrides?: AppearanceOverridesDocument | null;
|
||||
custom_overrides_allowed?: boolean;
|
||||
};
|
||||
|
||||
export type AuthTenant = {
|
||||
@@ -834,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;
|
||||
@@ -870,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;
|
||||
@@ -909,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;
|
||||
|
||||
@@ -32,6 +32,7 @@ const legacyToModuleScopes: Record<string, string> = {
|
||||
"system:access:read": "access:system_role:read",
|
||||
"system:access:assign": "access:system_role:assign",
|
||||
"system:audit:read": "access:audit:read",
|
||||
"system:audit:evidence:export": "audit:system_evidence:export",
|
||||
"system:settings:read": "access:system_setting:read",
|
||||
"system:settings:write": "access:system_setting:write",
|
||||
"system:maintenance:access": "access:maintenance:access",
|
||||
|
||||
@@ -22,6 +22,7 @@ const defaultWebModulePackages = [
|
||||
"@govoplan/cases-webui",
|
||||
"@govoplan/campaign-webui",
|
||||
"@govoplan/committee-webui",
|
||||
"@govoplan/connectors-webui",
|
||||
"@govoplan/dataflow-webui",
|
||||
"@govoplan/datasources-webui",
|
||||
"@govoplan/dashboard-webui",
|
||||
@@ -31,7 +32,9 @@ 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",
|
||||
"@govoplan/mail-webui",
|
||||
"@govoplan/notifications-webui",
|
||||
@@ -50,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"
|
||||
];
|
||||
|
||||
@@ -255,6 +260,7 @@ export default defineConfig({
|
||||
fileURLToPath(new URL('../../govoplan-audit/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-calendar/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-cases/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-connectors/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-dataflow/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-datasources/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-dashboard/webui', import.meta.url)),
|
||||
@@ -263,7 +269,9 @@ 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)),
|
||||
fileURLToPath(new URL('../../govoplan-notifications/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-organizations/webui', import.meta.url)),
|
||||
@@ -279,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))
|
||||
]
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@govoplan/core-webui": resolve(import.meta.dirname, "conformance/QuickAccessCoreFacade.ts"),
|
||||
"virtual:govoplan-installed-modules": resolve(import.meta.dirname, "conformance/installedModules.ts"),
|
||||
react: resolve(import.meta.dirname, "node_modules/react"),
|
||||
"react-router": resolve(import.meta.dirname, "node_modules/react-router"),
|
||||
"lucide-react": resolve(import.meta.dirname, "node_modules/lucide-react")
|
||||
|
||||
Reference in New Issue
Block a user