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