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