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.odf.schedulable;
017
018import java.io.File;
019import java.io.FileOutputStream;
020import java.io.IOException;
021import java.io.InputStream;
022import java.io.OutputStream;
023import java.time.ZonedDateTime;
024import java.time.format.DateTimeFormatter;
025import java.util.ArrayList;
026import java.util.HashMap;
027import java.util.List;
028import java.util.Map;
029import java.util.Map.Entry;
030import java.util.Optional;
031import java.util.stream.Collectors;
032import java.util.stream.Stream;
033
034import org.apache.avalon.framework.service.ServiceException;
035import org.apache.avalon.framework.service.ServiceManager;
036import org.apache.cocoon.components.ContextHelper;
037import org.apache.cocoon.components.source.impl.SitemapSource;
038import org.apache.cocoon.environment.Request;
039import org.apache.commons.io.FileUtils;
040import org.apache.commons.lang.StringUtils;
041import org.apache.commons.lang3.exception.ExceptionUtils;
042import org.apache.excalibur.source.SourceResolver;
043import org.apache.excalibur.source.SourceUtil;
044import org.quartz.JobDataMap;
045import org.quartz.JobDetail;
046import org.quartz.JobExecutionContext;
047
048import org.ametys.cms.repository.Content;
049import org.ametys.cms.schedule.AbstractSendingMailSchedulable;
050import org.ametys.cms.workflow.ContentWorkflowHelper;
051import org.ametys.core.schedule.Schedulable;
052import org.ametys.odf.ProgramItem;
053import org.ametys.odf.schedulable.EducationalBookletSchedulable.EducationalBookletReport.ReportStatus;
054import org.ametys.plugins.core.schedule.Scheduler;
055import org.ametys.plugins.repository.AmetysObjectResolver;
056import org.ametys.runtime.config.Config;
057import org.ametys.runtime.i18n.I18nizableText;
058import org.ametys.runtime.i18n.I18nizableTextParameter;
059import org.ametys.runtime.util.AmetysHomeHelper;
060
061/**
062 * {@link Schedulable} for educational booklet.
063 */
064public class EducationalBookletSchedulable extends AbstractSendingMailSchedulable
065{
066    /** The directory under ametys home data directory for educational booklet */
067    public static final String EDUCATIONAL_BOOKLET_DIR_NAME = "odf/booklet";
068    
069    /** Scheduler parameter name of including of subprograms */
070    public static final String PARAM_INCLUDE_SUBPROGRAMS = "includeSubPrograms";
071    
072    /** Scheduler parameter name of including of subprograms */
073    public static final String PARAM_PROGRAM_ITEM_ID = "programItemId";
074    
075    /** Map key where the report is stored */
076    protected static final String _EDUCATIONAL_BOOKLET_REPORT = "educationalBookletReport";
077
078    /** The avalon source resolver. */
079    protected SourceResolver _sourceResolver;
080    
081    /** The ametys object resolver */
082    protected AmetysObjectResolver _resolver;
083    
084    /** The content workflow helper */
085    protected ContentWorkflowHelper _contentWorkflowHelper;
086    
087    @Override
088    public void service(ServiceManager manager) throws ServiceException
089    {
090        super.service(manager);
091        _sourceResolver = (SourceResolver) manager.lookup(SourceResolver.ROLE);
092        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
093        _contentWorkflowHelper = (ContentWorkflowHelper) manager.lookup(ContentWorkflowHelper.ROLE);
094    }
095
096    @Override
097    protected void _doExecute(JobExecutionContext context) throws Exception
098    {
099        File bookletDirectory = new File(AmetysHomeHelper.getAmetysHomeData(), EDUCATIONAL_BOOKLET_DIR_NAME);
100        FileUtils.forceMkdir(bookletDirectory);
101        
102        Map<String, Object> pdfParameters = new HashMap<>();
103        
104        JobDataMap jobDataMap = context.getJobDetail().getJobDataMap();
105        pdfParameters.put(PARAM_INCLUDE_SUBPROGRAMS, jobDataMap.get(Scheduler.PARAM_VALUES_PREFIX + PARAM_INCLUDE_SUBPROGRAMS));
106        
107        _generateProgramItemsEducationalBooklet(context, bookletDirectory, pdfParameters);
108    }
109
110    @Override
111    protected I18nizableText _getSuccessMailSubject(JobExecutionContext context)
112    {
113        EducationalBookletReport report = (EducationalBookletReport) context.get(_EDUCATIONAL_BOOKLET_REPORT);
114        return new I18nizableText("plugin.odf", _getMailSubjectBaseKey() + report.getCurrentStatus());
115    }
116    
117    @Override
118    protected I18nizableText _getErrorMailSubject(JobExecutionContext context)
119    {
120        return new I18nizableText("plugin.odf", _getMailSubjectBaseKey() + "ERROR");
121    }
122    
123    /**
124     * The base key for mail subjects.
125     * @return The prefix of an I18N key
126     */
127    protected String _getMailSubjectBaseKey()
128    {
129        return "PLUGINS_ODF_EDUCATIONAL_BOOKLET_PROGRAMITEM_MAIL_SUBJECT_";
130    }
131    
132    @Override
133    protected I18nizableText _getSuccessMailBody(JobExecutionContext context) throws IOException
134    {
135        EducationalBookletReport report = (EducationalBookletReport) context.get(_EDUCATIONAL_BOOKLET_REPORT);
136
137        Map<String, I18nizableTextParameter> params = new HashMap<>();
138        
139        List<Content> exportedProgramItems = report.getExportedProgramItems();
140        List<Content> programItemsInError = report.getProgramItemsInError();
141
142        ReportStatus status = report.getCurrentStatus();
143        String i18nKey = _getMailBodyBaseKey();
144        switch (status)
145        {
146            case ERROR:
147                i18nKey += "ERROR";
148                if (programItemsInError.size() > 1)
149                {
150                    i18nKey += "_SEVERAL";
151                }
152                params.put("programItem", _getProgramItemsI18nText(programItemsInError));
153                break;
154            case PARTIAL:
155                params.put("error", new I18nizableText("plugin.odf", _getMailBodyBaseKey() + "ERROR", Map.of("programItem", _getProgramItemsI18nText(programItemsInError))));
156                // fallthrough
157            case SUCCESS:
158                i18nKey += "SUCCESS";
159                String downloadLink = StringUtils.removeEndIgnoreCase(Config.getInstance().getValue("cms.url"), "/index.html");
160                if (exportedProgramItems.size() > 1)
161                {
162                    i18nKey += "_SEVERAL";
163                    // Compress to a ZIP if there are several exported program items
164                    DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
165                    String zipKey = ZonedDateTime.now().format(formatter);
166                    _generateEducationalBookletZip(context, report.getBookletDirectory(), exportedProgramItems, zipKey);
167                    downloadLink += "/plugins/odf/download/educational-booklet-" + zipKey + "/educational-booklet.zip";
168                }
169                else
170                {
171                    Content content = exportedProgramItems.get(0); 
172                    downloadLink += "/plugins/odf/download/" + content.getLanguage() + "/educational-booklet.pdf?programItemId=" + content.getId();
173                }
174                params.put("link", new I18nizableText(downloadLink));
175                params.put("programItem", _getProgramItemsI18nText(exportedProgramItems));
176                break;
177            default:
178                // Shouldn't happen
179                break;
180        }
181        
182        return new I18nizableText("plugin.odf", i18nKey, params);
183    }
184
185    @Override
186    protected I18nizableText _getErrorMailBody(JobExecutionContext context, Throwable throwable)
187    {
188        List<Content> programItems = Optional.of(context)
189            .map(JobExecutionContext::getJobDetail)
190            .map(JobDetail::getJobDataMap)
191            .map(map -> map.getString(Scheduler.PARAM_VALUES_PREFIX + "programItemIds"))
192            .map(ids -> ids.split(","))
193            .map(Stream::of)
194            .orElseGet(() -> Stream.empty())
195            .filter(StringUtils::isNotBlank)
196            .map(_resolver::<Content>resolveById)
197            .collect(Collectors.toList());
198
199        Map<String, I18nizableTextParameter> params = new HashMap<>();
200        
201        params.put("programItem", _getProgramItemsI18nText(programItems));
202        
203        if (throwable != null)
204        {
205            String error = ExceptionUtils.getStackTrace(throwable);
206            params.put("error", new I18nizableText(error));
207        }
208        
209        return new I18nizableText("plugin.odf", _getMailBodyBaseKey() + "FAILURE" + (programItems.size() > 1 ? "_SEVERAL" : StringUtils.EMPTY), params);
210    }
211
212    /**
213     * The base key for mail bodies.
214     * @return The prefix of an I18N key
215     */
216    protected String _getMailBodyBaseKey()
217    {
218        return "PLUGINS_ODF_EDUCATIONAL_BOOKLET_PROGRAMITEM_MAIL_BODY_";
219    }
220    
221    /**
222     * Transform a list of program items in a readable list.
223     * @param programItems The program items to iterate on
224     * @return An {@link I18nizableText} representing the program items
225     */
226    protected I18nizableText _getProgramItemsI18nText(List<Content> programItems)
227    {
228        List<String> programItemsTitle = programItems.stream()
229            .map(c -> c.getTitle())
230            .collect(Collectors.toList());
231        
232        String readableTitles = programItemsTitle.size() == 1 ? programItemsTitle.get(0) : "-" + StringUtils.join(programItemsTitle, "\n- ");
233       
234        return new I18nizableText(readableTitles);
235    }
236    
237    /**
238     * Generate educational booklet for each program items
239     * @param context the context
240     * @param bookletDirectory the booklet directory
241     * @param pdfParameters the parameters to generate PDF
242     */
243    protected void _generateProgramItemsEducationalBooklet(JobExecutionContext context, File bookletDirectory, Map<String, Object> pdfParameters)
244    {
245        EducationalBookletReport report = new EducationalBookletReport(bookletDirectory);
246        
247        JobDataMap jobDataMap = context.getJobDetail().getJobDataMap();
248        String idsAsString = jobDataMap.getString(Scheduler.PARAM_VALUES_PREFIX + "programItemIds");
249        for (String programItemId : StringUtils.split(idsAsString, ","))
250        {
251            Content programItem = _resolver.resolveById(programItemId);
252            try
253            {
254                _generateProgramItemEducationalBookletPDF(bookletDirectory, programItem, pdfParameters);
255                report.addExportedProgramItem(programItem);
256            }
257            catch (IOException e)
258            {
259                getLogger().error("An error occurred while generating the educational booklet of program item '{}' ({}).", programItem.getTitle(), ((ProgramItem) programItem).getCode(), e);
260                report.addProgramItemIhError(programItem);
261            }
262        }
263        
264        context.put(_EDUCATIONAL_BOOKLET_REPORT, report);
265    }
266    
267    /**
268     * Generate the educational booklet for one program item
269     * @param bookletDirectory the booklet directory
270     * @param programItem the program item
271     * @param pdfParameters the parameters to generate PDF
272     * @throws IOException if an error occured with files
273     */
274    protected void _generateProgramItemEducationalBookletPDF(File bookletDirectory, Content programItem, Map<String, Object> pdfParameters) throws IOException
275    {
276        File programItemDir = new File(bookletDirectory, programItem.getName());
277        if (!programItemDir.exists())
278        {
279            programItemDir.mkdir();
280        }
281        
282        File langDir = new File (programItemDir, programItem.getLanguage());
283        if (!langDir.exists())
284        {
285            langDir.mkdir();
286        }
287        
288        Map<String, Object> localPdfParameters = new HashMap<>(pdfParameters);
289        localPdfParameters.put(PARAM_PROGRAM_ITEM_ID, programItem.getId());
290        _generateFile(
291            langDir,
292            "cocoon://_plugins/odf/booklet/" + programItem.getLanguage() + "/educational-booklet.pdf",
293            localPdfParameters,
294            "educational-booklet",
295            "pdf"
296        );
297    }
298    
299    /**
300     * Generate the zip with the educational booklet for each exported program items
301     * @param context the context
302     * @param bookletDirectory the booklet directory
303     * @param exportedProgramItems the exported program items
304     * @param zipKey the zip key
305     * @throws IOException if an error occured with files
306     */
307    protected void _generateEducationalBookletZip(JobExecutionContext context, File bookletDirectory, List<Content> exportedProgramItems, String zipKey) throws IOException
308    {
309        String ids = exportedProgramItems.stream()
310            .map(Content::getId)
311            .collect(Collectors.joining(","));
312        
313        _generateFile(
314            bookletDirectory,
315            "cocoon://_plugins/odf/booklet/educational-booklet.zip",
316            Map.of("programItemIds", ids),
317            "educational-booklet-" + zipKey,
318            "zip"
319        );
320    }
321
322    /**
323     * Generate a file from the uri
324     * @param bookletDirectory the booklet directory where the file are created
325     * @param uri the uri
326     * @param params the parameters of the uri
327     * @param name the name of the file
328     * @param extension the extension of the file
329     * @throws IOException if an error occured with files
330     */
331    protected void _generateFile(File bookletDirectory, String uri, Map<String, Object> params, String name, String extension) throws IOException
332    {
333        Request request = ContextHelper.getRequest(_context);
334        
335        SitemapSource source = null;
336        File pdfTmpFile = null;
337        try
338        {
339            // Set PDF parameters as request attributes
340            for (Entry<String, Object> param : params.entrySet())
341            {
342                request.setAttribute(param.getKey(), param.getValue());
343            }
344            // Resolve the export to the appropriate pdf url.
345            source = (SitemapSource) _sourceResolver.resolveURI(uri, null, params);
346            
347            // Save the pdf into a temporary file.
348            String tmpFile = name + ".tmp." + extension;
349            pdfTmpFile = new File(bookletDirectory, tmpFile);
350            
351            try (OutputStream pdfTmpOs = new FileOutputStream(pdfTmpFile); InputStream sourceIs = source.getInputStream())
352            {
353                SourceUtil.copy(sourceIs, pdfTmpOs);
354            }
355            
356            // If all went well until now, rename the temporary file 
357            String fileName = name + "." + extension;
358            File bookletFile = new File(bookletDirectory, fileName);
359            if (bookletFile.exists())
360            {
361                bookletFile.delete();
362            }
363            
364            if (!pdfTmpFile.renameTo(bookletFile))
365            {
366                throw new IOException("Fail to rename " + tmpFile + " to " + fileName);
367            }
368        }
369        finally
370        {
371            if (pdfTmpFile != null)
372            {
373                FileUtils.deleteQuietly(pdfTmpFile);
374            }
375            
376            if (source != null)
377            {
378                _sourceResolver.release(source);
379            }
380            
381            for (Entry<String, Object> param : params.entrySet())
382            {
383                request.removeAttribute(param.getKey());
384            }
385        }
386    }
387    
388    /**
389     * Object to represent list of programs exported and list of programs with error after PDF generation
390     */
391    protected static class EducationalBookletReport
392    {
393        /**
394         * Status of export
395         */
396        public enum ReportStatus
397        {
398            
399            /** All program items have been exported successfully */
400            SUCCESS,
401            /** Program items have been exported partially */
402            PARTIAL,
403            /** Error during export */
404            ERROR
405            
406        }
407        
408        private File _bookletDirectory;
409        private List<Content> _exportedProgramItems;
410        private List<Content> _programItemsInError;
411        
412        /**
413         * The constructor
414         * @param bookletDirectory The booklet directory
415         */
416        public EducationalBookletReport(File bookletDirectory)
417        {
418            _bookletDirectory = bookletDirectory;
419            _exportedProgramItems = new ArrayList<>();
420            _programItemsInError = new ArrayList<>();
421        }
422        
423        /**
424         * Get the booklet directory
425         * @return the booklet directory
426         */
427        public File getBookletDirectory()
428        {
429            return _bookletDirectory;
430        }
431        
432        /**
433         * Get the list of exported program items
434         * @return the list of exported program items
435         */
436        public List<Content> getExportedProgramItems()
437        {
438            return _exportedProgramItems;
439        }
440        
441        /**
442         * Add a content as exported
443         * @param content the content to add
444         */
445        public void addExportedProgramItem(Content content)
446        {
447            _exportedProgramItems.add(content);
448        }
449        
450        /**
451         * Set the exported program items
452         * @param programItems the list of exported program items
453         */
454        public void setExportedProgramItems(List<Content> programItems)
455        {
456            _exportedProgramItems = programItems;
457        }
458        
459        /**
460         * Get the program items in error
461         * @return the list of program items in error
462         */
463        public List<Content> getProgramItemsInError()
464        {
465            return _programItemsInError;
466        }
467        
468        /**
469         * Add program item as error
470         * @param programItem the program item to add
471         */
472        public void addProgramItemIhError(Content programItem)
473        {
474            _programItemsInError.add(programItem);
475        }
476        
477        /**
478         * The current status of the educational booklet generation.
479         * @return The report status
480         */
481        public ReportStatus getCurrentStatus()
482        {
483            if (_programItemsInError.isEmpty())
484            {
485                return ReportStatus.SUCCESS;
486            }
487            
488            if (_exportedProgramItems.isEmpty())
489            {
490                return ReportStatus.ERROR;
491            }
492            
493            return ReportStatus.PARTIAL;
494        }
495    }
496}