Project unread postbox work with shared authorization
This commit is contained in:
@@ -168,6 +168,12 @@ unified inbox views and keep other responsibilities separate. Grouping is a
|
||||
query projection only. It never merges source containers, messages, read or
|
||||
acknowledgement state, retention, encryption keys, or audit evidence.
|
||||
|
||||
The optional Tasks module may aggregate available unread Postbox messages into
|
||||
the common work inbox. This is a current, permission-rechecked projection of a
|
||||
personal read receipt, not a copied task or message. Reading the message in
|
||||
Postbox removes the projection; Postbox remains authoritative for content,
|
||||
access, acknowledgement, reply, retention, and evidence.
|
||||
|
||||
Grouping summaries calculate total and unread counts over the currently
|
||||
visible source Postboxes in one tenant-bounded query. Hidden sources retained
|
||||
for later reassignment do not leak counts into the projection.
|
||||
|
||||
@@ -45,27 +45,28 @@ from govoplan_core.core.postbox import (
|
||||
CAPABILITY_POSTBOX_ROUTING,
|
||||
)
|
||||
from govoplan_core.core.search import SearchSourceProviderRegistration
|
||||
from govoplan_core.core.tasks import WorkItemProviderRegistration
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_postbox.backend.db import models as postbox_models
|
||||
from govoplan_postbox.backend.search_source import create_postbox_search_source
|
||||
from govoplan_postbox.backend.permissions import (
|
||||
ACKNOWLEDGE_SCOPE,
|
||||
BINDING_ADMIN_SCOPE,
|
||||
CONFIDENTIAL_SCOPE,
|
||||
DELIVERY_SCOPE,
|
||||
READ_SCOPE,
|
||||
REPLY_SCOPE,
|
||||
RESTRICTED_SCOPE,
|
||||
SEND_SCOPE,
|
||||
TEMPLATE_ADMIN_SCOPE,
|
||||
)
|
||||
|
||||
|
||||
MODULE_ID = "postbox"
|
||||
MODULE_NAME = "Postbox"
|
||||
MODULE_VERSION = "0.1.18"
|
||||
|
||||
READ_SCOPE = "postbox:postbox:read"
|
||||
SEND_SCOPE = "postbox:message:write"
|
||||
REPLY_SCOPE = "postbox:message:reply"
|
||||
ACKNOWLEDGE_SCOPE = "postbox:message:acknowledge"
|
||||
DELIVERY_SCOPE = "postbox:delivery:write"
|
||||
BINDING_ADMIN_SCOPE = "postbox:binding:admin"
|
||||
TEMPLATE_ADMIN_SCOPE = "postbox:template:admin"
|
||||
CONFIDENTIAL_SCOPE = "postbox:classification:confidential"
|
||||
RESTRICTED_SCOPE = "postbox:classification:restricted"
|
||||
|
||||
|
||||
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||
module_id, resource, action = scope.split(":", 2)
|
||||
return PermissionDefinition(
|
||||
@@ -169,6 +170,12 @@ def _router(context: ModuleContext):
|
||||
return router
|
||||
|
||||
|
||||
def _work_items(context: ModuleContext):
|
||||
from govoplan_postbox.backend.work_items import PostboxWorkItemProvider
|
||||
|
||||
return PostboxWorkItemProvider(registry=context.registry)
|
||||
|
||||
|
||||
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||
return {
|
||||
"postboxes": session.query(postbox_models.Postbox)
|
||||
@@ -225,6 +232,7 @@ manifest = ModuleManifest(
|
||||
"views",
|
||||
"workflow_engine",
|
||||
"search",
|
||||
"tasks",
|
||||
),
|
||||
required_capabilities=(
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
@@ -284,6 +292,13 @@ manifest = ModuleManifest(
|
||||
factory=create_postbox_search_source,
|
||||
),
|
||||
),
|
||||
work_item_providers=(
|
||||
WorkItemProviderRegistration(
|
||||
id="postbox.unread",
|
||||
factory=_work_items,
|
||||
order=40,
|
||||
),
|
||||
),
|
||||
nav_items=(
|
||||
NavItem(
|
||||
path="/postbox",
|
||||
@@ -413,7 +428,9 @@ manifest = ModuleManifest(
|
||||
"server-envelope profile stores message bodies as ciphertext and "
|
||||
"uses the optional Encryption capability for authorized reads. "
|
||||
"External ciphertext profiles retain producer-managed references "
|
||||
"and keys; neither profile is described as end-to-end encryption."
|
||||
"and keys; neither profile is described as end-to-end encryption. "
|
||||
"When Tasks is enabled, currently readable unread messages also appear "
|
||||
"in the common work inbox and disappear when the personal read receipt is recorded."
|
||||
),
|
||||
layer="available",
|
||||
documentation_types=("admin", "user"),
|
||||
@@ -425,6 +442,7 @@ manifest = ModuleManifest(
|
||||
"campaigns",
|
||||
"files",
|
||||
"notifications",
|
||||
"tasks",
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
READ_SCOPE = "postbox:postbox:read"
|
||||
SEND_SCOPE = "postbox:message:write"
|
||||
REPLY_SCOPE = "postbox:message:reply"
|
||||
ACKNOWLEDGE_SCOPE = "postbox:message:acknowledge"
|
||||
DELIVERY_SCOPE = "postbox:delivery:write"
|
||||
BINDING_ADMIN_SCOPE = "postbox:binding:admin"
|
||||
TEMPLATE_ADMIN_SCOPE = "postbox:template:admin"
|
||||
CONFIDENTIAL_SCOPE = "postbox:classification:confidential"
|
||||
RESTRICTED_SCOPE = "postbox:classification:restricted"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ACKNOWLEDGE_SCOPE",
|
||||
"BINDING_ADMIN_SCOPE",
|
||||
"CONFIDENTIAL_SCOPE",
|
||||
"DELIVERY_SCOPE",
|
||||
"READ_SCOPE",
|
||||
"REPLY_SCOPE",
|
||||
"RESTRICTED_SCOPE",
|
||||
"SEND_SCOPE",
|
||||
"TEMPLATE_ADMIN_SCOPE",
|
||||
]
|
||||
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal, has_scope
|
||||
from govoplan_core.core.postbox import PostboxActorRef
|
||||
from govoplan_postbox.backend.permissions import (
|
||||
ACKNOWLEDGE_SCOPE,
|
||||
BINDING_ADMIN_SCOPE,
|
||||
CONFIDENTIAL_SCOPE,
|
||||
READ_SCOPE,
|
||||
REPLY_SCOPE,
|
||||
RESTRICTED_SCOPE,
|
||||
SEND_SCOPE,
|
||||
TEMPLATE_ADMIN_SCOPE,
|
||||
)
|
||||
|
||||
|
||||
class PostboxPrincipalError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def actor_from_principal(
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
assignment_context_id: str | None = None,
|
||||
) -> PostboxActorRef:
|
||||
actions: set[str] = set()
|
||||
if has_scope(principal, READ_SCOPE):
|
||||
actions.update(("discover", "read"))
|
||||
if has_scope(principal, SEND_SCOPE):
|
||||
actions.add("send")
|
||||
if has_scope(principal, REPLY_SCOPE):
|
||||
actions.add("reply")
|
||||
if has_scope(principal, ACKNOWLEDGE_SCOPE):
|
||||
actions.add("acknowledge")
|
||||
if has_scope(principal, BINDING_ADMIN_SCOPE) or has_scope(
|
||||
principal,
|
||||
TEMPLATE_ADMIN_SCOPE,
|
||||
):
|
||||
actions.add("administer")
|
||||
if (
|
||||
assignment_context_id is not None
|
||||
and assignment_context_id not in principal.function_assignment_ids
|
||||
):
|
||||
raise PostboxPrincipalError(
|
||||
"The selected assignment context is not active for this principal."
|
||||
)
|
||||
selected = assignment_context_id or principal.acting_assignment_id
|
||||
if selected is None and len(principal.function_assignment_ids) == 1:
|
||||
selected = next(iter(principal.function_assignment_ids))
|
||||
classifications = {"public", "internal"}
|
||||
if has_scope(principal, CONFIDENTIAL_SCOPE):
|
||||
classifications.add("confidential")
|
||||
if has_scope(principal, RESTRICTED_SCOPE):
|
||||
classifications.update(("confidential", "restricted"))
|
||||
return PostboxActorRef(
|
||||
account_id=principal.account_id,
|
||||
identity_id=principal.identity_id,
|
||||
selected_assignment_id=selected,
|
||||
acting_for_account_id=principal.acting_for_account_id,
|
||||
authorized_actions=frozenset(actions), # type: ignore[arg-type]
|
||||
authorized_classifications=frozenset(classifications), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["PostboxPrincipalError", "actor_from_principal"]
|
||||
@@ -28,11 +28,9 @@ from govoplan_core.db.session import get_session
|
||||
from govoplan_postbox.backend.manifest import (
|
||||
ACKNOWLEDGE_SCOPE,
|
||||
BINDING_ADMIN_SCOPE,
|
||||
CONFIDENTIAL_SCOPE,
|
||||
DELIVERY_SCOPE,
|
||||
READ_SCOPE,
|
||||
REPLY_SCOPE,
|
||||
RESTRICTED_SCOPE,
|
||||
SEND_SCOPE,
|
||||
TEMPLATE_ADMIN_SCOPE,
|
||||
)
|
||||
@@ -67,6 +65,10 @@ from govoplan_postbox.backend.schemas import (
|
||||
PostboxTemplateReviseRequest,
|
||||
)
|
||||
from govoplan_postbox.backend.service import PostboxError
|
||||
from govoplan_postbox.backend.principals import (
|
||||
PostboxPrincipalError,
|
||||
actor_from_principal,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/postbox", tags=["postbox"])
|
||||
@@ -94,44 +96,16 @@ def _actor(
|
||||
*,
|
||||
assignment_context_id: str | None = None,
|
||||
) -> PostboxActorRef:
|
||||
actions: set[str] = set()
|
||||
if has_scope(principal, READ_SCOPE):
|
||||
actions.update(("discover", "read"))
|
||||
if has_scope(principal, SEND_SCOPE):
|
||||
actions.add("send")
|
||||
if has_scope(principal, REPLY_SCOPE):
|
||||
actions.add("reply")
|
||||
if has_scope(principal, ACKNOWLEDGE_SCOPE):
|
||||
actions.add("acknowledge")
|
||||
if has_scope(principal, BINDING_ADMIN_SCOPE) or has_scope(
|
||||
try:
|
||||
return actor_from_principal(
|
||||
principal,
|
||||
TEMPLATE_ADMIN_SCOPE,
|
||||
):
|
||||
actions.add("administer")
|
||||
if (
|
||||
assignment_context_id is not None
|
||||
and assignment_context_id not in principal.function_assignment_ids
|
||||
):
|
||||
assignment_context_id=assignment_context_id,
|
||||
)
|
||||
except PostboxPrincipalError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="The selected assignment context is not active for this principal.",
|
||||
)
|
||||
selected = assignment_context_id or principal.acting_assignment_id
|
||||
if selected is None and len(principal.function_assignment_ids) == 1:
|
||||
selected = next(iter(principal.function_assignment_ids))
|
||||
classifications = {"public", "internal"}
|
||||
if has_scope(principal, CONFIDENTIAL_SCOPE):
|
||||
classifications.add("confidential")
|
||||
if has_scope(principal, RESTRICTED_SCOPE):
|
||||
classifications.update(("confidential", "restricted"))
|
||||
return PostboxActorRef(
|
||||
account_id=principal.account_id,
|
||||
identity_id=principal.identity_id,
|
||||
selected_assignment_id=selected,
|
||||
acting_for_account_id=principal.acting_for_account_id,
|
||||
authorized_actions=frozenset(actions), # type: ignore[arg-type]
|
||||
authorized_classifications=frozenset(classifications), # type: ignore[arg-type]
|
||||
)
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
|
||||
def _http_error(exc: PostboxError) -> HTTPException:
|
||||
|
||||
@@ -739,6 +739,71 @@ class PostboxService:
|
||||
or 0
|
||||
)
|
||||
|
||||
def list_available_unread_messages(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
postbox_ids: Sequence[str],
|
||||
actor: PostboxActorRef,
|
||||
limit: int = 100,
|
||||
query: str | None = None,
|
||||
) -> tuple[tuple[PostboxMessageRef, ...], int]:
|
||||
"""Return unread messages that can still be acted on by this actor."""
|
||||
|
||||
db = _session(session)
|
||||
allowed_ids = self._allowed_postbox_ids(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
postbox_ids=postbox_ids,
|
||||
actor=actor,
|
||||
action="read",
|
||||
)
|
||||
if not allowed_ids:
|
||||
return (), 0
|
||||
now = utc_now()
|
||||
messages = self._messages_query(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
allowed_ids=allowed_ids,
|
||||
account_id=actor.account_id,
|
||||
allowed_classifications=tuple(actor.authorized_classifications),
|
||||
query=query,
|
||||
state="unread",
|
||||
).filter(
|
||||
PostboxMessage.withdrawn_at.is_(None),
|
||||
or_(
|
||||
PostboxMessage.expires_at.is_(None),
|
||||
PostboxMessage.expires_at > now,
|
||||
),
|
||||
)
|
||||
total = int(
|
||||
messages.order_by(None)
|
||||
.with_entities(func.count(PostboxMessage.id))
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
rows = (
|
||||
messages.options(
|
||||
selectinload(PostboxMessage.participants),
|
||||
selectinload(PostboxMessage.attachments),
|
||||
selectinload(PostboxMessage.receipts),
|
||||
)
|
||||
.order_by(
|
||||
PostboxMessage.delivered_at.desc(),
|
||||
PostboxMessage.id.desc(),
|
||||
)
|
||||
.limit(min(max(limit, 1), 500))
|
||||
.all()
|
||||
)
|
||||
return (
|
||||
tuple(
|
||||
self._message_ref(message, account_id=actor.account_id)
|
||||
for message in rows
|
||||
),
|
||||
total,
|
||||
)
|
||||
|
||||
def message_counts_by_postbox(
|
||||
self,
|
||||
session: object,
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import quote
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal, has_scope
|
||||
from govoplan_core.core.postbox import PostboxDirectoryEntryRef, PostboxMessageRef
|
||||
from govoplan_core.core.tasks import (
|
||||
WorkAssignmentRef,
|
||||
WorkItem,
|
||||
WorkItemPage,
|
||||
WorkItemQuery,
|
||||
WorkSourceRef,
|
||||
)
|
||||
from govoplan_postbox.backend.permissions import READ_SCOPE
|
||||
from govoplan_postbox.backend.principals import actor_from_principal
|
||||
from govoplan_postbox.backend.service import PostboxService
|
||||
|
||||
|
||||
PROVIDER_ID = "postbox.unread"
|
||||
|
||||
|
||||
class PostboxWorkItemProvider:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
registry: object | None = None,
|
||||
service: PostboxService | None = None,
|
||||
) -> None:
|
||||
self.registry = registry
|
||||
self.service = service
|
||||
|
||||
def list_items(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
query: WorkItemQuery,
|
||||
) -> WorkItemPage:
|
||||
if not isinstance(session, Session):
|
||||
raise TypeError("Postbox work aggregation requires a SQLAlchemy Session.")
|
||||
if not isinstance(principal, ApiPrincipal):
|
||||
return WorkItemPage(items=(), total=0)
|
||||
if principal.tenant_id != query.tenant_id or not has_scope(
|
||||
principal, READ_SCOPE
|
||||
):
|
||||
return WorkItemPage(items=(), total=0)
|
||||
if query.statuses and "open" not in query.statuses:
|
||||
return WorkItemPage(items=(), total=0)
|
||||
if query.priorities and "normal" not in query.priorities:
|
||||
return WorkItemPage(items=(), total=0)
|
||||
if query.due_before is not None:
|
||||
return WorkItemPage(items=(), total=0)
|
||||
|
||||
actor = actor_from_principal(principal)
|
||||
service = self._service()
|
||||
postboxes = service.list_visible_postboxes(
|
||||
session,
|
||||
tenant_id=query.tenant_id,
|
||||
actor=actor,
|
||||
)
|
||||
by_id = {postbox.id: postbox for postbox in postboxes}
|
||||
messages, total = service.list_available_unread_messages(
|
||||
session,
|
||||
tenant_id=query.tenant_id,
|
||||
postbox_ids=tuple(by_id),
|
||||
actor=actor,
|
||||
limit=query.limit,
|
||||
query=query.text,
|
||||
)
|
||||
return WorkItemPage(
|
||||
items=tuple(
|
||||
_work_item(message, by_id[message.postbox_id], principal)
|
||||
for message in messages
|
||||
),
|
||||
total=total,
|
||||
truncated=total > len(messages),
|
||||
)
|
||||
|
||||
def _service(self) -> PostboxService:
|
||||
if self.service is not None:
|
||||
return self.service
|
||||
if self.registry is None:
|
||||
raise RuntimeError("Postbox work aggregation requires a registry.")
|
||||
return PostboxService.from_registry(self.registry) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _work_item(
|
||||
message: PostboxMessageRef,
|
||||
postbox: PostboxDirectoryEntryRef,
|
||||
principal: ApiPrincipal,
|
||||
) -> WorkItem:
|
||||
action_url = (
|
||||
f"/postbox?postbox={quote(message.postbox_id, safe='')}"
|
||||
f"&message={quote(message.id, safe='')}"
|
||||
)
|
||||
sources = [
|
||||
WorkSourceRef(
|
||||
module_id="postbox",
|
||||
resource_type="postbox_message",
|
||||
resource_id=message.id,
|
||||
revision=message.delivered_at.isoformat(),
|
||||
url=action_url,
|
||||
label=message.subject,
|
||||
)
|
||||
]
|
||||
if (
|
||||
message.producer_module
|
||||
and message.producer_resource_type
|
||||
and message.producer_resource_id
|
||||
):
|
||||
sources.append(
|
||||
WorkSourceRef(
|
||||
module_id=message.producer_module,
|
||||
resource_type=message.producer_resource_type,
|
||||
resource_id=message.producer_resource_id,
|
||||
)
|
||||
)
|
||||
return WorkItem(
|
||||
id=message.id,
|
||||
provider_id=PROVIDER_ID,
|
||||
owner_module="postbox",
|
||||
tenant_id=message.tenant_id,
|
||||
title=message.subject,
|
||||
summary=(
|
||||
f"{postbox.name} · {message.sender_label}"
|
||||
if message.sender_label
|
||||
else postbox.name
|
||||
),
|
||||
status="open",
|
||||
priority="normal",
|
||||
required_action="Read the Postbox message.",
|
||||
action_url=action_url,
|
||||
assignments=_assignments(postbox, principal),
|
||||
sources=tuple(sources),
|
||||
provenance={
|
||||
"postbox_id": postbox.id,
|
||||
"address_key": postbox.address_key,
|
||||
"producer_module": message.producer_module,
|
||||
},
|
||||
metadata={
|
||||
"classification": message.classification,
|
||||
"attachment_count": len(message.attachments),
|
||||
"encrypted": message.encryption_profile != "plaintext_v1",
|
||||
},
|
||||
revision=f"{message.status}:{message.delivered_at.isoformat()}",
|
||||
created_at=message.delivered_at,
|
||||
updated_at=message.delivered_at,
|
||||
)
|
||||
|
||||
|
||||
def _assignments(
|
||||
postbox: PostboxDirectoryEntryRef,
|
||||
principal: ApiPrincipal,
|
||||
) -> tuple[WorkAssignmentRef, ...]:
|
||||
access = postbox.access
|
||||
assignment_ids = tuple(access.assignment_ids) if access is not None else ()
|
||||
if access is not None and access.selected_assignment_id:
|
||||
assignment_ids = (access.selected_assignment_id,)
|
||||
assignments = tuple(
|
||||
WorkAssignmentRef(
|
||||
kind="function_assignment",
|
||||
id=assignment_id,
|
||||
label=postbox.function_name,
|
||||
)
|
||||
for assignment_id in assignment_ids
|
||||
)
|
||||
if assignments:
|
||||
return assignments
|
||||
return (WorkAssignmentRef(kind="account", id=principal.account_id),)
|
||||
|
||||
|
||||
__all__ = ["PROVIDER_ID", "PostboxWorkItemProvider"]
|
||||
@@ -41,6 +41,7 @@ class PostboxManifestTests(unittest.TestCase):
|
||||
manifest.required_capabilities,
|
||||
)
|
||||
self.assertIn("encryption", manifest.optional_dependencies)
|
||||
self.assertEqual("postbox.unread", manifest.work_item_providers[0].id)
|
||||
self.assertTrue(
|
||||
any(
|
||||
requirement.name == CAPABILITY_ENCRYPTION_CONTENT_CIPHER
|
||||
|
||||
@@ -6,7 +6,10 @@ from datetime import timedelta
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.postbox import PostboxActorRef
|
||||
from govoplan_core.core.tasks import WorkItemQuery
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.db.session import (
|
||||
DatabaseHandle,
|
||||
@@ -41,6 +44,8 @@ from govoplan_postbox.backend.db.models import (
|
||||
PostboxTemplateRevision,
|
||||
)
|
||||
from govoplan_postbox.backend.service import PostboxService
|
||||
from govoplan_postbox.backend.principals import actor_from_principal
|
||||
from govoplan_postbox.backend.work_items import PostboxWorkItemProvider
|
||||
|
||||
|
||||
TABLES = (
|
||||
@@ -269,6 +274,67 @@ class PostboxRealDirectoryAccessTests(unittest.TestCase):
|
||||
self.assertFalse(expired.allowed)
|
||||
self.assertEqual(expired.reason_code, "effective_assignment_missing")
|
||||
|
||||
def test_unread_message_is_projected_as_current_work_until_read(self) -> None:
|
||||
self._add_assignment(
|
||||
assignment_id="owner-assignment",
|
||||
identity_id="identity-owner",
|
||||
account_id="account-owner",
|
||||
)
|
||||
principal = ApiPrincipal(
|
||||
principal=PrincipalRef(
|
||||
account_id="account-owner",
|
||||
membership_id="membership-owner",
|
||||
tenant_id="tenant-1",
|
||||
identity_id="identity-owner",
|
||||
scopes=frozenset({"postbox:postbox:read"}),
|
||||
function_assignment_ids=frozenset({"owner-assignment"}),
|
||||
),
|
||||
account=object(),
|
||||
user=object(),
|
||||
)
|
||||
provider = PostboxWorkItemProvider(service=self.service)
|
||||
with self.database.SessionLocal() as session:
|
||||
message = PostboxMessage(
|
||||
tenant_id="tenant-1",
|
||||
postbox_id=self.postbox_id,
|
||||
subject="Review the submitted evidence",
|
||||
status="delivered",
|
||||
classification="internal",
|
||||
sender_label="Permit service",
|
||||
delivered_at=utc_now(),
|
||||
wrapped_keys=[],
|
||||
external_recipient_tokens=[],
|
||||
metadata_={},
|
||||
)
|
||||
session.add(message)
|
||||
session.commit()
|
||||
|
||||
page = provider.list_items(
|
||||
session,
|
||||
principal,
|
||||
query=WorkItemQuery(tenant_id="tenant-1"),
|
||||
)
|
||||
self.assertEqual(1, page.total)
|
||||
self.assertEqual(message.id, page.items[0].id)
|
||||
self.assertEqual("owner-assignment", page.items[0].assignments[0].id)
|
||||
|
||||
self.service.mark_message(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
message_id=message.id,
|
||||
actor=actor_from_principal(principal),
|
||||
state="read",
|
||||
)
|
||||
session.commit()
|
||||
self.assertEqual(
|
||||
0,
|
||||
provider.list_items(
|
||||
session,
|
||||
principal,
|
||||
query=WorkItemQuery(tenant_id="tenant-1"),
|
||||
).total,
|
||||
)
|
||||
|
||||
def test_real_organization_state_and_function_move_fail_closed(self) -> None:
|
||||
self._add_assignment(
|
||||
assignment_id="owner-assignment",
|
||||
|
||||
@@ -22,10 +22,9 @@ assert(page.includes("useUnsavedDraftGuard") && admin.includes("useUnsavedDraftG
|
||||
assert(page.includes("delete_grouping_confirmation") && page.includes("ConfirmDialog"), "Deleting a unified view confirms that source records remain unchanged");
|
||||
assert(admin.includes("archive_confirmation") && admin.includes("retire_template_confirmation"), "Address and template lifecycle actions use shared destructive confirmation");
|
||||
assert(patterns.includes('topicId: "postbox.function-bound-containers"') && patterns.includes('topicId: "postbox.reference.fields-and-consequences"'), "Postbox uses manifest-backed help references");
|
||||
assert(moduleSource.includes('version: "0.1.2"') && moduleSource.includes("generatedTranslations"), "WebUI metadata matches the module release and registers translations");
|
||||
assert(moduleSource.includes('version: "0.1.18"') && moduleSource.includes("generatedTranslations"), "WebUI metadata matches the module release and registers translations");
|
||||
assert(translations.includes('"i18n:govoplan-postbox.unavailable_message_reason"'), "Access-sensitive unavailable states are localized");
|
||||
assert(widget.includes("usePlatformLanguage") && widget.includes("i18nMessage"), "Widget dates and dynamic accessible labels follow the platform locale");
|
||||
assert(!page.includes("window.confirm") && !admin.includes("window.confirm"), "Postbox does not use browser-native consequential confirmation");
|
||||
|
||||
console.log("Postbox surfaces satisfy the recorded interface pattern-language contract.");
|
||||
|
||||
|
||||
+5
-2
@@ -87,19 +87,22 @@ const postboxAdminSections: AdminSectionsUiCapability = {
|
||||
export const postboxModule: PlatformWebModule = {
|
||||
id: "postbox",
|
||||
label: "i18n:govoplan-postbox.postbox",
|
||||
version: "0.1.2",
|
||||
version: "0.1.18",
|
||||
dependencies: ["identity", "organizations", "idm"],
|
||||
optionalDependencies: [
|
||||
"access",
|
||||
"audit",
|
||||
"campaigns",
|
||||
"encryption",
|
||||
"files",
|
||||
"mail",
|
||||
"notifications",
|
||||
"policy",
|
||||
"portal",
|
||||
"search",
|
||||
"tasks",
|
||||
"views",
|
||||
"workflow"
|
||||
"workflow_engine"
|
||||
],
|
||||
translations,
|
||||
navItems: [
|
||||
|
||||
Reference in New Issue
Block a user