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