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