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