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}