diff --git a/first_hour_on_vwb/batch_template.csv b/first_hour_on_vwb/batch_template.csv
index 75becdd..2fa6ece 100644
--- a/first_hour_on_vwb/batch_template.csv
+++ b/first_hour_on_vwb/batch_template.csv
@@ -1,4 +1,4 @@
-WORKBENCH_USER_EMAIL,POLICY
+WORKBENCH_USER_EMAIL,ROLE
EXAMPLE_USER1@domain.com,MEMBER
EXAMPLE_USER2@domain.com,MEMBER
EXAMPLE_USER3@domain.com,MEMBER
\ No newline at end of file
diff --git a/first_hour_on_vwb/vwb_org_group_utils.py b/first_hour_on_vwb/vwb_org_group_utils.py
new file mode 100644
index 0000000..ab92ca1
--- /dev/null
+++ b/first_hour_on_vwb/vwb_org_group_utils.py
@@ -0,0 +1,41 @@
+import json
+import subprocess
+import sys
+
+def get_org_linked_group_roles(org_id, group_name):
+ """
+ Return a flattened mapping of users to roles for a named org-linked group and org ID.
+ """
+ roles_dict = {role: set() for role in ["ADMIN", "MEMBER", "READER", "SUPPORT"]}
+
+ wb_command = ["wb","group","role","list",f"--org={org_id}",f"--name={group_name}","--format=JSON"]
+ result = subprocess.run(wb_command,capture_output=True,text=True)
+ nested_roles = json.loads(result.stdout)
+
+ for item in nested_roles:
+ if item['principal']['principalType'] == "GROUP":
+ roles_dict[role].update(get_org_linked_group_roles(item['principal']['groupOrg'], item['principal']['groupName'])["MEMBER"])
+ else:
+ for role in item['roles']:
+ if item['principal']['userEmail'] != None:
+ roles_dict[role].add(item['principal']['userEmail'])
+
+ return roles_dict
+
+def get_flat_roles_html(roles_dict):
+ html = f"""
"""
+ html += "ROLE | "
+ html += "USER_EMAILS | "
+ html += ""
+ for role in roles_dict:
+ html += "
"
+ if roles_dict[role] != set():
+ print(f"role: {role}, users: {roles_dict[role]}")
+ users = ", ".join(str(e) for e in sorted(roles_dict[role]))
+ html += f"{role} | "
+ html += f"{users} | "
+ else:
+ html += f"{role} | "
+ html += f"NONE | "
+ html += "
"
+ return html
\ No newline at end of file
diff --git a/first_hour_on_vwb/widget_utils.py b/first_hour_on_vwb/widget_utils.py
index 3d90922..3787834 100644
--- a/first_hour_on_vwb/widget_utils.py
+++ b/first_hour_on_vwb/widget_utils.py
@@ -40,25 +40,24 @@ def list_bq_tables(json_string):
for table in tables:
all_tables.append(f"{row['projectId']}.{row['datasetId']}.{table.table_id}")
return all_tables
-
-def list_groups(json_string):
+def list_legacy_groups(json_string):
""" List VWB groups in which user is a member in HTML table form.
"""
html = f""""""
json_data = json.loads(json_string)
+ # html += "ORG_ID | "
html += "NAME | "
- html += "EMAIL | "
- html += "POLICIES | "
+ html += "CURRENT_USER_ROLES | "
for row in json_data:
- html += ""
- html += f"{row['name']} | "
- html += f"{row['email']} | "
- html += f"{row['currentUserPolicies'][0]} | "
+ if not row['orgId']:
+ html += "
"
+ html += f"{row['name']} | "
+ html += f"{sorted(row['currentUserRoles'])} | "
html += "
"
return html
-def list_group_members(json_string):
+def list_legacy_group_members(json_string):
"""
List members of a VWB group in HTML table form.
"""
@@ -69,10 +68,29 @@ def list_group_members(json_string):
for row in json_data:
html += ""
html += f"{row['email']} | "
- html += f"{row['policies'][0]} | "
+ html += f"{sorted(row['policies'])} | "
html += ""
return html
+
+def list_org_groups(json_string):
+ """ List VWB groups in which user is a member in HTML table form.
+ """
+ html = f""""""
+ json_data = json.loads(json_string)
+ html += "ORG_ID | "
+ html += "NAME | "
+ # html += "CURRENT_USER_ROLES | "
+ for row in json_data:
+ if row['orgId'] != 'null':
+ html += ""
+ html += f"{row['orgId']} | "
+ html += f"{row['name']} | "
+ # html += f"{sorted(row['currentUserRoles'])} | "
+ html += "
"
+ return html
+
+
def list_data_collections(json_string):
"""
Given the output of the command `wb workspace list`,
@@ -149,6 +167,32 @@ def get(self):
return self.dropdown_widget
+@dataclass
+class BoundIntInputWidget:
+ """A styled input accepting bounded integers widget with layout.
+ """
+ default_value: int = 0
+ min_value: int = 0
+ max_value: int = 0
+ description: str = ""
+ bound_int_widget: widgets.BoundedIntText = None
+
+ def __post_init__(self):
+ self.bound_int_widget = widgets.BoundedIntText(
+ value=self.default_value,
+ min=self.min_value,
+ max=self.max_value,
+ step=1,
+ description=self.description,
+ disabled=False,
+ layout = {'width' : 'max-content'},
+ style=input_style
+ )
+
+ def get(self):
+ '''Return widget.'''
+ return self.bound_int_widget
+
@dataclass
class StyledButton():
""" A styled button widget with layout.
@@ -162,7 +206,7 @@ def __post_init__(self):
self.button = widgets.Button(
description=self.description,
disabled=False,
- layout=widgets.Layout(width='75%'),
+ layout=widgets.Layout(width='100%'),
display='flex',
align_items='stretch',
button_style='',
@@ -203,3 +247,20 @@ def __init__(self):
def get(self):
'''Return widget.'''
return self.checkbox
+
+
+class LongLabelCheckbox:
+ """ Creates a styled checkbox.
+ """
+ def __init__(self, description):
+ self.checkbox = widgets.Checkbox(
+ False,
+ description = description,
+ layout = {'width' : 'max-content'},
+ disabled = False,
+ indent = False
+ )
+
+ def get(self):
+ '''Return widget.'''
+ return self.checkbox
\ No newline at end of file
diff --git a/first_hour_on_vwb/working_with_groups.ipynb b/first_hour_on_vwb/working_with_groups.ipynb
index 5beb224..a5b35f0 100644
--- a/first_hour_on_vwb/working_with_groups.ipynb
+++ b/first_hour_on_vwb/working_with_groups.ipynb
@@ -32,7 +32,8 @@
{
"cell_type": "markdown",
"metadata": {
- "id": "d975e698c9a4"
+ "id": "d975e698c9a4",
+ "jp-MarkdownHeadingCollapsed": true
},
"source": [
"### Objective\n",
@@ -76,6 +77,7 @@
"from typing import List\n",
"import csv\n",
"import widget_utils as wu\n",
+ "import vwb_org_group_utils as vogu\n",
"import ipywidgets as widgets\n",
"import subprocess"
]
@@ -83,25 +85,27 @@
{
"cell_type": "markdown",
"metadata": {
+ "jp-MarkdownHeadingCollapsed": true,
"tags": []
},
"source": [
- "## View your Verily Workbench group memberships\n",
+ "## Legacy Verily Workbench group memberships\n",
"\n",
"\n",
- "Run the cell below to view a list of the Verily Workbench groups in which you are a member. You should be a member of at least one group, specific to your organization, which will have the name `-users`."
+ "On April 14, 2025, Workbench rolled out a change to the implementation of Workbench groups that allows users to create groups which are linked to their organization. Prior to this change, all groups were global in scope -- not linked to any organization. We refer to these global groups which predate this change as \"legacy Workbench groups\". To view any legacy Workbench groups in which you have a role, run the cell below."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {
+ "scrolled": true,
"tags": []
},
"outputs": [],
"source": [
"result = subprocess.run(['wb','group','list','--format=JSON'],capture_output=True,text=True)\n",
- "print(result.stderr) if not result.stdout else display(HTML(wu.list_groups(result.stdout)))"
+ "print(result.stderr) if not result.stdout else display(HTML(wu.list_legacy_groups(result.stdout)))"
]
},
{
@@ -110,15 +114,16 @@
"tags": []
},
"source": [
- "## View members of a Verily Workbench group\n",
+ "### View members of legacy Verily Workbench groups\n",
"\n",
- "In order to view the members of a Verily Workbench group, you must be an owner of that group. To investigate the membership of your group, run the cell below to create a widget. Provide the group name in the widget input field and click the button to view a list of the members of the desired group."
+ "In order to view the members of a legacy Verily Workbench group, you must be an admin of that group. To investigate the membership of your group, run the cell below to create a widget. Provide the group name in the widget input field and click the button to view a list of the members of the desired group."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {
+ "scrolled": true,
"tags": []
},
"outputs": [],
@@ -134,13 +139,100 @@
"\n",
" \n",
" def list_members(self,b):\n",
+ " self.output.clear_output()\n",
" with self.output:\n",
" wb_command = [\"wb\",\"group\",\"list-users\",f\"--name={self.input_group_name.value}\",\"--format=JSON\"]\n",
" result = subprocess.run(wb_command,capture_output=True,text=True)\n",
" if not result.stdout:\n",
" self.output.append_display_data(result.stderr)\n",
" else:\n",
- " self.output.append_display_data(HTML(wu.list_group_members(result.stdout)))\n",
+ " self.output.append_display_data(HTML(wu.list_legacy_group_members(result.stdout)))\n",
+ "\n",
+ "# Create widget \n",
+ "list_members_widget = ListMembersWidget()\n",
+ "display(list_members_widget.vb)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "jp-MarkdownHeadingCollapsed": true
+ },
+ "source": [
+ "## Organization-linked Workbench groups\n",
+ "\n",
+ "Starting on April 14, 2025, it is no longer possible to create \"legacy\" Workbench groups. Users must provide an organization when creating groups, and org admins and members can create org-linked groups. To view the org-linked Workbench groups in which you have a role, run the cell below."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "tags": []
+ },
+ "outputs": [],
+ "source": [
+ "class ListOrgGroupsWidget(object):\n",
+ " def __init__(self):\n",
+ " self.label = widgets.Label(value='Please provide the org ID for a Verily Workbench org.')\n",
+ " self.input_org_id = wu.TextInputWidget('',\"Org ID:\").get()\n",
+ " self.output = widgets.Output()\n",
+ " self.list_button = wu.StyledButton('List org-linked group','Click to list org-linked groups.','list').get()\n",
+ " self.vb = widgets.VBox([self.label,self.input_org_id,self.list_button,self.output],layout=wu.vbox_layout)\n",
+ " self.list_button.on_click(self.list_groups)\n",
+ "\n",
+ " \n",
+ " def list_groups(self,b):\n",
+ " self.output.clear_output()\n",
+ " with self.output:\n",
+ " wb_command = ['wb', 'org','group','list',f\"--org={self.input_org_id.value}\",\"--format=JSON\"]\n",
+ " result = subprocess.run(wb_command,capture_output=True,text=True)\n",
+ " if not result.stdout:\n",
+ " self.output.append_display_data(result.stderr)\n",
+ " else:\n",
+ " self.output.append_display_data(HTML(wu.list_org_groups(result.stdout)))\n",
+ "\n",
+ "# Create widget \n",
+ "list_org_groups_widget = ListOrgGroupsWidget()\n",
+ "display(list_org_groups_widget.vb)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "jp-MarkdownHeadingCollapsed": true,
+ "tags": []
+ },
+ "source": [
+ "## View members of org-linked Verily Workbench groups\n",
+ "\n",
+ "In order to view the members of a org-linked Verily Workbench group, you must either be an admin of that group or an org admin. To investigate the membership of your group, run the cell below to create a widget. Provide the *group name* and the *org ID* in the widget input fields and click the button to view a list of the members of the desired group."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "scrolled": true,
+ "tags": []
+ },
+ "outputs": [],
+ "source": [
+ "class ListMembersWidget(object):\n",
+ " def __init__(self):\n",
+ " self.label = widgets.Label(value='Please provide the name for a Verily Workbench group.')\n",
+ " self.input_group_name = wu.TextInputWidget('',\"Group Name:\").get()\n",
+ " self.input_org_id = wu.TextInputWidget('',\"Org ID:\").get()\n",
+ " self.output = widgets.Output()\n",
+ " self.list_button = wu.StyledButton('List users in group','Click to list users in this Verily Workbench group.','list').get()\n",
+ " self.vb = widgets.VBox([self.label,self.input_group_name,self.input_org_id,self.list_button,self.output],layout=wu.vbox_layout)\n",
+ " self.list_button.on_click(self.list_members)\n",
+ "\n",
+ " \n",
+ " def list_members(self,b):\n",
+ " self.output.clear_output()\n",
+ " with self.output:\n",
+ " self.output.append_display_data(HTML(vogu.get_flat_roles_html(vogu.get_org_linked_group_roles(self.input_org_id.value, self.input_group_name.value))))\n",
"\n",
"# Create widget \n",
"list_members_widget = ListMembersWidget()\n",
@@ -156,17 +248,28 @@
"## Create a new Verily Workbench group\n",
"\n",
"\n",
- "Run the cell below to create a widget, then populate the widget's fields and click the button to create a new Verily Workbench group. \n",
- "The value provided for the `GROUP_NAME` should be unique, and reflect the purpose of the group (for example, for a group of Verily researchers working on the 1000 Genomes dataset, `verily-1000-genomes-researchers` would be appropriate).\n",
+ "Run the cell below to create a widget that creates an org-linked Workbench group. Then, populate the widget's input fields:\n",
"\n",
+ "- `Group Name`: Should be unique and reflect the purpose of the group (for example, for a group of Verily researchers working on the 1000 Genomes dataset, `verily-1000-genomes-researchers` would be appropriate).\n",
+ "- `Org ID`: Your organization's ID.\n",
+ "- `Expiration`: The default and maximimum MEMBER role expiration in *days*. Zero means the MEMBER role does not expire.\n",
+ "- `Expiration Notification`: Notify group admins about member expirations.\n",
+ "- `Require Grant Reason`: Require a reason on grants to the group.\n",
+ "\n",
+ "Click the button to create a new Verily Workbench group. \n",
"You should see output resembling:\n",
"\n",
"```\n",
"Workbench group created.\n",
"\n",
- " Email: @verily.com\n",
- " # Members: 1\n",
- " Current user's policies: ADMIN\n",
+ " Organization : \n",
+ " Description : \n",
+ " Expiration days : \n",
+ " Expiration notify : \n",
+ " Sync to Google Group : \n",
+ " Grant reason required : \n",
+ " Email : _@verily-bvdp.com\n",
+ " Current user's roles : ADMIN\n",
"```"
]
},
@@ -180,18 +283,35 @@
"source": [
"class CreateGroupWidget(object):\n",
" def __init__(self):\n",
- " self.label = widgets.Label(value='Please provide the name for a Verily Workbench group.')\n",
+ " self.label = widgets.Label(value='Please provide the name & org ID for your Verily Workbench group.')\n",
" self.input_group_name = wu.TextInputWidget('',\"Group Name:\").get()\n",
+ " self.input_org_id = wu.TextInputWidget('', \"Organization ID:\").get()\n",
+ " self.input_expiration_duration = wu.BoundIntInputWidget(0,0,366,'Expiration Duration in Days:').get()\n",
+ " self.enable_expiration_notification = wu.LongLabelCheckbox('Enable notifications of expiration to group admins.').get()\n",
+ " self.require_grant_reason = wu.LongLabelCheckbox('Require admins to provide a reason when granting a role on this group.').get()\n",
" self.output = widgets.Output()\n",
" self.button = wu.StyledButton('Create group','Click to create a Verily Workbench group.','plus').get()\n",
- " self.vb = widgets.VBox([self.label,self.input_group_name,self.button,self.output],layout=wu.vbox_layout)\n",
+ " self.required_fields = [\n",
+ " self.label,self.input_group_name,self.input_org_id,self.input_expiration_duration,\n",
+ " self.enable_expiration_notification, self.require_grant_reason, self.button,self.output\n",
+ " ]\n",
+ " self.vb = widgets.VBox(children=self.required_fields,layout=wu.vbox_layout)\n",
" self.button.on_click(self.create_group)\n",
"\n",
" def create_group(self,b):\n",
" with self.output:\n",
" self.output.clear_output()\n",
- " wb_command = [\"wb\",\"group\",\"create\",f\"--name={self.input_group_name.value}\",\"--format=text\"]\n",
- " result = subprocess.run(wb_command,capture_output=True,text=True)\n",
+ " commandList = [\"wb\",\"group\",\"create\",\n",
+ " f\"--name={self.input_group_name.value}\",\n",
+ " f\"--org={self.input_org_id.value}\",\n",
+ " f\"--expiration={int(self.input_expiration_duration.value)}\",\n",
+ " \"--format=text\"]\n",
+ "\n",
+ " if self.enable_expiration_notification.value:\n",
+ " commandList.append(f\"--expiration-notification\")\n",
+ " if self.require_grant_reason.value:\n",
+ " commandList.append(f\"--require-grant-reason\")\n",
+ " result = subprocess.run(commandList,capture_output=True,text=True)\n",
" print(result.stderr) if not result.stdout else print(result.stdout)\n",
"\n",
"# Instantiate widget\n",
@@ -584,7 +704,7 @@
"metadata": {},
"source": [
"---\n",
- "Copyright 2024 Verily Life Sciences LLC\n",
+ "Copyright 2025 Verily Life Sciences LLC\n",
"\n",
"Use of this source code is governed by a BSD-style \n",
"license that can be found in the LICENSE file or at \n",
@@ -607,7 +727,7 @@
"uri": "gcr.io/deeplearning-platform-release/:m115"
},
"kernelspec": {
- "display_name": "Python 3 (Local)",
+ "display_name": "Python 3",
"language": "python",
"name": "python3"
},
@@ -621,7 +741,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
- "version": "3.10.13"
+ "version": "3.10.16"
}
},
"nbformat": 4,