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.StringUtils;
035
036import org.ametys.cms.transformation.xslt.ResolveURIComponent;
037import org.ametys.core.observation.Event;
038import org.ametys.core.observation.ObservationManager;
039import org.ametys.core.right.RightManager;
040import org.ametys.core.user.CurrentUserProvider;
041import org.ametys.core.user.UserManager;
042import org.ametys.core.util.I18nUtils;
043import org.ametys.plugins.core.user.UserHelper;
044import org.ametys.plugins.explorer.ExplorerNode;
045import org.ametys.plugins.explorer.resources.ModifiableResourceCollection;
046import org.ametys.plugins.explorer.resources.jcr.JCRResourcesCollectionFactory;
047import org.ametys.plugins.repository.AmetysObject;
048import org.ametys.plugins.repository.AmetysObjectIterable;
049import org.ametys.plugins.repository.AmetysObjectResolver;
050import org.ametys.plugins.repository.AmetysRepositoryException;
051import org.ametys.plugins.repository.ModifiableTraversableAmetysObject;
052import org.ametys.plugins.repository.activities.Activity;
053import org.ametys.plugins.repository.activities.ActivityHelper;
054import org.ametys.plugins.repository.activities.ActivityTypeExpression;
055import org.ametys.plugins.repository.query.expression.AndExpression;
056import org.ametys.plugins.repository.query.expression.Expression;
057import org.ametys.plugins.repository.query.expression.Expression.Operator;
058import org.ametys.plugins.repository.query.expression.OrExpression;
059import org.ametys.plugins.repository.query.expression.StringExpression;
060import org.ametys.plugins.workspaces.activities.AbstractWorkspacesActivityType;
061import org.ametys.plugins.workspaces.activities.activitystream.ActivityStreamClientInteraction;
062import org.ametys.plugins.workspaces.project.ProjectConstants;
063import org.ametys.plugins.workspaces.project.ProjectManager;
064import org.ametys.plugins.workspaces.project.modules.WorkspaceModule;
065import org.ametys.plugins.workspaces.project.modules.WorkspaceModuleExtensionPoint;
066import org.ametys.plugins.workspaces.project.objects.Project;
067import org.ametys.plugins.workspaces.project.rights.ProjectRightHelper;
068import org.ametys.plugins.workspaces.util.StatisticColumn;
069import org.ametys.plugins.workspaces.util.StatisticsColumnType;
070import org.ametys.runtime.i18n.I18nizableText;
071import org.ametys.runtime.model.ElementDefinition;
072import org.ametys.runtime.plugin.component.AbstractLogEnabled;
073import org.ametys.runtime.plugin.component.PluginAware;
074import org.ametys.web.ObservationConstants;
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        List<String> modulePageIds = _getModulePages(project)
328                .stream()
329                .filter(p -> !"index".equals(p.getPathInSitemap()) && (visible && !p.isVisible() || !visible && p.isVisible()))
330                .map(Page::getId)
331                .collect(Collectors.toList());
332        
333        _pageDAO.setVisibility(modulePageIds, visible);
334    }
335    
336    /**
337     * Delete the module pages and their related contents
338     * @param project The project
339     */
340    protected void _deletePages(Project project)
341    {
342        List<Page> modulePages = _getModulePages(project);
343        
344        for (Page page : modulePages)
345        {
346            _pageDAO.deletePage((ModifiablePage) page, true);
347        }
348    }
349    
350    /**
351     * Get the module pages
352     * @param project the project
353     * @return the module pages
354     */
355    protected List<Page> _getModulePages(Project project)
356    {
357        String modulePageName = getModulePageName();
358        List<Page> pages = new ArrayList<>();
359        Site site = project.getSite();
360        if (site != null)
361        {
362            for (Sitemap sitemap : site.getSitemaps())
363            {
364                if (sitemap.hasChild(modulePageName))
365                {
366                    pages.add(sitemap.getChild(modulePageName));
367                }
368            }
369        }
370        
371        return pages;
372    }
373
374    /**
375     * Delete all activities related to this module
376     * @param project The project
377     */
378    protected void _deleteActivities(Project project)
379    {
380        Expression projectExpression = new StringExpression(AbstractWorkspacesActivityType.PROJECT_NAME, Operator.EQ, project.getName());
381        List<Expression> eventTypeExpressions = new ArrayList<>();
382        for (String eventType: getAllowedEventTypes())
383        {
384            eventTypeExpressions.add(new ActivityTypeExpression(Operator.EQ, eventType));
385        }
386        Expression moduleActivityExpression = new AndExpression(projectExpression, new OrExpression((Expression[]) eventTypeExpressions.toArray()));
387        String query = ActivityHelper.getActivityXPathQuery(moduleActivityExpression);
388        AmetysObjectIterable<Activity> activities = _resolver.query(query);
389        for (Activity activity : activities)
390        {
391            activity.remove();
392        }
393    }
394    
395    /**
396     * Get the default value of the XSLT parameter of the given service.
397     * @param serviceId the service ID.
398     * @return the default XSLT parameter value.
399     */
400    protected String _getDefaultXslt(String serviceId)
401    {
402        Service service = _serviceEP.hasExtension(serviceId) ? _serviceEP.getExtension(serviceId) : null;
403        if (service != null)
404        {
405            @SuppressWarnings("unchecked")
406            ElementDefinition<String> xsltParameterDefinition = (ElementDefinition<String>) service.getParameters().get("xslt");
407            
408            if (xsltParameterDefinition != null)
409            {
410                return xsltParameterDefinition.getDefaultValue();
411            }
412        }
413        
414        return StringUtils.EMPTY;
415    }
416    
417    /**
418     * Returns the module page's name
419     * @return The module page's name
420     */
421    protected abstract String getModulePageName();
422    
423    /**
424     * Returns the module page's title as i18n
425     * @return The module page's title
426     */
427    protected abstract I18nizableText getModulePageTitle();
428    
429    /**
430     * Returns the template to use for module's page. Can be null if the page should be a node page
431     * @return The template
432     */
433    protected String getModulePageTemplate()
434    {
435        return ProjectConstants.PROJECT_TEMPLATE;
436    }
437    
438    /**
439     * Initialize the module page
440     * @param modulePage The module page
441     */
442    protected abstract void initializeModulePage(ModifiablePage modulePage);
443    
444    /**
445     * Internal process when module is deactivated
446     * @param project The project
447     */
448    protected void _internalDeactivateModule(Project project)
449    {
450        // Empty
451    }
452    
453    /**
454     * Internal process to delete data
455     * @param project The project
456     */
457    protected void _internalDeleteData(Project project)
458    {
459        // Empty
460    }
461    
462    /**
463     * Internal process when module is activated
464     * @param project The project
465     * @param additionalValues A list of optional additional values. Accepted values are : description, mailingList, inscriptionStatus, defaultProfile, tags, categoryTags, keywords and language
466     */
467    protected void _internalActivateModule(Project project, Map<String, Object> additionalValues)
468    {
469        // Empty
470    }
471    
472    /**
473     * Utility method to get or create an ametys object
474     * @param <A> A sub class of AmetysObject
475     * @param parent The parent object
476     * @param name The ametys object name
477     * @param type The ametys object type
478     * @param create True to create the object if it does not exist
479     * @return ametys object
480     * @throws AmetysRepositoryException if an repository error occurs
481     */
482    protected <A extends AmetysObject> A _getAmetysObject(ModifiableTraversableAmetysObject parent, String name, String type, boolean create) throws AmetysRepositoryException
483    {
484        A object = null;
485        
486        if (parent.hasChild(name))
487        {
488            object = parent.getChild(name);
489        }
490        else if (create)
491        {
492            object = parent.createChild(name, type);
493            parent.saveChanges();
494        }
495        
496        return object;
497    }
498
499    @Override
500    public Map<String, Object> getStatistics(Project project)
501    {
502        Map<String, Object> statistics = new HashMap<>();
503
504        if (ArrayUtils.contains(project.getModules(), getId()))
505        {
506            statistics.put(_getModuleLastActivityKey(), _getModuleLastActivity(project));
507            statistics.put(_getModuleAtivateKey(), true);
508            statistics.put(getModuleSizeKey(), _getModuleSize(project));
509            statistics.putAll(_getInternalStatistics(project, true));
510        }
511        else
512        {
513            statistics.put(_getModuleAtivateKey(), false);
514            statistics.putAll(_getInternalStatistics(project, false));
515            // Use -2 as default empty value, so the sort in columns can work. It will be replaced by empty value in the renderer.
516            statistics.put(getModuleSizeKey(), __SIZE_INACTIVE);
517        }
518        
519        return statistics;
520    }
521
522    /**
523     * Get the internal statistics of the module
524     * @param project The project
525     * @param isActive true if module is active
526     * @return a map of internal statistics
527     */
528    protected Map<String, Object> _getInternalStatistics(Project project, boolean isActive)
529    {
530        return Map.of();
531    }
532    
533    @Override
534    public List<StatisticColumn> getStatisticModel()
535    {
536        List<StatisticColumn> statisticHeaders = new ArrayList<>();
537
538        if (_showActivatedStatus())
539        {
540            statisticHeaders.add(new StatisticColumn(_getModuleAtivateKey(), getModuleTitle())
541                    .withGroup(GROUP_HEADER_ACTIVATED_ID)
542                    .withType(StatisticsColumnType.BOOLEAN));
543        }
544        if (_showLastActivity())
545        {
546            statisticHeaders.add(new StatisticColumn(_getModuleLastActivityKey(), getModuleTitle())
547                    .withGroup(GROUP_HEADER_LAST_ACTIVITY_ID)
548                    .withType(StatisticsColumnType.DATE));
549        }
550        
551        if (_showModuleSize())
552        {
553            statisticHeaders.add(new StatisticColumn(getModuleSizeKey(), getModuleTitle())
554                    .withType(StatisticsColumnType.LONG)
555                    .withGroup(GROUP_HEADER_SIZE_ID)
556                    .withRenderer("Ametys.plugins.workspaces.project.tool.ProjectsGridHelper.renderSize")
557                    .isHidden(true));
558        }
559        
560        statisticHeaders.addAll(_getInternalStatisticModel());
561        
562        return statisticHeaders;
563    }
564    
565    /**
566     * Get the headers of statistics
567     * @return a list of statistics headers
568     */
569    protected List<StatisticColumn> _getInternalStatisticModel()
570    {
571        return List.of();
572    }
573
574    @Override
575    public String getModuleSizeKey()
576    {
577        return getModuleName() + "$size";
578    }
579    
580    private String _getModuleAtivateKey()
581    {
582        return getModuleName() + "$activated";
583    }
584    
585    private String _getModuleLastActivityKey()
586    {
587        return getModuleName() + "$lastActivity";
588    }
589
590    /**
591     * Get the size of module in bytes
592     * @param project The project
593     * @return the size of module in bytes
594     */
595    protected long _getModuleSize(Project project)
596    {
597        return 0;
598    }
599
600    /**
601     * Check if activated status should be shown or not
602     * @return true if activated status should be shown
603     */
604    protected boolean _showActivatedStatus()
605    {
606        return true;
607    }
608
609    /**
610     * Check if module size should be shown or not
611     * @return true if module size should be shown
612     */
613    protected boolean _showModuleSize()
614    {
615        return false;
616    }
617
618    public Set<String> getAllEventTypes()
619    {
620        return getAllowedEventTypes();
621    }
622
623    /**
624     * Check if the last activity should be shown or not
625     * @return true if last activity should be shown
626     */
627    protected boolean _showLastActivity()
628    {
629        return getAllEventTypes().size() != 0;
630    }
631    
632    /**
633     * Get the size of module in bytes
634     * @param project The project
635     * @return the size of module in bytes
636     */
637    protected ZonedDateTime _getModuleLastActivity(Project project)
638    {
639        return _activityStream.getDateOfLastActivityByActivityType(project.getName(), getAllowedEventTypes());
640    }
641    
642    public ModifiableResourceCollection getModuleRoot(Project project, boolean create)
643    {
644        try
645        {
646            ExplorerNode projectRootNode = project.getExplorerRootNode();
647            
648            if (projectRootNode instanceof ModifiableResourceCollection mProjectRootNode)
649            {
650                return _getAmetysObject(mProjectRootNode, getModuleName(), JCRResourcesCollectionFactory.RESOURCESCOLLECTION_NODETYPE, create);
651            }
652            else
653            {
654                throw new IllegalArgumentException("Root module '" + projectRootNode.getPath() + "' is not modifiable");
655            }
656        }
657        catch (AmetysRepositoryException e)
658        {
659            throw new AmetysRepositoryException("Error getting the " + getModuleName() + " root node.", e);
660        }
661    }
662}