001/*
002 *  Copyright 2010 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.cms.alerts;
017
018import java.time.LocalDate;
019import java.time.ZonedDateTime;
020import java.util.ArrayList;
021import java.util.Collections;
022import java.util.HashMap;
023import java.util.List;
024import java.util.Map;
025import java.util.UUID;
026
027import org.apache.avalon.framework.service.ServiceException;
028import org.apache.avalon.framework.service.ServiceManager;
029import org.apache.commons.lang3.StringUtils;
030import org.quartz.SchedulerException;
031
032import org.ametys.cms.content.ContentHelper;
033import org.ametys.cms.lock.LockContentManager;
034import org.ametys.cms.repository.Content;
035import org.ametys.cms.repository.ModifiableContent;
036import org.ametys.core.schedule.Runnable;
037import org.ametys.core.schedule.Runnable.FireProcess;
038import org.ametys.core.schedule.Runnable.MisfirePolicy;
039import org.ametys.core.ui.Callable;
040import org.ametys.core.ui.StaticClientSideElement;
041import org.ametys.core.util.DateUtils;
042import org.ametys.plugins.core.impl.schedule.DefaultRunnable;
043import org.ametys.plugins.core.schedule.Scheduler;
044import org.ametys.plugins.repository.AmetysObjectResolver;
045import org.ametys.plugins.repository.AmetysRepositoryException;
046import org.ametys.plugins.repository.data.holder.ModelLessDataHolder;
047import org.ametys.plugins.repository.data.holder.ModifiableModelLessDataHolder;
048import org.ametys.plugins.repository.lock.LockAwareAmetysObject;
049import org.ametys.plugins.repository.version.DataAndVersionAwareAmetysObject;
050import org.ametys.plugins.repository.version.ModifiableDataAwareVersionableAmetysObject;
051import org.ametys.runtime.config.Config;
052import org.ametys.runtime.i18n.I18nizableText;
053
054/**
055 * This element creates a toggle button representing the reminders state.
056 */
057public class ContentAlertsClientSideElement extends StaticClientSideElement
058{
059    /** Repository content. */
060    protected AmetysObjectResolver _resolver;
061
062    /** The lock manager. */
063    protected LockContentManager _lockManager;
064
065    /** The ametys scheduler */
066    protected Scheduler _scheduler;
067    
068    /** The service manager */
069    protected ServiceManager _smanager;
070    
071    /** The content helper */
072    protected ContentHelper _contentHelper;
073
074    @Override
075    public void service(ServiceManager serviceManager) throws ServiceException
076    {
077        super.service(serviceManager);
078        _scheduler = (Scheduler) serviceManager.lookup(Scheduler.ROLE);
079        _smanager = serviceManager;
080        _resolver = (AmetysObjectResolver) serviceManager.lookup(AmetysObjectResolver.ROLE);
081        _lockManager = (LockContentManager) serviceManager.lookup(LockContentManager.ROLE);
082        _contentHelper = (ContentHelper) serviceManager.lookup(ContentHelper.ROLE);
083    }
084
085    enum AlertsStatus 
086    {
087        /** All the alerts are enabled */
088        ENABLED,
089
090        /** All the alerts are disabled */
091        DISABLED,
092
093        /** Some alerts are enabled, others are disabled */
094        MIXED
095    }
096
097    /**
098     * Get information on reminders state.
099     * 
100     * @param contentsId The list of contents' ids
101     * @return informations on reminders state.
102     */
103    @Callable
104    public Map<String, Object> getAlertsInformations(List<String> contentsId)
105    {
106        Map<String, Object> results = new HashMap<>();
107
108        Long validationAlertDelay = Config.getInstance().getValue("remind.content.validation.delay");
109        Long unmodifiedAlertDelay = Config.getInstance().getValue("remind.unmodified.content.delay");
110
111        results.put("alert-enabled-contents", new ArrayList<Map<String, Object>>());
112        results.put("alert-disabled-contents", new ArrayList<Map<String, Object>>());
113        results.put("all-right-contents", new ArrayList<Map<String, Object>>());
114        results.put("not-unlockable-contents", new ArrayList<Map<String, Object>>());
115
116        AlertsStatus unmodifiedAlertStatus = null;
117        AlertsStatus reminderAlertStatus = null;
118
119        for (String contentId : contentsId)
120        {
121            Content content = _resolver.resolveById(contentId);
122
123            if (content instanceof DataAndVersionAwareAmetysObject && content instanceof ModifiableContent)
124            {
125                Map<String, Object> contentParams = getContentDefaultParameters(content);
126
127                // Test if the content is locked and cannot be unlocked by the
128                // current user.
129                boolean isValid = !isUnlockable(content, results);
130
131                ModelLessDataHolder dataHolder = ((DataAndVersionAwareAmetysObject) content).getUnversionedDataHolder();
132                
133                boolean unmodifiedAlertEnabled = dataHolder.getValue(AlertsConstants.UNMODIFIED_ALERT_ENABLED, false);
134                if (unmodifiedAlertEnabled)
135                {
136                    String unmodifiedAlertText = dataHolder.getValue(AlertsConstants.UNMODIFIED_ALERT_TEXT, StringUtils.EMPTY);
137                    results.put("unmodifiedAlertText", unmodifiedAlertText);
138                }
139
140                unmodifiedAlertStatus = _getUnmodifiedStatusAlertsInformations(unmodifiedAlertStatus, unmodifiedAlertEnabled);
141
142                boolean reminderEnabled = dataHolder.getValue(AlertsConstants.REMINDER_ENABLED, false);
143                if (reminderEnabled)
144                {
145                    ZonedDateTime reminderDate = dataHolder.getValue(AlertsConstants.REMINDER_DATE, null);
146                    String reminderText = dataHolder.getValue(AlertsConstants.REMINDER_TEXT, StringUtils.EMPTY);
147
148                    results.put("reminderDate", reminderDate != null ? DateUtils.zonedDateTimeToString(reminderDate) : StringUtils.EMPTY);
149                    results.put("reminderText", reminderText);
150                }
151
152                reminderAlertStatus = _getUnmodifiedStatusAlertsInformations(reminderAlertStatus, reminderEnabled);
153
154                // Alerts enabled/disabled
155                _getEnabledDisabledAlertsInformations(results, unmodifiedAlertDelay, content, contentParams, unmodifiedAlertEnabled, reminderEnabled);
156
157                // All right
158                if (isValid)
159                {
160                    @SuppressWarnings("unchecked")
161                    List<Map<String, Object>> validContents = (List<Map<String, Object>>) results.get("all-right-contents");
162                    validContents.add(contentParams);
163                }
164            }
165        }
166
167        results.put("reminderEnabled", reminderAlertStatus == AlertsStatus.MIXED ? null : (reminderAlertStatus == AlertsStatus.ENABLED ? true : false));
168        results.put("unmodifiedAlertEnabled", unmodifiedAlertStatus == AlertsStatus.MIXED ? null : (unmodifiedAlertStatus == AlertsStatus.ENABLED ? true : false));
169
170        results.put("validation-alert-delay", validationAlertDelay);
171        results.put("unmodified-alert-delay", unmodifiedAlertDelay);
172
173        return results;
174    }
175
176    private AlertsStatus _getUnmodifiedStatusAlertsInformations(AlertsStatus unmodifiedAlertStatus, boolean unmodifiedAlertEnabled)
177    {
178        AlertsStatus localUnmodifiedAlertStatus = unmodifiedAlertStatus;
179        if (localUnmodifiedAlertStatus == null)
180        {
181            // Initialize the alert status with first content
182            localUnmodifiedAlertStatus = unmodifiedAlertEnabled ? AlertsStatus.ENABLED : AlertsStatus.DISABLED;
183        }
184        else if (localUnmodifiedAlertStatus == AlertsStatus.ENABLED && !unmodifiedAlertEnabled
185                || localUnmodifiedAlertStatus == AlertsStatus.DISABLED && unmodifiedAlertEnabled)
186        {
187            // Alert status is different for at least one content
188            localUnmodifiedAlertStatus = AlertsStatus.MIXED;
189        }
190        return localUnmodifiedAlertStatus;
191    }
192
193    private void _getEnabledDisabledAlertsInformations(Map<String, Object> results, Long unmodifiedAlertDelay, Content content, Map<String, Object> contentParams,
194            boolean unmodifiedAlertEnabled, boolean reminderEnabled)
195    {
196        if (unmodifiedAlertEnabled && unmodifiedAlertDelay > 0 || reminderEnabled)
197        {
198            I18nizableText ed = (I18nizableText) this._script.getParameters().get("alerts-enabled-description");
199            I18nizableText msg = new I18nizableText(ed.getCatalogue(), ed.getKey(), Collections.singletonList(_contentHelper.getTitle(content)));
200
201            contentParams.put("description", msg);
202
203            @SuppressWarnings("unchecked")
204            List<Map<String, Object>> enabledAlerts = (List<Map<String, Object>>) results.get("alert-enabled-contents");
205            enabledAlerts.add(contentParams);
206        }
207        else
208        {
209            I18nizableText ed = (I18nizableText) this._script.getParameters().get("alerts-disabled-description");
210            I18nizableText msg = new I18nizableText(ed.getCatalogue(), ed.getKey(), Collections.singletonList(_contentHelper.getTitle(content)));
211
212            contentParams.put("description", msg);
213
214            @SuppressWarnings("unchecked")
215            List<Map<String, Object>> disabledAlerts = (List<Map<String, Object>>) results.get("alert-disabled-contents");
216            disabledAlerts.add(contentParams);
217        }
218    }
219
220    /**
221     * Test if the content is locked and cannot be unlocked by the current user.
222     * <br>
223     * If this is so, fill the "not-unlockable" list in the result map.
224     * 
225     * @param content the content to test.
226     * @param results the result map.
227     * @return true if the content is locked and cannot be unlocked by the
228     *         current user, false otherwise.
229     */
230    @SuppressWarnings("unchecked")
231    protected boolean isUnlockable(Content content, Map<String, Object> results)
232    {
233        boolean isUnlockable = false;
234
235        if (content instanceof LockAwareAmetysObject)
236        {
237            LockAwareAmetysObject lockAwareContent = (LockAwareAmetysObject) content;
238
239            if (lockAwareContent.isLocked() && !_lockManager.canUnlock(lockAwareContent))
240            {
241                Map<String, Object> contentParams = getContentDefaultParameters(content);
242
243                I18nizableText ed = (I18nizableText) this._script.getParameters().get("not-unlockable-description");
244                I18nizableText msg = new I18nizableText(ed.getCatalogue(), ed.getKey(), Collections.singletonList(_contentHelper.getTitle(content)));
245
246                contentParams.put("description", msg);
247
248                List<Map<String, Object>> notUnlockable = (List<Map<String, Object>>) results.get("not-unlockable-contents");
249                notUnlockable.add(contentParams);
250
251                isUnlockable = true;
252            }
253        }
254
255        return isUnlockable;
256    }
257
258    /**
259     * Get the default content's parameters
260     * 
261     * @param content The content
262     * @return The default parameters
263     */
264    protected Map<String, Object> getContentDefaultParameters(Content content)
265    {
266        Map<String, Object> contentParams = new HashMap<>();
267        contentParams.put("id", content.getId());
268        contentParams.put("title", _contentHelper.getTitle(content));
269
270        return contentParams;
271    }
272
273    /**
274     * Set alerts on content
275     * 
276     * @param contentIds the content's id
277     * @param params the alerts' parameters
278     * @throws ServiceException if an error occurred while retrieving the alert
279     *             scheduler component
280     * @throws SchedulerException if an error occurred while computing the runnable id 
281     */
282    @Callable
283    public void setAlertsOnContent(List<String> contentIds, Map<String, Object> params) throws ServiceException, SchedulerException 
284    {
285        for (String contentId : contentIds)
286        {
287            Content content = _resolver.resolveById(contentId);
288            if (content instanceof ModifiableDataAwareVersionableAmetysObject)
289            {
290                _setAlerts((ModifiableDataAwareVersionableAmetysObject) content, params);
291            }
292        }
293
294        boolean instantAlertEnabled = (Boolean) params.get("instantAlertEnabled");
295        if (instantAlertEnabled)
296        {
297            // get the schedulable id provided by the caller. Or the CMS alertSchedulable if none.
298            String schedulableId = params.containsKey("schedulable-id") ? (String) params.get("schedulable-id") : AlertSchedulable.SCHEDULABLE_ID;
299
300            String instantAlertText = (String) params.get("instantAlertText");
301            Map<String, Object> jobParams = new HashMap<>();
302            jobParams.put(AlertSchedulable.JOBDATAMAP_INSTANT_MODE_KEY, true);
303            jobParams.put(AlertSchedulable.JOBDATAMAP_CONTENT_IDS_KEY, contentIds);
304            jobParams.put(AlertSchedulable.JOBDATAMAP_MESSAGE_KEY, instantAlertText);
305            
306            List<String> i18nParams = new ArrayList<>();
307            i18nParams.add(StringUtils.join(contentIds, ", "));
308            
309            Runnable runnable = new DefaultRunnable(_generateRunnableId(), 
310                    new I18nizableText("plugin.cms", "PLUGINS_CMS_CONTENTS_INSTANT_ALERTS_RUNNABLE_LABEL", i18nParams),
311                    new I18nizableText("plugin.cms", contentIds.size() <= 1 ? "PLUGINS_CMS_CONTENTS_INSTANT_ALERTS_RUNNABLE_DESCRIPTION" : "PLUGINS_CMS_CONTENTS_INSTANT_ALERTS_RUNNABLE_DESCRIPTION_MULTIPLE" , i18nParams),
312                    FireProcess.NOW,
313                    null /* cron*/, 
314                    schedulableId, 
315                    true /* removable */, 
316                    false /* modifiable */, 
317                    false /* deactivatable */, 
318                    MisfirePolicy.FIRE_ONCE, 
319                    true /* isVolatile */, 
320                    _currentUserProvider.getUser(), 
321                    jobParams
322                );
323            
324            try
325            {
326                _scheduler.scheduleJob(runnable);
327            }
328            catch (SchedulerException e)
329            {
330                getLogger().error("An error occured while trying to schedule the sending of alerts", e);
331            }
332        }
333    }
334
335    private String _generateRunnableId()
336    {
337        return "org.ametys.cms.alerts.InstantAlertRunnable" + "$" + UUID.randomUUID();
338    }
339
340    /**
341     * Sets the alerts on the specified content.
342     * 
343     * @param content the content to set the alerts on.
344     * @param params the alerts' parameters
345     * @throws AmetysRepositoryException if a repository error occurs.
346     */
347    protected void _setAlerts(ModifiableDataAwareVersionableAmetysObject content, Map<String, Object> params) throws AmetysRepositoryException
348    {
349        ModifiableModelLessDataHolder dataHolder = content.getUnversionedDataHolder();
350
351        // Set alert for unmodified contents
352        if (params.get("unmodifiedAlertEnabled") != null)
353        {
354            boolean unmodifiedAlertEnabled = (Boolean) params.get("unmodifiedAlertEnabled");
355            String unmodifiedAlertText = (String) params.get("unmodifiedAlertText");
356
357            dataHolder.setValue(AlertsConstants.UNMODIFIED_ALERT_ENABLED, unmodifiedAlertEnabled);
358            dataHolder.setValue(AlertsConstants.UNMODIFIED_ALERT_TEXT, StringUtils.trimToEmpty(unmodifiedAlertText));
359        }
360
361        // Set reminders
362        if (params.get("reminderEnabled") != null)
363        {
364            boolean reminderEnabled = (Boolean) params.get("reminderEnabled");
365            dataHolder.setValue(AlertsConstants.REMINDER_ENABLED, reminderEnabled);
366
367            String reminderDateStr = (String) params.get("reminderDate");
368            String reminderText = (String) params.get("reminderText");
369            dataHolder.setValue(AlertsConstants.REMINDER_TEXT, StringUtils.trimToEmpty(reminderText));
370
371            // Parse the reminder date.
372            if (StringUtils.isNotBlank(reminderDateStr))
373            {
374                LocalDate localDate = DateUtils.parseLocalDate(reminderDateStr);
375                if (localDate != null)
376                {
377                    ZonedDateTime zonedDateTime = DateUtils.asZonedDateTime(localDate, null);
378                    dataHolder.setValue(AlertsConstants.REMINDER_DATE, zonedDateTime);
379                }
380                else
381                {
382                    getLogger().error("Unable to parse reminder date " + reminderDateStr);
383                }
384            }
385        }
386
387        content.saveChanges();
388    }
389
390    @Override
391    public List<Script> getScripts(boolean ignoreRights, Map<String, Object> contextParameters)
392    {
393        boolean enabled = Config.getInstance().getValue("remind.content.enabled");
394        if (enabled)
395        {
396            return super.getScripts(ignoreRights, contextParameters);
397        }
398
399        return new ArrayList<>();
400    }
401}