001/*
002 *  Copyright 2017 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;
017
018import java.time.ZonedDateTime;
019import java.util.ArrayList;
020import java.util.HashMap;
021import java.util.List;
022import java.util.Map;
023import java.util.Optional;
024import java.util.Set;
025import java.util.stream.Collectors;
026
027import javax.jcr.Node;
028import javax.jcr.NodeIterator;
029import javax.jcr.RepositoryException;
030
031import org.apache.avalon.framework.context.Context;
032import org.apache.avalon.framework.context.ContextException;
033import org.apache.avalon.framework.context.Contextualizable;
034import org.apache.avalon.framework.service.ServiceException;
035import org.apache.avalon.framework.service.ServiceManager;
036import org.apache.avalon.framework.service.Serviceable;
037import org.apache.commons.lang.ArrayUtils;
038import org.apache.commons.lang.StringUtils;
039
040import org.ametys.cms.transformation.xslt.ResolveURIComponent;
041import org.ametys.core.observation.Event;
042import org.ametys.core.observation.ObservationManager;
043import org.ametys.core.right.RightManager;
044import org.ametys.core.user.CurrentUserProvider;
045import org.ametys.core.user.UserManager;
046import org.ametys.core.util.I18nUtils;
047import org.ametys.plugins.core.user.UserHelper;
048import org.ametys.plugins.explorer.resources.ModifiableResourceCollection;
049import org.ametys.plugins.repository.AmetysObject;
050import org.ametys.plugins.repository.AmetysObjectResolver;
051import org.ametys.plugins.repository.AmetysRepositoryException;
052import org.ametys.plugins.repository.ModifiableTraversableAmetysObject;
053import org.ametys.plugins.repository.events.JCREventHelper;
054import org.ametys.plugins.workspaces.events.activitystream.ActivityStreamClientInteraction;
055import org.ametys.plugins.workspaces.project.ProjectConstants;
056import org.ametys.plugins.workspaces.project.ProjectManager;
057import org.ametys.plugins.workspaces.project.modules.WorkspaceModule;
058import org.ametys.plugins.workspaces.project.modules.WorkspaceModuleExtensionPoint;
059import org.ametys.plugins.workspaces.project.objects.Project;
060import org.ametys.plugins.workspaces.project.rights.ProjectRightHelper;
061import org.ametys.plugins.workspaces.util.StatisticColumn;
062import org.ametys.plugins.workspaces.util.StatisticsColumnType;
063import org.ametys.runtime.i18n.I18nizableText;
064import org.ametys.runtime.model.ElementDefinition;
065import org.ametys.runtime.plugin.component.AbstractLogEnabled;
066import org.ametys.runtime.plugin.component.PluginAware;
067import org.ametys.web.ObservationConstants;
068import org.ametys.web.repository.page.ModifiablePage;
069import org.ametys.web.repository.page.MoveablePage;
070import org.ametys.web.repository.page.Page;
071import org.ametys.web.repository.page.Page.PageType;
072import org.ametys.web.repository.page.PageDAO;
073import org.ametys.web.repository.site.Site;
074import org.ametys.web.repository.sitemap.Sitemap;
075import org.ametys.web.service.Service;
076import org.ametys.web.service.ServiceExtensionPoint;
077import org.ametys.web.skin.Skin;
078import org.ametys.web.skin.SkinTemplate;
079import org.ametys.web.skin.SkinsManager;
080
081/**
082 * Abstract class for {@link WorkspaceModule} implementation
083 *
084 */
085public abstract class AbstractWorkspaceModule extends AbstractLogEnabled implements WorkspaceModule, Serviceable, Contextualizable, PluginAware
086{
087
088    /** Size value constants in case of size computation error */
089    protected static final Long __SIZE_ERROR = -1L;
090    
091    /** Size value constants for inactive modules */
092    protected static final Long __SIZE_INACTIVE = -2L;
093    
094    /** Project manager */
095    protected ProjectManager _projectManager;
096    /** Project right helper */
097    protected ProjectRightHelper _projectRightHelper;
098    /** User manager */
099    protected UserManager _userManager;
100    /** Ametys resolver */
101    protected AmetysObjectResolver _resolver;
102    /** The rights manager */
103    protected RightManager _rightManager;
104    /** Observer manager. */
105    protected ObservationManager _observationManager;
106    /** The current user provider. */
107    protected CurrentUserProvider _currentUserProvider;
108    /** The users manager */
109    protected UserHelper _userHelper;
110    /** The i18n utils. */
111    protected I18nUtils _i18nUtils;
112    /** The skins manager. */
113    protected SkinsManager _skinsManager;
114    /** The page DAO */
115    protected PageDAO _pageDAO;
116    /** The avalon context */
117    protected Context _context;
118    /** The plugin name */
119    protected String _pluginName;
120    /** The services handler */
121    protected ServiceExtensionPoint _serviceEP;
122    /** The modules extension point */
123    protected WorkspaceModuleExtensionPoint _modulesEP;
124    /** The activity stream manager */
125    protected ActivityStreamClientInteraction _activityStream;
126    
127    @Override
128    public void service(ServiceManager manager) throws ServiceException
129    {
130        _projectManager = (ProjectManager) manager.lookup(ProjectManager.ROLE);
131        _projectRightHelper = (ProjectRightHelper) manager.lookup(ProjectRightHelper.ROLE);
132        _userManager = (UserManager) manager.lookup(UserManager.ROLE);
133        _userHelper = (UserHelper) manager.lookup(UserHelper.ROLE);
134        _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE);
135        _rightManager = (RightManager) manager.lookup(RightManager.ROLE);
136        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
137        _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE);
138        _i18nUtils = (I18nUtils) manager.lookup(I18nUtils.ROLE);
139        _skinsManager = (SkinsManager) manager.lookup(SkinsManager.ROLE);
140        _pageDAO = (PageDAO) manager.lookup(PageDAO.ROLE);
141        _serviceEP = (ServiceExtensionPoint) manager.lookup(ServiceExtensionPoint.ROLE);
142        _modulesEP = (WorkspaceModuleExtensionPoint) manager.lookup(WorkspaceModuleExtensionPoint.ROLE);
143        _activityStream = (ActivityStreamClientInteraction) manager.lookup(ActivityStreamClientInteraction.ROLE);
144    }
145
146    @Override
147    public void contextualize(Context context) throws ContextException
148    {
149        _context = context;
150    }
151    
152    public void setPluginInfo(String pluginName, String featureName, String id)
153    {
154        _pluginName = pluginName;
155    }
156    
157    @Override
158    public void deleteData(Project project)
159    {
160        // Delete module pages
161        _deletePages(project);
162        
163        _internalDeleteData(project);
164        
165        // Delete root
166        ModifiableResourceCollection moduleRoot = getModuleRoot(project, false);
167        if (moduleRoot != null)
168        {
169            moduleRoot.remove();
170        }
171        
172        // Delete events
173        _deleteEvents(project);
174    }
175    
176    @Override
177    public void deactivateModule(Project project)
178    {
179        // Hide module pages
180        _setPagesVisibility(project, false);
181        
182        _internalDeactivateModule(project);
183    }
184    
185    @Override
186    public void activateModule(Project project, Map<String, Object> additionalValues)
187    {
188        // create the resources root node
189        getModuleRoot(project, true);
190        _internalActivateModule(project, additionalValues);
191        
192        Site site = project.getSite();
193        if (site != null)
194        {
195            for (Sitemap sitemap : site.getSitemaps())
196            {
197                initializeSitemap(project, sitemap);
198            }
199        }
200        
201        _setPagesVisibility(project, true);
202    }
203    
204    @Override
205    public void initializeSitemap(Project project, Sitemap sitemap)
206    {
207        ModifiablePage page = _createModulePage(project, sitemap, getModulePageName(), getModulePageTitle(), getModulePageTemplate());
208        
209        if (page != null)
210        {
211            page.tag("SECTION");
212            _projectManager.tagProjectPage(page, getModuleRoot(project, true));
213            
214            initializeModulePage(page);
215            
216            page.saveChanges();
217            
218            Map<String, Object> eventParams = new HashMap<>();
219            eventParams.put(ObservationConstants.ARGS_PAGE, page);
220            _observationManager.notify(new Event(ObservationConstants.EVENT_PAGE_ADDED, _currentUserProvider.getUser(), eventParams));
221        }
222    }
223    
224    @Override
225    public String getModuleUrl(Project project)
226    {
227        Optional<String> url = _projectManager.getModulePages(project, this).stream()
228            .findFirst()
229            .map(page -> ResolveURIComponent.resolve("page", page.getId()));
230        
231        if (url.isPresent())
232        {
233            return url.get();
234        }
235        else
236        {
237            // No page found
238            return null;
239        }
240    }
241    
242    /**
243     * Create a new page if not already exists
244     * @param project The module project
245     * @param sitemap The sitemap where the page will be created
246     * @param name The page's name
247     * @param pageTitle The page's title as i18nizable text
248     * @param skinTemplate The template from the skin to apply on the page
249     * @return the created page or <code>null</code> if page already exists
250     */
251    protected ModifiablePage _createModulePage(Project project, Sitemap sitemap, String name, I18nizableText pageTitle, String skinTemplate)
252    {
253        if (!sitemap.hasChild(name))
254        {
255            ModifiablePage page = sitemap.createChild(name, "ametys:defaultPage");
256            
257            // Title should not be missing, but just in case if the i18n message or the whole catalog does not exists in the requested language
258            // to prevent a non-user-friendly error and still generate the project workspace.
259            page.setTitle(StringUtils.defaultIfEmpty(_i18nUtils.translate(pageTitle, sitemap.getName()), "Missing title"));
260            page.setType(PageType.NODE);
261            page.setSiteName(sitemap.getSiteName());
262            page.setSitemapName(sitemap.getName());
263            
264            Site site = page.getSite();
265            Skin skin = _skinsManager.getSkin(site.getSkinId());
266            
267            if (skinTemplate != null)
268            {
269                SkinTemplate template = skin.getTemplate(skinTemplate);
270                if (template != null)
271                {
272                    // Set the type and template.
273                    page.setType(PageType.CONTAINER);
274                    page.setTemplate(skinTemplate);
275                }
276                else
277                {
278                    getLogger().error(String.format(
279                            "The project workspace  '%s' was created with the skin '%s'  which doesn't possess the mandatory template '%s'.\nThe '%s' page of the project workspace could not be initialized.",
280                            site.getName(), site.getSkinId(), skinTemplate, page.getName()));
281                }
282            }
283            
284            sitemap.saveChanges();
285
286            // Move module page to ensure pages order
287            for (WorkspaceModule otherModule : _modulesEP.getModules())
288            {
289                if (otherModule.compareTo(this) > 0)
290                {
291                    Set<Page> modulePages = _projectManager.getModulePages(project, otherModule);
292                    if (!modulePages.isEmpty())
293                    {
294                        ((MoveablePage) page).orderBefore(modulePages.iterator().next());
295                        break;
296                    }
297                }
298            }
299
300            sitemap.saveChanges();
301
302            return page;
303        }
304        else
305        {
306            return null;
307        }
308    }
309    
310    /**
311     * Change the visibility of module pages if needed
312     * @param project The project
313     * @param visible visible <code>true</code> to set pages as visible, <code>false</code> otherwise
314     */
315    protected void _setPagesVisibility(Project project, boolean visible)
316    {
317        List<String> modulePageIds = _getModulePages(project)
318                .stream()
319                .filter(p -> !"index".equals(p.getPathInSitemap()) && (visible && !p.isVisible() || !visible && p.isVisible()))
320                .map(Page::getId)
321                .collect(Collectors.toList());
322        
323        _pageDAO.setVisibility(modulePageIds, visible);
324    }
325    
326    /**
327     * Delete the module pages and their related contents
328     * @param project The project
329     */
330    protected void _deletePages(Project project)
331    {
332        List<Page> modulePages = _getModulePages(project);
333        
334        for (Page page : modulePages)
335        {
336            _pageDAO.deletePage((ModifiablePage) page, true);
337        }
338    }
339    
340    /**
341     * Get the module pages
342     * @param project the project
343     * @return the module pages
344     */
345    protected List<Page> _getModulePages(Project project)
346    {
347        String modulePageName = getModulePageName();
348        List<Page> pages = new ArrayList<>();
349        Site site = project.getSite();
350        if (site != null)
351        {
352            for (Sitemap sitemap : site.getSitemaps())
353            {
354                if (sitemap.hasChild(modulePageName))
355                {
356                    pages.add(sitemap.getChild(modulePageName));
357                }
358            }
359        }
360        
361        return pages;
362    }
363
364    /**
365     * Delete all events related to this module
366     * @param project The project
367     */
368    protected void _deleteEvents(Project project)
369    {
370        try
371        {
372            NodeIterator events = JCREventHelper.getEvents(project, getAllowedEventTypes().toArray(new String[]{}));
373            while (events.hasNext())
374            {
375                Node event = (Node) events.next();
376                event.remove();
377            }
378        }
379        catch (RepositoryException e)
380        {
381            getLogger().warn("Unable to delete project '" + project.getName() + "' events for module '" + this.getId() + "'", e);
382        }
383    }
384    
385    /**
386     * Get the default value of the XSLT parameter of the given service.
387     * @param serviceId the service ID.
388     * @return the default XSLT parameter value.
389     */
390    protected String _getDefaultXslt(String serviceId)
391    {
392        Service service = _serviceEP.hasExtension(serviceId) ? _serviceEP.getExtension(serviceId) : null;
393        if (service != null)
394        {
395            @SuppressWarnings("unchecked")
396            ElementDefinition<String> xsltParameterDefinition = (ElementDefinition<String>) service.getParameters().get("xslt");
397            
398            if (xsltParameterDefinition != null)
399            {
400                return xsltParameterDefinition.getDefaultValue();
401            }
402        }
403        
404        return StringUtils.EMPTY;
405    }
406    
407    /**
408     * Returns the module page's name
409     * @return The module page's name
410     */
411    protected abstract String getModulePageName();
412    
413    /**
414     * Returns the module page's title as i18n
415     * @return The module page's title
416     */
417    protected abstract I18nizableText getModulePageTitle();
418    
419    /**
420     * Returns the template to use for module's page. Can be null if the page should be a node page
421     * @return The template
422     */
423    protected String getModulePageTemplate() 
424    {
425        return ProjectConstants.PROJECT_TEMPLATE;
426    }
427    
428    /**
429     * Initialize the module page
430     * @param modulePage The module page
431     */
432    protected abstract void initializeModulePage(ModifiablePage modulePage);
433    
434    /**
435     * Internal process when module is deactivated
436     * @param project The project
437     */
438    protected void _internalDeactivateModule(Project project) 
439    {
440        // Empty
441    }
442    
443    /**
444     * Internal process to delete data
445     * @param project The project
446     */
447    protected void _internalDeleteData(Project project) 
448    {
449        // Empty
450    }
451    
452    /**
453     * Internal process when module is activated
454     * @param project The project
455     * @param additionalValues A list of optional additional values. Accepted values are : description, mailingList, inscriptionStatus, defaultProfile, tags, categoryTags, keywords and language
456     */
457    protected void _internalActivateModule(Project project, Map<String, Object> additionalValues)
458    {
459        // Empty
460    }
461    
462    /**
463     * Utility method to get or create an ametys object
464     * @param <A> A sub class of AmetysObject
465     * @param parent The parent object
466     * @param name The ametys object name
467     * @param type The ametys object type
468     * @param create True to create the object if it does not exist
469     * @return ametys object
470     * @throws AmetysRepositoryException if an repository error occurs
471     */
472    protected <A extends AmetysObject> A _getAmetysObject(ModifiableTraversableAmetysObject parent, String name, String type, boolean create) throws AmetysRepositoryException
473    {
474        A object = null;
475        
476        if (parent.hasChild(name))
477        {
478            object = parent.getChild(name);
479        }
480        else if (create)
481        {
482            object = parent.createChild(name, type);
483            parent.saveChanges();
484        }
485        
486        return object;
487    }
488
489    @Override
490    public Map<String, Object> getStatistics(Project project)
491    {
492        Map<String, Object> statistics = new HashMap<>();
493
494        if (ArrayUtils.contains(project.getModules(), getId()))
495        {
496            statistics.put(_getModuleLastActivityKey(), _getModuleLastActivity(project));
497            statistics.put(_getModuleAtivateKey(), true);
498            statistics.put(getModuleSizeKey(), _getModuleSize(project));
499            statistics.putAll(_getInternalStatistics(project, true));
500        }
501        else 
502        {
503            statistics.put(_getModuleAtivateKey(), false);
504            statistics.putAll(_getInternalStatistics(project, false));
505            // Use -2 as default empty value, so the sort in columns can work. It will be replaced by empty value in the renderer.
506            statistics.put(getModuleSizeKey(), __SIZE_INACTIVE);
507        }
508        
509        return statistics;
510    }
511
512    /**
513     * Get the internal statistics of the module
514     * @param project The project
515     * @param isActive true if module is active
516     * @return a map of internal statistics
517     */
518    protected Map<String, Object> _getInternalStatistics(Project project, boolean isActive)
519    {
520        return Map.of();
521    }
522    
523    @Override
524    public List<StatisticColumn> getStatisticModel()
525    {
526        List<StatisticColumn> statisticHeaders = new ArrayList<>();
527
528        if (_showActivatedStatus())
529        {
530            statisticHeaders.add(new StatisticColumn(_getModuleAtivateKey(), getModuleTitle())
531                    .withGroup(GROUP_HEADER_ACTIVATED_ID)
532                    .withType(StatisticsColumnType.BOOLEAN));
533        }
534        if (_showLastActivity())
535        {
536            statisticHeaders.add(new StatisticColumn(_getModuleLastActivityKey(), getModuleTitle())
537                    .withGroup(GROUP_HEADER_LAST_ACTIVITY_ID)
538                    .withType(StatisticsColumnType.DATE));
539        }
540        
541        if (_showModuleSize())
542        {
543            statisticHeaders.add(new StatisticColumn(getModuleSizeKey(), getModuleTitle())
544                    .withType(StatisticsColumnType.LONG)
545                    .withGroup(GROUP_HEADER_SIZE_ID)
546                    .withRenderer("Ametys.plugins.workspaces.project.tool.ProjectsGridHelper.renderSize")
547                    .isHidden(true));
548        }
549        
550        statisticHeaders.addAll(_getInternalStatisticModel());
551        
552        return statisticHeaders;
553    }
554    
555    /**
556     * Get the headers of statistics
557     * @return a list of statistics headers
558     */
559    protected List<StatisticColumn> _getInternalStatisticModel()
560    {
561        return List.of();
562    }
563
564    @Override
565    public String getModuleSizeKey()
566    {
567        return getModuleName() + "$size";
568    }
569    
570    private String _getModuleAtivateKey()
571    {
572        return getModuleName() + "$activated";
573    }
574    
575    private String _getModuleLastActivityKey()
576    {
577        return getModuleName() + "$lastActivity";
578    }
579
580    /**
581     * Get the size of module in bytes
582     * @param project The project
583     * @return the size of module in bytes
584     */
585    protected long _getModuleSize(Project project)
586    {
587        return 0;
588    }
589
590    /**
591     * Check if activated status should be shown or not
592     * @return true if activated status should be shown
593     */
594    protected boolean _showActivatedStatus()
595    {
596        return true;
597    }
598
599    /**
600     * Check if module size should be shown or not
601     * @return true if module size should be shown
602     */
603    protected boolean _showModuleSize()
604    {
605        return false;
606    }
607
608    public Set<String> getAllEventTypes()
609    {
610        return getAllowedEventTypes();
611    }
612
613    /**
614     * Check if the last activity should be shown or not
615     * @return true if last activity should be shown
616     */
617    protected boolean _showLastActivity()
618    {
619        return getAllEventTypes().size() != 0;
620    }
621    
622    /**
623     * Get the size of module in bytes
624     * @param project The project
625     * @return the size of module in bytes
626     */
627    protected ZonedDateTime _getModuleLastActivity(Project project)
628    {
629        return _activityStream.getDateOfLastEventByEventType(project.getName(), getAllowedEventTypes());
630    }
631}