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)