feat(UserInfo): Migrate User Info FAB to React (#33620)

This commit is contained in:
Enzo Martellucci
2025-06-03 19:24:22 +02:00
committed by GitHub
parent cacf1e06d6
commit 20519158d2
16 changed files with 727 additions and 9 deletions

View File

@@ -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';

View File

@@ -68,6 +68,7 @@ import {
FileOutlined,
FileTextOutlined,
FireOutlined,
FormOutlined,
FullscreenExitOutlined,
FullscreenOutlined,
FundProjectionScreenOutlined,
@@ -172,6 +173,7 @@ const AntdIcons = {
FileOutlined,
FileTextOutlined,
FireOutlined,
FormOutlined,
FullscreenExitOutlined,
FullscreenOutlined,
FundProjectionScreenOutlined,

View File

@@ -23,7 +23,7 @@ import { useState, useCallback } from 'react';
import { t } from '@superset-ui/core';
export interface FormModalProps extends ModalProps {
initialValues: Object;
initialValues?: Object;
formSubmitHandler: (values: Object) => Promise<void>;
onSave: () => void;
requiredFields: string[];

View File

@@ -0,0 +1,150 @@
/**
* 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 { 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 { User } from 'src/types/bootstrapTypes';
import { BaseUserListModalProps, FormValues } from '../users/types';
export interface UserInfoModalProps extends BaseUserListModalProps {
isEditMode?: boolean;
user?: User;
}
function UserInfoModal({
show,
onHide,
onSave,
isEditMode,
user,
}: UserInfoModalProps) {
const { addDangerToast, addSuccessToast } = useToasts();
const requiredFields = isEditMode
? ['first_name', 'last_name']
: ['password', 'confirm_password'];
const initialValues = isEditMode
? {
first_name: user?.firstName,
last_name: user?.lastName,
}
: {};
const handleFormSubmit = async (values: FormValues) => {
try {
const { confirm_password, ...payload } = values;
await SupersetClient.put({
endpoint: `/api/v1/me/`,
jsonPayload: { ...payload },
});
addSuccessToast(
isEditMode
? t('The user was updated successfully')
: t('The password reset was successfull'),
);
onSave();
} catch (error) {
addDangerToast(t('Something went wrong while saving the user info'));
}
};
const EditModeFields = () => (
<>
<FormItem
name="first_name"
label={t('First name')}
rules={[{ required: true, message: t('First name is required') }]}
>
<Input
name="first_name"
placeholder={t("Enter the user's first name")}
/>
</FormItem>
<FormItem
name="last_name"
label={t('Last name')}
rules={[{ required: true, message: t('Last name is required') }]}
>
<Input name="last_name" placeholder={t("Enter the user's last name")} />
</FormItem>
</>
);
const ResetPasswordFields = () => (
<>
<FormItem
name="password"
label={t('Password')}
rules={[{ required: true, message: t('Password is required') }]}
>
<Input.Password
name="password"
placeholder="Enter the user's password"
/>
</FormItem>
<FormItem
name="confirm_password"
label={t('Confirm Password')}
dependencies={['password']}
rules={[
{
required: true,
message: t('Please confirm your password'),
},
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('password') === value) {
return Promise.resolve();
}
return Promise.reject(new Error(t('Passwords do not match!')));
},
}),
]}
>
<Input.Password
name="confirm_password"
placeholder={t("Confirm the user's password")}
/>
</FormItem>
</>
);
return (
<FormModal
show={show}
onHide={onHide}
title={isEditMode ? t('Edit user') : t('Reset password')}
onSave={onSave}
formSubmitHandler={handleFormSubmit}
requiredFields={requiredFields}
initialValues={initialValues}
>
{isEditMode ? <EditModeFields /> : <ResetPasswordFields />}
</FormModal>
);
}
export const UserInfoResetPasswordModal = (
props: Omit<UserInfoModalProps, 'isEditMode' | 'user'>,
) => <UserInfoModal {...props} isEditMode={false} />;
export const UserInfoEditModal = (
props: Omit<UserInfoModalProps, 'isEditMode'> & { user: User },
) => <UserInfoModal {...props} isEditMode />;

View File

@@ -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(
<MemoryRouter>
<QueryParamProvider>
<UserInfo user={mockUser} />
</QueryParamProvider>
</MemoryRouter>,
{ 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);
});
});

View File

@@ -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: (
<>
<Icons.LockOutlined
iconColor={theme.colors.primary.base}
iconSize="m"
css={css`
margin: auto ${theme.gridUnit * 2}px auto 0;
vertical-align: text-top;
`}
/>
{t('Reset my password')}
</>
),
buttonStyle: 'secondary',
onClick: () => {
openModal(ModalType.ResetPassword);
},
'data-test': 'reset-password-button',
},
{
name: (
<>
<Icons.FormOutlined
iconColor={theme.colors.primary.light5}
iconSize="m"
css={css`
margin: auto ${theme.gridUnit * 2}px auto 0;
vertical-align: text-top;
`}
/>
{t('Edit user')}
</>
),
buttonStyle: 'primary',
onClick: () => {
openModal(ModalType.Edit);
},
'data-test': 'edit-user-button',
},
];
return (
<StyledLayout>
<StyledHeader>Your user information</StyledHeader>
<DescriptionsContainer>
<Collapse defaultActiveKey={['userInfo', 'personalInfo']} ghost>
<Collapse.Panel
header={<DescriptionTitle>User info</DescriptionTitle>}
key="userInfo"
>
<Descriptions
bordered
size="small"
column={1}
labelStyle={{ width: '120px' }}
>
<Descriptions.Item label="User Name">
{user.username}
</Descriptions.Item>
<Descriptions.Item label="Is Active?">
{user.isActive ? 'Yes' : 'No'}
</Descriptions.Item>
<Descriptions.Item label="Role">
{user.roles ? Object.keys(user.roles).join(', ') : 'None'}
</Descriptions.Item>
<Descriptions.Item label="Login count">
{user.loginCount}
</Descriptions.Item>
</Descriptions>
</Collapse.Panel>
<Collapse.Panel
header={<DescriptionTitle>Personal info</DescriptionTitle>}
key="personalInfo"
>
<Descriptions
bordered
size="small"
column={1}
labelStyle={{ width: '120px' }}
>
<Descriptions.Item label="First Name">
{userDetails.firstName}
</Descriptions.Item>
<Descriptions.Item label="Last Name">
{userDetails.lastName}
</Descriptions.Item>
<Descriptions.Item label="Email">{user.email}</Descriptions.Item>
</Descriptions>
</Collapse.Panel>
</Collapse>
</DescriptionsContainer>
{modalState.resetPassword && (
<UserInfoResetPasswordModal
onHide={() => closeModal(ModalType.ResetPassword)}
show={modalState.resetPassword}
onSave={() => {
closeModal(ModalType.ResetPassword);
}}
/>
)}
{modalState.edit && (
<UserInfoEditModal
onHide={() => closeModal(ModalType.Edit)}
show={modalState.edit}
onSave={() => {
closeModal(ModalType.Edit);
getUserDetails();
}}
user={userDetails}
/>
)}
<SubMenu buttons={SubMenuButtons} />
</StyledLayout>
);
}
export default UserInfo;

View File

@@ -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<string, [string, string][]>;

View File

@@ -137,6 +137,10 @@ const RolesList = lazy(
const UsersList: LazyExoticComponent<any> = lazy(
() => import(/* webpackChunkName: "UsersList" */ 'src/pages/UsersList'),
);
const UserInfo = lazy(
() => import(/* webpackChunkName: "UserInfo" */ 'src/pages/UserInfo'),
);
const ActionLogList: LazyExoticComponent<any> = lazy(
() => import(/* webpackChunkName: "ActionLogList" */ 'src/pages/ActionLog'),
);
@@ -246,6 +250,7 @@ export const routes: Routes = [
path: '/sqllab/',
Component: SqlLab,
},
{ path: '/user_info/', Component: UserInfo },
{
path: '/actionlog/list',
Component: ActionLogList,

View File

@@ -185,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
@@ -348,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

View File

@@ -282,9 +282,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,

View File

@@ -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()

View File

@@ -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"""

View File

@@ -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},
)

View File

@@ -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:

View File

@@ -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

View File

@@ -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):