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

msyavuz pushed a commit to branch msyavuz/feat/canvas
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 5c1c3db9db2f5cdb7080fe785c1b6cb3303846b7
Author: Mehmet Salih Yavuz <[email protected]>
AuthorDate: Thu Oct 1 12:51:03 2026 +0300

    feat(canvas): slug, theme, CSS and certification metadata
    
    Canvases gain the row metadata dashboards have: a unique slug used in
    the canvas URL (never all digits, and not "list"), a theme, custom CSS,
    certification and external-management fields. Create and update reject
    a slug already taken by any canvas and a theme that doesn't exist. The
    canvas page is served by id or slug.
---
 superset/canvas/api.py                             | 29 ++++++++-
 superset/canvas/schemas.py                         | 68 +++++++++++++++++++++-
 superset/commands/canvas/create.py                 |  2 +
 superset/commands/canvas/update.py                 |  4 +-
 superset/commands/canvas/utils.py                  | 43 ++++++++++++++
 superset/daos/canvas.py                            |  8 +++
 .../2026-09-30_12-00_6d55f8a0851e_add_canvases.py  | 19 ++++++
 superset/models/canvas.py                          | 14 ++++-
 superset/views/canvas.py                           |  4 +-
 tests/unit_tests/canvas/api_test.py                | 35 +++++++++++
 10 files changed, 218 insertions(+), 8 deletions(-)

diff --git a/superset/canvas/api.py b/superset/canvas/api.py
index f1c460ea316..87f26ec05ff 100644
--- a/superset/canvas/api.py
+++ b/superset/canvas/api.py
@@ -180,6 +180,15 @@ class CanvasRestApi(BaseSupersetModelRestApi):
         "created_by.id",
         "created_by.last_name",
     ]
+    _metadata_columns = [
+        "slug",
+        "css",
+        "theme_id",
+        "certified_by",
+        "certification_details",
+        "is_managed_externally",
+        "external_url",
+    ]
     show_columns = [
         "id",
         "uuid",
@@ -187,6 +196,10 @@ class CanvasRestApi(BaseSupersetModelRestApi):
         "description",
         "revision",
         "url",
+        *_metadata_columns,
+        "theme.id",
+        "theme.theme_name",
+        "theme.json_data",
         *_subject_columns,
         *_viewer_columns,
         *_audit_columns,
@@ -197,15 +210,25 @@ class CanvasRestApi(BaseSupersetModelRestApi):
         "title",
         "description",
         "url",
+        "slug",
+        "certified_by",
+        "certification_details",
         "changed_on",
         *_subject_columns,
         *_audit_columns,
     ]
     list_select_columns = list_columns + ["changed_by_fk", "created_on"]
-    add_columns = ["title", "description", "editors", "viewers", "definition"]
-    edit_columns = ["title", "description", "editors", "viewers"]
+    add_columns = [
+        "title",
+        "description",
+        "editors",
+        "viewers",
+        *_metadata_columns,
+        "definition",
+    ]
+    edit_columns = ["title", "description", "editors", "viewers", 
*_metadata_columns]
     order_columns = ["title", "changed_on"]
-    search_columns = ["title", "id"]
+    search_columns = ["title", "id", "slug"]
     search_filters = {"title": [CanvasAllTextFilter], "id": 
[CanvasEditableFilter]}
 
     add_model_schema = CanvasPostSchema()
diff --git a/superset/canvas/schemas.py b/superset/canvas/schemas.py
index 633efb80e11..5056e4bfbc3 100644
--- a/superset/canvas/schemas.py
+++ b/superset/canvas/schemas.py
@@ -17,7 +17,7 @@
 """Request schemas for the canvas REST API."""
 
 from marshmallow import fields, Schema
-from marshmallow.validate import Length
+from marshmallow.validate import Length, Regexp
 
 get_delete_ids_schema = {"type": "array", "items": {"type": "integer"}}
 
@@ -28,6 +28,18 @@ editors_description = (
     "to the creating user."
 )
 viewers_description = "Subject ids (users, roles or groups) that can view the 
canvas."
+slug_description = (
+    "Readable key for the canvas URL, e.g. /canvas/sales-overview/. Unique, 
not "
+    "all digits, and not 'list'."
+)
+css_description = "Custom CSS applied to the canvas page."
+theme_id_description = "Theme applied to the canvas; the default theme when 
unset."
+certified_by_description = "Person or group that has certified this canvas."
+certification_details_description = "Details of the certification."
+is_managed_externally_description = (
+    "Whether the canvas is managed by an external system, e.g. as code."
+)
+external_url_description = "Where the canvas is managed when managed 
externally."
 definition_description = (
     "Initial canvas definition. Changed afterwards only through the "
     "definition operations endpoint."
@@ -45,6 +57,33 @@ class CanvasPostSchema(Schema):
     )
     editors = fields.List(fields.Integer(metadata={"description": 
editors_description}))
     viewers = fields.List(fields.Integer(metadata={"description": 
viewers_description}))
+    slug = fields.String(
+        allow_none=True,
+        validate=[
+            Length(1, 255),
+            Regexp(
+                r"^(?!\d+$)(?!list$)[\w-]+$",
+                error="Use letters, digits, - and _; not all digits, not 
'list'.",
+            ),
+        ],
+        metadata={"description": slug_description},
+    )
+    css = fields.String(allow_none=True, metadata={"description": 
css_description})
+    theme_id = fields.Integer(
+        allow_none=True, metadata={"description": theme_id_description}
+    )
+    certified_by = fields.String(
+        allow_none=True, metadata={"description": certified_by_description}
+    )
+    certification_details = fields.String(
+        allow_none=True, metadata={"description": 
certification_details_description}
+    )
+    is_managed_externally = fields.Boolean(
+        allow_none=True, metadata={"description": 
is_managed_externally_description}
+    )
+    external_url = fields.String(
+        allow_none=True, metadata={"description": external_url_description}
+    )
     definition = fields.Dict(
         allow_none=True, metadata={"description": definition_description}
     )
@@ -59,3 +98,30 @@ class CanvasPutSchema(Schema):
     )
     editors = fields.List(fields.Integer(metadata={"description": 
editors_description}))
     viewers = fields.List(fields.Integer(metadata={"description": 
viewers_description}))
+    slug = fields.String(
+        allow_none=True,
+        validate=[
+            Length(1, 255),
+            Regexp(
+                r"^(?!\d+$)(?!list$)[\w-]+$",
+                error="Use letters, digits, - and _; not all digits, not 
'list'.",
+            ),
+        ],
+        metadata={"description": slug_description},
+    )
+    css = fields.String(allow_none=True, metadata={"description": 
css_description})
+    theme_id = fields.Integer(
+        allow_none=True, metadata={"description": theme_id_description}
+    )
+    certified_by = fields.String(
+        allow_none=True, metadata={"description": certified_by_description}
+    )
+    certification_details = fields.String(
+        allow_none=True, metadata={"description": 
certification_details_description}
+    )
+    is_managed_externally = fields.Boolean(
+        allow_none=True, metadata={"description": 
is_managed_externally_description}
+    )
+    external_url = fields.String(
+        allow_none=True, metadata={"description": external_url_description}
+    )
diff --git a/superset/commands/canvas/create.py 
b/superset/commands/canvas/create.py
index d02af52e913..78abc083c9f 100644
--- a/superset/commands/canvas/create.py
+++ b/superset/commands/canvas/create.py
@@ -38,6 +38,7 @@ from superset.commands.canvas.exceptions import (
     CanvasCreateFailedError,
     CanvasInvalidError,
 )
+from superset.commands.canvas.utils import validate_metadata
 from superset.commands.utils import populate_subjects
 from superset.daos.canvas import CanvasDAO
 from superset.models.canvas import Canvas
@@ -58,6 +59,7 @@ class CreateCanvasCommand(CreateMixin, BaseCommand):
         exceptions: list[ValidationError] = []
         # Defaults the editors to the requesting user, and resolves viewers.
         populate_subjects(self._properties, exceptions)
+        validate_metadata(self._properties, exceptions)
         self._validate_definition(exceptions)
         if exceptions:
             raise CanvasInvalidError(exceptions=exceptions)
diff --git a/superset/commands/canvas/update.py 
b/superset/commands/canvas/update.py
index 421c0fa11f1..7a3d8811119 100644
--- a/superset/commands/canvas/update.py
+++ b/superset/commands/canvas/update.py
@@ -31,6 +31,7 @@ from superset.commands.canvas.exceptions import (
     CanvasNotFoundError,
     CanvasUpdateFailedError,
 )
+from superset.commands.canvas.utils import validate_metadata
 from superset.commands.utils import compute_subjects
 from superset.daos.canvas import CanvasDAO
 from superset.exceptions import SupersetSecurityException
@@ -40,7 +41,7 @@ from superset.utils.decorators import on_error, transaction
 
 class UpdateCanvasCommand(UpdateMixin, BaseCommand):
     """
-    Update title, description, editors and viewers. The definition changes
+    Update the canvas' properties, metadata and sharing. The definition changes
     only through operations (``ApplyCanvasOperationsCommand``).
     """
 
@@ -67,5 +68,6 @@ class UpdateCanvasCommand(UpdateMixin, BaseCommand):
             raise CanvasForbiddenError() from ex
         exceptions: list[ValidationError] = []
         compute_subjects(self._model, self._properties, exceptions)
+        validate_metadata(self._properties, exceptions, self._model.id)
         if exceptions:
             raise CanvasInvalidError(exceptions=exceptions)
diff --git a/superset/commands/canvas/utils.py 
b/superset/commands/canvas/utils.py
new file mode 100644
index 00000000000..952fe4d7961
--- /dev/null
+++ b/superset/commands/canvas/utils.py
@@ -0,0 +1,43 @@
+# 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.
+"""Validation shared by the canvas create and update commands."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from flask_babel import gettext as _
+from marshmallow import ValidationError
+
+from superset import db
+from superset.daos.canvas import CanvasDAO
+from superset.models.core import Theme
+
+
+def validate_metadata(
+    properties: dict[str, Any],
+    exceptions: list[ValidationError],
+    canvas_id: int | None = None,
+) -> None:
+    """Check the slug is free and the theme exists."""
+    if (slug := properties.get("slug")) and CanvasDAO.slug_in_use(slug, 
canvas_id):
+        exceptions.append(ValidationError(_("Must be unique"), 
field_name="slug"))
+    theme_id = properties.get("theme_id")
+    if theme_id is not None and db.session.get(Theme, theme_id) is None:
+        exceptions.append(
+            ValidationError(_("Theme does not exist"), field_name="theme_id")
+        )
diff --git a/superset/daos/canvas.py b/superset/daos/canvas.py
index f4c6a810900..11d343a425b 100644
--- a/superset/daos/canvas.py
+++ b/superset/daos/canvas.py
@@ -45,6 +45,14 @@ class CanvasDAO(BaseDAO[Canvas]):
             .one()
         )
 
+    @staticmethod
+    def slug_in_use(slug: str, exclude_id: int | None = None) -> bool:
+        """Whether another canvas, visible to the caller or not, has 
``slug``."""
+        query = db.session.query(Canvas).filter(Canvas.slug == slug)
+        if exclude_id is not None:
+            query = query.filter(Canvas.id != exclude_id)
+        return db.session.query(query.exists()).scalar()
+
     @staticmethod
     def load(canvas: Canvas) -> dict[str, Any]:
         """The stored definition, upgraded to the current schema version."""
diff --git 
a/superset/migrations/versions/2026-09-30_12-00_6d55f8a0851e_add_canvases.py 
b/superset/migrations/versions/2026-09-30_12-00_6d55f8a0851e_add_canvases.py
index d4676e42f6e..e9393917d6a 100644
--- a/superset/migrations/versions/2026-09-30_12-00_6d55f8a0851e_add_canvases.py
+++ b/superset/migrations/versions/2026-09-30_12-00_6d55f8a0851e_add_canvases.py
@@ -72,13 +72,32 @@ def upgrade() -> None:
         sa.Column("definition", MediumText(), nullable=False),
         sa.Column("definition_version", sa.Integer(), nullable=False),
         sa.Column("revision", sa.BigInteger(), nullable=False),
+        sa.Column("slug", sa.String(255), nullable=True),
+        sa.Column("css", MediumText(), nullable=True),
+        sa.Column("theme_id", sa.Integer(), nullable=True),
+        sa.Column("certified_by", sa.Text(), nullable=True),
+        sa.Column("certification_details", sa.Text(), nullable=True),
+        sa.Column(
+            "is_managed_externally",
+            sa.Boolean(),
+            nullable=False,
+            server_default=sa.false(),
+        ),
+        sa.Column("external_url", sa.Text(), nullable=True),
         sa.ForeignKeyConstraint(
             ["created_by_fk"], ["ab_user.id"], 
name="fk_canvases_created_by_fk_ab_user"
         ),
         sa.ForeignKeyConstraint(
             ["changed_by_fk"], ["ab_user.id"], 
name="fk_canvases_changed_by_fk_ab_user"
         ),
+        sa.ForeignKeyConstraint(
+            ["theme_id"],
+            ["themes.id"],
+            name="fk_canvases_theme_id_themes",
+            ondelete="SET NULL",
+        ),
         sa.UniqueConstraint("uuid", name="uq_canvases_uuid"),
+        sa.UniqueConstraint("slug", name="uq_canvases_slug"),
     )
     _subject_table("canvas_editors")
     _subject_table("canvas_viewers")
diff --git a/superset/models/canvas.py b/superset/models/canvas.py
index 9e8ac71b154..d327910133a 100644
--- a/superset/models/canvas.py
+++ b/superset/models/canvas.py
@@ -24,6 +24,7 @@ from typing import Any
 from flask_appbuilder import Model
 from sqlalchemy import (
     BigInteger,
+    Boolean,
     Column,
     DateTime,
     ForeignKey,
@@ -60,6 +61,17 @@ class Canvas(AuditMixinNullable, UUIDMixin, Model):
     # Bumped by every accepted definition write; the base for stale-edit
     # detection.
     revision = Column(BigInteger, nullable=False, default=1)
+    # Readable URL key; never all digits, so it can't be mistaken for an id.
+    slug = Column(String(255), unique=True)
+    css = Column(utils.MediumText())
+    theme_id = Column(
+        Integer, ForeignKey("themes.id", ondelete="SET NULL"), nullable=True
+    )
+    theme = relationship("Theme", foreign_keys=[theme_id])
+    certified_by = Column(Text)
+    certification_details = Column(Text)
+    is_managed_externally = Column(Boolean, nullable=False, default=False)
+    external_url = Column(Text, nullable=True)
 
     editors = relationship(Subject, secondary=canvas_editors, 
passive_deletes=True)
     viewers = relationship(Subject, secondary=canvas_viewers, 
passive_deletes=True)
@@ -75,7 +87,7 @@ class Canvas(AuditMixinNullable, UUIDMixin, Model):
 
     @property
     def url(self) -> str:
-        return f"/canvas/{self.id}/"
+        return f"/canvas/{self.slug or self.id}/"
 
 
 class CanvasOp(Model):
diff --git a/superset/views/canvas.py b/superset/views/canvas.py
index 980f4f7fcd3..cd1251ae149 100644
--- a/superset/views/canvas.py
+++ b/superset/views/canvas.py
@@ -38,10 +38,10 @@ class CanvasView(BaseSupersetView):
             return abort(404)
         return super().render_app_template()
 
-    @expose("/<int:pk>/")
+    @expose("/<id_or_slug>/")
     @has_access
     @permission_name("read")
-    def show(self, pk: int) -> FlaskResponse:  # pylint: 
disable=unused-argument
+    def show(self, id_or_slug: str) -> FlaskResponse:  # pylint: 
disable=unused-argument
         if not feature_flag_manager.is_feature_enabled("CANVAS"):
             return abort(404)
         return super().render_app_template()
diff --git a/tests/unit_tests/canvas/api_test.py 
b/tests/unit_tests/canvas/api_test.py
index 086958d4cda..06bb5885e4c 100644
--- a/tests/unit_tests/canvas/api_test.py
+++ b/tests/unit_tests/canvas/api_test.py
@@ -92,6 +92,41 @@ def test_create_and_read_a_canvas(
     assert definition["definition"]["nodes"] == {}
 
 
+def test_metadata_and_slug(
+    client: Any,
+    full_api_access: None,
+    canvas: Any,
+    mocker: MockerFixture,
+    session: Session,
+) -> None:
+    from superset.models.canvas import Canvas
+
+    mocker.patch("superset.commands.canvas.create.populate_subjects")
+    metadata = {
+        "slug": "sales-overview",
+        "css": ".x { color: red; }",
+        "certified_by": "Data team",
+        "certification_details": "Reviewed weekly",
+        "external_url": "https://example.com/canvas.yaml";,
+        "is_managed_externally": True,
+    }
+
+    response = client.post(BASE + "/", json={"title": "Sales", **metadata})
+
+    assert response.status_code == 201
+    created = session.get(Canvas, response.json["id"])
+    assert {key: getattr(created, key) for key in metadata} == metadata
+    assert created.url == "/canvas/sales-overview/"
+
+    taken = client.put(f"{BASE}/100", json={"slug": "sales-overview"})
+    assert taken.status_code == 422
+    assert taken.json["message"] == {"slug": ["Must be unique"]}
+    for slug in ("123", "list", "has space"):
+        assert client.put(f"{BASE}/100", json={"slug": slug}).status_code == 
400
+    missing_theme = client.put(f"{BASE}/100", json={"theme_id": 999})
+    assert missing_theme.json["message"] == {"theme_id": ["Theme does not 
exist"]}
+
+
 def test_create_validates_the_body(
     client: Any, full_api_access: None, canvas: Any
 ) -> None:

Reply via email to