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