chore: Migrate /superset/user_slices and /superset/fave_slices to API v1 (#22964)

Co-authored-by: hughhhh <hughmil3s@gmail.com>
This commit is contained in:
Diego Medina 2023-04-03 14:29:02 -03:00 committed by GitHub
parent 13ffb4b7c2
commit cdc7af11bf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 208 additions and 43 deletions

View File

@ -1783,12 +1783,21 @@
"changed_on_delta_humanized": { "changed_on_delta_humanized": {
"readOnly": true "readOnly": true
}, },
"changed_on_dttm": {
"readOnly": true
},
"changed_on_utc": { "changed_on_utc": {
"readOnly": true "readOnly": true
}, },
"created_by": { "created_by": {
"$ref": "#/components/schemas/ChartDataRestApi.get_list.User2" "$ref": "#/components/schemas/ChartDataRestApi.get_list.User2"
}, },
"created_by_name": {
"readOnly": true
},
"created_by_url": {
"readOnly": true
},
"created_on_delta_humanized": { "created_on_delta_humanized": {
"readOnly": true "readOnly": true
}, },
@ -1821,6 +1830,9 @@
"edit_url": { "edit_url": {
"readOnly": true "readOnly": true
}, },
"form_data": {
"readOnly": true
},
"id": { "id": {
"format": "int32", "format": "int32",
"type": "integer" "type": "integer"
@ -1848,6 +1860,9 @@
"nullable": true, "nullable": true,
"type": "string" "type": "string"
}, },
"slice_url": {
"readOnly": true
},
"table": { "table": {
"$ref": "#/components/schemas/ChartDataRestApi.get_list.SqlaTable" "$ref": "#/components/schemas/ChartDataRestApi.get_list.SqlaTable"
}, },
@ -2576,12 +2591,21 @@
"changed_on_delta_humanized": { "changed_on_delta_humanized": {
"readOnly": true "readOnly": true
}, },
"changed_on_dttm": {
"readOnly": true
},
"changed_on_utc": { "changed_on_utc": {
"readOnly": true "readOnly": true
}, },
"created_by": { "created_by": {
"$ref": "#/components/schemas/ChartRestApi.get_list.User2" "$ref": "#/components/schemas/ChartRestApi.get_list.User2"
}, },
"created_by_name": {
"readOnly": true
},
"created_by_url": {
"readOnly": true
},
"created_on_delta_humanized": { "created_on_delta_humanized": {
"readOnly": true "readOnly": true
}, },
@ -2614,6 +2638,9 @@
"edit_url": { "edit_url": {
"readOnly": true "readOnly": true
}, },
"form_data": {
"readOnly": true
},
"id": { "id": {
"format": "int32", "format": "int32",
"type": "integer" "type": "integer"
@ -2641,6 +2668,9 @@
"nullable": true, "nullable": true,
"type": "string" "type": "string"
}, },
"slice_url": {
"readOnly": true
},
"table": { "table": {
"$ref": "#/components/schemas/ChartRestApi.get_list.SqlaTable" "$ref": "#/components/schemas/ChartRestApi.get_list.SqlaTable"
}, },

View File

@ -17,6 +17,7 @@
* under the License. * under the License.
*/ */
import React from 'react'; import React from 'react';
import rison from 'rison';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { CompactPicker } from 'react-color'; import { CompactPicker } from 'react-color';
import Button from 'src/components/Button'; import Button from 'src/components/Button';
@ -315,34 +316,45 @@ class AnnotationLayer extends React.PureComponent {
}); });
}); });
} else if (requiresQuery(sourceType)) { } else if (requiresQuery(sourceType)) {
SupersetClient.get({ endpoint: '/superset/user_slices' }).then( const queryParams = rison.encode({
({ json }) => { filters: [
const registry = getChartMetadataRegistry(); {
this.setState({ col: 'id',
isLoadingOptions: false, opr: 'chart_owned_created_favored_by_me',
valueOptions: json value: true,
.filter(x => { },
const metadata = registry.get(x.viz_type); ],
return ( order_column: 'slice_name',
metadata && metadata.canBeAnnotationType(annotationType) order_direction: 'asc',
); page: 0,
}) page_size: 25,
.map(x => ({ });
value: x.id, SupersetClient.get({
label: x.title, endpoint: `/api/v1/chart/?q=${queryParams}`,
slice: { }).then(({ json }) => {
...x, const registry = getChartMetadataRegistry();
data: { this.setState({
...x.data, isLoadingOptions: false,
groupby: x.data.groupby?.map(column => valueOptions: json.result
getColumnLabel(column), .filter(x => {
), const metadata = registry.get(x.viz_type);
}, return metadata && metadata.canBeAnnotationType(annotationType);
})
.map(x => ({
value: x.id,
label: x.slice_name,
slice: {
...x,
data: {
...x.form_data,
groupby: x.form_data.groupby?.map(column =>
getColumnLabel(column),
),
}, },
})), },
}); })),
}, });
); });
} else { } else {
this.setState({ this.setState({
isLoadingOptions: false, isLoadingOptions: false,

View File

@ -40,9 +40,11 @@ beforeAll(() => {
result: [{ label: 'Chart A', value: 'a' }], result: [{ label: 'Chart A', value: 'a' }],
}); });
fetchMock.get('glob:*/superset/user_slices*', [ fetchMock.get('glob:*/api/v1/chart/*', {
{ id: 'a', title: 'Chart A', viz_type: 'table', data: {} }, result: [
]); { id: 'a', slice_name: 'Chart A', viz_type: 'table', form_data: {} },
],
});
setupColors(); setupColors();

View File

@ -22,7 +22,7 @@ import moment from 'moment';
import { t } from '@superset-ui/core'; import { t } from '@superset-ui/core';
import { DashboardResponse, BootstrapUser } from 'src/types/bootstrapTypes'; import { DashboardResponse, BootstrapUser } from 'src/types/bootstrapTypes';
import TableLoader from '../../components/TableLoader'; import TableLoader from '../../components/TableLoader';
import { Slice } from '../types'; import { Chart } from '../types';
interface FavoritesProps { interface FavoritesProps {
user: BootstrapUser; user: BootstrapUser;
@ -30,16 +30,31 @@ interface FavoritesProps {
export default class Favorites extends React.PureComponent<FavoritesProps> { export default class Favorites extends React.PureComponent<FavoritesProps> {
renderSliceTable() { renderSliceTable() {
const mutator = (data: Slice[]) => const mutator = (payload: { result: Chart[] }) =>
data.map(slice => ({ payload.result.map(slice => ({
slice: <a href={slice.url}>{slice.title}</a>, slice: <a href={slice.slice_url}>{slice.slice_name}</a>,
creator: <a href={slice.creator_url}>{slice.creator}</a>, creator: <a href={slice.created_by_url}>{slice.created_by_name}</a>,
favorited: moment.utc(slice.dttm).fromNow(), favorited: moment.utc(slice.changed_on_dttm).fromNow(),
_favorited: slice.dttm, _favorited: slice.changed_on_dttm,
})); }));
const query = rison.encode({
filters: [
{
col: 'id',
opr: 'chart_is_favorite',
value: true,
},
],
order_column: 'slice_name',
order_direction: 'asc',
page: 0,
page_size: 25,
});
return ( return (
<TableLoader <TableLoader
dataEndpoint={`/superset/fave_slices/${this.props.user?.userId}/`} dataEndpoint={`/api/v1/chart/?q=${query}`}
className="table-condensed" className="table-condensed"
columns={['slice', 'creator', 'favorited']} columns={['slice', 'creator', 'favorited']}
mutator={mutator} mutator={mutator}

View File

@ -26,6 +26,15 @@ export type Slice = {
viz_type: string; viz_type: string;
}; };
export type Chart = {
id: number;
slice_name: string;
slice_url: string;
created_by_name?: string;
created_by_url?: string;
changed_on_dttm: number;
};
export type Activity = { export type Activity = {
action: string; action: string;
item_title: string; item_title: string;

View File

@ -55,6 +55,7 @@ from superset.charts.filters import (
ChartFavoriteFilter, ChartFavoriteFilter,
ChartFilter, ChartFilter,
ChartHasCreatedByFilter, ChartHasCreatedByFilter,
ChartOwnedCreatedFavoredByMeFilter,
ChartTagFilter, ChartTagFilter,
) )
from superset.charts.schemas import ( from superset.charts.schemas import (
@ -158,10 +159,13 @@ class ChartRestApi(BaseSupersetModelRestApi):
"changed_by_name", "changed_by_name",
"changed_by_url", "changed_by_url",
"changed_on_delta_humanized", "changed_on_delta_humanized",
"changed_on_dttm",
"changed_on_utc", "changed_on_utc",
"created_by.first_name", "created_by.first_name",
"created_by.id", "created_by.id",
"created_by.last_name", "created_by.last_name",
"created_by_name",
"created_by_url",
"created_on_delta_humanized", "created_on_delta_humanized",
"datasource_id", "datasource_id",
"datasource_name_text", "datasource_name_text",
@ -170,6 +174,7 @@ class ChartRestApi(BaseSupersetModelRestApi):
"description", "description",
"description_markeddown", "description_markeddown",
"edit_url", "edit_url",
"form_data",
"id", "id",
"last_saved_at", "last_saved_at",
"last_saved_by.id", "last_saved_by.id",
@ -183,6 +188,7 @@ class ChartRestApi(BaseSupersetModelRestApi):
"dashboards.dashboard_title", "dashboards.dashboard_title",
"params", "params",
"slice_name", "slice_name",
"slice_url",
"table.default_endpoint", "table.default_endpoint",
"table.table_name", "table.table_name",
"thumbnail_url", "thumbnail_url",
@ -224,7 +230,11 @@ class ChartRestApi(BaseSupersetModelRestApi):
base_order = ("changed_on", "desc") base_order = ("changed_on", "desc")
base_filters = [["id", ChartFilter, lambda: []]] base_filters = [["id", ChartFilter, lambda: []]]
search_filters = { search_filters = {
"id": [ChartFavoriteFilter, ChartCertifiedFilter], "id": [
ChartFavoriteFilter,
ChartCertifiedFilter,
ChartOwnedCreatedFavoredByMeFilter,
],
"slice_name": [ChartAllTextFilter], "slice_name": [ChartAllTextFilter],
"created_by": [ChartHasCreatedByFilter, ChartCreatedByMeFilter], "created_by": [ChartHasCreatedByFilter, ChartCreatedByMeFilter],
} }

View File

@ -21,9 +21,10 @@ from sqlalchemy import and_, or_
from sqlalchemy.orm import aliased from sqlalchemy.orm import aliased
from sqlalchemy.orm.query import Query from sqlalchemy.orm.query import Query
from superset import security_manager from superset import db, security_manager
from superset.connectors.sqla import models from superset.connectors.sqla import models
from superset.connectors.sqla.models import SqlaTable from superset.connectors.sqla.models import SqlaTable
from superset.models.core import FavStar
from superset.models.slice import Slice from superset.models.slice import Slice
from superset.utils.core import get_user_id from superset.utils.core import get_user_id
from superset.utils.filters import get_dataset_access_filters from superset.utils.filters import get_dataset_access_filters
@ -127,3 +128,43 @@ class ChartCreatedByMeFilter(BaseFilter): # pylint: disable=too-few-public-meth
== get_user_id(), == get_user_id(),
) )
) )
class ChartOwnedCreatedFavoredByMeFilter(
BaseFilter
): # pylint: disable=too-few-public-methods
"""
Custom filter for the GET chart that filters all charts the user
owns, created, changed or favored.
"""
name = _("Owned Created or Favored")
arg_name = "chart_owned_created_favored_by_me"
def apply(self, query: Query, value: Any) -> Query:
# If anonymous user filter nothing
if security_manager.current_user is None:
return query
owner_ids_query = (
db.session.query(Slice.id)
.join(Slice.owners)
.filter(security_manager.user_model.id == get_user_id())
)
return query.join(
FavStar,
and_(
FavStar.user_id == get_user_id(),
FavStar.class_name == "slice",
Slice.id == FavStar.obj_id,
),
isouter=True,
).filter( # pylint: disable=comparison-with-callable
or_(
Slice.id.in_(owner_ids_query),
Slice.created_by_fk == get_user_id(),
Slice.changed_by_fk == get_user_id(),
FavStar.user_id == get_user_id(),
)
)

View File

@ -147,6 +147,7 @@ MODEL_API_RW_METHOD_PERMISSION_MAP = {
"delete_ssh_tunnel": "write", "delete_ssh_tunnel": "write",
"get_updated_since": "read", "get_updated_since": "read",
"stop_query": "read", "stop_query": "read",
"get_user_slices": "read",
"schemas_access_for_file_upload": "read", "schemas_access_for_file_upload": "read",
"get_objects": "read", "get_objects": "read",
"get_all_objects": "read", "get_all_objects": "read",

View File

@ -89,6 +89,7 @@ from superset.superset_typing import (
) )
from superset.utils import core as utils from superset.utils import core as utils
from superset.utils.core import get_user_id from superset.utils.core import get_user_id
from superset.utils.dates import datetime_to_epoch
if TYPE_CHECKING: if TYPE_CHECKING:
from superset.connectors.sqla.models import SqlMetric, TableColumn from superset.connectors.sqla.models import SqlMetric, TableColumn
@ -492,6 +493,12 @@ class AuditMixinNullable(AuditMixin):
nullable=True, nullable=True,
) )
@property
def created_by_name(self) -> str:
if self.created_by:
return escape("{}".format(self.created_by))
return ""
@property @property
def changed_by_name(self) -> str: def changed_by_name(self) -> str:
if self.changed_by: if self.changed_by:
@ -514,6 +521,10 @@ class AuditMixinNullable(AuditMixin):
def changed_on_delta_humanized(self) -> str: def changed_on_delta_humanized(self) -> str:
return self.changed_on_humanized return self.changed_on_humanized
@renders("changed_on")
def changed_on_dttm(self) -> float:
return datetime_to_epoch(self.changed_on)
@renders("created_on") @renders("created_on")
def created_on_delta_humanized(self) -> str: def created_on_delta_humanized(self) -> str:
return self.created_on_humanized return self.created_on_humanized

View File

@ -332,6 +332,12 @@ class Slice( # pylint: disable=too-many-public-methods
name = escape(self.chart) name = escape(self.chart)
return Markup(f'<a href="{self.url}">{name}</a>') return Markup(f'<a href="{self.url}">{name}</a>')
@property
def created_by_url(self) -> str:
if not self.created_by:
return ""
return f"/superset/profile/{self.created_by.username}"
@property @property
def changed_by_url(self) -> str: def changed_by_url(self) -> str:
return f"/superset/profile/{self.changed_by.username}" # type: ignore return f"/superset/profile/{self.changed_by.username}" # type: ignore

View File

@ -1559,6 +1559,7 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
@event_logger.log_this @event_logger.log_this
@expose("/user_slices", methods=["GET"]) @expose("/user_slices", methods=["GET"])
@expose("/user_slices/<int:user_id>/", methods=["GET"]) @expose("/user_slices/<int:user_id>/", methods=["GET"])
@deprecated()
def user_slices(self, user_id: Optional[int] = None) -> FlaskResponse: def user_slices(self, user_id: Optional[int] = None) -> FlaskResponse:
"""List of slices a user owns, created, modified or faved""" """List of slices a user owns, created, modified or faved"""
if not user_id: if not user_id:
@ -1644,6 +1645,7 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
@event_logger.log_this @event_logger.log_this
@expose("/fave_slices", methods=["GET"]) @expose("/fave_slices", methods=["GET"])
@expose("/fave_slices/<int:user_id>/", methods=["GET"]) @expose("/fave_slices/<int:user_id>/", methods=["GET"])
@deprecated()
def fave_slices(self, user_id: Optional[int] = None) -> FlaskResponse: def fave_slices(self, user_id: Optional[int] = None) -> FlaskResponse:
"""Favorite slices for a user""" """Favorite slices for a user"""
if user_id is None: if user_id is None:

View File

@ -28,7 +28,7 @@ from sqlalchemy import and_
from sqlalchemy.sql import func from sqlalchemy.sql import func
from superset.connectors.sqla.models import SqlaTable from superset.connectors.sqla.models import SqlaTable
from superset.extensions import cache_manager, db from superset.extensions import cache_manager, db, security_manager
from superset.models.core import Database, FavStar, FavStarClassName from superset.models.core import Database, FavStar, FavStarClassName
from superset.models.dashboard import Dashboard from superset.models.dashboard import Dashboard
from superset.reports.models import ReportSchedule, ReportScheduleType from superset.reports.models import ReportSchedule, ReportScheduleType
@ -1600,3 +1600,29 @@ class TestChartApi(SupersetTestCase, ApiOwnersTestCaseMixin, InsertChartMixin):
self.assertEqual(rv.status_code, 200) self.assertEqual(rv.status_code, 200)
data = json.loads(rv.data.decode("utf-8")) data = json.loads(rv.data.decode("utf-8"))
self.assertEqual(data["count"], 8) self.assertEqual(data["count"], 8)
@pytest.mark.usefixtures("create_charts")
def test_gets_owned_created_favorited_by_me_filter(self):
"""
Chart API: Test ChartOwnedCreatedFavoredByMeFilter
"""
self.login(username="admin")
arguments = {
"filters": [
{
"col": "id",
"opr": "chart_owned_created_favored_by_me",
"value": True,
}
],
"order_column": "slice_name",
"order_direction": "asc",
"page": 0,
"page_size": 25,
}
rv = self.client.get(f"api/v1/chart/?q={prison.dumps(arguments)}")
self.assertEqual(rv.status_code, 200)
data = json.loads(rv.data.decode("utf-8"))
assert data["result"][0]["slice_name"] == "name0"
assert data["result"][0]["datasource_id"] == 1

View File

@ -377,7 +377,7 @@ class TestChartsUpdateCommand(SupersetTestCase):
def test_query_context_update_command(self, mock_sm_g, mock_g): def test_query_context_update_command(self, mock_sm_g, mock_g):
""" """
Test that a user can generate the chart query context Test that a user can generate the chart query context
payloadwithout affecting owners payload without affecting owners
""" """
chart = db.session.query(Slice).all()[0] chart = db.session.query(Slice).all()[0]
pk = chart.id pk = chart.id

View File

@ -37,8 +37,8 @@ def session_with_data(session: Session) -> Iterator[Session]:
datasource_name="tmp_perm_table", datasource_name="tmp_perm_table",
slice_name="slice_name", slice_name="slice_name",
) )
session.add(slice_obj) session.add(slice_obj)
session.commit() session.commit()
yield session yield session
session.rollback() session.rollback()