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