Skip to content

Commit

Permalink
add Tableau filters to embedded Visuals (#761)
Browse files Browse the repository at this point in the history
* add a tableau filter

* fix tests

* add docstrings/tests

* update wheel

* fix PropTypes
  • Loading branch information
shapiromatron authored Jan 4, 2023
1 parent eb2b91c commit f0c4317
Show file tree
Hide file tree
Showing 11 changed files with 210 additions and 56 deletions.
1 change: 1 addition & 0 deletions frontend/summary/summary/ExternalSiteVisual.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
Expand Down
15 changes: 13 additions & 2 deletions frontend/summary/summary/TableauDashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -31,14 +31,25 @@ class TableauDashboard extends Component {

let fullPath = queryArgs && queryArgs.length > 0 ? `${path}?${queryArgs.join("&")}` : path;

return <tableau-viz src={hostUrl + fullPath} height={height} width={width}></tableau-viz>;
return (
<tableau-viz src={hostUrl + fullPath} height={height} width={width}>
{filters.map((filter, i) => {
return (
<viz-filter key={i} field={filter.field} value={filter.value}></viz-filter>
);
})}
</tableau-viz>
);
}
}

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;
33 changes: 32 additions & 1 deletion hawc/apps/common/validators.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import re
from typing import Optional, Sequence
from functools import partial
from typing import Callable, Optional, Sequence
from urllib import parse

import bleach
from bleach.css_sanitizer import CSSSanitizer
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<tag>\w+)[^>]*>")
hyperlink_regex = re.compile(r"href\s*=\s*['\"](.*?)['\"]")
Expand Down Expand Up @@ -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)
10 changes: 10 additions & 0 deletions hawc/apps/summary/constants.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from django.db import models
from pydantic import BaseModel


class StudyType(models.IntegerChoices):
Expand Down Expand Up @@ -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]
15 changes: 14 additions & 1 deletion hawc/apps/summary/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -823,20 +824,31 @@ class ExternalSiteForm(VisualForm):
Embed an external website. The following websites can be linked to:
</p>
<ul class="form-text text-muted">
<li><a href="https://public.tableau.com/">Tableau (public)</a> - press the "share" icon and then select the URL in the "link" text box</li>
<li><a href="https://public.tableau.com/">Tableau (public)</a> - press the "share" icon and then select the URL in the "link" text box. For example, <code>https://public.tableau.com/shared/JWH9N8XGN</code>.</li>
</ul>
<p class="form-text text-muted">
If you'd like to link to another website, please contact us.
</p>
""",
)
filters = forms.CharField(
label="Data filters",
initial="[]",
validators=[validate_json_pydantic(constants.TableauFilterList)],
help_text="""<p class="form-text text-muted">
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 <a href="https://help.tableau.com/current/api/embedding_api/en-us/docs/embedding_api_filter.html">Tableau documentation</a>. For example: <code>[{"field": "Category", "value": "Technology"}, {"field": "State", "value": "North Carolina,Virginia"}]</code>. To remove filters, set string to <code>[]</code>.
</p>""",
)

def __init__(self, *args, **kwargs):
super().__init__(*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()

Expand All @@ -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)
Expand Down
44 changes: 44 additions & 0 deletions hawc/apps/summary/migrations/0040_tableau_filters.py
Original file line number Diff line number Diff line change
@@ -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),
]
2 changes: 1 addition & 1 deletion requirements/dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion tests/data/api/api-visual-embedded-tableau.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"external_url_query_args": [
":showVizHome=no",
":embed=y"
]
],
"filters": []
},
"slug": "embedded-tableau",
"sort_order": "short_citation",
Expand Down
2 changes: 1 addition & 1 deletion tests/data/fixtures/db.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: <p>iris (flowers)</p>
published: true
sort_order: short_citation
Expand Down
17 changes: 17 additions & 0 deletions tests/hawc/apps/common/test_validators.py
Original file line number Diff line number Diff line change
@@ -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,
)


Expand Down Expand Up @@ -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
124 changes: 75 additions & 49 deletions tests/hawc/apps/summary/test_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

0 comments on commit f0c4317

Please sign in to comment.