10 Commits
Author SHA1 Message Date
zemion 713f2d3c63 docs(notifications): complete German reference coverage
Module Package Release / publish-packages (push) Successful in 11s
2026-08-23 19:31:22 +02:00
zemion 975d90b012 feat(notifications): add governed DSAR coverage 2026-08-21 04:08:21 +02:00
zemion 5c28ac5094 fix(notifications): enforce personal source mutes 2026-08-20 04:58:34 +02:00
zemion f9f7ab75d3 refactor(webui): adopt semantic workspace actions 2026-08-19 18:47:45 +02:00
zemion da0960967d feat: align notifications with shared UI foundations 2026-08-18 21:32:42 +02:00
zemion e4d6dff1d7 Adopt shared WebUI structural primitives 2026-08-18 13:17:31 +02:00
zemion 178888c7ac Adopt shared WebUI layout primitives 2026-08-18 11:30:40 +02:00
zemion bf0ebe1541 Adopt shared WebUI layout primitives 2026-08-18 10:42:52 +02:00
zemion 09205374f9 Release v0.1.18
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 21:07:52 +02:00
zemion 239664092a Release v0.1.17
Module Package Release / publish-packages (push) Successful in 11s
2026-08-05 20:34:13 +02:00
13 changed files with 1608 additions and 172 deletions
+2 -2
View File
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-notifications"
version = "0.1.16"
version = "0.1.19"
description = "GovOPlaN notification inbox and delivery module."
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.16",
"govoplan-core>=0.1.18",
]
[tool.setuptools.packages.find]
@@ -0,0 +1,655 @@
from __future__ import annotations
import math
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime, timezone
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarExecutionResultRef,
DsarRecordRef,
DsarSubjectRef,
dsar_capability_name,
)
from govoplan_notifications.backend.db.models import (
NotificationMessage,
NotificationPreference,
)
NOTIFICATIONS_DSAR_CAPABILITY = dsar_capability_name("notifications")
_MAX_RECORDS = 1_000
_MAX_ATTEMPTS_PER_MESSAGE = 100
_MAX_BODY_CHARS = 100_000
_MAX_JSON_NODES = 10_000
_MAX_JSON_CHARS = 100_000
_CONFLICT = object()
_SENSITIVE_KEY_PARTS = (
"authorization",
"cookie",
"credential",
"password",
"secret",
"token",
"api_key",
"apikey",
)
@dataclass(frozen=True, slots=True)
class _SubjectSelectors:
actor_ids: tuple[str, ...]
email: str | None
notification_id: str | None
preference_id: str | None
@dataclass(slots=True)
class _JsonBudget:
nodes: int = 0
characters: int = 0
class NotificationsDsarProvider:
provider_id = "notifications"
module_id = "notifications"
def search_subject(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
) -> Sequence[DsarRecordRef]:
db = _session(session)
selectors = _subject_selectors(subject)
if selectors is None:
return ()
records: list[DsarRecordRef] = []
if selectors.preference_id is None:
messages = db.query(NotificationMessage).filter(
NotificationMessage.tenant_id == tenant_id,
_recipient_filter(selectors),
)
if selectors.notification_id:
messages = messages.filter(
NotificationMessage.id == selectors.notification_id
)
rows = (
messages.order_by(
NotificationMessage.created_at,
NotificationMessage.id,
)
.limit(_MAX_RECORDS + 1)
.all()
)
if len(rows) > _MAX_RECORDS:
raise ValueError(
"Notifications DSAR result limit exceeded; narrow the selectors."
)
records.extend(_message_record(row) for row in rows)
if selectors.notification_id is None and selectors.actor_ids:
preferences = db.query(NotificationPreference).filter(
NotificationPreference.tenant_id == tenant_id,
NotificationPreference.user_id.in_(selectors.actor_ids),
)
if selectors.preference_id:
preferences = preferences.filter(
NotificationPreference.id == selectors.preference_id
)
rows = (
preferences.order_by(NotificationPreference.id)
.limit(_MAX_RECORDS + 1)
.all()
)
if len(rows) > _MAX_RECORDS:
raise ValueError(
"Notification preference DSAR result limit exceeded; narrow the selectors."
)
records.extend(_preference_record(row) for row in rows)
if len(records) > _MAX_RECORDS:
raise ValueError(
"Notifications DSAR combined result limit exceeded; narrow the selectors."
)
order = {"notification_preference": 10, "notification_message": 20}
return tuple(
sorted(
records,
key=lambda item: (order[item.resource_type], item.resource_id),
)
)
def plan_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
records: Sequence[DsarRecordRef],
) -> Sequence[DsarErasureActionRef]:
db = _session(session)
selectors = _subject_selectors(subject)
if selectors is None:
raise ValueError("Notifications DSAR subject selectors conflict.")
actions: list[DsarErasureActionRef] = []
for record in records:
_validate_record(record)
if record.resource_type == "notification_preference":
preference = (
db.query(NotificationPreference)
.filter(
NotificationPreference.tenant_id == tenant_id,
NotificationPreference.id == record.resource_id,
)
.one_or_none()
)
if (
preference is None
or preference.user_id not in selectors.actor_ids
or (
selectors.preference_id
and preference.id != selectors.preference_id
)
):
actions.append(
_manual_action(record, "preference ownership mismatch")
)
continue
actions.append(_delete_action(record, preference.updated_at))
continue
notification = (
db.query(NotificationMessage)
.filter(
NotificationMessage.tenant_id == tenant_id,
NotificationMessage.id == record.resource_id,
_recipient_filter(selectors),
)
.one_or_none()
)
if notification is None or (
selectors.notification_id
and notification.id != selectors.notification_id
):
actions.append(_manual_action(record, "recipient or selector mismatch"))
elif notification.channel == "inbox":
actions.append(_delete_action(record, notification.updated_at))
else:
actions.append(
_manual_action(
record,
"external-channel delivery and provider evidence require retention review",
)
)
return tuple(actions)
def execute_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
actions: Sequence[DsarErasureActionRef],
request_id: str,
) -> Sequence[DsarExecutionResultRef]:
db = _session(session)
selectors = _subject_selectors(subject)
if selectors is None:
raise ValueError("Notifications DSAR subject selectors conflict.")
results: list[DsarExecutionResultRef] = []
for action in actions:
_validate_action(action)
if not action.executable or action.kind != "delete":
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary=(
"External notification delivery evidence requires an "
"authorized retention review."
),
)
)
continue
if action.resource_type == "notification_preference":
row = (
db.query(NotificationPreference)
.filter(
NotificationPreference.tenant_id == tenant_id,
NotificationPreference.id == action.resource_id,
)
.one_or_none()
)
if row is None:
results.append(_unchanged(action, request_id, "preference"))
continue
if (
row.user_id not in selectors.actor_ids
or str(action.metadata.get("owner_id") or "") != row.user_id
or (selectors.preference_id and row.id != selectors.preference_id)
):
results.append(_blocked(action, "preference ownership mismatch"))
continue
elif action.resource_type == "notification_message":
row = (
db.query(NotificationMessage)
.filter(
NotificationMessage.tenant_id == tenant_id,
NotificationMessage.id == action.resource_id,
)
.one_or_none()
)
if row is None:
results.append(_unchanged(action, request_id, "notification"))
continue
if (
row.channel != "inbox"
or not _recipient_matches(row, selectors)
or (
selectors.notification_id
and row.id != selectors.notification_id
)
):
results.append(_blocked(action, "recipient or channel mismatch"))
continue
else:
raise ValueError("Unsupported executable Notifications DSAR action.")
if action.metadata.get("updated_at") != _iso(row.updated_at):
results.append(_blocked(action, "the record changed after planning"))
continue
resource_id = str(row.id)
db.delete(row)
db.flush()
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="executed",
summary=(
"Deleted the personal Notifications record without "
"traversing or changing its source-module data."
),
evidence={
"request_id": request_id,
"resource_type": action.resource_type,
"resource_id": resource_id,
},
)
)
return tuple(results)
def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None:
references = subject.external_references
values = {
"account_id": _coalesce(
subject.account_id,
references.get("notifications.account"),
references.get("access.account"),
),
"membership_id": _coalesce(
subject.membership_id,
references.get("notifications.membership"),
references.get("tenancy.membership"),
),
"identity_id": _coalesce(
subject.identity_id,
references.get("notifications.identity"),
references.get("identity.id"),
),
"user_id": _coalesce(
references.get("notifications.user"),
references.get("idm.user"),
),
"email": _coalesce(
_normalized_email(subject.email),
_normalized_email(references.get("notifications.email")),
),
"notification_id": _coalesce(
references.get("notifications.notification"),
references.get("notifications.message"),
),
"preference_id": _coalesce(
references.get("notifications.preference"),
references.get("notifications.preference_id"),
),
}
if any(value is _CONFLICT for value in values.values()):
return None
actor_ids = tuple(
dict.fromkeys(
value
for key in ("account_id", "membership_id", "identity_id", "user_id")
if (value := _optional_string(values[key]))
)
)
email = _optional_string(values["email"])
if not actor_ids and email is None:
return None
return _SubjectSelectors(
actor_ids=actor_ids,
email=email,
notification_id=_optional_string(values["notification_id"]),
preference_id=_optional_string(values["preference_id"]),
)
def _coalesce(*values: str | None) -> str | None | object:
normalized = {str(value).strip() for value in values if str(value or "").strip()}
if len(normalized) > 1:
return _CONFLICT
return next(iter(normalized), None)
def _optional_string(value: object) -> str | None:
return value if isinstance(value, str) and value else None
def _normalized_email(value: str | None) -> str | None:
normalized = (value or "").strip().casefold()
return normalized or None
def _recipient_filter(selectors: _SubjectSelectors):
filters = []
if selectors.actor_ids:
filters.append(NotificationMessage.recipient_id.in_(selectors.actor_ids))
if selectors.email:
filters.extend(
(
func.lower(NotificationMessage.recipient) == selectors.email,
func.lower(NotificationMessage.recipient_id) == selectors.email,
)
)
return or_(*filters)
def _recipient_matches(
notification: NotificationMessage,
selectors: _SubjectSelectors,
) -> bool:
if notification.recipient_id in selectors.actor_ids:
return True
return bool(
selectors.email
and selectors.email
in {
_normalized_email(notification.recipient),
_normalized_email(notification.recipient_id),
}
)
def _message_record(notification: NotificationMessage) -> DsarRecordRef:
attempts = list(notification.attempts)
if len(attempts) > _MAX_ATTEMPTS_PER_MESSAGE:
raise ValueError(
"Notifications DSAR delivery-attempt limit exceeded; narrow the selectors."
)
return DsarRecordRef(
provider_id="notifications",
module_id="notifications",
resource_type="notification_message",
resource_id=notification.id,
category="direct_notification",
title=f"Notification: {(notification.subject or notification.event_kind)[:200]}",
data={
"source_module": notification.source_module,
"source_resource_type": notification.source_resource_type,
"source_resource_id": notification.source_resource_id,
"event_kind": notification.event_kind,
"channel": notification.channel,
"recipient": notification.recipient,
"recipient_type": notification.recipient_type,
"recipient_id": notification.recipient_id,
"recipient_label": notification.recipient_label,
"subject": notification.subject,
"body_text": _bounded_text(notification.body_text, "body text"),
"body_html": _bounded_text(notification.body_html, "body HTML"),
"action_url": notification.action_url,
"priority": notification.priority,
"status": notification.status,
"not_before_at": _iso(notification.not_before_at),
"queued_at": _iso(notification.queued_at),
"sent_at": _iso(notification.sent_at),
"failed_at": _iso(notification.failed_at),
"read_at": _iso(notification.read_at),
"acknowledged_at": _iso(notification.acknowledged_at),
"cancelled_at": _iso(notification.cancelled_at),
"attempt_count": notification.attempt_count,
"last_error": _bounded_text(notification.last_error, "last error"),
"external_message_id": notification.external_message_id,
"payload": _bounded_json(notification.payload, label="payload"),
"metadata": _bounded_json(notification.metadata_, label="metadata"),
"deleted_at": _iso(notification.deleted_at),
"attempts": [
{
"id": attempt.id,
"attempt_no": attempt.attempt_no,
"channel": attempt.channel,
"provider": attempt.provider,
"status": attempt.status,
"started_at": _iso(attempt.started_at),
"finished_at": _iso(attempt.finished_at),
"external_message_id": attempt.external_message_id,
"error": _bounded_text(attempt.error, "attempt error"),
"details": _bounded_json(
attempt.details,
label="attempt details",
),
}
for attempt in attempts
],
},
observed_at=_aware(notification.updated_at),
retention_reason=(
"External-channel messages require delivery and source-record retention review."
if notification.channel != "inbox"
else None
),
)
def _preference_record(preference: NotificationPreference) -> DsarRecordRef:
muted = preference.muted_source_modules
if not isinstance(muted, list) or len(muted) > 500:
raise ValueError("Notification muted-source preference exceeds the DSAR bound.")
return DsarRecordRef(
provider_id="notifications",
module_id="notifications",
resource_type="notification_preference",
resource_id=preference.id,
category="personal_communication_preference",
title="Personal notification preferences",
data={
"user_id": preference.user_id,
"show_unread_badge": preference.show_unread_badge,
"email_enabled": preference.email_enabled,
"email_digest_enabled": preference.email_digest_enabled,
"muted_source_modules": [str(value)[:100] for value in muted],
"metadata": _bounded_json(
preference.metadata_,
label="preference metadata",
),
},
observed_at=_aware(preference.updated_at),
)
def _bounded_text(value: str | None, label: str) -> str | None:
if value is None:
return None
if len(value) > _MAX_BODY_CHARS:
raise ValueError(
f"Notification {label} exceeds the DSAR export bound; narrow and review the record."
)
return value
def _bounded_json(value: object, *, label: str) -> object:
budget = _JsonBudget()
def project(item: object, depth: int) -> object:
budget.nodes += 1
if budget.nodes > _MAX_JSON_NODES or depth > 12:
raise ValueError(f"Notification {label} exceeds the DSAR structure bound.")
if item is None or isinstance(item, (bool, int)):
return item
if isinstance(item, float):
if not math.isfinite(item):
raise ValueError(f"Notification {label} contains a non-finite number.")
return item
if isinstance(item, str):
budget.characters += len(item)
if budget.characters > _MAX_JSON_CHARS:
raise ValueError(
f"Notification {label} exceeds the DSAR character bound."
)
return item
if isinstance(item, Mapping):
if len(item) > 1_000:
raise ValueError(f"Notification {label} contains too many fields.")
projected: dict[str, object] = {}
for raw_key, raw_value in item.items():
key = str(raw_key)
if len(key) > 500:
raise ValueError(f"Notification {label} contains an oversized key.")
budget.characters += len(key)
if budget.characters > _MAX_JSON_CHARS:
raise ValueError(
f"Notification {label} exceeds the DSAR character bound."
)
projected[key] = (
"[redacted]"
if _is_sensitive_key(key)
else project(raw_value, depth + 1)
)
return projected
if isinstance(item, (list, tuple)):
if len(item) > 1_000:
raise ValueError(f"Notification {label} contains too many items.")
return [project(entry, depth + 1) for entry in item]
raise ValueError(f"Notification {label} contains an unsupported value.")
return project(value, 0)
def _is_sensitive_key(value: str) -> bool:
normalized = value.strip().casefold().replace("-", "_")
return any(part in normalized for part in _SENSITIVE_KEY_PARTS)
def _delete_action(
record: DsarRecordRef,
updated_at: datetime | None,
) -> DsarErasureActionRef:
metadata: dict[str, object] = {"updated_at": _iso(updated_at)}
if record.resource_type == "notification_preference":
metadata["owner_id"] = str(record.data.get("user_id") or "")
return DsarErasureActionRef(
action_id=f"notifications:delete:{record.resource_type}:{record.resource_id}",
provider_id="notifications",
module_id="notifications",
kind="delete",
resource_type=record.resource_type,
resource_id=record.resource_id,
title=f"Delete {record.title}",
rationale=(
"The record is a subject-owned preference or derived in-product "
"notification copy; source-module data remains unchanged."
),
executable=True,
irreversible=True,
metadata=metadata,
)
def _manual_action(record: DsarRecordRef, reason: str) -> DsarErasureActionRef:
return DsarErasureActionRef(
action_id=(
f"notifications:manual_review:{record.resource_type}:{record.resource_id}"
),
provider_id="notifications",
module_id="notifications",
kind="manual_review",
resource_type=record.resource_type,
resource_id=record.resource_id,
title=f"Review {record.title}",
rationale=f"Automatic deletion stopped because {reason}.",
executable=False,
)
def _unchanged(
action: DsarErasureActionRef,
request_id: str,
label: str,
) -> DsarExecutionResultRef:
return DsarExecutionResultRef(
action_id=action.action_id,
status="unchanged",
summary=f"The personal notification {label} was already absent.",
evidence={"request_id": request_id},
)
def _blocked(
action: DsarErasureActionRef,
reason: str,
) -> DsarExecutionResultRef:
return DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary=f"Notifications erasure stopped because of {reason}.",
)
def _iso(value: datetime | None) -> str | None:
aware = _aware(value)
return aware.isoformat() if aware else None
def _aware(value: datetime | None) -> datetime | None:
if value is None or value.tzinfo is not None:
return value
return value.replace(tzinfo=timezone.utc)
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Notifications DSAR requires a SQLAlchemy Session.")
return value
def _validate_record(record: DsarRecordRef) -> None:
if record.provider_id != "notifications" or record.module_id != "notifications":
raise ValueError("Notifications DSAR cannot plan a foreign provider record.")
if (
record.resource_type
not in {
"notification_message",
"notification_preference",
}
or not record.resource_id
):
raise ValueError("Notifications DSAR record identity is invalid.")
def _validate_action(action: DsarErasureActionRef) -> None:
if action.provider_id != "notifications" or action.module_id != "notifications":
raise ValueError("Notifications DSAR cannot execute a foreign provider action.")
if not action.action_id.startswith("notifications:"):
raise ValueError("Notifications DSAR action identity is invalid.")
__all__ = ["NOTIFICATIONS_DSAR_CAPABILITY", "NotificationsDsarProvider"]
+260 -16
View File
@@ -2,9 +2,17 @@ from __future__ import annotations
from pathlib import Path
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
from govoplan_core.core.access import (
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
)
from govoplan_core.core.module_guards import (
drop_table_retirement_provider,
persistent_table_uninstall_guard,
)
from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationCondition,
DocumentationTopic,
FrontendModule,
FrontendRoute,
@@ -13,6 +21,7 @@ from govoplan_core.core.modules import (
ModuleInterfaceProvider,
ModuleManifest,
PermissionDefinition,
ProductAreaContribution,
RoleTemplate,
)
from govoplan_core.core.provider_governance import declared_module_architecture
@@ -20,11 +29,15 @@ from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_notifications.backend.db import models as notification_models # noqa: F401 - populate Notifications ORM metadata
from govoplan_notifications.backend.dsar_provider import (
NOTIFICATIONS_DSAR_CAPABILITY,
NotificationsDsarProvider,
)
MODULE_ID = "notifications"
MODULE_NAME = "Notifications"
MODULE_VERSION = "0.1.16"
MODULE_VERSION = "0.1.19"
READ_SCOPE = "notifications:notification:read"
WRITE_SCOPE = "notifications:notification:write"
DISPATCH_SCOPE = "notifications:delivery:dispatch"
@@ -46,10 +59,26 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
PERMISSIONS = (
_permission(READ_SCOPE, "View notifications", "Read the authenticated actor's notification inbox and delivery state."),
_permission(WRITE_SCOPE, "Manage notifications", "Create, update, cancel, read, and acknowledge notifications."),
_permission(DISPATCH_SCOPE, "Dispatch notifications", "Run notification delivery attempts and delivery workers."),
_permission(ADMIN_SCOPE, "Administer notifications", "Explicitly read and manage tenant-wide notification delivery state."),
_permission(
READ_SCOPE,
"View notifications",
"Read the authenticated actor's notification inbox and delivery state.",
),
_permission(
WRITE_SCOPE,
"Manage notifications",
"Create, update, cancel, read, and acknowledge notifications.",
),
_permission(
DISPATCH_SCOPE,
"Dispatch notifications",
"Run notification delivery attempts and delivery workers.",
),
_permission(
ADMIN_SCOPE,
"Administer notifications",
"Explicitly read and manage tenant-wide notification delivery state.",
),
)
ROLE_TEMPLATES = (
@@ -72,8 +101,19 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
from govoplan_notifications.backend.db.models import NotificationMessage
return {
"notifications": session.query(NotificationMessage).filter(NotificationMessage.tenant_id == tenant_id, NotificationMessage.deleted_at.is_(None)).count(),
"pending_notifications": session.query(NotificationMessage).filter(NotificationMessage.tenant_id == tenant_id, NotificationMessage.status.in_(("pending", "queued", "failed")), NotificationMessage.deleted_at.is_(None)).count(),
"notifications": session.query(NotificationMessage)
.filter(
NotificationMessage.tenant_id == tenant_id,
NotificationMessage.deleted_at.is_(None),
)
.count(),
"pending_notifications": session.query(NotificationMessage)
.filter(
NotificationMessage.tenant_id == tenant_id,
NotificationMessage.status.in_(("pending", "queued", "failed")),
NotificationMessage.deleted_at.is_(None),
)
.count(),
}
@@ -83,28 +123,140 @@ def _notifications_router(_context: ModuleContext):
return router
def _dsar_provider(_context: ModuleContext) -> NotificationsDsarProvider:
return NotificationsDsarProvider()
manifest = ModuleManifest(
id=MODULE_ID,
name=MODULE_NAME,
version=MODULE_VERSION,
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=("mail", "tasks", "portal", "workflow_engine", "calendar", "scheduling"),
provides_interfaces=(ModuleInterfaceProvider(name="notifications.dispatch", version="0.1.8"),),
required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
),
optional_dependencies=(
"mail",
"tasks",
"portal",
"workflow_engine",
"calendar",
"scheduling",
),
provides_interfaces=(
ModuleInterfaceProvider(name="notifications.dispatch", version="0.1.8"),
ModuleInterfaceProvider(
name=NOTIFICATIONS_DSAR_CAPABILITY,
version="0.1.0",
),
),
permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES,
route_factory=_notifications_router,
tenant_summary_providers=(_tenant_summary,),
documentation=(
DocumentationTopic(
id="notifications.data-subject-requests",
title="Notification data-subject requests",
summary=(
"Export subject-addressed notifications and preferences, delete "
"personal inbox copies, and review external delivery evidence."
),
body=(
"Notifications correlates verified account, membership, identity, "
"user, and email selectors only inside the active tenant. The access "
"package contains bounded message content, recipient fields, lifecycle "
"timestamps, source references, payload and metadata, and sanitized "
"delivery attempts. Personal badge, email, digest, and source-muting "
"preferences are also included. Explicit notification or preference "
"references can narrow a request and must still match the subject. "
"In-product inbox messages are derived copies, so erasure can delete "
"them after recipient and change checks without following the source "
"reference into its owning module. Personal preferences can also be "
"deleted and then return to defaults. Mail and other external-channel "
"records require manual retention review because provider acceptance "
"and delivery evidence may remain outside Notifications. Repeated "
"execution is unchanged; unrelated tenants and recipients are never "
"included."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "tenant_admin", "operator", "auditor"),
related_modules=("core", "mail", "tasks", "workflow_engine"),
metadata={
"help_contexts": [
"notifications.page.inbox",
"notifications.page.detail",
"notifications.page.delivery",
"notifications.settings.preferences",
"privacy.data-subject-requests",
],
"consequence_classes": {
"delete_inbox_copy": (
"Removes the derived in-product notification and attempts, "
"not the source-module record."
),
"delete_preferences": (
"Removes personal overrides; notification defaults apply again."
),
"review_external_delivery": (
"Requires an authorized retention review before changing "
"provider-delivery evidence."
),
},
},
translations={
"de": {
"title": "Datenschutzanfragen zu Benachrichtigungen",
"summary": (
"An eine betroffene Person adressierte Benachrichtigungen und Einstellungen ausgeben, "
"persönliche Posteingangskopien löschen und externe Zustellnachweise prüfen."
),
"body": (
"Notifications gleicht verifizierte Konto-, Mitgliedschafts-, Identitäts-, Benutzer- und "
"E-Mail-Selektoren ausschließlich innerhalb des aktiven Mandanten ab. Das Auskunftspaket "
"enthält begrenzte Nachrichteninhalte, Empfängerfelder, Lebenszykluszeitpunkte, Quellverweise, "
"Nutzdaten und Metadaten sowie bereinigte Zustellversuche. Persönliche Einstellungen für "
"Kennzeichen, E-Mail, Zusammenfassungen und stummgeschaltete Quellmodule werden ebenfalls "
"einbezogen. Ausdrückliche Benachrichtigungs- oder Einstellungsverweise dürfen eine Anfrage nur "
"einschränken und müssen weiterhin zur betroffenen Person gehören. Nachrichten im internen "
"Posteingang sind abgeleitete Kopien und können deshalb nach Empfänger- und Änderungsprüfung "
"gelöscht werden, ohne dem Quellverweis in das Eigentümermodul zu folgen. Persönliche "
"Einstellungen können ebenfalls gelöscht werden; anschließend gelten wieder die Standardwerte. "
"Mail- und andere externe Kanalnachweise erfordern eine manuelle Aufbewahrungsprüfung, weil beim "
"Anbieter Annahme- und Zustellnachweise verbleiben können. Eine wiederholte Ausführung bleibt "
"unverändert; fremde Mandanten und Empfänger werden nie einbezogen."
),
}
},
structured_translation_version="1",
structured_translations={
"de": {
"consequence_classes": {
"delete_inbox_copy": (
"Entfernt die abgeleitete interne Benachrichtigung und ihre Versuche, nicht den Datensatz des Quellmoduls."
),
"delete_preferences": (
"Entfernt persönliche Abweichungen; anschließend gelten wieder die Benachrichtigungsstandardwerte."
),
"review_external_delivery": (
"Erfordert eine autorisierte Aufbewahrungsprüfung, bevor externe Zustellnachweise verändert werden."
),
}
}
},
),
DocumentationTopic(
id="notifications.center-and-preferences",
title="Use the notification center",
summary="The title-bar badge and notification center collect durable notices that require attention outside an immediate request.",
body="Open the notification center to read, acknowledge, or follow notifications from enabled modules. Preferences control eligible delivery channels and categories. Disabling an optional external channel does not remove the in-product notification unless the originating module's retention policy does so.",
body="Open the notification center to read, acknowledge, or follow notifications from enabled modules. Personal source-muting preferences remove matching entries from the personal list and badge counts even when a producer addressed the actor through an account, membership, or identity identifier; tenant-administrator evidence views remain complete. Preferences also control eligible delivery channels and categories. Disabling an optional external channel does not remove an unmuted in-product notification unless the originating module's retention policy does so.",
documentation_types=("user",),
audience=("user",),
conditions=(DocumentationCondition(required_scopes=(READ_SCOPE,)),),
related_modules=("mail", "calendar", "scheduling", "workflow_engine"),
metadata={
"kind": "reference",
"kind": "workflow",
"help_contexts": [
"notifications.route.notifications",
"notifications.page.inbox",
@@ -120,6 +272,39 @@ manifest = ModuleManifest(
"update_preferences": "replace the current user's badge, channel, digest, and source-muting preferences",
},
},
translations={
"de": {
"title": "Benachrichtigungszentrale verwenden",
"summary": (
"Das Kennzeichen in der Titelleiste und die Benachrichtigungszentrale bündeln dauerhafte Hinweise, "
"die außerhalb einer unmittelbaren Anfrage Aufmerksamkeit erfordern."
),
"body": (
"Die Benachrichtigungszentrale zeigt Hinweise aktivierter Module zum Lesen, Bestätigen oder "
"Weiterverfolgen. Persönlich stummgeschaltete Quellmodule entfernen passende Einträge aus der "
"persönlichen Liste und der Kennzahl, auch wenn ein Erzeugermodul die Person über Konto, "
"Mitgliedschaft oder Identität adressiert hat; Nachweisansichten für Mandantenadministratoren "
"bleiben vollständig. Die Einstellungen steuern außerdem zulässige Zustellkanäle und Kategorien. "
"Das Abschalten eines optionalen externen Kanals entfernt keine nicht stummgeschaltete interne "
"Benachrichtigung, sofern dies nicht die Aufbewahrungsregel des Ursprungsmoduls vorsieht."
),
}
},
structured_translation_version="1",
structured_translations={
"de": {
"consequence_classes": {
"mark_read": "Markiert die Benachrichtigung für den aktuellen Empfänger als gelesen.",
"acknowledge": "Hält zusätzlich zum Lesestatus eine ausdrückliche Bestätigung des Empfängers fest.",
"cancel": (
"Stoppt eine geeignete Benachrichtigung vor der Anbieterannahme, ohne einen Fernrückruf zu behaupten."
),
"update_preferences": (
"Ersetzt die Einstellungen des aktuellen Benutzers für Kennzeichen, Kanäle, Zusammenfassungen und stummgeschaltete Quellen."
),
}
}
},
),
DocumentationTopic(
id="notifications.delivery-operations",
@@ -141,6 +326,37 @@ manifest = ModuleManifest(
"inspect_attempts": "read sanitized provider, status, timing, and error evidence for the selected notification",
},
},
translations={
"de": {
"title": "Benachrichtigungszustellung betreiben",
"summary": (
"Notifications speichert Nachrichtenabsicht und begrenzte kanalbezogene Versuche, bevor Worker optionale Zustellkanäle ausführen."
),
"body": (
"Erzeugende Module übergeben Benachrichtigungen über die Dispatch-Fähigkeit und verwalten keine "
"Zugangsdaten für die Zustellung. Die interne Zustellung ist die Grundlage. Produktive "
"E-Mail-Zustellung steht nur über eine aktivierte Mail-Fähigkeit bereit; Dateizustellung bleibt auf "
"Entwicklung beschränkt. Betreiber können ausstehende und fehlgeschlagene Versuche prüfen und nur "
"Ergebnisse erneut versuchen, deren Wiederholung sicher ist. Die Modulberechtigung des Mandanten "
"wird vor dem Einreihen und erneut vor der Worker-Zustellung geprüft. Das Deaktivieren von "
"Notifications bewahrt angenommene Nachrichten und bietet eine Betreiberaktion an, statt sie "
"unbemerkt zu verarbeiten."
),
}
},
structured_translation_version="1",
structured_translations={
"de": {
"consequence_classes": {
"dispatch_pending": (
"Versucht die Zustellung für höchstens die angeforderte Anzahl geeigneter Mandantenbenachrichtigungen."
),
"inspect_attempts": (
"Liest bereinigte Anbieter-, Status-, Zeit- und Fehlernachweise der ausgewählten Benachrichtigung."
),
}
}
},
),
),
frontend=FrontendModule(
@@ -155,6 +371,17 @@ manifest = ModuleManifest(
surface_id="notifications.route.notifications",
),
),
product_areas=(
ProductAreaContribution(
id="communication",
module_id=MODULE_ID,
label="i18n:govoplan-core.product_area.communication",
icon="mail",
description="i18n:govoplan-core.product_area.communication_description",
surface_ids=("notifications.route.notifications",),
order=40,
),
),
view_surfaces=(
ViewSurface(
id="notifications.page.inbox",
@@ -254,6 +481,17 @@ manifest = ModuleManifest(
"govoplan_notifications.backend.capabilities",
fromlist=["dispatch_capability"],
).dispatch_capability(context),
NOTIFICATIONS_DSAR_CAPABILITY: _dsar_provider,
},
capability_documentation={
NOTIFICATIONS_DSAR_CAPABILITY: CapabilityDocumentation(
label="Notifications data-subject request provider",
summary=(
"Exports subject-addressed notification data, deletes personal "
"inbox copies and preferences, and isolates external delivery evidence."
),
contract_version="0.1.0",
),
},
architecture=declared_module_architecture(
layer="communication_participation",
@@ -261,8 +499,14 @@ manifest = ModuleManifest(
maturity="vertical_slice",
documentation_ref="docs/NOTIFICATION_INBOX_BOUNDARY.md",
test_ref="tests/test_notifications.py",
known_limits=("Production email delivery depends on the optional Mail capability and does not provide an independent transport.",),
owned_concepts=("notification", "notification preference", "notification delivery attempt"),
known_limits=(
"Production email delivery depends on the optional Mail capability and does not provide an independent transport.",
),
owned_concepts=(
"notification",
"notification preference",
"notification delivery attempt",
),
non_owned_concepts=("mail transport", "domain event", "portal message"),
recovery_docs=("docs/EMAIL_DELIVERY.md",),
operations_docs=("docs/EMAIL_DELIVERY.md",),
+10 -1
View File
@@ -95,6 +95,14 @@ def api_list_notifications(
recipient_ids = _recipient_ids_for_view(principal, view)
if recipient_id is not None and recipient_ids is not None and recipient_id not in recipient_ids:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot read another recipient's notifications")
muted_source_modules: tuple[str, ...] = ()
if view == "personal":
preference = get_notification_preferences(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
)
muted_source_modules = tuple(preference.muted_source_modules or ())
notifications = list_notifications(
session,
tenant_id=principal.tenant_id,
@@ -103,6 +111,7 @@ def api_list_notifications(
source_module=source_module,
recipient_id=recipient_id,
recipient_ids=recipient_ids,
muted_source_modules=muted_source_modules,
limit=limit,
)
return NotificationListResponse(notifications=[_response(notification) for notification in notifications])
@@ -119,7 +128,7 @@ def api_notification_summary(
notification_summary(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
user_id=principal.user.id if view == "personal" else None,
recipient_ids=_recipient_ids_for_view(principal, view),
)
)
+24 -4
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from collections.abc import Mapping
from collections.abc import Mapping, Sequence
from datetime import datetime, timezone
from email.message import EmailMessage
from pathlib import Path
@@ -180,6 +180,7 @@ def list_notifications(
source_module: str | None = None,
recipient_id: str | None = None,
recipient_ids: tuple[str, ...] | None = None,
muted_source_modules: Sequence[str] = (),
limit: int = 100,
) -> list[NotificationMessage]:
query = session.query(NotificationMessage).filter(
@@ -196,6 +197,9 @@ def list_notifications(
query = query.filter(NotificationMessage.recipient_id.in_(recipient_ids))
if recipient_id:
query = query.filter(NotificationMessage.recipient_id == recipient_id)
muted_sources = _clean_source_modules(list(muted_source_modules))
if muted_sources:
query = query.filter(NotificationMessage.source_module.notin_(muted_sources))
return query.order_by(NotificationMessage.created_at.desc(), NotificationMessage.id.asc()).limit(limit).all()
@@ -206,12 +210,28 @@ def notification_summary(
user_id: str | None = None,
recipient_ids: tuple[str, ...] | None = None,
) -> dict[str, int | bool]:
preference = (
get_notification_preferences(
session,
tenant_id=tenant_id,
user_id=user_id,
)
if user_id
else None
)
filters = [
NotificationMessage.tenant_id == tenant_id,
NotificationMessage.deleted_at.is_(None),
]
if recipient_ids is not None:
filters.append(NotificationMessage.recipient_id.in_(recipient_ids))
muted_sources = _clean_source_modules(
list(preference.muted_source_modules or [])
if preference is not None
else []
)
if muted_sources:
filters.append(NotificationMessage.source_module.notin_(muted_sources))
active = NotificationMessage.status.notin_(["cancelled", "skipped"])
total, unread, pending, failed = (
session.query(
@@ -267,9 +287,9 @@ def notification_summary(
.filter(*filters)
.one()
)
show_unread_badge = True
if user_id:
show_unread_badge = get_notification_preferences(session, tenant_id=tenant_id, user_id=user_id).show_unread_badge
show_unread_badge = (
preference.show_unread_badge if preference is not None else True
)
return {
"total": int(total or 0),
"unread": int(unread or 0),
+476
View File
@@ -0,0 +1,476 @@
from __future__ import annotations
import json
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarProvider,
DsarRecordRef,
DsarSubjectRef,
)
from govoplan_core.db.base import Base
from govoplan_core.privacy.dsar_workflow import (
create_data_subject_request,
search_data_subject_request,
)
from govoplan_notifications.backend.db.models import (
NotificationDeliveryAttempt,
NotificationMessage,
NotificationPreference,
)
from govoplan_notifications.backend.dsar_provider import (
NOTIFICATIONS_DSAR_CAPABILITY,
NotificationsDsarProvider,
)
from govoplan_notifications.backend.manifest import manifest
class _Registry:
def __init__(
self,
provider: NotificationsDsarProvider,
*,
active: bool = True,
) -> None:
self.provider = provider
self.active = active
def capability_names(self):
return (NOTIFICATIONS_DSAR_CAPABILITY,)
def capability_owner(self, name):
self._assert_capability(name)
return "notifications"
def tenant_entitlement_resolver(self):
active = self.active
class _Resolver:
@staticmethod
def resolve(session, tenant_id):
del session, tenant_id
return type(
"State",
(),
{"effective_modules": ("notifications",) if active else ()},
)()
return _Resolver()
def require_tenant_capability(self, name, session, **kwargs):
del session, kwargs
self._assert_capability(name)
return self.provider
def manifests(self):
return (type("Manifest", (), {"id": "notifications"})(),)
@staticmethod
def _assert_capability(name: str) -> None:
if name != NOTIFICATIONS_DSAR_CAPABILITY:
raise KeyError(name)
class NotificationsDsarProviderTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(self.engine)
self.session = Session(self.engine)
self.provider = NotificationsDsarProvider()
self.assertIsInstance(self.provider, DsarProvider)
self._seed()
self.session.commit()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def _message(
self,
notification_id: str,
*,
tenant_id: str = "tenant-1",
channel: str = "inbox",
recipient: str | None = None,
recipient_id: str | None = "membership-1",
subject: str,
) -> NotificationMessage:
return NotificationMessage(
id=notification_id,
tenant_id=tenant_id,
source_module="tasks",
source_resource_type="task",
source_resource_id=f"task-{notification_id}",
event_kind="task_due",
channel=channel,
recipient=recipient,
recipient_type="email" if recipient else "membership",
recipient_id=recipient_id,
recipient_label="Resident Example",
subject=subject,
body_text=f"Private body for {notification_id}",
body_html=f"<p>Private HTML for {notification_id}</p>",
action_url="/tasks",
priority=2,
status="sent",
attempt_count=0,
payload={
"case_reference": f"CASE-{notification_id}",
"access_token": "provider-secret-do-not-export",
},
metadata_={"classification": "personal"},
)
def _seed(self) -> None:
inbox = self._message(
"notification-inbox",
subject="Personal inbox notice",
)
mail = self._message(
"notification-mail",
channel="mail",
recipient="Resident@Example.test",
recipient_id=None,
subject="Personal mail notice",
)
mail.attempt_count = 1
mail.attempts.append(
NotificationDeliveryAttempt(
id="attempt-mail",
tenant_id="tenant-1",
attempt_no=1,
channel="mail",
provider="mail.delivery_outbox",
status="accepted",
external_message_id="mail-command-1",
details={
"provider_state": "accepted",
"authorization": "Bearer provider-secret-do-not-export",
},
)
)
self.session.add_all(
(
inbox,
mail,
self._message(
"notification-other-recipient",
recipient_id="membership-other",
subject="Other recipient private notice",
),
self._message(
"notification-other-tenant",
tenant_id="tenant-2",
subject="Other tenant private notice",
),
NotificationPreference(
id="preference-personal",
tenant_id="tenant-1",
user_id="membership-1",
show_unread_badge=False,
email_enabled=True,
email_digest_enabled=True,
muted_source_modules=["campaign", "calendar"],
metadata_={"digest_hour": 8},
),
NotificationPreference(
id="preference-other",
tenant_id="tenant-1",
user_id="membership-other",
show_unread_badge=True,
email_enabled=False,
email_digest_enabled=False,
muted_source_modules=[],
metadata_={},
),
)
)
@staticmethod
def _subject() -> DsarSubjectRef:
return DsarSubjectRef(
account_id="account-1",
membership_id="membership-1",
identity_id="identity-1",
email="resident@example.test",
)
def test_search_is_tenant_recipient_scoped_and_exports_bounded_content(
self,
) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=self._subject(),
)
self.assertEqual(
[
"preference-personal",
"notification-inbox",
"notification-mail",
],
[record.resource_id for record in records],
)
exported = json.dumps([record.to_dict() for record in records])
self.assertIn("Private body for notification-inbox", exported)
self.assertIn("CASE-notification-mail", exported)
self.assertIn("mail-command-1", exported)
self.assertIn("digest_hour", exported)
self.assertIn("[redacted]", exported)
self.assertNotIn("provider-secret-do-not-export", exported)
self.assertNotIn("notification-other-recipient", exported)
self.assertNotIn("notification-other-tenant", exported)
def test_resource_references_narrow_and_alias_conflicts_fail_closed(self) -> None:
notification = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
membership_id="membership-1",
external_references={
"notifications.notification": "notification-inbox"
},
),
)
preference = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
membership_id="membership-1",
external_references={"notifications.preference": "preference-personal"},
),
)
conflict = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-1",
external_references={"notifications.account": "account-other"},
),
)
reference_only = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
external_references={"notifications.notification": "notification-inbox"}
),
)
self.assertEqual(
["notification-inbox"], [item.resource_id for item in notification]
)
self.assertEqual(
["preference-personal"], [item.resource_id for item in preference]
)
self.assertEqual((), conflict)
self.assertEqual((), reference_only)
def test_erasure_deletes_preferences_and_inbox_but_reviews_mail(self) -> None:
subject = self._subject()
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=subject,
)
actions = self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
records=records,
)
self.assertEqual(
["delete", "delete", "manual_review"],
[action.kind for action in actions],
)
first = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=actions,
request_id="dsar-1",
)
second = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=actions,
request_id="dsar-1",
)
self.assertEqual(
["executed", "executed", "blocked"],
[result.status for result in first],
)
self.assertEqual(
["unchanged", "unchanged", "blocked"],
[result.status for result in second],
)
self.assertIsNone(
self.session.get(NotificationPreference, "preference-personal")
)
self.assertIsNone(self.session.get(NotificationMessage, "notification-inbox"))
self.assertIsNotNone(self.session.get(NotificationMessage, "notification-mail"))
self.assertIsNotNone(
self.session.get(NotificationMessage, "notification-other-recipient")
)
def test_changed_and_foreign_resources_are_blocked(self) -> None:
subject = self._subject()
record = next(
item
for item in self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=subject,
)
if item.resource_id == "notification-inbox"
)
action = self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
records=(record,),
)[0]
notification = self.session.get(NotificationMessage, "notification-inbox")
notification.subject = "Changed after planning"
self.session.flush()
changed = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=(action,),
request_id="dsar-1",
)
self.assertEqual("blocked", changed[0].status)
with self.assertRaisesRegex(ValueError, "foreign provider record"):
self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
records=(
DsarRecordRef(
provider_id="mail",
module_id="mail",
resource_type="notification_message",
resource_id="notification-inbox",
category="message",
title="Foreign message",
),
),
)
with self.assertRaisesRegex(ValueError, "foreign provider action"):
self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=(
DsarErasureActionRef(
action_id="mail:delete:notification:notification-inbox",
provider_id="mail",
module_id="mail",
kind="delete",
resource_type="notification_message",
resource_id="notification-inbox",
title="Delete notification",
rationale="Foreign action",
executable=True,
),
),
request_id="dsar-1",
)
def test_oversized_content_fails_closed(self) -> None:
notification = self.session.get(
NotificationMessage,
"notification-inbox",
)
notification.body_text = "x" * 100_001
self.session.flush()
with self.assertRaisesRegex(ValueError, "body text exceeds"):
self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
membership_id="membership-1",
external_references={
"notifications.notification": "notification-inbox"
},
),
)
def test_core_workflow_and_manifest_register_provider(self) -> None:
row = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-NOTIFICATIONS-1",
request_kind="access",
subject=self._subject(),
purpose="Respond to a verified request.",
legal_basis="Article 15 GDPR",
due_at=None,
requested_by_account_id="privacy-officer",
)
self.session.commit()
search_data_subject_request(
self.session,
registry=_Registry(self.provider),
row=row,
expected_revision=1,
)
self.assertEqual(
[NOTIFICATIONS_DSAR_CAPABILITY],
row.coverage["provider_capabilities"],
)
self.assertEqual(3, row.search_result["record_count"])
inactive = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-NOTIFICATIONS-2",
request_kind="access",
subject=self._subject(),
purpose="Respond to a verified request.",
legal_basis="Article 15 GDPR",
due_at=None,
requested_by_account_id="privacy-officer",
)
self.session.commit()
search_data_subject_request(
self.session,
registry=_Registry(self.provider, active=False),
row=inactive,
expected_revision=1,
)
self.assertEqual(
[NOTIFICATIONS_DSAR_CAPABILITY],
inactive.coverage["inactive_provider_capabilities"],
)
self.assertIn(NOTIFICATIONS_DSAR_CAPABILITY, manifest.capability_factories)
self.assertIn(
NOTIFICATIONS_DSAR_CAPABILITY,
manifest.capability_documentation,
)
self.assertIn(
NOTIFICATIONS_DSAR_CAPABILITY,
{item.name for item in manifest.provides_interfaces},
)
self.assertTrue(
any(
topic.id == "notifications.data-subject-requests"
and {"admin", "user"}.issubset(topic.documentation_types)
for topic in manifest.documentation
)
)
if __name__ == "__main__":
unittest.main()
@@ -3,6 +3,10 @@ from __future__ import annotations
from pathlib import Path
import unittest
from govoplan_core.core.modules import (
documentation_structured_translation_issues,
localizable_documentation_metadata_keys,
)
from govoplan_notifications.backend.manifest import get_manifest
@@ -59,6 +63,25 @@ class NotificationsInterfaceDocumentationContractTests(unittest.TestCase):
self.assertIn("notifications.action.dispatch", delivery.metadata["help_contexts"])
self.assertIn("dispatch_pending", delivery.metadata["consequence_classes"])
def test_german_reference_documentation_is_complete(self) -> None:
topics = get_manifest().documentation
self.assertEqual(3, len(topics))
for topic in topics:
translation = topic.translations.get("de", {})
self.assertTrue(translation.get("title"), topic.id)
self.assertTrue(translation.get("summary"), topic.id)
self.assertTrue(translation.get("body"), topic.id)
if localizable_documentation_metadata_keys(topic):
self.assertEqual("1", topic.structured_translation_version, topic.id)
self.assertIn("de", topic.structured_translations, topic.id)
self.assertEqual((), documentation_structured_translation_issues(topic))
center = next(topic for topic in topics if topic.id == "notifications.center-and-preferences")
delivery = next(topic for topic in topics if topic.id == "notifications.delivery-operations")
self.assertEqual("workflow", center.metadata["kind"])
self.assertTrue(center.conditions)
self.assertEqual("reference", delivery.metadata["kind"])
def test_webui_uses_shared_consequence_and_draft_patterns(self) -> None:
center = (REPO_ROOT / "webui/src/features/notifications/NotificationCenterPage.tsx").read_text(encoding="utf-8")
settings = (REPO_ROOT / "webui/src/features/notifications/NotificationSettingsPanel.tsx").read_text(encoding="utf-8")
+87
View File
@@ -551,6 +551,93 @@ class NotificationServiceTests(unittest.TestCase):
self.assertEqual(["calendar", "campaign"], response["muted_source_modules"])
self.assertFalse(summary["show_unread_badge"])
def test_personal_source_mutes_apply_across_recipient_identifiers(self) -> None:
with self.Session() as session:
postbox = create_notification(
session,
tenant_id="tenant-1",
payload=NotificationCreateRequest(
source_module="postbox",
source_resource_type="postbox",
event_kind="postbox.assignment.newly_visible.v1",
channel="inbox",
recipient_type="account",
recipient_id="account-1",
subject="Postbox responsibility changed",
enqueue_delivery=False,
),
)
calendar = create_notification(
session,
tenant_id="tenant-1",
payload=NotificationCreateRequest(
source_module="calendar",
source_resource_type="calendar_event",
event_kind="calendar.reminder.v1",
channel="inbox",
recipient_type="account",
recipient_id="account-1",
subject="Calendar reminder",
enqueue_delivery=False,
),
)
update_notification_preferences(
session,
tenant_id="tenant-1",
user_id="user-1",
payload=NotificationPreferencesUpdateRequest(
muted_source_modules=["postbox"],
),
)
principal = self._principal(
tenant_id="tenant-1",
user_id="user-1",
account_id="account-1",
scopes={"notifications:notification:read"},
)
personal = api_list_notifications(
status_filter=None,
channel=None,
source_module=None,
recipient_id=None,
view="personal",
limit=100,
session=session,
principal=principal,
)
personal_summary = api_notification_summary(
view="personal",
session=session,
principal=principal,
)
tenant_admin = self._principal(
tenant_id="tenant-1",
user_id="user-1",
account_id="account-1",
scopes={
"notifications:notification:read",
"notifications:notification:admin",
},
)
tenant_view = api_list_notifications(
status_filter=None,
channel=None,
source_module=None,
recipient_id=None,
view="tenant",
limit=100,
session=session,
principal=tenant_admin,
)
self.assertEqual([calendar.id], [item.id for item in personal.notifications])
self.assertEqual(1, personal_summary.total)
self.assertEqual(
{postbox.id, calendar.id},
{item.id for item in tenant_view.notifications},
)
if __name__ == "__main__":
unittest.main()
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/notifications-webui",
"version": "0.1.16",
"version": "0.1.19",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -18,7 +18,7 @@
"test:ui-structure": "node scripts/test-notification-page-structure.mjs"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.16",
"@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
@@ -1,16 +1,21 @@
import { useEffect, useMemo, useState } from "react";
import { Bell, Check, ExternalLink, RefreshCw, Send, XCircle } from "lucide-react";
import { Bell, Check, ExternalLink, Send, XCircle } from "lucide-react";
import {
ActionBlockerHint,
AdminIconButton,
ActionToolbar,
Button,
ConfirmDialog,
CountBadge,
DismissibleAlert,
DocumentationHelpLink,
SegmentedControl,
SelectionList,
SelectionListItem,
StatePanel,
StatusBadge,
WorkspaceActionBar,
WorkspaceFrame,
WorkspaceLayout,
hasScope,
i18nMessage,
type ApiSettings,
@@ -152,7 +157,7 @@ export default function NotificationCenterPage({ settings, auth }: { settings: A
if (!canRead) {
return (
<main className="notifications-page">
<WorkspaceFrame as="main" height="viewport" surface="plain" className="notifications-page" label="Notification center">
<div className="notifications-permission-state">
<ActionBlockerHint
tone="warning"
@@ -167,28 +172,38 @@ export default function NotificationCenterPage({ settings, auth }: { settings: A
documentation={NOTIFICATIONS_DOCUMENTATION}
/>
</div>
</main>
</WorkspaceFrame>
);
}
return (
<main className="notifications-page">
<div className="notifications-shell">
<aside className="notifications-sidebar">
<div className="notifications-sidebar-bar">
<div className="notifications-title">
<WorkspaceFrame as="main" height="viewport" surface="plain" className="notifications-page" label="Notification center">
<WorkspaceLayout
variant="split"
primarySize="default"
primaryScrollable={false}
contentScrollable={false}
surface="contained"
primaryClassName="notifications-sidebar"
contentClassName="notifications-workspace"
primaryLabel="i18n:govoplan-notifications.notifications"
contentLabel="i18n:govoplan-notifications.surface.center"
interfaceId="notifications.center.workspace"
helpContextId="notifications.page.center"
helpModuleId="notifications"
primary={<>
<WorkspaceActionBar
scope="collection-pane"
variant="collection"
refreshable
reloadAction={{ onReload: () => void load(), loading: loading || busy, label: "i18n:govoplan-notifications.refresh" }}
className="notifications-sidebar-bar"
contextActions={<div className="notifications-title">
<Bell size={17} />
<strong>i18n:govoplan-notifications.notifications</strong>
{unreadCount > 0 ? <span className="notifications-count">{unreadCount}</span> : null}
</div>
<AdminIconButton
label="i18n:govoplan-notifications.refresh"
icon={<RefreshCw size={16} aria-hidden="true" />}
onClick={() => void load()}
disabled={loading || busy}
disabledReason={loading ? NOTIFICATIONS_I18N.loading : busy ? NOTIFICATIONS_I18N.actionActive : undefined}
{unreadCount > 0 ? <CountBadge>{unreadCount}</CountBadge> : null}
</div>}
/>
</div>
<SegmentedControl
className="notifications-status-filter"
options={statusFilters.map((status) => ({ id: status, label: statusLabel(status) }))}
@@ -201,7 +216,7 @@ export default function NotificationCenterPage({ settings, auth }: { settings: A
{loading ? <div className="notifications-note">i18n:govoplan-notifications.loading_notifications</div> : null}
{!loading && notifications.length === 0 ? <div className="notifications-note">i18n:govoplan-notifications.no_notifications</div> : null}
{notifications.length > 0 ? (
<SelectionList label="i18n:govoplan-notifications.notifications" className="notifications-selection-list">
<SelectionList variant="navigation" label="i18n:govoplan-notifications.notifications" className="notifications-selection-list">
{notifications.map((notification) => (
<SelectionListItem
key={notification.id}
@@ -222,29 +237,32 @@ export default function NotificationCenterPage({ settings, auth }: { settings: A
</SelectionList>
) : null}
</div>
</aside>
<section className="notifications-workspace">
<div className="notifications-topbar">
<div className="notifications-title-line">
</>}
>
<WorkspaceActionBar
scope="detail-pane"
variant="detail"
className="notifications-topbar"
contextActions={<div className="notifications-title-line">
<Bell size={18} />
<strong>{selected?.subject || selected?.event_kind || "i18n:govoplan-notifications.surface.center"}</strong>
</div>
<div className="notifications-actions">
<DocumentationHelpLink reference={NOTIFICATIONS_DOCUMENTATION} />
</div>}
helpAction={<DocumentationHelpLink reference={NOTIFICATIONS_DOCUMENTATION} />}
primaryActions={<div className="notifications-actions">
<Button onClick={() => void markSelected("read")} disabled={Boolean(markReadDisabledReason)} disabledReason={markReadDisabledReason}>
<Check size={16} /> i18n:govoplan-notifications.mark_read
</Button>
<Button onClick={() => void markSelected("acknowledged")} disabled={Boolean(acknowledgeDisabledReason)} disabledReason={acknowledgeDisabledReason}>
<Check size={16} /> i18n:govoplan-notifications.acknowledge
</Button>
<Button variant="danger" onClick={() => setConfirmingAction("cancel")} disabled={Boolean(cancelDisabledReason)} disabledReason={cancelDisabledReason}>
<XCircle size={16} /> i18n:govoplan-notifications.cancel_delivery
</Button>
<Button onClick={() => setConfirmingAction("dispatch")} disabled={Boolean(dispatchDisabledReason)} disabledReason={dispatchDisabledReason}>
<Send size={16} /> i18n:govoplan-notifications.dispatch_pending
</Button>
</div>
</div>
</div>}
destructiveActions={<Button variant="danger" onClick={() => setConfirmingAction("cancel")} disabled={Boolean(cancelDisabledReason)} disabledReason={cancelDisabledReason}>
<XCircle size={16} /> i18n:govoplan-notifications.cancel_delivery
</Button>}
/>
{error ? <DismissibleAlert tone="danger" compact resetKey={error}>{error}</DismissibleAlert> : null}
@@ -264,14 +282,9 @@ export default function NotificationCenterPage({ settings, auth }: { settings: A
) : null}
{selected ? <NotificationDetails notification={selected} /> : (
<div className="notifications-empty-state">
<Bell size={22} />
<h1>i18n:govoplan-notifications.notifications</h1>
<p>i18n:govoplan-notifications.select_notification_help</p>
</div>
<StatePanel size="fill" icon={<Bell size={22} />} title="i18n:govoplan-notifications.notifications" description="i18n:govoplan-notifications.select_notification_help" />
)}
</section>
</div>
</WorkspaceLayout>
<ConfirmDialog
open={confirmingAction === "cancel"}
title="i18n:govoplan-notifications.cancel_delivery_title"
@@ -291,7 +304,7 @@ export default function NotificationCenterPage({ settings, auth }: { settings: A
onCancel={() => setConfirmingAction(null)}
onConfirm={() => void confirmAction()}
/>
</main>
</WorkspaceFrame>
);
}
@@ -330,10 +343,10 @@ function NotificationDetails({ notification }: { notification: NotificationMessa
</section>
<section className="notifications-attempts">
<div className="notifications-section-heading">
<ActionToolbar surface="section-header" className="notifications-section-heading">
<h2>i18n:govoplan-notifications.delivery_attempts</h2>
<DocumentationHelpLink reference={NOTIFICATIONS_DELIVERY_DOCUMENTATION} />
</div>
</ActionToolbar>
{notification.attempts.length === 0 ? <p className="muted">i18n:govoplan-notifications.no_delivery_attempt</p> : null}
{notification.attempts.map((attempt) => (
<div className="notifications-attempt" key={attempt.id}>
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import { Mail, Save } from "lucide-react";
import {
import { FormGrid, ContentGrid,
ActionBlockerHint,
Button,
Card,
@@ -182,7 +182,7 @@ export default function NotificationSettingsPanel({ settings, auth }: { settings
const digestToggleDisabled = preferenceControlsDisabled || !mailAvailable || !draft.email_enabled;
return (
<div className="dashboard-grid settings-dashboard-grid notifications-settings-panel">
<ContentGrid columns={2} collapseAt="workspace" className="notifications-settings-panel">
<div className="notifications-settings-documentation">
<DocumentationHelpLink reference={NOTIFICATIONS_DOCUMENTATION} />
</div>
@@ -201,7 +201,7 @@ export default function NotificationSettingsPanel({ settings, auth }: { settings
/>
) : null}
<Card title="i18n:govoplan-notifications.notifications">
<div className="form-grid">
<FormGrid columns={1} collapseAt="standard" className="">
<ToggleSwitch
label="i18n:govoplan-notifications.unread_badge"
help="i18n:govoplan-notifications.unread_badge_help"
@@ -228,10 +228,10 @@ export default function NotificationSettingsPanel({ settings, auth }: { settings
</Button>
</div>
{message ? <DismissibleAlert tone={messageTone} resetKey={message} floating>{message}</DismissibleAlert> : null}
</div>
</FormGrid>
</Card>
<Card title="i18n:govoplan-notifications.delivery">
<div className="form-grid">
<FormGrid columns={1} collapseAt="standard" className="">
<DocumentationHelpLink reference={NOTIFICATIONS_DELIVERY_DOCUMENTATION} />
<div className="notifications-settings-inline-title">
<Mail size={16} />
@@ -265,8 +265,8 @@ export default function NotificationSettingsPanel({ settings, auth }: { settings
disabled={digestToggleDisabled}
onChange={(value) => setDraft((current) => ({ ...current, email_digest_enabled: value }))}
/>
</div>
</FormGrid>
</Card>
</div>
</ContentGrid>
);
}
@@ -1,3 +1,4 @@
import { MetricGrid } from "@govoplan/core-webui";
import { Link } from "react-router";
import {
DismissibleAlert,
@@ -40,7 +41,7 @@ export default function NotificationSummaryWidget({
{error}
</DismissibleAlert>
)}
<div className="metric-grid inside dashboard-widget-metrics">
<MetricGrid columns={3} spacing="none">
<MetricCard
label="i18n:govoplan-notifications.unread"
value={summary?.unread ?? 0}
@@ -63,7 +64,7 @@ export default function NotificationSummaryWidget({
detail="i18n:govoplan-notifications.delivery_failures"
/>
)}
</div>
</MetricGrid>
<div className="notifications-widget-actions">
<DocumentationHelpLink reference={NOTIFICATIONS_DOCUMENTATION} />
{showCenterLink && (
+4 -96
View File
@@ -1,18 +1,3 @@
.notifications-page {
box-sizing: border-box;
height: calc(100vh - 115px);
min-height: 0;
overflow: hidden;
color: var(--text);
background: var(--bg);
}
.notifications-page *,
.notifications-page *::before,
.notifications-page *::after {
box-sizing: border-box;
}
.notifications-widget-actions {
display: flex;
align-items: center;
@@ -21,28 +6,14 @@
margin-top: 14px;
}
.notifications-shell {
height: 100%;
min-height: 0;
display: grid;
grid-template-columns: minmax(270px, 340px) minmax(0, 1fr);
border: var(--border-line);
background: var(--panel);
overflow: hidden;
}
.notifications-sidebar {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
border-right: var(--border-line);
background: var(--panel-soft);
}
.notifications-sidebar-bar,
.notifications-title,
.notifications-topbar,
.notifications-title-line,
.notifications-actions,
.notifications-message-meta,
@@ -52,27 +23,6 @@
gap: 8px;
}
.notifications-sidebar-bar,
.notifications-topbar {
min-height: 54px;
justify-content: space-between;
border-bottom: var(--border-line);
background: var(--panel-header);
padding: 9px 12px;
}
.notifications-count {
min-width: 21px;
height: 21px;
display: inline-grid;
place-items: center;
border-radius: 999px;
background: var(--accent);
color: var(--on-accent);
font-size: 12px;
font-weight: 800;
}
.notifications-status-filter {
width: calc(100% - 16px);
margin: 8px;
@@ -191,7 +141,7 @@
}
.notifications-message p {
max-width: 840px;
max-width: 900px;
margin: 0 0 14px;
line-height: 1.6;
white-space: pre-line;
@@ -244,17 +194,6 @@
font-size: 15px;
}
.notifications-section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.notifications-section-heading h2 {
margin: 0;
}
.notifications-properties dl {
display: grid;
grid-template-columns: repeat(2, minmax(180px, 1fr));
@@ -283,7 +222,7 @@
width: max-content;
max-width: 100%;
margin: 14px 0 0;
border-radius: 6px;
border-radius: var(--radius-compact);
background: var(--danger-soft);
color: var(--danger-text);
padding: 8px 10px;
@@ -294,7 +233,7 @@
grid-template-columns: minmax(0, 1fr) auto;
gap: 3px 12px;
border: var(--border-line);
border-radius: 6px;
border-radius: var(--radius-compact);
background: var(--surface);
margin-bottom: 8px;
padding: 10px;
@@ -307,44 +246,13 @@
color: var(--muted);
}
.notifications-empty-state {
min-height: 100%;
display: grid;
place-items: center;
align-content: center;
gap: 8px;
padding: 32px;
text-align: center;
}
.notifications-permission-state {
max-width: 860px;
max-width: 900px;
margin: 0 auto;
padding: 32px 20px;
}
.notifications-empty-state h1 {
margin: 0;
color: var(--text-strong);
}
.notifications-empty-state p {
max-width: 520px;
margin: 0;
color: var(--muted);
}
@media (max-width: 900px) {
.notifications-shell {
grid-template-columns: 1fr;
}
.notifications-sidebar {
min-height: 220px;
border-right: 0;
border-bottom: var(--border-line);
}
.notifications-topbar {
align-items: flex-start;
flex-direction: column;