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