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
|
query projection only. It never merges source containers, messages, read or
|
||||||
acknowledgement state, retention, encryption keys, or audit evidence.
|
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
|
Grouping summaries calculate total and unread counts over the currently
|
||||||
visible source Postboxes in one tenant-bounded query. Hidden sources retained
|
visible source Postboxes in one tenant-bounded query. Hidden sources retained
|
||||||
for later reassignment do not leak counts into the projection.
|
for later reassignment do not leak counts into the projection.
|
||||||
|
|||||||
@@ -45,27 +45,28 @@ from govoplan_core.core.postbox import (
|
|||||||
CAPABILITY_POSTBOX_ROUTING,
|
CAPABILITY_POSTBOX_ROUTING,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.search import SearchSourceProviderRegistration
|
from govoplan_core.core.search import SearchSourceProviderRegistration
|
||||||
|
from govoplan_core.core.tasks import WorkItemProviderRegistration
|
||||||
from govoplan_core.core.views import ViewSurface
|
from govoplan_core.core.views import ViewSurface
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_postbox.backend.db import models as postbox_models
|
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.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_ID = "postbox"
|
||||||
MODULE_NAME = "Postbox"
|
MODULE_NAME = "Postbox"
|
||||||
MODULE_VERSION = "0.1.18"
|
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:
|
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||||
module_id, resource, action = scope.split(":", 2)
|
module_id, resource, action = scope.split(":", 2)
|
||||||
return PermissionDefinition(
|
return PermissionDefinition(
|
||||||
@@ -169,6 +170,12 @@ def _router(context: ModuleContext):
|
|||||||
return router
|
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]:
|
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||||
return {
|
return {
|
||||||
"postboxes": session.query(postbox_models.Postbox)
|
"postboxes": session.query(postbox_models.Postbox)
|
||||||
@@ -225,6 +232,7 @@ manifest = ModuleManifest(
|
|||||||
"views",
|
"views",
|
||||||
"workflow_engine",
|
"workflow_engine",
|
||||||
"search",
|
"search",
|
||||||
|
"tasks",
|
||||||
),
|
),
|
||||||
required_capabilities=(
|
required_capabilities=(
|
||||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
@@ -284,6 +292,13 @@ manifest = ModuleManifest(
|
|||||||
factory=create_postbox_search_source,
|
factory=create_postbox_search_source,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
work_item_providers=(
|
||||||
|
WorkItemProviderRegistration(
|
||||||
|
id="postbox.unread",
|
||||||
|
factory=_work_items,
|
||||||
|
order=40,
|
||||||
|
),
|
||||||
|
),
|
||||||
nav_items=(
|
nav_items=(
|
||||||
NavItem(
|
NavItem(
|
||||||
path="/postbox",
|
path="/postbox",
|
||||||
@@ -413,7 +428,9 @@ manifest = ModuleManifest(
|
|||||||
"server-envelope profile stores message bodies as ciphertext and "
|
"server-envelope profile stores message bodies as ciphertext and "
|
||||||
"uses the optional Encryption capability for authorized reads. "
|
"uses the optional Encryption capability for authorized reads. "
|
||||||
"External ciphertext profiles retain producer-managed references "
|
"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",
|
layer="available",
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
@@ -425,6 +442,7 @@ manifest = ModuleManifest(
|
|||||||
"campaigns",
|
"campaigns",
|
||||||
"files",
|
"files",
|
||||||
"notifications",
|
"notifications",
|
||||||
|
"tasks",
|
||||||
),
|
),
|
||||||
links=(
|
links=(
|
||||||
DocumentationLink(
|
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 (
|
from govoplan_postbox.backend.manifest import (
|
||||||
ACKNOWLEDGE_SCOPE,
|
ACKNOWLEDGE_SCOPE,
|
||||||
BINDING_ADMIN_SCOPE,
|
BINDING_ADMIN_SCOPE,
|
||||||
CONFIDENTIAL_SCOPE,
|
|
||||||
DELIVERY_SCOPE,
|
DELIVERY_SCOPE,
|
||||||
READ_SCOPE,
|
READ_SCOPE,
|
||||||
REPLY_SCOPE,
|
REPLY_SCOPE,
|
||||||
RESTRICTED_SCOPE,
|
|
||||||
SEND_SCOPE,
|
SEND_SCOPE,
|
||||||
TEMPLATE_ADMIN_SCOPE,
|
TEMPLATE_ADMIN_SCOPE,
|
||||||
)
|
)
|
||||||
@@ -67,6 +65,10 @@ from govoplan_postbox.backend.schemas import (
|
|||||||
PostboxTemplateReviseRequest,
|
PostboxTemplateReviseRequest,
|
||||||
)
|
)
|
||||||
from govoplan_postbox.backend.service import PostboxError
|
from govoplan_postbox.backend.service import PostboxError
|
||||||
|
from govoplan_postbox.backend.principals import (
|
||||||
|
PostboxPrincipalError,
|
||||||
|
actor_from_principal,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/postbox", tags=["postbox"])
|
router = APIRouter(prefix="/postbox", tags=["postbox"])
|
||||||
@@ -94,44 +96,16 @@ def _actor(
|
|||||||
*,
|
*,
|
||||||
assignment_context_id: str | None = None,
|
assignment_context_id: str | None = None,
|
||||||
) -> PostboxActorRef:
|
) -> PostboxActorRef:
|
||||||
actions: set[str] = set()
|
try:
|
||||||
if has_scope(principal, READ_SCOPE):
|
return actor_from_principal(
|
||||||
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,
|
principal,
|
||||||
TEMPLATE_ADMIN_SCOPE,
|
assignment_context_id=assignment_context_id,
|
||||||
):
|
)
|
||||||
actions.add("administer")
|
except PostboxPrincipalError as exc:
|
||||||
if (
|
|
||||||
assignment_context_id is not None
|
|
||||||
and assignment_context_id not in principal.function_assignment_ids
|
|
||||||
):
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="The selected assignment context is not active for this principal.",
|
detail=str(exc),
|
||||||
)
|
) from exc
|
||||||
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]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _http_error(exc: PostboxError) -> HTTPException:
|
def _http_error(exc: PostboxError) -> HTTPException:
|
||||||
|
|||||||
@@ -739,6 +739,71 @@ class PostboxService:
|
|||||||
or 0
|
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(
|
def message_counts_by_postbox(
|
||||||
self,
|
self,
|
||||||
session: object,
|
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,
|
manifest.required_capabilities,
|
||||||
)
|
)
|
||||||
self.assertIn("encryption", manifest.optional_dependencies)
|
self.assertIn("encryption", manifest.optional_dependencies)
|
||||||
|
self.assertEqual("postbox.unread", manifest.work_item_providers[0].id)
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
any(
|
any(
|
||||||
requirement.name == CAPABILITY_ENCRYPTION_CONTENT_CIPHER
|
requirement.name == CAPABILITY_ENCRYPTION_CONTENT_CIPHER
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ from datetime import timedelta
|
|||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import Session
|
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.postbox import PostboxActorRef
|
||||||
|
from govoplan_core.core.tasks import WorkItemQuery
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_core.db.session import (
|
from govoplan_core.db.session import (
|
||||||
DatabaseHandle,
|
DatabaseHandle,
|
||||||
@@ -41,6 +44,8 @@ from govoplan_postbox.backend.db.models import (
|
|||||||
PostboxTemplateRevision,
|
PostboxTemplateRevision,
|
||||||
)
|
)
|
||||||
from govoplan_postbox.backend.service import PostboxService
|
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 = (
|
TABLES = (
|
||||||
@@ -269,6 +274,67 @@ class PostboxRealDirectoryAccessTests(unittest.TestCase):
|
|||||||
self.assertFalse(expired.allowed)
|
self.assertFalse(expired.allowed)
|
||||||
self.assertEqual(expired.reason_code, "effective_assignment_missing")
|
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:
|
def test_real_organization_state_and_function_move_fail_closed(self) -> None:
|
||||||
self._add_assignment(
|
self._add_assignment(
|
||||||
assignment_id="owner-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(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(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(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(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(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");
|
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.");
|
console.log("Postbox surfaces satisfy the recorded interface pattern-language contract.");
|
||||||
|
|
||||||
|
|||||||
+5
-2
@@ -87,19 +87,22 @@ const postboxAdminSections: AdminSectionsUiCapability = {
|
|||||||
export const postboxModule: PlatformWebModule = {
|
export const postboxModule: PlatformWebModule = {
|
||||||
id: "postbox",
|
id: "postbox",
|
||||||
label: "i18n:govoplan-postbox.postbox",
|
label: "i18n:govoplan-postbox.postbox",
|
||||||
version: "0.1.2",
|
version: "0.1.18",
|
||||||
dependencies: ["identity", "organizations", "idm"],
|
dependencies: ["identity", "organizations", "idm"],
|
||||||
optionalDependencies: [
|
optionalDependencies: [
|
||||||
"access",
|
"access",
|
||||||
"audit",
|
"audit",
|
||||||
"campaigns",
|
"campaigns",
|
||||||
|
"encryption",
|
||||||
"files",
|
"files",
|
||||||
"mail",
|
"mail",
|
||||||
"notifications",
|
"notifications",
|
||||||
"policy",
|
"policy",
|
||||||
"portal",
|
"portal",
|
||||||
|
"search",
|
||||||
|
"tasks",
|
||||||
"views",
|
"views",
|
||||||
"workflow"
|
"workflow_engine"
|
||||||
],
|
],
|
||||||
translations,
|
translations,
|
||||||
navItems: [
|
navItems: [
|
||||||
|
|||||||
Reference in New Issue
Block a user