feat: complete postbox access evidence
This commit is contained in:
@@ -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_NAME = "Postbox"
|
||||
MODULE_VERSION = "0.1.1"
|
||||
MODULE_VERSION = "0.1.2"
|
||||
|
||||
READ_SCOPE = "postbox:postbox:read"
|
||||
SEND_SCOPE = "postbox:message:write"
|
||||
|
||||
@@ -69,6 +69,7 @@ class PostboxMessageItem(BaseModel):
|
||||
subject: str
|
||||
body_text: str | None = None
|
||||
status: str
|
||||
availability: Literal["available", "withdrawn", "expired"]
|
||||
classification: str
|
||||
sender_label: str | None = None
|
||||
delivered_at: datetime
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
from collections import Counter
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Literal
|
||||
@@ -48,11 +49,13 @@ from govoplan_core.core.postbox import (
|
||||
PostboxActorRef,
|
||||
PostboxAttachmentRef,
|
||||
PostboxDeliveryCatalogRef,
|
||||
PostboxDeliveryReceiptSummaryRef,
|
||||
PostboxDeliveryRequest,
|
||||
PostboxDeliveryRejected,
|
||||
PostboxDeliveryResult,
|
||||
PostboxDeliveryTemplateRef,
|
||||
PostboxDirectoryEntryRef,
|
||||
PostboxMessageAvailability,
|
||||
PostboxMessageRef,
|
||||
PostboxMessageListState,
|
||||
PostboxOrganizationFunctionTargetRef,
|
||||
@@ -84,6 +87,7 @@ from govoplan_postbox.backend.hierarchy_routing import (
|
||||
normalized_routing_policy,
|
||||
plan_hierarchy_routes,
|
||||
)
|
||||
from govoplan_postbox.backend.access_decisions import evaluate_postbox_access
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -123,6 +127,28 @@ def _as_utc(value: datetime) -> datetime:
|
||||
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(
|
||||
session: Session,
|
||||
event_type: str,
|
||||
@@ -651,6 +677,12 @@ class PostboxService:
|
||||
)
|
||||
if not decision.allowed:
|
||||
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 = (
|
||||
db.query(PostboxMessageReceipt)
|
||||
.filter(
|
||||
@@ -1918,6 +1950,189 @@ class PostboxService:
|
||||
)
|
||||
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
|
||||
def list_admin_postboxes(
|
||||
self,
|
||||
@@ -2825,92 +3040,24 @@ class PostboxService:
|
||||
)
|
||||
holders = self._holders(postbox.tenant_id, function_id, holder_cache)
|
||||
holder_count = len({holder.identity_id for holder in holders})
|
||||
base = {
|
||||
"action": action,
|
||||
"postbox_id": postbox.id,
|
||||
"organization_unit_id": unit_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:
|
||||
binding_assignments: list[OrganizationFunctionAssignmentRef] = []
|
||||
if binding is not None:
|
||||
for assignment in assignments:
|
||||
if assignment.tenant_id != postbox.tenant_id:
|
||||
continue
|
||||
if (
|
||||
assignment.acting_for_account_id
|
||||
and actor.acting_for_account_id
|
||||
!= assignment.acting_for_account_id
|
||||
):
|
||||
if not self._assignment_matches_binding(assignment, binding):
|
||||
continue
|
||||
matching.append(assignment)
|
||||
if not matching:
|
||||
return PostboxAccessDecisionRef(
|
||||
allowed=False,
|
||||
reason_code="effective_assignment_missing",
|
||||
explanation=(
|
||||
"No current function assignment grants this account access "
|
||||
"to the Postbox."
|
||||
),
|
||||
**base,
|
||||
)
|
||||
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,
|
||||
binding_assignments.append(assignment)
|
||||
return evaluate_postbox_access(
|
||||
postbox_id=postbox.id,
|
||||
postbox_active=postbox.status == "active",
|
||||
action=action,
|
||||
actor=actor,
|
||||
organization_unit_id=unit_id,
|
||||
function_id=function_id,
|
||||
holder_count=holder_count,
|
||||
binding_available=binding is not None,
|
||||
binding_assignments=binding_assignments,
|
||||
)
|
||||
|
||||
def _assignment_matches_binding(
|
||||
@@ -3046,13 +3193,16 @@ class PostboxService:
|
||||
),
|
||||
None,
|
||||
)
|
||||
availability = _message_availability(message)
|
||||
content_available = availability == "available"
|
||||
return PostboxMessageRef(
|
||||
id=message.id,
|
||||
tenant_id=message.tenant_id,
|
||||
postbox_id=message.postbox_id,
|
||||
subject=message.subject,
|
||||
body_text=message.body_text,
|
||||
body_text=message.body_text if content_available else None,
|
||||
status=message.status,
|
||||
availability=availability,
|
||||
classification=message.classification,
|
||||
sender_label=message.sender_label,
|
||||
delivered_at=message.delivered_at,
|
||||
@@ -3076,6 +3226,7 @@ class PostboxService:
|
||||
address=item.address,
|
||||
)
|
||||
for item in message.participants
|
||||
if content_available
|
||||
),
|
||||
attachments=tuple(
|
||||
PostboxAttachmentRef(
|
||||
@@ -3088,6 +3239,7 @@ class PostboxService:
|
||||
metadata=_mapping(item.metadata_),
|
||||
)
|
||||
for item in message.attachments
|
||||
if content_available
|
||||
),
|
||||
metadata=_mapping(message.metadata_),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user