Skip to content

Commit

Permalink
Merge pull request #579 from gooddata/rha/cq-227/stringify-applied-fo…
Browse files Browse the repository at this point in the history
…rmat

CQ-233: Add human-readable filters description 

Reviewed-by: Dan Homola
             https://github.com/no23reason
  • Loading branch information
gdgate authored Mar 7, 2024
2 parents 6f76338 + eadec5d commit 836c3be
Show file tree
Hide file tree
Showing 8 changed files with 219 additions and 8 deletions.
9 changes: 9 additions & 0 deletions gooddata-sdk/gooddata_sdk/compute/model/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,12 @@ def is_noop(self) -> bool:

def as_api_model(self) -> OpenApiModel:
raise NotImplementedError()

def description(self, labels: dict[str, str]) -> str:
"""
Description of the filter as it's visible for customer in UI.
:param labels: Dict of labels in a form of `id: label`. Measures and attributes are expected to be here.
:return: Filter's human-readable description
"""
raise NotImplementedError()
23 changes: 23 additions & 0 deletions gooddata-sdk/gooddata_sdk/compute/model/execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,29 @@ def result_id(self) -> str:
def dimensions(self) -> Any:
return self.bare_exec_response._exec_response["dimensions"]

def get_labels_and_formats(self) -> tuple[dict[str, str], dict[str, str]]:
"""
Extracts labels and custom measure formats from the execution response.
:return: tuple of labels dict ({"label_id":"Label"}) and formats dict ({"measure_id":"#,##0.00"})
"""
labels = {}
formats = {}
for dim in self.dimensions:
for hdr in dim["headers"]:
if "attributeHeader" in hdr:
labels[hdr["attributeHeader"]["localIdentifier"]] = hdr["attributeHeader"].get(
"alias", hdr["attributeHeader"]["labelName"]
)
labels[hdr["attributeHeader"]["label"]["id"]] = labels[hdr["attributeHeader"]["localIdentifier"]]
elif "measureGroupHeaders" in hdr:
for m_group in hdr["measureGroupHeaders"]:
if "name" in m_group:
labels[m_group["localIdentifier"]] = m_group.get("alias", m_group["name"])
if "format" in m_group:
formats[m_group["localIdentifier"]] = m_group["format"]
return labels, formats

def read_result(self, limit: Union[int, list[int]], offset: Union[None, int, list[int]] = None) -> ExecutionResult:
return self.bare_exec_response.read_result(limit, offset)

Expand Down
103 changes: 103 additions & 0 deletions gooddata-sdk/gooddata_sdk/compute/model/filter.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# (C) 2022 GoodData Corporation
from __future__ import annotations

from datetime import datetime
from typing import Optional, Union

import gooddata_api_client.models as afm_models
Expand All @@ -18,6 +19,17 @@
from gooddata_sdk.compute.model.base import Filter, ObjId
from gooddata_sdk.compute.model.metric import Metric

_DATE_FORMAT_INPUT = "%Y-%m-%d"
_DATE_FORMAT_OUTPUT = "%-m/%-d/%Y"
_METRIC_VALUE_FILTER_OPERATOR_LABEL = {
"EQUAL_TO": "=",
"GREATER_THAN": ">",
"GREATER_THAN_OR_EQUAL_TO": ">=",
"LESS_THAN": "<",
"LESS_THAN_OR_EQUAL_TO": "<=",
"NOT_EQUAL_TO": "!=",
}


def _extract_id_or_local_id(val: Union[ObjId, Attribute, Metric, str]) -> Union[ObjId, str]:
if isinstance(val, (str, ObjId)):
Expand Down Expand Up @@ -70,6 +82,11 @@ def as_api_model(self) -> afm_models.PositiveAttributeFilter:
body = PositiveAttributeFilterBody(label=label_id, _in=elements, _check_type=False)
return afm_models.PositiveAttributeFilter(body, _check_type=False)

def description(self, labels: dict[str, str]) -> str:
label_id = self.label.id if isinstance(self.label, ObjId) else self.label
values = ", ".join(self.values) if len(self.values) else "All"
return f"{labels.get(label_id, label_id)}: {values}"


class NegativeAttributeFilter(AttributeFilter):
def is_noop(self) -> bool:
Expand All @@ -81,6 +98,11 @@ def as_api_model(self) -> afm_models.NegativeAttributeFilter:
body = NegativeAttributeFilterBody(label=label_id, not_in=elements, _check_type=False)
return afm_models.NegativeAttributeFilter(body)

def description(self, labels: dict[str, str]) -> str:
label_id = self.label.id if isinstance(self.label, ObjId) else self.label
values = "All except " + ", ".join(self.values) if len(self.values) else "All"
return f"{labels.get(label_id, label_id)}: {values}"


_GRANULARITY: set[str] = {
"YEAR",
Expand Down Expand Up @@ -146,6 +168,45 @@ def as_api_model(self) -> afm_models.RelativeDateFilter:
)
return afm_models.RelativeDateFilter(body)

def description(self, labels: dict[str, str]) -> str:
# TODO compare with other period is not implemented as it's not defined in the filter but in measures
from_shift = self.from_shift
to_shift = self.to_shift
gr = self.granularity
range_str = "All time"
if from_shift == -1 and to_shift == -1:
range_str = "Yesterday" if gr == "DAY" else "Last " + gr.lower()
elif from_shift == 0 and to_shift == 0:
range_str = "Today" if gr == "DAY" else "This " + gr.lower()
elif from_shift == 1 and to_shift == 1:
range_str = "Tomorrow" if gr == "DAY" else "Next " + gr.lower()
else:
if to_shift == 0:
range_str = f"Last {abs(from_shift) + 1} " + gr.lower() + "s"
elif from_shift == 0:
range_str = f"Next {to_shift + 1} " + gr.lower() + "s"
else:
abs_from_shift = abs(from_shift)
abs_to_shift = abs(to_shift)
plural_from_shift = "s" if abs_from_shift > 1 else ""
plural_to_shift = "s" if abs_to_shift > 1 else ""
if from_shift < 0 < to_shift:
range_str = (
f"From {abs_from_shift} {gr.lower()}{plural_from_shift} ago "
f"to {to_shift} {gr.lower()}{plural_to_shift} ahead"
)
elif from_shift < 0 and to_shift < 0:
range_str = (
f"From {abs_from_shift} {gr.lower()}{plural_from_shift} "
f"to {abs_to_shift} {gr.lower()}{plural_to_shift} ago"
)
elif from_shift > 0 and to_shift > 0:
range_str = (
f"From {from_shift} {gr.lower()}{plural_from_shift} "
f"to {to_shift} {gr.lower()}{plural_to_shift} ahead"
)
return f"{labels.get(self.dataset.id, self.dataset.id)}: {range_str}"


# noinspection PyAbstractClass
class AllTimeFilter(Filter):
Expand All @@ -159,9 +220,20 @@ class AllTimeFilter(Filter):
The main feature of this filter is noop.
"""

def __init__(self, dataset: ObjId) -> None:
super(AllTimeFilter, self).__init__()
self._dataset = dataset

@property
def dataset(self) -> ObjId:
return self._dataset

def is_noop(self) -> bool:
return True

def description(self, labels: dict[str, str]) -> str:
return f"{labels.get(self.dataset.id, self.dataset.id)}: All time"


class AbsoluteDateFilter(Filter):
def __init__(self, dataset: ObjId, from_date: str, to_date: str) -> None:
Expand Down Expand Up @@ -203,6 +275,12 @@ def __eq__(self, other: object) -> bool:
and self._to_date == other._to_date
)

def description(self, labels: dict[str, str]) -> str:
# TODO long-term this should reflect locales once requested
from_date = datetime.strptime(self.from_date.split(" ")[0], _DATE_FORMAT_INPUT).strftime(_DATE_FORMAT_OUTPUT)
to_date = datetime.strptime(self.to_date.split(" ")[0], _DATE_FORMAT_INPUT).strftime(_DATE_FORMAT_OUTPUT)
return f"{labels.get(self.dataset.id, self.dataset.id)}: {from_date} - {to_date}"


_METRIC_VALUE_FILTER_OPERATORS = {
"EQUAL_TO": "comparison",
Expand Down Expand Up @@ -296,6 +374,17 @@ def as_api_model(self) -> Union[afm_models.ComparisonMeasureValueFilter, afm_mod
body = RangeMeasureValueFilterBody(**kwargs)
return afm_models.RangeMeasureValueFilter(body)

def description(self, labels: dict[str, str]) -> str:
metric_id = self.metric.id if isinstance(self.metric, ObjId) else self.metric
if self.operator in ["BETWEEN", "NOT_BETWEEN"] and len(self.values) == 2:
not_between = "not" if self.operator == "NOT_BETWEEN" else ""
return f"{labels.get(metric_id, metric_id)}: {not_between}between {self.values[0]} - {self.values[1]}"
else:
return (
f"{labels.get(metric_id, metric_id)}: "
f"{_METRIC_VALUE_FILTER_OPERATOR_LABEL.get(self.operator, self.operator)} {self.values[0]}"
)


_RANKING_OPERATORS = {"TOP", "BOTTOM"}

Expand Down Expand Up @@ -349,3 +438,17 @@ def as_api_model(self) -> afm_models.RankingFilter:
measures=measures, operator=self.operator, value=self.value, _check_type=False, **dimensionality
)
return afm_models.RankingFilter(body)

def description(self, labels: dict[str, str]) -> str:
# TODO more metrics and dimensions not supported now as it's not supported on FE as well
dimensionality_ids = (
[d.id if isinstance(d, ObjId) else d for d in self.dimensionality] if self.dimensionality else []
)
dimensionality_str = (
f" out of {labels.get(dimensionality_ids[0], dimensionality_ids[0])} based on" if dimensionality_ids else ""
)
metric_ids = [m.id if isinstance(m, ObjId) else m for m in self.metrics]
return (
f"{self.operator.capitalize()} {self.value}{dimensionality_str} "
f"{labels.get(metric_ids[0], metric_ids[0])}"
)
23 changes: 22 additions & 1 deletion gooddata-sdk/gooddata_sdk/visualization.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ def _convert_filter_to_computable(filter_obj: dict[str, Any]) -> Filter:

# there is filter present, but uses all time
if ("from" not in f) or ("to" not in f):
return AllTimeFilter()
return AllTimeFilter(_ref_extract_obj_id(f["dataSet"]))

return RelativeDateFilter(
dataset=_ref_extract_obj_id(f["dataSet"]),
Expand Down Expand Up @@ -537,6 +537,27 @@ def get_metadata(self, id_obj: IdObjType) -> Optional[Any]:
# otherwise, try to use the id object as is
return self._side_loads.find(id_obj)

def get_labels_and_formats(self) -> tuple[dict[str, str], dict[str, str]]:
"""
Extracts labels and custom measure formats from the insight.
:return: tuple of labels dict ({"label_id":"Label"}) and formats dict ({"measure_id":"#,##0.00"})
"""
labels = {}
formats = {}
for bucket in self.buckets:
for item in bucket.items:
for item_values in item.values():
label = item_values.get("alias", item_values.get("title", None))
if label is not None:
labels[item_values["localIdentifier"]] = label
if "format" in item_values:
formats[item_values["localIdentifier"]] = item_values["format"]
return labels, formats

def get_filters_description(self, labels: dict[str, str]) -> list[str]:
return [f.as_computable().description(labels) for f in self.filters]

def __str__(self) -> str:
return self.__repr__()

Expand Down
19 changes: 17 additions & 2 deletions gooddata-sdk/tests/compute_model/test_attribute_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,32 +15,42 @@ def _scenario_to_snapshot_name(scenario: str):
return f"{scenario.replace(' ', '_')}.snapshot.json"


description_labels = {
"label.id": "Label ID",
"local_id": "Local ID",
}

test_filters = [
["empty positive attribute filter", PositiveAttributeFilter(label="local_id")],
["empty positive attribute filter", PositiveAttributeFilter(label="local_id"), "Local ID: All"],
[
"positive filter using local id",
PositiveAttributeFilter(label="local_id", values=["val1", "val2"]),
"Local ID: val1, val2",
],
[
"positive filter using object id",
PositiveAttributeFilter(label=ObjId(type="label", id="label.id"), values=["val1", "val2"]),
"Label ID: val1, val2",
],
[
"empty negative attribute filter",
NegativeAttributeFilter(label="local_id", values=[]),
"Local ID: All",
],
[
"negative filter using local id",
NegativeAttributeFilter(label="local_id", values=["val1", "val2"]),
"Local ID: All except val1, val2",
],
[
"negative filter using object id",
NegativeAttributeFilter(label=ObjId(type="label", id="label.id"), values=["val1", "val2"]),
"Label ID: All except val1, val2",
],
]


@pytest.mark.parametrize("scenario,filter", test_filters)
@pytest.mark.parametrize("scenario,filter", [sublist[:2] for sublist in test_filters])
def test_attribute_filters_to_api_model(scenario, filter, snapshot):
# it is essential to define snapshot dir using absolute path, otherwise snapshots cannot be found when
# running in tox
Expand All @@ -52,6 +62,11 @@ def test_attribute_filters_to_api_model(scenario, filter, snapshot):
)


@pytest.mark.parametrize("scenario,filter,description", test_filters)
def test_attribute_filters_description(scenario, filter, description):
assert filter.description(description_labels) == description


def test_empty_negative_filter_is_noop():
f = NegativeAttributeFilter(label="test", values=[])

Expand Down
17 changes: 14 additions & 3 deletions gooddata-sdk/tests/compute_model/test_date_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ def _scenario_to_snapshot_name(scenario: str):
return f"{scenario.replace(' ', '_')}.snapshot.json"


description_labels = {
"dataset.id": "DataSet ID",
}

test_filters = [
[
"absolute date filter",
Expand All @@ -23,6 +27,7 @@ def _scenario_to_snapshot_name(scenario: str):
from_date="2021-07-01 18:23",
to_date="2021-07-16 18:23",
),
"DataSet ID: 7/1/2021 - 7/16/2021",
],
[
"relative date filter",
Expand All @@ -32,12 +37,13 @@ def _scenario_to_snapshot_name(scenario: str):
from_shift=-10,
to_shift=-1,
),
"DataSet ID: From 10 days to 1 day ago",
],
]


@pytest.mark.parametrize("scenario,filter", test_filters)
def test_attribute_filters_to_api_model(scenario, filter, snapshot):
@pytest.mark.parametrize("scenario,filter", [sublist[:2] for sublist in test_filters])
def test_date_filters_to_api_model(scenario, filter, snapshot):
# it is essential to define snapshot dir using absolute path, otherwise snapshots cannot be found when
# running in tox
snapshot.snapshot_dir = os.path.join(_current_dir, "date_filters")
Expand All @@ -48,11 +54,16 @@ def test_attribute_filters_to_api_model(scenario, filter, snapshot):
)


@pytest.mark.parametrize("scenario,filter,description", test_filters)
def test_date_filters_description(scenario, filter, description):
assert filter.description(description_labels) == description


def test_cannot_create_api_model_from_all_time_filter():
"""As All time filter from GoodData.CN does not contain from and to fields,
we are not sure how to make valid model from it. We prefer to fail, until
we decide what to do with this situation.
"""
with pytest.raises(NotImplementedError):
f = AllTimeFilter()
f = AllTimeFilter(dataset=ObjId(type="dataset", id="dataset.id"))
f.as_api_model()
Loading

0 comments on commit 836c3be

Please sign in to comment.