From f0c43179c688872cbb595ed1269d2908d3a89099 Mon Sep 17 00:00:00 2001 From: Andy Shapiro Date: Wed, 4 Jan 2023 09:11:32 -0500 Subject: [PATCH] add Tableau filters to embedded Visuals (#761) * add a tableau filter * fix tests * add docstrings/tests * update wheel * fix PropTypes --- .../summary/summary/ExternalSiteVisual.js | 1 + frontend/summary/summary/TableauDashboard.js | 15 ++- hawc/apps/common/validators.py | 33 ++++- hawc/apps/summary/constants.py | 10 ++ hawc/apps/summary/forms.py | 15 ++- .../migrations/0040_tableau_filters.py | 44 +++++++ requirements/dev.txt | 2 +- .../data/api/api-visual-embedded-tableau.json | 3 +- tests/data/fixtures/db.yaml | 2 +- tests/hawc/apps/common/test_validators.py | 17 +++ tests/hawc/apps/summary/test_forms.py | 124 +++++++++++------- 11 files changed, 210 insertions(+), 56 deletions(-) create mode 100644 hawc/apps/summary/migrations/0040_tableau_filters.py diff --git a/frontend/summary/summary/ExternalSiteVisual.js b/frontend/summary/summary/ExternalSiteVisual.js index 54160a1b40..1ffafd7266 100644 --- a/frontend/summary/summary/ExternalSiteVisual.js +++ b/frontend/summary/summary/ExternalSiteVisual.js @@ -22,6 +22,7 @@ class ExternalWebsite extends BaseVisual { hostUrl={settings.external_url_hostname} path={settings.external_url_path} queryArgs={settings.external_url_query_args} + filters={settings.filters} />, el ); diff --git a/frontend/summary/summary/TableauDashboard.js b/frontend/summary/summary/TableauDashboard.js index 3513f67d9d..1324c1bc80 100644 --- a/frontend/summary/summary/TableauDashboard.js +++ b/frontend/summary/summary/TableauDashboard.js @@ -22,7 +22,7 @@ class TableauDashboard extends Component { } render() { - const {hostUrl, path, queryArgs} = this.props, + const {hostUrl, path, queryArgs, filters} = this.props, contentSize = h.getHawcContentSize(), MIN_HEIGHT = 600, MIN_WIDTH = 700, @@ -31,7 +31,15 @@ class TableauDashboard extends Component { let fullPath = queryArgs && queryArgs.length > 0 ? `${path}?${queryArgs.join("&")}` : path; - return ; + return ( + + {filters.map((filter, i) => { + return ( + + ); + })} + + ); } } @@ -39,6 +47,9 @@ TableauDashboard.propTypes = { hostUrl: PropTypes.string.isRequired, path: PropTypes.string.isRequired, queryArgs: PropTypes.arrayOf(PropTypes.string), + filters: PropTypes.arrayOf( + PropTypes.shape({field: PropTypes.string.isRequired, value: PropTypes.string.isRequired}) + ), }; export default TableauDashboard; diff --git a/hawc/apps/common/validators.py b/hawc/apps/common/validators.py index 206045bcb4..b43f175fc6 100644 --- a/hawc/apps/common/validators.py +++ b/hawc/apps/common/validators.py @@ -1,5 +1,6 @@ import re -from typing import Optional, Sequence +from functools import partial +from typing import Callable, Optional, Sequence from urllib import parse import bleach @@ -7,6 +8,8 @@ from django.core.exceptions import ValidationError from django.core.validators import RegexValidator, URLValidator from django.utils.encoding import force_str +from pydantic import BaseModel +from pydantic import ValidationError as PydanticValidationError tag_regex = re.compile(r"\w+)[^>]*>") hyperlink_regex = re.compile(r"href\s*=\s*['\"](.*?)['\"]") @@ -178,3 +181,31 @@ class NumericTextValidator(RegexValidator): # alternative: r"^[<,≤,≥,>]? (?:LOD|[+-]?\d+\.?\d*(?:[eE][+-]?\d+)?)$" regex = r"^[<,≤,≥,>]? ?(?:LOD|LOQ|[+-]?\d+\.?\d*(?:[eE][+-]?\d+)?)$" message = "Must be number-like, including {<,≤,≥,>,LOD,LOQ} (ex: 3.4, 1.2E-5, < LOD)" + + +def _validate_json_pydantic(value: str, Model: type[BaseModel]): + """Validate a JSON string to ensure it conforms to a pydantic model + + Args: + value (str): an input string + Model (type[BaseModel]): A matching pydantic schema + + Raises: + django.core.exceptions.ValidationError: If data do not conform + """ + try: + Model.parse_raw(value) + except PydanticValidationError as err: + raise ValidationError(err.json()) + + +def validate_json_pydantic(Model: type[BaseModel]) -> Callable: + """Create a django validator function to validate JSON in a string field + + Args: + Model (type[BaseModel]): A Pydantic model class + + Returns: + Callable: A django validator function to be used in a form or model + """ + return partial(_validate_json_pydantic, Model=Model) diff --git a/hawc/apps/summary/constants.py b/hawc/apps/summary/constants.py index 442dc95e32..77a1944f25 100644 --- a/hawc/apps/summary/constants.py +++ b/hawc/apps/summary/constants.py @@ -1,4 +1,5 @@ from django.db import models +from pydantic import BaseModel class StudyType(models.IntegerChoices): @@ -33,3 +34,12 @@ class SortOrder(models.TextChoices): class ExportStyle(models.IntegerChoices): EXPORT_GROUP = 0, "One row per Endpoint-group/Result-group" EXPORT_ENDPOINT = 1, "One row per Endpoint/Result" + + +class TableauFilter(BaseModel): + field: str + value: str + + +class TableauFilterList(BaseModel): + __root__: list[TableauFilter] diff --git a/hawc/apps/summary/forms.py b/hawc/apps/summary/forms.py index c8e20bf98b..6fe6f528d0 100644 --- a/hawc/apps/summary/forms.py +++ b/hawc/apps/summary/forms.py @@ -13,6 +13,7 @@ from ..common import validators from ..common.autocomplete import AutocompleteChoiceField from ..common.forms import BaseFormHelper, check_unique_for_assessment +from ..common.validators import validate_json_pydantic from ..epi.models import Outcome from ..invitro.models import IVChemical, IVEndpointCategory from ..lit.models import ReferenceFilterTag @@ -823,13 +824,22 @@ class ExternalSiteForm(VisualForm): Embed an external website. The following websites can be linked to:

If you'd like to link to another website, please contact us.

""", ) + filters = forms.CharField( + label="Data filters", + initial="[]", + validators=[validate_json_pydantic(constants.TableauFilterList)], + help_text="""

+ Data are expected to be in JSON format, where the each key is a filter name and value is a filter value. + For more details, view the Tableau documentation. For example: [{"field": "Category", "value": "Technology"}, {"field": "State", "value": "North Carolina,Virginia"}]. To remove filters, set string to []. +

""", + ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -837,6 +847,8 @@ def __init__(self, *args, **kwargs): data = json.loads(self.instance.settings) if "external_url" in data: self.fields["external_url"].initial = data["external_url"] + if "filters" in data: + self.fields["filters"].initial = json.dumps(data["filters"]) self.helper = self.setHelper() @@ -847,6 +859,7 @@ def save(self, commit=True): external_url_hostname=self.cleaned_data["external_url_hostname"], external_url_path=self.cleaned_data["external_url_path"], external_url_query_args=self.cleaned_data["external_url_query_args"], + filters=json.loads(self.cleaned_data["filters"]), ) ) return super().save(commit) diff --git a/hawc/apps/summary/migrations/0040_tableau_filters.py b/hawc/apps/summary/migrations/0040_tableau_filters.py new file mode 100644 index 0000000000..fe32fa27b0 --- /dev/null +++ b/hawc/apps/summary/migrations/0040_tableau_filters.py @@ -0,0 +1,44 @@ +import json + +from django.db import migrations + + +def forwards(apps, schema_editor): + Visual = apps.get_model("summary", "visual") + updates = [] + for visual in Visual.objects.filter(visual_type=5): + try: + settings = json.loads(visual.settings) + except json.JSONDecodeError: + continue + settings["filters"] = [] + visual.settings = json.dumps(settings) + updates.append(visual) + if updates: + Visual.objects.bulk_update(updates, ["settings"]) + + +def backwards(apps, schema_editor): + Visual = apps.get_model("summary", "visual") + updates = [] + for visual in Visual.objects.filter(visual_type=5): + try: + settings = json.loads(visual.settings) + except json.JSONDecodeError: + continue + settings.pop("filters") + visual.settings = json.dumps(settings) + updates.append(visual) + if updates: + Visual.objects.bulk_update(updates, ["settings"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ("summary", "0039_update_rob_legend"), + ] + + operations = [ + migrations.RunPython(forwards, reverse_code=backwards), + ] diff --git a/requirements/dev.txt b/requirements/dev.txt index 9620a9b275..fb3c992cb7 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -24,7 +24,7 @@ pytest-playwright==0.3.0 # distribution tools build==0.8.0 twine==4.0.1 -wheel==0.37.1 +wheel==0.38.4 # hawc; save in editable mode so it doesn't copy to venv # but instead stays in place diff --git a/tests/data/api/api-visual-embedded-tableau.json b/tests/data/api/api-visual-embedded-tableau.json index 377e7d7b83..bbcd8bee02 100644 --- a/tests/data/api/api-visual-embedded-tableau.json +++ b/tests/data/api/api-visual-embedded-tableau.json @@ -17,7 +17,8 @@ "external_url_query_args": [ ":showVizHome=no", ":embed=y" - ] + ], + "filters": [] }, "slug": "embedded-tableau", "sort_order": "short_citation", diff --git a/tests/data/fixtures/db.yaml b/tests/data/fixtures/db.yaml index ac6224a9cc..53314c31e3 100644 --- a/tests/data/fixtures/db.yaml +++ b/tests/data/fixtures/db.yaml @@ -6086,7 +6086,7 @@ settings: '{"external_url": "https://public.tableau.com/views/Iris_15675445278420/Iris-Actual", "external_url_hostname": "https://public.tableau.com", "external_url_path": "/views/Iris_15675445278420/Iris-Actual", "external_url_query_args": [":showVizHome=no", - ":embed=y"]}' + ":embed=y"], "filters": []}' caption:

iris (flowers)

published: true sort_order: short_citation diff --git a/tests/hawc/apps/common/test_validators.py b/tests/hawc/apps/common/test_validators.py index 32ef3609e7..7f5c384b93 100644 --- a/tests/hawc/apps/common/test_validators.py +++ b/tests/hawc/apps/common/test_validators.py @@ -1,11 +1,13 @@ import pytest from django.core.exceptions import ValidationError +from pydantic import BaseModel from hawc.apps.common.validators import ( NumericTextValidator, validate_exact_ids, validate_html_tags, validate_hyperlinks, + validate_json_pydantic, ) @@ -78,3 +80,18 @@ def test_numeric_text_validator(): for value in ["non-numeric", "< 3.2 LOD", "<", "<<2", "1 2", "e-4"]: with pytest.raises(ValidationError, match="Must be number-like"): validator(value) + + +class PersonSchema(BaseModel): + name: str + age: int + + +def test_validate_json_pydantic(): + validator = validate_json_pydantic(PersonSchema) + # invalid + for data in ["", "not JSON", "[]", "{}", '{"name": "johnny"}']: + with pytest.raises(ValidationError): + validator(data) + # valid + assert validator('{"name": "johnny", "age": 30}') is None diff --git a/tests/hawc/apps/summary/test_forms.py b/tests/hawc/apps/summary/test_forms.py index 61aeca87b6..eff22636bd 100644 --- a/tests/hawc/apps/summary/test_forms.py +++ b/tests/hawc/apps/summary/test_forms.py @@ -6,52 +6,78 @@ @pytest.mark.django_db -def test_ExternalSiteForm(db_keys): - assessment = Assessment.objects.get(id=db_keys.assessment_working) - visual_type = VisualType.EXTERNAL_SITE - - data = dict( - title="title", - slug="slug", - description="hi", - external_url="https://public.tableau.com/views/foo1/foo2?:display_count=y&:origin=viz_share_link", - ) - - # demo what success looks like - form = ExternalSiteForm( - data=data, - parent=assessment, - visual_type=visual_type, - ) - assert form.is_valid() - assert form.cleaned_data == { - "title": "title", - "slug": "slug", - "caption": "", - "published": False, - "external_url": "https://public.tableau.com/views/foo1/foo2", - "external_url_hostname": "https://public.tableau.com", - "external_url_path": "/views/foo1/foo2", - "external_url_query_args": [":showVizHome=no", ":embed=y"], - } - - # make sure our site allowlist works - for url in [ - "google.com", - "http://google.com", - "https://google.com", - ]: - data["external_url"] = url - form = ExternalSiteForm(data=data, parent=assessment, visual_type=visual_type) - assert form.is_valid() is False - assert "not on the list of accepted domains" in form.errors["external_url"][0] - - # make sure our path check works - for url in [ - "public.tableau.com", - "public.tableau.com/", - ]: - data["external_url"] = url - form = ExternalSiteForm(data=data, parent=assessment, visual_type=visual_type) - assert form.is_valid() is False - assert "A URL path must be specified." == form.errors["external_url"][0] +class TestExternalSiteForm: + def valid_data(self): + return dict( + title="title", + slug="slug", + description="hi", + external_url="https://public.tableau.com/views/foo1/foo2?:display_count=y&:origin=viz_share_link", + filters="[]", + ) + + def test_success(self, db_keys): + assessment = Assessment.objects.get(id=db_keys.assessment_working) + visual_type = VisualType.EXTERNAL_SITE + + data = self.valid_data() + form = ExternalSiteForm( + data=data, + parent=assessment, + visual_type=visual_type, + ) + assert form.is_valid() + assert form.cleaned_data == { + "title": "title", + "slug": "slug", + "caption": "", + "published": False, + "external_url": "https://public.tableau.com/views/foo1/foo2", + "external_url_hostname": "https://public.tableau.com", + "external_url_path": "/views/foo1/foo2", + "external_url_query_args": [":showVizHome=no", ":embed=y"], + "filters": "[]", + } + + def test_urls(self, db_keys): + assessment = Assessment.objects.get(id=db_keys.assessment_working) + visual_type = VisualType.EXTERNAL_SITE + data = self.valid_data() + # make sure our site allowlist works + for url in [ + "google.com", + "http://google.com", + "https://google.com", + ]: + data["external_url"] = url + form = ExternalSiteForm(data=data, parent=assessment, visual_type=visual_type) + assert form.is_valid() is False + assert "not on the list of accepted domains" in form.errors["external_url"][0] + + # make sure our path check works + for url in [ + "public.tableau.com", + "public.tableau.com/", + ]: + data["external_url"] = url + form = ExternalSiteForm(data=data, parent=assessment, visual_type=visual_type) + assert form.is_valid() is False + assert "A URL path must be specified." == form.errors["external_url"][0] + + def test_filters(self, db_keys): + assessment = Assessment.objects.get(id=db_keys.assessment_working) + visual_type = VisualType.EXTERNAL_SITE + data = self.valid_data() + + # test valid filters + for filters in ["[]", '[{"field":"hi", "value":"ho"}]']: + data["filters"] = filters + form = ExternalSiteForm(data=data, parent=assessment, visual_type=visual_type) + assert form.is_valid() is True + + # test invalid filters + for filters in ["[123]", '[{"field":"hi"}]']: + data["filters"] = filters + form = ExternalSiteForm(data=data, parent=assessment, visual_type=visual_type) + assert form.is_valid() is False + assert "filters" in form.errors