001/*
002 *  Copyright 2016 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.members;
017
018import java.util.ArrayList;
019import java.util.Arrays;
020import java.util.Collection;
021import java.util.Comparator;
022import java.util.HashMap;
023import java.util.List;
024import java.util.Map;
025import java.util.Map.Entry;
026import java.util.Objects;
027import java.util.Optional;
028import java.util.Set;
029import java.util.TreeSet;
030import java.util.function.BiPredicate;
031import java.util.function.Predicate;
032import java.util.regex.Matcher;
033import java.util.regex.Pattern;
034import java.util.stream.Collectors;
035
036import org.apache.avalon.framework.activity.Initializable;
037import org.apache.avalon.framework.component.Component;
038import org.apache.avalon.framework.context.Context;
039import org.apache.avalon.framework.context.ContextException;
040import org.apache.avalon.framework.context.Contextualizable;
041import org.apache.avalon.framework.service.ServiceException;
042import org.apache.avalon.framework.service.ServiceManager;
043import org.apache.avalon.framework.service.Serviceable;
044import org.apache.cocoon.components.ContextHelper;
045import org.apache.cocoon.environment.Request;
046import org.apache.commons.collections.CollectionUtils;
047import org.apache.commons.lang3.StringUtils;
048import org.apache.http.annotation.Obsolete;
049
050import org.ametys.cms.languages.Language;
051import org.ametys.cms.languages.LanguagesManager;
052import org.ametys.cms.repository.Content;
053import org.ametys.cms.transformation.URIResolverExtensionPoint;
054import org.ametys.core.cache.AbstractCacheManager;
055import org.ametys.core.cache.Cache;
056import org.ametys.core.group.Group;
057import org.ametys.core.group.GroupDirectoryContextHelper;
058import org.ametys.core.group.GroupIdentity;
059import org.ametys.core.group.GroupManager;
060import org.ametys.core.observation.Event;
061import org.ametys.core.observation.ObservationManager;
062import org.ametys.core.right.ProfileAssignmentStorage.UserOrGroup;
063import org.ametys.core.right.ProfileAssignmentStorageExtensionPoint;
064import org.ametys.core.right.RightManager;
065import org.ametys.core.right.RightProfilesDAO;
066import org.ametys.core.ui.Callable;
067import org.ametys.core.user.CurrentUserProvider;
068import org.ametys.core.user.User;
069import org.ametys.core.user.UserIdentity;
070import org.ametys.core.user.UserManager;
071import org.ametys.core.user.directory.NotUniqueUserException;
072import org.ametys.plugins.core.impl.cache.AbstractCacheKey;
073import org.ametys.plugins.core.user.UserHelper;
074import org.ametys.plugins.explorer.resources.ModifiableResourceCollection;
075import org.ametys.plugins.repository.AmetysObject;
076import org.ametys.plugins.repository.AmetysObjectResolver;
077import org.ametys.plugins.repository.AmetysRepositoryException;
078import org.ametys.plugins.repository.ModifiableAmetysObject;
079import org.ametys.plugins.repository.ModifiableTraversableAmetysObject;
080import org.ametys.plugins.repository.RepositoryConstants;
081import org.ametys.plugins.userdirectory.UserDirectoryHelper;
082import org.ametys.plugins.userdirectory.page.UserDirectoryPageResolver;
083import org.ametys.plugins.userdirectory.page.UserPage;
084import org.ametys.plugins.workspaces.ObservationConstants;
085import org.ametys.plugins.workspaces.members.JCRProjectMember.MemberType;
086import org.ametys.plugins.workspaces.project.ProjectManager;
087import org.ametys.plugins.workspaces.project.modules.WorkspaceModule;
088import org.ametys.plugins.workspaces.project.modules.WorkspaceModuleExtensionPoint;
089import org.ametys.plugins.workspaces.project.objects.Project;
090import org.ametys.plugins.workspaces.project.objects.Project.InscriptionStatus;
091import org.ametys.plugins.workspaces.project.rights.ProjectRightHelper;
092import org.ametys.runtime.authentication.AccessDeniedException;
093import org.ametys.runtime.config.Config;
094import org.ametys.runtime.i18n.I18nizableText;
095import org.ametys.runtime.plugin.component.AbstractLogEnabled;
096import org.ametys.web.WebHelper;
097import org.ametys.web.population.PopulationContextHelper;
098import org.ametys.web.repository.site.Site;
099import org.ametys.web.usermanagement.UserManagementException;
100import org.ametys.web.usermanagement.UserSignupManager;
101
102/**
103 * Helper component for managing project's users
104 */
105public class ProjectMemberManager extends AbstractLogEnabled implements Serviceable, Component, Contextualizable, Initializable
106{
107    /** Avalon Role */
108    public static final String ROLE = ProjectMemberManager.class.getName();
109    
110    /** The id of the members service */
111    public static final String __WORKSPACES_SERVICE_MEMBERS = "org.ametys.plugins.workspaces.module.Members";
112    
113    private static final String __PROJECT_MEMBER_CACHE = "projectMemberCache";
114    
115    @Obsolete // For v1 project only
116    private static final String __PROJECT_RIGHT_PROFILE = "PROJECT";
117    
118    /** Constants for users project node */
119    private static final String __PROJECT_MEMBERS_NODE = RepositoryConstants.NAMESPACE_PREFIX_INTERNAL + ":members";
120    
121    /** The type of the project users node type */
122    private static final String __PROJECT_MEMBERS_NODE_TYPE = RepositoryConstants.NAMESPACE_PREFIX + ":unstructured";
123
124    /** The type of a project user node type */
125    private static final String __PROJECT_MEMBER_NODE_TYPE = RepositoryConstants.NAMESPACE_PREFIX + ":project-member";
126
127    private static Pattern __MAIL_BETWEEN_BRACKETS_PATTERN = Pattern.compile("^[^<]*<(.*@.*)>$");
128    
129    /** Avalon context */
130    protected Context _context;
131    
132    /** Project manager */
133    protected ProjectManager _projectManager;
134
135    /** Project rights helper */
136    protected ProjectRightHelper _projectRightHelper;
137
138    /** Profiles right manager */
139    protected RightProfilesDAO _rightProfilesDAO;
140
141    /** Profile assignment storage */
142    protected ProfileAssignmentStorageExtensionPoint _profileAssignmentStorageExtensionPoint;
143    
144    /** Ametys object resolver */
145    protected AmetysObjectResolver _resolver;
146
147    /** Rights manager */
148    protected RightManager _rightManager;
149
150    /** Current user provider */
151    protected CurrentUserProvider _currentUserProvider;
152
153    /** Users manager */
154    protected UserManager _userManager;
155
156    /** The observation manager */
157    protected ObservationManager _observationManager;
158
159    /** Module managers EP */
160    protected WorkspaceModuleExtensionPoint _moduleManagerEP;
161    
162    /** The user helper */
163    protected UserHelper _userHelper;
164
165    /** The groups manager */
166    protected GroupManager _groupManager;
167
168    /** The population context helper */
169    protected PopulationContextHelper _populationContextHelper;
170
171    /** The user directory helper */
172    protected UserDirectoryHelper _userDirectoryHelper;
173    
174    /** The project invitation helper */
175    protected ProjectInvitationHelper _projectInvitationHelper;
176
177    /** The language manager */
178    protected LanguagesManager _languagesManager;
179        
180    /** The resolver for user directory pages */
181    protected UserDirectoryPageResolver _userDirectoryPageResolver;
182
183    /** The page URI resolver. */
184    protected URIResolverExtensionPoint _uriResolver;
185
186    /** The group directory context helper */
187    protected GroupDirectoryContextHelper _groupDirectoryContextHelper;
188
189    /** The cache manager */
190    protected AbstractCacheManager _abstractCacheManager;
191    
192    /** The user signup manager */
193    protected UserSignupManager _userSignupManager;
194    
195    @Override
196    public void contextualize(Context context) throws ContextException
197    {
198        _context = context;
199    }
200    
201    @Override
202    public void service(ServiceManager manager) throws ServiceException
203    {
204        _abstractCacheManager = (AbstractCacheManager) manager.lookup(AbstractCacheManager.ROLE);
205        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
206        _projectManager = (ProjectManager) manager.lookup(ProjectManager.ROLE);
207        _projectRightHelper = (ProjectRightHelper) manager.lookup(ProjectRightHelper.ROLE);
208        _rightProfilesDAO = (RightProfilesDAO) manager.lookup(RightProfilesDAO.ROLE);
209        _profileAssignmentStorageExtensionPoint = (ProfileAssignmentStorageExtensionPoint) manager.lookup(ProfileAssignmentStorageExtensionPoint.ROLE);
210        _rightManager = (RightManager) manager.lookup(RightManager.ROLE);
211        _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE);
212        _userManager = (UserManager) manager.lookup(UserManager.ROLE);
213        _groupManager = (GroupManager) manager.lookup(GroupManager.ROLE);
214        _userHelper = (UserHelper) manager.lookup(UserHelper.ROLE);
215        _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE);
216        _moduleManagerEP = (WorkspaceModuleExtensionPoint) manager.lookup(WorkspaceModuleExtensionPoint.ROLE);
217        _populationContextHelper = (PopulationContextHelper) manager.lookup(org.ametys.core.user.population.PopulationContextHelper.ROLE);
218        _userDirectoryHelper = (UserDirectoryHelper) manager.lookup(UserDirectoryHelper.ROLE);
219        _projectInvitationHelper = (ProjectInvitationHelper) manager.lookup(ProjectInvitationHelper.ROLE);
220        _languagesManager = (LanguagesManager) manager.lookup(LanguagesManager.ROLE);
221        _userDirectoryPageResolver = (UserDirectoryPageResolver) manager.lookup(UserDirectoryPageResolver.ROLE);
222        _uriResolver = (URIResolverExtensionPoint) manager.lookup(URIResolverExtensionPoint.ROLE);
223        _groupDirectoryContextHelper = (GroupDirectoryContextHelper) manager.lookup(GroupDirectoryContextHelper.ROLE);
224        _userSignupManager = (UserSignupManager) manager.lookup(UserSignupManager.ROLE);
225    }
226    
227    public void initialize() throws Exception
228    {
229        _abstractCacheManager.createMemoryCache(__PROJECT_MEMBER_CACHE,
230                new I18nizableText("plugin.workspaces", "PLUGIN_WORKSPACES_CACHE_PROJECT_MEMBER_LABEL"),
231                new I18nizableText("plugin.workspaces", "PLUGIN_WORKSPACES_CACHE_PROJECT_MEMBER_DESCRIPTION"),
232                true,
233                null);
234    }
235    
236    /**
237     * Retrieve the data of a member of a project, or the default data if no user is provided
238     * @param projectName The name of the project
239     * @param identity The user or group identity. If null, return the default profiles for a new user
240     * @param type The type of the identity. Can be "user" or "group"
241     * @return The map of profiles per module for the user
242     */
243    @Callable
244    public Map<String, Object> getProjectMemberData(String projectName, String identity, String type)
245    {
246        Map<String, Object> result = new HashMap<>();
247        
248        boolean isTypeUser = JCRProjectMember.MemberType.USER.name().equals(type.toUpperCase());
249        boolean isTypeGroup = JCRProjectMember.MemberType.GROUP.name().equals(type.toUpperCase());
250        UserIdentity user = Optional.ofNullable(identity)
251                                    .filter(id -> id != null && isTypeUser)
252                                    .map(UserIdentity::stringToUserIdentity)
253                                    .orElse(null);
254        GroupIdentity group = Optional.ofNullable(identity)
255                                      .filter(id -> id != null && isTypeGroup)
256                                      .map(GroupIdentity::stringToGroupIdentity)
257                                      .orElse(null);
258        
259        if (identity != null)
260        {
261            if (isTypeGroup && group == null)
262            {
263                result.put("message", "unknow-group");
264                result.put("success", false);
265                return result;
266            }
267            else if (isTypeUser && user == null)
268            {
269                result.put("message", "unknow-user");
270                result.put("success", false);
271                return result;
272            }
273        }
274        
275        Project project = _projectManager.getProject(projectName);
276        if (project == null)
277        {
278            result.put("message", "unknow-project");
279            result.put("success", false);
280            return result;
281        }
282        
283        if (!_projectRightHelper.canAddMember(project))
284        {
285            throw new AccessDeniedException("User '" + _currentUserProvider.getUser() + "' tried to get member's rights without convenient right [" + projectName + ", " + identity + "]");
286        }
287        
288        boolean newMember = true;
289        Map<String, String> userProfiles;
290        
291        if (user != null || group != null)
292        {
293            JCRProjectMember projectMember = user != null ? _getOrCreateJCRProjectMember(project, user) : _getOrCreateJCRProjectMember(project, group);
294            
295            newMember = projectMember.needsSave();
296            
297            String role = projectMember.getRole();
298            if (role != null)
299            {
300                result.put("role", role);
301            }
302            
303            userProfiles = _getMemberProfiles(projectMember, project);
304        }
305        else
306        {
307            userProfiles = new HashMap<>();
308        }
309        
310        result.put("profiles", userProfiles);
311        result.put("status", newMember ? "new" : "edit");
312        result.put("success", true);
313        
314        return result;
315    }
316
317    private Map<String, String> _getMemberProfiles(JCRProjectMember member, Project project)
318    {
319        Map<String, String> userProfiles = new HashMap<>();
320        
321        // Get allowed profile on modules (among the project members's profiles)
322        for (WorkspaceModule module : _projectManager.getModules(project))
323        {
324            String allowedProfileOnProject = _getAllowedProfileOnModule(project, module, member);
325            userProfiles.put(module.getId(), allowedProfileOnProject);
326        }
327        
328        return userProfiles;
329    }
330    
331    private String _getAllowedProfileOnModule (Project project, WorkspaceModule module, JCRProjectMember member)
332    {
333        Set<String> profileIds = _projectRightHelper.getProfilesIds();
334        
335        AmetysObject moduleObject = module.getModuleRoot(project, false);
336        Set<String> allowedProfilesForMember = _getAllowedProfile(member, moduleObject);
337        
338        for (String allowedProfile : allowedProfilesForMember)
339        {
340            if (profileIds.contains(allowedProfile))
341            {
342                // Get the first allowed profile among the project's members profiles
343                return allowedProfile;
344            }
345        }
346        
347        return null;
348    }
349    
350    /**
351     * Add new members and invitation by email
352     * @param projectName The project name
353     * @param newMembers The members to add (users or groups)
354     * @param invitEmails The invitation emails
355     * @return the result with errors
356     */
357    @SuppressWarnings("unchecked")
358    @Callable
359    public Map<String, Object> addMembers(String projectName, List<Map<String, String>> newMembers, List<String> invitEmails)
360    {
361        Map<String, Object> result = new HashMap<>();
362        
363        Request request = ContextHelper.getRequest(_context);
364        String siteName = WebHelper.getSiteName(request);
365        
366        boolean hasError = false;
367        boolean inviteError = false;
368        boolean unknownProject = false;
369        List<String> unknownGroups = new ArrayList<>();
370        List<String> unknownUsers = new ArrayList<>();
371        List<Map<String, Object>> existingUsers = new ArrayList<>();
372        List<Map<String, Object>> usersAdded = new ArrayList<>();
373        
374        List<String> filteredInvitEmails = new ArrayList<>();
375        if (invitEmails != null)
376        {
377            try
378            {
379                for (String invitEmail : invitEmails)
380                {
381                    
382                    
383                    String filteredInvitEmail = invitEmail;
384
385                    // Regexp pattern to extract "email@domain.com" from "FirstName LastName <email@domain.com>"
386                    // ^[^<]*<(.*@.*)>$
387                    // ^  => asserts position at start of a line
388                    // [^<]* => match any characters that are not '<', so the matched group start at the first bracket
389                    // <(.*@.*)> => match text between brackets, containing '@'
390                    Matcher matcher =  __MAIL_BETWEEN_BRACKETS_PATTERN.matcher(invitEmail);
391                    if (matcher.matches() && matcher.groupCount() == 1)
392                    {
393                        filteredInvitEmail = matcher.group(1);
394                    }
395                    
396                    Optional<User> userIfExists = _userSignupManager.getUserIfHeExists(filteredInvitEmail, siteName);
397                    if (userIfExists.isPresent())
398                    {
399                        newMembers.add(Map.of(
400                            "id", UserIdentity.userIdentityToString(userIfExists.get().getIdentity()),
401                            "type", "user"
402                        ));
403                    }
404                    else
405                    {
406                        filteredInvitEmails.add(filteredInvitEmail);
407                    }
408                }
409            }
410            catch (UserManagementException e)
411            {
412                hasError = true;
413                inviteError = true;
414                getLogger().error("Impossible to send email invitations", e);
415            }
416            catch (NotUniqueUserException e)
417            {
418                hasError = true;
419                inviteError = true;
420                getLogger().error("Impossible to send email invitations, some user already exist", e);
421            }
422        }
423        
424        for (Map<String, String> newMember : newMembers)
425        {
426            Map<String, Object> addResult = addMember(projectName, newMember.get("id"), newMember.get("type"));
427            boolean success = (boolean) addResult.get("success");
428            if (!success)
429            {
430                String error = (String) addResult.get("message");
431                if ("unknown-user".equals(error)) 
432                {
433                    hasError = true;
434                    unknownUsers.add(newMember.get("id"));
435                }
436                else if ("unknown-group".equals(error))
437                {
438                    hasError = true;
439                    unknownGroups.add(newMember.get("id"));
440                }
441                else if ("unknown-project".equals(error))
442                {
443                    hasError = true;
444                    unknownProject = true;
445                }
446                else if ("existing-user".equals(error))
447                {
448                    existingUsers.add((Map<String, Object>) addResult.get("existing-user"));
449                }
450            }
451            else
452            {
453                User user = _userManager.getUser(UserIdentity.stringToUserIdentity(newMember.get("id")));
454                usersAdded.add(_userHelper.user2json(user, true));
455            }
456        }
457        
458        if (!filteredInvitEmails.isEmpty())
459        {
460            Map<String, String> newProfiles = _getDefaultProfilesByModule();
461            
462            try
463            {
464                Map<String, Object> inviteEmails = _projectInvitationHelper.inviteEmails(projectName, filteredInvitEmails, newProfiles);
465                List<String> errors = (List<String>) inviteEmails.get("email-error");
466                if (!errors.isEmpty())
467                {
468                    hasError = true;
469                    inviteError = true;
470                }
471                existingUsers.addAll((List<Map<String, Object>>) inviteEmails.get("existing-users"));
472            }
473            catch (UserManagementException e)
474            {
475                hasError = true;
476                inviteError = true;
477                getLogger().error("Impossible to send email invitations", e);
478            }
479            catch (NotUniqueUserException e)
480            {
481                hasError = true;
482                inviteError = true;
483                getLogger().error("Impossible to send email invitations, some user already exist", e);
484            }
485            
486        }
487        
488        result.put("invite-error", inviteError);
489        result.put("existing-users", existingUsers);
490        result.put("unknown-groups", unknownGroups);
491        result.put("unknown-users", unknownUsers);
492        result.put("unknown-project", unknownProject);
493        result.put("users-added", usersAdded);
494        result.put("success", !hasError);
495        
496        return result;
497    }
498    
499    /**
500     * Add a new member
501     * @param projectName The project name
502     * @param identity The user or group identity.
503     * @param type The type of the identity. Can be "user" or "group"
504     * @return the result
505     */
506    @Callable
507    public Map<String, Object> addMember(String projectName, String identity, String type)
508    {
509        Map<String, String> newProfiles = _getDefaultProfilesByModule();
510        
511        return _setProjectMemberData(projectName, identity, type, newProfiles, null, true);
512    }
513    
514    /**
515     * Get a map of each available module, with the default profile
516     * @return A map with moduleId : profileId
517     */
518    protected Map<String, String> _getDefaultProfilesByModule()
519    {
520        Map<String, String> newProfiles = new HashMap<>();
521        
522        String defaultProfile = StringUtils.defaultString(Config.getInstance().getValue("workspaces.profile.default"));
523        for (String moduleId : _moduleManagerEP.getExtensionsIds())
524        {
525            newProfiles.put(moduleId, defaultProfile);
526        }
527        
528        return newProfiles;
529    }
530    
531    /**
532     * Set the user data in the project
533     * @param projectName The project name
534     * @param identity The user or group identity.
535     * @param type The type of the identity. Can be "user" or "group"
536     * @param newProfiles The profiles to affect, mapped by module
537     * @param role The user role inside the project
538     * @return The result
539     */
540    @Callable
541    public Map<String, Object> setProjectMemberData(String projectName, String identity, String type, Map<String, String> newProfiles, String role)
542    {
543        return _setProjectMemberData(projectName, identity, type, newProfiles, role, false);
544    }
545    
546    /**
547     * Set the user data in the project
548     * @param projectName The project name
549     * @param identity The user or group identity.
550     * @param type The type of the identity. Can be "user" or "group"
551     * @param newProfiles The profiles to affect, mapped by module
552     * @param role The user role inside the project
553     * @param isNewUser <code>true</code> if the user is just added
554     * @return The result
555     */
556    protected Map<String, Object> _setProjectMemberData(String projectName, String identity, String type, Map<String, String> newProfiles, String role, boolean isNewUser)
557    {
558        Map<String, Object> result = new HashMap<>();
559        Project project = _projectManager.getProject(projectName);
560        if (project == null)
561        {
562            result.put("success", false);
563            result.put("message", "unknow-project");
564            return result;
565        }
566        
567        boolean isTypeUser = JCRProjectMember.MemberType.USER.name().equals(type.toUpperCase());
568        boolean isTypeGroup = JCRProjectMember.MemberType.GROUP.name().equals(type.toUpperCase());
569        UserIdentity user = Optional.ofNullable(identity)
570                                    .filter(id -> id != null && isTypeUser)
571                                    .map(UserIdentity::stringToUserIdentity)
572                                    .orElse(null);
573        GroupIdentity group = Optional.ofNullable(identity)
574                                      .filter(id -> id != null && isTypeGroup)
575                                      .map(GroupIdentity::stringToGroupIdentity)
576                                      .orElse(null);
577        
578        if (group == null && user == null)
579        {
580            result.put("success", false);
581            result.put("message", isTypeGroup ? "unknow-group" : "unknow-user");
582            return result;
583        }
584        
585        if (isNewUser && !_projectRightHelper.canAddMember(project) || !isNewUser && !_projectRightHelper.canEditMember(project))
586        {
587            throw new AccessDeniedException("User '" + _currentUserProvider.getUser() + "' tried to set member rights without convenient right [" + projectName + ", " + identity + "]");
588        }
589        
590        if (isNewUser && isTypeUser && _getProjectMember(project, user) != null)
591        {
592            result.put("success", false);
593            result.put("message", "existing-user");
594            result.put("existing-user", _userHelper.user2json(user, true));
595            
596            return result;
597        }
598        
599        boolean userAdded = isTypeUser ? addOrUpdateProjectMember(project, user, newProfiles) : addOrUpdateProjectMember(project, group, newProfiles);
600        
601        result.put("success", userAdded);
602        return result;
603    }
604    
605    /**
606     * Add a user to a project with open inscriptions, using the default values
607     * @param project The project
608     * @param user The user
609     * @return True if the user was successfully added
610     */
611    public boolean addProjectMember(Project project, UserIdentity user)
612    {
613        InscriptionStatus inscriptionStatus = project.getInscriptionStatus();
614        if (!inscriptionStatus.equals(InscriptionStatus.OPEN))
615        {
616            return false;
617        }
618        
619        return addOrUpdateProjectMember(project, user, Map.of());
620    }
621    
622    /**
623     * Add a user to a project, using the provided profile values
624     * @param project The project
625     * @param user The user
626     * @param allowedProfiles the profile values
627     * @return True if the user was successfully added
628     */
629    public boolean addOrUpdateProjectMember(Project project, UserIdentity user, Map<String, String> allowedProfiles)
630    {
631        return addOrUpdateProjectMember(project, user, allowedProfiles, _currentUserProvider.getUser());
632    }
633    /**
634     * Add a user to a project, using the provided profile values
635     * @param project The project
636     * @param user The user
637     * @param allowedProfiles the profile values
638     * @param issuer identity of the user that approved the member
639     * @return True if the user was successfully added
640     */
641    public boolean addOrUpdateProjectMember(Project project, UserIdentity user, Map<String, String> allowedProfiles, UserIdentity issuer)
642    {
643        if (user == null)
644        {
645            return false;
646        }
647        
648        JCRProjectMember projectMember = _getOrCreateJCRProjectMember(project, user);
649        _setMemberProfiles(allowedProfiles, projectMember, project);
650        _saveAndNotifyProjectMemberUpdate(project, projectMember, UserIdentity.userIdentityToString(user), issuer);
651        return true;
652    }
653    
654    /**
655     * Add a group to a project, using the provided profile values
656     * @param project The project
657     * @param group The group
658     * @param allowedProfiles the profile values
659     * @return True if the user was successfully added
660     */
661    public boolean addOrUpdateProjectMember(Project project, GroupIdentity group, Map<String, String> allowedProfiles)
662    {
663        if (group == null)
664        {
665            return false;
666        }
667        
668        JCRProjectMember projectMember = _getOrCreateJCRProjectMember(project, group);
669        _setMemberProfiles(allowedProfiles, projectMember, project);
670        _saveAndNotifyProjectMemberUpdate(project, projectMember, GroupIdentity.groupIdentityToString(group), _currentUserProvider.getUser());
671        return true;
672    }
673
674    private void _saveAndNotifyProjectMemberUpdate(Project project, JCRProjectMember projectMember, String userIdentityString, UserIdentity issuer)
675    {
676        project.saveChanges();
677        
678        _getCache().invalidate(ProjectMemberCacheKey.of(project.getId(), null));
679        
680        // Notify listeners
681        Map<String, Object> eventParams = new HashMap<>();
682        eventParams.put(ObservationConstants.ARGS_MEMBER, projectMember);
683        eventParams.put(ObservationConstants.ARGS_MEMBER_ID, projectMember.getId());
684        eventParams.put(ObservationConstants.ARGS_PROJECT, project);
685        eventParams.put(ObservationConstants.ARGS_PROJECT_ID, project.getId());
686        eventParams.put(ObservationConstants.ARGS_MEMBER_IDENTITY, userIdentityString);
687        eventParams.put(ObservationConstants.ARGS_MEMBER_IDENTITY_TYPE, projectMember.getType());
688        
689        _observationManager.notify(new Event(ObservationConstants.EVENT_MEMBER_ADDED, issuer, eventParams));
690    }
691
692    /**
693     * Set the profiles for a member
694     * @param newProfiles The allowed profile by module
695     * @param projectMember The member
696     * @param project The project
697     */
698    private void _setMemberProfiles(Map<String, String> newProfiles, JCRProjectMember projectMember, Project project)
699    {
700        String defaultProfile = project.getDefaultProfile();
701        Set<String> defaultProfiles;
702        if (StringUtils.isEmpty(defaultProfile))
703        {
704            defaultProfiles = Set.of();
705        }
706        else
707        {
708            defaultProfiles = Set.of(defaultProfile);
709        }
710        
711        for (WorkspaceModule module : _moduleManagerEP.getModules())
712        {
713            Set<String> moduleProfiles;
714            if (newProfiles.containsKey(module.getId()))
715            {
716                String profile = newProfiles.get(module.getId());
717                moduleProfiles = StringUtils.isEmpty(profile) ? Set.of() : Set.of(profile);
718            }
719            else
720            {
721                moduleProfiles = defaultProfiles;
722            }
723            _setProfileOnModule(projectMember, project, module, moduleProfiles);
724        }
725    }
726    
727    private void _setProfileOnModule(JCRProjectMember member, Project project, WorkspaceModule module, Set<String> allowedProfiles)
728    {
729        if (module != null && _projectManager.isModuleActivated(project, module.getId()))
730        {
731            AmetysObject moduleObject = module.getModuleRoot(project, false);
732            _setMemberProfiles(member, allowedProfiles, moduleObject);
733        }
734    }
735    
736    private Set<String> _getAllowedProfile(JCRProjectMember member, AmetysObject object)
737    {
738        if (MemberType.GROUP == member.getType())
739        {
740            Map<GroupIdentity, Map<UserOrGroup, Set<String>>> profilesForGroups = _profileAssignmentStorageExtensionPoint.getProfilesForGroups(object, Set.of(member.getGroup()));
741            return Optional.ofNullable(profilesForGroups.get(member.getGroup())).map(a -> a.get(UserOrGroup.ALLOWED)).orElse(Set.of());
742        }
743        else
744        {
745            Map<UserIdentity, Map<UserOrGroup, Set<String>>> profilesForUsers = _profileAssignmentStorageExtensionPoint.getProfilesForUsers(object, member.getUser());
746            return Optional.ofNullable(profilesForUsers.get(member.getUser())).map(a -> a.get(UserOrGroup.ALLOWED)).orElse(Set.of());
747        }
748    }
749
750    private void _setMemberProfiles(JCRProjectMember member, Set<String> allowedProfiles, AmetysObject object)
751    {
752        Set<String> currentAllowedProfiles = _getAllowedProfile(member, object);
753        
754        Collection<String> profilesToRemove  = CollectionUtils.removeAll(currentAllowedProfiles, allowedProfiles);
755        
756        Collection<String> profilesToAdd  = CollectionUtils.removeAll(allowedProfiles, currentAllowedProfiles);
757        
758        for (String profileId : profilesToRemove)
759        {
760            _removeProfile(member, profileId, object);
761        }
762        
763        for (String profileId : profilesToAdd)
764        {
765            _addProfile(member, profileId, object);
766        }
767        
768        Collection<String> updatedProfiles = CollectionUtils.union(profilesToAdd, profilesToRemove);
769        
770        if (updatedProfiles.size() > 0)
771        {
772            _notifyAclUpdated(_currentUserProvider.getUser(), object, updatedProfiles);
773        }
774    }
775    
776    private void _removeProfile(JCRProjectMember member, String profileId, AmetysObject aclObject)
777    {
778        if (MemberType.GROUP == member.getType())
779        {
780            _profileAssignmentStorageExtensionPoint.removeAllowedProfileFromGroup(member.getGroup(), profileId, aclObject);
781        }
782        else
783        {
784            _profileAssignmentStorageExtensionPoint.removeAllowedProfileFromUser(member.getUser(), profileId, aclObject);
785        }
786    }
787    
788    private void _addProfile(JCRProjectMember member, String profileId, AmetysObject aclObject)
789    {
790        if (MemberType.GROUP == member.getType())
791        {
792            _profileAssignmentStorageExtensionPoint.allowProfileToGroup(member.getGroup(), profileId, aclObject);
793        }
794        else
795        {
796            _profileAssignmentStorageExtensionPoint.allowProfileToUser(member.getUser(), profileId, aclObject);
797        }
798    }
799
800    private void _removeMemberProfiles(JCRProjectMember member, AmetysObject object)
801    {
802        Set<String> currentAllowedProfiles = _getAllowedProfile(member, object);
803        
804        for (String allowedProfile : currentAllowedProfiles)
805        {
806            _removeProfile(member, allowedProfile, object);
807        }
808        
809        if (currentAllowedProfiles.size() > 0)
810        {
811            ((ModifiableAmetysObject) object).saveChanges();
812            
813            Map<String, Object> eventParams = new HashMap<>();
814            eventParams.put(org.ametys.core.ObservationConstants.ARGS_ACL_CONTEXT, object);
815            eventParams.put(org.ametys.core.ObservationConstants.ARGS_ACL_CONTEXT_IDENTIFIER, object.getId());
816            eventParams.put(org.ametys.core.ObservationConstants.ARGS_ACL_PROFILES, currentAllowedProfiles);
817            eventParams.put(org.ametys.cms.ObservationConstants.ARGS_ACL_SOLR_CACHE_UNINFLUENTIAL, true);
818            
819            _observationManager.notify(new Event(org.ametys.core.ObservationConstants.EVENT_ACL_UPDATED, _currentUserProvider.getUser(), eventParams));
820        }
821    }
822    
823    /**
824     * Get the current user information
825     * @return The user
826     */
827    @Callable
828    public Map<String, Object> getCurrentUser()
829    {
830        Map<String, Object> result = new HashMap<>();
831        result.put("user", _userHelper.user2json(_currentUserProvider.getUser()));
832        return result;
833    }
834    
835    /**
836     * Get the members of current project or all the members of all projects in where is no current project
837     * @return The members
838     */
839    @Callable
840    public Map<String, Object> getProjectMembers()
841    {
842        Map<String, Object> result = new HashMap<>();
843        
844        Request request = ContextHelper.getRequest(_context);
845        String projectName = (String) request.getAttribute("projectName");
846        
847        Collection<Project> projects = new ArrayList<>();
848        
849        if (StringUtils.isNotEmpty(projectName))
850        {
851            projects.add(_projectManager.getProject(projectName));
852        }
853        else
854        {
855            _projectManager.getProjects()
856                           .stream()
857                           .forEach(project -> projects.add(project));
858        }
859                                        
860        result.put("users", projects.stream()
861                                    .map(project -> getProjectMembers(project, true))
862                                    .flatMap(Set::stream)
863                                    .map(ProjectMember::getUser)
864                                    .distinct()
865                                    .map(user -> _userHelper.user2json(user, true))
866                                    .collect(Collectors.toList()));
867        
868        return result;
869    }
870    
871    /**
872     * Get the members of a project, sorted by managers, non empty role and name
873     * @param projectName the project's name
874     * @param lang the language to get user content
875     * @return the members of project
876     * @throws IllegalAccessException if an error occurred
877     * @throws AmetysRepositoryException if an error occurred
878     */
879    @Callable
880    public Map<String, Object> getProjectMembers(String projectName, String lang) throws IllegalAccessException, AmetysRepositoryException
881    {
882        return getProjectMembers(projectName, lang, false);
883    }
884    
885    /**
886     * Get the members of a project, sorted by managers, non empty role and name
887     * @param projectName the project's name
888     * @param lang the language to get user content
889     * @param expandGroup true if groups are expanded
890     * @return the members of project
891     * @throws AmetysRepositoryException if an error occurred
892     */
893    @Callable
894    public Map<String, Object> getProjectMembers(String projectName, String lang, boolean expandGroup) throws AmetysRepositoryException
895    {
896        Map<String, Object> result = new HashMap<>();
897        
898        Project project = _projectManager.getProject(projectName);
899        if (project == null)
900        {
901            result.put("message", "unknow-project");
902            result.put("success", false);
903            return result;
904        }
905        if (!_projectRightHelper.hasReadAccess(project))
906        {
907            throw new AccessDeniedException("User '" + _currentUserProvider.getUser() + "' tried to access a privilege feature without reader right in the project " + project.getPath());
908        }
909        
910        List<Map<String, Object>> membersData = new ArrayList<>();
911        
912        Set<ProjectMember> projectMembers = getProjectMembers(project, expandGroup);
913        
914        for (ProjectMember projectMember : projectMembers)
915        {
916            Map<String, Object> memberData = new HashMap<>();
917            
918            memberData.put("type", projectMember.getType().name().toLowerCase());
919            memberData.put("title", projectMember.getTitle());
920            memberData.put("sortabletitle", projectMember.getSortableTitle());
921            memberData.put("manager", projectMember.isManager());
922            
923            String role = projectMember.getRole();
924            if (StringUtils.isNotEmpty(role))
925            {
926                memberData.put("role", role);
927            }
928            
929            User user = projectMember.getUser();
930            if (user != null)
931            {
932                memberData.put("id", UserIdentity.userIdentityToString(user.getIdentity()));
933                memberData.putAll(_userHelper.user2json(user));
934
935                Content userContent = getUserContent(lang, user);
936                
937                if (userContent != null)
938                {
939                    if (userContent.hasValue("function"))
940                    {
941                        memberData.put("function", userContent.getValue("function"));
942                    }
943
944                    if (userContent.hasValue("organisation-accronym"))
945                    {
946                        memberData.put("organisationAcronym", userContent.getValue("organisation-accronym"));
947                    }
948                    String usersDirectorySiteName = _projectManager.getUsersDirectorySiteName();
949                    String[] contentTypes = userContent.getTypes();
950                    for (String contentType : contentTypes)
951                    {
952                        // Try to see if a user page exists for this content type
953                        UserPage userPage = _userDirectoryPageResolver.getUserPage(userContent, usersDirectorySiteName, lang, contentType);
954                        if (userPage != null)
955                        {
956                            memberData.put("link", _uriResolver.getResolverForType("page").resolve(userPage.getId(), false, true, false));
957                        }
958                    }
959                        
960                }
961                else if (getLogger().isDebugEnabled())
962                {
963                    getLogger().debug("User content not found for user : " + user);
964                }
965            }
966            
967            Group group = projectMember.getGroup();
968            if (group != null)
969            {
970                memberData.putAll(group2Json(group));
971            }
972            
973            membersData.add(memberData);
974        }
975        
976        result.put("members", membersData);
977        result.put("success", true);
978        
979        return result;
980    }
981
982    /**
983     * Get user content
984     * @param lang the lang
985     * @param user the user
986     * @return the user content or null if no exist
987     */
988    public Content getUserContent(String lang, User user)
989    {
990        Content userContent = _userDirectoryHelper.getUserContent(user.getIdentity(), lang);
991
992        if (userContent == null)
993        {
994            userContent = _userDirectoryHelper.getUserContent(user.getIdentity(), "en");
995        }
996        
997        if (userContent == null)
998        {
999            Map<String, Language> availableLanguages = _languagesManager.getAvailableLanguages();
1000            for (Language availableLanguage : availableLanguages.values())
1001            {
1002                if (userContent == null)
1003                {
1004                    userContent = _userDirectoryHelper.getUserContent(user.getIdentity(), availableLanguage.getCode());
1005                }
1006            }
1007        }
1008        return userContent;
1009    }
1010
1011    /**
1012     * Get the members of a project, sorted by managers, non empty role and name
1013     * @param project the project
1014     * @param expandGroup true to expand the user of a group
1015     * @return the members of project
1016     * @throws AmetysRepositoryException if an error occurred
1017     */
1018    public Set<ProjectMember> getProjectMembers(Project project, boolean expandGroup) throws AmetysRepositoryException
1019    {
1020        return getProjectMembers(project, expandGroup, Set.of());
1021    }
1022
1023    /**
1024     * Get the members of a project, sorted by managers, non empty role and name
1025     * @param project the project
1026     * @param expandGroup true to expand the user of a group
1027     * @param defaultSet default set to return when project has no site
1028     * @return the members of project
1029     * @throws AmetysRepositoryException if an error occurred
1030     */
1031    public Set<ProjectMember> getProjectMembers(Project project, boolean expandGroup, Set<ProjectMember> defaultSet) throws AmetysRepositoryException
1032    {
1033        Cache<ProjectMemberCacheKey, Set<ProjectMember>> cache = _getCache();
1034        
1035        ProjectMemberCacheKey cacheKey = ProjectMemberCacheKey.of(project.getId(), expandGroup);
1036        if (cache.hasKey(cacheKey))
1037        {
1038            Set<ProjectMember> projectMembers = cache.get(cacheKey);
1039            return projectMembers != null ? projectMembers : defaultSet;
1040        }
1041        else
1042        {
1043            Set<ProjectMember> projectMembers = _getProjectMembers(project, expandGroup);
1044            cache.put(cacheKey, projectMembers);
1045            return projectMembers != null ? projectMembers : defaultSet;
1046        }
1047    }
1048
1049    private Set<ProjectMember> _getProjectMembers(Project project, boolean expandGroup)
1050    {
1051        Comparator<ProjectMember> managerComparator = Comparator.comparing(m -> m.isManager() ? 0 : 1);
1052        Comparator<ProjectMember> roleComparator = Comparator.comparing(m -> StringUtils.isNotBlank(m.getRole()) ? 0 : 1);
1053        // Use sortable title for sort, and concatenate it with hash code of user, so that homonyms do not appear equals
1054        Comparator<ProjectMember> nameComparator = (m1, m2) -> (m1.getSortableTitle() + m1.hashCode()).compareToIgnoreCase(m2.getSortableTitle() + m2.hashCode());
1055        
1056        Set<ProjectMember> members = new TreeSet<>(managerComparator.thenComparing(roleComparator).thenComparing(nameComparator));
1057        
1058        Map<JCRProjectMember, Object> jcrMembers = _getJCRProjectMembers(project);
1059        List<UserIdentity> managers = Arrays.asList(project.getManagers());
1060        
1061        Site site = project.getSite();
1062        if (site == null)
1063        {
1064            getLogger().error("Can not compute members in the project " + project.getName() + " because it can not be linked to an existing site");
1065            return null;
1066        }
1067        String projectSiteName = site.getName();
1068
1069        Set<String> projectGroupDirectory = _groupDirectoryContextHelper.getGroupDirectoriesOnContext("/sites/" + projectSiteName);
1070        
1071        for (Entry<JCRProjectMember, Object> entry : jcrMembers.entrySet())
1072        {
1073            JCRProjectMember jcrMember = entry.getKey();
1074            if (MemberType.USER == jcrMember.getType())
1075            {
1076                User user = (User) entry.getValue();
1077                boolean isManager = managers.contains(jcrMember.getUser());
1078                
1079                ProjectMember projectMember = new ProjectMember(user.getFullName(), user.getSortableName(), user, jcrMember.getRole(), isManager);
1080                if (!members.add(projectMember) && _projectManager.isUserInProjectPopulations(project, user.getIdentity())) 
1081                {
1082                    //if set already contains the user, override it (users always take over users' group)
1083                    members.remove(projectMember); // remove the one in  the set
1084                    members.add(projectMember); // add the new one
1085                }
1086            }
1087            else if (MemberType.GROUP == jcrMember.getType())
1088            {
1089                Group group = (Group) entry.getValue();
1090                if (projectGroupDirectory.contains(group.getGroupDirectory().getId()))
1091                {
1092                    if (expandGroup)
1093                    {
1094                        for (UserIdentity userIdentity : group.getUsers())
1095                        {
1096                            User user = _userManager.getUser(userIdentity);
1097                            if (user != null && _projectManager.isUserInProjectPopulations(project, userIdentity))
1098                            {
1099                                ProjectMember projectMember = new ProjectMember(user.getFullName(), user.getSortableName(), user, null, false);
1100                                members.add(projectMember); // add if does not exist yet
1101                            }
1102                        }
1103                    }
1104                    else
1105                    {
1106                        // Add the member as group
1107                        members.add(new ProjectMember(group.getLabel(), group.getLabel(), group));
1108                    }
1109                }
1110            }
1111        }
1112        return members;
1113    }
1114    
1115    /**
1116     * Retrieves the rights for the current user in the project
1117     * @param projectName The project Name
1118     * @return The project
1119     */
1120    @Callable
1121    public Map<String, Object> getMemberModuleRights(String projectName)
1122    {
1123        Map<String, Object> results = new HashMap<>();
1124        Map<String, Object> rights = new HashMap<>();
1125        
1126        Project project = _projectManager.getProject(projectName);
1127        if (project == null)
1128        {
1129            results.put("message", "unknow-project");
1130            results.put("success", false);
1131        }
1132        else
1133        {
1134            rights.put("view", _projectRightHelper.canViewMembers(project));
1135            rights.put("add", _projectRightHelper.canAddMember(project));
1136            rights.put("edit", _projectRightHelper.canEditMember(project));
1137            rights.put("delete", _projectRightHelper.canRemoveMember(project));
1138            results.put("rights", rights);
1139            results.put("success", true);
1140        }
1141        
1142        return results;
1143    }
1144    
1145    /**
1146     * Get the list of users of the project
1147     * @param project The project
1148     * @return The list of users
1149     */
1150    protected  Map<JCRProjectMember, Object> _getJCRProjectMembers(Project project)
1151    {
1152        Map<JCRProjectMember, Object> projectMembers = new HashMap<>();
1153        
1154        if (project != null)
1155        {
1156            ModifiableTraversableAmetysObject membersNode = _getProjectMembersNode(project);
1157            
1158            for (AmetysObject memberNode : membersNode.getChildren())
1159            {
1160                if (memberNode instanceof JCRProjectMember)
1161                {
1162                    JCRProjectMember jCRProjectMember = (JCRProjectMember) memberNode;
1163                    if (jCRProjectMember.getType() == MemberType.USER)
1164                    {
1165                        UserIdentity userIdentity = jCRProjectMember.getUser();
1166                        User user = _userManager.getUser(userIdentity);
1167                        if (user != null)
1168                        {
1169                            projectMembers.put((JCRProjectMember) memberNode, user);
1170                        }
1171                    }
1172                    else
1173                    {
1174                        GroupIdentity groupIdentity = jCRProjectMember.getGroup();
1175                        Group group = _groupManager.getGroup(groupIdentity);
1176                        if (group != null)
1177                        {
1178                            projectMembers.put((JCRProjectMember) memberNode, group);
1179                        }
1180                    }
1181                }
1182            }
1183        }
1184        
1185        return projectMembers;
1186    }
1187    
1188    /**
1189     * Test if an user is a member of a project (directly or by a group)
1190     * @param project The project
1191     * @param userIdentity The user identity
1192     * @return True if this user is a member of this project
1193     */
1194    public boolean isProjectMember(Project project, UserIdentity userIdentity)
1195    {
1196        return getProjectMember(project, userIdentity) != null;
1197    }
1198    
1199    /**
1200     * Retrieve the member of a project corresponding to the user identity
1201     * @param project The project
1202     * @param userIdentity The user identity
1203     * @return The member of this project, which can be of type "user" or "group", or null if the user is not in the project
1204     */
1205    public ProjectMember getProjectMember(Project project, UserIdentity userIdentity)
1206    {
1207        return getProjectMember(project, userIdentity, null);
1208    }
1209    
1210    /**
1211     * Retrieve the member of a project corresponding to the user identity
1212     * @param project The project
1213     * @param userIdentity The user identity
1214     * @param userGroups The user groups. If null the user's groups will be expanded.
1215     * @return The member of this project, which can be of type "user" or "group", or null if the user is not in the project
1216     */
1217    public ProjectMember getProjectMember(Project project, UserIdentity userIdentity, Set<GroupIdentity> userGroups)
1218    {
1219        if (userIdentity == null)
1220        {
1221            return null;
1222        }
1223                
1224        Set<ProjectMember> members = getProjectMembers(project, true);
1225        
1226        ProjectMember projectMember = members.stream()
1227                .filter(member -> MemberType.USER == member.getType())
1228                .filter(member -> userIdentity.equals(member.getUser().getIdentity()))
1229                .findFirst()
1230                .orElse(null);
1231        
1232        if (projectMember != null)
1233        {
1234            return projectMember;
1235        }
1236        
1237        Set<GroupIdentity> groups = userGroups == null ? _groupManager.getUserGroups(userIdentity) : userGroups; // get user's groups
1238
1239        if (!groups.isEmpty())
1240        {
1241            return members.stream()
1242                    .filter(member -> MemberType.GROUP == member.getType())
1243                    .filter(member -> groups.contains(member.getGroup().getIdentity()))
1244                    .findFirst()
1245                    .orElse(null);
1246        }
1247        
1248        return null;
1249    }
1250    
1251    /**
1252     * Set the manager of a project
1253     * @param projectName The project name
1254     * @param profileId The profile id to affect
1255     * @param managers The managers' user identity
1256     */
1257    public void setProjectManager(String projectName, String profileId, List<UserIdentity> managers)
1258    {
1259        Project project = _projectManager.getProject(projectName);
1260        if (project == null)
1261        {
1262            return;
1263        }
1264        
1265        project.setManagers(managers.toArray(new UserIdentity[managers.size()]));
1266        
1267        for (UserIdentity userIdentity : managers)
1268        {
1269            JCRProjectMember member = _getOrCreateJCRProjectMember(project, userIdentity);
1270            
1271            Set<String> allowedProfiles = Set.of(profileId);
1272            for (WorkspaceModule module : _projectManager.getModules(project))
1273            {
1274                _setProfileOnModule(member, project, module, allowedProfiles);        
1275            }
1276        }
1277        
1278        project.saveChanges();
1279        
1280        _getCache().invalidate(ProjectMemberCacheKey.of(project.getId(), null));
1281        
1282        // Clear rights manager cache (if I remove my own rights)
1283        _rightManager.clearCaches();
1284        
1285//        Request request = ContextHelper.getRequest(_context);
1286//        if (request != null)
1287//        {
1288//            request.removeAttribute(RightManager.CACHE_REQUEST_ATTRIBUTE_NAME);
1289//        }
1290    }
1291    
1292    private void _notifyAclUpdated(UserIdentity userIdentity, AmetysObject aclContext, Collection<String> aclProfiles)
1293    {
1294        Map<String, Object> eventParams = new HashMap<>();
1295        eventParams.put(org.ametys.core.ObservationConstants.ARGS_ACL_CONTEXT, aclContext);
1296        eventParams.put(org.ametys.core.ObservationConstants.ARGS_ACL_CONTEXT_IDENTIFIER, aclContext.getId());
1297        eventParams.put(org.ametys.core.ObservationConstants.ARGS_ACL_PROFILES, aclProfiles);
1298        eventParams.put(org.ametys.cms.ObservationConstants.ARGS_ACL_SOLR_CACHE_UNINFLUENTIAL, true);
1299        
1300        _observationManager.notify(new Event(org.ametys.core.ObservationConstants.EVENT_ACL_UPDATED, userIdentity, eventParams));
1301    }
1302
1303    /**
1304     * Retrieve or create a user in a project
1305     * @param project The project
1306     * @param userIdentity the user
1307     * @return The user
1308     */
1309    private JCRProjectMember _getOrCreateJCRProjectMember(Project project, UserIdentity userIdentity)
1310    {
1311        Predicate<? super AmetysObject> findMemberPredicate = memberNode -> MemberType.USER == ((JCRProjectMember) memberNode).getType() 
1312                && userIdentity.equals(((JCRProjectMember) memberNode).getUser());
1313        JCRProjectMember projectMember = _getOrCreateJCRProjectMember(project, findMemberPredicate);
1314        
1315        if (projectMember.needsSave())
1316        {
1317            projectMember.setUser(userIdentity);
1318            projectMember.setType(MemberType.USER);
1319        }
1320        
1321        return projectMember; 
1322    }
1323
1324    /**
1325     * Retrieve or create a group as a member in a project
1326     * @param project The project
1327     * @param groupIdentity the group
1328     * @return The user
1329     */
1330    private JCRProjectMember _getOrCreateJCRProjectMember(Project project, GroupIdentity groupIdentity)
1331    {
1332        Predicate<? super AmetysObject> findMemberPredicate = memberNode -> MemberType.GROUP == ((JCRProjectMember) memberNode).getType() 
1333                && groupIdentity.equals(((JCRProjectMember) memberNode).getGroup());
1334        JCRProjectMember projectMember = _getOrCreateJCRProjectMember(project, findMemberPredicate);
1335        
1336        if (projectMember.needsSave())
1337        {
1338            projectMember.setGroup(groupIdentity);
1339            projectMember.setType(MemberType.GROUP);
1340        }
1341        
1342        return projectMember; 
1343    }
1344    
1345    /**
1346     * Retrieve or create a member in a project
1347     * @param project The project
1348     * @param findMemberPredicate The predicate to find the member node
1349     * @return The member node. A new node is created if the member node was not found
1350     */
1351    protected JCRProjectMember _getOrCreateJCRProjectMember(Project project, Predicate<? super AmetysObject> findMemberPredicate)
1352    {
1353        ModifiableTraversableAmetysObject membersNode = _getProjectMembersNode(project);
1354        
1355        Optional<AmetysObject> member = _getProjectMembersNode(project).getChildren()
1356                                                                       .stream()
1357                                                                       .filter(memberNode -> memberNode instanceof JCRProjectMember)
1358                                                                       .filter(findMemberPredicate)
1359                                                                       .findFirst();
1360        if (member.isPresent())
1361        {
1362            return (JCRProjectMember) member.get();
1363        }
1364        
1365        String baseName = "member";
1366        String name = baseName;
1367        int index = 1;
1368        while (membersNode.hasChild(name))
1369        {
1370            index++;
1371            name = baseName + "-" + index;
1372        }
1373        
1374        JCRProjectMember jcrProjectMember = membersNode.createChild(name, __PROJECT_MEMBER_NODE_TYPE);
1375        
1376        // we invalidate the cache has we had to create a new user
1377        _getCache().invalidate(ProjectMemberCacheKey.of(project.getId(), null));
1378        
1379        return jcrProjectMember;
1380    }
1381
1382    
1383    /**
1384     * Remove a user from a project
1385     * @param projectName The project name
1386     * @param identity The identity of the user or group, who must be a member of the project 
1387     * @param type The type of the member, user or group
1388     * @return The error code, if an error occurred
1389     */
1390    @Callable
1391    public Map<String, Object> removeMember(String projectName, String identity, String type)
1392    {
1393        Map<String, Object> result = new HashMap<>();
1394        
1395        MemberType memberType = MemberType.valueOf(type.toUpperCase());
1396        boolean isTypeUser = MemberType.USER == memberType;
1397        boolean isTypeGroup = MemberType.GROUP == memberType;
1398        UserIdentity user = Optional.ofNullable(identity)
1399                                    .filter(id -> id != null && isTypeUser)
1400                                    .map(UserIdentity::stringToUserIdentity)
1401                                    .orElse(null);
1402        GroupIdentity group = Optional.ofNullable(identity)
1403                                      .filter(id -> id != null && isTypeGroup)
1404                                      .map(GroupIdentity::stringToGroupIdentity)
1405                                      .orElse(null);
1406        
1407        if (isTypeGroup && group == null
1408            || isTypeUser && user == null)
1409        {
1410            result.put("success", false);
1411            result.put("message", isTypeGroup ? "unknow-group" : "unknow-user");
1412            return result;
1413        }
1414        
1415        Project project = _projectManager.getProject(projectName);
1416        if (project == null)
1417        {
1418            result.put("success", false);
1419            result.put("message", "unknow-project");
1420            return result;
1421        }
1422        
1423        if (_isCurrentUser(isTypeUser, user))
1424        {
1425            result.put("success", false);
1426            result.put("message", "current-user");
1427            return result;
1428        }
1429        
1430        // If there is only one manager, do not remove him from the project's members
1431        if (_isOnlyManager(project, isTypeUser, user))
1432        {
1433            result.put("success", false);
1434            result.put("message", "only-manager");
1435            return result;
1436        }
1437        
1438        if (!_projectRightHelper.canRemoveMember(project))
1439        {
1440            throw new AccessDeniedException("User '" + _currentUserProvider.getUser() + "' tried to remove member without convenient right [" + projectName + ", " + identity + "]");
1441        }
1442        
1443        JCRProjectMember projectMember = null;
1444        if (isTypeUser)
1445        {
1446            projectMember = _getProjectMember(project, user);
1447        }
1448        else if (isTypeGroup)
1449        {
1450            projectMember = _getProjectMember(project, group);
1451        }
1452        
1453        if (projectMember == null)
1454        {
1455            result.put("success", false);
1456            result.put("message", "unknow-member");
1457            return result;
1458        }
1459        
1460        _removeManager(project, isTypeUser, user);
1461        _removeMemberProfiles(project, projectMember);
1462
1463        projectMember.remove();
1464        project.saveChanges();
1465        
1466        _getCache().invalidate(ProjectMemberCacheKey.of(project.getId(), null));
1467        
1468        Map<String, Object> eventParams = new HashMap<>();
1469        eventParams.put(ObservationConstants.ARGS_MEMBER_IDENTITY, identity);
1470        eventParams.put(ObservationConstants.ARGS_MEMBER_IDENTITY_TYPE, memberType);
1471        eventParams.put(ObservationConstants.ARGS_PROJECT, project);
1472        eventParams.put(ObservationConstants.ARGS_PROJECT_ID, project.getId());
1473        _observationManager.notify(new Event(ObservationConstants.EVENT_MEMBER_DELETED, _currentUserProvider.getUser(), eventParams));
1474        
1475        result.put("success", true);
1476        return result;
1477    }
1478
1479    private boolean _isCurrentUser(boolean isTypeUser, UserIdentity user)
1480    {
1481        return isTypeUser && _currentUserProvider.getUser().equals(user);
1482    }
1483
1484    private boolean _isOnlyManager(Project project, boolean isTypeUser, UserIdentity user)
1485    {
1486        UserIdentity[] managers = project.getManagers();
1487        return isTypeUser && managers.length == 1 && managers[0].equals(user);
1488    }
1489
1490    private JCRProjectMember _getProjectMember(Project project, GroupIdentity group)
1491    {
1492        JCRProjectMember projectMember = null;
1493        ModifiableTraversableAmetysObject membersNode = _getProjectMembersNode(project);
1494        
1495        for (AmetysObject memberNode : membersNode.getChildren())
1496        {
1497            if (memberNode instanceof JCRProjectMember)
1498            {
1499                JCRProjectMember member = (JCRProjectMember) memberNode;
1500                if (MemberType.GROUP == member.getType() && group.equals(member.getGroup()))
1501                {
1502                    projectMember = (JCRProjectMember) memberNode;
1503                }
1504
1505            }
1506        }
1507        return projectMember;
1508    }
1509
1510    private JCRProjectMember _getProjectMember(Project project, UserIdentity user)
1511    {
1512        JCRProjectMember projectMember = null;
1513        ModifiableTraversableAmetysObject membersNode = _getProjectMembersNode(project);
1514        
1515        for (AmetysObject memberNode : membersNode.getChildren())
1516        {
1517            if (memberNode instanceof JCRProjectMember)
1518            {
1519                JCRProjectMember member = (JCRProjectMember) memberNode;
1520                if (MemberType.USER == member.getType() && user.equals(member.getUser()))
1521                {
1522                    projectMember = (JCRProjectMember) memberNode;
1523                }
1524            }
1525        }
1526        return projectMember;
1527    }
1528    
1529    private void _removeManager(Project project, boolean isTypeUser, UserIdentity user)
1530    {
1531        if (isTypeUser)
1532        {
1533            UserIdentity[] oldManagers = project.getManagers();
1534            
1535            // Remove the user from the project's managers
1536            UserIdentity[] managers = Arrays.stream(oldManagers)
1537                  .filter(manager -> !manager.equals(user))
1538                  .toArray(UserIdentity[]::new);
1539            
1540            project.setManagers(managers);
1541        }
1542    }
1543
1544    private void _removeMemberProfiles(Project project, JCRProjectMember projectMember)
1545    {
1546        for (WorkspaceModule module : _projectManager.getModules(project))
1547        {
1548            ModifiableResourceCollection moduleRootNode = module.getModuleRoot(project, false);
1549            _removeMemberProfiles(projectMember, moduleRootNode);
1550        }
1551    }
1552    
1553    /**
1554     * Retrieves the users node of the project
1555     * The users node will be created if necessary
1556     * @param project The project
1557     * @return The users node of the project
1558     */
1559    protected ModifiableTraversableAmetysObject _getProjectMembersNode(Project project)
1560    {
1561        if (project == null)
1562        {
1563            throw new AmetysRepositoryException("Error getting the project users node, project is null");
1564        }
1565        
1566        try
1567        {
1568            ModifiableTraversableAmetysObject membersNode;
1569            if (project.hasChild(__PROJECT_MEMBERS_NODE))
1570            {
1571                membersNode = project.getChild(__PROJECT_MEMBERS_NODE);
1572            }
1573            else
1574            {
1575                membersNode = project.createChild(__PROJECT_MEMBERS_NODE, __PROJECT_MEMBERS_NODE_TYPE);
1576            }
1577            
1578            return membersNode;
1579        }
1580        catch (AmetysRepositoryException e)
1581        {
1582            throw new AmetysRepositoryException("Error getting the project users node", e);
1583        }
1584    }
1585    
1586    /**
1587     * Get the JSON representation of a group
1588     * @param group The group
1589     * @return The group
1590     */
1591    protected Map<String, Object> group2Json(Group group)
1592    {
1593        Map<String, Object> infos = new HashMap<>();
1594        infos.put("id", GroupIdentity.groupIdentityToString(group.getIdentity()));
1595        infos.put("groupId", group.getIdentity().getId());
1596        infos.put("label", group.getLabel());
1597        infos.put("sortablename", group.getLabel());
1598        infos.put("groupDirectory", group.getIdentity().getDirectoryId());
1599        return infos;
1600    }
1601
1602    /**
1603     * Count the total of unique users in the project and in the project's group
1604     * @param project The project
1605     * @return The total of members
1606     */
1607    public Long getMembersCount(Project project)
1608    {
1609        Set<ProjectMember> projectMembers = getProjectMembers(project, true);
1610                
1611        return (long) projectMembers.size();
1612    }
1613
1614    /**
1615     * Get the users from a group that are part of the project. They can be filtered with a predicate
1616     * @param group The group
1617     * @param project The project
1618     * @param filteringPredicate The predicate to filter
1619     * @return The list of users
1620     */
1621    public List<User> getGroupUsersFromProject(Group group, Project project, BiPredicate<Project, UserIdentity> filteringPredicate)
1622    {
1623        Set<String> projectPopulations = _populationContextHelper.getUserPopulationsOnContexts(List.of("/sites/" + project.getSite().getName()), false, false);
1624        
1625        return group.getUsers().stream()
1626                .filter(user -> projectPopulations.contains(user.getPopulationId()))
1627                .filter(user -> filteringPredicate.test(project, user))
1628                .map(_userManager::getUser)
1629                .filter(Objects::nonNull)
1630                .collect(Collectors.toList());
1631    }
1632    
1633    private Cache<ProjectMemberCacheKey, Set<ProjectMember>> _getCache()
1634    {
1635        return _abstractCacheManager.get(__PROJECT_MEMBER_CACHE);
1636    }
1637    
1638    /**
1639     * This class represents a member of a project. Could be a user or a group
1640     *
1641     */
1642    public static class ProjectMember
1643    {
1644        private String _title;
1645        private String _sortableTitle;
1646        private MemberType _type;
1647        private String _role;
1648        private User _user;
1649        private Group _group;
1650        private boolean _isManager;
1651        
1652        /**
1653         * Create a project member as a group
1654         * @param title the member's title (user's full name or group's label)
1655         * @param sortableTitle the sortable title
1656         * @param group the group attached to this member. Cannot be null.
1657         */
1658        public ProjectMember(String title, String sortableTitle, Group group)
1659        {
1660            _title = title;
1661            _sortableTitle = sortableTitle;
1662            _type = MemberType.GROUP;
1663            _role = null;
1664            _isManager = false;
1665            _user = null;
1666            _group = group;
1667        }
1668        
1669        /**
1670         * Create a project member as a group
1671         * @param title the member's title (user's full name or group's label)
1672         * @param sortableTitle the sortable title
1673         * @param role the role
1674         * @param isManager true if the member is a manager of the project
1675         * @param user the user attached to this member. Cannot be null.
1676         */
1677        public ProjectMember(String title, String sortableTitle, User user, String role, boolean isManager)
1678        {
1679            _title = title;
1680            _sortableTitle = sortableTitle;
1681            _type = MemberType.USER;
1682            _role = role;
1683            _isManager = isManager;
1684            _user = user;
1685            _group = null;
1686        }
1687        
1688        /**
1689         * Get the title of the member.
1690         * @return The title of the member
1691         */
1692        public String getTitle()
1693        {
1694            return _title;
1695        }
1696        
1697        /**
1698         * Get the sortable title of the member.
1699         * @return The sortable title of the member
1700         */
1701        public String getSortableTitle()
1702        {
1703            return _sortableTitle;
1704        }
1705
1706        /**
1707         * Get the type of the member. It can be a user or a group
1708         * @return The type of the member
1709         */
1710        public MemberType getType()
1711        {
1712            return _type;
1713        }
1714
1715        /**
1716         * Get the role of the member.
1717         * @return The role of the member
1718         */
1719        public String getRole()
1720        {
1721            return _role;
1722        }
1723
1724        /**
1725         * Test if the member is a manager of the project
1726         * @return True if this user is a manager of the project
1727         */
1728        public boolean isManager()
1729        {
1730            return _isManager;
1731        }
1732        
1733        /**
1734         * Get the user of the member.
1735         * @return The user of the member
1736         */
1737        public User getUser()
1738        {
1739            return _user;
1740        }
1741        
1742        /**
1743         * Get the group of the member.
1744         * @return The group of the member
1745         */
1746        public Group getGroup()
1747        {
1748            return _group;
1749        }
1750        
1751        @Override
1752        public boolean equals(Object obj)
1753        {
1754            if (obj == null || !(obj instanceof ProjectMember))
1755            {
1756                return false;
1757            }
1758            
1759            ProjectMember otherMember = (ProjectMember) obj;
1760            
1761            if (getType() != otherMember.getType())
1762            {
1763                return false;
1764            }
1765            
1766            if (getType() == MemberType.USER)
1767            {
1768                return getUser().equals(otherMember.getUser());
1769            }
1770            else
1771            {
1772                return getGroup().equals(otherMember.getGroup());
1773            }
1774        }
1775        
1776        @Override
1777        public int hashCode()
1778        {
1779            return getType() == MemberType.USER ? getUser().getIdentity().hashCode() : getGroup().getIdentity().hashCode();
1780        }
1781    }
1782    
1783    private static final class ProjectMemberCacheKey extends AbstractCacheKey
1784    {
1785        public ProjectMemberCacheKey(String projectId, Boolean extendGroup)
1786        {
1787            super(projectId, extendGroup);
1788        }
1789        
1790        public static ProjectMemberCacheKey of(String projectId, Boolean withExpandedGroup)
1791        {
1792            return new ProjectMemberCacheKey(projectId, withExpandedGroup);
1793        }
1794    }
1795}