diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 6c7e8d610a8..da2ced8d5b8 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-24.04 strategy: matrix: - python-version: ["current", "previous"] + python-version: ["current", "previous", "next"] steps: - name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )" uses: actions/checkout@v4 diff --git a/.github/workflows/superset-python-integrationtest.yml b/.github/workflows/superset-python-integrationtest.yml index cd19ecb64c7..03da74abbfd 100644 --- a/.github/workflows/superset-python-integrationtest.yml +++ b/.github/workflows/superset-python-integrationtest.yml @@ -77,7 +77,7 @@ jobs: runs-on: ubuntu-24.04 strategy: matrix: - python-version: ["current", "previous"] + python-version: ["current", "previous", "next"] env: PYTHONPATH: ${{ github.workspace }} SUPERSET_CONFIG: tests.integration_tests.superset_test_config diff --git a/.github/workflows/superset-python-unittest.yml b/.github/workflows/superset-python-unittest.yml index a8feed453cd..208cf82421a 100644 --- a/.github/workflows/superset-python-unittest.yml +++ b/.github/workflows/superset-python-unittest.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-24.04 strategy: matrix: - python-version: ["previous", "current"] + python-version: ["previous", "current", "next"] env: PYTHONPATH: ${{ github.workspace }} steps: diff --git a/RESOURCES/INTHEWILD.md b/RESOURCES/INTHEWILD.md index 85cc82e1750..389afcd6ffc 100644 --- a/RESOURCES/INTHEWILD.md +++ b/RESOURCES/INTHEWILD.md @@ -105,6 +105,7 @@ Join our growing community! - [Formbricks](https://formbricks.com) - [Gavagai](https://gavagai.io) [@gavagai-corp] - [GfK Data Lab](https://www.gfk.com/home) [@mherr] +- [HPE](https://www.hpe.com/in/en/home.html) [@anmol-hpe] - [Hydrolix](https://www.hydrolix.io/) - [Intercom](https://www.intercom.com/) [@kate-gallo] - [jampp](https://jampp.com/) diff --git a/docs/docs/configuration/databases.mdx b/docs/docs/configuration/databases.mdx index 2c8124e942f..0aeee2afa06 100644 --- a/docs/docs/configuration/databases.mdx +++ b/docs/docs/configuration/databases.mdx @@ -72,7 +72,8 @@ are compatible with Superset. | [PostgreSQL](/docs/configuration/databases#postgres) | `pip install psycopg2` | `postgresql://:@/` | | [Presto](/docs/configuration/databases#presto) | `pip install pyhive` | `presto://{username}:{password}@{hostname}:{port}/{database}` | | [Rockset](/docs/configuration/databases#rockset) | `pip install rockset-sqlalchemy` | `rockset://:@` | -| [SAP Hana](/docs/configuration/databases#hana) | `pip install hdbcli sqlalchemy-hana` or `pip install apache_superset[hana]` | `hana://{username}:{password}@{host}:{port}` | +| [SAP Hana](/docs/configuration/databases#hana) | `pip install hdbcli sqlalchemy-hana` or `pip install apache_superset[hana]` | `hana://{username}:{password}@{host}:{port}` | +| [SingleStore](/docs/configuration/databases#singlestore) | `pip install sqlalchemy-singlestoredb` | `singlestoredb://{username}:{password}@{host}:{port}/{database}` | | [StarRocks](/docs/configuration/databases#starrocks) | `pip install starrocks` | `starrocks://:@:/.` | | [Snowflake](/docs/configuration/databases#snowflake) | `pip install snowflake-sqlalchemy` | `snowflake://{user}:{password}@{account}.{region}/{database}?role={role}&warehouse={warehouse}` | | SQLite | No additional library needed | `sqlite://path/to/file.db?check_same_thread=false` | @@ -1299,6 +1300,16 @@ You might have noticed that some special charecters are used in the above connec For more information about this check the [sqlalchemy documentation](https://docs.sqlalchemy.org/en/20/core/engines.html#escaping-special-characters-such-as-signs-in-passwords). Which says `When constructing a fully formed URL string to pass to create_engine(), special characters such as those that may be used in the user and password need to be URL encoded to be parsed correctly. This includes the @ sign.` ::: +#### SingleStore + +The recommended connector library for SingleStore is +[sqlalchemy-singlestoredb](https://github.com/singlestore-labs/sqlalchemy-singlestoredb). + +The expected connection string is formatted as follows: + +``` +singlestoredb://{username}:{password}@{host}:{port}/{database} +``` #### StarRocks diff --git a/docs/docs/security/cves.mdx b/docs/docs/security/cves.mdx index e61d8602162..15871214b59 100644 --- a/docs/docs/security/cves.mdx +++ b/docs/docs/security/cves.mdx @@ -7,6 +7,7 @@ sidebar_position: 2 | CVE | Title | Affected | |:---------------|:-----------------------------------------------------------------------------------|---------:| | CVE-2025-27696 | Improper authorization leading to resource ownership takeover | < 4.1.2 | +| CVE-2025-48912 | Improper authorization bypass on row level security via SQL Injection | < 4.1.2 | #### Version 4.1.0 diff --git a/pyproject.toml b/pyproject.toml index c9ed5db5a83..e04dcf6711c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ authors = [ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", ] dependencies = [ "backoff>=1.8.0", @@ -66,7 +67,7 @@ dependencies = [ "markdown>=3.0", "msgpack>=1.0.0, <1.1", "nh3>=0.2.11, <0.3", - "numpy>1.23.5, <2", + "numpy>1.23.5, <2.3", "packaging", # -------------------------- # pandas and related (wanting pandas[performance] without numba as it's 100+MB and not needed) @@ -94,7 +95,8 @@ dependencies = [ "sqlalchemy-utils>=0.38.3, <0.39", "sqlglot>=26.1.3, <27", "sqlparse>=0.5.0", - "tabulate>=0.8.9, <0.9", + # newer pandas needs 0.9+ + "tabulate>=0.9.0, <1.0", "typing-extensions>=4, <5", "waitress; sys_platform == 'win32'", "wtforms>=2.3.3, <4", @@ -166,6 +168,7 @@ prophet = ["prophet>=1.1.5, <2"] redshift = ["sqlalchemy-redshift>=0.8.1, <0.9"] rockset = ["rockset-sqlalchemy>=0.0.1, <1"] shillelagh = ["shillelagh[all]>=1.2.18, <2"] +singlestore = ["sqlalchemy-singlestoredb>=1.1.1, <2"] snowflake = ["snowflake-sqlalchemy>=1.2.4, <2"] spark = [ "pyhive[hive]>=0.6.5;python_version<'3.11'", diff --git a/requirements/base.in b/requirements/base.in index f23e6ad1cad..e531ff644b2 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -33,3 +33,6 @@ apispec>=6.0.0,<6.7.0 # https://marshmallow-sqlalchemy.readthedocs.io/en/latest/changelog.html#id3 # Opened this issue https://github.com/marshmallow-code/marshmallow-sqlalchemy/issues/665 marshmallow-sqlalchemy>=1.3.0,<1.4.1 + +# needed for python 3.12 support +openapi-schema-validator>=0.6.3 diff --git a/requirements/base.txt b/requirements/base.txt index c887750e909..f9e20d836c7 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -11,9 +11,7 @@ apispec==6.6.1 apsw==3.49.2.0 # via shillelagh async-timeout==4.0.3 - # via - # -r requirements/base.in - # redis + # via -r requirements/base.in attrs==25.3.0 # via # cattrs @@ -99,11 +97,6 @@ email-validator==2.2.0 # via flask-appbuilder et-xmlfile==2.0.0 # via openpyxl -exceptiongroup==1.3.0 - # via - # cattrs - # trio - # trio-websocket flask==2.3.3 # via # apache-superset (pyproject.toml) @@ -189,9 +182,13 @@ jinja2==3.1.6 jsonpath-ng==1.7.0 # via apache-superset (pyproject.toml) jsonschema==4.23.0 - # via flask-appbuilder + # via + # flask-appbuilder + # openapi-schema-validator jsonschema-specifications==2025.4.1 - # via jsonschema + # via + # jsonschema + # openapi-schema-validator kombu==5.5.3 # via celery korean-lunar-calendar==0.3.1 @@ -238,6 +235,8 @@ numpy==1.26.4 # pandas odfpy==1.4.1 # via pandas +openapi-schema-validator==0.6.3 + # via -r requirements/base.in openpyxl==3.1.5 # via pandas ordered-set==4.1.0 @@ -336,6 +335,8 @@ requests==2.32.3 # shillelagh requests-cache==1.2.1 # via shillelagh +rfc3339-validator==0.1.4 + # via openapi-schema-validator rich==13.9.4 # via flask-limiter rpds-py==0.25.0 @@ -354,6 +355,8 @@ six==1.17.0 # via # prison # python-dateutil + # rfc3339-validator + # url-normalize # wtforms-json slack-sdk==3.35.0 # via apache-superset (pyproject.toml) @@ -380,7 +383,7 @@ sqlparse==0.5.3 # via apache-superset (pyproject.toml) sshtunnel==0.4.0 # via apache-superset (pyproject.toml) -tabulate==0.8.10 +tabulate==0.9.0 # via apache-superset (pyproject.toml) trio==0.30.0 # via @@ -392,12 +395,9 @@ typing-extensions==4.13.2 # via # apache-superset (pyproject.toml) # alembic - # cattrs - # exceptiongroup # limits # pyopenssl # referencing - # rich # selenium # shillelagh tzdata==2025.2 diff --git a/requirements/development.txt b/requirements/development.txt index 770406029b2..4a715b2a6ff 100644 --- a/requirements/development.txt +++ b/requirements/development.txt @@ -18,10 +18,6 @@ apsw==3.49.2.0 # via # -c requirements/base.txt # shillelagh -async-timeout==4.0.3 - # via - # -c requirements/base.txt - # redis attrs==25.3.0 # via # -c requirements/base.txt @@ -176,13 +172,6 @@ et-xmlfile==2.0.0 # via # -c requirements/base.txt # openpyxl -exceptiongroup==1.3.0 - # via - # -c requirements/base.txt - # cattrs - # pytest - # trio - # trio-websocket filelock==3.12.2 # via virtualenv flask==2.3.3 @@ -480,7 +469,9 @@ odfpy==1.4.1 # -c requirements/base.txt # pandas openapi-schema-validator==0.6.3 - # via openapi-spec-validator + # via + # -c requirements/base.txt + # openapi-spec-validator openapi-spec-validator==0.7.1 # via apache-superset openpyxl==3.1.5 @@ -727,7 +718,9 @@ requests-cache==1.2.1 requests-oauthlib==2.0.0 # via google-auth-oauthlib rfc3339-validator==0.1.4 - # via openapi-schema-validator + # via + # -c requirements/base.txt + # openapi-schema-validator rich==13.9.4 # via # -c requirements/base.txt @@ -815,14 +808,10 @@ sshtunnel==0.4.0 # apache-superset statsd==4.0.1 # via apache-superset -tabulate==0.8.10 +tabulate==0.9.0 # via # -c requirements/base.txt # apache-superset -tomli==2.2.1 - # via - # coverage - # pytest tqdm==4.67.1 # via # cmdstanpy @@ -843,12 +832,9 @@ typing-extensions==4.13.2 # -c requirements/base.txt # alembic # apache-superset - # cattrs - # exceptiongroup # limits # pyopenssl # referencing - # rich # selenium # shillelagh tzdata==2025.2 diff --git a/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx b/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx index d01b5efdb55..5383d075f59 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx @@ -76,6 +76,7 @@ import { FileOutlined, FileTextOutlined, FireOutlined, + FormOutlined, FullscreenExitOutlined, FullscreenOutlined, FundProjectionScreenOutlined, @@ -201,6 +202,7 @@ const AntdIcons = { FileOutlined, FileTextOutlined, FireOutlined, + FormOutlined, FullscreenExitOutlined, FullscreenOutlined, FundProjectionScreenOutlined, diff --git a/superset-frontend/packages/superset-ui-core/src/components/Modal/types.ts b/superset-frontend/packages/superset-ui-core/src/components/Modal/types.ts index 4e21be3c6c7..3be506d2fcd 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Modal/types.ts +++ b/superset-frontend/packages/superset-ui-core/src/components/Modal/types.ts @@ -65,7 +65,7 @@ export interface StyledModalProps { export type { ModalFuncProps }; export interface FormModalProps extends ModalProps { - initialValues: Object; + initialValues?: Object; formSubmitHandler: (values: Object) => Promise; onSave: () => void; requiredFields: string[]; diff --git a/superset-frontend/src/components/Descriptions/index.tsx b/superset-frontend/src/components/Descriptions/index.tsx new file mode 100644 index 00000000000..1d1de86d872 --- /dev/null +++ b/superset-frontend/src/components/Descriptions/index.tsx @@ -0,0 +1,21 @@ +/** + * 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. + */ + +export { Descriptions } from 'antd-v5'; +export type { DescriptionsProps } from 'antd-v5/es/descriptions'; diff --git a/superset-frontend/src/constants.ts b/superset-frontend/src/constants.ts index 4dc734651d5..0900265d1e9 100644 --- a/superset-frontend/src/constants.ts +++ b/superset-frontend/src/constants.ts @@ -182,3 +182,8 @@ export enum FilterPlugins { TimeColumn = 'filter_timecolumn', TimeGrain = 'filter_timegrain', } + +export enum Actions { + CREATE = 'create', + UPDATE = 'update', +} diff --git a/superset-frontend/src/features/groups/GroupListModal.tsx b/superset-frontend/src/features/groups/GroupListModal.tsx new file mode 100644 index 00000000000..ed9e708bc71 --- /dev/null +++ b/superset-frontend/src/features/groups/GroupListModal.tsx @@ -0,0 +1,164 @@ +/** + * 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. + */ +import { t } from '@superset-ui/core'; +import { useToasts } from 'src/components/MessageToasts/withToasts'; +import FormModal from 'src/components/Modal/FormModal'; +import { FormItem } from 'src/components/Form'; +import { Input } from 'src/components/Input'; +import Select from 'src/components/Select/Select'; +import { GroupObject, Role } from 'src/pages/GroupsList'; +import { Actions } from 'src/constants'; +import AsyncSelect from 'src/components/Select/AsyncSelect'; +import { BaseGroupListModalProps, FormValues } from './types'; +import { createGroup, fetchUserOptions, updateGroup } from './utils'; + +export interface GroupModalProps extends BaseGroupListModalProps { + roles: Role[]; + isEditMode?: boolean; + group?: GroupObject; +} + +function GroupListModal({ + show, + onHide, + onSave, + roles, + isEditMode = false, + group, +}: GroupModalProps) { + const { addDangerToast, addSuccessToast } = useToasts(); + const handleFormSubmit = async (values: FormValues) => { + const handleError = async ( + err: Response, + action: Actions.CREATE | Actions.UPDATE, + ) => { + let errorMessage = + action === Actions.CREATE + ? t('There was an error creating the group. Please, try again.') + : t('There was an error updating the group. Please, try again.'); + + if (err.status === 422) { + const errorData = await err.json(); + const detail = errorData?.message || ''; + + if (detail.includes('duplicate key value')) { + if (detail.includes('ab_group_name_key')) { + errorMessage = t( + 'This name is already taken. Please choose another one.', + ); + } + } + } + + addDangerToast(errorMessage); + throw err; + }; + + if (isEditMode) { + if (!group) { + throw new Error('Group is required in edit mode'); + } + try { + await updateGroup(group.id, values); + addSuccessToast(t('The group has been updated successfully.')); + } catch (err) { + await handleError(err, Actions.UPDATE); + } + } else { + try { + await createGroup(values); + addSuccessToast(t('The group has been created successfully.')); + } catch (err) { + await handleError(err, Actions.CREATE); + } + } + }; + + const requiredFields = ['name']; + const initialValues = { + ...group, + roles: group?.roles?.map(role => role.id) || [], + users: + group?.users?.map(user => ({ + value: user.id, + label: user.username, + })) || [], + }; + + return ( + + + + + + + + + + + + ({ label: user.username, value: user.id }))} - data-test="users-select" + mode="multiple" + placeholder={t('Select users')} + options={(filterValue, page, pageSize) => + fetchUserOptions(filterValue, page, pageSize, addDangerToast) + } + loading={loading} + data-test="roles-select" + /> + +); + +export const GroupsField: FC = ({ groups }) => ( + + + + + + + + ); + + const ResetPasswordFields = () => ( + <> + + + + ({ + validator(_, value) { + if (!value || getFieldValue('password') === value) { + return Promise.resolve(); + } + return Promise.reject(new Error(t('Passwords do not match!'))); + }, + }), + ]} + > + + + + ); + + return ( + + {isEditMode ? : } + + ); +} + +export const UserInfoResetPasswordModal = ( + props: Omit, +) => ; + +export const UserInfoEditModal = ( + props: Omit & { user: User }, +) => ; diff --git a/superset-frontend/src/features/users/UserListModal.tsx b/superset-frontend/src/features/users/UserListModal.tsx index 2806085e2b3..475e372a365 100644 --- a/superset-frontend/src/features/users/UserListModal.tsx +++ b/superset-frontend/src/features/users/UserListModal.tsx @@ -26,14 +26,16 @@ import { FormItem, FormInstance, } from '@superset-ui/core/components'; -import { Role, UserObject } from 'src/pages/UsersList'; +import { Group, Role, UserObject } from 'src/pages/UsersList'; +import { Actions } from 'src/constants'; import { BaseUserListModalProps, FormValues } from './types'; -import { createUser, updateUser } from './utils'; +import { createUser, updateUser, atLeastOneRoleOrGroup } from './utils'; export interface UserModalProps extends BaseUserListModalProps { roles: Role[]; isEditMode?: boolean; user?: UserObject; + groups: Group[]; } function UserListModal({ @@ -43,14 +45,18 @@ function UserListModal({ roles, isEditMode = false, user, + groups, }: UserModalProps) { const { addDangerToast, addSuccessToast } = useToasts(); const handleFormSubmit = async (values: FormValues) => { - const handleError = async (err: any, action: 'create' | 'update') => { + const handleError = async ( + err: any, + action: Actions.CREATE | Actions.UPDATE, + ) => { let errorMessage = - action === 'create' - ? t('Error while adding user!') - : t('Error while updating user!'); + action === Actions.CREATE + ? t('There was an error creating the user. Please, try again.') + : t('There was an error updating the user. Please, try again.'); if (err.status === 422) { const errorData = await err.json(); @@ -63,7 +69,7 @@ function UserListModal({ ); } else if (detail.includes('ab_user_email_key')) { errorMessage = t( - 'This email is already associated with an account.', + 'This email is already associated with an account. Please choose another one.', ); } } @@ -79,35 +85,35 @@ function UserListModal({ } try { await updateUser(user.id, values); - addSuccessToast(t('User was successfully updated!')); + addSuccessToast(t('The user has been updated successfully.')); } catch (err) { - await handleError(err, 'update'); + await handleError(err, Actions.UPDATE); } } else { try { await createUser(values); - addSuccessToast(t('User was successfully created!')); + addSuccessToast(t('The group has been created successfully.')); } catch (err) { - await handleError(err, 'create'); + await handleError(err, Actions.CREATE); } } }; const requiredFields = isEditMode - ? ['first_name', 'last_name', 'username', 'email', 'roles'] + ? ['first_name', 'last_name', 'username', 'email'] : [ 'first_name', 'last_name', 'username', 'email', 'password', - 'roles', 'confirmPassword', ]; const initialValues = { ...user, - roles: user?.roles.map(role => role.id) || [], + roles: user?.roles?.map(role => role.id) || [], + groups: user?.groups?.map(group => group.id) || [], }; return ( @@ -179,7 +185,8 @@ function UserListModal({ ({ + value: group.id, + label: group.name, + }))} + getPopupContainer={trigger => + trigger.closest('.ant-modal-content') + } + /> + {!isEditMode && ( <> { @@ -41,3 +42,22 @@ export const deleteUser = async (userId: number) => SupersetClient.delete({ endpoint: `/api/v1/security/users/${userId}`, }); + +export const atLeastOneRoleOrGroup = + (fieldToCheck: 'roles' | 'groups') => + ({ + getFieldValue, + }: { + getFieldValue: (field: string) => Array; + }) => ({ + validator(_: Object, value: Array) { + const current = value || []; + const other = getFieldValue(fieldToCheck) || []; + if (current.length === 0 && other.length === 0) { + return Promise.reject( + new Error(t('Please select at least one role or group')), + ); + } + return Promise.resolve(); + }, + }); diff --git a/superset-frontend/src/pages/GroupsList/GroupsList.test.tsx b/superset-frontend/src/pages/GroupsList/GroupsList.test.tsx new file mode 100644 index 00000000000..24da1d85815 --- /dev/null +++ b/superset-frontend/src/pages/GroupsList/GroupsList.test.tsx @@ -0,0 +1,165 @@ +/** + * 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. + */ + +import fetchMock from 'fetch-mock'; +import configureStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; +import { + render, + screen, + fireEvent, + waitFor, + act, + within, +} from 'spec/helpers/testing-library'; +import { MemoryRouter } from 'react-router-dom'; +import { QueryParamProvider } from 'use-query-params'; +import GroupsList from './index'; + +const mockStore = configureStore([thunk]); +const store = mockStore({}); + +const mockUser = { + userId: 1, + firstName: 'Admin', + lastName: 'User', + roles: [{ id: 1, name: 'Admin' }], +}; + +const rolesEndpoint = 'glob:*/security/roles/?*'; +const usersEndpoint = 'glob:*/security/users/?*'; + +const mockRoles = Array.from({ length: 3 }, (_, i) => ({ + id: i, + name: `role ${i}`, +})); + +const mockUsers = Array.from({ length: 3 }, (_, i) => ({ + id: i, + username: `user${i}`, +})); + +fetchMock.get(usersEndpoint, { + result: mockUsers, + count: 3, +}); + +fetchMock.get(rolesEndpoint, { + result: mockRoles, + count: 3, +}); + +jest.mock('src/dashboard/util/permissionUtils', () => ({ + ...jest.requireActual('src/dashboard/util/permissionUtils'), + isUserAdmin: jest.fn(() => true), +})); + +describe('GroupsList', () => { + const renderComponent = async () => { + await act(async () => { + render( + + + + + , + { useRedux: true, store }, + ); + }); + }; + + beforeEach(() => { + fetchMock.resetHistory(); + }); + + it('renders the page', async () => { + await renderComponent(); + expect(await screen.findByText('List Groups')).toBeInTheDocument(); + }); + + it('fetches roles on load', async () => { + await renderComponent(); + await waitFor(() => { + expect(fetchMock.calls(rolesEndpoint).length).toBeGreaterThan(0); + }); + }); + + it('renders add group button and triggers modal', async () => { + await renderComponent(); + const addButton = screen.getByTestId('add-group-button'); + fireEvent.click(addButton); + expect(await screen.findByTestId('Add Group-modal')).toBeInTheDocument(); + }); + + it('renders actions column for admin', async () => { + await renderComponent(); + expect(screen.getByText('Actions')).toBeInTheDocument(); + }); + + it('renders the filters correctly', async () => { + await renderComponent(); + const filtersSelect = screen.getAllByTestId('filters-select')[0]; + + expect(within(filtersSelect).getByText(/name/i)).toBeInTheDocument(); + expect(within(filtersSelect).getByText(/label/i)).toBeInTheDocument(); + expect(within(filtersSelect).getByText(/description/i)).toBeInTheDocument(); + expect(within(filtersSelect).getByText(/roles/i)).toBeInTheDocument(); + expect(within(filtersSelect).getByText(/users/i)).toBeInTheDocument(); + }); + + it('renders correct columns in the table', async () => { + await renderComponent(); + const table = screen.getByRole('table'); + + expect(await within(table).findByText('Name')).toBeInTheDocument(); + expect(await within(table).findByText('Label')).toBeInTheDocument(); + expect(await within(table).findByText('Roles')).toBeInTheDocument(); + }); + + it('opens add group modal on button click', async () => { + await renderComponent(); + const addButton = screen.getByTestId('add-group-button'); + fireEvent.click(addButton); + + expect(await screen.findByTestId('Add Group-modal')).toBeInTheDocument(); + }); + + it('opens edit modal on edit button click', async () => { + fetchMock.get('glob:*/security/groups/?*', { + result: [ + { + id: 1, + name: 'Editors', + label: 'editors', + description: 'Group for editors', + roles: mockRoles, + users: mockUsers, + }, + ], + count: 1, + }); + + await renderComponent(); + + const editButtons = await screen.findAllByTestId('group-list-edit-action'); + fireEvent.click(editButtons[0]); + + expect(await screen.findByTestId('Edit Group-modal')).toBeInTheDocument(); + }); +}); diff --git a/superset-frontend/src/pages/GroupsList/index.tsx b/superset-frontend/src/pages/GroupsList/index.tsx new file mode 100644 index 00000000000..0d6c956ad53 --- /dev/null +++ b/superset-frontend/src/pages/GroupsList/index.tsx @@ -0,0 +1,459 @@ +/** + * 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. + */ + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { css, t, useTheme } from '@superset-ui/core'; +import { useListViewResource } from 'src/views/CRUD/hooks'; +import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu'; +import ActionsBar, { ActionProps } from 'src/components/ListView/ActionsBar'; +import ListView, { + ListViewProps, + Filters, + FilterOperator, +} from 'src/components/ListView'; +import DeleteModal from 'src/components/DeleteModal'; +import ConfirmStatusChange from 'src/components/ConfirmStatusChange'; +import { isUserAdmin } from 'src/dashboard/util/permissionUtils'; +import { Icons } from 'src/components/Icons'; +import { + GroupListAddModal, + GroupListEditModal, +} from 'src/features/groups/GroupListModal'; +import { useToasts } from 'src/components/MessageToasts/withToasts'; +import { deleteGroup, fetchUserOptions } from 'src/features/groups/utils'; +import { fetchPaginatedData } from 'src/utils/fetchOptions'; +import { Tooltip } from 'src/components/Tooltip'; + +const PAGE_SIZE = 25; + +interface GroupsListProps { + user: { + userId: string | number; + firstName: string; + lastName: string; + roles: object; + }; +} + +export type Role = { + id: number; + name: string; +}; + +export type User = { + id: number; + username: string; +}; + +export type GroupObject = { + id: number; + name: string; + label: string; + description: string; + roles: Role[]; + users: User[]; +}; + +enum ModalType { + ADD = 'add', + EDIT = 'edit', +} + +function GroupsList({ user }: GroupsListProps) { + const theme = useTheme(); + const { addDangerToast, addSuccessToast } = useToasts(); + const { + state: { + loading, + resourceCount: groupsCount, + resourceCollection: groups, + bulkSelectEnabled, + }, + fetchData, + refreshData, + toggleBulkSelect, + } = useListViewResource( + 'security/groups', + t('Group'), + addDangerToast, + ); + const [modalState, setModalState] = useState({ + edit: false, + add: false, + }); + const openModal = (type: ModalType) => + setModalState(prev => ({ ...prev, [type]: true })); + const closeModal = (type: ModalType) => + setModalState(prev => ({ ...prev, [type]: false })); + + const [currentGroup, setCurrentGroup] = useState(null); + const [groupCurrentlyDeleting, setGroupCurrentlyDeleting] = + useState(null); + const [loadingState, setLoadingState] = useState({ + roles: true, + }); + const [roles, setRoles] = useState([]); + + const isAdmin = useMemo(() => isUserAdmin(user), [user]); + + const fetchRoles = useCallback(() => { + fetchPaginatedData({ + endpoint: '/api/v1/security/roles/', + setData: setRoles, + setLoadingState, + loadingKey: 'roles', + addDangerToast, + errorMessage: t('Error while fetching roles'), + }); + }, [addDangerToast]); + + useEffect(() => { + fetchRoles(); + }, [fetchRoles]); + + const handleGroupDelete = async ({ id, name }: GroupObject) => { + try { + await deleteGroup(id); + refreshData(); + setGroupCurrentlyDeleting(null); + addSuccessToast(t('Deleted group: %s', name)); + } catch (error) { + addDangerToast(t('There was an issue deleting %s', name)); + } + }; + + const handleBulkGroupsDelete = (groupsToDelete: GroupObject[]) => { + const deletedGroupsNames: string[] = []; + + Promise.all( + groupsToDelete.map(group => + deleteGroup(group.id) + .then(() => { + deletedGroupsNames.push(group.name); + }) + .catch(err => { + addDangerToast(t('Error deleting %s', group.name)); + }), + ), + ) + .then(() => { + if (deletedGroupsNames.length > 0) { + addSuccessToast( + t('Deleted groups: %s', deletedGroupsNames.join(', ')), + ); + } + }) + .finally(() => { + refreshData(); + }); + }; + + const initialSort = [{ id: 'name', desc: true }]; + const columns = useMemo( + () => [ + { + accessor: 'name', + id: 'name', + Header: t('Name'), + Cell: ({ + row: { + original: { name }, + }, + }: any) => {name}, + }, + { + accessor: 'label', + id: 'label', + Header: t('Label'), + Cell: ({ + row: { + original: { label }, + }, + }: any) => {label}, + }, + { + accessor: 'description', + id: 'description', + Header: t('Description'), + Cell: ({ + row: { + original: { description }, + }, + }: any) => {description}, + hidden: true, + }, + { + accessor: 'roles', + id: 'roles', + Header: t('Roles'), + Cell: ({ + row: { + original: { roles }, + }, + }: any) => ( + role.name).join(', ') || t('No roles') + } + > + {roles?.map((role: Role) => role.name).join(', ')} + + ), + disableSortBy: true, + }, + { + accessor: 'users', + id: 'users', + Header: t('Users'), + Cell: ({ + row: { + original: { users }, + }, + }: any) => ( + {users?.map((user: User) => user.username).join(', ')} + ), + disableSortBy: true, + hidden: true, + }, + { + Cell: ({ row: { original } }: any) => { + const handleEdit = () => { + setCurrentGroup(original); + openModal(ModalType.EDIT); + }; + const handleDelete = () => setGroupCurrentlyDeleting(original); + const actions = isAdmin + ? [ + { + label: 'group-list-edit-action', + tooltip: t('Edit group'), + placement: 'bottom', + icon: 'EditOutlined', + onClick: handleEdit, + }, + { + label: 'group-list-delete-action', + tooltip: t('Delete group'), + placement: 'bottom', + icon: 'DeleteOutlined', + onClick: handleDelete, + }, + ] + : []; + + return ; + }, + Header: t('Actions'), + id: 'actions', + disableSortBy: true, + hidden: !isAdmin, + size: 'xl', + }, + ], + [isAdmin], + ); + + const subMenuButtons: SubMenuProps['buttons'] = []; + + if (isAdmin) { + subMenuButtons.push( + { + name: ( + <> + + {t('Group')} + + ), + buttonStyle: 'primary', + onClick: () => { + openModal(ModalType.ADD); + }, + loading: loadingState.roles, + 'data-test': 'add-group-button', + }, + { + name: t('Bulk select'), + onClick: toggleBulkSelect, + buttonStyle: 'secondary', + }, + ); + } + + const filters: Filters = useMemo( + () => [ + { + Header: t('Name'), + key: 'name', + id: 'name', + input: 'search', + operator: FilterOperator.Contains, + }, + { + Header: t('Label'), + key: 'label', + id: 'label', + input: 'search', + operator: FilterOperator.Contains, + }, + { + Header: t('Description'), + key: 'description', + id: 'description', + input: 'search', + operator: FilterOperator.Contains, + }, + { + Header: t('Roles'), + key: 'roles', + id: 'roles', + input: 'select', + operator: FilterOperator.RelationManyMany, + unfilteredLabel: t('All'), + selects: roles?.map(role => ({ + label: role.name, + value: role.id, + })), + loading: loadingState.roles, + }, + { + Header: t('Users'), + key: 'users', + id: 'users', + input: 'select', + operator: FilterOperator.RelationManyMany, + unfilteredLabel: t('All'), + fetchSelects: async (filterValue, page, pageSize) => + fetchUserOptions(filterValue, page, pageSize, addDangerToast), + }, + ], + [loadingState.roles, roles], + ); + + const emptyState = { + title: t('No groups yet'), + image: 'filter-results.svg', + ...(isAdmin && { + buttonAction: () => { + openModal(ModalType.ADD); + }, + buttonText: ( + <> + + {t('Group')} + + ), + }), + }; + + return ( + <> + + closeModal(ModalType.ADD)} + show={modalState.add} + onSave={() => { + refreshData(); + closeModal(ModalType.ADD); + }} + roles={roles} + /> + {modalState.edit && currentGroup && ( + closeModal(ModalType.EDIT)} + onSave={() => { + refreshData(); + closeModal(ModalType.EDIT); + }} + roles={roles} + /> + )} + + {groupCurrentlyDeleting && ( + { + if (groupCurrentlyDeleting) { + handleGroupDelete(groupCurrentlyDeleting); + } + }} + onHide={() => setGroupCurrentlyDeleting(null)} + open + title={t('Delete Group?')} + /> + )} + + {confirmDelete => { + const bulkActions: ListViewProps['bulkActions'] = isAdmin + ? [ + { + key: 'delete', + name: t('Delete'), + onSelect: confirmDelete, + type: 'danger', + }, + ] + : []; + + return ( + + className="group-list-view" + columns={columns} + count={groupsCount} + data={groups} + fetchData={fetchData} + filters={filters} + initialSort={initialSort} + loading={loading} + pageSize={PAGE_SIZE} + bulkActions={bulkActions} + bulkSelectEnabled={bulkSelectEnabled} + disableBulkSelect={toggleBulkSelect} + addDangerToast={addDangerToast} + addSuccessToast={addSuccessToast} + emptyState={emptyState} + refreshData={refreshData} + /> + ); + }} + + + ); +} + +export default GroupsList; diff --git a/superset-frontend/src/pages/RolesList/RolesList.test.tsx b/superset-frontend/src/pages/RolesList/RolesList.test.tsx index c4c4ee25541..23890199f3d 100644 --- a/superset-frontend/src/pages/RolesList/RolesList.test.tsx +++ b/superset-frontend/src/pages/RolesList/RolesList.test.tsx @@ -137,13 +137,11 @@ describe('RolesList', () => { }); }); - it('fetches permissions and users on load', async () => { + it('fetches permissions on load', async () => { await renderAndWait(); await waitFor(() => { const permissionCalls = fetchMock.calls(permissionsEndpoint); - const userCalls = fetchMock.calls(usersEndpoint); expect(permissionCalls.length).toBeGreaterThan(0); - expect(userCalls.length).toBeGreaterThan(0); }); }); @@ -151,7 +149,7 @@ describe('RolesList', () => { await renderAndWait(); const typeFilter = screen.queryAllByTestId('filters-select'); - expect(typeFilter).toHaveLength(3); + expect(typeFilter).toHaveLength(4); }); it('renders correct list columns', async () => { diff --git a/superset-frontend/src/pages/RolesList/index.tsx b/superset-frontend/src/pages/RolesList/index.tsx index 75708afa409..474c3631154 100644 --- a/superset-frontend/src/pages/RolesList/index.tsx +++ b/superset-frontend/src/pages/RolesList/index.tsx @@ -34,13 +34,12 @@ import { type ListViewActionProps, type ListViewFilters, } from 'src/components'; -import { - FormattedPermission, - PermissionResource, - UserObject, -} from 'src/features/roles/types'; +import { FormattedPermission, UserObject } from 'src/features/roles/types'; import { isUserAdmin } from 'src/dashboard/util/permissionUtils'; import { Icons } from '@superset-ui/core/components/Icons'; +import { fetchPaginatedData } from 'src/utils/fetchOptions'; +import { fetchUserOptions } from 'src/features/groups/utils'; +import { GroupObject } from '../GroupsList'; const PAGE_SIZE = 25; @@ -61,6 +60,7 @@ export type RoleObject = { permission_ids: number[]; users?: Array; user_ids: number[]; + group_ids: number[]; }; enum ModalType { @@ -100,108 +100,48 @@ function RolesList({ addDangerToast, addSuccessToast, user }: RolesListProps) { const [roleCurrentlyDeleting, setRoleCurrentlyDeleting] = useState(null); const [permissions, setPermissions] = useState([]); - const [users, setUsers] = useState([]); + const [groups, setGroups] = useState([]); const [loadingState, setLoadingState] = useState({ permissions: true, - users: true, + groups: true, }); const isAdmin = useMemo(() => isUserAdmin(user), [user]); - const fetchPermissions = useCallback(async () => { - try { - const pageSize = 100; + const fetchPermissions = useCallback(() => { + fetchPaginatedData({ + endpoint: '/api/v1/security/permissions-resources/', + setData: setPermissions, + setLoadingState, + loadingKey: 'permissions', + addDangerToast, + errorMessage: 'Error while fetching permissions', + mapResult: ({ permission, view_menu, id }) => ({ + label: `${permission.name.replace(/_/g, ' ')} ${view_menu.name.replace(/_/g, ' ')}`, + value: `${permission.name}__${view_menu.name}`, + id, + }), + }); + }, [addDangerToast]); - const fetchPage = async (pageIndex: number) => { - const response = await SupersetClient.get({ - endpoint: `api/v1/security/permissions-resources/?q=(page_size:${pageSize},page:${pageIndex})`, - }); - - return { - count: response.json.count, - results: response.json.result.map( - ({ permission, view_menu, id }: PermissionResource) => ({ - label: `${permission.name.replace(/_/g, ' ')} ${view_menu.name.replace(/_/g, ' ')}`, - value: `${permission.name}__${view_menu.name}`, - id, - }), - ), - }; - }; - - const initialResponse = await fetchPage(0); - const totalPermissions = initialResponse.count; - const firstPageResults = initialResponse.results; - - if (firstPageResults.length >= totalPermissions) { - setPermissions(firstPageResults); - return; - } - - const totalPages = Math.ceil(totalPermissions / pageSize); - - const permissionRequests = Array.from( - { length: totalPages - 1 }, - (_, i) => fetchPage(i + 1), - ); - const remainingResults = await Promise.all(permissionRequests); - - setPermissions([ - ...firstPageResults, - ...remainingResults.flatMap(res => res.results), - ]); - } catch (err) { - addDangerToast(t('Error while fetching permissions')); - } finally { - setLoadingState(prev => ({ ...prev, permissions: false })); - } - }, []); - - const fetchUsers = useCallback(async () => { - try { - const pageSize = 100; - - const fetchPage = async (pageIndex: number) => { - const response = await SupersetClient.get({ - endpoint: `api/v1/security/users/?q=(page_size:${pageSize},page:${pageIndex})`, - }); - return response.json; - }; - - const initialResponse = await fetchPage(0); - const totalUsers = initialResponse.count; - const firstPageResults = initialResponse.result; - - if (pageSize >= totalUsers) { - setUsers(firstPageResults); - return; - } - - const totalPages = Math.ceil(totalUsers / pageSize); - - const userRequests = Array.from({ length: totalPages - 1 }, (_, i) => - fetchPage(i + 1), - ); - const remainingResults = await Promise.all(userRequests); - - setUsers([ - ...firstPageResults, - ...remainingResults.flatMap(res => res.result), - ]); - } catch (err) { - addDangerToast(t('Error while fetching users')); - } finally { - setLoadingState(prev => ({ ...prev, users: false })); - } - }, []); + const fetchGroups = useCallback(() => { + fetchPaginatedData({ + endpoint: '/api/v1/security/groups/', + setData: setGroups, + setLoadingState, + loadingKey: 'groups', + addDangerToast, + errorMessage: t('Error while fetching groups'), + }); + }, [addDangerToast]); useEffect(() => { fetchPermissions(); }, [fetchPermissions]); useEffect(() => { - fetchUsers(); - }, [fetchUsers]); + fetchGroups(); + }, [fetchGroups]); const handleRoleDelete = async ({ id, name }: RoleObject) => { try { @@ -246,28 +186,42 @@ function RolesList({ addDangerToast, addSuccessToast, user }: RolesListProps) { () => [ { accessor: 'name', + id: 'name', Header: t('Name'), Cell: ({ row: { original: { name }, }, }: any) => {name}, - id: 'name', }, { accessor: 'user_ids', + id: 'user_ids', Header: t('Users'), hidden: true, Cell: ({ row: { original } }: any) => original.user_ids.join(', '), - id: 'user_ids', + }, + { + accessor: 'group_ids', + id: 'group_ids', + Header: t('Groups'), + hidden: true, + Cell: ({ row: { original } }: any) => original.groups_ids.join(', '), + }, + { + accessor: 'group_ids', + id: 'group_ids', + Header: t('Groups'), + hidden: true, + Cell: ({ row: { original } }: any) => original.groups_ids.join(', '), }, { accessor: 'permission_ids', + id: 'permission_ids', Header: t('Permissions'), hidden: true, Cell: ({ row: { original } }: any) => original.permission_ids.join(', '), - id: 'permission_ids', }, { Cell: ({ row: { original } }: any) => { @@ -363,11 +317,8 @@ function RolesList({ addDangerToast, addSuccessToast, user }: RolesListProps) { input: 'select', operator: FilterOperator.RelationOneMany, unfilteredLabel: t('All'), - selects: users?.map(user => ({ - label: user.username, - value: user.id, - })), - loading: loadingState.users, + fetchSelects: async (filterValue, page, pageSize) => + fetchUserOptions(filterValue, page, pageSize, addDangerToast), }, { Header: t('Permissions'), @@ -382,8 +333,21 @@ function RolesList({ addDangerToast, addSuccessToast, user }: RolesListProps) { })), loading: loadingState.permissions, }, + { + Header: t('Groups'), + key: 'group_ids', + id: 'group_ids', + input: 'select', + operator: FilterOperator.RelationOneMany, + unfilteredLabel: t('All'), + selects: groups?.map(group => ({ + label: group.name, + value: group.id, + })), + loading: loadingState.groups, + }, ], - [permissions, users, loadingState.users, loadingState.permissions], + [permissions, groups, loadingState.groups, loadingState.permissions], ); const emptyState = { @@ -422,10 +386,9 @@ function RolesList({ addDangerToast, addSuccessToast, user }: RolesListProps) { onSave={() => { refreshData(); closeModal(ModalType.EDIT); - fetchUsers(); }} permissions={permissions} - users={users} + groups={groups} /> )} {modalState.duplicate && currentRole && ( diff --git a/superset-frontend/src/pages/UserInfo/UserInfo.test.tsx b/superset-frontend/src/pages/UserInfo/UserInfo.test.tsx new file mode 100644 index 00000000000..479d9c3ee4a --- /dev/null +++ b/superset-frontend/src/pages/UserInfo/UserInfo.test.tsx @@ -0,0 +1,126 @@ +/** + * 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. + */ + +import fetchMock from 'fetch-mock'; +import { + render, + screen, + fireEvent, + waitFor, + act, +} from 'spec/helpers/testing-library'; +import configureStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; +import { MemoryRouter } from 'react-router-dom'; +import { QueryParamProvider } from 'use-query-params'; +import UserInfo from 'src/pages/UserInfo'; +import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes'; + +const mockStore = configureStore([thunk]); +const store = mockStore({}); + +const meEndpoint = 'glob:*/api/v1/me/'; + +const mockUser: UserWithPermissionsAndRoles = { + userId: 1, + firstName: 'John', + lastName: 'Doe', + username: 'johndoe', + email: 'john@example.com', + isActive: true, + loginCount: 12, + roles: { + Admin: [ + ['can_read', 'Dashboard'], + ['can_write', 'Chart'], + ], + }, + createdOn: new Date().toISOString(), + isAnonymous: false, + permissions: { + database_access: ['examples', 'birth_names'], + datasource_access: ['examples.babynames', 'examples.world_health'], + }, +}; + +describe('UserInfo', () => { + const renderPage = async () => { + await act(async () => { + render( + + + + + , + { useRedux: true, store }, + ); + }); + }; + + beforeEach(() => { + fetchMock.restore(); + fetchMock.get(meEndpoint, { + result: { + ...mockUser, + first_name: 'John', + last_name: 'Doe', + }, + }); + }); + + afterAll(() => { + fetchMock.restore(); + }); + + it('renders the user info page', async () => { + await renderPage(); + + expect( + await screen.findByText('Your user information'), + ).toBeInTheDocument(); + expect(screen.getByText('johndoe')).toBeInTheDocument(); + expect(screen.getByText('Yes')).toBeInTheDocument(); + expect(screen.getByText('Admin')).toBeInTheDocument(); + expect(screen.getByText('12')).toBeInTheDocument(); + expect(await screen.findByText('John')).toBeInTheDocument(); + expect(screen.getByText('Doe')).toBeInTheDocument(); + expect(screen.getByText('john@example.com')).toBeInTheDocument(); + }); + + it('calls the /me endpoint on mount', async () => { + await renderPage(); + await waitFor(() => { + expect(fetchMock.called(meEndpoint)).toBe(true); + }); + }); + + it('opens the reset password modal on button click', async () => { + await renderPage(); + const button = await screen.findByTestId('reset-password-button'); + fireEvent.click(button); + expect(await screen.findByText(/Reset password/i)).toBeInTheDocument(); + }); + + it('opens the edit user modal on button click', async () => { + await renderPage(); + const button = await screen.findByTestId('edit-user-button'); + fireEvent.click(button); + expect(await screen.getAllByText(/Edit user/i).length).toBeGreaterThan(0); + }); +}); diff --git a/superset-frontend/src/pages/UserInfo/index.tsx b/superset-frontend/src/pages/UserInfo/index.tsx new file mode 100644 index 00000000000..270f743a845 --- /dev/null +++ b/superset-frontend/src/pages/UserInfo/index.tsx @@ -0,0 +1,238 @@ +/** + * 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. + */ + +import { useCallback, useEffect, useState } from 'react'; +import { css, t, SupersetClient, useTheme, styled } from '@superset-ui/core'; +import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu'; +import { Icons } from 'src/components/Icons'; +import { useToasts } from 'src/components/MessageToasts/withToasts'; +import { Descriptions } from 'src/components/Descriptions'; +import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes'; +import { + UserInfoEditModal, + UserInfoResetPasswordModal, +} from 'src/features/userInfo/UserInfoModal'; +import Collapse from 'src/components/Collapse'; + +interface UserInfoProps { + user: UserWithPermissionsAndRoles; +} + +const StyledHeader = styled.div` + ${({ theme }) => css` + font-weight: ${theme.typography.weights.bold}; + text-align: left; + font-size: 18px; + padding: ${theme.gridUnit * 3}px; + padding-left: ${theme.gridUnit * 7}px; + display: inline-block; + line-height: ${theme.gridUnit * 9}px; + width: 100%; + background-color: ${theme.colors.grayscale.light5}; + margin-bottom: ${theme.gridUnit * 6}px; + `} +`; + +const DescriptionsContainer = styled.div` + ${({ theme }) => css` + margin: 0px ${theme.gridUnit * 3}px ${theme.gridUnit * 6}px + ${theme.gridUnit * 3}px; + background-color: ${theme.colors.grayscale.light5}; + `} +`; + +const StyledLayout = styled.div` + ${({ theme }) => css` + .antd5-row { + margin: 0px ${theme.gridUnit * 3}px ${theme.gridUnit * 6}px + ${theme.gridUnit * 3}px; + } + && .menu > .antd5-menu { + padding: 0px; + } + && .nav-right { + left: 0; + padding-left: ${theme.gridUnit * 4}px; + position: relative; + height: ${theme.gridUnit * 15}px; + background-color: ${theme.colors.grayscale.light5}; + } + `} +`; + +const DescriptionTitle = styled.span` + font-weight: ${({ theme }) => theme.typography.weights.bold}; +`; + +enum ModalType { + ResetPassword = 'resetPassword', + Edit = 'edit', +} + +export function UserInfo({ user }: UserInfoProps) { + const theme = useTheme(); + const [modalState, setModalState] = useState({ + resetPassword: false, + edit: false, + }); + const openModal = (type: ModalType) => + setModalState(prev => ({ ...prev, [type]: true })); + const closeModal = (type: ModalType) => + setModalState(prev => ({ ...prev, [type]: false })); + const { addDangerToast } = useToasts(); + const [userDetails, setUserDetails] = useState(user); + + useEffect(() => { + getUserDetails(); + }, []); + + const getUserDetails = useCallback(() => { + SupersetClient.get({ endpoint: '/api/v1/me/' }) + .then(({ json }) => { + const transformedUser = { + ...json.result, + firstName: json.result.first_name, + lastName: json.result.last_name, + }; + setUserDetails(transformedUser); + }) + .catch(error => { + addDangerToast('Failed to fetch user info:', error); + }); + }, [userDetails]); + + const SubMenuButtons: SubMenuProps['buttons'] = [ + { + name: ( + <> + + {t('Reset my password')} + + ), + buttonStyle: 'secondary', + onClick: () => { + openModal(ModalType.ResetPassword); + }, + 'data-test': 'reset-password-button', + }, + { + name: ( + <> + + {t('Edit user')} + + ), + buttonStyle: 'primary', + onClick: () => { + openModal(ModalType.Edit); + }, + 'data-test': 'edit-user-button', + }, + ]; + + return ( + + Your user information + + + User info} + key="userInfo" + > + + + {user.username} + + + {user.isActive ? 'Yes' : 'No'} + + + {user.roles ? Object.keys(user.roles).join(', ') : 'None'} + + + {user.loginCount} + + + + Personal info} + key="personalInfo" + > + + + {userDetails.firstName} + + + {userDetails.lastName} + + {user.email} + + + + + {modalState.resetPassword && ( + closeModal(ModalType.ResetPassword)} + show={modalState.resetPassword} + onSave={() => { + closeModal(ModalType.ResetPassword); + }} + /> + )} + {modalState.edit && ( + closeModal(ModalType.Edit)} + show={modalState.edit} + onSave={() => { + closeModal(ModalType.Edit); + getUserDetails(); + }} + user={userDetails} + /> + )} + + + ); +} + +export default UserInfo; diff --git a/superset-frontend/src/pages/UsersList/index.tsx b/superset-frontend/src/pages/UsersList/index.tsx index 57e11000b59..5e28033d786 100644 --- a/superset-frontend/src/pages/UsersList/index.tsx +++ b/superset-frontend/src/pages/UsersList/index.tsx @@ -18,7 +18,7 @@ */ import { useCallback, useEffect, useMemo, useState } from 'react'; -import { t, SupersetClient } from '@superset-ui/core'; +import { css, t, useTheme } from '@superset-ui/core'; import { useListViewResource } from 'src/views/CRUD/hooks'; import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu'; import { ActionsBar, ActionProps } from 'src/components/ListView/ActionsBar'; @@ -37,6 +37,8 @@ import { } from 'src/features/users/UserListModal'; import { useToasts } from 'src/components/MessageToasts/withToasts'; import { deleteUser } from 'src/features/users/utils'; +import { fetchPaginatedData } from 'src/utils/fetchOptions'; +import { Tooltip } from 'src/components/Tooltip'; const PAGE_SIZE = 25; @@ -54,6 +56,11 @@ export type Role = { name: string; }; +export type Group = { + id: number; + name: string; +}; + export type UserObject = { active: boolean; changed_by: string | null; @@ -69,6 +76,7 @@ export type UserObject = { login_count: number; roles: Role[]; username: string; + groups: Group[]; }; enum ModalType { @@ -107,7 +115,6 @@ function UsersList({ user }: UsersListProps) { const [modalState, setModalState] = useState({ edit: false, add: false, - duplicate: false, }); const openModal = (type: ModalType) => setModalState(prev => ({ ...prev, [type]: true })); @@ -117,8 +124,12 @@ function UsersList({ user }: UsersListProps) { const [currentUser, setCurrentUser] = useState(null); const [userCurrentlyDeleting, setUserCurrentlyDeleting] = useState(null); - const [isLoading, setIsLoading] = useState(true); + const [loadingState, setLoadingState] = useState({ + roles: true, + groups: true, + }); const [roles, setRoles] = useState([]); + const [groups, setGroups] = useState([]); const loginCountStats = useMemo(() => { if (!users || users.length === 0) return { min: 0, max: 0 }; @@ -140,48 +151,36 @@ function UsersList({ user }: UsersListProps) { const isAdmin = useMemo(() => isUserAdmin(user), [user]); - const fetchRoles = useCallback(async () => { - try { - const pageSize = 100; + const fetchRoles = useCallback(() => { + fetchPaginatedData({ + endpoint: '/api/v1/security/roles/', + setData: setRoles, + setLoadingState, + loadingKey: 'roles', + addDangerToast, + errorMessage: t('Error while fetching roles'), + }); + }, [addDangerToast]); - const fetchPage = async (pageIndex: number) => { - const response = await SupersetClient.get({ - endpoint: `api/v1/security/roles/?q=(page_size:${pageSize},page:${pageIndex})`, - }); - return response.json; - }; - - const initialResponse = await fetchPage(0); - const totalRoles = initialResponse.count; - const firstPageResults = initialResponse.result; - - if (pageSize >= totalRoles) { - setRoles(firstPageResults); - return; - } - - const totalPages = Math.ceil(totalRoles / pageSize); - - const roleRequests = Array.from({ length: totalPages - 1 }, (_, i) => - fetchPage(i + 1), - ); - const remainingResults = await Promise.all(roleRequests); - - setRoles([ - ...firstPageResults, - ...remainingResults.flatMap(res => res.result), - ]); - } catch (err) { - addDangerToast(t('Error while fetching roles')); - } finally { - setIsLoading(false); - } - }, []); + const fetchGroups = useCallback(() => { + fetchPaginatedData({ + endpoint: '/api/v1/security/groups/', + setData: setGroups, + setLoadingState, + loadingKey: 'groups', + addDangerToast, + errorMessage: t('Error while fetching groups'), + }); + }, [addDangerToast]); useEffect(() => { fetchRoles(); }, [fetchRoles]); + useEffect(() => { + fetchGroups(); + }, [fetchGroups]); + const handleUserDelete = async ({ id, username }: UserObject) => { try { await deleteUser(id); @@ -279,7 +278,33 @@ function UsersList({ user }: UsersListProps) { original: { roles }, }, }: any) => ( - {roles.map((role: Role) => role.name).join(', ')} + role.name).join(', ') || t('No roles') + } + > + {roles?.map((role: Role) => role.name).join(', ')} + + ), + disableSortBy: true, + }, + { + accessor: 'groups', + Header: t('Groups'), + id: 'groups', + Cell: ({ + row: { + original: { groups }, + }, + }: any) => ( + group.name).join(', ') || + t('No groups') + } + > + {groups?.map((group: Group) => group.name).join(', ')} + ), disableSortBy: true, }, @@ -383,7 +408,7 @@ function UsersList({ user }: UsersListProps) { onClick: () => { openModal(ModalType.ADD); }, - loading: isLoading, + loading: loadingState.roles || loadingState.groups, 'data-test': 'add-user-button', }, { @@ -435,7 +460,6 @@ function UsersList({ user }: UsersListProps) { label: option.label, value: option.value, })), - loading: isLoading, }, { Header: t('Roles'), @@ -448,7 +472,20 @@ function UsersList({ user }: UsersListProps) { label: role.name, value: role.id, })), - loading: isLoading, + loading: loadingState.roles, + }, + { + Header: t('Groups'), + key: 'groups', + id: 'groups', + input: 'select', + operator: FilterOperator.RelationManyMany, + unfilteredLabel: t('All'), + selects: groups?.map(group => ({ + label: group.name, + value: group.id, + })), + loading: loadingState.groups, }, { Header: t('Created on'), @@ -491,7 +528,14 @@ function UsersList({ user }: UsersListProps) { operator: ListViewFilterOperator.Between, }, ], - [isLoading, roles, loginCountStats, failLoginCountStats], + [ + loadingState.roles, + roles, + groups, + loadingState.groups, + loginCountStats, + failLoginCountStats, + ], ); const emptyState = { @@ -521,6 +565,7 @@ function UsersList({ user }: UsersListProps) { closeModal(ModalType.ADD); }} roles={roles} + groups={groups} /> {modalState.edit && currentUser && ( )} diff --git a/superset-frontend/src/types/bootstrapTypes.ts b/superset-frontend/src/types/bootstrapTypes.ts index ba62a0fd011..1e943fcdbeb 100644 --- a/superset-frontend/src/types/bootstrapTypes.ts +++ b/superset-frontend/src/types/bootstrapTypes.ts @@ -39,6 +39,7 @@ export type User = { lastName: string; userId?: number; // optional because guest user doesn't have a user id username: string; + loginCount?: number; }; export type UserRoles = Record; diff --git a/superset-frontend/src/utils/fetchOptions.ts b/superset-frontend/src/utils/fetchOptions.ts new file mode 100644 index 00000000000..a09ca471e3e --- /dev/null +++ b/superset-frontend/src/utils/fetchOptions.ts @@ -0,0 +1,113 @@ +/** + * 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. + */ + +import { SupersetClient, t } from '@superset-ui/core'; +import rison from 'rison'; +import { Dispatch, SetStateAction } from 'react'; + +interface FetchPaginatedOptions { + endpoint: string; + pageSize?: number; + setData: (data: any[]) => void; + setLoadingState: Dispatch>; + filters?: SupersetFilter[]; + loadingKey: string; + addDangerToast: (message: string) => void; + errorMessage?: string; + mapResult?: (item: any) => any; +} + +interface QueryObj { + page_size: number; + page: number; + filters?: SupersetFilter[]; +} + +interface SupersetFilter { + col: string; + opr: string; + value: string | number | (string | number)[]; +} + +export const fetchPaginatedData = async ({ + endpoint, + pageSize = 100, + setData, + filters, + setLoadingState, + loadingKey, + addDangerToast, + errorMessage = 'Error while fetching data', + mapResult = (item: any) => item, +}: FetchPaginatedOptions) => { + try { + const fetchPage = async (pageIndex: number) => { + const queryObj: QueryObj = { + page_size: pageSize, + page: pageIndex, + }; + if (filters) { + queryObj.filters = filters; + } + const encodedQuery = rison.encode(queryObj); + + const response = await SupersetClient.get({ + endpoint: `${endpoint}?q=${encodedQuery}`, + }); + + return { + count: response.json.count, + results: response.json.result.map(mapResult), + }; + }; + + const initialResponse = await fetchPage(0); + const totalItems = initialResponse.count; + const firstPageResults = initialResponse.results; + + if (pageSize >= totalItems) { + setData(firstPageResults); + return; + } + + const totalPages = Math.ceil(totalItems / pageSize); + + const requests = Array.from({ length: totalPages - 1 }, (_, i) => + fetchPage(i + 1), + ); + const remainingResults = await Promise.all(requests); + + setData([ + ...firstPageResults, + ...remainingResults.flatMap(res => res.results), + ]); + } catch (err) { + addDangerToast(t(errorMessage)); + } finally { + setLoadingState((prev: boolean | Record) => { + if (typeof prev === 'boolean') { + return false; + } + return { + ...prev, + [loadingKey]: false, + }; + }); + } +}; diff --git a/superset-frontend/src/views/routes.tsx b/superset-frontend/src/views/routes.tsx index 242314ad079..64a86b55a41 100644 --- a/superset-frontend/src/views/routes.tsx +++ b/superset-frontend/src/views/routes.tsx @@ -137,6 +137,10 @@ const RolesList = lazy( const UsersList: LazyExoticComponent = lazy( () => import(/* webpackChunkName: "UsersList" */ 'src/pages/UsersList'), ); + +const UserInfo = lazy( + () => import(/* webpackChunkName: "UserInfo" */ 'src/pages/UserInfo'), +); const ActionLogList: LazyExoticComponent = lazy( () => import(/* webpackChunkName: "ActionLogList" */ 'src/pages/ActionLog'), ); @@ -149,6 +153,9 @@ const Register = lazy( () => import(/* webpackChunkName: "Register" */ 'src/pages/Register'), ); +const GroupsList: LazyExoticComponent = lazy( + () => import(/* webpackChunkName: "GroupsList" */ 'src/pages/GroupsList'), +); type Routes = { path: string; Component: ComponentType; @@ -263,6 +270,7 @@ export const routes: Routes = [ path: '/sqllab/', Component: SqlLab, }, + { path: '/user_info/', Component: UserInfo }, { path: '/actionlog/list', Component: ActionLogList, @@ -293,6 +301,10 @@ if (isAdmin) { path: '/users/', Component: UsersList, }, + { + path: '/list_groups/', + Component: GroupsList, + }, ); } diff --git a/superset/connectors/sqla/models.py b/superset/connectors/sqla/models.py index 275cc0cdbf4..8dd7daec850 100644 --- a/superset/connectors/sqla/models.py +++ b/superset/connectors/sqla/models.py @@ -49,7 +49,6 @@ from sqlalchemy import ( String, Table as DBTable, Text, - update, ) from sqlalchemy.engine.base import Connection from sqlalchemy.ext.declarative import declared_attr @@ -794,7 +793,8 @@ class AnnotationDatasource(BaseDatasource): qry = qry.filter(Annotation.end_dttm <= query_obj["to_dttm"]) status = QueryStatus.SUCCESS try: - df = pd.read_sql_query(qry.statement, db.engine) + with db.engine.connect() as con: + df = pd.read_sql_query(qry.statement, con) except Exception as ex: # pylint: disable=broad-except df = pd.DataFrame() status = QueryStatus.FAILED @@ -2022,21 +2022,6 @@ class SqlaTable( target.load_database() security_manager.dataset_before_update(mapper, connection, target) - @staticmethod - def update_column( # pylint: disable=unused-argument - mapper: Mapper, connection: Connection, target: SqlMetric | TableColumn - ) -> None: - """ - :param mapper: Unused. - :param connection: Unused. - :param target: The metric or column that was updated. - """ - session = inspect(target).session # pylint: disable=disallowed-name - - # Forces an update to the table's changed_on value when a metric or column on the # noqa: E501 - # table is updated. This busts the cache key for all charts that use the table. - session.execute(update(SqlaTable).where(SqlaTable.id == target.table.id)) - @staticmethod def after_insert( mapper: Mapper, @@ -2072,8 +2057,6 @@ class SqlaTable( sa.event.listen(SqlaTable, "before_update", SqlaTable.before_update) sa.event.listen(SqlaTable, "after_insert", SqlaTable.after_insert) sa.event.listen(SqlaTable, "after_delete", SqlaTable.after_delete) -sa.event.listen(SqlMetric, "after_update", SqlaTable.update_column) -sa.event.listen(TableColumn, "after_update", SqlaTable.update_column) RLSFilterRoles = DBTable( "rls_filter_roles", diff --git a/superset/daos/dataset.py b/superset/daos/dataset.py index 57d498661fe..e49585dced2 100644 --- a/superset/daos/dataset.py +++ b/superset/daos/dataset.py @@ -17,7 +17,7 @@ from __future__ import annotations import logging -from datetime import datetime +from datetime import datetime, timezone from typing import Any import dateutil.parser @@ -184,15 +184,21 @@ class DatasetDAO(BaseDAO[SqlaTable]): """ if item and attributes: + force_update: bool = False if "columns" in attributes: cls.update_columns( item, attributes.pop("columns"), override_columns=bool(attributes.get("override_columns")), ) + force_update = True if "metrics" in attributes: cls.update_metrics(item, attributes.pop("metrics")) + force_update = True + + if force_update: + attributes["changed_on"] = datetime.now(tz=timezone.utc) return super().update(item, attributes) diff --git a/superset/db_engine_specs/singlestore.py b/superset/db_engine_specs/singlestore.py new file mode 100644 index 00000000000..9dba3e452a5 --- /dev/null +++ b/superset/db_engine_specs/singlestore.py @@ -0,0 +1,549 @@ +# 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. + +import logging +import re +from datetime import datetime +from typing import Any, Optional +from urllib import parse + +from flask import current_app +from sqlalchemy import types +from sqlalchemy.engine import URL + +from superset.constants import TimeGrain +from superset.db_engine_specs.base import ( + BaseEngineSpec, + BasicParametersMixin, + ColumnTypeMapping, + LimitMethod, +) +from superset.models.core import Database +from superset.models.sql_lab import Query +from superset.utils.core import GenericDataType + +logger = logging.getLogger(__name__) + + +class SingleStoreSpec(BasicParametersMixin, BaseEngineSpec): + engine_name = "SingleStore" + + engine = "singlestoredb" + drivers = {"singlestoredb": "SingleStore Python client"} + default_driver = "singlestoredb" + + limit_method = LimitMethod.FORCE_LIMIT + allows_joins = True + allows_subqueries = True + allows_alias_in_select = True + allows_alias_in_orderby = True + time_groupby_inline = False + allows_alias_to_source_column = True + allows_hidden_orderby_agg = True + allows_hidden_cc_in_orderby = False + allows_cte_in_subquery = True + allow_limit_clause = True + max_column_name_length = 256 + allows_sql_comments = True + allows_escaped_colons = True + supports_file_upload = True + supports_dynamic_schema = True + disable_ssh_tunneling = False + + sqlalchemy_uri_placeholder = ( + "singlestoredb://{username}:{password}@{host}:{port}/{database}" + ) + + _time_grain_expressions = { + None: "{col}", + TimeGrain.SECOND: "DATE_TRUNC('second', {col})", + TimeGrain.MINUTE: "DATE_TRUNC('minute', {col})", + TimeGrain.HOUR: "DATE_TRUNC('hour', {col})", + TimeGrain.DAY: "DATE_TRUNC('day', {col})", + TimeGrain.WEEK: "DATE_TRUNC('week', {col})", + TimeGrain.MONTH: "DATE_TRUNC('month', {col})", + TimeGrain.QUARTER: "DATE_TRUNC('quarter', {col})", + TimeGrain.YEAR: "DATE_TRUNC('year', {col})", + } + + column_type_mappings: tuple[ColumnTypeMapping, ...] = ( + ( + re.compile(r"^tinyint", re.IGNORECASE), + types.SmallInteger(), + GenericDataType.NUMERIC, + ), + ( + re.compile(r"^mediumint", re.IGNORECASE), + types.Integer(), + GenericDataType.NUMERIC, + ), + ( + re.compile(r"^year", re.IGNORECASE), + types.Integer(), + GenericDataType.NUMERIC, + ), + ( + re.compile(r"^bit", re.IGNORECASE), + types.LargeBinary(), + GenericDataType.STRING, + ), + ( + re.compile(r"^(var)?binary", re.IGNORECASE), + types.LargeBinary(), + GenericDataType.STRING, + ), + ( + re.compile(r"^(tiny|medium|long)?blob", re.IGNORECASE), + types.LargeBinary(), + GenericDataType.STRING, + ), + ( + re.compile(r"^json", re.IGNORECASE), + types.String(), + GenericDataType.STRING, + ), + ( + re.compile(r"^bson", re.IGNORECASE), + types.LargeBinary(), + GenericDataType.STRING, + ), + ( + re.compile(r"^geographypoint", re.IGNORECASE), + types.String(), + GenericDataType.STRING, + ), + ( + re.compile(r"^geography", re.IGNORECASE), + types.String(), + GenericDataType.STRING, + ), + ( + re.compile(r"^vector", re.IGNORECASE), + types.String(), + GenericDataType.STRING, + ), + ( + re.compile(r"^enum", re.IGNORECASE), + types.String(), + GenericDataType.STRING, + ), + ( + re.compile(r"^set", re.IGNORECASE), + types.String(), + GenericDataType.STRING, + ), + ) + + @classmethod + def get_function_names( + cls, + database: Database, + ) -> list[str]: + """ + Get a list of function names that are able to be called on the database. + Used for SQL Lab autocomplete. + + :param database: The database to get functions for + :return: A list of function names usable in the database + """ + + functions: set[str] = { + "ABS", + "ACOS", + "ADDTIME", + "AES_DECRYPT", + "AES_ENCRYPT", + "AGGREGATOR_ID", + "ANY_VALUE", + "APPROX_COUNT_DISTINCT", + "APPROX_COUNT_DISTINCT_ACCUMULATE", + "APPROX_COUNT_DISTINCT_COMBINE", + "APPROX_COUNT_DISTINCT_ESTIMATE", + "APPROX_GEOGRAPHY_INTERSECTS", + "APPROX_PERCENTILE", + "ASCII", + "ASIN", + "ATAN", + "ATAN2", + "AVG", + "BETWEEN", + "NOT", + "BIN", + "BIN_TO_UUID", + "BINARY", + "BIT_AND", + "BIT_COUNT", + "BIT_OR", + "BIT_XOR", + "BM25", + "BSON_ARRAY_CONTAINS_BSON", + "BSON_ARRAY_PUSH", + "BSON_ARRAY_SLICE", + "BSON_BUILD_ARRAY", + "BSON_BUILD_OBJECT", + "BSON_COMPARE", + "BSON_EXTRACT_BIGINT", + "BSON_EXTRACT_BOOL", + "BSON_EXTRACT_BSON", + "BSON_EXTRACT_DATETIME", + "BSON_EXTRACT_DOUBLE", + "BSON_EXTRACT_STRING", + "BSON_GET_TYPE", + "BSON_INCLUDE_MASK", + "BSON_EXCLUDE_MASK", + "BSON_LENGTH", + "BSON_MATCH_ANY", + "BSON_MATCH_ANY_EXISTS", + "BSON_MERGE", + "BSON_NORMALIZE", + "BSON_NORMALIZE_ASC", + "BSON_NORMALIZE_DESC", + "BSON_NORMALIZE_NO_ARRAYBSON_SET_BSON", + "BSON_UNWIND", + "CASE", + "CEIL", + "CHAR", + "CHARACTER_LENGTH", + "CHARSET", + "COALESCE", + "CONCAT", + "CONCAT_WS", + "CONNECTION_ID", + "CONV", + "CAST", + "CONVERT", + "CONVERT_TZ", + "COS", + "COT", + "COUNT", + "CRC32", + "CURRENT_DATE", + "CURDATE", + "CURRENT_TIME", + "CURTIME", + "CURRENT_TIMESTAMP", + "DATABASE", + "DATE", + "DATE_ADD", + "DATE_FORMAT", + "DATE_SUB", + "DATE_TRUNC", + "DATEDIFF", + "DAY", + "DAYNAME", + "DAYOFWEEK", + "DAYOFYEAR", + "DECODE", + "DEGREES", + "DENSE_RANK", + "DOT_PRODUCT", + "ELT", + "ESTIMATED_QUERY_LEAF_MEMORY", + "ESTIMATED_QUERY_RUNTIME", + "EUCLIDEAN_DISTANCE", + "EXP", + "EXTRACT", + "FIELD", + "FIRST", + "FIRST_VALUE", + "FLOOR", + "FORMAT", + "FOUND_ROWS", + "FROM_BASE64", + "FROM_DAYS", + "FROM_UNIXTIME", + "GEOGRAPHY_AREA", + "GEOGRAPHY_CONTAINS", + "GEOGRAPHY_DISTANCE", + "GEOGRAPHY_INTERSECTS", + "GEOGRAPHY_LATITUDE", + "GEOGRAPHY_LENGTH", + "GEOGRAPHY_LONGITUDE", + "GEOGRAPHY_POINT", + "GEOGRAPHY_WITHIN_DISTANCE", + "GET_FORMAT", + "GREATEST", + "GROUP_CONCAT", + "HEX", + "HIGHLIGHT", + "HOUR", + "IF", + "IN", + "INET_ATON", + "INET_NTOA", + "INET6_ATON", + "INET6_NTOA", + "INITCAP", + "INSTR", + "IS_BSON_NULL", + "IS_UUID", + "ISNULL", + "ISNUMERIC", + "JSON_AGG", + "JSON_ARRAY_CONTAINS_DOUBLE", + "JSON_ARRAY_CONTAINS_STRING", + "JSON_ARRAY_CONTAINS_JSON", + "JSON_ARRAY_PACK", + "JSON_ARRAY_PUSH_DOUBLE", + "JSON_ARRAY_PUSH_STRING", + "JSON_ARRAY_PUSH_JSON", + "JSON_ARRAY_UNPACK", + "JSON_BUILD_ARRAY", + "JSON_BUILD_OBJECT", + "JSON_DELETE_KEY", + "JSON_EXTRACT_DOUBLE", + "JSON_EXTRACT_STRING", + "JSON_EXTRACT_JSON", + "JSON_EXTRACT_BIGINT", + "JSON_GET_TYPE", + "JSON_KEYS", + "JSON_LENGTH", + "JSON_MATCH_ANY", + "JSON_MERGE_PATCH", + "JSON_PRETTY", + "JSON_SET_DOUBLE", + "JSON_SET_STRING", + "JSON_SET_JSON", + "JSON_SPLICE_DOUBLE", + "JSON_SPLICE_STRING", + "JSON_SPLICE_JSON", + "JSON_TO_ARRAY", + "LAG", + "LAST", + "LAST_DAY", + "LAST_INSERT_ID", + "LAST_VALUE", + "LCASE", + "LEAD", + "LEAST", + "LEFT", + "LENGTH", + "LIKE", + "LN", + "LOCALTIMESTAMP", + "LOCATE", + "LOG", + "LOG10", + "LOG2", + "LPAD", + "LTRIM", + "MATCH", + "MAX", + "MD5", + "MEDIAN", + "MICROSECOND", + "MIN", + "MINUTE", + "MOD", + "MONTH", + "MONTHNAME", + "MONTHS_BETWEEN", + "NOPARAM", + "NOW", + "NTH_VALUE", + "NTILE", + "NULLIF", + "NVL", + "IFNULL", + "PERCENT_RANK", + "PERCENTILE_CONT", + "PERCENTILE_DISC", + "PI", + "POW", + "QUARTER", + "QUOTE", + "RADIANS", + "RAND", + "RANK", + "REDUCE", + "REGEXP_INSTR", + "REGEXP_MATCH", + "REGEXP_REPLACE", + "REGEXP_SUBSTR", + "REPLACE", + "REVERSE", + "RIGHT", + "RLIKE", + "REGEXP", + "ROUND", + "ROW_COUNT", + "ROW_NUMBER", + "RPAD", + "RTRIM", + "SCALAR_VECTOR_MUL", + "SEC_TO_TIME", + "SECOND", + "SECRET", + "SET", + "SHA1", + "SHA2", + "SIGMOID", + "SIGN", + "SIN", + "SLEEP", + "SPLIT", + "SQRT", + "STD", + "STDDEV", + "STDDEV_POP", + "STDDEV_SAMP", + "STR_TO_DATE", + "strcmp", + "STRING_BYTES", + "SUBSTRING", + "SUBSTRING_INDEX", + "SUM", + "SYS_GUID", + "UUID", + "TAN", + "TIME", + "TIME_BUCKET", + "TIME_FORMAT", + "TIME_TO_SEC", + "TIMEDIFF", + "TIMESTAMP", + "TIMESTAMPADD", + "TIMESTAMPDIFF", + "TO_BASE64", + "TO_CHAR", + "TO_DATE", + "TO_DAYS", + "TO_JSON", + "TO_NUMBER", + "TO_SECONDS", + "TO_TIMESTAMP", + "TRIM", + "TRUNC", + "TRUNCATE", + "UCASE", + "UNHEX", + "UNIX_TIMESTAMP", + "USER", + "UTC_DATE", + "UTC_TIME", + "UTC_TIMESTAMP", + "UUID_TO_BIN", + "VARIANCE", + "VAR_SAMP", + "VECTOR_ADD", + "VECTOR_ELEMENTS_SUM", + "VECTOR_KTH_ELEMENT", + "VECTOR_MUL", + "VECTOR_NUM_ELEMENTS", + "VECTOR_SORT", + "VECTOR_SUB", + "VECTOR_SUBVECTOR", + "VECTOR_SUM", + "WEEK", + "WEEKDAY", + "YEAR", + } + + if (database_name := cls.get_default_schema(database, None)) is not None: + df = database.get_df( + f"SHOW FUNCTIONS IN `{database_name.replace('`', '``')}`" + ) + + functions.update(df.iloc[:, 0].tolist()) + + return list(functions) + + @classmethod + def epoch_to_dttm(cls) -> str: + return "from_unixtime({col})" + + @classmethod + def convert_dttm( + cls, target_type: str, dttm: datetime, db_extra: Optional[dict[str, Any]] = None + ) -> Optional[str]: + sqla_type = cls.get_sqla_column_type(target_type) + + if isinstance(sqla_type, types.Date): + return f"CAST('{dttm.date().isoformat()}' AS DATE)" + if isinstance(sqla_type, types.TIMESTAMP): + return f"""('{dttm.isoformat(sep=" ", timespec="microseconds")}' :> TIMESTAMP(6))""" # noqa: E501 + if isinstance(sqla_type, types.DateTime): + return f"""CAST('{dttm.isoformat(sep=" ", timespec="microseconds")}' AS DATETIME(6))""" # noqa: E501 + if isinstance(sqla_type, types.Time): + return f"""CAST('{dttm.strftime("%H:%M:%S.%f")}' AS TIME(6))""" + + return None + + @classmethod + def adjust_engine_params( + cls, + uri: URL, + connect_args: dict[str, Any], + catalog: Optional[str] = None, + schema: Optional[str] = None, + ) -> tuple[URL, dict[str, Any]]: + if schema: + uri = uri.set(database=parse.quote(schema, safe="")) + + connect_args.setdefault( + "conn_attrs", + { + "_connector_name": "SingleStore Superset Database Engine", + "_connector_version": current_app.config.get("VERSION_STRING", "dev"), + "_product_version": current_app.config.get("VERSION_STRING", "dev"), + }, + ) + return uri, connect_args + + @classmethod + def get_schema_from_engine_params( + cls, + sqlalchemy_uri: URL, + connect_args: dict[str, Any], + ) -> Optional[str]: + """ + Return the configured schema. + + A MySQL database is a SQLAlchemy schema. + """ + return parse.unquote(sqlalchemy_uri.database) + + @classmethod + def get_cancel_query_id(cls, cursor: Any, query: Query) -> Optional[str]: + """ + Get SingleStore connection ID and aggregator ID that will be used to cancel all + other running queries in the same connection. + + :param cursor: Cursor instance in which the query will be executed + :param query: Query instance + :return: SingleStore connection ID and aggregator ID + """ + cursor.execute("SELECT CONNECTION_ID(), AGGREGATOR_ID()") + row = cursor.fetchone() + return " ".join(str(item) for item in row) + + @classmethod + def cancel_query(cls, cursor: Any, query: Query, cancel_query_id: str) -> bool: + """ + Cancel query in the underlying database. + + :param cursor: New cursor instance to the db of the query + :param query: Query instance + :param cancel_query_id: SingleStore connection ID and aggregator ID + :return: True if query cancelled successfully, False otherwise + """ + try: + cursor.execute(f"KILL CONNECTION {cancel_query_id}") + except Exception: # pylint: disable=broad-except + return False + + return True diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py index f00f8fb146e..4b0c910c709 100644 --- a/superset/initialization/__init__.py +++ b/superset/initialization/__init__.py @@ -170,6 +170,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods from superset.views.dynamic_plugins import DynamicPluginsView from superset.views.error_handling import set_app_error_handlers from superset.views.explore import ExplorePermalinkView, ExploreView + from superset.views.groups import GroupsListView from superset.views.log.api import LogRestApi from superset.views.logs import ActionLogView from superset.views.roles import RolesListView @@ -184,6 +185,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods ) from superset.views.sqllab import SqllabView from superset.views.tags import TagModelView, TagView + from superset.views.user_info import UserInfoView from superset.views.users.api import CurrentUserRestApi, UserRestApi from superset.views.users_list import UsersListView @@ -298,6 +300,14 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods category_label=__("Security"), ) + appbuilder.add_view( + GroupsListView, + "List Groups", + label=__("List Groups"), + category="Security", + category_label=__("Security"), + ) + appbuilder.add_view( DynamicPluginsView, "Plugins", @@ -339,6 +349,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods appbuilder.add_view_no_menu(TagView) appbuilder.add_view_no_menu(ReportView) appbuilder.add_view_no_menu(RoleRestAPI) + appbuilder.add_view_no_menu(UserInfoView) # # Add links diff --git a/superset/models/helpers.py b/superset/models/helpers.py index 341585e5da5..5647bc871bb 100644 --- a/superset/models/helpers.py +++ b/superset/models/helpers.py @@ -1375,10 +1375,11 @@ class ExploreMixin: # pylint: disable=too-many-public-methods if engine.dialect.identifier_preparer._double_percents: sql = sql.replace("%%", "%") - df = pd.read_sql_query(sql=self.text(sql), con=engine) - # replace NaN with None to ensure it can be serialized to JSON - df = df.replace({np.nan: None}) - return df["column_values"].to_list() + with engine.connect() as con: + df = pd.read_sql_query(sql=self.text(sql), con=con) + # replace NaN with None to ensure it can be serialized to JSON + df = df.replace({np.nan: None}) + return df["column_values"].to_list() def get_timestamp_expression( self, diff --git a/superset/security/api.py b/superset/security/api.py index 756f1d7bbd3..c0890da3ccd 100644 --- a/superset/security/api.py +++ b/superset/security/api.py @@ -308,6 +308,9 @@ class RoleRestAPI(BaseSupersetApi): Role.permissions.any(id=filter_dict["permission_ids"]) ) + if "group_ids" in filter_dict: + query = query.filter(Role.groups.any(id=filter_dict["group_ids"])) + if "name" in filter_dict: query = query.filter(Role.name.ilike(f"%{filter_dict['name']}%")) @@ -323,6 +326,7 @@ class RoleRestAPI(BaseSupersetApi): "name": role.name, "user_ids": [user.id for user in role.user], "permission_ids": [perm.id for perm in role.permissions], + "group_ids": [group.id for group in role.groups], } for role in roles ], diff --git a/superset/security/manager.py b/superset/security/manager.py index 84d31971ae1..b33622b27d2 100644 --- a/superset/security/manager.py +++ b/superset/security/manager.py @@ -145,6 +145,7 @@ class SupersetUserApi(UserApi): search_columns = [ "id", "roles", + "groups", "first_name", "last_name", "username", @@ -281,6 +282,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods "User's Statistics", # Guarding all AB_ADD_SECURITY_API = True REST APIs "RoleRestAPI", + "Group", "Role", "Permission", "PermissionViewMenu", @@ -2797,7 +2799,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods for view in list(self.appbuilder.baseviews): if isinstance(view, self.rolemodelview.__class__) and getattr( view, "route_base", None - ) in ["/roles", "/users"]: + ) in ["/roles", "/users", "/groups"]: self.appbuilder.baseviews.remove(view) security_menu = next( @@ -2805,5 +2807,5 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods ) if security_menu: for item in list(security_menu.childs): - if item.name in ["List Roles", "List Users"]: + if item.name in ["List Roles", "List Users", "List Groups"]: security_menu.childs.remove(item) diff --git a/superset/sql/parse.py b/superset/sql/parse.py index 73255bef13e..98dd8ef55ac 100644 --- a/superset/sql/parse.py +++ b/superset/sql/parse.py @@ -83,6 +83,7 @@ SQLGLOT_DIALECTS = { "risingwave": Dialects.RISINGWAVE, # "rockset": ??? "shillelagh": Dialects.SQLITE, + "singlestore": Dialects.MYSQL, "snowflake": Dialects.SNOWFLAKE, # "solr": ??? "spark": Dialects.SPARK, diff --git a/superset/sql_parse.py b/superset/sql_parse.py index 6422a5c7bbe..969c060ee9b 100644 --- a/superset/sql_parse.py +++ b/superset/sql_parse.py @@ -819,7 +819,7 @@ SQLOXIDE_DIALECTS = { "ansi": {"trino", "trinonative", "presto"}, "hive": {"hive", "databricks"}, "ms": {"mssql"}, - "mysql": {"mysql"}, + "mysql": {"mysql", "singlestore"}, "postgres": { "cockroachdb", "hana", diff --git a/superset/views/base.py b/superset/views/base.py index 18c0dcb459a..2b7dd076fa1 100644 --- a/superset/views/base.py +++ b/superset/views/base.py @@ -283,9 +283,7 @@ def menu_data(user: User) -> dict[str, Any]: "show_language_picker": len(languages) > 1, "user_is_anonymous": user.is_anonymous, "user_info_url": ( - None - if is_feature_enabled("MENU_HIDE_USER_INFO") - else appbuilder.get_url_for_userinfo + None if is_feature_enabled("MENU_HIDE_USER_INFO") else "/user_info/" ), "user_logout_url": appbuilder.get_url_for_logout, "user_login_url": appbuilder.get_url_for_login, diff --git a/superset/views/groups.py b/superset/views/groups.py new file mode 100644 index 00000000000..61dc9dc659d --- /dev/null +++ b/superset/views/groups.py @@ -0,0 +1,34 @@ +# 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. +from flask_appbuilder import permission_name +from flask_appbuilder.api import expose +from flask_appbuilder.security.decorators import has_access + +from superset.superset_typing import FlaskResponse + +from .base import BaseSupersetView + + +class GroupsListView(BaseSupersetView): + route_base = "/" + class_permission_name = "security" + + @expose("/list_groups/") + @has_access + @permission_name("read") + def list(self) -> FlaskResponse: + return super().render_app_template() diff --git a/superset/views/user_info.py b/superset/views/user_info.py new file mode 100644 index 00000000000..24449709e88 --- /dev/null +++ b/superset/views/user_info.py @@ -0,0 +1,34 @@ +# 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. +from flask_appbuilder import permission_name +from flask_appbuilder.api import expose +from flask_appbuilder.security.decorators import has_access + +from superset.superset_typing import FlaskResponse + +from .base import BaseSupersetView + + +class UserInfoView(BaseSupersetView): + route_base = "/" + class_permission_name = "user" + + @expose("/user_info/") + @has_access + @permission_name("read") + def list(self) -> FlaskResponse: + return super().render_app_template() diff --git a/superset/views/users/api.py b/superset/views/users/api.py index 82089fe84fe..4e24c62b07d 100644 --- a/superset/views/users/api.py +++ b/superset/views/users/api.py @@ -14,16 +14,23 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. -from flask import g, redirect, Response +from datetime import datetime +from typing import Any, Dict + +from flask import g, redirect, request, Response from flask_appbuilder.api import expose, safe +from flask_appbuilder.security.sqla.models import User from flask_jwt_extended.exceptions import NoAuthorizationError +from marshmallow import ValidationError from sqlalchemy.orm.exc import NoResultFound +from werkzeug.security import generate_password_hash from superset import app, is_feature_enabled from superset.daos.user import UserDAO +from superset.extensions import db, event_logger from superset.utils.slack import get_user_avatar, SlackClientError -from superset.views.base_api import BaseSupersetApi -from superset.views.users.schemas import UserResponseSchema +from superset.views.base_api import BaseSupersetApi, requires_json, statsd_metrics +from superset.views.users.schemas import CurrentUserPutSchema, UserResponseSchema from superset.views.utils import bootstrap_user_data user_response_schema = UserResponseSchema() @@ -34,7 +41,23 @@ class CurrentUserRestApi(BaseSupersetApi): resource_name = "me" openapi_spec_tag = "Current User" - openapi_spec_component_schemas = (UserResponseSchema,) + openapi_spec_component_schemas = (UserResponseSchema, CurrentUserPutSchema) + + current_user_put_schema = CurrentUserPutSchema() + + def pre_update(self, item: User, data: Dict[str, Any]) -> None: + item.changed_on = datetime.now() + item.changed_by_fk = g.user.id + if "password" in data and data["password"]: + item.password = generate_password_hash( + password=data["password"], + method=self.appbuilder.get_app.config.get( + "FAB_PASSWORD_HASH_METHOD", "scrypt" + ), + salt_length=self.appbuilder.get_app.config.get( + "FAB_PASSWORD_HASH_SALT_LENGTH", 16 + ), + ) @expose("/", methods=("GET",)) @safe @@ -98,6 +121,61 @@ class CurrentUserRestApi(BaseSupersetApi): user = bootstrap_user_data(g.user, include_perms=True) return self.response(200, result=user) + @expose("/", methods=["PUT"]) + @safe + @statsd_metrics + @event_logger.log_this_with_context( + action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.put", + log_to_statsd=False, + ) + @requires_json + def update_me(self) -> Response: + """Update current user information + --- + put: + summary: Update the current user + description: >- + Updates the current user's first name, last name, or password. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CurrentUserPutSchema' + responses: + 200: + description: User updated successfully + content: + application/json: + schema: + type: object + properties: + result: + $ref: '#/components/schemas/UserResponseSchema' + 400: + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' + """ + try: + if g.user is None or g.user.is_anonymous: + return self.response_401() + except NoAuthorizationError: + return self.response_401() + try: + item = self.current_user_put_schema.load(request.json) + if not item: + return self.response_400(message="At least one field must be provided.") + + for key, value in item.items(): + setattr(g.user, key, value) + + self.pre_update(g.user, item) + db.session.commit() + return self.response(200, result=user_response_schema.dump(g.user)) + except ValidationError as error: + return self.response_400(message=error.messages) + class UserRestApi(BaseSupersetApi): """An API to get information about users""" diff --git a/superset/views/users/schemas.py b/superset/views/users/schemas.py index 021209edafb..f74d1cbca50 100644 --- a/superset/views/users/schemas.py +++ b/superset/views/users/schemas.py @@ -14,8 +14,17 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. -from marshmallow import Schema +from flask_appbuilder.security.sqla.apis.user.schema import User +from flask_appbuilder.security.sqla.apis.user.validator import ( + PasswordComplexityValidator, +) +from marshmallow import fields, Schema from marshmallow.fields import Boolean, Integer, String +from marshmallow.validate import Length + +first_name_description = "The current user's first name" +last_name_description = "The current user's last name" +password_description = "The current user's password for authentication" # noqa: S105 class UserResponseSchema(Schema): @@ -26,3 +35,24 @@ class UserResponseSchema(Schema): last_name = String() is_active = Boolean() is_anonymous = Boolean() + login_count = Integer() + + +class CurrentUserPutSchema(Schema): + model_cls = User + + first_name = fields.String( + required=False, + metadata={"description": first_name_description}, + validate=[Length(1, 64)], + ) + last_name = fields.String( + required=False, + metadata={"description": last_name_description}, + validate=[Length(1, 64)], + ) + password = fields.String( + required=False, + validate=[PasswordComplexityValidator()], + metadata={"description": password_description}, + ) diff --git a/superset/views/utils.py b/superset/views/utils.py index 1f6a4348fd2..819641fbe31 100644 --- a/superset/views/utils.py +++ b/superset/views/utils.py @@ -90,6 +90,7 @@ def bootstrap_user_data(user: User, include_perms: bool = False) -> dict[str, An "isAnonymous": user.is_anonymous, "createdOn": user.created_on.isoformat(), "email": user.email, + "loginCount": user.login_count, } if include_perms: diff --git a/tests/integration_tests/datasets/api_tests.py b/tests/integration_tests/datasets/api_tests.py index 8ed7ec216ba..ab645d300e1 100644 --- a/tests/integration_tests/datasets/api_tests.py +++ b/tests/integration_tests/datasets/api_tests.py @@ -17,6 +17,7 @@ from __future__ import annotations import unittest +from datetime import timedelta from io import BytesIO from unittest.mock import ANY, patch from zipfile import is_zipfile, ZipFile @@ -24,6 +25,7 @@ from zipfile import is_zipfile, ZipFile import prison import pytest import yaml +from freezegun import freeze_time from sqlalchemy import inspect from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import joinedload @@ -1144,9 +1146,9 @@ class TestDatasetApi(SupersetTestCase): """ Dataset API: Test update dataset create column """ - # create example dataset by Command dataset = self.insert_default_dataset() + current_changed_on = dataset.changed_on new_column_data = { "column_name": "new_col", @@ -1188,13 +1190,16 @@ class TestDatasetApi(SupersetTestCase): metric.pop("type_generic", None) data["result"]["metrics"].append(new_metric_data) - rv = self.client.put( - uri, - json={ - "columns": data["result"]["columns"], - "metrics": data["result"]["metrics"], - }, - ) + + with freeze_time() as frozen: + frozen.tick(delta=timedelta(seconds=3)) + rv = self.client.put( + uri, + json={ + "columns": data["result"]["columns"], + "metrics": data["result"]["metrics"], + }, + ) assert rv.status_code == 200 @@ -1233,6 +1238,10 @@ class TestDatasetApi(SupersetTestCase): assert metrics[1].warning_text == new_metric_data["warning_text"] assert str(metrics[1].uuid) == new_metric_data["uuid"] + # Validate that the changed_on is updated + updated_dataset = db.session.query(SqlaTable).filter_by(id=dataset.id).first() + assert updated_dataset.changed_on > current_changed_on + self.items_to_delete = [dataset] def test_update_dataset_delete_column(self): diff --git a/tests/integration_tests/query_context_tests.py b/tests/integration_tests/query_context_tests.py index cb5afc98aff..661c39eedcb 100644 --- a/tests/integration_tests/query_context_tests.py +++ b/tests/integration_tests/query_context_tests.py @@ -14,6 +14,7 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. +import copy import re import time from typing import Any @@ -30,7 +31,7 @@ from superset.common.chart_data import ChartDataResultFormat, ChartDataResultTyp from superset.common.query_context import QueryContext from superset.common.query_context_factory import QueryContextFactory from superset.common.query_object import QueryObject -from superset.connectors.sqla.models import SqlMetric +from superset.daos.dataset import DatasetDAO from superset.daos.datasource import DatasourceDAO from superset.extensions import cache_manager from superset.superset_typing import AdhocColumn @@ -47,6 +48,7 @@ from tests.integration_tests.conftest import ( only_sqlite, with_feature_flags, ) +from tests.integration_tests.constants import ADMIN_USERNAME from tests.integration_tests.fixtures.birth_names_dashboard import ( load_birth_names_dashboard_with_slices, # noqa: F401 load_birth_names_data, # noqa: F401 @@ -171,17 +173,28 @@ class TestQueryContext(SupersetTestCase): assert cache_key_original != cache_key_new def test_query_cache_key_changes_when_metric_is_updated(self): + """ + Test that the query cache key changes when a metric is updated. + """ + self.login(ADMIN_USERNAME) payload = get_query_context("birth_names") - # make temporary change and revert it to refresh the changed_on property - datasource = DatasourceDAO.get_datasource( - datasource_type=DatasourceType(payload["datasource"]["type"]), - datasource_id=payload["datasource"]["id"], - ) - - datasource.metrics.append(SqlMetric(metric_name="foo", expression="select 1;")) + dataset = DatasetDAO.find_by_id(payload["datasource"]["id"]) + dataset_payload = { + "metrics": [ + { + "metric_name": "foo", + "expression": "select 1;", + } + ] + } + DatasetDAO.update(dataset, copy.deepcopy(dataset_payload)) db.session.commit() + # Add metric ID to the payload for future update + updated_dataset = DatasetDAO.find_by_id(dataset.id) + dataset_payload["metrics"][0]["id"] = updated_dataset.metrics[0].id + # construct baseline query_cache_key query_context = ChartDataQueryContextSchema().load(payload) query_object = query_context.queries[0] @@ -190,7 +203,8 @@ class TestQueryContext(SupersetTestCase): # wait a second since mysql records timestamps in second granularity time.sleep(1) - datasource.metrics[0].expression = "select 2;" + dataset_payload["metrics"][0]["expression"] = "select 2;" + DatasetDAO.update(updated_dataset, copy.deepcopy(dataset_payload)) db.session.commit() # create new QueryContext with unchanged attributes, extract new query_cache_key @@ -198,7 +212,8 @@ class TestQueryContext(SupersetTestCase): query_object = query_context.queries[0] cache_key_new = query_context.query_cache_key(query_object) - datasource.metrics = [] + dataset_payload["metrics"] = [] + DatasetDAO.update(updated_dataset, dataset_payload) db.session.commit() # the new cache_key should be different due to updated datasource diff --git a/tests/integration_tests/reports/commands_tests.py b/tests/integration_tests/reports/commands_tests.py index d3798d05ec8..30a9d174c5b 100644 --- a/tests/integration_tests/reports/commands_tests.py +++ b/tests/integration_tests/reports/commands_tests.py @@ -1978,7 +1978,7 @@ def test_slack_token_callable_chart_report( TEST_ID, create_report_slack_chart.id, datetime.utcnow() ).run() app.config["SLACK_API_TOKEN"].assert_called() - assert slack_client_mock_class.called_with(token="cool_code", proxy="") # noqa: S106 + slack_client_mock_class.assert_called_with(token="cool_code", proxy=None) # noqa: S106 assert_log(ReportState.SUCCESS) diff --git a/tests/integration_tests/security_tests.py b/tests/integration_tests/security_tests.py index aaad6005d31..da342f97d3e 100644 --- a/tests/integration_tests/security_tests.py +++ b/tests/integration_tests/security_tests.py @@ -1538,6 +1538,7 @@ class TestRolePermission(SupersetTestCase): ["AuthDBView", "login"], ["AuthDBView", "logout"], ["CurrentUserRestApi", "get_me"], + ["CurrentUserRestApi", "update_me"], ["CurrentUserRestApi", "get_my_roles"], ["UserRestApi", "avatar"], # TODO (embedded) remove Dashboard:embedded after uuids have been shipped diff --git a/tests/integration_tests/users/api_tests.py b/tests/integration_tests/users/api_tests.py index 9f7423bb13a..0b6822ebbf3 100644 --- a/tests/integration_tests/users/api_tests.py +++ b/tests/integration_tests/users/api_tests.py @@ -67,6 +67,37 @@ class TestCurrentUserApi(SupersetTestCase): rv = self.client.get(meUri) assert 401 == rv.status_code + def test_update_me_success(self): + self.login(ADMIN_USERNAME) + + payload = { + "first_name": "UpdatedFirst", + "last_name": "UpdatedLast", + } + + rv = self.client.put("/api/v1/me/", json=payload) + assert rv.status_code == 200 + + data = json.loads(rv.data.decode("utf-8")) + assert data["result"]["first_name"] == "UpdatedFirst" + assert data["result"]["last_name"] == "UpdatedLast" + + def test_update_me_unauthenticated(self): + rv = self.client.put("/api/v1/me/", json={"first_name": "Hacker"}) + assert rv.status_code == 401 + + def test_update_me_invalid_payload(self): + self.login(ADMIN_USERNAME) + rv = self.client.put("/api/v1/me/", json={"first_name": 123}) + assert rv.status_code == 400 + data = json.loads(rv.data.decode("utf-8")) + assert "first_name" in data["message"] + + def test_update_me_empty_payload(self): + self.login(ADMIN_USERNAME) + rv = self.client.put("/api/v1/me/", json={}) + assert rv.status_code == 400 + class TestUserApi(SupersetTestCase): def test_avatar_with_invalid_user(self): diff --git a/tests/unit_tests/commands/dataset/update_test.py b/tests/unit_tests/commands/dataset/update_test.py index 45a1a4160d8..452d60cda4f 100644 --- a/tests/unit_tests/commands/dataset/update_test.py +++ b/tests/unit_tests/commands/dataset/update_test.py @@ -95,7 +95,7 @@ def test_update_dataset_forbidden(mocker: MockerFixture) -> None: ), ], ) -def test_update_validation_errors( +def test_update_dataset_validation_errors( payload: dict[str, Any], exception: Exception, error_msg: str, diff --git a/tests/unit_tests/dao/dataset_test.py b/tests/unit_tests/dao/dataset_test.py index ff6d93a8647..001e8ba9cba 100644 --- a/tests/unit_tests/dao/dataset_test.py +++ b/tests/unit_tests/dao/dataset_test.py @@ -15,8 +15,16 @@ # specific language governing permissions and limitations # under the License. +import copy +from datetime import datetime, timezone +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from freezegun import freeze_time from sqlalchemy.orm.session import Session +from superset.daos.base import BaseDAO from superset.daos.dataset import DatasetDAO from superset.sql_parse import Table @@ -77,3 +85,94 @@ def test_validate_update_uniqueness(session: Session) -> None: ) is True ) + + +@freeze_time("2025-01-01 00:00:00") +@patch.object(DatasetDAO, "update_columns") +@patch.object(DatasetDAO, "update_metrics") +@patch.object(BaseDAO, "update") +@pytest.mark.parametrize( + "attributes,expected_attributes", + [ + ( + { + "columns": [{"id": 1, "name": "col1"}], + "metrics": [{"id": 1, "name": "metric1"}], + }, + {"changed_on": datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc)}, + ), + ( + { + "columns": [{"id": 1, "name": "col1"}], + "metrics": [{"id": 1, "name": "metric1"}], + "description": "test description", + }, + { + "description": "test description", + "changed_on": datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + }, + ), + ( + { + "columns": [{"id": 1, "name": "col1"}], + }, + {"changed_on": datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc)}, + ), + ( + { + "columns": [{"id": 1, "name": "col1"}], + "description": "test description", + }, + { + "description": "test description", + "changed_on": datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + }, + ), + ( + { + "metrics": [{"id": 1, "name": "metric1"}], + }, + {"changed_on": datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc)}, + ), + ( + { + "metrics": [{"id": 1, "name": "metric1"}], + "description": "test description", + }, + { + "description": "test description", + "changed_on": datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + }, + ), + ( + {"description": "test description"}, + {"description": "test description"}, + ), + ], +) +def test_update_dataset_related_metadata_updates_changed_on( + base_update_mock: MagicMock, + update_metrics_mock: MagicMock, + update_columns_mock: MagicMock, + attributes: dict[str, Any], + expected_attributes: dict[str, Any], +) -> None: + """ + Test that the changed_on property is updated when a metric or column is updated. + """ + item = MagicMock() + DatasetDAO.update(item, copy.deepcopy(attributes)) + + if "columns" in attributes: + update_columns_mock.assert_called_once_with( + item, attributes["columns"], override_columns=False + ) + else: + update_columns_mock.assert_not_called() + + if "metrics" in attributes: + update_metrics_mock.assert_called_once_with(item, attributes["metrics"]) + else: + update_metrics_mock.assert_not_called() + + base_update_mock.assert_called_once_with(item, expected_attributes) diff --git a/tests/unit_tests/db_engine_specs/test_singlestore.py b/tests/unit_tests/db_engine_specs/test_singlestore.py new file mode 100644 index 00000000000..52ec960ada4 --- /dev/null +++ b/tests/unit_tests/db_engine_specs/test_singlestore.py @@ -0,0 +1,216 @@ +# 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. + +from datetime import datetime +from typing import Any, Optional +from unittest.mock import MagicMock, Mock + +import pandas as pd +import pytest +from sqlalchemy import types +from sqlalchemy.engine import make_url + +from superset.db_engine_specs.singlestore import SingleStoreSpec +from superset.models.sql_lab import Query +from superset.utils.core import GenericDataType +from tests.unit_tests.db_engine_specs.utils import ( + assert_column_spec, + assert_convert_dttm, +) +from tests.unit_tests.fixtures.common import dttm # noqa: F401 + + +@pytest.mark.parametrize( + "target_type,expected_result", + [ + ("Date", "CAST('2019-01-02' AS DATE)"), + ("DateTime", "CAST('2019-01-02 03:04:05.678900' AS DATETIME(6))"), + ("Timestamp", "('2019-01-02 03:04:05.678900' :> TIMESTAMP(6))"), + ("Time", "CAST('03:04:05.678900' AS TIME(6))"), + ("UnknownType", None), + ], +) +def test_convert_dttm( + target_type: str, + expected_result: Optional[str], + dttm: datetime, # noqa: F811 +) -> None: + assert_convert_dttm(SingleStoreSpec, target_type, expected_result, dttm) + + +def test_epoch_to_dttm() -> None: + assert SingleStoreSpec.epoch_to_dttm() == "from_unixtime({col})" + + +def test_cancel_query_success() -> None: + query = Query() + cursor_mock = Mock() + assert SingleStoreSpec.cancel_query(cursor_mock, query, "123 5") is True + + cursor_mock.execute.assert_called_once_with("KILL CONNECTION 123 5") + + +def test_cancel_query_failed() -> None: + query = Query() + cursor_mock = Mock() + cursor_mock.execute.side_effect = Exception("Execution failed") + + assert SingleStoreSpec.cancel_query(cursor_mock, query, "123 6") is False + + cursor_mock.execute.assert_called_once_with("KILL CONNECTION 123 6") + + +def test_get_cancel_query_id() -> None: + query = Query() + cursor_mock = Mock() + cursor_mock.fetchone.return_value = (123, 6) + + assert SingleStoreSpec.get_cancel_query_id(cursor_mock, query) == "123 6" + cursor_mock.execute.assert_called_once_with( + "SELECT CONNECTION_ID(), AGGREGATOR_ID()" + ) + + +def test_get_schema_from_engine_params() -> None: + assert ( + SingleStoreSpec.get_schema_from_engine_params( + make_url("singlestoredb://admin:admin@localhost:10000/dbName!"), {} + ) + == "dbName!" + ) + + +def test_adjust_engine_params() -> None: + adjusted = SingleStoreSpec.adjust_engine_params( + make_url("singlestoredb://user:password@host:5432/dev"), + {}, + schema="pro d", + ) + assert adjusted == ( + make_url("singlestoredb://user:password@host:5432/pro%20d"), + { + "conn_attrs": { + "_connector_name": "SingleStore Superset Database Engine", + "_connector_version": "0.0.0-dev", + "_product_version": "0.0.0-dev", + } + }, + ) + + +@pytest.mark.parametrize( + "native_type,sqla_type,attrs,generic_type,is_dttm", + [ + # Numeric + ("TINYINT", types.SmallInteger, None, GenericDataType.NUMERIC, False), + ("SMALLINT", types.SmallInteger, None, GenericDataType.NUMERIC, False), + ("MEDIUMINT", types.Integer, None, GenericDataType.NUMERIC, False), + ("INT", types.Integer, None, GenericDataType.NUMERIC, False), + ("BIGINT", types.BigInteger, None, GenericDataType.NUMERIC, False), + ("YEAR", types.Integer, None, GenericDataType.NUMERIC, False), + ("FLOAT", types.Float, None, GenericDataType.NUMERIC, False), + ("DOUBLE", types.Float, None, GenericDataType.NUMERIC, False), + ("DECIMAL", types.Numeric, None, GenericDataType.NUMERIC, False), + ("DECIMAL(10)", types.Numeric, None, GenericDataType.NUMERIC, False), + ("DECIMAL(10, 10)", types.Numeric, None, GenericDataType.NUMERIC, False), + # String + ("CHAR", types.String, None, GenericDataType.STRING, False), + ("VARCHAR", types.String, None, GenericDataType.STRING, False), + ("TEXT", types.String, None, GenericDataType.STRING, False), + ("TINYTEXT", types.String, None, GenericDataType.STRING, False), + ("MEDIUMTEXT", types.String, None, GenericDataType.STRING, False), + ("LONGTEXT", types.String, None, GenericDataType.STRING, False), + ("BINARY", types.LargeBinary, None, GenericDataType.STRING, False), + ("VARBINARY", types.LargeBinary, None, GenericDataType.STRING, False), + ("BLOB", types.LargeBinary, None, GenericDataType.STRING, False), + ("TINYBLOB", types.LargeBinary, None, GenericDataType.STRING, False), + ("MEDIUMBLOB", types.LargeBinary, None, GenericDataType.STRING, False), + ("LONGBLOB", types.LargeBinary, None, GenericDataType.STRING, False), + ("JSON", types.String, None, GenericDataType.STRING, False), + ("BSON", types.LargeBinary, None, GenericDataType.STRING, False), + ("GEOGRAPHYPOINT", types.String, None, GenericDataType.STRING, False), + ("GEOGRAPHY", types.String, None, GenericDataType.STRING, False), + ("ENUM('a', 'b')", types.String, None, GenericDataType.STRING, False), + ("SET('a', 'b')", types.String, None, GenericDataType.STRING, False), + ("BIT", types.LargeBinary, None, GenericDataType.STRING, False), + ("VECTOR(3, I32)", types.String, None, GenericDataType.STRING, False), + # Temporal + ("DATE", types.Date, None, GenericDataType.TEMPORAL, True), + ("TIMESTAMP", types.TIMESTAMP, None, GenericDataType.TEMPORAL, True), + ("TIMESTAMP(6)", types.TIMESTAMP, None, GenericDataType.TEMPORAL, True), + ("TIME", types.Time, None, GenericDataType.TEMPORAL, True), + ("TIME(6)", types.Time, None, GenericDataType.TEMPORAL, True), + ("DATETIME", types.DateTime, None, GenericDataType.TEMPORAL, True), + ("DATETIME(6)", types.DateTime, None, GenericDataType.TEMPORAL, True), + ], +) +def test_get_column_spec( + native_type: str, + sqla_type: type[types.TypeEngine], + attrs: dict[str, Any] | None, + generic_type: GenericDataType, + is_dttm: bool, +) -> None: + assert_column_spec( + SingleStoreSpec, native_type, sqla_type, attrs, generic_type, is_dttm + ) + + +def test_get_function_names_no_db() -> None: + fake_inspector = MagicMock() + fake_inspector.default_schema_name = None + + mock_inspector_ctx = MagicMock() + mock_inspector_ctx.__enter__.return_value = fake_inspector + mock_inspector_ctx.__exit__.return_value = None + + mock_database = MagicMock() + mock_database.get_inspector.return_value = mock_inspector_ctx + + assert len(SingleStoreSpec.get_function_names(mock_database)) == 289 + + +def test_get_function_names_with_db() -> None: + mock_inspector = MagicMock() + mock_inspector.default_schema_name = "db`1" + + mock_inspector_ctx = MagicMock() + mock_inspector_ctx.__enter__.return_value = mock_inspector + mock_inspector_ctx.__exit__.return_value = None + + mock_database = MagicMock() + mock_database.get_inspector.return_value = mock_inspector_ctx + + data = [ + { + "Functions_in_db`1": "is_prime", + "Function Type": "User Defined Function", + "Definer": "admin", + "Data Format": "", + "Runtime Type": "PSQL", + "Link": "", + } + ] + + df = pd.DataFrame(data) + mock_database.get_df.return_value = df + + functions = SingleStoreSpec.get_function_names(mock_database) + assert len(functions) == 290 + assert "is_prime" in functions + + mock_database.get_df.assert_called_once_with("SHOW FUNCTIONS IN `db``1`") diff --git a/tests/unit_tests/models/helpers_test.py b/tests/unit_tests/models/helpers_test.py index 4e8aa493be3..d0dfef43b86 100644 --- a/tests/unit_tests/models/helpers_test.py +++ b/tests/unit_tests/models/helpers_test.py @@ -209,4 +209,4 @@ def test_values_for_column_double_percents( called_conn = pd.read_sql_query.call_args.kwargs["con"] assert called_sql.compare(expected_sql) is True - assert called_conn == engine + assert called_conn.engine == engine