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