From d9e8fd00d81056a0cb63199e300e48b6489aeab6 Mon Sep 17 00:00:00 2001 From: Sonia Zorba <sonia.zorba@inaf.it> Date: Mon, 28 Oct 2019 17:45:00 +0100 Subject: [PATCH] Search functionality --- README.md | 18 ++++ gms-ui/src/App.vue | 37 +++++---- .../api/mock/data/openUserSearchResult.json | 13 +++ gms-ui/src/api/mock/data/search.json | 20 +++++ gms-ui/src/api/mock/index.js | 8 ++ gms-ui/src/api/server/index.js | 27 ++++++ .../src/components/GenericSearchResults.vue | 54 ++++++++++++ gms-ui/src/components/GroupsPanel.vue | 17 +--- gms-ui/src/components/TopMenu.vue | 29 +++++-- gms-ui/src/components/UserSearchResult.vue | 71 ++++++++++++++++ gms-ui/src/main.js | 4 +- gms-ui/src/store.js | 53 +++++++++++- .../it/inaf/ia2/gms/authn/SecurityConfig.java | 7 ++ .../ia2/gms/model/response/UserGroup.java | 25 ++++++ .../gms/model/response/UserPermission.java | 16 ++++ .../model/response/UserSearchResponse.java | 14 ++-- .../ia2/gms/service/GroupNameService.java | 81 ++++++++++++++++++ .../inaf/ia2/gms/service/SearchService.java | 67 ++++++++++++--- .../ia2/gms/service/GroupNameServiceTest.java | 82 +++++++++++++++++++ .../ia2/gms/service/SearchServiceTest.java | 37 ++++++++- 20 files changed, 618 insertions(+), 62 deletions(-) create mode 100644 gms-ui/src/api/mock/data/openUserSearchResult.json create mode 100644 gms-ui/src/api/mock/data/search.json create mode 100644 gms-ui/src/components/GenericSearchResults.vue create mode 100644 gms-ui/src/components/UserSearchResult.vue create mode 100644 gms/src/main/java/it/inaf/ia2/gms/model/response/UserGroup.java create mode 100644 gms/src/main/java/it/inaf/ia2/gms/model/response/UserPermission.java create mode 100644 gms/src/main/java/it/inaf/ia2/gms/service/GroupNameService.java create mode 100644 gms/src/test/java/it/inaf/ia2/gms/service/GroupNameServiceTest.java diff --git a/README.md b/README.md index 7b8b824..31c6305 100644 --- a/README.md +++ b/README.md @@ -19,3 +19,21 @@ To build the image: To run: docker run --env-file docker-env -d -p 8081:8081 -i -t gms:latest + +## Developer notes + +Backend and frontend are 2 separate applications: + +* the backend is the Maven application in the gms folder, based on Java and Spring Boot; +* the frontend is the npm application is the gms-ui folder, based on Vue.js. + +The Maven application automatically packs the Vue.js products inside the final jar, however the frontend application can be tested isolatedly running `npm run serve` in order to take advantage of the npm autoreload functionalities. + +By default http calls are mocked inside the Vue.js application. +In order to rely on real server calls edit the .env.development file in this way: + + VUE_APP_API_CLIENT = 'server' + VUE_APP_API_BASE_URL = 'http://localhost:8081/gms/' + +This assumes that your backend runs on 8081 port (with dev profile active, in order to enable the CORS policy) and the frontend runs on 8080 port. +First, do the login using the application running on the 8081 port, then you can access the frontend on the 8080. diff --git a/gms-ui/src/App.vue b/gms-ui/src/App.vue index d47d343..89678b2 100644 --- a/gms-ui/src/App.vue +++ b/gms-ui/src/App.vue @@ -1,44 +1,53 @@ <template> - <div id="app" v-if="model"> - <TopMenu v-bind:user="model.user" /> - <div class="container"> - <Main /> - </div> - <div id="loading" v-if="loading"> - <div id="spinner-wrapper"> - <b-spinner variant="primary" style="width: 3rem; height: 3rem;" label="Loading"></b-spinner> - </div> +<div id="app" v-if="model"> + <TopMenu v-bind:user="model.user" /> + <div class="container"> + <Main v-if="page === 'main'" /> + <GenericSearchResults v-if="page === 'search'" /> + <UserSearchResult v-if="page === 'userSearch'" /> + </div> + <div id="loading" v-if="loading"> + <div id="spinner-wrapper"> + <b-spinner variant="primary" style="width: 3rem; height: 3rem;" label="Loading"></b-spinner> </div> </div> +</div> </template> <script> import TopMenu from './components/TopMenu.vue'; import Main from './components/Main.vue'; -import { mapState } from 'vuex'; +import GenericSearchResults from './components/GenericSearchResults.vue'; +import UserSearchResult from './components/UserSearchResult.vue'; +import { + mapState +} from 'vuex'; import client from 'api-client'; export default { name: 'app', components: { TopMenu, - Main + Main, + GenericSearchResults, + UserSearchResult }, computed: mapState({ model: state => state.model, input: state => state.input, - loading: state => state.loading + loading: state => state.loading, + page: state => state.page }), mounted: function() { var self = this; - document.addEventListener('apiError', function (event) { + document.addEventListener('apiError', function(event) { self.$bvToast.toast(event.message, { title: "Error", variant: 'danger', solid: true }); }); - document.addEventListener('loading', function (event) { + document.addEventListener('loading', function(event) { self.$store.commit('setLoading', event.value); }); diff --git a/gms-ui/src/api/mock/data/openUserSearchResult.json b/gms-ui/src/api/mock/data/openUserSearchResult.json new file mode 100644 index 0000000..65d38ea --- /dev/null +++ b/gms-ui/src/api/mock/data/openUserSearchResult.json @@ -0,0 +1,13 @@ +{ + "groups": [{ + "id": "744e38e8f6d04e4e9418ae5f131c9b6b", + "name": "LBT", + "path": "744e38e8f6d04e4e9418ae5f131c9b6b" + }], + "permissions": [{ + "userId": "4", + "groupId": "744e38e8f6d04e4e9418ae5f131c9b6b", + "permission": "VIEW_MEMBERS", + "groupPath": "744e38e8f6d04e4e9418ae5f131c9b6b" + }] +} diff --git a/gms-ui/src/api/mock/data/search.json b/gms-ui/src/api/mock/data/search.json new file mode 100644 index 0000000..d81df04 --- /dev/null +++ b/gms-ui/src/api/mock/data/search.json @@ -0,0 +1,20 @@ +{ + "items": [{ + "id": "4", + "type": "USER", + "label": "Name Surname" + }, + { + "id": "group_id", + "type": "GROUP", + "label": "Group 1" + } + ], + "currentPage": 1, + "links": [1], + "totalItems": 2, + "pageSize": 20, + "totalPages": 1, + "hasPreviousPages": false, + "hasFollowingPages": false +} diff --git a/gms-ui/src/api/mock/index.js b/gms-ui/src/api/mock/index.js index 85dfeb0..5938d36 100644 --- a/gms-ui/src/api/mock/index.js +++ b/gms-ui/src/api/mock/index.js @@ -5,6 +5,8 @@ import membersPanel from './data/membersPanel'; import permissionsPanel from './data/permissionsPanel'; import searchUser from './data/searchUser'; import permission from './data/permission'; +import search from './data/search'; +import openUserSearchResult from './data/openUserSearchResult'; const fetch = (mockData, time = 0) => { return new Promise((resolve) => { @@ -56,5 +58,11 @@ export default { }, removeMember() { return fetch(membersPanel, 500); + }, + search() { + return fetch(search, 500); + }, + openUserSearchResult() { + return fetch(openUserSearchResult, 500); } } diff --git a/gms-ui/src/api/server/index.js b/gms-ui/src/api/server/index.js index b64e8ae..b05cca5 100644 --- a/gms-ui/src/api/server/index.js +++ b/gms-ui/src/api/server/index.js @@ -275,5 +275,32 @@ export default { 'Accept': 'application/json', } }); + }, + search(input) { + let url = BASE_API_URL + 'search?query=' + input.genericSearch.filter + + '&page=' + input.genericSearch.paginatorPage + '&pageSize=' + input.genericSearch.paginatorPageSize; + + return apiRequest(url, { + method: 'GET', + cache: 'no-cache', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + } + }); + }, + openUserSearchResult(userId) { + let url = BASE_API_URL + 'search/user/' + userId; + + return apiRequest(url, { + method: 'GET', + cache: 'no-cache', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + } + }); } }; diff --git a/gms-ui/src/components/GenericSearchResults.vue b/gms-ui/src/components/GenericSearchResults.vue new file mode 100644 index 0000000..d7fdade --- /dev/null +++ b/gms-ui/src/components/GenericSearchResults.vue @@ -0,0 +1,54 @@ +<template> +<div class="mt-sm-3"> + <div> + <p>Search results:</p> + <b-list-group v-for="item in model.genericSearchResults.items" v-bind:key="item.id"> + <b-list-group-item href="#" v-on:click="openSearchResult(item)"> + <span class="float-left"> + <font-awesome-icon icon="folder" v-if="item.type === 'GROUP'"></font-awesome-icon> + <font-awesome-icon icon="user" v-if="item.type === 'USER'"></font-awesome-icon> + {{item.label}} + </span> + </b-list-group-item> + </b-list-group> + <Paginator :paginatedPanel="model.genericSearchResults" :onUpdate="updateSearchResults" /> + </div> +</div> +</template> + +<script> +import client from 'api-client'; +import Paginator from './Paginator.vue'; +import { + mapState +} from 'vuex'; + +export default { + name: 'GenericSearchResults', + components: { + Paginator + }, + computed: mapState({ + model: state => state.model, + input: state => state.input + }), + methods: { + openSearchResult: function(result) { + switch (result.type) { + case 'GROUP': + this.$store.commit('openGroup', result.id); + break; + case 'USER': + client.openUserSearchResult(result.id) + .then(model => { + this.$store.commit('displayUserSearchResults', [result.label, model]); + }); + break; + } + }, + updateSearchResults: function() { + + } + } +} +</script> diff --git a/gms-ui/src/components/GroupsPanel.vue b/gms-ui/src/components/GroupsPanel.vue index 4522e71..a30ab4d 100644 --- a/gms-ui/src/components/GroupsPanel.vue +++ b/gms-ui/src/components/GroupsPanel.vue @@ -50,24 +50,9 @@ export default { model: state => state.model, input: state => state.input }), - data: function() { - return { - groupFilter: '' - }; - }, methods: { openGroup: function(group) { - this.$store.state.input.selectedGroupId = group.groupId; - this.$store.state.input.searchFilter = null; - client.fetchGroupsTab(this.input) - .then(model => { - if (model.groupsPanel.items.length > 0) { - this.$store.commit('updateGroups', model); - } else { - // If there are no subgroups show the members panel - this.$store.commit('setTabIndex', '1'); - } - }); + this.$store.commit('openGroup', group.groupId); }, openRenameGroupModal: function(group) { this.$refs.renameGroupModal.openRenameGroupModal(group); diff --git a/gms-ui/src/components/TopMenu.vue b/gms-ui/src/components/TopMenu.vue index f3f91ca..8a6677d 100644 --- a/gms-ui/src/components/TopMenu.vue +++ b/gms-ui/src/components/TopMenu.vue @@ -1,7 +1,7 @@ <template> <div> <b-navbar toggleable="lg" type="dark" variant="info"> - <b-navbar-brand href="#" class="d-none d-md-block">Group Membership Service</b-navbar-brand> + <b-navbar-brand href="#" class="d-none d-md-block" v-on:click="showMainPage">Group Membership Service</b-navbar-brand> <b-navbar-toggle target="nav-collapse"></b-navbar-toggle> @@ -9,12 +9,10 @@ <!-- Right aligned nav items --> <b-navbar-nav class="ml-auto"> - <!-- <b-nav-form> - <b-form-input size="sm" class="mr-sm-2" placeholder="Search"></b-form-input> - <b-button size="sm" class="my-2 my-sm-0" type="submit">Search</b-button> + <b-form-input size="sm" class="mr-sm-2" placeholder="Search" v-model="input.genericSearch.filter"></b-form-input> + <b-button size="sm" class="my-2 my-sm-0" type="button" v-on:click="genericSearch()">Search</b-button> </b-nav-form> - --> <b-nav-item-dropdown :text="user" right v-if="user"> <b-dropdown-item href="logout">Logout</b-dropdown-item> </b-nav-item-dropdown> @@ -25,10 +23,31 @@ </template> <script> +import client from 'api-client'; +import { + mapState +} from 'vuex'; + export default { name: 'TopMenu', props: { user: String + }, + computed: mapState({ + input: state => state.input, + }), + methods: { + showMainPage() { + this.$store.commit('showMainPage'); + }, + genericSearch() { + this.input.genericSearch.page = 1; + this.input.genericSearch.pageSize = 20; + client.search(this.input) + .then(results => { + this.$store.commit('displaySearchResults', results); + }); + } } } </script> diff --git a/gms-ui/src/components/UserSearchResult.vue b/gms-ui/src/components/UserSearchResult.vue new file mode 100644 index 0000000..a2b87c8 --- /dev/null +++ b/gms-ui/src/components/UserSearchResult.vue @@ -0,0 +1,71 @@ +<template> +<div class="mt-sm-3" v-if="userLabel !== null"> + <b-button variant="primary" class="float-right" v-on:click="back()">Back</b-button> + <h5>Results for <strong>{{userLabel}}</strong>:</h5> + + <b-container class="mt-sm-5"> + <b-row> + <b-col class="text-left"> + <h5>Is member of</h5> + <div v-if="groups.length === 0"> + No groups to show + </div> + <div v-if="groups.length > 0"> + <ul> + <li v-for="group in groups" v-bind:key="group.groupId"> + <a href="#" v-on:click="openGroup(group.groupId)"> + {{group.groupCompleteName.join(' / ')}} + </a> + </li> + </ul> + </div> + </b-col> + <b-col v-if="permissions.length > 0"> + <h5>Permissions</h5> + <table class="table table-striped"> + <thead> + <tr> + <th>Group</th> + <th>Permission</th> + </tr> + </thead> + <tbody> + <tr v-for="(p, rowIndex) in permissions" v-bind:key="rowIndex"> + <td> + <a href="#" v-on:click="openGroup(p.groupId)"> + {{p.groupCompleteName.join(' / ')}} + </a> + </td> + <td>{{p.permission}}</td> + </tr> + </tbody> + </table> + </b-col> + </b-row> + </b-container> + +</div> +</template> + +<script> +import { + mapState +} from 'vuex'; + +export default { + name: 'UserSearchResult', + computed: mapState({ + userLabel: state => state.model.userSearchResults.userLabel, + groups: state => state.model.userSearchResults.groups, + permissions: state => state.model.userSearchResults.permissions + }), + methods: { + back() { + this.$store.commit('displaySearchResults'); + }, + openGroup(groupId) { + this.$store.commit('openGroup', groupId); + } + } +} +</script> diff --git a/gms-ui/src/main.js b/gms-ui/src/main.js index 1d6dc7e..fe6307e 100644 --- a/gms-ui/src/main.js +++ b/gms-ui/src/main.js @@ -5,10 +5,10 @@ import store from './store.js' import './plugins/bootstrap-vue' import App from './App.vue' import { library } from '@fortawesome/fontawesome-svg-core' -import { faTrash, faEdit, faSpinner } from '@fortawesome/free-solid-svg-icons' +import { faTrash, faEdit, faSpinner, faFolder, faUser } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' -library.add(faTrash, faEdit, faSpinner); +library.add(faTrash, faEdit, faSpinner, faFolder, faUser); Vue.component('font-awesome-icon', FontAwesomeIcon); diff --git a/gms-ui/src/store.js b/gms-ui/src/store.js index 1525101..06f5ed3 100644 --- a/gms-ui/src/store.js +++ b/gms-ui/src/store.js @@ -2,6 +2,7 @@ import Vue from 'vue'; import Vuex from 'vuex'; +import client from 'api-client'; Vue.use(Vuex); @@ -14,7 +15,13 @@ export default new Vuex.Store({ permissionsPanel: null, membersPanel: null, permission: null, - user: null + user: null, + genericSearchResults: [], + userSearchResults: { + userLabel: null, + groups: {}, + permissions: {} + } }, // values used to perform API calls input: { @@ -23,9 +30,15 @@ export default new Vuex.Store({ paginatorPage: 1, selectedTab: 'groups', tabIndex: 0, - searchFilter: null + searchFilter: null, + genericSearch: { + filter: null, + paginatorPage: 1, + paginatorPageSize: 20 + } }, - loading: false + loading: false, + page: 'main' }, mutations: { updateHomePageModel(state, model) { @@ -34,6 +47,22 @@ export default new Vuex.Store({ this.state.model.permission = model.permission; this.state.model.user = model.user; }, + openGroup(state, groupId) { + let input = this.state.input; + input.selectedGroupId = groupId; + input.searchFilter = null; + client.fetchGroupsTab(input) + .then(model => { + if (model.groupsPanel.items.length > 0) { + this.commit('setTabIndex', 0); + this.commit('updateGroups', model); + } else { + // If there are no subgroups show the members panel + this.commit('setTabIndex', 1); + } + this.commit('showMainPage'); + }); + }, updateGroups(state, model) { this.state.model.breadcrumbs = model.breadcrumbs; this.state.model.groupsPanel = model.groupsPanel; @@ -54,6 +83,24 @@ export default new Vuex.Store({ }, setLoading(state, loading) { this.state.loading = loading; + }, + showMainPage(state) { + this.state.page = 'main'; + }, + displaySearchResults(state, results) { + this.state.page = 'search'; + if (results) { + this.state.model.genericSearchResults = results; + } + }, + updateSearchResults(state, results) { + this.state.model.genericSearchResults = results; + }, + displayUserSearchResults(state, data) { + this.state.page = 'userSearch'; + this.state.model.userSearchResults.userLabel = data[0]; + this.state.model.userSearchResults.groups = data[1].groups; + this.state.model.userSearchResults.permissions = data[1].permissions; } }, getters: { diff --git a/gms/src/main/java/it/inaf/ia2/gms/authn/SecurityConfig.java b/gms/src/main/java/it/inaf/ia2/gms/authn/SecurityConfig.java index e77eaaf..04c3994 100644 --- a/gms/src/main/java/it/inaf/ia2/gms/authn/SecurityConfig.java +++ b/gms/src/main/java/it/inaf/ia2/gms/authn/SecurityConfig.java @@ -1,6 +1,8 @@ package it.inaf.ia2.gms.authn; import java.util.Arrays; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.security.oauth2.client.EnableOAuth2Sso; @@ -23,6 +25,8 @@ import org.springframework.web.filter.CorsFilter; @EnableOAuth2Sso public class SecurityConfig extends WebSecurityConfigurerAdapter { + private static final Logger LOG = LoggerFactory.getLogger(SecurityConfig.class); + @Autowired private Environment env; @@ -92,6 +96,9 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter { @Bean @Profile("dev") public FilterRegistrationBean corsFilter() { + + LOG.warn("Development profile active: CORS filter enabled"); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); CorsConfiguration config = new CorsConfiguration().applyPermitDefaultValues(); config.addAllowedMethod(HttpMethod.PUT); diff --git a/gms/src/main/java/it/inaf/ia2/gms/model/response/UserGroup.java b/gms/src/main/java/it/inaf/ia2/gms/model/response/UserGroup.java new file mode 100644 index 0000000..26fdd28 --- /dev/null +++ b/gms/src/main/java/it/inaf/ia2/gms/model/response/UserGroup.java @@ -0,0 +1,25 @@ +package it.inaf.ia2.gms.model.response; + +import java.util.List; + +public class UserGroup { + + private String groupId; + private List<String> groupCompleteName; + + public String getGroupId() { + return groupId; + } + + public void setGroupId(String groupId) { + this.groupId = groupId; + } + + public List<String> getGroupCompleteName() { + return groupCompleteName; + } + + public void setGroupCompleteName(List<String> groupCompleteName) { + this.groupCompleteName = groupCompleteName; + } +} diff --git a/gms/src/main/java/it/inaf/ia2/gms/model/response/UserPermission.java b/gms/src/main/java/it/inaf/ia2/gms/model/response/UserPermission.java new file mode 100644 index 0000000..892777c --- /dev/null +++ b/gms/src/main/java/it/inaf/ia2/gms/model/response/UserPermission.java @@ -0,0 +1,16 @@ +package it.inaf.ia2.gms.model.response; + +import it.inaf.ia2.gms.model.Permission; + +public class UserPermission extends UserGroup { + + private Permission permission; + + public Permission getPermission() { + return permission; + } + + public void setPermission(Permission permission) { + this.permission = permission; + } +} diff --git a/gms/src/main/java/it/inaf/ia2/gms/model/response/UserSearchResponse.java b/gms/src/main/java/it/inaf/ia2/gms/model/response/UserSearchResponse.java index d09cc2b..0ec8567 100644 --- a/gms/src/main/java/it/inaf/ia2/gms/model/response/UserSearchResponse.java +++ b/gms/src/main/java/it/inaf/ia2/gms/model/response/UserSearchResponse.java @@ -1,27 +1,25 @@ package it.inaf.ia2.gms.model.response; -import it.inaf.ia2.gms.persistence.model.GroupEntity; -import it.inaf.ia2.gms.persistence.model.PermissionEntity; import java.util.List; public class UserSearchResponse { - private List<GroupEntity> groups; - private List<PermissionEntity> permissions; + private List<UserGroup> groups; + private List<UserPermission> permissions; - public List<GroupEntity> getGroups() { + public List<UserGroup> getGroups() { return groups; } - public void setGroups(List<GroupEntity> groups) { + public void setGroups(List<UserGroup> groups) { this.groups = groups; } - public List<PermissionEntity> getPermissions() { + public List<UserPermission> getPermissions() { return permissions; } - public void setPermissions(List<PermissionEntity> permissions) { + public void setPermissions(List<UserPermission> permissions) { this.permissions = permissions; } } diff --git a/gms/src/main/java/it/inaf/ia2/gms/service/GroupNameService.java b/gms/src/main/java/it/inaf/ia2/gms/service/GroupNameService.java new file mode 100644 index 0000000..20c04e9 --- /dev/null +++ b/gms/src/main/java/it/inaf/ia2/gms/service/GroupNameService.java @@ -0,0 +1,81 @@ +package it.inaf.ia2.gms.service; + +import it.inaf.ia2.gms.persistence.GroupsDAO; +import it.inaf.ia2.gms.persistence.model.GroupEntity; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +/** + * Utility class for retrieving the complete names (including parents) from a + * set of group paths. + */ +@Service +public class GroupNameService { + + @Autowired + private GroupsDAO groupsDAO; + + /** + * @param groupsIdPath map having group id as keys and group paths as values + * @return map having group id as keys and group names as values + */ + public Map<String, List<String>> getNames(List<Map.Entry<String, String>> groupsIdPath) { + + Set<String> allIdentifiers = new HashSet<>(); + for (Map.Entry<String, String> entry : groupsIdPath) { + allIdentifiers.addAll(getIdentifiers(entry.getValue())); + } + + Map<String, String> groupSingleNamesMap = getGroupSingleNamesMap(allIdentifiers); + + Map<String, List<String>> groupCompleteNamesMap = new HashMap<>(); + for (Map.Entry<String, String> entry : groupsIdPath) { + List<String> groupCompleteName = getGroupCompleteName(groupSingleNamesMap, entry.getValue()); + groupCompleteNamesMap.put(entry.getKey(), groupCompleteName); + } + + return groupCompleteNamesMap; + } + + private Map<String, String> getGroupSingleNamesMap(Set<String> allIdentifiers) { + Map<String, String> groupNamesMap = new HashMap<>(); + for (GroupEntity group : groupsDAO.findGroupsByIds(allIdentifiers)) { + groupNamesMap.put(group.getId(), group.getName()); + } + + return groupNamesMap; + } + + private List<String> getGroupCompleteName(Map<String, String> groupNamesMap, String groupPath) { + List<String> names = new ArrayList<>(); + if (groupPath.isEmpty()) { + names.add("Root"); + } else { + List<String> identifiers = getIdentifiers(groupPath); + for (String groupId : identifiers) { + names.add(groupNamesMap.get(groupId)); + } + } + return names; + } + + /** + * Returns the list of all identifiers including parent ones. + */ + private List<String> getIdentifiers(String groupPath) { + List<String> identifiers = new ArrayList<>(); + if (!groupPath.isEmpty()) { + for (String id : groupPath.split(Pattern.quote("."))) { + identifiers.add(id); + } + } + return identifiers; + } +} diff --git a/gms/src/main/java/it/inaf/ia2/gms/service/SearchService.java b/gms/src/main/java/it/inaf/ia2/gms/service/SearchService.java index f809a61..7aa9a38 100644 --- a/gms/src/main/java/it/inaf/ia2/gms/service/SearchService.java +++ b/gms/src/main/java/it/inaf/ia2/gms/service/SearchService.java @@ -4,6 +4,8 @@ import it.inaf.ia2.gms.model.Permission; import it.inaf.ia2.gms.model.response.PaginatedData; import it.inaf.ia2.gms.model.response.SearchResponseItem; import it.inaf.ia2.gms.model.response.SearchResponseType; +import it.inaf.ia2.gms.model.response.UserGroup; +import it.inaf.ia2.gms.model.response.UserPermission; import it.inaf.ia2.gms.model.response.UserSearchResponse; import it.inaf.ia2.gms.persistence.GroupsDAO; import it.inaf.ia2.gms.persistence.MembershipsDAO; @@ -11,8 +13,10 @@ import it.inaf.ia2.gms.persistence.PermissionsDAO; import it.inaf.ia2.gms.persistence.model.GroupEntity; import it.inaf.ia2.gms.persistence.model.PermissionEntity; import it.inaf.ia2.gms.rap.RapClient; +import java.util.AbstractMap.SimpleEntry; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -35,6 +39,9 @@ public class SearchService { @Autowired private MembershipsDAO membershipsDAO; + @Autowired + private GroupNameService groupNameService; + /** * Generic search (both groups and users). */ @@ -91,30 +98,66 @@ public class SearchService { */ public UserSearchResponse getUserSearchResult(String actorUserId, String targetUserId) { - List<GroupEntity> allGroups = membershipsDAO.getUserMemberships(targetUserId); + // Select only the information visible to the actor user + List<PermissionEntity> actorPermissions = permissionsDAO.findUserPermissions(actorUserId); - // Select only the groups visible to the user - List<PermissionEntity> permissions = permissionsDAO.findUserPermissions(actorUserId); + UserSearchResponse response = new UserSearchResponse(); + response.setGroups(getUserGroups(targetUserId, actorPermissions)); + response.setPermissions(getUserPermission(targetUserId, actorPermissions)); + + return response; + } + + private List<UserGroup> getUserGroups(String targetUserId, List<PermissionEntity> actorPermissions) { + + List<GroupEntity> allGroups = membershipsDAO.getUserMemberships(targetUserId); - List<GroupEntity> visibleGroups = new ArrayList<>(); + // Select only groups visible to the actor user + List<Map.Entry<String, String>> visibleGroupsIdPath = new ArrayList<>(); for (GroupEntity group : allGroups) { - PermissionUtils.getGroupPermission(group, permissions).ifPresent(permission -> { - visibleGroups.add(group); + PermissionUtils.getGroupPermission(group, actorPermissions).ifPresent(permission -> { + visibleGroupsIdPath.add(new SimpleEntry<>(group.getId(), group.getPath())); }); } - UserSearchResponse response = new UserSearchResponse(); - response.setGroups(visibleGroups); + return groupNameService.getNames(visibleGroupsIdPath).entrySet().stream() + .map(entry -> { + UserGroup ug = new UserGroup(); + ug.setGroupId(entry.getKey()); + ug.setGroupCompleteName(entry.getValue()); + return ug; + }) + .collect(Collectors.toList()); + } + + private List<UserPermission> getUserPermission(String targetUserId, List<PermissionEntity> actorPermissions) { + + List<UserPermission> permissions = new ArrayList<>(); // Super-admin user is able to see also other user permissions - PermissionUtils.getGroupPermission(groupsService.getRoot(), permissions).ifPresent(permission -> { + PermissionUtils.getGroupPermission(groupsService.getRoot(), actorPermissions).ifPresent(permission -> { if (permission.equals(Permission.ADMIN)) { - List<PermissionEntity> targetUserPermissions = permissionsDAO.findUserPermissions(targetUserId); - response.setPermissions(targetUserPermissions); + + Map<String, PermissionEntity> targetUserPermissions + = permissionsDAO.findUserPermissions(targetUserId).stream() + .collect(Collectors.toMap(PermissionEntity::getGroupId, p -> p)); + + List<Map.Entry<String, String>> groupsIdPath = new ArrayList<>(); + for (PermissionEntity p : targetUserPermissions.values()) { + groupsIdPath.add(new SimpleEntry<>(p.getGroupId(), p.getGroupPath())); + } + + for (Map.Entry<String, List<String>> entry : groupNameService.getNames(groupsIdPath).entrySet()) { + UserPermission up = new UserPermission(); + up.setGroupId(entry.getKey()); + up.setGroupCompleteName(entry.getValue()); + up.setPermission(targetUserPermissions.get(entry.getKey()).getPermission()); + permissions.add(up); + } } }); - return response; + return permissions; } } diff --git a/gms/src/test/java/it/inaf/ia2/gms/service/GroupNameServiceTest.java b/gms/src/test/java/it/inaf/ia2/gms/service/GroupNameServiceTest.java new file mode 100644 index 0000000..1ec9f9a --- /dev/null +++ b/gms/src/test/java/it/inaf/ia2/gms/service/GroupNameServiceTest.java @@ -0,0 +1,82 @@ +package it.inaf.ia2.gms.service; + +import it.inaf.ia2.gms.persistence.GroupsDAO; +import it.inaf.ia2.gms.persistence.model.GroupEntity; +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import static org.junit.Assert.assertEquals; +import org.junit.Test; +import org.junit.runner.RunWith; +import static org.mockito.ArgumentMatchers.any; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import static org.mockito.Mockito.when; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public class GroupNameServiceTest { + + @Mock + private GroupsDAO groupsDAO; + + @InjectMocks + private GroupNameService groupNameService; + + @Test + public void getNamesTest() { + + mockGroupsDAO(); + + List<Map.Entry<String, String>> groupsIdPath = new ArrayList<>(); + groupsIdPath.add(new AbstractMap.SimpleEntry<>("def", "abc.def")); + + Map<String, List<String>> names = groupNameService.getNames(groupsIdPath); + assertEquals(1, names.size()); + assertEquals(2, names.get("def").size()); + assertEquals("Group 1", names.get("def").get(0)); + assertEquals("Group 2", names.get("def").get(1)); + } + + public void mockGroupsDAO() { + + List<GroupEntity> groups = new ArrayList<>(); + + GroupEntity group1 = new GroupEntity(); + group1.setId("abc"); + group1.setName("Group 1"); + group1.setPath("abc"); + groups.add(group1); + + GroupEntity group2 = new GroupEntity(); + group2.setId("def"); + group2.setName("Group 2"); + group2.setPath("abc.def"); + groups.add(group2); + + when(groupsDAO.findGroupsByIds(any())).thenReturn(groups); + } + + @Test + public void getRootTest() { + + List<GroupEntity> groups = new ArrayList<>(); + + GroupEntity root = new GroupEntity(); + root.setId("ROOT"); + root.setName("Root"); + root.setPath(""); + groups.add(root); + + when(groupsDAO.findGroupsByIds(any())).thenReturn(groups); + + List<Map.Entry<String, String>> groupsIdPath = new ArrayList<>(); + groupsIdPath.add(new AbstractMap.SimpleEntry<>("ROOT", "")); + + Map<String, List<String>> names = groupNameService.getNames(groupsIdPath); + assertEquals(1, names.size()); + assertEquals(1, names.get("ROOT").size()); + assertEquals("Root", names.get("ROOT").get(0)); + } +} diff --git a/gms/src/test/java/it/inaf/ia2/gms/service/SearchServiceTest.java b/gms/src/test/java/it/inaf/ia2/gms/service/SearchServiceTest.java index 7d11078..b05d539 100644 --- a/gms/src/test/java/it/inaf/ia2/gms/service/SearchServiceTest.java +++ b/gms/src/test/java/it/inaf/ia2/gms/service/SearchServiceTest.java @@ -16,9 +16,11 @@ import it.inaf.ia2.gms.persistence.model.PermissionEntity; import it.inaf.ia2.gms.rap.RapClient; import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.Map.Entry; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; import org.junit.Test; import org.junit.runner.RunWith; import static org.mockito.ArgumentMatchers.any; @@ -46,6 +48,9 @@ public class SearchServiceTest { @Mock private MembershipsDAO membershipsDAO; + @Mock + private GroupNameService groupNameService; + @InjectMocks private SearchService searchService; @@ -120,14 +125,42 @@ public class SearchServiceTest { when(permissionsDAO.findUserPermissions(eq("admin_id"))) .thenReturn(Collections.singletonList(adminPermission)); + PermissionEntity targetUserPermission = new PermissionEntity(); + targetUserPermission.setGroupId("group1_id"); + targetUserPermission.setUserId("target_id"); + targetUserPermission.setGroupPath("group1_id"); + targetUserPermission.setPermission(Permission.MANAGE_MEMBERS); + + when(permissionsDAO.findUserPermissions(eq("target_id"))) + .thenReturn(Collections.singletonList(targetUserPermission)); + GroupEntity root = new GroupEntity(); root.setId("ROOT"); root.setName("Root"); root.setPath(""); when(groupsService.getRoot()).thenReturn(root); + when(groupNameService.getNames(any())).then(invocation -> { + Map<String, List<String>> result = new HashMap<>(); + List<Map.Entry<String, String>> arg = invocation.getArgument(0); + for (Entry<String, String> entry : arg) { + List<String> names = new ArrayList<>(); + switch (entry.getKey()) { + case "ROOT": + names.add("Root"); + break; + case "group1_id": + names.add("Group 1"); + break; + } + result.put(entry.getKey(), names); + } + return result; + }); + UserSearchResponse response = searchService.getUserSearchResult("admin_id", "target_id"); assertEquals(1, response.getGroups().size()); - assertNotNull(response.getPermissions()); + assertEquals(1, response.getPermissions().size()); + assertEquals(Permission.MANAGE_MEMBERS, response.getPermissions().get(0).getPermission()); } } -- GitLab