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.Date;
020import java.util.GregorianCalendar;
021import java.util.HashMap;
022import java.util.List;
023import java.util.Map;
024import java.util.Map.Entry;
025import java.util.Set;
026
027import javax.jcr.Node;
028import javax.jcr.NodeIterator;
029import javax.jcr.PathNotFoundException;
030import javax.jcr.Repository;
031import javax.jcr.RepositoryException;
032import javax.jcr.Session;
033import javax.jcr.Value;
034import javax.jcr.ValueFormatException;
035import javax.jcr.query.Query;
036
037import org.apache.avalon.framework.service.ServiceException;
038import org.apache.avalon.framework.service.ServiceManager;
039import org.apache.commons.lang3.StringUtils;
040
041import org.ametys.core.user.UserIdentity;
042import org.ametys.plugins.explorer.resources.ModifiableResourceCollection;
043import org.ametys.plugins.explorer.resources.jcr.JCRResourcesCollection;
044import org.ametys.plugins.repository.AmetysRepositoryException;
045import org.ametys.plugins.repository.RepositoryConstants;
046import org.ametys.plugins.repository.data.holder.ModifiableModelAwareDataHolder;
047import org.ametys.plugins.repository.jcr.NodeTypeHelper;
048import org.ametys.plugins.repository.provider.AbstractRepository;
049import org.ametys.plugins.repository.query.SortCriteria;
050import org.ametys.plugins.repository.query.expression.Expression;
051import org.ametys.plugins.repository.query.expression.Expression.Operator;
052import org.ametys.plugins.repository.query.expression.StringExpression;
053import org.ametys.plugins.workspaces.AbstractWorkspaceModule;
054import org.ametys.plugins.workspaces.ObservationConstants;
055import org.ametys.plugins.workspaces.members.JCRProjectMember.MemberType;
056import org.ametys.plugins.workspaces.members.ProjectMemberManager.ProjectMember;
057import org.ametys.plugins.workspaces.project.modules.WorkspaceModule;
058import org.ametys.plugins.workspaces.project.modules.WorkspaceModuleExtensionPoint;
059import org.ametys.plugins.workspaces.project.objects.Project;
060import org.ametys.plugins.workspaces.util.StatisticColumn;
061import org.ametys.plugins.workspaces.util.StatisticsColumnType;
062import org.ametys.runtime.i18n.I18nizableText;
063import org.ametys.web.repository.page.ModifiablePage;
064import org.ametys.web.repository.page.ModifiableZone;
065import org.ametys.web.repository.page.ModifiableZoneItem;
066import org.ametys.web.repository.page.ZoneItem.ZoneType;
067
068import com.google.common.collect.ImmutableSet;
069
070/**
071 * Helper component for managing members
072 */
073public class MembersWorkspaceModule extends AbstractWorkspaceModule
074{
075    /** The id of members module */
076    public static final String MEMBERS_MODULE_ID = MembersWorkspaceModule.class.getName();
077    
078    /** Id of service of members */
079    public static final String MEMBERS_SERVICE_ID = "org.ametys.plugins.workspaces.module.Members";
080    
081    /** Workspaces members node name */
082    private static final String __WORKSPACES_MEMBERS_NODE_NAME = "members";
083    
084    private static final String __INVITATIONS_NODE_NAME = RepositoryConstants.NAMESPACE_PREFIX_INTERNAL + ":invitations";
085    
086    private static final String __INVITATION_NODE_NAME = RepositoryConstants.NAMESPACE_PREFIX + ":invitation";
087    
088    private static final String __NODETYPE_INVITATIONS = RepositoryConstants.NAMESPACE_PREFIX + ":invitations";
089    
090    private static final String __NODETYPE_INVITATION = RepositoryConstants.NAMESPACE_PREFIX + ":invitation";
091
092    private static final String __MEMBER_NUMBER_HEADER_ID = __WORKSPACES_MEMBERS_NODE_NAME + "$member_number";
093    
094    /** Constants for invitation's date property */
095    private static final String __INVITATION_DATE = RepositoryConstants.NAMESPACE_PREFIX + ":date";
096    /** Constants for invitation's mail property */
097    private static final String __INVITATION_MAIL = RepositoryConstants.NAMESPACE_PREFIX + ":mail";
098    /** Constants for invitation's author property */
099    private static final String __INVITATION_AUTHOR = RepositoryConstants.NAMESPACE_PREFIX + ":author";
100    /** Constants for invitation's acl node */
101    private static final String __INVITATION_ACL = RepositoryConstants.NAMESPACE_PREFIX_INTERNAL + ":temp-acl";
102
103    private WorkspaceModuleExtensionPoint _moduleEP;
104    
105    private Repository _repository;
106    
107    private ProjectMemberManager _projectMemberManager;
108    
109    @Override
110    public void service(ServiceManager smanager) throws ServiceException
111    {
112        super.service(smanager);
113        _moduleEP = (WorkspaceModuleExtensionPoint) smanager.lookup(WorkspaceModuleExtensionPoint.ROLE);
114        _repository = (Repository) smanager.lookup(AbstractRepository.ROLE);
115        _projectMemberManager = (ProjectMemberManager) smanager.lookup(ProjectMemberManager.ROLE);
116    }
117    
118    @Override
119    public String getId()
120    {
121        return MEMBERS_MODULE_ID;
122    }
123    
124    public int getOrder()
125    {
126        return ORDER_MEMBERS;
127    }
128    
129    public String getModuleName()
130    {
131        return __WORKSPACES_MEMBERS_NODE_NAME;
132    }
133    
134    @Override
135    protected String getModulePageName()
136    {
137        return "members";
138    }
139    
140    public I18nizableText getModuleTitle()
141    {
142        return new I18nizableText("plugin." + _pluginName, "PLUGINS_WORKSPACES_PROJECT_SERVICE_MODULE_MEMBERS_LABEL");
143    }
144    public I18nizableText getModuleDescription()
145    {
146        return new I18nizableText("plugin." + _pluginName, "PLUGINS_WORKSPACES_PROJECT_SERVICE_MODULE_MEMBERS_DESCRIPTION");
147    }
148    @Override
149    protected I18nizableText getModulePageTitle()
150    {
151        return new I18nizableText("plugin." + _pluginName, "PLUGINS_WORKSPACES_PROJECT_WORKSPACE_PAGE_MEMBERS_TITLE");
152    }
153    
154    @Override
155    protected void initializeModulePage(ModifiablePage memberPage)
156    {
157        ModifiableZone defaultZone = memberPage.createZone("default");
158        
159        ModifiableZoneItem defaultZoneItem = defaultZone.addZoneItem();
160        defaultZoneItem.setType(ZoneType.SERVICE);
161        defaultZoneItem.setServiceId(MEMBERS_SERVICE_ID);
162        
163        ModifiableModelAwareDataHolder params = defaultZoneItem.getServiceParameters();
164        
165        params.setValue("header", _i18nUtils.translate(getModulePageTitle(), memberPage.getSitemapName()));
166        // params.setValue("expandGroup", false); FIXME new service
167        // params.setValue("nbMembers", -1); FIXME new service
168        params.setValue("xslt", _getDefaultXslt(MEMBERS_SERVICE_ID));
169    }
170
171    @Override
172    public Set<String> getAllowedEventTypes()
173    {
174        return ImmutableSet.of(ObservationConstants.EVENT_MEMBER_ADDED,
175                ObservationConstants.EVENT_MEMBER_JOINED,
176                ObservationConstants.EVENT_MEMBER_UPDATED);
177    }
178    
179    // -------------------------------------------------
180    //                  INVITATIONS
181    // -------------------------------------------------
182    
183    /**
184     * Get the node holding the invitations
185     * @param project The project
186     * @param create true to create the node if it does not exist
187     * @return the invitations' root node
188     * @throws RepositoryException if an error occurred
189     */
190    public Node getInvitationsRootNode(Project project, boolean create) throws RepositoryException
191    {
192        ModifiableResourceCollection moduleRoot = getModuleRoot(project, create);
193        
194        Node moduleNode = ((JCRResourcesCollection) moduleRoot).getNode();
195        Node node = null;
196        
197        if (moduleNode.hasNode(__INVITATIONS_NODE_NAME))
198        {
199            node = moduleNode.getNode(__INVITATIONS_NODE_NAME);
200        }
201        else if (create)
202        {
203            node = moduleNode.addNode(__INVITATIONS_NODE_NAME, __NODETYPE_INVITATIONS);
204            moduleNode.getSession().save();
205        }
206        
207        return node;
208    }
209    
210    /**
211     * Get the invitations for a given mail
212     * @param email the email
213     * @return the invitations
214     */
215    public List<Invitation> getInvitations(String email)
216    {
217        Session session = null;
218        try
219        {
220            session = _repository.login();
221            
222            Expression expr = new StringExpression("mail", Operator.EQ, email);
223            String xPathQuery = getInvitationXPathQuery(null, expr, null);
224            
225            @SuppressWarnings("deprecation")
226            Query query = session.getWorkspace().getQueryManager().createQuery(xPathQuery, Query.XPATH);
227            NodeIterator nodes = query.execute().getNodes();
228     
229            List<Invitation> invitations = new ArrayList<>();
230            
231            while (nodes.hasNext())
232            {
233                Node node = (Node) nodes.next();
234                invitations.add(_getInvitation(node));
235            }
236            
237            return invitations;
238        }
239        catch (RepositoryException ex)
240        {
241            if (session != null)
242            {
243                session.logout();
244            }
245
246            throw new AmetysRepositoryException("An error occurred executing the JCR query to get invitations for email " + email, ex);
247        }
248    }
249    
250    /**
251     * Returns the invitation sorted by ascending date
252     * @param project The project
253     * @return The invitations node
254     * @throws RepositoryException if an error occurred
255     */
256    public List<Invitation> getInvitations(Project project) throws RepositoryException
257    {
258        List<Invitation> invitations = new ArrayList<>();
259        
260        SortCriteria sortCriteria = new SortCriteria();
261        sortCriteria.addJCRPropertyCriterion(__INVITATION_DATE, false, false);
262        
263        String xPathQuery = getInvitationXPathQuery(project, null, sortCriteria);
264        
265        @SuppressWarnings("deprecation")
266        Query query = project.getNode().getSession().getWorkspace().getQueryManager().createQuery(xPathQuery, Query.XPATH);
267        NodeIterator nodes = query.execute().getNodes();
268        
269        while (nodes.hasNext())
270        {
271            Node node = (Node) nodes.next();
272            invitations.add(_getInvitation(node));
273        }
274        
275        return invitations;
276    }
277    
278    private Invitation _getInvitation(Node node) throws ValueFormatException, PathNotFoundException, RepositoryException
279    {
280        String email = node.getProperty(__INVITATION_MAIL).getString();
281        Date date = node.getProperty(__INVITATION_DATE).getDate().getTime();
282        Node authorNode = node.getNode(__INVITATION_AUTHOR);
283        UserIdentity author = new UserIdentity(authorNode.getProperty(RepositoryConstants.NAMESPACE_PREFIX + ":login").getString(), authorNode.getProperty(RepositoryConstants.NAMESPACE_PREFIX + ":population").getString());
284        
285        Map<String, String> allowedProfileByModules = new HashMap<>();
286        
287        Node aclNode = node.getNode(RepositoryConstants.NAMESPACE_PREFIX_INTERNAL + ":temp-acl");
288        NodeIterator children = aclNode.getNodes();
289        while (children.hasNext())
290        {
291            Node child = (Node) children.next();
292            if (child.hasProperty(RepositoryConstants.NAMESPACE_PREFIX + ":allowed-profiles"))
293            {
294                Value[] values = child.getProperty(RepositoryConstants.NAMESPACE_PREFIX + ":allowed-profiles").getValues();
295                if (values.length > 0)
296                {
297                    String moduleName = child.getName();
298                    WorkspaceModule module = _moduleEP.getModuleByName(moduleName);
299                    if (module != null)
300                    {
301                        allowedProfileByModules.put(module.getId(), values[0].getString());
302                    }
303                }
304            }
305        }
306        
307        return new Invitation(email, date, author, allowedProfileByModules, _getProjectName(node));
308    }
309    
310    private String _getProjectName(Node node)
311    {
312        try
313        {
314            Node parentNode = node.getParent();
315            
316            while (parentNode != null && !NodeTypeHelper.isNodeType(parentNode, "ametys:project"))
317            {
318                parentNode = parentNode.getParent();
319            }
320            
321            return parentNode != null ? parentNode.getName() : null;
322        }
323        catch (RepositoryException e)
324        {
325            getLogger().error("Unable to get parent project", e);
326            return null;
327        }
328    }
329    
330    /**
331     * Creates the XPath query corresponding to specified {@link Expression}.
332     * @param project The project. Can be null to browser all projects
333     * @param invitExpression the query predicates.
334     * @param sortCriteria the sort criteria.
335     * @return the created XPath query.
336     * @throws RepositoryException if an error occurred
337     */
338    public String getInvitationXPathQuery(Project project, Expression invitExpression, SortCriteria sortCriteria) throws RepositoryException
339    {
340        String predicats = null;
341        
342        if (invitExpression != null)
343        {
344            predicats = StringUtils.trimToNull(invitExpression.build());
345        }
346        
347        StringBuilder xpathQuery = new StringBuilder();
348        
349        if (project != null)
350        {
351            xpathQuery.append("/jcr:root")
352                .append(getInvitationsRootNode(project, true).getPath());
353        }
354        
355        xpathQuery.append("//element(*, " + __NODETYPE_INVITATION + ")");
356        
357        if (predicats != null)
358        {
359            xpathQuery.append("[" + predicats + "]");
360        }
361        
362        if (sortCriteria != null)
363        {
364            xpathQuery.append(" " + sortCriteria.build());
365        }
366        
367        return xpathQuery.toString();
368    }
369    
370    /**
371     * Add an invitation
372     * @param project The project
373     * @param mail The mail
374     * @param invitDate The invitation's date
375     * @param author The invitation's author
376     * @param allowedProfileByModules The allowed profiles by modules
377     * @return The created invitation node
378     * @throws RepositoryException if an error occurred
379     */
380    public Invitation addInvitation (Project project, Date invitDate, String mail, UserIdentity author, Map<String, String> allowedProfileByModules) throws RepositoryException
381    {
382        Node contextNode = getInvitationsRootNode(project, true);
383        
384        Node invitNode = contextNode.addNode(__INVITATION_NODE_NAME, __NODETYPE_INVITATION);
385        
386        // Date
387        GregorianCalendar gc = new GregorianCalendar();
388        gc.setTime(invitDate);
389        
390        invitNode.setProperty(__INVITATION_DATE, gc);
391        
392        // Mail
393        invitNode.setProperty(__INVITATION_MAIL, mail);
394        
395        // Author
396        Node authorNode = invitNode.addNode(__INVITATION_AUTHOR);
397        authorNode.setProperty(RepositoryConstants.NAMESPACE_PREFIX + ":login", author.getLogin());
398        authorNode.setProperty(RepositoryConstants.NAMESPACE_PREFIX + ":population", author.getPopulationId());
399        
400        Node aclNode = invitNode.addNode(__INVITATION_ACL);
401        
402        for (Entry<String, String> allowedProfile : allowedProfileByModules.entrySet())
403        {
404            String moduleId = allowedProfile.getKey();
405            String profileId = allowedProfile.getValue();
406            
407            WorkspaceModule module = _moduleEP.getExtension(moduleId);
408            if (module != null)
409            {
410                Node moduleNode = aclNode.addNode(module.getModuleName());
411                moduleNode.setProperty(RepositoryConstants.NAMESPACE_PREFIX + ":allowed-profiles", new String[] {profileId});
412            }
413        }
414        
415        return new Invitation(mail, invitDate, author, allowedProfileByModules, project.getName());
416    }
417    
418    /**
419     * Determines if a invitation already exists for this email
420     * @param project the project
421     * @param mail the mail to test
422     * @return true if a invitation exists
423     * @throws RepositoryException if an error occured
424     */
425    public boolean invitationExists(Project project, String mail) throws RepositoryException
426    {
427        Node contextNode = getInvitationsRootNode(project, true);
428        
429        NodeIterator nodes = contextNode.getNodes(__INVITATION_NODE_NAME);
430        while (nodes.hasNext())
431        {
432            Node node = (Node) nodes.next();
433            if (node.hasProperty(__INVITATION_MAIL) && node.getProperty(__INVITATION_MAIL).getString().equals(mail))
434            {
435                return true;
436            }
437        }
438        
439        return false;
440    }
441    
442    /**
443     * Remove a invitation
444     * @param project the project
445     * @param mail the mail to remove
446     * @return true if a invitation has been removed
447     * @throws RepositoryException if an error occured
448     */
449    public boolean removeInvitation(Project project, String mail) throws RepositoryException
450    {
451        Node contextNode = getInvitationsRootNode(project, true);
452        
453        NodeIterator nodes = contextNode.getNodes(__INVITATION_NODE_NAME);
454        while (nodes.hasNext())
455        {
456            Node node = (Node) nodes.next();
457            if (node.hasProperty(__INVITATION_MAIL) && node.getProperty(__INVITATION_MAIL).getString().equals(mail))
458            {
459                node.remove();
460                return true;
461            }
462        }
463        
464        return false;
465    }
466    
467    /**
468     * Bean representing a invitation by email
469     *
470     */
471    public class Invitation
472    {
473        private Date _date;
474        private UserIdentity _author;
475        private String _email;
476        private Map<String, String> _allowedProfileByModules;
477        private String _projectName;
478        
479        /**
480         * Constructor
481         * @param email The email
482         * @param date The date of invitation
483         * @param author The author of invitation
484         * @param allowedProfileByModules The rights
485         * @param projectName the name of parent project
486         */
487        public Invitation(String email, Date date, UserIdentity author, Map<String, String> allowedProfileByModules, String projectName)
488        {
489            _email = email;
490            _date = date;
491            _author = author;
492            _allowedProfileByModules = allowedProfileByModules;
493            _projectName = projectName;
494        }
495        
496        /**
497         * Get the email
498         * @return the email
499         */
500        public String getEmail()
501        {
502            return _email;
503        }
504        
505        /**
506         * Get the date of invitation
507         * @return the date of invitation
508         */
509        public Date getDate()
510        {
511            return _date;
512        }
513        
514        /**
515         * Get the author of invitation
516         * @return the author of invitation
517         */
518        public UserIdentity getAuthor()
519        {
520            return _author;
521        }
522        
523        /**
524         * Get the allowed profile for each module
525         * @return the allowed profile for each module
526         */
527        public Map<String, String> getAllowedProfileByModules()
528        {
529            return _allowedProfileByModules;
530        }
531        
532        /**
533         * Get the parent project name
534         * @return the parent project name
535         */
536        public String getProjectName()
537        {
538            return _projectName;
539        }
540        
541        @Override
542        public String toString()
543        {
544            return "Invitation [mail=" + _email + ", project=" + _projectName + "]";
545        }
546    }
547
548    @Override
549    public Map<String, Object> _getInternalStatistics(Project project, boolean isActive)
550    {
551        if (isActive)
552        {
553            Set<ProjectMember> projectMembers = _projectMemberManager.getProjectMembers(project, true, null);
554
555            if (projectMembers != null)
556            {
557                return Map.of(__MEMBER_NUMBER_HEADER_ID, projectMembers.size());
558            }
559            else
560            {
561                return Map.of(__MEMBER_NUMBER_HEADER_ID, __SIZE_ERROR);
562            }
563        }
564        else
565        {
566            return Map.of(__MEMBER_NUMBER_HEADER_ID, __SIZE_INACTIVE);
567        }
568    }
569    
570    @Override
571    public List<StatisticColumn> _getInternalStatisticModel()
572    {
573        return List.of(new StatisticColumn(__MEMBER_NUMBER_HEADER_ID, new I18nizableText("plugin." + _pluginName, "PLUGINS_WORKSPACES_PROJECT_STATISTICS_TOOL_COLUMN_MEMBERS"))
574                .withRenderer("Ametys.plugins.workspaces.project.tool.ProjectsGridHelper.renderElements")
575                .withType(StatisticsColumnType.LONG)
576                .withGroup(GROUP_HEADER_ELEMENTS_ID));
577    }
578
579    @Override
580    protected boolean _showActivatedStatus()
581    {
582        return false;
583    }
584
585    @Override
586    public Set<String> getAllEventTypes()
587    {
588        return Set.of(ObservationConstants.EVENT_MEMBER_ADDED,
589                ObservationConstants.EVENT_MEMBER_JOINED,
590                ObservationConstants.EVENT_MEMBER_DELETED);
591    }
592    
593
594    /**
595     * Get the URI of a member in project'site
596     * Implementation does not currently use memberIdentity and type parameters but they could be useful in the future if we want to handle deep links
597     * @param project The project
598     * @param memberIdentity The member identity (user or group)
599     * @param type The member type
600     * @return The member uri
601     */
602    public String getMemberUri(Project project, String memberIdentity, MemberType type)
603    {
604        return getModuleUrl(project);
605    }
606}
607