diff --git a/ckanext/datagov_inventory/action.py b/ckanext/datagov_inventory/action.py new file mode 100644 index 00000000..0a944674 --- /dev/null +++ b/ckanext/datagov_inventory/action.py @@ -0,0 +1,85 @@ +import ckan.model as model +import ckan.plugins.toolkit as toolkit + + +@toolkit.side_effect_free +def user_org_roles(context, data_dict): + """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() + users = model.Session.query(model.User).filter( + model.User.state.in_([model.State.ACTIVE, model.State.DELETED]) + ).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, + 'last_active': _format_datetime(user.last_active), + 'state': user.state, + 'sysadmin': user.sysadmin, + 'organizations': organizations, + }) + + 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. + """ + 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): + """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']: + group = 0 + elif user['organizations']: + group = 1 + else: + group = 2 + + return (group, (user['name'] or '').lower()) 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/styles/datagov_inventory.css b/ckanext/datagov_inventory/fanstatic/styles/datagov_inventory.css index 3da9ce71..96a0b7d0 100644 --- a/ckanext/datagov_inventory/fanstatic/styles/datagov_inventory.css +++ b/ckanext/datagov_inventory/fanstatic/styles/datagov_inventory.css @@ -16,4 +16,91 @@ font-weight: 700; float: right; padding-right: 5px; -} \ No newline at end of file +} + +.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: 40px; +} + +.user-org-roles-section h2 { + border-bottom: 1px solid #ddd; + font-size: 20px; + margin-bottom: 16px; + padding-bottom: 8px; +} + +.user-org-roles .table { + 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; +} + +.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/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/plugin.py b/ckanext/datagov_inventory/plugin.py index a8c8e65c..0d0486cc 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 @@ -8,10 +9,12 @@ 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 import re +from urllib.parse import quote log = logging.getLogger(__name__) pusher = Blueprint('datagov_inventory', __name__) @@ -81,8 +84,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 +110,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') @@ -126,6 +140,139 @@ 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( + '/user/user-org-roles', + view_func=user_org_roles_table +) + + +def user_org_roles_table_sections(users): + 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 active_users + if not user['sysadmin'] and user['organizations'] + ] + users_without_orgs = [ + user for user in active_users + if not user['sysadmin'] and not user['organizations'] + ] + + return [ + _user_org_roles_section('Sysadmins', 'sysadmins', sysadmins, + ['user', 'email', 'last_active', + 'organization', 'role']), + _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('Users without organizations', + 'users-without-organizations', + 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), + ] + + +def _user_org_roles_section(title, section_id, 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 { + 'id': section_id, + 'title': title, + 'columns': columns, + 'count': len(rows), + 'labels': _user_org_roles_column_labels(columns), + 'rows': rows, + 'sortable': sortable, + } + + +def _user_org_roles_column_labels(columns): + labels = { + 'user': 'User', + 'email': 'Email', + 'last_active': 'Last Active', + '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 '', + 'last_active': user['last_active'] or '', + 'sysadmin': 'yes' if user['sysadmin'] else 'no', + 'organization': organization['name'] or '', + 'role': organization['role'] or '', + } + return [ + { + 'value': values[column], + '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: + return '' + return '/organization/manage_members/{}'.format(quote(name)) + + @pusher.before_app_request def check_dataset_access(): if toolkit.request.path in ('/dataset/', '/dataset'): 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 new file mode 100644 index 00000000..754f90e9 --- /dev/null +++ b/ckanext/datagov_inventory/templates/user_org_roles_table.html @@ -0,0 +1,84 @@ +{% extends "page.html" %} + +{% block subtitle %}{{ _('User Roles in Organizations') }}{% endblock %} + +{% block breadcrumb_content %} + {{ h.build_nav('user.index', _('Users')) }} +
  • {{ _('User Roles in Organizations') }}
  • +{% endblock %} + +{% block primary_content %} +
    +
    +

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

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

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

    +
    + + + + {% for label in section.labels %} + {% set column = section.columns[loop.index0] %} + {% if section.sortable and column in ('user', 'email', 'last_active', 'organization', 'role') %} + + {% else %} + + {% endif %} + {% endfor %} + + + + {% for row in section.rows %} + + {% for cell in row %} + + {% endfor %} + + {% else %} + + + + {% endfor %} + +
    + + {{ label }}
    + {% if cell.url %} + {{ cell.value }} + {% else %} + {{ cell.value }} + {% endif %} +
    {{ _('No users') }}
    +
    +

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

    +
    + {% endfor %} +
    +
    +{% endblock %} + +{% block secondary_content %} + {% snippet 'user/snippets/user_search.html' %} +{% endblock %} + +{% block scripts %} + {{ super() }} + {% asset 'datagov_inventory/scripts' %} +{% endblock %} diff --git a/ckanext/datagov_inventory/tests/logic/auth/test_auth.py b/ckanext/datagov_inventory/tests/logic/auth/test_auth.py index 87bd9db4..9dda3b40 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,46 @@ 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') + factories.User(name='deleted_user', state='deleted') + + 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 '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' + 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 new file mode 100644 index 00000000..3011d9cd --- /dev/null +++ b/e2e/cypress/integration/user_org_roles.cy.js @@ -0,0 +1,126 @@ +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(); + }); + + 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('.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('.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) => { + 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'); + }); + 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'