This is an automated email from the ASF dual-hosted git repository.

yjc pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new c3ac61271a feat(dashboard): make permalink deterministic (#20632)
c3ac61271a is described below

commit c3ac61271ab0b5637b3b199b43fa69be1cfd3bae
Author: Jesse Yang <[email protected]>
AuthorDate: Tue Jul 12 16:33:18 2022 -0700

    feat(dashboard): make permalink deterministic (#20632)
---
 superset/dashboards/permalink/commands/create.py        | 17 ++++++++++++-----
 superset/key_value/commands/upsert.py                   |  4 ++--
 superset/key_value/utils.py                             | 11 +++++++++--
 superset/utils/core.py                                  |  5 +++--
 .../integration_tests/dashboards/permalink/api_tests.py | 16 +++++++++++-----
 5 files changed, 37 insertions(+), 16 deletions(-)

diff --git a/superset/dashboards/permalink/commands/create.py 
b/superset/dashboards/permalink/commands/create.py
index b8cbdbd3a0..75cb4f5748 100644
--- a/superset/dashboards/permalink/commands/create.py
+++ b/superset/dashboards/permalink/commands/create.py
@@ -22,13 +22,19 @@ from superset.dashboards.dao import DashboardDAO
 from superset.dashboards.permalink.commands.base import 
BaseDashboardPermalinkCommand
 from superset.dashboards.permalink.exceptions import 
DashboardPermalinkCreateFailedError
 from superset.dashboards.permalink.types import DashboardPermalinkState
-from superset.key_value.commands.create import CreateKeyValueCommand
-from superset.key_value.utils import encode_permalink_key
+from superset.key_value.commands.upsert import UpsertKeyValueCommand
+from superset.key_value.utils import encode_permalink_key, 
get_deterministic_uuid
+from superset.utils.core import get_user_id
 
 logger = logging.getLogger(__name__)
 
 
 class CreateDashboardPermalinkCommand(BaseDashboardPermalinkCommand):
+    """
+    Get or create a permalink key for the given dashboard in certain state.
+    Will reuse the key for the same user and dashboard state.
+    """
+
     def __init__(
         self,
         dashboard_id: str,
@@ -45,12 +51,13 @@ class 
CreateDashboardPermalinkCommand(BaseDashboardPermalinkCommand):
                 "dashboardId": self.dashboard_id,
                 "state": self.state,
             }
-            key = CreateKeyValueCommand(
+            user_id = get_user_id()
+            key = UpsertKeyValueCommand(
                 resource=self.resource,
+                key=get_deterministic_uuid(self.salt, (user_id, value)),
                 value=value,
             ).run()
-            if key.id is None:
-                raise DashboardPermalinkCreateFailedError("Unexpected missing 
key id")
+            assert key.id  # for type checks
             return encode_permalink_key(key=key.id, salt=self.salt)
         except SQLAlchemyError as ex:
             logger.exception("Error running create command")
diff --git a/superset/key_value/commands/upsert.py 
b/superset/key_value/commands/upsert.py
index 4bb64aa249..80b0255595 100644
--- a/superset/key_value/commands/upsert.py
+++ b/superset/key_value/commands/upsert.py
@@ -63,7 +63,7 @@ class UpsertKeyValueCommand(BaseCommand):
         self.value = value
         self.expires_on = expires_on
 
-    def run(self) -> Optional[Key]:
+    def run(self) -> Key:
         try:
             return self.upsert()
         except SQLAlchemyError as ex:
@@ -74,7 +74,7 @@ class UpsertKeyValueCommand(BaseCommand):
     def validate(self) -> None:
         pass
 
-    def upsert(self) -> Optional[Key]:
+    def upsert(self) -> Key:
         filter_ = get_filter(self.resource, self.key)
         entry: KeyValueEntry = (
             db.session.query(KeyValueEntry)
diff --git a/superset/key_value/utils.py b/superset/key_value/utils.py
index b2e8e729b0..2468618a81 100644
--- a/superset/key_value/utils.py
+++ b/superset/key_value/utils.py
@@ -18,14 +18,15 @@ from __future__ import annotations
 
 from hashlib import md5
 from secrets import token_urlsafe
-from typing import Union
-from uuid import UUID
+from typing import Any, Union
+from uuid import UUID, uuid3
 
 import hashids
 from flask_babel import gettext as _
 
 from superset.key_value.exceptions import KeyValueParseKeyError
 from superset.key_value.types import KeyValueFilter, KeyValueResource
+from superset.utils.core import json_dumps_w_dates
 
 HASHIDS_MIN_LENGTH = 11
 
@@ -63,3 +64,9 @@ def get_uuid_namespace(seed: str) -> UUID:
     md5_obj = md5()
     md5_obj.update(seed.encode("utf-8"))
     return UUID(md5_obj.hexdigest())
+
+
+def get_deterministic_uuid(namespace: str, payload: Any) -> UUID:
+    """Get a deterministic UUID (uuid3) from a salt and a JSON-serializable 
payload."""
+    payload_str = json_dumps_w_dates(payload, sort_keys=True)
+    return uuid3(get_uuid_namespace(namespace), payload_str)
diff --git a/superset/utils/core.py b/superset/utils/core.py
index 557d14c819..5ce52f9f4b 100644
--- a/superset/utils/core.py
+++ b/superset/utils/core.py
@@ -608,8 +608,9 @@ def json_int_dttm_ser(obj: Any) -> float:
     return obj
 
 
-def json_dumps_w_dates(payload: Dict[Any, Any]) -> str:
-    return json.dumps(payload, default=json_int_dttm_ser)
+def json_dumps_w_dates(payload: Dict[Any, Any], sort_keys: bool = False) -> 
str:
+    """Dumps payload to JSON with Datetime objects properly converted"""
+    return json.dumps(payload, default=json_int_dttm_ser, sort_keys=sort_keys)
 
 
 def error_msg_from_exception(ex: Exception) -> str:
diff --git a/tests/integration_tests/dashboards/permalink/api_tests.py 
b/tests/integration_tests/dashboards/permalink/api_tests.py
index 12d758d5eb..036b42857a 100644
--- a/tests/integration_tests/dashboards/permalink/api_tests.py
+++ b/tests/integration_tests/dashboards/permalink/api_tests.py
@@ -71,11 +71,17 @@ 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
-    data = json.loads(resp.data.decode("utf-8"))
+    data = resp.json
     key = data["key"]
     url = data["url"]
     assert key in url
     id_ = decode_permalink_id(key, permalink_salt)
+
+    assert (
+        data
+        == client.post(f"api/v1/dashboard/{dashboard_id}/permalink", 
json=STATE).json
+    ), "Should always return the same permalink key for the same payload"
+
     db.session.query(KeyValueEntry).filter_by(id=id_).delete()
     db.session.commit()
 
@@ -98,12 +104,12 @@ def test_post_invalid_schema(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"))
-    key = data["key"]
+    key = client.post(f"api/v1/dashboard/{dashboard_id}/permalink", 
json=STATE).json[
+        "key"
+    ]
     resp = client.get(f"api/v1/dashboard/permalink/{key}")
     assert resp.status_code == 200
-    result = json.loads(resp.data.decode("utf-8"))
+    result = resp.json
     assert result["dashboardId"] == str(dashboard_id)
     assert result["state"] == STATE
     id_ = decode_permalink_id(key, permalink_salt)

Reply via email to