diff options
Diffstat (limited to 'app/Model')
-rw-r--r-- | app/Model/Acl.php | 11 | ||||
-rw-r--r-- | app/Model/Action.php | 1 | ||||
-rw-r--r-- | app/Model/Authentication.php | 5 | ||||
-rw-r--r-- | app/Model/Board.php | 11 | ||||
-rw-r--r-- | app/Model/Color.php | 2 | ||||
-rw-r--r-- | app/Model/DateParser.php | 51 | ||||
-rw-r--r-- | app/Model/Notification.php | 4 | ||||
-rw-r--r-- | app/Model/ProjectAnalytic.php | 94 | ||||
-rw-r--r-- | app/Model/ProjectDailyColumnStats.php (renamed from app/Model/ProjectDailySummary.php) | 42 | ||||
-rw-r--r-- | app/Model/ProjectDailyStats.php | 72 | ||||
-rw-r--r-- | app/Model/TaskAnalytic.php | 71 | ||||
-rw-r--r-- | app/Model/TaskCreation.php | 9 | ||||
-rwxr-xr-x | app/Model/TaskDuplication.php | 41 | ||||
-rw-r--r-- | app/Model/TaskFilter.php | 219 | ||||
-rw-r--r-- | app/Model/TaskFinder.php | 22 | ||||
-rw-r--r-- | app/Model/TaskLink.php | 1 | ||||
-rw-r--r-- | app/Model/TaskModification.php | 3 | ||||
-rw-r--r-- | app/Model/TaskPosition.php | 228 | ||||
-rw-r--r-- | app/Model/TaskValidator.php | 2 | ||||
-rw-r--r-- | app/Model/Transition.php | 16 | ||||
-rw-r--r-- | app/Model/User.php | 14 | ||||
-rw-r--r-- | app/Model/UserSession.php | 48 |
22 files changed, 776 insertions, 191 deletions
diff --git a/app/Model/Acl.php b/app/Model/Acl.php index 91ed035b..95056de6 100644 --- a/app/Model/Acl.php +++ b/app/Model/Acl.php @@ -18,12 +18,12 @@ class Acl extends Base */ private $public_acl = array( 'auth' => array('login', 'check'), - 'user' => array('google', 'github'), 'task' => array('readonly'), 'board' => array('readonly'), 'webhook' => '*', 'ical' => '*', 'feed' => '*', + 'oauth' => array('google', 'github'), ); /** @@ -37,9 +37,14 @@ class Acl extends Base 'comment' => '*', 'file' => '*', 'project' => array('show'), - 'projectinfo' => array('tasks', 'search', 'activity'), + 'listing' => '*', + 'activity' => '*', 'subtask' => '*', 'task' => '*', + 'taskduplication' => '*', + 'taskcreation' => '*', + 'taskmodification' => '*', + 'taskstatus' => '*', 'tasklink' => '*', 'timer' => '*', 'calendar' => array('show', 'project'), @@ -69,7 +74,7 @@ class Acl extends Base * @var array */ private $admin_acl = array( - 'user' => array('index', 'create', 'save', 'remove'), + 'user' => array('index', 'create', 'save', 'remove', 'authentication'), 'config' => '*', 'link' => '*', 'project' => array('remove'), diff --git a/app/Model/Action.php b/app/Model/Action.php index d0607794..5e994c99 100644 --- a/app/Model/Action.php +++ b/app/Model/Action.php @@ -92,6 +92,7 @@ class Action extends Base GitlabWebhook::EVENT_COMMIT => t('Gitlab commit received'), GitlabWebhook::EVENT_ISSUE_OPENED => t('Gitlab issue opened'), GitlabWebhook::EVENT_ISSUE_CLOSED => t('Gitlab issue closed'), + GitlabWebhook::EVENT_ISSUE_COMMENT => t('Gitlab issue comment created'), BitbucketWebhook::EVENT_COMMIT => t('Bitbucket commit received'), BitbucketWebhook::EVENT_ISSUE_OPENED => t('Bitbucket issue opened'), BitbucketWebhook::EVENT_ISSUE_CLOSED => t('Bitbucket issue closed'), diff --git a/app/Model/Authentication.php b/app/Model/Authentication.php index 86c1c43f..31969b57 100644 --- a/app/Model/Authentication.php +++ b/app/Model/Authentication.php @@ -49,11 +49,6 @@ class Authentication extends Base return false; } - // We update each time the RememberMe cookie tokens - if ($this->backend('rememberMe')->hasCookie()) { - $this->backend('rememberMe')->refresh(); - } - return true; } diff --git a/app/Model/Board.php b/app/Model/Board.php index f6f968f4..bcf77b3e 100644 --- a/app/Model/Board.php +++ b/app/Model/Board.php @@ -237,10 +237,11 @@ class Board extends Base * Get all tasks sorted by columns and swimlanes * * @access public - * @param integer $project_id Project id + * @param integer $project_id + * @param callable $callback * @return array */ - public function getBoard($project_id) + public function getBoard($project_id, $callback = null) { $swimlanes = $this->swimlane->getSwimlanes($project_id); $columns = $this->getColumns($project_id); @@ -253,7 +254,11 @@ class Board extends Base $swimlanes[$i]['nb_tasks'] = 0; for ($j = 0; $j < $nb_columns; $j++) { - $swimlanes[$i]['columns'][$j]['tasks'] = $this->taskFinder->getTasksByColumnAndSwimlane($project_id, $columns[$j]['id'], $swimlanes[$i]['id']); + + $column_id = $columns[$j]['id']; + $swimlane_id = $swimlanes[$i]['id']; + + $swimlanes[$i]['columns'][$j]['tasks'] = $callback === null ? $this->taskFinder->getTasksByColumnAndSwimlane($project_id, $column_id, $swimlane_id) : $callback($project_id, $column_id, $swimlane_id); $swimlanes[$i]['columns'][$j]['nb_tasks'] = count($swimlanes[$i]['columns'][$j]['tasks']); $swimlanes[$i]['columns'][$j]['score'] = $this->getColumnSum($swimlanes[$i]['columns'][$j]['tasks'], 'score'); $swimlanes[$i]['nb_tasks'] += $swimlanes[$i]['columns'][$j]['nb_tasks']; diff --git a/app/Model/Color.php b/app/Model/Color.php index 1fd81b85..73e5d629 100644 --- a/app/Model/Color.php +++ b/app/Model/Color.php @@ -147,7 +147,7 @@ class Color extends Base */ public function getDefaultColor() { - return 'yellow'; // TODO: make this parameter configurable + return $this->config->get('default_color', 'yellow'); } /** diff --git a/app/Model/DateParser.php b/app/Model/DateParser.php index be79a92e..79a8385c 100644 --- a/app/Model/DateParser.php +++ b/app/Model/DateParser.php @@ -85,8 +85,7 @@ class DateParser extends Base */ public function getTimestamp($value) { - foreach ($this->getDateFormats() as $format) { - + foreach ($this->getAllFormats() as $format) { $timestamp = $this->getValidDate($value, $format); if ($timestamp !== 0) { @@ -98,6 +97,25 @@ class DateParser extends Base } /** + * Get all combinations of date/time formats + * + * @access public + * @return []string + */ + public function getAllFormats() + { + $formats = array(); + + foreach ($this->getDateFormats() as $date) { + foreach ($this->getTimeFormats() as $time) { + $formats[] = $date.' '.$time; + } + } + + return array_merge($formats, $this->getDateFormats()); + } + + /** * Return the list of supported date formats (for the parser) * * @access public @@ -113,6 +131,21 @@ class DateParser extends Base } /** + * Return the list of supported time formats (for the parser) + * + * @access public + * @return string[] + */ + public function getTimeFormats() + { + return array( + 'H:i', + 'g:i A', + 'g:iA', + ); + } + + /** * Return the list of available date formats (for the config page) * * @access public @@ -143,7 +176,7 @@ class DateParser extends Base * Get a timetstamp from an ISO date format * * @access public - * @param string $date Date format + * @param string $date * @return integer */ public function getTimestampFromIsoFormat($date) @@ -166,7 +199,6 @@ class DateParser extends Base } foreach ($fields as $field) { - if (! empty($values[$field])) { $values[$field] = date($format, $values[$field]); } @@ -180,15 +212,16 @@ class DateParser extends Base * Convert date (form input data) * * @access public - * @param array $values Database values - * @param string[] $fields Date fields + * @param array $values Database values + * @param string[] $fields Date fields + * @param boolean $keep_time Keep time or not */ - public function convert(array &$values, array $fields) + public function convert(array &$values, array $fields, $keep_time = false) { foreach ($fields as $field) { - if (! empty($values[$field]) && ! is_numeric($values[$field])) { - $values[$field] = $this->removeTimeFromTimestamp($this->getTimestamp($values[$field])); + $timestamp = $this->getTimestamp($values[$field]); + $values[$field] = $keep_time ? $timestamp : $this->removeTimeFromTimestamp($timestamp); } } } diff --git a/app/Model/Notification.php b/app/Model/Notification.php index 6a50f7ba..9628e344 100644 --- a/app/Model/Notification.php +++ b/app/Model/Notification.php @@ -37,7 +37,6 @@ class Notification extends Base public function sendOverdueTaskNotifications() { $tasks = $this->taskFinder->getOverdueTasks(); - $projects = array(); foreach ($this->groupByColumn($tasks, 'project_id') as $project_id => $project_tasks) { @@ -157,10 +156,9 @@ class Notification extends Base * * @access public * @param array $user - * @param array $event_data * @return boolean */ - public function filterNone(array $user, array $event_data) + public function filterNone(array $user) { return $user['notifications_filter'] == self::FILTER_NONE; } diff --git a/app/Model/ProjectAnalytic.php b/app/Model/ProjectAnalytic.php index a663f921..8ac22626 100644 --- a/app/Model/ProjectAnalytic.php +++ b/app/Model/ProjectAnalytic.php @@ -49,7 +49,7 @@ class ProjectAnalytic extends Base * Get users repartition * * @access public - * @param integer $project_id Project id + * @param integer $project_id * @return array */ public function getUserRepartition($project_id) @@ -87,4 +87,96 @@ class ProjectAnalytic extends Base return array_values($metrics); } + + /** + * Get the average lead and cycle time + * + * @access public + * @param integer $project_id + * @return array + */ + public function getAverageLeadAndCycleTime($project_id) + { + $stats = array( + 'count' => 0, + 'total_lead_time' => 0, + 'total_cycle_time' => 0, + 'avg_lead_time' => 0, + 'avg_cycle_time' => 0, + ); + + $tasks = $this->db + ->table(Task::TABLE) + ->columns('date_completed', 'date_creation', 'date_started') + ->eq('project_id', $project_id) + ->desc('id') + ->limit(1000) + ->findAll(); + + foreach ($tasks as &$task) { + $stats['count']++; + $stats['total_lead_time'] += ($task['date_completed'] ?: time()) - $task['date_creation']; + $stats['total_cycle_time'] += empty($task['date_started']) ? 0 : ($task['date_completed'] ?: time()) - $task['date_started']; + } + + $stats['avg_lead_time'] = $stats['count'] > 0 ? (int) ($stats['total_lead_time'] / $stats['count']) : 0; + $stats['avg_cycle_time'] = $stats['count'] > 0 ? (int) ($stats['total_cycle_time'] / $stats['count']) : 0; + + return $stats; + } + + /** + * Get the average time spent into each column + * + * @access public + * @param integer $project_id + * @return array + */ + public function getAverageTimeSpentByColumn($project_id) + { + $stats = array(); + $columns = $this->board->getColumnsList($project_id); + + // Get the time spent of the last move for each tasks + $tasks = $this->db + ->table(Task::TABLE) + ->columns('id', 'date_completed', 'date_moved', 'column_id') + ->eq('project_id', $project_id) + ->desc('id') + ->limit(1000) + ->findAll(); + + // Init values + foreach ($columns as $column_id => $column_title) { + $stats[$column_id] = array( + 'count' => 0, + 'time_spent' => 0, + 'average' => 0, + 'title' => $column_title, + ); + } + + // Get time spent foreach task/column and take into account the last move + foreach ($tasks as &$task) { + $sums = $this->transition->getTimeSpentByTask($task['id']); + + if (! isset($sums[$task['column_id']])) { + $sums[$task['column_id']] = 0; + } + + $sums[$task['column_id']] += ($task['date_completed'] ?: time()) - $task['date_moved']; + + foreach ($sums as $column_id => $time_spent) { + $stats[$column_id]['count']++; + $stats[$column_id]['time_spent'] += $time_spent; + } + } + + // Calculate average for each column + foreach ($columns as $column_id => $column_title) { + $stats[$column_id]['average'] = $stats[$column_id]['count'] > 0 ? (int) ($stats[$column_id]['time_spent'] / $stats[$column_id]['count']) : 0; + } + + return $stats; + } } diff --git a/app/Model/ProjectDailySummary.php b/app/Model/ProjectDailyColumnStats.php index 04dc5629..26e9d8b7 100644 --- a/app/Model/ProjectDailySummary.php +++ b/app/Model/ProjectDailyColumnStats.php @@ -3,22 +3,22 @@ namespace Model; /** - * Project daily summary + * Project Daily Column Stats * * @package model * @author Frederic Guillot */ -class ProjectDailySummary extends Base +class ProjectDailyColumnStats extends Base { /** * SQL table name * * @var string */ - const TABLE = 'project_daily_summaries'; + const TABLE = 'project_daily_column_stats'; /** - * Update daily totals for the project + * Update daily totals for the project and foreach column * * "total" is the number open of tasks in the column * "score" is the sum of tasks score in the column @@ -38,7 +38,7 @@ class ProjectDailySummary extends Base // This call will fail if the record already exists // (cross database driver hack for INSERT..ON DUPLICATE KEY UPDATE) - $db->table(ProjectDailySummary::TABLE)->insert(array( + $db->table(ProjectDailyColumnStats::TABLE)->insert(array( 'day' => $date, 'project_id' => $project_id, 'column_id' => $column_id, @@ -46,7 +46,7 @@ class ProjectDailySummary extends Base 'score' => 0, )); - $db->table(ProjectDailySummary::TABLE) + $db->table(ProjectDailyColumnStats::TABLE) ->eq('project_id', $project_id) ->eq('column_id', $column_id) ->eq('day', $date) @@ -95,19 +95,19 @@ class ProjectDailySummary extends Base */ public function getRawMetrics($project_id, $from, $to) { - return $this->db->table(ProjectDailySummary::TABLE) + return $this->db->table(ProjectDailyColumnStats::TABLE) ->columns( - ProjectDailySummary::TABLE.'.column_id', - ProjectDailySummary::TABLE.'.day', - ProjectDailySummary::TABLE.'.total', - ProjectDailySummary::TABLE.'.score', + ProjectDailyColumnStats::TABLE.'.column_id', + ProjectDailyColumnStats::TABLE.'.day', + ProjectDailyColumnStats::TABLE.'.total', + ProjectDailyColumnStats::TABLE.'.score', Board::TABLE.'.title AS column_title' ) ->join(Board::TABLE, 'id', 'column_id') - ->eq(ProjectDailySummary::TABLE.'.project_id', $project_id) + ->eq(ProjectDailyColumnStats::TABLE.'.project_id', $project_id) ->gte('day', $from) ->lte('day', $to) - ->asc(ProjectDailySummary::TABLE.'.day') + ->asc(ProjectDailyColumnStats::TABLE.'.day') ->findAll(); } @@ -122,17 +122,17 @@ class ProjectDailySummary extends Base */ public function getRawMetricsByDay($project_id, $from, $to) { - return $this->db->table(ProjectDailySummary::TABLE) + return $this->db->table(ProjectDailyColumnStats::TABLE) ->columns( - ProjectDailySummary::TABLE.'.day', - 'SUM('.ProjectDailySummary::TABLE.'.total) AS total', - 'SUM('.ProjectDailySummary::TABLE.'.score) AS score' + ProjectDailyColumnStats::TABLE.'.day', + 'SUM('.ProjectDailyColumnStats::TABLE.'.total) AS total', + 'SUM('.ProjectDailyColumnStats::TABLE.'.score) AS score' ) - ->eq(ProjectDailySummary::TABLE.'.project_id', $project_id) + ->eq(ProjectDailyColumnStats::TABLE.'.project_id', $project_id) ->gte('day', $from) ->lte('day', $to) - ->asc(ProjectDailySummary::TABLE.'.day') - ->groupBy(ProjectDailySummary::TABLE.'.day') + ->asc(ProjectDailyColumnStats::TABLE.'.day') + ->groupBy(ProjectDailyColumnStats::TABLE.'.day') ->findAll(); } @@ -160,7 +160,7 @@ class ProjectDailySummary extends Base $aggregates = array(); // Fetch metrics for the project - $records = $this->db->table(ProjectDailySummary::TABLE) + $records = $this->db->table(ProjectDailyColumnStats::TABLE) ->eq('project_id', $project_id) ->gte('day', $from) ->lte('day', $to) diff --git a/app/Model/ProjectDailyStats.php b/app/Model/ProjectDailyStats.php new file mode 100644 index 00000000..56a51730 --- /dev/null +++ b/app/Model/ProjectDailyStats.php @@ -0,0 +1,72 @@ +<?php + +namespace Model; + +/** + * Project Daily Stats + * + * @package model + * @author Frederic Guillot + */ +class ProjectDailyStats extends Base +{ + /** + * SQL table name + * + * @var string + */ + const TABLE = 'project_daily_stats'; + + /** + * Update daily totals for the project + * + * @access public + * @param integer $project_id Project id + * @param string $date Record date (YYYY-MM-DD) + * @return boolean + */ + public function updateTotals($project_id, $date) + { + $lead_cycle_time = $this->projectAnalytic->getAverageLeadAndCycleTime($project_id); + + return $this->db->transaction(function($db) use ($project_id, $date, $lead_cycle_time) { + + // This call will fail if the record already exists + // (cross database driver hack for INSERT..ON DUPLICATE KEY UPDATE) + $db->table(ProjectDailyStats::TABLE)->insert(array( + 'day' => $date, + 'project_id' => $project_id, + 'avg_lead_time' => 0, + 'avg_cycle_time' => 0, + )); + + $db->table(ProjectDailyStats::TABLE) + ->eq('project_id', $project_id) + ->eq('day', $date) + ->update(array( + 'avg_lead_time' => $lead_cycle_time['avg_lead_time'], + 'avg_cycle_time' => $lead_cycle_time['avg_cycle_time'], + )); + }); + } + + /** + * Get raw metrics for the project within a data range + * + * @access public + * @param integer $project_id Project id + * @param string $from Start date (ISO format YYYY-MM-DD) + * @param string $to End date + * @return array + */ + public function getRawMetrics($project_id, $from, $to) + { + return $this->db->table(self::TABLE) + ->columns('day', 'avg_lead_time', 'avg_cycle_time') + ->eq(self::TABLE.'.project_id', $project_id) + ->gte('day', $from) + ->lte('day', $to) + ->asc(self::TABLE.'.day') + ->findAll(); + } +} diff --git a/app/Model/TaskAnalytic.php b/app/Model/TaskAnalytic.php new file mode 100644 index 00000000..33a645c1 --- /dev/null +++ b/app/Model/TaskAnalytic.php @@ -0,0 +1,71 @@ +<?php + +namespace Model; + +/** + * Task Analytic + * + * @package model + * @author Frederic Guillot + */ +class TaskAnalytic extends Base +{ + /** + * Get the time between date_creation and date_completed or now if empty + * + * @access public + * @param array $task + * @return integer + */ + public function getLeadTime(array $task) + { + return ($task['date_completed'] ?: time()) - $task['date_creation']; + } + + /** + * Get the time between date_started and date_completed or now if empty + * + * @access public + * @param array $task + * @return integer + */ + public function getCycleTime(array $task) + { + if (empty($task['date_started'])) { + return 0; + } + + return ($task['date_completed'] ?: time()) - $task['date_started']; + } + + /** + * Get the average time spent in each column + * + * @access public + * @param array $task + * @return array + */ + public function getTimeSpentByColumn(array $task) + { + $result = array(); + $columns = $this->board->getColumnsList($task['project_id']); + $sums = $this->transition->getTimeSpentByTask($task['id']); + + foreach ($columns as $column_id => $column_title) { + + $time_spent = isset($sums[$column_id]) ? $sums[$column_id] : 0; + + if ($task['column_id'] == $column_id) { + $time_spent += ($task['date_completed'] ?: time()) - $task['date_moved']; + } + + $result[] = array( + 'id' => $column_id, + 'title' => $column_title, + 'time_spent' => $time_spent, + ); + } + + return $result; + } +} diff --git a/app/Model/TaskCreation.php b/app/Model/TaskCreation.php index 893cbc43..e530da13 100644 --- a/app/Model/TaskCreation.php +++ b/app/Model/TaskCreation.php @@ -43,9 +43,10 @@ class TaskCreation extends Base */ public function prepare(array &$values) { - $this->dateParser->convert($values, array('date_due', 'date_started')); + $this->dateParser->convert($values, array('date_due')); + $this->dateParser->convert($values, array('date_started'), true); $this->removeFields($values, array('another_task')); - $this->resetFields($values, array('owner_id', 'swimlane_id', 'date_due', 'score', 'category_id', 'time_estimated')); + $this->resetFields($values, array('creator_id', 'owner_id', 'swimlane_id', 'date_due', 'score', 'category_id', 'time_estimated')); if (empty($values['column_id'])) { $values['column_id'] = $this->board->getFirstColumn($values['project_id']); @@ -59,6 +60,10 @@ class TaskCreation extends Base $values['title'] = t('Untitled'); } + if ($this->userSession->isLogged()) { + $values['creator_id'] = $this->userSession->getId(); + } + $values['swimlane_id'] = empty($values['swimlane_id']) ? 0 : $values['swimlane_id']; $values['date_creation'] = time(); $values['date_modification'] = $values['date_creation']; diff --git a/app/Model/TaskDuplication.php b/app/Model/TaskDuplication.php index afcac4c7..8048f036 100755 --- a/app/Model/TaskDuplication.php +++ b/app/Model/TaskDuplication.php @@ -93,15 +93,22 @@ class TaskDuplication extends Base * Duplicate a task to another project * * @access public - * @param integer $task_id Task id - * @param integer $project_id Project id - * @return boolean|integer Duplicated task id + * @param integer $task_id + * @param integer $project_id + * @param integer $swimlane_id + * @param integer $column_id + * @param integer $category_id + * @param integer $owner_id + * @return boolean|integer */ - public function duplicateToProject($task_id, $project_id) + public function duplicateToProject($task_id, $project_id, $swimlane_id = null, $column_id = null, $category_id = null, $owner_id = null) { $values = $this->copyFields($task_id); $values['project_id'] = $project_id; - $values['column_id'] = $this->board->getFirstColumn($project_id); + $values['column_id'] = $column_id !== null ? $column_id : $this->board->getFirstColumn($project_id); + $values['swimlane_id'] = $swimlane_id !== null ? $swimlane_id : $values['swimlane_id']; + $values['category_id'] = $category_id !== null ? $category_id : $values['category_id']; + $values['owner_id'] = $owner_id !== null ? $owner_id : $values['owner_id']; $this->checkDestinationProjectValues($values); @@ -112,22 +119,26 @@ class TaskDuplication extends Base * Move a task to another project * * @access public - * @param integer $task_id Task id - * @param integer $project_id Project id + * @param integer $task_id + * @param integer $project_id + * @param integer $swimlane_id + * @param integer $column_id + * @param integer $category_id + * @param integer $owner_id * @return boolean */ - public function moveToProject($task_id, $project_id) + public function moveToProject($task_id, $project_id, $swimlane_id = null, $column_id = null, $category_id = null, $owner_id = null) { $task = $this->taskFinder->getById($task_id); $values = array(); $values['is_active'] = 1; $values['project_id'] = $project_id; - $values['column_id'] = $this->board->getFirstColumn($project_id); + $values['column_id'] = $column_id !== null ? $column_id : $this->board->getFirstColumn($project_id); $values['position'] = $this->taskFinder->countByColumnId($project_id, $values['column_id']) + 1; - $values['owner_id'] = $task['owner_id']; - $values['category_id'] = $task['category_id']; - $values['swimlane_id'] = $task['swimlane_id']; + $values['swimlane_id'] = $swimlane_id !== null ? $swimlane_id : $task['swimlane_id']; + $values['category_id'] = $category_id !== null ? $category_id : $task['category_id']; + $values['owner_id'] = $owner_id !== null ? $owner_id : $task['owner_id']; $this->checkDestinationProjectValues($values); @@ -144,10 +155,10 @@ class TaskDuplication extends Base /** * Check if the assignee and the category are available in the destination project * - * @access private + * @access public * @param array $values */ - private function checkDestinationProjectValues(&$values) + public function checkDestinationProjectValues(array &$values) { // Check if the assigned user is allowed for the destination project if ($values['owner_id'] > 0 && ! $this->projectPermission->isUserAllowed($values['project_id'], $values['owner_id'])) { @@ -169,6 +180,8 @@ class TaskDuplication extends Base $this->swimlane->getNameById($values['swimlane_id']) ); } + + return $values; } /** diff --git a/app/Model/TaskFilter.php b/app/Model/TaskFilter.php index 31080cb5..77ab1f3c 100644 --- a/app/Model/TaskFilter.php +++ b/app/Model/TaskFilter.php @@ -50,6 +50,12 @@ class TaskFilter extends Base case 'T_DUE': $this->filterByDueDate($value); break; + case 'T_UPDATED': + $this->filterByModificationDate($value); + break; + case 'T_CREATED': + $this->filterByCreationDate($value); + break; case 'T_TITLE': $this->filterByTitle($value); break; @@ -68,6 +74,12 @@ class TaskFilter extends Base case 'T_COLUMN': $this->filterByColumnName($value); break; + case 'T_REFERENCE': + $this->filterByReference($value); + break; + case 'T_SWIMLANE': + $this->filterBySwimlaneName($value); + break; } } @@ -98,6 +110,25 @@ class TaskFilter extends Base } /** + * Create a new subtask query + * + * @access public + * @return \PicoDb\Table + */ + public function createSubtaskQuery() + { + return $this->db->table(Subtask::TABLE) + ->columns( + Subtask::TABLE.'.user_id', + Subtask::TABLE.'.task_id', + User::TABLE.'.name', + User::TABLE.'.username' + ) + ->join(User::TABLE, 'id', 'user_id', Subtask::TABLE) + ->neq(Subtask::TABLE.'.status', Subtask::STATUS_DONE); + } + + /** * Clone the filter * * @access public @@ -115,7 +146,7 @@ class TaskFilter extends Base * Exclude a list of task_id * * @access public - * @param array $task_ids + * @param integer[] $task_ids * @return TaskFilter */ public function excludeTasks(array $task_ids) @@ -141,6 +172,22 @@ class TaskFilter extends Base } /** + * Filter by reference + * + * @access public + * @param string $reference + * @return TaskFilter + */ + public function filterByReference($reference) + { + if (! empty($reference)) { + $this->query->eq(Task::TABLE.'.reference', $reference); + } + + return $this; + } + + /** * Filter by title * * @access public @@ -154,7 +201,7 @@ class TaskFilter extends Base } /** - * Filter by title + * Filter by title or id if the string is like #123 or an integer * * @access public * @param string $title @@ -162,7 +209,16 @@ class TaskFilter extends Base */ public function filterByTitle($title) { - $this->query->ilike(Task::TABLE.'.title', '%'.$title.'%'); + if (strlen($title) > 1 && $title{0} === '#' && ctype_digit(substr($title, 1))) { + $this->query->eq(Task::TABLE.'.id', substr($title, 1)); + } + else if (ctype_digit($title)) { + $this->query->eq(Task::TABLE.'.id', $title); + } + else { + $this->query->ilike(Task::TABLE.'.title', '%'.$title.'%'); + } + return $this; } @@ -219,6 +275,30 @@ class TaskFilter extends Base } /** + * Filter by swimlane name + * + * @access public + * @param array $values List of swimlane name + * @return TaskFilter + */ + public function filterBySwimlaneName(array $values) + { + $this->query->beginOr(); + + foreach ($values as $swimlane) { + if ($swimlane === 'default') { + $this->query->eq(Task::TABLE.'.swimlane_id', 0); + } + else { + $this->query->ilike(Swimlane::TABLE.'.name', $swimlane); + $this->query->addCondition(Task::TABLE.'.swimlane_id=0 AND '.Project::TABLE.'.default_swimlane '.$this->db->getDriver()->getOperator('ILIKE')." '$swimlane'"); + } + } + + $this->query->closeOr(); + } + + /** * Filter by category id * * @access public @@ -285,7 +365,6 @@ class TaskFilter extends Base $this->query->beginOr(); foreach ($values as $assignee) { - switch ($assignee) { case 'me': $this->query->eq(Task::TABLE.'.owner_id', $this->userSession->getId()); @@ -299,7 +378,40 @@ class TaskFilter extends Base } } + $this->filterBySubtaskAssignee($values); + $this->query->closeOr(); + + return $this; + } + + /** + * Filter by subtask assignee names + * + * @access public + * @param array $values List of assignees + * @return TaskFilter + */ + public function filterBySubtaskAssignee(array $values) + { + $subtaskQuery = $this->createSubtaskQuery(); + $subtaskQuery->beginOr(); + + foreach ($values as $assignee) { + if ($assignee === 'me') { + $subtaskQuery->eq(Subtask::TABLE.'.user_id', $this->userSession->getId()); + } + else { + $subtaskQuery->ilike(User::TABLE.'.username', '%'.$assignee.'%'); + $subtaskQuery->ilike(User::TABLE.'.name', '%'.$assignee.'%'); + } + } + + $subtaskQuery->closeOr(); + + $this->query->in(Task::TABLE.'.id', $subtaskQuery->findAllByColumn('task_id')); + + return $this; } /** @@ -474,6 +586,22 @@ class TaskFilter extends Base * Filter by creation date * * @access public + * @param string $date ISO8601 date format + * @return TaskFilter + */ + public function filterByCreationDate($date) + { + if ($date === 'recently') { + return $this->filterRecentlyDate(Task::TABLE.'.date_creation'); + } + + return $this->filterWithOperator(Task::TABLE.'.date_creation', $date, true); + } + + /** + * Filter by creation date + * + * @access public * @param string $start * @param string $end * @return TaskFilter @@ -491,6 +619,22 @@ class TaskFilter extends Base } /** + * Filter by modification date + * + * @access public + * @param string $date ISO8601 date format + * @return TaskFilter + */ + public function filterByModificationDate($date) + { + if ($date === 'recently') { + return $this->filterRecentlyDate(Task::TABLE.'.date_modification'); + } + + return $this->filterWithOperator(Task::TABLE.'.date_modification', $date, true); + } + + /** * Get all results of the filter * * @access public @@ -513,6 +657,23 @@ class TaskFilter extends Base } /** + * Get swimlanes and tasks to display the board + * + * @access public + * @return array + */ + public function getBoard($project_id) + { + $tasks = $this->filterByProject($project_id)->query->asc(Task::TABLE.'.position')->findAll(); + + return $this->board->getBoard($project_id, function ($project_id, $column_id, $swimlane_id) use ($tasks) { + return array_filter($tasks, function(array $task) use ($column_id, $swimlane_id) { + return $task['column_id'] == $column_id && $task['swimlane_id'] == $swimlane_id; + }); + }); + } + + /** * Format the results to the ajax autocompletion * * @access public @@ -589,10 +750,10 @@ class TaskFilter extends Base * Transform results to ical events * * @access public - * @param string $start_column Column name for the start date - * @param string $end_column Column name for the end date - * @param Eluceo\iCal\Component\Calendar $vCalendar Calendar object - * @return Eluceo\iCal\Component\Calendar + * @param string $start_column Column name for the start date + * @param string $end_column Column name for the end date + * @param Calendar $vCalendar Calendar object + * @return Calendar */ public function addDateTimeIcalEvents($start_column, $end_column, Calendar $vCalendar = null) { @@ -622,9 +783,9 @@ class TaskFilter extends Base * Transform results to all day ical events * * @access public - * @param string $column Column name for the date - * @param Eluceo\iCal\Component\Calendar $vCalendar Calendar object - * @return Eluceo\iCal\Component\Calendar + * @param string $column Column name for the date + * @param Calendar $vCalendar Calendar object + * @return Calendar */ public function addAllDayIcalEvents($column = 'date_due', Calendar $vCalendar = null) { @@ -654,7 +815,7 @@ class TaskFilter extends Base * @access protected * @param array $task * @param string $uid - * @return Eluceo\iCal\Component\Event + * @return Event */ protected function getTaskIcalEvent(array &$task, $uid) { @@ -671,11 +832,11 @@ class TaskFilter extends Base $vEvent->setSummary(t('#%d', $task['id']).' '.$task['title']); $vEvent->setUrl($this->helper->url->base().$this->helper->url->to('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']))); - if (! empty($task['creator_id'])) { - $vEvent->setOrganizer('MAILTO:'.($task['creator_email'] ?: $task['creator_username'].'@kanboard.local')); + if (! empty($task['owner_id'])) { + $vEvent->setOrganizer('MAILTO:'.($task['assignee_email'] ?: $task['assignee_username'].'@kanboard.local')); } - if (! empty($task['owner_id'])) { + if (! empty($task['creator_id'])) { $attendees = new Attendees; $attendees->add('MAILTO:'.($task['creator_email'] ?: $task['creator_username'].'@kanboard.local')); $vEvent->setAttendees($attendees); @@ -703,7 +864,6 @@ class TaskFilter extends Base ); foreach ($operators as $operator => $method) { - if (strpos($value, $operator) === 0) { $value = substr($value, strlen($operator)); $this->query->$method($field, $is_date ? $this->dateParser->getTimestampFromIsoFormat($value) : $value); @@ -711,7 +871,32 @@ class TaskFilter extends Base } } - $this->query->eq($field, $is_date ? $this->dateParser->getTimestampFromIsoFormat($value) : $value); + if ($is_date) { + $timestamp = $this->dateParser->getTimestampFromIsoFormat($value); + $this->query->gte($field, $timestamp); + $this->query->lte($field, $timestamp + 86399); + } + else { + $this->query->eq($field, $value); + } + + return $this; + } + + /** + * Use the board_highlight_period for the "recently" keyword + * + * @access private + * @param string $field + * @return TaskFilter + */ + private function filterRecentlyDate($field) + { + $duration = $this->config->get('board_highlight_period', 0); + + if ($duration > 0) { + $this->query->gte($field, time() - $duration); + } return $this; } diff --git a/app/Model/TaskFinder.php b/app/Model/TaskFinder.php index f061cef0..a16cb69f 100644 --- a/app/Model/TaskFinder.php +++ b/app/Model/TaskFinder.php @@ -13,20 +13,6 @@ use PDO; class TaskFinder extends Base { /** - * Get query for closed tasks - * - * @access public - * @param integer $project_id Project id - * @return \PicoDb\Table - */ - public function getClosedTaskQuery($project_id) - { - return $this->getExtendedQuery() - ->eq('project_id', $project_id) - ->eq('is_active', Task::STATUS_CLOSED); - } - - /** * Get query for assigned user tasks * * @access public @@ -77,6 +63,7 @@ class TaskFinder extends Base 'tasks.date_creation', 'tasks.date_modification', 'tasks.date_completed', + 'tasks.date_started', 'tasks.date_due', 'tasks.color_id', 'tasks.project_id', @@ -102,11 +89,14 @@ class TaskFinder extends Base Category::TABLE.'.name AS category_name', Category::TABLE.'.description AS category_description', Board::TABLE.'.title AS column_name', + Swimlane::TABLE.'.name AS swimlane_name', + Project::TABLE.'.default_swimlane', Project::TABLE.'.name AS project_name' ) ->join(User::TABLE, 'id', 'owner_id', Task::TABLE) ->join(Category::TABLE, 'id', 'category_id', Task::TABLE) ->join(Board::TABLE, 'id', 'column_id', Task::TABLE) + ->join(Swimlane::TABLE, 'id', 'swimlane_id', Task::TABLE) ->join(Project::TABLE, 'id', 'project_id', Task::TABLE); } @@ -142,8 +132,8 @@ class TaskFinder extends Base { return $this->db ->table(Task::TABLE) - ->eq('project_id', $project_id) - ->eq('is_active', $status_id) + ->eq(Task::TABLE.'.project_id', $project_id) + ->eq(Task::TABLE.'.is_active', $status_id) ->findAll(); } diff --git a/app/Model/TaskLink.php b/app/Model/TaskLink.php index 7d3a8918..3fdbd04b 100644 --- a/app/Model/TaskLink.php +++ b/app/Model/TaskLink.php @@ -4,7 +4,6 @@ namespace Model; use SimpleValidator\Validator; use SimpleValidator\Validators; -use PicoDb\Table; /** * TaskLink model diff --git a/app/Model/TaskModification.php b/app/Model/TaskModification.php index 4691ce81..b67106e1 100644 --- a/app/Model/TaskModification.php +++ b/app/Model/TaskModification.php @@ -83,7 +83,8 @@ class TaskModification extends Base */ public function prepare(array &$values) { - $this->dateParser->convert($values, array('date_due', 'date_started')); + $this->dateParser->convert($values, array('date_due')); + $this->dateParser->convert($values, array('date_started'), true); $this->removeFields($values, array('another_task', 'id')); $this->resetFields($values, array('date_due', 'date_started', 'score', 'category_id', 'time_estimated', 'time_spent')); $this->convertIntegerFields($values, array('is_active', 'recurrence_status', 'recurrence_trigger', 'recurrence_factor', 'recurrence_timeframe', 'recurrence_basedate')); diff --git a/app/Model/TaskPosition.php b/app/Model/TaskPosition.php index 0c4beb2d..874633b1 100644 --- a/app/Model/TaskPosition.php +++ b/app/Model/TaskPosition.php @@ -26,104 +26,176 @@ class TaskPosition extends Base */ public function movePosition($project_id, $task_id, $column_id, $position, $swimlane_id = 0, $fire_events = true) { - $original_task = $this->taskFinder->getById($task_id); + if ($position < 1) { + return false; + } - $result = $this->calculateAndSave($project_id, $task_id, $column_id, $position, $swimlane_id); + $task = $this->taskFinder->getById($task_id); - if ($result) { + // Ignore closed tasks + if ($task['is_active'] == Task::STATUS_CLOSED) { + return true; + } - if ($original_task['swimlane_id'] != $swimlane_id) { - $this->calculateAndSave($project_id, 0, $column_id, 1, $original_task['swimlane_id']); - } + $result = false; - if ($fire_events) { - $this->fireEvents($original_task, $column_id, $position, $swimlane_id); - } + if ($task['swimlane_id'] != $swimlane_id) { + $result = $this->saveSwimlaneChange($project_id, $task_id, $position, $task['column_id'], $column_id, $task['swimlane_id'], $swimlane_id); + } + else if ($task['column_id'] != $column_id) { + $result = $this->saveColumnChange($project_id, $task_id, $position, $swimlane_id, $task['column_id'], $column_id); + } + else if ($task['position'] != $position) { + $result = $this->savePositionChange($project_id, $task_id, $position, $column_id, $swimlane_id); + } + + if ($result && $fire_events) { + $this->fireEvents($task, $column_id, $position, $swimlane_id); } return $result; } /** - * Calculate the new position of all tasks + * Move a task to another swimlane * - * @access public - * @param integer $project_id Project id - * @param integer $task_id Task id - * @param integer $column_id Column id - * @param integer $position Position (must be >= 1) - * @param integer $swimlane_id Swimlane id - * @return array|boolean + * @access private + * @param integer $project_id + * @param integer $task_id + * @param integer $position + * @param integer $original_column_id + * @param integer $new_column_id + * @param integer $original_swimlane_id + * @param integer $new_swimlane_id + * @return boolean */ - public function calculatePositions($project_id, $task_id, $column_id, $position, $swimlane_id = 0) + private function saveSwimlaneChange($project_id, $task_id, $position, $original_column_id, $new_column_id, $original_swimlane_id, $new_swimlane_id) { - // The position can't be lower than 1 - if ($position < 1) { - return false; - } + $this->db->startTransaction(); + $r1 = $this->saveTaskPositions($project_id, $task_id, 0, $original_column_id, $original_swimlane_id); + $r2 = $this->saveTaskPositions($project_id, $task_id, $position, $new_column_id, $new_swimlane_id); + $this->db->closeTransaction(); - $board = $this->db->table(Board::TABLE)->eq('project_id', $project_id)->asc('position')->findAllByColumn('id'); - $columns = array(); - - // For each column fetch all tasks ordered by position - foreach ($board as $board_column_id) { - - $columns[$board_column_id] = $this->db->table(Task::TABLE) - ->eq('is_active', 1) - ->eq('swimlane_id', $swimlane_id) - ->eq('project_id', $project_id) - ->eq('column_id', $board_column_id) - ->neq('id', $task_id) - ->asc('position') - ->asc('id') // Fix Postgresql unit test - ->findAllByColumn('id'); - } + return $r1 && $r2; + } - // The column must exists - if (! isset($columns[$column_id])) { - return false; - } + /** + * Move a task to another column + * + * @access private + * @param integer $project_id + * @param integer $task_id + * @param integer $position + * @param integer $swimlane_id + * @param integer $original_column_id + * @param integer $new_column_id + * @return boolean + */ + private function saveColumnChange($project_id, $task_id, $position, $swimlane_id, $original_column_id, $new_column_id) + { + $this->db->startTransaction(); + $r1 = $this->saveTaskPositions($project_id, $task_id, 0, $original_column_id, $swimlane_id); + $r2 = $this->saveTaskPositions($project_id, $task_id, $position, $new_column_id, $swimlane_id); + $this->db->closeTransaction(); - // We put our task to the new position - if ($task_id) { - array_splice($columns[$column_id], $position - 1, 0, $task_id); - } + return $r1 && $r2; + } + + /** + * Move a task to another position in the same column + * + * @access private + * @param integer $project_id + * @param integer $task_id + * @param integer $position + * @param integer $column_id + * @param integer $swimlane_id + * @return boolean + */ + private function savePositionChange($project_id, $task_id, $position, $column_id, $swimlane_id) + { + $this->db->startTransaction(); + $result = $this->saveTaskPositions($project_id, $task_id, $position, $column_id, $swimlane_id); + $this->db->closeTransaction(); - return $columns; + return $result; } /** - * Save task positions + * Save all task positions for one column * * @access private - * @param array $columns Sorted tasks - * @param integer $swimlane_id Swimlane id + * @param integer $project_id + * @param integer $task_id + * @param integer $position + * @param integer $column_id + * @param integer $swimlane_id * @return boolean */ - private function savePositions(array $columns, $swimlane_id) + private function saveTaskPositions($project_id, $task_id, $position, $column_id, $swimlane_id) { - return $this->db->transaction(function ($db) use ($columns, $swimlane_id) { + $tasks_ids = $this->db->table(Task::TABLE) + ->eq('is_active', 1) + ->eq('swimlane_id', $swimlane_id) + ->eq('project_id', $project_id) + ->eq('column_id', $column_id) + ->neq('id', $task_id) + ->asc('position') + ->asc('id') + ->findAllByColumn('id'); + + $offset = 1; + + foreach ($tasks_ids as $current_task_id) { + + // Insert the new task + if ($position == $offset) { + if (! $this->saveTaskPosition($task_id, $offset, $column_id, $swimlane_id)) { + return false; + } + $offset++; + } - foreach ($columns as $column_id => $column) { + // Rewrite other tasks position + if (! $this->saveTaskPosition($current_task_id, $offset, $column_id, $swimlane_id)) { + return false; + } - $position = 1; + $offset++; + } - foreach ($column as $task_id) { + // Insert the new task at the bottom and normalize bad position + if ($position >= $offset && ! $this->saveTaskPosition($task_id, $offset, $column_id, $swimlane_id)) { + return false; + } - $result = $db->table(Task::TABLE)->eq('id', $task_id)->update(array( - 'position' => $position, - 'column_id' => $column_id, - 'swimlane_id' => $swimlane_id, - )); + return true; + } - if (! $result) { - return false; - } + /** + * Save new task position + * + * @access private + * @param integer $task_id + * @param integer $position + * @param integer $column_id + * @param integer $swimlane_id + * @return boolean + */ + private function saveTaskPosition($task_id, $position, $column_id, $swimlane_id) + { + $result = $this->db->table(Task::TABLE)->eq('id', $task_id)->update(array( + 'position' => $position, + 'column_id' => $column_id, + 'swimlane_id' => $swimlane_id, + )); + + if (! $result) { + $this->db->cancelTransaction(); + return false; + } - $position++; - } - } - }); + return true; } /** @@ -160,26 +232,4 @@ class TaskPosition extends Base $this->container['dispatcher']->dispatch(Task::EVENT_MOVE_POSITION, new TaskEvent($event_data)); } } - - /** - * Calculate the new position of all tasks - * - * @access private - * @param integer $project_id Project id - * @param integer $task_id Task id - * @param integer $column_id Column id - * @param integer $position Position (must be >= 1) - * @param integer $swimlane_id Swimlane id - * @return boolean - */ - private function calculateAndSave($project_id, $task_id, $column_id, $position, $swimlane_id) - { - $positions = $this->calculatePositions($project_id, $task_id, $column_id, $position, $swimlane_id); - - if ($positions === false || ! $this->savePositions($positions, $swimlane_id)) { - return false; - } - - return true; - } } diff --git a/app/Model/TaskValidator.php b/app/Model/TaskValidator.php index ec1383ad..95b8a26c 100644 --- a/app/Model/TaskValidator.php +++ b/app/Model/TaskValidator.php @@ -39,7 +39,7 @@ class TaskValidator extends Base new Validators\Integer('recurrence_status', t('This value must be an integer')), new Validators\MaxLength('title', t('The maximum length is %d characters', 200), 200), new Validators\Date('date_due', t('Invalid date'), $this->dateParser->getDateFormats()), - new Validators\Date('date_started', t('Invalid date'), $this->dateParser->getDateFormats()), + new Validators\Date('date_started', t('Invalid date'), $this->dateParser->getAllFormats()), new Validators\Numeric('time_spent', t('This value must be numeric')), new Validators\Numeric('time_estimated', t('This value must be numeric')), ); diff --git a/app/Model/Transition.php b/app/Model/Transition.php index cb759e4a..ac3fba54 100644 --- a/app/Model/Transition.php +++ b/app/Model/Transition.php @@ -39,6 +39,22 @@ class Transition extends Base } /** + * Get time spent by task for each column + * + * @access public + * @param integer $task_id + * @return array + */ + public function getTimeSpentByTask($task_id) + { + return $this->db + ->hashtable(self::TABLE) + ->groupBy('src_column_id') + ->eq('task_id', $task_id) + ->getAll('src_column_id', 'SUM(time_spent) AS time_spent'); + } + + /** * Get all transitions by task * * @access public diff --git a/app/Model/User.php b/app/Model/User.php index 4c32942c..b6804abc 100644 --- a/app/Model/User.php +++ b/app/Model/User.php @@ -122,13 +122,13 @@ class User extends Base } /** - * Get a specific user by the GitHub id + * Get a specific user by the Github id * * @access public - * @param string $github_id GitHub user id + * @param string $github_id Github user id * @return array|boolean */ - public function getByGitHubId($github_id) + public function getByGithubId($github_id) { if (empty($github_id)) { return false; @@ -377,6 +377,7 @@ class User extends Base new Validators\Unique('username', t('The username must be unique'), $this->db->getConnection(), self::TABLE, 'id'), new Validators\Email('email', t('Email address invalid')), new Validators\Integer('is_admin', t('This value must be an integer')), + new Validators\Integer('is_ldap_user', t('This value must be an integer')), ); } @@ -409,7 +410,12 @@ class User extends Base new Validators\Required('username', t('The username is required')), ); - $v = new Validator($values, array_merge($rules, $this->commonValidationRules(), $this->commonPasswordValidationRules())); + if (isset($values['is_ldap_user']) && $values['is_ldap_user'] == 1) { + $v = new Validator($values, array_merge($rules, $this->commonValidationRules())); + } + else { + $v = new Validator($values, array_merge($rules, $this->commonValidationRules(), $this->commonPasswordValidationRules())); + } return array( $v->execute(), diff --git a/app/Model/UserSession.php b/app/Model/UserSession.php index f1f2ffee..44a9c2a2 100644 --- a/app/Model/UserSession.php +++ b/app/Model/UserSession.php @@ -94,4 +94,52 @@ class UserSession extends Base { return ! empty($this->session['user']); } + + /** + * Get project filters from the session + * + * @access public + * @param integer $project_id + * @return string + */ + public function getFilters($project_id) + { + return ! empty($_SESSION['filters'][$project_id]) ? $_SESSION['filters'][$project_id] : 'status:open'; + } + + /** + * Save project filters in the session + * + * @access public + * @param integer $project_id + * @param string $filters + */ + public function setFilters($project_id, $filters) + { + $_SESSION['filters'][$project_id] = $filters; + } + + /** + * Is board collapsed or expanded + * + * @access public + * @param integer $project_id + * @return boolean + */ + public function isBoardCollapsed($project_id) + { + return ! empty($_SESSION['board_collapsed'][$project_id]) ? $_SESSION['board_collapsed'][$project_id] : false; + } + + /** + * Set board display mode + * + * @access public + * @param integer $project_id + * @param boolean $collapsed + */ + public function setBoardDisplayMode($project_id, $collapsed) + { + $_SESSION['board_collapsed'][$project_id] = $collapsed; + } } |