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