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