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}