Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add user group db schema support #1144

Merged
merged 8 commits into from
Feb 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions docs_website/docs/developer_guide/users_and_groups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
id: users_and_groups
title: Users and Groups
sidebar_label: Users and Groups
---

Querybook supports adding/loading users and user groups.

## DB Schema
We use the `User` model to represent a user or a group.

A user or a group can have below properties
- username: It is the unique identifier of a user or a group.
- fullname: Full name or display name of a user or a group.
- password: Password of a user. Only applies to users when using default user/password auth.
- email: Email of a user or a group.
- profile_img: Profile image url of a user or a group.
- deleted: To indicate if this user has been deleted/deactivated or not.
- is_group: To indicate it is a user group if it's true.
- properties: Any addiontal properties are stored in this column. It is a freeform JSON object, you can basically add any properties inside it. By default all the properties inside `properties` are private, which are invisiable to end users.
- public_info: Only properties stored inside `properties.public_info` are visible to end users.
- description: [optional] Description of a user or a group.


For the detailed DB schema and table relationships, please check the model file [here](https://github.com/pinterest/querybook/blob/master/querybook/server/models/user.py)


## Create/Load Users and Group
For users
- default user/password authentication: people can sign up as a new user on UI.
- oauth/ldap authentication: they both support auto-creation of users.

For user groups, they can only be sync'ed now and can not be created on UI. You can have a scheduled task for your organization to sync them from either ldap, metastore or any other system which contains user groups.
3 changes: 2 additions & 1 deletion docs_website/sidebars.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@
"developer_guide/developer_setup",
"developer_guide/reinitialize_es",
"developer_guide/run_db_migration",
"developer_guide/storybook"
"developer_guide/storybook",
"developer_guide/users_and_groups"
],

"User Guide": ["user_guide/api_token", "user_guide/faq"],
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "querybook",
"version": "3.17.1",
"version": "3.18.0",
"description": "A Big Data Webapp",
"private": true,
"scripts": {
Expand Down
Original file line number Diff line number Diff line change
@@ -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=False),
sa.Column("uid", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
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 ###
10 changes: 10 additions & 0 deletions querybook/server/datasources/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@ def get_user_info(uid):
return user


@register("/user/<int:gid>/group_members/", methods=["GET"])
def get_user_group_members(gid):
group = logic.get_user_by_id(gid)

if group is None:
abort(RESOURCE_NOT_FOUND_STATUS_CODE)

return group.group_members


@register("/user/name/<username>/", methods=["GET"])
def get_user_info_by_username(username):
user = logic.get_user_by_name(username)
Expand Down
4 changes: 3 additions & 1 deletion querybook/server/logic/elasticsearch.py
Original file line number Diff line number Diff line change
Expand Up @@ -705,7 +705,9 @@ def _bulk_insert_users():
index_name = ES_CONFIG["users"]["index_name"]

for user in get_users_iter():
_insert(index_name, user["id"], user)
# skip indexing user groups before having the correct permission setup for it.
if not user.is_group:
_insert(index_name, user["id"], user)


def _bulk_update_users(fields: Set[str] = None):
Expand Down
19 changes: 19 additions & 0 deletions querybook/server/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -66,6 +74,8 @@ def to_dict(self, with_roles=False):
"profile_img": self.profile_img,
"email": self.email,
"deleted": self.deleted,
"is_group": self.is_group,
"properties": self.properties.get("public_info", {}),
}

if with_roles:
Expand Down Expand Up @@ -107,3 +117,12 @@ 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"))
czgu marked this conversation as resolved.
Show resolved Hide resolved
created_at = sql.Column(sql.DateTime, default=now)
Empty file.
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,13 @@ export const DataTableHeader: React.FunctionComponent<IDataTableHeader> = ({
);

const ownershipDOM = (tableOwnerships || []).map((ownership) => (
<UserBadge key={ownership.uid} uid={ownership.uid} mini cardStyle />
<UserBadge
key={ownership.uid}
uid={ownership.uid}
mini
cardStyle
groupPopover
/>
));

// Ownership cannot be removed if owner in db
Expand Down
59 changes: 41 additions & 18 deletions querybook/webapp/components/UserBadge/UserBadge.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -15,6 +18,7 @@ type IProps = {
isOnline?: boolean;
mini?: boolean;
cardStyle?: boolean;
groupPopover?: boolean;
} & ICommonUserLoaderProps;

export const UserBadge: React.FunctionComponent<IProps> = ({
Expand All @@ -23,6 +27,7 @@ export const UserBadge: React.FunctionComponent<IProps> = ({
isOnline,
mini,
cardStyle,
groupPopover = true,
}) => {
const { loading, userInfo } = useUser({ uid, name });

Expand All @@ -46,24 +51,20 @@ export const UserBadge: React.FunctionComponent<IProps> = ({

const deletedText = userInfo?.deleted ? DELETED_USER_MSG : '';

if (mini) {
return (
<span
className={clsx({
UserBadge: true,
mini: true,
'card-style': cardStyle,
})}
>
<figure>{avatarDOM}</figure>
<AccentText className="username" weight="bold">
{userInfo?.fullname ?? userName} {deletedText}
</AccentText>
</span>
);
}

return (
const badgeDOM = mini ? (
<span
className={clsx({
UserBadge: true,
mini: true,
'card-style': cardStyle,
})}
>
<figure>{avatarDOM}</figure>
<AccentText className="username" weight="bold">
{userInfo?.fullname ?? userName} {deletedText}
</AccentText>
</span>
) : (
<div
className={clsx({
UserBadge: true,
Expand All @@ -89,4 +90,26 @@ export const UserBadge: React.FunctionComponent<IProps> = ({
</div>
</div>
);

return groupPopover && userInfo?.is_group ? (
czgu marked this conversation as resolved.
Show resolved Hide resolved
<PopoverHoverWrapper>
{(showPopover, anchorElement) => (
<>
{badgeDOM}

{showPopover && (
<Popover
onHide={() => null}
anchor={anchorElement}
layout={['right', 'top']}
>
<UserGroupCard userGroup={userInfo} />
</Popover>
)}
</>
)}
</PopoverHoverWrapper>
) : (
badgeDOM
);
};
9 changes: 9 additions & 0 deletions querybook/webapp/components/UserGroupCard/UserGroupCard.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.UserGroupCard {
max-width: 400px;

.members-container {
display: flex;
align-items: center;
flex-wrap: wrap;
}
}
73 changes: 73 additions & 0 deletions querybook/webapp/components/UserGroupCard/UserGroupCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import React, { useCallback } from 'react';

import { UserBadge } from 'components/UserBadge/UserBadge';
import { MAX_USER_GROUP_MEMBERS_TO_SHOW } from 'const/uiConfig';
import { IUserInfo } from 'const/user';
import { useResource } from 'hooks/useResource';
import { UserResource } from 'resource/user';
import { AccentText } from 'ui/StyledText/StyledText';

import './UserGroupCard.scss';

interface IProps {
userGroup: IUserInfo;
}

export const UserGroupCard = ({ userGroup }: IProps) => {
const { data: members } = useResource(
useCallback(
() => UserResource.getUserGroupMembers(userGroup.id),
[userGroup.id]
)
);

const membersDOM = members && (
<>
{members.slice(0, MAX_USER_GROUP_MEMBERS_TO_SHOW).map((m) => (
<div key={m.id} className="flex-row ml8">
<UserBadge uid={m.id} mini />
</div>
))}
{members.length > MAX_USER_GROUP_MEMBERS_TO_SHOW && (
<div className="ml8"> and more</div>
)}
</>
);

return (
<div className="UserGroupCard">
<div>
<AccentText color="dark" weight="bold" size="xsmall">
{userGroup.fullname ?? userGroup.username}
</AccentText>
<AccentText color="light" size="xsmall">
{userGroup.username}
</AccentText>
</div>
{userGroup.email && (
<div className="mt8">
<AccentText color="dark" weight="bold" size="xsmall">
Email
</AccentText>
<AccentText color="light" size="xsmall">
{userGroup.email}
</AccentText>
</div>
)}
<div className="mt8">
<AccentText color="dark" weight="bold" size="xsmall">
Description
</AccentText>
<AccentText color="light" size="xsmall">
{userGroup.properties.description}
</AccentText>
</div>
<div className="mt8">
<AccentText color="dark" weight="bold" size="xsmall">
Group members
</AccentText>
<div className="members-container">{membersDOM}</div>
</div>
</div>
);
};
6 changes: 6 additions & 0 deletions querybook/webapp/const/uiConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,9 @@ export const MIN_ENGINE_TO_SHOW_FILTER = 4;
* the search bar in "hide columns" popover
*/
export const MIN_COLUMN_TO_SHOW_FILTER = 5;

/**
* this constant controls the maximum number
* of members to show in the user group popover
*/
export const MAX_USER_GROUP_MEMBERS_TO_SHOW = 10;
6 changes: 6 additions & 0 deletions querybook/webapp/const/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,14 @@ export interface IUserInfo {
fullname: string;
profile_img: string;
deleted: boolean;
email: string;
is_group: boolean;

roles?: number[];

properties: {
description?: string;
};
}

export interface IMyUserInfo {
Expand Down
2 changes: 2 additions & 0 deletions querybook/webapp/resource/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export const UserResource = {
[visibleEnvironments: IEnvironment[], userEnvironmentIds: number[]]
>('/user/environment/'),
getNotifiers: () => ds.fetch<INotifier[]>('/user/notifiers/'),
getUserGroupMembers: (id: number) =>
ds.fetch<IUserInfo[]>(`/user/${id}/group_members/`),
};

export const UserSettingResource = {
Expand Down