You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by as...@apache.org on 2026/07/20 11:16:16 UTC

(superset) 01/02: pushing changes

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

alexandrusoare pushed a commit to branch alexandrusoare/feat/folders-testing-phase2
in repository https://gitbox.apache.org/repos/asf/superset.git

commit c5671bf47c4969706a818c740bbc3d537843ccfe
Author: alexandrusoare <al...@gmail.com>
AuthorDate: Tue Jul 7 12:48:11 2026 +0300

    pushing changes
---
 .../src/pages/AnalyticsList/CreateFolderModal.tsx  | 15 ++++-
 .../src/pages/AnalyticsList/index.tsx              | 44 ++++++++++----
 superset-frontend/src/pages/AnalyticsList/types.ts |  2 +
 superset/commands/folder/delete.py                 |  7 +++
 superset/commands/folder/update.py                 | 12 ++++
 superset/daos/folder.py                            | 68 +++++++++++++++++++++-
 superset/daos/folder_permissions.py                | 12 +++-
 superset/folders/api.py                            | 48 +++++++++++----
 superset/folders/hooks.py                          | 67 +++++++++++++++++++++
 superset/folders/schemas.py                        |  2 +
 10 files changed, 249 insertions(+), 28 deletions(-)

diff --git a/superset-frontend/src/pages/AnalyticsList/CreateFolderModal.tsx b/superset-frontend/src/pages/AnalyticsList/CreateFolderModal.tsx
index ab22124e5ec..f092b9f067e 100644
--- a/superset-frontend/src/pages/AnalyticsList/CreateFolderModal.tsx
+++ b/superset-frontend/src/pages/AnalyticsList/CreateFolderModal.tsx
@@ -19,7 +19,8 @@
 import { useCallback, useState } from 'react';
 import { SupersetClient } from '@superset-ui/core';
 import { t } from '@apache-superset/core/translation';
-import { Input } from '@superset-ui/core/components';
+import { styled, css } from '@apache-superset/core/theme';
+import { Checkbox, Input } from '@superset-ui/core/components';
 import { StandardModal } from 'src/components/Modal';
 
 interface CreateFolderModalProps {
@@ -43,6 +44,7 @@ export default function CreateFolderModal({
   addSuccessToast,
 }: CreateFolderModalProps) {
   const [name, setName] = useState('');
+  const [isPrivate, setIsPrivate] = useState(false);
   const [saving, setSaving] = useState(false);
 
   const handleSave = useCallback(async () => {
@@ -58,6 +60,7 @@ export default function CreateFolderModal({
           name: name.trim(),
           folder_type: 'analytics',
           ...(parentFolderUuid ? { parent_uuid: parentFolderUuid } : {}),
+          ...(isPrivate ? { is_private: true } : {}),
         },
       });
       addSuccessToast(t('Folder "%s" created', name.trim()));
@@ -100,6 +103,16 @@ export default function CreateFolderModal({
             autoFocus
           />
         </FormGroup>
+        {!parentFolderUuid && (
+          <FormGroup>
+            <Checkbox
+              checked={isPrivate}
+              onChange={e => setIsPrivate(e.target.checked)}
+            >
+              {t('Private folder')}
+            </Checkbox>
+          </FormGroup>
+        )}
       </ModalContent>
     </StandardModal>
   );
diff --git a/superset-frontend/src/pages/AnalyticsList/index.tsx b/superset-frontend/src/pages/AnalyticsList/index.tsx
index 155b50001a3..255c5e3fc69 100644
--- a/superset-frontend/src/pages/AnalyticsList/index.tsx
+++ b/superset-frontend/src/pages/AnalyticsList/index.tsx
@@ -706,7 +706,7 @@ function AnalyticsList({
             onClick: () => void;
           }> = [];
 
-          if (isAtRoot) {
+          if (isAtRoot && !original.is_only_me) {
             if (isPinned(original)) {
               contextMenuItems.push({
                 key: 'unpin',
@@ -723,7 +723,7 @@ function AnalyticsList({
               });
             }
           }
-          if (canEditCurrentFolder) {
+          if (canEditCurrentFolder && !original.is_only_me) {
             contextMenuItems.push({
               key: 'move',
               label: t('Move to…'),
@@ -742,13 +742,35 @@ function AnalyticsList({
                     if (e.key === 'Enter') drillInto(original);
                   }}
                 >
-                  <Icons.FolderOutlined
-                    iconSize="m"
-                    css={{
-                      color: theme.colorSuccess,
-                    }}
-                  />
+                  {original.is_private ? (
+                    <Icons.LockOutlined
+                      iconSize="m"
+                      css={{ color: theme.colorTextSecondary }}
+                    />
+                  ) : (
+                    <Icons.FolderOutlined
+                      iconSize="m"
+                      css={{
+                        color: original.parent_uuid
+                          ? theme.colorWarning
+                          : theme.colorSuccess,
+                      }}
+                    />
+                  )}
                   {highlightName(original.name)}
+                  {original.is_private && (
+                    <span
+                      css={{
+                        fontSize: theme.fontSizeXS,
+                        padding: `0 ${theme.sizeUnit}px`,
+                        borderRadius: theme.borderRadius,
+                        background: theme.colorBgLayout,
+                        color: theme.colorTextSecondary,
+                      }}
+                    >
+                      {t('Private')}
+                    </span>
+                  )}
                   {pinIcon}
                 </NameLink>
               );
@@ -936,7 +958,7 @@ function AnalyticsList({
             const canEdit = original.user_permission === 'editor';
             return (
               <Actions className="actions">
-                {canEdit && (
+                {canEdit && !original.is_private && (
                   <Tooltip title={t('Manage permissions')} placement="bottom">
                     <span
                       role="button"
@@ -948,7 +970,7 @@ function AnalyticsList({
                     </span>
                   </Tooltip>
                 )}
-                {canEdit && (
+                {canEdit && !original.is_only_me && (
                   <Tooltip title={t('Rename folder')} placement="bottom">
                     <span
                       role="button"
@@ -987,7 +1009,7 @@ function AnalyticsList({
                       />
                     </Tooltip>
                   )}
-                {canEdit && (
+                {canEdit && !original.is_only_me && (
                   <Tooltip title={t('Delete folder')} placement="bottom">
                     <span
                       role="button"
diff --git a/superset-frontend/src/pages/AnalyticsList/types.ts b/superset-frontend/src/pages/AnalyticsList/types.ts
index 22875205163..b890354cb7a 100644
--- a/superset-frontend/src/pages/AnalyticsList/types.ts
+++ b/superset-frontend/src/pages/AnalyticsList/types.ts
@@ -46,5 +46,7 @@ export interface ContentItem {
   user_permission?: 'editor' | 'viewer' | null;
   parent_uuid?: string | null;
   inherits_permissions?: boolean;
+  is_private?: boolean;
+  is_only_me?: boolean;
   folder_path?: Array<{ uuid: string; name: string }>;
 }
diff --git a/superset/commands/folder/delete.py b/superset/commands/folder/delete.py
index 014da68235e..70e32e52fad 100644
--- a/superset/commands/folder/delete.py
+++ b/superset/commands/folder/delete.py
@@ -46,10 +46,17 @@ class DeleteFolderCommand(BaseCommand):
         FolderDAO.delete_folder(self._model, self._archive_items)
 
     def validate(self) -> None:
+        from superset.utils import json as json_utils
+
         self._model = FolderDAO.find_by_id_or_uuid(self._id)
         if not self._model:
             raise FolderNotFoundError()
 
+        # "Only Me" folder cannot be deleted
+        extra = json_utils.loads(self._model.extra) if self._model.extra else {}
+        if extra.get("only_me"):
+            raise FolderForbiddenError()
+
         if not security_manager.is_admin():
             user_id = get_user_id()
             if not user_id or not FolderPermissionDAO.user_is_folder_editor(
diff --git a/superset/commands/folder/update.py b/superset/commands/folder/update.py
index 0c8951d7506..1b6fd909a0e 100644
--- a/superset/commands/folder/update.py
+++ b/superset/commands/folder/update.py
@@ -110,6 +110,8 @@ class UpdateFolderCommand(BaseCommand):
         return new_parent_id
 
     def validate(self) -> None:
+        from superset.utils import json as json_utils
+
         self._model = FolderDAO.find_by_id_or_uuid(self._id)
         if not self._model:
             raise FolderNotFoundError()
@@ -121,6 +123,16 @@ class UpdateFolderCommand(BaseCommand):
             ):
                 raise FolderForbiddenError()
 
+        # "Only Me" folder: reject name/parent changes (sync_permissions is allowed)
+        extra = json_utils.loads(self._model.extra) if self._model.extra else {}
+        if extra.get("only_me"):
+            if "name" in self._properties or "parent_uuid" in self._properties:
+                raise FolderForbiddenError()
+
+        # Private folders cannot be moved into other folders
+        if self._model.is_private and "parent_uuid" in self._properties:
+            raise FolderForbiddenError()
+
         exceptions: list[ValidationError] = []
         new_parent_id = self._validate_parent(exceptions)
 
diff --git a/superset/daos/folder.py b/superset/daos/folder.py
index a1af64ddee3..b633ff69288 100644
--- a/superset/daos/folder.py
+++ b/superset/daos/folder.py
@@ -200,6 +200,19 @@ class FolderDAO(BaseDAO[Folder]):
                 fq = fq.where(Folder.changed_on >= modified_start)
             if modified_end:
                 fq = fq.where(Folder.changed_on <= modified_end)
+            # Hide other users' private folders from everyone (including admins)
+            if user_id:
+                own_private_ids = (
+                    select(folder_editors.c.folder_id).where(
+                        folder_editors.c.user_id == user_id
+                    )
+                )
+                fq = fq.where(
+                    or_(
+                        Folder.is_private.is_(False),
+                        Folder.id.in_(own_private_ids),
+                    )
+                )
             # Access filter: non-admins only see folders they are a member of
             if not is_admin and user_id:
                 from superset.folders.utils import user_accessible_folder_ids
@@ -423,9 +436,19 @@ class FolderDAO(BaseDAO[Folder]):
         sort_col = sort_map.get(sort_column, unioned.c.changed_on)
         order = sort_col.asc() if sort_order == "asc" else sort_col.desc()
 
-        # Pinned items always on top (root level only, per-user)
+        # Sort priority at root: Only Me (0) → pinned (1-3) → unpinned (4)
         pin_order = (literal(4) + literal(0)).label("pin_order")
         if folder_id is None and user_id:
+            only_me_order = (
+                select(literal(0) + literal(0))
+                .where(
+                    unioned.c.item_type == "folder",
+                    Folder.id == unioned.c.item_id,
+                    Folder.is_private.is_(True),
+                )
+                .correlate(unioned)
+                .scalar_subquery()
+            )
             pin_position = (
                 select(FolderPin.position)
                 .where(
@@ -448,7 +471,9 @@ class FolderDAO(BaseDAO[Folder]):
                 .correlate(unioned)
                 .scalar_subquery()
             )
-            pin_order = func.coalesce(pin_position, literal(4) + literal(0)).label("pin_order")
+            pin_order = func.coalesce(
+                only_me_order, pin_position, literal(4) + literal(0)
+            ).label("pin_order")
 
         page_rows = db.session.execute(
             select(unioned.c.item_type, unioned.c.item_id)
@@ -697,6 +722,45 @@ class FolderDAO(BaseDAO[Folder]):
             db.session.query(model.id).filter(model.id == asset_id).first() is not None
         )
 
+    # ------------------------------------------------------------------ #
+    # Private / "Only Me" folders
+    # ------------------------------------------------------------------ #
+    @classmethod
+    def get_or_create_only_me_folder(cls, user_id: int) -> Folder:
+        """Return the user's 'Only Me' folder, creating it if it doesn't exist."""
+        from superset.utils import json as json_utils
+
+        folder = (
+            db.session.query(Folder)
+            .join(folder_editors, folder_editors.c.folder_id == Folder.id)
+            .filter(
+                folder_editors.c.user_id == user_id,
+                Folder.is_private.is_(True),
+                Folder.parent_id.is_(None),
+            )
+            .all()
+        )
+        for f in folder:
+            extra = json_utils.loads(f.extra) if f.extra else {}
+            if extra.get("only_me"):
+                return f
+
+        new_folder = cls.create(
+            attributes={
+                "name": "Only Me",
+                "is_private": True,
+                "parent_id": None,
+                "folder_type": "analytics",
+                "extra": json_utils.dumps({"only_me": True, "inherits_permissions": False}),
+            }
+        )
+        db.session.flush()
+        db.session.execute(
+            folder_editors.insert().values(folder_id=new_folder.id, user_id=user_id)
+        )
+        db.session.flush()
+        return new_folder
+
     # ------------------------------------------------------------------ #
     # Pins
     # ------------------------------------------------------------------ #
diff --git a/superset/daos/folder_permissions.py b/superset/daos/folder_permissions.py
index 1dda753cb1c..1342c6fdbcc 100644
--- a/superset/daos/folder_permissions.py
+++ b/superset/daos/folder_permissions.py
@@ -127,11 +127,15 @@ class FolderPermissionDAO:
     def copy_permissions_to_subfolder(
         parent_folder_id: int, child_folder_id: int
     ) -> None:
-        """Copy all editors/viewers from parent to child folder.
+        """Replace child folder permissions with parent's.
 
-        Called when a subfolder is created. Sets extra.inherits_permissions = true
-        on the child.
+        Clears existing editors/viewers on the child, then copies the parent's.
+        Sets extra.inherits_permissions = true on the child.
+        Skips private folders — their permissions are owner-only.
         """
+        child = db.session.query(Folder).get(child_folder_id)
+        if child and child.is_private:
+            return
 
         # Clear child's existing permissions
         db.session.execute(
@@ -200,6 +204,8 @@ class FolderPermissionDAO:
             )
             result = []
             for child in children:
+                if child.is_private:
+                    continue
                 extra = json_utils.loads(child.extra) if child.extra else {}
                 if extra.get("inherits_permissions", True):
                     result.append(child.id)
diff --git a/superset/folders/api.py b/superset/folders/api.py
index 1f0662e3874..60f866b87d4 100644
--- a/superset/folders/api.py
+++ b/superset/folders/api.py
@@ -102,6 +102,14 @@ def _get_user_permission(folder: Folder) -> str | None:
     return None
 
 
+def _is_only_me(folder: Folder) -> bool:
+    """Return whether this is the system 'Only Me' private folder."""
+    if not folder.is_private or not folder.extra:
+        return False
+    extra = json_utils.loads(folder.extra)
+    return bool(extra.get("only_me"))
+
+
 def _get_inherits_permissions(folder: Folder) -> bool:
     """Return whether this folder inherits permissions from its parent."""
     if not folder.extra:
@@ -143,6 +151,7 @@ def serialize_folder(
         "changed_by": _serialize_user(folder.changed_by),
         "user_permission": _get_user_permission(folder),
         "inherits_permissions": _get_inherits_permissions(folder),
+        "is_only_me": _is_only_me(folder),
         "owners": [
             _serialize_user(u) for u in (folder.editors or [])
         ],
@@ -412,17 +421,22 @@ class FolderRestApi(BaseSupersetApi):
         folder_type = request.args.get("folder_type")
         folders = FolderDAO.get_folders(folder_type=folder_type)
 
-        # Non-admins only see folders they have access to
-        if not security_manager.is_admin():
-            user_id = get_user_id()
-            if user_id:
-                folders = [
-                    f
-                    for f in folders
-                    if FolderPermissionDAO.user_has_folder_access(user_id, f.id)
-                ]
-            else:
-                folders = []
+        # Filter folders by access — hide other users' private folders from everyone
+        if (user_id := get_user_id()):
+            folders = [
+                f
+                for f in folders
+                if (
+                    not f.is_private
+                    or FolderPermissionDAO.user_has_folder_access(user_id, f.id)
+                )
+                and (
+                    security_manager.is_admin()
+                    or FolderPermissionDAO.user_has_folder_access(user_id, f.id)
+                )
+            ]
+        else:
+            folders = []
 
         # Precompute counts to avoid N+1 queries
         folder_ids = [f.id for f in folders]
@@ -500,6 +514,12 @@ class FolderRestApi(BaseSupersetApi):
               $ref: '#/components/responses/500'
         """
         folder_type = request.args.get("folder_type", DEFAULT_FOLDER_TYPE)
+
+        # Lazy-create the "Only Me" folder for eligible users
+        user_id = get_user_id()
+        if user_id and can_manage_folders(g.user):
+            FolderDAO.get_or_create_only_me_folder(user_id)
+
         parsed = _parse_rison_args(kwargs.get("rison", {}))
         rows, count = FolderDAO.get_contents(None, folder_type, **parsed)
         result = (
@@ -1196,6 +1216,8 @@ class FolderRestApi(BaseSupersetApi):
         folder = FolderDAO.get_by_uuid(folder_uuid)
         if not folder:
             return self.response_404()
+        if folder.is_private:
+            return self.response_403()
         try:
             self._raise_for_folder_edit(folder)
         except FolderForbiddenError:
@@ -1253,6 +1275,8 @@ class FolderRestApi(BaseSupersetApi):
         folder = FolderDAO.get_by_uuid(folder_uuid)
         if not folder:
             return self.response_404()
+        if folder.is_private:
+            return self.response_403()
         try:
             self._raise_for_folder_edit(folder)
         except FolderForbiddenError:
@@ -1298,6 +1322,8 @@ class FolderRestApi(BaseSupersetApi):
         folder = FolderDAO.get_by_uuid(folder_uuid)
         if not folder:
             return self.response_404()
+        if folder.is_private:
+            return self.response_403()
         try:
             self._raise_for_folder_edit(folder)
         except FolderForbiddenError:
diff --git a/superset/folders/hooks.py b/superset/folders/hooks.py
index 3cc132e18e1..2c441840a49 100644
--- a/superset/folders/hooks.py
+++ b/superset/folders/hooks.py
@@ -55,6 +55,21 @@ def folder_raise_for_access_bypass(**kwargs: Any) -> bool:
 
     dashboard = kwargs.get("dashboard")
     chart = kwargs.get("chart")
+
+    # Never bypass for assets in private folders unless the user is the owner
+    if is_asset_in_private_folder(
+        dashboard_id=dashboard.id if dashboard else None,
+        chart_id=chart.id if chart else None,
+    ):
+        from superset.daos.folder_permissions import FolderPermissionDAO as _PDAO
+
+        has_access = _PDAO.user_has_folder_access_for_asset(
+            user_id=user_id,
+            dashboard_id=dashboard.id if dashboard else None,
+            chart_id=chart.id if chart else None,
+        )
+        return has_access
+
     query_context = kwargs.get("query_context")
     viz = kwargs.get("viz")
     datasource = kwargs.get("datasource")
@@ -114,3 +129,55 @@ def folder_extra_owners(resource: Any) -> list[Any]:
         }
         for u in get_folder_editor_users(fo.folder_id)
     ]
+
+
+def after_asset_create(asset: Any, asset_type: str) -> None:
+    """Auto-assign newly created charts/dashboards to the user's 'Only Me' folder."""
+    import logging
+
+    logger = logging.getLogger(__name__)
+    logger.info("[after_asset_create] called with asset_type=%s, asset_id=%s", asset_type, asset.id)
+
+    from superset.daos.folder import FolderDAO
+    from superset.folders.utils import can_manage_folders
+
+    user_id = get_user_id()
+    logger.info("[after_asset_create] user_id=%s", user_id)
+    if not user_id:
+        logger.info("[after_asset_create] no user_id, returning")
+        return
+
+    from flask import g
+
+    if not hasattr(g, "user"):
+        logger.info("[after_asset_create] no g.user, returning")
+        return
+    if not can_manage_folders(g.user):
+        logger.info("[after_asset_create] user cannot manage folders, roles=%s", [r.name for r in g.user.roles])
+        return
+
+    logger.info("[after_asset_create] creating/getting Only Me folder")
+    folder = FolderDAO.get_or_create_only_me_folder(user_id)
+    logger.info("[after_asset_create] assigning asset to folder %s", folder.id)
+    FolderDAO.assign_assets(folder, [{"type": asset_type, "id": asset.id}])
+    logger.info("[after_asset_create] done")
+
+
+def is_asset_in_private_folder(
+    dashboard_id: int | None = None,
+    chart_id: int | None = None,
+) -> bool:
+    """Check if an asset is in a private folder."""
+    from superset.folders.models import Folder, FolderObject
+
+    query = db.session.query(FolderObject).join(
+        Folder, Folder.id == FolderObject.folder_id
+    ).filter(Folder.is_private.is_(True))
+
+    if dashboard_id:
+        if query.filter(FolderObject.dashboard_id == dashboard_id).first():
+            return True
+    if chart_id:
+        if query.filter(FolderObject.chart_id == chart_id).first():
+            return True
+    return False
diff --git a/superset/folders/schemas.py b/superset/folders/schemas.py
index 59904f4f127..6ef1eb137cd 100644
--- a/superset/folders/schemas.py
+++ b/superset/folders/schemas.py
@@ -99,6 +99,7 @@ class FolderSchema(Schema):
     parent_uuid = fields.String(allow_none=True)
     folder_type = fields.String()
     is_private = fields.Boolean()
+    is_only_me = fields.Boolean()
     children_count = fields.Integer()
     asset_count = fields.Integer()
     created_on = fields.DateTime()
@@ -135,6 +136,7 @@ class FolderContentItemSchema(Schema):
     parent_uuid = fields.String(allow_none=True)
     folder_type = fields.String(allow_none=True)
     is_private = fields.Boolean(allow_none=True)
+    is_only_me = fields.Boolean(allow_none=True)
     children_count = fields.Integer(allow_none=True)
     asset_count = fields.Integer(allow_none=True)
     viz_type = fields.String(allow_none=True)