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