001/*
002 *  Copyright 2021 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.util.ArrayList;
024import java.util.Arrays;
025import java.util.HashMap;
026import java.util.List;
027import java.util.Map;
028import java.util.Objects;
029import java.util.Random;
030
031import org.apache.avalon.framework.service.ServiceException;
032import org.apache.avalon.framework.service.ServiceManager;
033import org.apache.cocoon.components.ContextHelper;
034import org.apache.cocoon.components.source.impl.SitemapSource;
035import org.apache.commons.io.FileUtils;
036import org.apache.commons.lang3.StringUtils;
037import org.apache.commons.lang3.Strings;
038import org.apache.commons.lang3.exception.ExceptionUtils;
039import org.apache.excalibur.source.SourceResolver;
040import org.apache.excalibur.source.SourceUtil;
041import org.quartz.JobDataMap;
042import org.quartz.JobExecutionContext;
043
044import org.ametys.cms.schedule.AbstractSendingMailSchedulable;
045import org.ametys.core.schedule.progression.ContainerProgressionTracker;
046import org.ametys.core.ui.mail.StandardMailBodyHelper;
047import org.ametys.core.util.JSONUtils;
048import org.ametys.odf.ODFHelper;
049import org.ametys.odf.catalog.Catalog;
050import org.ametys.odf.catalog.CatalogsManager;
051import org.ametys.odf.enumeration.OdfReferenceTableHelper;
052import org.ametys.odf.orgunit.OrgUnit;
053import org.ametys.plugins.core.schedule.Scheduler;
054import org.ametys.plugins.repository.AmetysObjectResolver;
055import org.ametys.plugins.repository.UnknownAmetysObjectException;
056import org.ametys.runtime.config.Config;
057import org.ametys.runtime.i18n.I18nizableText;
058import org.ametys.runtime.model.ElementDefinition;
059import org.ametys.runtime.util.AmetysHomeHelper;
060
061/**
062 * Schedulable to export the ODF catalog as PDF
063 */
064public class CatalogPDFExportSchedulable extends AbstractSendingMailSchedulable
065{
066    /** The key for the catalog */
067    public static final String JOBDATAMAP_CATALOG_KEY = "catalog";
068    /** The key for the lang */
069    public static final String JOBDATAMAP_LANG_KEY = "lang";
070    /** The key for the orgunits */
071    public static final String JOBDATAMAP_ORGUNIT_KEY = "orgunit";
072    /** The key for the degrees */
073    public static final String JOBDATAMAP_DEGREE_KEY = "degree";
074    /** The key for the query'id */
075    public static final String JOBDATAMAP_QUERY_KEY = "queryId";
076    /** The key for the mode */
077    public static final String JOBDATAMAP_MODE_KEY = "mode";
078    /** The key for including subprograms */
079    public static final String JOBDATAMAP_INCLUDE_SUBPROGRAMS = "includeSubPrograms";
080    /** Mode when catalog is generated from a query */
081    public static final String MODE_QUERY = "QUERY";
082
083    /** Map key where the generated filename is stored */
084    protected static final String _CATALOG_FILENAME = "catalogFilename";
085    
086    /** The Ametys object resolver. */
087    protected AmetysObjectResolver _resolver;
088    /** The ODF reference table helper. */
089    protected OdfReferenceTableHelper _odfRefTableHelper;
090    /** The avalon source resolver. */
091    protected SourceResolver _sourceResolver;
092
093    /** The catalog directory. */
094    protected File _catalogRootDirectory;
095    /** The JSON utils */
096    protected JSONUtils _jsonUtils;
097    /** The catalog manager */
098    protected CatalogsManager _catalogsManager;
099    
100    @Override
101    public void service(ServiceManager manager) throws ServiceException
102    {
103        super.service(manager);
104        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
105        _odfRefTableHelper = (OdfReferenceTableHelper) manager.lookup(OdfReferenceTableHelper.ROLE);
106        _sourceResolver = (SourceResolver) manager.lookup(SourceResolver.ROLE);
107        _jsonUtils = (JSONUtils) manager.lookup(JSONUtils.ROLE);
108        _catalogsManager = (CatalogsManager) manager.lookup(CatalogsManager.ROLE);
109    }
110    
111    @Override
112    public void initialize() throws Exception
113    {
114        super.initialize();
115        _catalogRootDirectory = new File(AmetysHomeHelper.getAmetysHomeData(), "odf/catalog");
116    }
117    
118    @Override
119    protected void _doExecute(JobExecutionContext context, ContainerProgressionTracker progressionTracker) throws Exception
120    {
121        SitemapSource source = null;
122        File pdfTmpFile = null;
123        try
124        {
125            JobDataMap jobDataMap = context.getJobDetail().getJobDataMap();
126            String catalog = jobDataMap.getString(Scheduler.PARAM_VALUES_PREFIX + JOBDATAMAP_CATALOG_KEY);
127            String lang = jobDataMap.getString(Scheduler.PARAM_VALUES_PREFIX + JOBDATAMAP_LANG_KEY);
128            
129            FileUtils.forceMkdir(_catalogRootDirectory);
130            
131            File catalogDir = new File (_catalogRootDirectory, catalog);
132            if (!catalogDir.exists())
133            {
134                catalogDir.mkdir();
135            }
136            
137            File langDir = new File (catalogDir, lang);
138            if (!langDir.exists())
139            {
140                langDir.mkdir();
141            }
142            
143            String catalogFilename;
144            
145            // Resolve the export to the appropriate pdf url.
146            Map<String, Object> params = new HashMap<>();
147            
148            String mode = jobDataMap.getString(Scheduler.PARAM_VALUES_PREFIX + JOBDATAMAP_MODE_KEY);
149            params.put(JOBDATAMAP_MODE_KEY, mode);
150            
151            if (MODE_QUERY.equals(mode))
152            {
153                String queryId = jobDataMap.getString(Scheduler.PARAM_VALUES_PREFIX + JOBDATAMAP_QUERY_KEY);
154                
155                if (StringUtils.isEmpty(queryId))
156                {
157                    throw new IllegalArgumentException("Id of query is missing to generate PDF catalog from query");
158                }
159                params.put(JOBDATAMAP_QUERY_KEY, queryId);
160                
161                catalogFilename = _getCatalogFilename(queryId, null, null);
162            }
163            else
164            {
165                // Org units
166                Object[] orgunits = _jsonUtils.convertJsonToArray(jobDataMap.getString(Scheduler.PARAM_VALUES_PREFIX + JOBDATAMAP_ORGUNIT_KEY));
167                if (orgunits.length > 0)
168                {
169                    params.put(JOBDATAMAP_ORGUNIT_KEY, orgunits);
170                }
171                
172                // Degrees
173                Object[] degrees =  _jsonUtils.convertJsonToArray(jobDataMap.getString(Scheduler.PARAM_VALUES_PREFIX + JOBDATAMAP_DEGREE_KEY));
174                if (degrees.length > 0)
175                {
176                    params.put(JOBDATAMAP_DEGREE_KEY, degrees);
177                }
178                
179                catalogFilename = _getCatalogFilename(null, orgunits, degrees);
180            }
181            
182            // Include subprograms
183            boolean includeSubprograms = jobDataMap.getBoolean(Scheduler.PARAM_VALUES_PREFIX + JOBDATAMAP_INCLUDE_SUBPROGRAMS);
184            params.put(JOBDATAMAP_INCLUDE_SUBPROGRAMS, includeSubprograms);
185            
186            // Set the attribute to force the switch to live data
187            ContextHelper.getRequest(_context).setAttribute(ODFHelper.REQUEST_ATTRIBUTE_VALID_LABEL, true);
188            source = (SitemapSource) _sourceResolver.resolveURI("cocoon://_plugins/odf/programs/" + catalog + "/" + lang + "/catalog.pdf", null, params);
189            
190            // Save the pdf into a temporary file.
191            String tmpFilename = catalogFilename + "-" + new Random().nextInt() + ".tmp.pdf";
192            pdfTmpFile = new File(langDir, tmpFilename);
193            
194            try (
195                OutputStream pdfTmpOs = new FileOutputStream(pdfTmpFile);
196                InputStream sourceIs = source.getInputStream()
197            )
198            {
199                SourceUtil.copy(sourceIs, pdfTmpOs);
200            }
201            
202            // If all went well until now, rename the temporary file
203            File catalogFile = new File(langDir, catalogFilename + ".pdf");
204            if (catalogFile.exists())
205            {
206                catalogFile.delete();
207            }
208            
209            context.put(_CATALOG_FILENAME, catalogFile.getName());
210            
211            if (!pdfTmpFile.renameTo(catalogFile))
212            {
213                throw new IOException("Fail to rename catalog.tmp.pdf to catalog.pdf");
214            }
215        }
216        finally
217        {
218            if (pdfTmpFile != null)
219            {
220                FileUtils.deleteQuietly(pdfTmpFile);
221            }
222            
223            if (source != null)
224            {
225                _sourceResolver.release(source);
226            }
227        }
228        
229    }
230    
231    /**
232     * Get the catalog PDF file name from configuration
233     * @param queryId The id of query to execute. <code>null</code> when export is not based on a query
234     * @param orgunits The restricted orgunits. <code>null</code> when export is based on a query
235     * @param degrees The restricted degrees. <code>null</code> when export is based on a query
236     * @return the computed catalog file name
237     */
238    protected String _getCatalogFilename(String queryId, Object[] orgunits, Object[] degrees)
239    {
240        List<String> filenamePrefix = new ArrayList<>();
241        
242        filenamePrefix.add("catalog");
243        
244        if (StringUtils.isNotEmpty(queryId))
245        {
246            filenamePrefix.add(StringUtils.substringAfter(queryId, "query://"));
247        }
248        else
249        {
250            if (orgunits != null && orgunits.length > 0)
251            {
252                Arrays.stream(orgunits)
253                    .map(String.class::cast)
254                    .map(this::_resolveSilently)
255                    .filter(Objects::nonNull)
256                    .map(OrgUnit::getUAICode)
257                    .filter(StringUtils::isNotEmpty)
258                    .forEach(filenamePrefix::add);
259            }
260            
261            // Degrees
262            if (degrees != null && degrees.length > 0)
263            {
264                Arrays.stream(degrees)
265                        .map(String.class::cast)
266                        .map(_odfRefTableHelper::getItemCode)
267                        .filter(StringUtils::isNotEmpty)
268                        .forEach(filenamePrefix::add);
269            }
270        }
271        return StringUtils.join(filenamePrefix, "-");
272    }
273    
274    private OrgUnit _resolveSilently(String ouId)
275    {
276        try
277        {
278            return _resolver.resolveById(ouId);
279        }
280        catch (UnknownAmetysObjectException e)
281        {
282            getLogger().warn("Can't find orgunit with id {}", ouId);
283            return null;
284        }
285    }
286    
287    @Override
288    public Map<String, ElementDefinition> getParameters()
289    {
290        // Remove unsupported widgets if necessary
291        return ODFSchedulableHelper.cleanUnsupportedWidgets(ContextHelper.getRequest(_context), super.getParameters());
292    }
293    
294    @Override
295    protected I18nizableText _getSuccessMailSubject(JobExecutionContext context) throws Exception
296    {
297        return new I18nizableText("plugin.odf", "PLUGINS_ODF_PDF_EXPORT_MAIL_SUBJECT");
298    }
299    
300    @Override
301    protected boolean _isMailBodyInHTML(JobExecutionContext context) throws Exception
302    {
303        return true;
304    }
305    
306    @Override
307    protected String _getSuccessMailBody(JobExecutionContext context, String language) throws Exception
308    {
309        JobDataMap jobDataMap = context.getJobDetail().getJobDataMap();
310        String catalogName = jobDataMap.getString(Scheduler.PARAM_VALUES_PREFIX + JOBDATAMAP_CATALOG_KEY);
311        String lang = jobDataMap.getString(Scheduler.PARAM_VALUES_PREFIX + JOBDATAMAP_LANG_KEY);
312        
313        String catalogTitle = _getCatalogTitle(context);
314        
315        String downloadLink = Strings.CI.removeEnd(Config.getInstance().getValue("cms.url"), "index.html");
316        downloadLink += (downloadLink.endsWith("/") ? "" : "/") + "plugins/odf/download/" + catalogName + "/" + lang + "/" + context.get(_CATALOG_FILENAME);
317
318        try
319        {
320            return StandardMailBodyHelper.newHTMLBody()
321                    .withTitle(new I18nizableText("plugin.odf", "PLUGINS_ODF_PDF_EXPORT_MAIL_SUBJECT"))
322                    .withMessage(new I18nizableText("plugin.odf", "PLUGINS_ODF_PDF_EXPORT_MAIL_BODY_SUCCESS", List.of(catalogTitle, downloadLink)))
323                    .withLink(downloadLink, new I18nizableText("plugin.odf", "PLUGINS_ODF_PDF_EXPORT_MAIL_BODY_DOWNLOAD_LINK"))
324                    .withLanguage(language)
325                    .build();
326        }
327        catch (IOException e)
328        {
329            getLogger().warn("Failed to build HTML email body for PDF export result. Fallback to no wrapped email", e);
330            return _i18nUtils.translate(new I18nizableText("plugin.odf", "PLUGINS_ODF_PDF_EXPORT_MAIL_BODY_SUCCESS", List.of(catalogTitle, downloadLink)), language);
331        }
332    }
333
334    @Override
335    protected I18nizableText _getErrorMailSubject(JobExecutionContext context) throws Exception
336    {
337        return new I18nizableText("plugin.odf", "PLUGINS_ODF_PDF_EXPORT_MAIL_SUBJECT");
338    }
339
340    @Override
341    protected String _getErrorMailBody(JobExecutionContext context, String language, Throwable throwable) throws Exception
342    {
343        try
344        {
345            String catalogTitle = _getCatalogTitle(context);
346            String error = ExceptionUtils.getStackTrace(throwable);
347            
348            return StandardMailBodyHelper.newHTMLBody()
349                    .withTitle(new I18nizableText("plugin.odf", "PLUGINS_ODF_PDF_EXPORT_MAIL_SUBJECT"))
350                    .withMessage(new I18nizableText("plugin.odf", "PLUGINS_ODF_PDF_EXPORT_MAIL_BODY_FAILURE", List.of(catalogTitle)))
351                    .withDetails(null, error, true)
352                    .withLanguage(language)
353                    .build();
354        }
355        catch (IOException e)
356        {
357            getLogger().warn("Failed to build HTML email body for PDF export result. Fallback to no wrapped email", e);
358            return _i18nUtils.translate(new I18nizableText("plugin.odf", "PLUGINS_ODF_PDF_EXPORT_MAIL_BODY_FAILURE"), language);
359        }
360    }
361    
362    private String _getCatalogTitle(JobExecutionContext context)
363    {
364        JobDataMap jobDataMap = context.getJobDetail().getJobDataMap();
365        String catalogName = jobDataMap.getString(Scheduler.PARAM_VALUES_PREFIX + JOBDATAMAP_CATALOG_KEY);
366        
367        Catalog catalog = _catalogsManager.getCatalog(catalogName);
368        return catalog.getTitle();
369    }
370}