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.calendars;
017
018import java.text.DateFormat;
019import java.text.SimpleDateFormat;
020import java.util.ArrayList;
021import java.util.Collections;
022import java.util.Comparator;
023import java.util.Date;
024import java.util.HashMap;
025import java.util.List;
026import java.util.Map;
027import java.util.Set;
028import java.util.stream.Collectors;
029
030import org.apache.avalon.framework.service.ServiceException;
031import org.apache.avalon.framework.service.ServiceManager;
032import org.apache.cocoon.components.ContextHelper;
033import org.apache.cocoon.environment.Request;
034import org.apache.commons.collections.ListUtils;
035import org.apache.commons.lang.IllegalClassException;
036import org.apache.commons.lang3.StringUtils;
037
038import org.ametys.core.right.RightManager.RightResult;
039import org.ametys.core.ui.Callable;
040import org.ametys.core.user.UserIdentity;
041import org.ametys.core.util.DateUtils;
042import org.ametys.plugins.explorer.ExplorerNode;
043import org.ametys.plugins.explorer.calendars.Calendar;
044import org.ametys.plugins.explorer.calendars.Calendar.CalendarVisibility;
045import org.ametys.plugins.explorer.calendars.CalendarEvent;
046import org.ametys.plugins.explorer.calendars.actions.CalendarDAO;
047import org.ametys.plugins.explorer.calendars.jcr.JCRCalendarEvent;
048import org.ametys.plugins.explorer.calendars.jcr.JCRCalendarEventFactory;
049import org.ametys.plugins.explorer.resources.ModifiableResourceCollection;
050import org.ametys.plugins.explorer.resources.jcr.JCRResourcesCollectionFactory;
051import org.ametys.plugins.repository.AmetysObjectIterable;
052import org.ametys.plugins.repository.AmetysObjectIterator;
053import org.ametys.plugins.repository.AmetysRepositoryException;
054import org.ametys.plugins.repository.UnknownAmetysObjectException;
055import org.ametys.plugins.repository.data.holder.ModifiableModelAwareDataHolder;
056import org.ametys.plugins.repository.query.QueryHelper;
057import org.ametys.plugins.repository.query.SortCriteria;
058import org.ametys.plugins.repository.query.expression.AndExpression;
059import org.ametys.plugins.repository.query.expression.DateExpression;
060import org.ametys.plugins.repository.query.expression.Expression;
061import org.ametys.plugins.repository.query.expression.Expression.Operator;
062import org.ametys.plugins.repository.query.expression.OrExpression;
063import org.ametys.plugins.repository.query.expression.StringExpression;
064import org.ametys.plugins.workspaces.AbstractWorkspaceModule;
065import org.ametys.plugins.workspaces.project.objects.Project;
066import org.ametys.runtime.i18n.I18nizableText;
067import org.ametys.web.repository.page.ModifiablePage;
068import org.ametys.web.repository.page.ModifiableZone;
069import org.ametys.web.repository.page.ModifiableZoneItem;
070import org.ametys.web.repository.page.Page;
071import org.ametys.web.repository.page.ZoneItem.ZoneType;
072
073import com.google.common.collect.ImmutableSet;
074
075/**
076 * Helper component for managing calendars
077 */
078public class CalendarWorkspaceModule extends AbstractWorkspaceModule
079{
080    /** The id of calendar module */
081    public static final String CALENDAR_MODULE_ID = CalendarWorkspaceModule.class.getName();
082    
083    /** Workspaces calendars node name */
084    private static final String __WORKSPACES_CALENDARS_NODE_NAME = "calendars";
085    
086    private static final String __CALENDAR_CACHE_REQUEST_ATTR = CalendarWorkspaceModule.class.getName() + "$calendarCache";
087    
088    private WorkspaceCalendarDAO _calendarDAO;
089    
090    @Override
091    public void service(ServiceManager manager) throws ServiceException
092    {
093        super.service(manager);
094        _calendarDAO = (WorkspaceCalendarDAO) manager.lookup(WorkspaceCalendarDAO.ROLE);
095    }
096    
097    @Override
098    public String getId()
099    {
100        return CALENDAR_MODULE_ID;
101    }
102    
103    public int getOrder()
104    {
105        return ORDER_CALENDAR;
106    }
107    
108    public String getModuleName()
109    {
110        return __WORKSPACES_CALENDARS_NODE_NAME;
111    }
112    
113    @Override
114    protected String getModulePageName()
115    {
116        return "calendars";
117    }
118    
119    public I18nizableText getModuleTitle()
120    {
121        return new I18nizableText("plugin." + _pluginName, "PLUGINS_WORKSPACES_PROJECT_SERVICE_MODULE_CALENDAR_LABEL");
122    }
123    public I18nizableText getModuleDescription()
124    {
125        return new I18nizableText("plugin." + _pluginName, "PLUGINS_WORKSPACES_PROJECT_SERVICE_MODULE_CALENDAR_DESCRIPTION");
126    }
127    @Override
128    protected I18nizableText getModulePageTitle()
129    {
130        return new I18nizableText("plugin." + _pluginName, "PLUGINS_WORKSPACES_PROJECT_WORKSPACE_PAGE_CALENDARS_TITLE");
131    }
132    
133    @Override
134    protected void initializeModulePage(ModifiablePage calendarPage)
135    {
136        ModifiableZone defaultZone = calendarPage.createZone("default");
137        
138        String serviceId = "org.ametys.plugins.workspaces.module.Calendar";
139        ModifiableZoneItem defaultZoneItem = defaultZone.addZoneItem();
140        defaultZoneItem.setType(ZoneType.SERVICE);
141        defaultZoneItem.setServiceId(serviceId);
142        
143        ModifiableModelAwareDataHolder serviceDataHolder = defaultZoneItem.getServiceParameters();
144        serviceDataHolder.setValue("xslt", _getDefaultXslt(serviceId));
145    }
146    
147    /**
148     * Get the calendars of a project
149     * @param project The project
150     * @return The list of calendar
151     */
152    public AmetysObjectIterable<Calendar> getCalendars(Project project)
153    {
154        ModifiableResourceCollection moduleRoot = getModuleRoot(project, false);
155        return moduleRoot != null ? moduleRoot.getChildren() : null;
156    }
157    
158    @Override
159    public ModifiableResourceCollection getModuleRoot(Project project, boolean create)
160    {
161        try
162        {
163            ExplorerNode projectRootNode = project.getExplorerRootNode();
164            
165            if (projectRootNode instanceof ModifiableResourceCollection)
166            {
167                ModifiableResourceCollection projectRootNodeRc = (ModifiableResourceCollection) projectRootNode;
168                return _getAmetysObject(projectRootNodeRc, __WORKSPACES_CALENDARS_NODE_NAME, JCRResourcesCollectionFactory.RESOURCESCOLLECTION_NODETYPE, create);
169            }
170            else
171            {
172                throw new IllegalClassException(ModifiableResourceCollection.class, projectRootNode.getClass());
173            }
174        }
175        catch (AmetysRepositoryException e)
176        {
177            throw new AmetysRepositoryException("Error getting the documents root node.", e);
178        }
179    }
180
181    /**
182     * Retrieves the set of general rights used in the calendar module for the current user
183     * @return The map of right data. Keys are the rights id, and values indicates whether the current user has the right or not.
184     */
185    @Callable
186    public Map<String, Object> getModuleBaseRights()
187    {
188        Request request = ContextHelper.getRequest(_context);
189        String projectName = (String) request.getAttribute("projectName");
190        
191        Project project = _projectManager.getProject(projectName);
192        ModifiableResourceCollection calendarRoot = getModuleRoot(project, false);
193        
194        Map<String, Object> rightsData = new HashMap<>();
195        UserIdentity user = _currentUserProvider.getUser();
196        
197        // Add calendar
198        rightsData.put("add-calendar", calendarRoot != null && _rightManager.hasRight(user, CalendarDAO.RIGHTS_CALENDAR_ADD, calendarRoot) == RightResult.RIGHT_ALLOW);
199        
200        // Tags
201        rightsData.put("add-tag", _projectRightHelper.canAddTag(project));
202        rightsData.put("remove-tag", _projectRightHelper.canRemoveTag(project));
203        
204        // Places
205        rightsData.put("add-place", _projectRightHelper.canAddPlace(project));
206        rightsData.put("remove-place", _projectRightHelper.canRemovePlace(project));
207        
208        return rightsData;
209    }
210    
211    /**
212     * Get the rights of the current user for the calendar service
213     * @param calendarIds The list of calendars
214     * @return The rights
215     */
216    @Callable
217    public Map<String, Object> getCalendarServiceRights(List<String> calendarIds)
218    {
219        Map<String, Object> rights = new HashMap<>();
220        
221        List<String> rightEventAdd = new ArrayList<>();
222        List<String> rightTagAdd = new ArrayList<>();
223        List<String> rightPlaceAdd = new ArrayList<>();
224        
225        UserIdentity currentUser = _currentUserProvider.getUser();
226        
227        List<String> ids = calendarIds;
228        if (calendarIds == null || (calendarIds.size() == 1 && calendarIds.get(0) == null))
229        {
230            ids = getCalendarsData().stream().map(c -> (String) c.get("id")).collect(Collectors.toList());
231        }
232        
233        for (String calendarId : ids)
234        {
235            Calendar calendar = _resolver.resolveById(calendarId);
236            
237            if (_rightManager.hasRight(currentUser, CalendarDAO.RIGHTS_EVENT_ADD, calendar) == RightResult.RIGHT_ALLOW)
238            {
239                rightEventAdd.add(calendarId);
240            }
241        }
242        
243        rights.put("eventAdd", rightEventAdd);
244        rights.put("tagAdd", rightTagAdd);
245        rights.put("placetAdd", rightPlaceAdd);
246        
247        return rights;
248    }
249    
250    /**
251     * Get the URI of a thread in project'site
252     * @param project The project
253     * @param calendarId The id of calendar
254     * @param eventId The id of event
255     * @return The thread uri
256     */
257    public String getEventUri(Project project, String calendarId, String eventId)
258    {
259        String moduleUrl = getModuleUrl(project);
260        if (moduleUrl != null)
261        {
262            StringBuilder sb = new StringBuilder();
263            sb.append(moduleUrl);
264            
265            try
266            {
267                CalendarEvent event = _resolver.resolveById(eventId);
268                
269                if (event.getStartDate() != null)
270                {
271                    DateFormat df = new SimpleDateFormat("yyyy-MM-dd");
272                    sb.append("?date=").append(df.format(event.getStartDate()));
273                }
274            }
275            catch (UnknownAmetysObjectException e)
276            {
277                // Nothing
278            }
279            
280            sb.append("#").append(calendarId);
281            
282            return sb.toString();
283        }
284        
285        return null;
286    }
287    
288    /**
289     * Add a calendar
290     * @param inputName The desired name for the calendar
291     * @param description The calendar description
292     * @param templateDesc The calendar template description
293     * @param color The calendar color
294     * @param visibility The calendar visibility
295     * @param workflowName The calendar workflow name
296     * @param renameIfExists True to rename if existing
297     * @return The result map with id, parentId and name keys
298     * @throws IllegalAccessException If the user has no sufficient rights
299     */
300    @Callable
301    public Map<String, Object> addCalendar(String inputName, String description, String templateDesc, String color, String visibility, String workflowName, Boolean renameIfExists) throws IllegalAccessException
302    {
303        Request request = ContextHelper.getRequest(_context);
304        String projectName = (String) request.getAttribute("projectName");
305        
306        Project project = _projectManager.getProject(projectName);
307        ModifiableResourceCollection calendarRoot = getModuleRoot(project, false);
308        assert calendarRoot != null;
309        
310        // TODO catch IllegalAccessException -> error = has-right
311        
312        return _calendarDAO.addCalendar(calendarRoot.getId(), inputName, description, templateDesc, color, visibility, workflowName, renameIfExists);
313    }
314    
315    /**
316     * Add additional information on project and parent calendar
317     * @param event The event
318     * @param eventData The event data to complete
319     */
320    @SuppressWarnings("unchecked")
321    protected void _addAdditionalEventData(CalendarEvent event, Map<String, Object> eventData)
322    {
323        Request request = ContextHelper.getRequest(_context);
324        
325        Calendar calendar = event.getParent();
326        Project project = _projectManager.getParentProject(calendar);
327        
328        // Try to get calendar from cache if request is not null
329        if (request.getAttribute(__CALENDAR_CACHE_REQUEST_ATTR) == null)
330        {
331            request.setAttribute(__CALENDAR_CACHE_REQUEST_ATTR, new HashMap<String, Object>());
332        }
333        
334        Map<String, Object> calendarCache = (Map<String, Object>) request.getAttribute(__CALENDAR_CACHE_REQUEST_ATTR);
335        
336        if (!calendarCache.containsKey(calendar.getId()))
337        {
338            Map<String, Object> calendarInfo = new HashMap<>();
339            
340            calendarInfo.put("calendarName", calendar.getName());
341            calendarInfo.put("calendarIsPublic", CalendarVisibility.PUBLIC.equals(calendar.getVisibility()));
342            calendarInfo.put("calendarHasViewRight", canView(calendar));
343            
344            calendarInfo.put("projectId", project.getId());
345            calendarInfo.put("projectTitle", project.getTitle());
346            
347            Set<Page> calendarModulePages = _projectManager.getModulePages(project, this);
348            if (!calendarModulePages.isEmpty())
349            {
350                Page calendarModulePage = calendarModulePages.iterator().next();
351                calendarInfo.put("calendarModulePageId", calendarModulePage.getId());
352            }
353            
354            calendarCache.put(calendar.getId(), calendarInfo);
355        }
356        
357        eventData.putAll((Map<String, Object>) calendarCache.get(calendar.getId()));
358       
359        eventData.put("eventUrl", getEventUri(project, calendar.getId(), event.getId()));
360    }
361    
362    
363    
364    /**
365     * Get the upcoming events of the calendars on which the user has a right
366     * @param months the amount of months from today in which look for upcoming events
367     * @param maxResults the maximum results to display
368     * @param calendarIds the ids of the calendars to gather events from, null for all calendars
369     * @param tagIds the ids of the valid tags for the events, null for any tag 
370     * @return the upcoming events
371     */
372    @Callable
373    public List<Map<String, Object>> getUpcomingEvents(String months, String maxResults, List<String> calendarIds, List<String> tagIds)
374    {
375        int monthsAsInt = StringUtils.isBlank(months) ? 3 : Integer.parseInt(months);
376        int maxResultsAsInt = StringUtils.isBlank(maxResults) ? Integer.MAX_VALUE : Integer.parseInt(maxResults);
377        
378        return getUpcomingEvents(monthsAsInt, maxResultsAsInt, calendarIds, tagIds);
379    }
380    
381    /**
382     * Get the upcoming events of the calendars on which the user has a right
383     * @param months the amount of months from today in which look for upcoming events
384     * @param maxResults the maximum results to display
385     * @param calendarIds the ids of the calendars to gather events from, null for all calendars
386     * @param tagIds the ids of the valid tags for the events, null for any tag 
387     * @return the upcoming events
388     */
389    public List<Map<String, Object>> getUpcomingEvents(int months, int maxResults, List<String> calendarIds, List<String> tagIds)
390    {
391        List<Map<String, Object>> basicEventList = new ArrayList<> ();
392        List<Map<String, Object>> recurrentEventList = new ArrayList<> ();
393        
394        java.util.Calendar cal = java.util.Calendar.getInstance();
395        cal.set(java.util.Calendar.HOUR_OF_DAY, 0);
396        cal.set(java.util.Calendar.MINUTE, 0);
397        cal.set(java.util.Calendar.SECOND, 0);
398        cal.set(java.util.Calendar.MILLISECOND, 0);
399        Date startDate = cal.getTime();
400
401        Date endDate = org.apache.commons.lang3.time.DateUtils.addMonths(startDate, months);
402        
403        Expression nonRecurrentExpr = new StringExpression(JCRCalendarEvent.METADATA_RECURRENCE_TYPE, Operator.EQ, "NEVER");
404        Expression startDateExpr = new DateExpression(JCRCalendarEvent.METADATA_START_DATE, Operator.GE, startDate);
405        Expression endDateExpr = new DateExpression(JCRCalendarEvent.METADATA_START_DATE, Operator.LE, endDate);
406        
407        Expression keywordsExpr = null;
408        
409        if (tagIds != null && !tagIds.isEmpty())
410        {
411            List<Expression> orExpr = new ArrayList<>();
412            for (String tagId : tagIds)
413            {
414                orExpr.add(new StringExpression(JCRCalendarEvent.METADATA_KEYWORDS, Operator.EQ, tagId));
415            }
416            keywordsExpr = new OrExpression(orExpr.toArray(new Expression[orExpr.size()]));
417        }
418        
419        // Get the non recurrent events sorted by ascending date and within the configured range
420        Expression eventExpr = new AndExpression(nonRecurrentExpr, startDateExpr, endDateExpr, keywordsExpr);
421        SortCriteria sortCriteria = new SortCriteria();
422        sortCriteria.addCriterion(JCRCalendarEvent.METADATA_START_DATE, true, false);
423        
424        String basicEventQuery = QueryHelper.getXPathQuery(null, JCRCalendarEventFactory.CALENDAR_EVENT_NODETYPE, eventExpr, sortCriteria);
425        AmetysObjectIterable<CalendarEvent> basicEvents = _resolver.query(basicEventQuery);
426        AmetysObjectIterator<CalendarEvent> basicEventIt = basicEvents.iterator();
427        
428        int processed = 0;
429        while (basicEventIt.hasNext() && processed < maxResults)
430        {
431            CalendarEvent event = basicEventIt.next();
432            Calendar holdingCalendar = (Calendar) event.getParent();
433            
434            if (_filterEvent(calendarIds, event) && _hasAccess(holdingCalendar))
435            {
436                // The event is in the list of selected calendars and has the appropriate tags (can be none if tagIds == null)
437                
438                // FIXME should use something like an EventInfo object with some data + calendar, project name
439                // And use a function to process the transformation...
440                // Function<EventInfo, Map<String, Object>> fn. eventData.putAll(fn.apply(info));
441                
442                // standard set of event data
443                Map<String, Object> eventData = _calendarDAO.getEventData(event, false);
444                basicEventList.add(eventData);
445                processed++;
446                
447                // add additional info
448                _addAdditionalEventData(event, eventData);
449            }
450        }
451        
452        Expression recurrentExpr = new StringExpression(JCRCalendarEvent.METADATA_RECURRENCE_TYPE, Operator.NE, "NEVER");
453        eventExpr = new AndExpression(recurrentExpr, keywordsExpr);
454        
455        String recurrentEventQuery = QueryHelper.getXPathQuery(null, JCRCalendarEventFactory.CALENDAR_EVENT_NODETYPE, eventExpr, sortCriteria);
456        AmetysObjectIterable<CalendarEvent> recurrentEvents = _resolver.query(recurrentEventQuery);
457        AmetysObjectIterator<CalendarEvent> recurrentEventIt = recurrentEvents.iterator();
458        
459        Date nextDate = null;
460        // FIXME cannot count processed here...
461        processed = 0;
462        while (recurrentEventIt.hasNext() /*&& processed < maxResultsAsInt*/)
463        {
464            CalendarEvent event = recurrentEventIt.next();
465            nextDate = event.getNextOccurrence(startDate);
466            
467            // The recurrent event first occurrence is within the range
468            if (nextDate.before(endDate))
469            {
470                // FIXME calculate occurrences only if keep event...
471                List<Date> occurrences = event.getOccurrences(startDate, endDate);
472                Calendar holdingCalendar = (Calendar) event.getParent();
473                
474                if (_filterEvent(calendarIds, event) && _hasAccess(holdingCalendar))
475                {
476                    // The event is in the list of selected calendars and has the appropriate tags (can be none if tagIds == null)
477                    
478                    // Add all its occurrences that are within the range
479                    for (Date occurrence : occurrences)
480                    {
481                        Map<String, Object> eventData = _calendarDAO.getEventData(event, occurrence, false);
482                        recurrentEventList.add(eventData);
483                        processed++;
484                        
485                        _addAdditionalEventData(event, eventData);
486                        
487                    }
488                }
489            }
490        }
491        
492        // Re-sort chronologically the events' union
493        List<Map<String, Object>> allEvents = ListUtils.union(basicEventList, recurrentEventList);
494        Collections.sort(allEvents, new StartDateComparator());
495
496        // Return the first maxResults events
497        return allEvents.size() <= maxResults ? allEvents : allEvents.subList(0, maxResults);
498    }
499    
500    /**
501     * Determine whether the given event has to be kept or not depending on the given calendars
502     * @param calendarIds the ids of the calendars
503     * @param event the event
504     * @return true if the event can be kept, false otherwise
505     */
506    private boolean _filterEvent(List<String> calendarIds, CalendarEvent event)
507    {
508        Calendar holdingCalendar = (Calendar) event.getParent();
509        // FIXME calendarIds.get(0) == null means "All calendars" selected in the select calendar widget ??
510        // need cleaner code
511        return calendarIds == null || calendarIds.get(0) == null || calendarIds.contains(holdingCalendar.getId());
512    }
513    
514    private boolean _hasAccess(Calendar calendar)
515    {
516        return CalendarVisibility.PUBLIC.equals(calendar.getVisibility()) || canView(calendar);
517    }
518    
519    /**
520     * Get the data of every available calendar of the application
521     * @return the list of calendar data
522     */
523    @Callable
524    public List<Map<String, Object>> getCalendarsData()
525    {
526        List<Map<String, Object>> calendarsData = new ArrayList<>();
527        
528        AmetysObjectIterable<Project> projects = _projectManager.getProjects();
529        for (Project project : projects)
530        {
531            AmetysObjectIterable<Calendar> calendars = getCalendars(project);
532            if (calendars != null)
533            {
534                for (Calendar calendar : calendars)
535                {
536                    if (canView(calendar))
537                    {
538                        calendarsData.add(_calendarDAO.getCalendarData(calendar, false, false));
539                    }
540                }
541            }
542        }
543        
544        return calendarsData;
545    }
546    
547    /**
548     * Indicates if the current user can view the calendar
549     * @param calendar The calendar to test
550     * @return true if the calendar can be viewed 
551     */
552    public boolean canView(Calendar calendar)
553    {
554        return _rightManager.currentUserHasReadAccess(calendar);
555    }
556    
557    /**
558     * Indicates if the current user can view the event
559     * @param event The event to test
560     * @return true if the event can be viewed 
561     */
562    public boolean canView(CalendarEvent event)
563    {
564        return _rightManager.currentUserHasReadAccess(event.getParent());
565    }
566    
567    /**
568     * Compares events on their starting date
569     */
570    protected static class StartDateComparator implements Comparator<Map<String, Object>>
571    {
572        @Override
573        public int compare(Map<String, Object> calendarEventInfo1, Map<String, Object> calendarEventInfo2)
574        {
575            String startDate1asString = (String) calendarEventInfo1.get("startDate");
576            String startDate2asString = (String) calendarEventInfo2.get("startDate");
577            
578            Date startDate1 = DateUtils.parse(startDate1asString);
579            Date startDate2 = DateUtils.parse(startDate2asString);
580            
581            // The start date is before if 
582            return startDate1.compareTo(startDate2);
583        }
584    }
585    
586    @Override
587    public Set<String> getAllowedEventTypes()
588    {
589        return ImmutableSet.of("calendar.event.created", "calendar.event.updated");
590    }
591
592}
593