523 lines
18 KiB
PHP
523 lines
18 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\Component\ComponentHelper;
|
|
use Joomla\CMS\Date\Date;
|
|
use Joomla\CMS\Factory;
|
|
use Joomla\CMS\Language\Text;
|
|
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
|
|
use Joomla\CMS\MVC\Model\ListModel;
|
|
use Joomla\CMS\Object\CMSObject;
|
|
use Joomla\Component\Scheduler\Administrator\Helper\SchedulerHelper;
|
|
use Joomla\Component\Scheduler\Administrator\Task\TaskOption;
|
|
use Joomla\Database\DatabaseQuery;
|
|
use Joomla\Database\ParameterType;
|
|
use Joomla\Database\QueryInterface;
|
|
use Joomla\Utilities\ArrayHelper;
|
|
|
|
// phpcs:disable PSR1.Files.SideEffects
|
|
\defined('_JEXEC') or die;
|
|
// phpcs:enable PSR1.Files.SideEffects
|
|
|
|
/**
|
|
* The MVC Model for TasksView.
|
|
* Defines methods to deal with operations concerning multiple `#__scheduler_tasks` entries.
|
|
*
|
|
* @since 4.1.0
|
|
*/
|
|
class TasksModel extends ListModel
|
|
{
|
|
protected $listForbiddenList = ['select', 'multi_ordering'];
|
|
|
|
/**
|
|
* Constructor.
|
|
*
|
|
* @param array $config An optional associative array of configuration settings.
|
|
* @param MVCFactoryInterface|null $factory The factory.
|
|
*
|
|
* @since 4.1.0
|
|
* @throws \Exception
|
|
* @see \JControllerLegacy
|
|
*/
|
|
public function __construct($config = [], MVCFactoryInterface $factory = null)
|
|
{
|
|
if (empty($config['filter_fields'])) {
|
|
$config['filter_fields'] = [
|
|
'id', 'a.id',
|
|
'asset_id', 'a.asset_id',
|
|
'title', 'a.title',
|
|
'type', 'a.type',
|
|
'type_title', 'j.type_title',
|
|
'state', 'a.state',
|
|
'last_exit_code', 'a.last_exit_code',
|
|
'last_execution', 'a.last_execution',
|
|
'next_execution', 'a.next_execution',
|
|
'times_executed', 'a.times_executed',
|
|
'times_failed', 'a.times_failed',
|
|
'ordering', 'a.ordering',
|
|
'priority', 'a.priority',
|
|
'note', 'a.note',
|
|
'created', 'a.created',
|
|
'created_by', 'a.created_by',
|
|
];
|
|
}
|
|
|
|
parent::__construct($config, $factory);
|
|
}
|
|
|
|
/**
|
|
* Method to get a store id based on model configuration state.
|
|
*
|
|
* This is necessary because the model is used by the component and
|
|
* different modules that might need different sets of data or different
|
|
* ordering requirements.
|
|
*
|
|
* @param string $id A prefix for the store id.
|
|
*
|
|
* @return string A store id.
|
|
*
|
|
* @since 4.1.0
|
|
*/
|
|
protected function getStoreId($id = ''): string
|
|
{
|
|
// Compile the store id.
|
|
$id .= ':' . $this->getState('filter.search');
|
|
$id .= ':' . $this->getState('filter.state');
|
|
$id .= ':' . $this->getState('filter.type');
|
|
$id .= ':' . $this->getState('filter.orphaned');
|
|
$id .= ':' . $this->getState('filter.due');
|
|
$id .= ':' . $this->getState('filter.locked');
|
|
$id .= ':' . $this->getState('filter.trigger');
|
|
$id .= ':' . $this->getState('list.select');
|
|
|
|
return parent::getStoreId($id);
|
|
}
|
|
|
|
/**
|
|
* Method to create a query for a list of items.
|
|
*
|
|
* @return DatabaseQuery
|
|
*
|
|
* @since 4.1.0
|
|
* @throws \Exception
|
|
*/
|
|
protected function getListQuery(): QueryInterface
|
|
{
|
|
// Create a new query object.
|
|
$db = $this->getDatabase();
|
|
$query = $db->getQuery(true);
|
|
|
|
/**
|
|
* Select the required fields from the table.
|
|
* ? Do we need all these defaults ?
|
|
* ? Does 'list.select' exist ?
|
|
*/
|
|
$query->select(
|
|
$this->getState(
|
|
'list.select',
|
|
[
|
|
$db->quoteName('a.id'),
|
|
$db->quoteName('a.asset_id'),
|
|
$db->quoteName('a.title'),
|
|
$db->quoteName('a.type'),
|
|
$db->quoteName('a.execution_rules'),
|
|
$db->quoteName('a.state'),
|
|
$db->quoteName('a.last_exit_code'),
|
|
$db->quoteName('a.locked'),
|
|
$db->quoteName('a.last_execution'),
|
|
$db->quoteName('a.next_execution'),
|
|
$db->quoteName('a.times_executed'),
|
|
$db->quoteName('a.times_failed'),
|
|
$db->quoteName('a.priority'),
|
|
$db->quoteName('a.ordering'),
|
|
$db->quoteName('a.note'),
|
|
$db->quoteName('a.checked_out'),
|
|
$db->quoteName('a.checked_out_time'),
|
|
]
|
|
)
|
|
)
|
|
->select(
|
|
[
|
|
$db->quoteName('uc.name', 'editor'),
|
|
]
|
|
)
|
|
->from($db->quoteName('#__scheduler_tasks', 'a'))
|
|
->join('LEFT', $db->quoteName('#__users', 'uc'), $db->quoteName('uc.id') . ' = ' . $db->quoteName('a.checked_out'));
|
|
|
|
// Filters go below
|
|
$filterCount = 0;
|
|
|
|
/**
|
|
* Extends query if already filtered.
|
|
*
|
|
* @param string $outerGlue
|
|
* @param array $conditions
|
|
* @param string $innerGlue
|
|
*
|
|
* @since 4.1.0
|
|
*/
|
|
$extendWhereIfFiltered = static function (
|
|
string $outerGlue,
|
|
array $conditions,
|
|
string $innerGlue
|
|
) use (
|
|
$query,
|
|
&$filterCount
|
|
) {
|
|
if ($filterCount++) {
|
|
$query->extendWhere($outerGlue, $conditions, $innerGlue);
|
|
} else {
|
|
$query->where($conditions, $innerGlue);
|
|
}
|
|
};
|
|
|
|
// Filter over ID, title (redundant to search, but) ---
|
|
if (is_numeric($id = $this->getState('filter.id'))) {
|
|
$filterCount++;
|
|
$id = (int) $id;
|
|
$query->where($db->quoteName('a.id') . ' = :id')
|
|
->bind(':id', $id, ParameterType::INTEGER);
|
|
} elseif ($title = $this->getState('filter.title')) {
|
|
$filterCount++;
|
|
$match = "%$title%";
|
|
$query->where($db->quoteName('a.title') . ' LIKE :match')
|
|
->bind(':match', $match);
|
|
}
|
|
|
|
// Filter orphaned (-1: exclude, 0: include, 1: only) ----
|
|
$filterOrphaned = (int) $this->getState('filter.orphaned');
|
|
|
|
if ($filterOrphaned !== 0) {
|
|
$filterCount++;
|
|
$taskOptions = SchedulerHelper::getTaskOptions();
|
|
|
|
// Array of all active routine ids
|
|
$activeRoutines = array_map(
|
|
static function (TaskOption $taskOption): string {
|
|
return $taskOption->id;
|
|
},
|
|
$taskOptions->options
|
|
);
|
|
|
|
if ($filterOrphaned === -1) {
|
|
$query->whereIn($db->quoteName('type'), $activeRoutines, ParameterType::STRING);
|
|
} else {
|
|
$query->whereNotIn($db->quoteName('type'), $activeRoutines, ParameterType::STRING);
|
|
}
|
|
}
|
|
|
|
// Filter over state ----
|
|
$state = $this->getState('filter.state');
|
|
|
|
if ($state !== '*') {
|
|
$filterCount++;
|
|
|
|
if (is_numeric($state)) {
|
|
$state = (int) $state;
|
|
|
|
$query->where($db->quoteName('a.state') . ' = :state')
|
|
->bind(':state', $state, ParameterType::INTEGER);
|
|
} else {
|
|
$query->whereIn($db->quoteName('a.state'), [0, 1]);
|
|
}
|
|
}
|
|
|
|
// Filter over type ----
|
|
$typeFilter = $this->getState('filter.type');
|
|
|
|
if ($typeFilter) {
|
|
$filterCount++;
|
|
$query->where($db->quotename('a.type') . '= :type')
|
|
->bind(':type', $typeFilter);
|
|
}
|
|
|
|
// Filter over exit code ----
|
|
$exitCode = $this->getState('filter.last_exit_code');
|
|
|
|
if (is_numeric($exitCode)) {
|
|
$filterCount++;
|
|
$exitCode = (int) $exitCode;
|
|
$query->where($db->quoteName('a.last_exit_code') . '= :last_exit_code')
|
|
->bind(':last_exit_code', $exitCode, ParameterType::INTEGER);
|
|
}
|
|
|
|
// Filter due (-1: exclude, 0: include, 1: only) ----
|
|
$due = $this->getState('filter.due');
|
|
|
|
if (is_numeric($due) && $due != 0) {
|
|
$now = Factory::getDate('now', 'GMT')->toSql();
|
|
$operator = $due == 1 ? ' <= ' : ' > ';
|
|
$filterCount++;
|
|
$query->where($db->quoteName('a.next_execution') . $operator . ':now')
|
|
->bind(':now', $now);
|
|
}
|
|
|
|
/*
|
|
* Filter locked ---
|
|
* Locks can be either hard locks or soft locks. Locks that have expired (exceeded the task timeout) are soft
|
|
* locks. Hard-locked tasks are assumed to be running. Soft-locked tasks are assumed to have suffered a fatal
|
|
* failure.
|
|
* {-2: exclude-all, -1: exclude-hard-locked, 0: include, 1: include-only-locked, 2: include-only-soft-locked}
|
|
*/
|
|
$locked = $this->getState('filter.locked');
|
|
|
|
if (is_numeric($locked) && $locked != 0) {
|
|
$now = Factory::getDate('now', 'GMT');
|
|
$timeout = ComponentHelper::getParams('com_scheduler')->get('timeout', 300);
|
|
$timeout = new \DateInterval(sprintf('PT%dS', $timeout));
|
|
$timeoutThreshold = (clone $now)->sub($timeout)->toSql();
|
|
$now = $now->toSql();
|
|
|
|
switch ($locked) {
|
|
case -2:
|
|
$query->where($db->quoteName('a.locked') . 'IS NULL');
|
|
break;
|
|
case -1:
|
|
$extendWhereIfFiltered(
|
|
'AND',
|
|
[
|
|
$db->quoteName('a.locked') . ' IS NULL',
|
|
$db->quoteName('a.locked') . ' < :threshold',
|
|
],
|
|
'OR'
|
|
);
|
|
$query->bind(':threshold', $timeoutThreshold);
|
|
break;
|
|
case 1:
|
|
$query->where($db->quoteName('a.locked') . ' IS NOT NULL');
|
|
break;
|
|
case 2:
|
|
$query->where($db->quoteName('a.locked') . ' < :threshold')
|
|
->bind(':threshold', $timeoutThreshold);
|
|
}
|
|
}
|
|
|
|
// Filter over search string if set (title, type title, note, id) ----
|
|
$searchStr = $this->getState('filter.search');
|
|
|
|
if (!empty($searchStr)) {
|
|
// Allow search by ID
|
|
if (stripos($searchStr, 'id:') === 0) {
|
|
// Add array support [?]
|
|
$id = (int) substr($searchStr, 3);
|
|
$query->where($db->quoteName('a.id') . '= :id')
|
|
->bind(':id', $id, ParameterType::INTEGER);
|
|
} elseif (stripos($searchStr, 'type:') !== 0) {
|
|
// Search by type is handled exceptionally in _getList() [@todo: remove refs]
|
|
$searchStr = "%$searchStr%";
|
|
|
|
// Bind keys to query
|
|
$query->bind(':title', $searchStr)
|
|
->bind(':note', $searchStr);
|
|
$conditions = [
|
|
$db->quoteName('a.title') . ' LIKE :title',
|
|
$db->quoteName('a.note') . ' LIKE :note',
|
|
];
|
|
$extendWhereIfFiltered('AND', $conditions, 'OR');
|
|
}
|
|
}
|
|
|
|
// Add list ordering clause. ----
|
|
// @todo implement multi-column ordering someway
|
|
$multiOrdering = $this->state->get('list.multi_ordering');
|
|
|
|
if (!$multiOrdering || !\is_array($multiOrdering)) {
|
|
$orderCol = $this->state->get('list.ordering', 'a.title');
|
|
$orderDir = $this->state->get('list.direction', 'asc');
|
|
|
|
// Type title ordering is handled exceptionally in _getList()
|
|
if ($orderCol !== 'j.type_title') {
|
|
$query->order($db->quoteName($orderCol) . ' ' . $orderDir);
|
|
|
|
// If ordering by type or state, also order by title.
|
|
if (\in_array($orderCol, ['a.type', 'a.state', 'a.priority'])) {
|
|
// @todo : Test if things are working as expected
|
|
$query->order($db->quoteName('a.title') . ' ' . $orderDir);
|
|
}
|
|
}
|
|
} else {
|
|
$orderClauses = [];
|
|
|
|
// Loop through provided clauses
|
|
foreach ($multiOrdering as $ordering) {
|
|
[$column, $direction] = explode(' ', $ordering);
|
|
|
|
$orderClauses[] = $db->quoteName($column) . ' ' . $direction;
|
|
}
|
|
|
|
// At least one correct order clause
|
|
if (count($orderClauses) > 0) {
|
|
$query->order($orderClauses);
|
|
}
|
|
}
|
|
|
|
return $query;
|
|
}
|
|
|
|
/**
|
|
* Overloads the parent _getList() method.
|
|
* Takes care of attaching TaskOption objects and sorting by type titles.
|
|
*
|
|
* @param DatabaseQuery $query The database query to get the list with
|
|
* @param int $limitstart The list offset
|
|
* @param int $limit Number of list items to fetch
|
|
*
|
|
* @return object[]
|
|
*
|
|
* @since 4.1.0
|
|
* @throws \Exception
|
|
*/
|
|
protected function _getList($query, $limitstart = 0, $limit = 0): array
|
|
{
|
|
// Get stuff from the model state
|
|
$listOrder = $this->getState('list.ordering', 'a.title');
|
|
$listDirectionN = strtolower($this->getState('list.direction', 'asc')) === 'desc' ? -1 : 1;
|
|
|
|
// Set limit parameters and get object list
|
|
$query->setLimit($limit, $limitstart);
|
|
$this->getDatabase()->setQuery($query);
|
|
|
|
// Return optionally an extended class.
|
|
// @todo: Use something other than CMSObject..
|
|
if ($this->getState('list.customClass')) {
|
|
$responseList = array_map(
|
|
static function (array $arr) {
|
|
$o = new CMSObject();
|
|
|
|
foreach ($arr as $k => $v) {
|
|
$o->{$k} = $v;
|
|
}
|
|
|
|
return $o;
|
|
},
|
|
$this->getDatabase()->loadAssocList() ?: []
|
|
);
|
|
} else {
|
|
$responseList = $this->getDatabase()->loadObjectList();
|
|
}
|
|
|
|
// Attach TaskOptions objects and a safe type title
|
|
$this->attachTaskOptions($responseList);
|
|
|
|
// If ordering by non-db fields, we need to sort here in code
|
|
if ($listOrder === 'j.type_title') {
|
|
$responseList = ArrayHelper::sortObjects($responseList, 'safeTypeTitle', $listDirectionN, true, false);
|
|
}
|
|
|
|
return $responseList;
|
|
}
|
|
|
|
/**
|
|
* For an array of items, attaches TaskOption objects and (safe) type titles to each.
|
|
*
|
|
* @param array $items Array of items, passed by reference
|
|
*
|
|
* @return void
|
|
*
|
|
* @since 4.1.0
|
|
* @throws \Exception
|
|
*/
|
|
private function attachTaskOptions(array $items): void
|
|
{
|
|
$taskOptions = SchedulerHelper::getTaskOptions();
|
|
|
|
foreach ($items as $item) {
|
|
$item->taskOption = $taskOptions->findOption($item->type);
|
|
$item->safeTypeTitle = $item->taskOption->title ?? Text::_('JGLOBAL_NONAPPLICABLE');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Proxy for the parent method.
|
|
* Sets ordering defaults.
|
|
*
|
|
* @param string $ordering Field to order/sort list by
|
|
* @param string $direction Direction in which to sort list
|
|
*
|
|
* @return void
|
|
* @since 4.1.0
|
|
*/
|
|
protected function populateState($ordering = 'a.title', $direction = 'ASC'): void
|
|
{
|
|
$app = Factory::getApplication();
|
|
|
|
// Clean the multiorder values
|
|
if ($list = $app->getUserStateFromRequest($this->context . '.list', 'list', [], 'array')) {
|
|
if (!empty($list['multi_ordering']) && \is_array($list['multi_ordering'])) {
|
|
$orderClauses = [];
|
|
|
|
// Loop through provided clauses
|
|
foreach ($list['multi_ordering'] as $multiOrdering) {
|
|
// Split the combined string into individual variables
|
|
$multiOrderingParts = explode(' ', $multiOrdering, 2);
|
|
|
|
// Check that at least the column is present
|
|
if (count($multiOrderingParts) < 1) {
|
|
continue;
|
|
}
|
|
|
|
// Assign variables
|
|
$multiOrderingColumn = $multiOrderingParts[0];
|
|
$multiOrderingDir = count($multiOrderingParts) === 2 ? $multiOrderingParts[1] : 'asc';
|
|
|
|
// Validate provided column
|
|
if (!\in_array($multiOrderingColumn, $this->filter_fields)) {
|
|
continue;
|
|
}
|
|
|
|
// Validate order dir
|
|
if (strtolower($multiOrderingDir) !== 'asc' && strtolower($multiOrderingDir) !== 'desc') {
|
|
continue;
|
|
}
|
|
|
|
$orderClauses[] = $multiOrderingColumn . ' ' . $multiOrderingDir;
|
|
}
|
|
|
|
$this->setState('list.multi_ordering', $orderClauses);
|
|
}
|
|
}
|
|
|
|
// Call the parent method
|
|
parent::populateState($ordering, $direction);
|
|
}
|
|
|
|
/**
|
|
* Check if we have any enabled due tasks and no locked tasks.
|
|
*
|
|
* @param Date $time The next execution time to check against
|
|
*
|
|
* @return boolean
|
|
* @since 4.4.0
|
|
*/
|
|
public function hasDueTasks(Date $time): bool
|
|
{
|
|
$db = $this->getDatabase();
|
|
$now = $time->toSql();
|
|
|
|
$query = $db->getQuery(true)
|
|
// Count due tasks
|
|
->select('SUM(CASE WHEN ' . $db->quoteName('a.next_execution') . ' <= :now THEN 1 ELSE 0 END) AS due_count')
|
|
// Count locked tasks
|
|
->select('SUM(CASE WHEN ' . $db->quoteName('a.locked') . ' IS NULL THEN 0 ELSE 1 END) AS locked_count')
|
|
->from($db->quoteName('#__scheduler_tasks', 'a'))
|
|
->where($db->quoteName('a.state') . ' = 1')
|
|
->bind(':now', $now);
|
|
|
|
$db->setQuery($query);
|
|
|
|
$taskDetails = $db->loadObject();
|
|
|
|
// False if we don't have due tasks, or we have locked tasks
|
|
return $taskDetails && $taskDetails->due_count && !$taskDetails->locked_count;
|
|
}
|
|
}
|