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"?(?P\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:
- - Tableau (public) - press the "share" icon and then select the URL in the "link" text box
+ - Tableau (public) - press the "share" icon and then select the URL in the "link" text box. For example,
https://public.tableau.com/shared/JWH9N8XGN
.
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