feat: add permalink to dashboard and explore (#19078)

* rename key_value to temporary_cache

* add migration

* create new key_value package

* add commands

* lots of new stuff

* fix schema reference

* remove redundant filter state from bootstrap data

* add missing license headers

* fix pylint

* fix dashboard permalink access

* use valid json mocks for filter state tests

* fix temporary cache tests

* add anchors to dashboard state

* lint

* fix util test

* fix url shortlink button tests

* remove legacy shortner

* remove unused imports

* fix js tests

* fix test

* add native filter state to anchor link

* add UPDATING.md section

* address comments

* address comments

* lint

* fix test

* add utils tests + other test stubs

* add key_value integration tests

* add filter box state to permalink state

* fully support persisting url parameters

* lint, add redirects and a few integration tests

* fix test + clean up trailing comma

* fix anchor bug

* change value to LargeBinary to support persisting binary values

* fix urlParams type and simplify urlencode

* lint

* add optional entry expiration

* fix incorrect chart id + add test
This commit is contained in:
Ville Brofeldt
2022-03-17 01:15:52 +02:00
committed by GitHub
parent d01fdad1d8
commit b7a0559aaf
94 changed files with 2943 additions and 439 deletions

View File

@@ -0,0 +1,16 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.

View File

@@ -0,0 +1,174 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import logging
from flask import current_app, g, request, Response
from flask_appbuilder.api import BaseApi, expose, protect, safe
from marshmallow import ValidationError
from superset.charts.commands.exceptions import (
ChartAccessDeniedError,
ChartNotFoundError,
)
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod
from superset.datasets.commands.exceptions import (
DatasetAccessDeniedError,
DatasetNotFoundError,
)
from superset.explore.permalink.commands.create import CreateExplorePermalinkCommand
from superset.explore.permalink.commands.get import GetExplorePermalinkCommand
from superset.explore.permalink.exceptions import ExplorePermalinkInvalidStateError
from superset.explore.permalink.schemas import ExplorePermalinkPostSchema
from superset.extensions import event_logger
from superset.key_value.exceptions import KeyValueAccessDeniedError
from superset.views.base_api import requires_json
logger = logging.getLogger(__name__)
class ExplorePermalinkRestApi(BaseApi):
add_model_schema = ExplorePermalinkPostSchema()
method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
include_route_methods = {
RouteMethod.POST,
RouteMethod.PUT,
RouteMethod.GET,
RouteMethod.DELETE,
}
allow_browser_login = True
class_permission_name = "ExplorePermalinkRestApi"
resource_name = "explore"
openapi_spec_tag = "Explore Permanent Link"
openapi_spec_component_schemas = (ExplorePermalinkPostSchema,)
@expose("/permalink", methods=["POST"])
@protect()
@safe
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.post",
log_to_statsd=False,
)
@requires_json
def post(self) -> Response:
"""Stores a new permanent link.
---
post:
description: >-
Stores a new permanent link.
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ExplorePermalinkPostSchema'
responses:
201:
description: The permanent link was stored successfully.
content:
application/json:
schema:
type: object
properties:
key:
type: string
description: The key to retrieve the permanent link data.
url:
type: string
description: pemanent link.
400:
$ref: '#/components/responses/400'
401:
$ref: '#/components/responses/401'
422:
$ref: '#/components/responses/422'
500:
$ref: '#/components/responses/500'
"""
key_type = current_app.config["PERMALINK_KEY_TYPE"]
try:
state = self.add_model_schema.load(request.json)
key = CreateExplorePermalinkCommand(
actor=g.user, state=state, key_type=key_type,
).run()
http_origin = request.headers.environ.get("HTTP_ORIGIN")
url = f"{http_origin}/superset/explore/p/{key}/"
return self.response(201, key=key, url=url)
except ValidationError as ex:
return self.response(400, message=ex.messages)
except (
ChartAccessDeniedError,
DatasetAccessDeniedError,
KeyValueAccessDeniedError,
) as ex:
return self.response(403, message=str(ex))
except (ChartNotFoundError, DatasetNotFoundError) as ex:
return self.response(404, message=str(ex))
@expose("/permalink/<string:key>", methods=["GET"])
@protect()
@safe
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.get",
log_to_statsd=False,
)
def get(self, key: str) -> Response:
"""Retrives permanent link state for chart.
---
get:
description: >-
Retrives chart state associated with a permanent link.
parameters:
- in: path
schema:
type: string
name: key
responses:
200:
description: Returns the stored form_data.
content:
application/json:
schema:
type: object
properties:
state:
type: object
description: The stored state
400:
$ref: '#/components/responses/400'
401:
$ref: '#/components/responses/401'
404:
$ref: '#/components/responses/404'
422:
$ref: '#/components/responses/422'
500:
$ref: '#/components/responses/500'
"""
try:
key_type = current_app.config["PERMALINK_KEY_TYPE"]
value = GetExplorePermalinkCommand(
actor=g.user, key=key, key_type=key_type
).run()
if not value:
return self.response_404()
return self.response(200, **value)
except ExplorePermalinkInvalidStateError as ex:
return self.response(400, message=str(ex))
except (ChartAccessDeniedError, DatasetAccessDeniedError,) as ex:
return self.response(403, message=str(ex))
except (ChartNotFoundError, DatasetNotFoundError) as ex:
return self.response(404, message=str(ex))

View File

@@ -0,0 +1,16 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.

View File

@@ -0,0 +1,23 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from abc import ABC
from superset.commands.base import BaseCommand
class BaseExplorePermalinkCommand(BaseCommand, ABC):
resource = "explore_permalink"

View File

@@ -0,0 +1,60 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import logging
from typing import Any, Dict, Optional
from flask_appbuilder.security.sqla.models import User
from sqlalchemy.exc import SQLAlchemyError
from superset.explore.permalink.commands.base import BaseExplorePermalinkCommand
from superset.explore.permalink.exceptions import ExplorePermalinkCreateFailedError
from superset.explore.utils import check_access
from superset.key_value.commands.create import CreateKeyValueCommand
from superset.key_value.types import KeyType
logger = logging.getLogger(__name__)
class CreateExplorePermalinkCommand(BaseExplorePermalinkCommand):
def __init__(self, actor: User, state: Dict[str, Any], key_type: KeyType):
self.actor = actor
self.chart_id: Optional[int] = state["formData"].get("slice_id")
self.datasource: str = state["formData"]["datasource"]
self.state = state
self.key_type = key_type
def run(self) -> str:
self.validate()
try:
dataset_id = int(self.datasource.split("__")[0])
check_access(dataset_id, self.chart_id, self.actor)
value = {
"chartId": self.chart_id,
"datasetId": dataset_id,
"datasource": self.datasource,
"state": self.state,
}
command = CreateKeyValueCommand(
self.actor, self.resource, value, self.key_type
)
return command.run()
except SQLAlchemyError as ex:
logger.exception("Error running create command")
raise ExplorePermalinkCreateFailedError() from ex
def validate(self) -> None:
pass

View File

@@ -0,0 +1,66 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import logging
from typing import Optional
from flask_appbuilder.security.sqla.models import User
from sqlalchemy.exc import SQLAlchemyError
from superset.datasets.commands.exceptions import DatasetNotFoundError
from superset.explore.permalink.commands.base import BaseExplorePermalinkCommand
from superset.explore.permalink.exceptions import ExplorePermalinkGetFailedError
from superset.explore.permalink.types import ExplorePermalinkValue
from superset.explore.utils import check_access
from superset.key_value.commands.get import GetKeyValueCommand
from superset.key_value.exceptions import KeyValueGetFailedError, KeyValueParseKeyError
from superset.key_value.types import KeyType
logger = logging.getLogger(__name__)
class GetExplorePermalinkCommand(BaseExplorePermalinkCommand):
def __init__(
self, actor: User, key: str, key_type: KeyType,
):
self.actor = actor
self.key = key
self.key_type = key_type
def run(self) -> Optional[ExplorePermalinkValue]:
self.validate()
try:
value: Optional[ExplorePermalinkValue] = GetKeyValueCommand(
self.resource, self.key, key_type=self.key_type
).run()
if value:
chart_id: Optional[int] = value.get("chartId")
dataset_id = value["datasetId"]
check_access(dataset_id, chart_id, self.actor)
return value
return None
except (
DatasetNotFoundError,
KeyValueGetFailedError,
KeyValueParseKeyError,
) as ex:
raise ExplorePermalinkGetFailedError(message=ex.message) from ex
except SQLAlchemyError as ex:
logger.exception("Error running get command")
raise ExplorePermalinkGetFailedError() from ex
def validate(self) -> None:
pass

View File

@@ -0,0 +1,31 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from flask_babel import lazy_gettext as _
from superset.commands.exceptions import CommandException, CreateFailedError
class ExplorePermalinkInvalidStateError(CreateFailedError):
message = _("Invalid state.")
class ExplorePermalinkCreateFailedError(CreateFailedError):
message = _("An error occurred while creating the value.")
class ExplorePermalinkGetFailedError(CommandException):
message = _("An error occurred while accessing the value.")

View File

@@ -0,0 +1,37 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from marshmallow import fields, Schema
class ExplorePermalinkPostSchema(Schema):
formData = fields.Dict(
required=True, allow_none=False, description="Chart form data",
)
urlParams = fields.List(
fields.Tuple(
(
fields.String(required=True, allow_none=True, description="Key"),
fields.String(required=True, allow_none=True, description="Value"),
),
required=False,
allow_none=True,
description="URL Parameter key-value pair",
),
required=False,
allow_none=True,
description="URL Parameters",
)

View File

@@ -0,0 +1,29 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from typing import Any, Dict, List, Optional, Tuple, TypedDict
class ExplorePermalinkState(TypedDict, total=False):
formData: Dict[str, Any]
urlParams: Optional[List[Tuple[str, str]]]
class ExplorePermalinkValue(TypedDict):
chartId: Optional[int]
datasetId: int
datasource: str
state: ExplorePermalinkState