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