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