mirror of
https://github.com/apache/superset.git
synced 2026-04-20 00:24:38 +00:00
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:
16
superset/explore/permalink/__init__.py
Normal file
16
superset/explore/permalink/__init__.py
Normal 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.
|
||||
174
superset/explore/permalink/api.py
Normal file
174
superset/explore/permalink/api.py
Normal 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))
|
||||
16
superset/explore/permalink/commands/__init__.py
Normal file
16
superset/explore/permalink/commands/__init__.py
Normal 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.
|
||||
23
superset/explore/permalink/commands/base.py
Normal file
23
superset/explore/permalink/commands/base.py
Normal 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"
|
||||
60
superset/explore/permalink/commands/create.py
Normal file
60
superset/explore/permalink/commands/create.py
Normal 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
|
||||
66
superset/explore/permalink/commands/get.py
Normal file
66
superset/explore/permalink/commands/get.py
Normal 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
|
||||
31
superset/explore/permalink/exceptions.py
Normal file
31
superset/explore/permalink/exceptions.py
Normal 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.")
|
||||
37
superset/explore/permalink/schemas.py
Normal file
37
superset/explore/permalink/schemas.py
Normal 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",
|
||||
)
|
||||
29
superset/explore/permalink/types.py
Normal file
29
superset/explore/permalink/types.py
Normal 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
|
||||
Reference in New Issue
Block a user