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}