001/*
002 *  Copyright 2020 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.project;
017
018import java.time.ZonedDateTime;
019import java.util.ArrayList;
020import java.util.Arrays;
021import java.util.Collection;
022import java.util.Collections;
023import java.util.HashMap;
024import java.util.HashSet;
025import java.util.Iterator;
026import java.util.LinkedHashSet;
027import java.util.List;
028import java.util.Map;
029import java.util.Objects;
030import java.util.Optional;
031import java.util.Set;
032import java.util.function.Predicate;
033import java.util.regex.Pattern;
034import java.util.stream.Collectors;
035import java.util.stream.Stream;
036import java.util.stream.StreamSupport;
037
038import javax.jcr.Node;
039import javax.jcr.PathNotFoundException;
040import javax.jcr.Property;
041import javax.jcr.RepositoryException;
042import javax.jcr.Session;
043import javax.jcr.Value;
044
045import org.apache.avalon.framework.activity.Initializable;
046import org.apache.avalon.framework.component.Component;
047import org.apache.avalon.framework.context.Context;
048import org.apache.avalon.framework.context.ContextException;
049import org.apache.avalon.framework.context.Contextualizable;
050import org.apache.avalon.framework.logger.AbstractLogEnabled;
051import org.apache.avalon.framework.service.ServiceException;
052import org.apache.avalon.framework.service.ServiceManager;
053import org.apache.avalon.framework.service.Serviceable;
054import org.apache.cocoon.components.ContextHelper;
055import org.apache.cocoon.environment.Request;
056import org.apache.commons.collections.CollectionUtils;
057import org.apache.commons.lang3.ArrayUtils;
058import org.apache.commons.lang3.StringUtils;
059import org.apache.commons.lang3.tuple.Pair;
060
061import org.ametys.cms.tag.Tag;
062import org.ametys.core.cache.AbstractCacheManager;
063import org.ametys.core.cache.AbstractCacheManager.CacheType;
064import org.ametys.core.cache.Cache;
065import org.ametys.core.cache.CacheException;
066import org.ametys.core.group.GroupDirectoryContextHelper;
067import org.ametys.core.group.GroupIdentity;
068import org.ametys.core.observation.Event;
069import org.ametys.core.observation.ObservationManager;
070import org.ametys.core.observation.Observer;
071import org.ametys.core.right.RightManager;
072import org.ametys.core.right.RightManager.RightResult;
073import org.ametys.core.ui.Callable;
074import org.ametys.core.user.CurrentUserProvider;
075import org.ametys.core.user.UserIdentity;
076import org.ametys.core.user.population.PopulationContextHelper;
077import org.ametys.core.util.I18nUtils;
078import org.ametys.core.util.LambdaUtils;
079import org.ametys.plugins.core.impl.cache.AbstractCacheKey;
080import org.ametys.plugins.core.search.UserAndGroupSearchManager;
081import org.ametys.plugins.core.user.UserHelper;
082import org.ametys.plugins.explorer.ExplorerNode;
083import org.ametys.plugins.explorer.resources.ModifiableResourceCollection;
084import org.ametys.plugins.repository.AmetysObject;
085import org.ametys.plugins.repository.AmetysObjectIterable;
086import org.ametys.plugins.repository.AmetysObjectResolver;
087import org.ametys.plugins.repository.AmetysRepositoryException;
088import org.ametys.plugins.repository.CollectionIterable;
089import org.ametys.plugins.repository.ModifiableAmetysObject;
090import org.ametys.plugins.repository.ModifiableTraversableAmetysObject;
091import org.ametys.plugins.repository.RepositoryConstants;
092import org.ametys.plugins.repository.UnknownAmetysObjectException;
093import org.ametys.plugins.repository.jcr.JCRAmetysObject;
094import org.ametys.plugins.repository.jcr.NodeTypeHelper;
095import org.ametys.plugins.repository.provider.AbstractRepository;
096import org.ametys.plugins.repository.provider.JackrabbitRepository;
097import org.ametys.plugins.repository.provider.WorkspaceSelector;
098import org.ametys.plugins.repository.query.expression.Expression;
099import org.ametys.plugins.repository.query.expression.Expression.Operator;
100import org.ametys.plugins.repository.query.expression.StringExpression;
101import org.ametys.plugins.workspaces.ObservationConstants;
102import org.ametys.plugins.workspaces.catalog.CatalogSiteType;
103import org.ametys.plugins.workspaces.categories.Category;
104import org.ametys.plugins.workspaces.categories.CategoryProviderExtensionPoint;
105import org.ametys.plugins.workspaces.members.JCRProjectMember;
106import org.ametys.plugins.workspaces.members.JCRProjectMember.MemberType;
107import org.ametys.plugins.workspaces.members.ProjectMemberManager;
108import org.ametys.plugins.workspaces.members.ProjectMemberManager.ProjectMember;
109import org.ametys.plugins.workspaces.project.modules.WorkspaceModule;
110import org.ametys.plugins.workspaces.project.modules.WorkspaceModuleExtensionPoint;
111import org.ametys.plugins.workspaces.project.objects.Project;
112import org.ametys.plugins.workspaces.project.objects.Project.InscriptionStatus;
113import org.ametys.plugins.workspaces.project.rights.ProjectRightHelper;
114import org.ametys.plugins.workspaces.tags.ProjectTagProviderExtensionPoint;
115import org.ametys.plugins.workspaces.util.StatisticColumn;
116import org.ametys.plugins.workspaces.util.StatisticsColumnType;
117import org.ametys.runtime.authentication.AccessDeniedException;
118import org.ametys.runtime.config.Config;
119import org.ametys.runtime.i18n.I18nizableText;
120import org.ametys.runtime.plugin.component.PluginAware;
121import org.ametys.web.repository.page.ModifiablePage;
122import org.ametys.web.repository.page.Page;
123import org.ametys.web.repository.page.PageQueryHelper;
124import org.ametys.web.repository.page.SitemapElement;
125import org.ametys.web.repository.page.ZoneItem;
126import org.ametys.web.repository.site.Site;
127import org.ametys.web.repository.site.SiteDAO;
128import org.ametys.web.repository.site.SiteManager;
129import org.ametys.web.repository.sitemap.Sitemap;
130import org.ametys.web.site.SiteConfigurationManager;
131
132/**
133 * Helper component for managing project workspaces
134 */
135public class ProjectManager extends AbstractLogEnabled implements Serviceable, Component, Contextualizable, PluginAware, Initializable, Observer
136{
137    /** Avalon Role */
138    public static final String ROLE = ProjectManager.class.getName();
139    
140    /** Constant for the {@link Cache} id (the {@link Cache} is in {@link CacheType#REQUEST REQUEST} attribute) for the {@link Project}s objects
141     *  in cache by {@link RequestProjectCacheKey} (composition of project name and workspace name). */
142    public static final String REQUEST_PROJECTBYID_CACHE = ProjectManager.class.getName() + "$ProjectById";
143    
144    /** Constant for the {@link Cache} id for the {@link Project} ids (as {@link String}s) in cache by project name (for whole application). */
145    public static final String MEMORY_PROJECTIDBYNAMECACHE = ProjectManager.class.getName() + "$UUID";
146
147    /** Constant for the {@link Cache} id (the {@link Cache} is in {@link CacheType#REQUEST REQUEST} attribute) for the {@link Page}s objects
148     *  in cache by {@link RequestModuleCacheKey} (composition of project name and module name). */
149    public static final String REQUEST_PAGESBYPROJECTANDMODULE_CACHE = ProjectManager.class.getName() + "$PagesByModule";
150    
151    /** Constant for the {@link Cache} id for the {@link Project} ids (as {@link String}s) in cache by project name (for whole application). */
152    public static final String MEMORY_PAGESBYIDCACHE = ProjectManager.class.getName() + "$PageUUID";
153
154    /** Constant for the {@link Cache} id for the {@link Project} ids (as {@link String}s) in cache by site name (for whole application). */
155    public static final String MEMORY_SITEASSOCIATION_CACHE = ProjectManager.class.getName() + "$SiteAssociation";
156
157    /** Workspaces plugin node name */
158    private static final String __WORKSPACES_PLUGIN_NODE_NAME = "workspaces";
159    
160    /** Workspaces plugin node name */
161    private static final String __WORKSPACES_PLUGIN_NODE_TYPE = RepositoryConstants.NAMESPACE_PREFIX + ":unstructured";
162    
163    /** The name of the projects root node */
164    private static final String __PROJECTS_ROOT_NODE_NAME = "projects";
165    
166    /** The type of the projects root node */
167    private static final String __PROJECTS_ROOT_NODE_TYPE = RepositoryConstants.NAMESPACE_PREFIX + ":unstructured";
168    
169    /** Constants for tags metadata */
170    private static final String __PROJECTS_TAGS_PROPERTY = RepositoryConstants.NAMESPACE_PREFIX_INTERNAL + ":tags";
171    
172    private static final String __PAGE_MODULES_VALUE = "workspaces-modules";
173
174    private static final String __IS_CACHE_FILLED = "###iscachefilled###";
175
176    /** Ametys object resolver */
177    protected AmetysObjectResolver _resolver;
178    
179    /** The i18n utils. */
180    protected I18nUtils _i18nUtils;
181    
182    /** Site manager */
183    protected SiteManager _siteManager;
184    
185    /** Site DAO */
186    protected SiteDAO _siteDao;
187    
188    /** Site configuration manager */
189    protected SiteConfigurationManager _siteConfigurationManager;
190    
191    /** Module Managers EP */
192    protected WorkspaceModuleExtensionPoint _moduleManagerEP;
193
194    /** Helper for user population */
195    protected PopulationContextHelper _populationContextHelper;
196    
197    /** Helper for group directory's context */
198    protected GroupDirectoryContextHelper _groupDirectoryContextHelper;
199    
200    /** The project members' manager */
201    protected ProjectMemberManager _projectMemberManager;
202
203    /** Avalon context */
204    protected Context _context;
205    
206    private ObservationManager _observationManager;
207    
208    private CurrentUserProvider _currentUserProvider;
209
210    private ProjectMemberManager _projectMembers;
211
212    private String _pluginName;
213
214    private ProjectRightHelper _projectRightHelper;
215
216    private ProjectTagProviderExtensionPoint _projectTagProviderEP;
217
218    private CategoryProviderExtensionPoint _categoryProviderEP;
219    
220    private UserHelper _userHelper;
221
222    private AbstractCacheManager _cacheManager;
223
224    private UserAndGroupSearchManager _userAndGroupSearchManager;
225
226    private JackrabbitRepository _repository;
227
228    private WorkspaceSelector _workspaceSelector;
229
230    private RightManager _rightManager;
231
232    private GroupDirectoryContextHelper _directoryContextHelper;
233
234    @Override
235    public void contextualize(Context context) throws ContextException
236    {
237        _context = context;
238    }
239    
240    @Override
241    public void service(ServiceManager manager) throws ServiceException
242    {
243        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
244        _repository = (JackrabbitRepository) manager.lookup(AbstractRepository.ROLE);
245        _workspaceSelector = (WorkspaceSelector) manager.lookup(WorkspaceSelector.ROLE);
246        _i18nUtils = (I18nUtils) manager.lookup(I18nUtils.ROLE);
247        _siteManager = (SiteManager) manager.lookup(SiteManager.ROLE);
248        _siteDao = (SiteDAO) manager.lookup(SiteDAO.ROLE);
249        _siteConfigurationManager = (SiteConfigurationManager) manager.lookup(SiteConfigurationManager.ROLE);
250        _projectMembers = (ProjectMemberManager) manager.lookup(ProjectMemberManager.ROLE);
251        _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE);
252        _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE);
253        _moduleManagerEP = (WorkspaceModuleExtensionPoint) manager.lookup(WorkspaceModuleExtensionPoint.ROLE);
254        _projectRightHelper = (ProjectRightHelper) manager.lookup(ProjectRightHelper.ROLE);
255        _projectTagProviderEP = (ProjectTagProviderExtensionPoint) manager.lookup(ProjectTagProviderExtensionPoint.ROLE);
256        _userHelper = (UserHelper) manager.lookup(UserHelper.ROLE);
257        _categoryProviderEP = (CategoryProviderExtensionPoint) manager.lookup(CategoryProviderExtensionPoint.ROLE);
258        _cacheManager = (AbstractCacheManager) manager.lookup(AbstractCacheManager.ROLE);
259        _populationContextHelper = (PopulationContextHelper) manager.lookup(PopulationContextHelper.ROLE);
260        _groupDirectoryContextHelper = (GroupDirectoryContextHelper) manager.lookup(GroupDirectoryContextHelper.ROLE);
261        _projectMemberManager = (ProjectMemberManager) manager.lookup(ProjectMemberManager.ROLE);
262        _userAndGroupSearchManager = (UserAndGroupSearchManager) manager.lookup(UserAndGroupSearchManager.ROLE);
263        _rightManager = (RightManager) manager.lookup(RightManager.ROLE);
264        _directoryContextHelper = (GroupDirectoryContextHelper) manager.lookup(GroupDirectoryContextHelper.ROLE);
265    }
266    
267    public void initialize() throws Exception
268    {
269        _createCaches();
270        _observationManager.registerObserver(this);
271    }
272    
273    @Override
274    public void setPluginInfo(String pluginName, String featureName, String id)
275    {
276        _pluginName = pluginName;
277    }
278
279    /**
280     * Enumeration for the profile to assign for new module
281     */
282    public enum ProfileForNewModule
283    {
284        /** No profile assigned for new modules */
285        NONE,
286        
287        /** Default profile assigned for new modules */
288        DEFAULT_MEMBER_PROFILE
289        
290    }
291    
292    /**
293     * Retrieves all projects
294     * @return the projects
295     */
296    public AmetysObjectIterable<Project> getProjects()
297    {
298        return getProjects(true);
299    }
300    
301    /**
302     * Retrieves all projects
303     * @param onlyWorking true to retrieve only working projects with non null sites
304     * @return the projects
305     */
306    public AmetysObjectIterable<Project> getProjects(boolean onlyWorking)
307    {
308        // As cache is computed from default JCR workspace, we need to filter on sites that exist into the current JCR workspace
309        Set<Project> projects = _getUUIDCache().values().stream()
310                .filter(_resolver::hasAmetysObjectForId)
311                .map(_resolver::<Project>resolveById)
312                // If needed, check if the site is not null, as it can happen if site was deleted from admin side
313                .filter(project -> !onlyWorking || Objects.nonNull(project.getSite()))
314                .collect(Collectors.toSet());
315        
316        return new CollectionIterable<>(projects);
317    }
318    
319    /**
320     * Retrieves projects filtered by categories
321     * @param filteredCategories the filtered categories. Can be empty to no filter by categories.
322     * @return the projects
323     */
324    public List<Project> getProjects(Set<String> filteredCategories)
325    {
326        return getProjects(filteredCategories, null, null, true);
327    }
328    
329    /**
330     * Retrieves projects filtered by categories and/or keywords
331     * @param filteredCategories the filtered categories. Can be empty to no filter by categories.
332     * @param filteredKeywords the filtered keywords. Can be empty to no filter by keywords.
333     * @param anyMatch true to get projects matching categories OR keywords OR pattern
334     * @return the projects
335     */
336    public List<Project> getProjects(Set<String> filteredCategories, Set<String> filteredKeywords, boolean anyMatch)
337    {
338        return getProjects(filteredCategories, filteredKeywords, null, anyMatch);
339    }
340    
341    /**
342     * Retrieves projects filtered by categories and/or keywords and/or pattern
343     * @param filteredCategories the filtered categories. Can be empty to no filter by categories.
344     * @param filteredKeywords the filtered keywords. Can be empty to no filter by keywords.
345     * @param pattern to filter on pattern. Can be null or empty to no filter on pattern
346     * @param anyMatch true to get projects matching categories OR keywords OR pattern
347     * @return the projects
348     */
349    public List<Project> getProjects(Set<String> filteredCategories, Set<String> filteredKeywords, String pattern, boolean anyMatch)
350    {
351        return getProjects(filteredCategories, filteredKeywords, null, anyMatch, false);
352    }
353    
354    /**
355     * Retrieves projects filtered by categories and/or keywords and/or pattern
356     * @param filteredCategories the filtered categories. Can be null or empty to no filter by categories.
357     * @param filteredKeywords the filtered keywords. Can be empty to no filter by keywords.
358     * @param pattern to filter on pattern. Can be null or empty to no filter on pattern
359     * @param anyMatch true to get projects matching categories OR keywords OR pattern
360     * @param excludePrivate true to exclude private projects
361     * @return the projects
362     */
363    public List<Project> getProjects(Set<String> filteredCategories, Set<String> filteredKeywords, String pattern, boolean anyMatch, boolean excludePrivate)
364    {
365        List<Predicate<Project>> filters = new ArrayList<>();
366        if (filteredCategories != null && !filteredCategories.isEmpty())
367        {
368            filters.add(p -> !Collections.disjoint(p.getCategories(), filteredCategories));
369        }
370
371        if (filteredKeywords != null && !filteredKeywords.isEmpty())
372        {
373            filters.add(p -> !Collections.disjoint(Arrays.asList(p.getKeywords()), filteredKeywords));
374        }
375
376        Pattern patternFilter = StringUtils.isNotEmpty(pattern) ? Pattern.compile(pattern, Pattern.CASE_INSENSITIVE) : null;
377        if (patternFilter != null)
378        {
379            filters.add(p -> p.getTitle() != null && patternFilter.matcher(p.getTitle()).find() || p.getDescription() != null && patternFilter.matcher(p.getDescription()).find());
380        }
381
382        Predicate<Project> fullMatch = filters.stream().reduce(anyMatch ? Predicate::or : Predicate::and).orElse(p -> Boolean.TRUE);
383        Predicate<Project> matchStatus = p ->  !excludePrivate || p.getInscriptionStatus() != InscriptionStatus.PRIVATE;
384        
385        return getProjects()
386                .stream()
387                .filter(matchStatus)
388                .filter(fullMatch)
389                .collect(Collectors.toList());
390    }
391    
392    /**
393     * Get the projects categories
394     * @return the projects categories
395     */
396    public Set<Category> getProjectsCategories()
397    {
398        return getProjects().stream()
399            .map(Project::getCategories)
400            .flatMap(Collection::stream)
401            .map(id -> _categoryProviderEP.getTag(id, null))
402            .filter(Objects::nonNull)
403            .collect(Collectors.toSet());
404    }
405    
406    /**
407     * Get the user's projects categories
408     * @param user the user
409     * @return the user's projects categories
410     */
411    public Set<Category> getUserProjectsCategories(UserIdentity user)
412    {
413        return getUserProjects(user).keySet()
414                .stream()
415                .map(Project::getCategories)
416                .flatMap(Collection::stream)
417                .map(id -> _categoryProviderEP.getTag(id, null))
418                .filter(Objects::nonNull)
419                .collect(Collectors.toSet());
420    }
421    
422    /**
423     * Get the projects modules
424     * @return the projects modules
425     */
426    public Set<WorkspaceModule> getProjectsModules()
427    {
428        return getProjects().stream()
429            .map(Project::getModules)
430            .flatMap(Arrays::stream)
431            .map(id -> _moduleManagerEP.<WorkspaceModule>getModule(id))
432            .filter(Objects::nonNull)
433            .collect(Collectors.toSet());
434    }
435    
436    /**
437     * Get the projects modules for new members (depending on the project configuration)
438     * @param project the project
439     * @return the project modules
440     */
441    public Set<WorkspaceModule> getProjectModulesForNewMembers(Project project)
442    {
443        Site projectSite = project.getSite();
444        
445        // Check if project site overrides catalog configuration
446        String membersModulesPolicy = projectSite.getValue(ProjectWorkspaceSiteType.PROJECT_OVERRIDE_MEMBERS_MODULES_POLICY_SITE_PARAM, false, ProjectWorkspaceSiteType.MembersModulesPolicy.GLOBAL.name());
447
448        String membersModulesAsString = null;
449        if (membersModulesPolicy.equals(ProjectWorkspaceSiteType.MembersModulesPolicy.MEMBERS_MODULES_BY_PROJECT.name()))
450        {
451            membersModulesAsString = projectSite.getValue(ProjectWorkspaceSiteType.PROJECT_MEMBERS_MODULES_PROJECT_SITE_PARAM);
452        }
453        else
454        {
455            Site catalogSite = _siteManager.getSite(getCatalogSiteName());
456            // Use #hasValueOrEmpty because we can't use #getValue with a default value. Indeed, the #getValue use the default value for null value or empty value.
457            membersModulesAsString = catalogSite.hasValueOrEmpty(CatalogSiteType.PROJECT_MEMBERS_MODULES_SITE_PARAM) 
458                    ? catalogSite.getValue(CatalogSiteType.PROJECT_MEMBERS_MODULES_SITE_PARAM) 
459                    : String.join(",", _moduleManagerEP.getExtensionsIds());
460        }
461        
462        String[] membersModuleIds = StringUtils.split(StringUtils.defaultString(membersModulesAsString), ",");
463        
464        Set<WorkspaceModule> modules = new HashSet<>();
465        for (String moduleId : membersModuleIds)
466        {
467            if (StringUtils.isNotBlank(moduleId))
468            {
469                WorkspaceModule module = _moduleManagerEP.getModule(moduleId.trim());
470                modules.add(module);
471            }
472        }
473        return modules;
474    }
475    
476    /**
477     * Get the projects modules
478     * @param user the user
479     * @return the projects modules
480     */
481    public Set<WorkspaceModule> getUserProjectsModules(UserIdentity user)
482    {
483        return getUserProjects(user).keySet()
484            .stream()
485            .map(Project::getModules)
486            .flatMap(Arrays::stream)
487            .map(id -> _moduleManagerEP.<WorkspaceModule>getModule(id))
488            .filter(Objects::nonNull)
489            .collect(Collectors.toSet());
490    }
491    
492    /**
493     * Retrieves all projects for client side
494     * @return the projects
495     */
496    @Callable(rights = "Runtime_Rights_Admin_Access", context = "/admin")
497    public List<Map<String, Object>> getProjectsForClientSide()
498    {
499        return getProjects(false)
500                .stream()
501                .map(p -> getProjectProperties(p))
502                .collect(Collectors.toList());
503    }
504    
505    
506    /**
507     * Retrieves a project by its name
508     * @param projectName The project name
509     * @return the project or <code>null</code> if not found
510     */
511    public Project getProject(String projectName)
512    {
513        if (StringUtils.isBlank(projectName))
514        {
515            return null;
516        }
517        
518        Request request = _getRequest();
519        if (request == null)
520        {
521            // There is no request to store cache
522            return _computeProject(projectName);
523        }
524        
525        Cache<RequestProjectCacheKey, Project> projectsCache = _getRequestProjectCache();
526        
527        // The site key in the cache is of the form {site + workspace}.
528        String currentWorkspace = _workspaceSelector.getWorkspace();
529        RequestProjectCacheKey projectKey = RequestProjectCacheKey.of(projectName, currentWorkspace);
530        
531        try
532        {
533            Project project = projectsCache.get(projectKey, __ -> _computeProject(projectName));
534            return project;
535        }
536        catch (CacheException e)
537        {
538            if (e.getCause() instanceof UnknownAmetysObjectException)
539            {
540                throw new UnknownAmetysObjectException(e.getMessage());
541            }
542            else
543            {
544                throw e;
545            }
546        }
547    }
548    
549    /**
550     * Get the user's projects
551     * @param user the user
552     * @return the user's projects
553     */
554    public Map<Project, MemberType> getUserProjects(UserIdentity user)
555    {
556        return getUserProjects(user, Set.of());
557    }
558    
559    /**
560     * Get the user's projects filtered by categories
561     * @param user the user
562     * @param filteredCategories the filtered categories. Can be empty to no filter by categories
563     * @return the user's projects
564     */
565    public Map<Project, MemberType> getUserProjects(UserIdentity user, Set<String> filteredCategories)
566    {
567        return getUserProjects(user, filteredCategories, null, null, true);
568    }
569    
570    /**
571     * Get the user's projects filtered by categories OR keywords
572     * @param user the user
573     * @param filteredCategories the filtered categories. Can be empty to no filter by categories
574     * @param filteredKeywords the filtered keywords. Can be empty to no filter by keywords
575     * @return the user's projects
576     */
577    public Map<Project, MemberType> getUserProjects(UserIdentity user, Set<String> filteredCategories, Set<String> filteredKeywords)
578    {
579        return getUserProjects(user, filteredCategories, filteredKeywords, null, true);
580    }
581    
582    /**
583     * Get the user's projects filtered by categories, keywords and/or pattern
584     * @param user the user
585     * @param filteredCategories the filtered categories. Can be empty to no filter by categories.
586     * @param filteredKeywords the filtered keywords. Can be empty to no filter by keywords.
587     * @param pattern to filter on pattern. Can be null or empty to no filter on pattern
588     * @param anyMatch true to get projects matching categories OR keywords OR pattern
589     * @return the user's projects
590     */
591    public Map<Project, MemberType> getUserProjects(UserIdentity user, Set<String> filteredCategories, Set<String> filteredKeywords, String pattern, boolean anyMatch)
592    {
593        return getUserProjects(user, filteredCategories, filteredKeywords, pattern, true, false);
594    }
595    
596    /**
597     * Get the user's projects filtered by categories, keywords and/or pattern
598     * @param user the user
599     * @param filteredCategories the filtered categories. Can be empty to no filter by categories.
600     * @param filteredKeywords the filtered keywords. Can be empty to no filter by keywords.
601     * @param pattern to filter on pattern. Can be null or empty to no filter on pattern
602     * @param anyMatch true to get projects matching categories OR keywords OR pattern
603     * @param excludePrivate true to exclude private projects
604     * @return the user's projects
605     */
606    public Map<Project, MemberType> getUserProjects(UserIdentity user, Set<String> filteredCategories, Set<String> filteredKeywords, String pattern, boolean anyMatch, boolean excludePrivate)
607    {
608        Map<Project, MemberType> userProjects = new HashMap<>();
609        
610        List<Project> projects = getProjects(filteredCategories, filteredKeywords, pattern, anyMatch, excludePrivate);
611        for (Project project : projects)
612        {
613            ProjectMember member = _projectMembers.getProjectMember(project, user);
614            if (member != null)
615            {
616                userProjects.put(project, member.getType());
617            }
618        }
619        
620        return userProjects;
621    }
622
623    /**
624     * Get the projects managed by the user
625     * @param user the user
626     * @return the projects for which the user is a manager
627     */
628    public List<Project> getManagedProjects(UserIdentity user)
629    {
630        return getManagedProjects(user, Set.of());
631    }
632    
633    /**
634     * Get the projects managed by the user
635     * @param user the user
636     * @param filteredCategories the filtered categories. Can be empty to no filter by categories.
637     * @return the projects for which the user is a manager
638     */
639    public List<Project> getManagedProjects(UserIdentity user, Set<String> filteredCategories)
640    {
641        return getProjects(filteredCategories)
642            .stream()
643            .filter(p -> ArrayUtils.contains(p.getManagers(), user))
644            .collect(Collectors.toList());
645    }
646    
647    /**
648     * Returns true if the given project exists.
649     * @param projectName the project name.
650     * @return true if the given project exists.
651     */
652    public boolean hasProject(String projectName)
653    {
654        Map<String, String> uuidCache = _getUUIDCache();
655        if (uuidCache.containsKey(projectName))
656        {
657            // As cache is computed from default JCR workspace, we need to check if the project exists into the current JCR workspace
658            return _resolver.hasAmetysObjectForId(uuidCache.get(projectName));
659        }
660        return false;
661    }
662    
663    /**
664     * Get all managers
665     * @return the managers
666     */
667    public Set<UserIdentity> getManagers()
668    {
669        return getProjects()
670            .stream()
671            .map(Project::getManagers)
672            .flatMap(Arrays::stream)
673            .collect(Collectors.toSet());
674    }
675    
676    /**
677     * Determines if the current user is a manager of at least one project
678     * @return true if the user is a manager
679     */
680    public boolean isManager()
681    {
682        return isManager(_currentUserProvider.getUser());
683    }
684    
685    /**
686     * Determines if the user is a manager of at least one project
687     * @param user the user
688     * @return true if the user is a manager
689     */
690    public boolean isManager(UserIdentity user)
691    {
692        AmetysObjectIterable<Project> projects = getProjects();
693        for (Project project : projects)
694        {
695            if (isManager(project, user))
696            {
697                return true;
698            }
699        }
700        return false;
701    }
702    
703    /**
704     * Determines if the user is a manager of the project
705     * @param projectName the project name
706     * @param user the user
707     * @return true if the user is a manager
708     */
709    public boolean isManager(String projectName, UserIdentity user)
710    {
711        Project project = getProject(projectName);
712        if (project != null)
713        {
714            return isManager(project, user);
715        }
716        return false;
717    }
718    
719    /**
720     * Determines if the user is a manager of the project
721     * @param project the project
722     * @param user the user
723     * @return true if the user is a manager
724     */
725    public boolean isManager(Project project, UserIdentity user)
726    {
727        return ArrayUtils.contains(project.getManagers(), user);
728    }
729
730    /**
731     * Can the current user access backoffice on the site of the current project
732     * @param project The non null project to analyse
733     * @return true if the user can access to the backoffice
734     */
735    public boolean canAccessBO(Project project)
736    {
737        Site site = project.getSite();
738        if (site == null)
739        {
740            return false;
741        }
742
743        Request request = ContextHelper.getRequest(_context);
744        String currentSiteName = (String) request.getAttribute("siteName");
745        try
746        {
747            request.setAttribute("siteName", site.getName()); // Setting temporarily this attribute to check user rights on any object on this site
748            return !_rightManager.getUserRights(_currentUserProvider.getUser(), "/cms").isEmpty();
749        }
750        finally
751        {
752            request.setAttribute("siteName", currentSiteName);
753        }
754    }
755    
756    /**
757     * Can the current user leave the project
758     * @param project The non null project to analyse
759     * @return true if the user can leave the project
760     */
761    public boolean canLeaveProject(Project project)
762    {
763        if (project == null)
764        {
765            return false;
766        }
767        
768        Site site = project.getSite();
769        if (site == null)
770        {
771            return false;
772        }
773        
774        UserIdentity userIdentity = _currentUserProvider.getUser();
775        
776        // As a user in a group can't leave the project, check if he is registered as User member
777        Set<ProjectMember> members = _projectMemberManager.getProjectMembers(project, false);
778        ProjectMember projectMember = members.stream()
779                .filter(member -> MemberType.USER == member.getType())
780                .filter(member -> userIdentity.equals(member.getUser().getIdentity()))
781                .findFirst()
782                .orElse(null);
783        
784        // The user is either on a group, or has not been found
785        if (projectMember == null)
786        {
787            return false;
788        }
789
790        // The user is the only remaining manager, and therefore can not leave the project
791        if (_projectMemberManager.isOnlyManager(project, userIdentity))
792        {
793            return false;
794        }
795        
796        return true;
797    }
798    
799    /**
800     * Retrieves the mapping of all the projects name with their title on which the current user has access
801     * @return the map (projectName, projectTitle) for all projects
802     */
803    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION)
804    public List<Map<String, Object>> getUserProjectsData()
805    {
806        return getUserProjects(_currentUserProvider.getUser())
807                .keySet()
808                .stream()
809                .map(p -> _project2json(p))
810                .collect(Collectors.toList());
811    }
812
813    /**
814     * Get the user modules on the given project.
815     * @param projectName the project name
816     * @return The maps of modules with the read access information
817     */
818    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION)
819    public Map<String, Object> getUserModules(String projectName)
820    {
821        Project project = this.getProject(projectName);
822        UserIdentity currentUser = _currentUserProvider.getUser();
823        
824        return Arrays.stream(project.getModules())
825            .collect(Collectors.toMap(
826                mId -> mId,
827                mId -> _projectRightHelper.hasReadAccessOnModule(project, mId, currentUser)));
828    }
829
830    /**
831     * Retrieves the users that have not been yet added to a project with a given criteria
832     * @param projectName the project name
833     * @param limit limit of request
834     * @param criteria the criteria of the search
835     * @param previousSearchData the previous search data to compute offset. Null if first search
836     * @return list of users
837     */
838    @SuppressWarnings("unchecked")
839    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION)
840    public Map<String, Object> searchUserByProject(String projectName, int limit, String criteria, Map<String, Object> previousSearchData)
841    {
842
843        Project project = this.getProject(projectName);
844
845        if (!_projectRightHelper.canAddMember(project))
846        {
847            throw new AccessDeniedException("User '" + _currentUserProvider.getUser() + "' tried to do read operation without convenient right");
848        }
849        
850        Map<String, Object> results = new HashMap<>();
851        Site site = project.getSite();
852        
853        Set<String> projectMemberList = _projectMemberManager.getProjectMembers(project, false)
854                .stream()
855                .map(member ->
856                {
857                    if (member.getType() == MemberType.USER)
858                    {
859                        return UserIdentity.userIdentityToString(member.getUser().getIdentity());
860                    }
861                    else
862                    {
863                        GroupIdentity groupIdentityAsString = member.getGroup().getIdentity();
864                        return GroupIdentity.groupIdentityToString(groupIdentityAsString);
865                    }
866                })
867                .collect(Collectors.toSet());
868        
869        Set<String> contexts = new HashSet<>(Arrays.asList("/sites/" + site.getName(), "/sites-fo/" + site.getName()));
870        
871        Map<String, Object> params = new HashMap<>();
872        params.put("pattern", criteria);
873        
874        Map<String, Object> result = new HashMap<>();
875        Map<String, Object> searchData = previousSearchData;
876        List<Map<String, Object>> memberList = new ArrayList<>();
877
878        Set<String> groupDirectories = _directoryContextHelper.getGroupDirectoriesOnContexts(contexts);
879        Set<String> userPopulations = getPopulation(site, true);
880
881        do
882        {
883            result = _userAndGroupSearchManager.searchUsersAndGroup(userPopulations, groupDirectories, limit - memberList.size(), searchData, params, true);
884            List<Map<String, Object>> filteredMembers = ((List<Map<String, Object>>) result.get("results"))
885                    .stream()
886                    .filter(member ->
887                    {
888                        return !projectMemberList.contains(member.get("login") + "#" + member.get("populationId")) && !projectMemberList.contains(member.get("id") + "#" + member.get("groupDirectory"));
889                    }).collect(Collectors.toList());
890            searchData = (Map<String, Object>) result.get("searchData");
891            memberList.addAll(filteredMembers);
892        }
893        while (!result.containsKey("finished") && memberList.size() < limit);
894
895        results.put("searchData", searchData);
896        results.put("memberList", memberList);
897        return results;
898    }
899    
900    /**
901     * Get the populations of the project
902     * @param site the project site
903     * @param excludeConfigurationPopulations true to exclude populations configured in the catalog site or in the project site
904     * @return the populations of the project
905     */
906    public Set<String> getPopulation(Site site, boolean excludeConfigurationPopulations)
907    {
908        Set<String> contexts = new HashSet<>(Arrays.asList("/sites/" + site.getName(), "/sites-fo/" + site.getName()));
909
910        Set<String> userPopulations = _populationContextHelper.getUserPopulationsOnContexts(contexts, false, true);
911        
912        if (excludeConfigurationPopulations)
913        {
914            Site catalogSite = _siteManager.getSite(getCatalogSiteName());
915            
916            // Get the excluded population configuration
917            String excludedPopulationsString = catalogSite.getValue(CatalogSiteType.PROJECT_EXCLUDED_POPULATIONS_SITE_PARAM);
918            
919            // Check if project site overrides catalog configuration
920            if (site.getValue(ProjectWorkspaceSiteType.PROJECT_OVERRIDE_EXCLUDED_POPULATIONS_SITE_PARAM, false, false))
921            {
922                excludedPopulationsString = site.getValue(ProjectWorkspaceSiteType.PROJECT_EXCLUDED_POPULATIONS_PROJECT_SITE_PARAM);
923            }
924            
925            List<String> excludedPopulationsList = Arrays.asList(StringUtils.split(StringUtils.defaultString(excludedPopulationsString), ","));
926            
927            if (!excludedPopulationsList.isEmpty())
928            {
929                userPopulations.removeAll(excludedPopulationsList);
930            }
931        }
932
933        return userPopulations;
934    }
935
936    /**
937     * Retrieves the mapping of all the projects name with their title (regarless user rights)
938     * @return the map (projectName, projectTitle) for all projects
939     */
940    @Callable(rights = "Runtime_Rights_Admin_Access", context = "/admin")
941    public List<Map<String, Object>> getProjectsData()
942    {
943        return getProjects()
944                .stream()
945                .map(p -> _project2json(p))
946                .collect(Collectors.toList());
947    }
948    
949    /**
950     * Get the project's main properties as json object
951     * @param project the project
952     * @return the json representation of project
953     */
954    protected Map<String, Object> _project2json(Project project)
955    {
956        Map<String, Object> json = new HashMap<>();
957        
958        json.put("id", project.getId());
959        json.put("name", project.getName());
960        json.put("title", project.getTitle());
961        json.put("url", getProjectUrl(project, StringUtils.EMPTY));
962        
963        return json;
964    }
965    /**
966     * Retrieves the project names
967     * @return the project names
968     */
969    @Callable(rights = "Runtime_Rights_Admin_Access", context = "/admin")
970    public Collection<String> getProjectNames()
971    {
972        // As cache is computed from default JCR workspace, we need to filter on sites that exist into the current JCR workspace
973        return _getUUIDCache().entrySet().stream()
974                .filter(e -> _resolver.hasAmetysObjectForId(e.getValue()))
975                .map(Map.Entry::getKey)
976                .collect(Collectors.toList());
977    }
978    
979    /**
980     * Return the root for projects
981     * The root node will be created if necessary
982     * @return The root for projects
983     */
984    public ModifiableTraversableAmetysObject getProjectsRoot()
985    {
986        try
987        {
988            ModifiableTraversableAmetysObject pluginsNode = _resolver.resolveByPath("/ametys:plugins");
989            ModifiableTraversableAmetysObject workspacesPluginNode = _getOrCreateObject(pluginsNode, __WORKSPACES_PLUGIN_NODE_NAME, __WORKSPACES_PLUGIN_NODE_TYPE);
990            return _getOrCreateObject(workspacesPluginNode, __PROJECTS_ROOT_NODE_NAME, __PROJECTS_ROOT_NODE_TYPE);
991        }
992        catch (AmetysRepositoryException e)
993        {
994            throw new AmetysRepositoryException("Error getting the projects root node.", e);
995        }
996    }
997    
998    /**
999     * Retrieves the standard information of a project
1000     * @param projectId Identifier of the project
1001     * @return The map of information
1002     */
1003    @Callable(rights = "Runtime_Rights_Admin_Access", context = "/admin")
1004    public Map<String, Object> getProjectProperties(String projectId)
1005    {
1006        return getProjectProperties((Project) _resolver.resolveById(projectId));
1007    }
1008    
1009    /**
1010     * Retrieves the standard information of a project
1011     * @param project The project
1012     * @return The map of information
1013     */
1014    public Map<String, Object> getProjectProperties(Project project)
1015    {
1016        Map<String, Object> info = new HashMap<>();
1017
1018        info.put("id", project.getId());
1019        info.put("name", project.getName());
1020        info.put("type", "project");
1021
1022        info.put("title", project.getTitle());
1023        info.put("description", project.getDescription());
1024        info.put("inscriptionStatus", project.getInscriptionStatus().toString());
1025        info.put("defaultProfile", project.getDefaultProfile());
1026
1027        info.put("creationDate", project.getCreationDate());
1028
1029        // check if the project workspace configuration is valid
1030        Site site = project.getSite();
1031        boolean valid = site != null && _siteConfigurationManager.isSiteConfigurationValid(site);
1032        
1033        Set<String> categories = project.getCategories();
1034        info.put("categories", categories.stream()
1035                .map(c -> _categoryProviderEP.getTag(c, new HashMap<>()))
1036                .filter(Objects::nonNull)
1037                .map(t -> _tag2json(t))
1038                .collect(Collectors.toList()));
1039        
1040        Set<String> tags = project.getTags();
1041        info.put("tags", tags.stream()
1042                .map(c -> _projectTagProviderEP.getTag(c, new HashMap<>()))
1043                .filter(Objects::nonNull)
1044                .map(t -> _tag2json(t))
1045                .collect(Collectors.toList()));
1046        
1047        info.put("valid", valid);
1048        
1049        UserIdentity[] managers = project.getManagers();
1050        info.put("managers", Arrays.stream(managers)
1051                .map(u -> _userHelper.user2json(u))
1052                .collect(Collectors.toList()));
1053        
1054        Map<String, String> siteProps = new HashMap<>();
1055        // site map with id ,name, title and url property
1056        // { id: site id, name: site name, title: site title, url: site url }
1057        if (site != null)
1058        {
1059            siteProps.put("id", site.getId());
1060            siteProps.put("name", site.getName());
1061            siteProps.put("title", site.getTitle());
1062            siteProps.put("url", site.getUrl());
1063        }
1064        info.put("site", siteProps);
1065
1066        return info;
1067    }
1068    
1069    private Map<String, Object> _tag2json(Tag tag)
1070    {
1071        Map<String, Object> json = new HashMap<>();
1072        json.put("id", tag.getId());
1073        json.put("name", tag.getName());
1074        json.put("title", tag.getTitle());
1075        return json;
1076    }
1077
1078    /**
1079     * Get the project URL.
1080     * @param project The project
1081     * @param defaultValue The default value to use if there is no site
1082     * @return The project URL if a site is configured, otherwise return the default value.
1083     */
1084    public String getProjectUrl(Project project, String defaultValue)
1085    {
1086        Site site = project.getSite();
1087        if (site == null)
1088        {
1089            return defaultValue;
1090        }
1091        else
1092        {
1093            return site.getUrl();
1094        }
1095    }
1096    
1097    /**
1098     * Create a project
1099     * @param name The project name
1100     * @param title The project title
1101     * @param description The project description
1102     * @param emailList Project mailing list
1103     * @param inscriptionStatus The inscription status of the project
1104     * @param defaultProfile The default profile for new members
1105     * @return A map containing the id of the new project or an error key.
1106     */
1107    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION)
1108    public Map<String, Object> createProject(String name, String title, String description, String emailList, String inscriptionStatus, String defaultProfile)
1109    {
1110        checkRightsForProjectCreation(InscriptionStatus.valueOf(inscriptionStatus.toUpperCase()), null);
1111
1112        Map<String, Object> result = new HashMap<>();
1113        List<String> errors = new ArrayList<>();
1114        
1115        Map<String, Object> additionalValues = new HashMap<>();
1116        additionalValues.put("description", description);
1117        additionalValues.put("emailList", emailList);
1118        additionalValues.put("inscriptionStatus", inscriptionStatus);
1119        additionalValues.put("defaultProfile", defaultProfile);
1120        
1121        Project project = createProject(name, title, additionalValues, null, errors);
1122        
1123        if (CollectionUtils.isEmpty(errors))
1124        {
1125            result.put("id", project.getId());
1126        }
1127        else
1128        {
1129            result.put("error", errors.get(0));
1130        }
1131        
1132        return result;
1133    }
1134    
1135    /**
1136     * Create a project
1137     * @param name The project name
1138     * @param title The project title
1139     * @param additionalValues A list of optional additional values. Accepted values are : description, mailingList, inscriptionStatus, defaultProfile, tags, categoryTags, keywords and language
1140     * @param modulesIds The list of modules to activate. Can be null to activate all modules
1141     * @param errors A list that will be populated with the encountered errors. If null, errors will not be tracked.
1142     * @return The id of the new project
1143     */
1144    public Project createProject(String name, String title, Map<String, Object> additionalValues, Set<String> modulesIds, List<String> errors)
1145    {
1146        if (StringUtils.isEmpty(title))
1147        {
1148            throw new IllegalArgumentException(String.format("Cannot create project. Title is mandatory"));
1149        }
1150        
1151        ModifiableTraversableAmetysObject projectsRoot = getProjectsRoot();
1152        
1153        // Project name should be unique
1154        if (hasProject(name))
1155        {
1156            if (getLogger().isWarnEnabled())
1157            {
1158                getLogger().warn(String.format("A project with the name '%s' already exists", name));
1159            }
1160            
1161            if (errors != null)
1162            {
1163                errors.add("project-exists");
1164            }
1165            
1166            return null;
1167        }
1168        
1169        Project project = projectsRoot.createChild(name, Project.NODE_TYPE);
1170        project.setTitle(title);
1171        String description = (String) additionalValues.getOrDefault("description", null);
1172        if (StringUtils.isNotEmpty(description))
1173        {
1174            project.setDescription(description);
1175        }
1176        String mailingList = (String) additionalValues.getOrDefault("emailList", null);
1177        if (StringUtils.isNotEmpty(mailingList))
1178        {
1179            project.setMailingList(mailingList);
1180        }
1181        
1182        String inscriptionStatus = (String) additionalValues.getOrDefault("inscriptionStatus", null);
1183        if (StringUtils.isNotEmpty(inscriptionStatus))
1184        {
1185            project.setInscriptionStatus(inscriptionStatus);
1186        }
1187        
1188        String defaultProfile = (String) additionalValues.getOrDefault("defaultProfile", null);
1189        if (StringUtils.isNotEmpty(defaultProfile))
1190        {
1191            project.setDefaultProfile(defaultProfile);
1192        }
1193        
1194        @SuppressWarnings("unchecked")
1195        List<String> tags = (List<String>) additionalValues.getOrDefault("tags", null);
1196        if (tags != null)
1197        {
1198            project.setTags(tags);
1199        }
1200        @SuppressWarnings("unchecked")
1201        List<String> categoryTags = (List<String>) additionalValues.getOrDefault("categoryTags", null);
1202        if (categoryTags != null)
1203        {
1204            project.setCategoryTags(categoryTags);
1205        }
1206        
1207        @SuppressWarnings("unchecked")
1208        List<String> keywords = (List<String>) additionalValues.getOrDefault("keywords", null);
1209        if (keywords != null)
1210        {
1211            project.setKeywords(keywords.toArray(new String[keywords.size()]));
1212        }
1213
1214        project.setCreationDate(ZonedDateTime.now());
1215        
1216        // Create the project workspace = a site + a set of pages
1217        _createProjectWorkspace(project, errors);
1218        
1219        activateModules(project, modulesIds, additionalValues);
1220        
1221        if (CollectionUtils.isEmpty(errors))
1222        {
1223            project.saveChanges();
1224         
1225            // Notify observers
1226            Map<String, Object> eventParams = new HashMap<>();
1227            eventParams.put(ObservationConstants.ARGS_PROJECT, project);
1228            _observationManager.notify(new Event(ObservationConstants.EVENT_PROJECT_ADDED, _currentUserProvider.getUser(), eventParams));
1229            
1230        }
1231        else
1232        {
1233            deleteProject(project);
1234        }
1235        
1236        clearCaches();
1237        
1238        return project;
1239    }
1240    
1241    /**
1242     * Edit a project
1243     * @param id The project identifier
1244     * @param title The title to set
1245     * @param description The description to set
1246     * @param mailingList Project mailing list
1247     * @param inscriptionStatus The inscription status of the project
1248     * @param defaultProfile The default profile for new members
1249     */
1250    @Callable(rights = ProjectConstants.RIGHT_PROJECT_EDIT, context = "/admin")
1251    public void editProject(String id, String title, String description, String mailingList, String inscriptionStatus, String defaultProfile)
1252    {
1253        Project project = _resolver.resolveById(id);
1254        editProject(project, title, description, mailingList, inscriptionStatus, defaultProfile);
1255    }
1256    
1257    /**
1258     * Edit a project
1259     * @param project The project
1260     * @param title The title to set
1261     * @param description The description to set
1262     * @param mailingList Project mailing list
1263     * @param inscriptionStatus The inscription status of the project
1264     * @param defaultProfile The default profile for new members
1265     */
1266    public void editProject(Project project, String title, String description, String mailingList, String inscriptionStatus, String defaultProfile)
1267    {
1268        checkRightsForProjectEdition(project, InscriptionStatus.valueOf(inscriptionStatus.toUpperCase()), null);
1269        
1270        project.setTitle(title);
1271        
1272        if (StringUtils.isNotEmpty(description))
1273        {
1274            project.setDescription(description);
1275        }
1276        else
1277        {
1278            project.removeDescription();
1279        }
1280        
1281        if (StringUtils.isNotEmpty(mailingList))
1282        {
1283            project.setMailingList(mailingList);
1284        }
1285        else
1286        {
1287            project.removeMailingList();
1288        }
1289
1290        project.setInscriptionStatus(inscriptionStatus);
1291        project.setDefaultProfile(defaultProfile);
1292        
1293        project.saveChanges();
1294        
1295        // Notify observers
1296        Map<String, Object> eventParams = new HashMap<>();
1297        eventParams.put(ObservationConstants.ARGS_PROJECT, project);
1298        eventParams.put(org.ametys.plugins.workspaces.ObservationConstants.ARGS_PROJECT_ID, project.getId());
1299        _observationManager.notify(new Event(ObservationConstants.EVENT_PROJECT_UPDATED, _currentUserProvider.getUser(), eventParams));
1300    }
1301    
1302    /**
1303     * Delete a list of project.
1304     * @param ids The ids of projects to delete
1305     * @return The ids of the deleted projects, unknowns projects and the deleted sites
1306     */
1307    @Callable(rights = ProjectConstants.RIGHT_PROJECT_DELETE, context = "/admin")
1308    public Map<String, Object> deleteProjectsByIds(List<String> ids)
1309    {
1310        Map<String, Object> result = new HashMap<>();
1311        List<Map<String, Object>> deleted = new ArrayList<>();
1312        List<String> unknowns = new ArrayList<>();
1313        
1314        for (String id : ids)
1315        {
1316            try
1317            {
1318                Project project = _resolver.resolveById(id);
1319                
1320                Map<String, Object> projectInfo = new HashMap<>();
1321                projectInfo.put("id", id);
1322                projectInfo.put("title", project.getTitle());
1323                projectInfo.put("sites", deleteProject(project));
1324                
1325                deleted.add(projectInfo);
1326            }
1327            catch (UnknownAmetysObjectException e)
1328            {
1329                getLogger().warn(String.format("Unable to delete the definition of id '%s', because it does not exist.", id), e);
1330                unknowns.add(id);
1331            }
1332        }
1333        
1334        result.put("deleted", deleted);
1335        result.put("unknowns", unknowns);
1336        
1337        return result;
1338    }
1339    
1340    /**
1341     * Delete a project.
1342     * @param projects The list of projects to delete
1343     * @return list of deleted sites (each list entry contains a data map with
1344     *         the id and the name of the delete site).
1345     */
1346    public List<Map<String, String>> deleteProject(List<Project> projects)
1347    {
1348        List<Map<String, String>> deletedSitesInfo = new ArrayList<>();
1349        
1350        for (Project project : projects)
1351        {
1352            deletedSitesInfo.addAll(deleteProject(project));
1353        }
1354        
1355        return deletedSitesInfo;
1356    }
1357    
1358    /**
1359     * Delete a project and its sites
1360     * @param project The project to delete
1361     * @return list of deleted sites (each list entry contains a data map with
1362     *         the id and the name of the delete site).
1363     */
1364    public List<Map<String, String>> deleteProject(Project project)
1365    {
1366        ModifiableAmetysObject parent = project.getParent();
1367        
1368        
1369        // list of map entry with id, name and title property
1370        // { id: site id, name: site name }
1371        List<Map<String, String>> deletedSitesInfo = new ArrayList<>();
1372        
1373        Site site = project.getSite();
1374        if (site != null)
1375        {
1376            try
1377            {
1378                Map<String, String> siteProps = new HashMap<>();
1379                siteProps.put("id", site.getId());
1380                siteProps.put("name", site.getName());
1381                
1382                _siteDao.deleteSite(site.getId());
1383                deletedSitesInfo.add(siteProps);
1384            }
1385            catch (RepositoryException e)
1386            {
1387                String errorMsg = String.format("Error while trying to delete the site '%s' for the project '%s'.", site.getName(), project.getName());
1388                getLogger().error(errorMsg, e);
1389            }
1390        }
1391        
1392        String projectId = project.getId();
1393        Collection<ProjectMember> projectMembers = _projectMembers.getProjectMembers(project, true);
1394        project.remove();
1395        parent.saveChanges();
1396        
1397        // Notify observers
1398        Map<String, Object> eventParams = new HashMap<>();
1399        eventParams.put(ObservationConstants.ARGS_PROJECT_ID, projectId);
1400        eventParams.put(ObservationConstants.ARGS_PROJECT_NAME, project.getName());
1401        eventParams.put(ObservationConstants.ARGS_PROJECT_MEMBERS, projectMembers);
1402        _observationManager.notify(new Event(ObservationConstants.EVENT_PROJECT_DELETED, _currentUserProvider.getUser(), eventParams));
1403        
1404        clearCaches();
1405        
1406        return deletedSitesInfo;
1407    }
1408    
1409    /**
1410     * Utility method to get or create an ametys object
1411     * @param <A> A sub class of AmetysObject
1412     * @param parent The parent object
1413     * @param name The ametys object name
1414     * @param type The ametys object type
1415     * @return ametys object
1416     * @throws AmetysRepositoryException if an repository error occurs
1417     */
1418    private <A extends AmetysObject> A _getOrCreateObject(ModifiableTraversableAmetysObject parent, String name, String type) throws AmetysRepositoryException
1419    {
1420        A object;
1421        
1422        if (parent.hasChild(name))
1423        {
1424            object = parent.getChild(name);
1425        }
1426        else
1427        {
1428            object = parent.createChild(name, type);
1429            parent.saveChanges();
1430        }
1431        
1432        return object;
1433    }
1434    
1435    /**
1436     * Get the project of an ametys object inside a project.
1437     * It can be an explorer node, or any type of resource in a module.
1438     * @param id The identifier of the ametys object
1439     * @return the project or null if not found
1440     */
1441    public Project getParentProject(String id)
1442    {
1443        return getParentProject(_resolver.<AmetysObject>resolveById(id));
1444    }
1445    
1446    /**
1447     * Get the project of an ametys object inside a project.
1448     * It can be an explorer node, or any type of resource in a module.
1449     * @param object The ametys object
1450     * @return the project or null if not found
1451     */
1452    public Project getParentProject(AmetysObject object)
1453    {
1454        AmetysObject ametysObject = object;
1455        // Go back to the local explorer root.
1456        do
1457        {
1458            ametysObject = ametysObject.getParent();
1459        }
1460        while (ametysObject instanceof ExplorerNode);
1461        
1462        if (!(ametysObject instanceof Project))
1463        {
1464            getLogger().warn(String.format("No project found for ametys object with id '%s'", ametysObject.getId()));
1465            return null;
1466        }
1467        
1468        return (Project) ametysObject;
1469    }
1470    
1471    /**
1472     * Get the list of project names for a given site
1473     * @param siteName The site name
1474     * @return the list of project names
1475     */
1476    @Callable(rights = "Runtime_Rights_Admin_Access", context = "/admin")
1477    public List<String> getProjectsForSite(String siteName)
1478    {
1479        Cache<String, List<Pair<String, String>>> cache = _getMemorySiteAssociationCache();
1480        if (cache.hasKey(siteName))
1481        {
1482            return cache.get(siteName).stream()
1483                    .map(p -> p.getRight())
1484                    .collect(Collectors.toList());
1485        }
1486        else
1487        {
1488            List<String> projectNames = new ArrayList<>();
1489            
1490            if (_siteManager.hasSite(siteName))
1491            {
1492                Site site = _siteManager.getSite(siteName);
1493                getProjectsForSite(site)
1494                    .stream()
1495                    .map(Project::getName)
1496                    .forEach(projectNames::add);
1497            }
1498            
1499            return projectNames;
1500        }
1501    }
1502    
1503    /**
1504     * Get the list of project for a given site
1505     * @param site The site
1506     * @return the list of project
1507     */
1508    public List<Project> getProjectsForSite(Site site)
1509    {
1510        Cache<String, List<Pair<String, String>>> cache = _getMemorySiteAssociationCache();
1511        if (cache.hasKey(site.getName()))
1512        {
1513            return cache.get(site.getName()).stream()
1514                        .map(p -> _resolver.<Project>resolveById(p.getLeft()))
1515                        .collect(Collectors.toList());
1516        }
1517        
1518        try
1519        {
1520            // Stream over the weak reference properties pointing to this
1521            // node to find referencing projects
1522            Iterator<Property> propertyIterator = site.getNode().getWeakReferences();
1523            Iterable<Property> propertyIterable = () -> propertyIterator;
1524            
1525            List<Project> projects = StreamSupport.stream(propertyIterable.spliterator(), false)
1526                    .map(p ->
1527                    {
1528                        try
1529                        {
1530                            Node parent = p.getParent();
1531
1532                            // Check if the parent is a project"
1533                            if (NodeTypeHelper.isNodeType(parent, "ametys:project"))
1534                            {
1535                                Project project = _resolver.resolve(parent, false);
1536                                return project;
1537                            }
1538                        }
1539                        catch (Exception e)
1540                        {
1541                            if (getLogger().isWarnEnabled())
1542                            {
1543                                // this weak reference is not from a project
1544                                String propertyPath = null;
1545                                try
1546                                {
1547                                    propertyPath = p.getPath();
1548                                }
1549                                catch (Exception e2)
1550                                {
1551                                    // ignore
1552                                }
1553                                
1554                                String warnMsg = String.format("Site '%s' is pointed by a weak reference '%s' which is not representing a relation with project. This reference is ignored.", site.getName(), propertyPath);
1555                                getLogger().warn(warnMsg);
1556                            }
1557                        }
1558                        
1559                        return null;
1560                    })
1561                    .filter(Objects::nonNull)
1562                    .collect(Collectors.toList());
1563            
1564            List<Pair<String, String>> projectsPairs = projects.stream().map(p -> Pair.of(p.getId(), p.getName())).collect(Collectors.toList());
1565            cache.put(site.getName(), projectsPairs);
1566            return projects;
1567        }
1568        catch (RepositoryException e)
1569        {
1570            getLogger().error(String.format("Unable to find projects for site '%s'", site.getName()), e);
1571        }
1572        
1573        return new ArrayList<>();
1574    }
1575    
1576    /**
1577     * Create the project workspace for a given project.
1578     * @param project The project for which the workspace must be created
1579     * @param errors A list of possible errors to populate. Can be null if the caller is not interested in error tracking.
1580     * @return The site created for this workspace
1581     */
1582    protected Site _createProjectWorkspace(Project project, List<String> errors)
1583    {
1584        String initialSiteName = project.getName();
1585        Site site = null;
1586        
1587        Site catalogSite = _siteManager.getSite(getCatalogSiteName());
1588        String rootId = catalogSite != null ? catalogSite.getId() : null;
1589        
1590        Map<String, Object> result = _siteDao.createSite(rootId, initialSiteName, ProjectWorkspaceSiteType.TYPE_ID, true);
1591        
1592        String siteId = (String) result.get("id");
1593        String siteName = (String) result.get("name");
1594        if (StringUtils.isNotEmpty(siteId))
1595        {
1596            // Creation success
1597            site = _siteManager.getSite(siteName);
1598            
1599            setProjectSiteTitle(site, project.getTitle());
1600          
1601            // Add site to project
1602            project.setSite(site);
1603            
1604            site.saveChanges();
1605        }
1606        
1607        return site;
1608    }
1609
1610    /**
1611     * Get the project's tags
1612     * @return The project's tags
1613     */
1614    public List<String> getTags()
1615    {
1616        AmetysObject projectsRootNode = getProjectsRoot();
1617        if (projectsRootNode instanceof JCRAmetysObject)
1618        {
1619            Node node = ((JCRAmetysObject) projectsRootNode).getNode();
1620            
1621            try
1622            {
1623                return Arrays.stream(node.getProperty(__PROJECTS_TAGS_PROPERTY).getValues())
1624                    .map(LambdaUtils.wrap(Value::getString))
1625                    .collect(Collectors.toList());
1626            }
1627            catch (PathNotFoundException e)
1628            {
1629                // property is not set, empty list will be returned.
1630            }
1631            catch (RepositoryException e)
1632            {
1633                throw new AmetysRepositoryException(e);
1634            }
1635        }
1636        
1637        return new ArrayList<>();
1638    }
1639
1640    /**
1641     * Add project's tags
1642     * @param newTags The new tags to add
1643     */
1644    public synchronized void addTags(Collection<String> newTags)
1645    {
1646        if (CollectionUtils.isNotEmpty(newTags))
1647        {
1648            AmetysObject projectsRootNode = getProjectsRoot();
1649            if (projectsRootNode instanceof JCRAmetysObject)
1650            {
1651                // Concat existing tags with new lowercased tags
1652                String[] tags = Stream.concat(getTags().stream(), newTags.stream().map(String::trim).map(String::toLowerCase).filter(StringUtils::isNotEmpty))
1653                        .distinct()
1654                        .toArray(String[]::new);
1655                
1656                try
1657                {
1658                    ((JCRAmetysObject) projectsRootNode).getNode().setProperty(__PROJECTS_TAGS_PROPERTY, tags);
1659                }
1660                catch (RepositoryException e)
1661                {
1662                    throw new AmetysRepositoryException(e);
1663                }
1664            }
1665        }
1666    }
1667    
1668    /**
1669     * Get the list of activated modules for a project
1670     * @param project The project
1671     * @return The list of activated modules
1672     */
1673    public List<WorkspaceModule> getModules(Project project)
1674    {
1675        return _moduleManagerEP.getModules().stream()
1676                .filter(module -> isModuleActivated(project, module.getId()))
1677                .collect(Collectors.toList());
1678    }
1679    
1680    /**
1681     * Retrieves the page of the module for all available languages
1682     * @param project The project
1683     * @param moduleId The project module id
1684     * @return the page or null if not found
1685     */
1686    public Set<Page> getModulePages(Project project, String moduleId)
1687    {
1688        if (_moduleManagerEP.hasExtension(moduleId))
1689        {
1690            WorkspaceModule module = _moduleManagerEP.getExtension(moduleId);
1691            return getModulePages(project, module);
1692        }
1693        return null;
1694    }
1695    
1696    /**
1697     * Return the possible module roots associated to a page
1698     * @param page The given page
1699     * @return A non null set of the data of the linked modules
1700     */
1701    public Set<ModifiableResourceCollection> pageToModuleRoot(Page page)
1702    {
1703        Set<ModifiableResourceCollection> data = new LinkedHashSet<>();
1704        
1705        Page rootPage = page;
1706        SitemapElement parent = page.getParent();
1707        while (!(parent instanceof Sitemap) && !page.hasValue(__PAGE_MODULES_VALUE))
1708        {
1709            rootPage = (Page) parent;
1710            parent = parent.getParent();
1711        }
1712        
1713        String[] modulesRootsIds = rootPage.getValue(__PAGE_MODULES_VALUE, new String[0]);
1714        if (modulesRootsIds.length > 0)
1715        {
1716            for (String moduleRootId : modulesRootsIds)
1717            {
1718                try
1719                {
1720                    ModifiableResourceCollection moduleRoot = _resolver.resolveById(moduleRootId);
1721                    data.add(moduleRoot);
1722                }
1723                catch (UnknownAmetysObjectException e)
1724                {
1725                    // Ignore obsolete data
1726                }
1727            }
1728        }
1729        
1730        return data;
1731    }
1732    
1733    /**
1734     * Mark the given page as this module page. The modified page will not be saved.
1735     * @param page The page to change
1736     * @param moduleRoot The workspace module that use this page
1737     */
1738    public void tagProjectPage(ModifiablePage page, ModifiableResourceCollection moduleRoot)
1739    {
1740        String[] currentModules = page.getValue(__PAGE_MODULES_VALUE, new String[0]);
1741        
1742        Set<String> modules = new LinkedHashSet<>(Arrays.asList(currentModules));
1743        modules.add(moduleRoot.getId());
1744        
1745        String[] newModules = new String[modules.size()];
1746        modules.toArray(newModules);
1747        
1748        page.setValue(__PAGE_MODULES_VALUE, newModules);
1749    }
1750
1751    /**
1752     * Remove the mark on the given page of this module. The modified page will not be saved.
1753     * @param page The page to change
1754     * @param moduleRoot The workspace module that use this page
1755     */
1756    public void untagProjectPage(ModifiablePage page, ModifiableResourceCollection moduleRoot)
1757    {
1758        if (moduleRoot != null)
1759        {
1760            String[] currentModules = page.getValue(__PAGE_MODULES_VALUE, new String[0]);
1761            
1762            Set<String> modules = new LinkedHashSet<>(Arrays.asList(currentModules));
1763            modules.remove(moduleRoot.getId());
1764            
1765            String[] newModules = new String[modules.size()];
1766            modules.toArray(newModules);
1767            
1768            page.setValue(__PAGE_MODULES_VALUE, newModules);
1769        }
1770    }
1771
1772    /**
1773     * Get a page in the site of a given project with a specific tag
1774     * @param project The project
1775     * @param workspaceModule the module
1776     * @return The module's pages
1777     */
1778    public Set<Page> getModulePages(Project project, WorkspaceModule workspaceModule)
1779    {
1780        Request request = _getRequest();
1781        if (request == null)
1782        {
1783            // There is no request to store cache
1784            return _computePages(project, workspaceModule);
1785        }
1786        
1787        Cache<RequestModuleCacheKey, Set<Page>> pagesCache = _getRequestPageCache();
1788        
1789        // The site key in the cache is of the form {site + workspace}.
1790        String currentWorkspace = _workspaceSelector.getWorkspace();
1791        RequestModuleCacheKey pagesKey = RequestModuleCacheKey.of(project.getName(), workspaceModule.getId(), currentWorkspace);
1792        
1793        try
1794        {
1795            return pagesCache.get(pagesKey, __ -> _computePages(project, workspaceModule));
1796        }
1797        catch (CacheException e)
1798        {
1799            if (e.getCause() instanceof UnknownAmetysObjectException)
1800            {
1801                throw (UnknownAmetysObjectException) e.getCause();
1802            }
1803            else
1804            {
1805                throw new RuntimeException("An error occurred while computing page of module " + workspaceModule.getModuleName() + " in project " + project.getName(), e);
1806            }
1807        }
1808    }
1809    
1810    private Set<Page> _computePages(Project project, WorkspaceModule workspaceModule)
1811    {
1812        Set<String> pagesUUids = _getMemoryPageCache().get(ModuleCacheKey.of(project.getName(), workspaceModule.getId()), __ -> _computePagesIds(project, workspaceModule));
1813        if (pagesUUids != null)
1814        {
1815            return pagesUUids.stream().map(uuid -> _resolver.<Page>resolveById(uuid)).collect(Collectors.toSet());
1816        }
1817        else
1818        {
1819            // Project may be present in cache for 'default' workspace but does not exist in current JCR workspace
1820            throw new UnknownAmetysObjectException("There is no pages for '" + project.getName() + "', module '" + workspaceModule.getModuleName() + "'");
1821        }
1822    }
1823    
1824    private Set<String> _computePagesIds(Project project, WorkspaceModule workspaceModule)
1825    {
1826        Site site = project.getSite();
1827        String siteName = site != null ? site.getName() : null;
1828        if (StringUtils.isEmpty(siteName))
1829        {
1830            return null;
1831        }
1832        
1833        ModifiableResourceCollection moduleRoot = workspaceModule.getModuleRoot(project, false);
1834        if (moduleRoot != null)
1835        {
1836            Expression expression = new StringExpression(__PAGE_MODULES_VALUE, Operator.EQ, moduleRoot.getId());
1837            String query = PageQueryHelper.getPageXPathQuery(siteName, null, null, expression, null);
1838    
1839            return StreamSupport.stream(_resolver.query(query).spliterator(), false)
1840                        .map(page -> page.getId())
1841                        .collect(Collectors.toSet());
1842        }
1843        else
1844        {
1845            return Set.of();
1846        }
1847    }
1848
1849    /**
1850     * Activate the list of module of the project
1851     * @param project The project
1852     * @param moduleIds The list of modules. Can be null to activate all modules
1853     * @param additionalValues A list of optional additional values. Accepted values are : description, mailingList, inscriptionStatus, defaultProfile, tags, categoryTags, keywords and language
1854     */
1855    public void activateModules(Project project, Set<String> moduleIds, Map<String, Object> additionalValues)
1856    {
1857        Set<String> modules = moduleIds == null ? _moduleManagerEP.getExtensionsIds() : moduleIds;
1858        
1859        for (String moduleId : modules)
1860        {
1861            WorkspaceModule module = _moduleManagerEP.getModule(moduleId);
1862            if (module != null && !isModuleActivated(project, moduleId))
1863            {
1864                module.activateModule(project, additionalValues);
1865                project.addModule(moduleId);
1866            }
1867        }
1868        
1869        _setDefaultProfileForMembers(project, modules);
1870        
1871        project.saveChanges();
1872    }
1873
1874    private void _setDefaultProfileForMembers(Project project, Set<String> modules)
1875    {
1876        String profileForNewModule = StringUtils.defaultString(Config.getInstance().getValue("workspaces.profile.new.module"));
1877        
1878        if (profileForNewModule.equals(ProfileForNewModule.DEFAULT_MEMBER_PROFILE.name()))
1879        {
1880            Set<String> defaultProfiles = Set.of(StringUtils.defaultString(Config.getInstance().getValue("workspaces.profile.default")));
1881            Map<JCRProjectMember, Object> projectMembers = _projectMembers.getJCRProjectMembers(project);
1882            
1883            Set<WorkspaceModule> modulesForMembers = getProjectModulesForNewMembers(project);
1884            for (String moduleId : modules)
1885            {
1886                WorkspaceModule module = _moduleManagerEP.getModule(moduleId);
1887                Set<String> profiles = modulesForMembers.contains(module) ? defaultProfiles : Set.of();
1888                for (JCRProjectMember member : projectMembers.keySet())
1889                {
1890                    _projectMembers.setProfileOnModule(member, project, module, profiles);
1891                }
1892            }
1893        }
1894    }
1895    
1896    /**
1897     * Initialize the sitemap with the active module of the project
1898     * @param project The project
1899     * @param sitemap The sitemap
1900     */
1901    public void initializeModulesSitemap(Project project, Sitemap sitemap)
1902    {
1903        Set<String> modules = _moduleManagerEP.getExtensionsIds();
1904        
1905        for (String moduleId : modules)
1906        {
1907            if (_moduleManagerEP.hasExtension(moduleId))
1908            {
1909                WorkspaceModule module = _moduleManagerEP.getExtension(moduleId);
1910                
1911                if (isModuleActivated(project, moduleId))
1912                {
1913                    module.initializeSitemap(project, sitemap);
1914                }
1915            }
1916        }
1917    }
1918    
1919    /**
1920     * Determines if a module is activated
1921     * @param project The project
1922     * @param moduleId The id of module
1923     * @return true if the module the currently activated
1924     */
1925    public boolean isModuleActivated(Project project, String moduleId)
1926    {
1927        return ArrayUtils.contains(project.getModules(), moduleId);
1928    }
1929    
1930    /**
1931     * Remove the explorer root node of the project module, remove all events
1932     * related to that module and set it to deactivated
1933     * @param project The project
1934     * @param moduleIds The id of module to activate
1935     */
1936    public void deactivateModules(Project project, Set<String> moduleIds)
1937    {
1938        for (String moduleId : moduleIds)
1939        {
1940            WorkspaceModule module = _moduleManagerEP.getModule(moduleId);
1941            if (module != null && isModuleActivated(project, moduleId))
1942            {
1943                module.deactivateModule(project);
1944                project.removeModule(moduleId);
1945            }
1946        }
1947        
1948        project.saveChanges();
1949    }
1950    
1951    
1952    /**
1953     * Get the list of profiles configured for the workspaces' projects
1954     * @return The list of profiles as JSON
1955     */
1956    @Callable(rights = {ProjectConstants.RIGHT_PROJECT_CREATE_PRIVATE, ProjectConstants.RIGHT_PROJECT_CREATE_PUBLIC_MODERATED, ProjectConstants.RIGHT_PROJECT_CREATE_PUBLIC_OPENED, ProjectConstants.RIGHT_PROJECT_EDIT}, context = "/admin")
1957    public Map<String, Object> getProjectProfiles()
1958    {
1959        Map<String, Object> result = new HashMap<>();
1960        List<Map<String, Object>> profiles = _projectRightHelper.getProfiles().stream().map(p -> p.toJSON()).collect(Collectors.toList());
1961        result.put("profiles", profiles);
1962        return result;
1963    }
1964    
1965    /**
1966     * Get the site name holding the catalog of projects
1967     * @return the catalog's site name
1968     * @throws UnknownCatalogSiteException when the config is invalid
1969     */
1970    public String getCatalogSiteName() throws UnknownCatalogSiteException
1971    {
1972        String catalogSiteName = Config.getInstance().getValue("workspaces.catalog.site.name");
1973        if (!_siteManager.hasSite(catalogSiteName))
1974        {
1975            throw new UnknownCatalogSiteException("Unknown site '" + catalogSiteName + "'. The global Ametys configuration is invalid for the parameter 'workspaces.catalog.site.name'");
1976        }
1977        return catalogSiteName;
1978    }
1979    
1980    /**
1981     * Get the site name holding the users directory
1982     * @return the users directory's site name
1983     * @throws UnknownUserDirectorySiteException when the config is invalid
1984     */
1985    public String getUsersDirectorySiteName() throws UnknownUserDirectorySiteException
1986    {
1987        String udSiteName = Config.getInstance().getValue("workspaces.member.userdirectory.site.name");
1988        if (!_siteManager.hasSite(udSiteName))
1989        {
1990            throw new UnknownUserDirectorySiteException("Unknown site '" + udSiteName + "'. The global Ametys configuration is invalid for the parameter 'workspaces.member.userdirectory.site.name'");
1991        }
1992        return udSiteName;
1993    }
1994    
1995    @Override
1996    public boolean supports(Event event)
1997    {
1998        return event.getId().equals(ObservationConstants.EVENT_PROJECT_DELETED)
1999                || event.getId().equals(ObservationConstants.EVENT_PROJECT_UPDATED)
2000                || event.getId().equals(ObservationConstants.EVENT_PROJECT_ADDED)
2001                || event.getId().equals(org.ametys.web.ObservationConstants.EVENT_PAGE_ADDED)
2002                || event.getId().equals(org.ametys.web.ObservationConstants.EVENT_PAGE_DELETED);
2003    }
2004
2005    public int getPriority()
2006    {
2007        return 0;
2008    }
2009
2010    public void observe(Event event, Map<String, Object> transientVars) throws Exception
2011    {
2012        clearCaches();
2013    }
2014
2015    /**
2016     * Prefix project title
2017     * @param site the site
2018     * @param title the title
2019     */
2020    public void setProjectSiteTitle(Site site, String title)
2021    {
2022        I18nizableText i18nSiteTitle = new I18nizableText("plugin." + _pluginName, "PLUGINS_WORKSPACES_PROJECT_DEFAULT_PROJECT_WORKSPACE_TITLE", Arrays.asList(title));
2023        site.setTitle(_i18nUtils.translate(i18nSiteTitle));
2024    }
2025    
2026    private Project _computeProject(String projectName)
2027    {
2028        if (hasProject(projectName))
2029        {
2030            String uuid = _getUUIDCache().get(projectName);
2031            return _resolver.<Project>resolveById(uuid);
2032        }
2033        else
2034        {
2035            // Project may be present in cache for 'default' workspace but does not exist in current JCR workspace
2036            throw new UnknownAmetysObjectException("There is no site named '" + projectName + "'");
2037        }
2038    }
2039    
2040    /**
2041     * Check rights to create project
2042     * @param inscriptionStatus the inscription status
2043     * @param zoneItem the zoneItem containing catalog service. Must be a catalog service if not null
2044     */
2045    public void checkRightsForProjectCreation(InscriptionStatus inscriptionStatus, ZoneItem zoneItem)
2046    {
2047        SitemapElement catalogPage = zoneItem != null ? zoneItem.getZone().getSitemapElement() : null;
2048        
2049        if (catalogPage != null && !_projectRightHelper.hasCatalogReadAccess(zoneItem))
2050        {
2051            throw new AccessDeniedException("User " + _currentUserProvider.getUser() + " tried to create project from page '" + catalogPage.getId() + "' without sufficient rights");
2052        }
2053        
2054        switch (inscriptionStatus)
2055        {
2056            case PRIVATE:
2057                boolean hasRightToCreatePrivateProjetOnPages = catalogPage != null ? _rightManager.currentUserHasRight(ProjectConstants.RIGHT_PROJECT_CREATE_PRIVATE, catalogPage) == RightResult.RIGHT_ALLOW : false;
2058                if (!hasRightToCreatePrivateProjetOnPages && _rightManager.currentUserHasRight(ProjectConstants.RIGHT_PROJECT_CREATE_PRIVATE, "/${WorkspaceName}") != RightResult.RIGHT_ALLOW)
2059                {
2060                    throw new AccessDeniedException("User " + _currentUserProvider.getUser() + " tried to create private project without sufficient rights");
2061                }
2062                break;
2063            case MODERATED:
2064                boolean hasRightToCreateModeratedProjetOnPages = catalogPage != null ? _rightManager.currentUserHasRight(ProjectConstants.RIGHT_PROJECT_CREATE_PUBLIC_MODERATED, catalogPage) == RightResult.RIGHT_ALLOW : false;
2065                if (!hasRightToCreateModeratedProjetOnPages && _rightManager.currentUserHasRight(ProjectConstants.RIGHT_PROJECT_CREATE_PUBLIC_MODERATED, "/${WorkspaceName}") != RightResult.RIGHT_ALLOW)
2066                {
2067                    throw new AccessDeniedException("User " + _currentUserProvider.getUser() + " tried to create public project with moderation without sufficient rights");
2068                }
2069                break;
2070            case OPEN:
2071                boolean hasRightToCreateOpenProjetOnPages = catalogPage != null ? _rightManager.currentUserHasRight(ProjectConstants.RIGHT_PROJECT_CREATE_PUBLIC_OPENED, catalogPage) == RightResult.RIGHT_ALLOW : false;
2072                if (!hasRightToCreateOpenProjetOnPages && _rightManager.currentUserHasRight(ProjectConstants.RIGHT_PROJECT_CREATE_PUBLIC_OPENED, "/${WorkspaceName}") != RightResult.RIGHT_ALLOW)
2073                {
2074                    throw new AccessDeniedException("User " + _currentUserProvider.getUser() + " tried to create public project without sufficient rights");
2075                }
2076                break;
2077            default:
2078                throw new IllegalArgumentException("Inscription status '" + inscriptionStatus.toString() + "' is unknown");
2079        }
2080    }
2081    
2082    /**
2083     * Check rights to edit project
2084     * @param project the project
2085     * @param inscriptionStatus the inscription status
2086     * @param zoneItem the zoneItem containing catalog service. Must be a catalog service if not null
2087     */
2088    public void checkRightsForProjectEdition(Project project, InscriptionStatus inscriptionStatus, ZoneItem zoneItem)
2089    {
2090        InscriptionStatus oldInscriptionStatus = project.getInscriptionStatus();
2091        SitemapElement catalogPage = zoneItem != null ? zoneItem.getZone().getSitemapElement() : null;
2092        
2093        if (catalogPage != null && !_projectRightHelper.hasCatalogReadAccess(zoneItem))
2094        {
2095            throw new AccessDeniedException("User " + _currentUserProvider.getUser() + " tried to edit project from page '" + catalogPage.getId() + "' without sufficient rights");
2096        }
2097        
2098        if (oldInscriptionStatus != inscriptionStatus)
2099        {
2100            boolean canCreatePrivateProjet = _rightManager.currentUserHasRight(ProjectConstants.RIGHT_PROJECT_CREATE_PRIVATE, "/${WorkspaceName}") == RightResult.RIGHT_ALLOW
2101                    || (catalogPage != null ? _rightManager.currentUserHasRight(ProjectConstants.RIGHT_PROJECT_CREATE_PRIVATE, catalogPage) == RightResult.RIGHT_ALLOW : false);
2102            boolean canCreatePublicProjetWithModeration = _rightManager.currentUserHasRight(ProjectConstants.RIGHT_PROJECT_CREATE_PUBLIC_MODERATED, "/${WorkspaceName}") == RightResult.RIGHT_ALLOW
2103                    || (catalogPage != null ? _rightManager.currentUserHasRight(ProjectConstants.RIGHT_PROJECT_CREATE_PUBLIC_MODERATED, catalogPage) == RightResult.RIGHT_ALLOW : false);
2104            boolean canCreatePublicProjet = _rightManager.currentUserHasRight(ProjectConstants.RIGHT_PROJECT_CREATE_PUBLIC_OPENED, "/${WorkspaceName}") == RightResult.RIGHT_ALLOW
2105                    || (catalogPage != null ? _rightManager.currentUserHasRight(ProjectConstants.RIGHT_PROJECT_CREATE_PUBLIC_OPENED, catalogPage) == RightResult.RIGHT_ALLOW : false);
2106            
2107            switch (oldInscriptionStatus)
2108            {
2109                case PRIVATE:
2110                    if (!canCreatePrivateProjet)
2111                    {
2112                        throw new AccessDeniedException("User " + _currentUserProvider.getUser() + " tried to edit private project without sufficient rights");
2113                    }
2114                    break;
2115                case MODERATED:
2116                    if (!canCreatePublicProjetWithModeration)
2117                    {
2118                        throw new AccessDeniedException("User " + _currentUserProvider.getUser() + " tried to edit public project with moderation without sufficient rights");
2119                    }
2120                    break;
2121                case OPEN:
2122                    if (!canCreatePublicProjet)
2123                    {
2124                        throw new AccessDeniedException("User " + _currentUserProvider.getUser() + " tried to edit public project without sufficient rights");
2125                    }
2126                    break;
2127                default:
2128                    throw new IllegalArgumentException("Inscription status '" + oldInscriptionStatus.toString() + "' is unknown");
2129            }
2130            
2131            switch (inscriptionStatus)
2132            {
2133                case PRIVATE:
2134                    if (!canCreatePrivateProjet)
2135                    {
2136                        throw new AccessDeniedException("User " + _currentUserProvider.getUser() + " tried to edit project to private project without sufficient rights");
2137                    }
2138                    break;
2139                case MODERATED:
2140                    if (!canCreatePublicProjetWithModeration)
2141                    {
2142                        throw new AccessDeniedException("User " + _currentUserProvider.getUser() + " tried to edit project to public project with moderation without sufficient rights");
2143                    }
2144                    break;
2145                case OPEN:
2146                    if (!canCreatePublicProjet)
2147                    {
2148                        throw new AccessDeniedException("User " + _currentUserProvider.getUser() + " tried to edit project to public project without sufficient rights");
2149                    }
2150                    break;
2151                default:
2152                    throw new IllegalArgumentException("Inscription status '" + inscriptionStatus.toString() + "' is unknown");
2153            }
2154        }
2155    }
2156    
2157    /**
2158     * Clear the site cache
2159     */
2160    public void clearCaches ()
2161    {
2162        _getMemorySiteAssociationCache().invalidateAll();
2163        _getMemoryProjectCache().invalidateAll();
2164        _getMemoryPageCache().invalidateAll();
2165        _getRequestProjectCache().invalidateAll();
2166        _getRequestPageCache().invalidateAll();
2167    }
2168    
2169    private Cache<String, List<Pair<String, String>>> _getMemorySiteAssociationCache()
2170    {
2171        return _cacheManager.get(MEMORY_SITEASSOCIATION_CACHE);
2172    }
2173    
2174    private Cache<String, String> _getMemoryProjectCache()
2175    {
2176        return _cacheManager.get(MEMORY_PROJECTIDBYNAMECACHE);
2177    }
2178    
2179    private Cache<ModuleCacheKey, Set<String>> _getMemoryPageCache()
2180    {
2181        return _cacheManager.get(MEMORY_PAGESBYIDCACHE);
2182    }
2183    
2184    private Cache<RequestProjectCacheKey, Project> _getRequestProjectCache()
2185    {
2186        return _cacheManager.get(REQUEST_PROJECTBYID_CACHE);
2187    }
2188    
2189    private Cache<RequestModuleCacheKey, Set<Page>> _getRequestPageCache()
2190    {
2191        return _cacheManager.get(REQUEST_PAGESBYPROJECTANDMODULE_CACHE);
2192    }
2193
2194    
2195    /**
2196     * Creates the caches
2197     */
2198    protected void _createCaches()
2199    {
2200        _cacheManager.createMemoryCache(MEMORY_SITEASSOCIATION_CACHE,
2201                new I18nizableText("plugin.workspaces", "PLUGINS_WORKSPACES_CACHE_PROJECT_MANAGER_LABEL"),
2202                new I18nizableText("plugin.workspaces", "PLUGINS_WORKSPACES_CACHE_PROJECT_MANAGER_DESCRIPTION"),
2203                true,
2204                null);
2205        _cacheManager.createMemoryCache(MEMORY_PROJECTIDBYNAMECACHE,
2206                new I18nizableText("plugin.workspaces", "PLUGINS_WORKSPACES_PROJECT_MANAGER_UUID_CACHE_LABEL"),
2207                new I18nizableText("plugin.workspaces", "PLUGINS_WORKSPACES_PROJECT_MANAGER_UUID_CACHE_DESCRIPTION"),
2208                true,
2209                null);
2210        _cacheManager.createMemoryCache(MEMORY_PAGESBYIDCACHE,
2211                new I18nizableText("plugin.workspaces", "PLUGINS_WORKSPACES_PROJECT_MANAGER_PAGEUUID_CACHE_LABEL"),
2212                new I18nizableText("plugin.workspaces", "PLUGINS_WORKSPACES_PROJECT_MANAGER_PAGEUUID_CACHE_DESCRIPTION"),
2213                true,
2214                null);
2215        _cacheManager.createRequestCache(REQUEST_PROJECTBYID_CACHE,
2216                new I18nizableText("plugin.workspaces", "PLUGINS_WORKSPACES_PROJECT_MANAGER_REQUEST_CACHE_LABEL"),
2217                new I18nizableText("plugin.workspaces", "PLUGINS_WORKSPACES_PROJECT_MANAGER_REQUEST_CACHE_DESCRIPTION"),
2218                false);
2219        _cacheManager.createRequestCache(REQUEST_PAGESBYPROJECTANDMODULE_CACHE,
2220                new I18nizableText("plugin.workspaces", "PLUGINS_WORKSPACES_PROJECT_MANAGER_PAGEREQUEST_CACHE_LABEL"),
2221                new I18nizableText("plugin.workspaces", "PLUGINS_WORKSPACES_PROJECT_MANAGER_PAGEREQUEST_CACHE_DESCRIPTION"),
2222                false);
2223    }
2224    
2225    private synchronized Map<String, String> _getUUIDCache()
2226    {
2227        if (!_getMemoryProjectCache().hasKey(__IS_CACHE_FILLED))
2228        {
2229            Session defaultSession = null;
2230            try
2231            {
2232                // Force default workspace to execute query
2233                defaultSession = _repository.login(RepositoryConstants.DEFAULT_WORKSPACE);
2234                
2235                String jcrQuery = "//element(*, ametys:project)";
2236                
2237                AmetysObjectIterable<Project> projects = _resolver.query(jcrQuery, defaultSession);
2238                
2239                for (Project project : projects)
2240                {
2241                    _getMemoryProjectCache().put(project.getName(), project.getId());
2242                }
2243                
2244                _getMemoryProjectCache().put(__IS_CACHE_FILLED, null);
2245            }
2246            catch (RepositoryException e)
2247            {
2248                throw new AmetysRepositoryException(e);
2249            }
2250            finally
2251            {
2252                if (defaultSession != null)
2253                {
2254                    defaultSession.logout();
2255                }
2256            }
2257        }
2258        
2259        Map<String, String> cacheAsMap = _getMemoryProjectCache().asMap();
2260        cacheAsMap.remove(__IS_CACHE_FILLED);
2261        return cacheAsMap;
2262    }
2263    
2264    private static final class RequestProjectCacheKey extends AbstractCacheKey
2265    {
2266        private RequestProjectCacheKey(String projectName, String workspaceName)
2267        {
2268            super(projectName, workspaceName);
2269        }
2270        
2271        static RequestProjectCacheKey of(String projectName, String workspaceName)
2272        {
2273            return new RequestProjectCacheKey(projectName, workspaceName);
2274        }
2275    }
2276    
2277    private static final class ModuleCacheKey extends AbstractCacheKey
2278    {
2279        private ModuleCacheKey(String projectName, String moduleId)
2280        {
2281            super(projectName, moduleId);
2282        }
2283        
2284        static ModuleCacheKey of(String projectName, String moduleId)
2285        {
2286            return new ModuleCacheKey(projectName, moduleId);
2287        }
2288    }
2289    
2290    private static final class RequestModuleCacheKey extends AbstractCacheKey
2291    {
2292        private RequestModuleCacheKey(String projectName, String moduleId, String workspaceName)
2293        {
2294            super(projectName, moduleId, workspaceName);
2295        }
2296        
2297        static RequestModuleCacheKey of(String projectName, String moduleId, String workspaceName)
2298        {
2299            return new RequestModuleCacheKey(projectName, moduleId, workspaceName);
2300        }
2301    }
2302    
2303    private Request _getRequest ()
2304    {
2305        try
2306        {
2307            return (Request) _context.get(ContextHelper.CONTEXT_REQUEST_OBJECT);
2308        }
2309        catch (ContextException ce)
2310        {
2311            getLogger().info("Unable to get the request", ce);
2312            return null;
2313        }
2314    }
2315
2316    /**
2317     * Retrieves all projects for client side
2318     * @return the projects
2319     */
2320    @Callable(rights = "Runtime_Rights_Admin_Access", context = "/admin")
2321    public List<Map<String, Object>> getProjectsStatisticsForClientSide()
2322    {
2323        return getProjects()
2324                .stream()
2325                .map(p -> getProjectStatistics(p))
2326                .collect(Collectors.toList());
2327    }
2328    
2329    /**
2330     * Retrieves the standard information of a project
2331     * @param project The project
2332     * @return The map of information
2333     */
2334    public Map<String, Object> getProjectStatistics(Project project)
2335    {
2336        Map<String, Object> statistics = new HashMap<>();
2337
2338        statistics.put("title", project.getTitle());
2339
2340        long totalSize = 0;
2341        for (WorkspaceModule moduleManager : _moduleManagerEP.getModules())
2342        {
2343            Map<String, Object> moduleStatistics = moduleManager.getStatistics(project);
2344            statistics.putAll(moduleStatistics);
2345            Long size = (Long) moduleStatistics.get(moduleManager.getModuleSizeKey());
2346            totalSize += (size != null && size >= 0) ? (Long) moduleStatistics.get(moduleManager.getModuleSizeKey()) : 0;
2347        }
2348
2349        statistics.put("totalSize", totalSize);
2350
2351        ZonedDateTime creationDate = project.getCreationDate();
2352        
2353        statistics.put("creationDate", creationDate);
2354        statistics.put("managers", Arrays.stream(project.getManagers())
2355                .map(u -> _userHelper.user2json(u))
2356                .collect(Collectors.toList()));
2357
2358        return statistics;
2359    }
2360
2361    /**
2362     * Retrieves all projects for client side
2363     * @return the projects
2364     */
2365    @Callable(rights = "Runtime_Rights_Admin_Access", context = "/admin")
2366    public List<Map<String, Object>> getProjectsStatisticsColumnsModel()
2367    {
2368        return getStatisticHeaders()
2369                .stream()
2370                .map(p -> p.convertToJSON())
2371                .collect(Collectors.toList());
2372    }
2373
2374    private List<StatisticColumn> getStatisticHeaders()
2375    {
2376
2377        List<StatisticColumn> flatStatisticHeaders = getFlatStatisticHeaders();
2378        List<StatisticColumn> headers = new ArrayList<>();
2379        for (StatisticColumn statisticColumn : flatStatisticHeaders)
2380        {
2381            // this column have a parent, we have to find it and attach it
2382            if (statisticColumn.getGroup() != null)
2383            {
2384                Optional<StatisticColumn> parent = flatStatisticHeaders.stream()
2385                                                    .filter(column -> column.getId().equals(statisticColumn.getGroup()))
2386                                                    .findAny();
2387                if (parent.isPresent())
2388                {
2389                    parent.get().addSubColumn(statisticColumn);
2390                }
2391            }
2392            else
2393            {
2394                headers.add(statisticColumn);
2395            }
2396        }
2397        
2398        return headers;
2399    }
2400    
2401    private List<StatisticColumn> getFlatStatisticHeaders()
2402    {
2403        List<StatisticColumn> headers = new ArrayList<>();
2404        headers.add(new StatisticColumn("title", new I18nizableText("plugin.workspaces", "PLUGINS_WORKSPACES_PROJECT_STATISTICS_TOOL_COLUMN_TITLE"))
2405                .withType(StatisticsColumnType.STRING)
2406                .withWidth(200)
2407                .withRenderer("Ametys.plugins.workspaces.project.tool.ProjectsGridHelper.renderTitle"));
2408        headers.add(new StatisticColumn("creationDate", new I18nizableText("plugin.workspaces", "PLUGINS_WORKSPACES_PROJECT_STATISTICS_TOOL_COLUMN_CREATION"))
2409                .withType(StatisticsColumnType.DATE)
2410                .withWidth(150));
2411        headers.add(new StatisticColumn("managers", new I18nizableText("plugin.workspaces", "PLUGINS_WORKSPACES_PROJECT_STATISTICS_TOOL_COLUMN_MANAGERS"))
2412                .withRenderer("Ametys.grid.GridColumnHelper.renderUser")
2413                .withFilter(false));
2414        for (WorkspaceModule moduleManager : _moduleManagerEP.getModules())
2415        {
2416            headers.addAll(moduleManager.getStatisticModel());
2417        }
2418        
2419        StatisticColumn elements = new StatisticColumn(WorkspaceModule.GROUP_HEADER_ELEMENTS_ID, new I18nizableText("plugin.workspaces", "PLUGINS_WORKSPACES_PROJECT_STATISTICS_TOOL_COLUMN_ELEMENTS"))
2420                .withFilter(false);
2421        headers.add(elements);
2422
2423        StatisticColumn activatedModules = new StatisticColumn(WorkspaceModule.GROUP_HEADER_ACTIVATED_ID, new I18nizableText("plugin.workspaces", "PLUGINS_WORKSPACES_PROJECT_STATISTICS_TOOL_COLUMN_ACTIVE_MODULES"))
2424                .isHidden(true)
2425                .withFilter(false);
2426        headers.add(activatedModules);
2427        
2428        StatisticColumn lastActivity = new StatisticColumn(WorkspaceModule.GROUP_HEADER_LAST_ACTIVITY_ID, new I18nizableText("plugin.workspaces", "PLUGINS_WORKSPACES_PROJECT_STATISTICS_TOOL_COLUMN_LAST_ACTIVITY")).isHidden(true);
2429        headers.add(lastActivity);
2430
2431        StatisticColumn modulesSize = new StatisticColumn(WorkspaceModule.GROUP_HEADER_SIZE_ID, new I18nizableText("plugin.workspaces", "PLUGINS_WORKSPACES_PROJECT_STATISTICS_TOOL_COLUMN_MODULES_SIZE"))
2432                .withFilter(false);
2433        modulesSize.addSubColumn(new StatisticColumn("totalSize", new I18nizableText("plugin.workspaces", "PLUGINS_WORKSPACES_PROJECT_STATISTICS_TOOL_COLUMN_MODULES_SIZE_TOTAL"))
2434                .withRenderer("Ametys.plugins.workspaces.project.tool.ProjectsGridHelper.renderSize")
2435                .withType(StatisticsColumnType.LONG));
2436        
2437        headers.add(modulesSize);
2438        
2439        return headers;
2440    }
2441    
2442    /**
2443     * Check if the user is in one of the populations of project
2444     * @param project the project
2445     * @param user the user
2446     * @return true if the user is in one of the populations of project
2447     */
2448    public boolean isUserInProjectPopulations(Project project, UserIdentity user)
2449    {
2450        Site site = project.getSite();
2451
2452        if (site == null)
2453        {
2454            throw new IllegalArgumentException("Cannot determine if user " + UserIdentity.userIdentityToString(user) + " can connect to the project " + project.getName() + " since the project has no associated site to determine the populations");
2455        }
2456        String siteName = site.getName();
2457
2458        Set<String> populations = _populationContextHelper.getUserPopulationsOnContext("/sites/" + siteName, false);
2459        Set<String> frontPopulations = _populationContextHelper.getUserPopulationsOnContext("/sites-fo/" + siteName, false);
2460        
2461        return populations.contains(user.getPopulationId()) || frontPopulations.contains(user.getPopulationId());
2462    }
2463    
2464    /**
2465     * Thrown to indicate that the catalog site is unknown
2466     */
2467    public class UnknownCatalogSiteException extends IllegalArgumentException
2468    {
2469        /**
2470         * Construct a {@code UnknownCatalogSiteException} with the specified message
2471         * @param message the message
2472         */
2473        public UnknownCatalogSiteException(String message)
2474        {
2475            super(message);
2476        }
2477    }
2478    
2479    /**
2480     * Thrown to indicate that the user directory site is unknown
2481     */
2482    public class UnknownUserDirectorySiteException extends IllegalArgumentException
2483    {
2484        /**
2485         * Construct a {@code UnknownUserDirectorySiteException} with the specified message
2486         * @param message the message
2487         */
2488        public UnknownUserDirectorySiteException(String message)
2489        {
2490            super(message);
2491        }
2492    }
2493}