001/* 002 * Copyright 2012 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.plugins.newsletter.auto; 017 018import java.time.ZoneId; 019import java.time.ZonedDateTime; 020import java.time.temporal.TemporalAdjusters; 021import java.util.ArrayList; 022import java.util.Collection; 023import java.util.Collections; 024import java.util.Date; 025import java.util.HashMap; 026import java.util.List; 027import java.util.Map; 028 029import org.apache.avalon.framework.configuration.Configuration; 030import org.apache.avalon.framework.configuration.ConfigurationException; 031import org.apache.avalon.framework.service.ServiceException; 032import org.apache.avalon.framework.service.ServiceManager; 033import org.apache.cocoon.components.ContextHelper; 034import org.apache.cocoon.environment.Request; 035import org.apache.commons.lang.StringUtils; 036import org.quartz.JobExecutionContext; 037 038import org.ametys.cms.filter.ContentFilter; 039import org.ametys.cms.filter.ContentFilterExtensionPoint; 040import org.ametys.cms.repository.Content; 041import org.ametys.cms.repository.WorkflowAwareContent; 042import org.ametys.cms.workflow.AbstractContentWorkflowComponent; 043import org.ametys.cms.workflow.CreateContentFunction; 044import org.ametys.cms.workflow.SendMailFunction; 045import org.ametys.core.authentication.AuthenticateAction; 046import org.ametys.core.util.I18nUtils; 047import org.ametys.plugins.core.impl.schedule.AbstractStaticSchedulable; 048import org.ametys.plugins.newsletter.category.Category; 049import org.ametys.plugins.newsletter.category.CategoryProvider; 050import org.ametys.plugins.newsletter.category.CategoryProviderExtensionPoint; 051import org.ametys.plugins.newsletter.workflow.CreateNewsletterFunction; 052import org.ametys.plugins.repository.AmetysObjectIterable; 053import org.ametys.plugins.repository.AmetysObjectResolver; 054import org.ametys.plugins.repository.provider.RequestAttributeWorkspaceSelector; 055import org.ametys.plugins.workflow.AbstractWorkflowComponent; 056import org.ametys.plugins.workflow.component.CheckRightsCondition; 057import org.ametys.plugins.workflow.support.WorkflowProvider; 058import org.ametys.plugins.workflow.support.WorkflowProvider.AmetysObjectWorkflow; 059import org.ametys.runtime.i18n.I18nizableText; 060import org.ametys.runtime.i18n.I18nizableTextParameter; 061import org.ametys.web.WebConstants; 062import org.ametys.web.filter.ContentFilterHelper; 063import org.ametys.web.filter.WebContentFilter; 064import org.ametys.web.repository.site.Site; 065import org.ametys.web.repository.site.SiteManager; 066import org.ametys.web.repository.sitemap.Sitemap; 067 068import com.opensymphony.workflow.InvalidActionException; 069import com.opensymphony.workflow.WorkflowException; 070 071/** 072 * Runnable engine that creates the automatic newsletter contents. 073 */ 074public class AutomaticNewslettersSchedulable extends AbstractStaticSchedulable 075{ 076 077 /** The newsletter content type. */ 078 protected static final String _NEWSLETTER_CONTENT_TYPE = "org.ametys.plugins.newsletter.Content.newsletter"; 079 080 /** The instant the engine was started. */ 081 protected Date _runDate; 082 083 /** The newsletter workflow name. */ 084 protected String _workflowName; 085 086 /** The workflow initial action ID. */ 087 protected int _wfInitialActionId; 088 089 /** A list of action IDs to validate a newsletter from initial step. */ 090 protected List<Integer> _wfValidateActionIds; 091 092 /** A map of the content IDs by filter, reset on each run. */ 093 protected Map<String, List<String>> _filterContentIdCache; 094 095 /** The ametys object resolver. */ 096 protected AmetysObjectResolver _resolver; 097 098 /** The site manager. */ 099 protected SiteManager _siteManager; 100 101 /** The workflow provider. */ 102 protected WorkflowProvider _workflowProvider; 103 104 /** The automatic newsletter extension point. */ 105 protected AutomaticNewsletterExtensionPoint _autoNewsletterEP; 106 107 /** The newsletter category provider extension point. */ 108 protected CategoryProviderExtensionPoint _categoryEP; 109 110 /** The content filter extension point. */ 111 protected ContentFilterExtensionPoint _contentFilterEP; 112 113 /** The content filter helper. */ 114 protected ContentFilterHelper _contentFilterHelper; 115 116 /** The i18n utils. */ 117 protected I18nUtils _i18nUtils; 118 119 @Override 120 public void service(ServiceManager manager) throws ServiceException 121 { 122 super.service(manager); 123 // Lookup the needed components. 124 _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE); 125 _siteManager = (SiteManager) manager.lookup(SiteManager.ROLE); 126 _workflowProvider = (WorkflowProvider) manager.lookup(WorkflowProvider.ROLE); 127 _autoNewsletterEP = (AutomaticNewsletterExtensionPoint) manager.lookup(AutomaticNewsletterExtensionPoint.ROLE); 128 _categoryEP = (CategoryProviderExtensionPoint) manager.lookup(CategoryProviderExtensionPoint.ROLE); 129 _contentFilterEP = (ContentFilterExtensionPoint) manager.lookup(ContentFilterExtensionPoint.ROLE); 130 _contentFilterHelper = (ContentFilterHelper) manager.lookup(ContentFilterHelper.ROLE); 131 _i18nUtils = (I18nUtils) manager.lookup(I18nUtils.ROLE); 132 133 _filterContentIdCache = new HashMap<>(); 134 } 135 136 /** 137 * Configure the engine (to be called by the scheduler or the action). 138 * @param configuration the component configuration. 139 * @throws ConfigurationException if an error occurs in the configuration. 140 */ 141 @Override 142 public void configure(Configuration configuration) throws ConfigurationException 143 { 144 super.configure(configuration); 145 Configuration workflowConf = configuration.getChild("workflow"); 146 _workflowName = workflowConf.getAttribute("name"); 147 _wfInitialActionId = workflowConf.getAttributeAsInteger("initialActionId"); 148 149 String[] validateActionIds = StringUtils.split(workflowConf.getAttribute("validateActionIds"), ", "); 150 _wfValidateActionIds = new ArrayList<>(validateActionIds.length); 151 for (String actionId : validateActionIds) 152 { 153 try 154 { 155 _wfValidateActionIds.add(Integer.valueOf(actionId)); 156 } 157 catch (NumberFormatException e) 158 { 159 throw new ConfigurationException("Invalid validation action ID.", e); 160 } 161 } 162 } 163 164 @Override 165 public void execute(JobExecutionContext context) throws Exception 166 { 167 // Store the date and time. 168 _runDate = new Date(); 169 170 // Reset the cache. 171 _filterContentIdCache.clear(); 172 173 try (AmetysObjectIterable<Site> sites = _siteManager.getSites();) 174 { 175 for (Site site : sites) 176 { 177 try (AmetysObjectIterable<Sitemap> sitemaps = site.getSitemaps();) 178 { 179 for (Sitemap sitemap : sitemaps) 180 { 181 createAutomaticNewsletters(site.getName(), sitemap.getName()); 182 } 183 } 184 } 185 } 186 } 187 188 /** 189 * Test each category in a site and sitemap and launch the newsletter creation if needed. 190 * @param siteName the site name. 191 * @param sitemapName the sitemap name. 192 */ 193 protected void createAutomaticNewsletters(String siteName, String sitemapName) 194 { 195 Request request = ContextHelper.getRequest(_context); 196 request.setAttribute(AuthenticateAction.REQUEST_ATTRIBUTE_INTERNAL_ALLOWED, true); 197 request.setAttribute("siteName", siteName); 198 199 for (String providerId : _categoryEP.getExtensionsIds()) 200 { 201 CategoryProvider provider = _categoryEP.getExtension(providerId); 202 203 // Browse all categories for this site and sitemap. 204 for (Category category : provider.getAllCategories(siteName, sitemapName)) 205 { 206 // Get the automatic newsletter assigned to this category. 207 Collection<String> automaticIds = provider.getAutomaticIds(category.getId()); 208 209 for (String autoNewsletterId : automaticIds) 210 { 211 AutomaticNewsletter autoNewsletter = _autoNewsletterEP.getExtension(autoNewsletterId); 212 213 // Test if an automatic newsletter content has to be created today. 214 if (autoNewsletter != null && createNow(autoNewsletter)) 215 { 216 createAndValidateAutomaticNewsletter(siteName, sitemapName, category, provider, autoNewsletter); 217 } 218 } 219 } 220 221 } 222 } 223 224 /** 225 * Create an automatic newsletter content in a category. 226 * @param sitemapName the sitemap name. 227 * @param siteName the site name. 228 * @param category the newsletter category. 229 * @param provider the category provider. 230 * @param autoNewsletter the associated automatic newsletter. 231 */ 232 protected void createAndValidateAutomaticNewsletter(String siteName, String sitemapName, Category category, CategoryProvider provider, AutomaticNewsletter autoNewsletter) 233 { 234 if (getLogger().isInfoEnabled()) 235 { 236 getLogger().info("Preparing to create an automatic newsletter for category " + category.getId() + " in " + siteName + " and sitemap " + sitemapName); 237 } 238 239 // Get the list of content IDs by filter name. 240 Map<String, AutomaticNewsletterFilterResult> contentsByFilter = getFilterResults(siteName, sitemapName, autoNewsletter); 241 242 try 243 { 244 if (hasResults(contentsByFilter.values())) 245 { 246 // Compute the next newsletter number in this category. 247 long nextNumber = getNextNumber(category, provider, siteName, sitemapName); 248 249 // Create newsletter content. 250 WorkflowAwareContent content = createNewsletterContent(siteName, sitemapName, category, autoNewsletter, nextNumber, contentsByFilter); 251 252 // Validate and send. 253 validateNewsletter(content); 254 } 255 else 256 { 257 if (getLogger().isInfoEnabled()) 258 { 259 getLogger().info("No content has been returned by the filters for the automatic newsletter in category " + category.getId() + " in site " + siteName + " and sitemap " + sitemapName + ": no newsletter has been created."); 260 } 261 } 262 } 263 catch (InvalidActionException | WorkflowException e) 264 { 265 getLogger().error("Unable to create and validate an automatic newsletter for category " + category.getId() + " in site " + siteName + " and sitemap " + sitemapName, e); 266 } 267 } 268 269 /** 270 * Get the list of contents for the automatic newsletter filters. 271 * @param siteName the site name. 272 * @param sitemapName the sitemap name. 273 * @param autoNewsletter the automatic newsletter. 274 * @return the results, indexed by filter name (in the auto newsletter). 275 */ 276 protected Map<String, AutomaticNewsletterFilterResult> getFilterResults(String siteName, String sitemapName, AutomaticNewsletter autoNewsletter) 277 { 278 Map<String, AutomaticNewsletterFilterResult> contentsByFilter = new HashMap<>(); 279 280 Request request = ContextHelper.getRequest(_context); 281 282 Map<String, String> filters = autoNewsletter.getFilters(); 283 284 for (String name : filters.keySet()) 285 { 286 String filterId = filters.get(name); 287 288 AutomaticNewsletterFilterResult result = new AutomaticNewsletterFilterResult(); 289 contentsByFilter.put(name, result); 290 291 List<String> contentIds = new ArrayList<>(); 292 293 ContentFilter filter = _contentFilterEP.getExtension(filterId); 294 295 if (filter != null && filter instanceof WebContentFilter) 296 { 297 WebContentFilter webFilter = (WebContentFilter) filter; 298 result.setViewName(filter.getView()); 299 300 String cacheKey = siteName + "/" + sitemapName + "/" + filterId; 301 302 if (_filterContentIdCache.containsKey(cacheKey)) 303 { 304 contentIds = _filterContentIdCache.get(cacheKey); 305 } 306 else 307 { 308 // Get the contents in the live workspace. 309 String originalWorkspace = RequestAttributeWorkspaceSelector.getForcedWorkspace(request); 310 RequestAttributeWorkspaceSelector.setForcedWorkspace(request, WebConstants.LIVE_WORKSPACE); 311 312 contentIds = _contentFilterHelper.getMatchingContentIds(webFilter, siteName, sitemapName, null); 313 314 // Set the workspace back to its original value. 315 RequestAttributeWorkspaceSelector.setForcedWorkspace(request, originalWorkspace); 316 317 // Cache the results. 318 _filterContentIdCache.put(cacheKey, contentIds); 319 } 320 } 321 322 result.setContentIds(contentIds); 323 } 324 325 return contentsByFilter; 326 } 327 328 /** 329 * Create the newsletter content. 330 * @param siteName the site name. 331 * @param language the language. 332 * @param category the category. 333 * @param autoNewsletter the automatic newsletter. 334 * @param newsletterNumber the newsletter number. 335 * @param filterResults the filter results (content IDs for each filter). 336 * @return The newly created newsletter content. 337 * @throws WorkflowException if a workflow error occurs. 338 */ 339 protected WorkflowAwareContent createNewsletterContent(String siteName, String language, Category category, AutomaticNewsletter autoNewsletter, long newsletterNumber, Map<String, AutomaticNewsletterFilterResult> filterResults) throws WorkflowException 340 { 341 String contentName = category.getName() + "-" + newsletterNumber; 342 343 String title = getNewsletterTitle(language, category, autoNewsletter, newsletterNumber); 344 345 Map<String, Object> params = new HashMap<>(); 346 347 // Workflow result. 348 Map<String, Object> workflowResult = new HashMap<>(); 349 params.put(AbstractWorkflowComponent.RESULT_MAP_KEY, workflowResult); 350 351 // Workflow parameters. 352 params.put("workflowName", _workflowName); 353 params.put(org.ametys.web.workflow.CreateContentFunction.SITE_KEY, siteName); 354 params.put(CreateContentFunction.CONTENT_NAME_KEY, contentName); 355 params.put(CreateContentFunction.CONTENT_TITLE_KEY, title); 356 params.put(CreateContentFunction.CONTENT_TYPES_KEY, new String[]{_NEWSLETTER_CONTENT_TYPE}); 357 params.put(CreateContentFunction.CONTENT_LANGUAGE_KEY, language); 358 params.put(CreateNewsletterFunction.NEWSLETTER_CATEGORY_KEY, category.getId()); 359 params.put(CreateNewsletterFunction.NEWSLETTER_NUMBER_KEY, Long.valueOf(newsletterNumber)); 360 params.put(CreateNewsletterFunction.NEWSLETTER_DATE_KEY, _runDate); 361 params.put(CreateNewsletterFunction.NEWSLETTER_IS_AUTOMATIC_KEY, "true"); 362 params.put(CreateNewsletterFunction.NEWSLETTER_PROCESS_AUTO_SECTIONS_KEY, "true"); 363 params.put(CreateNewsletterFunction.NEWSLETTER_CONTENT_ID_MAP_KEY, filterResults); 364 365 // Trigger the creation. 366 AmetysObjectWorkflow workflow = _workflowProvider.getAmetysObjectWorkflow(); 367 workflow.initialize(_workflowName, _wfInitialActionId, params); 368 369 // Get the content in the results and return it. 370 WorkflowAwareContent content = (WorkflowAwareContent) workflowResult.get(AbstractContentWorkflowComponent.CONTENT_KEY); 371 372 return content; 373 } 374 375 /** 376 * Validate the newly created newsletter. 377 * @param newsletterContent the newsletter content, must be in draft state. 378 * @throws WorkflowException if a workflow error occurs. 379 */ 380 protected void validateNewsletter(WorkflowAwareContent newsletterContent) throws WorkflowException 381 { 382 long workflowId = newsletterContent.getWorkflowId(); 383 384 Map<String, Object> inputs = new HashMap<>(); 385 386 inputs.put(AbstractContentWorkflowComponent.CONTENT_KEY, newsletterContent); 387 // Do not send workflow mail notifications. 388 inputs.put(SendMailFunction.SEND_MAIL, "false"); 389 390 inputs.put(CheckRightsCondition.FORCE, true); 391 392 // Without this attribute, the newsletter is not sent to subscribers. 393 Request request = ContextHelper.getRequest(_context); 394 request.setAttribute("send", "true"); 395 396 // Successively execute all the configured actions. 397 AmetysObjectWorkflow workflow = _workflowProvider.getAmetysObjectWorkflow(newsletterContent); 398 for (Integer actionId : _wfValidateActionIds) 399 { 400 workflow.doAction(workflowId, actionId, inputs); 401 } 402 } 403 404 /** 405 * Compute the newsletter title. 406 * @param language the language. 407 * @param category the newsletter category. 408 * @param autoNewsletter the automatic newsletter. 409 * @param newsletterNumber the newsletter number. 410 * @return the newsletter title. 411 */ 412 protected String getNewsletterTitle(String language, Category category, AutomaticNewsletter autoNewsletter, long newsletterNumber) 413 { 414 String title = ""; 415 416 I18nizableText newsletterTitle = autoNewsletter.getNewsletterTitle(); 417 if (newsletterTitle == null || StringUtils.isEmpty(newsletterTitle.toString())) 418 { 419 // The newsletter title is not set in the auto newsletter: 420 // create the newsletter title from the category title. 421 String categoryTitle = _i18nUtils.translate(category.getTitle(), language); 422 title = categoryTitle + " " + newsletterNumber; 423 } 424 else if (newsletterTitle.isI18n()) 425 { 426 // The newsletter title is set as a parametrizable I18nizableText in the auto newsletter. 427 Map<String, I18nizableTextParameter> params = Collections.singletonMap("number", new I18nizableText(Long.toString(newsletterNumber))); 428 I18nizableText titleI18n = new I18nizableText(newsletterTitle.getCatalogue(), newsletterTitle.getKey(), params); 429 title = _i18nUtils.translate(titleI18n, language); 430 } 431 else 432 { 433 // The newsletter title is set as a non-i18n I18nizableText. 434 title = newsletterTitle.getLabel(); 435 if (title.contains("{number}")) 436 { 437 title = title.replaceAll("\\{number\\}", String.valueOf(newsletterNumber)); 438 } 439 else 440 { 441 title += " " + newsletterNumber; 442 } 443 } 444 445 return title; 446 } 447 448 /** 449 * Compute the newsletter number. 450 * @param category the newsletter category. 451 * @param provider the category provider. 452 * @param siteName the site name. 453 * @param language the language. 454 * @return the newsletter number. 455 */ 456 protected long getNextNumber(Category category, CategoryProvider provider, String siteName, String language) 457 { 458 long number = 0; 459 460 // Browse all existing numbers to get the highest number. 461 try (AmetysObjectIterable<Content> newsletters = provider.getNewsletters(category.getId(), siteName, language);) 462 { 463 for (Content newsletterContent : newsletters) 464 { 465 long contentNumber = newsletterContent.getValue("newsletter-number", false, 0L); 466 467 // Keep the number if it's higher. 468 number = Math.max(number, contentNumber); 469 } 470 471 // Return the next newsletter number. 472 return number + 1; 473 } 474 } 475 476 /** 477 * Test if there is at least one content in a collection of filter results. 478 * @param results a collection of filter results. 479 * @return true if at least one filter yielded a result, false otherwise. 480 */ 481 protected boolean hasResults(Collection<AutomaticNewsletterFilterResult> results) 482 { 483 boolean hasResults = false; 484 485 for (AutomaticNewsletterFilterResult result : results) 486 { 487 if (result.hasResults()) 488 { 489 hasResults = true; 490 } 491 } 492 493 return hasResults; 494 } 495 496 /** 497 * Test if an automatic newsletter content has to be created now. 498 * @param autoNewsletter the automatic newsletter. 499 * @return true if an automatic newsletter content has to be created now, false otherwise. 500 */ 501 protected boolean createNow(AutomaticNewsletter autoNewsletter) 502 { 503 boolean createToday = false; 504 505 // The time the engine was launched. 506 ZonedDateTime runDate = _runDate.toInstant().atZone(ZoneId.systemDefault()); 507 508 // The days at which the newsletter has to be created. 509 Collection<Integer> dayNumbers = autoNewsletter.getDayNumbers(); 510 511 switch (autoNewsletter.getFrequencyType()) 512 { 513 case MONTH: 514 // Test with a month frequency. 515 createToday = testMonth(dayNumbers, runDate); 516 break; 517 case WEEK: 518 // Test with a week frequency. 519 createToday = testWeek(dayNumbers, runDate); 520 break; 521 default: 522 break; 523 } 524 525 return createToday; 526 } 527 528 /** 529 * Test if we are in the configured month creation period. 530 * @param dayNumbers the days in the month on which a newsletter is to be created. 531 * @param runDate the instant the engine was launched. 532 * @return true if we are in the configured month creation period, false otherwise. 533 */ 534 protected boolean testMonth(Collection<Integer> dayNumbers, ZonedDateTime runDate) 535 { 536 boolean createToday = false; 537 538 int dayOfMonth = runDate.getDayOfMonth(); 539 int lastDayOfMonth = runDate.with(TemporalAdjusters.lastDayOfMonth()).getDayOfMonth(); 540 541 for (Integer dayNumber : dayNumbers) 542 { 543 if (dayNumber.intValue() == dayOfMonth) 544 { 545 createToday = true; 546 } 547 else if (dayNumber.intValue() > lastDayOfMonth && dayOfMonth == lastDayOfMonth) 548 { 549 // If the configured day is outside the current month (for instance, if "31" is configured), 550 // run the last day of the current month. 551 createToday = true; 552 } 553 } 554 555 return createToday; 556 } 557 558 /** 559 * Test if we are in the configured week creation period. 560 * @param dayNumbers the days in the month on which a newsletter is to be created. 561 * @param runDate the instant the engine was launched. 562 * @return true if we are in the configured week creation period, false otherwise. 563 */ 564 protected boolean testWeek(Collection<Integer> dayNumbers, ZonedDateTime runDate) 565 { 566 boolean createToday = false; 567 568 int dayOfWeek = runDate.getDayOfWeek().getValue(); 569 570 for (Integer dayNumber : dayNumbers) 571 { 572 if (dayNumber.intValue() == dayOfWeek) 573 { 574 createToday = true; 575 } 576 } 577 578 return createToday; 579 } 580 581}