feat: add searchable view assignment targets

This commit is contained in:
2026-07-29 14:16:29 +02:00
parent 3de8d4aa5f
commit c24d1f2ca4
7 changed files with 525 additions and 30 deletions
+232 -7
View File
@@ -5,6 +5,7 @@ from sqlalchemy.orm import Session
from govoplan_core.audit.logging import audit_event
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.core.access import CAPABILITY_ACCESS_DIRECTORY, AccessDirectory
from govoplan_core.core.views import VIEW_SURFACE_CONTRACT_VERSION, ViewSurface
from govoplan_core.db.session import get_session
from govoplan_views.backend.manifest import (
@@ -31,6 +32,8 @@ from govoplan_views.backend.schemas import (
ViewAssignmentCreateRequest,
ViewAssignmentListResponse,
ViewAssignmentResponse,
ViewAssignmentTargetListResponse,
ViewAssignmentTargetResponse,
ViewAssignmentUpdateRequest,
ViewDefinitionCreateRequest,
ViewDefinitionListResponse,
@@ -77,6 +80,168 @@ def _catalogue() -> tuple[ViewSurface, ...]:
return get_registry().view_surfaces()
def _optional_access_directory() -> AccessDirectory | None:
registry = get_registry()
if not registry.has_capability(CAPABILITY_ACCESS_DIRECTORY):
return None
capability = registry.capability(CAPABILITY_ACCESS_DIRECTORY)
if not isinstance(capability, AccessDirectory):
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Access directory capability is invalid.",
)
return capability
def _all_assignment_targets(
directory: AccessDirectory,
*,
tenant_id: str,
scope_type: str,
) -> list[ViewAssignmentTargetResponse]:
if scope_type == "user":
by_account_id: dict[str, ViewAssignmentTargetResponse] = {}
for user in directory.users_for_tenant(tenant_id):
label = (user.display_name or user.email or user.account_id).strip()
detail = user.email if user.email and user.email != label else None
target = ViewAssignmentTargetResponse(
id=user.account_id,
scope_type="user",
label=label,
detail=detail,
disabled=user.status != "active",
disabled_reason=(
"This user is inactive." if user.status != "active" else None
),
)
current = by_account_id.get(user.account_id)
if current is None or (current.disabled and not target.disabled):
by_account_id[user.account_id] = target
targets = list(by_account_id.values())
elif scope_type == "group":
targets = [
ViewAssignmentTargetResponse(
id=group.id,
scope_type="group",
label=group.name,
disabled=group.status != "active",
disabled_reason=(
"This group is inactive." if group.status != "active" else None
),
)
for group in directory.groups_for_tenant(tenant_id)
]
else:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"Unsupported View assignment target scope: {scope_type}",
)
return sorted(
targets,
key=lambda item: (item.disabled, item.label.casefold(), item.id),
)
def _search_assignment_targets(
directory: AccessDirectory,
*,
tenant_id: str,
scope_type: str,
query: str,
limit: int,
) -> list[ViewAssignmentTargetResponse]:
needle = query.strip().casefold()
targets = _all_assignment_targets(
directory,
tenant_id=tenant_id,
scope_type=scope_type,
)
if needle:
targets = [
target
for target in targets
if needle
in " ".join(
(
target.label,
target.detail or "",
target.id,
)
).casefold()
]
return targets[:limit]
def _assignment_target_lookup(
directory: AccessDirectory | None,
*,
tenant_id: str | None,
assignments: list[object],
) -> dict[tuple[str, str], ViewAssignmentTargetResponse]:
if directory is None or tenant_id is None:
return {}
scope_types = {
str(getattr(assignment, "scope_type", ""))
for assignment in assignments
if getattr(assignment, "scope_id", None)
}
result: dict[tuple[str, str], ViewAssignmentTargetResponse] = {}
for scope_type in scope_types.intersection({"group", "user"}):
for target in _all_assignment_targets(
directory,
tenant_id=tenant_id,
scope_type=scope_type,
):
result[(scope_type, target.id)] = target
return result
def _assignment_response(
assignment: object,
*,
targets: dict[tuple[str, str], ViewAssignmentTargetResponse],
) -> ViewAssignmentResponse:
payload = assignment_payload(assignment)
scope_type = str(payload.get("scope_type") or "")
scope_id = str(payload.get("scope_id") or "")
target = targets.get((scope_type, scope_id))
if target is not None:
payload["scope_label"] = target.label
payload["scope_detail"] = target.detail
return ViewAssignmentResponse.model_validate(payload)
def _validate_assignment_target(
directory: AccessDirectory | None,
*,
tenant_id: str | None,
scope_type: str,
scope_id: str | None,
) -> None:
if directory is None or tenant_id is None or scope_type not in {"group", "user"}:
return
target = next(
(
candidate
for candidate in _all_assignment_targets(
directory,
tenant_id=tenant_id,
scope_type=scope_type,
)
if candidate.id == scope_id
),
None,
)
if target is None:
raise ViewsValidationError(
f"The selected {scope_type} does not exist in the current tenant."
)
if target.disabled:
raise ViewsValidationError(
f"The selected {scope_type} is inactive and cannot receive a View assignment."
)
def _require_any_scope(principal: ApiPrincipal, *scopes: str) -> None:
if any(has_scope(principal, scope) for scope in scopes):
return
@@ -697,6 +862,42 @@ def api_archive_definition(
raise _http_error(exc) from exc
@router.get(
"/assignment-targets",
response_model=ViewAssignmentTargetListResponse,
)
def api_list_assignment_targets(
scope_type: str = Query(pattern="^(group|user)$"),
query: str = Query(default="", max_length=200),
limit: int = Query(default=50, ge=1, le=200),
principal: ApiPrincipal = Depends(get_api_principal),
) -> ViewAssignmentTargetListResponse:
_require_assignment_read(principal, "tenant")
if principal.tenant_id is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="User and group View assignments require an active tenant.",
)
directory = _optional_access_directory()
if directory is None:
return ViewAssignmentTargetListResponse(
directory_available=False,
unavailable_reason=(
"User and group selection requires an enabled Access directory."
),
)
return ViewAssignmentTargetListResponse(
directory_available=True,
targets=_search_assignment_targets(
directory,
tenant_id=principal.tenant_id,
scope_type=scope_type,
query=query,
limit=limit,
),
)
@router.get("/assignments", response_model=ViewAssignmentListResponse)
def api_list_assignments(
scope_type: str = Query(default="tenant", pattern="^(system|tenant)$"),
@@ -705,15 +906,22 @@ def api_list_assignments(
principal: ApiPrincipal = Depends(get_api_principal),
) -> ViewAssignmentListResponse:
_require_assignment_read(principal, scope_type)
assignments = list_assignments(
session,
assignments = list(
list_assignments(
session,
tenant_id=principal.tenant_id,
scope_type=scope_type,
include_inherited=include_inherited,
)
)
targets = _assignment_target_lookup(
_optional_access_directory(),
tenant_id=principal.tenant_id,
scope_type=scope_type,
include_inherited=include_inherited,
assignments=assignments,
)
return ViewAssignmentListResponse(
assignments=[
ViewAssignmentResponse.model_validate(assignment_payload(assignment))
_assignment_response(assignment, targets=targets)
for assignment in assignments
]
)
@@ -731,6 +939,13 @@ def api_create_assignment(
) -> ViewAssignmentResponse:
_require_assignment_write(principal, payload.scope_type)
try:
directory = _optional_access_directory()
_validate_assignment_target(
directory,
tenant_id=principal.tenant_id,
scope_type=payload.scope_type,
scope_id=payload.scope_id,
)
definition = get_definition(
session,
tenant_id=principal.tenant_id,
@@ -764,7 +979,12 @@ def api_create_assignment(
},
)
session.commit()
return ViewAssignmentResponse.model_validate(assignment_payload(assignment))
targets = _assignment_target_lookup(
directory,
tenant_id=principal.tenant_id,
assignments=[assignment],
)
return _assignment_response(assignment, targets=targets)
except ViewsError as exc:
session.rollback()
raise _http_error(exc) from exc
@@ -803,7 +1023,12 @@ def api_update_assignment(
details={"fields": sorted(payload.model_fields_set)},
)
session.commit()
return ViewAssignmentResponse.model_validate(assignment_payload(assignment))
targets = _assignment_target_lookup(
_optional_access_directory(),
tenant_id=principal.tenant_id,
assignments=[assignment],
)
return _assignment_response(assignment, targets=targets)
except ViewsError as exc:
session.rollback()
raise _http_error(exc) from exc
+17
View File
@@ -91,6 +91,8 @@ class ViewAssignmentResponse(BaseModel):
tenant_id: str | None = None
scope_type: AssignmentScopeType
scope_id: str | None = None
scope_label: str | None = None
scope_detail: str | None = None
definition_id: str
revision_id: str | None = None
mode: AssignmentMode
@@ -107,6 +109,21 @@ class ViewAssignmentListResponse(BaseModel):
assignments: list[ViewAssignmentResponse]
class ViewAssignmentTargetResponse(BaseModel):
id: str
scope_type: Literal["group", "user"]
label: str
detail: str | None = None
disabled: bool = False
disabled_reason: str | None = None
class ViewAssignmentTargetListResponse(BaseModel):
directory_available: bool
targets: list[ViewAssignmentTargetResponse] = Field(default_factory=list)
unavailable_reason: str | None = None
class ViewAssignmentCreateRequest(BaseModel):
scope_type: AssignmentScopeType
scope_id: str | None = Field(default=None, max_length=255)
+102
View File
@@ -0,0 +1,102 @@
from __future__ import annotations
import unittest
from govoplan_core.core.access import GroupRef, UserRef
from govoplan_views.backend.router import (
_all_assignment_targets,
_search_assignment_targets,
_validate_assignment_target,
)
from govoplan_views.backend.service import ViewsValidationError
class FakeDirectory:
def users_for_tenant(self, tenant_id: str) -> tuple[UserRef, ...]:
return (
UserRef(
id="membership-ada",
account_id="account-ada",
tenant_id=tenant_id,
email="ada@example.test",
display_name="Ada Lovelace",
),
UserRef(
id="membership-inactive",
account_id="account-inactive",
tenant_id=tenant_id,
email="inactive@example.test",
status="inactive",
),
)
def groups_for_tenant(self, tenant_id: str) -> tuple[GroupRef, ...]:
return (
GroupRef(
id="group-finance",
tenant_id=tenant_id,
name="Finance",
),
GroupRef(
id="group-retired",
tenant_id=tenant_id,
name="Retired group",
status="inactive",
),
)
class ViewAssignmentTargetTests(unittest.TestCase):
def setUp(self) -> None:
self.directory = FakeDirectory()
def test_user_targets_store_account_ids_and_expose_readable_labels(self) -> None:
targets = _all_assignment_targets(
self.directory, # type: ignore[arg-type]
tenant_id="tenant-1",
scope_type="user",
)
self.assertEqual("account-ada", targets[0].id)
self.assertEqual("Ada Lovelace", targets[0].label)
self.assertEqual("ada@example.test", targets[0].detail)
self.assertNotEqual("membership-ada", targets[0].id)
def test_search_matches_labels_and_secondary_details(self) -> None:
by_name = _search_assignment_targets(
self.directory, # type: ignore[arg-type]
tenant_id="tenant-1",
scope_type="group",
query="finance",
limit=10,
)
by_email = _search_assignment_targets(
self.directory, # type: ignore[arg-type]
tenant_id="tenant-1",
scope_type="user",
query="ada@example",
limit=10,
)
self.assertEqual(["group-finance"], [target.id for target in by_name])
self.assertEqual(["account-ada"], [target.id for target in by_email])
def test_inactive_and_unknown_targets_cannot_receive_new_assignments(self) -> None:
with self.assertRaisesRegex(ViewsValidationError, "inactive"):
_validate_assignment_target(
self.directory, # type: ignore[arg-type]
tenant_id="tenant-1",
scope_type="user",
scope_id="account-inactive",
)
with self.assertRaisesRegex(ViewsValidationError, "does not exist"):
_validate_assignment_target(
self.directory, # type: ignore[arg-type]
tenant_id="tenant-1",
scope_type="group",
scope_id="group-missing",
)
if __name__ == "__main__":
unittest.main()
+1 -2
View File
@@ -18,7 +18,7 @@
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
"react-router-dom": ">=7.18.2 <8"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
@@ -26,4 +26,3 @@
}
}
}
+35
View File
@@ -45,6 +45,8 @@ export type ViewAssignment = {
tenant_id?: string | null;
scope_type: ViewAssignmentScopeType;
scope_id?: string | null;
scope_label?: string | null;
scope_detail?: string | null;
definition_id: string;
revision_id?: string | null;
mode: ViewAssignmentMode;
@@ -57,6 +59,21 @@ export type ViewAssignment = {
updated_at: string;
};
export type ViewAssignmentTarget = {
id: string;
scope_type: "group" | "user";
label: string;
detail?: string | null;
disabled: boolean;
disabled_reason?: string | null;
};
export type ViewAssignmentTargetList = {
directory_available: boolean;
targets: ViewAssignmentTarget[];
unavailable_reason?: string | null;
};
type EffectiveViewApiResponse = {
active_view_id?: string | null;
active_revision_id?: string | null;
@@ -239,6 +256,24 @@ export async function fetchViewAssignments(
return response.assignments;
}
export function fetchViewAssignmentTargets(
settings: ApiSettings,
scopeType: "group" | "user",
query: string,
limit: number,
signal?: AbortSignal
): Promise<ViewAssignmentTargetList> {
return apiFetch(
settings,
apiPath("/api/v1/views/assignment-targets", {
scope_type: scopeType,
query: query || undefined,
limit
}),
{ signal }
);
}
export function createViewAssignment(
settings: ApiSettings,
payload: {
+135 -20
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Archive,
CheckSquare2,
@@ -22,6 +22,7 @@ import {
ExplorerTree,
FormField,
IconButton,
SearchableSelect,
SelectionList,
SelectionListItem,
StatusBadge,
@@ -32,7 +33,8 @@ import {
useUnsavedDraftGuard,
useViewSurfaces,
type ApiSettings,
type PlatformViewSurface
type PlatformViewSurface,
type SearchableSelectOption
} from "@govoplan/core-webui";
import {
archiveViewDefinition,
@@ -40,6 +42,7 @@ import {
createViewDefinition,
createViewRevision,
deleteViewAssignment,
fetchViewAssignmentTargets,
fetchViewAssignments,
fetchViewDefinitions,
publishViewRevision,
@@ -62,6 +65,8 @@ type DefinitionDraft = {
type AssignmentDraft = {
scopeType: ViewAssignmentScopeType;
scopeId: string;
scopeLabel: string;
scopeDetail: string;
definitionId: string;
mode: ViewAssignmentMode;
priority: number;
@@ -337,6 +342,14 @@ export default function ViewsAdminPanel({
setAssignmentDraft({
scopeType: assignment.scope_type,
scopeId: assignment.scope_id ?? "",
scopeLabel:
assignment.scope_label ??
(assignment.scope_id
? `Unavailable ${assignment.scope_type}`
: assignment.scope_type),
scopeDetail:
assignment.scope_detail ??
(assignment.scope_label ? "" : assignment.scope_id ?? ""),
definitionId: assignment.definition_id,
mode: assignment.mode,
priority: assignment.priority,
@@ -729,6 +742,7 @@ export default function ViewsAdminPanel({
editing={assignmentEditor}
draft={assignmentDraft}
definitions={definitions}
settings={settings}
scopeType={scopeType}
busy={busy}
onChange={setAssignmentDraft}
@@ -814,6 +828,14 @@ function AssignmentsSection({
{assignments.map((assignment) => {
const inherited =
scopeType === "tenant" && assignment.tenant_id == null;
const targetLabel =
assignment.scope_label ??
(assignment.scope_id
? `Unavailable ${assignment.scope_type}`
: assignmentScopeLabel(assignment.scope_type));
const targetDetail =
assignment.scope_detail ??
(assignment.scope_label ? null : assignment.scope_id);
return (
<div key={assignment.id} className="views-assignment-row">
<div className="views-assignment-view">
@@ -822,9 +844,10 @@ function AssignmentsSection({
assignment.definition_id}
</strong>
<span>
{assignment.scope_type}
{assignment.scope_id ? ` · ${assignment.scope_id}` : ""}
{assignmentScopeLabel(assignment.scope_type)}
{assignment.scope_id ? ` · ${targetLabel}` : ""}
</span>
{targetDetail && <small>{targetDetail}</small>}
</div>
<StatusBadge status={assignment.mode} />
<span className="views-assignment-priority">
@@ -873,6 +896,7 @@ function AssignmentDialog({
editing,
draft,
definitions,
settings,
scopeType,
busy,
onChange,
@@ -883,12 +907,17 @@ function AssignmentDialog({
editing: ViewAssignment | "new" | null;
draft: AssignmentDraft;
definitions: ViewDefinition[];
settings: ApiSettings;
scopeType: ViewScopeType;
busy: boolean;
onChange: (draft: AssignmentDraft) => void;
onClose: () => void;
onSave: () => void;
}) {
const [directoryIssue, setDirectoryIssue] = useState<string | null>(null);
useEffect(() => {
setDirectoryIssue(null);
}, [draft.scopeType, open]);
const definition = definitions.find(
(item) => item.id === draft.definitionId
);
@@ -914,6 +943,53 @@ function AssignmentDialog({
: [];
const targetRequired =
draft.scopeType === "group" || draft.scopeType === "user";
const loadTargets = useCallback(
async (
query: string,
options: { limit: number; signal: AbortSignal }
): Promise<readonly SearchableSelectOption[]> => {
if (draft.scopeType !== "group" && draft.scopeType !== "user") {
return [];
}
const response = await fetchViewAssignmentTargets(
settings,
draft.scopeType,
query,
options.limit,
options.signal
);
if (!response.directory_available) {
setDirectoryIssue(
response.unavailable_reason ??
"The Access directory is not available."
);
return [];
}
setDirectoryIssue(null);
return response.targets.map((target) => ({
value: target.id,
label: target.label,
description:
[target.detail, target.disabled_reason]
.filter(Boolean)
.join(" · ") || null,
disabled: target.disabled
}));
},
[
draft.scopeType,
settings.accessToken,
settings.apiBaseUrl,
settings.apiKey
]
);
const selectedTarget: SearchableSelectOption | null = draft.scopeId
? {
value: draft.scopeId,
label: draft.scopeLabel || draft.scopeId,
description: draft.scopeDetail || null
}
: null;
const canSave = Boolean(
definition?.published_revision &&
(!targetRequired || draft.scopeId.trim()) &&
@@ -949,7 +1025,9 @@ function AssignmentDialog({
onChange({
...draft,
scopeType: event.target.value as ViewAssignmentScopeType,
scopeId: ""
scopeId: "",
scopeLabel: "",
scopeDetail: ""
})
}
>
@@ -964,21 +1042,44 @@ function AssignmentDialog({
)}
</select>
</FormField>
<FormField label="Target ID">
<input
value={
draft.scopeType === "system" || draft.scopeType === "tenant"
? ""
: draft.scopeId
}
placeholder={
targetRequired ? `${draft.scopeType} identifier` : "Current scope"
}
disabled={editing !== "new" || !targetRequired}
onChange={(event) =>
onChange({ ...draft, scopeId: event.target.value })
}
/>
<FormField label="Target">
{targetRequired ? (
<>
<SearchableSelect
id="view-assignment-target"
aria-label={`Select ${draft.scopeType}`}
value={draft.scopeId}
selectedOption={selectedTarget}
loadOptions={loadTargets}
placeholder={`Select a ${draft.scopeType}`}
searchPlaceholder={`Search ${draft.scopeType}s`}
emptyText={`No matching ${draft.scopeType}s.`}
disabled={editing !== "new" || busy}
required
onChange={(value, option) =>
onChange({
...draft,
scopeId: value,
scopeLabel: option?.label ?? "",
scopeDetail: option?.description ?? ""
})
}
/>
{directoryIssue && (
<p className="muted small-note">{directoryIssue}</p>
)}
</>
) : (
<input
value={
draft.scopeType === "system"
? "Current system"
: "Current tenant"
}
disabled
readOnly
/>
)}
</FormField>
<FormField label="View">
<select
@@ -1250,6 +1351,8 @@ function emptyAssignmentDraft(scopeType: ViewScopeType): AssignmentDraft {
return {
scopeType: scopeType === "system" ? "system" : "tenant",
scopeId: "",
scopeLabel: "",
scopeDetail: "",
definitionId: "",
mode: "available",
priority: 0,
@@ -1259,6 +1362,18 @@ function emptyAssignmentDraft(scopeType: ViewScopeType): AssignmentDraft {
}
function assignmentScopeLabel(
scopeType: ViewAssignmentScopeType
): string {
return {
system: "System",
tenant: "Tenant",
group: "Group",
user: "User"
}[scopeType];
}
function scopeLabel(definition: ViewDefinition): string {
const label = {
system: "System",
+3 -1
View File
@@ -261,11 +261,13 @@
}
.views-assignment-view strong,
.views-assignment-view span {
.views-assignment-view span,
.views-assignment-view small {
display: block;
}
.views-assignment-view span,
.views-assignment-view small,
.views-assignment-priority,
.views-assignment-revision {
color: var(--muted);