From 66bd8f05c1d232bcb3e31cc3e02953abc263655a Mon Sep 17 00:00:00 2001 From: "J.C. Zhong" Date: Sat, 4 Feb 2023 00:11:23 +0000 Subject: [PATCH] feat: add user group db schema support --- .../1b8aba201c94_add_user_group_support.py | 39 ++++++++++++ querybook/server/datasources/user.py | 10 ++++ querybook/server/logic/user.py | 5 ++ querybook/server/models/user.py | 20 +++++++ querybook/server/models/usergroup.py | 0 .../DataTableView/DataTableHeader.tsx | 8 ++- .../webapp/components/UserBadge/UserBadge.tsx | 59 ++++++++++++------ .../UserGroupCard/UserGroupCard.scss | 9 +++ .../UserGroupCard/UserGroupCard.tsx | 60 +++++++++++++++++++ querybook/webapp/const/user.ts | 5 ++ querybook/webapp/resource/user.ts | 2 + 11 files changed, 198 insertions(+), 19 deletions(-) create mode 100644 querybook/migrations/versions/1b8aba201c94_add_user_group_support.py create mode 100644 querybook/server/models/usergroup.py create mode 100644 querybook/webapp/components/UserGroupCard/UserGroupCard.scss create mode 100644 querybook/webapp/components/UserGroupCard/UserGroupCard.tsx diff --git a/querybook/migrations/versions/1b8aba201c94_add_user_group_support.py b/querybook/migrations/versions/1b8aba201c94_add_user_group_support.py new file mode 100644 index 000000000..c8b54bece --- /dev/null +++ b/querybook/migrations/versions/1b8aba201c94_add_user_group_support.py @@ -0,0 +1,39 @@ +"""add user group support + +Revision ID: 1b8aba201c94 +Revises: 27ed76f75106 +Create Date: 2023-02-03 00:31:09.209132 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = "1b8aba201c94" +down_revision = "27ed76f75106" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "user_group_member", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("gid", sa.Integer(), nullable=True), + sa.Column("uid", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(["gid"], ["user.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["uid"], ["user.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.add_column("user", sa.Column("is_group", sa.Boolean(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("user", "is_group") + op.drop_table("user_group_member") + # ### end Alembic commands ### + diff --git a/querybook/server/datasources/user.py b/querybook/server/datasources/user.py index c819dde68..dcc686972 100644 --- a/querybook/server/datasources/user.py +++ b/querybook/server/datasources/user.py @@ -40,6 +40,16 @@ def get_user_info(uid): return user +@register("/user//group_members/", methods=["GET"]) +def get_user_group_members(uid): + group = logic.get_user_by_id(uid) + + if group is None: + abort(RESOURCE_NOT_FOUND_STATUS_CODE) + + return [g.to_dict() for g in group.group_members] + + @register("/user/name//", methods=["GET"]) def get_user_info_by_username(username): user = logic.get_user_by_name(username) diff --git a/querybook/server/logic/user.py b/querybook/server/logic/user.py index 7014bb7b0..88d176cf7 100644 --- a/querybook/server/logic/user.py +++ b/querybook/server/logic/user.py @@ -52,6 +52,11 @@ def get_user_by_name(username, case_sensitive=True, session=None): return None +@with_session +def get_user_group_members(id, session=None): + return User.get(id=id, session=session).group_members + + @with_session def create_user( username, diff --git a/querybook/server/models/user.py b/querybook/server/models/user.py index 5b0666ce3..a08eee8fd 100644 --- a/querybook/server/models/user.py +++ b/querybook/server/models/user.py @@ -31,11 +31,19 @@ class User(CRUDMixin, Base): email = sql.Column(sql.String(length=name_length)) profile_img = sql.Column(sql.String(length=url_length)) deleted = sql.Column(sql.Boolean, default=False) + is_group = sql.Column(sql.Boolean, default=False) properties = sql.Column(sql.JSON, default={}) settings = relationship("UserSetting", cascade="all, delete", passive_deletes=True) roles = relationship("UserRole", cascade="all, delete", passive_deletes=True) + group_members = relationship( + "User", + secondary="user_group_member", + primaryjoin="User.id == UserGroupMember.gid", + secondaryjoin="User.id == UserGroupMember.uid", + backref="user_groups", + ) @hybrid_property def password(self): @@ -66,8 +74,12 @@ def to_dict(self, with_roles=False): "profile_img": self.profile_img, "email": self.email, "deleted": self.deleted, + "is_group": self.is_group, } + if self.is_group: + user_dict["properties"] = self.properties + if with_roles: user_dict["roles"] = [role.role.value for role in self.roles] @@ -107,3 +119,11 @@ def to_dict(self): "role": self.role.value, "created_at": self.created_at, } + + +class UserGroupMember(Base): + __tablename__ = "user_group_member" + + id = sql.Column(sql.Integer, primary_key=True) + gid = sql.Column(sql.Integer, sql.ForeignKey("user.id", ondelete="CASCADE")) + uid = sql.Column(sql.Integer, sql.ForeignKey("user.id", ondelete="CASCADE")) diff --git a/querybook/server/models/usergroup.py b/querybook/server/models/usergroup.py new file mode 100644 index 000000000..e69de29bb diff --git a/querybook/webapp/components/DataTableView/DataTableHeader.tsx b/querybook/webapp/components/DataTableView/DataTableHeader.tsx index 13e631df6..0f908dbab 100644 --- a/querybook/webapp/components/DataTableView/DataTableHeader.tsx +++ b/querybook/webapp/components/DataTableView/DataTableHeader.tsx @@ -115,7 +115,13 @@ export const DataTableHeader: React.FunctionComponent = ({ ); const ownershipDOM = (tableOwnerships || []).map((ownership) => ( - + )); // Ownership cannot be removed if owner in db diff --git a/querybook/webapp/components/UserBadge/UserBadge.tsx b/querybook/webapp/components/UserBadge/UserBadge.tsx index 9da923ed8..5f81e0d2c 100644 --- a/querybook/webapp/components/UserBadge/UserBadge.tsx +++ b/querybook/webapp/components/UserBadge/UserBadge.tsx @@ -1,8 +1,11 @@ import clsx from 'clsx'; import React, { useMemo } from 'react'; +import { UserGroupCard } from 'components/UserGroupCard/UserGroupCard'; import { DELETED_USER_MSG } from 'const/user'; import { useUser } from 'hooks/redux/useUser'; +import { Popover } from 'ui/Popover/Popover'; +import { PopoverHoverWrapper } from 'ui/Popover/PopoverHoverWrapper'; import { AccentText } from 'ui/StyledText/StyledText'; import { ICommonUserLoaderProps } from './types'; @@ -15,6 +18,7 @@ type IProps = { isOnline?: boolean; mini?: boolean; cardStyle?: boolean; + groupPopover?: boolean; } & ICommonUserLoaderProps; export const UserBadge: React.FunctionComponent = ({ @@ -23,6 +27,7 @@ export const UserBadge: React.FunctionComponent = ({ isOnline, mini, cardStyle, + groupPopover, }) => { const { loading, userInfo } = useUser({ uid, name }); @@ -46,24 +51,20 @@ export const UserBadge: React.FunctionComponent = ({ const deletedText = userInfo?.deleted ? DELETED_USER_MSG : ''; - if (mini) { - return ( - -
{avatarDOM}
- - {userInfo?.fullname ?? userName} {deletedText} - -
- ); - } - - return ( + const badgeDOM = mini ? ( + +
{avatarDOM}
+ + {userInfo?.fullname ?? userName} {deletedText} + +
+ ) : (
= ({
); + + return groupPopover && userInfo?.is_group ? ( + + {(showPopover, anchorElement) => ( + <> + {badgeDOM} + + {showPopover && ( + null} + anchor={anchorElement} + layout={['right', 'top']} + > + + + )} + + )} + + ) : ( + badgeDOM + ); }; diff --git a/querybook/webapp/components/UserGroupCard/UserGroupCard.scss b/querybook/webapp/components/UserGroupCard/UserGroupCard.scss new file mode 100644 index 000000000..c6874cdfb --- /dev/null +++ b/querybook/webapp/components/UserGroupCard/UserGroupCard.scss @@ -0,0 +1,9 @@ +.UserGroupCard { + max-width: 400px; + + .members-container { + display: flex; + align-items: center; + flex-wrap: wrap; + } +} diff --git a/querybook/webapp/components/UserGroupCard/UserGroupCard.tsx b/querybook/webapp/components/UserGroupCard/UserGroupCard.tsx new file mode 100644 index 000000000..880e0cc8a --- /dev/null +++ b/querybook/webapp/components/UserGroupCard/UserGroupCard.tsx @@ -0,0 +1,60 @@ +import React, { useEffect, useState } from 'react'; + +import { UserBadge } from 'components/UserBadge/UserBadge'; +import { IUserInfo } from 'const/user'; +import { UserResource } from 'resource/user'; +import { AccentText } from 'ui/StyledText/StyledText'; + +import './UserGroupCard.scss'; + +interface IProps { + userGroup: IUserInfo; +} + +export const UserGroupCard = ({ userGroup }: IProps) => { + const [members, setMembers] = useState([]); + + useEffect(() => { + UserResource.getUserGroupMembers(userGroup.id).then(({ data }) => { + setMembers(data); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+
+ + {userGroup.fullname} + + {userGroup.username} +
+
+ + Email + + {userGroup.email} +
+
+ + Description + + + {userGroup.properties?.description} + +
+
+ + Group members + +
+ {members.map((m) => ( +
+ +
+ ))} +
+
+
+ ); +}; diff --git a/querybook/webapp/const/user.ts b/querybook/webapp/const/user.ts index 23d3fdec2..a2cd1842e 100644 --- a/querybook/webapp/const/user.ts +++ b/querybook/webapp/const/user.ts @@ -9,8 +9,13 @@ export interface IUserInfo { fullname: string; profile_img: string; deleted: boolean; + email: string; + is_group: boolean; roles?: number[]; + properties?: { + description?: string; + }; } export interface IMyUserInfo { diff --git a/querybook/webapp/resource/user.ts b/querybook/webapp/resource/user.ts index 1d8294108..79b03ef30 100644 --- a/querybook/webapp/resource/user.ts +++ b/querybook/webapp/resource/user.ts @@ -35,6 +35,8 @@ export const UserResource = { [visibleEnvironments: IEnvironment[], userEnvironmentIds: number[]] >('/user/environment/'), getNotifiers: () => ds.fetch('/user/notifiers/'), + getUserGroupMembers: (id: number) => + ds.fetch(`/user/${id}/group_members/`), }; export const UserSettingResource = {