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