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