218 lines
7.6 KiB
Python
218 lines
7.6 KiB
Python
from __future__ import annotations
|
|
|
|
from collections.abc import Mapping
|
|
from typing import Any, cast
|
|
|
|
from sqlalchemy import or_
|
|
from sqlalchemy.orm import Session
|
|
|
|
from govoplan_core.core.policy import (
|
|
POLICY_SCOPE_TYPES,
|
|
PolicyImpactPopulationRequest,
|
|
PolicyImpactSubject,
|
|
PolicyImpactSubjectBatch,
|
|
PolicyScopeType,
|
|
)
|
|
from govoplan_views.backend.db.models import ViewDefinition
|
|
|
|
|
|
VIEW_IMPACT_ACTIONS = (
|
|
"view",
|
|
"select",
|
|
"assign",
|
|
"edit",
|
|
"derive",
|
|
"workflow_activate",
|
|
)
|
|
|
|
|
|
class ViewsPolicyImpactSubjectProvider:
|
|
provider_id = "views"
|
|
supported_policy_families = ("view",)
|
|
|
|
def __init__(self, registry: object) -> None:
|
|
self._registry = registry
|
|
|
|
def collect_policy_impact_subjects(
|
|
self,
|
|
session: object | None = None,
|
|
*,
|
|
request: PolicyImpactPopulationRequest,
|
|
) -> PolicyImpactSubjectBatch:
|
|
if request.policy_family != "view":
|
|
return PolicyImpactSubjectBatch(
|
|
provider_id=self.provider_id,
|
|
state="unavailable",
|
|
explanation="Views only contributes subjects for the View policy family.",
|
|
)
|
|
if not isinstance(session, Session):
|
|
return PolicyImpactSubjectBatch(
|
|
provider_id=self.provider_id,
|
|
state="unavailable",
|
|
explanation="The Views impact population requires a database session.",
|
|
)
|
|
|
|
selector = request.selector
|
|
_validate_selector_keys(selector)
|
|
actions = _actions(selector)
|
|
include_views = _selector_flag(selector, "include_views", default=True)
|
|
include_surfaces = _selector_flag(
|
|
selector,
|
|
"include_surfaces",
|
|
default=True,
|
|
)
|
|
explicit_view_ids = _selector_ids(selector, "view_ids")
|
|
explicit_surface_ids = _selector_ids(selector, "surface_ids")
|
|
|
|
subjects: list[PolicyImpactSubject] = []
|
|
total_available = 0
|
|
if include_views:
|
|
query = session.query(ViewDefinition).filter(
|
|
ViewDefinition.deleted_at.is_(None),
|
|
or_(
|
|
ViewDefinition.tenant_id == request.tenant_id,
|
|
ViewDefinition.tenant_id.is_(None),
|
|
),
|
|
)
|
|
if explicit_view_ids is not None:
|
|
query = query.filter(ViewDefinition.id.in_(explicit_view_ids))
|
|
definition_count = query.count()
|
|
definition_limit = max(
|
|
1,
|
|
(request.limit + len(actions) - 1) // len(actions),
|
|
)
|
|
definitions = query.order_by(
|
|
ViewDefinition.scope_type.asc(),
|
|
ViewDefinition.name.asc(),
|
|
ViewDefinition.id.asc(),
|
|
).limit(definition_limit).all()
|
|
total_available += definition_count * len(actions)
|
|
for definition in definitions:
|
|
scope_type = (
|
|
cast(PolicyScopeType, definition.scope_type)
|
|
if definition.scope_type in POLICY_SCOPE_TYPES
|
|
else None
|
|
)
|
|
for action in actions:
|
|
subjects.append(
|
|
PolicyImpactSubject(
|
|
module_id="views",
|
|
resource_type="view",
|
|
resource_id=definition.id,
|
|
action=action,
|
|
label=(
|
|
definition.name
|
|
if request.allow_sensitive_details
|
|
else None
|
|
),
|
|
scope_type=scope_type,
|
|
scope_id=definition.scope_id,
|
|
attributes={"definition_scope": definition.scope_type},
|
|
)
|
|
)
|
|
|
|
if include_surfaces:
|
|
surfaces = _surfaces(self._registry)
|
|
if explicit_surface_ids is not None:
|
|
allowed_surface_ids = set(explicit_surface_ids)
|
|
surfaces = tuple(
|
|
surface
|
|
for surface in surfaces
|
|
if str(getattr(surface, "id", "")) in allowed_surface_ids
|
|
)
|
|
total_available += len(surfaces)
|
|
for surface in surfaces:
|
|
subjects.append(
|
|
PolicyImpactSubject(
|
|
module_id=str(getattr(surface, "module_id", "core")),
|
|
resource_type="surface",
|
|
resource_id=str(getattr(surface, "id", "")),
|
|
action="view",
|
|
label=(
|
|
str(getattr(surface, "label", "")) or None
|
|
if request.allow_sensitive_details
|
|
else None
|
|
),
|
|
attributes={"surface_kind": str(getattr(surface, "kind", ""))},
|
|
)
|
|
)
|
|
|
|
subjects = subjects[: request.limit]
|
|
truncated = total_available > len(subjects)
|
|
return PolicyImpactSubjectBatch(
|
|
provider_id=self.provider_id,
|
|
subjects=tuple(subjects),
|
|
state="truncated" if truncated else "complete",
|
|
total_available=total_available,
|
|
explanation=(
|
|
f"The bounded preview returned {len(subjects)} of "
|
|
f"{total_available} matching View subjects."
|
|
if truncated
|
|
else "The explicit Views catalogue population was evaluated completely."
|
|
),
|
|
)
|
|
|
|
|
|
def _actions(selector: Mapping[str, Any]) -> tuple[str, ...]:
|
|
value = selector.get("actions")
|
|
if value is None:
|
|
return VIEW_IMPACT_ACTIONS
|
|
if not isinstance(value, (list, tuple)):
|
|
raise ValueError("View impact actions must be a list")
|
|
actions = tuple(dict.fromkeys(str(item).strip() for item in value))
|
|
if not actions or any(action not in VIEW_IMPACT_ACTIONS for action in actions):
|
|
raise ValueError("View impact actions contain an unsupported action")
|
|
return actions
|
|
|
|
|
|
def _validate_selector_keys(selector: Mapping[str, Any]) -> None:
|
|
supported = {
|
|
"actions",
|
|
"include_views",
|
|
"include_surfaces",
|
|
"view_ids",
|
|
"surface_ids",
|
|
}
|
|
unknown = sorted(str(key) for key in selector if str(key) not in supported)
|
|
if unknown:
|
|
raise ValueError(
|
|
"View impact selector contains unsupported fields: " + ", ".join(unknown)
|
|
)
|
|
|
|
|
|
def _selector_flag(
|
|
selector: Mapping[str, Any],
|
|
key: str,
|
|
*,
|
|
default: bool,
|
|
) -> bool:
|
|
value = selector.get(key, default)
|
|
if not isinstance(value, bool):
|
|
raise ValueError(f"View impact selector {key} must be boolean")
|
|
return value
|
|
|
|
|
|
def _selector_ids(
|
|
selector: Mapping[str, Any],
|
|
key: str,
|
|
) -> tuple[str, ...] | None:
|
|
if key not in selector:
|
|
return None
|
|
value = selector.get(key)
|
|
if not isinstance(value, (list, tuple)) or len(value) > 500:
|
|
raise ValueError(f"View impact selector {key} must contain at most 500 IDs")
|
|
result = tuple(dict.fromkeys(str(item).strip() for item in value))
|
|
if any(not item or len(item) > 240 for item in result):
|
|
raise ValueError(f"View impact selector {key} contains an invalid ID")
|
|
return result
|
|
|
|
|
|
def _surfaces(registry: object) -> tuple[object, ...]:
|
|
if not hasattr(registry, "view_surfaces"):
|
|
return ()
|
|
value = registry.view_surfaces()
|
|
return tuple(value) if isinstance(value, (list, tuple)) else tuple(value or ())
|
|
|
|
|
|
__all__ = ["VIEW_IMPACT_ACTIONS", "ViewsPolicyImpactSubjectProvider"]
|