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