perf(campaign): search share targets server-side

This commit is contained in:
2026-07-30 01:30:13 +02:00
parent 46df12c025
commit c769be39da
5 changed files with 285 additions and 22 deletions

View File

@@ -32,6 +32,7 @@ from govoplan_core.core.postbox import (
CAPABILITY_POSTBOX_DELIVERY, CAPABILITY_POSTBOX_DELIVERY,
CAPABILITY_POSTBOX_DIRECTORY, 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.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.db import models as campaign_models # noqa: F401 - populate Campaign ORM metadata
from govoplan_campaign.backend.documentation import CAMPAIGN_USER_DOCUMENTATION, documentation_topics from govoplan_campaign.backend.documentation import CAMPAIGN_USER_DOCUMENTATION, documentation_topics
@@ -200,6 +201,7 @@ manifest = ModuleManifest(
name="Campaigns", name="Campaigns",
version="0.1.12", version="0.1.12",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_capabilities=(CAPABILITY_ACCESS_REFERENCE_OPTIONS,),
optional_dependencies=( optional_dependencies=(
"files", "files",
"mail", "mail",
@@ -215,6 +217,12 @@ manifest = ModuleManifest(
ModuleInterfaceProvider(name="campaigns.retention", version="0.1.6"), ModuleInterfaceProvider(name="campaigns.retention", version="0.1.6"),
), ),
requires_interfaces=( requires_interfaces=(
ModuleInterfaceRequirement(
name=CAPABILITY_ACCESS_REFERENCE_OPTIONS,
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement( ModuleInterfaceRequirement(
name="files.campaign_attachments", name="files.campaign_attachments",
version_min="0.1.0", version_min="0.1.0",

View File

@@ -1,9 +1,15 @@
from __future__ import annotations from __future__ import annotations
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.api.v1.schemas import (
ReferenceOptionListResponse,
ReferenceOptionResponse,
)
from govoplan_campaign.backend.schemas import ( from govoplan_campaign.backend.schemas import (
CampaignShareItem, CampaignShareItem,
CampaignShareListResponse, CampaignShareListResponse,
@@ -19,6 +25,11 @@ from govoplan_campaign.backend.db.models import (
CampaignShare, CampaignShare,
) )
from govoplan_core.db.session import get_session 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 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 = 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) @router.get("/{campaign_id}/share-targets", response_model=CampaignShareTargetsResponse)
def list_campaign_share_targets( def list_campaign_share_targets(
campaign_id: str, campaign_id: str,

View File

@@ -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()

View File

@@ -1,5 +1,9 @@
import type { ApiSettings, CampaignListItem, DeltaDeletedItem } from "../types"; import type { ApiSettings, CampaignListItem, DeltaDeletedItem } from "../types";
import { apiDownload, apiFetch } from "./client"; import { apiDownload, apiFetch } from "./client";
import {
apiReferenceOptionProvider,
type ReferenceOptionProvider
} from "@govoplan/core-webui";
import { campaignJobsQueryParams, type CampaignJobsQueryParameters } from "../features/campaigns/utils/jobListQuery"; import { campaignJobsQueryParams, type CampaignJobsQueryParameters } from "../features/campaigns/utils/jobListQuery";
export { fetchResourceAccessExplanation } from "@govoplan/core-webui"; export { fetchResourceAccessExplanation } from "@govoplan/core-webui";
export type { export type {
@@ -1125,6 +1129,18 @@ export async function getCampaignShareTargets(settings: ApiSettings, campaignId:
return apiFetch<CampaignShareTargets>(settings, `/api/v1/campaigns/${campaignId}/share-targets`); return apiFetch<CampaignShareTargets>(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<CampaignShare[]> { export async function getCampaignShares(settings: ApiSettings, campaignId: string): Promise<CampaignShare[]> {
const pageSize = 500; const pageSize = 500;
const shares: CampaignShare[] = []; const shares: CampaignShare[] = [];

View File

@@ -4,15 +4,20 @@ import { KeyRound, Trash2 } from "lucide-react";
import { import {
fetchResourceAccessExplanation, fetchResourceAccessExplanation,
getCampaignShares, getCampaignShares,
getCampaignShareTargets, campaignShareTargetProvider,
revokeCampaignShare, revokeCampaignShare,
updateCampaignOwner, updateCampaignOwner,
upsertCampaignShare, upsertCampaignShare,
type CampaignShare, type CampaignShare,
type CampaignShareTargets,
type ResourceAccessExplanationResponse } from type ResourceAccessExplanationResponse } from
"../../../api/campaigns"; "../../../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 { Card } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui"; import { ConfirmDialog } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } 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<void>;onError: (message: string) => void;}) { }: {settings: ApiSettings;auth: AuthInfo;campaign: CampaignListItem;onChanged: () => Promise<void>;onError: (message: string) => void;}) {
const [available, setAvailable] = useState<boolean | null>(null); const [available, setAvailable] = useState<boolean | null>(null);
const [targets, setTargets] = useState<CampaignShareTargets>({ users: [], groups: [] }); const [targets, setTargets] = useState<{
users: readonly ReferenceOption[];
groups: readonly ReferenceOption[];
}>({ users: [], groups: [] });
const [shares, setShares] = useState<CampaignShare[]>([]); const [shares, setShares] = useState<CampaignShare[]>([]);
const [ownerOpen, setOwnerOpen] = useState(false); const [ownerOpen, setOwnerOpen] = useState(false);
const [shareOpen, setShareOpen] = useState(false); const [shareOpen, setShareOpen] = useState(false);
@@ -57,6 +65,14 @@ export default function CampaignAccessCard({
hasScope(auth, "admin:roles:read") || hasScope(auth, "admin:roles:read") ||
hasScope(auth, "access:membership:read") || hasScope(auth, "access:membership:read") ||
hasScope(auth, "access:role: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({ useUnsavedDraftGuard({
dirty: ownerDirty || shareDirty, dirty: ownerDirty || shareDirty,
@@ -69,12 +85,25 @@ export default function CampaignAccessCard({
async function load() { async function load() {
try { try {
const [nextTargets, nextShares] = await Promise.all([ const nextShares = (await getCampaignShares(settings, campaign.id))
getCampaignShareTargets(settings, campaign.id), .filter((item) => !item.revoked_at);
getCampaignShares(settings, campaign.id)] const userIds = uniqueTargetIds(
nextShares,
"user",
campaign.owner_user_id
); );
setTargets(nextTargets); const groupIds = uniqueTargetIds(
setShares(nextShares.filter((item) => !item.revoked_at)); 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); setAvailable(true);
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(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"); setOwnerType(campaign.owner_group_id ? "group" : "user");
setOwnerId(campaign.owner_group_id || campaign.owner_user_id || ""); setOwnerId(campaign.owner_group_id || campaign.owner_user_id || "");
void load(); 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 ownerTargetProvider = ownerType === "user" ? userTargetProvider : groupTargetProvider;
const shareTargetOptions = shareType === "user" ? targets.users : targets.groups; const shareTargetProvider = shareType === "user" ? userTargetProvider : groupTargetProvider;
const targetMap = useMemo(() => new Map([ const targetMap = useMemo(() => new Map([
...targets.users.map((item) => [`user:${item.id}`, item] as const), ...targets.users.map((item) => [`user:${item.value}`, item] as const),
...targets.groups.map((item) => [`group:${item.id}`, item] as const)] ...targets.groups.map((item) => [`group:${item.value}`, item] as const)]
), [targets]); ), [targets]);
const ownerLabel = campaign.owner_group_id ? const ownerLabel = campaign.owner_group_id ?
targetMap.get(`group:${campaign.owner_group_id}`)?.name || "i18n:govoplan-campaign.group_owner.55630670" : targetMap.get(`group:${campaign.owner_group_id}`)?.label || "i18n:govoplan-campaign.group_owner.55630670" :
targetMap.get(`user:${campaign.owner_user_id || ""}`)?.name || "i18n:govoplan-campaign.user_owner.86e3faab"; targetMap.get(`user:${campaign.owner_user_id || ""}`)?.label || "i18n:govoplan-campaign.user_owner.86e3faab";
function closeOwnerDialog() { function closeOwnerDialog() {
setOwnerOpen(false); setOwnerOpen(false);
@@ -189,10 +227,10 @@ export default function CampaignAccessCard({
sticky: "start", sticky: "start",
sortable: true, sortable: true,
filterable: 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) => { render: (row) => {
const target = targetMap.get(`${row.target_type}:${row.target_id}`); const target = targetMap.get(`${row.target_type}:${row.target_id}`);
return <div><strong>{target?.name || row.target_id}</strong><div className="muted small-note">{row.target_type}{target?.secondary ? i18nMessage("i18n:govoplan-campaign.value.48afe802", { value0: target.secondary }) : ""}</div></div>; return <div><strong>{target?.label || row.target_id}</strong><div className="muted small-note">{row.target_type}{target?.description ? i18nMessage("i18n:govoplan-campaign.value.48afe802", { value0: target.description }) : ""}</div></div>;
} }
}, },
{ id: "permission", header: "i18n:govoplan-campaign.access.2f81a22d", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.permission, render: (row) => <StatusBadge status={row.permission === "write" ? "active" : "built"} label={row.permission === "write" ? "i18n:govoplan-campaign.can_edit.cb0ab3da" : "i18n:govoplan-campaign.can_view.1b3e4006"} /> }, { id: "permission", header: "i18n:govoplan-campaign.access.2f81a22d", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.permission, render: (row) => <StatusBadge status={row.permission === "write" ? "active" : "built"} label={row.permission === "write" ? "i18n:govoplan-campaign.can_edit.cb0ab3da" : "i18n:govoplan-campaign.can_view.1b3e4006"} /> },
@@ -210,14 +248,32 @@ export default function CampaignAccessCard({
</Card> </Card>
<Dialog open={ownerOpen} className="campaign-access-dialog" title="i18n:govoplan-campaign.change_campaign_owner.63f80aef" onClose={() => !busy && closeOwnerDialog()} footer={<><Button onClick={closeOwnerDialog} disabled={busy}>i18n:govoplan-campaign.cancel.77dfd213</Button><Button variant="primary" onClick={() => void saveOwner()} disabled={busy || !ownerId}>i18n:govoplan-campaign.save_owner.b6763847</Button></>}> <Dialog open={ownerOpen} className="campaign-access-dialog" title="i18n:govoplan-campaign.change_campaign_owner.63f80aef" onClose={() => !busy && closeOwnerDialog()} footer={<><Button onClick={closeOwnerDialog} disabled={busy}>i18n:govoplan-campaign.cancel.77dfd213</Button><Button variant="primary" onClick={() => void saveOwner()} disabled={busy || !ownerId}>i18n:govoplan-campaign.save_owner.b6763847</Button></>}>
<FormField label="i18n:govoplan-campaign.owner_type.6b86eacc"><select value={ownerType} onChange={(event) => {const next = event.target.value as TargetType;setOwnerType(next);setOwnerId((next === "user" ? targets.users : targets.groups)[0]?.id || "");}}><option value="user">i18n:govoplan-campaign.user.9f8a2389</option><option value="group">i18n:govoplan-campaign.group.171a0606</option></select></FormField> <FormField label="i18n:govoplan-campaign.owner_type.6b86eacc"><select value={ownerType} onChange={(event) => {const next = event.target.value as TargetType;setOwnerType(next);setOwnerId("");}}><option value="user">i18n:govoplan-campaign.user.9f8a2389</option><option value="group">i18n:govoplan-campaign.group.171a0606</option></select></FormField>
<FormField label="i18n:govoplan-campaign.owner.89ff3122"><select value={ownerId} onChange={(event) => setOwnerId(event.target.value)}>{targetOptions.map((item) => <option key={item.id} value={item.id}>{item.name}{item.secondary ? i18nMessage("i18n:govoplan-campaign.value.48afe802", { value0: item.secondary }) : ""}</option>)}</select></FormField> <FormField label="i18n:govoplan-campaign.owner.89ff3122">
<ReferenceSelect
value={ownerId}
onChange={(value) => setOwnerId(value)}
provider={ownerTargetProvider}
selectedOption={targetMap.get(`${ownerType}:${ownerId}`) ?? null}
disabled={busy}
placeholder="i18n:govoplan-campaign.select.349ac8fb"
/>
</FormField>
<p className="muted small-note">Changing the owner clears a selected reusable mail profile from the editable current version and requires profile reselection plus validation before live delivery.</p> <p className="muted small-note">Changing the owner clears a selected reusable mail profile from the editable current version and requires profile reselection plus validation before live delivery.</p>
</Dialog> </Dialog>
<Dialog open={shareOpen} className="campaign-access-dialog" title="i18n:govoplan-campaign.share_campaign.b605982b" onClose={() => !busy && closeShareDialog()} footer={<><Button onClick={closeShareDialog} disabled={busy}>i18n:govoplan-campaign.cancel.77dfd213</Button><Button variant="primary" onClick={() => void saveShare()} disabled={busy || !shareTargetId}>i18n:govoplan-campaign.save_share.bcf6ed94</Button></>}> <Dialog open={shareOpen} className="campaign-access-dialog" title="i18n:govoplan-campaign.share_campaign.b605982b" onClose={() => !busy && closeShareDialog()} footer={<><Button onClick={closeShareDialog} disabled={busy}>i18n:govoplan-campaign.cancel.77dfd213</Button><Button variant="primary" onClick={() => void saveShare()} disabled={busy || !shareTargetId}>i18n:govoplan-campaign.save_share.bcf6ed94</Button></>}>
<FormField label="i18n:govoplan-campaign.target_type.a45f8055"><select value={shareType} onChange={(event) => {const next = event.target.value as TargetType;setShareType(next);setShareTargetId((next === "user" ? targets.users : targets.groups)[0]?.id || "");}}><option value="user">i18n:govoplan-campaign.user.9f8a2389</option><option value="group">i18n:govoplan-campaign.group.171a0606</option></select></FormField> <FormField label="i18n:govoplan-campaign.target_type.a45f8055"><select value={shareType} onChange={(event) => {const next = event.target.value as TargetType;setShareType(next);setShareTargetId("");}}><option value="user">i18n:govoplan-campaign.user.9f8a2389</option><option value="group">i18n:govoplan-campaign.group.171a0606</option></select></FormField>
<FormField label="i18n:govoplan-campaign.user_or_group.53406ef0"><select value={shareTargetId} onChange={(event) => setShareTargetId(event.target.value)}><option value="">i18n:govoplan-campaign.select.349ac8fb</option>{shareTargetOptions.map((item) => <option key={item.id} value={item.id}>{item.name}{item.secondary ? i18nMessage("i18n:govoplan-campaign.value.48afe802", { value0: item.secondary }) : ""}</option>)}</select></FormField> <FormField label="i18n:govoplan-campaign.user_or_group.53406ef0">
<ReferenceSelect
value={shareTargetId}
onChange={(value) => setShareTargetId(value)}
provider={shareTargetProvider}
selectedOption={targetMap.get(`${shareType}:${shareTargetId}`) ?? null}
disabled={busy}
placeholder="i18n:govoplan-campaign.select.349ac8fb"
/>
</FormField>
<FormField label="i18n:govoplan-campaign.access.2f81a22d"><select value={sharePermission} onChange={(event) => setSharePermission(event.target.value as "read" | "write")}><option value="read">i18n:govoplan-campaign.can_view.1b3e4006</option><option value="write">i18n:govoplan-campaign.can_edit_and_operate.77acb15e</option></select></FormField> <FormField label="i18n:govoplan-campaign.access.2f81a22d"><select value={sharePermission} onChange={(event) => setSharePermission(event.target.value as "read" | "write")}><option value="read">i18n:govoplan-campaign.can_view.1b3e4006</option><option value="write">i18n:govoplan-campaign.can_edit_and_operate.77acb15e</option></select></FormField>
</Dialog> </Dialog>
@@ -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<readonly ReferenceOption[]> {
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
});
}