Skip to content

Commit

Permalink
feat: Slack Avatar integration (apache#27849)
Browse files Browse the repository at this point in the history
  • Loading branch information
mistercrunch authored Apr 16, 2024
1 parent e38e1d8 commit 8736537
Show file tree
Hide file tree
Showing 18 changed files with 441 additions and 23 deletions.
3 changes: 3 additions & 0 deletions UPDATING.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ assists people when migrating to a new version.
- [27697](https://github.com/apache/superset/pull/27697) [minor] flask-session bump leads to them
deprecating `SESSION_USE_SIGNER`, check your configs as this flag won't do anything moving
forward.
- [27849](https://github.com/apache/superset/pull/27849/) More of an FYI, but we have a
new config `SLACK_ENABLE_AVATARS` (False by default) that works in conjunction with
set `SLACK_API_TOKEN` to fetch and serve Slack avatar links

## 4.0.0

Expand Down
16 changes: 10 additions & 6 deletions superset-frontend/src/components/FacePile/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,14 @@ interface FacePileProps {

const colorList = getCategoricalSchemeRegistry().get()?.colors ?? [];

const customAvatarStyler = (theme: SupersetTheme) => `
width: ${theme.gridUnit * 6}px;
height: ${theme.gridUnit * 6}px;
line-height: ${theme.gridUnit * 6}px;
font-size: ${theme.typography.sizes.m}px;
`;
const customAvatarStyler = (theme: SupersetTheme) => {
const size = theme.gridUnit * 8;
return `
width: ${size}px;
height: ${size}px;
line-height: ${size}px;
font-size: ${theme.typography.sizes.s}px;`;
};

const StyledAvatar = styled(Avatar)`
${({ theme }) => customAvatarStyler(theme)}
Expand All @@ -58,6 +60,7 @@ export default function FacePile({ users, maxCount = 4 }: FacePileProps) {
const name = `${first_name} ${last_name}`;
const uniqueKey = `${id}-${first_name}-${last_name}`;
const color = getRandomColor(uniqueKey, colorList);
const avatarUrl = `/api/v1/user/${id}/avatar.png`;
return (
<Tooltip key={name} title={name} placement="top">
<StyledAvatar
Expand All @@ -66,6 +69,7 @@ export default function FacePile({ users, maxCount = 4 }: FacePileProps) {
backgroundColor: color,
borderColor: color,
}}
src={avatarUrl}
>
{first_name?.[0]?.toLocaleUpperCase()}
{last_name?.[0]?.toLocaleUpperCase()}
Expand Down
3 changes: 3 additions & 0 deletions superset-frontend/src/pages/DashboardList/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,9 @@ function DashboardList(props: DashboardListProps) {
Header: t('Owners'),
accessor: 'owners',
disableSortBy: true,
cellProps: {
style: { padding: '0px' },
},
size: 'xl',
},
{
Expand Down
7 changes: 7 additions & 0 deletions superset/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1330,6 +1330,11 @@ def EMAIL_HEADER_MUTATOR( # pylint: disable=invalid-name,unused-argument
SLACK_API_TOKEN: Callable[[], str] | str | None = None
SLACK_PROXY = None

# Whether Superset should use Slack avatars for users.
# If on, you'll want to add "https://avatars.slack-edge.com" to the list of allowed
# domains in your TALISMAN_CONFIG
SLACK_ENABLE_AVATARS = False

# The webdriver to use for generating reports. Use one of the following
# firefox
# Requires: geckodriver and firefox installations
Expand Down Expand Up @@ -1454,6 +1459,7 @@ def EMAIL_HEADER_MUTATOR( # pylint: disable=invalid-name,unused-argument
"data:",
"https://apachesuperset.gateway.scarf.sh",
"https://static.scarf.sh/",
# "https://avatars.slack-edge.com", # Uncomment when SLACK_ENABLE_AVATARS is True
],
"worker-src": ["'self'", "blob:"],
"connect-src": [
Expand Down Expand Up @@ -1483,6 +1489,7 @@ def EMAIL_HEADER_MUTATOR( # pylint: disable=invalid-name,unused-argument
"data:",
"https://apachesuperset.gateway.scarf.sh",
"https://static.scarf.sh/",
"https://avatars.slack-edge.com",
],
"worker-src": ["'self'", "blob:"],
"connect-src": [
Expand Down
43 changes: 43 additions & 0 deletions superset/daos/user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# 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 __future__ import annotations

import logging

from flask_appbuilder.security.sqla.models import User

from superset.daos.base import BaseDAO
from superset.extensions import db
from superset.models.user_attributes import UserAttribute

logger = logging.getLogger(__name__)


class UserDAO(BaseDAO[User]):
@staticmethod
def get_by_id(user_id: int) -> User:
return db.session.query(User).filter_by(id=user_id).one()

@staticmethod
def set_avatar_url(user: User, url: str) -> None:
if user.extra_attributes:
user.extra_attributes[0].avatar_url = url
else:
attrs = UserAttribute(avatar_url=url, user_id=user.id)
user.extra_attributes = [attrs]
db.session.add(attrs)
db.session.commit()
3 changes: 2 additions & 1 deletion superset/initialization/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ def init_views(self) -> None:
)
from superset.views.sqllab import SqllabView
from superset.views.tags import TagModelView, TagView
from superset.views.users.api import CurrentUserRestApi
from superset.views.users.api import CurrentUserRestApi, UserRestApi

#
# Setup API views
Expand All @@ -204,6 +204,7 @@ def init_views(self) -> None:
appbuilder.add_api(ChartDataRestApi)
appbuilder.add_api(CssTemplateRestApi)
appbuilder.add_api(CurrentUserRestApi)
appbuilder.add_api(UserRestApi)
appbuilder.add_api(DashboardFilterStateRestApi)
appbuilder.add_api(DashboardPermalinkRestApi)
appbuilder.add_api(DashboardRestApi)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# 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.
"""empty message
Revision ID: c22cb5c2e546
Revises: be1b217cd8cd
Create Date: 2024-04-01 22:44:40.386543
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql

# revision identifiers, used by Alembic.
revision = "c22cb5c2e546"
down_revision = "be1b217cd8cd"


def upgrade():
op.add_column(
"user_attribute", sa.Column("avatar_url", sa.String(length=100), nullable=True)
)


def downgrade():
op.drop_column("user_attribute", "avatar_url")
38 changes: 38 additions & 0 deletions superset/migrations/versions/2024-04-11_00-49_bbf146925528_.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# 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.
"""empty message
Revision ID: bbf146925528
Revises: ('678eefb4ab44', 'c22cb5c2e546')
Create Date: 2024-04-11 00:49:51.592325
"""

# revision identifiers, used by Alembic.
revision = "bbf146925528"
down_revision = ("678eefb4ab44", "c22cb5c2e546")

import sqlalchemy as sa
from alembic import op


def upgrade():
pass


def downgrade():
pass
38 changes: 38 additions & 0 deletions superset/migrations/versions/2024-04-15_16-06_0dc386701747_.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# 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.
"""empty message
Revision ID: 0dc386701747
Revises: ('5ad7321c2169', 'bbf146925528')
Create Date: 2024-04-15 16:06:29.946059
"""

# revision identifiers, used by Alembic.
revision = "0dc386701747"
down_revision = ("5ad7321c2169", "bbf146925528")

import sqlalchemy as sa
from alembic import op


def upgrade():
pass


def downgrade():
pass
5 changes: 3 additions & 2 deletions superset/models/user_attributes.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.

from flask_appbuilder import Model
from sqlalchemy import Column, ForeignKey, Integer
from sqlalchemy import Column, ForeignKey, Integer, String
from sqlalchemy.orm import relationship

from superset import security_manager
Expand All @@ -39,6 +40,6 @@ class UserAttribute(Model, AuditMixinNullable):
user = relationship(
security_manager.user_model, backref="extra_attributes", foreign_keys=[user_id]
)

welcome_dashboard_id = Column(Integer, ForeignKey("dashboards.id"))
welcome_dashboard = relationship("Dashboard")
avatar_url = Column(String(100))
8 changes: 2 additions & 6 deletions superset/reports/notifications/slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
import pandas as pd
from flask import g
from flask_babel import gettext as __
from slack_sdk import WebClient
from slack_sdk.errors import (
BotUserAccessError,
SlackApiError,
Expand All @@ -36,7 +35,6 @@
SlackTokenRotationError,
)

from superset import app
from superset.reports.models import ReportRecipientType
from superset.reports.notifications.base import BaseNotification
from superset.reports.notifications.exceptions import (
Expand All @@ -47,6 +45,7 @@
)
from superset.utils.core import get_email_address_list
from superset.utils.decorators import statsd_gauge
from superset.utils.slack import get_slack_client

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -181,10 +180,7 @@ def send(self) -> None:
body = self._get_body()
global_logs_context = getattr(g, "logs_context", {}) or {}
try:
token = app.config["SLACK_API_TOKEN"]
if callable(token):
token = token()
client = WebClient(token=token, proxy=app.config["SLACK_PROXY"])
client = get_slack_client()
# files_upload returns SlackResponse as we run it in sync mode.
if files:
for file in files:
Expand Down
53 changes: 53 additions & 0 deletions superset/utils/slack.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# 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 import current_app
from slack_sdk import WebClient


class SlackClientError(Exception):
pass


def get_slack_client() -> WebClient:
token: str = current_app.config["SLACK_API_TOKEN"]
if callable(token):
token = token()
return WebClient(token=token, proxy=current_app.config["SLACK_PROXY"])


def get_user_avatar(email: str, client: WebClient = None) -> str:
client = client or get_slack_client()
try:
response = client.users_lookupByEmail(email=email)
except Exception as ex:
raise SlackClientError(f"Failed to lookup user by email: {email}") from ex

user = response.data.get("user")
if user is None:
raise SlackClientError("No user found with that email.")

profile = user.get("profile")
if profile is None:
raise SlackClientError("User found but no profile available.")

avatar_url = profile.get("image_192")
if avatar_url is None:
raise SlackClientError("Profile image is not available.")

return avatar_url
Loading

0 comments on commit 8736537

Please sign in to comment.