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