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}