From 8795ab4c2e1ba64baf90ca82c47aad6cc7dc0298 Mon Sep 17 00:00:00 2001 From: Elizabeth Thompson Date: Thu, 24 Aug 2023 17:26:42 -0700 Subject: [PATCH] initial commit --- package-lock.json | 139 ++++++++++++ package.json | 5 + .../components/SliceHeaderControls/index.tsx | 200 +++++++++++++++--- .../dashboard/containers/DashboardPage.tsx | 33 +++ superset-frontend/src/views/index.tsx | 33 ++- superset/cord_auth_token/api.py | 80 +++++++ superset/cord_auth_token/commands/create.py | 161 ++++++++++++++ superset/cord_auth_token/models.py | 29 +++ superset/daos/message_thread.py | 83 ++++++++ superset/initialization/__init__.py | 4 + superset/message_threads/api.py | 84 ++++++++ .../message_threads/commands/get_or_create.py | 65 ++++++ superset/message_threads/models.py | 22 ++ ...08-25_16-27_6dadc1a73f3a_create_threads.py | 56 +++++ 14 files changed, 966 insertions(+), 28 deletions(-) create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 superset/cord_auth_token/api.py create mode 100644 superset/cord_auth_token/commands/create.py create mode 100644 superset/cord_auth_token/models.py create mode 100644 superset/daos/message_thread.py create mode 100644 superset/message_threads/api.py create mode 100644 superset/message_threads/commands/get_or_create.py create mode 100644 superset/message_threads/models.py create mode 100644 superset/migrations/versions/2023-08-25_16-27_6dadc1a73f3a_create_threads.py diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000000000..9bfecfbda2d09 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,139 @@ +{ + "name": "superset", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "dependencies": { + "@cord-sdk/react": "^1.7.0" + } + }, + "node_modules/@cord-sdk/components": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@cord-sdk/components/-/components-1.7.0.tgz", + "integrity": "sha512-fqOG2CVl1Erb69RiJvvwqmqcmSravuFUfnjqgJ+JvWlWjP7Hb4mcYlMd5rNoNx6S5/olrYGpuIcxYuVIT8QUdg==", + "dependencies": { + "@cord-sdk/types": "1.7.0" + } + }, + "node_modules/@cord-sdk/react": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@cord-sdk/react/-/react-1.7.0.tgz", + "integrity": "sha512-xlqOY1rplA4kHfmN7GDyy0hL/WuA10k7P4/83wlrMUo0D+vOXXSyMRvql0PTdsVgHtT6dLTPt1lpZLwNOyYM8w==", + "dependencies": { + "@cord-sdk/components": "1.7.0", + "@cord-sdk/types": "1.7.0", + "classnames": "^2.3.1", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": ">=17.0.0" + } + }, + "node_modules/@cord-sdk/types": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@cord-sdk/types/-/types-1.7.0.tgz", + "integrity": "sha512-q4IiAx9IGynewjsZVg8fcQIDwk87sXReskarhg4vFuL+3n8urlTNuKFt9HtsI5MDD4FBr1aiu+vjs6g109lbMQ==" + }, + "node_modules/classnames": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz", + "integrity": "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "peer": true + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "peer": true, + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/react": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", + "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + } + }, + "dependencies": { + "@cord-sdk/components": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@cord-sdk/components/-/components-1.7.0.tgz", + "integrity": "sha512-fqOG2CVl1Erb69RiJvvwqmqcmSravuFUfnjqgJ+JvWlWjP7Hb4mcYlMd5rNoNx6S5/olrYGpuIcxYuVIT8QUdg==", + "requires": { + "@cord-sdk/types": "1.7.0" + } + }, + "@cord-sdk/react": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@cord-sdk/react/-/react-1.7.0.tgz", + "integrity": "sha512-xlqOY1rplA4kHfmN7GDyy0hL/WuA10k7P4/83wlrMUo0D+vOXXSyMRvql0PTdsVgHtT6dLTPt1lpZLwNOyYM8w==", + "requires": { + "@cord-sdk/components": "1.7.0", + "@cord-sdk/types": "1.7.0", + "classnames": "^2.3.1", + "lodash": "^4.17.21" + } + }, + "@cord-sdk/types": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@cord-sdk/types/-/types-1.7.0.tgz", + "integrity": "sha512-q4IiAx9IGynewjsZVg8fcQIDwk87sXReskarhg4vFuL+3n8urlTNuKFt9HtsI5MDD4FBr1aiu+vjs6g109lbMQ==" + }, + "classnames": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz", + "integrity": "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==" + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "peer": true + }, + "lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "peer": true, + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + }, + "react": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", + "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "peer": true, + "requires": { + "loose-envify": "^1.1.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000000000..c4565ee521e30 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "@cord-sdk/react": "^1.7.0" + } +} diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx index 287d83692f9d2..10dc8b407c06f 100644 --- a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx +++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx @@ -22,6 +22,7 @@ import React, { ReactChild, useState, useCallback, + useEffect, } from 'react'; import { Link, @@ -40,6 +41,8 @@ import { styled, t, useTheme, + SupersetClient, + logging, } from '@superset-ui/core'; import { useSelector } from 'react-redux'; import { Menu } from 'src/components/Menu'; @@ -58,6 +61,7 @@ import { DrillDetailMenuItems } from 'src/components/Chart/DrillDetail'; import { LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE } from 'src/logger/LogUtils'; import { RootState } from 'src/dashboard/types'; import { useCrossFiltersScopingModal } from '../nativeFilters/FilterBar/CrossFilters/ScopingModal/useCrossFiltersScopingModal'; +import { Thread, ThreadList } from '@cord-sdk/react'; const MENU_KEYS = { DOWNLOAD_AS_IMAGE: 'download_as_image', @@ -169,6 +173,53 @@ const dropdownIconsStyles = css` } `; +// eslint-disable-next-line theme-colors/no-literal-colors +const cordStyles = () => css` + position: relative; + .flex { + flex-direction: row; + display: flex; + } + + cord-page-presence { + display: flex; + justify-content: flex-end; + .cord-facepile { + margin-right: 40px; + } + .cord-avatar-container { + width: 36px; + height: 36px; + border-radius: 50%; + } + } + cord-thread:not(.new-thread) { + position: absolute; + background-color: #ffffff; + right: 6px; + z-index: 1000; + border: 1px solid #ccc; + top: 15px; + &[collapsed='true'] { + display: none; + } + .cord-collapsed-thread { + height: 0; + } + } + cord-thread.new-thread { + margin: 0; + border: none; + } + cord-thread .cord-send-button { + background-color: #ee6611; + } + + cord-thread .cord-send-button:hover { + background-color: #ff7722; + } +`; + const ViewResultsModalTrigger = ({ exploreUrl, triggerNode, @@ -373,6 +424,29 @@ const SliceHeaderControls = (props: SliceHeaderControlsPropsWithRouter) => { ? t('Exit fullscreen') : t('Enter fullscreen'); + const [threadListId, setthreadListId] = useState(null); + const [threadIsOpen, setThreadIsOpen] = useState(false); + const [selectedThreadID, setSelectedThreadID] = useState(null); + const [showNewThread, setShowNewThread] = useState(false); + + useEffect(() => { + // fetch or get the thread id + SupersetClient.post({ + endpoint: `/api/v1/message_threads`, + jsonPayload: { + chart_id: props.slice.slice_id, + dashboard_id: props.dashboardId, + }, + }) + .then(r => { + setthreadListId(r.json?.result?.thread_id); + }) + .catch(err => { + console.error(err); + return null; + }); + }, []); + const menu = ( { ); + const buttonHeight = 30; + const threadListHeight = 400; + const threadHeight = threadListHeight - buttonHeight; + const width = 300; + return ( - <> - {isFullSize && ( - { - props.handleToggleFullSize(); +
+
+ {isFullSize && ( + { + props.handleToggleFullSize(); + }} + /> + )} + setThreadIsOpen(!threadIsOpen)} /> + + + + + + {canEditCrossFilters && scopingModal} +
+ {threadListId && ( +
- )} - - - - - - {canEditCrossFilters && scopingModal} - + {!selectedThreadID && ( + + )} +
+ + {selectedThreadID && ( + + )} +
+ { + setSelectedThreadID(threadID); + }} + location={{ threadListId }} + style={{ + display: selectedThreadID ? 'none' : 'block', + minHeight: '50px', + width, + }} + /> +
+ {/* This is a new thread */} + { + if (messageCount) { + setShowNewThread(false); + } + }} + /> +
+
+ )} +
); }; diff --git a/superset-frontend/src/dashboard/containers/DashboardPage.tsx b/superset-frontend/src/dashboard/containers/DashboardPage.tsx index aef0fb3b6e7c2..18678bfbfaa0a 100644 --- a/superset-frontend/src/dashboard/containers/DashboardPage.tsx +++ b/superset-frontend/src/dashboard/containers/DashboardPage.tsx @@ -19,6 +19,7 @@ import React, { FC, useEffect, useMemo, useRef } from 'react'; import { Global } from '@emotion/react'; import { useHistory } from 'react-router-dom'; +import { PagePresence, Thread } from '@cord-sdk/react'; import { CategoricalColorNamespace, FeatureFlag, @@ -27,6 +28,7 @@ import { SharedLabelColorSource, t, useTheme, + css, } from '@superset-ui/core'; import pick from 'lodash/pick'; import { useDispatch, useSelector } from 'react-redux'; @@ -67,6 +69,35 @@ import { export const DashboardPageIdContext = React.createContext(''); +// eslint-disable-next-line theme-colors/no-literal-colors +const cordStyles = () => css` + cord-page-presence { + display: flex; + justify-content: flex-end; + padding: 8px 0; + .cord-facepile { + margin-right: 40px; + } + .cord-avatar-container { + width: 36px; + height: 36px; + border-radius: 50%; + } + } + cord-thread { + width: 25%; + margin-left: 75%; + margin-top: 20px; + } + cord-thread .cord-send-button { + background-color: #ee6611; + } + + cord-thread .cord-send-button:hover { + background-color: #ff7722; + } +`; + setupPlugins(); const DashboardContainer = React.lazy( () => @@ -293,8 +324,10 @@ export const DashboardPage: FC = ({ idOrSlug }: PageProps) => { filterCardPopoverStyle(theme), headerStyles(theme), chartContextMenuStyles(theme), + cordStyles(), ]} /> + diff --git a/superset-frontend/src/views/index.tsx b/superset-frontend/src/views/index.tsx index c257009e64fd5..272cf5787b77b 100644 --- a/superset-frontend/src/views/index.tsx +++ b/superset-frontend/src/views/index.tsx @@ -16,8 +16,37 @@ * specific language governing permissions and limitations * under the License. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import ReactDOM from 'react-dom'; +import { CordProvider } from '@cord-sdk/react'; import App from './App'; +import { makeApi } from '@superset-ui/core'; -ReactDOM.render(, document.getElementById('app')); +function CordApp() { + const [cordToken, setCordToken] = useState(undefined); + + useEffect(() => { + async function fetchToken() { + try { + await makeApi({ + method: 'POST', + endpoint: `/api/v1/cord_token`, + })({}).then(json => { + setCordToken(json.response.auth_token); + }); + } catch (error) { + console.log('Something went wrong!: ', error); + } + } + fetchToken(); + }, []); + // this needs to be synchronous, but there's probably a better way to do this + // we can't load the provider without the token, but we don't necessarily just want a blank page either + return cordToken ? ( + + + + ) : null; +} + +ReactDOM.render(, document.getElementById('app')); diff --git a/superset/cord_auth_token/api.py b/superset/cord_auth_token/api.py new file mode 100644 index 0000000000000..f9a3586927424 --- /dev/null +++ b/superset/cord_auth_token/api.py @@ -0,0 +1,80 @@ +# 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 + +from flask import request, Response +from flask_appbuilder.api import expose, protect, rison, safe +from flask_appbuilder.models.sqla.interface import SQLAInterface + +from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod +from superset.cord_auth_token.commands.create import CreateCordAuthTokenCommand +from superset.cord_auth_token.models import CordAuthToken +from superset.extensions import event_logger +from superset.views.base_api import ( + BaseSupersetModelRestApi, + RelatedFieldFilter, + statsd_metrics, +) +from superset.views.filters import BaseFilterRelatedUsers, FilterRelatedOwners + +logger = logging.getLogger(__name__) + + +# This api would live in manager. It will get the workspace name from shell +class CordTokenRestApi(BaseSupersetModelRestApi): + datamodel = SQLAInterface(CordAuthToken) # this model does not persist in db + include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET + + resource_name = "cord_token" + allow_browser_login = True + + class_permission_name = "CordToken" + method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP + + list_columns = [ + "id", + "user_id", + "auth_token", + ] + + list_select_columns = list_columns + add_columns = list_columns + + show_columns = [ + "id", + "user_id", + "auth_token", + ] + + # in manager it will be something like /api/v1/cord_token/ + @expose("/", methods=("POST",)) + @protect() + @safe + @statsd_metrics + @event_logger.log_this_with_context( + action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.add_token", + log_to_statsd=False, + ) + def post(self) -> Response: + try: + cord_auth_token = CreateCordAuthTokenCommand().run() + return self.response(201, response={"auth_token": cord_auth_token}) + except KeyError: + return self.response( + 400, + message="Missing required field", + ) diff --git a/superset/cord_auth_token/commands/create.py b/superset/cord_auth_token/commands/create.py new file mode 100644 index 0000000000000..8a585dc039620 --- /dev/null +++ b/superset/cord_auth_token/commands/create.py @@ -0,0 +1,161 @@ +# 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 json +import logging +from datetime import datetime, timedelta, timezone +from typing import Union + +import jwt +from flask import g + +from superset.commands.base import BaseCommand, CreateMixin +from superset.daos.exceptions import DAOCreateFailedError + +logger = logging.getLogger(__name__) + +CORD_ENDPOINT = "https://api.cord.com/v1/" + +from enum import Enum + + +class status(Enum): + ACTIVE = "active" + DELETED = "deleted" + + +class platformUserVariables: + def __init__( + self, + email: str, + name: str = None, + status: status = None, + profile_picture_url: str = None, + first_name: str = None, + last_name: str = None, + ): + self.email = email + self.name = name + self.status = status + self.profile_picture_url = profile_picture_url + self.first_name = first_name + self.last_name = last_name + + +class platformOrganizationVariables: + def __init__(self, name: str, status: status = None, members: list[str] = None): + self.name = name + self.status = status + self.members = members + + +class clientPlatformUserVariables: + def __init__( + self, + id: str, + email: str, + name: str = None, + status: status = None, + profile_picture_url: str = None, + first_name: str = None, + last_name: str = None, + ): + self.id = id + self.email = email + self.name = name + self.status = status + self.profile_picture_url = profile_picture_url + self.first_name = first_name + self.last_name = last_name + + +class clientPlatformOrganizationVariables: + def __init__( + self, id: str, name: str, status: status = None, members: list[str] = None + ): + self.id = id + self.name = name + self.status = status + self.members = members + + +def toJson(obj): + return json.dumps( + obj, + default=lambda o: {key: value for key, value in o.__dict__.items() if value}, + indent=4, + allow_nan=False, + ) + + +# payload: { +# # The user ID can be any identifier that makes sense to your application. +# # As long as it's unique per-user, Cord can use it to represent your user. +# user_id: 'severusatreides', + +# # Same as above. An organization ID can be any unique string. Organizations +# # are groups of users. +# organization_id: 'starpotterdunewars', + +# # By supplying the `user_details` object, you can create the user in +# # Cord's backend on-the-fly. No need to pre-sync your users. +# user_details: { +# email: 'sevvy@arrakis.spice', +# name: 'Severus Atreides', +# }, + + +# # By supplying the `organization_details` object, just like the user, +# # Cord will create the organization on-the-fly. +# organization_details: { +# name: "starpotterdunewars", +# }, +# } +def get_client_auth_token( + app_id: str, secret: str, payload: dict[str, Union[str, dict]] +) -> str: + return jwt.encode( + payload={ + "app_id": app_id, + "exp": datetime.now(tz=timezone.utc) + timedelta(minutes=1), + "iat": datetime.now(tz=timezone.utc), + **payload, + }, + key=secret, + algorithm="HS512", + ) + + +class CreateCordAuthTokenCommand(BaseCommand): + def run(self) -> Union[str, None]: + # self.validate() + try: + # this is a singleton command. It will return + # the existing auth_token if it exists + return get_client_auth_token( + app_id="", + secret="", + payload={ + "user_id": g.user.email, + "organization_id": "superset", + }, + ) + except DAOCreateFailedError as ex: + logger.exception(ex.exception) + # raise TagCreateFailedError() from ex + + def validate(self) -> None: + return super().validate() diff --git a/superset/cord_auth_token/models.py b/superset/cord_auth_token/models.py new file mode 100644 index 0000000000000..b38fe8b6387e2 --- /dev/null +++ b/superset/cord_auth_token/models.py @@ -0,0 +1,29 @@ +import time +import uuid + +from flask_appbuilder import Model +from sqlalchemy import Column, ForeignKey, Integer, String + + +# This table should live in manager. It will get the workspace name from shell +class CordAuthToken(Model): + __tablename__ = "cord_auth_token" + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey("ab_user.id"), nullable=False) + workspace_name = Column(String(255), nullable=False) + auth_token = Column(String(255), nullable=False) + user_uuid = Column(String(255), nullable=False, default=uuid.uuid4) + expires_at = Column(Integer, nullable=False) + + def __init__(self, user_id, workspace_name, auth_token, expires_at, user_uuid): + self.user_id = user_id + self.workspace_name = workspace_name + self.user_uuid = user_uuid + self.auth_token = auth_token + self.expires_at = expires_at + + def __repr__(self): + return f"" + + def is_expired(self): + return self.expires_at < int(time.time()) diff --git a/superset/daos/message_thread.py b/superset/daos/message_thread.py new file mode 100644 index 0000000000000..c94f44e59d470 --- /dev/null +++ b/superset/daos/message_thread.py @@ -0,0 +1,83 @@ +# 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 +from typing import Union + +from flask import g +from sqlalchemy.exc import SQLAlchemyError + +from superset.daos.base import BaseDAO +from superset.daos.exceptions import DAOCreateFailedError, DAODeleteFailedError +from superset.exceptions import MissingUserContextException +from superset.extensions import db +from superset.message_threads.models import MessageThread +from superset.models.dashboard import Dashboard +from superset.models.slice import Slice +from superset.models.sql_lab import SavedQuery +from superset.tags.commands.exceptions import TagNotFoundError +from superset.utils.core import get_user_id + +logger = logging.getLogger(__name__) + + +class MessageThreadDAO(BaseDAO[MessageThread]): + # base_filter = TagAccessFilter + + @staticmethod + def get_or_create( + workspace_name, chart_id, dashboard_id + ) -> Union[MessageThread, None]: + """ + Gets or creates a Cord Auth Token object + """ + + # check that the user can access the chart and dashboard + slice = db.session.query(Slice).filter(Slice.id == chart_id).first() + dataset = slice.table + dataset.raise_for_access() + + # not sure if we also need this check + dashboard = ( + db.session.query(Dashboard).filter(Dashboard.id == dashboard_id).first() + ) + dashboard.raise_for_access() + message_thread = ( + db.session.query(MessageThread) + .filter( + MessageThread.workspace_name == workspace_name, + MessageThread.chart_id == chart_id, + MessageThread.dashboard_id == dashboard_id, + ) + .one_or_none() + ) + logger.info("message_thread: %s", message_thread) + if not message_thread: + # Create new thread + + message_thread = MessageThread( + workspace_name=workspace_name, + dashboard_id=dashboard_id, + chart_id=chart_id, + ) + logger.info("new message_thread: %s", message_thread.uuid) + try: + db.session.add(message_thread) + db.session.commit() + except SQLAlchemyError as ex: + pass + + return message_thread diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py index f5473ba25e84c..e641639e7db25 100644 --- a/superset/initialization/__init__.py +++ b/superset/initialization/__init__.py @@ -131,6 +131,7 @@ def init_views(self) -> None: TableColumnInlineView, TableModelView, ) + from superset.cord_auth_token.api import CordTokenRestApi from superset.css_templates.api import CssTemplateRestApi from superset.dashboards.api import DashboardRestApi from superset.dashboards.filter_sets.api import FilterSetRestApi @@ -147,6 +148,7 @@ def init_views(self) -> None: from superset.explore.form_data.api import ExploreFormDataRestApi from superset.explore.permalink.api import ExplorePermalinkRestApi from superset.importexport.api import ImportExportRestApi + from superset.message_threads.api import MessageThreadRestApi from superset.queries.api import QueryRestApi from superset.queries.saved_queries.api import SavedQueryRestApi from superset.reports.api import ReportScheduleRestApi @@ -226,6 +228,8 @@ def init_views(self) -> None: appbuilder.add_api(RLSRestApi) appbuilder.add_api(SavedQueryRestApi) appbuilder.add_api(TagRestApi) + appbuilder.add_api(MessageThreadRestApi) + appbuilder.add_api(CordTokenRestApi) appbuilder.add_api(SqlLabRestApi) # # Setup regular views diff --git a/superset/message_threads/api.py b/superset/message_threads/api.py new file mode 100644 index 0000000000000..c6906990b3b8d --- /dev/null +++ b/superset/message_threads/api.py @@ -0,0 +1,84 @@ +# 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 + +from flask import request, Response +from flask_appbuilder.api import expose, protect, rison, safe +from flask_appbuilder.models.sqla.interface import SQLAInterface + +from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod +from superset.extensions import event_logger +from superset.message_threads.commands.get_or_create import GetOrCreateThreadCommand +from superset.message_threads.models import MessageThread +from superset.views.base_api import ( + BaseSupersetModelRestApi, + RelatedFieldFilter, + statsd_metrics, +) +from superset.views.filters import BaseFilterRelatedUsers, FilterRelatedOwners + +logger = logging.getLogger(__name__) + + +class MessageThreadRestApi(BaseSupersetModelRestApi): + datamodel = SQLAInterface(MessageThread) + include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET + + resource_name = "message_threads" + allow_browser_login = True + + class_permission_name = "MessageThread" + method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP + + list_columns = [ + "id", + "dashboard_id", + "chart_id", + ] + + list_select_columns = list_columns + add_columns = list_columns + + show_columns = ["id", "dashboard_id", "chart_id", "uuid"] + + @expose("/", methods=("POST",)) + @protect() + @safe + @statsd_metrics + @event_logger.log_this_with_context( + action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.add_threads", + log_to_statsd=False, + ) + def post(self) -> Response: + try: + data = request.json + chart_id, dashboard_id = ( + data.get(key) for key in ["chart_id", "dashboard_id"] + ) + # This validates custom Schema with custom validations + thread = GetOrCreateThreadCommand( + workspace_name="test", + chart_id=chart_id, + dashboard_id=dashboard_id, + ).run() + logger.info("thread: %s", thread.uuid) + return self.response(201, result={"thread_id": thread.uuid}) + except KeyError: + return self.response( + 400, + message="Missing required field", + ) diff --git a/superset/message_threads/commands/get_or_create.py b/superset/message_threads/commands/get_or_create.py new file mode 100644 index 0000000000000..cebfd5a6bf094 --- /dev/null +++ b/superset/message_threads/commands/get_or_create.py @@ -0,0 +1,65 @@ +# 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 +from typing import Optional, Union + +from superset.commands.base import BaseCommand, CreateMixin +from superset.daos.exceptions import DAOCreateFailedError +from superset.daos.message_thread import MessageThreadDAO +from superset.message_threads.models import MessageThread + +logger = logging.getLogger(__name__) + + +class GetOrCreateThreadCommand(BaseCommand): + def __init__( + self, + workspace_name, + chart_id: Optional[int], + dashboard_id: Optional[int], + ): + self.workspace_name = workspace_name + self.chart_id = chart_id + self.dashboard_id = dashboard_id + + def run(self) -> Union[MessageThread, None]: + try: + message_thread = MessageThreadDAO.get_or_create( + workspace_name=self.workspace_name, + dashboard_id=self.dashboard_id, + chart_id=self.chart_id, + ) + return message_thread + except DAOCreateFailedError as ex: + logger.exception(ex.exception) + + def validate(self) -> None: + return super().validate() + + # def validate(self) -> None: + # exceptions = [] + # # Validate object_id + # if self._object_id == 0: + # exceptions.append(TagCreateFailedError()) + # # Validate object type + # object_type = to_object_type(self._object_type) + # if not object_type: + # exceptions.append( + # TagCreateFailedError(f"invalid object type {self._object_type}") + # ) + # if exceptions: + # raise TagInvalidError(exceptions=exceptions) diff --git a/superset/message_threads/models.py b/superset/message_threads/models.py new file mode 100644 index 0000000000000..bc16127d759bb --- /dev/null +++ b/superset/message_threads/models.py @@ -0,0 +1,22 @@ +import uuid + +from flask_appbuilder import Model +from sqlalchemy import Column, ForeignKey, Integer, String + + +# this will be stored in manager +class MessageThread(Model): + __tablename__ = "message_threads" + id = Column(Integer, primary_key=True) + dashboard_id = Column(Integer, ForeignKey("dashboards.id"), nullable=True) + chart_id = Column(Integer, ForeignKey("slices.id"), nullable=True) + uuid = Column(String(36), unique=True, nullable=False, default=uuid.uuid4) + workspace_name = Column(String(255), nullable=False) + + def __init__(self, workspace_name, dashboard_id=None, chart_id=None): + self.workspace_name = workspace_name + self.dashboard_id = dashboard_id + self.chart_id = chart_id + + def __repr__(self): + return f"" diff --git a/superset/migrations/versions/2023-08-25_16-27_6dadc1a73f3a_create_threads.py b/superset/migrations/versions/2023-08-25_16-27_6dadc1a73f3a_create_threads.py new file mode 100644 index 0000000000000..f36415fdeedf9 --- /dev/null +++ b/superset/migrations/versions/2023-08-25_16-27_6dadc1a73f3a_create_threads.py @@ -0,0 +1,56 @@ +# 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. +"""create threads + +Revision ID: 6dadc1a73f3a +Revises: ec54aca4c8a2 +Create Date: 2023-08-25 16:27:34.631925 + +""" + +# revision identifiers, used by Alembic. +revision = "6dadc1a73f3a" +down_revision = "ec54aca4c8a2" + +import uuid + +import sqlalchemy as sa +from alembic import op + + +def upgrade(): + op.create_table( + "message_threads", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("dashboard_id", sa.Integer(), nullable=True), + sa.Column("chart_id", sa.Integer(), nullable=True), + sa.Column( + "uuid", + sa.String(36), + unique=True, + nullable=False, + default=str(uuid.uuid4), + ), + sa.Column("workspace_name", sa.String(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint(["dashboard_id"], ["dashboards.id"]), + sa.ForeignKeyConstraint(["chart_id"], ["slices.id"]), + ) + + +def downgrade(): + op.drop_table("message_threads")