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