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}