001/*
002 *  Copyright 2017 Anyware Services
003 *
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016package org.ametys.plugins.workspaces.project;
017
018import java.io.IOException;
019import java.io.InputStream;
020import java.time.ZonedDateTime;
021import java.util.ArrayList;
022import java.util.Arrays;
023import java.util.Collection;
024import java.util.Collections;
025import java.util.HashMap;
026import java.util.HashSet;
027import java.util.Iterator;
028import java.util.List;
029import java.util.Map;
030import java.util.Objects;
031import java.util.Optional;
032import java.util.Set;
033import java.util.function.Predicate;
034import java.util.stream.Collectors;
035import java.util.stream.Stream;
036
037import javax.mail.MessagingException;
038
039import org.apache.avalon.framework.component.Component;
040import org.apache.avalon.framework.context.Context;
041import org.apache.avalon.framework.context.ContextException;
042import org.apache.avalon.framework.context.Contextualizable;
043import org.apache.avalon.framework.logger.AbstractLogEnabled;
044import org.apache.avalon.framework.service.ServiceException;
045import org.apache.avalon.framework.service.ServiceManager;
046import org.apache.avalon.framework.service.Serviceable;
047import org.apache.cocoon.servlet.multipart.Part;
048import org.apache.cocoon.xml.AttributesImpl;
049import org.apache.cocoon.xml.XMLUtils;
050import org.apache.commons.collections.CollectionUtils;
051import org.apache.commons.lang.StringUtils;
052import org.apache.commons.lang3.tuple.Pair;
053import org.apache.excalibur.source.Source;
054import org.apache.excalibur.source.SourceResolver;
055import org.apache.tika.io.FilenameUtils;
056import org.xml.sax.ContentHandler;
057import org.xml.sax.SAXException;
058
059import org.ametys.cms.FilterNameHelper;
060import org.ametys.cms.languages.LanguagesManager;
061import org.ametys.cms.transformation.xslt.ResolveURIComponent;
062import org.ametys.core.group.GroupDirectoryContextHelper;
063import org.ametys.core.observation.Event;
064import org.ametys.core.observation.ObservationManager;
065import org.ametys.core.right.ProfileAssignmentStorageExtensionPoint;
066import org.ametys.core.right.RightManager;
067import org.ametys.core.right.RightManager.RightResult;
068import org.ametys.core.ui.Callable;
069import org.ametys.core.user.CurrentUserProvider;
070import org.ametys.core.user.User;
071import org.ametys.core.user.UserIdentity;
072import org.ametys.core.user.UserManager;
073import org.ametys.core.user.population.PopulationContextHelper;
074import org.ametys.core.util.I18nUtils;
075import org.ametys.core.util.URIUtils;
076import org.ametys.core.util.mail.SendMailHelper;
077import org.ametys.plugins.core.user.UserHelper;
078import org.ametys.plugins.repository.AmetysObjectResolver;
079import org.ametys.plugins.repository.data.holder.ModelAwareDataHolder;
080import org.ametys.plugins.workspaces.alert.AlertWorkspaceModule;
081import org.ametys.plugins.workspaces.categories.Category;
082import org.ametys.plugins.workspaces.categories.CategoryHelper;
083import org.ametys.plugins.workspaces.categories.CategoryProviderExtensionPoint;
084import org.ametys.plugins.workspaces.editionfo.EditionFOWorkspaceModule;
085import org.ametys.plugins.workspaces.keywords.KeywordProviderExtensionPoint;
086import org.ametys.plugins.workspaces.keywords.KeywordsDAO;
087import org.ametys.plugins.workspaces.members.MembersWorkspaceModule;
088import org.ametys.plugins.workspaces.members.ProjectMemberManager;
089import org.ametys.plugins.workspaces.members.ProjectMemberManager.ProjectMember;
090import org.ametys.plugins.workspaces.news.NewsWorkspaceModule;
091import org.ametys.plugins.workspaces.project.modules.WorkspaceModuleExtensionPoint;
092import org.ametys.plugins.workspaces.project.objects.Project;
093import org.ametys.plugins.workspaces.project.objects.Project.InscriptionStatus;
094import org.ametys.plugins.workspaces.project.rights.ProjectRightHelper;
095import org.ametys.plugins.workspaces.tags.ProjectTagProviderExtensionPoint;
096import org.ametys.runtime.config.Config;
097import org.ametys.runtime.i18n.I18nizableText;
098import org.ametys.runtime.i18n.I18nizableTextParameter;
099import org.ametys.web.ObservationConstants;
100import org.ametys.web.repository.page.Page;
101import org.ametys.web.repository.page.ZoneItem;
102import org.ametys.web.repository.site.Site;
103import org.ametys.web.repository.site.SiteDAO;
104import org.ametys.web.site.SiteConfigurationManager;
105
106import com.google.common.collect.Iterables;
107
108/**
109 * Manager for the Projects Catalogue service
110 */
111public class ProjectsCatalogueManager extends AbstractLogEnabled implements Serviceable, Component, Contextualizable
112{
113    /** Avalon Role */
114    public static final String ROLE = ProjectsCatalogueManager.class.getName();
115    
116    /** The identifier of modules that are always active */
117    public static final Set<String> DEFAULT_MODULES = Set.of(MembersWorkspaceModule.MEMBERS_MODULE_ID,
118                                                             EditionFOWorkspaceModule.EDITIONFO_MODULE_ID,
119                                                             NewsWorkspaceModule.NEWS_MODULE_ID,
120                                                             AlertWorkspaceModule.ALERT_MODULE_ID);
121
122    /** List of allowed field received from the front */
123    private static final String[] __ALLOWED_FORM_DATA = {"description", "emailList", "inscriptionStatus", "defaultProfile", "tags", "categoryTags", "keywords"};
124
125    /** Ametys Object Resolver */
126    protected AmetysObjectResolver _resolver;
127    /** Current user provider */
128    protected CurrentUserProvider _currentUserProvider;
129    /** The project members' manager */
130    protected ProjectMemberManager _projectMemberManager;
131
132    /** The right manager */
133    protected RightManager _rightManager;
134    /** The project manager */
135    protected ProjectManager _projectManager;
136    /** Helper for project's rights */
137    protected ProjectRightHelper _projectRightHelper;
138    /** The language manager */
139    protected LanguagesManager _languagesManager;
140    /** The source resolver */
141    protected SourceResolver _sourceResolver;
142    /** The site dao */
143    protected SiteDAO _siteDAO;
144    /** The site's configuration manager */
145    protected SiteConfigurationManager _siteConfigurationManager;
146    /** Helper for user population */
147    protected PopulationContextHelper _populationContextHelper;
148    /** The extension point for workspace's modules */
149    protected WorkspaceModuleExtensionPoint _moduleManagerEP;
150    /** The extension point for profiles' storage */
151    protected ProfileAssignmentStorageExtensionPoint _profileAssignmentStorageExtensionPoint;
152    /** The user manager */
153    protected UserManager _userManager;
154    /** Utils for i18n */
155    protected I18nUtils _i18nUtils;
156    /** The observation manager */
157    protected ObservationManager _observationManager;
158    /** Helper for group directory's context */
159    protected GroupDirectoryContextHelper _groupDirectoryContextHelper;
160    /** The extension point for project's tags */
161    protected ProjectTagProviderExtensionPoint _projectTagProviderEP;
162    /** The extension point for project's categories */
163    protected CategoryProviderExtensionPoint _categoryProviderEP;
164    /** The extension point for project's keywords */
165    protected KeywordProviderExtensionPoint _keywordProviderEP;
166    
167    /** The avalon context */
168    protected Context _context;
169    
170    private CategoryHelper _categoryHelper;
171    
172    private ProjectMemberManager _projectMembers;
173
174    private UserHelper _userHelper;
175
176    private KeywordsDAO _keywordsDAO;
177
178    public void service(ServiceManager manager) throws ServiceException
179    {
180        _userHelper = (UserHelper) manager.lookup(UserHelper.ROLE);
181        _rightManager = (RightManager) manager.lookup(RightManager.ROLE);
182        _projectManager = (ProjectManager) manager.lookup(ProjectManager.ROLE);
183        _projectMemberManager = (ProjectMemberManager) manager.lookup(ProjectMemberManager.ROLE);
184        _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE);
185        _projectRightHelper = (ProjectRightHelper) manager.lookup(ProjectRightHelper.ROLE);
186        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
187        _languagesManager = (LanguagesManager) manager.lookup(LanguagesManager.ROLE);
188        _sourceResolver = (SourceResolver) manager.lookup(SourceResolver.ROLE);
189        _siteConfigurationManager = (SiteConfigurationManager) manager.lookup(SiteConfigurationManager.ROLE);
190        _siteDAO = (SiteDAO) manager.lookup(SiteDAO.ROLE);
191        _populationContextHelper = (PopulationContextHelper) manager.lookup(PopulationContextHelper.ROLE);
192        _moduleManagerEP = (WorkspaceModuleExtensionPoint) manager.lookup(WorkspaceModuleExtensionPoint.ROLE);
193        _profileAssignmentStorageExtensionPoint = (ProfileAssignmentStorageExtensionPoint) manager.lookup(ProfileAssignmentStorageExtensionPoint.ROLE);
194        _userManager = (UserManager) manager.lookup(UserManager.ROLE);
195        _i18nUtils = (I18nUtils) manager.lookup(I18nUtils.ROLE);
196        _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE);
197        _groupDirectoryContextHelper = (GroupDirectoryContextHelper) manager.lookup(GroupDirectoryContextHelper.ROLE);
198        _projectTagProviderEP = (ProjectTagProviderExtensionPoint) manager.lookup(ProjectTagProviderExtensionPoint.ROLE);
199        _categoryProviderEP = (CategoryProviderExtensionPoint) manager.lookup(CategoryProviderExtensionPoint.ROLE);
200        _keywordProviderEP = (KeywordProviderExtensionPoint) manager.lookup(KeywordProviderExtensionPoint.ROLE);
201        _categoryHelper = (CategoryHelper) manager.lookup(CategoryHelper.ROLE);
202        _projectMembers = (ProjectMemberManager) manager.lookup(ProjectMemberManager.ROLE);
203        _keywordsDAO = (KeywordsDAO) manager.lookup(KeywordsDAO.ROLE);
204    }
205    
206    public void contextualize(Context context) throws ContextException
207    {
208        _context = context;
209    }
210    
211    private Pair<List<String>, List<Map<String, Object>>>  _createMissingKeywords(List<Object> keywords) throws IllegalAccessException
212    {
213        List<Map<String, Object>> newKeywordsInfo = Collections.emptyList();
214        String[] keywordsToCreate = keywords.stream().filter(t -> t instanceof Map).map(t -> (String) ((Map) t).get("text")).toArray(String[]::new);
215        if (keywordsToCreate.length > 0)
216        {
217            // check right
218            if (_rightManager.currentUserHasRight(ProjectConstants.RIGHT_PROJECT_HANDLE_PROJECTKEYWORDS, "/cms") != RightResult.RIGHT_ALLOW)
219            {
220                throw new IllegalAccessException("User " + _currentUserProvider.getUser() + " tried to create a project tag without the convinient rights");
221            }
222            
223            newKeywordsInfo = _keywordsDAO.addKeywords(keywordsToCreate); 
224        }
225        
226        Iterator<Map<String, Object>> newKeywordsInfoIterator = newKeywordsInfo.iterator();
227        List<String> keywordsToSet = keywords.stream().map(t -> t instanceof String ? (String) t : (String) newKeywordsInfoIterator.next().get("name")).collect(Collectors.toList());
228        return Pair.of(keywordsToSet, newKeywordsInfo);
229    }
230    
231    /**
232     * Create a project
233     * @param zoneItemId The id of the zoneitem holding the catalog service
234     * @param title The title
235     * @param description The description (can be empty)
236     * @param illustration The illustration (can be a File or a local path)
237     * @param category The category
238     * @param keywords The project keywords
239     * @param visibility The visibility
240     * @param defaultProfile For public projects, profile for self registered users
241     * @param language The language code
242     * @param managers The managers url
243     * @param modules The selected modules
244     * @return Information about the new project
245     * @throws IllegalAccessException If user has no right to create tags and ask to
246     */
247    @Callable(right = ProjectConstants.RIGHT_PROJECT_CREATE, context = "/cms")
248    public Map<String, Object> createProject(String zoneItemId, String title, String description, Object illustration, String category, List<Object> keywords, Integer visibility, String defaultProfile, String language, List<String> managers, List<String> modules) throws IllegalAccessException
249    {
250        // Get service parameters
251        ZoneItem zoneItem = _resolver.resolveById(zoneItemId);
252        ModelAwareDataHolder serviceDataHolder = zoneItem.getServiceParameters();
253        String titlePrefix = serviceDataHolder.getValue("titlePrefix", false, "");
254        String urlPrefix = serviceDataHolder.getValue("urlPrefix", false, "");
255        String[] availableLanguages = serviceDataHolder.getValue("availableLanguages");
256        String skin = serviceDataHolder.getValue("skin", false, "");
257        String forceAcceptCookie = serviceDataHolder.getValue("force_accept_cookies", false, "");
258
259        Site catalogSite = zoneItem.getZone().getPage().getSite();
260
261        // Check language
262        if (Arrays.stream(availableLanguages).filter(language::equals).findFirst().isEmpty())
263        {
264            throw new IllegalArgumentException("Cannot create project with language '" + language + "' since it is not part of the available languages " + availableLanguages);
265        }
266        // Check profile
267        String defaultManagerProfile = Config.getInstance().getValue("workspaces.profile.managerdefault", false, null);
268        if (StringUtils.isBlank(defaultManagerProfile))
269        {
270            throw new IllegalArgumentException("The general configuration parameter 'workspaces.profile.managerdefault' cannot be empty");
271        }
272        
273        // Create tags if necessary
274        Pair<List<String>, List<Map<String, Object>>> keywordsInfo = _createMissingKeywords(keywords); 
275        List<String> keywordsToSet = keywordsInfo.getLeft();
276        List<Map<String, Object>> newKeywordsInfo = keywordsInfo.getRight();
277
278        // Create project object in repo
279        Map<String, Object> additionalValues = new HashMap<>();
280        additionalValues.put("description", StringUtils.defaultString(description));
281        additionalValues.put("categoryTags", Collections.singletonList(category));
282        additionalValues.put("inscriptionStatus", visibility == 1 ? InscriptionStatus.PRIVATE.toString()
283                                               : (visibility == 2 ? InscriptionStatus.MODERATED.toString()
284                                               :                    InscriptionStatus.OPEN.toString()));
285        additionalValues.put("defaultProfile", defaultProfile);
286        additionalValues.put("keywords", keywordsToSet); 
287
288        List<String> errors = new ArrayList<>();
289        String prefixedTitle = (titlePrefix + " " + title).trim();
290        Project project = _projectManager.createProject(_findName(prefixedTitle), 
291                                                        prefixedTitle, 
292                                                        additionalValues, 
293                                                        _withDefaultModules(modules), 
294                                                        errors);
295                                                        
296        if (!CollectionUtils.isEmpty(errors))
297        {
298            Map<String, Object> result = new HashMap<>();
299            result.put("success", false);
300            
301            return result;
302        }
303        
304        // Add site infos
305        _updateSiteInfos(project, urlPrefix, skin, forceAcceptCookie, catalogSite, illustration, language);
306        
307        // Assign populations and manager
308        _assignPopulations(project, catalogSite);
309        _assignManagers(project, managers, defaultManagerProfile);
310        project.saveChanges();
311
312        Map<String, Object> result = new HashMap<>();
313        result.put("success", true);
314        result.put("project", _detailedMyProject2json(project));
315        result.put("keywords", newKeywordsInfo);
316        return result;
317    }
318    private void _assignPopulations(Project project, Site catalogSite)
319    {
320        Site site = project.getSites().iterator().next();
321    
322        Set<String> populations = _populationContextHelper.getUserPopulationsOnContext("/sites/" + catalogSite.getName(), false);
323        Set<String> frontPopulations = _populationContextHelper.getUserPopulationsOnContext("/sites-fo/" + catalogSite.getName(), false);
324        Set<String> groupDirectories = _groupDirectoryContextHelper.getGroupDirectoriesOnContext("/sites/" + catalogSite.getName());
325        Set<String> frontGroupDirectories = _groupDirectoryContextHelper.getGroupDirectoriesOnContext("/sites-fo/" + catalogSite.getName());
326        
327        _populationContextHelper.link("/sites/" + site.getName(), populations);
328        _populationContextHelper.link("/sites-fo/" + site.getName(), frontPopulations);
329        _groupDirectoryContextHelper.link("/sites/" + site.getName(), new ArrayList<>(groupDirectories));
330        _groupDirectoryContextHelper.link("/sites-fo/" + site.getName(), new ArrayList<>(frontGroupDirectories));
331    }
332    
333    private void _assignManagers(Project project, List<String> managers, String defaultManagerProfile)
334    {
335        Site site = project.getSites().iterator().next();
336    
337        Set<String> populations = _populationContextHelper.getUserPopulationsOnContext("/sites/" + site.getName(), false);
338        Set<String> frontPopulations = _populationContextHelper.getUserPopulationsOnContext("/sites-fo/" + site.getName(), false);
339
340        Predicate<UserIdentity> isInPopulations = user -> populations.contains(user.getPopulationId()) || frontPopulations.contains(user.getPopulationId());
341        
342        List<UserIdentity> projectManagers = managers.stream()
343                .map(UserIdentity::stringToUserIdentity)
344                .filter(Objects::nonNull)
345                .filter(isInPopulations)
346                .collect(Collectors.toList());
347        
348        _projectMemberManager.setProjectManager(project.getName(), defaultManagerProfile, projectManagers);
349    }
350    
351    private void _updateSiteInfos(Project project, String urlPrefix, String skin, String forceAcceptCookie, Site catalogSite, Object illustration, String language)
352    {
353        Site site = project.getSites().iterator().next();
354        
355        site.setUrl(urlPrefix + "/" + project.getName());
356        site.setValue("skin", skin);
357        site.setValue("force-accept-cookies", forceAcceptCookie);
358
359        site.setValue("display-restricted-pages", false);
360        site.setValue("ping_activated", false);
361        site.setValue("site-mail-from", catalogSite.getValue("site-mail-from"));
362        site.setValue("site-contents-comments-postvalidation", catalogSite.getValue("site-contents-comments-postvalidation"));
363        site.setValue("color", catalogSite.getValue("color"));
364        
365        _setIllustration(site, illustration);
366
367        _siteDAO.setLanguages(site, Collections.singletonList(language));
368        
369        site.saveChanges();
370        
371        Map<String, Object> eventParams = new HashMap<>();
372        eventParams = new HashMap<>();
373        eventParams.put(org.ametys.web.ObservationConstants.ARGS_SITE, site);
374        _observationManager.notify(new Event(org.ametys.web.ObservationConstants.EVENT_SITE_UPDATED, _currentUserProvider.getUser(), eventParams));
375    }
376
377    private void _setIllustration(Site site, Object illustration)
378    {
379        Object illustrationObject = null;
380        try
381        {
382            illustrationObject = _getIllustrationSource(illustration);
383            if (illustrationObject instanceof Source)
384            {
385                Source illustrationSource = (Source) illustrationObject;
386                try (InputStream is = illustrationSource.getInputStream())
387                {
388                    site.setIllustration(is, illustrationSource.getMimeType(), FilenameUtils.getName(illustrationSource.getURI()), ZonedDateTime.now());
389                }
390            }
391            else if (illustrationObject instanceof Part)
392            {
393                Part illustrationPart = (Part) illustrationObject;
394                try (InputStream is = illustrationPart.getInputStream())
395                {
396                    site.setIllustration(is, illustrationPart.getMimeType(), illustrationPart.getUploadName(), ZonedDateTime.now());
397                }
398            }
399        }
400        catch (IOException e)
401        {
402            throw new IllegalArgumentException("Cannot not get illustration", e);
403        }
404        finally 
405        {
406            if (illustrationObject instanceof Source)
407            {
408                _sourceResolver.release((Source) illustrationObject);
409            }
410        }
411
412    }
413
414    private Object _getIllustrationSource(Object illustration) throws IOException
415    {
416        if (illustration instanceof String)
417        {
418            String illustrationAsString = (String) illustration;
419            if (illustrationAsString.contains("/") || illustrationAsString.contains("\\"))
420            {
421                throw new IllegalArgumentException("Cannot choose an illustration outside the library directory");
422            }
423            
424            return _sourceResolver.resolveURI("plugin:workspaces://resources/img/catalog/library/" + illustrationAsString);
425        }
426        else if (illustration instanceof Part)
427        {
428            Part illustrationAsFile = (Part) illustration;
429            return illustrationAsFile;
430        }
431        else // boolean => unchanged
432        {
433            return null;
434        }
435    }
436
437    private Set<String> _withDefaultModules(List<String> modules)
438    {
439        Set<String> modulesToActivate = new HashSet<>();
440        
441        modulesToActivate.addAll(DEFAULT_MODULES);
442        modulesToActivate.addAll(modules);
443        
444        return modulesToActivate;
445    }
446
447    private String _findName(String title)
448    {
449        String originalName = FilterNameHelper.filterName(title);
450        String name = originalName;
451        
452        int index = 2;
453        while (_projectManager.hasProject(name))
454        {
455            name = originalName + "-" + (index++);
456        }
457
458        return name;
459    }
460
461    /**
462     * Edit an existing project
463     * @param projectId The id of the project
464     * @param title New title
465     * @param description New description
466     * @param illustration New illustration
467     * @param category New category
468     * @param keywords The project keywords
469     * @param visibility New visibility
470     * @param defaultProfile New default profile
471     * @param managers New managers
472     * @param modules  New modules
473     * @return The success map with project description
474     * @throws IllegalAccessException if user has not the convenient rights
475     */    
476    @Callable
477    public Map<String, Object> editProject(String projectId, String title, String description, Object illustration, String category, List<Object> keywords, Integer visibility, String defaultProfile, List<String> managers, List<String> modules) throws IllegalAccessException
478    {
479        Project project = _resolver.resolveById(projectId);
480        if (project == null)
481        {
482            throw new IllegalArgumentException("Unable to edit a project, invalid project id received '" + projectId + "'");
483        }
484        // Check profile
485        String defaultManagerProfile = Config.getInstance().getValue("workspaces.profile.managerdefault", false, null);
486        if (StringUtils.isBlank(defaultManagerProfile))
487        {
488            throw new IllegalArgumentException("The general configuration parameter 'workspaces.profile.managerdefault' cannot be empty");
489        }
490
491
492        if (_rightManager.currentUserHasRight(ProjectConstants.RIGHT_PROJECT_EDIT, project) != RightResult.RIGHT_ALLOW)
493        {
494            throw new IllegalAccessException("User '" + _currentUserProvider.getUser() + "' tried to edit the project '" + projectId + "' without sufficient rights");
495        }
496        
497        // Create keywords if necessary
498        Pair<List<String>, List<Map<String, Object>>> keywordsInfo = _createMissingKeywords(keywords); 
499        List<String> keywordsToSet = keywordsInfo.getLeft();
500        List<Map<String, Object>> newKeywordsInfo = keywordsInfo.getRight();
501        
502        project.setTitle(title);
503        project.setDescription(StringUtils.defaultString(description));
504        project.setInscriptionStatus(visibility == 1 ? InscriptionStatus.PRIVATE.toString()
505                                  : (visibility == 2 ? InscriptionStatus.MODERATED.toString()
506                                  :                    InscriptionStatus.OPEN.toString()));
507        project.setDefaultProfile(defaultProfile);
508        project.setCategoryTags(Collections.singletonList(category));
509        project.setKeywords(keywordsToSet.toArray(new String[keywordsToSet.size()]));
510
511        Site site = project.getSites().iterator().next();
512
513        _projectManager.setProjectSiteTitle(site, project.getTitle());
514        _setIllustration(site, illustration);
515        if (site.needsSave())
516        {
517            site.saveChanges();
518                    
519            Map<String, Object> eventParams = new HashMap<>();
520            eventParams.put(ObservationConstants.ARGS_SITE, site);
521            _observationManager.notify(new Event(ObservationConstants.EVENT_SITE_UPDATED, _currentUserProvider.getUser(), eventParams));
522        }
523        
524        _updateModules(project, _withDefaultModules(modules));
525        _assignManagers(project, managers, defaultManagerProfile);
526
527        if (project.needsSave())
528        {
529            project.saveChanges();
530            
531            // Notify observers
532            Map<String, Object> eventParams = new HashMap<>();
533            eventParams.put(org.ametys.plugins.workspaces.ObservationConstants.ARGS_PROJECT, project);
534            _observationManager.notify(new Event(org.ametys.plugins.workspaces.ObservationConstants.EVENT_PROJECT_UPDATED, _currentUserProvider.getUser(), eventParams));
535        }
536        
537        Map<String, Object> result = new HashMap<>();
538        result.put("success", true);
539        result.put("project", _detailedMyProject2json(project));
540        result.put("keywords", newKeywordsInfo);
541        return result;
542    }
543    
544    private void _updateModules(Project project, Set<String> modules)
545    {
546        Set<String> modulesToActivate = new HashSet<>(modules);
547        Set<String> modulesToDeactivate = new HashSet<>(Arrays.asList(project.getModules()));
548        modulesToActivate.removeAll(new HashSet<>(Arrays.asList(project.getModules())));
549        modulesToDeactivate.removeAll(modules);
550
551        if (!modulesToActivate.isEmpty())
552        {
553            _projectManager.activateModules(project, modulesToActivate);
554        }
555        if (!modulesToDeactivate.isEmpty())
556        {
557            _projectManager.deactivateModules(project, modulesToDeactivate);
558        }
559    }
560    
561    /**
562     * Delete a project
563     * @param projectId The project id
564     * @return The result
565     * @throws IllegalAccessException  if an error occurred
566     */
567    @Callable
568    public Map<String, Object> deleteProject(String projectId) throws IllegalAccessException
569    {
570        Project project = _resolver.resolveById(projectId);
571        
572        if (project == null)
573        {
574            throw new IllegalArgumentException("Unable to delete a project, invalid project id received '" + projectId + "'");
575        }
576        
577        if (_rightManager.currentUserHasRight(ProjectConstants.RIGHT_PROJECT_DELETE, project) != RightResult.RIGHT_ALLOW)
578        {
579            throw new IllegalAccessException("User '" + _currentUserProvider.getUser() + "' tried to delete the project '" + projectId + "' without sufficient rights");
580        }
581        
582        Map<String, Object> result = new HashMap<>();
583        result.put("sites", _projectManager.deleteProject(project));
584        result.put("success", true);
585        return result;
586    }
587    
588    /**
589     * Add the current user to the project, if the project's inscriptions are opened
590     * @param projectId The project id
591     * @return The result
592     * @throws MessagingException If an error occurred sending a notification mail to the project manager
593     */
594    @Callable
595    public Map<String, Object> joinProject(String projectId) throws MessagingException
596    {
597        Map<String, Object> result = new HashMap<>();
598        Project project = _resolver.resolveById(projectId);
599        
600        if (project == null)
601        {
602            throw new IllegalArgumentException("Unable to join a project, invalid project id received '" + projectId + "'");
603        }
604
605        UserIdentity currentUser = _currentUserProvider.getUser();
606        
607        Optional<Site> projectSite = project.getSites().stream().filter(site -> StringUtils.isNotEmpty(site.getUrl())).findFirst();
608        String siteName = projectSite.isPresent() ? projectSite.get().getName() : null;
609        if (siteName == null || (!_populationContextHelper.getUserPopulationsOnContext("/sites/" + siteName, false).contains(currentUser.getPopulationId())
610                && !_populationContextHelper.getUserPopulationsOnContext("/sites-fo/" + siteName, false).contains(currentUser.getPopulationId())))
611        {
612            //  User is not in the project site populations, cannot be added
613            result.put("success", false);
614            return result;
615        }
616
617        
618        boolean success = _projectMemberManager.addProjectMember(project, currentUser);
619        
620        result.put("success", success);
621        if (success)
622        {
623            String url = null;
624            if (projectSite.isPresent())
625            {
626                url = projectSite.get().getUrl();
627                result.put("url", url);
628            }
629            
630            String mailFrom = Config.getInstance().getValue("smtp.mail.from");
631            
632            List<String> managersEmails = Arrays.stream(project.getManagers())
633                    .map(manager -> _userManager.getUser(manager))
634                    .filter(Objects::nonNull)
635                    .map(User::getEmail)
636                    .filter(StringUtils::isNotEmpty)
637                    .collect(Collectors.toList());
638            
639            if (managersEmails.size() > 0 && mailFrom != null)
640            {
641                Map<String, I18nizableTextParameter> params = new HashMap<>();
642                User current = _userManager.getUser(currentUser);
643                params.put("user", new I18nizableText(current != null ? current.getFullName() : currentUser.getLogin()));
644                params.put("project", new I18nizableText(project.getTitle()));
645                params.put("url", new I18nizableText(url != null ? url : ""));
646                String subject = _i18nUtils.translate(new I18nizableText("plugin.workspaces", "PLUGINS_WORKSPACES_CATALOGUE_JOINPROJECT_MAIL_TITLE", params));
647                String textBody = _i18nUtils.translate(new I18nizableText("plugin.workspaces", "PLUGINS_WORKSPACES_CATALOGUE_JOINPROJECT_MAIL_BODY", params));
648                
649                for (String managerMail : managersEmails)
650                {
651                    SendMailHelper.sendMail(subject, null, textBody, managerMail, mailFrom);
652                }
653            }
654        }
655        return result;
656    }
657    
658    /**
659     * Send a demand to join a project to the project's manager, if the project's inscriptions are moderated 
660     * @param projectId The project to join
661     * @param message A message to send to the project's manager.
662     * @return The result
663     * @throws MessagingException If an error occurred sending the email to the project's manager
664     */
665    @Callable
666    public Map<String, Object> askToJoinProject(String projectId, String message) throws MessagingException
667    {
668        Map<String, Object> result = new HashMap<>();
669
670        Project project = _resolver.resolveById(projectId);
671        if (project == null)
672        {
673            throw new IllegalArgumentException("Unable to join a project, invalid project id received '" + projectId + "'");
674        }
675
676        UserIdentity currentUser = _currentUserProvider.getUser();
677        
678        String siteName = Iterables.getFirst(_projectManager.getProjectNames(project), null);
679        if (siteName == null || (!_populationContextHelper.getUserPopulationsOnContext("/sites/" + siteName, false).contains(currentUser.getPopulationId())
680                && !_populationContextHelper.getUserPopulationsOnContext("/sites-fo/" + siteName, false).contains(currentUser.getPopulationId())))
681        {
682            //  User is not in the project site populations, cannot be added
683            result.put("success", false);
684            return result;
685        }
686        
687        _sendAskToJoinMail(message, project, currentUser);
688        result.put("success", true);
689        result.put("added-notification", Config.getInstance().getValue("workspaces.member.added.send.notification"));
690        return result;
691    }
692
693    private void _sendAskToJoinMail(String message, Project project, UserIdentity joiningUser) throws MessagingException
694    {
695        String url = getAddUserUrl(project, joiningUser);
696        
697        String mailFrom = Config.getInstance().getValue("smtp.mail.from");
698        
699        List<String> managersEmails = Arrays.stream(project.getManagers())
700                .map(manager -> _userManager.getUser(manager))
701                .filter(Objects::nonNull)
702                .map(User::getEmail)
703                .filter(StringUtils::isNotEmpty)
704                .collect(Collectors.toList());
705        
706        if (managersEmails.size() > 0 && mailFrom != null)
707        {
708            String escapedMessage = StringUtils.isEmpty(message) ? null : message.replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\n", "<br/>");
709            
710            Map<String, I18nizableTextParameter> params = new HashMap<>();
711            User current = _userManager.getUser(joiningUser);
712            params.put("user", new I18nizableText(current != null ? current.getFullName() : joiningUser.getLogin()));
713            params.put("project", new I18nizableText(project.getTitle()));
714            params.put("url", new I18nizableText(url));
715            params.put("message", new I18nizableText(escapedMessage != null ? escapedMessage : ""));
716            String subject = _i18nUtils.translate(new I18nizableText("plugin.workspaces", "PLUGINS_WORKSPACES_CATALOGUE_JOINPROJECT_ASK_MAIL_TITLE", params));
717            String htmlBody = _i18nUtils.translate(new I18nizableText("plugin.workspaces", escapedMessage != null ? "PLUGINS_WORKSPACES_CATALOGUE_JOINPROJECT_ASK_MAIL_BODY" : "PLUGINS_WORKSPACES_CATALOGUE_JOINPROJECT_ASK_MAIL_BODY_EMPTY" , params));
718            
719            for (String managerMail : managersEmails)
720            {
721                SendMailHelper.sendMail(subject, htmlBody, null, managerMail, mailFrom);
722            }
723        }
724    }
725    
726    /**
727     * Get the absolute url to add a user to a project
728     * @param project The project
729     * @param user the identity of user to add
730     * @return the absolute page url
731     */
732    protected String getAddUserUrl(Project project, UserIdentity user)
733    {
734        String memberPage = "";
735        
736        Set<Page> membersPages = _projectManager.getModulePages(project, MembersWorkspaceModule.MEMBERS_MODULE_ID);
737        if (!membersPages.isEmpty())
738        {
739            memberPage = ResolveURIComponent.resolve("page", membersPages.iterator().next().getId(), false, true);
740        }
741
742        Site site = project.getSites().iterator().next();
743        String siteURL = site.getUrl();
744        String urlWithoutScheme = StringUtils.substringAfter(siteURL, "://");
745        String relativeURL = StringUtils.contains(urlWithoutScheme, "/") ? "/" + StringUtils.substringAfter(urlWithoutScheme, "/") : "";
746        return siteURL + "/_authenticate?requestedURL=" + URIUtils.encodeParameter(relativeURL + "/plugins/workspaces/add-member?redirect=" + URIUtils.encodeParameter(memberPage + "?added=" + URIUtils.encodeParameter(UserIdentity.userIdentityToString(user))) + "&user=" + URIUtils.encodeParameter(UserIdentity.userIdentityToString(user)) + "&project=" + URIUtils.encodeParameter(project.getName()));
747    }
748    
749    /**
750     * Get the list of allowed data in the form
751     * @return the list of allowed data in the form
752     */
753    protected String[] getAllowedFormData()
754    {
755        return __ALLOWED_FORM_DATA;
756    }
757
758
759    /**
760     * Callable to get projects of the user and the public projects he can subscribe.
761     * @return A map with three entries an entry for user projects, another one for public projects and finally one for the project's creation right
762     */
763    @Callable
764    public Map<String, Object> getUserAndPublicProjects()
765    {
766        Map<String, Object> result = new HashMap<>();
767        
768        UserIdentity user = _currentUserProvider.getUser();
769
770        result.put("canCreate", _rightManager.currentUserHasRight(ProjectConstants.RIGHT_PROJECT_CREATE, "/cms") == RightResult.RIGHT_ALLOW);
771        
772        List<Map<String, Object>> userProjects = new ArrayList<>();
773        List<Map<String, Object>> publicProjects = new ArrayList<>();
774        
775        for (Project project : _projectManager.getProjects())
776        {
777            if (_projectMembers.isProjectMember(project, user))
778            {
779                Map<String, Object> json = _detailedMyProject2json(project);
780                userProjects.add(json);
781            }
782            else if (project.getInscriptionStatus() != InscriptionStatus.PRIVATE)
783            {
784                Map<String, Object> json = detailedProject2json(project);
785                publicProjects.add(json);
786            }
787        }
788
789        result.put("userProjects", userProjects);
790        result.put("availablePublicProjects", publicProjects);
791        
792        return result;
793    }
794    
795    /**
796     * Callable to get projects of the user.
797     * @return A map with the user projects
798     */
799    @Callable
800    public List<Map<String, Object>> getUserProjects()
801    {
802        
803        UserIdentity user = _currentUserProvider.getUser();
804
805        
806        List<Map<String, Object>> userProjects = new ArrayList<>();
807        
808        for (Project project : _projectManager.getProjects())
809        {
810            if (_projectMembers.isProjectMember(project, user))
811            {
812                Map<String, Object> json = _detailedMyProject2json(project);
813                userProjects.add(json);
814            }
815        }
816
817        return userProjects;
818    }
819    
820    private Map<String, Object> _detailedMyProject2json(Project project)
821    {
822        Map<String, Object> json = detailedProject2json(project);
823        json.put("favorite", false);
824        json.put("notification", true);
825        json.put("canEdit", _rightManager.currentUserHasRight(ProjectConstants.RIGHT_PROJECT_EDIT, project) == RightResult.RIGHT_ALLOW);
826        json.put("canDelete", _rightManager.currentUserHasRight(ProjectConstants.RIGHT_PROJECT_DELETE, project) == RightResult.RIGHT_ALLOW);
827        json.put("canAccessBO", _projectManager.canAccessBO(project));
828        return json;
829    }
830    
831    /**
832     * Transform a {@link Project} into a json map
833     * @param project the project to parse
834     * @return a json map
835     */
836    public Map<String, Object> detailedProject2json(Project project)
837    {
838        Map<String, Object> json = new HashMap<>();
839        
840        json.put("id", project.getId());
841        json.put("name", project.getName());
842        json.put("title", project.getTitle());
843        json.put("url", Iterables.getFirst(_projectManager.getProjectUrls(project), StringUtils.EMPTY));
844        
845        json.put("defaultProfile", project.getDefaultProfile());
846        
847        Set<String> categories = project.getCategories();
848        if (categories.size() != 1)
849        {
850            getLogger().warn("Project " + project.getTitle() + " (" + project.getId() + ") should have one and only one category");
851        }
852        
853        if (!categories.isEmpty())
854        {
855            String c = categories.iterator().next();
856            Category category = _categoryProviderEP.getTag(c, new HashMap<>());
857            
858            if (category != null)
859            {
860                Map<String, Object> map = new HashMap<>();
861                map.put("id", category.getId());
862                map.put("name", category.getName());
863                map.put("title", category.getTitle());            
864                map.put("color", _categoryHelper.getCategoryColor(category).get("main"));
865    
866                json.put("category", map);
867            }
868        }
869        
870        String[] keywords = Stream.of(project.getKeywords())
871            .filter(k -> _keywordProviderEP.getTag(k, new HashMap<>()) != null)
872            .toArray(String[]::new);
873        json.put("keywords", keywords);
874
875        UserIdentity[] managers = project.getManagers();
876        if (managers.length > 0)
877        {
878            json.put("managers", Arrays.stream(managers)
879                                       .map(_userHelper::user2json)
880                                       .filter(userAsJson -> !userAsJson.equals(Collections.EMPTY_MAP))
881                                       .collect(Collectors.toList()));
882        }
883        
884        List<Map<String, Object>> members = new ArrayList<>();
885        for (int i = 1; i < Math.min(managers.length, 4); i++)
886        {
887            members.add(_userHelper.user2json(managers[i]));
888        }
889        
890        if (members.size() < 3)
891        {
892            List<UserIdentity> managersList = Arrays.asList(managers);
893            
894            members.addAll(
895                _projectMembers.getProjectMembers(project, true, false)
896                    .stream()
897                    .map(ProjectMember::getUser)
898                    .map(User::getIdentity)
899                    .filter(Predicate.not(managersList::contains))
900                    .limit(3 - members.size())
901                    .map(_userHelper::user2json)
902                    .collect(Collectors.toList())
903            );
904        }
905        
906        json.put("members", members);
907        json.put("membersCount", _projectMembers.getMembersCount(project));
908        
909        json.put("modules", Arrays.asList(project.getModules()));
910
911        switch (project.getInscriptionStatus())
912        {
913            case PRIVATE: json.put("visibility", 1); break;
914            case MODERATED: json.put("visibility", 2); break;
915            case OPEN:
916                // fallthrought
917            default: 
918                json.put("visibility", 3); break;
919        }
920        
921        json.put("description", project.getDescription());
922
923        Collection<Site> sites = project.getSites();
924        if (!sites.isEmpty())
925        {
926            Site firstSite = sites.iterator().next();
927            json.put("site", firstSite.getName());
928            json.put("language", firstSite.getSitemaps().iterator().next().getName());
929        }
930
931        Optional<String> illustration = sites.stream()
932            .filter(s -> s.getIllustration() != null)
933            .map(s -> ResolveURIComponent.resolveCroppedImage("site-parameter", s.getName() + ";illustration", 252, 389, false, true))
934            .filter(StringUtils::isNotEmpty)
935            .findFirst();
936        if (illustration.isPresent())
937        {
938            json.put("illustration", illustration.get());
939        }
940        
941        return json;
942    }
943    
944    /**
945     * SAX a project
946     * @param contentHandler The content handler to sax into
947     * @param project the project
948     * @throws SAXException if an error occurred while saxing
949     */
950    public void saxProject(ContentHandler contentHandler, Project project) throws SAXException
951    {
952        AttributesImpl attrs = new AttributesImpl();
953        attrs.addCDATAAttribute("id", project.getId());
954        attrs.addCDATAAttribute("name", project.getName());
955        
956        XMLUtils.startElement(contentHandler, "project", attrs);
957        
958        XMLUtils.createElement(contentHandler, "title", project.getTitle());
959        
960        XMLUtils.createElement(contentHandler, "inscriptionStatus", project.getInscriptionStatus().name());
961        
962        String description = project.getDescription();
963        if (description != null)
964        {
965            XMLUtils.createElement(contentHandler, "description", description);
966        }
967        XMLUtils.createElement(contentHandler, "url", Iterables.getFirst(_projectManager.getProjectUrls(project), StringUtils.EMPTY));
968        
969        saxCategory(contentHandler, project);
970        
971        for (String keyword : project.getKeywords())
972        {
973            XMLUtils.createElement(contentHandler, "keyword", keyword);
974        }
975        
976        UserIdentity[] managers = project.getManagers();
977        if (managers.length > 0)
978        {
979            for (UserIdentity userIdentity : managers)
980            {
981                _userHelper.saxUserIdentity(userIdentity, contentHandler, "manager");
982            }
983        }
984        
985        Collection<Site> sites = project.getSites();
986        if (!sites.isEmpty())
987        {
988            Site firstSite = sites.iterator().next();
989            attrs.clear();
990            attrs.addCDATAAttribute("name", firstSite.getName());
991            attrs.addCDATAAttribute("language", firstSite.getSitemaps().iterator().next().getName());
992            
993            XMLUtils.createElement(contentHandler, "site", attrs);
994        }
995        
996        XMLUtils.endElement(contentHandler, "project");
997    }
998    
999    /**
1000     * SAX the project's category
1001     * @param contentHandler the content handler to sax into
1002     * @param project the project
1003     * @throws SAXException if an error occurred while saxing
1004     */
1005    public void saxCategory(ContentHandler contentHandler, Project project) throws SAXException
1006    {
1007        saxCategory(contentHandler, project, "category");
1008    }
1009    
1010    /**
1011     * SAX the project's category
1012     * @param contentHandler the content handler to sax into
1013     * @param project the project
1014     * @param tagName the tag name for category
1015     * @throws SAXException if an error occurred while saxing
1016     */
1017    public void saxCategory(ContentHandler contentHandler, Project project, String tagName) throws SAXException
1018    {
1019        Set<String> categories = project.getCategories();
1020        if (!categories.isEmpty())
1021        {
1022            String c = categories.iterator().next();
1023            Category category = _categoryProviderEP.getTag(c, new HashMap<>());
1024            
1025            if (category != null)
1026            {
1027                AttributesImpl attrs = new AttributesImpl();
1028                attrs.addCDATAAttribute("id", category.getId());
1029                attrs.addCDATAAttribute("name", category.getName());
1030                attrs.addCDATAAttribute("color", _categoryHelper.getCategoryColor(category).get("main"));
1031                
1032                XMLUtils.startElement(contentHandler, tagName, attrs);
1033                category.getTitle().toSAX(contentHandler);
1034                XMLUtils.endElement(contentHandler, tagName);
1035            }
1036        }
1037    }
1038}