Compare commits

...
25 Commits
Author SHA1 Message Date
zemion a090e5af20 feat(tickets): add optional integration contracts and WebUI composition
Module Package Release / publish-packages (push) Successful in 13s
2026-08-22 12:06:04 +02:00
zemion 0c1358b862 feat(policy): add delegation and escalation contracts
Module Package Release / publish-packages (push) Failing after 6s
2026-08-22 03:12:30 +02:00
zemion a9035c4c3b feat: add Campaign work orchestration contract
Module Package Release / publish-packages (push) Failing after 6s
2026-08-22 02:15:32 +02:00
zemion 137c7c005f test(core): recognize Campaign work integrations
Module Package Release / publish-packages (push) Successful in 12s
2026-08-22 01:06:12 +02:00
zemion 8eeea968f2 chore(release): load Campaign 0.1.22
Module Package Release / publish-packages (push) Successful in 12s
2026-08-22 01:01:35 +02:00
zemion 0ca6568005 chore(release): load Campaign 0.1.21
Module Package Release / publish-packages (push) Successful in 12s
2026-08-22 00:54:00 +02:00
zemion af90db44c9 chore(webui): load Campaign collaboration release
Module Package Release / publish-packages (push) Successful in 12s
2026-08-22 00:18:06 +02:00
zemion 5de46e9c0e chore(webui): load Files folder sync release
Module Package Release / publish-packages (push) Successful in 12s
2026-08-21 22:50:48 +02:00
zemion 1d9b677c1b chore(webui): load IDM relationship administration release
Module Package Release / publish-packages (push) Successful in 12s
2026-08-21 22:06:29 +02:00
zemion 54178ee56c chore(webui): load Access credential help release
Module Package Release / publish-packages (push) Successful in 12s
2026-08-21 21:15:31 +02:00
zemion 10e7597612 feat(core): define datasource visibility policy contract
Module Package Release / publish-packages (push) Failing after 6s
2026-08-21 20:31:51 +02:00
zemion 142ccbc587 feat(core): define managed tabular file contract
Module Package Release / publish-packages (push) Successful in 12s
2026-08-21 19:29:48 +02:00
zemion f75ad48d78 test(core): isolate entitlement navigation fixture 2026-08-21 19:26:00 +02:00
zemion 5a9e8f79f9 test(webui): cover contextual help in the browser 2026-08-21 17:36:06 +02:00
zemion fbea74a74b feat(core): define durable datasource artifact contract 2026-08-21 17:36:05 +02:00
zemion 925dc33696 feat(docs): define semantic subject contract 2026-08-21 14:55:58 +02:00
zemion 4b0737e1cd feat: support autonomous campaign schedule results 2026-08-20 21:10:04 +02:00
zemion 4f4007aff1 feat: define policy impact subject contract 2026-08-20 20:27:17 +02:00
zemion 8e687c4420 feat: define bulk governance projection contract 2026-08-20 19:46:53 +02:00
zemion 604f20eed7 feat: classify audit evidence export scope 2026-08-20 18:00:18 +02:00
zemion 6a2da94e47 feat: load connector governance webui 2026-08-20 12:46:01 +02:00
zemion e121ca900e feat: load identity administration webui 2026-08-20 12:27:37 +02:00
zemion 79629c5a2c feat: define campaign archive encryption policy contract 2026-08-20 12:12:27 +02:00
zemion 026e451aa4 feat: configure scheduling self-enrollment limits 2026-08-20 11:45:07 +02:00
zemion f11c675d11 feat: govern accessible appearance overrides 2026-08-20 10:50:55 +02:00
57 changed files with 4552 additions and 477 deletions
+2
View File
@@ -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. |
+21
View File
@@ -48,6 +48,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 +63,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 +88,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
```
+11
View File
@@ -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:
+41
View File
@@ -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
+118
View File
@@ -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
View File
@@ -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.
+59
View File
@@ -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.
+5 -4
View File
@@ -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
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-core"
version = "0.1.18"
version = "0.1.30"
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
readme = "README.md"
requires-python = ">=3.12"
+36 -1
View File
@@ -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):
+11 -1
View File
@@ -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
+90
View File
@@ -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:
+114
View File
@@ -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",
]
+140 -2
View File
@@ -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
)
+151 -2
View File
@@ -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,6 +94,8 @@ 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
@@ -154,6 +166,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(
@@ -185,6 +203,8 @@ 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,
@@ -197,6 +217,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
@@ -309,12 +362,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 +445,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 +678,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 +706,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 +745,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 +769,10 @@ __all__ = [
"DatasourceOriginProvider",
"DatasourceOriginReadRequest",
"DatasourceOriginReadResult",
"DatasourcePublicationProvider",
"DatasourcePublicationRequest",
"DatasourcePublicationResult",
"DatasourcePublicationStatus",
"DatasourceReadRequest",
"DatasourceReadResult",
"DatasourceShape",
@@ -638,7 +780,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",
]
+119
View File
@@ -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",
]
+4
View File
@@ -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
+255
View File
@@ -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):
+47
View File
@@ -61,6 +61,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,
@@ -1215,6 +1220,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",
]
+179
View File
@@ -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",
]
@@ -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",
+10
View File
@@ -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.
+78
View File
@@ -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()
+264
View File
@@ -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"],
+74 -1
View File
@@ -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"}
}
+106
View File
@@ -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()
+20
View File
@@ -3,11 +3,14 @@ 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,
DatasourceLifecycleProvider,
@@ -19,10 +22,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 +162,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 +177,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 +190,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 +201,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:
+90
View File
@@ -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()
+24
View File
@@ -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)
+6
View File
@@ -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(
+21
View File
@@ -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:
+30
View File
@@ -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")
+91
View File
@@ -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()
+94
View File
@@ -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()
+28
View File
@@ -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");
+3
View File
@@ -0,0 +1,3 @@
const installedModules: Array<never> = [];
export default installedModules;
+22 -3
View File
@@ -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>
<UnsavedChangesProvider>
<ConformanceApp />
</UnsavedChangesProvider>
<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({
+105 -23
View File
@@ -1,12 +1,12 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.18",
"version": "0.1.30",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@govoplan/core-webui",
"version": "0.1.18",
"version": "0.1.30",
"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,6 +49,7 @@
"@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/workflow-webui": "file:../../govoplan-workflow/webui",
@@ -80,7 +84,7 @@
},
"../../govoplan-access/webui": {
"name": "@govoplan/access-webui",
"version": "0.1.18",
"version": "0.1.19",
"devDependencies": {
"typescript": "^5.7.2"
},
@@ -149,7 +153,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 +188,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 +210,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 +240,20 @@
}
}
},
"../../govoplan-connectors/webui": {
"name": "@govoplan/connectors-webui",
"version": "0.1.20",
"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 +272,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 +290,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 +324,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 +358,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 +393,29 @@
},
"../../govoplan-forms/webui": {
"name": "@govoplan/forms-webui",
"version": "0.1.18",
"version": "0.1.19",
"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 +438,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 +474,7 @@
},
"../../govoplan-mail/webui": {
"name": "@govoplan/mail-webui",
"version": "0.1.18",
"version": "0.1.21",
"devDependencies": {
"typescript": "^5.7.2"
},
@@ -462,7 +512,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 +566,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 +582,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 +598,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 +630,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 +729,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 +775,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",
@@ -758,7 +824,7 @@
},
"../../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 +1628,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 +1668,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 +1756,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
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.18",
"version": "0.1.30",
"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,6 +99,7 @@
"@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/workflow-webui": "file:../../govoplan-workflow/webui",
+8 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.18",
"version": "0.1.30",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -26,19 +26,22 @@
"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/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.19",
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.18",
"@govoplan/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",
"@tiptap/core": "^3.29.2",
"@tiptap/extension-image": "^3.29.2",
"@tiptap/pm": "^3.29.2",
+9 -1
View File
@@ -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,6 +43,7 @@ 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",
workflow: "@govoplan/workflow-webui"
@@ -69,15 +73,19 @@ 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: "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 +110,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"] }
];
const npmExec = process.env.npm_execpath;
+9
View File
@@ -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
View File
@@ -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
};
}
@@ -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));
}
+20 -4
View File
@@ -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
};
}
+69 -9
View File
@@ -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",
+8
View File
@@ -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";
+57
View File
@@ -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);
+13
View File
@@ -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 = {
+1
View File
@@ -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",
+8
View File
@@ -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,6 +53,7 @@ const defaultWebModulePackages = [
"@govoplan/search-webui",
"@govoplan/tenancy-webui",
"@govoplan/templates-webui",
"@govoplan/tickets-webui",
"@govoplan/views-webui",
"@govoplan/voting-webui",
"@govoplan/workflow-webui"
@@ -255,6 +259,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 +268,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,6 +286,7 @@ 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-workflow/webui', import.meta.url))
+1
View File
@@ -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")