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