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