feat: introduce hashids permalink keys (#19324)

* feat: introduce hashids permalink keys

* implement dashboard permalinks

* remove shorturl notice from UPDATING.md

* lint

* fix test

* introduce KeyValueResource

* make filterState optional

* fix test

* fix resource names

(cherry picked from commit f4b71abb22)
This commit is contained in:
Ville Brofeldt
2022-03-24 21:53:09 +02:00
committed by Ville Brofeldt
parent 18f82411c9
commit a6a2def6d3
39 changed files with 344 additions and 367 deletions

View File

@@ -15,7 +15,9 @@
# specific language governing permissions and limitations
# under the License.
import json
from typing import Iterator
from unittest.mock import patch
from uuid import uuid3
import pytest
from flask_appbuilder.security.sqla.models import User
@@ -24,8 +26,9 @@ from sqlalchemy.orm import Session
from superset import db
from superset.dashboards.commands.exceptions import DashboardAccessDeniedError
from superset.key_value.models import KeyValueEntry
from superset.key_value.types import KeyValueResource
from superset.key_value.utils import decode_permalink_id
from superset.models.dashboard import Dashboard
from superset.models.slice import Slice
from tests.integration_tests.base_tests import login
from tests.integration_tests.fixtures.client import client
from tests.integration_tests.fixtures.world_bank_dashboard import (
@@ -35,7 +38,7 @@ from tests.integration_tests.fixtures.world_bank_dashboard import (
from tests.integration_tests.test_app import app
STATE = {
"filterState": {"FILTER_1": "foo",},
"filterState": {"FILTER_1": "foo"},
"hash": "my-anchor",
}
@@ -48,7 +51,22 @@ def dashboard_id(load_world_bank_dashboard_with_slices) -> int:
return dashboard.id
def test_post(client, dashboard_id: int):
@pytest.fixture
def permalink_salt() -> Iterator[str]:
from superset.key_value.shared_entries import get_permalink_salt, get_uuid_namespace
from superset.key_value.types import SharedKey
key = SharedKey.DASHBOARD_PERMALINK_SALT
salt = get_permalink_salt(key)
yield salt
namespace = get_uuid_namespace(salt)
db.session.query(KeyValueEntry).filter_by(
resource=KeyValueResource.APP, uuid=uuid3(namespace, key),
)
db.session.commit()
def test_post(client, dashboard_id: int, permalink_salt: str) -> None:
login(client, "admin")
resp = client.post(f"api/v1/dashboard/{dashboard_id}/permalink", json=STATE)
assert resp.status_code == 201
@@ -56,7 +74,8 @@ def test_post(client, dashboard_id: int):
key = data["key"]
url = data["url"]
assert key in url
db.session.query(KeyValueEntry).filter_by(uuid=key).delete()
id_ = decode_permalink_id(key, permalink_salt)
db.session.query(KeyValueEntry).filter_by(id=id_).delete()
db.session.commit()
@@ -76,7 +95,7 @@ def test_post_invalid_schema(client, dashboard_id: int):
assert resp.status_code == 400
def test_get(client, dashboard_id: int):
def test_get(client, dashboard_id: int, permalink_salt: str):
login(client, "admin")
resp = client.post(f"api/v1/dashboard/{dashboard_id}/permalink", json=STATE)
data = json.loads(resp.data.decode("utf-8"))
@@ -86,5 +105,6 @@ def test_get(client, dashboard_id: int):
result = json.loads(resp.data.decode("utf-8"))
assert result["dashboardId"] == str(dashboard_id)
assert result["state"] == STATE
db.session.query(KeyValueEntry).filter_by(uuid=key).delete()
id_ = decode_permalink_id(key, permalink_salt)
db.session.query(KeyValueEntry).filter_by(id=id_).delete()
db.session.commit()

View File

@@ -16,14 +16,16 @@
# under the License.
import json
import pickle
from typing import Any, Dict
from uuid import UUID
from typing import Any, Dict, Iterator
from uuid import uuid3
import pytest
from sqlalchemy.orm import Session
from superset import db
from superset.key_value.models import KeyValueEntry
from superset.key_value.types import KeyValueResource
from superset.key_value.utils import decode_permalink_id, encode_permalink_key
from superset.models.slice import Slice
from tests.integration_tests.base_tests import login
from tests.integration_tests.fixtures.client import client
@@ -51,7 +53,22 @@ def form_data(chart) -> Dict[str, Any]:
}
def test_post(client, form_data):
@pytest.fixture
def permalink_salt() -> Iterator[str]:
from superset.key_value.shared_entries import get_permalink_salt, get_uuid_namespace
from superset.key_value.types import SharedKey
key = SharedKey.EXPLORE_PERMALINK_SALT
salt = get_permalink_salt(key)
yield salt
namespace = get_uuid_namespace(salt)
db.session.query(KeyValueEntry).filter_by(
resource=KeyValueResource.APP, uuid=uuid3(namespace, key),
)
db.session.commit()
def test_post(client, form_data: Dict[str, Any], permalink_salt: str):
login(client, "admin")
resp = client.post(f"api/v1/explore/permalink", json={"formData": form_data})
assert resp.status_code == 201
@@ -59,7 +76,8 @@ def test_post(client, form_data):
key = data["key"]
url = data["url"]
assert key in url
db.session.query(KeyValueEntry).filter_by(uuid=key).delete()
id_ = decode_permalink_id(key, permalink_salt)
db.session.query(KeyValueEntry).filter_by(id=id_).delete()
db.session.commit()
@@ -69,21 +87,18 @@ def test_post_access_denied(client, form_data):
assert resp.status_code == 404
def test_get_missing_chart(client, chart):
def test_get_missing_chart(client, chart, permalink_salt: str) -> None:
from superset.key_value.models import KeyValueEntry
key = 1234
uuid_key = "e2ea9d19-7988-4862-aa69-c3a1a7628cb9"
chart_id = 1234
entry = KeyValueEntry(
id=int(key),
uuid=UUID("e2ea9d19-7988-4862-aa69-c3a1a7628cb9"),
resource="explore_permalink",
resource=KeyValueResource.EXPLORE_PERMALINK,
value=pickle.dumps(
{
"chartId": key,
"chartId": chart_id,
"datasetId": chart.datasource.id,
"formData": {
"slice_id": key,
"slice_id": chart_id,
"datasource": f"{chart.datasource.id}__{chart.datasource.type}",
},
}
@@ -91,20 +106,21 @@ def test_get_missing_chart(client, chart):
)
db.session.add(entry)
db.session.commit()
key = encode_permalink_key(entry.id, permalink_salt)
login(client, "admin")
resp = client.get(f"api/v1/explore/permalink/{uuid_key}")
resp = client.get(f"api/v1/explore/permalink/{key}")
assert resp.status_code == 404
db.session.delete(entry)
db.session.commit()
def test_post_invalid_schema(client):
def test_post_invalid_schema(client) -> None:
login(client, "admin")
resp = client.post(f"api/v1/explore/permalink", json={"abc": 123})
assert resp.status_code == 400
def test_get(client, form_data):
def test_get(client, form_data: Dict[str, Any], permalink_salt: str) -> None:
login(client, "admin")
resp = client.post(f"api/v1/explore/permalink", json={"formData": form_data})
data = json.loads(resp.data.decode("utf-8"))
@@ -113,5 +129,6 @@ def test_get(client, form_data):
assert resp.status_code == 200
result = json.loads(resp.data.decode("utf-8"))
assert result["state"]["formData"] == form_data
db.session.query(KeyValueEntry).filter_by(uuid=key).delete()
id_ = decode_permalink_id(key, permalink_salt)
db.session.query(KeyValueEntry).filter_by(id=id_).delete()
db.session.commit()

View File

@@ -36,12 +36,8 @@ def test_create_id_entry(app_context: AppContext, admin: User) -> None:
from superset.key_value.commands.create import CreateKeyValueCommand
from superset.key_value.models import KeyValueEntry
key = CreateKeyValueCommand(
actor=admin, resource=RESOURCE, value=VALUE, key_type="id",
).run()
entry = (
db.session.query(KeyValueEntry).filter_by(id=int(key)).autoflush(False).one()
)
key = CreateKeyValueCommand(actor=admin, resource=RESOURCE, value=VALUE).run()
entry = db.session.query(KeyValueEntry).filter_by(id=key.id).autoflush(False).one()
assert pickle.loads(entry.value) == VALUE
assert entry.created_by_fk == admin.id
db.session.delete(entry)
@@ -52,11 +48,9 @@ def test_create_uuid_entry(app_context: AppContext, admin: User) -> None:
from superset.key_value.commands.create import CreateKeyValueCommand
from superset.key_value.models import KeyValueEntry
key = CreateKeyValueCommand(
actor=admin, resource=RESOURCE, value=VALUE, key_type="uuid",
).run()
key = CreateKeyValueCommand(actor=admin, resource=RESOURCE, value=VALUE).run()
entry = (
db.session.query(KeyValueEntry).filter_by(uuid=UUID(key)).autoflush(False).one()
db.session.query(KeyValueEntry).filter_by(uuid=key.uuid).autoflush(False).one()
)
assert pickle.loads(entry.value) == VALUE
assert entry.created_by_fk == admin.id

View File

@@ -30,8 +30,8 @@ from tests.integration_tests.key_value.commands.fixtures import admin, RESOURCE,
if TYPE_CHECKING:
from superset.key_value.models import KeyValueEntry
ID_KEY = "234"
UUID_KEY = "5aae143c-44f1-478e-9153-ae6154df333a"
ID_KEY = 234
UUID_KEY = UUID("5aae143c-44f1-478e-9153-ae6154df333a")
@pytest.fixture
@@ -39,10 +39,7 @@ def key_value_entry() -> KeyValueEntry:
from superset.key_value.models import KeyValueEntry
entry = KeyValueEntry(
id=int(ID_KEY),
uuid=UUID(UUID_KEY),
resource=RESOURCE,
value=pickle.dumps(VALUE),
id=ID_KEY, uuid=UUID_KEY, resource=RESOURCE, value=pickle.dumps(VALUE),
)
db.session.add(entry)
db.session.commit()
@@ -55,10 +52,7 @@ def test_delete_id_entry(
from superset.key_value.commands.delete import DeleteKeyValueCommand
from superset.key_value.models import KeyValueEntry
assert (
DeleteKeyValueCommand(resource=RESOURCE, key=ID_KEY, key_type="id",).run()
is True
)
assert DeleteKeyValueCommand(resource=RESOURCE, key=ID_KEY).run() is True
def test_delete_uuid_entry(
@@ -67,10 +61,7 @@ def test_delete_uuid_entry(
from superset.key_value.commands.delete import DeleteKeyValueCommand
from superset.key_value.models import KeyValueEntry
assert (
DeleteKeyValueCommand(resource=RESOURCE, key=UUID_KEY, key_type="uuid").run()
is True
)
assert DeleteKeyValueCommand(resource=RESOURCE, key=UUID_KEY).run() is True
def test_delete_entry_missing(
@@ -79,7 +70,4 @@ def test_delete_entry_missing(
from superset.key_value.commands.delete import DeleteKeyValueCommand
from superset.key_value.models import KeyValueEntry
assert (
DeleteKeyValueCommand(resource=RESOURCE, key="456", key_type="id").run()
is False
)
assert DeleteKeyValueCommand(resource=RESOURCE, key=456).run() is False

View File

@@ -26,14 +26,15 @@ from flask_appbuilder.security.sqla.models import User
from sqlalchemy.orm import Session
from superset.extensions import db
from superset.key_value.types import KeyValueResource
from tests.integration_tests.test_app import app
if TYPE_CHECKING:
from superset.key_value.models import KeyValueEntry
ID_KEY = "123"
UUID_KEY = "3e7a2ab8-bcaf-49b0-a5df-dfb432f291cc"
RESOURCE = "my_resource"
ID_KEY = 123
UUID_KEY = UUID("3e7a2ab8-bcaf-49b0-a5df-dfb432f291cc")
RESOURCE = KeyValueResource.APP
VALUE = {"foo": "bar"}
@@ -42,10 +43,7 @@ def key_value_entry() -> Generator[KeyValueEntry, None, None]:
from superset.key_value.models import KeyValueEntry
entry = KeyValueEntry(
id=int(ID_KEY),
uuid=UUID(UUID_KEY),
resource=RESOURCE,
value=pickle.dumps(VALUE),
id=ID_KEY, uuid=UUID_KEY, resource=RESOURCE, value=pickle.dumps(VALUE),
)
db.session.add(entry)
db.session.commit()

View File

@@ -39,7 +39,7 @@ if TYPE_CHECKING:
def test_get_id_entry(app_context: AppContext, key_value_entry: KeyValueEntry) -> None:
from superset.key_value.commands.get import GetKeyValueCommand
value = GetKeyValueCommand(resource=RESOURCE, key=ID_KEY, key_type="id").run()
value = GetKeyValueCommand(resource=RESOURCE, key=ID_KEY).run()
assert value == VALUE
@@ -48,7 +48,7 @@ def test_get_uuid_entry(
) -> None:
from superset.key_value.commands.get import GetKeyValueCommand
value = GetKeyValueCommand(resource=RESOURCE, key=UUID_KEY, key_type="uuid").run()
value = GetKeyValueCommand(resource=RESOURCE, key=UUID_KEY).run()
assert value == VALUE
@@ -57,7 +57,7 @@ def test_get_id_entry_missing(
) -> None:
from superset.key_value.commands.get import GetKeyValueCommand
value = GetKeyValueCommand(resource=RESOURCE, key="456", key_type="id").run()
value = GetKeyValueCommand(resource=RESOURCE, key=456).run()
assert value is None
@@ -74,7 +74,7 @@ def test_get_expired_entry(app_context: AppContext) -> None:
)
db.session.add(entry)
db.session.commit()
value = GetKeyValueCommand(resource=RESOURCE, key=ID_KEY, key_type="id").run()
value = GetKeyValueCommand(resource=RESOURCE, key=ID_KEY).run()
assert value is None
db.session.delete(entry)
db.session.commit()
@@ -94,7 +94,7 @@ def test_get_future_expiring_entry(app_context: AppContext) -> None:
)
db.session.add(entry)
db.session.commit()
value = GetKeyValueCommand(resource=RESOURCE, key=str(id_), key_type="id").run()
value = GetKeyValueCommand(resource=RESOURCE, key=id_).run()
assert value == VALUE
db.session.delete(entry)
db.session.commit()

View File

@@ -46,12 +46,10 @@ def test_update_id_entry(
from superset.key_value.models import KeyValueEntry
key = UpdateKeyValueCommand(
actor=admin, resource=RESOURCE, key=ID_KEY, value=NEW_VALUE, key_type="id",
actor=admin, resource=RESOURCE, key=ID_KEY, value=NEW_VALUE,
).run()
assert key == ID_KEY
entry = (
db.session.query(KeyValueEntry).filter_by(id=int(ID_KEY)).autoflush(False).one()
)
assert key.id == ID_KEY
entry = db.session.query(KeyValueEntry).filter_by(id=ID_KEY).autoflush(False).one()
assert pickle.loads(entry.value) == NEW_VALUE
assert entry.changed_by_fk == admin.id
@@ -63,25 +61,20 @@ def test_update_uuid_entry(
from superset.key_value.models import KeyValueEntry
key = UpdateKeyValueCommand(
actor=admin, resource=RESOURCE, key=UUID_KEY, value=NEW_VALUE, key_type="uuid",
actor=admin, resource=RESOURCE, key=UUID_KEY, value=NEW_VALUE,
).run()
assert key == UUID_KEY
assert key.uuid == UUID_KEY
entry = (
db.session.query(KeyValueEntry)
.filter_by(uuid=UUID(UUID_KEY))
.autoflush(False)
.one()
db.session.query(KeyValueEntry).filter_by(uuid=UUID_KEY).autoflush(False).one()
)
assert pickle.loads(entry.value) == NEW_VALUE
assert entry.changed_by_fk == admin.id
def test_update_missing_entry(
app_context: AppContext, admin: User, key_value_entry: KeyValueEntry,
) -> None:
def test_update_missing_entry(app_context: AppContext, admin: User) -> None:
from superset.key_value.commands.update import UpdateKeyValueCommand
key = UpdateKeyValueCommand(
actor=admin, resource=RESOURCE, key="456", value=NEW_VALUE, key_type="id",
actor=admin, resource=RESOURCE, key=456, value=NEW_VALUE,
).run()
assert key is None

View File

@@ -46,9 +46,9 @@ def test_upsert_id_entry(
from superset.key_value.models import KeyValueEntry
key = UpsertKeyValueCommand(
actor=admin, resource=RESOURCE, key=ID_KEY, value=NEW_VALUE, key_type="id",
actor=admin, resource=RESOURCE, key=ID_KEY, value=NEW_VALUE,
).run()
assert key == ID_KEY
assert key.id == ID_KEY
entry = (
db.session.query(KeyValueEntry).filter_by(id=int(ID_KEY)).autoflush(False).one()
)
@@ -63,28 +63,23 @@ def test_upsert_uuid_entry(
from superset.key_value.models import KeyValueEntry
key = UpsertKeyValueCommand(
actor=admin, resource=RESOURCE, key=UUID_KEY, value=NEW_VALUE, key_type="uuid",
actor=admin, resource=RESOURCE, key=UUID_KEY, value=NEW_VALUE,
).run()
assert key == UUID_KEY
assert key.uuid == UUID_KEY
entry = (
db.session.query(KeyValueEntry)
.filter_by(uuid=UUID(UUID_KEY))
.autoflush(False)
.one()
db.session.query(KeyValueEntry).filter_by(uuid=UUID_KEY).autoflush(False).one()
)
assert pickle.loads(entry.value) == NEW_VALUE
assert entry.changed_by_fk == admin.id
def test_upsert_missing_entry(
app_context: AppContext, admin: User, key_value_entry: KeyValueEntry,
) -> None:
def test_upsert_missing_entry(app_context: AppContext, admin: User) -> None:
from superset.key_value.commands.upsert import UpsertKeyValueCommand
from superset.key_value.models import KeyValueEntry
key = UpsertKeyValueCommand(
actor=admin, resource=RESOURCE, key="456", value=NEW_VALUE, key_type="id",
actor=admin, resource=RESOURCE, key=456, value=NEW_VALUE,
).run()
assert key == "456"
assert key.id == 456
db.session.query(KeyValueEntry).filter_by(id=456).delete()
db.session.commit()