From 8ac2d146e9dd7136e783de98b93462fbcd1c20a5 Mon Sep 17 00:00:00 2001 From: Fuhu Xia Date: Wed, 27 May 2026 11:14:50 -0400 Subject: [PATCH 01/16] add action user_org_roles --- ckanext/datagov_inventory/action.py | 67 +++++++++++++++++++ ckanext/datagov_inventory/plugin.py | 12 ++++ .../tests/logic/auth/test_auth.py | 40 +++++++++++ 3 files changed, 119 insertions(+) create mode 100644 ckanext/datagov_inventory/action.py diff --git a/ckanext/datagov_inventory/action.py b/ckanext/datagov_inventory/action.py new file mode 100644 index 00000000..e8773009 --- /dev/null +++ b/ckanext/datagov_inventory/action.py @@ -0,0 +1,67 @@ +import ckan.model as model +import ckan.plugins.toolkit as toolkit + + +@toolkit.side_effect_free +def user_org_roles(context, data_dict): + """Return active users with organization roles, grouped by priority.""" + toolkit.check_access('user_org_roles', context, data_dict) + + org_roles_by_user = _org_roles_by_user() + users = model.Session.query(model.User).filter( + model.User.state == 'active' + ).all() + + result = [] + for user in users: + organizations = sorted( + org_roles_by_user.get(user.id, []), + key=lambda org: (org['name'] or '').lower() + ) + result.append({ + 'id': user.id, + 'name': user.name, + 'fullname': user.fullname, + 'email': user.email, + 'sysadmin': user.sysadmin, + 'organizations': organizations, + }) + + return sorted(result, key=_user_sort_key) + + +def _org_roles_by_user(): + """ + Retrieve a dictionary mapping user IDs to their roles within organizations. + """ + org_roles_by_user = {} + query = model.Session.query(model.Member, model.Group).join( + model.Group, + model.Member.group_id == model.Group.id + ).filter( + model.Member.table_name == 'user', + model.Member.state == 'active', + model.Group.state == 'active', + model.Group.is_organization.is_(True), + ) + + for member, organization in query: + org_roles_by_user.setdefault(member.table_id, []).append({ + 'id': organization.id, + 'name': organization.name, + 'title': organization.title, + 'role': member.capacity, + }) + + return org_roles_by_user + + +def _user_sort_key(user): + if user['sysadmin']: + group = 0 + elif user['organizations']: + group = 1 + else: + group = 2 + + return (group, (user['name'] or '').lower()) diff --git a/ckanext/datagov_inventory/plugin.py b/ckanext/datagov_inventory/plugin.py index a8c8e65c..9c810e8d 100644 --- a/ckanext/datagov_inventory/plugin.py +++ b/ckanext/datagov_inventory/plugin.py @@ -8,6 +8,7 @@ from ckan.logic.auth.get import package_show from ckan.plugins.toolkit import config import ckan.authz as authz +from ckanext.datagov_inventory import action from flask import Blueprint, redirect, session import logging @@ -81,8 +82,15 @@ def inventory_package_show(context, data_dict): return package_show(context, data_dict) +def user_org_roles(context, data_dict): + if authz.is_sysadmin(context.get('user')): + return {'success': True} + return {'success': False} + + class Datagov_IauthfunctionsPlugin(plugins.SingletonPlugin): plugins.implements(plugins.IAuthFunctions) + plugins.implements(plugins.IActions) plugins.implements(plugins.IConfigurer) plugins.implements(plugins.IBlueprint) @@ -100,11 +108,15 @@ def get_auth_functions(self): 'tag_show': restrict_anon_access, 'task_status_show': restrict_anon_access, 'user_list': restrict_anon_access, + 'user_org_roles': user_org_roles, 'user_show': restrict_anon_access, 'vocabulary_list': restrict_anon_access, 'vocabulary_show': restrict_anon_access, } + def get_actions(self): + return {'user_org_roles': action.user_org_roles} + # render our custom 403 template def update_config(self, config): toolkit.add_template_directory(config, 'templates') diff --git a/ckanext/datagov_inventory/tests/logic/auth/test_auth.py b/ckanext/datagov_inventory/tests/logic/auth/test_auth.py index 87bd9db4..381acd17 100644 --- a/ckanext/datagov_inventory/tests/logic/auth/test_auth.py +++ b/ckanext/datagov_inventory/tests/logic/auth/test_auth.py @@ -72,6 +72,7 @@ def setup_test_orgs_users(self): # Create test users self.test_users = { + 'sysadmin': factories.Sysadmin(name='sysadmin'), 'gsa_admin': factories.User(name='gsa_admin'), 'gsa_editor': factories.User(name='gsa_editor'), 'gsa_member': factories.User(name='gsa_member'), @@ -365,6 +366,7 @@ def test_auth_user_list(self): self.setup_test_orgs_users() self.assert_user_authorization('user_list', { + 'sysadmin': is_allowed, 'gsa_admin': is_allowed, 'gsa_editor': is_allowed, 'gsa_member': is_allowed, @@ -384,6 +386,7 @@ def test_auth_user_show(self): self.setup_test_orgs_users() self.assert_user_authorization('user_show', { + 'sysadmin': is_allowed, 'gsa_admin': is_allowed, 'gsa_editor': is_allowed, 'gsa_member': is_allowed, @@ -439,3 +442,40 @@ def test_resource_show_request_private_dataset(self): with self.app.flask_app.test_request_context(test_url): assert inventory_package_show(context, data_dict) == {'success': False} + + def test_auth_user_org_roles(self): + self.setup_test_orgs_users() + + self.assert_user_authorization('user_org_roles', { + 'sysadmin': is_allowed, + 'gsa_admin': is_denied, + 'gsa_editor': is_denied, + 'gsa_member': is_denied, + 'doi_admin': is_denied, + 'doi_member': is_denied, + 'anonymous': is_denied + }) + + def test_user_org_roles(self): + self.setup_test_orgs_users() + factories.User(name='no_org_user') + + context = { + 'model': model, + 'ignore_auth': False, + 'user': self.test_users['sysadmin']['name'] + } + result = helpers.call_action('user_org_roles', context=context) + + users = {user['name']: user for user in result} + assert users['sysadmin']['sysadmin'] is True + assert users['gsa_admin']['organizations'][0]['name'] == 'gsa' + assert users['gsa_admin']['organizations'][0]['role'] == 'admin' + assert users['gsa_editor']['organizations'][0]['role'] == 'editor' + assert users['doi_member']['organizations'][0]['role'] == 'member' + assert users['no_org_user']['organizations'] == [] + + assert result.index(users['sysadmin']) < result.index( + users['gsa_admin']) + assert result.index(users['gsa_member']) < result.index( + users['no_org_user']) From 43b901393730c1668e6fc0e7ff31b1e7aea0b21b Mon Sep 17 00:00:00 2001 From: Fuhu Xia Date: Wed, 27 May 2026 11:14:57 -0400 Subject: [PATCH 02/16] add tempalte to display user_org_roles in table --- ckanext/datagov_inventory/plugin.py | 86 +++++++++++++ .../templates/user_org_roles_table.html | 114 ++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 ckanext/datagov_inventory/templates/user_org_roles_table.html diff --git a/ckanext/datagov_inventory/plugin.py b/ckanext/datagov_inventory/plugin.py index 9c810e8d..c4587143 100644 --- a/ckanext/datagov_inventory/plugin.py +++ b/ckanext/datagov_inventory/plugin.py @@ -1,6 +1,7 @@ import ckan.plugins as plugins import ckan.plugins.toolkit as toolkit import ckan.logic as logic +import ckan.model as model from ckan.model import User from ckan.common import _, g, current_user, request as ckan_request import ckan.lib.base as base @@ -138,6 +139,91 @@ def redirect_homepage(): pusher.add_url_rule('/', view_func=redirect_homepage) +def user_org_roles_table(): + context = { + 'model': model, + 'ignore_auth': False, + 'user': g.user, + } + try: + users = toolkit.get_action('user_org_roles')(context, {}) + except logic.NotAuthorized: + toolkit.abort(403, _('Not authorized to list user organization roles')) + + return base.render( + u'user_org_roles_table.html', + {'sections': user_org_roles_table_sections(users)} + ) + + +pusher.add_url_rule( + '/api/action/user_org_roles_table', + view_func=user_org_roles_table +) + + +def user_org_roles_table_sections(users): + sysadmins = [user for user in users if user['sysadmin']] + users_with_orgs = [ + user for user in users if not user['sysadmin'] and user['organizations'] + ] + users_without_orgs = [ + user for user in users if not user['sysadmin'] and not user['organizations'] + ] + + return [ + _user_org_roles_section('Sysadmin', sysadmins, + ['user', 'email', 'organization', 'role']), + _user_org_roles_section('User with org', users_with_orgs, + ['user', 'email', 'organization', 'role'], + sortable=True), + _user_org_roles_section('User without orgs', users_without_orgs, + ['user', 'email']), + ] + + +def _user_org_roles_section(title, users, columns, sortable=False): + rows = [] + for user in users: + organizations = user['organizations'] or [{ + 'name': '', + 'title': '', + 'role': '', + }] + for organization in organizations: + rows.append(_user_org_roles_row_values(user, organization, columns)) + + return { + 'title': title, + 'columns': columns, + 'labels': _user_org_roles_column_labels(columns), + 'rows': rows, + 'sortable': sortable, + } + + +def _user_org_roles_column_labels(columns): + labels = { + 'user': 'User', + 'email': 'Email', + 'sysadmin': 'Sysadmin', + 'organization': 'Organization', + 'role': 'Role', + } + return [labels[column] for column in columns] + + +def _user_org_roles_row_values(user, organization, columns): + values = { + 'user': user['name'] or '', + 'email': user['email'] or '', + 'sysadmin': 'yes' if user['sysadmin'] else 'no', + 'organization': organization['name'] or '', + 'role': organization['role'] or '', + } + return [values[column] for column in columns] + + @pusher.before_app_request def check_dataset_access(): if toolkit.request.path in ('/dataset/', '/dataset'): diff --git a/ckanext/datagov_inventory/templates/user_org_roles_table.html b/ckanext/datagov_inventory/templates/user_org_roles_table.html new file mode 100644 index 00000000..31525527 --- /dev/null +++ b/ckanext/datagov_inventory/templates/user_org_roles_table.html @@ -0,0 +1,114 @@ + + + + User organization roles + + + +

User organization roles

+ + {% for section in sections %} +

{{ section.title }}

+ + + + {% for label in section.labels %} + {% set column = section.columns[loop.index0] %} + {% if section.sortable and column in ('user', 'organization') %} + + + + {% else %} + {{ label }} + {% endif %} + {% endfor %} + + + + {% for row in section.rows %} + + {% for value in row %} + {{ value }} + {% endfor %} + + {% else %} + + No users + + {% endfor %} + + + {% endfor %} + + + + From 9e2de6f6d9bb36681fd715c2f37ab3d8a4dd9163 Mon Sep 17 00:00:00 2001 From: Fuhu Xia Date: Wed, 27 May 2026 11:15:02 -0400 Subject: [PATCH 03/16] add style --- .../datagov_inventory/templates/user_org_roles_table.html | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ckanext/datagov_inventory/templates/user_org_roles_table.html b/ckanext/datagov_inventory/templates/user_org_roles_table.html index 31525527..5d5ba903 100644 --- a/ckanext/datagov_inventory/templates/user_org_roles_table.html +++ b/ckanext/datagov_inventory/templates/user_org_roles_table.html @@ -10,11 +10,16 @@ th button { background: transparent; border: 0; + color: #005ea8; cursor: pointer; font: inherit; font-weight: bold; padding: 0; + text-decoration: underline; } + th.sortable { background: #e7f6ff; } + th.sortable button:hover, + th.sortable button:focus { color: #1a4480; } tr:nth-child(even) { background: #fafafa; } h2 { margin-top: 2rem; } @@ -30,7 +35,7 @@

{{ section.title }}

{% for label in section.labels %} {% set column = section.columns[loop.index0] %} {% if section.sortable and column in ('user', 'organization') %} - + {% else %} From 3269bb1e993b73a1321222665d7fdeb37cb1bc33 Mon Sep 17 00:00:00 2001 From: Fuhu Xia Date: Wed, 27 May 2026 11:15:09 -0400 Subject: [PATCH 04/16] link org --- ckanext/datagov_inventory/plugin.py | 17 ++++++++++++++++- .../templates/user_org_roles_table.html | 11 +++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/ckanext/datagov_inventory/plugin.py b/ckanext/datagov_inventory/plugin.py index c4587143..b658955b 100644 --- a/ckanext/datagov_inventory/plugin.py +++ b/ckanext/datagov_inventory/plugin.py @@ -14,6 +14,7 @@ from flask import Blueprint, redirect, session import logging import re +from urllib.parse import quote log = logging.getLogger(__name__) pusher = Blueprint('datagov_inventory', __name__) @@ -221,7 +222,21 @@ def _user_org_roles_row_values(user, organization, columns): 'organization': organization['name'] or '', 'role': organization['role'] or '', } - return [values[column] for column in columns] + return [ + { + 'value': values[column], + 'url': _organization_manage_members_url(organization) + if column == 'organization' else '', + } + for column in columns + ] + + +def _organization_manage_members_url(organization): + name = organization['name'] or '' + if not name: + return '' + return '/organization/manage_members/{}'.format(quote(name)) @pusher.before_app_request diff --git a/ckanext/datagov_inventory/templates/user_org_roles_table.html b/ckanext/datagov_inventory/templates/user_org_roles_table.html index 5d5ba903..f7f6969a 100644 --- a/ckanext/datagov_inventory/templates/user_org_roles_table.html +++ b/ckanext/datagov_inventory/templates/user_org_roles_table.html @@ -20,6 +20,7 @@ th.sortable { background: #e7f6ff; } th.sortable button:hover, th.sortable button:focus { color: #1a4480; } + td a { color: #005ea8; } tr:nth-child(even) { background: #fafafa; } h2 { margin-top: 2rem; } @@ -47,8 +48,14 @@

{{ section.title }}

{% for row in section.rows %} - {% for value in row %} - {{ value }} + {% for cell in row %} + + {% if cell.url %} + {{ cell.value }} + {% else %} + {{ cell.value }} + {% endif %} + {% endfor %} {% else %} From 5538e21cd5b21c482cbb8b4b73216c92cdb7cae9 Mon Sep 17 00:00:00 2001 From: Fuhu Xia Date: Wed, 27 May 2026 11:19:17 -0400 Subject: [PATCH 05/16] lint --- ckanext/datagov_inventory/plugin.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/ckanext/datagov_inventory/plugin.py b/ckanext/datagov_inventory/plugin.py index b658955b..0a2d3953 100644 --- a/ckanext/datagov_inventory/plugin.py +++ b/ckanext/datagov_inventory/plugin.py @@ -166,10 +166,12 @@ def user_org_roles_table(): def user_org_roles_table_sections(users): sysadmins = [user for user in users if user['sysadmin']] users_with_orgs = [ - user for user in users if not user['sysadmin'] and user['organizations'] + user for user in users + if not user['sysadmin'] and user['organizations'] ] users_without_orgs = [ - user for user in users if not user['sysadmin'] and not user['organizations'] + user for user in users + if not user['sysadmin'] and not user['organizations'] ] return [ @@ -192,7 +194,9 @@ def _user_org_roles_section(title, users, columns, sortable=False): 'role': '', }] for organization in organizations: - rows.append(_user_org_roles_row_values(user, organization, columns)) + rows.append( + _user_org_roles_row_values(user, organization, columns) + ) return { 'title': title, From 8265c61ae90de8bedc699077efa63f1c017a41e0 Mon Sep 17 00:00:00 2001 From: Fuhu Xia Date: Wed, 27 May 2026 12:50:06 -0400 Subject: [PATCH 06/16] link user --- ckanext/datagov_inventory/plugin.py | 21 +++++++++-- .../templates/user_org_roles_table.html | 36 ++++++++++--------- 2 files changed, 37 insertions(+), 20 deletions(-) diff --git a/ckanext/datagov_inventory/plugin.py b/ckanext/datagov_inventory/plugin.py index 0a2d3953..eec85f56 100644 --- a/ckanext/datagov_inventory/plugin.py +++ b/ckanext/datagov_inventory/plugin.py @@ -181,7 +181,8 @@ def user_org_roles_table_sections(users): ['user', 'email', 'organization', 'role'], sortable=True), _user_org_roles_section('User without orgs', users_without_orgs, - ['user', 'email']), + ['user', 'email'], + sortable=True), ] @@ -229,13 +230,27 @@ def _user_org_roles_row_values(user, organization, columns): return [ { 'value': values[column], - 'url': _organization_manage_members_url(organization) - if column == 'organization' else '', + 'url': _user_org_roles_cell_url(column, user, organization), } for column in columns ] +def _user_org_roles_cell_url(column, user, organization): + if column == 'user': + return _user_url(user) + if column == 'organization': + return _organization_manage_members_url(organization) + return '' + + +def _user_url(user): + name = user['name'] or '' + if not name: + return '' + return '/user/{}'.format(quote(name)) + + def _organization_manage_members_url(organization): name = organization['name'] or '' if not name: diff --git a/ckanext/datagov_inventory/templates/user_org_roles_table.html b/ckanext/datagov_inventory/templates/user_org_roles_table.html index f7f6969a..36736499 100644 --- a/ckanext/datagov_inventory/templates/user_org_roles_table.html +++ b/ckanext/datagov_inventory/templates/user_org_roles_table.html @@ -30,12 +30,12 @@

User organization roles

{% for section in sections %}

{{ section.title }}

- + {% for label in section.labels %} {% set column = section.columns[loop.index0] %} - {% if section.sortable and column in ('user', 'organization') %} + {% if section.sortable and column in ('user', 'email', 'organization', 'role') %} @@ -69,19 +69,11 @@

{{ section.title }}

From a0b8bcdc33eff73a170a20358799f410153213b2 Mon Sep 17 00:00:00 2001 From: Fuhu Xia Date: Wed, 27 May 2026 12:50:13 -0400 Subject: [PATCH 07/16] add last_active --- ckanext/datagov_inventory/action.py | 7 +++++++ ckanext/datagov_inventory/plugin.py | 10 +++++++--- .../templates/user_org_roles_table.html | 2 +- .../datagov_inventory/tests/logic/auth/test_auth.py | 1 + 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/ckanext/datagov_inventory/action.py b/ckanext/datagov_inventory/action.py index e8773009..e4a71f8a 100644 --- a/ckanext/datagov_inventory/action.py +++ b/ckanext/datagov_inventory/action.py @@ -23,6 +23,7 @@ def user_org_roles(context, data_dict): 'name': user.name, 'fullname': user.fullname, 'email': user.email, + 'last_active': _format_datetime(user.last_active), 'sysadmin': user.sysadmin, 'organizations': organizations, }) @@ -30,6 +31,12 @@ def user_org_roles(context, data_dict): return sorted(result, key=_user_sort_key) +def _format_datetime(value): + if not value: + return '' + return value.replace(microsecond=0).isoformat(sep=' ') + + def _org_roles_by_user(): """ Retrieve a dictionary mapping user IDs to their roles within organizations. diff --git a/ckanext/datagov_inventory/plugin.py b/ckanext/datagov_inventory/plugin.py index eec85f56..3f9800a1 100644 --- a/ckanext/datagov_inventory/plugin.py +++ b/ckanext/datagov_inventory/plugin.py @@ -176,12 +176,14 @@ def user_org_roles_table_sections(users): return [ _user_org_roles_section('Sysadmin', sysadmins, - ['user', 'email', 'organization', 'role']), + ['user', 'email', 'last_active', + 'organization', 'role']), _user_org_roles_section('User with org', users_with_orgs, - ['user', 'email', 'organization', 'role'], + ['user', 'email', 'last_active', + 'organization', 'role'], sortable=True), _user_org_roles_section('User without orgs', users_without_orgs, - ['user', 'email'], + ['user', 'email', 'last_active'], sortable=True), ] @@ -212,6 +214,7 @@ def _user_org_roles_column_labels(columns): labels = { 'user': 'User', 'email': 'Email', + 'last_active': 'Last Active', 'sysadmin': 'Sysadmin', 'organization': 'Organization', 'role': 'Role', @@ -223,6 +226,7 @@ def _user_org_roles_row_values(user, organization, columns): values = { 'user': user['name'] or '', 'email': user['email'] or '', + 'last_active': user['last_active'] or '', 'sysadmin': 'yes' if user['sysadmin'] else 'no', 'organization': organization['name'] or '', 'role': organization['role'] or '', diff --git a/ckanext/datagov_inventory/templates/user_org_roles_table.html b/ckanext/datagov_inventory/templates/user_org_roles_table.html index 36736499..efc6145e 100644 --- a/ckanext/datagov_inventory/templates/user_org_roles_table.html +++ b/ckanext/datagov_inventory/templates/user_org_roles_table.html @@ -35,7 +35,7 @@

{{ section.title }}

{% for label in section.labels %} {% set column = section.columns[loop.index0] %} - {% if section.sortable and column in ('user', 'email', 'organization', 'role') %} + {% if section.sortable and column in ('user', 'email', 'last_active', 'organization', 'role') %} diff --git a/ckanext/datagov_inventory/tests/logic/auth/test_auth.py b/ckanext/datagov_inventory/tests/logic/auth/test_auth.py index 381acd17..ed4b0fef 100644 --- a/ckanext/datagov_inventory/tests/logic/auth/test_auth.py +++ b/ckanext/datagov_inventory/tests/logic/auth/test_auth.py @@ -469,6 +469,7 @@ def test_user_org_roles(self): users = {user['name']: user for user in result} assert users['sysadmin']['sysadmin'] is True + assert 'last_active' in users['sysadmin'] assert users['gsa_admin']['organizations'][0]['name'] == 'gsa' assert users['gsa_admin']['organizations'][0]['role'] == 'admin' assert users['gsa_editor']['organizations'][0]['role'] == 'editor' From 65a4bf01b6827e658717ced2c1bdd46fe322b629 Mon Sep 17 00:00:00 2001 From: Fuhu Xia Date: Thu, 28 May 2026 10:20:04 -0400 Subject: [PATCH 08/16] move table to /user/user-org-roles --- .../fanstatic/styles/datagov_inventory.css | 40 ++- ckanext/datagov_inventory/plugin.py | 2 +- .../templates/user/snippets/user_search.html | 18 ++ .../templates/user_org_roles_table.html | 230 +++++++++--------- 4 files changed, 171 insertions(+), 119 deletions(-) create mode 100644 ckanext/datagov_inventory/templates/user/snippets/user_search.html diff --git a/ckanext/datagov_inventory/fanstatic/styles/datagov_inventory.css b/ckanext/datagov_inventory/fanstatic/styles/datagov_inventory.css index 3da9ce71..e87d0301 100644 --- a/ckanext/datagov_inventory/fanstatic/styles/datagov_inventory.css +++ b/ckanext/datagov_inventory/fanstatic/styles/datagov_inventory.css @@ -16,4 +16,42 @@ font-weight: 700; float: right; padding-right: 5px; -} \ No newline at end of file +} + +.user-org-roles .page-heading { + margin-bottom: 24px; +} + +.user-org-roles-section + .user-org-roles-section { + margin-top: 32px; +} + +.user-org-roles-section h2 { + font-size: 20px; + margin-bottom: 12px; +} + +.user-org-roles .table { + margin-bottom: 0; +} + +.user-org-roles th.sortable { + background-color: #f5f5f5; +} + +.user-org-roles-sort { + background: transparent; + border: 0; + color: #005ea8; + cursor: pointer; + font: inherit; + font-weight: 700; + padding: 0; + text-align: left; +} + +.user-org-roles-sort:hover, +.user-org-roles-sort:focus { + color: #1a4480; + text-decoration: underline; +} diff --git a/ckanext/datagov_inventory/plugin.py b/ckanext/datagov_inventory/plugin.py index 3f9800a1..2683a08a 100644 --- a/ckanext/datagov_inventory/plugin.py +++ b/ckanext/datagov_inventory/plugin.py @@ -158,7 +158,7 @@ def user_org_roles_table(): pusher.add_url_rule( - '/api/action/user_org_roles_table', + '/user/user-org-roles', view_func=user_org_roles_table ) diff --git a/ckanext/datagov_inventory/templates/user/snippets/user_search.html b/ckanext/datagov_inventory/templates/user/snippets/user_search.html new file mode 100644 index 00000000..cae31be4 --- /dev/null +++ b/ckanext/datagov_inventory/templates/user/snippets/user_search.html @@ -0,0 +1,18 @@ +
+

{{ _('Users') }}

+
+
+ + + +
+
+ +
diff --git a/ckanext/datagov_inventory/templates/user_org_roles_table.html b/ckanext/datagov_inventory/templates/user_org_roles_table.html index efc6145e..cc9b54f8 100644 --- a/ckanext/datagov_inventory/templates/user_org_roles_table.html +++ b/ckanext/datagov_inventory/templates/user_org_roles_table.html @@ -1,128 +1,124 @@ - - - - User organization roles - - - -

User organization roles

+{% extends "page.html" %} - {% for section in sections %} -

{{ section.title }}

- - - - {% for label in section.labels %} - {% set column = section.columns[loop.index0] %} - {% if section.sortable and column in ('user', 'email', 'last_active', 'organization', 'role') %} - - - - {% else %} - {{ label }} - {% endif %} - {% endfor %} - - - - {% for row in section.rows %} - - {% for cell in row %} - - {% if cell.url %} - {{ cell.value }} - {% else %} - {{ cell.value }} - {% endif %} - - {% endfor %} - - {% else %} - - No users - - {% endfor %} - - - {% endfor %} +{% block subtitle %}{{ _('User Roles in Organizations') }}{% endblock %} - - - + updateHeaders(buttons, sortState); + }); + }()); + +{% endblock %} From 57f650b2273db8180a698591f4a6a70c89484566 Mon Sep 17 00:00:00 2001 From: Fuhu Xia Date: Thu, 28 May 2026 10:57:42 -0400 Subject: [PATCH 09/16] add test --- e2e/cypress/integration/user_org_roles.cy.js | 36 ++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 e2e/cypress/integration/user_org_roles.cy.js diff --git a/e2e/cypress/integration/user_org_roles.cy.js b/e2e/cypress/integration/user_org_roles.cy.js new file mode 100644 index 00000000..b3600b8d --- /dev/null +++ b/e2e/cypress/integration/user_org_roles.cy.js @@ -0,0 +1,36 @@ +describe('User organization roles', () => { + + beforeEach(() => { + cy.login(); + }); + + it('highlights All Users on the user list page', () => { + cy.visit('/user/'); + + cy.title().should('include', 'All Users'); + cy.get('.secondary .nav-simple .nav-item.active') + .should('contain', 'All Users') + .and('not.contain', 'User Roles in Organizations'); + cy.get('.secondary .nav-simple') + .contains('a', 'User Roles in Organizations') + .should('have.attr', 'href', '/user/user-org-roles'); + }); + + it('renders and highlights User Roles in Organizations', () => { + cy.visit('/user/user-org-roles'); + + cy.title().should('include', 'User Roles in Organizations'); + cy.get('.breadcrumb .active') + .should('contain', 'User Roles in Organizations'); + cy.get('.secondary .nav-simple .nav-item.active') + .should('contain', 'User Roles in Organizations') + .and('not.contain', 'All Users'); + cy.get('article.user-org-roles') + .should('contain', 'User Roles in Organizations'); + cy.get('article.user-org-roles table.table-header') + .should('exist'); + cy.get('article.user-org-roles .user-org-roles-sort') + .should('exist'); + }); + +}); From 4a35595cf9152d59bd84e4dab051f521ba6089d9 Mon Sep 17 00:00:00 2001 From: Fuhu Xia Date: Thu, 28 May 2026 11:19:57 -0400 Subject: [PATCH 10/16] polish the table --- .../fanstatic/styles/datagov_inventory.css | 44 ++++++++++++++++++- ckanext/datagov_inventory/plugin.py | 13 ++++-- .../templates/user_org_roles_table.html | 19 ++++++-- e2e/cypress/integration/user_org_roles.cy.js | 9 ++++ 4 files changed, 76 insertions(+), 9 deletions(-) diff --git a/ckanext/datagov_inventory/fanstatic/styles/datagov_inventory.css b/ckanext/datagov_inventory/fanstatic/styles/datagov_inventory.css index e87d0301..4af68da4 100644 --- a/ckanext/datagov_inventory/fanstatic/styles/datagov_inventory.css +++ b/ckanext/datagov_inventory/fanstatic/styles/datagov_inventory.css @@ -19,16 +19,56 @@ } .user-org-roles .page-heading { + margin-bottom: 16px; +} + +.user-org-roles-summary { + border-bottom: 1px solid #ddd; margin-bottom: 24px; + padding-bottom: 16px; +} + +.user-org-roles-summary ul { + display: flex; + flex-wrap: wrap; + gap: 8px; + list-style: none; + margin: 0; + padding: 0; +} + +.user-org-roles-summary a { + background-color: #f5f5f5; + border: 1px solid #ddd; + border-radius: 4px; + display: inline-block; + padding: 6px 10px; +} + +.user-org-roles-summary a:hover, +.user-org-roles-summary a:focus { + background-color: #fff; + border-color: #005ea8; + text-decoration: none; +} + +.user-org-roles-summary span, +.user-org-roles-section h2 span { + color: #555; + font-size: 14px; + font-weight: 400; + margin-left: 4px; } .user-org-roles-section + .user-org-roles-section { - margin-top: 32px; + margin-top: 40px; } .user-org-roles-section h2 { + border-bottom: 1px solid #ddd; font-size: 20px; - margin-bottom: 12px; + margin-bottom: 16px; + padding-bottom: 8px; } .user-org-roles .table { diff --git a/ckanext/datagov_inventory/plugin.py b/ckanext/datagov_inventory/plugin.py index 2683a08a..1122f5fe 100644 --- a/ckanext/datagov_inventory/plugin.py +++ b/ckanext/datagov_inventory/plugin.py @@ -175,20 +175,23 @@ def user_org_roles_table_sections(users): ] return [ - _user_org_roles_section('Sysadmin', sysadmins, + _user_org_roles_section('Sysadmins', 'sysadmins', sysadmins, ['user', 'email', 'last_active', 'organization', 'role']), - _user_org_roles_section('User with org', users_with_orgs, + _user_org_roles_section('Users with organizations', + 'users-with-organizations', users_with_orgs, ['user', 'email', 'last_active', 'organization', 'role'], sortable=True), - _user_org_roles_section('User without orgs', users_without_orgs, + _user_org_roles_section('Users without organizations', + 'users-without-organizations', + users_without_orgs, ['user', 'email', 'last_active'], sortable=True), ] -def _user_org_roles_section(title, users, columns, sortable=False): +def _user_org_roles_section(title, section_id, users, columns, sortable=False): rows = [] for user in users: organizations = user['organizations'] or [{ @@ -202,8 +205,10 @@ def _user_org_roles_section(title, users, columns, sortable=False): ) return { + 'id': section_id, 'title': title, 'columns': columns, + 'count': len(rows), 'labels': _user_org_roles_column_labels(columns), 'rows': rows, 'sortable': sortable, diff --git a/ckanext/datagov_inventory/templates/user_org_roles_table.html b/ckanext/datagov_inventory/templates/user_org_roles_table.html index cc9b54f8..83c481d1 100644 --- a/ckanext/datagov_inventory/templates/user_org_roles_table.html +++ b/ckanext/datagov_inventory/templates/user_org_roles_table.html @@ -12,9 +12,22 @@

{{ _('User Roles in Organizations') }}

+ + {% for section in sections %} -
-

{{ section.title }}

+
+

{{ section.title }} {{ section.count }} {{ _('rows') }}

@@ -75,7 +88,7 @@

{{ section.title }}

var th = button.closest('th'); var indicator = ''; if (Number(th.dataset.sortIndex) === sortState.index) { - indicator = sortState.direction === 'asc' ? ' (asc)' : ' (desc)'; + indicator = sortState.direction === 'asc' ? ' ▲' : ' ▼'; } button.textContent = th.dataset.label + indicator; }); diff --git a/e2e/cypress/integration/user_org_roles.cy.js b/e2e/cypress/integration/user_org_roles.cy.js index b3600b8d..279e9cdd 100644 --- a/e2e/cypress/integration/user_org_roles.cy.js +++ b/e2e/cypress/integration/user_org_roles.cy.js @@ -27,6 +27,15 @@ describe('User organization roles', () => { .and('not.contain', 'All Users'); cy.get('article.user-org-roles') .should('contain', 'User Roles in Organizations'); + cy.get('.user-org-roles-summary') + .contains('a', 'Sysadmins') + .should('have.attr', 'href', '#sysadmins'); + cy.get('.user-org-roles-summary') + .contains('a', 'Users with organizations') + .should('have.attr', 'href', '#users-with-organizations'); + cy.get('.user-org-roles-summary') + .contains('a', 'Users without organizations') + .should('have.attr', 'href', '#users-without-organizations'); cy.get('article.user-org-roles table.table-header') .should('exist'); cy.get('article.user-org-roles .user-org-roles-sort') From 5da8597f08d26283e7267c1bf487e923728022ca Mon Sep 17 00:00:00 2001 From: Fuhu Xia Date: Thu, 28 May 2026 11:41:19 -0400 Subject: [PATCH 11/16] show deleted users --- ckanext/datagov_inventory/action.py | 9 ++++++--- .../fanstatic/styles/datagov_inventory.css | 9 +++++++++ ckanext/datagov_inventory/plugin.py | 12 +++++++++--- .../templates/user_org_roles_table.html | 5 ++++- .../datagov_inventory/tests/logic/auth/test_auth.py | 5 +++++ e2e/cypress/integration/user_org_roles.cy.js | 9 +++++++++ 6 files changed, 42 insertions(+), 7 deletions(-) diff --git a/ckanext/datagov_inventory/action.py b/ckanext/datagov_inventory/action.py index e4a71f8a..4ad60984 100644 --- a/ckanext/datagov_inventory/action.py +++ b/ckanext/datagov_inventory/action.py @@ -4,12 +4,12 @@ @toolkit.side_effect_free def user_org_roles(context, data_dict): - """Return active users with organization roles, grouped by priority.""" + """Return users with organization roles, grouped by priority.""" toolkit.check_access('user_org_roles', context, data_dict) org_roles_by_user = _org_roles_by_user() users = model.Session.query(model.User).filter( - model.User.state == 'active' + model.User.state.in_([model.State.ACTIVE, model.State.DELETED]) ).all() result = [] @@ -24,6 +24,7 @@ def user_org_roles(context, data_dict): 'fullname': user.fullname, 'email': user.email, 'last_active': _format_datetime(user.last_active), + 'state': user.state, 'sysadmin': user.sysadmin, 'organizations': organizations, }) @@ -64,7 +65,9 @@ def _org_roles_by_user(): def _user_sort_key(user): - if user['sysadmin']: + if user['state'] == model.State.DELETED: + group = 3 + elif user['sysadmin']: group = 0 elif user['organizations']: group = 1 diff --git a/ckanext/datagov_inventory/fanstatic/styles/datagov_inventory.css b/ckanext/datagov_inventory/fanstatic/styles/datagov_inventory.css index 4af68da4..96a0b7d0 100644 --- a/ckanext/datagov_inventory/fanstatic/styles/datagov_inventory.css +++ b/ckanext/datagov_inventory/fanstatic/styles/datagov_inventory.css @@ -75,6 +75,15 @@ margin-bottom: 0; } +.user-org-roles-back-to-top { + margin: 10px 0 0; + text-align: right; +} + +.user-org-roles-back-to-top a { + font-size: 14px; +} + .user-org-roles th.sortable { background-color: #f5f5f5; } diff --git a/ckanext/datagov_inventory/plugin.py b/ckanext/datagov_inventory/plugin.py index 1122f5fe..0d0486cc 100644 --- a/ckanext/datagov_inventory/plugin.py +++ b/ckanext/datagov_inventory/plugin.py @@ -164,13 +164,15 @@ def user_org_roles_table(): def user_org_roles_table_sections(users): - sysadmins = [user for user in users if user['sysadmin']] + active_users = [user for user in users if user['state'] == 'active'] + deleted_users = [user for user in users if user['state'] == 'deleted'] + sysadmins = [user for user in active_users if user['sysadmin']] users_with_orgs = [ - user for user in users + user for user in active_users if not user['sysadmin'] and user['organizations'] ] users_without_orgs = [ - user for user in users + user for user in active_users if not user['sysadmin'] and not user['organizations'] ] @@ -188,6 +190,10 @@ def user_org_roles_table_sections(users): users_without_orgs, ['user', 'email', 'last_active'], sortable=True), + _user_org_roles_section('Deleted Users', 'deleted-users', + deleted_users, + ['user', 'email', 'last_active'], + sortable=True), ] diff --git a/ckanext/datagov_inventory/templates/user_org_roles_table.html b/ckanext/datagov_inventory/templates/user_org_roles_table.html index 83c481d1..274eec21 100644 --- a/ckanext/datagov_inventory/templates/user_org_roles_table.html +++ b/ckanext/datagov_inventory/templates/user_org_roles_table.html @@ -10,7 +10,7 @@ {% block primary_content %}
-

{{ _('User Roles in Organizations') }}

+

{{ _('User Roles in Organizations') }}

+

+ {{ _('Go to top') }} +

{% endfor %}
diff --git a/ckanext/datagov_inventory/tests/logic/auth/test_auth.py b/ckanext/datagov_inventory/tests/logic/auth/test_auth.py index ed4b0fef..9dda3b40 100644 --- a/ckanext/datagov_inventory/tests/logic/auth/test_auth.py +++ b/ckanext/datagov_inventory/tests/logic/auth/test_auth.py @@ -459,6 +459,7 @@ def test_auth_user_org_roles(self): def test_user_org_roles(self): self.setup_test_orgs_users() factories.User(name='no_org_user') + factories.User(name='deleted_user', state='deleted') context = { 'model': model, @@ -475,8 +476,12 @@ def test_user_org_roles(self): assert users['gsa_editor']['organizations'][0]['role'] == 'editor' assert users['doi_member']['organizations'][0]['role'] == 'member' assert users['no_org_user']['organizations'] == [] + assert users['deleted_user']['state'] == 'deleted' + assert users['deleted_user']['organizations'] == [] assert result.index(users['sysadmin']) < result.index( users['gsa_admin']) assert result.index(users['gsa_member']) < result.index( users['no_org_user']) + assert result.index(users['no_org_user']) < result.index( + users['deleted_user']) diff --git a/e2e/cypress/integration/user_org_roles.cy.js b/e2e/cypress/integration/user_org_roles.cy.js index 279e9cdd..29889072 100644 --- a/e2e/cypress/integration/user_org_roles.cy.js +++ b/e2e/cypress/integration/user_org_roles.cy.js @@ -36,8 +36,17 @@ describe('User organization roles', () => { cy.get('.user-org-roles-summary') .contains('a', 'Users without organizations') .should('have.attr', 'href', '#users-without-organizations'); + cy.get('.user-org-roles-summary') + .contains('a', 'Deleted Users') + .should('have.attr', 'href', '#deleted-users'); cy.get('article.user-org-roles table.table-header') .should('exist'); + cy.get('article.user-org-roles .user-org-roles-section') + .each(($section) => { + cy.wrap($section) + .contains('a', 'Go to top') + .should('have.attr', 'href', '#user-org-roles-top'); + }); cy.get('article.user-org-roles .user-org-roles-sort') .should('exist'); }); From 34e38ea513f39d03f2877d91ce15af9a451bc679 Mon Sep 17 00:00:00 2001 From: Fuhu Xia Date: Thu, 28 May 2026 14:13:54 -0400 Subject: [PATCH 12/16] docstring to user sort catagories --- ckanext/datagov_inventory/action.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/ckanext/datagov_inventory/action.py b/ckanext/datagov_inventory/action.py index 4ad60984..0a944674 100644 --- a/ckanext/datagov_inventory/action.py +++ b/ckanext/datagov_inventory/action.py @@ -4,7 +4,7 @@ @toolkit.side_effect_free def user_org_roles(context, data_dict): - """Return users with organization roles, grouped by priority.""" + """Return users with organization roles, grouped by catagories.""" toolkit.check_access('user_org_roles', context, data_dict) org_roles_by_user = _org_roles_by_user() @@ -65,6 +65,14 @@ def _org_roles_by_user(): def _user_sort_key(user): + """Sort users by display category, then by username within each category. + + The category order is: + 1. active sysadmins + 2. active users with at least one organization role + 3. active users without organization roles + 4. deleted users + """ if user['state'] == model.State.DELETED: group = 3 elif user['sysadmin']: From 0cf133f4157c6b2b6d4975461ade386e0ff0d2e7 Mon Sep 17 00:00:00 2001 From: Fuhu Xia Date: Thu, 28 May 2026 14:14:16 -0400 Subject: [PATCH 13/16] move js to a file --- .../fanstatic/scripts/datagov_inventory.js | 55 ++++++++++++++++++ .../datagov_inventory/fanstatic/webassets.yml | 5 ++ .../templates/user_org_roles_table.html | 58 +------------------ 3 files changed, 61 insertions(+), 57 deletions(-) create mode 100644 ckanext/datagov_inventory/fanstatic/scripts/datagov_inventory.js diff --git a/ckanext/datagov_inventory/fanstatic/scripts/datagov_inventory.js b/ckanext/datagov_inventory/fanstatic/scripts/datagov_inventory.js new file mode 100644 index 00000000..e551f219 --- /dev/null +++ b/ckanext/datagov_inventory/fanstatic/scripts/datagov_inventory.js @@ -0,0 +1,55 @@ +(function () { + function cellText(row, index) { + return row.children[index].textContent.trim().toLowerCase(); + } + + function updateHeaders(buttons, sortState) { + buttons.forEach(function (button) { + var th = button.closest('th'); + var indicator = ''; + if (Number(th.dataset.sortIndex) === sortState.index) { + indicator = sortState.direction === 'asc' ? ' \u25B2' : ' \u25BC'; + } + button.textContent = th.dataset.label + indicator; + }); + } + + function sortTable(table, buttons, sortState, index) { + if (sortState.index === index) { + sortState.direction = sortState.direction === 'asc' ? 'desc' : 'asc'; + } else { + sortState.index = index; + sortState.direction = 'asc'; + } + + var tbody = table.querySelector('tbody'); + var rows = Array.prototype.slice.call(tbody.querySelectorAll('tr')); + rows.sort(function (left, right) { + var leftText = cellText(left, index); + var rightText = cellText(right, index); + var comparison = leftText.localeCompare(rightText); + return sortState.direction === 'asc' ? comparison : -comparison; + }); + rows.forEach(function (row) { + tbody.appendChild(row); + }); + updateHeaders(buttons, sortState); + } + + document.querySelectorAll('table[data-sortable-table]').forEach(function (table) { + var sortState = { index: 0, direction: 'asc' }; + var buttons = table.querySelectorAll('th[data-sort-index] button'); + + buttons.forEach(function (button) { + button.addEventListener('click', function () { + sortTable( + table, + buttons, + sortState, + Number(button.closest('th').dataset.sortIndex) + ); + }); + }); + updateHeaders(buttons, sortState); + }); +}()); diff --git a/ckanext/datagov_inventory/fanstatic/webassets.yml b/ckanext/datagov_inventory/fanstatic/webassets.yml index cf1e0e12..c0501970 100644 --- a/ckanext/datagov_inventory/fanstatic/webassets.yml +++ b/ckanext/datagov_inventory/fanstatic/webassets.yml @@ -2,3 +2,8 @@ styles: output: datagov_inventory/datagov_inventory.css contents: # list of files that are included into asset - styles/datagov_inventory.css + +scripts: + output: datagov_inventory/datagov_inventory.js + contents: + - scripts/datagov_inventory.js diff --git a/ckanext/datagov_inventory/templates/user_org_roles_table.html b/ckanext/datagov_inventory/templates/user_org_roles_table.html index 274eec21..754f90e9 100644 --- a/ckanext/datagov_inventory/templates/user_org_roles_table.html +++ b/ckanext/datagov_inventory/templates/user_org_roles_table.html @@ -80,61 +80,5 @@

{{ section.title }} {{ section.count }} {{ _('rows') }}

{% block scripts %} {{ super() }} - + {% asset 'datagov_inventory/scripts' %} {% endblock %} From f1ea83686616d4c0f5530ab59d469a010673fae9 Mon Sep 17 00:00:00 2001 From: Fuhu Xia Date: Thu, 28 May 2026 14:15:10 -0400 Subject: [PATCH 14/16] add rows to test --- e2e/cypress/integration/user_org_roles.cy.js | 24 ++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/e2e/cypress/integration/user_org_roles.cy.js b/e2e/cypress/integration/user_org_roles.cy.js index 29889072..8bee657e 100644 --- a/e2e/cypress/integration/user_org_roles.cy.js +++ b/e2e/cypress/integration/user_org_roles.cy.js @@ -43,6 +43,30 @@ describe('User organization roles', () => { .should('exist'); cy.get('article.user-org-roles .user-org-roles-section') .each(($section) => { + const sectionId = $section.attr('id'); + + cy.wrap($section) + .find('h2 span') + .invoke('text') + .then((text) => { + const match = text.match(/^(\d+)\s+rows$/); + expect(match, `row count for ${sectionId}`).to.not.be.null; + + const rowCount = Number(match[1]); + cy.get(`.user-org-roles-summary a[href="#${sectionId}"]`) + .should('contain', `${rowCount} rows`); + + cy.wrap($section) + .find('tbody tr') + .then(($rows) => { + if (rowCount === 0) { + expect($rows).to.have.length(1); + expect($rows.eq(0)).to.contain('No users'); + } else { + expect($rows).to.have.length(rowCount); + } + }); + }); cy.wrap($section) .contains('a', 'Go to top') .should('have.attr', 'href', '#user-org-roles-top'); From 34f8d3100c862d363e8f7cd10b4e399458ba6265 Mon Sep 17 00:00:00 2001 From: Fuhu Xia Date: Thu, 28 May 2026 14:24:18 -0400 Subject: [PATCH 15/16] test sorting --- e2e/cypress/integration/user_org_roles.cy.js | 48 ++++++++++++++++++++ e2e/cypress/support/command.js | 1 + 2 files changed, 49 insertions(+) diff --git a/e2e/cypress/integration/user_org_roles.cy.js b/e2e/cypress/integration/user_org_roles.cy.js index 8bee657e..3011d9cd 100644 --- a/e2e/cypress/integration/user_org_roles.cy.js +++ b/e2e/cypress/integration/user_org_roles.cy.js @@ -1,4 +1,31 @@ describe('User organization roles', () => { + const orgA = 'cypress-user-org-roles-a'; + const orgB = 'cypress-user-org-roles-b'; + const userA = 'gsa_admin'; + const userB = 'doi_admin'; + const userPassword = 'Password123!'; + + before(() => { + cy.create_token(); + cy.delete_user(userA); + cy.delete_user(userB); + cy.delete_organization(orgA); + cy.delete_organization(orgB); + cy.create_organization(orgA, 'Cypress user org roles A'); + cy.create_organization(orgB, 'Cypress user org roles B'); + cy.create_user(userA, 'gsa_admin@example.com', userPassword); + cy.create_user(userB, 'doi_admin@example.com', userPassword); + cy.assign_user(orgA, userA, 'admin'); + cy.assign_user(orgB, userB, 'admin'); + }); + + after(() => { + cy.delete_user(userA); + cy.delete_user(userB); + cy.delete_organization(orgA); + cy.delete_organization(orgB); + cy.revoke_token(); + }); beforeEach(() => { cy.login(); @@ -73,6 +100,27 @@ describe('User organization roles', () => { }); cy.get('article.user-org-roles .user-org-roles-sort') .should('exist'); + + cy.get('#users-with-organizations table[data-sortable-table]') + .within(() => { + cy.contains('button', 'Organization').click(); + cy.get('tbody tr').then(($rows) => { + const rowTexts = [...$rows].map((row) => row.innerText); + expect(rowTexts.findIndex((text) => text.includes(userA))) + .to.be.lessThan( + rowTexts.findIndex((text) => text.includes(userB)) + ); + }); + + cy.contains('button', 'Organization').click(); + cy.get('tbody tr').then(($rows) => { + const rowTexts = [...$rows].map((row) => row.innerText); + expect(rowTexts.findIndex((text) => text.includes(userB))) + .to.be.lessThan( + rowTexts.findIndex((text) => text.includes(userA)) + ); + }); + }); }); }); diff --git a/e2e/cypress/support/command.js b/e2e/cypress/support/command.js index 3a6d0e8f..158a0907 100644 --- a/e2e/cypress/support/command.js +++ b/e2e/cypress/support/command.js @@ -279,6 +279,7 @@ Cypress.Commands.add('delete_user', (userName) => { let request_obj = { method: 'POST', + failOnStatusCode: false, headers: { 'X-CKAN-API-Key': token_data.api_token, 'Content-Type': 'application/json' From 1cc5c2763af5844fda26da7b9ac980d791470e1f Mon Sep 17 00:00:00 2001 From: Fuhu Xia Date: Fri, 29 May 2026 15:00:28 -0400 Subject: [PATCH 16/16] trigger snyk