Files
conservatorio-tomadini/administrator/components/com_scheduler/src/Model/TaskModel.php
2024-12-17 17:34:10 +01:00

868 lines
29 KiB
PHP

<?php
/**
* @package Joomla.Administrator
* @subpackage com_scheduler
*
* @copyright (C) 2021 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Component\Scheduler\Administrator\Model;
use Joomla\CMS\Application\AdministratorApplication;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Event\AbstractEvent;
use Joomla\CMS\Factory;
use Joomla\CMS\Form\Form;
use Joomla\CMS\Form\FormFactoryInterface;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Log\Log;
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
use Joomla\CMS\MVC\Model\AdminModel;
use Joomla\CMS\Object\CMSObject;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\CMS\Table\Table;
use Joomla\Component\Scheduler\Administrator\Helper\ExecRuleHelper;
use Joomla\Component\Scheduler\Administrator\Helper\SchedulerHelper;
use Joomla\Component\Scheduler\Administrator\Table\TaskTable;
use Joomla\Component\Scheduler\Administrator\Task\TaskOption;
use Joomla\Database\ParameterType;
use Symfony\Component\OptionsResolver\Exception\AccessException;
use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException;
use Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException;
use Symfony\Component\OptionsResolver\OptionsResolver;
// phpcs:disable PSR1.Files.SideEffects
\defined('_JEXEC') or die;
// phpcs:enable PSR1.Files.SideEffects
/**
* MVC Model to interact with the Scheduler DB.
* Implements methods to add, remove, edit tasks.
*
* @since 4.1.0
*/
class TaskModel extends AdminModel
{
/**
* Maps logical states to their values in the DB
* ? Do we end up using this?
*
* @var array
* @since 4.1.0
*/
protected const TASK_STATES = [
'enabled' => 1,
'disabled' => 0,
'trashed' => -2,
];
/**
* The name of the database table with task records.
*
* @var string
* @since 4.1.0
*/
public const TASK_TABLE = '#__scheduler_tasks';
/**
* Prefix used with controller messages
*
* @var string
* @since 4.1.0
*/
protected $text_prefix = 'COM_SCHEDULER';
/**
* Type alias for content type
*
* @var string
* @since 4.1.0
*/
public $typeAlias = 'com_scheduler.task';
/**
* The Application object, for convenience
*
* @var AdministratorApplication $app
* @since 4.1.0
*/
protected $app;
/**
* The event to trigger before unlocking the data.
*
* @var string
* @since 4.1.0
*/
protected $event_before_unlock = null;
/**
* The event to trigger after unlocking the data.
*
* @var string
* @since 4.1.0
*/
protected $event_unlock = null;
/**
* TaskModel constructor. Needed just to set $app
*
* @param array $config An array of configuration options
* @param ?MVCFactoryInterface $factory The factory
* @param ?FormFactoryInterface $formFactory The form factory
*
* @since 4.1.0
* @throws \Exception
*/
public function __construct($config = [], ?MVCFactoryInterface $factory = null, ?FormFactoryInterface $formFactory = null)
{
$config['events_map'] = $config['events_map'] ?? [];
$config['events_map'] = array_merge(
[
'save' => 'task',
'validate' => 'task',
'unlock' => 'task',
],
$config['events_map']
);
if (isset($config['event_before_unlock'])) {
$this->event_before_unlock = $config['event_before_unlock'];
} elseif (empty($this->event_before_unlock)) {
$this->event_before_unlock = 'onContentBeforeUnlock';
}
if (isset($config['event_unlock'])) {
$this->event_unlock = $config['event_unlock'];
} elseif (empty($this->event_unlock)) {
$this->event_unlock = 'onContentUnlock';
}
$this->app = Factory::getApplication();
parent::__construct($config, $factory, $formFactory);
}
/**
* Fetches the form object associated with this model. By default,
* loads the corresponding data from the DB and binds it with the form.
*
* @param array $data Data that needs to go into the form
* @param bool $loadData Should the form load its data from the DB?
*
* @return Form|boolean A Form object on success, false on failure.
*
* @since 4.1.0
* @throws \Exception
*/
public function getForm($data = [], $loadData = true)
{
Form::addFieldPath(JPATH_ADMINISTRATOR . 'components/com_scheduler/src/Field');
/**
* loadForm() (defined by FormBehaviourTrait) also loads the form data by calling
* loadFormData() : $data [implemented here] and binds it to the form by calling
* $form->bind($data).
*/
$form = $this->loadForm('com_scheduler.task', 'task', ['control' => 'jform', 'load_data' => $loadData]);
if (empty($form)) {
return false;
}
$user = $this->app->getIdentity();
// If new entry, set task type from state
if ($this->getState('task.id', 0) === 0 && $this->getState('task.type') !== null) {
$form->setValue('type', null, $this->getState('task.type'));
}
// @todo : Check if this is working as expected for new items (id == 0)
if (!$user->authorise('core.edit.state', 'com_scheduler.task.' . $this->getState('task.id'))) {
// Disable fields
$form->setFieldAttribute('state', 'disabled', 'true');
// No "hacking" ._.
$form->setFieldAttribute('state', 'filter', 'unset');
}
return $form;
}
/**
* Determine whether a record may be deleted taking into consideration
* the user's permissions over the record.
*
* @param object $record The database row/record in question
*
* @return boolean True if the record may be deleted
*
* @since 4.1.0
* @throws \Exception
*/
protected function canDelete($record): bool
{
// Record doesn't exist, can't delete
if (empty($record->id)) {
return false;
}
return $this->app->getIdentity()->authorise('core.delete', 'com_scheduler.task.' . $record->id);
}
/**
* Populate the model state, we use these instead of toying with input or the global state
*
* @return void
*
* @since 4.1.0
* @throws \Exception
*/
protected function populateState(): void
{
$app = $this->app;
$taskId = $app->getInput()->getInt('id');
$taskType = $app->getUserState('com_scheduler.add.task.task_type');
// @todo: Remove this. Get the option through a helper call.
$taskOption = $app->getUserState('com_scheduler.add.task.task_option');
$this->setState('task.id', $taskId);
$this->setState('task.type', $taskType);
$this->setState('task.option', $taskOption);
// Load component params, though com_scheduler does not (yet) have any params
$cParams = ComponentHelper::getParams($this->option);
$this->setState('params', $cParams);
}
/**
* Don't need to define this method since the parent getTable()
* implicitly deduces $name and $prefix anyways. This makes the object
* more transparent though.
*
* @param string $name Name of the table
* @param string $prefix Class prefix
* @param array $options Model config array
*
* @return Table
*
* @since 4.1.0
* @throws \Exception
*/
public function getTable($name = 'Task', $prefix = 'Table', $options = []): Table
{
return parent::getTable($name, $prefix, $options);
}
/**
* Fetches the data to be injected into the form
*
* @return object Associative array of form data.
*
* @since 4.1.0
* @throws \Exception
*/
protected function loadFormData()
{
$data = $this->app->getUserState('com_scheduler.edit.task.data', []);
// If the data from UserState is empty, we fetch it with getItem()
if (empty($data)) {
/** @var CMSObject $data */
$data = $this->getItem();
// @todo : further data processing goes here
// For a fresh object, set exec-day and exec-time
if (!($data->id ?? 0)) {
$data->execution_rules['exec-day'] = gmdate('d');
$data->execution_rules['exec-time'] = gmdate('H:i');
}
if ($data->next_execution) {
$data->next_execution = Factory::getDate($data->next_execution);
$data->next_execution->setTimezone(new \DateTimeZone($this->app->get('offset', 'UTC')));
$data->next_execution = $data->next_execution->toSql(true);
}
if ($data->last_execution) {
$data->last_execution = Factory::getDate($data->last_execution);
$data->last_execution->setTimezone(new \DateTimeZone($this->app->get('offset', 'UTC')));
$data->last_execution = $data->last_execution->toSql(true);
}
}
// Let plugins manipulate the data
$this->preprocessData('com_scheduler.task', $data, 'task');
return $data;
}
/**
* Overloads the parent getItem() method.
*
* @param integer $pk Primary key
*
* @return object|boolean Object on success, false on failure
*
* @since 4.1.0
* @throws \Exception
*/
public function getItem($pk = null)
{
$item = parent::getItem($pk);
if (!\is_object($item)) {
return false;
}
// Parent call leaves `execution_rules` and `cron_rules` JSON encoded
$item->set('execution_rules', json_decode($item->get('execution_rules', '')));
$item->set('cron_rules', json_decode($item->get('cron_rules', '')));
$taskOption = SchedulerHelper::getTaskOptions()->findOption(
($item->id ?? 0) ? ($item->type ?? 0) : $this->getState('task.type')
);
$item->set('taskOption', $taskOption);
return $item;
}
/**
* Get a task from the database, only if an exclusive "lock" on the task can be acquired.
* The method supports options to customise the limitations on the fetch.
*
* @param array $options Array with options to fetch the task:
* 1. `id`: Optional id of the task to fetch.
* 2. `allowDisabled`: If true, disabled tasks can also be fetched.
* (default: false)
* 3. `bypassScheduling`: If true, tasks that are not due can also be
* fetched. Should only be true if an `id` is targeted instead of the
* task queue. (default: false)
* 4. `allowConcurrent`: If true, fetches even when another task is
* running ('locked'). (default: false)
* 5. `includeCliExclusive`: If true, can also fetch CLI exclusive tasks. (default: true)
*
* @return ?\stdClass Task entry as in the database.
*
* @since 4.1.0
* @throws UndefinedOptionsException|InvalidOptionsException
* @throws \RuntimeException
*/
public function getTask(array $options = []): ?\stdClass
{
$resolver = new OptionsResolver();
try {
static::configureTaskGetterOptions($resolver);
} catch (\Exception $e) {
}
try {
$options = $resolver->resolve($options);
} catch (UndefinedOptionsException | InvalidOptionsException $e) {
throw $e;
}
$db = $this->getDatabase();
$now = Factory::getDate()->toSql();
$affectedRows = 0;
try {
$db->lockTable(self::TASK_TABLE);
if (!$options['allowConcurrent'] && $this->hasRunningTasks($db)) {
return null;
}
$lockQuery = $this->buildLockQuery($db, $now, $options);
if ($options['id'] > 0) {
$lockQuery->where($db->quoteName('id') . ' = :taskId')
->bind(':taskId', $options['id'], ParameterType::INTEGER);
} else {
$id = $this->getNextTaskId($db, $now, $options);
if (\count($id) === 0) {
return null;
}
$lockQuery->where($db->quoteName('id') . ' = :taskId')
->bind(':taskId', $id, ParameterType::INTEGER);
}
$db->setQuery($lockQuery)->execute();
$affectedRows = $db->getAffectedRows();
} catch (\RuntimeException $e) {
return null;
} finally {
$db->unlockTables();
}
if ($affectedRows != 1) {
return null;
}
return $this->fetchTask($db, $now);
}
/**
* Checks if there are any running tasks in the database.
*
* @param \JDatabaseDriver $db The database driver to use.
* @return bool True if there are running tasks, false otherwise.
* @since 4.4.9
*/
private function hasRunningTasks($db): bool
{
$lockCountQuery = $db->getQuery(true)
->select('COUNT(id)')
->from($db->quoteName(self::TASK_TABLE))
->where($db->quoteName('locked') . ' IS NOT NULL')
->where($db->quoteName('state') . ' = 1');
try {
$runningCount = $db->setQuery($lockCountQuery)->loadResult();
} catch (\RuntimeException $e) {
return false;
}
return $runningCount != 0;
}
/**
* Builds a query to lock a task.
*
* @param Database $db The database object.
* @param string $now The current time.
* @param array $options The options for building the query.
* - includeCliExclusive: Whether to include CLI exclusive tasks.
* - bypassScheduling: Whether to bypass scheduling.
* - allowDisabled: Whether to allow disabled tasks.
* - id: The ID of the task.
* @return Query The lock query.
* @since 5.2.0
*/
private function buildLockQuery($db, $now, $options)
{
$lockQuery = $db->getQuery(true)
->update($db->quoteName(self::TASK_TABLE))
->set($db->quoteName('locked') . ' = :now1')
->bind(':now1', $now);
$activeRoutines = array_map(
static function (TaskOption $taskOption): string {
return $taskOption->id;
},
SchedulerHelper::getTaskOptions()->options
);
$lockQuery->whereIn($db->quoteName('type'), $activeRoutines, ParameterType::STRING);
if (!$options['includeCliExclusive']) {
$lockQuery->where($db->quoteName('cli_exclusive') . ' = 0');
}
if (!$options['bypassScheduling']) {
$lockQuery->where($db->quoteName('next_execution') . ' <= :now2')
->bind(':now2', $now);
}
$stateCondition = $options['allowDisabled'] ? [0, 1] : [1];
$lockQuery->whereIn($db->quoteName('state'), $stateCondition);
return $lockQuery;
}
/**
* Retrieves the ID of the next task based on the given criteria.
*
* @param \JDatabaseDriver $db The database object.
* @param string $now The current time.
* @param array $options The options for retrieving the next task.
* - includeCliExclusive: Whether to include CLI exclusive tasks.
* - bypassScheduling: Whether to bypass scheduling.
* - allowDisabled: Whether to allow disabled tasks.
* @return array The ID of the next task, or an empty array if no task is found.
*
* @since 5.2.0
* @throws \RuntimeException If there is an error executing the query.
*/
private function getNextTaskId($db, $now, $options)
{
$idQuery = $db->getQuery(true)
->from($db->quoteName(self::TASK_TABLE))
->select($db->quoteName('id'));
$activeRoutines = array_map(
static function (TaskOption $taskOption): string {
return $taskOption->id;
},
SchedulerHelper::getTaskOptions()->options
);
$idQuery->whereIn($db->quoteName('type'), $activeRoutines, ParameterType::STRING);
if (!$options['includeCliExclusive']) {
$idQuery->where($db->quoteName('cli_exclusive') . ' = 0');
}
if (!$options['bypassScheduling']) {
$idQuery->where($db->quoteName('next_execution') . ' <= :now2')
->bind(':now2', $now);
}
$stateCondition = $options['allowDisabled'] ? [0, 1] : [1];
$idQuery->whereIn($db->quoteName('state'), $stateCondition);
$idQuery->where($db->quoteName('next_execution') . ' IS NOT NULL')
->order($db->quoteName('priority') . ' DESC')
->order($db->quoteName('next_execution') . ' ASC')
->setLimit(1);
try {
return $db->setQuery($idQuery)->loadColumn();
} catch (\RuntimeException $e) {
return [];
}
}
/**
* Fetches a task from the database based on the current time.
*
* @param \JDatabaseDriver $db The database driver to use.
* @param string $now The current time in the database's time format.
* @return \stdClass|null The fetched task object, or null if no task was found.
* @since 5.2.0
* @throws \RuntimeException If there was an error executing the query.
*/
private function fetchTask($db, $now): ?\stdClass
{
$getQuery = $db->getQuery(true)
->select('*')
->from($db->quoteName(self::TASK_TABLE))
->where($db->quoteName('locked') . ' = :now')
->bind(':now', $now);
try {
$task = $db->setQuery($getQuery)->loadObject();
} catch (\RuntimeException $e) {
return null;
}
$task->execution_rules = json_decode($task->execution_rules);
$task->cron_rules = json_decode($task->cron_rules);
$task->taskOption = SchedulerHelper::getTaskOptions()->findOption($task->type);
return $task;
}
/**
* Set up an {@see OptionsResolver} to resolve options compatible with the {@see GetTask()} method.
*
* @param OptionsResolver $resolver The {@see OptionsResolver} instance to set up.
*
* @return OptionsResolver
*
* @since 4.1.0
* @throws AccessException
*/
public static function configureTaskGetterOptions(OptionsResolver $resolver): OptionsResolver
{
$resolver->setDefaults(
[
'id' => 0,
'allowDisabled' => false,
'bypassScheduling' => false,
'allowConcurrent' => false,
'includeCliExclusive' => true,
]
)
->setAllowedTypes('id', 'numeric')
->setAllowedTypes('allowDisabled', 'bool')
->setAllowedTypes('bypassScheduling', 'bool')
->setAllowedTypes('allowConcurrent', 'bool')
->setAllowedTypes('includeCliExclusive', 'bool');
return $resolver;
}
/**
* @param array $data The form data
*
* @return boolean True on success, false on failure
*
* @since 4.1.0
* @throws \Exception
*/
public function save($data): bool
{
$id = (int) ($data['id'] ?? $this->getState('task.id'));
$isNew = $id === 0;
// Clean up execution rules
$data['execution_rules'] = $this->processExecutionRules($data['execution_rules']);
// If a new entry, we'll have to put in place a pseudo-last_execution
if ($isNew) {
$basisDayOfMonth = $data['execution_rules']['exec-day'];
[$basisHour, $basisMinute] = explode(':', $data['execution_rules']['exec-time']);
$data['last_execution'] = Factory::getDate('now', 'GMT')->format('Y-m')
. "-$basisDayOfMonth $basisHour:$basisMinute:00";
} else {
$data['last_execution'] = $this->getItem($id)->last_execution;
}
// Build the `cron_rules` column from `execution_rules`
$data['cron_rules'] = $this->buildExecutionRules($data['execution_rules']);
// `next_execution` would be null if scheduling is disabled with the "manual" rule!
$data['next_execution'] = (new ExecRuleHelper($data))->nextExec();
if ($isNew) {
$data['last_execution'] = null;
}
// If no params, we set as empty array.
// ? Is this the right place to do this
$data['params'] = $data['params'] ?? [];
// Parent method takes care of saving to the table
return parent::save($data);
}
/**
* Clean up and standardise execution rules
*
* @param array $unprocessedRules The form data [? can just replace with execution_interval]
*
* @return array Processed rules
*
* @since 4.1.0
*/
private function processExecutionRules(array $unprocessedRules): array
{
$executionRules = $unprocessedRules;
$ruleType = $executionRules['rule-type'];
$retainKeys = ['rule-type', $ruleType, 'exec-day', 'exec-time'];
$executionRules = array_intersect_key($executionRules, array_flip($retainKeys));
// Default to current date-time in UTC/GMT as the basis
$executionRules['exec-day'] = $executionRules['exec-day'] ?: (string) gmdate('d');
$executionRules['exec-time'] = $executionRules['exec-time'] ?: (string) gmdate('H:i');
// If custom ruleset, sort it
// ? Is this necessary
if ($ruleType === 'cron-expression') {
foreach ($executionRules['cron-expression'] as &$values) {
sort($values);
}
}
return $executionRules;
}
/**
* Private method to build execution expression from input execution rules.
* This expression is used internally to determine execution times/conditions.
*
* @param array $executionRules Execution rules from the Task form, post-processing.
*
* @return array
*
* @since 4.1.0
* @throws \Exception
*/
private function buildExecutionRules(array $executionRules): array
{
// Maps interval strings, use with \sprintf($map[intType], $interval)
$intervalStringMap = [
'minutes' => 'PT%dM',
'hours' => 'PT%dH',
'days' => 'P%dD',
'months' => 'P%dM',
'years' => 'P%dY',
];
$ruleType = $executionRules['rule-type'];
$ruleClass = strpos($ruleType, 'interval') === 0 ? 'interval' : $ruleType;
$buildExpression = '';
if ($ruleClass === 'interval') {
// Rule type for intervals interval-<minute/hours/...>
$intervalType = explode('-', $ruleType)[1];
$interval = $executionRules["interval-$intervalType"];
$buildExpression = \sprintf($intervalStringMap[$intervalType], $interval);
}
if ($ruleClass === 'cron-expression') {
// ! custom matches are disabled in the form
$matches = $executionRules['cron-expression'];
$buildExpression .= $this->wildcardIfMatch($matches['minutes'], range(0, 59), true);
$buildExpression .= ' ' . $this->wildcardIfMatch($matches['hours'], range(0, 23), true);
$buildExpression .= ' ' . $this->wildcardIfMatch($matches['days_month'], range(1, 31), true);
$buildExpression .= ' ' . $this->wildcardIfMatch($matches['months'], range(1, 12), true);
$buildExpression .= ' ' . $this->wildcardIfMatch($matches['days_week'], range(0, 6), true);
}
return [
'type' => $ruleClass,
'exp' => $buildExpression,
];
}
/**
* This method releases "locks" on a set of tasks from the database.
* These locks are pseudo-locks that are used to keep a track of running tasks. However, they require require manual
* intervention to release these locks in cases such as when a task process crashes, leaving the task "locked".
*
* @param array $pks A list of the primary keys to unlock.
*
* @return boolean True on success.
*
* @since 4.1.0
* @throws \RuntimeException|\UnexpectedValueException|\BadMethodCallException
*/
public function unlock(array &$pks): bool
{
/** @var TaskTable $table */
$table = $this->getTable();
$user = $this->getCurrentUser();
$context = $this->option . '.' . $this->name;
// Include the plugins for the change of state event.
PluginHelper::importPlugin($this->events_map['unlock']);
// Access checks.
foreach ($pks as $i => $pk) {
$table->reset();
if ($table->load($pk)) {
if (!$this->canEditState($table)) {
// Prune items that you can't change.
unset($pks[$i]);
Log::add(Text::_('JLIB_APPLICATION_ERROR_EDITSTATE_NOT_PERMITTED'), Log::WARNING, 'jerror');
return false;
}
// Prune items that are already at the given state.
$lockedColumnName = $table->getColumnAlias('locked');
if (property_exists($table, $lockedColumnName) && \is_null($table->$lockedColumnName)) {
unset($pks[$i]);
}
}
}
// Check if there are items to change.
if (!\count($pks)) {
return true;
}
$event = AbstractEvent::create(
$this->event_before_unlock,
[
'subject' => $this,
'context' => $context,
'pks' => $pks,
]
);
try {
Factory::getApplication()->getDispatcher()->dispatch($this->event_before_unlock, $event);
} catch (\RuntimeException $e) {
$this->setError($e->getMessage());
return false;
}
// Attempt to unlock the records.
if (!$table->unlock($pks, $user->id)) {
$this->setError($table->getError());
return false;
}
// Trigger the after unlock event
$event = AbstractEvent::create(
$this->event_unlock,
[
'subject' => $this,
'context' => $context,
'pks' => $pks,
]
);
try {
Factory::getApplication()->getDispatcher()->dispatch($this->event_unlock, $event);
} catch (\RuntimeException $e) {
$this->setError($e->getMessage());
return false;
}
// Clear the component's cache
$this->cleanCache();
return true;
}
/**
* Determine if an array is populated by all its possible values by comparison to a reference array, if found a
* match a wildcard '*' is returned.
*
* @param array $target The target array
* @param array $reference The reference array, populated by the complete set of possible values in $target
* @param bool $targetToInt If true, converts $target array values to integers before comparing
*
* @return string A wildcard string if $target is fully populated, else $target itself.
*
* @since 4.1.0
*/
private function wildcardIfMatch(array $target, array $reference, bool $targetToInt = false): string
{
if ($targetToInt) {
$target = array_map(
static function (string $x): int {
return (int) $x;
},
$target
);
}
$isMatch = array_diff($reference, $target) === [];
return $isMatch ? "*" : implode(',', $target);
}
/**
* Method to allow derived classes to preprocess the form.
*
* @param Form $form A Form object.
* @param mixed $data The data expected for the form.
* @param string $group The name of the plugin group to import (defaults to "content").
*
* @return void
*
* @since 4.1.0
* @throws \Exception if there is an error in the form event.
*/
protected function preprocessForm(Form $form, $data, $group = 'content'): void
{
// Load the 'task' plugin group
PluginHelper::importPlugin('task');
// Let the parent method take over
parent::preprocessForm($form, $data, $group);
}
}