feat: complete postbox access evidence
This commit is contained in:
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-postbox"
|
name = "govoplan-postbox"
|
||||||
version = "0.1.1"
|
version = "0.1.2"
|
||||||
description = "Function-bound institutional postboxes for GovOPlaN."
|
description = "Function-bound institutional postboxes for GovOPlaN."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Callable, Sequence
|
||||||
|
|
||||||
|
from govoplan_core.core.idm import OrganizationFunctionAssignmentRef
|
||||||
|
from govoplan_core.core.postbox import (
|
||||||
|
PostboxAccessDecisionRef,
|
||||||
|
PostboxAction,
|
||||||
|
PostboxActorRef,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PostboxAccessContext:
|
||||||
|
postbox_id: str
|
||||||
|
postbox_active: bool
|
||||||
|
action: PostboxAction
|
||||||
|
actor: PostboxActorRef
|
||||||
|
organization_unit_id: str | None
|
||||||
|
function_id: str | None
|
||||||
|
holder_count: int
|
||||||
|
binding_available: bool
|
||||||
|
binding_assignments: tuple[OrganizationFunctionAssignmentRef, ...]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def base(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"action": self.action,
|
||||||
|
"postbox_id": self.postbox_id,
|
||||||
|
"organization_unit_id": self.organization_unit_id,
|
||||||
|
"function_id": self.function_id,
|
||||||
|
"holder_count": self.holder_count,
|
||||||
|
"vacant": self.holder_count == 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class AccessRule:
|
||||||
|
name: str
|
||||||
|
matches: Callable[[PostboxAccessContext], bool]
|
||||||
|
decision: Callable[[PostboxAccessContext], PostboxAccessDecisionRef]
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_postbox_access(
|
||||||
|
*,
|
||||||
|
postbox_id: str,
|
||||||
|
postbox_active: bool,
|
||||||
|
action: PostboxAction,
|
||||||
|
actor: PostboxActorRef,
|
||||||
|
organization_unit_id: str | None,
|
||||||
|
function_id: str | None,
|
||||||
|
holder_count: int,
|
||||||
|
binding_available: bool,
|
||||||
|
binding_assignments: Sequence[OrganizationFunctionAssignmentRef],
|
||||||
|
) -> PostboxAccessDecisionRef:
|
||||||
|
context = PostboxAccessContext(
|
||||||
|
postbox_id=postbox_id,
|
||||||
|
postbox_active=postbox_active,
|
||||||
|
action=action,
|
||||||
|
actor=actor,
|
||||||
|
organization_unit_id=organization_unit_id,
|
||||||
|
function_id=function_id,
|
||||||
|
holder_count=holder_count,
|
||||||
|
binding_available=binding_available,
|
||||||
|
binding_assignments=tuple(binding_assignments),
|
||||||
|
)
|
||||||
|
for rule in ACCESS_DECISION_TABLE:
|
||||||
|
if rule.matches(context):
|
||||||
|
return rule.decision(context)
|
||||||
|
return _assignment_decision(context)
|
||||||
|
|
||||||
|
|
||||||
|
def _deny(
|
||||||
|
context: PostboxAccessContext,
|
||||||
|
*,
|
||||||
|
reason_code: str,
|
||||||
|
explanation: str,
|
||||||
|
) -> PostboxAccessDecisionRef:
|
||||||
|
return PostboxAccessDecisionRef(
|
||||||
|
allowed=False,
|
||||||
|
reason_code=reason_code,
|
||||||
|
explanation=explanation,
|
||||||
|
**context.base,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _inactive(context: PostboxAccessContext) -> PostboxAccessDecisionRef:
|
||||||
|
return _deny(
|
||||||
|
context,
|
||||||
|
reason_code="postbox_inactive",
|
||||||
|
explanation="The Postbox is not active.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _permission_missing(
|
||||||
|
context: PostboxAccessContext,
|
||||||
|
) -> PostboxAccessDecisionRef:
|
||||||
|
return _deny(
|
||||||
|
context,
|
||||||
|
reason_code="generic_permission_missing",
|
||||||
|
explanation=(
|
||||||
|
"The account lacks the generic Postbox permission for this action."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _administrator(context: PostboxAccessContext) -> PostboxAccessDecisionRef:
|
||||||
|
return PostboxAccessDecisionRef(
|
||||||
|
allowed=True,
|
||||||
|
reason_code="generic_administrator",
|
||||||
|
explanation="The account has Postbox administration permission.",
|
||||||
|
**context.base,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _binding_missing(context: PostboxAccessContext) -> PostboxAccessDecisionRef:
|
||||||
|
return _deny(
|
||||||
|
context,
|
||||||
|
reason_code="function_binding_missing",
|
||||||
|
explanation="This Postbox has no active organization-function binding.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _assignment_decision(
|
||||||
|
context: PostboxAccessContext,
|
||||||
|
) -> PostboxAccessDecisionRef:
|
||||||
|
eligible: list[OrganizationFunctionAssignmentRef] = []
|
||||||
|
acting_candidates: list[OrganizationFunctionAssignmentRef] = []
|
||||||
|
for assignment in context.binding_assignments:
|
||||||
|
if assignment.source != "acting_for":
|
||||||
|
eligible.append(assignment)
|
||||||
|
continue
|
||||||
|
acting_candidates.append(assignment)
|
||||||
|
if context.actor.selected_assignment_id != assignment.id:
|
||||||
|
continue
|
||||||
|
if (
|
||||||
|
assignment.acting_for_account_id
|
||||||
|
and context.actor.acting_for_account_id
|
||||||
|
!= assignment.acting_for_account_id
|
||||||
|
):
|
||||||
|
return _deny(
|
||||||
|
context,
|
||||||
|
reason_code="acting_account_mismatch",
|
||||||
|
explanation=(
|
||||||
|
"The selected acting assignment belongs to another "
|
||||||
|
"represented account context."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
eligible.append(assignment)
|
||||||
|
|
||||||
|
if not eligible:
|
||||||
|
if acting_candidates and not context.actor.selected_assignment_id:
|
||||||
|
return _deny(
|
||||||
|
context,
|
||||||
|
reason_code="acting_context_required",
|
||||||
|
explanation=(
|
||||||
|
"Select the acting assignment context before opening this "
|
||||||
|
"Postbox."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if acting_candidates:
|
||||||
|
return _deny(
|
||||||
|
context,
|
||||||
|
reason_code="acting_assignment_not_selected",
|
||||||
|
explanation=(
|
||||||
|
"The selected assignment context does not grant access to "
|
||||||
|
"this Postbox."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return _deny(
|
||||||
|
context,
|
||||||
|
reason_code="effective_assignment_missing",
|
||||||
|
explanation=(
|
||||||
|
"No current function assignment grants this account access "
|
||||||
|
"to the Postbox."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
selected = next(
|
||||||
|
(
|
||||||
|
assignment
|
||||||
|
for assignment in eligible
|
||||||
|
if assignment.id == context.actor.selected_assignment_id
|
||||||
|
),
|
||||||
|
eligible[0],
|
||||||
|
)
|
||||||
|
return PostboxAccessDecisionRef(
|
||||||
|
allowed=True,
|
||||||
|
reason_code=f"effective_{selected.source}_assignment",
|
||||||
|
explanation=(
|
||||||
|
"Access follows the current effective organization-function "
|
||||||
|
f"assignment ({selected.source})."
|
||||||
|
),
|
||||||
|
assignment_ids=tuple(item.id for item in eligible),
|
||||||
|
assignment_sources=tuple(item.source for item in eligible),
|
||||||
|
selected_assignment_id=selected.id,
|
||||||
|
**context.base,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
ACCESS_DECISION_TABLE = (
|
||||||
|
AccessRule(
|
||||||
|
name="inactive_postbox",
|
||||||
|
matches=lambda context: not context.postbox_active,
|
||||||
|
decision=_inactive,
|
||||||
|
),
|
||||||
|
AccessRule(
|
||||||
|
name="generic_permission",
|
||||||
|
matches=lambda context: context.action not in context.actor.authorized_actions,
|
||||||
|
decision=_permission_missing,
|
||||||
|
),
|
||||||
|
AccessRule(
|
||||||
|
name="administrator",
|
||||||
|
matches=lambda context: context.action == "administer",
|
||||||
|
decision=_administrator,
|
||||||
|
),
|
||||||
|
AccessRule(
|
||||||
|
name="active_function_binding",
|
||||||
|
matches=lambda context: (
|
||||||
|
not context.binding_available
|
||||||
|
or not context.function_id
|
||||||
|
or not context.organization_unit_id
|
||||||
|
),
|
||||||
|
decision=_binding_missing,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ACCESS_DECISION_TABLE",
|
||||||
|
"AccessRule",
|
||||||
|
"PostboxAccessContext",
|
||||||
|
"evaluate_postbox_access",
|
||||||
|
]
|
||||||
@@ -48,7 +48,7 @@ from govoplan_postbox.backend.db import models as postbox_models
|
|||||||
|
|
||||||
MODULE_ID = "postbox"
|
MODULE_ID = "postbox"
|
||||||
MODULE_NAME = "Postbox"
|
MODULE_NAME = "Postbox"
|
||||||
MODULE_VERSION = "0.1.1"
|
MODULE_VERSION = "0.1.2"
|
||||||
|
|
||||||
READ_SCOPE = "postbox:postbox:read"
|
READ_SCOPE = "postbox:postbox:read"
|
||||||
SEND_SCOPE = "postbox:message:write"
|
SEND_SCOPE = "postbox:message:write"
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ class PostboxMessageItem(BaseModel):
|
|||||||
subject: str
|
subject: str
|
||||||
body_text: str | None = None
|
body_text: str | None = None
|
||||||
status: str
|
status: str
|
||||||
|
availability: Literal["available", "withdrawn", "expired"]
|
||||||
classification: str
|
classification: str
|
||||||
sender_label: str | None = None
|
sender_label: str | None = None
|
||||||
delivered_at: datetime
|
delivered_at: datetime
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
from collections import Counter
|
||||||
from collections.abc import Mapping, Sequence
|
from collections.abc import Mapping, Sequence
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
@@ -48,11 +49,13 @@ from govoplan_core.core.postbox import (
|
|||||||
PostboxActorRef,
|
PostboxActorRef,
|
||||||
PostboxAttachmentRef,
|
PostboxAttachmentRef,
|
||||||
PostboxDeliveryCatalogRef,
|
PostboxDeliveryCatalogRef,
|
||||||
|
PostboxDeliveryReceiptSummaryRef,
|
||||||
PostboxDeliveryRequest,
|
PostboxDeliveryRequest,
|
||||||
PostboxDeliveryRejected,
|
PostboxDeliveryRejected,
|
||||||
PostboxDeliveryResult,
|
PostboxDeliveryResult,
|
||||||
PostboxDeliveryTemplateRef,
|
PostboxDeliveryTemplateRef,
|
||||||
PostboxDirectoryEntryRef,
|
PostboxDirectoryEntryRef,
|
||||||
|
PostboxMessageAvailability,
|
||||||
PostboxMessageRef,
|
PostboxMessageRef,
|
||||||
PostboxMessageListState,
|
PostboxMessageListState,
|
||||||
PostboxOrganizationFunctionTargetRef,
|
PostboxOrganizationFunctionTargetRef,
|
||||||
@@ -84,6 +87,7 @@ from govoplan_postbox.backend.hierarchy_routing import (
|
|||||||
normalized_routing_policy,
|
normalized_routing_policy,
|
||||||
plan_hierarchy_routes,
|
plan_hierarchy_routes,
|
||||||
)
|
)
|
||||||
|
from govoplan_postbox.backend.access_decisions import evaluate_postbox_access
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -123,6 +127,28 @@ def _as_utc(value: datetime) -> datetime:
|
|||||||
return value.astimezone(timezone.utc)
|
return value.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _message_availability(
|
||||||
|
message: PostboxMessage,
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> PostboxMessageAvailability:
|
||||||
|
if message.withdrawn_at is not None:
|
||||||
|
return "withdrawn"
|
||||||
|
if message.expires_at is not None and _as_utc(message.expires_at) <= _as_utc(
|
||||||
|
now or utc_now()
|
||||||
|
):
|
||||||
|
return "expired"
|
||||||
|
return "available"
|
||||||
|
|
||||||
|
|
||||||
|
def _first_timestamp(values: Sequence[datetime]) -> datetime | None:
|
||||||
|
return min(values, key=_as_utc) if values else None
|
||||||
|
|
||||||
|
|
||||||
|
def _last_timestamp(values: Sequence[datetime]) -> datetime | None:
|
||||||
|
return max(values, key=_as_utc) if values else None
|
||||||
|
|
||||||
|
|
||||||
def _publish_postbox_event(
|
def _publish_postbox_event(
|
||||||
session: Session,
|
session: Session,
|
||||||
event_type: str,
|
event_type: str,
|
||||||
@@ -651,6 +677,12 @@ class PostboxService:
|
|||||||
)
|
)
|
||||||
if not decision.allowed:
|
if not decision.allowed:
|
||||||
raise PostboxError("access_denied", decision.explanation)
|
raise PostboxError("access_denied", decision.explanation)
|
||||||
|
availability = _message_availability(message)
|
||||||
|
if availability != "available":
|
||||||
|
raise PostboxError(
|
||||||
|
f"message_{availability}",
|
||||||
|
f"This Postbox message is {availability} and cannot be changed.",
|
||||||
|
)
|
||||||
receipt = (
|
receipt = (
|
||||||
db.query(PostboxMessageReceipt)
|
db.query(PostboxMessageReceipt)
|
||||||
.filter(
|
.filter(
|
||||||
@@ -1918,6 +1950,189 @@ class PostboxService:
|
|||||||
)
|
)
|
||||||
return self._message_ref(message, account_id="")
|
return self._message_ref(message, account_id="")
|
||||||
|
|
||||||
|
def delivery_receipt_summaries(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
producer_module: str,
|
||||||
|
delivery_ids: Sequence[str],
|
||||||
|
) -> Mapping[str, PostboxDeliveryReceiptSummaryRef]:
|
||||||
|
db = _session(session)
|
||||||
|
requested_ids = tuple(
|
||||||
|
dict.fromkeys(
|
||||||
|
str(delivery_id).strip()
|
||||||
|
for delivery_id in delivery_ids
|
||||||
|
if str(delivery_id).strip()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not requested_ids:
|
||||||
|
return {}
|
||||||
|
if len(requested_ids) > 1000:
|
||||||
|
raise PostboxError(
|
||||||
|
"delivery_summary_limit",
|
||||||
|
"At most 1000 Postbox deliveries can be summarized at once.",
|
||||||
|
)
|
||||||
|
|
||||||
|
deliveries = (
|
||||||
|
db.query(PostboxDelivery)
|
||||||
|
.filter(
|
||||||
|
PostboxDelivery.tenant_id == tenant_id,
|
||||||
|
PostboxDelivery.producer_module == producer_module,
|
||||||
|
PostboxDelivery.id.in_(requested_ids),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if not deliveries:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
accepted_ids = tuple(delivery.id for delivery in deliveries)
|
||||||
|
routes = (
|
||||||
|
db.query(PostboxRoute)
|
||||||
|
.filter(
|
||||||
|
PostboxRoute.tenant_id == tenant_id,
|
||||||
|
PostboxRoute.delivery_id.in_(accepted_ids),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
routes_by_delivery: dict[str, list[PostboxRoute]] = {
|
||||||
|
delivery_id: [] for delivery_id in accepted_ids
|
||||||
|
}
|
||||||
|
message_ids_by_delivery: dict[str, set[str]] = {
|
||||||
|
delivery.id: {delivery.message_id} for delivery in deliveries
|
||||||
|
}
|
||||||
|
for route in routes:
|
||||||
|
routes_by_delivery.setdefault(route.delivery_id, []).append(route)
|
||||||
|
if route.target_message_id:
|
||||||
|
message_ids_by_delivery.setdefault(route.delivery_id, set()).add(
|
||||||
|
route.target_message_id
|
||||||
|
)
|
||||||
|
|
||||||
|
message_ids = tuple(
|
||||||
|
{
|
||||||
|
message_id
|
||||||
|
for ids in message_ids_by_delivery.values()
|
||||||
|
for message_id in ids
|
||||||
|
}
|
||||||
|
)
|
||||||
|
messages = (
|
||||||
|
db.query(PostboxMessage)
|
||||||
|
.options(selectinload(PostboxMessage.receipts))
|
||||||
|
.filter(
|
||||||
|
PostboxMessage.tenant_id == tenant_id,
|
||||||
|
PostboxMessage.id.in_(message_ids),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
messages_by_id = {message.id: message for message in messages}
|
||||||
|
postbox_ids = tuple({message.postbox_id for message in messages})
|
||||||
|
postboxes = (
|
||||||
|
db.query(Postbox)
|
||||||
|
.options(
|
||||||
|
selectinload(Postbox.address_record),
|
||||||
|
selectinload(Postbox.bindings),
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
Postbox.tenant_id == tenant_id,
|
||||||
|
Postbox.id.in_(postbox_ids),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
postboxes_by_id = {postbox.id: postbox for postbox in postboxes}
|
||||||
|
holder_cache = self._holder_cache(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
postboxes=postboxes,
|
||||||
|
)
|
||||||
|
holder_counts: dict[str, int] = {}
|
||||||
|
for postbox in postboxes:
|
||||||
|
binding = self._active_binding(postbox)
|
||||||
|
function_id = (
|
||||||
|
binding.function_id
|
||||||
|
if binding is not None
|
||||||
|
else postbox.address_record.function_id
|
||||||
|
)
|
||||||
|
holder_counts[postbox.id] = len(
|
||||||
|
{
|
||||||
|
holder.identity_id
|
||||||
|
for holder in self._holders(
|
||||||
|
tenant_id,
|
||||||
|
function_id,
|
||||||
|
holder_cache,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
now = utc_now()
|
||||||
|
summaries: dict[str, PostboxDeliveryReceiptSummaryRef] = {}
|
||||||
|
for delivery in deliveries:
|
||||||
|
delivery_messages = [
|
||||||
|
messages_by_id[message_id]
|
||||||
|
for message_id in message_ids_by_delivery.get(delivery.id, ())
|
||||||
|
if message_id in messages_by_id
|
||||||
|
]
|
||||||
|
read_times = [
|
||||||
|
receipt.read_at
|
||||||
|
for message in delivery_messages
|
||||||
|
for receipt in message.receipts
|
||||||
|
if receipt.read_at is not None
|
||||||
|
]
|
||||||
|
acknowledged_times = [
|
||||||
|
receipt.acknowledged_at
|
||||||
|
for message in delivery_messages
|
||||||
|
for receipt in message.receipts
|
||||||
|
if receipt.acknowledged_at is not None
|
||||||
|
]
|
||||||
|
availabilities = {
|
||||||
|
message.id: _message_availability(message, now=now)
|
||||||
|
for message in delivery_messages
|
||||||
|
}
|
||||||
|
readable_count = sum(
|
||||||
|
1
|
||||||
|
for message in delivery_messages
|
||||||
|
if availabilities[message.id] == "available"
|
||||||
|
and (
|
||||||
|
postbox := postboxes_by_id.get(message.postbox_id)
|
||||||
|
) is not None
|
||||||
|
and postbox.status == "active"
|
||||||
|
and holder_counts.get(postbox.id, 0) > 0
|
||||||
|
)
|
||||||
|
delivery_routes = routes_by_delivery.get(delivery.id, ())
|
||||||
|
route_status_counts = Counter(route.status for route in delivery_routes)
|
||||||
|
summaries[delivery.id] = PostboxDeliveryReceiptSummaryRef(
|
||||||
|
delivery_id=delivery.id,
|
||||||
|
message_id=delivery.message_id,
|
||||||
|
postbox_id=delivery.postbox_id,
|
||||||
|
delivery_status=delivery.status,
|
||||||
|
accepted_at=delivery.accepted_at,
|
||||||
|
current_holder_count=holder_counts.get(delivery.postbox_id, 0),
|
||||||
|
currently_readable=readable_count > 0,
|
||||||
|
message_count=len(delivery_messages),
|
||||||
|
routed_message_count=len(
|
||||||
|
{
|
||||||
|
route.target_message_id
|
||||||
|
for route in delivery_routes
|
||||||
|
if route.target_message_id
|
||||||
|
}
|
||||||
|
),
|
||||||
|
readable_message_count=readable_count,
|
||||||
|
read_receipt_count=len(read_times),
|
||||||
|
acknowledged_receipt_count=len(acknowledged_times),
|
||||||
|
withdrawn_message_count=sum(
|
||||||
|
availability == "withdrawn"
|
||||||
|
for availability in availabilities.values()
|
||||||
|
),
|
||||||
|
expired_message_count=sum(
|
||||||
|
availability == "expired"
|
||||||
|
for availability in availabilities.values()
|
||||||
|
),
|
||||||
|
first_read_at=_first_timestamp(read_times),
|
||||||
|
last_read_at=_last_timestamp(read_times),
|
||||||
|
first_acknowledged_at=_first_timestamp(acknowledged_times),
|
||||||
|
last_acknowledged_at=_last_timestamp(acknowledged_times),
|
||||||
|
route_status_counts=dict(sorted(route_status_counts.items())),
|
||||||
|
)
|
||||||
|
return summaries
|
||||||
|
|
||||||
# Administration
|
# Administration
|
||||||
def list_admin_postboxes(
|
def list_admin_postboxes(
|
||||||
self,
|
self,
|
||||||
@@ -2825,92 +3040,24 @@ class PostboxService:
|
|||||||
)
|
)
|
||||||
holders = self._holders(postbox.tenant_id, function_id, holder_cache)
|
holders = self._holders(postbox.tenant_id, function_id, holder_cache)
|
||||||
holder_count = len({holder.identity_id for holder in holders})
|
holder_count = len({holder.identity_id for holder in holders})
|
||||||
base = {
|
binding_assignments: list[OrganizationFunctionAssignmentRef] = []
|
||||||
"action": action,
|
if binding is not None:
|
||||||
"postbox_id": postbox.id,
|
for assignment in assignments:
|
||||||
"organization_unit_id": unit_id,
|
if assignment.tenant_id != postbox.tenant_id:
|
||||||
"function_id": function_id,
|
|
||||||
"holder_count": holder_count,
|
|
||||||
"vacant": holder_count == 0,
|
|
||||||
}
|
|
||||||
if postbox.status != "active":
|
|
||||||
return PostboxAccessDecisionRef(
|
|
||||||
allowed=False,
|
|
||||||
reason_code="postbox_inactive",
|
|
||||||
explanation="The Postbox is not active.",
|
|
||||||
**base,
|
|
||||||
)
|
|
||||||
if action not in actor.authorized_actions:
|
|
||||||
return PostboxAccessDecisionRef(
|
|
||||||
allowed=False,
|
|
||||||
reason_code="generic_permission_missing",
|
|
||||||
explanation=(
|
|
||||||
"The account lacks the generic Postbox permission for this action."
|
|
||||||
),
|
|
||||||
**base,
|
|
||||||
)
|
|
||||||
if action == "administer":
|
|
||||||
return PostboxAccessDecisionRef(
|
|
||||||
allowed=True,
|
|
||||||
reason_code="generic_administrator",
|
|
||||||
explanation="The account has Postbox administration permission.",
|
|
||||||
**base,
|
|
||||||
)
|
|
||||||
if binding is None or not function_id or not unit_id:
|
|
||||||
return PostboxAccessDecisionRef(
|
|
||||||
allowed=False,
|
|
||||||
reason_code="function_binding_missing",
|
|
||||||
explanation=(
|
|
||||||
"This Postbox has no active organization-function binding."
|
|
||||||
),
|
|
||||||
**base,
|
|
||||||
)
|
|
||||||
|
|
||||||
matching: list[OrganizationFunctionAssignmentRef] = []
|
|
||||||
for assignment in assignments:
|
|
||||||
if assignment.tenant_id != postbox.tenant_id:
|
|
||||||
continue
|
|
||||||
if not self._assignment_matches_binding(assignment, binding):
|
|
||||||
continue
|
|
||||||
if assignment.source == "acting_for":
|
|
||||||
if actor.selected_assignment_id != assignment.id:
|
|
||||||
continue
|
continue
|
||||||
if (
|
if not self._assignment_matches_binding(assignment, binding):
|
||||||
assignment.acting_for_account_id
|
|
||||||
and actor.acting_for_account_id
|
|
||||||
!= assignment.acting_for_account_id
|
|
||||||
):
|
|
||||||
continue
|
continue
|
||||||
matching.append(assignment)
|
binding_assignments.append(assignment)
|
||||||
if not matching:
|
return evaluate_postbox_access(
|
||||||
return PostboxAccessDecisionRef(
|
postbox_id=postbox.id,
|
||||||
allowed=False,
|
postbox_active=postbox.status == "active",
|
||||||
reason_code="effective_assignment_missing",
|
action=action,
|
||||||
explanation=(
|
actor=actor,
|
||||||
"No current function assignment grants this account access "
|
organization_unit_id=unit_id,
|
||||||
"to the Postbox."
|
function_id=function_id,
|
||||||
),
|
holder_count=holder_count,
|
||||||
**base,
|
binding_available=binding is not None,
|
||||||
)
|
binding_assignments=binding_assignments,
|
||||||
selected = next(
|
|
||||||
(
|
|
||||||
assignment
|
|
||||||
for assignment in matching
|
|
||||||
if assignment.id == actor.selected_assignment_id
|
|
||||||
),
|
|
||||||
matching[0],
|
|
||||||
)
|
|
||||||
return PostboxAccessDecisionRef(
|
|
||||||
allowed=True,
|
|
||||||
reason_code=f"effective_{selected.source}_assignment",
|
|
||||||
explanation=(
|
|
||||||
"Access follows the current effective organization-function "
|
|
||||||
f"assignment ({selected.source})."
|
|
||||||
),
|
|
||||||
assignment_ids=tuple(item.id for item in matching),
|
|
||||||
assignment_sources=tuple(item.source for item in matching),
|
|
||||||
selected_assignment_id=selected.id,
|
|
||||||
**base,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _assignment_matches_binding(
|
def _assignment_matches_binding(
|
||||||
@@ -3046,13 +3193,16 @@ class PostboxService:
|
|||||||
),
|
),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
availability = _message_availability(message)
|
||||||
|
content_available = availability == "available"
|
||||||
return PostboxMessageRef(
|
return PostboxMessageRef(
|
||||||
id=message.id,
|
id=message.id,
|
||||||
tenant_id=message.tenant_id,
|
tenant_id=message.tenant_id,
|
||||||
postbox_id=message.postbox_id,
|
postbox_id=message.postbox_id,
|
||||||
subject=message.subject,
|
subject=message.subject,
|
||||||
body_text=message.body_text,
|
body_text=message.body_text if content_available else None,
|
||||||
status=message.status,
|
status=message.status,
|
||||||
|
availability=availability,
|
||||||
classification=message.classification,
|
classification=message.classification,
|
||||||
sender_label=message.sender_label,
|
sender_label=message.sender_label,
|
||||||
delivered_at=message.delivered_at,
|
delivered_at=message.delivered_at,
|
||||||
@@ -3076,6 +3226,7 @@ class PostboxService:
|
|||||||
address=item.address,
|
address=item.address,
|
||||||
)
|
)
|
||||||
for item in message.participants
|
for item in message.participants
|
||||||
|
if content_available
|
||||||
),
|
),
|
||||||
attachments=tuple(
|
attachments=tuple(
|
||||||
PostboxAttachmentRef(
|
PostboxAttachmentRef(
|
||||||
@@ -3088,6 +3239,7 @@ class PostboxService:
|
|||||||
metadata=_mapping(item.metadata_),
|
metadata=_mapping(item.metadata_),
|
||||||
)
|
)
|
||||||
for item in message.attachments
|
for item in message.attachments
|
||||||
|
if content_available
|
||||||
),
|
),
|
||||||
metadata=_mapping(message.metadata_),
|
metadata=_mapping(message.metadata_),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_core.core.idm import OrganizationFunctionAssignmentRef
|
||||||
|
from govoplan_core.core.postbox import PostboxActorRef
|
||||||
|
from govoplan_postbox.backend.access_decisions import (
|
||||||
|
ACCESS_DECISION_TABLE,
|
||||||
|
evaluate_postbox_access,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def assignment(
|
||||||
|
source: str = "direct",
|
||||||
|
*,
|
||||||
|
assignment_id: str | None = None,
|
||||||
|
tenant_id: str = "tenant-1",
|
||||||
|
acting_for_account_id: str | None = None,
|
||||||
|
) -> OrganizationFunctionAssignmentRef:
|
||||||
|
return OrganizationFunctionAssignmentRef(
|
||||||
|
id=assignment_id or f"{source}-assignment",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
identity_id="identity-1",
|
||||||
|
account_id="account-1",
|
||||||
|
function_id="function-1",
|
||||||
|
organization_unit_id="unit-1",
|
||||||
|
source=source, # type: ignore[arg-type]
|
||||||
|
acting_for_account_id=acting_for_account_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def decide(
|
||||||
|
*,
|
||||||
|
status: str = "active",
|
||||||
|
action: str = "read",
|
||||||
|
authorized_actions: frozenset[str] = frozenset({"read"}),
|
||||||
|
binding_available: bool = True,
|
||||||
|
assignments=(),
|
||||||
|
selected_assignment_id: str | None = None,
|
||||||
|
acting_for_account_id: str | None = None,
|
||||||
|
):
|
||||||
|
return evaluate_postbox_access(
|
||||||
|
postbox_id="postbox-1",
|
||||||
|
postbox_active=status == "active",
|
||||||
|
action=action, # type: ignore[arg-type]
|
||||||
|
actor=PostboxActorRef(
|
||||||
|
account_id="account-1",
|
||||||
|
identity_id="identity-1",
|
||||||
|
selected_assignment_id=selected_assignment_id,
|
||||||
|
acting_for_account_id=acting_for_account_id,
|
||||||
|
authorized_actions=authorized_actions, # type: ignore[arg-type]
|
||||||
|
),
|
||||||
|
organization_unit_id="unit-1" if binding_available else None,
|
||||||
|
function_id="function-1" if binding_available else None,
|
||||||
|
holder_count=len(assignments),
|
||||||
|
binding_available=binding_available,
|
||||||
|
binding_assignments=assignments,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PostboxAccessDecisionTableTests(unittest.TestCase):
|
||||||
|
def test_rule_order_is_fail_closed(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
[rule.name for rule in ACCESS_DECISION_TABLE],
|
||||||
|
[
|
||||||
|
"inactive_postbox",
|
||||||
|
"generic_permission",
|
||||||
|
"administrator",
|
||||||
|
"active_function_binding",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
inactive_admin = decide(
|
||||||
|
status="archived",
|
||||||
|
action="administer",
|
||||||
|
authorized_actions=frozenset({"administer"}),
|
||||||
|
)
|
||||||
|
self.assertFalse(inactive_admin.allowed)
|
||||||
|
self.assertEqual(inactive_admin.reason_code, "postbox_inactive")
|
||||||
|
|
||||||
|
def test_permission_and_binding_denials_have_stable_provenance(self) -> None:
|
||||||
|
missing_permission = decide(
|
||||||
|
assignments=(assignment(),),
|
||||||
|
authorized_actions=frozenset(),
|
||||||
|
)
|
||||||
|
missing_binding = decide(binding_available=False)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
missing_permission.reason_code,
|
||||||
|
"generic_permission_missing",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
missing_binding.reason_code,
|
||||||
|
"function_binding_missing",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_administration_is_generic_but_still_requires_active_postbox(self) -> None:
|
||||||
|
decision = decide(
|
||||||
|
action="administer",
|
||||||
|
authorized_actions=frozenset({"administer"}),
|
||||||
|
binding_available=False,
|
||||||
|
)
|
||||||
|
self.assertTrue(decision.allowed)
|
||||||
|
self.assertEqual(decision.reason_code, "generic_administrator")
|
||||||
|
|
||||||
|
def test_direct_delegated_directory_governance_and_system_sources_are_allowed(self) -> None:
|
||||||
|
for source in (
|
||||||
|
"direct",
|
||||||
|
"delegated",
|
||||||
|
"directory",
|
||||||
|
"governance",
|
||||||
|
"system",
|
||||||
|
):
|
||||||
|
with self.subTest(source=source):
|
||||||
|
decision = decide(assignments=(assignment(source),))
|
||||||
|
self.assertTrue(decision.allowed)
|
||||||
|
self.assertEqual(
|
||||||
|
decision.reason_code,
|
||||||
|
f"effective_{source}_assignment",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_selected_direct_context_is_preferred_without_hiding_other_matches(self) -> None:
|
||||||
|
direct = assignment("direct", assignment_id="direct-1")
|
||||||
|
delegated = assignment("delegated", assignment_id="delegated-1")
|
||||||
|
decision = decide(
|
||||||
|
assignments=(direct, delegated),
|
||||||
|
selected_assignment_id=delegated.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(decision.allowed)
|
||||||
|
self.assertEqual(decision.selected_assignment_id, delegated.id)
|
||||||
|
self.assertEqual(
|
||||||
|
decision.assignment_ids,
|
||||||
|
("direct-1", "delegated-1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_acting_access_requires_exact_assignment_and_represented_account(self) -> None:
|
||||||
|
acting = assignment(
|
||||||
|
"acting_for",
|
||||||
|
assignment_id="acting-1",
|
||||||
|
acting_for_account_id="represented-1",
|
||||||
|
)
|
||||||
|
missing_context = decide(assignments=(acting,))
|
||||||
|
wrong_assignment = decide(
|
||||||
|
assignments=(acting,),
|
||||||
|
selected_assignment_id="acting-other",
|
||||||
|
)
|
||||||
|
wrong_account = decide(
|
||||||
|
assignments=(acting,),
|
||||||
|
selected_assignment_id=acting.id,
|
||||||
|
acting_for_account_id="represented-other",
|
||||||
|
)
|
||||||
|
allowed = decide(
|
||||||
|
assignments=(acting,),
|
||||||
|
selected_assignment_id=acting.id,
|
||||||
|
acting_for_account_id="represented-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
missing_context.reason_code,
|
||||||
|
"acting_context_required",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
wrong_assignment.reason_code,
|
||||||
|
"acting_assignment_not_selected",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
wrong_account.reason_code,
|
||||||
|
"acting_account_mismatch",
|
||||||
|
)
|
||||||
|
self.assertTrue(allowed.allowed)
|
||||||
|
self.assertEqual(
|
||||||
|
allowed.reason_code,
|
||||||
|
"effective_acting_for_assignment",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_vacancy_is_provenance_not_an_implicit_access_override(self) -> None:
|
||||||
|
denied = decide(assignments=())
|
||||||
|
self.assertFalse(denied.allowed)
|
||||||
|
self.assertTrue(denied.vacant)
|
||||||
|
self.assertEqual(
|
||||||
|
denied.reason_code,
|
||||||
|
"effective_assignment_missing",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+120
-1
@@ -49,7 +49,7 @@ from govoplan_postbox.backend.db.models import (
|
|||||||
PostboxTemplate,
|
PostboxTemplate,
|
||||||
PostboxTemplateRevision,
|
PostboxTemplateRevision,
|
||||||
)
|
)
|
||||||
from govoplan_postbox.backend.service import PostboxService
|
from govoplan_postbox.backend.service import PostboxError, PostboxService
|
||||||
|
|
||||||
|
|
||||||
POSTBOX_TABLES = (
|
POSTBOX_TABLES = (
|
||||||
@@ -915,6 +915,12 @@ class PostboxServiceTests(unittest.TestCase):
|
|||||||
actor=self.actor,
|
actor=self.actor,
|
||||||
state="acknowledged",
|
state="acknowledged",
|
||||||
)
|
)
|
||||||
|
receipt_summary = service.delivery_receipt_summaries(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
producer_module="campaigns",
|
||||||
|
delivery_ids=(first.delivery_id,),
|
||||||
|
)[first.delivery_id]
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
self.assertFalse(first.duplicate)
|
self.assertFalse(first.duplicate)
|
||||||
@@ -922,6 +928,20 @@ class PostboxServiceTests(unittest.TestCase):
|
|||||||
self.assertEqual(first.message_id, second.message_id)
|
self.assertEqual(first.message_id, second.message_id)
|
||||||
self.assertIsNotNone(marked.read_at)
|
self.assertIsNotNone(marked.read_at)
|
||||||
self.assertIsNotNone(marked.acknowledged_at)
|
self.assertIsNotNone(marked.acknowledged_at)
|
||||||
|
self.assertTrue(receipt_summary.currently_readable)
|
||||||
|
self.assertEqual(1, receipt_summary.current_holder_count)
|
||||||
|
self.assertEqual(1, receipt_summary.read_receipt_count)
|
||||||
|
self.assertEqual(1, receipt_summary.acknowledged_receipt_count)
|
||||||
|
self.assertEqual(0, receipt_summary.withdrawn_message_count)
|
||||||
|
self.assertEqual(
|
||||||
|
{},
|
||||||
|
service.delivery_receipt_summaries(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
producer_module="another-module",
|
||||||
|
delivery_ids=(first.delivery_id,),
|
||||||
|
),
|
||||||
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
1,
|
1,
|
||||||
session.query(PostboxMessage).count(),
|
session.query(PostboxMessage).count(),
|
||||||
@@ -971,6 +991,95 @@ class PostboxServiceTests(unittest.TestCase):
|
|||||||
[event.type for event in events],
|
[event.type for event in events],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_withdrawn_and_expired_messages_keep_metadata_but_hide_content(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
self.idm.assignments.append(self.assignment)
|
||||||
|
with Session(self.engine) as session:
|
||||||
|
postbox = self._create_exact(session)
|
||||||
|
withdrawn = self.service.deliver(
|
||||||
|
session,
|
||||||
|
PostboxDeliveryRequest(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
target=PostboxTargetRef(postbox_id=postbox.id),
|
||||||
|
producer_module="campaigns",
|
||||||
|
producer_resource_type="campaign_recipient",
|
||||||
|
producer_resource_id="recipient-withdrawn",
|
||||||
|
idempotency_key="withdrawn-message",
|
||||||
|
subject="Withdrawn decision",
|
||||||
|
body_text="Sensitive withdrawn content",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
expired = self.service.deliver(
|
||||||
|
session,
|
||||||
|
PostboxDeliveryRequest(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
target=PostboxTargetRef(postbox_id=postbox.id),
|
||||||
|
producer_module="campaigns",
|
||||||
|
producer_resource_type="campaign_recipient",
|
||||||
|
producer_resource_id="recipient-expired",
|
||||||
|
idempotency_key="expired-message",
|
||||||
|
subject="Expired decision",
|
||||||
|
body_text="Sensitive expired content",
|
||||||
|
expires_at=utc_now() - timedelta(minutes=1),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
withdrawn_model = session.get(PostboxMessage, withdrawn.message_id)
|
||||||
|
assert withdrawn_model is not None
|
||||||
|
withdrawn_model.withdrawn_at = utc_now()
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
withdrawn_ref = self.service.get_message(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
message_id=withdrawn.message_id,
|
||||||
|
actor=self.actor,
|
||||||
|
)
|
||||||
|
expired_ref = self.service.get_message(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
message_id=expired.message_id,
|
||||||
|
actor=self.actor,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert withdrawn_ref is not None
|
||||||
|
assert expired_ref is not None
|
||||||
|
self.assertEqual("withdrawn", withdrawn_ref.availability)
|
||||||
|
self.assertIsNone(withdrawn_ref.body_text)
|
||||||
|
self.assertEqual((), withdrawn_ref.attachments)
|
||||||
|
self.assertEqual("expired", expired_ref.availability)
|
||||||
|
self.assertIsNone(expired_ref.body_text)
|
||||||
|
with self.assertRaisesRegex(PostboxError, "withdrawn"):
|
||||||
|
self.service.mark_message(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
message_id=withdrawn.message_id,
|
||||||
|
actor=self.actor,
|
||||||
|
state="read",
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(PostboxError, "expired"):
|
||||||
|
self.service.mark_message(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
message_id=expired.message_id,
|
||||||
|
actor=self.actor,
|
||||||
|
state="acknowledged",
|
||||||
|
)
|
||||||
|
|
||||||
|
summaries = self.service.delivery_receipt_summaries(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
producer_module="campaigns",
|
||||||
|
delivery_ids=(withdrawn.delivery_id, expired.delivery_id),
|
||||||
|
)
|
||||||
|
self.assertFalse(summaries[withdrawn.delivery_id].currently_readable)
|
||||||
|
self.assertEqual(
|
||||||
|
1,
|
||||||
|
summaries[withdrawn.delivery_id].withdrawn_message_count,
|
||||||
|
)
|
||||||
|
self.assertFalse(summaries[expired.delivery_id].currently_readable)
|
||||||
|
self.assertEqual(1, summaries[expired.delivery_id].expired_message_count)
|
||||||
|
|
||||||
def test_hierarchy_linked_copy_snapshots_path_and_independent_state(
|
def test_hierarchy_linked_copy_snapshots_path_and_independent_state(
|
||||||
self,
|
self,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -1023,6 +1132,16 @@ class PostboxServiceTests(unittest.TestCase):
|
|||||||
session.commit()
|
session.commit()
|
||||||
receipts = session.query(PostboxMessageReceipt).all()
|
receipts = session.query(PostboxMessageReceipt).all()
|
||||||
self.assertEqual([route.target_message_id], [item.message_id for item in receipts])
|
self.assertEqual([route.target_message_id], [item.message_id for item in receipts])
|
||||||
|
summary = service.delivery_receipt_summaries(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
producer_module="campaigns",
|
||||||
|
delivery_ids=(result.delivery_id,),
|
||||||
|
)[result.delivery_id]
|
||||||
|
self.assertEqual(2, summary.message_count)
|
||||||
|
self.assertEqual(1, summary.routed_message_count)
|
||||||
|
self.assertEqual(1, summary.read_receipt_count)
|
||||||
|
self.assertEqual({"accepted": 1}, summary.route_status_counts)
|
||||||
|
|
||||||
def test_hierarchy_dry_run_explains_gates_depth_and_parallel_structure(
|
def test_hierarchy_dry_run_explains_gates_depth_and_parallel_structure(
|
||||||
self,
|
self,
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/postbox-webui",
|
"name": "@govoplan/postbox-webui",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export type PostboxMessage = {
|
|||||||
subject: string;
|
subject: string;
|
||||||
body_text?: string | null;
|
body_text?: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
|
availability: "available" | "withdrawn" | "expired";
|
||||||
classification: string;
|
classification: string;
|
||||||
sender_label?: string | null;
|
sender_label?: string | null;
|
||||||
delivered_at: string;
|
delivered_at: string;
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
StatusBadge,
|
StatusBadge,
|
||||||
ToggleSwitch,
|
ToggleSwitch,
|
||||||
hasScope,
|
hasScope,
|
||||||
|
isApiError,
|
||||||
type ApiSettings,
|
type ApiSettings,
|
||||||
type AuthInfo
|
type AuthInfo
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
@@ -80,6 +81,7 @@ export default function PostboxPage({
|
|||||||
const [messages, setMessages] = useState<PostboxMessage[]>([]);
|
const [messages, setMessages] = useState<PostboxMessage[]>([]);
|
||||||
const [selectedMessageId, setSelectedMessageId] = useState("");
|
const [selectedMessageId, setSelectedMessageId] = useState("");
|
||||||
const [selectedMessage, setSelectedMessage] = useState<PostboxMessage | null>(null);
|
const [selectedMessage, setSelectedMessage] = useState<PostboxMessage | null>(null);
|
||||||
|
const [unavailableSelection, setUnavailableSelection] = useState("");
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [messageState, setMessageState] = useState<MessageStateFilter>("all");
|
const [messageState, setMessageState] = useState<MessageStateFilter>("all");
|
||||||
const [searchDraft, setSearchDraft] = useState("");
|
const [searchDraft, setSearchDraft] = useState("");
|
||||||
@@ -145,6 +147,7 @@ export default function PostboxPage({
|
|||||||
setMessages([]);
|
setMessages([]);
|
||||||
setSelectedMessageId("");
|
setSelectedMessageId("");
|
||||||
setSelectedMessage(null);
|
setSelectedMessage(null);
|
||||||
|
setUnavailableSelection("");
|
||||||
setTotal(0);
|
setTotal(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -203,15 +206,28 @@ export default function PostboxPage({
|
|||||||
const message = await getPostboxMessage(settings, messageId);
|
const message = await getPostboxMessage(settings, messageId);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (!postboxes.some((postbox) => postbox.id === message.postbox_id)) {
|
if (!postboxes.some((postbox) => postbox.id === message.postbox_id)) {
|
||||||
setError("The linked message is not available in your current Postbox assignments.");
|
setSelectedMessageId(messageId);
|
||||||
|
setSelectedMessage(null);
|
||||||
|
setUnavailableSelection(
|
||||||
|
"This message is outside your current Postbox assignments."
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSelectedScope("all");
|
setSelectedScope("all");
|
||||||
setSelectedPostboxId(message.postbox_id);
|
setSelectedPostboxId(message.postbox_id);
|
||||||
setSelectedMessageId(message.id);
|
setSelectedMessageId(message.id);
|
||||||
setSelectedMessage(message);
|
setSelectedMessage(message);
|
||||||
|
setUnavailableSelection("");
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
if (!cancelled) setError(errorMessage(loadError));
|
if (!cancelled && isApiError(loadError, 403, 404)) {
|
||||||
|
setSelectedMessageId(messageId);
|
||||||
|
setSelectedMessage(null);
|
||||||
|
setUnavailableSelection(
|
||||||
|
"This message is no longer available or is outside your current Postbox assignments."
|
||||||
|
);
|
||||||
|
} else if (!cancelled) {
|
||||||
|
setError(errorMessage(loadError));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
@@ -222,10 +238,11 @@ export default function PostboxPage({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedMessageId) return;
|
if (!selectedMessageId) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
setUnavailableSelection("");
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
let message = await getPostboxMessage(settings, selectedMessageId);
|
let message = await getPostboxMessage(settings, selectedMessageId);
|
||||||
if (!message.read_at) {
|
if (message.availability === "available" && !message.read_at) {
|
||||||
message = await markPostboxMessage(settings, selectedMessageId, "read");
|
message = await markPostboxMessage(settings, selectedMessageId, "read");
|
||||||
}
|
}
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -234,7 +251,14 @@ export default function PostboxPage({
|
|||||||
items.map((item) => (item.id === message.id ? message : item))
|
items.map((item) => (item.id === message.id ? message : item))
|
||||||
);
|
);
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
if (!cancelled) setError(errorMessage(loadError));
|
if (!cancelled && isApiError(loadError, 403, 404)) {
|
||||||
|
setSelectedMessage(null);
|
||||||
|
setUnavailableSelection(
|
||||||
|
"This message is no longer available or is outside your current Postbox assignments."
|
||||||
|
);
|
||||||
|
} else if (!cancelled) {
|
||||||
|
setError(errorMessage(loadError));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
@@ -243,7 +267,13 @@ export default function PostboxPage({
|
|||||||
}, [selectedMessageId, settings]);
|
}, [selectedMessageId, settings]);
|
||||||
|
|
||||||
async function acknowledgeSelected() {
|
async function acknowledgeSelected() {
|
||||||
if (!selectedMessage || !canAcknowledge) return;
|
if (
|
||||||
|
!selectedMessage ||
|
||||||
|
selectedMessage.availability !== "available" ||
|
||||||
|
!canAcknowledge
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
@@ -268,6 +298,7 @@ export default function PostboxPage({
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
setSelectedMessageId("");
|
setSelectedMessageId("");
|
||||||
setSelectedMessage(null);
|
setSelectedMessage(null);
|
||||||
|
setUnavailableSelection("");
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectScope(scopeId: string) {
|
function selectScope(scopeId: string) {
|
||||||
@@ -276,6 +307,7 @@ export default function PostboxPage({
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
setSelectedMessageId("");
|
setSelectedMessageId("");
|
||||||
setSelectedMessage(null);
|
setSelectedMessage(null);
|
||||||
|
setUnavailableSelection("");
|
||||||
}
|
}
|
||||||
|
|
||||||
function openNewGrouping() {
|
function openNewGrouping() {
|
||||||
@@ -544,6 +576,12 @@ export default function PostboxPage({
|
|||||||
{message.acknowledged_at ? (
|
{message.acknowledged_at ? (
|
||||||
<span><CheckCheck size={13} /> Acknowledged</span>
|
<span><CheckCheck size={13} /> Acknowledged</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
{message.availability !== "available" ? (
|
||||||
|
<StatusBadge
|
||||||
|
status="warning"
|
||||||
|
label={message.availability === "withdrawn" ? "Withdrawn" : "Expired"}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
</SelectionListItem>
|
</SelectionListItem>
|
||||||
))}
|
))}
|
||||||
@@ -579,8 +617,20 @@ export default function PostboxPage({
|
|||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => void acknowledgeSelected()}
|
onClick={() => void acknowledgeSelected()}
|
||||||
disabled={!selectedMessage || Boolean(selectedMessage.acknowledged_at) || busy}
|
disabled={
|
||||||
disabledReason={!canAcknowledge ? "You cannot acknowledge Postbox messages." : undefined}
|
!selectedMessage ||
|
||||||
|
selectedMessage.availability !== "available" ||
|
||||||
|
Boolean(selectedMessage.acknowledged_at) ||
|
||||||
|
busy
|
||||||
|
}
|
||||||
|
disabledReason={
|
||||||
|
!canAcknowledge
|
||||||
|
? "You cannot acknowledge Postbox messages."
|
||||||
|
: selectedMessage &&
|
||||||
|
selectedMessage.availability !== "available"
|
||||||
|
? "Withdrawn or expired messages cannot be acknowledged."
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<CheckCheck size={16} /> Acknowledge
|
<CheckCheck size={16} /> Acknowledge
|
||||||
</Button>
|
</Button>
|
||||||
@@ -590,6 +640,12 @@ export default function PostboxPage({
|
|||||||
message={selectedMessage}
|
message={selectedMessage}
|
||||||
postbox={postboxes.find((item) => item.id === selectedMessage.postbox_id)}
|
postbox={postboxes.find((item) => item.id === selectedMessage.postbox_id)}
|
||||||
/>
|
/>
|
||||||
|
) : unavailableSelection ? (
|
||||||
|
<div className="postbox-empty postbox-unavailable-message">
|
||||||
|
<Archive size={24} />
|
||||||
|
<strong>Message unavailable</strong>
|
||||||
|
<p>{unavailableSelection}</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="postbox-empty">
|
<div className="postbox-empty">
|
||||||
<Inbox size={24} />
|
<Inbox size={24} />
|
||||||
@@ -694,6 +750,17 @@ function MessageDetail({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="postbox-message-detail">
|
<div className="postbox-message-detail">
|
||||||
|
{message.availability !== "available" ? (
|
||||||
|
<DismissibleAlert
|
||||||
|
tone="warning"
|
||||||
|
compact
|
||||||
|
resetKey={`${message.id}:${message.availability}`}
|
||||||
|
>
|
||||||
|
{message.availability === "withdrawn"
|
||||||
|
? "This message was withdrawn. Its audit metadata remains visible, but its content and actions are unavailable."
|
||||||
|
: "This message has expired. Its audit metadata remains visible, but its content and actions are unavailable."}
|
||||||
|
</DismissibleAlert>
|
||||||
|
) : null}
|
||||||
<header>
|
<header>
|
||||||
<div className="postbox-detail-status">
|
<div className="postbox-detail-status">
|
||||||
<StatusBadge status={message.status} />
|
<StatusBadge status={message.status} />
|
||||||
|
|||||||
Reference in New Issue
Block a user