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}