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