001/*
002 *  Copyright 2020 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.report;
017
018import java.io.IOException;
019import java.text.SimpleDateFormat;
020import java.time.format.DateTimeFormatter;
021import java.util.ArrayList;
022import java.util.Arrays;
023import java.util.Calendar;
024import java.util.Collections;
025import java.util.Comparator;
026import java.util.List;
027import java.util.Objects;
028import java.util.Set;
029import java.util.stream.Collectors;
030
031import javax.jcr.Node;
032import javax.jcr.NodeIterator;
033import javax.jcr.Repository;
034import javax.jcr.RepositoryException;
035import javax.jcr.Session;
036import javax.jcr.query.Query;
037
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.service.ServiceException;
042import org.apache.avalon.framework.service.ServiceManager;
043import org.apache.cocoon.ProcessingException;
044import org.apache.cocoon.components.ContextHelper;
045import org.apache.cocoon.environment.Request;
046import org.apache.cocoon.generation.ServiceableGenerator;
047import org.apache.cocoon.xml.XMLUtils;
048import org.apache.commons.lang3.StringUtils;
049import org.xml.sax.SAXException;
050
051import org.ametys.cms.data.ContentValue;
052import org.ametys.cms.repository.Content;
053import org.ametys.cms.tag.Tag;
054import org.ametys.core.group.GroupManager;
055import org.ametys.core.user.User;
056import org.ametys.core.user.UserIdentity;
057import org.ametys.core.user.UserManager;
058import org.ametys.core.util.I18nUtils;
059import org.ametys.plugins.repository.AmetysRepositoryException;
060import org.ametys.plugins.repository.data.holder.ModelAwareDataHolder;
061import org.ametys.plugins.repository.events.EventType;
062import org.ametys.plugins.repository.provider.AbstractRepository;
063import org.ametys.plugins.repository.query.SortCriteria;
064import org.ametys.plugins.userdirectory.UserDirectoryHelper;
065import org.ametys.plugins.workspaces.keywords.KeywordsDAO;
066import org.ametys.plugins.workspaces.members.ProjectMemberManager;
067import org.ametys.plugins.workspaces.project.ProjectManager;
068import org.ametys.plugins.workspaces.project.objects.Project;
069import org.ametys.plugins.workspaces.project.objects.Project.InscriptionStatus;
070import org.ametys.runtime.i18n.I18nizableText;
071import org.ametys.web.repository.site.Site;
072import org.ametys.web.repository.site.SiteManager;
073import org.ametys.web.repository.sitemap.Sitemap;
074
075/**
076 * Report projects with theirs managers/users
077 */
078public class ReportGenerator extends ServiceableGenerator implements Contextualizable
079{
080    /** Time format for csv export */
081    protected static final String __DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm";
082    
083    /** Zoned dateTime formatter */
084    protected static final DateTimeFormatter __ZONED_DATE_FORMATER = DateTimeFormatter.ofPattern(__DATE_TIME_FORMAT);
085
086    /** DateTime formatter */
087    protected static final SimpleDateFormat __DATE_FORMATER = new SimpleDateFormat(__DATE_TIME_FORMAT);
088    
089    
090    /** number of columns for a project */
091    protected static final int __REPORT_COLUMNS_SIZE_PROJECT = 8;
092    
093    /** number of columns for a member */
094    protected static final int __REPORT_COLUMNS_SIZE_MEMBER = 8;
095    
096    /** Report Project Manager */
097    protected ReportHelper _reportProjectManager;
098    
099    /** The project manager */
100    protected ProjectManager _projectManager;
101    
102    /** Site manager */
103    protected SiteManager _siteManager;
104
105    /** Repository */
106    protected Repository _repository;
107
108    /** User Manager */
109    protected UserManager _userManager;
110    
111    /** Group Manager */
112    protected GroupManager _groupManager;
113    
114    /** Project Member Manager */
115    protected ProjectMemberManager _projectMemberManager;
116    
117    /** User Directory Helper */
118    protected UserDirectoryHelper _userDirectoryHelper;
119    
120    /** I18n Utils */
121    protected I18nUtils _i18nUtils;
122    
123    /** The keywords DAO */
124    protected KeywordsDAO _keywordsDAO;
125    
126    /** Avalon context */
127    protected Context _context;
128
129    @Override
130    public void contextualize(Context context) throws ContextException
131    {
132        _context = context;
133    }
134    
135    @Override
136    public void service(ServiceManager smanager) throws ServiceException
137    {
138        super.service(smanager);
139        _reportProjectManager = (ReportHelper) smanager.lookup(ReportHelper.ROLE);
140        _projectManager = (ProjectManager) smanager.lookup(ProjectManager.ROLE);
141        _siteManager = (SiteManager) smanager.lookup(SiteManager.ROLE);
142        _repository = (Repository) smanager.lookup(AbstractRepository.ROLE);
143        _userManager = (UserManager) smanager.lookup(UserManager.ROLE);
144        _groupManager = (GroupManager) smanager.lookup(GroupManager.ROLE);
145        _projectMemberManager = (ProjectMemberManager) smanager.lookup(ProjectMemberManager.ROLE);
146        _userDirectoryHelper = (UserDirectoryHelper) smanager.lookup(UserDirectoryHelper.ROLE);
147        _i18nUtils = (I18nUtils) smanager.lookup(I18nUtils.ROLE);
148        _keywordsDAO = (KeywordsDAO) manager.lookup(KeywordsDAO.ROLE);
149    }
150
151    public void generate() throws IOException, SAXException, ProcessingException
152    {
153        Request request = ContextHelper.getRequest(_context);
154        
155        String lang = parameters.getParameter("lang", null);
156        String[] projectsValues = request.getParameterValues("project");
157        String[] categoriesValues = request.getParameterValues("category");
158
159        // Filters because the js may send the parameter empty (which will create a non-emply list with an empty string)
160        List<String> projects = projectsValues == null ? Collections.EMPTY_LIST : Arrays.asList(projectsValues)
161                .stream()
162                .filter(StringUtils::isNotBlank)
163                .collect(Collectors.toList());
164        
165        List<String> categories = categoriesValues == null ? Collections.EMPTY_LIST : Arrays.asList(categoriesValues)
166                .stream()
167                .filter(StringUtils::isNotBlank)
168                .collect(Collectors.toList());
169
170        boolean reportWithMembers = "true".equals(request.getParameter("with-members"));
171        boolean reportWithManagers = "true".equals(request.getParameter("with-managers"));
172        
173        contentHandler.startDocument();
174        List<Project> availableProjects = _reportProjectManager.getAvailableProjects(projects, categories);
175        if (availableProjects != null && !availableProjects.isEmpty())
176        {
177            XMLUtils.createElement(contentHandler, "text", generateProjects(availableProjects, reportWithMembers, reportWithManagers, lang));
178        }
179        else
180        {
181            XMLUtils.createElement(contentHandler, "text");
182        }
183        contentHandler.endDocument();
184    }
185
186    /**
187     * Generate the list of projects
188     * @param projects list of projects to generate
189     * @param reportWithMembers also report members
190     * @param reportWithManagers also report managers
191     * @param lang language used to parse agents (if not available, another langage will still be used)
192     * @return A String that will represent the csv
193     */
194    protected String generateProjects(List<Project> projects, boolean reportWithMembers, boolean reportWithManagers, String lang)
195    {
196        // Languages used to use one of them on each content to report
197        List<String> langages = getCatalogSiteLangages();
198        
199        StringBuilder sb = new StringBuilder();
200        sb.append("\ufeff"); // BOM character
201        sb.append("\"" + translateKey("PLUGIN_WORKSPACES_SERVICE_REPORTS_CSV_HEADER_TYPE", lang) + "\";");
202        sb.append(getProjectCsvHeader(reportWithMembers, reportWithManagers, lang));
203        
204        sb.append("\n");
205        
206        // categories
207        sb.append(projects.stream()
208                .map(project -> translateKey("PLUGIN_WORKSPACES_SERVICE_REPORTS_CSV_HEADER_PROJECT", lang) + ";" + projectToCsv(project, reportWithMembers, reportWithManagers, lang, langages))
209                .collect(Collectors.joining("\n")));
210
211        return sb.toString();
212    }
213    
214    /**
215     * Get the languages of the catalog site
216     * @return the list of catalog site languages
217     */
218    protected List<String> getCatalogSiteLangages()
219    {
220        String catalogSiteName = _projectManager.getCatalogSiteName();
221        if (StringUtils.isNotEmpty(catalogSiteName))
222        {
223            Site siteCatalog = _siteManager.getSite(catalogSiteName);
224            if (siteCatalog != null)
225            {
226                return siteCatalog.getSitemaps().stream()
227                    .map(Sitemap::getSitemapName)
228                    .collect(Collectors.toList());
229            }
230        }
231        return null;
232    }
233    
234    /**
235     * Format a String to csv
236     * @param line strings to transform
237     * @return a string representing the input strings
238     */
239    protected static String formatToCsv(String... line)
240    {
241        return Arrays.stream(line).collect(Collectors.joining("\";\"", "\"", "\""));
242    }
243    
244    /**
245     * Generate the header for projects
246     * @param reportWithMembers also report members
247     * @param reportWithManagers also report managers
248     * @param lang language to use to create header
249     * @return a String representing the csv header
250     */
251    protected String getProjectCsvHeader(boolean reportWithMembers, boolean reportWithManagers, String lang)
252    {
253        StringBuilder sb = new StringBuilder(formatToCsv(
254                translateKey("PLUGIN_WORKSPACES_SERVICE_REPORTS_CSV_HEADER_PROJECT_NAME", lang),
255                translateKey("PLUGIN_WORKSPACES_SERVICE_REPORTS_CSV_HEADER_PROJECT_DESC", lang),
256                translateKey("PLUGIN_WORKSPACES_SERVICE_REPORTS_CSV_HEADER_PROJECT_CATEGORY", lang),
257                translateKey("PLUGIN_WORKSPACES_SERVICE_REPORTS_CSV_HEADER_PROJECT_CREATION_DATE", lang),
258                translateKey("PLUGIN_WORKSPACES_SERVICE_REPORTS_CSV_HEADER_PROJECT_ACTIVITY_DATE", lang),
259                translateKey("PLUGIN_WORKSPACES_SERVICE_REPORTS_CSV_HEADER_PROJECT_KEYWONDS", lang),
260                translateKey("PLUGIN_WORKSPACES_SERVICE_REPORTS_CSV_HEADER_PROJECT_MANAGERS", lang),
261                translateKey("PLUGIN_WORKSPACES_SERVICE_REPORTS_CSV_HEADER_PROJECT_VISIBILITY", lang)));
262        
263        if (reportWithMembers || reportWithManagers)
264        {
265            sb.append(";");
266            sb.append(getMemberCsvHeader(lang));
267        }
268        
269        
270        return sb.toString();
271    }
272    
273    private String getMemberCsvHeader(String lang)
274    {
275        return formatToCsv(
276                translateKey("PLUGIN_WORKSPACES_SERVICE_REPORTS_CSV_HEADER_MEMBER_TITLE", lang),
277                translateKey("PLUGIN_WORKSPACES_SERVICE_REPORTS_CSV_HEADER_MEMBER_FIRSTNAME", lang),
278                translateKey("PLUGIN_WORKSPACES_SERVICE_REPORTS_CSV_HEADER_MEMBER_LASTNAME", lang),
279                translateKey("PLUGIN_WORKSPACES_SERVICE_REPORTS_CSV_HEADER_MEMBER_FUNCTION", lang),
280                translateKey("PLUGIN_WORKSPACES_SERVICE_REPORTS_CSV_HEADER_MEMBER_ORGANISATION", lang),
281                translateKey("PLUGIN_WORKSPACES_SERVICE_REPORTS_CSV_HEADER_MEMBER_PHONE", lang),
282                translateKey("PLUGIN_WORKSPACES_SERVICE_REPORTS_CSV_HEADER_MEMBER_MAIL", lang),
283                translateKey("PLUGIN_WORKSPACES_SERVICE_REPORTS_CSV_HEADER_MEMBER_SKILLS", lang));
284    }
285    
286    private String projectToCsv(Project project, boolean reportWithMembers, boolean reportWithManagers, String lang, List<String> langages)
287    {
288        Set<String> projectCategories = project.getCategories();
289        String mainCategory = projectCategories.size() > 0 ? projectCategories.iterator().next() : "";
290                
291        String lastActivityDate = getProjectLastActivityDate(project);
292        
293        String projectmanager = Arrays.stream(project.getManagers())
294            .map(_userManager::getUser)
295            .filter(Objects::nonNull)
296            .map(user -> user.getFullName() + " (" + user.getIdentity().getPopulationId() + ")")
297            .collect(Collectors.joining(","));
298        
299        InscriptionStatus inscriptionStatus = project.getInscriptionStatus();
300        String inscriptionStatusString = InscriptionStatus.PRIVATE.equals(inscriptionStatus) ? translateKey("PLUGIN_WORKSPACES_SERVICE_REPORTS_CSV_HEADER_INSCRIPTION_STATUS_PRIVATE", lang)
301                : InscriptionStatus.MODERATED.equals(inscriptionStatus) ? translateKey("PLUGIN_WORKSPACES_SERVICE_REPORTS_CSV_HEADER_INSCRIPTION_STATUS_MODERATED", lang)
302                        : InscriptionStatus.OPEN.equals(inscriptionStatus) ? translateKey("PLUGIN_WORKSPACES_SERVICE_REPORTS_CSV_HEADER_INSCRIPTION_STATUS_OPEN", lang) : "";
303        
304        
305        StringBuilder sb = new StringBuilder(formatToCsv(
306                project.getTitle(),
307                StringUtils.defaultString(project.getDescription(), "").replaceAll("[\r\n]+", " / "),
308                mainCategory,
309                __ZONED_DATE_FORMATER.format(project.getCreationDate()),
310                lastActivityDate,
311                getKeywords(project, lang),
312                projectmanager,
313                inscriptionStatusString
314            ));
315        
316        int projectPadding = (reportWithMembers || reportWithManagers) ? __REPORT_COLUMNS_SIZE_MEMBER : 0;
317        
318        sb.append(StringUtils.repeat(";-", projectPadding));
319        
320        if (reportWithManagers)
321        {
322            // project managers
323            String managerPrefix = "\"" + translateKey("PLUGIN_WORKSPACES_SERVICE_REPORTS_CSV_HEADER_PROJECT_MANAGER", lang) + "\";\"" + project.getTitle() + "\"" + StringUtils.repeat(";-", __REPORT_COLUMNS_SIZE_PROJECT - 1) + ";";
324                    
325            String managersCsv = Arrays.stream(project.getManagers())
326                .map(userIdentity -> createProjectMemberFromUser(userIdentity, lang,  langages))
327                .filter(Objects::nonNull)
328                .map(ProjectMember::toCsv)
329                .map(managerCsv -> managerPrefix + managerCsv)
330                .collect(Collectors.joining("\n"));
331            if (StringUtils.isNotEmpty(managersCsv))
332            {
333                sb.append("\n");
334                sb.append(managersCsv);
335            }
336        }
337        
338        if (reportWithMembers)
339        {
340            // project members
341            String memberPrefix = "\"" + translateKey("PLUGIN_WORKSPACES_SERVICE_REPORTS_CSV_HEADER_PROJECT_MEMBER", lang) + "\";\"" + project.getTitle() + "\"" + StringUtils.repeat(";-", __REPORT_COLUMNS_SIZE_PROJECT - 1) + ";";
342            
343            String projectMembersCsv = getProjectMembersCsv(project, lang, langages).stream()
344                    .map(memberCsv -> memberPrefix + memberCsv)
345                    .collect(Collectors.joining("\n"));
346            
347            if (StringUtils.isNotEmpty(projectMembersCsv))
348            {
349                sb.append("\n");
350                sb.append(projectMembersCsv);
351            }
352        }
353        
354
355        
356        return sb.toString();
357    }
358    
359    /**
360     * Get the project's keywords
361     * @param project the project
362     * @param lang the current language
363     * @return the keywords as label
364     */
365    protected String getKeywords(Project project, String lang)
366    {
367        List<String> keywordLabels = new ArrayList<>();
368        
369        for (String keyword : project.getKeywords())
370        {
371            Tag tag = _keywordsDAO.getTag(keyword, Collections.emptyMap());
372            if (tag != null)
373            {
374                String title = _i18nUtils.translate(tag.getTitle(), lang);
375                keywordLabels.add(title);
376            }
377        }
378        
379        return StringUtils.join(keywordLabels, ",");
380    }
381    
382    /**
383     * Get the last activity date of the project
384     * @param project project to analyze
385     * @return a formatted date
386     */
387    protected String getProjectLastActivityDate(Project project)
388    {
389        SortCriteria sortCriteria = new SortCriteria();
390        sortCriteria.addJCRPropertyCriterion(EventType.EVENT_DATE, false, false);
391        String xpathQuery = "//element(*, ametys:event)[@ametys:projectName = '" + project.getName() + "']" + sortCriteria.build();
392
393        Session session = null;
394        try
395        {
396            session  = _repository.login();
397            @SuppressWarnings("deprecation")
398            Query query = session.getWorkspace().getQueryManager().createQuery(xpathQuery, Query.XPATH);
399            NodeIterator nodes = query.execute().getNodes();
400            if (nodes.hasNext())
401            {
402                Node node = (Node) nodes.next();
403                Calendar date = node.getProperty("ametys:date").getValue().getDate();
404                return __DATE_FORMATER.format(date.getTime());
405            }
406        }
407        catch (RepositoryException ex)
408        {
409            throw new AmetysRepositoryException("An error occured executing the JCR query : " + xpathQuery, ex);
410        }
411        finally
412        {
413            if (session != null)
414            {
415                session.logout();
416            }
417        }
418        
419        return "";
420    }
421    
422    /**
423     * Get a {@link ProjectMember} from a user
424     * @param userIdentity user
425     * @param lang language to use to parse user
426     * @param projectLanguages languages of the catalog site
427     * @return the user as a {@link ProjectMember}
428     */
429    protected ProjectMember createProjectMemberFromUser(UserIdentity userIdentity, String lang, List<String> projectLanguages)
430    {
431        User user = _userManager.getUser(userIdentity);
432        if (user == null)
433        {
434            return null;
435        }
436        Content userContent = null;
437        if (StringUtils.isNotEmpty(lang))
438        {
439            userContent = _userDirectoryHelper.getUserContent(userIdentity, lang);
440        }
441        
442        // If requested language is not available, find one that is available
443        if (userContent == null)
444        {
445            userContent = projectLanguages != null ? projectLanguages.stream()
446                .map(l -> _userDirectoryHelper.getUserContent(userIdentity, l))
447                .filter(Objects::nonNull)
448                .findFirst()
449                .orElse(null) : null;
450        }
451        
452        if (userContent == null)
453        {
454            return new ProjectMember(UserIdentity.userIdentityToString(userIdentity),
455                    "", 
456                    user.getFirstName(), 
457                    user.getLastName(), 
458                    "",
459                    "", 
460                    "", 
461                    StringUtils.defaultIfEmpty(user.getEmail(), ""), 
462                    "");
463        }
464        
465        ModelAwareDataHolder dataHolder = userContent.getDataHolder();
466        ContentValue[] skillsContents = dataHolder.getValue("skills");
467        String skills = skillsContents != null ? Arrays.stream(skillsContents)
468                .filter(Objects::nonNull)
469                .map(ContentValue::getContent)
470                .filter(Objects::nonNull)
471                .map(skill -> (String) skill.getDataHolder().getValue("title"))
472                .collect(Collectors.joining(","))
473                : "";
474                
475        
476        return new ProjectMember(UserIdentity.userIdentityToString(userIdentity),
477                StringUtils.defaultIfEmpty(dataHolder.getValue("title"), ""), 
478                user.getFirstName(), 
479                user.getLastName(), 
480                StringUtils.defaultIfEmpty(dataHolder.getValue("function"), ""),
481                StringUtils.defaultIfEmpty(dataHolder.getValue("organisation"), ""), 
482                StringUtils.defaultIfEmpty(dataHolder.getValue("phone"), ""), 
483                StringUtils.defaultIfEmpty(user.getEmail(), ""), 
484                skills);
485    }
486    
487    /**
488     * Get the csv for members
489     * @param project project to use
490     * @param lang language to use to parse user
491     * @param languages languages of the catalog site
492     * @return users as csv
493     */
494    protected List<String> getProjectMembersCsv(Project project, String lang, List<String> languages)
495    {
496        
497        
498        return _projectMemberManager.getProjectMembers(project, true, false)
499                .stream()
500                .map(member -> createProjectMemberFromUser(member.getUser().getIdentity(), lang, languages))
501                .sorted(Comparator.comparing(ProjectMember::getSortableName))
502                .map(ProjectMember::toCsv)
503                .collect(Collectors.toList());
504    }
505        
506    /**
507     * Small shortcut to translate a key
508     * @param key i18n key
509     * @param lang requested language
510     * @return the translated key
511     */
512    protected String translateKey(String key, String lang)
513    {
514        I18nizableText i18n = new I18nizableText("plugin.workspaces", key);
515        return _i18nUtils.translate(i18n, lang);
516    }
517    
518    static class ProjectMember
519    {
520        private String _id;
521        private String _title;
522        private String _firstname;
523        private String _lastname;
524        private String _role;
525        private String _structure;
526        private String _telephone;
527        private String _email;
528        private String _skills;
529        
530        ProjectMember(String id, String title, String firstname, String lastname, String role, String structure, String telephone, String email, String skills)
531        {
532            _id = id;
533            _title = title;
534            _firstname = firstname;
535            _lastname = lastname;
536            _role = role;
537            _structure = structure;
538            _telephone = telephone;
539            _email = email;
540            _skills = skills;
541        }
542        
543        public String getId()
544        {
545            return _id;
546        }
547        
548        public String getSortableName()
549        {
550            return _lastname + " " + _firstname;
551        }
552        
553        @Override
554        public boolean equals(Object obj)
555        {
556            return _id != null && obj instanceof ProjectMember && _id.equals(((ProjectMember) obj).getId());
557        }
558        
559        /**
560         * Format a {@link ProjectMember} to a csv line
561         * @return a String representing a {@link ProjectMember} in a csv format
562         */
563        public String toCsv()
564        {
565            return formatToCsv(_title,
566                _firstname,
567                _lastname,
568                _role,
569                _structure,
570                _telephone,
571                _email,
572                _skills);
573        }
574
575        @Override
576        public int hashCode()
577        {
578            return _id.hashCode();
579        }
580    }
581}