Add cross-module operational and interaction contracts
This commit is contained in:
@@ -157,7 +157,7 @@ release evidence.
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `REDIS_URL` | `redis://redis:6379/0` | Celery broker/result backend when async workers are enabled. |
|
| `REDIS_URL` | `redis://redis:6379/0` | Celery broker/result backend when async workers are enabled. |
|
||||||
| `CELERY_ENABLED` | `false` | Local/dev can send synchronously. Production campaign delivery should run workers and set this to `true`. |
|
| `CELERY_ENABLED` | `false` | Local/dev can send synchronously. Production campaign delivery should run workers and set this to `true`. |
|
||||||
| `CELERY_QUEUES` | `send_email,append_sent,notifications,calendar,dataflow,workflow,events,default` | Queue list expected by worker/process manager definitions. The `events` queue drains transactional platform events; `dataflow` drains trigger deliveries and schedules; `workflow` reconciles Workflow Engine instances and module standards. |
|
| `CELERY_QUEUES` | `send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,default` | Queue list expected by worker/process manager definitions. Keep this aligned with every enabled module task route; Ops reports missing worker queue consumers. |
|
||||||
| `PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS` | `8` | Failed durable consumer deliveries are quarantined after this many attempts. |
|
| `PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS` | `8` | Failed durable consumer deliveries are quarantined after this many attempts. |
|
||||||
| `PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS` | `90` | Successful event envelopes older than this are removed by the daily retention task. Quarantined evidence is retained. |
|
| `PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS` | `90` | Successful event envelopes older than this are removed by the daily retention task. Quarantined evidence is retained. |
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@ Worker command:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m celery -A govoplan_core.celery_app:celery worker \
|
python -m celery -A govoplan_core.celery_app:celery worker \
|
||||||
--queues send_email,append_sent,notifications,calendar,dataflow,events,default \
|
--queues send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,default \
|
||||||
--loglevel INFO
|
--loglevel INFO
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -343,7 +343,7 @@ the checked in `.env.example`. It runs:
|
|||||||
- explicit `ENABLED_MODULES`
|
- explicit `ENABLED_MODULES`
|
||||||
- explicit migrations and `--with-dev-data` bootstrap
|
- explicit migrations and `--with-dev-data` bootstrap
|
||||||
- API via the module-aware devserver
|
- API via the module-aware devserver
|
||||||
- a Celery worker for `send_email,append_sent,notifications,calendar,default`
|
- a Celery worker for `send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,default`
|
||||||
- WebUI through the Vite dev server
|
- WebUI through the Vite dev server
|
||||||
- durable local files under `runtime/production-like/files`
|
- durable local files under `runtime/production-like/files`
|
||||||
|
|
||||||
@@ -462,7 +462,9 @@ Run the rollback drill before relying on installer automation in a new
|
|||||||
environment:
|
environment:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
/mnt/DATA/git/govoplan/tools/checks/module-installer-rollback-drill.py --format json
|
/mnt/DATA/git/govoplan/tools/checks/module-installer-rollback-drill.py \
|
||||||
|
--format json \
|
||||||
|
--evidence-path runtime/module-installer/restore-drill-evidence.json
|
||||||
```
|
```
|
||||||
|
|
||||||
The drill uses temporary SQLite databases and simulated package commands. It
|
The drill uses temporary SQLite databases and simulated package commands. It
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# WebUI Theme Contract
|
||||||
|
|
||||||
|
GovOPlaN supports `system`, `light`, and `dark` as persisted user preferences.
|
||||||
|
`system` follows `prefers-color-scheme` live; it is not resolved permanently at
|
||||||
|
save time. Core applies the resolved mode through `data-theme` on the document
|
||||||
|
root and exposes the selected preference through `data-theme-preference`.
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
|
- Core owns semantic CSS tokens, native `color-scheme`, preference persistence,
|
||||||
|
the Settings selector, and the shared shell.
|
||||||
|
- Modules consume semantic tokens such as `--surface`, `--text`, `--line`, and
|
||||||
|
the status token families. They may define domain aliases whose values resolve
|
||||||
|
to shared tokens.
|
||||||
|
- User preference selects the mode. Tenant and system policy may provide a
|
||||||
|
future default, but must not silently replace an explicit user choice.
|
||||||
|
- Tenant branding is a separate policy surface and must preserve contrast and
|
||||||
|
status semantics in both modes.
|
||||||
|
|
||||||
|
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 the root behavior and representative
|
||||||
|
Campaign, Calendar, Files, and Mail token consumption. The check runs before a
|
||||||
|
production WebUI build.
|
||||||
|
|
||||||
@@ -73,6 +73,12 @@ class SwitchTenantRequest(BaseModel):
|
|||||||
tenant_id: str
|
tenant_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class SwitchActingContextRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
assignment_id: str | None = Field(default=None, max_length=36)
|
||||||
|
|
||||||
|
|
||||||
class TenantInfo(BaseModel):
|
class TenantInfo(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
slug: str
|
slug: str
|
||||||
@@ -178,6 +184,7 @@ class PrincipalContextInfo(BaseModel):
|
|||||||
api_key_id: str | None = None
|
api_key_id: str | None = None
|
||||||
session_id: str | None = None
|
session_id: str | None = None
|
||||||
service_account_id: str | None = None
|
service_account_id: str | None = None
|
||||||
|
acting_assignment_id: str | None = None
|
||||||
acting_for_account_id: str | None = None
|
acting_for_account_id: str | None = None
|
||||||
email: str | None = None
|
email: str | None = None
|
||||||
display_name: str | None = None
|
display_name: str | None = None
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ class ApiPrincipal:
|
|||||||
def acting_for_account_id(self) -> str | None:
|
def acting_for_account_id(self) -> str | None:
|
||||||
return self.principal.acting_for_account_id
|
return self.principal.acting_for_account_id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def acting_assignment_id(self) -> str | None:
|
||||||
|
return self.principal.acting_assignment_id
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def auth_method(self) -> str:
|
def auth_method(self) -> str:
|
||||||
return self.principal.auth_method
|
return self.principal.auth_method
|
||||||
|
|||||||
@@ -24,7 +24,12 @@ from govoplan_core.core.idm import (
|
|||||||
IdmAssignmentLifecycle,
|
IdmAssignmentLifecycle,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
|
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
|
||||||
from govoplan_core.core.mail import CAPABILITY_MAIL_DELIVERY_OUTBOX, MailDeliveryOutboxProvider
|
from govoplan_core.core.mail import (
|
||||||
|
CAPABILITY_MAIL_BOUNCE_PROCESSING,
|
||||||
|
CAPABILITY_MAIL_DELIVERY_OUTBOX,
|
||||||
|
MailBounceProcessingProvider,
|
||||||
|
MailDeliveryOutboxProvider,
|
||||||
|
)
|
||||||
from govoplan_core.core.modules import ModuleContext
|
from govoplan_core.core.modules import ModuleContext
|
||||||
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH, NotificationDispatchProvider
|
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH, NotificationDispatchProvider
|
||||||
from govoplan_core.core.postbox import (
|
from govoplan_core.core.postbox import (
|
||||||
@@ -58,6 +63,7 @@ celery.conf.update(
|
|||||||
"govoplan.notifications.deliver_pending": {"queue": "notifications"},
|
"govoplan.notifications.deliver_pending": {"queue": "notifications"},
|
||||||
"govoplan.mail.dispatch_outbox": {"queue": "mail"},
|
"govoplan.mail.dispatch_outbox": {"queue": "mail"},
|
||||||
"govoplan.mail.purge_outbox": {"queue": "mail"},
|
"govoplan.mail.purge_outbox": {"queue": "mail"},
|
||||||
|
"govoplan.mail.scan_bounces": {"queue": "mail"},
|
||||||
"govoplan.calendar.dispatch_outbox": {"queue": "calendar"},
|
"govoplan.calendar.dispatch_outbox": {"queue": "calendar"},
|
||||||
"govoplan.dataflow.dispatch_runs": {"queue": "dataflow"},
|
"govoplan.dataflow.dispatch_runs": {"queue": "dataflow"},
|
||||||
"govoplan.dataflow.purge_runs": {"queue": "dataflow"},
|
"govoplan.dataflow.purge_runs": {"queue": "dataflow"},
|
||||||
@@ -87,6 +93,11 @@ celery.conf.update(
|
|||||||
"schedule": 24 * 60 * 60.0,
|
"schedule": 24 * 60 * 60.0,
|
||||||
"args": (250,),
|
"args": (250,),
|
||||||
},
|
},
|
||||||
|
"mail-bounces-every-five-minutes": {
|
||||||
|
"task": "govoplan.mail.scan_bounces",
|
||||||
|
"schedule": 5 * 60.0,
|
||||||
|
"args": (None, 250),
|
||||||
|
},
|
||||||
"dataflow-triggers-every-minute": {
|
"dataflow-triggers-every-minute": {
|
||||||
"task": "govoplan.dataflow.dispatch_triggers",
|
"task": "govoplan.dataflow.dispatch_triggers",
|
||||||
"schedule": 60.0,
|
"schedule": 60.0,
|
||||||
@@ -184,6 +195,16 @@ def _mail_delivery_outbox() -> MailDeliveryOutboxProvider | None:
|
|||||||
return capability
|
return capability
|
||||||
|
|
||||||
|
|
||||||
|
def _mail_bounce_processing() -> MailBounceProcessingProvider | None:
|
||||||
|
registry = _platform_registry()
|
||||||
|
if not registry.has_capability(CAPABILITY_MAIL_BOUNCE_PROCESSING):
|
||||||
|
return None
|
||||||
|
capability = registry.require_capability(CAPABILITY_MAIL_BOUNCE_PROCESSING)
|
||||||
|
if not isinstance(capability, MailBounceProcessingProvider):
|
||||||
|
raise RuntimeError("Mail bounce-processing capability is invalid")
|
||||||
|
return capability
|
||||||
|
|
||||||
|
|
||||||
def _dataflow_trigger_dispatcher(
|
def _dataflow_trigger_dispatcher(
|
||||||
registry: PlatformRegistry | None = None,
|
registry: PlatformRegistry | None = None,
|
||||||
) -> DataflowTriggerDispatcher | None:
|
) -> DataflowTriggerDispatcher | None:
|
||||||
@@ -357,6 +378,30 @@ def purge_mail_outbox(self, limit: int = 250):
|
|||||||
return dict(provider.purge_expired(session, limit=limit))
|
return dict(provider.purge_expired(session, limit=limit))
|
||||||
|
|
||||||
|
|
||||||
|
@celery.task(name="govoplan.mail.scan_bounces", bind=True, max_retries=0)
|
||||||
|
def scan_mail_bounces(
|
||||||
|
self,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
limit: int = 250,
|
||||||
|
):
|
||||||
|
"""Read configured DSN folders without mutating provider mailbox flags."""
|
||||||
|
|
||||||
|
from govoplan_core.db.session import get_database
|
||||||
|
|
||||||
|
with get_database().SessionLocal() as session:
|
||||||
|
provider = _mail_bounce_processing()
|
||||||
|
if provider is None:
|
||||||
|
return {
|
||||||
|
"sources": 0,
|
||||||
|
"processed_messages": 0,
|
||||||
|
"observations": 0,
|
||||||
|
"failures": [],
|
||||||
|
}
|
||||||
|
result = dict(provider.scan_due(session, tenant_id=tenant_id, limit=limit))
|
||||||
|
session.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@celery.task(name="govoplan.calendar.dispatch_outbox", bind=True, max_retries=0)
|
@celery.task(name="govoplan.calendar.dispatch_outbox", bind=True, max_retries=0)
|
||||||
def dispatch_calendar_outbox(self, tenant_id: str | None = None, limit: int = 50):
|
def dispatch_calendar_outbox(self, tenant_id: str | None = None, limit: int = 50):
|
||||||
"""Drain durable Calendar operations; retry timing lives in the database."""
|
"""Drain durable Calendar operations; retry timing lives in the database."""
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ class PrincipalRef:
|
|||||||
api_key_id: str | None = None
|
api_key_id: str | None = None
|
||||||
session_id: str | None = None
|
session_id: str | None = None
|
||||||
service_account_id: str | None = None
|
service_account_id: str | None = None
|
||||||
|
acting_assignment_id: str | None = None
|
||||||
acting_for_account_id: str | None = None
|
acting_for_account_id: str | None = None
|
||||||
email: str | None = None
|
email: str | None = None
|
||||||
display_name: str | None = None
|
display_name: str | None = None
|
||||||
@@ -144,6 +145,7 @@ class PrincipalRef:
|
|||||||
"api_key_id": self.api_key_id,
|
"api_key_id": self.api_key_id,
|
||||||
"session_id": self.session_id,
|
"session_id": self.session_id,
|
||||||
"service_account_id": self.service_account_id,
|
"service_account_id": self.service_account_id,
|
||||||
|
"acting_assignment_id": self.acting_assignment_id,
|
||||||
"acting_for_account_id": self.acting_for_account_id,
|
"acting_for_account_id": self.acting_for_account_id,
|
||||||
"email": self.email,
|
"email": self.email,
|
||||||
"display_name": self.display_name,
|
"display_name": self.display_name,
|
||||||
@@ -165,6 +167,7 @@ class PrincipalRef:
|
|||||||
api_key_id=_optional_str(value.get("api_key_id")),
|
api_key_id=_optional_str(value.get("api_key_id")),
|
||||||
session_id=_optional_str(value.get("session_id")),
|
session_id=_optional_str(value.get("session_id")),
|
||||||
service_account_id=_optional_str(value.get("service_account_id")),
|
service_account_id=_optional_str(value.get("service_account_id")),
|
||||||
|
acting_assignment_id=_optional_str(value.get("acting_assignment_id")),
|
||||||
acting_for_account_id=_optional_str(value.get("acting_for_account_id")),
|
acting_for_account_id=_optional_str(value.get("acting_for_account_id")),
|
||||||
email=_optional_str(value.get("email")),
|
email=_optional_str(value.get("email")),
|
||||||
display_name=_optional_str(value.get("display_name")),
|
display_name=_optional_str(value.get("display_name")),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from typing import Protocol, runtime_checkable
|
|||||||
|
|
||||||
CAPABILITY_CALENDAR_SCHEDULING = "calendar.scheduling"
|
CAPABILITY_CALENDAR_SCHEDULING = "calendar.scheduling"
|
||||||
CAPABILITY_CALENDAR_OUTBOX = "calendar.outbox"
|
CAPABILITY_CALENDAR_OUTBOX = "calendar.outbox"
|
||||||
|
CAPABILITY_CALENDAR_INVITATIONS = "calendar.invitations"
|
||||||
CALENDAR_AVAILABILITY_READ_SCOPE = "calendar:availability:read"
|
CALENDAR_AVAILABILITY_READ_SCOPE = "calendar:availability:read"
|
||||||
CALENDAR_EVENT_WRITE_SCOPE = "calendar:event:write"
|
CALENDAR_EVENT_WRITE_SCOPE = "calendar:event:write"
|
||||||
|
|
||||||
@@ -43,6 +44,52 @@ class CalendarEventRef:
|
|||||||
outbox_operation_id: str | None = None
|
outbox_operation_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CalendarInvitationAttendeeRequest:
|
||||||
|
address: str
|
||||||
|
name: str | None = None
|
||||||
|
role: str = "REQ-PARTICIPANT"
|
||||||
|
participation_status: str = "NEEDS-ACTION"
|
||||||
|
rsvp: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CalendarInvitationRequest:
|
||||||
|
correlation_id: str
|
||||||
|
source_module: str
|
||||||
|
source_resource_type: str
|
||||||
|
source_resource_id: str | None
|
||||||
|
summary: str
|
||||||
|
start_at: datetime
|
||||||
|
attendees: tuple[CalendarInvitationAttendeeRequest, ...]
|
||||||
|
calendar_id: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
location: str | None = None
|
||||||
|
end_at: datetime | None = None
|
||||||
|
timezone: str | None = None
|
||||||
|
organizer: Mapping[str, object] | None = None
|
||||||
|
classification: str = "PUBLIC"
|
||||||
|
categories: tuple[str, ...] = ()
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CalendarInvitationRef:
|
||||||
|
event_id: str
|
||||||
|
calendar_id: str
|
||||||
|
uid: str
|
||||||
|
correlation_id: str
|
||||||
|
source_module: str
|
||||||
|
source_resource_type: str
|
||||||
|
source_resource_id: str | None
|
||||||
|
attendees: tuple[Mapping[str, object], ...] = ()
|
||||||
|
external_state: str = "local"
|
||||||
|
outbox_operation_id: str | None = None
|
||||||
|
reply_ingress: str = "capability"
|
||||||
|
recurrence_supported: bool = False
|
||||||
|
degraded_reasons: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class CalendarSchedulingProvider(Protocol):
|
class CalendarSchedulingProvider(Protocol):
|
||||||
def list_freebusy(
|
def list_freebusy(
|
||||||
@@ -80,6 +127,44 @@ class CalendarOutboxProvider(Protocol):
|
|||||||
) -> Mapping[str, object]:
|
) -> Mapping[str, object]:
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class CalendarInvitationProvider(Protocol):
|
||||||
|
"""Correlation-aware invitation boundary for Campaign and Mail adapters."""
|
||||||
|
|
||||||
|
def upsert_invitation(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
user_id: str | None,
|
||||||
|
request: CalendarInvitationRequest,
|
||||||
|
) -> CalendarInvitationRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_invitation(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
correlation_id: str,
|
||||||
|
) -> CalendarInvitationRef | None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def record_response(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
attendee_address: str,
|
||||||
|
participation_status: str,
|
||||||
|
correlation_id: str | None = None,
|
||||||
|
uid: str | None = None,
|
||||||
|
responded_at: datetime | None = None,
|
||||||
|
evidence: Mapping[str, object] | None = None,
|
||||||
|
) -> CalendarInvitationRef:
|
||||||
|
...
|
||||||
|
|
||||||
def calendar_scheduling_provider(registry: object | None) -> CalendarSchedulingProvider | None:
|
def calendar_scheduling_provider(registry: object | None) -> CalendarSchedulingProvider | None:
|
||||||
if registry is None or not hasattr(registry, "has_capability"):
|
if registry is None or not hasattr(registry, "has_capability"):
|
||||||
return None
|
return None
|
||||||
@@ -96,3 +181,14 @@ def calendar_outbox_provider(registry: object | None) -> CalendarOutboxProvider
|
|||||||
return None
|
return None
|
||||||
capability = registry.capability(CAPABILITY_CALENDAR_OUTBOX)
|
capability = registry.capability(CAPABILITY_CALENDAR_OUTBOX)
|
||||||
return capability if isinstance(capability, CalendarOutboxProvider) else None
|
return capability if isinstance(capability, CalendarOutboxProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def calendar_invitation_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> CalendarInvitationProvider | None:
|
||||||
|
if registry is None or not hasattr(registry, "has_capability"):
|
||||||
|
return None
|
||||||
|
if not registry.has_capability(CAPABILITY_CALENDAR_INVITATIONS):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_CALENDAR_INVITATIONS)
|
||||||
|
return capability if isinstance(capability, CalendarInvitationProvider) else None
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_CONNECTORS_FEEDS = "connectors.feeds"
|
||||||
|
FeedFormat = Literal["rss", "atom"]
|
||||||
|
FeedVisibility = Literal["public", "tenant", "private"]
|
||||||
|
|
||||||
|
|
||||||
|
class FeedCapabilityError(ValueError):
|
||||||
|
"""Stable error raised by feed transport implementations."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FeedEntry:
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
url: str | None = None
|
||||||
|
summary: str | None = None
|
||||||
|
content: str | None = None
|
||||||
|
author: str | None = None
|
||||||
|
published_at: datetime | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
categories: tuple[str, ...] = ()
|
||||||
|
enclosures: tuple[Mapping[str, object], ...] = ()
|
||||||
|
visibility: FeedVisibility = "public"
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FeedDocument:
|
||||||
|
format: FeedFormat
|
||||||
|
title: str
|
||||||
|
source_url: str
|
||||||
|
entries: tuple[FeedEntry, ...]
|
||||||
|
description: str | None = None
|
||||||
|
home_url: str | None = None
|
||||||
|
language: str | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
acquired_at: datetime | None = None
|
||||||
|
fresh_until: datetime | None = None
|
||||||
|
etag: str | None = None
|
||||||
|
last_modified: str | None = None
|
||||||
|
content_type: str | None = None
|
||||||
|
sha256: str = ""
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FeedRenderRequest:
|
||||||
|
format: FeedFormat
|
||||||
|
title: str
|
||||||
|
feed_url: str
|
||||||
|
home_url: str
|
||||||
|
entries: tuple[FeedEntry, ...]
|
||||||
|
description: str | None = None
|
||||||
|
language: str | None = None
|
||||||
|
allowed_visibilities: frozenset[FeedVisibility] = frozenset({"public"})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FeedRenderResult:
|
||||||
|
format: FeedFormat
|
||||||
|
content_type: str
|
||||||
|
body: bytes
|
||||||
|
included_entries: int
|
||||||
|
excluded_entries: int
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class FeedProvider(Protocol):
|
||||||
|
def fetch(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
timeout: float = 15,
|
||||||
|
max_entries: int = 2_000,
|
||||||
|
) -> FeedDocument:
|
||||||
|
...
|
||||||
|
|
||||||
|
def parse(
|
||||||
|
self,
|
||||||
|
content: bytes,
|
||||||
|
*,
|
||||||
|
source_url: str,
|
||||||
|
content_type: str | None = None,
|
||||||
|
max_entries: int = 2_000,
|
||||||
|
) -> FeedDocument:
|
||||||
|
...
|
||||||
|
|
||||||
|
def render(self, request: FeedRenderRequest) -> FeedRenderResult:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def feed_provider(registry: object | None) -> FeedProvider | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not hasattr(registry, "capability")
|
||||||
|
or not registry.has_capability(CAPABILITY_CONNECTORS_FEEDS)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
provider = registry.capability(CAPABILITY_CONNECTORS_FEEDS)
|
||||||
|
return provider if isinstance(provider, FeedProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_CONNECTORS_FEEDS",
|
||||||
|
"FeedCapabilityError",
|
||||||
|
"FeedDocument",
|
||||||
|
"FeedEntry",
|
||||||
|
"FeedFormat",
|
||||||
|
"FeedProvider",
|
||||||
|
"FeedRenderRequest",
|
||||||
|
"FeedRenderResult",
|
||||||
|
"FeedVisibility",
|
||||||
|
"feed_provider",
|
||||||
|
]
|
||||||
@@ -373,7 +373,7 @@ ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,aud
|
|||||||
|
|
||||||
CELERY_ENABLED=true
|
CELERY_ENABLED=true
|
||||||
REDIS_URL=redis://127.0.0.1:6379/0
|
REDIS_URL=redis://127.0.0.1:6379/0
|
||||||
CELERY_QUEUES=send_email,append_sent,notifications,calendar,dataflow,workflow,events,default
|
CELERY_QUEUES=send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,default
|
||||||
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||||
PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS=8
|
PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS=8
|
||||||
PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||||
@@ -446,7 +446,7 @@ DATABASE_URL=postgresql+psycopg://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
|
|||||||
GOVOPLAN_DATABASE_URL_PGTOOLS=postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
|
GOVOPLAN_DATABASE_URL_PGTOOLS=postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
|
||||||
REDIS_URL=redis://127.0.0.1:56379/0
|
REDIS_URL=redis://127.0.0.1:56379/0
|
||||||
CELERY_ENABLED=true
|
CELERY_ENABLED=true
|
||||||
CELERY_QUEUES=send_email,append_sent,notifications,calendar,dataflow,workflow,events,default
|
CELERY_QUEUES=send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,default
|
||||||
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||||
PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS=8
|
PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS=8
|
||||||
PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
from typing import Protocol, runtime_checkable
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
CAPABILITY_MAIL_DELIVERY_OUTBOX = "mail.delivery_outbox"
|
CAPABILITY_MAIL_DELIVERY_OUTBOX = "mail.delivery_outbox"
|
||||||
CAPABILITY_MAIL_NOTIFICATION_DELIVERY = "mail.notificationDelivery"
|
CAPABILITY_MAIL_NOTIFICATION_DELIVERY = "mail.notificationDelivery"
|
||||||
|
CAPABILITY_MAIL_BOUNCE_PROCESSING = "mail.bounce_processing"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -36,7 +38,6 @@ class NotificationMailDeliveryProvider(Protocol):
|
|||||||
) -> Mapping[str, object]:
|
) -> Mapping[str, object]:
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class MailDeliveryOutboxProvider(Protocol):
|
class MailDeliveryOutboxProvider(Protocol):
|
||||||
"""Stable worker boundary for Mail-owned external delivery effects."""
|
"""Stable worker boundary for Mail-owned external delivery effects."""
|
||||||
@@ -60,6 +61,60 @@ class MailDeliveryOutboxProvider(Protocol):
|
|||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class MailBounceObservationRef:
|
||||||
|
id: str
|
||||||
|
tenant_id: str
|
||||||
|
profile_id: str
|
||||||
|
folder: str
|
||||||
|
uid: str
|
||||||
|
original_message_id: str | None
|
||||||
|
command_id: str | None
|
||||||
|
recipient: str | None
|
||||||
|
action: str
|
||||||
|
status_code: str | None
|
||||||
|
diagnostic: str | None
|
||||||
|
permanent: bool
|
||||||
|
observed_at: datetime
|
||||||
|
matched: bool
|
||||||
|
evidence: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class MailBounceProcessingProvider(Protocol):
|
||||||
|
"""Mail-owned DSN ingestion and durable correlation boundary."""
|
||||||
|
|
||||||
|
def process_raw_message(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
profile_id: str,
|
||||||
|
folder: str,
|
||||||
|
uid: str,
|
||||||
|
raw_message: bytes,
|
||||||
|
) -> tuple[MailBounceObservationRef, ...]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def scan_due(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def observations_for_commands(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
command_ids: tuple[str, ...],
|
||||||
|
) -> Mapping[str, tuple[MailBounceObservationRef, ...]]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
def notification_mail_delivery_provider(
|
def notification_mail_delivery_provider(
|
||||||
registry: object | None,
|
registry: object | None,
|
||||||
) -> NotificationMailDeliveryProvider | None:
|
) -> NotificationMailDeliveryProvider | None:
|
||||||
@@ -76,3 +131,21 @@ def notification_mail_delivery_provider(
|
|||||||
"NotificationMailDeliveryProvider"
|
"NotificationMailDeliveryProvider"
|
||||||
)
|
)
|
||||||
return provider
|
return provider
|
||||||
|
|
||||||
|
|
||||||
|
def mail_bounce_processing_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> MailBounceProcessingProvider | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not registry.has_capability(CAPABILITY_MAIL_BOUNCE_PROCESSING)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
provider = registry.require_capability(CAPABILITY_MAIL_BOUNCE_PROCESSING)
|
||||||
|
if not isinstance(provider, MailBounceProcessingProvider):
|
||||||
|
raise TypeError(
|
||||||
|
"mail.bounce_processing provider does not implement "
|
||||||
|
"MailBounceProcessingProvider"
|
||||||
|
)
|
||||||
|
return provider
|
||||||
|
|||||||
@@ -3679,10 +3679,21 @@ def _snapshot_sqlite_database(run_dir: Path, database_url: str | None) -> dict[s
|
|||||||
source.backup(target)
|
source.backup(target)
|
||||||
else:
|
else:
|
||||||
backup_path.touch()
|
backup_path.touch()
|
||||||
|
with closing(sqlite3.connect(str(backup_path))) as candidate:
|
||||||
|
row = candidate.execute("PRAGMA integrity_check").fetchone()
|
||||||
|
integrity = str(row[0] if row else "missing result")
|
||||||
|
if integrity.lower() != "ok":
|
||||||
|
raise ModuleInstallerError(
|
||||||
|
f"SQLite backup failed its restore-readiness integrity check: {integrity}"
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"type": "sqlite",
|
"type": "sqlite",
|
||||||
"source": str(db_path),
|
"source": str(db_path),
|
||||||
"path": backup_path.name,
|
"path": backup_path.name,
|
||||||
|
"restore_check": {
|
||||||
|
"type": "sqlite_integrity_check",
|
||||||
|
"result": integrity,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from govoplan_core.core.views import ViewSurface
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
from govoplan_core.core.operations import OperationalCheckProviderRegistration
|
||||||
from govoplan_core.core.search import (
|
from govoplan_core.core.search import (
|
||||||
SearchProviderRegistration,
|
SearchProviderRegistration,
|
||||||
SearchSourceProviderRegistration,
|
SearchSourceProviderRegistration,
|
||||||
@@ -268,6 +269,8 @@ class DocumentationTopic:
|
|||||||
i18n_key: str | None = None
|
i18n_key: str | None = None
|
||||||
translations: Mapping[str, Mapping[str, str]] = field(default_factory=dict)
|
translations: Mapping[str, Mapping[str, str]] = field(default_factory=dict)
|
||||||
source_module_id: str | None = None
|
source_module_id: str | None = None
|
||||||
|
version_min: str | None = None
|
||||||
|
version_max_exclusive: str | None = None
|
||||||
metadata: Mapping[str, Any] = field(default_factory=dict)
|
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
@@ -431,6 +434,10 @@ class ModuleManifest:
|
|||||||
capability_documentation: Mapping[str, CapabilityDocumentation] = field(default_factory=dict)
|
capability_documentation: Mapping[str, CapabilityDocumentation] = field(default_factory=dict)
|
||||||
search_providers: tuple["SearchProviderRegistration", ...] = ()
|
search_providers: tuple["SearchProviderRegistration", ...] = ()
|
||||||
search_sources: tuple["SearchSourceProviderRegistration", ...] = ()
|
search_sources: tuple["SearchSourceProviderRegistration", ...] = ()
|
||||||
|
operational_check_providers: tuple[
|
||||||
|
"OperationalCheckProviderRegistration",
|
||||||
|
...,
|
||||||
|
] = ()
|
||||||
compatibility: ModuleCompatibility = field(default_factory=ModuleCompatibility)
|
compatibility: ModuleCompatibility = field(default_factory=ModuleCompatibility)
|
||||||
on_activate: LifecycleHook | None = None
|
on_activate: LifecycleHook | None = None
|
||||||
on_deactivate: LifecycleHook | None = None
|
on_deactivate: LifecycleHook | None = None
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable, Mapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
|
||||||
|
OperationalCheckState = Literal["ok", "warning", "error", "inactive"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class OperationalCheck:
|
||||||
|
"""A bounded module-owned runtime check exposed through the Ops module."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
state: OperationalCheckState
|
||||||
|
detail: str
|
||||||
|
readiness_critical: bool = False
|
||||||
|
metrics: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"label": self.label,
|
||||||
|
"state": self.state,
|
||||||
|
"detail": self.detail,
|
||||||
|
"readiness_critical": self.readiness_critical,
|
||||||
|
"metrics": dict(self.metrics),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
OperationalCheckProvider = Callable[[], OperationalCheck]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class OperationalCheckProviderRegistration:
|
||||||
|
"""Register one independently executable operational check."""
|
||||||
|
|
||||||
|
module_id: str
|
||||||
|
check_id: str
|
||||||
|
provider: OperationalCheckProvider
|
||||||
|
cache_seconds: int = 60
|
||||||
|
|
||||||
@@ -196,6 +196,32 @@ class PostboxAttachmentRef:
|
|||||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PostboxWrappedKeyRef:
|
||||||
|
"""Opaque envelope-key record; key material remains crypto-provider owned."""
|
||||||
|
|
||||||
|
recipient_type: str
|
||||||
|
recipient_id: str
|
||||||
|
key_epoch: int
|
||||||
|
wrapped_key_ref: str
|
||||||
|
algorithm: str | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PostboxExternalRecipientTokenRef:
|
||||||
|
"""External grant state without the bearer secret itself."""
|
||||||
|
|
||||||
|
token_id: str
|
||||||
|
state: str
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
one_time: bool = False
|
||||||
|
key_fetched_at: datetime | None = None
|
||||||
|
revoked_at: datetime | None = None
|
||||||
|
assurance_profile: str | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class PostboxMessageRef:
|
class PostboxMessageRef:
|
||||||
id: str
|
id: str
|
||||||
@@ -221,6 +247,8 @@ class PostboxMessageRef:
|
|||||||
key_epoch: int = 1
|
key_epoch: int = 1
|
||||||
ciphertext_ref: str | None = None
|
ciphertext_ref: str | None = None
|
||||||
signed_manifest_ref: str | None = None
|
signed_manifest_ref: str | None = None
|
||||||
|
wrapped_keys: tuple[PostboxWrappedKeyRef, ...] = ()
|
||||||
|
external_recipient_tokens: tuple[PostboxExternalRecipientTokenRef, ...] = ()
|
||||||
participants: tuple[PostboxParticipantRef, ...] = ()
|
participants: tuple[PostboxParticipantRef, ...] = ()
|
||||||
attachments: tuple[PostboxAttachmentRef, ...] = ()
|
attachments: tuple[PostboxAttachmentRef, ...] = ()
|
||||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
@@ -275,6 +303,10 @@ class PostboxDeliveryRequest:
|
|||||||
participants: tuple[PostboxParticipantRef, ...] = ()
|
participants: tuple[PostboxParticipantRef, ...] = ()
|
||||||
attachments: tuple[PostboxAttachmentRef, ...] = ()
|
attachments: tuple[PostboxAttachmentRef, ...] = ()
|
||||||
expires_at: datetime | None = None
|
expires_at: datetime | None = None
|
||||||
|
ciphertext_ref: str | None = None
|
||||||
|
signed_manifest_ref: str | None = None
|
||||||
|
wrapped_keys: tuple[PostboxWrappedKeyRef, ...] = ()
|
||||||
|
external_recipient_tokens: tuple[PostboxExternalRecipientTokenRef, ...] = ()
|
||||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -616,6 +616,14 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
|
|||||||
for item in manifest.nav_items:
|
for item in manifest.nav_items:
|
||||||
_validate_nav_item(manifest.id, item)
|
_validate_nav_item(manifest.id, item)
|
||||||
for topic in manifest.documentation:
|
for topic in manifest.documentation:
|
||||||
|
if not version_range_is_valid(
|
||||||
|
version_min=topic.version_min,
|
||||||
|
version_max_exclusive=topic.version_max_exclusive,
|
||||||
|
):
|
||||||
|
raise RegistryError(
|
||||||
|
f"Module {manifest.id!r} documentation topic {topic.id!r} "
|
||||||
|
"declares an empty version range"
|
||||||
|
)
|
||||||
for issue in user_workflow_scope_condition_issues(topic):
|
for issue in user_workflow_scope_condition_issues(topic):
|
||||||
raise RegistryError(
|
raise RegistryError(
|
||||||
f"Module {manifest.id!r} documentation topic {topic.id!r}: {issue}"
|
f"Module {manifest.id!r} documentation topic {topic.id!r}: {issue}"
|
||||||
|
|||||||
@@ -155,8 +155,8 @@ class Settings(BaseSettings):
|
|||||||
master_key_b64: str | None = Field(default=None, alias="MASTER_KEY_B64")
|
master_key_b64: str | None = Field(default=None, alias="MASTER_KEY_B64")
|
||||||
celery_queues: str = Field(
|
celery_queues: str = Field(
|
||||||
default=(
|
default=(
|
||||||
"send_email,append_sent,notifications,calendar,"
|
"send_email,append_sent,notifications,mail,calendar,"
|
||||||
"dataflow,workflow,events,default"
|
"dataflow,workflow,postbox,events,idm,default"
|
||||||
),
|
),
|
||||||
alias="CELERY_QUEUES",
|
alias="CELERY_QUEUES",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_core.celery_app import celery
|
||||||
|
from govoplan_core.settings import settings
|
||||||
|
|
||||||
|
|
||||||
|
class CeleryQueueContractTests(unittest.TestCase):
|
||||||
|
def test_default_worker_queues_cover_every_routed_task(self) -> None:
|
||||||
|
configured = {
|
||||||
|
value.strip()
|
||||||
|
for value in settings.celery_queues.split(",")
|
||||||
|
if value.strip()
|
||||||
|
}
|
||||||
|
routes = celery.conf.task_routes
|
||||||
|
self.assertIsInstance(routes, dict)
|
||||||
|
routed = {
|
||||||
|
str(route["queue"])
|
||||||
|
for route in routes.values()
|
||||||
|
if isinstance(route, dict) and route.get("queue")
|
||||||
|
}
|
||||||
|
|
||||||
|
self.assertEqual(set(), routed - configured)
|
||||||
|
|
||||||
|
def test_delivery_tasks_keep_worker_loss_protection(self) -> None:
|
||||||
|
self.assertTrue(celery.conf.task_acks_late)
|
||||||
|
self.assertTrue(celery.conf.task_reject_on_worker_lost)
|
||||||
|
self.assertEqual(1, celery.conf.worker_prefetch_multiplier)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
||||||
@@ -32,6 +32,18 @@ def workflow_topic(
|
|||||||
|
|
||||||
|
|
||||||
class DocumentationTopicContractTests(unittest.TestCase):
|
class DocumentationTopicContractTests(unittest.TestCase):
|
||||||
|
def test_registry_rejects_empty_documentation_version_range(self) -> None:
|
||||||
|
topic = DocumentationTopic(
|
||||||
|
id="example.versioned",
|
||||||
|
title="Versioned",
|
||||||
|
summary="Invalid range",
|
||||||
|
version_min="2.0.0",
|
||||||
|
version_max_exclusive="2.0.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(RegistryError, "empty version range"):
|
||||||
|
registry_for(topic).validate()
|
||||||
|
|
||||||
def test_user_workflow_requires_scope_conditions(self) -> None:
|
def test_user_workflow_requires_scope_conditions(self) -> None:
|
||||||
topic = workflow_topic(conditions=())
|
topic = workflow_topic(conditions=())
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -26,12 +26,13 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 127.0.0.1 --port 5173",
|
"dev": "vite --host 127.0.0.1 --port 5173",
|
||||||
"prebuild": "npm run audit:i18n-structural",
|
"prebuild": "npm run audit:i18n-structural && npm run test:theme-contract",
|
||||||
"build": "tsc && vite build && node scripts/check-bundle-budget.mjs",
|
"build": "tsc && vite build && node scripts/check-bundle-budget.mjs",
|
||||||
"check:bundle-budget": "node scripts/check-bundle-budget.mjs",
|
"check:bundle-budget": "node scripts/check-bundle-budget.mjs",
|
||||||
"preview": "vite preview --host 127.0.0.1 --port 4173",
|
"preview": "vite preview --host 127.0.0.1 --port 4173",
|
||||||
"audit:i18n-structural": "node scripts/audit-i18n-structural.mjs",
|
"audit:i18n-structural": "node scripts/audit-i18n-structural.mjs",
|
||||||
"test:i18n-catalog": "node --test tests/i18n-catalog-validation.test.mjs",
|
"test:i18n-catalog": "node --test tests/i18n-catalog-validation.test.mjs",
|
||||||
|
"test:theme-contract": "node scripts/test-theme-contract.mjs",
|
||||||
"test:file-drop-zone": "rm -rf .file-drop-test-build && mkdir -p .file-drop-test-build && printf '{\"type\":\"commonjs\"}\\n' > .file-drop-test-build/package.json && tsc -p tsconfig.file-drop-tests.json && node .file-drop-test-build/tests/file-drop-resolver.test.js && node scripts/test-file-drop-zone-structure.mjs",
|
"test:file-drop-zone": "rm -rf .file-drop-test-build && mkdir -p .file-drop-test-build && printf '{\"type\":\"commonjs\"}\\n' > .file-drop-test-build/package.json && tsc -p tsconfig.file-drop-tests.json && node .file-drop-test-build/tests/file-drop-resolver.test.js && node scripts/test-file-drop-zone-structure.mjs",
|
||||||
"test:data-grid-actions": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/data-grid-actions.test.js && node .component-test-build/tests/data-grid-sizing.test.js",
|
"test:data-grid-actions": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/data-grid-actions.test.js && node .component-test-build/tests/data-grid-sizing.test.js",
|
||||||
"test:dialog-focus": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/dialog-focus.test.js && node scripts/test-dialog-focus-structure.mjs",
|
"test:dialog-focus": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/dialog-focus.test.js && node scripts/test-dialog-focus-structure.mjs",
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const webuiRoot = resolve(import.meta.dirname, "..");
|
||||||
|
const repositoryRoot = resolve(webuiRoot, "..", "..");
|
||||||
|
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");
|
||||||
|
|
||||||
|
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");
|
||||||
|
assert.match(app, /matchMedia\?\.\("\(prefers-color-scheme: dark\)"\)/, "system theme must follow OS changes");
|
||||||
|
assert.match(app, /dataset\.themePreference/, "the selected preference must remain inspectable");
|
||||||
|
for (const theme of ["system", "light", "dark"]) {
|
||||||
|
assert.match(settings, new RegExp(`value:\\s*"${theme}"`), `Settings must expose ${theme}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const representativeModules = [
|
||||||
|
"govoplan-campaign/webui/src/styles/campaign-workspace.css",
|
||||||
|
"govoplan-calendar/webui/src/styles/calendar.css",
|
||||||
|
"govoplan-files/webui/src/styles/file-manager.css",
|
||||||
|
"govoplan-mail/webui/src/styles/mail-profiles.css",
|
||||||
|
];
|
||||||
|
for (const relativePath of representativeModules) {
|
||||||
|
const css = readFileSync(resolve(repositoryRoot, relativePath), "utf8");
|
||||||
|
assert.match(css, /var\(--/, `${relativePath} must consume shared theme tokens`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Theme contract passed for core and representative module surfaces.");
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useRef, useState, useEffect } from "react";
|
import { useRef, useState, useEffect } from "react";
|
||||||
import { Bell, Check, LogOut, Settings, UserCircle } from "lucide-react";
|
import { Bell, Check, LogOut, Settings, UserCircle } from "lucide-react";
|
||||||
import type { ApiSettings, AuthInfo, AuthTenantMembership, AuthUpdate, LoginResponse, SearchRuntimeUiCapability, ViewsRuntimeUiCapability } from "../types";
|
import type { ActingContextRuntimeUiCapability, ApiSettings, AuthInfo, AuthTenantMembership, AuthUpdate, LoginResponse, SearchRuntimeUiCapability, ViewsRuntimeUiCapability } from "../types";
|
||||||
import HelpMenu from "./HelpMenu";
|
import HelpMenu from "./HelpMenu";
|
||||||
import LanguageMenu from "./LanguageMenu";
|
import LanguageMenu from "./LanguageMenu";
|
||||||
import LoginModal from "../features/auth/LoginModal";
|
import LoginModal from "../features/auth/LoginModal";
|
||||||
@@ -37,6 +37,8 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
|||||||
const projection = useEffectiveView();
|
const projection = useEffectiveView();
|
||||||
const viewsRuntime = usePlatformUiCapability<ViewsRuntimeUiCapability>("views.runtime");
|
const viewsRuntime = usePlatformUiCapability<ViewsRuntimeUiCapability>("views.runtime");
|
||||||
const ViewSelector = viewsRuntime?.Selector ?? null;
|
const ViewSelector = viewsRuntime?.Selector ?? null;
|
||||||
|
const actingRuntime = usePlatformUiCapability<ActingContextRuntimeUiCapability>("access.actingContext");
|
||||||
|
const ActingContextSelector = actingRuntime?.Selector ?? null;
|
||||||
const searchRuntime = usePlatformUiCapability<SearchRuntimeUiCapability>("search.runtime");
|
const searchRuntime = usePlatformUiCapability<SearchRuntimeUiCapability>("search.runtime");
|
||||||
const GlobalSearch = searchRuntime?.GlobalSearch ?? null;
|
const GlobalSearch = searchRuntime?.GlobalSearch ?? null;
|
||||||
const canUseGlobalSearch = Boolean(
|
const canUseGlobalSearch = Boolean(
|
||||||
@@ -60,7 +62,7 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
|||||||
);
|
);
|
||||||
const showTenantControl = Boolean(activeTenant && (canSwitchTenant || canAdministerTenants));
|
const showTenantControl = Boolean(activeTenant && (canSwitchTenant || canAdministerTenants));
|
||||||
const showContextSelectors = Boolean(
|
const showContextSelectors = Boolean(
|
||||||
auth && ((activeTenant && showTenantControl) || ViewSelector)
|
auth && ((activeTenant && showTenantControl) || ViewSelector || ActingContextSelector)
|
||||||
);
|
);
|
||||||
const notificationsAvailable = modules.some((module) => module.id === "notifications" && module.routes?.some((route) => route.path === "/notifications"));
|
const notificationsAvailable = modules.some((module) => module.id === "notifications" && module.routes?.some((route) => route.path === "/notifications"));
|
||||||
const notificationSummary = useSharedNotificationSummary(settings, {
|
const notificationSummary = useSharedNotificationSummary(settings, {
|
||||||
@@ -206,6 +208,9 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
|||||||
{ViewSelector &&
|
{ViewSelector &&
|
||||||
<ViewSelector settings={settings} auth={auth} projection={projection} />
|
<ViewSelector settings={settings} auth={auth} projection={projection} />
|
||||||
}
|
}
|
||||||
|
{ActingContextSelector &&
|
||||||
|
<ActingContextSelector settings={settings} auth={auth} onAuthChange={onAuthChange} />
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,6 +33,9 @@
|
|||||||
.titlebar-actions { grid-column: 2; display: flex; align-items: center; justify-self: end; min-width: 0; gap: 10px; }
|
.titlebar-actions { grid-column: 2; display: flex; align-items: center; justify-self: end; min-width: 0; gap: 10px; }
|
||||||
.titlebar.has-global-search .titlebar-actions { grid-column: 3; }
|
.titlebar.has-global-search .titlebar-actions { grid-column: 3; }
|
||||||
.titlebar-context-selectors { display: flex; align-items: center; min-width: 0; gap: 12px; }
|
.titlebar-context-selectors { display: flex; align-items: center; min-width: 0; gap: 12px; }
|
||||||
|
.acting-context-selector { display: inline-flex; align-items: center; min-width: 0; gap: 7px; color: var(--muted); }
|
||||||
|
.acting-context-selector select { width: auto; max-width: 260px; min-height: 32px; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--text-strong); font: inherit; font-weight: 700; padding: 5px 24px 5px 8px; }
|
||||||
|
.acting-context-selector select:hover, .acting-context-selector select:focus-visible { background: var(--titlebar-hover-bg); }
|
||||||
.tenant-selector { height: 40px; min-width: 210px; border: var(--border-line); border-radius: var(--radius-sm); background: var(--surface); display: flex; align-items: center; padding: 0 12px; gap: 5px; box-shadow: var(--shadow-xs); }
|
.tenant-selector { height: 40px; min-width: 210px; border: var(--border-line); border-radius: var(--radius-sm); background: var(--surface); display: flex; align-items: center; padding: 0 12px; gap: 5px; box-shadow: var(--shadow-xs); }
|
||||||
.tenant-label, .tenant-caret, .muted { color: var(--muted); }
|
.tenant-label, .tenant-caret, .muted { color: var(--muted); }
|
||||||
.block { display: block; }
|
.block { display: block; }
|
||||||
@@ -265,6 +268,8 @@
|
|||||||
.docs-outline { padding-top: 22px; }
|
.docs-outline { padding-top: 22px; }
|
||||||
.docs-sidebar-header { padding: 0 16px 16px; }
|
.docs-sidebar-header { padding: 0 16px 16px; }
|
||||||
.docs-sidebar-header .section-title { padding: 0 6px 12px; }
|
.docs-sidebar-header .section-title { padding: 0 6px 12px; }
|
||||||
|
.docs-version-selector { display: grid; gap: 5px; margin-top: 12px; color: var(--muted); font-size: 12px; font-weight: 700; }
|
||||||
|
.docs-version-selector select { width: 100%; min-height: 34px; border: var(--border-line); border-radius: var(--radius-sm); background: var(--input-bg); color: var(--text); font: inherit; padding: 5px 8px; }
|
||||||
.docs-audience-toggle { width: 100%; }
|
.docs-audience-toggle { width: 100%; }
|
||||||
.docs-tree { display: grid; gap: 2px; padding: 0 10px 24px; }
|
.docs-tree { display: grid; gap: 2px; padding: 0 10px 24px; }
|
||||||
.docs-tree-node { min-width: 0; }
|
.docs-tree-node { min-width: 0; }
|
||||||
|
|||||||
@@ -92,11 +92,22 @@ export type PrincipalContext = {
|
|||||||
api_key_id?: string | null;
|
api_key_id?: string | null;
|
||||||
session_id?: string | null;
|
session_id?: string | null;
|
||||||
service_account_id?: string | null;
|
service_account_id?: string | null;
|
||||||
|
acting_assignment_id?: string | null;
|
||||||
acting_for_account_id?: string | null;
|
acting_for_account_id?: string | null;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
display_name?: string | null;
|
display_name?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ActingContextSelectorProps = {
|
||||||
|
settings: ApiSettings;
|
||||||
|
auth: AuthInfo;
|
||||||
|
onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ActingContextRuntimeUiCapability = {
|
||||||
|
Selector: ComponentType<ActingContextSelectorProps>;
|
||||||
|
};
|
||||||
|
|
||||||
export type AuthInfo = {
|
export type AuthInfo = {
|
||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
// Backwards-compatible active tenant alias returned by older/newer APIs.
|
// Backwards-compatible active tenant alias returned by older/newer APIs.
|
||||||
|
|||||||
Reference in New Issue
Block a user