001/* 002 * Copyright 2020 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.time.ZonedDateTime; 024import java.time.format.DateTimeFormatter; 025import java.util.ArrayList; 026import java.util.HashMap; 027import java.util.List; 028import java.util.Map; 029import java.util.Map.Entry; 030import java.util.Optional; 031import java.util.stream.Collectors; 032import java.util.stream.Stream; 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.cocoon.environment.Request; 039import org.apache.commons.io.FileUtils; 040import org.apache.commons.lang.StringUtils; 041import org.apache.commons.lang3.exception.ExceptionUtils; 042import org.apache.excalibur.source.SourceResolver; 043import org.apache.excalibur.source.SourceUtil; 044import org.quartz.JobDataMap; 045import org.quartz.JobDetail; 046import org.quartz.JobExecutionContext; 047 048import org.ametys.cms.repository.Content; 049import org.ametys.cms.schedule.AbstractSendingMailSchedulable; 050import org.ametys.cms.workflow.ContentWorkflowHelper; 051import org.ametys.core.schedule.Schedulable; 052import org.ametys.odf.ProgramItem; 053import org.ametys.odf.schedulable.EducationalBookletSchedulable.EducationalBookletReport.ReportStatus; 054import org.ametys.plugins.core.schedule.Scheduler; 055import org.ametys.plugins.repository.AmetysObjectResolver; 056import org.ametys.runtime.config.Config; 057import org.ametys.runtime.i18n.I18nizableText; 058import org.ametys.runtime.i18n.I18nizableTextParameter; 059import org.ametys.runtime.util.AmetysHomeHelper; 060 061/** 062 * {@link Schedulable} for educational booklet. 063 */ 064public class EducationalBookletSchedulable extends AbstractSendingMailSchedulable 065{ 066 /** The directory under ametys home data directory for educational booklet */ 067 public static final String EDUCATIONAL_BOOKLET_DIR_NAME = "odf/booklet"; 068 069 /** Scheduler parameter name of including of subprograms */ 070 public static final String PARAM_INCLUDE_SUBPROGRAMS = "includeSubPrograms"; 071 072 /** Scheduler parameter name of including of subprograms */ 073 public static final String PARAM_PROGRAM_ITEM_ID = "programItemId"; 074 075 /** Map key where the report is stored */ 076 protected static final String _EDUCATIONAL_BOOKLET_REPORT = "educationalBookletReport"; 077 078 /** The avalon source resolver. */ 079 protected SourceResolver _sourceResolver; 080 081 /** The ametys object resolver */ 082 protected AmetysObjectResolver _resolver; 083 084 /** The content workflow helper */ 085 protected ContentWorkflowHelper _contentWorkflowHelper; 086 087 @Override 088 public void service(ServiceManager manager) throws ServiceException 089 { 090 super.service(manager); 091 _sourceResolver = (SourceResolver) manager.lookup(SourceResolver.ROLE); 092 _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE); 093 _contentWorkflowHelper = (ContentWorkflowHelper) manager.lookup(ContentWorkflowHelper.ROLE); 094 } 095 096 @Override 097 protected void _doExecute(JobExecutionContext context) throws Exception 098 { 099 File bookletDirectory = new File(AmetysHomeHelper.getAmetysHomeData(), EDUCATIONAL_BOOKLET_DIR_NAME); 100 FileUtils.forceMkdir(bookletDirectory); 101 102 Map<String, Object> pdfParameters = new HashMap<>(); 103 104 JobDataMap jobDataMap = context.getJobDetail().getJobDataMap(); 105 pdfParameters.put(PARAM_INCLUDE_SUBPROGRAMS, jobDataMap.get(Scheduler.PARAM_VALUES_PREFIX + PARAM_INCLUDE_SUBPROGRAMS)); 106 107 _generateProgramItemsEducationalBooklet(context, bookletDirectory, pdfParameters); 108 } 109 110 @Override 111 protected I18nizableText _getSuccessMailSubject(JobExecutionContext context) 112 { 113 EducationalBookletReport report = (EducationalBookletReport) context.get(_EDUCATIONAL_BOOKLET_REPORT); 114 return new I18nizableText("plugin.odf", _getMailSubjectBaseKey() + report.getCurrentStatus()); 115 } 116 117 @Override 118 protected I18nizableText _getErrorMailSubject(JobExecutionContext context) 119 { 120 return new I18nizableText("plugin.odf", _getMailSubjectBaseKey() + "ERROR"); 121 } 122 123 /** 124 * The base key for mail subjects. 125 * @return The prefix of an I18N key 126 */ 127 protected String _getMailSubjectBaseKey() 128 { 129 return "PLUGINS_ODF_EDUCATIONAL_BOOKLET_PROGRAMITEM_MAIL_SUBJECT_"; 130 } 131 132 @Override 133 protected I18nizableText _getSuccessMailBody(JobExecutionContext context) throws IOException 134 { 135 EducationalBookletReport report = (EducationalBookletReport) context.get(_EDUCATIONAL_BOOKLET_REPORT); 136 137 Map<String, I18nizableTextParameter> params = new HashMap<>(); 138 139 List<Content> exportedProgramItems = report.getExportedProgramItems(); 140 List<Content> programItemsInError = report.getProgramItemsInError(); 141 142 ReportStatus status = report.getCurrentStatus(); 143 String i18nKey = _getMailBodyBaseKey(); 144 switch (status) 145 { 146 case ERROR: 147 i18nKey += "ERROR"; 148 if (programItemsInError.size() > 1) 149 { 150 i18nKey += "_SEVERAL"; 151 } 152 params.put("programItem", _getProgramItemsI18nText(programItemsInError)); 153 break; 154 case PARTIAL: 155 params.put("error", new I18nizableText("plugin.odf", _getMailBodyBaseKey() + "ERROR", Map.of("programItem", _getProgramItemsI18nText(programItemsInError)))); 156 // fallthrough 157 case SUCCESS: 158 i18nKey += "SUCCESS"; 159 String downloadLink = StringUtils.removeEndIgnoreCase(Config.getInstance().getValue("cms.url"), "/index.html"); 160 if (exportedProgramItems.size() > 1) 161 { 162 i18nKey += "_SEVERAL"; 163 // Compress to a ZIP if there are several exported program items 164 DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); 165 String zipKey = ZonedDateTime.now().format(formatter); 166 _generateEducationalBookletZip(context, report.getBookletDirectory(), exportedProgramItems, zipKey); 167 downloadLink += "/plugins/odf/download/educational-booklet-" + zipKey + "/educational-booklet.zip"; 168 } 169 else 170 { 171 Content content = exportedProgramItems.get(0); 172 downloadLink += "/plugins/odf/download/" + content.getLanguage() + "/educational-booklet.pdf?programItemId=" + content.getId(); 173 } 174 params.put("link", new I18nizableText(downloadLink)); 175 params.put("programItem", _getProgramItemsI18nText(exportedProgramItems)); 176 break; 177 default: 178 // Shouldn't happen 179 break; 180 } 181 182 return new I18nizableText("plugin.odf", i18nKey, params); 183 } 184 185 @Override 186 protected I18nizableText _getErrorMailBody(JobExecutionContext context, Throwable throwable) 187 { 188 List<Content> programItems = Optional.of(context) 189 .map(JobExecutionContext::getJobDetail) 190 .map(JobDetail::getJobDataMap) 191 .map(map -> map.getString(Scheduler.PARAM_VALUES_PREFIX + "programItemIds")) 192 .map(ids -> ids.split(",")) 193 .map(Stream::of) 194 .orElseGet(() -> Stream.empty()) 195 .filter(StringUtils::isNotBlank) 196 .map(_resolver::<Content>resolveById) 197 .collect(Collectors.toList()); 198 199 Map<String, I18nizableTextParameter> params = new HashMap<>(); 200 201 params.put("programItem", _getProgramItemsI18nText(programItems)); 202 203 if (throwable != null) 204 { 205 String error = ExceptionUtils.getStackTrace(throwable); 206 params.put("error", new I18nizableText(error)); 207 } 208 209 return new I18nizableText("plugin.odf", _getMailBodyBaseKey() + "FAILURE" + (programItems.size() > 1 ? "_SEVERAL" : StringUtils.EMPTY), params); 210 } 211 212 /** 213 * The base key for mail bodies. 214 * @return The prefix of an I18N key 215 */ 216 protected String _getMailBodyBaseKey() 217 { 218 return "PLUGINS_ODF_EDUCATIONAL_BOOKLET_PROGRAMITEM_MAIL_BODY_"; 219 } 220 221 /** 222 * Transform a list of program items in a readable list. 223 * @param programItems The program items to iterate on 224 * @return An {@link I18nizableText} representing the program items 225 */ 226 protected I18nizableText _getProgramItemsI18nText(List<Content> programItems) 227 { 228 List<String> programItemsTitle = programItems.stream() 229 .map(c -> c.getTitle()) 230 .collect(Collectors.toList()); 231 232 String readableTitles = programItemsTitle.size() == 1 ? programItemsTitle.get(0) : "-" + StringUtils.join(programItemsTitle, "\n- "); 233 234 return new I18nizableText(readableTitles); 235 } 236 237 /** 238 * Generate educational booklet for each program items 239 * @param context the context 240 * @param bookletDirectory the booklet directory 241 * @param pdfParameters the parameters to generate PDF 242 */ 243 protected void _generateProgramItemsEducationalBooklet(JobExecutionContext context, File bookletDirectory, Map<String, Object> pdfParameters) 244 { 245 EducationalBookletReport report = new EducationalBookletReport(bookletDirectory); 246 247 JobDataMap jobDataMap = context.getJobDetail().getJobDataMap(); 248 String idsAsString = jobDataMap.getString(Scheduler.PARAM_VALUES_PREFIX + "programItemIds"); 249 for (String programItemId : StringUtils.split(idsAsString, ",")) 250 { 251 Content programItem = _resolver.resolveById(programItemId); 252 try 253 { 254 _generateProgramItemEducationalBookletPDF(bookletDirectory, programItem, pdfParameters); 255 report.addExportedProgramItem(programItem); 256 } 257 catch (IOException e) 258 { 259 getLogger().error("An error occurred while generating the educational booklet of program item '{}' ({}).", programItem.getTitle(), ((ProgramItem) programItem).getCode(), e); 260 report.addProgramItemIhError(programItem); 261 } 262 } 263 264 context.put(_EDUCATIONAL_BOOKLET_REPORT, report); 265 } 266 267 /** 268 * Generate the educational booklet for one program item 269 * @param bookletDirectory the booklet directory 270 * @param programItem the program item 271 * @param pdfParameters the parameters to generate PDF 272 * @throws IOException if an error occured with files 273 */ 274 protected void _generateProgramItemEducationalBookletPDF(File bookletDirectory, Content programItem, Map<String, Object> pdfParameters) throws IOException 275 { 276 File programItemDir = new File(bookletDirectory, programItem.getName()); 277 if (!programItemDir.exists()) 278 { 279 programItemDir.mkdir(); 280 } 281 282 File langDir = new File (programItemDir, programItem.getLanguage()); 283 if (!langDir.exists()) 284 { 285 langDir.mkdir(); 286 } 287 288 Map<String, Object> localPdfParameters = new HashMap<>(pdfParameters); 289 localPdfParameters.put(PARAM_PROGRAM_ITEM_ID, programItem.getId()); 290 _generateFile( 291 langDir, 292 "cocoon://_plugins/odf/booklet/" + programItem.getLanguage() + "/educational-booklet.pdf", 293 localPdfParameters, 294 "educational-booklet", 295 "pdf" 296 ); 297 } 298 299 /** 300 * Generate the zip with the educational booklet for each exported program items 301 * @param context the context 302 * @param bookletDirectory the booklet directory 303 * @param exportedProgramItems the exported program items 304 * @param zipKey the zip key 305 * @throws IOException if an error occured with files 306 */ 307 protected void _generateEducationalBookletZip(JobExecutionContext context, File bookletDirectory, List<Content> exportedProgramItems, String zipKey) throws IOException 308 { 309 String ids = exportedProgramItems.stream() 310 .map(Content::getId) 311 .collect(Collectors.joining(",")); 312 313 _generateFile( 314 bookletDirectory, 315 "cocoon://_plugins/odf/booklet/educational-booklet.zip", 316 Map.of("programItemIds", ids), 317 "educational-booklet-" + zipKey, 318 "zip" 319 ); 320 } 321 322 /** 323 * Generate a file from the uri 324 * @param bookletDirectory the booklet directory where the file are created 325 * @param uri the uri 326 * @param params the parameters of the uri 327 * @param name the name of the file 328 * @param extension the extension of the file 329 * @throws IOException if an error occured with files 330 */ 331 protected void _generateFile(File bookletDirectory, String uri, Map<String, Object> params, String name, String extension) throws IOException 332 { 333 Request request = ContextHelper.getRequest(_context); 334 335 SitemapSource source = null; 336 File pdfTmpFile = null; 337 try 338 { 339 // Set PDF parameters as request attributes 340 for (Entry<String, Object> param : params.entrySet()) 341 { 342 request.setAttribute(param.getKey(), param.getValue()); 343 } 344 // Resolve the export to the appropriate pdf url. 345 source = (SitemapSource) _sourceResolver.resolveURI(uri, null, params); 346 347 // Save the pdf into a temporary file. 348 String tmpFile = name + ".tmp." + extension; 349 pdfTmpFile = new File(bookletDirectory, tmpFile); 350 351 try (OutputStream pdfTmpOs = new FileOutputStream(pdfTmpFile); InputStream sourceIs = source.getInputStream()) 352 { 353 SourceUtil.copy(sourceIs, pdfTmpOs); 354 } 355 356 // If all went well until now, rename the temporary file 357 String fileName = name + "." + extension; 358 File bookletFile = new File(bookletDirectory, fileName); 359 if (bookletFile.exists()) 360 { 361 bookletFile.delete(); 362 } 363 364 if (!pdfTmpFile.renameTo(bookletFile)) 365 { 366 throw new IOException("Fail to rename " + tmpFile + " to " + fileName); 367 } 368 } 369 finally 370 { 371 if (pdfTmpFile != null) 372 { 373 FileUtils.deleteQuietly(pdfTmpFile); 374 } 375 376 if (source != null) 377 { 378 _sourceResolver.release(source); 379 } 380 381 for (Entry<String, Object> param : params.entrySet()) 382 { 383 request.removeAttribute(param.getKey()); 384 } 385 } 386 } 387 388 /** 389 * Object to represent list of programs exported and list of programs with error after PDF generation 390 */ 391 protected static class EducationalBookletReport 392 { 393 /** 394 * Status of export 395 */ 396 public enum ReportStatus 397 { 398 399 /** All program items have been exported successfully */ 400 SUCCESS, 401 /** Program items have been exported partially */ 402 PARTIAL, 403 /** Error during export */ 404 ERROR 405 406 } 407 408 private File _bookletDirectory; 409 private List<Content> _exportedProgramItems; 410 private List<Content> _programItemsInError; 411 412 /** 413 * The constructor 414 * @param bookletDirectory The booklet directory 415 */ 416 public EducationalBookletReport(File bookletDirectory) 417 { 418 _bookletDirectory = bookletDirectory; 419 _exportedProgramItems = new ArrayList<>(); 420 _programItemsInError = new ArrayList<>(); 421 } 422 423 /** 424 * Get the booklet directory 425 * @return the booklet directory 426 */ 427 public File getBookletDirectory() 428 { 429 return _bookletDirectory; 430 } 431 432 /** 433 * Get the list of exported program items 434 * @return the list of exported program items 435 */ 436 public List<Content> getExportedProgramItems() 437 { 438 return _exportedProgramItems; 439 } 440 441 /** 442 * Add a content as exported 443 * @param content the content to add 444 */ 445 public void addExportedProgramItem(Content content) 446 { 447 _exportedProgramItems.add(content); 448 } 449 450 /** 451 * Set the exported program items 452 * @param programItems the list of exported program items 453 */ 454 public void setExportedProgramItems(List<Content> programItems) 455 { 456 _exportedProgramItems = programItems; 457 } 458 459 /** 460 * Get the program items in error 461 * @return the list of program items in error 462 */ 463 public List<Content> getProgramItemsInError() 464 { 465 return _programItemsInError; 466 } 467 468 /** 469 * Add program item as error 470 * @param programItem the program item to add 471 */ 472 public void addProgramItemIhError(Content programItem) 473 { 474 _programItemsInError.add(programItem); 475 } 476 477 /** 478 * The current status of the educational booklet generation. 479 * @return The report status 480 */ 481 public ReportStatus getCurrentStatus() 482 { 483 if (_programItemsInError.isEmpty()) 484 { 485 return ReportStatus.SUCCESS; 486 } 487 488 if (_exportedProgramItems.isEmpty()) 489 { 490 return ReportStatus.ERROR; 491 } 492 493 return ReportStatus.PARTIAL; 494 } 495 } 496}