diff --git a/src/govoplan_views/backend/router.py b/src/govoplan_views/backend/router.py index ad329c3..0ff63f5 100644 --- a/src/govoplan_views/backend/router.py +++ b/src/govoplan_views/backend/router.py @@ -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 diff --git a/src/govoplan_views/backend/schemas.py b/src/govoplan_views/backend/schemas.py index 7854320..652f9b1 100644 --- a/src/govoplan_views/backend/schemas.py +++ b/src/govoplan_views/backend/schemas.py @@ -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) diff --git a/tests/test_assignment_targets.py b/tests/test_assignment_targets.py new file mode 100644 index 0000000..e036839 --- /dev/null +++ b/tests/test_assignment_targets.py @@ -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() diff --git a/webui/package.json b/webui/package.json index c267427..3f03c93 100644 --- a/webui/package.json +++ b/webui/package.json @@ -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 @@ } } } - diff --git a/webui/src/api/views.ts b/webui/src/api/views.ts index 96308e3..b0ae156 100644 --- a/webui/src/api/views.ts +++ b/webui/src/api/views.ts @@ -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 { + return apiFetch( + settings, + apiPath("/api/v1/views/assignment-targets", { + scope_type: scopeType, + query: query || undefined, + limit + }), + { signal } + ); +} + export function createViewAssignment( settings: ApiSettings, payload: { diff --git a/webui/src/features/views/ViewsAdminPanel.tsx b/webui/src/features/views/ViewsAdminPanel.tsx index e953818..9e77a52 100644 --- a/webui/src/features/views/ViewsAdminPanel.tsx +++ b/webui/src/features/views/ViewsAdminPanel.tsx @@ -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 (
@@ -822,9 +844,10 @@ function AssignmentsSection({ assignment.definition_id} - {assignment.scope_type} - {assignment.scope_id ? ` · ${assignment.scope_id}` : ""} + {assignmentScopeLabel(assignment.scope_type)} + {assignment.scope_id ? ` · ${targetLabel}` : ""} + {targetDetail && {targetDetail}}
@@ -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(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 => { + 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({ )} - - - onChange({ ...draft, scopeId: event.target.value }) - } - /> + + {targetRequired ? ( + <> + + onChange({ + ...draft, + scopeId: value, + scopeLabel: option?.label ?? "", + scopeDetail: option?.description ?? "" + }) + } + /> + {directoryIssue && ( +

{directoryIssue}

+ )} + + ) : ( + + )}