001/*
002 *  Copyright 2016 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.core.schedule;
017
018import java.util.ArrayList;
019import java.util.Collections;
020import java.util.Date;
021import java.util.HashMap;
022import java.util.LinkedHashMap;
023import java.util.List;
024import java.util.Map;
025import java.util.Objects;
026import java.util.Optional;
027import java.util.Properties;
028import java.util.Set;
029import java.util.stream.Collectors;
030
031import org.apache.avalon.framework.activity.Disposable;
032import org.apache.avalon.framework.activity.Initializable;
033import org.apache.avalon.framework.component.Component;
034import org.apache.avalon.framework.context.Context;
035import org.apache.avalon.framework.context.ContextException;
036import org.apache.avalon.framework.context.Contextualizable;
037import org.apache.avalon.framework.service.ServiceException;
038import org.apache.avalon.framework.service.ServiceManager;
039import org.apache.avalon.framework.service.Serviceable;
040import org.apache.cocoon.components.ContextHelper;
041import org.apache.cocoon.environment.Request;
042import org.apache.commons.lang3.StringUtils;
043import org.apache.excalibur.source.SourceResolver;
044import org.quartz.CronScheduleBuilder;
045import org.quartz.CronTrigger;
046import org.quartz.JobBuilder;
047import org.quartz.JobDataMap;
048import org.quartz.JobDetail;
049import org.quartz.JobExecutionContext;
050import org.quartz.JobKey;
051import org.quartz.SchedulerException;
052import org.quartz.Trigger;
053import org.quartz.Trigger.TriggerState;
054import org.quartz.TriggerBuilder;
055import org.quartz.TriggerKey;
056import org.quartz.impl.StdSchedulerFactory;
057import org.quartz.impl.matchers.GroupMatcher;
058import org.quartz.utils.PoolingConnectionProvider;
059
060import org.ametys.core.datasource.ConnectionHelper;
061import org.ametys.core.datasource.SQLDataSourceManager;
062import org.ametys.core.datasource.dbtype.SQLDatabaseType;
063import org.ametys.core.datasource.dbtype.SQLDatabaseTypeExtensionPoint;
064import org.ametys.core.right.RightManager;
065import org.ametys.core.schedule.AmetysJob;
066import org.ametys.core.schedule.Runnable;
067import org.ametys.core.schedule.Runnable.FireProcess;
068import org.ametys.core.schedule.RunnableExtensionPoint;
069import org.ametys.core.schedule.Schedulable;
070import org.ametys.core.schedule.SchedulableExtensionPoint;
071import org.ametys.core.script.SQLScriptHelper;
072import org.ametys.core.ui.Callable;
073import org.ametys.core.user.CurrentUserProvider;
074import org.ametys.core.user.UserIdentity;
075import org.ametys.core.user.UserManager;
076import org.ametys.core.util.LambdaUtils;
077import org.ametys.plugins.core.impl.schedule.DefaultRunnable;
078import org.ametys.plugins.core.user.UserHelper;
079import org.ametys.runtime.config.Config;
080import org.ametys.runtime.i18n.I18nizableText;
081import org.ametys.runtime.model.DefinitionContext;
082import org.ametys.runtime.model.ElementDefinition;
083import org.ametys.runtime.model.type.ElementType;
084import org.ametys.runtime.plugin.component.AbstractLogEnabled;
085import org.ametys.runtime.workspace.WorkspaceMatcher;
086
087/**
088 * The scheduler component
089 */
090public class Scheduler extends AbstractLogEnabled implements Component, Initializable, Disposable, Serviceable, Contextualizable
091{
092    /** The Avalon Role */
093    public static final String ROLE = Scheduler.class.getName();
094    
095    /** The group name for jobs */
096    public static final String JOB_GROUP = "runtime.job";
097    /** The group name for triggers */
098    public static final String TRIGGER_GROUP = "runtime.trigger";
099    /** The key for the id of the schedulable to execute */
100    public static final String KEY_SCHEDULABLE_ID = "schedulableId";
101    /** The key for the runnable id */
102    public static final String KEY_RUNNABLE_ID = "id";
103    /** The key for the runnable label */
104    public static final String KEY_RUNNABLE_LABEL = "label";
105    /** The key for the runnable description */
106    public static final String KEY_RUNNABLE_DESCRIPTION = "description";
107    /** The key for the runnable fire process property */
108    public static final String KEY_RUNNABLE_FIRE_PROCESS = "fireProcess";
109    /** The key for 'run at startup' jobs indicating if the job has already been executed and is now completed */
110    public static final String KEY_RUNNABLE_STARTUP_COMPLETED = "runAtStartupCompleted";
111    /** The key for the runnable cron expression */
112    public static final String KEY_RUNNABLE_CRON = "cron";
113    /** The key for the runnable removable property */
114    public static final String KEY_RUNNABLE_REMOVABLE = "removable";
115    /** The key for the runnable modifiable property */
116    public static final String KEY_RUNNABLE_MODIFIABLE = "modifiable";
117    /** The key for the runnable deactivatable property */
118    public static final String KEY_RUNNABLE_DEACTIVATABLE = "deactivatable";
119    /** The key for the runnable volatile property */
120    public static final String KEY_RUNNABLE_VOLATILE = "volatile";
121    /** The key to retrieve the {@link UserIdentity} */
122    public static final String KEY_RUNNABLE_USERIDENTITY = "userIdentity";
123    /** The prefix for the parameter values of the runnable job in the job data map */
124    public static final String PARAM_VALUES_PREFIX = "parameterValues#";
125    /** Name of the parameter holding the datasource id for Quartz */
126    public static final String DATASOURCE_CONFIG_NAME = "runtime.scheduler.datasource";
127    
128    /** The name of the configuration file for Quartz */
129    private static final String __QUARTZ_CONFIG_FILE_NAME = "quartz.properties";
130    /** The id of the right to execute actions on tasks */
131    private static final String __RIGHT_SCHEDULER = "CORE_Rights_TaskScheduler";
132    
133    /** The service manager */
134    protected ServiceManager _manager;
135    /** The context */
136    protected Context _context;
137    /** The extension point for {@link Runnable}s */
138    protected RunnableExtensionPoint _runnableEP;
139    /** The extension point for {@link Schedulable}s */
140    protected SchedulableExtensionPoint _schedulableEP;
141    /** The Quartz scheduler */
142    protected org.quartz.Scheduler _scheduler;
143    /** The manager for SQL datasources */
144    protected SQLDataSourceManager _sqlDataSourceManager;
145    /** The source resolver */
146    protected SourceResolver _sourceResolver;
147    /** The rights manager */
148    protected RightManager _rightManager;
149    /** The provider of current user */
150    protected CurrentUserProvider _currentUserProvider;
151    /** The sql database type ep */
152    protected SQLDatabaseTypeExtensionPoint _sqlDatabaseTypeEP;
153    /** The user manager */
154    protected UserManager _userManager;
155    /** The user helper */
156    protected UserHelper _userHelper;
157
158    @Override
159    public void contextualize(Context context) throws ContextException
160    {
161        _context = context;
162    }
163    
164    @Override
165    public void service(ServiceManager manager) throws ServiceException
166    {
167        _manager = manager;
168        _runnableEP = (RunnableExtensionPoint) manager.lookup(RunnableExtensionPoint.ROLE);
169        _schedulableEP = (SchedulableExtensionPoint) manager.lookup(SchedulableExtensionPoint.ROLE);
170        _sqlDataSourceManager = (SQLDataSourceManager) manager.lookup(SQLDataSourceManager.ROLE);
171        _sqlDatabaseTypeEP = (SQLDatabaseTypeExtensionPoint) manager.lookup(SQLDatabaseTypeExtensionPoint.ROLE);
172        _sourceResolver = (SourceResolver) manager.lookup(SourceResolver.ROLE);
173        _rightManager = (RightManager) manager.lookup(RightManager.ROLE);
174        _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE);
175        _userManager = (UserManager) manager.lookup(UserManager.ROLE);
176        _userHelper = (UserHelper) manager.lookup(UserHelper.ROLE);
177        
178        // Ensure statics methods will be available during initialize
179        manager.lookup(ConnectionHelper.ROLE);
180    }
181    
182    @Override
183    public void initialize() throws Exception
184    {
185        // Be sure the tables are created
186        String dsId = Config.getInstance().getValue(DATASOURCE_CONFIG_NAME);
187        _checkAndCreateTables(dsId);
188        
189        // Get info about datasource
190        Map<String, Object> dsParameters = _sqlDataSourceManager.getDataSourceDefinition(dsId).getParameters();
191        String url = (String) dsParameters.get("url");
192        String user = (String) dsParameters.get("user");
193        String password = (String) dsParameters.get("password");
194        String dbtype = (String) dsParameters.get("dbtype");
195        SQLDatabaseType sqlDbType = _sqlDatabaseTypeEP.getExtension(dbtype);
196        
197        String dbType = ConnectionHelper.getDatabaseType(url);
198        String dsName = "quartzDb";
199        
200        // Set the properties for Quartz
201        Properties props = new Properties();
202        props.load(Scheduler.class.getResourceAsStream(__QUARTZ_CONFIG_FILE_NAME));
203        props.setProperty("org.quartz.jobStore.dataSource", dsName);
204        props.setProperty(StdSchedulerFactory.PROP_DATASOURCE_PREFIX + "." + dsName + "." + PoolingConnectionProvider.DB_DRIVER, sqlDbType.getDriver());
205        props.setProperty(StdSchedulerFactory.PROP_DATASOURCE_PREFIX + "." + dsName + "." + PoolingConnectionProvider.DB_URL, url);
206        props.setProperty(StdSchedulerFactory.PROP_DATASOURCE_PREFIX + "." + dsName + "." + PoolingConnectionProvider.DB_USER, user);
207        props.setProperty(StdSchedulerFactory.PROP_DATASOURCE_PREFIX + "." + dsName + "." + PoolingConnectionProvider.DB_PASSWORD, password);
208        props.setProperty("org.quartz.jobStore.driverDelegateClass", _getDriverDelegateClass(dbType));
209        StdSchedulerFactory factory = new StdSchedulerFactory(props);
210        _scheduler = factory.getScheduler();
211        
212        // Initialize AmetysJob class
213        AmetysJob.initialize(_manager, _context);
214    }
215    
216    /* copy/paste of SqlTablesInit#init() because SqlTablesInit comes too late */
217    private void _checkAndCreateTables(String dataSourceId)
218    {
219        // Test and create tables
220        try
221        {
222            SQLScriptHelper.createTableIfNotExists(dataSourceId, "QRTZ_JOB_DETAILS", "plugin:core://scripts/%s/quartz.sql", _sourceResolver);
223        }
224        catch (Exception e)
225        {
226            String errorMsg = String.format("Error during SQL tables initialization for data source id: '%s'.", StringUtils.defaultString(dataSourceId));
227            getLogger().error(errorMsg, e);
228        }
229    }
230    
231    private String _getDriverDelegateClass(String dbType)
232    {
233        switch (dbType)
234        {
235            case ConnectionHelper.DATABASE_HSQLDB:
236                return "org.quartz.impl.jdbcjobstore.HSQLDBDelegate";
237            case ConnectionHelper.DATABASE_POSTGRES:
238                return "org.quartz.impl.jdbcjobstore.PostgreSQLDelegate";
239            default:
240                return "org.quartz.impl.jdbcjobstore.StdJDBCDelegate";
241        }
242    }
243    
244    /**
245     * Starts the Quartz scheduler. Only call this method once !
246     * @throws SchedulerException if an error occured
247     */
248    public void start() throws SchedulerException
249    {
250        _removeVolatileJobs();
251        _scheduleConfigurableJobs();
252        _triggerRunAtStartupJobs();
253        
254        getLogger().info("Start up the scheduler");
255        _scheduler.start();
256    }
257    
258    /**
259     * Get the Quartz scheduler
260     * @return the scheduler
261     */
262    public org.quartz.Scheduler getScheduler()
263    {
264        return _scheduler;
265    }
266    
267    private void _removeVolatileJobs() throws SchedulerException
268    {
269        for (JobKey jobKey : getJobs())
270        {
271            JobDataMap jobDataMap = _scheduler.getJobDetail(jobKey).getJobDataMap();
272            if (jobDataMap.getBoolean(KEY_RUNNABLE_VOLATILE))
273            {
274                _scheduler.deleteJob(jobKey);
275            }
276        }
277    }
278    
279    private void _scheduleConfigurableJobs()
280    {
281        try
282        {
283            for (String runnableId : _runnableEP.getExtensionsIds())
284            {
285                JobKey jobKey = new JobKey(runnableId, JOB_GROUP);
286                if (_scheduler.checkExists(jobKey))
287                {
288                    // Check if exists, but it should never do since the configurable are told volatile and were removed before
289                    _scheduler.deleteJob(jobKey);
290                }
291                Runnable runnable = _runnableEP.getExtension(runnableId);
292                
293                scheduleJob(runnable);
294            }
295        }
296        catch (SchedulerException e)
297        {
298            getLogger().error("An exception occured during the scheduling of configurable runnables", e);
299        }
300    }
301    
302    private void _triggerRunAtStartupJobs() throws SchedulerException
303    {
304        for (JobKey jobKey : getJobs())
305        {
306            JobDataMap jobDataMap = _scheduler.getJobDetail(jobKey).getJobDataMap();
307            if (FireProcess.STARTUP.toString().equals(jobDataMap.getString(KEY_RUNNABLE_FIRE_PROCESS))
308                    && !jobDataMap.getBoolean(KEY_RUNNABLE_STARTUP_COMPLETED))
309            {
310                _scheduler.triggerJob(jobKey);
311            }
312        }
313    }
314    
315    /**
316     * Schedules a job
317     * @param runnable The runnable job to schedule
318     * @throws SchedulerException if the Job or Trigger cannot be added to the Scheduler, or there is an internal Scheduler error.
319     */
320    public void scheduleJob(Runnable runnable) throws SchedulerException
321    {
322        String runnableId = runnable.getId();
323        String schedulableId = runnable.getSchedulableId();
324        
325        // Schedule the job
326        JobDetail jobDetail = null;
327        JobBuilder jobBuilder = JobBuilder.newJob(AmetysJob.class)
328                .withIdentity(runnableId, JOB_GROUP)
329                .usingJobData(KEY_SCHEDULABLE_ID, schedulableId)
330                .usingJobData(_runnableToJobDataMap(runnable));
331        
332        Date firstTime = null;
333        switch (runnable.getFireProcess())
334        {
335            case NEVER:
336                // will never fire
337                jobDetail = jobBuilder.storeDurably().build();
338                
339                _scheduler.addJob(jobDetail, true);
340                getLogger().info("{} has been scheduled to never run", jobDetail.getKey());
341                break;
342            case STARTUP:
343                // will fire at next startup
344                jobDetail = jobBuilder.storeDurably().build();
345                
346                _scheduler.addJob(jobDetail, true);
347                getLogger().info("{} has been scheduled to run at next startup of the application", jobDetail.getKey());
348                break;
349            case NOW:
350                // will be triggered now
351                jobDetail = jobBuilder.storeDurably().build();
352                
353                Trigger simpleTrigger = TriggerBuilder.newTrigger()
354                        .withIdentity(runnableId, TRIGGER_GROUP)
355                        .startNow()
356                        .build();
357                
358                firstTime = _scheduler.scheduleJob(jobDetail, simpleTrigger);
359                getLogger().info("{} has been scheduled to run as soon as possible", jobDetail.getKey());
360                break;
361            case CRON:
362            default:
363                // based on a cron trigger
364                jobDetail = jobBuilder.storeDurably().build();
365                
366                CronScheduleBuilder schedBuilder = CronScheduleBuilder.cronSchedule(runnable.getCronExpression());
367                switch (runnable.getMisfirePolicy())
368                {
369                    case IGNORE:
370                        schedBuilder.withMisfireHandlingInstructionIgnoreMisfires();
371                        break;
372                    case FIRE_ONCE:
373                        schedBuilder.withMisfireHandlingInstructionFireAndProceed();
374                        break;
375                    case DO_NOTHING:
376                    default:
377                        schedBuilder.withMisfireHandlingInstructionDoNothing();
378                        break;
379                }
380                CronTrigger cronTrigger = TriggerBuilder.newTrigger()
381                        .withIdentity(runnableId, TRIGGER_GROUP)
382                        .startNow()
383                        .withSchedule(schedBuilder)
384                        .build();
385                
386                firstTime = _scheduler.scheduleJob(jobDetail, cronTrigger);
387                getLogger().info("{} has been scheduled to run at: {} and repeat based on expression: {}", jobDetail.getKey(), firstTime, cronTrigger.getCronExpression());
388                break;
389        }
390    }
391    
392    private JobDataMap _runnableToJobDataMap(Runnable runnable)
393    {
394        Map<String, Object> result = new HashMap<>();
395        result.put(KEY_RUNNABLE_ID, runnable.getId());
396        result.put(KEY_RUNNABLE_LABEL, I18nizableText.i18nizableTextToString(runnable.getLabel()));
397        result.put(KEY_RUNNABLE_DESCRIPTION, I18nizableText.i18nizableTextToString(runnable.getDescription()));
398        result.put(KEY_RUNNABLE_FIRE_PROCESS, runnable.getFireProcess().toString());
399        result.put(KEY_RUNNABLE_CRON, runnable.getCronExpression());
400        result.put(KEY_RUNNABLE_REMOVABLE, runnable.isRemovable());
401        result.put(KEY_RUNNABLE_MODIFIABLE, runnable.isModifiable());
402        result.put(KEY_RUNNABLE_DEACTIVATABLE, runnable.isDeactivatable());
403        result.put(KEY_RUNNABLE_VOLATILE, runnable.isVolatile());
404        result.put(KEY_RUNNABLE_USERIDENTITY, UserIdentity.userIdentityToString(runnable.getUserIdentity()));
405        
406        
407        // parameter values
408        for (Object paramId : runnable.getParameterValues().keySet())
409        {
410            result.put(PARAM_VALUES_PREFIX + paramId, runnable.getParameterValues().get(paramId));
411        }
412        
413        return new JobDataMap(result);
414    }
415    
416    /**
417     * Gets the jobs of the Quartz scheduler
418     * @return the jobs
419     * @throws SchedulerException if an error occured
420     */
421    public Set<JobKey> getJobs() throws SchedulerException
422    {
423        return _scheduler.getJobKeys(GroupMatcher.jobGroupEquals(JOB_GROUP));
424    }
425    
426    /**
427     * Gets tasks information
428     * @param taskIds The ids of the tasks
429     * @return The tasks information
430     * @throws SchedulerException if an error occurred
431     */
432    @Callable(right = __RIGHT_SCHEDULER, context = "/admin")
433    public List<Map<String, Object>> getTasksInformation(List<String> taskIds) throws SchedulerException
434    {
435        List<Map<String, Object>> result = new ArrayList<>();
436        
437        for (JobKey jobKey : getJobs())
438        {
439            String id = jobKey.getName();
440            if (taskIds.contains(id))
441            {
442                Map<String, Object> task = new HashMap<>();
443                JobDataMap jobDataMap = _scheduler.getJobDetail(jobKey).getJobDataMap();
444                task.put("id", id);
445                task.put("modifiable", jobDataMap.getBoolean(KEY_RUNNABLE_MODIFIABLE));
446                task.put("removable", jobDataMap.getBoolean(KEY_RUNNABLE_REMOVABLE));
447                task.put("deactivatable", jobDataMap.getBoolean(KEY_RUNNABLE_DEACTIVATABLE));
448                result.add(task);
449            }
450        }
451        
452        return result;
453    }
454    
455    /**
456     * Gets all the scheduled tasks
457     * @return the scheduled tasks
458     * @throws Exception if an error occured
459     */
460    public List<Map<String, Object>> getTasksAsJson() throws Exception
461    {
462        List<JobKey> executingJobs = _getExecutingJobs();
463        
464        return getJobs().stream()
465                .map(LambdaUtils.wrap(key-> _jobToJson(key, executingJobs)))
466                .filter(Objects::nonNull)
467                .collect(Collectors.toList());
468    }
469    
470    private List<JobKey> _getExecutingJobs() throws SchedulerException
471    {
472        return _scheduler.getCurrentlyExecutingJobs().stream()
473                .map(JobExecutionContext::getJobDetail)
474                .map(JobDetail::getKey)
475                .collect(Collectors.toList());
476    }
477    
478    private Map<String, Object> _jobToJson(JobKey jobKey, List<JobKey> executingJobs) throws Exception
479    {
480        Map<String, Object> result = new HashMap<>();
481        
482        JobDataMap jobDataMap = _scheduler.getJobDetail(jobKey).getJobDataMap();
483        
484        String id = jobDataMap.getString(KEY_RUNNABLE_ID);
485        result.put("id", id);
486        
487        String schedulableId = jobDataMap.getString(KEY_SCHEDULABLE_ID);
488        Schedulable schedulable;
489        if (_schedulableEP.hasExtension(schedulableId))
490        {
491            schedulable = _schedulableEP.getExtension(schedulableId);
492        }
493        else
494        {
495            // Abort trying to JSONify this job, as its associated schedulable does not exist anymore
496            // It will not be displayed in the UI tool
497            getLogger().warn("The Runnable '{}' is associated to a Schedulable that does not exist anymore ({}).", id, schedulableId);
498            return null;
499        }
500        
501        result.put("label", I18nizableText.stringToI18nizableText((String) jobDataMap.get(KEY_RUNNABLE_LABEL)));
502        result.put("description", I18nizableText.stringToI18nizableText((String) jobDataMap.get(KEY_RUNNABLE_DESCRIPTION)));
503        String fireProcess = jobDataMap.getString(KEY_RUNNABLE_FIRE_PROCESS);
504        result.put("fireProcess", fireProcess);
505        result.put("launchUser", Optional.ofNullable(jobDataMap.getString(KEY_RUNNABLE_USERIDENTITY)) // Test if KEY_RUNNABLE_USERIDENTITY is null
506                                         .map(UserIdentity::stringToUserIdentity) // Get the UserIdentity
507                                         .map(_userManager::getUser) // Get the User from the UserIdentity
508                                         .map(u -> _userHelper.user2json(u, true)) // Transform the User in JSON
509                                         .orElse(Collections.EMPTY_MAP)); // Return an empty map if we get an Optionnal.EMPTY
510        result.put("removable", jobDataMap.getBoolean(KEY_RUNNABLE_REMOVABLE));
511        result.put("modifiable", jobDataMap.getBoolean(KEY_RUNNABLE_MODIFIABLE));
512        result.put("deactivatable", jobDataMap.getBoolean(KEY_RUNNABLE_DEACTIVATABLE));
513        result.put("lastDuration", jobDataMap.get(AmetysJob.KEY_LAST_DURATION));
514        result.put("success", jobDataMap.get(AmetysJob.KEY_SUCCESS));
515        result.put("running", _isRunningJob(jobKey, executingJobs));
516        
517        result.put("schedulable", _schedulableToJson(schedulable, false));
518        
519        Long previousFireTimeMillis = null;
520        String cronExpression = null;
521        TriggerKey triggerKey = new TriggerKey(jobKey.getName(), TRIGGER_GROUP);
522        if (_scheduler.checkExists(triggerKey))
523        {
524            Trigger trigger = _scheduler.getTrigger(triggerKey);
525            _triggerToJson(trigger, result);
526            if (trigger instanceof CronTrigger)
527            {
528                cronExpression = ((CronTrigger) trigger).getCronExpression();
529            }
530            result.put("completed", false);
531            Date previousFireTime  = trigger.getPreviousFireTime();
532            if (previousFireTime != null)
533            {
534                previousFireTimeMillis = previousFireTime.getTime();
535            }
536        }
537        else
538        {
539            result.put("enabled", true);
540            result.put("completed", !Runnable.FireProcess.STARTUP.toString().equals(fireProcess) || jobDataMap.getBoolean(KEY_RUNNABLE_STARTUP_COMPLETED));
541        }
542        
543        if (cronExpression == null && Runnable.FireProcess.CRON.toString().equals(fireProcess))
544        {
545            // CRON based job, but no trigger (because no next execution), so get the cron expr in the job data map
546            cronExpression = jobDataMap.getString(KEY_RUNNABLE_CRON);
547        }
548        result.put("cronExpression", cronExpression);
549        
550        if (previousFireTimeMillis == null && jobDataMap.containsKey(AmetysJob.KEY_PREVIOUS_FIRE_TIME))
551        {
552            previousFireTimeMillis = (Long) jobDataMap.get(AmetysJob.KEY_PREVIOUS_FIRE_TIME);
553        }
554        result.put("previousFireTime", previousFireTimeMillis);
555        
556        return result;
557    }
558    
559    private boolean _isRunningJob(JobKey jobKey, List<JobKey> executingJobs)
560    {
561        return executingJobs.contains(jobKey);
562    }
563    
564    private void _triggerToJson(Trigger trigger, Map<String, Object> result) throws Exception
565    {
566        result.put("enabled", !TriggerState.PAUSED.equals(_scheduler.getTriggerState(trigger.getKey())));
567        result.put("nextFireTime", trigger.getNextFireTime());
568    }
569    
570    private Map<String, Object> _schedulableToJson(Schedulable schedulable, boolean isEdition) throws Exception
571    {
572        Map<String, Object> result = new HashMap<>();
573        result.put("id", schedulable.getId());
574        result.put("label", schedulable.getLabel());
575        result.put("description", schedulable.getDescription());
576        result.put("iconGlyph", schedulable.getIconGlyph());
577        result.put("iconSmall", schedulable.getIconSmall());
578        result.put("iconMedium", schedulable.getIconMedium());
579        result.put("iconLarge", schedulable.getIconLarge());
580        result.put("private", schedulable.isPrivate());
581        
582        Map<String, Object> params = new LinkedHashMap<>();
583        for (String paramId : schedulable.getParameters().keySet())
584        {
585            params.put(schedulable.getId() + "$" + paramId, schedulable.getParameters().get(paramId).toJSON(DefinitionContext.newInstance().withEdition(isEdition)));
586        }
587        result.put("parameters", params);
588        
589        return result;
590    }
591    
592    /**
593     * Gets the configuration for creating/editing a runnable (so returns all the schedulables and their parameters)
594     * @return A map containing information about what is needed to create/edit a runnable
595     * @throws Exception If an error occurs.
596     */
597    @Callable(right = __RIGHT_SCHEDULER, context = "/admin")
598    public Map<String, Object> getEditionConfiguration() throws Exception
599    {
600        Map<String, Object> result = new HashMap<>();
601        
602        List<Map<String, Object>> fireProcesses = new ArrayList<>();
603        for (FireProcess fireProcessVal : FireProcess.values())
604        {
605            if (fireProcessVal.equals(FireProcess.NEVER))
606            {
607                // do not display the 'NEVER' option
608                continue;
609            }
610            Map<String, Object> fireProcess = new LinkedHashMap<>();
611            fireProcess.put("value", fireProcessVal.toString());
612            String i18nKey = "PLUGINS_CORE_UI_TASKS_DIALOG_FIRE_PROCESS_OPTION_" + fireProcessVal.toString() + "_LABEL";
613            fireProcess.put("label", new I18nizableText("plugin.core-ui", i18nKey));
614            fireProcesses.add(fireProcess);
615        }
616        result.put("fireProcesses", fireProcesses);
617        
618        List<Object> schedulables = new ArrayList<>();
619        for (String schedulableId : _schedulableEP.getExtensionsIds())
620        {
621            Schedulable schedulable = _schedulableEP.getExtension(schedulableId);
622            if (!schedulable.isPrivate())
623            {
624                schedulables.add(_schedulableToJson(schedulable, true));
625            }
626        }
627        result.put("schedulables", schedulables);
628        
629        return result;
630    }
631    
632    /**
633     * Gets the parameters of a given schedulable
634     * @param schedulableId The id of the schedulable
635     * @return A map containing parameters (prefixed) of the given schedulable
636     * @throws Exception If an error occurs.
637     */
638    @Callable
639    public Map<String, Object> getParameters(String schedulableId) throws Exception
640    {
641        Schedulable schedulable = _schedulableEP.getExtension(schedulableId);
642        Map<String, Object> params = new LinkedHashMap<>();
643        for (String paramId : schedulable.getParameters().keySet())
644        {
645            params.put(schedulable.getId() + "$" + paramId, schedulable.getParameters().get(paramId).toJSON(DefinitionContext.newInstance().withEdition(true)));
646        }
647        return params;
648    }
649    
650    /**
651     * Gets the values of the parameters of the given task
652     * @param id The id of the task
653     * @return The values of the parameters
654     */
655    @Callable(right = __RIGHT_SCHEDULER, context = "/admin")
656    public Map<String, Object> getParameterValues(String id)
657    {
658        Map<String, Object> result = new HashMap<>();
659        try
660        {
661            JobDetail jobDetail = _scheduler.getJobDetail(new JobKey(id, JOB_GROUP));
662            JobDataMap jobDataMap = jobDetail.getJobDataMap();
663            result.put("id", id);
664            result.put("label", I18nizableText.stringToI18nizableText((String) jobDataMap.get(KEY_RUNNABLE_LABEL)));
665            result.put("description", I18nizableText.stringToI18nizableText((String) jobDataMap.get(KEY_RUNNABLE_DESCRIPTION)));
666            result.put("fireProcess", jobDataMap.getString(KEY_RUNNABLE_FIRE_PROCESS));
667            result.put("cron", jobDataMap.getString(KEY_RUNNABLE_CRON));
668            String schedulableId = jobDataMap.getString(KEY_SCHEDULABLE_ID);
669            result.put("schedulableId", schedulableId);
670            result.put("launchUser", jobDataMap.getString(KEY_RUNNABLE_USERIDENTITY));
671            Map<String, Object> params = new HashMap<>();
672            result.put("params", params);
673            for (String param : jobDataMap.keySet())
674            {
675                if (param.startsWith(PARAM_VALUES_PREFIX))
676                {
677                    params.put(schedulableId + "$" + param.substring(PARAM_VALUES_PREFIX.length()), jobDataMap.get(param));
678                }
679            }
680            return result;
681        }
682        catch (SchedulerException e)
683        {
684            getLogger().error("An error occured when trying to retrieve the parameter values of the task " + id, e);
685            result.put("error", "scheduler-error");
686            return result;
687        }
688    }
689    
690    /**
691     * Returns true if the given task is modifiable.
692     * @param id The id of the task
693     * @return true if the given task is modifiable.
694     * @throws SchedulerException if an error occured 
695     */
696    @Callable(right = __RIGHT_SCHEDULER, context = "/admin")
697    public Map<String, Object> isModifiable(String id) throws SchedulerException
698    {
699        Map<String, Object> result = new HashMap<>();
700        
701        JobDetail jobDetail = _scheduler.getJobDetail(new JobKey(id, JOB_GROUP));
702        if (jobDetail == null)
703        {
704            // Does not exist anymore
705            result.put("error", "not-found");
706            return result;
707        }
708        JobDataMap jobDataMap = jobDetail.getJobDataMap();
709        result.put("modifiable", jobDataMap.getBoolean(KEY_RUNNABLE_MODIFIABLE));
710        return result;
711    }
712
713    /**
714     * Adds a new task.
715     * @param label The label
716     * @param description The description
717     * @param fireProcess the fire process
718     * @param cron The cron expression
719     * @param schedulableId The id of the schedulable model
720     * @param params The values of the parameters
721     * @return A result map
722     * @throws SchedulerException if an error occured
723     */
724    @Callable
725    public Map<String, Object> add(String label, String description, String fireProcess, String cron, String schedulableId, Map<String, String> params) throws SchedulerException
726    {
727        Request request = ContextHelper.getRequest(_context);
728        String workspaceName = (String) request.getAttribute(WorkspaceMatcher.WORKSPACE_NAME);
729        UserIdentity taskUser = _currentUserProvider.getUser();
730        if (taskUser == null && !workspaceName.equals("admin"))
731        {
732            Map<String, Object> result = new HashMap<>();
733            result.put("error", "invalid-schedulable");
734            return result;
735        }
736        return add(label, description, fireProcess, cron, schedulableId, _userHelper.user2json(taskUser), params);
737    }
738    
739    /**
740     * Adds a new task.
741     * @param label The label
742     * @param description The description
743     * @param fireProcess the fire process
744     * @param cron The cron expression
745     * @param schedulableId The id of the schedulable model
746     * @param launchUser The user to launch the task
747     * @param params The values of the parameters
748     * @return A result map
749     * @throws SchedulerException if an error occured
750     */
751    @Callable(right = __RIGHT_SCHEDULER, context = "/admin")
752    public Map<String, Object> add(String label, String description, String fireProcess, String cron, String schedulableId, Map<String, Object> launchUser, Map<String, String> params) throws SchedulerException
753    {
754        Map<String, Object> result = new HashMap<>();
755        
756        if (_schedulableEP.getExtension(schedulableId) == null)
757        {
758            result.put("error", "invalid-schedulable");
759            return result;
760        }
761        else if (_schedulableEP.getExtension(schedulableId).isPrivate())
762        {
763            result.put("error", "private");
764            return result;
765        }
766        
767        String id = _generateUniqueId(label);
768        return _edit(id, label, description, FireProcess.valueOf(fireProcess.toUpperCase()), cron, schedulableId, false, launchUser, params);
769    }
770    
771    private String _generateUniqueId(String label) throws SchedulerException
772    {
773        String value = label.toLowerCase().trim().replaceAll("[\\W_]", "-").replaceAll("-+", "-").replaceAll("^-", "");
774        int i = 2;
775        String suffixedValue = value;
776        while (_scheduler.checkExists(new JobKey(suffixedValue, JOB_GROUP)))
777        {
778            suffixedValue = value + i;
779            i++;
780        }
781        
782        return suffixedValue;
783    }
784    
785    /**
786     * Edits the given task.
787     * @param id The id of the task
788     * @param label The label
789     * @param description The description
790     * @param fireProcess the fire process
791     * @param cron The cron expression
792     * @param launchUser The user to launch the task
793     * @param params The values of the parameters
794     * @return A result map
795     * @throws SchedulerException if an error occured
796     */
797    @Callable(right = __RIGHT_SCHEDULER, context = "/admin")
798    public Map<String, Object> edit(String id, String label, String description, String fireProcess, String cron, Map<String, Object> launchUser, Map<String, String> params) throws SchedulerException
799    {
800        Map<String, Object> result = new HashMap<>();
801        
802        // Check if exist
803        JobDetail jobDetail = null;
804        JobKey jobKey = new JobKey(id, JOB_GROUP);
805        jobDetail = _scheduler.getJobDetail(jobKey);
806        if (jobDetail == null)
807        {
808            result.put("error", "not-found");
809            return result;
810        }
811        
812        // Check if modifiable
813        JobDataMap jobDataMap = jobDetail.getJobDataMap();
814        boolean isModifiable = jobDataMap.getBoolean(KEY_RUNNABLE_MODIFIABLE);
815        if (!isModifiable)
816        {
817            result.put("error", "no-modifiable");
818            return result;
819        }
820        
821        // Remove the associated job as we will create another one
822        if (!_scheduler.deleteJob(jobKey))
823        {
824            // Deletion did not succeed
825            result.put("error", "not-found");
826            return result;
827        }
828        
829        String schedulableId = jobDataMap.getString(KEY_SCHEDULABLE_ID);
830        boolean isVolatile = jobDataMap.getBoolean(KEY_RUNNABLE_VOLATILE);
831        return _edit(id, label, description, FireProcess.valueOf(fireProcess.toUpperCase()), cron, schedulableId, isVolatile, launchUser, params);
832    }
833    
834    private Map<String, Object> _edit(String id, String label, String description, FireProcess fireProcess, String cron, String schedulableId, boolean isVolatile, Map<String, Object> launchUser, Map<String, String> params)
835    {
836        Map<String, Object> result = new HashMap<>();
837        
838        Map<String, Object> typedParams = _getTypedParams(params, schedulableId);
839        
840        boolean deactivatable = !FireProcess.STARTUP.equals(fireProcess); // cannot disable a startup job as we do not attach any trigger to it
841        
842        // If the user is empty, we get the current user
843        UserIdentity userIdentity = _userHelper.json2userIdentity(launchUser);
844        if (userIdentity == null)
845        {
846            userIdentity = _currentUserProvider.getUser();
847        }
848        Runnable runnable = new DefaultRunnable(id, new I18nizableText(label), new I18nizableText(description), fireProcess, cron, schedulableId, true, true, deactivatable, null, isVolatile, userIdentity, typedParams);
849        
850        try
851        {
852            scheduleJob(runnable);
853        }
854        catch (SchedulerException e)
855        {
856            getLogger().error("An error occured when trying to add/edit the task " + id, e);
857            result.put("error", "scheduler-error");
858            return result;
859        }
860        
861        result.put("id", id);
862        return result;
863    }
864    
865    private Map<String, Object> _getTypedParams(Map<String, String> params, String schedulableId)
866    {
867        Map<String, Object> result = new HashMap<>();
868        
869        Map<String, ElementDefinition> declaredParams = _schedulableEP.getExtension(schedulableId).getParameters();
870        for (String nameWithPrefix : params.keySet())
871        {
872            String[] splitStr = nameWithPrefix.split("\\$", 2);
873            String prefix = splitStr[0];
874            String paramName = splitStr[1];
875            if (prefix.equals(schedulableId) && declaredParams.containsKey(paramName))
876            {
877                String originalValue = params.get(nameWithPrefix);
878                
879                ElementDefinition definition = declaredParams.get(paramName);
880                ElementType type = definition.getType();
881                
882                Object typedValue = type.castValue(originalValue);
883                result.put(paramName, typedValue);
884            }
885            else if (prefix.equals(schedulableId))
886            {
887                getLogger().warn("The parameter {} is not declared in schedulable {}. It will be ignored", paramName, schedulableId);
888            }
889        }
890        
891        return result;
892    }
893    
894    /**
895     * Removes the given task.
896     * @param id The id of the task
897     * @return A result map
898     * @throws SchedulerException if an error occured
899     */
900    @Callable(right = __RIGHT_SCHEDULER, context = "/admin")
901    public Map<String, Object> remove(String id) throws SchedulerException
902    {
903        Map<String, Object> result = new HashMap<>();
904        JobKey jobKey = new JobKey(id, JOB_GROUP);
905        JobDetail jobDetail = null;
906        try
907        {
908            jobDetail = _scheduler.getJobDetail(jobKey);
909        }
910        catch (SchedulerException e)
911        {
912            getLogger().error("An error occured when trying to remove the task " + id, e);
913            result.put("error", "scheduler-error");
914            return result;
915        }
916        if (jobDetail == null)
917        {
918            result.put("error", "not-found");
919            return result;
920        }
921        else if (_isRunningJob(jobKey, _getExecutingJobs()))
922        {
923            result.put("error", "is-running");
924            return result;
925        }
926        
927        JobDataMap jobDataMap = jobDetail.getJobDataMap();
928        boolean isRemovable = jobDataMap.getBoolean(KEY_RUNNABLE_REMOVABLE);
929        if (!isRemovable)
930        {
931            result.put("error", "no-removable");
932            return result;
933        }
934        
935        try
936        {
937            if (_scheduler.deleteJob(jobKey))
938            {
939                result.put("id", id);
940                return result;
941            }
942            else
943            {
944                // Deletion did not succeed
945                result.put("error", "no-delete");
946                return result;
947            }
948        }
949        catch (SchedulerException e)
950        {
951            getLogger().error("An error occured when trying to remove the task " + id, e);
952            result.put("error", "scheduler-error");
953            return result;
954        }
955    }
956    
957    /**
958     * Enables/disables the given task.
959     * @param id The id of the task
960     * @param enabled true to enable the task, false to disable it.
961     * @return A result map
962     */
963    @Callable(right = __RIGHT_SCHEDULER, context = "/admin")
964    public Map<String, Object> enable(String id, boolean enabled)
965    {
966        Map<String, Object> result = new HashMap<>();
967        
968        JobKey jobKey = new JobKey(id, JOB_GROUP);
969        JobDetail jobDetail = null;
970        try
971        {
972            jobDetail = _scheduler.getJobDetail(jobKey);
973        }
974        catch (SchedulerException e)
975        {
976            getLogger().error("An error occured when trying to enable/disable the task " + id, e);
977            result.put("error", "scheduler-error");
978            return result;
979        }
980        if (jobDetail == null)
981        {
982            result.put("error", "not-found");
983            return result;
984        }
985        JobDataMap jobDataMap = jobDetail.getJobDataMap();
986        boolean isDeactivatable = jobDataMap.getBoolean(KEY_RUNNABLE_DEACTIVATABLE);
987        if (!isDeactivatable)
988        {
989            result.put("error", "no-deactivatable");
990            return result;
991        }
992        
993        try
994        {
995            if (enabled)
996            {
997                _scheduler.resumeJob(jobKey);
998            }
999            else
1000            {
1001                _scheduler.pauseJob(jobKey);
1002            }
1003        }
1004        catch (SchedulerException e)
1005        {
1006            getLogger().error("An error occured when trying to enable/disable the task " + id, e);
1007            result.put("error", "scheduler-error");
1008            return result;
1009        }
1010        
1011        result.put("id", id);
1012        return result;
1013    }
1014    
1015    /**
1016     * Removes the completed tasks
1017     * @return The information of deleted tasks
1018     * @throws SchedulerException if an error occured
1019     */
1020    @Callable(right = __RIGHT_SCHEDULER, context = "/admin")
1021    public List<Map<String, Object>> removeCompletedTasks() throws SchedulerException
1022    {
1023        List<Map<String, Object>> targets = new ArrayList<>();
1024        
1025        List<JobKey> jobsToRemove = new ArrayList<>();
1026        for (JobKey jobKey : getJobs())
1027        {
1028            JobDetail jobDetail = _scheduler.getJobDetail(jobKey);
1029            JobDataMap jobDataMap = jobDetail.getJobDataMap();
1030            if (!Runnable.FireProcess.STARTUP.toString().equals(jobDataMap.getString(KEY_RUNNABLE_FIRE_PROCESS)) && !_scheduler.checkExists(new TriggerKey(jobKey.getName(), TRIGGER_GROUP)) // no trigger left when CRON or NOW
1031                    || Runnable.FireProcess.STARTUP.toString().equals(jobDataMap.getString(KEY_RUNNABLE_FIRE_PROCESS)) && jobDataMap.getBoolean(KEY_RUNNABLE_STARTUP_COMPLETED)) // explicitly said completed when STARTUP
1032            {
1033                jobsToRemove.add(jobKey);
1034            }
1035        }
1036        
1037        targets.addAll(getTasksInformation(jobsToRemove.stream().map(JobKey::getName).collect(Collectors.toList())));
1038        for (JobKey jobKey : jobsToRemove)
1039        {
1040            _scheduler.deleteJob(jobKey);
1041        }
1042        
1043        return targets;
1044    }
1045    
1046    /**
1047     * Returns the enabled state of the task.
1048     * @param id The id of the task
1049     * @return A result map
1050     */
1051    @Callable(right = __RIGHT_SCHEDULER, context = "/admin")
1052    public Map<String, Object> isEnabled(String id)
1053    {
1054        Map<String, Object> result = new HashMap<>();
1055        
1056        try
1057        {
1058            TriggerState state = _scheduler.getTriggerState(new TriggerKey(id, TRIGGER_GROUP));
1059            if (state == null)
1060            {
1061                // Does not exist anymore
1062                result.put("error", "not-found");
1063                return result;
1064            }
1065            result.put("enabled", !TriggerState.PAUSED.equals(state));
1066        }
1067        catch (SchedulerException e)
1068        {
1069            getLogger().error("An error occured when trying to retrieve the enable state of the task " + id, e);
1070            result.put("error", "scheduler-error");
1071            return result;
1072        }
1073        return result;
1074    }
1075    
1076    /**
1077     * Returns the running state of the task.
1078     * @param id The id of the task
1079     * @return A result map
1080     */
1081    @Callable(right = __RIGHT_SCHEDULER, context = "/admin")
1082    public Map<String, Object> isRunning(String id)
1083    {
1084        Map<String, Object> result = new HashMap<>();
1085        
1086        try
1087        {
1088            result.put("running", _isRunningJob(new JobKey(id, JOB_GROUP), _getExecutingJobs()));
1089        }
1090        catch (SchedulerException e)
1091        {
1092            getLogger().error("An error occured when trying to retrieve the running state of the task " + id, e);
1093            result.put("error", "scheduler-error");
1094        }
1095        return result;
1096    }
1097    
1098    /**
1099     * Gets all the schedulables (private or not)
1100     * @return the schedulables
1101     */
1102    @Callable(right = __RIGHT_SCHEDULER, context = "/admin")
1103    public List<Map<String, Object>> getSchedulables()
1104    {
1105        List<Map<String, Object>> result = new ArrayList<>();
1106        
1107        for (String schedulableId : _schedulableEP.getExtensionsIds())
1108        {
1109            Schedulable schedulable = _schedulableEP.getExtension(schedulableId);
1110            Map<String, Object> map = new HashMap<>();
1111            map.put("id", schedulable.getId());
1112            map.put("text", schedulable.getLabel());
1113            result.add(map);
1114        }
1115        
1116        return result;
1117    }
1118
1119    @Override
1120    public void dispose()
1121    {
1122        try
1123        {
1124            _scheduler.shutdown();
1125            AmetysJob.dispose();
1126        }
1127        catch (SchedulerException e)
1128        {
1129            getLogger().error("Fail to shutdown scheduler", e);
1130        }
1131    }
1132}