001/*
002 *  Copyright 2019 Anyware Services
003 *
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016package org.ametys.plugins.core.search;
017
018import java.util.ArrayList;
019import java.util.Collections;
020import java.util.Comparator;
021import java.util.HashMap;
022import java.util.List;
023import java.util.Map;
024import java.util.Objects;
025import java.util.Optional;
026import java.util.Set;
027import java.util.function.Function;
028import java.util.stream.Collectors;
029import java.util.stream.Stream;
030
031import org.apache.avalon.framework.component.Component;
032import org.apache.avalon.framework.logger.AbstractLogEnabled;
033import org.apache.avalon.framework.service.ServiceException;
034import org.apache.avalon.framework.service.ServiceManager;
035import org.apache.avalon.framework.service.Serviceable;
036
037import org.ametys.core.group.Group;
038import org.ametys.core.group.GroupDirectoryContextHelper;
039import org.ametys.core.group.GroupDirectoryDAO;
040import org.ametys.core.group.directory.GroupDirectory;
041import org.ametys.core.user.User;
042import org.ametys.core.user.UserManager;
043import org.ametys.core.user.population.PopulationContextHelper;
044import org.ametys.core.user.population.UserPopulation;
045import org.ametys.core.user.population.UserPopulationDAO;
046import org.ametys.plugins.core.group.GroupHelper;
047import org.ametys.plugins.core.user.UserHelper;
048
049/**
050 * Common helper for user and group searches
051 */
052public class UserAndGroupSearchManager extends AbstractLogEnabled implements Component, Serviceable 
053{
054    /** The avalon role */
055    public static final String ROLE = UserAndGroupSearchManager.class.getName();
056    
057    private static final int _DEFAULT_OFFSET_VALUE = 0;
058    private static final int _DEFAULT_COUNT_VALUE = 20;
059    
060    private UserManager _userManager;
061    private UserHelper _userHelper;
062    private GroupDirectoryDAO _groupDirectoryDAO;
063    private GroupDirectoryContextHelper _directoryContextHelper;
064    private PopulationContextHelper _populationContextHelper;
065    private UserPopulationDAO _userPopulationDAO;
066    private GroupHelper _groupHelper;
067    
068    public void service(ServiceManager manager) throws ServiceException
069    {
070        _userManager = (UserManager) manager.lookup(UserManager.ROLE);
071        _userHelper = (UserHelper) manager.lookup(UserHelper.ROLE);
072        _userPopulationDAO = (UserPopulationDAO) manager.lookup(UserPopulationDAO.ROLE);
073        _groupDirectoryDAO = (GroupDirectoryDAO) manager.lookup(GroupDirectoryDAO.ROLE);
074        _directoryContextHelper = (GroupDirectoryContextHelper) manager.lookup(GroupDirectoryContextHelper.ROLE);
075        _populationContextHelper = (PopulationContextHelper) manager.lookup(PopulationContextHelper.ROLE);
076        _groupHelper = (GroupHelper) manager.lookup(GroupHelper.ROLE);
077    }
078
079    /**
080     * Mixed users and groups sorted search, with multiple offsets
081     * @param contexts The context of the search
082     * @param limit The maximum number of results per group directory and population
083     * @param searchData The search data to fetch the next batch of the search. Must be null on the first search.
084     * @param params The parameters to pass to the group directories and user populations when retrieving their contents
085     * @return An object with the list of results, sorted, and the search data required to fetch the following batch
086     */
087    public Map<String, Object> searchUsersAndGroupByContext(Set<String> contexts, int limit, Map<String, Object> searchData, Map<String, Object> params)
088    {
089        Set<String> groupDirectories = _directoryContextHelper.getGroupDirectoriesOnContexts(contexts);
090        Set<String> userPopulations = _populationContextHelper.getUserPopulationsOnContexts(contexts, false, true);
091        return searchUsersAndGroup(userPopulations, groupDirectories, limit, searchData, params);
092    }
093
094    /**
095     * Mixed users and groups sorted search, with multiple offsets
096     * @param availablePopulations The populations of users available for search. On first search, all populations will be used
097     * @param availableDirectories The directories of groups available for search. On first search, all directories will be used
098     * @param limit The maximum number of results per group directory and population
099     * @param searchData The search data to fetch the next batch of the search. Must be null on the first search.
100     * @param params The parameters to pass to the group directories and user populations when retrieving their contents
101     * @return An object with the list of results, sorted, and the search data required to fetch the following batch
102     */
103    public Map<String, Object> searchUsersAndGroup(Set<String> availablePopulations, Set<String> availableDirectories, int limit, Map<String, Object> searchData, Map<String, Object> params)
104    {
105        Map<String, Integer> usersFromSearchData = _getFromSearchData("users", searchData);
106        Set<String> userPopulations = usersFromSearchData == null ? availablePopulations : _filterSetUnion(availablePopulations, usersFromSearchData.keySet());
107        Map<String, Integer> groupsFromSearchData = _getFromSearchData("groups", searchData);
108        Set<String> groupDirectories = groupsFromSearchData == null ? availableDirectories : _filterSetUnion(availableDirectories, groupsFromSearchData.keySet());
109        
110        Function<UserPopulation, Integer> getPopulationOffset = population -> usersFromSearchData != null 
111                ? usersFromSearchData.getOrDefault(population.getId(), _DEFAULT_OFFSET_VALUE) 
112                : _DEFAULT_OFFSET_VALUE;
113        Function<GroupDirectory, Integer> getDirectoryOffset = directory -> groupsFromSearchData != null 
114                ? groupsFromSearchData.getOrDefault(directory.getId(), _DEFAULT_OFFSET_VALUE) 
115                : _DEFAULT_OFFSET_VALUE;
116        
117        int count = limit <= 0 ? _DEFAULT_COUNT_VALUE : limit;
118
119        List<SearchDataResult> searchDataResults = Stream.concat(
120            userPopulations.stream()
121                    .map(_userPopulationDAO::getUserPopulation)
122                    .filter(Objects::nonNull)
123                    .map(population -> _searchUsers(population, count, getPopulationOffset.apply(population), params)),
124            groupDirectories.stream()
125                    .map(_groupDirectoryDAO::getGroupDirectory)
126                    .filter(Objects::nonNull)
127                    .map(directory -> _searchGroups(directory, count, getDirectoryOffset.apply(directory), params)))
128            .collect(Collectors.toList());
129        
130        if (searchDataResults.size() == 0)
131        {
132            return _formatSearchResults(Collections.emptyList(), null);
133        }
134        
135        if (searchDataResults.size() == 1)
136        {
137            SearchDataResult searchDataResult = searchDataResults.get(0);
138            return _formatSearchResults(searchDataResult.results2Json(), searchDataResult.isFinished() ? null : searchDataResults);
139        }
140        
141        if (searchDataResults.stream().allMatch(SearchDataResult::isFinished))
142        {
143            return _formatSearchResults(_mergeSearchResults(searchDataResults), null);
144        }
145        
146        searchDataResults = searchDataResults.stream()
147                .filter(searchDataResult -> searchDataResult.getLastSortableName() != null)
148                .sorted(Comparator.comparing(SearchDataResult::getLastSortableName))
149                .collect(Collectors.toList());
150        
151        String entryDelimitor = null;
152        for (SearchDataResult searchDataResult : searchDataResults)
153        {
154            if (entryDelimitor != null)
155            {
156                searchDataResult.truncateAfter(entryDelimitor);
157            }
158            else if (!searchDataResult.isFinished())
159            {
160                entryDelimitor = searchDataResult.getLastSortableName();
161            }
162        }
163        
164        List<SearchDataResult> unfinishedSearchDataResults = searchDataResults.stream()
165                .filter(searchDataResult -> !searchDataResult.isFinished())
166                .collect(Collectors.toList());
167        
168        return _formatSearchResults(_mergeSearchResults(searchDataResults), unfinishedSearchDataResults);
169    }
170    
171    private Map<String, Integer> _getFromSearchData(String property, Map<String, Object> searchData)
172    {
173        if (searchData == null)
174        {
175            return null;
176        }
177        
178        return Optional.ofNullable(searchData.getOrDefault(property, null))
179                .filter(Map.class::isInstance)
180                .map(Map.class::cast)
181                .orElse(Collections.emptyMap());
182    }
183    
184    private Set<String> _filterSetUnion(Set<String> values, Set<String> referenceSet)
185    {
186        return values.stream()
187                .filter(referenceSet::contains)
188                .collect(Collectors.toSet());
189    }
190    
191    private SearchDataResult _searchUsers(UserPopulation population, int limit, int offset, Map<String, Object> params)
192    {
193        List<User> users = new ArrayList<>(_userManager.getUsers(population, limit, offset, params));
194        
195        return new PopulationSearchDataResult(_userHelper, population.getId(), users.size() < limit, users, offset);
196    }
197    
198    private SearchDataResult _searchGroups(GroupDirectory directory, int limit, int offset, Map<String, Object> params)
199    {
200        List<Group> groups = directory.getGroups(limit, offset, params);
201        
202        return new DirectorySearchDataResult(_groupHelper, directory.getId(), groups.size() < limit, groups, offset);
203    }
204    
205    private Map<String, Object> _formatSearchResults(List<Map<String, Object>> results, List<SearchDataResult> searchDataResults)
206    {
207        Map<String, Object> jsonResult = new HashMap<>();
208        jsonResult.put("results", results);
209        if (searchDataResults == null)
210        {
211            jsonResult.put("finished", true);
212        }
213        else
214        {
215            Map<String, Map<String, Integer>> searchDataJson = new HashMap<>();
216            for (SearchDataResult searchDataResult: searchDataResults)
217            {
218                String type = searchDataResult.getType();
219                if (!searchDataJson.containsKey(type))
220                {
221                    searchDataJson.put(type, new HashMap<>());
222                }
223                searchDataJson.get(type).put(searchDataResult.getSourceId(), searchDataResult.getOffset());
224            }
225            
226            if (searchDataJson.size() > 0)
227            {
228                jsonResult.put("searchData", searchDataJson);
229            }
230            else
231            {
232                jsonResult.put("finished", true);
233            }
234        }
235        
236        return jsonResult;
237    }
238    
239    private List<Map<String, Object>> _mergeSearchResults(List<SearchDataResult> searchDataResults)
240    {
241        return searchDataResults.stream()
242                .map(SearchDataResult::results2Json)
243                .flatMap(List::stream)
244                .sorted((o1, o2) -> _getSortableName(o1).compareTo(_getSortableName(o2)))
245                .collect(Collectors.toList());
246    }
247    
248    /**
249     * Get the sortable name for the json object
250     * @param o The object
251     * @return The sortable name, or an empty string if no name was found
252     */
253    protected static String _getSortableName(Map<String, Object> o)
254    {
255        if (o.containsKey("sortablename"))
256        {
257            return ((String) o.get("sortablename")).toLowerCase();
258        }
259        
260        if (o.containsKey("label"))
261        {
262            return ((String) o.get("label")).toLowerCase();
263        }
264        
265        return "";
266    }
267
268    private static interface SearchDataResult
269    {
270        public String getType();
271        public String getSourceId();
272        public boolean isFinished();
273        public void truncateAfter(String entry);
274        public int getOffset();
275        public List<Map<String, Object>> results2Json();
276        public String getLastSortableName();
277    }
278    
279    private static class PopulationSearchDataResult implements SearchDataResult
280    {
281        private boolean _isFinished;
282        private List<User> _users;
283        private int _offset;
284        private UserHelper _helper;
285        private String _sourceId;
286        
287        public PopulationSearchDataResult(UserHelper userHelper, String sourceId, boolean isFinished, List<User> users, int initialOffset)
288        {
289            _helper = userHelper;
290            _sourceId = sourceId;
291            _isFinished = isFinished;
292            _users = users;
293            _offset = initialOffset;
294        }
295
296        public String getType()
297        {
298            return "users";
299        }
300        
301        public String getSourceId()
302        {
303            return _sourceId;
304        }
305
306        public boolean isFinished()
307        {
308            return _isFinished;
309        }
310
311        public void truncateAfter(String entry)
312        {
313            if (_users.removeIf(user -> user.getSortableName().toLowerCase().compareTo(entry) > 0))
314            {
315                _isFinished = false;
316            }
317        }
318
319        public int getOffset()
320        {
321            return _offset + _users.size();
322        }
323
324        public List<Map<String, Object>> results2Json()
325        {
326            return _helper.users2json(_users, true);
327        }
328        
329        public String getLastSortableName()
330        {
331            return _users.size() > 0 ? _users.get(_users.size() - 1).getSortableName().toLowerCase() : null;
332        }
333        
334        @Override
335        public String toString()
336        {
337            return SearchDataResult.class.getName() + "@" + Integer.toHexString(hashCode()) 
338                + "[" + _users.size() + " user(s) from population " + _sourceId + " at offset " + _offset + "]" 
339                + (_isFinished ? "[Finished]" : "");
340        }
341    }
342
343    private static class DirectorySearchDataResult implements SearchDataResult
344    {
345        private String _sourceId;
346        private boolean _isFinished;
347        private List<Group> _groups;
348        private int _offset;
349        private GroupHelper _helper;
350
351        public DirectorySearchDataResult(GroupHelper helper, String sourceId, boolean isFinished, List<Group> groups, int initialOffset)
352        {
353            _helper = helper;
354            _sourceId = sourceId;
355            _isFinished = isFinished;
356            _groups = groups;
357            _offset = initialOffset;
358        }
359        
360        public String getType()
361        {
362            return "groups";
363        }
364
365        public String getSourceId()
366        {
367            return _sourceId;
368        }
369        
370        public boolean isFinished()
371        {
372            return _isFinished;
373        }
374
375        public void truncateAfter(String entry)
376        {
377            if (_groups.removeIf(group -> group.getLabel().toLowerCase().compareTo(entry) > 0))
378            {
379                _isFinished = false;
380            }
381        }
382
383        public int getOffset()
384        {
385            return _offset + _groups.size();
386        }
387
388        public List<Map<String, Object>> results2Json()
389        {
390            return _helper.groups2JSON(_groups, false);
391        }
392
393        public String getLastSortableName()
394        {
395            return _groups.size() > 0 ? _groups.get(_groups.size() - 1).getLabel().toLowerCase() : null;
396        }
397
398        @Override
399        public String toString()
400        {
401            return SearchDataResult.class.getName() + "@" + Integer.toHexString(hashCode()) 
402                + "[" + _groups.size() + " group(s) from directory " + _sourceId + " at offset " + _offset + "]" 
403                + (_isFinished ? "[Finished]" : "");
404        }
405
406    }
407}