feat: project role-bound postboxes
This commit is contained in:
@@ -5,6 +5,7 @@ from govoplan_core.core.institutional import (
|
|||||||
CAPABILITY_SERVICE_DEFINITIONS,
|
CAPABILITY_SERVICE_DEFINITIONS,
|
||||||
service_launch_capability,
|
service_launch_capability,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.postbox import CAPABILITY_POSTBOX_PORTAL
|
||||||
from govoplan_core.core.modules import (
|
from govoplan_core.core.modules import (
|
||||||
CapabilityDocumentation,
|
CapabilityDocumentation,
|
||||||
DocumentationLink,
|
DocumentationLink,
|
||||||
@@ -61,11 +62,13 @@ manifest = ModuleManifest(
|
|||||||
"forms",
|
"forms",
|
||||||
"forms_runtime",
|
"forms_runtime",
|
||||||
"workflow_engine",
|
"workflow_engine",
|
||||||
|
"postbox",
|
||||||
),
|
),
|
||||||
optional_capabilities=(
|
optional_capabilities=(
|
||||||
CAPABILITY_SERVICE_DEFINITIONS,
|
CAPABILITY_SERVICE_DEFINITIONS,
|
||||||
CAPABILITY_SERVICE_AVAILABILITY,
|
CAPABILITY_SERVICE_AVAILABILITY,
|
||||||
*SERVICE_LAUNCH_CAPABILITIES,
|
*SERVICE_LAUNCH_CAPABILITIES,
|
||||||
|
CAPABILITY_POSTBOX_PORTAL,
|
||||||
),
|
),
|
||||||
permissions=(
|
permissions=(
|
||||||
PermissionDefinition(
|
PermissionDefinition(
|
||||||
@@ -103,6 +106,12 @@ manifest = ModuleManifest(
|
|||||||
)
|
)
|
||||||
for capability in SERVICE_LAUNCH_CAPABILITIES
|
for capability in SERVICE_LAUNCH_CAPABILITIES
|
||||||
),
|
),
|
||||||
|
ModuleInterfaceRequirement(
|
||||||
|
name=CAPABILITY_POSTBOX_PORTAL,
|
||||||
|
version_min="0.1.0",
|
||||||
|
version_max_exclusive="0.2.0",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
capability_factories={
|
capability_factories={
|
||||||
CAPABILITY_PORTAL_SERVICE_DIRECTORY: _service_directory,
|
CAPABILITY_PORTAL_SERVICE_DIRECTORY: _service_directory,
|
||||||
@@ -162,6 +171,34 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
documentation=(
|
documentation=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="portal.function-postboxes",
|
||||||
|
title="Portal-facing function Postboxes",
|
||||||
|
summary="Open explicitly published function Postboxes without moving their access rules into Portal.",
|
||||||
|
body=(
|
||||||
|
"When Postbox is installed, Portal can display Postboxes whose exact definition or published template revision is marked portal-visible. "
|
||||||
|
"Postbox re-evaluates the current function assignment, classification, and read authority for every projection. Portal stores no Postbox ACL, "
|
||||||
|
"does not expose vacant or inaccessible addresses, and links back to the authoritative Postbox surface."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("user", "operator", "module_admin"),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Portal-sichtbare Funktionspostfächer",
|
||||||
|
"summary": "Ausdrücklich veröffentlichte Funktionspostfächer öffnen, ohne ihre Zugriffsregeln in das Portal zu verlagern.",
|
||||||
|
"body": (
|
||||||
|
"Ist Postbox installiert, kann das Portal Postfächer anzeigen, deren exakte Definition oder veröffentlichte Vorlagenrevision als portalsichtbar markiert ist. "
|
||||||
|
"Postbox prüft für jede Projektion die aktuelle Funktionszuordnung, Klassifikation und Leseberechtigung erneut. Das Portal speichert keine Postfach-ACL, "
|
||||||
|
"zeigt keine unbesetzten oder nicht zugänglichen Adressen und verweist auf die maßgebliche Postbox-Oberfläche."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
links=(DocumentationLink(label="Portal", href="/portal", kind="runtime"),),
|
||||||
|
related_modules=("postbox", "idm", "organizations"),
|
||||||
|
metadata={"kind": "guide", "help_contexts": ["portal.postboxes"]},
|
||||||
|
order=20,
|
||||||
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="portal.service-directory",
|
id="portal.service-directory",
|
||||||
title="Service directory",
|
title="Service directory",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from dataclasses import asdict
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -10,11 +11,13 @@ from govoplan_core.core.institutional import (
|
|||||||
InstitutionalContextError,
|
InstitutionalContextError,
|
||||||
InstitutionalReference,
|
InstitutionalReference,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.postbox import postbox_portal_projection_provider
|
||||||
from govoplan_core.db.session import get_session
|
from govoplan_core.db.session import get_session
|
||||||
from govoplan_portal.backend.schemas import (
|
from govoplan_portal.backend.schemas import (
|
||||||
PortalServiceLaunchRequest,
|
PortalServiceLaunchRequest,
|
||||||
PortalServiceLaunchResponse,
|
PortalServiceLaunchResponse,
|
||||||
PortalServiceListResponse,
|
PortalServiceListResponse,
|
||||||
|
PortalPostboxListResponse,
|
||||||
)
|
)
|
||||||
from govoplan_portal.backend.service_directory import (
|
from govoplan_portal.backend.service_directory import (
|
||||||
PortalServiceDirectory,
|
PortalServiceDirectory,
|
||||||
@@ -66,6 +69,32 @@ def api_list_portal_services(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/postboxes", response_model=PortalPostboxListResponse)
|
||||||
|
def api_list_portal_postboxes(
|
||||||
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> PortalPostboxListResponse:
|
||||||
|
if not has_scope(principal, READ_SCOPE):
|
||||||
|
raise HTTPException(status_code=403, detail=f"Missing scope: {READ_SCOPE}")
|
||||||
|
provider = postbox_portal_projection_provider(_registry)
|
||||||
|
if provider is None:
|
||||||
|
return PortalPostboxListResponse(
|
||||||
|
provider_available=False,
|
||||||
|
postboxes=[],
|
||||||
|
)
|
||||||
|
entries = provider.list_portal_entries(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
return PortalPostboxListResponse(
|
||||||
|
provider_available=True,
|
||||||
|
postboxes=[asdict(entry) for entry in entries],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/services/{service_id}/launch",
|
"/services/{service_id}/launch",
|
||||||
response_model=PortalServiceLaunchResponse,
|
response_model=PortalServiceLaunchResponse,
|
||||||
|
|||||||
@@ -22,6 +22,22 @@ class PortalServiceListResponse(BaseModel):
|
|||||||
services: list[PortalServiceEntryResponse]
|
services: list[PortalServiceEntryResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class PortalPostboxEntryResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
postbox: dict[str, Any]
|
||||||
|
unread_count: int = Field(default=0, ge=0)
|
||||||
|
latest_message_at: datetime | None = None
|
||||||
|
route_path: str
|
||||||
|
|
||||||
|
|
||||||
|
class PortalPostboxListResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
provider_available: bool
|
||||||
|
postboxes: list[PortalPostboxEntryResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class PortalServiceLaunchRequest(BaseModel):
|
class PortalServiceLaunchRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
@@ -49,4 +65,6 @@ __all__ = [
|
|||||||
"PortalServiceLaunchRequest",
|
"PortalServiceLaunchRequest",
|
||||||
"PortalServiceLaunchResponse",
|
"PortalServiceLaunchResponse",
|
||||||
"PortalServiceListResponse",
|
"PortalServiceListResponse",
|
||||||
|
"PortalPostboxEntryResponse",
|
||||||
|
"PortalPostboxListResponse",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ from __future__ import annotations
|
|||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
import unittest
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from govoplan_core.core.institutional import (
|
from govoplan_core.core.institutional import (
|
||||||
CAPABILITY_SERVICE_AVAILABILITY,
|
CAPABILITY_SERVICE_AVAILABILITY,
|
||||||
@@ -23,7 +25,10 @@ from govoplan_core.core.access import (
|
|||||||
FunctionRef,
|
FunctionRef,
|
||||||
PrincipalRef,
|
PrincipalRef,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.postbox import PostboxDirectoryEntryRef, PostboxPortalEntryRef
|
||||||
from govoplan_portal.backend.manifest import get_manifest
|
from govoplan_portal.backend.manifest import get_manifest
|
||||||
|
from govoplan_portal.backend.router import api_list_portal_postboxes
|
||||||
from govoplan_portal.backend.service_directory import (
|
from govoplan_portal.backend.service_directory import (
|
||||||
PortalServiceDirectory,
|
PortalServiceDirectory,
|
||||||
principal_audiences,
|
principal_audiences,
|
||||||
@@ -189,6 +194,47 @@ class SemanticDirectory:
|
|||||||
|
|
||||||
|
|
||||||
class PortalServiceDirectoryTests(unittest.TestCase):
|
class PortalServiceDirectoryTests(unittest.TestCase):
|
||||||
|
def test_portal_postbox_endpoint_projects_optional_provider_entries(self) -> None:
|
||||||
|
principal = ApiPrincipal(
|
||||||
|
principal=PrincipalRef(
|
||||||
|
account_id="account-1",
|
||||||
|
membership_id="membership-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scopes=frozenset({"portal:service:read"}),
|
||||||
|
),
|
||||||
|
account=SimpleNamespace(id="account-1"),
|
||||||
|
user=SimpleNamespace(id="membership-1"),
|
||||||
|
)
|
||||||
|
entry = PostboxPortalEntryRef(
|
||||||
|
postbox=PostboxDirectoryEntryRef(
|
||||||
|
id="postbox-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
address="clerk.district",
|
||||||
|
address_key="clerk.district",
|
||||||
|
name="District / Clerk",
|
||||||
|
status="active",
|
||||||
|
classification="internal",
|
||||||
|
),
|
||||||
|
unread_count=3,
|
||||||
|
)
|
||||||
|
provider = SimpleNamespace(
|
||||||
|
list_portal_entries=lambda *_args, **_kwargs: (entry,)
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_portal.backend.router.postbox_portal_projection_provider",
|
||||||
|
return_value=provider,
|
||||||
|
):
|
||||||
|
response = api_list_portal_postboxes(
|
||||||
|
limit=100,
|
||||||
|
session=object(),
|
||||||
|
principal=principal,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(response.provider_available)
|
||||||
|
self.assertEqual("postbox-1", response.postboxes[0].postbox["id"])
|
||||||
|
self.assertEqual(3, response.postboxes[0].unread_count)
|
||||||
|
|
||||||
def test_provider_definition_is_available_when_requirements_exist(self) -> None:
|
def test_provider_definition_is_available_when_requirements_exist(self) -> None:
|
||||||
entries = PortalServiceDirectory(Registry(intake=True)).list_entries(
|
entries = PortalServiceDirectory(Registry(intake=True)).list_entries(
|
||||||
None,
|
None,
|
||||||
|
|||||||
@@ -35,6 +35,25 @@ export type PortalServiceListResponse = {
|
|||||||
services: PortalServiceEntry[];
|
services: PortalServiceEntry[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PortalPostboxEntry = {
|
||||||
|
postbox: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
organization_unit_name?: string | null;
|
||||||
|
function_name?: string | null;
|
||||||
|
classification: string;
|
||||||
|
};
|
||||||
|
unread_count: number;
|
||||||
|
latest_message_at?: string | null;
|
||||||
|
route_path: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PortalPostboxListResponse = {
|
||||||
|
provider_available: boolean;
|
||||||
|
postboxes: PortalPostboxEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
export type PortalServiceLaunchResult = {
|
export type PortalServiceLaunchResult = {
|
||||||
service_ref: PortalServiceDefinition["reference"];
|
service_ref: PortalServiceDefinition["reference"];
|
||||||
binding: PortalServiceBinding;
|
binding: PortalServiceBinding;
|
||||||
@@ -66,6 +85,17 @@ export function listPortalServices(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listPortalPostboxes(
|
||||||
|
settings: ApiSettings,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<PortalPostboxListResponse> {
|
||||||
|
return apiFetch<PortalPostboxListResponse>(
|
||||||
|
settings,
|
||||||
|
"/api/v1/portal/postboxes",
|
||||||
|
{ signal }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function launchPortalService(
|
export function launchPortalService(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
serviceId: string,
|
serviceId: string,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ArrowUpRight, Search } from "lucide-react";
|
import { ArrowUpRight, Inbox, Search } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
@@ -20,7 +20,9 @@ import {
|
|||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import {
|
import {
|
||||||
launchPortalService,
|
launchPortalService,
|
||||||
|
listPortalPostboxes,
|
||||||
listPortalServices,
|
listPortalServices,
|
||||||
|
type PortalPostboxEntry,
|
||||||
type PortalServiceEntry
|
type PortalServiceEntry
|
||||||
} from "../../api/portal";
|
} from "../../api/portal";
|
||||||
|
|
||||||
@@ -31,6 +33,7 @@ export default function PortalPage({ settings }: PlatformRouteContext) {
|
|||||||
const [submittedQuery, setSubmittedQuery] = useState("");
|
const [submittedQuery, setSubmittedQuery] = useState("");
|
||||||
const [includeUnavailable, setIncludeUnavailable] = useState(true);
|
const [includeUnavailable, setIncludeUnavailable] = useState(true);
|
||||||
const [services, setServices] = useState<PortalServiceEntry[]>([]);
|
const [services, setServices] = useState<PortalServiceEntry[]>([]);
|
||||||
|
const [postboxes, setPostboxes] = useState<PortalPostboxEntry[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [launchingId, setLaunchingId] = useState("");
|
const [launchingId, setLaunchingId] = useState("");
|
||||||
@@ -62,6 +65,18 @@ export default function PortalPage({ settings }: PlatformRouteContext) {
|
|||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [includeUnavailable, settings, submittedQuery]);
|
}, [includeUnavailable, settings, submittedQuery]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
listPortalPostboxes(settings, controller.signal)
|
||||||
|
.then((response) => setPostboxes(response.postboxes))
|
||||||
|
.catch((reason) => {
|
||||||
|
if ((reason as Error).name !== "AbortError") {
|
||||||
|
setError(reason instanceof Error ? reason.message : "Postboxes could not be loaded.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
const counts = useMemo(() => ({
|
const counts = useMemo(() => ({
|
||||||
available: services.filter((entry) => entry.state === "available").length,
|
available: services.filter((entry) => entry.state === "available").length,
|
||||||
unavailable: services.filter((entry) => entry.state === "unavailable").length
|
unavailable: services.filter((entry) => entry.state === "unavailable").length
|
||||||
@@ -146,6 +161,37 @@ export default function PortalPage({ settings }: PlatformRouteContext) {
|
|||||||
</DismissibleAlert>
|
</DismissibleAlert>
|
||||||
}
|
}
|
||||||
{loading && <LoadingIndicator label="Loading services" />}
|
{loading && <LoadingIndicator label="Loading services" />}
|
||||||
|
{postboxes.length > 0 && (
|
||||||
|
<section className="portal-postboxes" aria-labelledby="portal-postboxes-heading">
|
||||||
|
<div className="portal-section-heading">
|
||||||
|
<Inbox size={18} aria-hidden="true" />
|
||||||
|
<h2 id="portal-postboxes-heading">My function postboxes</h2>
|
||||||
|
</div>
|
||||||
|
<div className="portal-postbox-list">
|
||||||
|
{postboxes.map((entry) => (
|
||||||
|
<button
|
||||||
|
key={entry.postbox.id}
|
||||||
|
type="button"
|
||||||
|
className="portal-postbox-entry"
|
||||||
|
onClick={() => navigate(entry.route_path)}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<strong>{entry.postbox.name}</strong>
|
||||||
|
<small>
|
||||||
|
{[entry.postbox.organization_unit_name, entry.postbox.function_name]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" / ") || entry.postbox.address}
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
<span className="portal-postbox-count" aria-label={`${entry.unread_count} unread messages`}>
|
||||||
|
{entry.unread_count > 99 ? "99+" : entry.unread_count}
|
||||||
|
</span>
|
||||||
|
<ArrowUpRight size={15} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
{!loading && !error && services.length === 0 &&
|
{!loading && !error && services.length === 0 &&
|
||||||
<div className="portal-empty">No matching services.</div>
|
<div className="portal-empty">No matching services.</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,73 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.portal-postboxes {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-section-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-section-heading h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-postbox-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-postbox-entry {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 56px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface-raised);
|
||||||
|
color: var(--text);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-postbox-entry:hover,
|
||||||
|
.portal-postbox-entry:focus-visible {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-postbox-entry > span:first-child {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-postbox-entry small {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-soft);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-postbox-count {
|
||||||
|
min-width: 24px;
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--on-accent);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.portal-service-entry {
|
.portal-service-entry {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
Reference in New Issue
Block a user