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