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:
