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