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