001/*
002 *  Copyright 2016 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.workspaces.activities.activitystream;
017
018import java.time.ZonedDateTime;
019import java.util.ArrayList;
020import java.util.Collection;
021import java.util.Collections;
022import java.util.Date;
023import java.util.HashSet;
024import java.util.List;
025import java.util.Map;
026import java.util.Set;
027import java.util.function.Predicate;
028import java.util.stream.Collectors;
029
030import org.apache.avalon.framework.component.Component;
031import org.apache.avalon.framework.service.ServiceException;
032import org.apache.avalon.framework.service.ServiceManager;
033import org.apache.avalon.framework.service.Serviceable;
034import org.apache.commons.lang3.StringUtils;
035
036import org.ametys.core.right.RightManager;
037import org.ametys.core.ui.Callable;
038import org.ametys.core.user.CurrentUserProvider;
039import org.ametys.core.user.UserIdentity;
040import org.ametys.core.userpref.UserPreferencesException;
041import org.ametys.core.userpref.UserPreferencesManager;
042import org.ametys.core.util.DateUtils;
043import org.ametys.plugins.explorer.resources.ModifiableResourceCollection;
044import org.ametys.plugins.repository.AmetysObjectIterable;
045import org.ametys.plugins.repository.AmetysObjectResolver;
046import org.ametys.plugins.repository.activities.Activity;
047import org.ametys.plugins.repository.activities.ActivityHelper;
048import org.ametys.plugins.repository.activities.ActivityTypeExpression;
049import org.ametys.plugins.repository.activities.ActivityTypeExtensionPoint;
050import org.ametys.plugins.repository.query.expression.AndExpression;
051import org.ametys.plugins.repository.query.expression.DateExpression;
052import org.ametys.plugins.repository.query.expression.Expression;
053import org.ametys.plugins.repository.query.expression.Expression.Operator;
054import org.ametys.plugins.repository.query.expression.ExpressionContext;
055import org.ametys.plugins.repository.query.expression.OrExpression;
056import org.ametys.plugins.repository.query.expression.StringExpression;
057import org.ametys.plugins.workspaces.activities.AbstractWorkspacesActivityType;
058import org.ametys.plugins.workspaces.project.ProjectManager;
059import org.ametys.plugins.workspaces.project.modules.WorkspaceModule;
060import org.ametys.plugins.workspaces.project.objects.Project;
061import org.ametys.runtime.plugin.component.AbstractLogEnabled;
062
063/**
064 * Component gathering methods for the activity stream service
065 */
066public class ActivityStreamClientInteraction extends AbstractLogEnabled implements Component, Serviceable
067{
068    /** The Avalon role */
069    public static final String ROLE = ActivityStreamClientInteraction.class.getName();
070    
071    /** the user preferences context for activity stream */
072    public static final String ACTIVITY_STREAM_USER_PREF_CONTEXT = "/workspaces/activity-stream";
073    
074    /** the id of user preferences for the last update of activity stream*/
075    public static final String ACTIVITY_STREAM_USER_PREF_LAST_UPDATE = "lastUpdate";
076
077    private ProjectManager _projectManager;
078    private ActivityTypeExtensionPoint _activityTypeExtensionPoint;
079
080    private CurrentUserProvider _currentUserProvider;
081
082    private RightManager _rightManager;
083
084    private UserPreferencesManager _userPrefManager;
085
086    private AmetysObjectResolver _resolver;
087
088    @Override
089    public void service(ServiceManager serviceManager) throws ServiceException
090    {
091        _projectManager = (ProjectManager) serviceManager.lookup(ProjectManager.ROLE);
092        _rightManager = (RightManager) serviceManager.lookup(RightManager.ROLE);
093        _activityTypeExtensionPoint = (ActivityTypeExtensionPoint) serviceManager.lookup(ActivityTypeExtensionPoint.ROLE);
094        _currentUserProvider = (CurrentUserProvider) serviceManager.lookup(CurrentUserProvider.ROLE);
095        _userPrefManager = (UserPreferencesManager) serviceManager.lookup(UserPreferencesManager.ROLE);
096        _resolver = (AmetysObjectResolver) serviceManager.lookup(AmetysObjectResolver.ROLE);
097    }
098    
099    /**
100     * Get the activities of the given projects and of the given event types
101     * @param projectNames the names of the projects. Can not be null. 
102     * @param filterEventTypes the type of events to retain. Can be empty to get all activities.
103     * @param limit The max number of activities
104     * @return the retained activities
105     */
106    @Callable
107    public List<Map<String, Object>> getActivities(List<String> projectNames, List<String> filterEventTypes, int limit)
108    {
109        return getActivities(projectNames, filterEventTypes, null, null, null, limit);
110    }
111    
112    /**
113     * Get the activities of the given projects and of the given event types
114     * @param projectNames the names of the projects. Can not be null. 
115     * @param filterEventTypes the type of events to retain. Can be empty to get all activities.
116     * @param fromDate To get activities after the given date. Can be null.
117     * @param untilDate To get activities before the given date. Can be null.
118     * @param pattern A filter pattern. Can be null or empty
119     * @param limit The max number of activities
120     * @return the retained activities
121     */
122    public List<Map<String, Object>> getActivities(List<String> projectNames, List<String> filterEventTypes, Date fromDate, Date untilDate, String pattern, int limit)
123    {
124        List<Map<String, Object>> mergedActivities = new ArrayList<>();
125        
126        if (projectNames.isEmpty())
127        {
128            return mergedActivities;
129        }
130        
131        try (AmetysObjectIterable<Activity> activitiesIterable = _getActivities(projectNames, filterEventTypes, fromDate, untilDate, pattern))
132        {
133            if (activitiesIterable != null)
134            {
135                // FIXME Add 20% to requested limit to take into account merge of activities 
136                List<Activity> activities = activitiesIterable.stream().limit(Math.round(limit + limit * 0.5)).toList();
137                
138                // FIXME After merge, the number of activities could be lower than limit
139                mergedActivities.addAll(_activityTypeExtensionPoint.mergeActivities(activities));
140            }
141        }
142        
143        return mergedActivities.stream().limit(limit).toList();
144    }
145    
146    private AmetysObjectIterable<Activity> _getActivities(List<String> projectNames, List<String> filterEventTypes, Date fromDate, Date untilDate, String pattern)
147    {
148        List<Expression> exprs = new ArrayList<>();
149        
150        Set<String> allAllowedEventTypes = new HashSet<>();
151        
152        for (String projectName : projectNames)
153        {
154            Project project = _projectManager.getProject(projectName);
155            
156            Set<String> allowedEventTypes = _getAllowedEventTypesByProject(project);
157            
158            if (filterEventTypes != null && filterEventTypes.size() > 0)
159            {
160                allowedEventTypes.retainAll(filterEventTypes);
161            }
162            
163            allAllowedEventTypes.addAll(allowedEventTypes);
164            
165            if (allowedEventTypes.size() > 0)
166            {
167                Expression activityTypeExpr = new ActivityTypeExpression(Operator.EQ, allowedEventTypes.toArray(new String[allowedEventTypes.size()]));
168                Expression projectExpr = new StringExpression(AbstractWorkspacesActivityType.PROJECT_NAME, Operator.EQ, projectName);
169                
170                exprs.add(new AndExpression(activityTypeExpr, projectExpr));
171            }
172        }
173        
174        if (exprs.size() > 0)
175        {
176            List<Expression> finalExprs = new ArrayList<>();
177            
178            finalExprs.add(new OrExpression(exprs.toArray(new Expression[exprs.size()])));
179            
180            if (untilDate != null)
181            {
182                finalExprs.add(new DateExpression("date", Operator.LT, untilDate));
183            }
184            
185            if (fromDate != null)
186            {
187                finalExprs.add(new DateExpression("date", Operator.GT, fromDate));
188            }
189            
190            if (StringUtils.isNotEmpty(pattern))
191            {
192                List<Expression> patternExprs = new ArrayList<>();
193                
194                patternExprs.add(new StringExpression(AbstractWorkspacesActivityType.PROJECT_TITLE, Operator.WD, pattern, ExpressionContext.newInstance().withCaseInsensitive(true)));
195                
196                for (String allowedEventType : allAllowedEventTypes)
197                {
198                    _activityTypeExtensionPoint.getActivityTypes(allowedEventType)
199                            .stream()
200                            .filter(AbstractWorkspacesActivityType.class::isInstance)
201                            .map(AbstractWorkspacesActivityType.class::cast)
202                            .forEach(wsActivityType -> {
203                                Expression patternExpr = wsActivityType.getFilterPatternExpression(pattern);
204                                if (patternExpr != null)
205                                {
206                                    patternExprs.add(patternExpr);
207                                }
208                            });
209                }
210                
211                finalExprs.add(new OrExpression(patternExprs.toArray(new Expression[patternExprs.size()])));
212            }
213            
214            Expression finalExpr = new AndExpression(finalExprs.toArray(new Expression[finalExprs.size()]));
215            
216            String xpathQuery = ActivityHelper.getActivityXPathQuery(finalExpr);
217            return _resolver.query(xpathQuery);
218        }
219        
220        
221        return null;
222    }
223    
224    /**
225     * Get the date of last activity regardless the current user's rights
226     * @param projectName The project's name
227     * @param excludeActivityTypes the types of activity to ignore from this search
228     * @return the date of last activity or null if no activity found or an error occurred
229     */
230    public ZonedDateTime getDateOfLastActivity(String projectName, List<String> excludeActivityTypes)
231    {
232        List<Expression> expressions = new ArrayList<>();
233        
234        for (String eventType : excludeActivityTypes)
235        {
236            expressions.add(new ActivityTypeExpression(Operator.NE, eventType));
237        }
238        
239        return _getDateOfLastActivity(projectName, new AndExpression(expressions.toArray(new Expression[expressions.size()])));
240    }
241
242    /**
243     * Get the date of last activity regardless the current user's rights
244     * @param projectName The project's name
245     * @param includeActivityTypes the types of activity to ignore from this search
246     * @return the date of last activity or null if no activity found or an error occurred
247     */
248    public ZonedDateTime getDateOfLastActivityByActivityType(String projectName, Collection<String> includeActivityTypes)
249    {
250        List<Expression> expressions = new ArrayList<>();
251        
252        for (String eventType : includeActivityTypes)
253        {
254            expressions.add(new ActivityTypeExpression(Operator.EQ, eventType));
255        }
256        
257        return _getDateOfLastActivity(projectName, new OrExpression(expressions.toArray(new Expression[expressions.size()])));
258    }
259    
260    private ZonedDateTime _getDateOfLastActivity(String projectName, Expression eventTypesExpression)
261    {
262        Expression projectNameExpression = new StringExpression("projectName", Operator.EQ, projectName);
263        
264        Expression eventExpr = new AndExpression(projectNameExpression, eventTypesExpression);
265        
266        String xpathQuery = ActivityHelper.getActivityXPathQuery(eventExpr);
267        AmetysObjectIterable<Activity> activities = _resolver.query(xpathQuery);
268        
269        for (Activity activity: activities)
270        {
271            return activity.getDate();
272        }
273        
274        return null;
275    }
276    
277    /**
278     * Get the list of allowed event types for the given projects
279     * @param projects The projects
280     * @return The allowed event types
281     */
282    public Set<String> getAllowedEventTypes (Set<Project> projects)
283    {
284        Set<String> allowedTypes = new HashSet<>();
285        
286        for (Project project : projects)
287        {
288            allowedTypes.addAll(_getAllowedEventTypesByProject(project));
289        }
290        
291        return allowedTypes;
292    }
293    
294    // FIXME temporary method
295    // The allowed types are hard coded according the user access on root modules.
296    private Set<String> _getAllowedEventTypesByProject (Project project)
297    {
298        Set<String> allowedTypes = new HashSet<>();
299        
300        for (WorkspaceModule moduleManager : _projectManager.getModules(project))
301        {
302            ModifiableResourceCollection moduleRoot = moduleManager.getModuleRoot(project, false);
303            if (moduleRoot != null && _rightManager.currentUserHasReadAccess(moduleRoot))
304            {
305                allowedTypes.addAll(moduleManager.getAllowedEventTypes());
306            }
307        }
308        
309        return allowedTypes;
310    }
311    
312    /**
313     * Get the number of unread events for the current user
314     * @return the number of unread events or -1 if user never read events
315     */
316    @Callable
317    public long getNumberOfUnreadActivitiesForCurrentUser()
318    {
319        Date lastUpdate = _getLastReadDate();
320        if (lastUpdate != null)
321        {
322            Set<Project> userProjects = _getProjectsForCurrentUser(null);
323            List<String> projectNames = transformProjectsToName(userProjects);
324            Set<String> allowedEventTypes = getAllowedEventTypes(userProjects);
325            
326            AmetysObjectIterable<Activity> activities = _getActivities(projectNames, new ArrayList<>(allowedEventTypes), lastUpdate, null, null);
327            return activities != null ? activities.getSize() : -1;
328        }
329        return -1;
330    }
331    
332    /**
333     * Get the activities for the current user with the allowed event types get from the user projects.
334     * @param limit The max number of results
335     * @return The activities for the user projects
336     */
337    public List<Map<String, Object>> getActivitiesForCurrentUser(int limit)
338    {
339        return getActivitiesForCurrentUser((String) null, null, null, limit);
340    }
341    
342    /**
343     * Get the activities for the current user with the allowed event types get from the user projects.
344     * @param pattern Pattern to search on activity. Can null or empty to not filter on pattern.
345     * @param activityTypes the type of activities to retrieve. Can null or empty to not filter on activity types.
346     * @param categories the categories of projects to retrieve. Can null or empty to not filter on themes.
347     * @param limit The max number of results
348     * @return The activities for the user projects
349     */
350    public List<Map<String, Object>> getActivitiesForCurrentUser(String pattern, Set<String> categories, Set<String> activityTypes, int limit)
351    {
352        return getActivitiesForCurrentUser(pattern,  categories, activityTypes, null, null, limit);
353    }
354    
355    /**
356     * Get the activities for the current user with the allowed event types get from the user projects.
357     * @param pattern Pattern to search on activity. Can null or empty to not filter on pattern.
358     * @param activityTypes the type of activities to retrieve. Can null or empty to not filter on activity types.
359     * @param categories the categories of projects to retrieve. Can null or empty to not filter on themes.
360     * @param fromDate To get activities after the given date. Can be null.
361     * @param untilDate To get activities before the given date. Can be null.
362     * @param limit The max number of results
363     * @return The activities for the user projects
364     */
365    public List<Map<String, Object>> getActivitiesForCurrentUser(String pattern, Set<String> categories, Set<String> activityTypes, Date fromDate, Date untilDate, int limit)
366    {
367        Set<Project> userProjects = _getProjectsForCurrentUser(categories);
368        return getActivitiesForCurrentUser(userProjects, activityTypes, fromDate, untilDate, pattern, limit);
369    }
370    
371    /**
372     * Get the activities for the current user with the allowed event types get from the given projects.
373     * @param projects the projects
374     * @param activityTypes the type of activities to retrieve. Can null or empty to not filter on activity types.
375     * @param fromDate To get activities after the given date. Can be null.
376     * @param untilDate To get activities before the given date. Can be null.
377     * @param pattern Pattern to search on activity. Can null or empty to not filter on pattern.
378     * @param limit The max number of results
379     * @return The activities for the user projects
380     */
381    public List<Map<String, Object>> getActivitiesForCurrentUser(Set<Project> projects, Set<String> activityTypes, Date fromDate, Date untilDate, String pattern, int limit)
382    {
383        List<String> projectNames = transformProjectsToName(projects);
384        Set<String> allowedActivityTypes = getAllowedEventTypes(projects);
385        
386        if (activityTypes != null && activityTypes.size() > 0)
387        {
388            allowedActivityTypes.retainAll(activityTypes);
389        }
390        
391        List<Map<String, Object>> activities = getActivities(projectNames, new ArrayList<>(allowedActivityTypes), fromDate, untilDate, pattern, limit);
392        
393        // Add a parameter representing the date in the ISO 8601 format
394        activities.stream().forEach(activity -> 
395        {
396            String eventDate = (String) activity.get("date");
397            
398            Date lastReadDate = _getLastReadDate();
399            if (lastReadDate != null)
400            {
401                activity.put("unread", DateUtils.parse(eventDate).compareTo(lastReadDate) > 0);
402            }
403            // start date
404            activity.put("date-iso", eventDate);
405            
406            // optional end date
407            String endDate = (String) activity.get("endDate");
408            if (endDate != null)
409            {
410                activity.put("end-date-iso", endDate);
411            }
412        });
413        
414        return activities;
415    }
416    
417    private Date _getLastReadDate()
418    {
419        UserIdentity user = _currentUserProvider.getUser();
420        try
421        {
422            return _userPrefManager.getUserPreferenceAsDate(user, ACTIVITY_STREAM_USER_PREF_CONTEXT, Map.of(), ACTIVITY_STREAM_USER_PREF_LAST_UPDATE);
423        }
424        catch (UserPreferencesException e)
425        {
426            getLogger().warn("Unable to get last unread events date from user preferences", e);
427            return null;
428        }
429    }
430    
431    private Set<Project> _getProjectsForCurrentUser(Set<String> filteredCategories)
432    {
433        UserIdentity user = _currentUserProvider.getUser();
434        
435        Predicate<Project> matchCategories = p -> filteredCategories == null || filteredCategories.isEmpty() || !Collections.disjoint(p.getCategories(), filteredCategories);
436        
437        return _projectManager.getUserProjects(user).keySet()
438                   .stream()
439                   .filter(matchCategories)
440                   .collect(Collectors.toSet());
441    }
442    
443    private List<String> transformProjectsToName(Set<Project> userProjects)
444    {
445        return userProjects.stream()
446            .map(p -> p.getName())
447            .collect(Collectors.toList());
448    }
449}