chore: Migrate /superset/recent_activity/<user_id>/ to /api/v1/ (#22789)

This commit is contained in:
Jack Fragassi 2023-01-25 01:26:50 -08:00 committed by GitHub
parent 6f0fed77cf
commit 050cbe938f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 519 additions and 126 deletions

View File

@ -18,9 +18,10 @@
*/
import React from 'react';
import moment from 'moment';
import rison from 'rison';
import TableLoader from '../../components/TableLoader';
import { Activity } from '../types';
import { ActivityResult } from '../types';
import { BootstrapUser } from '../../types/bootstrapTypes';
interface RecentActivityProps {
@ -29,8 +30,8 @@ interface RecentActivityProps {
export default function RecentActivity({ user }: RecentActivityProps) {
const rowLimit = 50;
const mutator = function (data: Activity[]) {
return data
const mutator = function (data: ActivityResult) {
return data.result
.filter(row => row.action === 'dashboard' || row.action === 'explore')
.map(row => ({
name: <a href={row.item_url}>{row.item_title}</a>,
@ -39,13 +40,14 @@ export default function RecentActivity({ user }: RecentActivityProps) {
_time: row.time,
}));
};
const params = rison.encode({ page_size: rowLimit });
return (
<div>
<TableLoader
className="table-condensed"
mutator={mutator}
sortable
dataEndpoint={`/superset/recent_activity/${user?.userId}/?limit=${rowLimit}`}
dataEndpoint={`/api/v1/log/recent_activity/${user?.userId}/?q=${params}`}
/>
</div>
);

View File

@ -32,3 +32,7 @@ export type Activity = {
item_url: string;
time: number;
};
export type ActivityResult = {
result: Activity[];
};

View File

@ -197,7 +197,7 @@ export const getRecentAcitivtyObjs = (
return Promise.all(newBatch)
.then(([chartRes, dashboardRes]) => {
res.other = [...chartRes.json.result, ...dashboardRes.json.result];
res.viewed = recentsRes.json;
res.viewed = recentsRes.json.result;
return res;
})
.catch(errMsg =>

View File

@ -38,7 +38,7 @@ import EmptyState from './EmptyState';
import { WelcomeTable } from './types';
/**
* Return result from /superset/recent_activity/{user_id}
* Return result from /api/v1/log/recent_activity/{user_id}/
*/
interface RecentActivity {
action: string;

View File

@ -43,7 +43,7 @@ const dashboardFavoriteStatusEndpoint =
'glob:*/api/v1/dashboard/favorite_status?*';
const savedQueryEndpoint = 'glob:*/api/v1/saved_query/?*';
const savedQueryInfoEndpoint = 'glob:*/api/v1/saved_query/_info?*';
const recentActivityEndpoint = 'glob:*/superset/recent_activity/*';
const recentActivityEndpoint = 'glob:*/api/v1/log/recent_activity/*';
fetchMock.get(chartsEndpoint, {
result: [
@ -142,7 +142,7 @@ describe('Welcome with sql role', () => {
it('calls api methods in parallel on page load', () => {
const chartCall = fetchMock.calls(/chart\/\?q/);
const savedQueryCall = fetchMock.calls(/saved_query\/\?q/);
const recentCall = fetchMock.calls(/superset\/recent_activity\/*/);
const recentCall = fetchMock.calls(/api\/v1\/log\/recent_activity\/*/);
const dashboardCall = fetchMock.calls(/dashboard\/\?q/);
expect(chartCall).toHaveLength(2);
expect(recentCall).toHaveLength(1);
@ -186,7 +186,7 @@ describe('Welcome without sql role', () => {
it('calls api methods in parallel on page load', () => {
const chartCall = fetchMock.calls(/chart\/\?q/);
const savedQueryCall = fetchMock.calls(/saved_query\/\?q/);
const recentCall = fetchMock.calls(/superset\/recent_activity\/*/);
const recentCall = fetchMock.calls(/api\/v1\/log\/recent_activity\/*/);
const dashboardCall = fetchMock.calls(/dashboard\/\?q/);
expect(chartCall).toHaveLength(2);
expect(recentCall).toHaveLength(1);

View File

@ -23,6 +23,7 @@ import {
styled,
t,
} from '@superset-ui/core';
import rison from 'rison';
import Collapse from 'src/components/Collapse';
import { User } from 'src/types/bootstrapTypes';
import { reject } from 'lodash';
@ -165,7 +166,8 @@ function Welcome({ user, addDangerToast }: WelcomeProps) {
const canAccessSqlLab = canUserAccessSqlLab(user);
const userid = user.userId;
const id = userid!.toString(); // confident that user is not a guest user
const recent = `/superset/recent_activity/${user.userId}/?limit=6`;
const params = rison.encode({ page_size: 6 });
const recent = `/api/v1/log/recent_activity/${user.userId}/?q=${params}`;
const [activeChild, setActiveChild] = useState('Loading');
const userKey = dangerouslyGetItemDoNotUse(id, null);
let defaultChecked = false;

View File

@ -20,7 +20,7 @@ import json
import logging
from collections import defaultdict
from functools import partial
from typing import Any, Callable, Dict, List, Set, Tuple, Type, Union
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Type, Union
import sqlalchemy as sqla
from flask_appbuilder import Model
@ -177,6 +177,11 @@ class Dashboard(Model, AuditMixinNullable, ImportExportMixin):
def url(self) -> str:
return f"/superset/dashboard/{self.slug or self.id}/"
@staticmethod
def get_url(id_: int, slug: Optional[str] = None) -> str:
# To be able to generate URL's without instanciating a Dashboard object
return f"/superset/dashboard/{slug or id_}/"
@property
def datasources(self) -> Set[BaseDatasource]:
# Verbose but efficient database enumeration of dashboard datasources.

View File

@ -284,12 +284,18 @@ class Slice( # pylint: disable=too-many-public-methods
self,
base_url: str = "/explore",
overrides: Optional[Dict[str, Any]] = None,
) -> str:
return self.build_explore_url(self.id, base_url, overrides)
@staticmethod
def build_explore_url(
id_: int, base_url: str = "/explore", overrides: Optional[Dict[str, Any]] = None
) -> str:
overrides = overrides or {}
form_data = {"slice_id": self.id}
form_data = {"slice_id": id_}
form_data.update(overrides)
params = parse.quote(json.dumps(form_data))
return f"{base_url}/?slice_id={self.id}&form_data={params}"
return f"{base_url}/?slice_id={id_}&form_data={params}"
@property
def slice_url(self) -> str:

View File

@ -20,12 +20,11 @@ from __future__ import annotations
import logging
import re
from contextlib import closing
from datetime import datetime, timedelta
from datetime import datetime
from typing import Any, Callable, cast, Dict, List, Optional, Union
from urllib import parse
import backoff
import humanize
import pandas as pd
import simplejson as json
from flask import abort, flash, g, redirect, render_template, request, Response
@ -41,7 +40,6 @@ from flask_babel import gettext as __, lazy_gettext as _
from sqlalchemy import and_, or_
from sqlalchemy.exc import DBAPIError, NoSuchModuleError, SQLAlchemyError
from sqlalchemy.orm.session import Session
from sqlalchemy.sql import functions as func
from superset import (
app,
@ -98,7 +96,7 @@ from superset.explore.permalink.commands.get import GetExplorePermalinkCommand
from superset.explore.permalink.exceptions import ExplorePermalinkGetFailedError
from superset.extensions import async_query_manager, cache_manager
from superset.jinja_context import get_template_processor
from superset.models.core import Database, FavStar, Log
from superset.models.core import Database, FavStar
from superset.models.dashboard import Dashboard
from superset.models.datasource_access_request import DatasourceAccessRequest
from superset.models.slice import Slice
@ -155,6 +153,7 @@ from superset.views.base import (
json_success,
validate_sqlatable,
)
from superset.views.log.dao import LogDAO
from superset.views.sql_lab.schemas import SqlJsonPayloadSchema
from superset.views.utils import (
_deserialize_results_payload,
@ -1438,9 +1437,8 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
@has_access_api
@event_logger.log_this
@expose("/recent_activity/<int:user_id>/", methods=["GET"])
def recent_activity( # pylint: disable=too-many-locals
self, user_id: int
) -> FlaskResponse:
@deprecated()
def recent_activity(self, user_id: int) -> FlaskResponse:
"""Recent activity (actions) for a given user"""
error_obj = self.get_user_activity_access_error(user_id)
if error_obj:
@ -1452,96 +1450,8 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
# whether to get distinct subjects
distinct = request.args.get("distinct") != "false"
has_subject_title = or_(
and_(
Dashboard.dashboard_title is not None,
Dashboard.dashboard_title != "",
),
and_(Slice.slice_name is not None, Slice.slice_name != ""),
)
payload = LogDAO.get_recent_activity(user_id, actions, distinct, 0, limit)
if distinct:
one_year_ago = datetime.today() - timedelta(days=365)
subqry = (
db.session.query(
Log.dashboard_id,
Log.slice_id,
Log.action,
func.max(Log.dttm).label("dttm"),
)
.group_by(Log.dashboard_id, Log.slice_id, Log.action)
.filter(
and_(
Log.action.in_(actions),
Log.user_id == user_id,
# limit to one year of data to improve performance
Log.dttm > one_year_ago,
or_(Log.dashboard_id.isnot(None), Log.slice_id.isnot(None)),
)
)
.subquery()
)
qry = (
db.session.query(
subqry,
Dashboard.slug.label("dashboard_slug"),
Dashboard.dashboard_title,
Slice.slice_name,
)
.outerjoin(Dashboard, Dashboard.id == subqry.c.dashboard_id)
.outerjoin(
Slice,
Slice.id == subqry.c.slice_id,
)
.filter(has_subject_title)
.order_by(subqry.c.dttm.desc())
.limit(limit)
)
else:
qry = (
db.session.query(
Log.dttm,
Log.action,
Log.dashboard_id,
Log.slice_id,
Dashboard.slug.label("dashboard_slug"),
Dashboard.dashboard_title,
Slice.slice_name,
)
.outerjoin(Dashboard, Dashboard.id == Log.dashboard_id)
.outerjoin(Slice, Slice.id == Log.slice_id)
.filter(has_subject_title)
.order_by(Log.dttm.desc())
.limit(limit)
)
payload = []
for log in qry.all():
item_url = None
item_title = None
item_type = None
if log.dashboard_id:
item_type = "dashboard"
item_url = Dashboard(id=log.dashboard_id, slug=log.dashboard_slug).url
item_title = log.dashboard_title
elif log.slice_id:
slc = Slice(id=log.slice_id, slice_name=log.slice_name)
item_type = "slice"
item_url = slc.slice_url
item_title = slc.chart
payload.append(
{
"action": log.action,
"item_type": item_type,
"item_url": item_url,
"item_title": item_title,
"time": log.dttm,
"time_delta_humanized": humanize.naturaltime(
datetime.now() - log.dttm
),
}
)
return json_success(json.dumps(payload, default=utils.json_int_dttm_ser))
@api

View File

@ -14,12 +14,24 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from typing import Any, Optional
from flask import current_app as app
from flask_appbuilder.api import expose, protect, rison, safe
from flask_appbuilder.hooks import before_request
from flask_appbuilder.models.sqla.interface import SQLAInterface
import superset.models.core as models
from superset.views.base_api import BaseSupersetModelRestApi
from superset import event_logger, security_manager
from superset.exceptions import SupersetSecurityException
from superset.superset_typing import FlaskResponse
from superset.views.base_api import BaseSupersetModelRestApi, statsd_metrics
from superset.views.log.dao import LogDAO
from superset.views.log.schemas import (
get_recent_activity_schema,
RecentActivityResponseSchema,
RecentActivitySchema,
)
from ...constants import MODEL_API_RW_METHOD_PERMISSION_MAP
from . import LogMixin
@ -27,7 +39,7 @@ from . import LogMixin
class LogRestApi(LogMixin, BaseSupersetModelRestApi):
datamodel = SQLAInterface(models.Log)
include_route_methods = {"get_list", "get", "post"}
include_route_methods = {"get_list", "get", "post", "recent_activity"}
class_permission_name = "Log"
method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
resource_name = "log"
@ -44,13 +56,86 @@ class LogRestApi(LogMixin, BaseSupersetModelRestApi):
"referrer",
]
show_columns = list_columns
page_size = 20
apispec_parameter_schemas = {
"get_recent_activity_schema": get_recent_activity_schema,
}
openapi_spec_component_schemas = (
RecentActivityResponseSchema,
RecentActivitySchema,
)
@staticmethod
def is_enabled() -> bool:
return app.config["FAB_ADD_SECURITY_VIEWS"] and app.config["SUPERSET_LOG_VIEW"]
@before_request
@before_request(only=["get_list", "get", "post"])
def ensure_enabled(self) -> None:
if not self.is_enabled():
return self.response_404()
return None
def get_user_activity_access_error(self, user_id: int) -> Optional[FlaskResponse]:
try:
security_manager.raise_for_user_activity_access(user_id)
except SupersetSecurityException as ex:
return self.response(403, message=ex.message)
return None
@expose("/recent_activity/<int:user_id>/", methods=["GET"])
@protect()
@safe
@statsd_metrics
@rison(get_recent_activity_schema)
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}"
f".recent_activity",
log_to_statsd=False,
)
def recent_activity(self, user_id: int, **kwargs: Any) -> FlaskResponse:
"""Get recent activity data for a user
---
get:
summary: Get recent activity data for a user
parameters:
- in: path
schema:
type: integer
name: user_id
description: The id of the user
- in: query
name: q
content:
application/json:
schema:
$ref: '#/components/schemas/get_recent_activity_schema'
responses:
200:
description: A List of recent activity objects
content:
application/json:
schema:
$ref: "#/components/schemas/RecentActivityResponseSchema"
400:
$ref: '#/components/responses/400'
401:
$ref: '#/components/responses/401'
403:
$ref: '#/components/responses/403'
500:
$ref: '#/components/responses/500'
"""
error_obj = self.get_user_activity_access_error(user_id)
if error_obj:
return error_obj
args = kwargs["rison"]
page, page_size = self._sanitize_page_args(*self._handle_page_args(args))
actions = args.get("actions", ["explore", "dashboard"])
distinct = args.get("distinct", True)
payload = LogDAO.get_recent_activity(
user_id, actions, distinct, page, page_size
)
return self.response(200, result=payload)

131
superset/views/log/dao.py Normal file
View File

@ -0,0 +1,131 @@
# 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, timedelta
from typing import Any, Dict, List
import humanize
from sqlalchemy import and_, or_
from sqlalchemy.sql import functions as func
from superset import db
from superset.dao.base import BaseDAO
from superset.models.core import Log
from superset.models.dashboard import Dashboard
from superset.models.slice import Slice
from superset.utils.dates import datetime_to_epoch
class LogDAO(BaseDAO):
model_cls = Log
@staticmethod
def get_recent_activity(
user_id: int, actions: List[str], distinct: bool, page: int, page_size: int
) -> List[Dict[str, Any]]:
has_subject_title = or_(
and_(
Dashboard.dashboard_title is not None,
Dashboard.dashboard_title != "",
),
and_(Slice.slice_name is not None, Slice.slice_name != ""),
)
if distinct:
one_year_ago = datetime.today() - timedelta(days=365)
subqry = (
db.session.query(
Log.dashboard_id,
Log.slice_id,
Log.action,
func.max(Log.dttm).label("dttm"),
)
.group_by(Log.dashboard_id, Log.slice_id, Log.action)
.filter(
and_(
Log.action.in_(actions),
Log.user_id == user_id,
# limit to one year of data to improve performance
Log.dttm > one_year_ago,
or_(Log.dashboard_id.isnot(None), Log.slice_id.isnot(None)),
)
)
.subquery()
)
qry = (
db.session.query(
subqry,
Dashboard.slug.label("dashboard_slug"),
Dashboard.dashboard_title,
Slice.slice_name,
)
.outerjoin(Dashboard, Dashboard.id == subqry.c.dashboard_id)
.outerjoin(
Slice,
Slice.id == subqry.c.slice_id,
)
.filter(has_subject_title)
.order_by(subqry.c.dttm.desc())
.limit(page_size)
.offset(page * page_size)
)
else:
qry = (
db.session.query(
Log.dttm,
Log.action,
Log.dashboard_id,
Log.slice_id,
Dashboard.slug.label("dashboard_slug"),
Dashboard.dashboard_title,
Slice.slice_name,
)
.outerjoin(Dashboard, Dashboard.id == Log.dashboard_id)
.outerjoin(Slice, Slice.id == Log.slice_id)
.filter(has_subject_title)
.filter(Log.action.in_(actions), Log.user_id == user_id)
.order_by(Log.dttm.desc())
.limit(page_size)
.offset(page * page_size)
)
payload = []
for log in qry.all():
item_url = None
item_title = None
item_type = None
if log.dashboard_id:
item_type = "dashboard"
item_url = Dashboard.get_url(log.dashboard_id, log.dashboard_slug)
item_title = log.dashboard_title
elif log.slice_id:
item_type = "slice"
item_url = Slice.build_explore_url(log.slice_id)
item_title = log.slice_name or "<empty>"
payload.append(
{
"action": log.action,
"item_type": item_type,
"item_url": item_url,
"item_title": item_title,
"time": datetime_to_epoch(log.dttm),
"time_delta_humanized": humanize.naturaltime(
datetime.utcnow() - log.dttm
),
}
)
return payload

View File

@ -0,0 +1,45 @@
# 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 marshmallow import fields, Schema
get_recent_activity_schema = {
"type": "object",
"properties": {
"page": {"type": "number"},
"page_size": {"type": "number"},
"actions": {"type": "array", "items": {"type": "string"}},
"distinct": {"type": "boolean"},
},
}
class RecentActivitySchema(Schema):
action = fields.String(description="Action taken describing type of activity")
item_type = fields.String(description="Type of item, e.g. slice or dashboard")
item_url = fields.String(description="URL to item")
item_title = fields.String(description="Title of item")
time = fields.Float(description="Time of activity, in epoch milliseconds")
time_delta_humanized = fields.String(
description="Human-readable description of how long ago activity took place"
)
class RecentActivityResponseSchema(Schema):
result = fields.List(
fields.Nested(RecentActivitySchema),
description="A list of recent activity objects",
)

View File

@ -487,13 +487,18 @@ class TestDashboard(SupersetTestCase):
hidden_dash.slices = []
hidden_dash.owners = []
db.session.merge(dash)
db.session.merge(hidden_dash)
db.session.add(dash)
db.session.add(hidden_dash)
db.session.commit()
self.login(user.username)
resp = self.get_resp("/api/v1/dashboard/")
db.session.delete(dash)
db.session.delete(hidden_dash)
db.session.commit()
self.assertIn(f"/superset/dashboard/{my_dash_slug}/", resp)
self.assertNotIn(f"/superset/dashboard/{not_my_dash_slug}/", resp)
@ -510,8 +515,8 @@ class TestDashboard(SupersetTestCase):
regular_dash.dashboard_title = "A Plain Ol Dashboard"
regular_dash.slug = regular_dash_slug
db.session.merge(favorite_dash)
db.session.merge(regular_dash)
db.session.add(favorite_dash)
db.session.add(regular_dash)
db.session.commit()
dash = db.session.query(Dashboard).filter_by(slug=fav_dash_slug).first()
@ -521,12 +526,18 @@ class TestDashboard(SupersetTestCase):
favorites.class_name = "Dashboard"
favorites.user_id = user.id
db.session.merge(favorites)
db.session.add(favorites)
db.session.commit()
self.login(user.username)
resp = self.get_resp("/api/v1/dashboard/")
db.session.delete(favorites)
db.session.delete(regular_dash)
db.session.delete(favorite_dash)
db.session.commit()
self.assertIn(f"/superset/dashboard/{fav_dash_slug}/", resp)
def test_user_can_not_view_unpublished_dash(self):
@ -541,12 +552,16 @@ class TestDashboard(SupersetTestCase):
dash.owners = [admin_user]
dash.slices = []
dash.published = False
db.session.merge(dash)
db.session.add(dash)
db.session.commit()
# list dashboards as a gamma user
self.login(gamma_user.username)
resp = self.get_resp("/api/v1/dashboard/")
db.session.delete(dash)
db.session.commit()
self.assertNotIn(f"/superset/dashboard/{slug}/", resp)

View File

@ -80,8 +80,9 @@ def create_dashboard(
slug: str, title: str, position: str, slices: List[Slice]
) -> Dashboard:
dash = db.session.query(Dashboard).filter_by(slug=slug).one_or_none()
if not dash:
dash = Dashboard()
if dash:
return dash
dash = Dashboard()
dash.dashboard_title = title
if position is not None:
js = position
@ -90,7 +91,7 @@ def create_dashboard(
dash.slug = slug
if slices is not None:
dash.slices = slices
db.session.merge(dash)
db.session.add(dash)
db.session.commit()
return dash

View File

@ -756,7 +756,7 @@ class TestDashboardApi(SupersetTestCase, ApiOwnersTestCaseMixin, InsertChartMixi
rv = self.get_assert_metric(uri, "get_list")
self.assertEqual(rv.status_code, 200)
data = json.loads(rv.data.decode("utf-8"))
self.assertEqual(data["count"], 5)
self.assertEqual(data["count"], 0)
@pytest.mark.usefixtures("create_created_by_gamma_dashboards")
def test_get_dashboards_created_by_me(self):

View File

@ -137,8 +137,8 @@ class TestDashboardDatasetSecurity(DashboardTestCase):
regular_dash.dashboard_title = "A Plain Ol Dashboard"
regular_dash.slug = regular_dash_slug
db.session.merge(favorite_dash)
db.session.merge(regular_dash)
db.session.add(favorite_dash)
db.session.add(regular_dash)
db.session.commit()
dash = db.session.query(Dashboard).filter_by(slug=fav_dash_slug).first()
@ -148,7 +148,7 @@ class TestDashboardDatasetSecurity(DashboardTestCase):
favorites.class_name = "Dashboard"
favorites.user_id = user.id
db.session.merge(favorites)
db.session.add(favorites)
db.session.commit()
self.login(user.username)
@ -156,6 +156,12 @@ class TestDashboardDatasetSecurity(DashboardTestCase):
# act
get_dashboards_response = self.get_resp(DASHBOARDS_API_URL)
# cleanup
db.session.delete(favorites)
db.session.delete(favorite_dash)
db.session.delete(regular_dash)
db.session.commit()
# assert
self.assertIn(f"/superset/dashboard/{fav_dash_slug}/", get_dashboards_response)

View File

@ -16,16 +16,20 @@
# under the License.
# isort:skip_file
"""Unit tests for Superset"""
from datetime import datetime, timedelta
import json
from typing import Optional
from unittest.mock import ANY
import prison
from flask_appbuilder.security.sqla.models import User
import prison
from unittest.mock import patch
from superset import db
from superset.models.core import Log
from superset.views.log.api import LogRestApi
from tests.integration_tests.dashboard_utils import create_dashboard
from tests.integration_tests.test_app import app
from .base_tests import SupersetTestCase
@ -106,6 +110,8 @@ class TestLogApi(SupersetTestCase):
self.login(username="alpha")
rv = self.client.get(uri)
self.assertEqual(rv.status_code, 403)
db.session.delete(log)
db.session.commit()
def test_get_item(self):
"""
@ -152,3 +158,178 @@ class TestLogApi(SupersetTestCase):
self.assertEqual(rv.status_code, 405)
db.session.delete(log)
db.session.commit()
def test_get_recent_activity_no_broad_access(self):
"""
Log API: Test recent activity not visible for other users without
ENABLE_BROAD_ACTIVITY_ACCESS flag on
"""
admin_user = self.get_user("admin")
self.login(username="admin")
app.config["ENABLE_BROAD_ACTIVITY_ACCESS"] = False
uri = f"api/v1/log/recent_activity/{admin_user.id + 1}/"
rv = self.client.get(uri)
self.assertEqual(rv.status_code, 403)
app.config["ENABLE_BROAD_ACTIVITY_ACCESS"] = True
def test_get_recent_activity(self):
"""
Log API: Test recent activity endpoint
"""
admin_user = self.get_user("admin")
self.login(username="admin")
dash = create_dashboard("dash_slug", "dash_title", "{}", [])
log1 = self.insert_log("dashboard", admin_user, dashboard_id=dash.id)
log2 = self.insert_log("dashboard", admin_user, dashboard_id=dash.id)
uri = f"api/v1/log/recent_activity/{admin_user.id}/"
rv = self.client.get(uri)
self.assertEqual(rv.status_code, 200)
response = json.loads(rv.data.decode("utf-8"))
db.session.delete(log1)
db.session.delete(log2)
db.session.delete(dash)
db.session.commit()
self.assertEqual(
response,
{
"result": [
{
"action": "dashboard",
"item_type": "dashboard",
"item_url": "/superset/dashboard/dash_slug/",
"item_title": "dash_title",
"time": ANY,
"time_delta_humanized": ANY,
}
]
},
)
def test_get_recent_activity_actions_filter(self):
"""
Log API: Test recent activity actions argument
"""
admin_user = self.get_user("admin")
self.login(username="admin")
dash = create_dashboard("dash_slug", "dash_title", "{}", [])
log = self.insert_log("dashboard", admin_user, dashboard_id=dash.id)
log2 = self.insert_log("explore", admin_user, dashboard_id=dash.id)
arguments = {"actions": ["dashboard"]}
uri = f"api/v1/log/recent_activity/{admin_user.id}/?q={prison.dumps(arguments)}"
rv = self.client.get(uri)
db.session.delete(log)
db.session.delete(log2)
db.session.delete(dash)
db.session.commit()
self.assertEqual(rv.status_code, 200)
response = json.loads(rv.data.decode("utf-8"))
self.assertEqual(len(response["result"]), 1)
def test_get_recent_activity_distinct_false(self):
"""
Log API: Test recent activity when distinct is false
"""
db.session.query(Log).delete(synchronize_session=False)
db.session.commit()
admin_user = self.get_user("admin")
self.login(username="admin")
dash = create_dashboard("dash_slug", "dash_title", "{}", [])
log = self.insert_log("dashboard", admin_user, dashboard_id=dash.id)
log2 = self.insert_log("dashboard", admin_user, dashboard_id=dash.id)
arguments = {"distinct": False}
uri = f"api/v1/log/recent_activity/{admin_user.id}/?q={prison.dumps(arguments)}"
rv = self.client.get(uri)
db.session.delete(log)
db.session.delete(log2)
db.session.delete(dash)
db.session.commit()
self.assertEqual(rv.status_code, 200)
response = json.loads(rv.data.decode("utf-8"))
self.assertEqual(len(response["result"]), 2)
def test_get_recent_activity_pagination(self):
"""
Log API: Test recent activity pagination arguments
"""
admin_user = self.get_user("admin")
self.login(username="admin")
dash = create_dashboard("dash_slug", "dash_title", "{}", [])
dash2 = create_dashboard("dash2_slug", "dash2_title", "{}", [])
dash3 = create_dashboard("dash3_slug", "dash3_title", "{}", [])
log = self.insert_log("dashboard", admin_user, dashboard_id=dash.id)
log2 = self.insert_log("dashboard", admin_user, dashboard_id=dash2.id)
log3 = self.insert_log("dashboard", admin_user, dashboard_id=dash3.id)
now = datetime.now()
log3.dttm = now
log2.dttm = now - timedelta(days=1)
log.dttm = now - timedelta(days=2)
arguments = {"page": 0, "page_size": 2}
uri = f"api/v1/log/recent_activity/{admin_user.id}/?q={prison.dumps(arguments)}"
rv = self.client.get(uri)
self.assertEqual(rv.status_code, 200)
response = json.loads(rv.data.decode("utf-8"))
self.assertEqual(
response,
{
"result": [
{
"action": "dashboard",
"item_type": "dashboard",
"item_url": "/superset/dashboard/dash3_slug/",
"item_title": "dash3_title",
"time": ANY,
"time_delta_humanized": ANY,
},
{
"action": "dashboard",
"item_type": "dashboard",
"item_url": "/superset/dashboard/dash2_slug/",
"item_title": "dash2_title",
"time": ANY,
"time_delta_humanized": ANY,
},
]
},
)
arguments = {"page": 1, "page_size": 2}
uri = f"api/v1/log/recent_activity/{admin_user.id}/?q={prison.dumps(arguments)}"
rv = self.client.get(uri)
db.session.delete(log)
db.session.delete(log2)
db.session.delete(log3)
db.session.delete(dash)
db.session.delete(dash2)
db.session.delete(dash3)
db.session.commit()
self.assertEqual(rv.status_code, 200)
response = json.loads(rv.data.decode("utf-8"))
self.assertEqual(
response,
{
"result": [
{
"action": "dashboard",
"item_type": "dashboard",
"item_url": "/superset/dashboard/dash_slug/",
"item_title": "dash_title",
"time": ANY,
"time_delta_humanized": ANY,
}
]
},
)