Files
govoplan-reporting/src/govoplan_reporting/backend/search_source.py
T

203 lines
6.5 KiB
Python

from __future__ import annotations
from collections import defaultdict
from collections.abc import Mapping, Sequence
from urllib.parse import quote
from sqlalchemy import func
from sqlalchemy.orm import Session
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.search import (
SearchAuthorizationRequest,
SearchBackfillPage,
SearchBackfillRequest,
SearchDocument,
SearchResourceType,
)
from govoplan_reporting.backend.db.models import (
ReportingDefinitionGrant,
ReportingDefinitionIdentity,
ReportingDefinitionRevision,
)
from govoplan_reporting.backend.definitions import can_read_definition
PROVIDER_ID = "reporting.reports"
RESOURCE_TYPE = "report"
class ReportingSearchSource:
def resource_types(self) -> Sequence[SearchResourceType]:
return (
SearchResourceType(
provider_id=PROVIDER_ID,
module_id="reporting",
resource_type=RESOURCE_TYPE,
label="Reports",
requires_authorization_recheck=True,
),
)
def backfill(
self,
session: object,
*,
request: SearchBackfillRequest,
) -> SearchBackfillPage:
if request.provider_id != PROVIDER_ID or request.resource_type != RESOURCE_TYPE:
raise ValueError("Unsupported Reporting search source.")
db = _session(session)
query = db.query(ReportingDefinitionRevision).filter(
ReportingDefinitionRevision.tenant_id == request.tenant_id,
ReportingDefinitionRevision.definition_kind == "report",
ReportingDefinitionRevision.status != "retired",
ReportingDefinitionRevision.superseded_at.is_(None),
)
if request.cursor:
query = query.filter(ReportingDefinitionRevision.id > request.cursor)
rows = (
query.order_by(ReportingDefinitionRevision.id.asc())
.limit(request.limit + 1)
.all()
)
has_more = len(rows) > request.limit
selected = rows[: request.limit]
tokens = _acl_tokens(db, selected)
high_watermark = (
db.query(func.max(ReportingDefinitionRevision.updated_at))
.filter(
ReportingDefinitionRevision.tenant_id == request.tenant_id,
ReportingDefinitionRevision.definition_kind == "report",
ReportingDefinitionRevision.superseded_at.is_(None),
)
.scalar()
)
return SearchBackfillPage(
documents=tuple(
_document(row, tokens[row.definition_id]) for row in selected
),
next_cursor=selected[-1].id if has_more and selected else None,
complete=not has_more,
high_watermark=(
high_watermark.isoformat() if high_watermark is not None else None
),
)
def authorize(
self,
session: object,
principal: object,
*,
requests: Sequence[SearchAuthorizationRequest],
) -> Mapping[str, bool]:
db = _session(session)
tenant_id = str(getattr(principal, "tenant_id", "") or "")
decisions = {request.reference.key: False for request in requests}
for request in requests:
reference = request.reference
if (
reference.tenant_id != tenant_id
or reference.module_id != "reporting"
or reference.resource_type != RESOURCE_TYPE
):
continue
decisions[reference.key] = can_read_definition(
db,
principal,
definition_kind="report",
definition_id=reference.resource_id,
)
return decisions
def create_reporting_search_source(
context: ModuleContext,
) -> ReportingSearchSource:
del context
return ReportingSearchSource()
def _document(
row: ReportingDefinitionRevision,
tokens: tuple[str, ...],
) -> SearchDocument:
restricted_tokens = tuple(
dict.fromkeys((*tokens, "scope:reporting:definition:admin"))
)
return SearchDocument(
tenant_id=row.tenant_id,
module_id="reporting",
provider_id=PROVIDER_ID,
resource_type=RESOURCE_TYPE,
resource_id=row.definition_id,
title=row.name,
url=f"/reporting?reportId={quote(row.definition_id, safe='')}",
summary=row.description,
body=row.description,
keywords=(row.definition_key, row.status),
visibility=row.visibility,
acl_tokens=restricted_tokens if row.visibility == "restricted" else (),
source_revision=str(row.revision),
source_updated_at=row.updated_at or row.recorded_at,
metadata={
"definition_key": row.definition_key,
"status": row.status,
"content_hash": row.content_hash,
},
requires_authorization_recheck=True,
)
def _acl_tokens(
session: Session,
rows: Sequence[ReportingDefinitionRevision],
) -> Mapping[str, tuple[str, ...]]:
result: dict[str, list[str]] = defaultdict(list)
if not rows:
return result
identity_ids = {row.identity_id for row in rows}
for identity in (
session.query(ReportingDefinitionIdentity)
.filter(ReportingDefinitionIdentity.id.in_(identity_ids))
.all()
):
if identity.created_by:
result[identity.definition_id].append(f"account:{identity.created_by}")
definition_ids = {row.definition_id for row in rows}
grants = (
session.query(ReportingDefinitionGrant)
.filter(
ReportingDefinitionGrant.tenant_id == rows[0].tenant_id,
ReportingDefinitionGrant.definition_kind == "report",
ReportingDefinitionGrant.definition_id.in_(definition_ids),
ReportingDefinitionGrant.active.is_(True),
)
.all()
)
for grant in grants:
prefix = (
"function"
if grant.subject_kind == "function_assignment"
else grant.subject_kind
)
result[grant.definition_id].append(f"{prefix}:{grant.subject_id}")
return {
definition_id: tuple(dict.fromkeys(values))
for definition_id, values in result.items()
}
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Reporting search requires a SQLAlchemy session.")
return value
__all__ = [
"PROVIDER_ID",
"RESOURCE_TYPE",
"ReportingSearchSource",
"create_reporting_search_source",
]