From c769be39da4f8bca3f2e89d040402752d8b479e3 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Thu, 30 Jul 2026 01:30:13 +0200 Subject: [PATCH] perf(campaign): search share targets server-side --- src/govoplan_campaign/backend/manifest.py | 8 ++ .../backend/routes/sharing.py | 56 ++++++++ tests/test_share_target_search.py | 94 +++++++++++++ webui/src/api/campaigns.ts | 16 +++ .../components/CampaignAccessCard.tsx | 133 +++++++++++++++--- 5 files changed, 285 insertions(+), 22 deletions(-) create mode 100644 tests/test_share_target_search.py diff --git a/src/govoplan_campaign/backend/manifest.py b/src/govoplan_campaign/backend/manifest.py index 5c3bcba..483d694 100644 --- a/src/govoplan_campaign/backend/manifest.py +++ b/src/govoplan_campaign/backend/manifest.py @@ -32,6 +32,7 @@ from govoplan_core.core.postbox import ( CAPABILITY_POSTBOX_DELIVERY, CAPABILITY_POSTBOX_DIRECTORY, ) +from govoplan_core.core.references import CAPABILITY_ACCESS_REFERENCE_OPTIONS from govoplan_campaign.backend.change_tracking import register_campaign_change_tracking from govoplan_campaign.backend.db import models as campaign_models # noqa: F401 - populate Campaign ORM metadata from govoplan_campaign.backend.documentation import CAMPAIGN_USER_DOCUMENTATION, documentation_topics @@ -200,6 +201,7 @@ manifest = ModuleManifest( name="Campaigns", version="0.1.12", required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), + optional_capabilities=(CAPABILITY_ACCESS_REFERENCE_OPTIONS,), optional_dependencies=( "files", "mail", @@ -215,6 +217,12 @@ manifest = ModuleManifest( ModuleInterfaceProvider(name="campaigns.retention", version="0.1.6"), ), requires_interfaces=( + ModuleInterfaceRequirement( + name=CAPABILITY_ACCESS_REFERENCE_OPTIONS, + version_min="0.1.0", + version_max_exclusive="0.2.0", + optional=True, + ), ModuleInterfaceRequirement( name="files.campaign_attachments", version_min="0.1.0", diff --git a/src/govoplan_campaign/backend/routes/sharing.py b/src/govoplan_campaign/backend/routes/sharing.py index cb78645..7c696df 100644 --- a/src/govoplan_campaign/backend/routes/sharing.py +++ b/src/govoplan_campaign/backend/routes/sharing.py @@ -1,9 +1,15 @@ from __future__ import annotations +from typing import Literal + from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.orm import Session +from govoplan_core.api.v1.schemas import ( + ReferenceOptionListResponse, + ReferenceOptionResponse, +) from govoplan_campaign.backend.schemas import ( CampaignShareItem, CampaignShareListResponse, @@ -19,6 +25,11 @@ from govoplan_campaign.backend.db.models import ( CampaignShare, ) from govoplan_core.db.session import get_session +from govoplan_core.core.references import ( + access_scope_reference_page, + access_scope_reference_provider_available, +) +from govoplan_core.core.runtime import get_registry from govoplan_core.security.time import utc_now @@ -31,6 +42,51 @@ from govoplan_campaign.backend.route_support import ( router = APIRouter(prefix="/campaigns", tags=["campaigns"]) +@router.get( + "/{campaign_id}/share-target-options", + response_model=ReferenceOptionListResponse, +) +def search_campaign_share_targets( + campaign_id: str, + target_type: Literal["user", "group"], + q: str = "", + selected: list[str] = Query(default=[]), + limit: int = Query(default=50, ge=1, le=200), + cursor: str | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")), +) -> ReferenceOptionListResponse: + _get_campaign_for_principal(session, campaign_id, principal, write=True) + registry = get_registry() + try: + page = access_scope_reference_page( + registry, + principal, + scope_type=target_type, + reference_kind="membership" if target_type == "user" else "group", + query=q, + selected_values=selected, + limit=limit, + cursor=cursor, + administrative=True, + session=session, + ) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=str(exc), + ) from exc + return ReferenceOptionListResponse( + options=[ + ReferenceOptionResponse(**option.to_dict()) + for option in page.options + ], + provider_available=access_scope_reference_provider_available(registry), + next_cursor=page.next_cursor, + has_more=page.has_more, + ) + + @router.get("/{campaign_id}/share-targets", response_model=CampaignShareTargetsResponse) def list_campaign_share_targets( campaign_id: str, diff --git a/tests/test_share_target_search.py b/tests/test_share_target_search.py new file mode 100644 index 0000000..8e3688f --- /dev/null +++ b/tests/test_share_target_search.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +from govoplan_campaign.backend.routes.sharing import ( + search_campaign_share_targets, +) +from govoplan_core.core.references import ( + CAPABILITY_ACCESS_REFERENCE_OPTIONS, + ReferenceOption, + ReferenceSearchPage, +) + + +class _Provider: + def __init__(self) -> None: + self.requests = [] + + def search_reference_options(self, session, principal, *, request): + del session, principal + self.requests.append(request) + return ReferenceSearchPage( + options=( + ReferenceOption( + value="membership-1", + label="Ada", + kind="membership", + source_module="access", + ), + ), + next_cursor="offset:1", + has_more=True, + ) + + +class _Registry: + def __init__(self) -> None: + self.provider = _Provider() + + def has_capability(self, name): + return name == CAPABILITY_ACCESS_REFERENCE_OPTIONS + + def capability(self, name): + return self.provider if self.has_capability(name) else None + + +class CampaignShareTargetSearchTests(unittest.TestCase): + def test_search_uses_membership_references_and_retains_stale_targets(self) -> None: + registry = _Registry() + principal = SimpleNamespace( + tenant_id="tenant-1", + account_id="account-1", + membership_id="membership-1", + group_ids=frozenset(), + ) + + with ( + patch( + "govoplan_campaign.backend.routes.sharing._get_campaign_for_principal" + ), + patch( + "govoplan_campaign.backend.routes.sharing.get_registry", + return_value=registry, + ), + ): + response = search_campaign_share_targets( + "campaign-1", + "user", + q="Ada", + selected=["removed-membership"], + limit=25, + cursor="offset:0", + session=object(), # type: ignore[arg-type] + principal=principal, # type: ignore[arg-type] + ) + + request = registry.provider.requests[0] + self.assertEqual("membership", request.kind) + self.assertEqual("tenant-1", request.tenant_id) + self.assertEqual("ada", request.query) + self.assertTrue(request.context["administrative"]) + self.assertEqual( + ["membership-1", "removed-membership"], + [option.value for option in response.options], + ) + self.assertEqual("unavailable", response.options[1].availability) + self.assertTrue(response.has_more) + self.assertEqual("offset:1", response.next_cursor) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/src/api/campaigns.ts b/webui/src/api/campaigns.ts index 34d2773..2cc8055 100644 --- a/webui/src/api/campaigns.ts +++ b/webui/src/api/campaigns.ts @@ -1,5 +1,9 @@ import type { ApiSettings, CampaignListItem, DeltaDeletedItem } from "../types"; import { apiDownload, apiFetch } from "./client"; +import { + apiReferenceOptionProvider, + type ReferenceOptionProvider +} from "@govoplan/core-webui"; import { campaignJobsQueryParams, type CampaignJobsQueryParameters } from "../features/campaigns/utils/jobListQuery"; export { fetchResourceAccessExplanation } from "@govoplan/core-webui"; export type { @@ -1125,6 +1129,18 @@ export async function getCampaignShareTargets(settings: ApiSettings, campaignId: return apiFetch(settings, `/api/v1/campaigns/${campaignId}/share-targets`); } +export function campaignShareTargetProvider( + settings: ApiSettings, + campaignId: string, + targetType: "user" | "group" +): ReferenceOptionProvider { + return apiReferenceOptionProvider( + settings, + `/api/v1/campaigns/${campaignId}/share-target-options`, + { target_type: targetType } + ); +} + export async function getCampaignShares(settings: ApiSettings, campaignId: string): Promise { const pageSize = 500; const shares: CampaignShare[] = []; diff --git a/webui/src/features/campaigns/components/CampaignAccessCard.tsx b/webui/src/features/campaigns/components/CampaignAccessCard.tsx index 64696c1..0db7a55 100644 --- a/webui/src/features/campaigns/components/CampaignAccessCard.tsx +++ b/webui/src/features/campaigns/components/CampaignAccessCard.tsx @@ -4,15 +4,20 @@ import { KeyRound, Trash2 } from "lucide-react"; import { fetchResourceAccessExplanation, getCampaignShares, - getCampaignShareTargets, + campaignShareTargetProvider, revokeCampaignShare, updateCampaignOwner, upsertCampaignShare, type CampaignShare, - type CampaignShareTargets, type ResourceAccessExplanationResponse } from "../../../api/campaigns"; -import { Button, ResourceAccessExplanation } from "@govoplan/core-webui"; +import { + Button, + ReferenceSelect, + ResourceAccessExplanation, + type ReferenceOption, + type ReferenceOptionProvider +} from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui"; import { ConfirmDialog } from "@govoplan/core-webui"; import { DataGrid, type DataGridColumn } from "@govoplan/core-webui"; @@ -34,7 +39,10 @@ export default function CampaignAccessCard({ }: {settings: ApiSettings;auth: AuthInfo;campaign: CampaignListItem;onChanged: () => Promise;onError: (message: string) => void;}) { const [available, setAvailable] = useState(null); - const [targets, setTargets] = useState({ users: [], groups: [] }); + const [targets, setTargets] = useState<{ + users: readonly ReferenceOption[]; + groups: readonly ReferenceOption[]; + }>({ users: [], groups: [] }); const [shares, setShares] = useState([]); const [ownerOpen, setOwnerOpen] = useState(false); const [shareOpen, setShareOpen] = useState(false); @@ -57,6 +65,14 @@ export default function CampaignAccessCard({ hasScope(auth, "admin:roles:read") || hasScope(auth, "access:membership:read") || hasScope(auth, "access:role:read"); + const userTargetProvider = useMemo( + () => campaignShareTargetProvider(settings, campaign.id, "user"), + [campaign.id, settings.accessToken, settings.apiBaseUrl, settings.apiKey] + ); + const groupTargetProvider = useMemo( + () => campaignShareTargetProvider(settings, campaign.id, "group"), + [campaign.id, settings.accessToken, settings.apiBaseUrl, settings.apiKey] + ); useUnsavedDraftGuard({ dirty: ownerDirty || shareDirty, @@ -69,12 +85,25 @@ export default function CampaignAccessCard({ async function load() { try { - const [nextTargets, nextShares] = await Promise.all([ - getCampaignShareTargets(settings, campaign.id), - getCampaignShares(settings, campaign.id)] + const nextShares = (await getCampaignShares(settings, campaign.id)) + .filter((item) => !item.revoked_at); + const userIds = uniqueTargetIds( + nextShares, + "user", + campaign.owner_user_id ); - setTargets(nextTargets); - setShares(nextShares.filter((item) => !item.revoked_at)); + const groupIds = uniqueTargetIds( + nextShares, + "group", + campaign.owner_group_id + ); + const controller = new AbortController(); + const [users, groups] = await Promise.all([ + resolveReferenceOptions(userTargetProvider, userIds, controller.signal), + resolveReferenceOptions(groupTargetProvider, groupIds, controller.signal) + ]); + setTargets({ users, groups }); + setShares(nextShares); setAvailable(true); } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -87,18 +116,27 @@ export default function CampaignAccessCard({ setOwnerType(campaign.owner_group_id ? "group" : "user"); setOwnerId(campaign.owner_group_id || campaign.owner_user_id || ""); void load(); - }, [campaign.id, campaign.owner_group_id, campaign.owner_user_id, settings.accessToken, settings.apiBaseUrl]); + }, [ + campaign.id, + campaign.owner_group_id, + campaign.owner_user_id, + groupTargetProvider, + settings.accessToken, + settings.apiBaseUrl, + settings.apiKey, + userTargetProvider + ]); - const targetOptions = ownerType === "user" ? targets.users : targets.groups; - const shareTargetOptions = shareType === "user" ? targets.users : targets.groups; + const ownerTargetProvider = ownerType === "user" ? userTargetProvider : groupTargetProvider; + const shareTargetProvider = shareType === "user" ? userTargetProvider : groupTargetProvider; const targetMap = useMemo(() => new Map([ - ...targets.users.map((item) => [`user:${item.id}`, item] as const), - ...targets.groups.map((item) => [`group:${item.id}`, item] as const)] + ...targets.users.map((item) => [`user:${item.value}`, item] as const), + ...targets.groups.map((item) => [`group:${item.value}`, item] as const)] ), [targets]); const ownerLabel = campaign.owner_group_id ? - targetMap.get(`group:${campaign.owner_group_id}`)?.name || "i18n:govoplan-campaign.group_owner.55630670" : - targetMap.get(`user:${campaign.owner_user_id || ""}`)?.name || "i18n:govoplan-campaign.user_owner.86e3faab"; + targetMap.get(`group:${campaign.owner_group_id}`)?.label || "i18n:govoplan-campaign.group_owner.55630670" : + targetMap.get(`user:${campaign.owner_user_id || ""}`)?.label || "i18n:govoplan-campaign.user_owner.86e3faab"; function closeOwnerDialog() { setOwnerOpen(false); @@ -189,10 +227,10 @@ export default function CampaignAccessCard({ sticky: "start", sortable: true, filterable: true, - value: (row) => targetMap.get(`${row.target_type}:${row.target_id}`)?.name || row.target_id, + value: (row) => targetMap.get(`${row.target_type}:${row.target_id}`)?.label || row.target_id, render: (row) => { const target = targetMap.get(`${row.target_type}:${row.target_id}`); - return
{target?.name || row.target_id}
{row.target_type}{target?.secondary ? i18nMessage("i18n:govoplan-campaign.value.48afe802", { value0: target.secondary }) : ""}
; + return
{target?.label || row.target_id}
{row.target_type}{target?.description ? i18nMessage("i18n:govoplan-campaign.value.48afe802", { value0: target.description }) : ""}
; } }, { id: "permission", header: "i18n:govoplan-campaign.access.2f81a22d", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.permission, render: (row) => }, @@ -210,14 +248,32 @@ export default function CampaignAccessCard({ !busy && closeOwnerDialog()} footer={<>}> - - + + + setOwnerId(value)} + provider={ownerTargetProvider} + selectedOption={targetMap.get(`${ownerType}:${ownerId}`) ?? null} + disabled={busy} + placeholder="i18n:govoplan-campaign.select.349ac8fb" + /> +

Changing the owner clears a selected reusable mail profile from the editable current version and requires profile reselection plus validation before live delivery.

!busy && closeShareDialog()} footer={<>}> - - + + + setShareTargetId(value)} + provider={shareTargetProvider} + selectedOption={targetMap.get(`${shareType}:${shareTargetId}`) ?? null} + disabled={busy} + placeholder="i18n:govoplan-campaign.select.349ac8fb" + /> + @@ -229,3 +285,36 @@ export default function CampaignAccessCard({ ); } + +function uniqueTargetIds( + shares: readonly CampaignShare[], + targetType: TargetType, + ownerId?: string | null +): string[] { + return [...new Set([ + ...(ownerId ? [ownerId] : []), + ...shares + .filter((share) => share.target_type === targetType) + .map((share) => share.target_id) + ])]; +} + +async function resolveReferenceOptions( + provider: ReferenceOptionProvider, + values: readonly string[], + signal: AbortSignal +): Promise { + if (!values.length) return []; + if (!provider.resolve) { + return provider.search("", { + selectedValues: values, + limit: Math.min(200, Math.max(1, values.length)), + signal + }); + } + return provider.resolve(values, { + selectedValues: values, + limit: Math.min(200, Math.max(1, values.length)), + signal + }); +}