feat: add searchable view assignment targets
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user