diff options
author | Gerardo Zamudio <gerardozamudio@users.noreply.github.com> | 2016-02-24 23:48:50 -0600 |
---|---|---|
committer | Gerardo Zamudio <gerardozamudio@users.noreply.github.com> | 2016-02-24 23:48:50 -0600 |
commit | e4de6b3898b64b26d29aff31f21df5fda8055686 (patch) | |
tree | 575f8a65440f291d70a070d168eafca8c82a6459 /app/Model | |
parent | d9ffbea174ea6524d0a22f8375ca8b3aa04a3c96 (diff) | |
parent | a6540bc604c837d92c9368540c145606723e97f7 (diff) |
Merge pull request #1 from fguillot/master
Update from upstream
Diffstat (limited to 'app/Model')
60 files changed, 3065 insertions, 3737 deletions
diff --git a/app/Model/Acl.php b/app/Model/Acl.php deleted file mode 100644 index 62f850cb..00000000 --- a/app/Model/Acl.php +++ /dev/null @@ -1,289 +0,0 @@ -<?php - -namespace Kanboard\Model; - -/** - * Access List - * - * @package model - * @author Frederic Guillot - */ -class Acl extends Base -{ - /** - * Controllers and actions allowed from outside - * - * @access private - * @var array - */ - private $public_acl = array( - 'auth' => array('login', 'check', 'captcha'), - 'task' => array('readonly'), - 'board' => array('readonly'), - 'webhook' => '*', - 'ical' => '*', - 'feed' => '*', - 'oauth' => array('google', 'github', 'gitlab'), - ); - - /** - * Controllers and actions for project members - * - * @access private - * @var array - */ - private $project_member_acl = array( - 'board' => '*', - 'comment' => '*', - 'file' => '*', - 'project' => array('show'), - 'listing' => '*', - 'activity' => '*', - 'subtask' => '*', - 'task' => '*', - 'taskduplication' => '*', - 'taskcreation' => '*', - 'taskmodification' => '*', - 'taskstatus' => '*', - 'tasklink' => '*', - 'timer' => '*', - 'customfilter' => '*', - 'calendar' => array('show', 'project'), - ); - - /** - * Controllers and actions for project managers - * - * @access private - * @var array - */ - private $project_manager_acl = array( - 'action' => '*', - 'analytic' => '*', - 'category' => '*', - 'column' => '*', - 'export' => '*', - 'taskimport' => '*', - 'project' => array('edit', 'update', 'share', 'integrations', 'notifications', 'users', 'alloweverybody', 'allow', 'setowner', 'revoke', 'duplicate', 'disable', 'enable'), - 'swimlane' => '*', - 'gantt' => array('project', 'savetaskdate', 'task', 'savetask'), - ); - - /** - * Controllers and actions for project admins - * - * @access private - * @var array - */ - private $project_admin_acl = array( - 'project' => array('remove'), - 'projectuser' => '*', - 'gantt' => array('projects', 'saveprojectdate'), - ); - - /** - * Controllers and actions for admins - * - * @access private - * @var array - */ - private $admin_acl = array( - 'user' => array('index', 'create', 'save', 'remove', 'authentication'), - 'userimport' => '*', - 'config' => '*', - 'link' => '*', - 'currency' => '*', - 'twofactor' => array('disable'), - ); - - /** - * Extend ACL rules - * - * @access public - * @param string $acl_name - * @param aray $rules - */ - public function extend($acl_name, array $rules) - { - $this->$acl_name = array_merge($this->$acl_name, $rules); - } - - /** - * Return true if the specified controller/action match the given acl - * - * @access public - * @param array $acl Acl list - * @param string $controller Controller name - * @param string $action Action name - * @return bool - */ - public function matchAcl(array $acl, $controller, $action) - { - $controller = strtolower($controller); - $action = strtolower($action); - return isset($acl[$controller]) && $this->hasAction($action, $acl[$controller]); - } - - /** - * Return true if the specified action is inside the list of actions - * - * @access public - * @param string $action Action name - * @param mixed $action Actions list - * @return bool - */ - public function hasAction($action, $actions) - { - if (is_array($actions)) { - return in_array($action, $actions); - } - - return $actions === '*'; - } - - /** - * Return true if the given action is public - * - * @access public - * @param string $controller Controller name - * @param string $action Action name - * @return bool - */ - public function isPublicAction($controller, $action) - { - return $this->matchAcl($this->public_acl, $controller, $action); - } - - /** - * Return true if the given action is for admins - * - * @access public - * @param string $controller Controller name - * @param string $action Action name - * @return bool - */ - public function isAdminAction($controller, $action) - { - return $this->matchAcl($this->admin_acl, $controller, $action); - } - - /** - * Return true if the given action is for project managers - * - * @access public - * @param string $controller Controller name - * @param string $action Action name - * @return bool - */ - public function isProjectManagerAction($controller, $action) - { - return $this->matchAcl($this->project_manager_acl, $controller, $action); - } - - /** - * Return true if the given action is for application managers - * - * @access public - * @param string $controller Controller name - * @param string $action Action name - * @return bool - */ - public function isProjectAdminAction($controller, $action) - { - return $this->matchAcl($this->project_admin_acl, $controller, $action); - } - - /** - * Return true if the given action is for project members - * - * @access public - * @param string $controller Controller name - * @param string $action Action name - * @return bool - */ - public function isProjectMemberAction($controller, $action) - { - return $this->matchAcl($this->project_member_acl, $controller, $action); - } - - /** - * Return true if the visitor is allowed to access to the given page - * We suppose the user already authenticated - * - * @access public - * @param string $controller Controller name - * @param string $action Action name - * @param integer $project_id Project id - * @return bool - */ - public function isAllowed($controller, $action, $project_id = 0) - { - // If you are admin you have access to everything - if ($this->userSession->isAdmin()) { - return true; - } - - // If you access to an admin action, your are not allowed - if ($this->isAdminAction($controller, $action)) { - return false; - } - - // Check project admin permissions - if ($this->isProjectAdminAction($controller, $action)) { - return $this->handleProjectAdminPermissions($project_id); - } - - // Check project manager permissions - if ($this->isProjectManagerAction($controller, $action)) { - return $this->handleProjectManagerPermissions($project_id); - } - - // Check project member permissions - if ($this->isProjectMemberAction($controller, $action)) { - return $project_id > 0 && $this->projectPermission->isMember($project_id, $this->userSession->getId()); - } - - // Other applications actions are allowed - return true; - } - - /** - * Handle permission for project manager - * - * @access public - * @param integer $project_id - * @return boolean - */ - public function handleProjectManagerPermissions($project_id) - { - if ($project_id > 0) { - if ($this->userSession->isProjectAdmin()) { - return $this->projectPermission->isMember($project_id, $this->userSession->getId()); - } - - return $this->projectPermission->isManager($project_id, $this->userSession->getId()); - } - - return false; - } - - /** - * Handle permission for project admins - * - * @access public - * @param integer $project_id - * @return boolean - */ - public function handleProjectAdminPermissions($project_id) - { - if (! $this->userSession->isProjectAdmin()) { - return false; - } - - if ($project_id > 0) { - return $this->projectPermission->isMember($project_id, $this->userSession->getId()); - } - - return true; - } -} diff --git a/app/Model/Action.php b/app/Model/Action.php index ba74218f..4da2fb8f 100644 --- a/app/Model/Action.php +++ b/app/Model/Action.php @@ -2,14 +2,8 @@ namespace Kanboard\Model; -use Kanboard\Integration\GitlabWebhook; -use Kanboard\Integration\GithubWebhook; -use Kanboard\Integration\BitbucketWebhook; -use SimpleValidator\Validator; -use SimpleValidator\Validators; - /** - * Action model + * Action Model * * @package model * @author Frederic Guillot @@ -24,152 +18,38 @@ class Action extends Base const TABLE = 'actions'; /** - * SQL table name for action parameters - * - * @var string - */ - const TABLE_PARAMS = 'action_has_params'; - - /** - * Extended actions - * - * @access private - * @var array - */ - private $actions = array(); - - /** - * Extend the list of default actions - * - * @access public - * @param string $className - * @param string $description - * @return Action - */ - public function extendActions($className, $description) - { - $this->actions[$className] = $description; - return $this; - } - - /** - * Return the name and description of available actions - * - * @access public - * @return array - */ - public function getAvailableActions() - { - $values = array( - 'TaskClose' => t('Close a task'), - 'TaskOpen' => t('Open a task'), - 'TaskAssignSpecificUser' => t('Assign the task to a specific user'), - 'TaskAssignCurrentUser' => t('Assign the task to the person who does the action'), - 'TaskDuplicateAnotherProject' => t('Duplicate the task to another project'), - 'TaskMoveAnotherProject' => t('Move the task to another project'), - 'TaskMoveColumnAssigned' => t('Move the task to another column when assigned to a user'), - 'TaskMoveColumnUnAssigned' => t('Move the task to another column when assignee is cleared'), - 'TaskAssignColorColumn' => t('Assign a color when the task is moved to a specific column'), - 'TaskAssignColorUser' => t('Assign a color to a specific user'), - 'TaskAssignColorCategory' => t('Assign automatically a color based on a category'), - 'TaskAssignCategoryColor' => t('Assign automatically a category based on a color'), - 'CommentCreation' => t('Create a comment from an external provider'), - 'TaskCreation' => t('Create a task from an external provider'), - 'TaskLogMoveAnotherColumn' => t('Add a comment log when moving the task between columns'), - 'TaskAssignUser' => t('Change the assignee based on an external username'), - 'TaskAssignCategoryLabel' => t('Change the category based on an external label'), - 'TaskUpdateStartDate' => t('Automatically update the start date'), - 'TaskMoveColumnCategoryChange' => t('Move the task to another column when the category is changed'), - 'TaskEmail' => t('Send a task by email to someone'), - 'TaskAssignColorLink' => t('Change task color when using a specific task link'), - ); - - $values = array_merge($values, $this->actions); - - asort($values); - - return $values; - } - - /** - * Return the name and description of available actions - * - * @access public - * @return array - */ - public function getAvailableEvents() - { - $values = array( - TaskLink::EVENT_CREATE_UPDATE => t('Task link creation or modification'), - Task::EVENT_MOVE_COLUMN => t('Move a task to another column'), - Task::EVENT_UPDATE => t('Task modification'), - Task::EVENT_CREATE => t('Task creation'), - Task::EVENT_OPEN => t('Reopen a task'), - Task::EVENT_CLOSE => t('Closing a task'), - Task::EVENT_CREATE_UPDATE => t('Task creation or modification'), - Task::EVENT_ASSIGNEE_CHANGE => t('Task assignee change'), - GithubWebhook::EVENT_COMMIT => t('Github commit received'), - GithubWebhook::EVENT_ISSUE_OPENED => t('Github issue opened'), - GithubWebhook::EVENT_ISSUE_CLOSED => t('Github issue closed'), - GithubWebhook::EVENT_ISSUE_REOPENED => t('Github issue reopened'), - GithubWebhook::EVENT_ISSUE_ASSIGNEE_CHANGE => t('Github issue assignee change'), - GithubWebhook::EVENT_ISSUE_LABEL_CHANGE => t('Github issue label change'), - GithubWebhook::EVENT_ISSUE_COMMENT => t('Github issue comment created'), - 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'), - BitbucketWebhook::EVENT_ISSUE_REOPENED => t('Bitbucket issue reopened'), - BitbucketWebhook::EVENT_ISSUE_ASSIGNEE_CHANGE => t('Bitbucket issue assignee change'), - BitbucketWebhook::EVENT_ISSUE_COMMENT => t('Bitbucket issue comment created'), - ); - - asort($values); - - return $values; - } - - /** - * Return the name and description of compatible actions + * Return actions and parameters for a given user * * @access public - * @param string $action_name Action name + * @param integer $user_id * @return array */ - public function getCompatibleEvents($action_name) + public function getAllByUser($user_id) { - $action = $this->load($action_name, 0, ''); - $compatible_events = $action->getCompatibleEvents(); - $events = array(); + $project_ids = $this->projectPermission->getActiveProjectIds($user_id); + $actions = array(); - foreach ($this->getAvailableEvents() as $event_name => $event_description) { - if (in_array($event_name, $compatible_events)) { - $events[$event_name] = $event_description; - } + if (! empty($project_ids)) { + $actions = $this->db->table(self::TABLE)->in('project_id', $project_ids)->findAll(); + $params = $this->actionParameter->getAllByActions(array_column($actions, 'id')); + $this->attachParamsToActions($actions, $params); } - return $events; + return $actions; } /** * Return actions and parameters for a given project * * @access public - * @param $project_id + * @param integer $project_id * @return array */ public function getAllByProject($project_id) { $actions = $this->db->table(self::TABLE)->eq('project_id', $project_id)->findAll(); - - foreach ($actions as &$action) { - $action['params'] = $this->db->table(self::TABLE_PARAMS)->eq('action_id', $action['id'])->findAll(); - } - - return $actions; + $params = $this->actionParameter->getAllByActions(array_column($actions, 'id')); + return $this->attachParamsToActions($actions, $params); } /** @@ -181,63 +61,51 @@ class Action extends Base public function getAll() { $actions = $this->db->table(self::TABLE)->findAll(); - $params = $this->db->table(self::TABLE_PARAMS)->findAll(); - - foreach ($actions as &$action) { - $action['params'] = array(); - - foreach ($params as $param) { - if ($param['action_id'] === $action['id']) { - $action['params'][] = $param; - } - } - } - - return $actions; + $params = $this->actionParameter->getAll(); + return $this->attachParamsToActions($actions, $params); } /** - * Get all required action parameters for all registered actions + * Fetch an action * * @access public - * @return array All required parameters for all actions + * @param integer $action_id + * @return array */ - public function getAllActionParameters() + public function getById($action_id) { - $params = array(); + $action = $this->db->table(self::TABLE)->eq('id', $action_id)->findOne(); - foreach ($this->getAll() as $action) { - $action = $this->load($action['action_name'], $action['project_id'], $action['event_name']); - $params += $action->getActionRequiredParameters(); + if (! empty($action)) { + $action['params'] = $this->actionParameter->getAllByAction($action_id); } - return $params; + return $action; } /** - * Fetch an action + * Attach parameters to actions * - * @access public - * @param integer $action_id Action id - * @return array Action data + * @access private + * @param array &$actions + * @param array &$params + * @return array */ - public function getById($action_id) + private function attachParamsToActions(array &$actions, array &$params) { - $action = $this->db->table(self::TABLE)->eq('id', $action_id)->findOne(); - - if (! empty($action)) { - $action['params'] = $this->db->table(self::TABLE_PARAMS)->eq('action_id', $action_id)->findAll(); + foreach ($actions as &$action) { + $action['params'] = isset($params[$action['id']]) ? $params[$action['id']] : array(); } - return $action; + return $actions; } /** * Remove an action * * @access public - * @param integer $action_id Action id - * @return bool Success or not + * @param integer $action_id + * @return bool */ public function remove($action_id) { @@ -261,24 +129,16 @@ class Action extends Base 'action_name' => $values['action_name'], ); - if (! $this->db->table(self::TABLE)->save($action)) { + if (! $this->db->table(self::TABLE)->insert($action)) { $this->db->cancelTransaction(); return false; } $action_id = $this->db->getLastId(); - foreach ($values['params'] as $param_name => $param_value) { - $action_param = array( - 'action_id' => $action_id, - 'name' => $param_name, - 'value' => $param_value, - ); - - if (! $this->db->table(self::TABLE_PARAMS)->save($action_param)) { - $this->db->cancelTransaction(); - return false; - } + if (! $this->actionParameter->create($action_id, $values)) { + $this->db->cancelTransaction(); + return false; } $this->db->closeTransaction(); @@ -287,42 +147,6 @@ class Action extends Base } /** - * Load all actions and attach events - * - * @access public - */ - public function attachEvents() - { - $actions = $this->getAll(); - - foreach ($actions as $action) { - $listener = $this->load($action['action_name'], $action['project_id'], $action['event_name']); - - foreach ($action['params'] as $param) { - $listener->setParam($param['name'], $param['value']); - } - - $this->container['dispatcher']->addListener($action['event_name'], array($listener, 'execute')); - } - } - - /** - * Load an action - * - * @access public - * @param string $name Action class name - * @param integer $project_id Project id - * @param string $event Event name - * @return \Action\Base - */ - public function load($name, $project_id, $event) - { - $className = $name{0} - !== '\\' ? '\Kanboard\Action\\'.$name : $name; - return new $className($this->container, $project_id, $event); - } - - /** * Copy actions from a project to another one (skip actions that cannot resolve parameters) * * @author Antonio Rabelo @@ -344,15 +168,14 @@ class Action extends Base ); if (! $this->db->table(self::TABLE)->insert($values)) { - $this->container['logger']->debug('Action::duplicate => unable to create '.$action['action_name']); $this->db->cancelTransaction(); continue; } $action_id = $this->db->getLastId(); - if (! $this->duplicateParameters($dst_project_id, $action_id, $action['params'])) { - $this->container['logger']->debug('Action::duplicate => unable to copy parameters for '.$action['action_name']); + if (! $this->actionParameter->duplicateParameters($dst_project_id, $action_id, $action['params'])) { + $this->logger->error('Action::duplicate => skip action '.$action['action_name'].' '.$action['id']); $this->db->cancelTransaction(); continue; } @@ -362,95 +185,4 @@ class Action extends Base return true; } - - /** - * Duplicate action parameters - * - * @access public - * @param integer $project_id - * @param integer $action_id - * @param array $params - * @return boolean - */ - public function duplicateParameters($project_id, $action_id, array $params) - { - foreach ($params as $param) { - $value = $this->resolveParameters($param, $project_id); - - if ($value === false) { - $this->container['logger']->debug('Action::duplicateParameters => unable to resolve '.$param['name'].'='.$param['value']); - return false; - } - - $values = array( - 'action_id' => $action_id, - 'name' => $param['name'], - 'value' => $value, - ); - - if (! $this->db->table(self::TABLE_PARAMS)->insert($values)) { - return false; - } - } - - return true; - } - - /** - * Resolve action parameter values according to another project - * - * @author Antonio Rabelo - * @access public - * @param array $param Action parameter - * @param integer $project_id Project to find the corresponding values - * @return mixed - */ - public function resolveParameters(array $param, $project_id) - { - switch ($param['name']) { - case 'project_id': - return $project_id; - case 'category_id': - return $this->category->getIdByName($project_id, $this->category->getNameById($param['value'])) ?: false; - case 'src_column_id': - case 'dest_column_id': - case 'dst_column_id': - case 'column_id': - $column = $this->board->getColumn($param['value']); - - if (empty($column)) { - return false; - } - - return $this->board->getColumnIdByTitle($project_id, $column['title']) ?: false; - case 'user_id': - case 'owner_id': - return $this->projectPermission->isMember($project_id, $param['value']) ? $param['value'] : false; - default: - return $param['value']; - } - } - - /** - * Validate action creation - * - * @access public - * @param array $values Required parameters to save an action - * @return array $valid, $errors [0] = Success or not, [1] = List of errors - */ - public function validateCreation(array $values) - { - $v = new Validator($values, array( - new Validators\Required('project_id', t('The project id is required')), - new Validators\Integer('project_id', t('This value must be an integer')), - new Validators\Required('event_name', t('This value is required')), - new Validators\Required('action_name', t('This value is required')), - new Validators\Required('params', t('This value is required')), - )); - - return array( - $v->execute(), - $v->getErrors() - ); - } } diff --git a/app/Model/ActionParameter.php b/app/Model/ActionParameter.php new file mode 100644 index 00000000..53edcbc8 --- /dev/null +++ b/app/Model/ActionParameter.php @@ -0,0 +1,162 @@ +<?php + +namespace Kanboard\Model; + +/** + * Action Parameter Model + * + * @package model + * @author Frederic Guillot + */ +class ActionParameter extends Base +{ + /** + * SQL table name + * + * @var string + */ + const TABLE = 'action_has_params'; + + /** + * Get all action params + * + * @access public + * @return array + */ + public function getAll() + { + $params = $this->db->table(self::TABLE)->findAll(); + return $this->toDictionary($params); + } + + /** + * Get all params for a list of actions + * + * @access public + * @param array $action_ids + * @return array + */ + public function getAllByActions(array $action_ids) + { + $params = $this->db->table(self::TABLE)->in('action_id', $action_ids)->findAll(); + return $this->toDictionary($params); + } + + /** + * Build params dictionary + * + * @access private + * @param array $params + * @return array + */ + private function toDictionary(array $params) + { + $result = array(); + + foreach ($params as $param) { + $result[$param['action_id']][$param['name']] = $param['value']; + } + + return $result; + } + + /** + * Get all action params for a given action + * + * @access public + * @param integer $action_id + * @return array + */ + public function getAllByAction($action_id) + { + return $this->db->hashtable(self::TABLE)->eq('action_id', $action_id)->getAll('name', 'value'); + } + + /** + * Insert new parameters for an action + * + * @access public + * @param integer $action_id + * @param array $values + * @return boolean + */ + public function create($action_id, array $values) + { + foreach ($values['params'] as $name => $value) { + $param = array( + 'action_id' => $action_id, + 'name' => $name, + 'value' => $value, + ); + + if (! $this->db->table(self::TABLE)->save($param)) { + return false; + } + } + + return true; + } + + /** + * Duplicate action parameters + * + * @access public + * @param integer $project_id + * @param integer $action_id + * @param array $params + * @return boolean + */ + public function duplicateParameters($project_id, $action_id, array $params) + { + foreach ($params as $name => $value) { + $value = $this->resolveParameter($project_id, $name, $value); + + if ($value === false) { + $this->logger->error('ActionParameter::duplicateParameters => unable to resolve '.$name.'='.$value); + return false; + } + + $values = array( + 'action_id' => $action_id, + 'name' => $name, + 'value' => $value, + ); + + if (! $this->db->table(self::TABLE)->insert($values)) { + return false; + } + } + + return true; + } + + /** + * Resolve action parameter values according to another project + * + * @access private + * @param integer $project_id + * @param string $name + * @param string $value + * @return mixed + */ + private function resolveParameter($project_id, $name, $value) + { + switch ($name) { + case 'project_id': + return $value != $project_id ? $value : false; + case 'category_id': + return $this->category->getIdByName($project_id, $this->category->getNameById($value)) ?: false; + case 'src_column_id': + case 'dest_column_id': + case 'dst_column_id': + case 'column_id': + $column = $this->column->getById($value); + return empty($column) ? false : $this->column->getColumnIdByTitle($project_id, $column['title']) ?: false; + case 'user_id': + case 'owner_id': + return $this->projectPermission->isAssignable($project_id, $value) ? $value : false; + default: + return $value; + } + } +} diff --git a/app/Model/Authentication.php b/app/Model/Authentication.php deleted file mode 100644 index 580c1e14..00000000 --- a/app/Model/Authentication.php +++ /dev/null @@ -1,202 +0,0 @@ -<?php - -namespace Kanboard\Model; - -use Kanboard\Core\Request; -use SimpleValidator\Validator; -use SimpleValidator\Validators; -use Gregwar\Captcha\CaptchaBuilder; - -/** - * Authentication model - * - * @package model - * @author Frederic Guillot - */ -class Authentication extends Base -{ - /** - * Load automatically an authentication backend - * - * @access public - * @param string $name Backend class name - * @return mixed - */ - public function backend($name) - { - if (! isset($this->container[$name])) { - $class = '\Kanboard\Auth\\'.ucfirst($name); - $this->container[$name] = new $class($this->container); - } - - return $this->container[$name]; - } - - /** - * Check if the current user is authenticated - * - * @access public - * @return bool - */ - public function isAuthenticated() - { - // If the user is already logged it's ok - if ($this->userSession->isLogged()) { - - // Check if the user session match an existing user - $userNotFound = ! $this->user->exists($this->userSession->getId()); - $reverseProxyWrongUser = REVERSE_PROXY_AUTH && $this->backend('reverseProxy')->getUsername() !== $_SESSION['user']['username']; - - if ($userNotFound || $reverseProxyWrongUser) { - $this->backend('rememberMe')->destroy($this->userSession->getId()); - $this->session->close(); - return false; - } - - return true; - } - - // We try first with the RememberMe cookie - if (REMEMBER_ME_AUTH && $this->backend('rememberMe')->authenticate()) { - return true; - } - - // Then with the ReverseProxy authentication - if (REVERSE_PROXY_AUTH && $this->backend('reverseProxy')->authenticate()) { - return true; - } - - return false; - } - - /** - * Authenticate a user by different methods - * - * @access public - * @param string $username Username - * @param string $password Password - * @return boolean - */ - public function authenticate($username, $password) - { - if ($this->user->isLocked($username)) { - $this->container['logger']->error('Account locked: '.$username); - return false; - } elseif ($this->backend('database')->authenticate($username, $password)) { - $this->user->resetFailedLogin($username); - return true; - } elseif (LDAP_AUTH && $this->backend('ldap')->authenticate($username, $password)) { - $this->user->resetFailedLogin($username); - return true; - } - - $this->handleFailedLogin($username); - return false; - } - - /** - * Return true if the captcha must be shown - * - * @access public - * @param string $username - * @return boolean - */ - public function hasCaptcha($username) - { - return $this->user->getFailedLogin($username) >= BRUTEFORCE_CAPTCHA; - } - - /** - * Handle failed login - * - * @access public - * @param string $username - */ - public function handleFailedLogin($username) - { - $this->user->incrementFailedLogin($username); - - if ($this->user->getFailedLogin($username) >= BRUTEFORCE_LOCKDOWN) { - $this->container['logger']->critical('Locking account: '.$username); - $this->user->lock($username, BRUTEFORCE_LOCKDOWN_DURATION); - } - } - - /** - * Validate user login form - * - * @access public - * @param array $values Form values - * @return array $valid, $errors [0] = Success or not, [1] = List of errors - */ - public function validateForm(array $values) - { - list($result, $errors) = $this->validateFormCredentials($values); - - if ($result) { - if ($this->validateFormCaptcha($values) && $this->authenticate($values['username'], $values['password'])) { - $this->createRememberMeSession($values); - } else { - $result = false; - $errors['login'] = t('Bad username or password'); - } - } - - return array($result, $errors); - } - - /** - * Validate credentials syntax - * - * @access public - * @param array $values Form values - * @return array $valid, $errors [0] = Success or not, [1] = List of errors - */ - public function validateFormCredentials(array $values) - { - $v = new Validator($values, array( - new Validators\Required('username', t('The username is required')), - new Validators\MaxLength('username', t('The maximum length is %d characters', 50), 50), - new Validators\Required('password', t('The password is required')), - )); - - return array( - $v->execute(), - $v->getErrors(), - ); - } - - /** - * Validate captcha - * - * @access public - * @param array $values Form values - * @return boolean - */ - public function validateFormCaptcha(array $values) - { - if ($this->hasCaptcha($values['username'])) { - $builder = new CaptchaBuilder; - $builder->setPhrase($this->session['captcha']); - return $builder->testPhrase(isset($values['captcha']) ? $values['captcha'] : ''); - } - - return true; - } - - /** - * Create remember me session if necessary - * - * @access private - * @param array $values Form values - */ - private function createRememberMeSession(array $values) - { - if (REMEMBER_ME_AUTH && ! empty($values['remember_me'])) { - $credentials = $this->backend('rememberMe') - ->create($this->userSession->getId(), Request::getIpAddress(), Request::getUserAgent()); - - $this->backend('rememberMe')->writeCookie($credentials['token'], $credentials['sequence'], $credentials['expiration']); - } - } -} diff --git a/app/Model/Board.php b/app/Model/Board.php index 79a1a92d..c10be19f 100644 --- a/app/Model/Board.php +++ b/app/Model/Board.php @@ -2,10 +2,6 @@ namespace Kanboard\Model; -use SimpleValidator\Validator; -use SimpleValidator\Validators; -use PicoDb\Database; - /** * Board model * @@ -15,13 +11,6 @@ use PicoDb\Database; class Board extends Base { /** - * SQL table name - * - * @var string - */ - const TABLE = 'columns'; - - /** * Get Kanboard default columns * * @access public @@ -75,7 +64,7 @@ class Board extends Base 'description' => $column['description'], ); - if (! $this->db->table(self::TABLE)->save($values)) { + if (! $this->db->table(Column::TABLE)->save($values)) { return false; } } @@ -93,7 +82,7 @@ class Board extends Base */ public function duplicate($project_from, $project_to) { - $columns = $this->db->table(Board::TABLE) + $columns = $this->db->table(Column::TABLE) ->columns('title', 'task_limit', 'description') ->eq('project_id', $project_from) ->asc('position') @@ -103,134 +92,6 @@ class Board extends Base } /** - * Add a new column to the board - * - * @access public - * @param integer $project_id Project id - * @param string $title Column title - * @param integer $task_limit Task limit - * @param string $description Column description - * @return boolean|integer - */ - public function addColumn($project_id, $title, $task_limit = 0, $description = '') - { - $values = array( - 'project_id' => $project_id, - 'title' => $title, - 'task_limit' => intval($task_limit), - 'position' => $this->getLastColumnPosition($project_id) + 1, - 'description' => $description, - ); - - return $this->persist(self::TABLE, $values); - } - - /** - * Update a column - * - * @access public - * @param integer $column_id Column id - * @param string $title Column title - * @param integer $task_limit Task limit - * @param string $description Optional description - * @return boolean - */ - public function updateColumn($column_id, $title, $task_limit = 0, $description = '') - { - return $this->db->table(self::TABLE)->eq('id', $column_id)->update(array( - 'title' => $title, - 'task_limit' => intval($task_limit), - 'description' => $description, - )); - } - - /** - * Get columns with consecutive positions - * - * If you remove a column, the positions are not anymore consecutives - * - * @access public - * @param integer $project_id - * @return array - */ - public function getNormalizedColumnPositions($project_id) - { - $columns = $this->db->hashtable(self::TABLE)->eq('project_id', $project_id)->asc('position')->getAll('id', 'position'); - $position = 1; - - foreach ($columns as $column_id => $column_position) { - $columns[$column_id] = $position++; - } - - return $columns; - } - - /** - * Save the new positions for a set of columns - * - * @access public - * @param array $columns Hashmap of column_id/column_position - * @return boolean - */ - public function saveColumnPositions(array $columns) - { - return $this->db->transaction(function (Database $db) use ($columns) { - - foreach ($columns as $column_id => $position) { - if (! $db->table(Board::TABLE)->eq('id', $column_id)->update(array('position' => $position))) { - return false; - } - } - }); - } - - /** - * Move a column down, increment the column position value - * - * @access public - * @param integer $project_id Project id - * @param integer $column_id Column id - * @return boolean - */ - public function moveDown($project_id, $column_id) - { - $columns = $this->getNormalizedColumnPositions($project_id); - $positions = array_flip($columns); - - if (isset($columns[$column_id]) && $columns[$column_id] < count($columns)) { - $position = ++$columns[$column_id]; - $columns[$positions[$position]]--; - - return $this->saveColumnPositions($columns); - } - - return false; - } - - /** - * Move a column up, decrement the column position value - * - * @access public - * @param integer $project_id Project id - * @param integer $column_id Column id - * @return boolean - */ - public function moveUp($project_id, $column_id) - { - $columns = $this->getNormalizedColumnPositions($project_id); - $positions = array_flip($columns); - - if (isset($columns[$column_id]) && $columns[$column_id] > 1) { - $position = --$columns[$column_id]; - $columns[$positions[$position]]++; - - return $this->saveColumnPositions($columns); - } - - return false; - } - - /** * Get all tasks sorted by columns and swimlanes * * @access public @@ -241,7 +102,7 @@ class Board extends Base public function getBoard($project_id, $callback = null) { $swimlanes = $this->swimlane->getSwimlanes($project_id); - $columns = $this->getColumns($project_id); + $columns = $this->column->getAll($project_id); $nb_columns = count($columns); for ($i = 0, $ilen = count($swimlanes); $i < $ilen; $i++) { @@ -309,174 +170,4 @@ class Board extends Base return $prepend ? array(-1 => t('All columns')) + $listing : $listing; } - - /** - * Get the first column id for a given project - * - * @access public - * @param integer $project_id Project id - * @return integer - */ - public function getFirstColumn($project_id) - { - return $this->db->table(self::TABLE)->eq('project_id', $project_id)->asc('position')->findOneColumn('id'); - } - - /** - * Get the last column id for a given project - * - * @access public - * @param integer $project_id Project id - * @return integer - */ - public function getLastColumn($project_id) - { - return $this->db->table(self::TABLE)->eq('project_id', $project_id)->desc('position')->findOneColumn('id'); - } - - /** - * Get the list of columns sorted by position [ column_id => title ] - * - * @access public - * @param integer $project_id Project id - * @param boolean $prepend Prepend a default value - * @return array - */ - public function getColumnsList($project_id, $prepend = false) - { - $listing = $this->db->hashtable(self::TABLE)->eq('project_id', $project_id)->asc('position')->getAll('id', 'title'); - return $prepend ? array(-1 => t('All columns')) + $listing : $listing; - } - - /** - * Get all columns sorted by position for a given project - * - * @access public - * @param integer $project_id Project id - * @return array - */ - public function getColumns($project_id) - { - return $this->db->table(self::TABLE)->eq('project_id', $project_id)->asc('position')->findAll(); - } - - /** - * Get the number of columns for a given project - * - * @access public - * @param integer $project_id Project id - * @return integer - */ - public function countColumns($project_id) - { - return $this->db->table(self::TABLE)->eq('project_id', $project_id)->count(); - } - - /** - * Get a column by the id - * - * @access public - * @param integer $column_id Column id - * @return array - */ - public function getColumn($column_id) - { - return $this->db->table(self::TABLE)->eq('id', $column_id)->findOne(); - } - - /** - * Get a column id by the name - * - * @access public - * @param integer $project_id - * @param string $title - * @return integer - */ - public function getColumnIdByTitle($project_id, $title) - { - return (int) $this->db->table(self::TABLE)->eq('project_id', $project_id)->eq('title', $title)->findOneColumn('id'); - } - - /** - * Get a column title by the id - * - * @access public - * @param integer $column_id - * @return integer - */ - public function getColumnTitleById($column_id) - { - return $this->db->table(self::TABLE)->eq('id', $column_id)->findOneColumn('title'); - } - - /** - * Get the position of the last column for a given project - * - * @access public - * @param integer $project_id Project id - * @return integer - */ - public function getLastColumnPosition($project_id) - { - return (int) $this->db - ->table(self::TABLE) - ->eq('project_id', $project_id) - ->desc('position') - ->findOneColumn('position'); - } - - /** - * Remove a column and all tasks associated to this column - * - * @access public - * @param integer $column_id Column id - * @return boolean - */ - public function removeColumn($column_id) - { - return $this->db->table(self::TABLE)->eq('id', $column_id)->remove(); - } - - /** - * Validate column modification - * - * @access public - * @param array $values Required parameters to update a column - * @return array $valid, $errors [0] = Success or not, [1] = List of errors - */ - public function validateModification(array $values) - { - $v = new Validator($values, array( - new Validators\Integer('task_limit', t('This value must be an integer')), - new Validators\Required('title', t('The title is required')), - new Validators\MaxLength('title', t('The maximum length is %d characters', 50), 50), - )); - - return array( - $v->execute(), - $v->getErrors() - ); - } - - /** - * Validate column creation - * - * @access public - * @param array $values Required parameters to save an action - * @return array $valid, $errors [0] = Success or not, [1] = List of errors - */ - public function validateCreation(array $values) - { - $v = new Validator($values, array( - new Validators\Required('project_id', t('The project id is required')), - new Validators\Integer('project_id', t('This value must be an integer')), - new Validators\Required('title', t('The title is required')), - new Validators\MaxLength('title', t('The maximum length is %d characters', 50), 50), - )); - - return array( - $v->execute(), - $v->getErrors() - ); - } } diff --git a/app/Model/Category.php b/app/Model/Category.php index bf40c60a..883fc282 100644 --- a/app/Model/Category.php +++ b/app/Model/Category.php @@ -2,9 +2,6 @@ namespace Kanboard\Model; -use SimpleValidator\Validator; -use SimpleValidator\Validators; - /** * Category model * @@ -196,11 +193,12 @@ class Category extends Base */ public function duplicate($src_project_id, $dst_project_id) { - $categories = $this->db->table(self::TABLE) - ->columns('name') - ->eq('project_id', $src_project_id) - ->asc('name') - ->findAll(); + $categories = $this->db + ->table(self::TABLE) + ->columns('name') + ->eq('project_id', $src_project_id) + ->asc('name') + ->findAll(); foreach ($categories as $category) { $category['project_id'] = $dst_project_id; @@ -212,63 +210,4 @@ class Category extends Base return true; } - - /** - * Validate category creation - * - * @access public - * @param array $values Form values - * @return array $valid, $errors [0] = Success or not, [1] = List of errors - */ - public function validateCreation(array $values) - { - $rules = array( - new Validators\Required('project_id', t('The project id is required')), - new Validators\Required('name', t('The name is required')), - ); - - $v = new Validator($values, array_merge($rules, $this->commonValidationRules())); - - return array( - $v->execute(), - $v->getErrors() - ); - } - - /** - * Validate category modification - * - * @access public - * @param array $values Form values - * @return array $valid, $errors [0] = Success or not, [1] = List of errors - */ - public function validateModification(array $values) - { - $rules = array( - new Validators\Required('id', t('The id is required')), - new Validators\Required('name', t('The name is required')), - ); - - $v = new Validator($values, array_merge($rules, $this->commonValidationRules())); - - return array( - $v->execute(), - $v->getErrors() - ); - } - - /** - * Common validation rules - * - * @access private - * @return array - */ - private function commonValidationRules() - { - return array( - new Validators\Integer('id', t('The id must be an integer')), - new Validators\Integer('project_id', t('The project id must be an integer')), - new Validators\MaxLength('name', t('The maximum length is %d characters', 50), 50) - ); - } } diff --git a/app/Model/Column.php b/app/Model/Column.php new file mode 100644 index 00000000..ccdcb049 --- /dev/null +++ b/app/Model/Column.php @@ -0,0 +1,209 @@ +<?php + +namespace Kanboard\Model; + +/** + * Column Model + * + * @package model + * @author Frederic Guillot + */ +class Column extends Base +{ + /** + * SQL table name + * + * @var string + */ + const TABLE = 'columns'; + + /** + * Get a column by the id + * + * @access public + * @param integer $column_id Column id + * @return array + */ + public function getById($column_id) + { + return $this->db->table(self::TABLE)->eq('id', $column_id)->findOne(); + } + + /** + * Get the first column id for a given project + * + * @access public + * @param integer $project_id Project id + * @return integer + */ + public function getFirstColumnId($project_id) + { + return $this->db->table(self::TABLE)->eq('project_id', $project_id)->asc('position')->findOneColumn('id'); + } + + /** + * Get the last column id for a given project + * + * @access public + * @param integer $project_id Project id + * @return integer + */ + public function getLastColumnId($project_id) + { + return $this->db->table(self::TABLE)->eq('project_id', $project_id)->desc('position')->findOneColumn('id'); + } + + /** + * Get the position of the last column for a given project + * + * @access public + * @param integer $project_id Project id + * @return integer + */ + public function getLastColumnPosition($project_id) + { + return (int) $this->db + ->table(self::TABLE) + ->eq('project_id', $project_id) + ->desc('position') + ->findOneColumn('position'); + } + + /** + * Get a column id by the name + * + * @access public + * @param integer $project_id + * @param string $title + * @return integer + */ + public function getColumnIdByTitle($project_id, $title) + { + return (int) $this->db->table(self::TABLE)->eq('project_id', $project_id)->eq('title', $title)->findOneColumn('id'); + } + + /** + * Get a column title by the id + * + * @access public + * @param integer $column_id + * @return integer + */ + public function getColumnTitleById($column_id) + { + return $this->db->table(self::TABLE)->eq('id', $column_id)->findOneColumn('title'); + } + + /** + * Get all columns sorted by position for a given project + * + * @access public + * @param integer $project_id Project id + * @return array + */ + public function getAll($project_id) + { + return $this->db->table(self::TABLE)->eq('project_id', $project_id)->asc('position')->findAll(); + } + + /** + * Get the list of columns sorted by position [ column_id => title ] + * + * @access public + * @param integer $project_id Project id + * @param boolean $prepend Prepend a default value + * @return array + */ + public function getList($project_id, $prepend = false) + { + $listing = $this->db->hashtable(self::TABLE)->eq('project_id', $project_id)->asc('position')->getAll('id', 'title'); + return $prepend ? array(-1 => t('All columns')) + $listing : $listing; + } + + /** + * Add a new column to the board + * + * @access public + * @param integer $project_id Project id + * @param string $title Column title + * @param integer $task_limit Task limit + * @param string $description Column description + * @return boolean|integer + */ + public function create($project_id, $title, $task_limit = 0, $description = '') + { + $values = array( + 'project_id' => $project_id, + 'title' => $title, + 'task_limit' => intval($task_limit), + 'position' => $this->getLastColumnPosition($project_id) + 1, + 'description' => $description, + ); + + return $this->persist(self::TABLE, $values); + } + + /** + * Update a column + * + * @access public + * @param integer $column_id Column id + * @param string $title Column title + * @param integer $task_limit Task limit + * @param string $description Optional description + * @return boolean + */ + public function update($column_id, $title, $task_limit = 0, $description = '') + { + return $this->db->table(self::TABLE)->eq('id', $column_id)->update(array( + 'title' => $title, + 'task_limit' => intval($task_limit), + 'description' => $description, + )); + } + + /** + * Remove a column and all tasks associated to this column + * + * @access public + * @param integer $column_id Column id + * @return boolean + */ + public function remove($column_id) + { + return $this->db->table(self::TABLE)->eq('id', $column_id)->remove(); + } + + /** + * Change column position + * + * @access public + * @param integer $project_id + * @param integer $column_id + * @param integer $position + * @return boolean + */ + public function changePosition($project_id, $column_id, $position) + { + if ($position < 1 || $position > $this->db->table(self::TABLE)->eq('project_id', $project_id)->count()) { + return false; + } + + $column_ids = $this->db->table(self::TABLE)->eq('project_id', $project_id)->neq('id', $column_id)->asc('position')->findAllByColumn('id'); + $offset = 1; + $results = array(); + + foreach ($column_ids as $current_column_id) { + if ($offset == $position) { + $offset++; + } + + $results[] = $this->db->table(self::TABLE)->eq('id', $current_column_id)->update(array('position' => $offset)); + $offset++; + } + + $results[] = $this->db->table(self::TABLE)->eq('id', $column_id)->update(array('position' => $position)); + + return !in_array(false, $results, true); + } +} diff --git a/app/Model/Comment.php b/app/Model/Comment.php index c7125a25..6eb4a1e5 100644 --- a/app/Model/Comment.php +++ b/app/Model/Comment.php @@ -3,8 +3,6 @@ namespace Kanboard\Model; use Kanboard\Event\CommentEvent; -use SimpleValidator\Validator; -use SimpleValidator\Validators; /** * Comment model @@ -26,8 +24,9 @@ class Comment extends Base * * @var string */ - const EVENT_UPDATE = 'comment.update'; - const EVENT_CREATE = 'comment.create'; + const EVENT_UPDATE = 'comment.update'; + const EVENT_CREATE = 'comment.create'; + const EVENT_USER_MENTION = 'comment.user.mention'; /** * Get all comments for a given task @@ -74,6 +73,7 @@ class Comment extends Base self::TABLE.'.user_id', self::TABLE.'.date_creation', self::TABLE.'.comment', + self::TABLE.'.reference', User::TABLE.'.username', User::TABLE.'.name' ) @@ -110,7 +110,9 @@ class Comment extends Base $comment_id = $this->persist(self::TABLE, $values); if ($comment_id) { - $this->container['dispatcher']->dispatch(self::EVENT_CREATE, new CommentEvent(array('id' => $comment_id) + $values)); + $event = new CommentEvent(array('id' => $comment_id) + $values); + $this->dispatcher->dispatch(self::EVENT_CREATE, $event); + $this->userMention->fireEvents($values['comment'], self::EVENT_USER_MENTION, $event); } return $comment_id; @@ -148,62 +150,4 @@ class Comment extends Base { return $this->db->table(self::TABLE)->eq('id', $comment_id)->remove(); } - - /** - * Validate comment creation - * - * @access public - * @param array $values Required parameters to save an action - * @return array $valid, $errors [0] = Success or not, [1] = List of errors - */ - public function validateCreation(array $values) - { - $rules = array( - new Validators\Required('task_id', t('This value is required')), - ); - - $v = new Validator($values, array_merge($rules, $this->commonValidationRules())); - - return array( - $v->execute(), - $v->getErrors() - ); - } - - /** - * Validate comment modification - * - * @access public - * @param array $values Required parameters to save an action - * @return array $valid, $errors [0] = Success or not, [1] = List of errors - */ - public function validateModification(array $values) - { - $rules = array( - new Validators\Required('id', t('This value is required')), - ); - - $v = new Validator($values, array_merge($rules, $this->commonValidationRules())); - - return array( - $v->execute(), - $v->getErrors() - ); - } - - /** - * Common validation rules - * - * @access private - * @return array - */ - private function commonValidationRules() - { - return array( - new Validators\Integer('id', t('This value must be an integer')), - new Validators\Integer('task_id', t('This value must be an integer')), - new Validators\Integer('user_id', t('This value must be an integer')), - new Validators\Required('comment', t('Comment is required')) - ); - } } diff --git a/app/Model/Config.php b/app/Model/Config.php index cf634f80..6f009175 100644 --- a/app/Model/Config.php +++ b/app/Model/Config.php @@ -3,8 +3,7 @@ namespace Kanboard\Model; use Kanboard\Core\Translator; -use Kanboard\Core\Security; -use Kanboard\Core\Session; +use Kanboard\Core\Security\Token; /** * Config model @@ -15,30 +14,6 @@ use Kanboard\Core\Session; class Config extends Setting { /** - * Get available currencies - * - * @access public - * @return array - */ - public function getCurrencies() - { - return array( - 'USD' => t('USD - US Dollar'), - 'EUR' => t('EUR - Euro'), - 'GBP' => t('GBP - British Pound'), - 'CHF' => t('CHF - Swiss Francs'), - 'CAD' => t('CAD - Canadian Dollar'), - 'AUD' => t('AUD - Australian Dollar'), - 'NZD' => t('NZD - New Zealand Dollar'), - 'INR' => t('INR - Indian Rupee'), - 'JPY' => t('JPY - Japanese Yen'), - 'RSD' => t('RSD - Serbian dinar'), - 'SEK' => t('SEK - Swedish Krona'), - 'NOK' => t('NOK - Norwegian Krone'), - ); - } - - /** * Get available timezones * * @access public @@ -58,6 +33,31 @@ class Config extends Setting } /** + * Get current timezone + * + * @access public + * @return string + */ + public function getCurrentTimezone() + { + if ($this->userSession->isLogged() && ! empty($this->sessionStorage->user['timezone'])) { + return $this->sessionStorage->user['timezone']; + } + + return $this->get('application_timezone', 'UTC'); + } + + /** + * Set timezone + * + * @access public + */ + public function setupTimezone() + { + date_default_timezone_set($this->getCurrentTimezone()); + } + + /** * Get available languages * * @access public @@ -69,14 +69,17 @@ class Config extends Setting // Sorted by value $languages = array( 'id_ID' => 'Bahasa Indonesia', + 'bs_BA' => 'Bosanski', 'cs_CZ' => 'Čeština', 'da_DK' => 'Dansk', 'de_DE' => 'Deutsch', 'en_US' => 'English', 'es_ES' => 'Español', 'fr_FR' => 'Français', + 'el_GR' => 'Grec', 'it_IT' => 'Italiano', 'hu_HU' => 'Magyar', + 'my_MY' => 'Melayu', 'nl_NL' => 'Nederlands', 'nb_NO' => 'Norsk', 'pl_PL' => 'Polski', @@ -108,7 +111,7 @@ class Config extends Setting public function getJsLanguageCode() { $languages = array( - 'cs_CZ' => 'cz', + 'cs_CZ' => 'cs', 'da_DK' => 'da', 'de_DE' => 'de', 'en_US' => 'en', @@ -129,7 +132,8 @@ class Config extends Setting 'zh_CN' => 'zh-cn', 'ja_JP' => 'ja', 'th_TH' => 'th', - 'id_ID' => 'id' + 'id_ID' => 'id', + 'el_GR' => 'el', ); $lang = $this->getCurrentLanguage(); @@ -145,51 +149,14 @@ class Config extends Setting */ public function getCurrentLanguage() { - if ($this->userSession->isLogged() && ! empty($this->session['user']['language'])) { - return $this->session['user']['language']; + if ($this->userSession->isLogged() && ! empty($this->sessionStorage->user['language'])) { + return $this->sessionStorage->user['language']; } return $this->get('application_language', 'en_US'); } /** - * Get a config variable from the session or the database - * - * @access public - * @param string $name Parameter name - * @param string $default_value Default value of the parameter - * @return string - */ - public function get($name, $default_value = '') - { - if (! Session::isOpen()) { - return $this->getOption($name, $default_value); - } - - // Cache config in session - if (! isset($this->session['config'][$name])) { - $this->session['config'] = $this->getAll(); - } - - if (! empty($this->session['config'][$name])) { - return $this->session['config'][$name]; - } - - return $default_value; - } - - /** - * Reload settings in the session and the translations - * - * @access public - */ - public function reload() - { - $this->session['config'] = $this->getAll(); - $this->setupTranslations(); - } - - /** * Load translations * * @access public @@ -200,28 +167,27 @@ class Config extends Setting } /** - * Get current timezone + * Get a config variable from the session or the database * * @access public + * @param string $name Parameter name + * @param string $default_value Default value of the parameter * @return string */ - public function getCurrentTimezone() + public function get($name, $default_value = '') { - if ($this->userSession->isLogged() && ! empty($this->session['user']['timezone'])) { - return $this->session['user']['timezone']; - } - - return $this->get('application_timezone', 'UTC'); + $options = $this->memoryCache->proxy($this, 'getAll'); + return isset($options[$name]) && $options[$name] !== '' ? $options[$name] : $default_value; } /** - * Set timezone + * Reload settings in the session and the translations * * @access public */ - public function setupTimezone() + public function reload() { - date_default_timezone_set($this->getCurrentTimezone()); + $this->setupTranslations(); } /** @@ -232,7 +198,7 @@ class Config extends Setting */ public function optimizeDatabase() { - return $this->db->getconnection()->exec("VACUUM"); + return $this->db->getconnection()->exec('VACUUM'); } /** @@ -262,10 +228,11 @@ class Config extends Setting * * @access public * @param string $option Parameter name + * @return boolean */ public function regenerateToken($option) { - $this->save(array($option => Security::generateToken())); + return $this->save(array($option => Token::getToken())); } /** diff --git a/app/Model/Currency.php b/app/Model/Currency.php index c1156610..abcce2f0 100644 --- a/app/Model/Currency.php +++ b/app/Model/Currency.php @@ -2,9 +2,6 @@ namespace Kanboard\Model; -use SimpleValidator\Validator; -use SimpleValidator\Validators; - /** * Currency * @@ -21,6 +18,32 @@ class Currency extends Base const TABLE = 'currencies'; /** + * Get available application currencies + * + * @access public + * @return array + */ + public function getCurrencies() + { + return array( + 'USD' => t('USD - US Dollar'), + 'EUR' => t('EUR - Euro'), + 'GBP' => t('GBP - British Pound'), + 'CHF' => t('CHF - Swiss Francs'), + 'CAD' => t('CAD - Canadian Dollar'), + 'AUD' => t('AUD - Australian Dollar'), + 'NZD' => t('NZD - New Zealand Dollar'), + 'INR' => t('INR - Indian Rupee'), + 'JPY' => t('JPY - Japanese Yen'), + 'RSD' => t('RSD - Serbian dinar'), + 'SEK' => t('SEK - Swedish Krona'), + 'NOK' => t('NOK - Norwegian Krone'), + 'BAM' => t('BAM - Konvertible Mark'), + 'RUB' => t('RUB - Russian Ruble'), + ); + } + + /** * Get all currency rates * * @access public @@ -45,7 +68,7 @@ class Currency extends Base $reference = $this->config->get('application_currency', 'USD'); if ($reference !== $currency) { - $rates = $rates === null ? $this->db->hashtable(self::TABLE)->getAll('currency', 'rate') : array(); + $rates = $rates === null ? $this->db->hashtable(self::TABLE)->getAll('currency', 'rate') : $rates; $rate = isset($rates[$currency]) ? $rates[$currency] : 1; return $rate * $price; @@ -68,7 +91,7 @@ class Currency extends Base return $this->update($currency, $rate); } - return $this->persist(self::TABLE, compact('currency', 'rate')); + return $this->db->table(self::TABLE)->insert(array('currency' => $currency, 'rate' => $rate)); } /** @@ -83,24 +106,4 @@ class Currency extends Base { return $this->db->table(self::TABLE)->eq('currency', $currency)->update(array('rate' => $rate)); } - - /** - * Validate - * - * @access public - * @param array $values Form values - * @return array $valid, $errors [0] = Success or not, [1] = List of errors - */ - public function validate(array $values) - { - $v = new Validator($values, array( - new Validators\Required('currency', t('Field required')), - new Validators\Required('rate', t('Field required')), - )); - - return array( - $v->execute(), - $v->getErrors() - ); - } } diff --git a/app/Model/CustomFilter.php b/app/Model/CustomFilter.php index 6550b4a7..3a6a1a3a 100644 --- a/app/Model/CustomFilter.php +++ b/app/Model/CustomFilter.php @@ -2,9 +2,6 @@ namespace Kanboard\Model; -use SimpleValidator\Validator; -use SimpleValidator\Validators; - /** * Custom Filter model * @@ -102,63 +99,4 @@ class CustomFilter extends Base { return $this->db->table(self::TABLE)->eq('id', $filter_id)->remove(); } - - /** - * Common validation rules - * - * @access private - * @return array - */ - private function commonValidationRules() - { - return array( - new Validators\Required('project_id', t('Field required')), - new Validators\Required('user_id', t('Field required')), - new Validators\Required('name', t('Field required')), - new Validators\Required('filter', t('Field required')), - new Validators\Integer('user_id', t('This value must be an integer')), - new Validators\Integer('project_id', t('This value must be an integer')), - new Validators\MaxLength('name', t('The maximum length is %d characters', 100), 100), - new Validators\MaxLength('filter', t('The maximum length is %d characters', 100), 100) - ); - } - - /** - * Validate filter creation - * - * @access public - * @param array $values Form values - * @return array $valid, $errors [0] = Success or not, [1] = List of errors - */ - public function validateCreation(array $values) - { - $v = new Validator($values, $this->commonValidationRules()); - - return array( - $v->execute(), - $v->getErrors() - ); - } - - /** - * Validate filter modification - * - * @access public - * @param array $values Form values - * @return array $valid, $errors [0] = Success or not, [1] = List of errors - */ - public function validateModification(array $values) - { - $rules = array( - new Validators\Required('id', t('Field required')), - new Validators\Integer('id', t('This value must be an integer')), - ); - - $v = new Validator($values, array_merge($rules, $this->commonValidationRules())); - - return array( - $v->execute(), - $v->getErrors() - ); - } } diff --git a/app/Model/File.php b/app/Model/File.php index daade517..03ea691d 100644 --- a/app/Model/File.php +++ b/app/Model/File.php @@ -2,31 +2,44 @@ namespace Kanboard\Model; +use Exception; use Kanboard\Event\FileEvent; use Kanboard\Core\Tool; use Kanboard\Core\ObjectStorage\ObjectStorageException; /** - * File model + * Base File Model * * @package model * @author Frederic Guillot */ -class File extends Base +abstract class File extends Base { /** - * SQL table name - * - * @var string - */ - const TABLE = 'files'; - - /** - * Events + * Get PicoDb query to get all files * - * @var string + * @access protected + * @return \PicoDb\Table */ - const EVENT_CREATE = 'file.create'; + protected function getQuery() + { + return $this->db + ->table(static::TABLE) + ->columns( + static::TABLE.'.id', + static::TABLE.'.name', + static::TABLE.'.path', + static::TABLE.'.is_image', + static::TABLE.'.'.static::FOREIGN_KEY, + static::TABLE.'.date', + static::TABLE.'.user_id', + static::TABLE.'.size', + User::TABLE.'.username', + User::TABLE.'.name as user_name' + ) + ->join(User::TABLE, 'id', 'user_id') + ->asc(static::TABLE.'.name'); + } /** * Get a file by the id @@ -37,146 +50,120 @@ class File extends Base */ public function getById($file_id) { - return $this->db->table(self::TABLE)->eq('id', $file_id)->findOne(); + return $this->db->table(static::TABLE)->eq('id', $file_id)->findOne(); } /** - * Remove a file + * Get all files * * @access public - * @param integer $file_id File id - * @return bool + * @param integer $id + * @return array */ - public function remove($file_id) + public function getAll($id) { - try { - $file = $this->getbyId($file_id); - $this->objectStorage->remove($file['path']); - - if ($file['is_image'] == 1) { - $this->objectStorage->remove($this->getThumbnailPath($file['path'])); - } - - return $this->db->table(self::TABLE)->eq('id', $file['id'])->remove(); - } catch (ObjectStorageException $e) { - $this->logger->error($e->getMessage()); - return false; - } + return $this->getQuery()->eq(static::FOREIGN_KEY, $id)->findAll(); } /** - * Remove all files for a given task + * Get all images * * @access public - * @param integer $task_id Task id - * @return bool + * @param integer $id + * @return array */ - public function removeAll($task_id) + public function getAllImages($id) { - $file_ids = $this->db->table(self::TABLE)->eq('task_id', $task_id)->asc('id')->findAllByColumn('id'); - $results = array(); - - foreach ($file_ids as $file_id) { - $results[] = $this->remove($file_id); - } + return $this->getQuery()->eq(static::FOREIGN_KEY, $id)->eq('is_image', 1)->findAll(); + } - return ! in_array(false, $results, true); + /** + * Get all files without images + * + * @access public + * @param integer $id + * @return array + */ + public function getAllDocuments($id) + { + return $this->getQuery()->eq(static::FOREIGN_KEY, $id)->eq('is_image', 0)->findAll(); } /** * Create a file entry in the database * * @access public - * @param integer $task_id Task id + * @param integer $id Foreign key * @param string $name Filename * @param string $path Path on the disk * @param integer $size File size * @return bool|integer */ - public function create($task_id, $name, $path, $size) + public function create($id, $name, $path, $size) { - $result = $this->db->table(self::TABLE)->save(array( - 'task_id' => $task_id, + $values = array( + static::FOREIGN_KEY => $id, 'name' => substr($name, 0, 255), 'path' => $path, 'is_image' => $this->isImage($name) ? 1 : 0, 'size' => $size, 'user_id' => $this->userSession->getId() ?: 0, 'date' => time(), - )); + ); - if ($result) { - $this->container['dispatcher']->dispatch( - self::EVENT_CREATE, - new FileEvent(array('task_id' => $task_id, 'name' => $name)) - ); + $result = $this->db->table(static::TABLE)->insert($values); - return (int) $this->db->getLastId(); + if ($result) { + $file_id = (int) $this->db->getLastId(); + $event = new FileEvent($values + array('file_id' => $file_id)); + $this->dispatcher->dispatch(static::EVENT_CREATE, $event); + return $file_id; } return false; } /** - * Get PicoDb query to get all files + * Remove all files * * @access public - * @return \PicoDb\Table + * @param integer $id + * @return bool */ - public function getQuery() + public function removeAll($id) { - return $this->db - ->table(self::TABLE) - ->columns( - self::TABLE.'.id', - self::TABLE.'.name', - self::TABLE.'.path', - self::TABLE.'.is_image', - self::TABLE.'.task_id', - self::TABLE.'.date', - self::TABLE.'.user_id', - self::TABLE.'.size', - User::TABLE.'.username', - User::TABLE.'.name as user_name' - ) - ->join(User::TABLE, 'id', 'user_id') - ->asc(self::TABLE.'.name'); - } + $file_ids = $this->db->table(static::TABLE)->eq(static::FOREIGN_KEY, $id)->asc('id')->findAllByColumn('id'); + $results = array(); - /** - * Get all files for a given task - * - * @access public - * @param integer $task_id Task id - * @return array - */ - public function getAll($task_id) - { - return $this->getQuery()->eq('task_id', $task_id)->findAll(); - } + foreach ($file_ids as $file_id) { + $results[] = $this->remove($file_id); + } - /** - * Get all images for a given task - * - * @access public - * @param integer $task_id Task id - * @return array - */ - public function getAllImages($task_id) - { - return $this->getQuery()->eq('task_id', $task_id)->eq('is_image', 1)->findAll(); + return ! in_array(false, $results, true); } /** - * Get all files without images for a given task + * Remove a file * * @access public - * @param integer $task_id Task id - * @return array + * @param integer $file_id File id + * @return bool */ - public function getAllDocuments($task_id) + public function remove($file_id) { - return $this->getQuery()->eq('task_id', $task_id)->eq('is_image', 0)->findAll(); + try { + $file = $this->getById($file_id); + $this->objectStorage->remove($file['path']); + + if ($file['is_image'] == 1) { + $this->objectStorage->remove($this->getThumbnailPath($file['path'])); + } + + return $this->db->table(static::TABLE)->eq('id', $file['id'])->remove(); + } catch (ObjectStorageException $e) { + $this->logger->error($e->getMessage()); + return false; + } } /** @@ -202,125 +189,96 @@ class File extends Base } /** - * Return the image mimetype based on the file extension + * Generate the path for a thumbnails * * @access public - * @param $filename + * @param string $key Storage key * @return string */ - public function getImageMimeType($filename) + public function getThumbnailPath($key) { - $extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); - - switch ($extension) { - case 'jpeg': - case 'jpg': - return 'image/jpeg'; - case 'png': - return 'image/png'; - case 'gif': - return 'image/gif'; - default: - return 'image/jpeg'; - } + return 'thumbnails'.DIRECTORY_SEPARATOR.$key; } /** * Generate the path for a new filename * * @access public - * @param integer $project_id Project id - * @param integer $task_id Task id + * @param integer $id Foreign key * @param string $filename Filename * @return string */ - public function generatePath($project_id, $task_id, $filename) - { - return $project_id.DIRECTORY_SEPARATOR.$task_id.DIRECTORY_SEPARATOR.hash('sha1', $filename.time()); - } - - /** - * Generate the path for a thumbnails - * - * @access public - * @param string $key Storage key - * @return string - */ - public function getThumbnailPath($key) + public function generatePath($id, $filename) { - return 'thumbnails'.DIRECTORY_SEPARATOR.$key; + return static::PATH_PREFIX.DIRECTORY_SEPARATOR.$id.DIRECTORY_SEPARATOR.hash('sha1', $filename.time()); } /** - * Handle file upload + * Upload multiple files * * @access public - * @param integer $project_id Project id - * @param integer $task_id Task id - * @param string $form_name File form name + * @param integer $id + * @param array $files * @return bool */ - public function uploadFiles($project_id, $task_id, $form_name) + public function uploadFiles($id, array $files) { try { - if (empty($_FILES[$form_name])) { + if (empty($files)) { return false; } - foreach ($_FILES[$form_name]['error'] as $key => $error) { - if ($error == UPLOAD_ERR_OK && $_FILES[$form_name]['size'][$key] > 0) { - $original_filename = $_FILES[$form_name]['name'][$key]; - $uploaded_filename = $_FILES[$form_name]['tmp_name'][$key]; - $destination_filename = $this->generatePath($project_id, $task_id, $original_filename); - - if ($this->isImage($original_filename)) { - $this->generateThumbnailFromFile($uploaded_filename, $destination_filename); - } - - $this->objectStorage->moveUploadedFile($uploaded_filename, $destination_filename); - - $this->create( - $task_id, - $original_filename, - $destination_filename, - $_FILES[$form_name]['size'][$key] - ); - } + foreach (array_keys($files['error']) as $key) { + $file = array( + 'name' => $files['name'][$key], + 'tmp_name' => $files['tmp_name'][$key], + 'size' => $files['size'][$key], + 'error' => $files['error'][$key], + ); + + $this->uploadFile($id, $file); } return true; - } catch (ObjectStorageException $e) { + } catch (Exception $e) { $this->logger->error($e->getMessage()); return false; } } /** - * Handle screenshot upload + * Upload a file * * @access public - * @param integer $project_id Project id - * @param integer $task_id Task id - * @param string $blob Base64 encoded image - * @return bool|integer + * @param integer $id + * @param array $file */ - public function uploadScreenshot($project_id, $task_id, $blob) + public function uploadFile($id, array $file) { - $original_filename = e('Screenshot taken %s', dt('%B %e, %Y at %k:%M %p', time())).'.png'; - return $this->uploadContent($project_id, $task_id, $original_filename, $blob); + if ($file['error'] == UPLOAD_ERR_OK && $file['size'] > 0) { + $destination_filename = $this->generatePath($id, $file['name']); + + if ($this->isImage($file['name'])) { + $this->generateThumbnailFromFile($file['tmp_name'], $destination_filename); + } + + $this->objectStorage->moveUploadedFile($file['tmp_name'], $destination_filename); + $this->create($id, $file['name'], $destination_filename, $file['size']); + } else { + throw new Exception('File not uploaded: '.var_export($file['error'], true)); + } } /** * Handle file upload (base64 encoded content) * * @access public - * @param integer $project_id Project id - * @param integer $task_id Task id - * @param string $original_filename Filename - * @param string $blob Base64 encoded file + * @param integer $id + * @param string $original_filename + * @param string $blob * @return bool|integer */ - public function uploadContent($project_id, $task_id, $original_filename, $blob) + public function uploadContent($id, $original_filename, $blob) { try { $data = base64_decode($blob); @@ -329,7 +287,7 @@ class File extends Base return false; } - $destination_filename = $this->generatePath($project_id, $task_id, $original_filename); + $destination_filename = $this->generatePath($id, $original_filename); $this->objectStorage->put($destination_filename, $data); if ($this->isImage($original_filename)) { @@ -337,7 +295,7 @@ class File extends Base } return $this->create( - $task_id, + $id, $original_filename, $destination_filename, strlen($data) diff --git a/app/Model/Group.php b/app/Model/Group.php new file mode 100644 index 00000000..67899503 --- /dev/null +++ b/app/Model/Group.php @@ -0,0 +1,117 @@ +<?php + +namespace Kanboard\Model; + +/** + * Group Model + * + * @package model + * @author Frederic Guillot + */ +class Group extends Base +{ + /** + * SQL table name + * + * @var string + */ + const TABLE = 'groups'; + + /** + * Get query to fetch all groups + * + * @access public + * @return \PicoDb\Table + */ + public function getQuery() + { + return $this->db->table(self::TABLE); + } + + /** + * Get a specific group by id + * + * @access public + * @param integer $group_id + * @return array + */ + public function getById($group_id) + { + return $this->getQuery()->eq('id', $group_id)->findOne(); + } + + /** + * Get a specific group by external id + * + * @access public + * @param integer $external_id + * @return array + */ + public function getByExternalId($external_id) + { + return $this->getQuery()->eq('external_id', $external_id)->findOne(); + } + + /** + * Get all groups + * + * @access public + * @return array + */ + public function getAll() + { + return $this->getQuery()->asc('name')->findAll(); + } + + /** + * Search groups by name + * + * @access public + * @param string $input + * @return array + */ + public function search($input) + { + return $this->db->table(self::TABLE)->ilike('name', '%'.$input.'%')->asc('name')->findAll(); + } + + /** + * Remove a group + * + * @access public + * @param integer $group_id + * @return array + */ + public function remove($group_id) + { + return $this->db->table(self::TABLE)->eq('id', $group_id)->remove(); + } + + /** + * Create a new group + * + * @access public + * @param string $name + * @param string $external_id + * @return integer|boolean + */ + public function create($name, $external_id = '') + { + return $this->persist(self::TABLE, array( + 'name' => $name, + 'external_id' => $external_id, + )); + } + + /** + * Update existing group + * + * @access public + * @param array $values + * @return boolean + */ + public function update(array $values) + { + return $this->db->table(self::TABLE)->eq('id', $values['id'])->update($values); + } +} diff --git a/app/Model/GroupMember.php b/app/Model/GroupMember.php new file mode 100644 index 00000000..7ed5f733 --- /dev/null +++ b/app/Model/GroupMember.php @@ -0,0 +1,111 @@ +<?php + +namespace Kanboard\Model; + +/** + * Group Member Model + * + * @package model + * @author Frederic Guillot + */ +class GroupMember extends Base +{ + /** + * SQL table name + * + * @var string + */ + const TABLE = 'group_has_users'; + + /** + * Get query to fetch all users + * + * @access public + * @param integer $group_id + * @return \PicoDb\Table + */ + public function getQuery($group_id) + { + return $this->db->table(self::TABLE) + ->join(User::TABLE, 'id', 'user_id') + ->eq('group_id', $group_id); + } + + /** + * Get all users + * + * @access public + * @param integer $group_id + * @return array + */ + public function getMembers($group_id) + { + return $this->getQuery($group_id)->findAll(); + } + + /** + * Get all not members + * + * @access public + * @param integer $group_id + * @return array + */ + public function getNotMembers($group_id) + { + $subquery = $this->db->table(self::TABLE) + ->columns('user_id') + ->eq('group_id', $group_id); + + return $this->db->table(User::TABLE) + ->notInSubquery('id', $subquery) + ->findAll(); + } + + /** + * Add user to a group + * + * @access public + * @param integer $group_id + * @param integer $user_id + * @return boolean + */ + public function addUser($group_id, $user_id) + { + return $this->db->table(self::TABLE)->insert(array( + 'group_id' => $group_id, + 'user_id' => $user_id, + )); + } + + /** + * Remove user from a group + * + * @access public + * @param integer $group_id + * @param integer $user_id + * @return boolean + */ + public function removeUser($group_id, $user_id) + { + return $this->db->table(self::TABLE) + ->eq('group_id', $group_id) + ->eq('user_id', $user_id) + ->remove(); + } + + /** + * Check if a user is member + * + * @access public + * @param integer $group_id + * @param integer $user_id + * @return boolean + */ + public function isMember($group_id, $user_id) + { + return $this->db->table(self::TABLE) + ->eq('group_id', $group_id) + ->eq('user_id', $user_id) + ->exists(); + } +} diff --git a/app/Model/LastLogin.php b/app/Model/LastLogin.php index 0f148ead..feb5f5a3 100644 --- a/app/Model/LastLogin.php +++ b/app/Model/LastLogin.php @@ -36,29 +36,39 @@ class LastLogin extends Base */ public function create($auth_type, $user_id, $ip, $user_agent) { - // Cleanup old sessions if necessary + $this->cleanup($user_id); + + return $this->db + ->table(self::TABLE) + ->insert(array( + 'auth_type' => $auth_type, + 'user_id' => $user_id, + 'ip' => $ip, + 'user_agent' => substr($user_agent, 0, 255), + 'date_creation' => time(), + )); + } + + /** + * Cleanup login history + * + * @access public + * @param integer $user_id + */ + public function cleanup($user_id) + { $connections = $this->db ->table(self::TABLE) ->eq('user_id', $user_id) - ->desc('date_creation') + ->desc('id') ->findAllByColumn('id'); if (count($connections) >= self::NB_LOGINS) { $this->db->table(self::TABLE) - ->eq('user_id', $user_id) - ->notin('id', array_slice($connections, 0, self::NB_LOGINS - 1)) - ->remove(); + ->eq('user_id', $user_id) + ->notin('id', array_slice($connections, 0, self::NB_LOGINS - 1)) + ->remove(); } - - return $this->db - ->table(self::TABLE) - ->insert(array( - 'auth_type' => $auth_type, - 'user_id' => $user_id, - 'ip' => $ip, - 'user_agent' => $user_agent, - 'date_creation' => time(), - )); } /** @@ -73,7 +83,7 @@ class LastLogin extends Base return $this->db ->table(self::TABLE) ->eq('user_id', $user_id) - ->desc('date_creation') + ->desc('id') ->columns('id', 'auth_type', 'ip', 'user_agent', 'date_creation') ->findAll(); } diff --git a/app/Model/Link.php b/app/Model/Link.php index 00b6dfc5..7b81a237 100644 --- a/app/Model/Link.php +++ b/app/Model/Link.php @@ -3,8 +3,6 @@ namespace Kanboard\Model; use PDO; -use SimpleValidator\Validator; -use SimpleValidator\Validators; /** * Link model @@ -176,47 +174,4 @@ class Link extends Base $this->db->table(self::TABLE)->eq('opposite_id', $link_id)->update(array('opposite_id' => 0)); return $this->db->table(self::TABLE)->eq('id', $link_id)->remove(); } - - /** - * Validate creation - * - * @access public - * @param array $values Form values - * @return array $valid, $errors [0] = Success or not, [1] = List of errors - */ - public function validateCreation(array $values) - { - $v = new Validator($values, array( - new Validators\Required('label', t('Field required')), - new Validators\Unique('label', t('This label must be unique'), $this->db->getConnection(), self::TABLE), - new Validators\NotEquals('label', 'opposite_label', t('The labels must be different')), - )); - - return array( - $v->execute(), - $v->getErrors() - ); - } - - /** - * Validate modification - * - * @access public - * @param array $values Form values - * @return array $valid, $errors [0] = Success or not, [1] = List of errors - */ - public function validateModification(array $values) - { - $v = new Validator($values, array( - new Validators\Required('id', t('Field required')), - new Validators\Required('opposite_id', t('Field required')), - new Validators\Required('label', t('Field required')), - new Validators\Unique('label', t('This label must be unique'), $this->db->getConnection(), self::TABLE), - )); - - return array( - $v->execute(), - $v->getErrors() - ); - } } diff --git a/app/Model/Metadata.php b/app/Model/Metadata.php index 83c8f499..690b2265 100644 --- a/app/Model/Metadata.php +++ b/app/Model/Metadata.php @@ -95,4 +95,17 @@ abstract class Metadata extends Base return ! in_array(false, $results, true); } + + /** + * Remove a metadata + * + * @access public + * @param integer $entity_id + * @param string $name + * @return bool + */ + public function remove($entity_id, $name) + { + return $this->db->table(static::TABLE)->eq($this->getEntityKey(), $entity_id)->eq('name', $name)->remove(); + } } diff --git a/app/Model/Notification.php b/app/Model/Notification.php index f1122993..c252aa31 100644 --- a/app/Model/Notification.php +++ b/app/Model/Notification.php @@ -72,8 +72,12 @@ class Notification extends Base return e('%s updated a comment on the task #%d', $event_author, $event_data['task']['id']); case Comment::EVENT_CREATE: return e('%s commented on the task #%d', $event_author, $event_data['task']['id']); - case File::EVENT_CREATE: + case TaskFile::EVENT_CREATE: return e('%s attached a file to the task #%d', $event_author, $event_data['task']['id']); + case Task::EVENT_USER_MENTION: + return e('%s mentioned you in the task #%d', $event_author, $event_data['task']['id']); + case Comment::EVENT_USER_MENTION: + return e('%s mentioned you in a comment on the task #%d', $event_author, $event_data['task']['id']); default: return e('Notification'); } @@ -90,53 +94,41 @@ class Notification extends Base public function getTitleWithoutAuthor($event_name, array $event_data) { switch ($event_name) { - case File::EVENT_CREATE: - $title = e('New attachment on task #%d: %s', $event_data['file']['task_id'], $event_data['file']['name']); - break; + case TaskFile::EVENT_CREATE: + return e('New attachment on task #%d: %s', $event_data['file']['task_id'], $event_data['file']['name']); case Comment::EVENT_CREATE: - $title = e('New comment on task #%d', $event_data['comment']['task_id']); - break; + return e('New comment on task #%d', $event_data['comment']['task_id']); case Comment::EVENT_UPDATE: - $title = e('Comment updated on task #%d', $event_data['comment']['task_id']); - break; + return e('Comment updated on task #%d', $event_data['comment']['task_id']); case Subtask::EVENT_CREATE: - $title = e('New subtask on task #%d', $event_data['subtask']['task_id']); - break; + return e('New subtask on task #%d', $event_data['subtask']['task_id']); case Subtask::EVENT_UPDATE: - $title = e('Subtask updated on task #%d', $event_data['subtask']['task_id']); - break; + return e('Subtask updated on task #%d', $event_data['subtask']['task_id']); case Task::EVENT_CREATE: - $title = e('New task #%d: %s', $event_data['task']['id'], $event_data['task']['title']); - break; + return e('New task #%d: %s', $event_data['task']['id'], $event_data['task']['title']); case Task::EVENT_UPDATE: - $title = e('Task updated #%d', $event_data['task']['id']); - break; + return e('Task updated #%d', $event_data['task']['id']); case Task::EVENT_CLOSE: - $title = e('Task #%d closed', $event_data['task']['id']); - break; + return e('Task #%d closed', $event_data['task']['id']); case Task::EVENT_OPEN: - $title = e('Task #%d opened', $event_data['task']['id']); - break; + return e('Task #%d opened', $event_data['task']['id']); case Task::EVENT_MOVE_COLUMN: - $title = e('Column changed for task #%d', $event_data['task']['id']); - break; + return e('Column changed for task #%d', $event_data['task']['id']); case Task::EVENT_MOVE_POSITION: - $title = e('New position for task #%d', $event_data['task']['id']); - break; + return e('New position for task #%d', $event_data['task']['id']); case Task::EVENT_MOVE_SWIMLANE: - $title = e('Swimlane changed for task #%d', $event_data['task']['id']); - break; + return e('Swimlane changed for task #%d', $event_data['task']['id']); case Task::EVENT_ASSIGNEE_CHANGE: - $title = e('Assignee changed on task #%d', $event_data['task']['id']); - break; + return e('Assignee changed on task #%d', $event_data['task']['id']); case Task::EVENT_OVERDUE: $nb = count($event_data['tasks']); - $title = $nb > 1 ? e('%d overdue tasks', $nb) : e('Task #%d is overdue', $event_data['tasks'][0]['id']); - break; + return $nb > 1 ? e('%d overdue tasks', $nb) : e('Task #%d is overdue', $event_data['tasks'][0]['id']); + case Task::EVENT_USER_MENTION: + return e('You were mentioned in the task #%d', $event_data['task']['id']); + case Comment::EVENT_USER_MENTION: + return e('You were mentioned in a comment on the task #%d', $event_data['task']['id']); default: - $title = e('Notification'); + return e('Notification'); } - - return $title; } } diff --git a/app/Model/PasswordReset.php b/app/Model/PasswordReset.php new file mode 100644 index 00000000..c2d7dde9 --- /dev/null +++ b/app/Model/PasswordReset.php @@ -0,0 +1,93 @@ +<?php + +namespace Kanboard\Model; + +/** + * Password Reset Model + * + * @package model + * @author Frederic Guillot + */ +class PasswordReset extends Base +{ + /** + * SQL table name + * + * @var string + */ + const TABLE = 'password_reset'; + + /** + * Token duration (30 minutes) + * + * @var string + */ + const DURATION = 1800; + + /** + * Get all tokens + * + * @access public + * @param integer $user_id + * @return array + */ + public function getAll($user_id) + { + return $this->db->table(self::TABLE)->eq('user_id', $user_id)->desc('date_creation')->limit(100)->findAll(); + } + + /** + * Generate a new reset token for a user + * + * @access public + * @param string $username + * @param integer $expiration + * @return boolean|string + */ + public function create($username, $expiration = 0) + { + $user_id = $this->db->table(User::TABLE)->eq('username', $username)->neq('email', '')->notNull('email')->findOneColumn('id'); + + if (! $user_id) { + return false; + } + + $token = $this->token->getToken(); + + $result = $this->db->table(self::TABLE)->insert(array( + 'token' => $token, + 'user_id' => $user_id, + 'date_expiration' => $expiration ?: time() + self::DURATION, + 'date_creation' => time(), + 'ip' => $this->request->getIpAddress(), + 'user_agent' => $this->request->getUserAgent(), + 'is_active' => 1, + )); + + return $result ? $token : false; + } + + /** + * Get user id from the token + * + * @access public + * @param string $token + * @return integer + */ + public function getUserIdByToken($token) + { + return $this->db->table(self::TABLE)->eq('token', $token)->eq('is_active', 1)->gte('date_expiration', time())->findOneColumn('user_id'); + } + + /** + * Disable all tokens for a user + * + * @access public + * @param integer $user_id + * @return boolean + */ + public function disable($user_id) + { + return $this->db->table(self::TABLE)->eq('user_id', $user_id)->update(array('is_active' => 0)); + } +} diff --git a/app/Model/Project.php b/app/Model/Project.php index b767af26..a79e46a1 100644 --- a/app/Model/Project.php +++ b/app/Model/Project.php @@ -2,9 +2,8 @@ namespace Kanboard\Model; -use SimpleValidator\Validator; -use SimpleValidator\Validators; -use Kanboard\Core\Security; +use Kanboard\Core\Security\Token; +use Kanboard\Core\Security\Role; /** * Project model @@ -48,6 +47,22 @@ class Project extends Base } /** + * Get a project by id with owner name + * + * @access public + * @param integer $project_id Project id + * @return array + */ + public function getByIdWithOwner($project_id) + { + return $this->db->table(self::TABLE) + ->columns(self::TABLE.'.*', User::TABLE.'.username AS owner_username', User::TABLE.'.name AS owner_name') + ->eq(self::TABLE.'.id', $project_id) + ->join(User::TABLE, 'id', 'owner_id') + ->findOne(); + } + + /** * Get a project by the name * * @access public @@ -226,7 +241,7 @@ class Project extends Base { $stats = array(); $stats['nb_active_tasks'] = 0; - $columns = $this->board->getColumns($project_id); + $columns = $this->column->getAll($project_id); $column_stats = $this->board->getColumnStats($project_id); foreach ($columns as &$column) { @@ -250,7 +265,7 @@ class Project extends Base */ public function getColumnStats(array &$project) { - $project['columns'] = $this->board->getColumns($project['id']); + $project['columns'] = $this->column->getAll($project['id']); $stats = $this->board->getColumnStats($project['id']); foreach ($project['columns'] as &$column) { @@ -277,23 +292,6 @@ class Project extends Base } /** - * Fetch more information for each project - * - * @access public - * @param array $projects - * @return array - */ - public function applyProjectDetails(array $projects) - { - foreach ($projects as &$project) { - $this->getColumnStats($project); - $project = array_merge($project, $this->projectPermission->getProjectUsers($project['id'])); - } - - return $projects; - } - - /** * Get project summary for a list of project * * @access public @@ -308,30 +306,13 @@ class Project extends Base return $this->db ->table(Project::TABLE) - ->in('id', $project_ids) + ->columns(self::TABLE.'.*', User::TABLE.'.username AS owner_username', User::TABLE.'.name AS owner_name') + ->join(User::TABLE, 'id', 'owner_id') + ->in(self::TABLE.'.id', $project_ids) ->callback(array($this, 'applyColumnStats')); } /** - * Get project details (users + columns) for a list of project - * - * @access public - * @param array $project_ids List of project id - * @return \PicoDb\Table - */ - public function getQueryProjectDetails(array $project_ids) - { - if (empty($project_ids)) { - return $this->db->table(Project::TABLE)->limit(0); - } - - return $this->db - ->table(Project::TABLE) - ->in('id', $project_ids) - ->callback(array($this, 'applyProjectDetails')); - } - - /** * Create a project * * @access public @@ -347,11 +328,14 @@ class Project extends Base $values['token'] = ''; $values['last_modified'] = time(); $values['is_private'] = empty($values['is_private']) ? 0 : 1; + $values['owner_id'] = $user_id; if (! empty($values['identifier'])) { $values['identifier'] = strtoupper($values['identifier']); } + $this->convertIntegerFields($values, array('priority_default', 'priority_start', 'priority_end')); + if (! $this->db->table(self::TABLE)->save($values)) { $this->db->cancelTransaction(); return false; @@ -365,7 +349,7 @@ class Project extends Base } if ($add_user && $user_id) { - $this->projectPermission->addManager($project_id, $user_id); + $this->projectUserRole->addUser($project_id, $user_id, Role::PROJECT_MANAGER); } $this->category->createDefaultCategories($project_id); @@ -418,6 +402,8 @@ class Project extends Base $values['identifier'] = strtoupper($values['identifier']); } + $this->convertIntegerFields($values, array('priority_default', 'priority_start', 'priority_end')); + return $this->exists($values['id']) && $this->db->table(self::TABLE)->eq('id', $values['id'])->save($values); } @@ -491,7 +477,7 @@ class Project extends Base $this->db ->table(self::TABLE) ->eq('id', $project_id) - ->save(array('is_public' => 1, 'token' => Security::generateToken())); + ->save(array('is_public' => 1, 'token' => Token::getToken())); } /** @@ -509,72 +495,4 @@ class Project extends Base ->eq('id', $project_id) ->save(array('is_public' => 0, 'token' => '')); } - - /** - * Common validation rules - * - * @access private - * @return array - */ - private function commonValidationRules() - { - return array( - new Validators\Integer('id', t('This value must be an integer')), - new Validators\Integer('is_active', t('This value must be an integer')), - new Validators\Required('name', t('The project name is required')), - new Validators\MaxLength('name', t('The maximum length is %d characters', 50), 50), - new Validators\MaxLength('identifier', t('The maximum length is %d characters', 50), 50), - new Validators\MaxLength('start_date', t('The maximum length is %d characters', 10), 10), - new Validators\MaxLength('end_date', t('The maximum length is %d characters', 10), 10), - new Validators\AlphaNumeric('identifier', t('This value must be alphanumeric')) , - new Validators\Unique('name', t('This project must be unique'), $this->db->getConnection(), self::TABLE), - new Validators\Unique('identifier', t('The identifier must be unique'), $this->db->getConnection(), self::TABLE), - ); - } - - /** - * Validate project creation - * - * @access public - * @param array $values Form values - * @return array $valid, $errors [0] = Success or not, [1] = List of errors - */ - public function validateCreation(array $values) - { - if (! empty($values['identifier'])) { - $values['identifier'] = strtoupper($values['identifier']); - } - - $v = new Validator($values, $this->commonValidationRules()); - - return array( - $v->execute(), - $v->getErrors() - ); - } - - /** - * Validate project modification - * - * @access public - * @param array $values Form values - * @return array $valid, $errors [0] = Success or not, [1] = List of errors - */ - public function validateModification(array $values) - { - if (! empty($values['identifier'])) { - $values['identifier'] = strtoupper($values['identifier']); - } - - $rules = array( - new Validators\Required('id', t('This value is required')), - ); - - $v = new Validator($values, array_merge($rules, $this->commonValidationRules())); - - return array( - $v->execute(), - $v->getErrors() - ); - } } diff --git a/app/Model/ProjectActivity.php b/app/Model/ProjectActivity.php index 309bab9a..74df26a1 100644 --- a/app/Model/ProjectActivity.php +++ b/app/Model/ProjectActivity.php @@ -168,15 +168,11 @@ class ProjectActivity extends Base */ public function cleanup($max) { - if ($this->db->table(self::TABLE)->count() > $max) { - $this->db->execute(' - DELETE FROM '.self::TABLE.' - WHERE id <= ( - SELECT id FROM ( - SELECT id FROM '.self::TABLE.' ORDER BY id DESC LIMIT 1 OFFSET '.$max.' - ) foo - )' - ); + $total = $this->db->table(self::TABLE)->count(); + + if ($total > $max) { + $ids = $this->db->table(self::TABLE)->asc('id')->limit($total - $max)->findAllByColumn('id'); + $this->db->table(self::TABLE)->in('id', $ids)->remove(); } } diff --git a/app/Model/ProjectAnalytic.php b/app/Model/ProjectAnalytic.php deleted file mode 100644 index 92364c0c..00000000 --- a/app/Model/ProjectAnalytic.php +++ /dev/null @@ -1,182 +0,0 @@ -<?php - -namespace Kanboard\Model; - -/** - * Project analytic model - * - * @package model - * @author Frederic Guillot - */ -class ProjectAnalytic extends Base -{ - /** - * Get tasks repartition - * - * @access public - * @param integer $project_id Project id - * @return array - */ - public function getTaskRepartition($project_id) - { - $metrics = array(); - $total = 0; - $columns = $this->board->getColumns($project_id); - - foreach ($columns as $column) { - $nb_tasks = $this->taskFinder->countByColumnId($project_id, $column['id']); - $total += $nb_tasks; - - $metrics[] = array( - 'column_title' => $column['title'], - 'nb_tasks' => $nb_tasks, - ); - } - - if ($total === 0) { - return array(); - } - - foreach ($metrics as &$metric) { - $metric['percentage'] = round(($metric['nb_tasks'] * 100) / $total, 2); - } - - return $metrics; - } - - /** - * Get users repartition - * - * @access public - * @param integer $project_id - * @return array - */ - public function getUserRepartition($project_id) - { - $metrics = array(); - $total = 0; - $tasks = $this->taskFinder->getAll($project_id); - $users = $this->projectPermission->getMemberList($project_id); - - foreach ($tasks as $task) { - $user = isset($users[$task['owner_id']]) ? $users[$task['owner_id']] : $users[0]; - $total++; - - if (! isset($metrics[$user])) { - $metrics[$user] = array( - 'nb_tasks' => 0, - 'percentage' => 0, - 'user' => $user, - ); - } - - $metrics[$user]['nb_tasks']++; - } - - if ($total === 0) { - return array(); - } - - foreach ($metrics as &$metric) { - $metric['percentage'] = round(($metric['nb_tasks'] * 100) / $total, 2); - } - - ksort($metrics); - - 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) { - if (isset($stats[$column_id])) { - $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/ProjectDailyColumnStats.php b/app/Model/ProjectDailyColumnStats.php index 8ed6137f..2bcc4d55 100644 --- a/app/Model/ProjectDailyColumnStats.php +++ b/app/Model/ProjectDailyColumnStats.php @@ -2,8 +2,6 @@ namespace Kanboard\Model; -use PicoDb\Database; - /** * Project Daily Column Stats * @@ -20,7 +18,7 @@ class ProjectDailyColumnStats extends Base const TABLE = 'project_daily_column_stats'; /** - * Update daily totals for the project and foreach column + * Update daily totals for the project and for each column * * "total" is the number open of tasks in the column * "score" is the sum of tasks score in the column @@ -32,42 +30,22 @@ class ProjectDailyColumnStats extends Base */ public function updateTotals($project_id, $date) { - $status = $this->config->get('cfd_include_closed_tasks') == 1 ? array(Task::STATUS_OPEN, Task::STATUS_CLOSED) : array(Task::STATUS_OPEN); - - return $this->db->transaction(function (Database $db) use ($project_id, $date, $status) { - - $column_ids = $db->table(Board::TABLE)->eq('project_id', $project_id)->findAllByColumn('id'); - - foreach ($column_ids as $column_id) { - - // This call will fail if the record already exists - // (cross database driver hack for INSERT..ON DUPLICATE KEY UPDATE) - $db->table(ProjectDailyColumnStats::TABLE)->insert(array( - 'day' => $date, - 'project_id' => $project_id, - 'column_id' => $column_id, - 'total' => 0, - 'score' => 0, - )); - - $db->table(ProjectDailyColumnStats::TABLE) - ->eq('project_id', $project_id) - ->eq('column_id', $column_id) - ->eq('day', $date) - ->update(array( - 'score' => $db->table(Task::TABLE) - ->eq('project_id', $project_id) - ->eq('column_id', $column_id) - ->eq('is_active', Task::STATUS_OPEN) - ->sum('score'), - 'total' => $db->table(Task::TABLE) - ->eq('project_id', $project_id) - ->eq('column_id', $column_id) - ->in('is_active', $status) - ->count() - )); - } - }); + $this->db->startTransaction(); + $this->db->table(self::TABLE)->eq('project_id', $project_id)->eq('day', $date)->remove(); + + foreach ($this->getStatsByColumns($project_id) as $column_id => $column) { + $this->db->table(self::TABLE)->insert(array( + 'day' => $date, + 'project_id' => $project_id, + 'column_id' => $column_id, + 'total' => $column['total'], + 'score' => $column['score'], + )); + } + + $this->db->closeTransaction(); + + return true; } /** @@ -81,43 +59,38 @@ class ProjectDailyColumnStats extends Base */ public function countDays($project_id, $from, $to) { - $rq = $this->db->execute( - 'SELECT COUNT(DISTINCT day) FROM '.self::TABLE.' WHERE day >= ? AND day <= ? AND project_id=?', - array($from, $to, $project_id) - ); - - return $rq !== false ? $rq->fetchColumn(0) : 0; + return $this->db->table(self::TABLE) + ->eq('project_id', $project_id) + ->gte('day', $from) + ->lte('day', $to) + ->findOneColumn('COUNT(DISTINCT day)'); } /** - * Get raw metrics for the project within a data range + * Get aggregated metrics for the project within a data range + * + * [ + * ['Date', 'Column1', 'Column2'], + * ['2014-11-16', 2, 5], + * ['2014-11-17', 20, 15], + * ] * * @access public * @param integer $project_id Project id * @param string $from Start date (ISO format YYYY-MM-DD) * @param string $to End date + * @param string $field Column to aggregate * @return array */ - public function getRawMetrics($project_id, $from, $to) + public function getAggregatedMetrics($project_id, $from, $to, $field = 'total') { - return $this->db->table(ProjectDailyColumnStats::TABLE) - ->columns( - 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(ProjectDailyColumnStats::TABLE.'.project_id', $project_id) - ->gte('day', $from) - ->lte('day', $to) - ->asc(ProjectDailyColumnStats::TABLE.'.day') - ->findAll(); + $columns = $this->column->getList($project_id); + $metrics = $this->getMetrics($project_id, $from, $to); + return $this->buildAggregate($metrics, $columns, $field); } /** - * Get raw metrics for the project within a data range grouped by day + * Fetch metrics * * @access public * @param integer $project_id Project id @@ -125,72 +98,155 @@ class ProjectDailyColumnStats extends Base * @param string $to End date * @return array */ - public function getRawMetricsByDay($project_id, $from, $to) + public function getMetrics($project_id, $from, $to) { - return $this->db->table(ProjectDailyColumnStats::TABLE) - ->columns( - ProjectDailyColumnStats::TABLE.'.day', - 'SUM('.ProjectDailyColumnStats::TABLE.'.total) AS total', - 'SUM('.ProjectDailyColumnStats::TABLE.'.score) AS score' - ) - ->eq(ProjectDailyColumnStats::TABLE.'.project_id', $project_id) - ->gte('day', $from) - ->lte('day', $to) - ->asc(ProjectDailyColumnStats::TABLE.'.day') - ->groupBy(ProjectDailyColumnStats::TABLE.'.day') - ->findAll(); + return $this->db->table(self::TABLE) + ->eq('project_id', $project_id) + ->gte('day', $from) + ->lte('day', $to) + ->asc(self::TABLE.'.day') + ->findAll(); } /** - * Get aggregated metrics for the project within a data range - * - * [ - * ['Date', 'Column1', 'Column2'], - * ['2014-11-16', 2, 5], - * ['2014-11-17', 20, 15], - * ] + * Build aggregate * - * @access public - * @param integer $project_id Project id - * @param string $from Start date (ISO format YYYY-MM-DD) - * @param string $to End date - * @param string $column Column to aggregate + * @access private + * @param array $metrics + * @param array $columns + * @param string $field * @return array */ - public function getAggregatedMetrics($project_id, $from, $to, $column = 'total') + private function buildAggregate(array &$metrics, array &$columns, $field) { - $columns = $this->board->getColumnsList($project_id); $column_ids = array_keys($columns); - $metrics = array(array_merge(array(e('Date')), array_values($columns))); - $aggregates = array(); - - // Fetch metrics for the project - $records = $this->db->table(ProjectDailyColumnStats::TABLE) - ->eq('project_id', $project_id) - ->gte('day', $from) - ->lte('day', $to) - ->findAll(); - - // Aggregate by day - foreach ($records as $record) { - if (! isset($aggregates[$record['day']])) { - $aggregates[$record['day']] = array($record['day']); - } + $days = array_unique(array_column($metrics, 'day')); + $rows = array(array_merge(array(e('Date')), array_values($columns))); - $aggregates[$record['day']][$record['column_id']] = $record[$column]; + foreach ($days as $day) { + $rows[] = $this->buildRowAggregate($metrics, $column_ids, $day, $field); } - // Aggregate by row - foreach ($aggregates as $aggregate) { - $row = array($aggregate[0]); + return $rows; + } - foreach ($column_ids as $column_id) { - $row[] = (int) $aggregate[$column_id]; + /** + * Build one row of the aggregate + * + * @access private + * @param array $metrics + * @param array $column_ids + * @param string $day + * @param string $field + * @return array + */ + private function buildRowAggregate(array &$metrics, array &$column_ids, $day, $field) + { + $row = array($day); + + foreach ($column_ids as $column_id) { + $row[] = $this->findValueInMetrics($metrics, $day, $column_id, $field); + } + + return $row; + } + + /** + * Find the value in the metrics + * + * @access private + * @param array $metrics + * @param string $day + * @param string $column_id + * @param string $field + * @return integer + */ + private function findValueInMetrics(array &$metrics, $day, $column_id, $field) + { + foreach ($metrics as $metric) { + if ($metric['day'] === $day && $metric['column_id'] == $column_id) { + return $metric[$field]; } + } + + return 0; + } - $metrics[] = $row; + /** + * Get number of tasks and score by columns + * + * @access private + * @param integer $project_id + * @return array + */ + private function getStatsByColumns($project_id) + { + $totals = $this->getTotalByColumns($project_id); + $scores = $this->getScoreByColumns($project_id); + $columns = array(); + + foreach ($totals as $column_id => $total) { + $columns[$column_id] = array('total' => $total, 'score' => 0); + } + + foreach ($scores as $column_id => $score) { + $columns[$column_id]['score'] = (int) $score; + } + + return $columns; + } + + /** + * Get number of tasks and score by columns + * + * @access private + * @param integer $project_id + * @return array + */ + private function getScoreByColumns($project_id) + { + $stats = $this->db->table(Task::TABLE) + ->columns('column_id', 'SUM(score) AS score') + ->eq('project_id', $project_id) + ->eq('is_active', Task::STATUS_OPEN) + ->notNull('score') + ->groupBy('column_id') + ->findAll(); + + return array_column($stats, 'score', 'column_id'); + } + + /** + * Get number of tasks and score by columns + * + * @access private + * @param integer $project_id + * @return array + */ + private function getTotalByColumns($project_id) + { + $stats = $this->db->table(Task::TABLE) + ->columns('column_id', 'COUNT(*) AS total') + ->eq('project_id', $project_id) + ->in('is_active', $this->getTaskStatusConfig()) + ->groupBy('column_id') + ->findAll(); + + return array_column($stats, 'total', 'column_id'); + } + + /** + * Get task status to use for total calculation + * + * @access private + * @return array + */ + private function getTaskStatusConfig() + { + if ($this->config->get('cfd_include_closed_tasks') == 1) { + return array(Task::STATUS_OPEN, Task::STATUS_CLOSED); } - return $metrics; + return array(Task::STATUS_OPEN); } } diff --git a/app/Model/ProjectDailyStats.php b/app/Model/ProjectDailyStats.php index 46ca0a4b..957ad51d 100644 --- a/app/Model/ProjectDailyStats.php +++ b/app/Model/ProjectDailyStats.php @@ -2,8 +2,6 @@ namespace Kanboard\Model; -use PicoDb\Database; - /** * Project Daily Stats * @@ -29,27 +27,22 @@ class ProjectDailyStats extends Base */ public function updateTotals($project_id, $date) { - $lead_cycle_time = $this->projectAnalytic->getAverageLeadAndCycleTime($project_id); + $this->db->startTransaction(); + + $lead_cycle_time = $this->averageLeadCycleTimeAnalytic->build($project_id); + + $this->db->table(self::TABLE)->eq('day', $date)->eq('project_id', $project_id)->remove(); - return $this->db->transaction(function (Database $db) use ($project_id, $date, $lead_cycle_time) { + $this->db->table(self::TABLE)->insert(array( + 'day' => $date, + 'project_id' => $project_id, + 'avg_lead_time' => $lead_cycle_time['avg_lead_time'], + 'avg_cycle_time' => $lead_cycle_time['avg_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, - )); + $this->db->closeTransaction(); - $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'], - )); - }); + return true; } /** @@ -64,11 +57,11 @@ class ProjectDailyStats extends Base 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(); + ->columns('day', 'avg_lead_time', 'avg_cycle_time') + ->eq('project_id', $project_id) + ->gte('day', $from) + ->lte('day', $to) + ->asc('day') + ->findAll(); } } diff --git a/app/Model/ProjectDuplication.php b/app/Model/ProjectDuplication.php index f0c66834..9c5f80ad 100644 --- a/app/Model/ProjectDuplication.php +++ b/app/Model/ProjectDuplication.php @@ -2,6 +2,8 @@ namespace Kanboard\Model; +use Kanboard\Core\Security\Role; + /** * Project Duplication * @@ -12,6 +14,28 @@ namespace Kanboard\Model; class ProjectDuplication extends Base { /** + * Get list of optional models to duplicate + * + * @access public + * @return string[] + */ + public function getOptionalSelection() + { + return array('category', 'projectPermission', 'action', 'swimlane', 'task'); + } + + /** + * Get list of all possible models to duplicate + * + * @access public + * @return string[] + */ + public function getPossibleSelection() + { + return array('board', 'category', 'projectPermission', 'action', 'swimlane', 'task'); + } + + /** * Get a valid project name for the duplication * * @access public @@ -31,78 +55,106 @@ class ProjectDuplication extends Base } /** - * Create a project from another one - * - * @param integer $project_id Project Id - * @return integer Cloned Project Id - */ - public function copy($project_id) - { - $project = $this->project->getById($project_id); - - $values = array( - 'name' => $this->getClonedProjectName($project['name']), - 'is_active' => true, - 'last_modified' => 0, - 'token' => '', - 'is_public' => 0, - 'is_private' => empty($project['is_private']) ? 0 : 1, - ); - - if (! $this->db->table(Project::TABLE)->save($values)) { - return 0; - } - - return $this->db->getLastId(); - } - - /** * Clone a project with all settings * - * @param integer $project_id Project Id - * @param array $part_selection Selection of optional project parts to duplicate. Possible options: 'swimlane', 'action', 'category', 'task' - * @return integer Cloned Project Id + * @param integer $src_project_id Project Id + * @param array $selection Selection of optional project parts to duplicate + * @param integer $owner_id Owner of the project + * @param string $name Name of the project + * @param boolean $private Force the project to be private + * @return integer Cloned Project Id */ - public function duplicate($project_id, $part_selection = array('category', 'action')) + public function duplicate($src_project_id, $selection = array('projectPermission', 'category', 'action'), $owner_id = 0, $name = null, $private = null) { $this->db->startTransaction(); // Get the cloned project Id - $clone_project_id = $this->copy($project_id); + $dst_project_id = $this->copy($src_project_id, $owner_id, $name, $private); - if (! $clone_project_id) { + if ($dst_project_id === false) { $this->db->cancelTransaction(); return false; } // Clone Columns, Categories, Permissions and Actions - $optional_parts = array('swimlane', 'action', 'category'); - foreach (array('board', 'category', 'projectPermission', 'action', 'swimlane') as $model) { + foreach ($this->getPossibleSelection() as $model) { // Skip if optional part has not been selected - if (in_array($model, $optional_parts) && ! in_array($model, $part_selection)) { + if (in_array($model, $this->getOptionalSelection()) && ! in_array($model, $selection)) { continue; } - if (! $this->$model->duplicate($project_id, $clone_project_id)) { + // Skip permissions for private projects + if ($private && $model === 'projectPermission') { + continue; + } + + if (! $this->$model->duplicate($src_project_id, $dst_project_id)) { $this->db->cancelTransaction(); return false; } } + if (! $this->makeOwnerManager($dst_project_id, $owner_id)) { + $this->db->cancelTransaction(); + return false; + } + $this->db->closeTransaction(); - // Clone Tasks if in $part_selection - if (in_array('task', $part_selection)) { - $tasks = $this->taskFinder->getAll($project_id); + return (int) $dst_project_id; + } + + /** + * Create a project from another one + * + * @access private + * @param integer $src_project_id + * @param integer $owner_id + * @param string $name + * @param boolean $private + * @return integer + */ + private function copy($src_project_id, $owner_id = 0, $name = null, $private = null) + { + $project = $this->project->getById($src_project_id); + $is_private = empty($project['is_private']) ? 0 : 1; + + $values = array( + 'name' => $name ?: $this->getClonedProjectName($project['name']), + 'is_active' => 1, + 'last_modified' => time(), + 'token' => '', + 'is_public' => 0, + 'is_private' => $private ? 1 : $is_private, + 'owner_id' => $owner_id, + ); + + if (! $this->db->table(Project::TABLE)->save($values)) { + return false; + } + + return $this->db->getLastId(); + } + + /** + * Make sure that the creator of the duplicated project is alsp owner + * + * @access private + * @param integer $dst_project_id + * @param integer $owner_id + * @return boolean + */ + private function makeOwnerManager($dst_project_id, $owner_id) + { + if ($owner_id > 0) { + $this->projectUserRole->removeUser($dst_project_id, $owner_id); - foreach ($tasks as $task) { - if (! $this->taskDuplication->duplicateToProject($task['id'], $clone_project_id)) { - return false; - } + if (! $this->projectUserRole->addUser($dst_project_id, $owner_id, Role::PROJECT_MANAGER)) { + return false; } } - return (int) $clone_project_id; + return true; } } diff --git a/app/Model/ProjectFile.php b/app/Model/ProjectFile.php new file mode 100644 index 00000000..aa9bf15b --- /dev/null +++ b/app/Model/ProjectFile.php @@ -0,0 +1,40 @@ +<?php + +namespace Kanboard\Model; + +/** + * Project File Model + * + * @package model + * @author Frederic Guillot + */ +class ProjectFile extends File +{ + /** + * SQL table name + * + * @var string + */ + const TABLE = 'project_has_files'; + + /** + * SQL foreign key + * + * @var string + */ + const FOREIGN_KEY = 'project_id'; + + /** + * Path prefix + * + * @var string + */ + const PATH_PREFIX = 'projects'; + + /** + * Events + * + * @var string + */ + const EVENT_CREATE = 'project.file.create'; +} diff --git a/app/Model/ProjectGroupRole.php b/app/Model/ProjectGroupRole.php new file mode 100644 index 00000000..750ba7fb --- /dev/null +++ b/app/Model/ProjectGroupRole.php @@ -0,0 +1,190 @@ +<?php + +namespace Kanboard\Model; + +use Kanboard\Core\Security\Role; + +/** + * Project Group Role + * + * @package model + * @author Frederic Guillot + */ +class ProjectGroupRole extends Base +{ + /** + * SQL table name + * + * @var string + */ + const TABLE = 'project_has_groups'; + + /** + * Get the list of project visible by the given user according to groups + * + * @access public + * @param integer $user_id + * @param array $status + * @return array + */ + public function getProjectsByUser($user_id, $status = array(Project::ACTIVE, Project::INACTIVE)) + { + return $this->db + ->hashtable(Project::TABLE) + ->join(self::TABLE, 'project_id', 'id') + ->join(GroupMember::TABLE, 'group_id', 'group_id', self::TABLE) + ->eq(GroupMember::TABLE.'.user_id', $user_id) + ->in(Project::TABLE.'.is_active', $status) + ->getAll(Project::TABLE.'.id', Project::TABLE.'.name'); + } + + /** + * For a given project get the role of the specified user + * + * @access public + * @param integer $project_id + * @param integer $user_id + * @return string + */ + public function getUserRole($project_id, $user_id) + { + $roles = $this->db->table(self::TABLE) + ->join(GroupMember::TABLE, 'group_id', 'group_id', self::TABLE) + ->eq(GroupMember::TABLE.'.user_id', $user_id) + ->eq(self::TABLE.'.project_id', $project_id) + ->findAllByColumn('role'); + + return $this->projectAccessMap->getHighestRole($roles); + } + + /** + * Get all groups associated directly to the project + * + * @access public + * @param integer $project_id + * @return array + */ + public function getGroups($project_id) + { + return $this->db->table(self::TABLE) + ->columns(Group::TABLE.'.id', Group::TABLE.'.name', self::TABLE.'.role') + ->join(Group::TABLE, 'id', 'group_id') + ->eq('project_id', $project_id) + ->asc('name') + ->findAll(); + } + + /** + * From groups get all users associated to the project + * + * @access public + * @param integer $project_id + * @return array + */ + public function getUsers($project_id) + { + return $this->db->table(self::TABLE) + ->columns(User::TABLE.'.id', User::TABLE.'.username', User::TABLE.'.name', self::TABLE.'.role') + ->join(GroupMember::TABLE, 'group_id', 'group_id', self::TABLE) + ->join(User::TABLE, 'id', 'user_id', GroupMember::TABLE) + ->eq(self::TABLE.'.project_id', $project_id) + ->asc(User::TABLE.'.username') + ->findAll(); + } + + /** + * From groups get all users assignable to tasks + * + * @access public + * @param integer $project_id + * @return array + */ + public function getAssignableUsers($project_id) + { + return $this->db->table(User::TABLE) + ->columns(User::TABLE.'.id', User::TABLE.'.username', User::TABLE.'.name') + ->join(GroupMember::TABLE, 'user_id', 'id', User::TABLE) + ->join(self::TABLE, 'group_id', 'group_id', GroupMember::TABLE) + ->eq(self::TABLE.'.project_id', $project_id) + ->eq(User::TABLE.'.is_active', 1) + ->in(self::TABLE.'.role', array(Role::PROJECT_MANAGER, Role::PROJECT_MEMBER)) + ->asc(User::TABLE.'.username') + ->findAll(); + } + + /** + * Add a group to the project + * + * @access public + * @param integer $project_id + * @param integer $group_id + * @param string $role + * @return boolean + */ + public function addGroup($project_id, $group_id, $role) + { + return $this->db->table(self::TABLE)->insert(array( + 'group_id' => $group_id, + 'project_id' => $project_id, + 'role' => $role, + )); + } + + /** + * Remove a group from the project + * + * @access public + * @param integer $project_id + * @param integer $group_id + * @return boolean + */ + public function removeGroup($project_id, $group_id) + { + return $this->db->table(self::TABLE)->eq('group_id', $group_id)->eq('project_id', $project_id)->remove(); + } + + /** + * Change a group role for the project + * + * @access public + * @param integer $project_id + * @param integer $group_id + * @param string $role + * @return boolean + */ + public function changeGroupRole($project_id, $group_id, $role) + { + return $this->db->table(self::TABLE) + ->eq('group_id', $group_id) + ->eq('project_id', $project_id) + ->update(array( + 'role' => $role, + )); + } + + /** + * Copy group access from a project to another one + * + * @param integer $project_src_id Project Template + * @return integer $project_dst_id Project that receives the copy + * @return boolean + */ + public function duplicate($project_src_id, $project_dst_id) + { + $rows = $this->db->table(self::TABLE)->eq('project_id', $project_src_id)->findAll(); + + foreach ($rows as $row) { + $result = $this->db->table(self::TABLE)->save(array( + 'project_id' => $project_dst_id, + 'group_id' => $row['group_id'], + 'role' => $row['role'], + )); + + if (! $result) { + return false; + } + } + + return true; + } +} diff --git a/app/Model/ProjectGroupRoleFilter.php b/app/Model/ProjectGroupRoleFilter.php new file mode 100644 index 00000000..989d3073 --- /dev/null +++ b/app/Model/ProjectGroupRoleFilter.php @@ -0,0 +1,89 @@ +<?php + +namespace Kanboard\Model; + +/** + * Project Group Role Filter + * + * @package model + * @author Frederic Guillot + */ +class ProjectGroupRoleFilter extends Base +{ + /** + * Query + * + * @access protected + * @var \PicoDb\Table + */ + protected $query; + + /** + * Initialize filter + * + * @access public + * @return UserFilter + */ + public function create() + { + $this->query = $this->db->table(ProjectGroupRole::TABLE); + return $this; + } + + /** + * Get all results of the filter + * + * @access public + * @param string $column + * @return array + */ + public function findAll($column = '') + { + if ($column !== '') { + return $this->query->asc($column)->findAllByColumn($column); + } + + return $this->query->findAll(); + } + + /** + * Get the PicoDb query + * + * @access public + * @return \PicoDb\Table + */ + public function getQuery() + { + return $this->query; + } + + /** + * Filter by project id + * + * @access public + * @param integer $project_id + * @return ProjectUserRoleFilter + */ + public function filterByProjectId($project_id) + { + $this->query->eq(ProjectGroupRole::TABLE.'.project_id', $project_id); + return $this; + } + + /** + * Filter by username + * + * @access public + * @param string $input + * @return ProjectUserRoleFilter + */ + public function startWithUsername($input) + { + $this->query + ->join(GroupMember::TABLE, 'group_id', 'group_id', ProjectGroupRole::TABLE) + ->join(User::TABLE, 'id', 'user_id', GroupMember::TABLE) + ->ilike(User::TABLE.'.username', $input.'%'); + + return $this; + } +} diff --git a/app/Model/ProjectPermission.php b/app/Model/ProjectPermission.php index d9eef4db..db1573ae 100644 --- a/app/Model/ProjectPermission.php +++ b/app/Model/ProjectPermission.php @@ -2,11 +2,10 @@ namespace Kanboard\Model; -use SimpleValidator\Validator; -use SimpleValidator\Validators; +use Kanboard\Core\Security\Role; /** - * Project permission model + * Project Permission * * @package model * @author Frederic Guillot @@ -14,117 +13,14 @@ use SimpleValidator\Validators; class ProjectPermission extends Base { /** - * SQL table name for permissions - * - * @var string - */ - const TABLE = 'project_has_users'; - - /** - * Get a list of people that can be assigned for tasks - * - * @access public - * @param integer $project_id Project id - * @param bool $prepend_unassigned Prepend the 'Unassigned' value - * @param bool $prepend_everybody Prepend the 'Everbody' value - * @param bool $allow_single_user If there is only one user return only this user - * @return array - */ - public function getMemberList($project_id, $prepend_unassigned = true, $prepend_everybody = false, $allow_single_user = false) - { - $allowed_users = $this->getMembers($project_id); - - if ($allow_single_user && count($allowed_users) === 1) { - return $allowed_users; - } - - if ($prepend_unassigned) { - $allowed_users = array(t('Unassigned')) + $allowed_users; - } - - if ($prepend_everybody) { - $allowed_users = array(User::EVERYBODY_ID => t('Everybody')) + $allowed_users; - } - - return $allowed_users; - } - - /** - * Get a list of members and managers with a single SQL query - * - * @access public - * @param integer $project_id Project id - * @return array - */ - public function getProjectUsers($project_id) - { - $result = array( - 'managers' => array(), - 'members' => array(), - ); - - $users = $this->db - ->table(self::TABLE) - ->join(User::TABLE, 'id', 'user_id') - ->eq('project_id', $project_id) - ->asc('username') - ->columns(User::TABLE.'.id', User::TABLE.'.username', User::TABLE.'.name', self::TABLE.'.is_owner') - ->findAll(); - - foreach ($users as $user) { - $key = $user['is_owner'] == 1 ? 'managers' : 'members'; - $result[$key][$user['id']] = $user['name'] ?: $user['username']; - } - - return $result; - } - - /** - * Get a list of allowed people for a project - * - * @access public - * @param integer $project_id Project id - * @return array - */ - public function getMembers($project_id) - { - if ($this->isEverybodyAllowed($project_id)) { - return $this->user->getList(); - } - - return $this->getAssociatedUsers($project_id); - } - - /** - * Get a list of owners for a project - * - * @access public - * @param integer $project_id Project id - * @return array - */ - public function getManagers($project_id) - { - $users = $this->db - ->table(self::TABLE) - ->join(User::TABLE, 'id', 'user_id') - ->eq('project_id', $project_id) - ->eq('is_owner', 1) - ->asc('username') - ->columns(User::TABLE.'.id', User::TABLE.'.username', User::TABLE.'.name') - ->findAll(); - - return $this->user->prepareList($users); - } - - /** * Get query for project users overview * * @access public * @param array $project_ids - * @param integer $is_owner + * @param string $role * @return \PicoDb\Table */ - public function getQueryByRole(array $project_ids, $is_owner = 0) + public function getQueryByRole(array $project_ids, $role) { if (empty($project_ids)) { $project_ids = array(-1); @@ -132,10 +28,10 @@ class ProjectPermission extends Base return $this ->db - ->table(self::TABLE) + ->table(ProjectUserRole::TABLE) ->join(User::TABLE, 'id', 'user_id') ->join(Project::TABLE, 'id', 'project_id') - ->eq(self::TABLE.'.is_owner', $is_owner) + ->eq(ProjectUserRole::TABLE.'.role', $role) ->eq(Project::TABLE.'.is_private', 0) ->in(Project::TABLE.'.id', $project_ids) ->columns( @@ -148,169 +44,22 @@ class ProjectPermission extends Base } /** - * Get a list of people associated to the project + * Get all usernames (fetch users from groups) * * @access public - * @param integer $project_id Project id + * @param integer $project_id + * @param string $input * @return array */ - public function getAssociatedUsers($project_id) + public function findUsernames($project_id, $input) { - $users = $this->db - ->table(self::TABLE) - ->join(User::TABLE, 'id', 'user_id') - ->eq('project_id', $project_id) - ->asc('username') - ->columns(User::TABLE.'.id', User::TABLE.'.username', User::TABLE.'.name') - ->findAll(); + $userMembers = $this->projectUserRoleFilter->create()->filterByProjectId($project_id)->startWithUsername($input)->findAll('username'); + $groupMembers = $this->projectGroupRoleFilter->create()->filterByProjectId($project_id)->startWithUsername($input)->findAll('username'); + $members = array_unique(array_merge($userMembers, $groupMembers)); - return $this->user->prepareList($users); - } + sort($members); - /** - * Get allowed and not allowed users for a project - * - * @access public - * @param integer $project_id Project id - * @return array - */ - public function getAllUsers($project_id) - { - $users = array( - 'allowed' => array(), - 'not_allowed' => array(), - 'managers' => array(), - ); - - $all_users = $this->user->getList(); - - $users['allowed'] = $this->getMembers($project_id); - $users['managers'] = $this->getManagers($project_id); - - foreach ($all_users as $user_id => $username) { - if (! isset($users['allowed'][$user_id])) { - $users['not_allowed'][$user_id] = $username; - } - } - - return $users; - } - - /** - * Add a new project member - * - * @access public - * @param integer $project_id Project id - * @param integer $user_id User id - * @return bool - */ - public function addMember($project_id, $user_id) - { - return $this->db - ->table(self::TABLE) - ->save(array('project_id' => $project_id, 'user_id' => $user_id)); - } - - /** - * Remove a member - * - * @access public - * @param integer $project_id Project id - * @param integer $user_id User id - * @return bool - */ - public function revokeMember($project_id, $user_id) - { - return $this->db - ->table(self::TABLE) - ->eq('project_id', $project_id) - ->eq('user_id', $user_id) - ->remove(); - } - - /** - * Add a project manager - * - * @access public - * @param integer $project_id Project id - * @param integer $user_id User id - * @return bool - */ - public function addManager($project_id, $user_id) - { - return $this->db - ->table(self::TABLE) - ->save(array('project_id' => $project_id, 'user_id' => $user_id, 'is_owner' => 1)); - } - - /** - * Change the role of a member - * - * @access public - * @param integer $project_id Project id - * @param integer $user_id User id - * @param integer $is_owner Is user owner of the project - * @return bool - */ - public function changeRole($project_id, $user_id, $is_owner) - { - return $this->db - ->table(self::TABLE) - ->eq('project_id', $project_id) - ->eq('user_id', $user_id) - ->update(array('is_owner' => (int) $is_owner)); - } - - /** - * Check if a specific user is member of a project - * - * @access public - * @param integer $project_id Project id - * @param integer $user_id User id - * @return bool - */ - public function isMember($project_id, $user_id) - { - if ($this->isEverybodyAllowed($project_id)) { - return true; - } - - return $this->db - ->table(self::TABLE) - ->eq('project_id', $project_id) - ->eq('user_id', $user_id) - ->exists(); - } - - /** - * Check if a specific user is manager of a given project - * - * @access public - * @param integer $project_id Project id - * @param integer $user_id User id - * @return bool - */ - public function isManager($project_id, $user_id) - { - return $this->db - ->table(self::TABLE) - ->eq('project_id', $project_id) - ->eq('user_id', $user_id) - ->eq('is_owner', 1) - ->exists(); - } - - /** - * Check if a specific user is allowed to access to a given project - * - * @access public - * @param integer $project_id Project id - * @param integer $user_id User id - * @return bool - */ - public function isUserAllowed($project_id, $user_id) - { - return $project_id === 0 || $this->user->isAdmin($user_id) || $this->isMember($project_id, $user_id); + return $members; } /** @@ -330,172 +79,73 @@ class ProjectPermission extends Base } /** - * Return a list of allowed active projects for a given user + * Return true if the user is allowed to access a project * - * @access public - * @param integer $user_id User id - * @return array + * @param integer $project_id + * @param integer $user_id + * @return boolean */ - public function getAllowedProjects($user_id) + public function isUserAllowed($project_id, $user_id) { - if ($this->user->isAdmin($user_id)) { - return $this->project->getListByStatus(Project::ACTIVE); + if ($this->userSession->isAdmin()) { + return true; } - return $this->getActiveMemberProjects($user_id); - } - - /** - * Return a list of projects where the user is member - * - * @access public - * @param integer $user_id User id - * @return array - */ - public function getMemberProjects($user_id) - { - return $this->db - ->hashtable(Project::TABLE) - ->beginOr() - ->eq(self::TABLE.'.user_id', $user_id) - ->eq(Project::TABLE.'.is_everybody_allowed', 1) - ->closeOr() - ->join(self::TABLE, 'project_id', 'id') - ->getAll('projects.id', 'name'); + return in_array( + $this->projectUserRole->getUserRole($project_id, $user_id), + array(Role::PROJECT_MANAGER, Role::PROJECT_MEMBER, Role::PROJECT_VIEWER) + ); } /** - * Return a list of project ids where the user is member + * Return true if the user is assignable * * @access public - * @param integer $user_id User id - * @return array + * @param integer $project_id + * @param integer $user_id + * @return boolean */ - public function getMemberProjectIds($user_id) + public function isAssignable($project_id, $user_id) { - return $this->db - ->table(Project::TABLE) - ->beginOr() - ->eq(self::TABLE.'.user_id', $user_id) - ->eq(Project::TABLE.'.is_everybody_allowed', 1) - ->closeOr() - ->join(self::TABLE, 'project_id', 'id') - ->findAllByColumn('projects.id'); + return $this->user->isActive($user_id) && + in_array($this->projectUserRole->getUserRole($project_id, $user_id), array(Role::PROJECT_MEMBER, Role::PROJECT_MANAGER)); } /** - * Return a list of active project ids where the user is member + * Return true if the user is member * * @access public - * @param integer $user_id User id - * @return array + * @param integer $project_id + * @param integer $user_id + * @return boolean */ - public function getActiveMemberProjectIds($user_id) + public function isMember($project_id, $user_id) { - return $this->db - ->table(Project::TABLE) - ->beginOr() - ->eq(self::TABLE.'.user_id', $user_id) - ->eq(Project::TABLE.'.is_everybody_allowed', 1) - ->closeOr() - ->eq(Project::TABLE.'.is_active', Project::ACTIVE) - ->join(self::TABLE, 'project_id', 'id') - ->findAllByColumn('projects.id'); + return in_array($this->projectUserRole->getUserRole($project_id, $user_id), array(Role::PROJECT_MEMBER, Role::PROJECT_MANAGER, Role::PROJECT_VIEWER)); } /** - * Return a list of active projects where the user is member + * Get active project ids by user * * @access public - * @param integer $user_id User id + * @param integer $user_id * @return array */ - public function getActiveMemberProjects($user_id) + public function getActiveProjectIds($user_id) { - return $this->db - ->hashtable(Project::TABLE) - ->beginOr() - ->eq(self::TABLE.'.user_id', $user_id) - ->eq(Project::TABLE.'.is_everybody_allowed', 1) - ->closeOr() - ->eq(Project::TABLE.'.is_active', Project::ACTIVE) - ->join(self::TABLE, 'project_id', 'id') - ->getAll('projects.id', 'name'); + return array_keys($this->projectUserRole->getActiveProjectsByUser($user_id)); } /** - * Copy user access from a project to another one + * Copy permissions to another project * - * @param integer $project_src Project Template - * @return integer $project_dst Project that receives the copy + * @param integer $project_src_id Project Template + * @param integer $project_dst_id Project that receives the copy * @return boolean */ - public function duplicate($project_src, $project_dst) - { - $rows = $this->db - ->table(self::TABLE) - ->columns('project_id', 'user_id', 'is_owner') - ->eq('project_id', $project_src) - ->findAll(); - - foreach ($rows as $row) { - $result = $this->db - ->table(self::TABLE) - ->save(array( - 'project_id' => $project_dst, - 'user_id' => $row['user_id'], - 'is_owner' => (int) $row['is_owner'], // (int) for postgres - )); - - if (! $result) { - return false; - } - } - - return true; - } - - /** - * Validate allow user - * - * @access public - * @param array $values Form values - * @return array $valid, $errors [0] = Success or not, [1] = List of errors - */ - public function validateUserModification(array $values) - { - $v = new Validator($values, array( - new Validators\Required('project_id', t('The project id is required')), - new Validators\Integer('project_id', t('This value must be an integer')), - new Validators\Required('user_id', t('The user id is required')), - new Validators\Integer('user_id', t('This value must be an integer')), - new Validators\Integer('is_owner', t('This value must be an integer')), - )); - - return array( - $v->execute(), - $v->getErrors() - ); - } - - /** - * Validate allow everybody - * - * @access public - * @param array $values Form values - * @return array $valid, $errors [0] = Success or not, [1] = List of errors - */ - public function validateProjectModification(array $values) + public function duplicate($project_src_id, $project_dst_id) { - $v = new Validator($values, array( - new Validators\Required('id', t('The project id is required')), - new Validators\Integer('id', t('This value must be an integer')), - new Validators\Integer('is_everybody_allowed', t('This value must be an integer')), - )); - - return array( - $v->execute(), - $v->getErrors() - ); + return $this->projectUserRole->duplicate($project_src_id, $project_dst_id) && + $this->projectGroupRole->duplicate($project_src_id, $project_dst_id); } } diff --git a/app/Model/ProjectUserRole.php b/app/Model/ProjectUserRole.php new file mode 100644 index 00000000..56da679c --- /dev/null +++ b/app/Model/ProjectUserRole.php @@ -0,0 +1,276 @@ +<?php + +namespace Kanboard\Model; + +use Kanboard\Core\Security\Role; + +/** + * Project User Role + * + * @package model + * @author Frederic Guillot + */ +class ProjectUserRole extends Base +{ + /** + * SQL table name + * + * @var string + */ + const TABLE = 'project_has_users'; + + /** + * Get the list of active project for the given user + * + * @access public + * @param integer $user_id + * @return array + */ + public function getActiveProjectsByUser($user_id) + { + return $this->getProjectsByUser($user_id, array(Project::ACTIVE)); + } + + /** + * Get the list of project visible for the given user + * + * @access public + * @param integer $user_id + * @param array $status + * @return array + */ + public function getProjectsByUser($user_id, $status = array(Project::ACTIVE, Project::INACTIVE)) + { + $userProjects = $this->db + ->hashtable(Project::TABLE) + ->beginOr() + ->eq(self::TABLE.'.user_id', $user_id) + ->eq(Project::TABLE.'.is_everybody_allowed', 1) + ->closeOr() + ->in(Project::TABLE.'.is_active', $status) + ->join(self::TABLE, 'project_id', 'id') + ->getAll(Project::TABLE.'.id', Project::TABLE.'.name'); + + $groupProjects = $this->projectGroupRole->getProjectsByUser($user_id, $status); + $projects = $userProjects + $groupProjects; + + asort($projects); + + return $projects; + } + + /** + * For a given project get the role of the specified user + * + * @access public + * @param integer $project_id + * @param integer $user_id + * @return string + */ + public function getUserRole($project_id, $user_id) + { + if ($this->projectPermission->isEverybodyAllowed($project_id)) { + return Role::PROJECT_MEMBER; + } + + $role = $this->db->table(self::TABLE)->eq('user_id', $user_id)->eq('project_id', $project_id)->findOneColumn('role'); + + if (empty($role)) { + $role = $this->projectGroupRole->getUserRole($project_id, $user_id); + } + + return $role; + } + + /** + * Get all users associated directly to the project + * + * @access public + * @param integer $project_id + * @return array + */ + public function getUsers($project_id) + { + return $this->db->table(self::TABLE) + ->columns(User::TABLE.'.id', User::TABLE.'.username', User::TABLE.'.name', self::TABLE.'.role') + ->join(User::TABLE, 'id', 'user_id') + ->eq('project_id', $project_id) + ->asc(User::TABLE.'.username') + ->asc(User::TABLE.'.name') + ->findAll(); + } + + /** + * Get all users (fetch users from groups) + * + * @access public + * @param integer $project_id + * @return array + */ + public function getAllUsers($project_id) + { + $userMembers = $this->getUsers($project_id); + $groupMembers = $this->projectGroupRole->getUsers($project_id); + $members = array_merge($userMembers, $groupMembers); + + return $this->user->prepareList($members); + } + + /** + * Get users grouped by role + * + * @access public + * @param integer $project_id Project id + * @return array + */ + public function getAllUsersGroupedByRole($project_id) + { + $users = array(); + + $userMembers = $this->getUsers($project_id); + $groupMembers = $this->projectGroupRole->getUsers($project_id); + $members = array_merge($userMembers, $groupMembers); + + foreach ($members as $user) { + if (! isset($users[$user['role']])) { + $users[$user['role']] = array(); + } + + $users[$user['role']][$user['id']] = $user['name'] ?: $user['username']; + } + + return $users; + } + + /** + * Get list of users that can be assigned to a task (only Manager and Member) + * + * @access public + * @param integer $project_id + * @return array + */ + public function getAssignableUsers($project_id) + { + if ($this->projectPermission->isEverybodyAllowed($project_id)) { + return $this->user->getActiveUsersList(); + } + + $userMembers = $this->db->table(self::TABLE) + ->columns(User::TABLE.'.id', User::TABLE.'.username', User::TABLE.'.name') + ->join(User::TABLE, 'id', 'user_id') + ->eq(User::TABLE.'.is_active', 1) + ->eq(self::TABLE.'.project_id', $project_id) + ->in(self::TABLE.'.role', array(Role::PROJECT_MANAGER, Role::PROJECT_MEMBER)) + ->findAll(); + + $groupMembers = $this->projectGroupRole->getAssignableUsers($project_id); + $members = array_merge($userMembers, $groupMembers); + + return $this->user->prepareList($members); + } + + /** + * Get list of users that can be assigned to a task (only Manager and Member) + * + * @access public + * @param integer $project_id Project id + * @param bool $unassigned Prepend the 'Unassigned' value + * @param bool $everybody Prepend the 'Everbody' value + * @param bool $singleUser If there is only one user return only this user + * @return array + */ + public function getAssignableUsersList($project_id, $unassigned = true, $everybody = false, $singleUser = false) + { + $users = $this->getAssignableUsers($project_id); + + if ($singleUser && count($users) === 1) { + return $users; + } + + if ($unassigned) { + $users = array(t('Unassigned')) + $users; + } + + if ($everybody) { + $users = array(User::EVERYBODY_ID => t('Everybody')) + $users; + } + + return $users; + } + + /** + * Add a user to the project + * + * @access public + * @param integer $project_id + * @param integer $user_id + * @param string $role + * @return boolean + */ + public function addUser($project_id, $user_id, $role) + { + return $this->db->table(self::TABLE)->insert(array( + 'user_id' => $user_id, + 'project_id' => $project_id, + 'role' => $role, + )); + } + + /** + * Remove a user from the project + * + * @access public + * @param integer $project_id + * @param integer $user_id + * @return boolean + */ + public function removeUser($project_id, $user_id) + { + return $this->db->table(self::TABLE)->eq('user_id', $user_id)->eq('project_id', $project_id)->remove(); + } + + /** + * Change a user role for the project + * + * @access public + * @param integer $project_id + * @param integer $user_id + * @param string $role + * @return boolean + */ + public function changeUserRole($project_id, $user_id, $role) + { + return $this->db->table(self::TABLE) + ->eq('user_id', $user_id) + ->eq('project_id', $project_id) + ->update(array( + 'role' => $role, + )); + } + + /** + * Copy user access from a project to another one + * + * @param integer $project_src_id Project Template + * @return integer $project_dst_id Project that receives the copy + * @return boolean + */ + public function duplicate($project_src_id, $project_dst_id) + { + $rows = $this->db->table(self::TABLE)->eq('project_id', $project_src_id)->findAll(); + + foreach ($rows as $row) { + $result = $this->db->table(self::TABLE)->save(array( + 'project_id' => $project_dst_id, + 'user_id' => $row['user_id'], + 'role' => $row['role'], + )); + + if (! $result) { + return false; + } + } + + return true; + } +} diff --git a/app/Model/ProjectUserRoleFilter.php b/app/Model/ProjectUserRoleFilter.php new file mode 100644 index 00000000..64403643 --- /dev/null +++ b/app/Model/ProjectUserRoleFilter.php @@ -0,0 +1,88 @@ +<?php + +namespace Kanboard\Model; + +/** + * Project User Role Filter + * + * @package model + * @author Frederic Guillot + */ +class ProjectUserRoleFilter extends Base +{ + /** + * Query + * + * @access protected + * @var \PicoDb\Table + */ + protected $query; + + /** + * Initialize filter + * + * @access public + * @return UserFilter + */ + public function create() + { + $this->query = $this->db->table(ProjectUserRole::TABLE); + return $this; + } + + /** + * Get all results of the filter + * + * @access public + * @param string $column + * @return array + */ + public function findAll($column = '') + { + if ($column !== '') { + return $this->query->asc($column)->findAllByColumn($column); + } + + return $this->query->findAll(); + } + + /** + * Get the PicoDb query + * + * @access public + * @return \PicoDb\Table + */ + public function getQuery() + { + return $this->query; + } + + /** + * Filter by project id + * + * @access public + * @param integer $project_id + * @return ProjectUserRoleFilter + */ + public function filterByProjectId($project_id) + { + $this->query->eq(ProjectUserRole::TABLE.'.project_id', $project_id); + return $this; + } + + /** + * Filter by username + * + * @access public + * @param string $input + * @return ProjectUserRoleFilter + */ + public function startWithUsername($input) + { + $this->query + ->join(User::TABLE, 'id', 'user_id') + ->ilike(User::TABLE.'.username', $input.'%'); + + return $this; + } +} diff --git a/app/Model/RememberMeSession.php b/app/Model/RememberMeSession.php new file mode 100644 index 00000000..8989a6d7 --- /dev/null +++ b/app/Model/RememberMeSession.php @@ -0,0 +1,151 @@ +<?php + +namespace Kanboard\Model; + +use Kanboard\Core\Security\Token; + +/** + * Remember Me Model + * + * @package model + * @author Frederic Guillot + */ +class RememberMeSession extends Base +{ + /** + * SQL table name + * + * @var string + */ + const TABLE = 'remember_me'; + + /** + * Expiration (60 days) + * + * @var integer + */ + const EXPIRATION = 5184000; + + /** + * Get a remember me record + * + * @access public + * @param $token + * @param $sequence + * @return mixed + */ + public function find($token, $sequence) + { + return $this->db + ->table(self::TABLE) + ->eq('token', $token) + ->eq('sequence', $sequence) + ->gt('expiration', time()) + ->findOne(); + } + + /** + * Get all sessions for a given user + * + * @access public + * @param integer $user_id User id + * @return array + */ + public function getAll($user_id) + { + return $this->db + ->table(self::TABLE) + ->eq('user_id', $user_id) + ->desc('date_creation') + ->columns('id', 'ip', 'user_agent', 'date_creation', 'expiration') + ->findAll(); + } + + /** + * Create a new RememberMe session + * + * @access public + * @param integer $user_id User id + * @param string $ip IP Address + * @param string $user_agent User Agent + * @return array + */ + public function create($user_id, $ip, $user_agent) + { + $token = hash('sha256', $user_id.$user_agent.$ip.Token::getToken()); + $sequence = Token::getToken(); + $expiration = time() + self::EXPIRATION; + + $this->cleanup($user_id); + + $this + ->db + ->table(self::TABLE) + ->insert(array( + 'user_id' => $user_id, + 'ip' => $ip, + 'user_agent' => $user_agent, + 'token' => $token, + 'sequence' => $sequence, + 'expiration' => $expiration, + 'date_creation' => time(), + )); + + return array( + 'token' => $token, + 'sequence' => $sequence, + 'expiration' => $expiration, + ); + } + + /** + * Remove a session record + * + * @access public + * @param integer $session_id Session id + * @return mixed + */ + public function remove($session_id) + { + return $this->db + ->table(self::TABLE) + ->eq('id', $session_id) + ->remove(); + } + + /** + * Remove old sessions for a given user + * + * @access public + * @param integer $user_id User id + * @return bool + */ + public function cleanup($user_id) + { + return $this->db + ->table(self::TABLE) + ->eq('user_id', $user_id) + ->lt('expiration', time()) + ->remove(); + } + + /** + * Return a new sequence token and update the database + * + * @access public + * @param string $token Session token + * @return string + */ + public function updateSequence($token) + { + $sequence = Token::getToken(); + + $this + ->db + ->table(self::TABLE) + ->eq('token', $token) + ->update(array('sequence' => $sequence)); + + return $sequence; + } +} diff --git a/app/Model/Setting.php b/app/Model/Setting.php index 3507d424..44e6c065 100644 --- a/app/Model/Setting.php +++ b/app/Model/Setting.php @@ -47,10 +47,12 @@ abstract class Setting extends Base */ public function getOption($name, $default = '') { - return $this->db + $value = $this->db ->table(self::TABLE) ->eq('option', $name) - ->findOneColumn('value') ?: $default; + ->findOneColumn('value'); + + return $value === null || $value === false || $value === '' ? $default : $value; } /** diff --git a/app/Model/Subtask.php b/app/Model/Subtask.php index 664e41e1..b5898fcf 100644 --- a/app/Model/Subtask.php +++ b/app/Model/Subtask.php @@ -4,11 +4,9 @@ namespace Kanboard\Model; use PicoDb\Database; use Kanboard\Event\SubtaskEvent; -use SimpleValidator\Validator; -use SimpleValidator\Validators; /** - * Subtask model + * Subtask Model * * @package model * @author Frederic Guillot @@ -265,89 +263,36 @@ class Subtask extends Base } /** - * Get subtasks with consecutive positions - * - * If you remove a subtask, the positions are not anymore consecutives - * - * @access public - * @param integer $task_id - * @return array - */ - public function getNormalizedPositions($task_id) - { - $subtasks = $this->db->hashtable(self::TABLE)->eq('task_id', $task_id)->asc('position')->getAll('id', 'position'); - $position = 1; - - foreach ($subtasks as $subtask_id => $subtask_position) { - $subtasks[$subtask_id] = $position++; - } - - return $subtasks; - } - - /** - * Save the new positions for a set of subtasks - * - * @access public - * @param array $subtasks Hashmap of column_id/column_position - * @return boolean - */ - public function savePositions(array $subtasks) - { - return $this->db->transaction(function (Database $db) use ($subtasks) { - - foreach ($subtasks as $subtask_id => $position) { - if (! $db->table(Subtask::TABLE)->eq('id', $subtask_id)->update(array('position' => $position))) { - return false; - } - } - }); - } - - /** - * Move a subtask down, increment the position value + * Save subtask position * * @access public * @param integer $task_id * @param integer $subtask_id + * @param integer $position * @return boolean */ - public function moveDown($task_id, $subtask_id) + public function changePosition($task_id, $subtask_id, $position) { - $subtasks = $this->getNormalizedPositions($task_id); - $positions = array_flip($subtasks); - - if (isset($subtasks[$subtask_id]) && $subtasks[$subtask_id] < count($subtasks)) { - $position = ++$subtasks[$subtask_id]; - $subtasks[$positions[$position]]--; - - return $this->savePositions($subtasks); + if ($position < 1 || $position > $this->db->table(self::TABLE)->eq('task_id', $task_id)->count()) { + return false; } - return false; - } - - /** - * Move a subtask up, decrement the position value - * - * @access public - * @param integer $task_id - * @param integer $subtask_id - * @return boolean - */ - public function moveUp($task_id, $subtask_id) - { - $subtasks = $this->getNormalizedPositions($task_id); - $positions = array_flip($subtasks); + $subtask_ids = $this->db->table(self::TABLE)->eq('task_id', $task_id)->neq('id', $subtask_id)->asc('position')->findAllByColumn('id'); + $offset = 1; + $results = array(); - if (isset($subtasks[$subtask_id]) && $subtasks[$subtask_id] > 1) { - $position = --$subtasks[$subtask_id]; - $subtasks[$positions[$position]]++; + foreach ($subtask_ids as $current_subtask_id) { + if ($offset == $position) { + $offset++; + } - return $this->savePositions($subtasks); + $results[] = $this->db->table(self::TABLE)->eq('id', $current_subtask_id)->update(array('position' => $offset)); + $offset++; } - return false; + $results[] = $this->db->table(self::TABLE)->eq('id', $subtask_id)->update(array('position' => $position)); + + return !in_array(false, $results, true); } /** @@ -355,15 +300,16 @@ class Subtask extends Base * * @access public * @param integer $subtask_id - * @return bool + * @return boolean|integer */ public function toggleStatus($subtask_id) { $subtask = $this->getById($subtask_id); + $status = ($subtask['status'] + 1) % 3; $values = array( 'id' => $subtask['id'], - 'status' => ($subtask['status'] + 1) % 3, + 'status' => $status, 'task_id' => $subtask['task_id'], ); @@ -371,7 +317,7 @@ class Subtask extends Base $values['user_id'] = $this->userSession->getId(); } - return $this->update($values); + return $this->update($values) ? $status : false; } /** @@ -437,10 +383,10 @@ class Subtask extends Base return $this->db->transaction(function (Database $db) use ($src_task_id, $dst_task_id) { $subtasks = $db->table(Subtask::TABLE) - ->columns('title', 'time_estimated', 'position') - ->eq('task_id', $src_task_id) - ->asc('position') - ->findAll(); + ->columns('title', 'time_estimated', 'position') + ->eq('task_id', $src_task_id) + ->asc('position') + ->findAll(); foreach ($subtasks as &$subtask) { $subtask['task_id'] = $dst_task_id; @@ -451,90 +397,4 @@ class Subtask extends Base } }); } - - /** - * Validate creation - * - * @access public - * @param array $values Form values - * @return array $valid, $errors [0] = Success or not, [1] = List of errors - */ - public function validateCreation(array $values) - { - $rules = array( - new Validators\Required('task_id', t('The task id is required')), - new Validators\Required('title', t('The title is required')), - ); - - $v = new Validator($values, array_merge($rules, $this->commonValidationRules())); - - return array( - $v->execute(), - $v->getErrors() - ); - } - - /** - * Validate modification - * - * @access public - * @param array $values Form values - * @return array $valid, $errors [0] = Success or not, [1] = List of errors - */ - public function validateModification(array $values) - { - $rules = array( - new Validators\Required('id', t('The subtask id is required')), - new Validators\Required('task_id', t('The task id is required')), - new Validators\Required('title', t('The title is required')), - ); - - $v = new Validator($values, array_merge($rules, $this->commonValidationRules())); - - return array( - $v->execute(), - $v->getErrors() - ); - } - - /** - * Validate API modification - * - * @access public - * @param array $values Form values - * @return array $valid, $errors [0] = Success or not, [1] = List of errors - */ - public function validateApiModification(array $values) - { - $rules = array( - new Validators\Required('id', t('The subtask id is required')), - new Validators\Required('task_id', t('The task id is required')), - ); - - $v = new Validator($values, array_merge($rules, $this->commonValidationRules())); - - return array( - $v->execute(), - $v->getErrors() - ); - } - - /** - * Common validation rules - * - * @access private - * @return array - */ - private function commonValidationRules() - { - return array( - new Validators\Integer('id', t('The subtask id must be an integer')), - new Validators\Integer('task_id', t('The task id must be an integer')), - new Validators\MaxLength('title', t('The maximum length is %d characters', 255), 255), - new Validators\Integer('user_id', t('The user id must be an integer')), - new Validators\Integer('status', t('The status must be an integer')), - new Validators\Numeric('time_estimated', t('The time must be a numeric value')), - new Validators\Numeric('time_spent', t('The time must be a numeric value')), - ); - } } diff --git a/app/Model/SubtaskTimeTracking.php b/app/Model/SubtaskTimeTracking.php index a741dbb5..b766b542 100644 --- a/app/Model/SubtaskTimeTracking.php +++ b/app/Model/SubtaskTimeTracking.php @@ -302,11 +302,11 @@ class SubtaskTimeTracking extends Base { $hook = 'model:subtask-time-tracking:calculate:time-spent'; $start_time = $this->db - ->table(self::TABLE) - ->eq('subtask_id', $subtask_id) - ->eq('user_id', $user_id) - ->eq('end', 0) - ->findOneColumn('start'); + ->table(self::TABLE) + ->eq('subtask_id', $subtask_id) + ->eq('user_id', $user_id) + ->eq('end', 0) + ->findOneColumn('start'); if (empty($start_time)) { return 0; diff --git a/app/Model/Swimlane.php b/app/Model/Swimlane.php index df44985a..721f20d3 100644 --- a/app/Model/Swimlane.php +++ b/app/Model/Swimlane.php @@ -2,9 +2,6 @@ namespace Kanboard\Model; -use SimpleValidator\Validator; -use SimpleValidator\Validators; - /** * Swimlanes * @@ -99,10 +96,11 @@ class Swimlane extends Base */ public function getDefault($project_id) { - $result = $this->db->table(Project::TABLE) - ->eq('id', $project_id) - ->columns('id', 'default_swimlane', 'show_default_swimlane') - ->findOne(); + $result = $this->db + ->table(Project::TABLE) + ->eq('id', $project_id) + ->columns('id', 'default_swimlane', 'show_default_swimlane') + ->findOne(); if ($result['default_swimlane'] === 'Default swimlane') { $result['default_swimlane'] = t($result['default_swimlane']); @@ -120,10 +118,11 @@ class Swimlane extends Base */ public function getAll($project_id) { - return $this->db->table(self::TABLE) - ->eq('project_id', $project_id) - ->orderBy('position', 'asc') - ->findAll(); + return $this->db + ->table(self::TABLE) + ->eq('project_id', $project_id) + ->orderBy('position', 'asc') + ->findAll(); } /** @@ -136,9 +135,10 @@ class Swimlane extends Base */ public function getAllByStatus($project_id, $status = self::ACTIVE) { - $query = $this->db->table(self::TABLE) - ->eq('project_id', $project_id) - ->eq('is_active', $status); + $query = $this->db + ->table(self::TABLE) + ->eq('project_id', $project_id) + ->eq('is_active', $status); if ($status == self::ACTIVE) { $query->asc('position'); @@ -158,17 +158,19 @@ class Swimlane extends Base */ public function getSwimlanes($project_id) { - $swimlanes = $this->db->table(self::TABLE) - ->columns('id', 'name', 'description') - ->eq('project_id', $project_id) - ->eq('is_active', self::ACTIVE) - ->orderBy('position', 'asc') - ->findAll(); - - $default_swimlane = $this->db->table(Project::TABLE) - ->eq('id', $project_id) - ->eq('show_default_swimlane', 1) - ->findOneColumn('default_swimlane'); + $swimlanes = $this->db + ->table(self::TABLE) + ->columns('id', 'name', 'description') + ->eq('project_id', $project_id) + ->eq('is_active', self::ACTIVE) + ->orderBy('position', 'asc') + ->findAll(); + + $default_swimlane = $this->db + ->table(Project::TABLE) + ->eq('id', $project_id) + ->eq('show_default_swimlane', 1) + ->findOneColumn('default_swimlane'); if ($default_swimlane) { if ($default_swimlane === 'Default swimlane') { @@ -203,11 +205,12 @@ class Swimlane extends Base $swimlanes[0] = $default === 'Default swimlane' ? t($default) : $default; } - return $swimlanes + $this->db->hashtable(self::TABLE) - ->eq('project_id', $project_id) - ->in('is_active', $only_active ? array(self::ACTIVE) : array(self::ACTIVE, self::INACTIVE)) - ->orderBy('position', 'asc') - ->getAll('id', 'name'); + return $swimlanes + $this->db + ->hashtable(self::TABLE) + ->eq('project_id', $project_id) + ->in('is_active', $only_active ? array(self::ACTIVE) : array(self::ACTIVE, self::INACTIVE)) + ->orderBy('position', 'asc') + ->getAll('id', 'name'); } /** @@ -235,9 +238,10 @@ class Swimlane extends Base */ public function update(array $values) { - return $this->db->table(self::TABLE) - ->eq('id', $values['id']) - ->update($values); + return $this->db + ->table(self::TABLE) + ->eq('id', $values['id']) + ->update($values); } /** @@ -250,12 +254,46 @@ class Swimlane extends Base public function updateDefault(array $values) { return $this->db - ->table(Project::TABLE) - ->eq('id', $values['id']) - ->update(array( - 'default_swimlane' => $values['default_swimlane'], - 'show_default_swimlane' => $values['show_default_swimlane'], - )); + ->table(Project::TABLE) + ->eq('id', $values['id']) + ->update(array( + 'default_swimlane' => $values['default_swimlane'], + 'show_default_swimlane' => $values['show_default_swimlane'], + )); + } + + /** + * Enable the default swimlane + * + * @access public + * @param integer $project_id + * @return bool + */ + public function enableDefault($project_id) + { + return $this->db + ->table(Project::TABLE) + ->eq('id', $project_id) + ->update(array( + 'show_default_swimlane' => 1, + )); + } + + /** + * Disable the default swimlane + * + * @access public + * @param integer $project_id + * @return bool + */ + public function disableDefault($project_id) + { + return $this->db + ->table(Project::TABLE) + ->eq('id', $project_id) + ->update(array( + 'show_default_swimlane' => 0, + )); } /** @@ -267,10 +305,11 @@ class Swimlane extends Base */ public function getLastPosition($project_id) { - return $this->db->table(self::TABLE) - ->eq('project_id', $project_id) - ->eq('is_active', 1) - ->count() + 1; + return $this->db + ->table(self::TABLE) + ->eq('project_id', $project_id) + ->eq('is_active', 1) + ->count() + 1; } /** @@ -284,12 +323,12 @@ class Swimlane extends Base public function disable($project_id, $swimlane_id) { $result = $this->db - ->table(self::TABLE) - ->eq('id', $swimlane_id) - ->update(array( - 'is_active' => self::INACTIVE, - 'position' => 0, - )); + ->table(self::TABLE) + ->eq('id', $swimlane_id) + ->update(array( + 'is_active' => self::INACTIVE, + 'position' => 0, + )); if ($result) { // Re-order positions @@ -310,12 +349,12 @@ class Swimlane extends Base public function enable($project_id, $swimlane_id) { return $this->db - ->table(self::TABLE) - ->eq('id', $swimlane_id) - ->update(array( - 'is_active' => self::ACTIVE, - 'position' => $this->getLastPosition($project_id), - )); + ->table(self::TABLE) + ->eq('id', $swimlane_id) + ->update(array( + 'is_active' => self::ACTIVE, + 'position' => $this->getLastPosition($project_id), + )); } /** @@ -356,11 +395,13 @@ class Swimlane extends Base public function updatePositions($project_id) { $position = 0; - $swimlanes = $this->db->table(self::TABLE) - ->eq('project_id', $project_id) - ->eq('is_active', 1) - ->asc('position') - ->findAllByColumn('id'); + $swimlanes = $this->db + ->table(self::TABLE) + ->eq('project_id', $project_id) + ->eq('is_active', 1) + ->asc('position') + ->asc('id') + ->findAllByColumn('id'); if (! $swimlanes) { return false; @@ -368,77 +409,50 @@ class Swimlane extends Base foreach ($swimlanes as $swimlane_id) { $this->db->table(self::TABLE) - ->eq('id', $swimlane_id) - ->update(array('position' => ++$position)); + ->eq('id', $swimlane_id) + ->update(array('position' => ++$position)); } return true; } /** - * Move a swimlane down, increment the position value + * Change swimlane position * * @access public - * @param integer $project_id Project id - * @param integer $swimlane_id Swimlane id + * @param integer $project_id + * @param integer $swimlane_id + * @param integer $position * @return boolean */ - public function moveDown($project_id, $swimlane_id) + public function changePosition($project_id, $swimlane_id, $position) { - $swimlanes = $this->db->hashtable(self::TABLE) - ->eq('project_id', $project_id) - ->eq('is_active', self::ACTIVE) - ->asc('position') - ->getAll('id', 'position'); - - $positions = array_flip($swimlanes); - - if (isset($swimlanes[$swimlane_id]) && $swimlanes[$swimlane_id] < count($swimlanes)) { - $position = ++$swimlanes[$swimlane_id]; - $swimlanes[$positions[$position]]--; - - $this->db->startTransaction(); - $this->db->table(self::TABLE)->eq('id', $swimlane_id)->update(array('position' => $position)); - $this->db->table(self::TABLE)->eq('id', $positions[$position])->update(array('position' => $swimlanes[$positions[$position]])); - $this->db->closeTransaction(); - - return true; + if ($position < 1 || $position > $this->db->table(self::TABLE)->eq('project_id', $project_id)->count()) { + return false; } - return false; - } + $swimlane_ids = $this->db->table(self::TABLE) + ->eq('is_active', 1) + ->eq('project_id', $project_id) + ->neq('id', $swimlane_id) + ->asc('position') + ->findAllByColumn('id'); - /** - * Move a swimlane up, decrement the position value - * - * @access public - * @param integer $project_id Project id - * @param integer $swimlane_id Swimlane id - * @return boolean - */ - public function moveUp($project_id, $swimlane_id) - { - $swimlanes = $this->db->hashtable(self::TABLE) - ->eq('project_id', $project_id) - ->eq('is_active', self::ACTIVE) - ->asc('position') - ->getAll('id', 'position'); - - $positions = array_flip($swimlanes); + $offset = 1; + $results = array(); - if (isset($swimlanes[$swimlane_id]) && $swimlanes[$swimlane_id] > 1) { - $position = --$swimlanes[$swimlane_id]; - $swimlanes[$positions[$position]]++; - - $this->db->startTransaction(); - $this->db->table(self::TABLE)->eq('id', $swimlane_id)->update(array('position' => $position)); - $this->db->table(self::TABLE)->eq('id', $positions[$position])->update(array('position' => $swimlanes[$positions[$position]])); - $this->db->closeTransaction(); + foreach ($swimlane_ids as $current_swimlane_id) { + if ($offset == $position) { + $offset++; + } - return true; + $results[] = $this->db->table(self::TABLE)->eq('id', $current_swimlane_id)->update(array('position' => $offset)); + $offset++; } - return false; + $results[] = $this->db->table(self::TABLE)->eq('id', $swimlane_id)->update(array('position' => $position)); + + return !in_array(false, $results, true); } /** @@ -470,85 +484,4 @@ class Swimlane extends Base return true; } - - /** - * Validate creation - * - * @access public - * @param array $values Form values - * @return array $valid, $errors [0] = Success or not, [1] = List of errors - */ - public function validateCreation(array $values) - { - $rules = array( - new Validators\Required('project_id', t('The project id is required')), - new Validators\Required('name', t('The name is required')), - ); - - $v = new Validator($values, array_merge($rules, $this->commonValidationRules())); - - return array( - $v->execute(), - $v->getErrors() - ); - } - - /** - * Validate modification - * - * @access public - * @param array $values Form values - * @return array $valid, $errors [0] = Success or not, [1] = List of errors - */ - public function validateModification(array $values) - { - $rules = array( - new Validators\Required('id', t('The id is required')), - new Validators\Required('name', t('The name is required')), - ); - - $v = new Validator($values, array_merge($rules, $this->commonValidationRules())); - - return array( - $v->execute(), - $v->getErrors() - ); - } - - /** - * Validate default swimlane modification - * - * @access public - * @param array $values Form values - * @return array $valid, $errors [0] = Success or not, [1] = List of errors - */ - public function validateDefaultModification(array $values) - { - $rules = array( - new Validators\Required('id', t('The id is required')), - new Validators\Required('default_swimlane', t('The name is required')), - ); - - $v = new Validator($values, array_merge($rules, $this->commonValidationRules())); - - return array( - $v->execute(), - $v->getErrors() - ); - } - - /** - * Common validation rules - * - * @access private - * @return array - */ - private function commonValidationRules() - { - return array( - new Validators\Integer('id', t('The id must be an integer')), - new Validators\Integer('project_id', t('The project id must be an integer')), - new Validators\MaxLength('name', t('The maximum length is %d characters', 50), 50) - ); - } } diff --git a/app/Model/Task.php b/app/Model/Task.php index f1cd094f..f8b41b9f 100644 --- a/app/Model/Task.php +++ b/app/Model/Task.php @@ -41,6 +41,8 @@ class Task extends Base const EVENT_CREATE_UPDATE = 'task.create_update'; const EVENT_ASSIGNEE_CHANGE = 'task.assignee_change'; const EVENT_OVERDUE = 'task.overdue'; + const EVENT_USER_MENTION = 'task.user.mention'; + const EVENT_DAILY_CRONJOB = 'task.cronjob.daily'; /** * Recurrence: status @@ -90,7 +92,7 @@ class Task extends Base return false; } - $this->file->removeAll($task_id); + $this->taskFile->removeAll($task_id); return $this->db->table(self::TABLE)->eq('id', $task_id)->remove(); } @@ -197,4 +199,25 @@ class Task extends Base return round(($position * 100) / count($columns), 1); } + + /** + * Helper method to duplicate all tasks to another project + * + * @access public + * @param integer $src_project_id + * @param integer $dst_project_id + * @return boolean + */ + public function duplicate($src_project_id, $dst_project_id) + { + $task_ids = $this->taskFinder->getAllIds($src_project_id, array(Task::STATUS_OPEN, Task::STATUS_CLOSED)); + + foreach ($task_ids as $task_id) { + if (! $this->taskDuplication->duplicateToProject($task_id, $dst_project_id)) { + return false; + } + } + + return true; + } } diff --git a/app/Model/TaskAnalytic.php b/app/Model/TaskAnalytic.php index bdfec3cb..cff56744 100644 --- a/app/Model/TaskAnalytic.php +++ b/app/Model/TaskAnalytic.php @@ -48,7 +48,7 @@ class TaskAnalytic extends Base public function getTimeSpentByColumn(array $task) { $result = array(); - $columns = $this->board->getColumnsList($task['project_id']); + $columns = $this->column->getList($task['project_id']); $sums = $this->transition->getTimeSpentByTask($task['id']); foreach ($columns as $column_id => $column_title) { diff --git a/app/Model/TaskCreation.php b/app/Model/TaskCreation.php index 5ef1a04b..576eb18c 100644 --- a/app/Model/TaskCreation.php +++ b/app/Model/TaskCreation.php @@ -49,13 +49,14 @@ class TaskCreation extends Base */ public function prepare(array &$values) { - $this->dateParser->convert($values, array('date_due')); - $this->dateParser->convert($values, array('date_started'), true); + $values = $this->dateParser->convert($values, array('date_due')); + $values = $this->dateParser->convert($values, array('date_started'), true); + $this->removeFields($values, array('another_task')); $this->resetFields($values, array('date_started', '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']); + $values['column_id'] = $this->column->getFirstColumnId($values['project_id']); } if (empty($values['color_id'])) { @@ -86,8 +87,16 @@ class TaskCreation extends Base */ private function fireEvents($task_id, array $values) { - $values['task_id'] = $task_id; - $this->container['dispatcher']->dispatch(Task::EVENT_CREATE_UPDATE, new TaskEvent($values)); - $this->container['dispatcher']->dispatch(Task::EVENT_CREATE, new TaskEvent($values)); + $event = new TaskEvent(array('task_id' => $task_id) + $values); + + $this->logger->debug('Event fired: '.Task::EVENT_CREATE_UPDATE); + $this->logger->debug('Event fired: '.Task::EVENT_CREATE); + + $this->dispatcher->dispatch(Task::EVENT_CREATE_UPDATE, $event); + $this->dispatcher->dispatch(Task::EVENT_CREATE, $event); + + if (! empty($values['description'])) { + $this->userMention->fireEvents($values['description'], Task::EVENT_USER_MENTION, $event); + } } } diff --git a/app/Model/TaskDuplication.php b/app/Model/TaskDuplication.php index e81fb232..b081aac1 100644 --- a/app/Model/TaskDuplication.php +++ b/app/Model/TaskDuplication.php @@ -64,7 +64,7 @@ class TaskDuplication extends Base if ($values['recurrence_status'] == Task::RECURRING_STATUS_PENDING) { $values['recurrence_parent'] = $task_id; - $values['column_id'] = $this->board->getFirstColumn($values['project_id']); + $values['column_id'] = $this->column->getFirstColumnId($values['project_id']); $this->calculateRecurringTaskDueDate($values); $recurring_task_id = $this->save($task_id, $values); @@ -181,12 +181,12 @@ class TaskDuplication extends Base // Check if the column exists for the destination project if ($values['column_id'] > 0) { - $values['column_id'] = $this->board->getColumnIdByTitle( + $values['column_id'] = $this->column->getColumnIdByTitle( $values['project_id'], - $this->board->getColumnTitleById($values['column_id']) + $this->column->getColumnTitleById($values['column_id']) ); - $values['column_id'] = $values['column_id'] ?: $this->board->getFirstColumn($values['project_id']); + $values['column_id'] = $values['column_id'] ?: $this->column->getFirstColumnId($values['project_id']); } return $values; diff --git a/app/Model/TaskExport.php b/app/Model/TaskExport.php index d38a5384..ed179a4f 100644 --- a/app/Model/TaskExport.php +++ b/app/Model/TaskExport.php @@ -58,6 +58,7 @@ class TaskExport extends Base tasks.date_due, creators.username AS creator_username, users.username AS assignee_username, + users.name AS assignee_name, tasks.score, tasks.title, tasks.date_creation, @@ -105,7 +106,7 @@ class TaskExport extends Base $task['score'] = $task['score'] ?: 0; $task['swimlane_id'] = isset($swimlanes[$task['swimlane_id']]) ? $swimlanes[$task['swimlane_id']] : '?'; - $this->dateParser->format($task, array('date_due', 'date_modification', 'date_creation', 'date_started', 'date_completed'), 'Y-m-d'); + $task = $this->dateParser->format($task, array('date_due', 'date_modification', 'date_creation', 'date_started', 'date_completed'), 'Y-m-d'); return $task; } @@ -129,7 +130,8 @@ class TaskExport extends Base e('Color'), e('Due date'), e('Creator'), - e('Assignee'), + e('Assignee Username'), + e('Assignee Name'), e('Complexity'), e('Title'), e('Creation date'), diff --git a/app/Model/TaskExternalLink.php b/app/Model/TaskExternalLink.php new file mode 100644 index 00000000..f2c756b4 --- /dev/null +++ b/app/Model/TaskExternalLink.php @@ -0,0 +1,99 @@ +<?php + +namespace Kanboard\Model; + +/** + * Task External Link Model + * + * @package model + * @author Frederic Guillot + */ +class TaskExternalLink extends Base +{ + /** + * SQL table name + * + * @var string + */ + const TABLE = 'task_has_external_links'; + + /** + * Get all links + * + * @access public + * @param integer $task_id + * @return array + */ + public function getAll($task_id) + { + $types = $this->externalLinkManager->getTypes(); + + $links = $this->db->table(self::TABLE) + ->columns(self::TABLE.'.*', User::TABLE.'.name AS creator_name', User::TABLE.'.username AS creator_username') + ->eq('task_id', $task_id) + ->asc('title') + ->join(User::TABLE, 'id', 'creator_id') + ->findAll(); + + foreach ($links as &$link) { + $link['dependency_label'] = $this->externalLinkManager->getDependencyLabel($link['link_type'], $link['dependency']); + $link['type'] = isset($types[$link['link_type']]) ? $types[$link['link_type']] : t('Unknown'); + } + + return $links; + } + + /** + * Get link + * + * @access public + * @param integer $link_id + * @return array + */ + public function getById($link_id) + { + return $this->db->table(self::TABLE)->eq('id', $link_id)->findOne(); + } + + /** + * Add a new link in the database + * + * @access public + * @param array $values Form values + * @return boolean|integer + */ + public function create(array $values) + { + unset($values['id']); + $values['creator_id'] = $this->userSession->getId(); + $values['date_creation'] = time(); + $values['date_modification'] = $values['date_creation']; + + return $this->persist(self::TABLE, $values); + } + + /** + * Modify external link + * + * @access public + * @param array $values Form values + * @return boolean + */ + public function update(array $values) + { + $values['date_modification'] = time(); + return $this->db->table(self::TABLE)->eq('id', $values['id'])->update($values); + } + + /** + * Remove a link + * + * @access public + * @param integer $link_id + * @return boolean + */ + public function remove($link_id) + { + return $this->db->table(self::TABLE)->eq('id', $link_id)->remove(); + } +} diff --git a/app/Model/TaskFile.php b/app/Model/TaskFile.php new file mode 100644 index 00000000..45a3b97f --- /dev/null +++ b/app/Model/TaskFile.php @@ -0,0 +1,54 @@ +<?php + +namespace Kanboard\Model; + +/** + * Task File Model + * + * @package model + * @author Frederic Guillot + */ +class TaskFile extends File +{ + /** + * SQL table name + * + * @var string + */ + const TABLE = 'task_has_files'; + + /** + * SQL foreign key + * + * @var string + */ + const FOREIGN_KEY = 'task_id'; + + /** + * Path prefix + * + * @var string + */ + const PATH_PREFIX = 'tasks'; + + /** + * Events + * + * @var string + */ + const EVENT_CREATE = 'task.file.create'; + + /** + * Handle screenshot upload + * + * @access public + * @param integer $task_id Task id + * @param string $blob Base64 encoded image + * @return bool|integer + */ + public function uploadScreenshot($task_id, $blob) + { + $original_filename = e('Screenshot taken %s', $this->helper->dt->datetime(time())).'.png'; + return $this->uploadContent($task_id, $original_filename, $blob); + } +} diff --git a/app/Model/TaskFilter.php b/app/Model/TaskFilter.php index 137a7a8e..1883298d 100644 --- a/app/Model/TaskFilter.php +++ b/app/Model/TaskFilter.php @@ -30,6 +30,7 @@ class TaskFilter extends Base 'T_COLUMN' => 'filterByColumnName', 'T_REFERENCE' => 'filterByReference', 'T_SWIMLANE' => 'filterBySwimlaneName', + 'T_LINK' => 'filterByLinkName', ); /** @@ -108,6 +109,22 @@ class TaskFilter extends Base } /** + * Create a new link query + * + * @access public + * @return \PicoDb\Table + */ + public function createLinkQuery() + { + return $this->db->table(TaskLink::TABLE) + ->columns( + TaskLink::TABLE.'.task_id', + Link::TABLE.'.label' + ) + ->join(Link::TABLE, 'id', 'link_id', TaskLink::TABLE); + } + + /** * Clone the filter * * @access public @@ -452,7 +469,7 @@ class TaskFilter extends Base $this->query->beginOr(); foreach ($values as $project) { - $this->query->ilike(Board::TABLE.'.title', $project); + $this->query->ilike(Column::TABLE.'.title', $project); } $this->query->closeOr(); @@ -507,6 +524,30 @@ class TaskFilter extends Base } /** + * Filter by link + * + * @access public + * @param array $values List of links + * @return TaskFilter + */ + public function filterByLinkName(array $values) + { + $this->query->beginOr(); + + $link_query = $this->createLinkQuery()->in(Link::TABLE.'.label', $values); + $matching_task_ids = $link_query->findAllByColumn('task_id'); + if (empty($matching_task_ids)) { + $this->query->eq(Task::TABLE.'.id', 0); + } else { + $this->query->in(Task::TABLE.'.id', $matching_task_ids); + } + + $this->query->closeOr(); + + return $this; + } + + /** * Filter by due date * * @access public diff --git a/app/Model/TaskFinder.php b/app/Model/TaskFinder.php index 9514fe4a..0492a9bf 100644 --- a/app/Model/TaskFinder.php +++ b/app/Model/TaskFinder.php @@ -38,14 +38,14 @@ class TaskFinder extends Base Task::TABLE.'.time_spent', Task::TABLE.'.time_estimated', Project::TABLE.'.name AS project_name', - Board::TABLE.'.title AS column_name', + Column::TABLE.'.title AS column_name', User::TABLE.'.username AS assignee_username', User::TABLE.'.name AS assignee_name' ) ->eq(Task::TABLE.'.is_active', $is_active) ->in(Project::TABLE.'.id', $project_ids) ->join(Project::TABLE, 'id', 'project_id') - ->join(Board::TABLE, 'id', 'column_id', Task::TABLE) + ->join(Column::TABLE, 'id', 'column_id', Task::TABLE) ->join(User::TABLE, 'id', 'owner_id', Task::TABLE); } @@ -88,11 +88,12 @@ class TaskFinder extends Base return $this->db ->table(Task::TABLE) ->columns( - '(SELECT count(*) FROM '.Comment::TABLE.' WHERE task_id=tasks.id) AS nb_comments', - '(SELECT count(*) FROM '.File::TABLE.' WHERE task_id=tasks.id) AS nb_files', - '(SELECT count(*) FROM '.Subtask::TABLE.' WHERE '.Subtask::TABLE.'.task_id=tasks.id) AS nb_subtasks', - '(SELECT count(*) FROM '.Subtask::TABLE.' WHERE '.Subtask::TABLE.'.task_id=tasks.id AND status=2) AS nb_completed_subtasks', - '(SELECT count(*) FROM '.TaskLink::TABLE.' WHERE '.TaskLink::TABLE.'.task_id = tasks.id) AS nb_links', + '(SELECT COUNT(*) FROM '.Comment::TABLE.' WHERE task_id=tasks.id) AS nb_comments', + '(SELECT COUNT(*) FROM '.TaskFile::TABLE.' WHERE task_id=tasks.id) AS nb_files', + '(SELECT COUNT(*) FROM '.Subtask::TABLE.' WHERE '.Subtask::TABLE.'.task_id=tasks.id) AS nb_subtasks', + '(SELECT COUNT(*) FROM '.Subtask::TABLE.' WHERE '.Subtask::TABLE.'.task_id=tasks.id AND status=2) AS nb_completed_subtasks', + '(SELECT COUNT(*) FROM '.TaskLink::TABLE.' WHERE '.TaskLink::TABLE.'.task_id = tasks.id) AS nb_links', + '(SELECT COUNT(*) FROM '.TaskExternalLink::TABLE.' WHERE '.TaskExternalLink::TABLE.'.task_id = tasks.id) AS nb_external_links', '(SELECT DISTINCT 1 FROM '.TaskLink::TABLE.' WHERE '.TaskLink::TABLE.'.task_id = tasks.id AND '.TaskLink::TABLE.'.link_id = 9) AS is_milestone', 'tasks.id', 'tasks.reference', @@ -113,6 +114,7 @@ class TaskFinder extends Base 'tasks.is_active', 'tasks.score', 'tasks.category_id', + 'tasks.priority', 'tasks.date_moved', 'tasks.recurrence_status', 'tasks.recurrence_trigger', @@ -122,19 +124,20 @@ class TaskFinder extends Base 'tasks.recurrence_parent', 'tasks.recurrence_child', 'tasks.time_estimated', + 'tasks.time_spent', User::TABLE.'.username AS assignee_username', User::TABLE.'.name AS assignee_name', Category::TABLE.'.name AS category_name', Category::TABLE.'.description AS category_description', - Board::TABLE.'.title AS column_name', - Board::TABLE.'.position AS column_position', + Column::TABLE.'.title AS column_name', + Column::TABLE.'.position AS column_position', 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(Column::TABLE, 'id', 'column_id', Task::TABLE) ->join(Swimlane::TABLE, 'id', 'swimlane_id', Task::TABLE) ->join(Project::TABLE, 'id', 'project_id', Task::TABLE); } @@ -177,6 +180,23 @@ class TaskFinder extends Base } /** + * Get all tasks for a given project and status + * + * @access public + * @param integer $project_id + * @param array $status + * @return array + */ + public function getAllIds($project_id, array $status = array(Task::STATUS_OPEN)) + { + return $this->db + ->table(Task::TABLE) + ->eq(Task::TABLE.'.project_id', $project_id) + ->in(Task::TABLE.'.is_active', $status) + ->findAllByColumn('id'); + } + + /** * Get overdue tasks query * * @access public @@ -307,6 +327,7 @@ class TaskFinder extends Base tasks.is_active, tasks.score, tasks.category_id, + tasks.priority, tasks.swimlane_id, tasks.date_moved, tasks.recurrence_status, diff --git a/app/Model/TaskImport.php b/app/Model/TaskImport.php index e8dd1946..ccab0152 100644 --- a/app/Model/TaskImport.php +++ b/app/Model/TaskImport.php @@ -111,7 +111,7 @@ class TaskImport extends Base } if (! empty($row['column'])) { - $values['column_id'] = $this->board->getColumnIdByTitle($this->projectId, $row['column']); + $values['column_id'] = $this->column->getColumnIdByTitle($this->projectId, $row['column']); } if (! empty($row['category'])) { diff --git a/app/Model/TaskLink.php b/app/Model/TaskLink.php index 1ac59203..a57bf3b0 100644 --- a/app/Model/TaskLink.php +++ b/app/Model/TaskLink.php @@ -2,8 +2,6 @@ namespace Kanboard\Model; -use SimpleValidator\Validator; -use SimpleValidator\Validators; use Kanboard\Event\TaskLinkEvent; /** @@ -77,22 +75,23 @@ class TaskLink extends Base Task::TABLE.'.title', Task::TABLE.'.is_active', Task::TABLE.'.project_id', + Task::TABLE.'.column_id', Task::TABLE.'.time_spent AS task_time_spent', Task::TABLE.'.time_estimated AS task_time_estimated', Task::TABLE.'.owner_id AS task_assignee_id', User::TABLE.'.username AS task_assignee_username', User::TABLE.'.name AS task_assignee_name', - Board::TABLE.'.title AS column_title', + Column::TABLE.'.title AS column_title', Project::TABLE.'.name AS project_name' ) ->eq(self::TABLE.'.task_id', $task_id) ->join(Link::TABLE, 'id', 'link_id') ->join(Task::TABLE, 'id', 'opposite_task_id') - ->join(Board::TABLE, 'id', 'column_id', Task::TABLE) + ->join(Column::TABLE, 'id', 'column_id', Task::TABLE) ->join(User::TABLE, 'id', 'owner_id', Task::TABLE) ->join(Project::TABLE, 'id', 'project_id', Task::TABLE) ->asc(Link::TABLE.'.id') - ->desc(Board::TABLE.'.position') + ->desc(Column::TABLE.'.position') ->desc(Task::TABLE.'.is_active') ->asc(Task::TABLE.'.position') ->asc(Task::TABLE.'.id') @@ -261,59 +260,4 @@ class TaskLink extends Base return true; } - - /** - * Common validation rules - * - * @access private - * @return array - */ - private function commonValidationRules() - { - return array( - new Validators\Required('task_id', t('Field required')), - new Validators\Required('opposite_task_id', t('Field required')), - new Validators\Required('link_id', t('Field required')), - new Validators\NotEquals('opposite_task_id', 'task_id', t('A task cannot be linked to itself')), - new Validators\Exists('opposite_task_id', t('This linked task id doesn\'t exists'), $this->db->getConnection(), Task::TABLE, 'id') - ); - } - - /** - * Validate creation - * - * @access public - * @param array $values Form values - * @return array $valid, $errors [0] = Success or not, [1] = List of errors - */ - public function validateCreation(array $values) - { - $v = new Validator($values, $this->commonValidationRules()); - - return array( - $v->execute(), - $v->getErrors() - ); - } - - /** - * Validate modification - * - * @access public - * @param array $values Form values - * @return array $valid, $errors [0] = Success or not, [1] = List of errors - */ - public function validateModification(array $values) - { - $rules = array( - new Validators\Required('id', t('Field required')), - ); - - $v = new Validator($values, array_merge($rules, $this->commonValidationRules())); - - return array( - $v->execute(), - $v->getErrors() - ); - } } diff --git a/app/Model/TaskModification.php b/app/Model/TaskModification.php index 781646b8..8e59b3fe 100644 --- a/app/Model/TaskModification.php +++ b/app/Model/TaskModification.php @@ -17,16 +17,17 @@ class TaskModification extends Base * * @access public * @param array $values + * @param boolean $fire_events * @return boolean */ - public function update(array $values) + public function update(array $values, $fire_events = true) { $original_task = $this->taskFinder->getById($values['id']); $this->prepare($values); $result = $this->db->table(Task::TABLE)->eq('id', $original_task['id'])->update($values); - if ($result) { + if ($fire_events && $result) { $this->fireEvents($original_task, $values); } @@ -51,13 +52,14 @@ class TaskModification extends Base if ($this->isFieldModified('owner_id', $event_data['changes'])) { $events[] = Task::EVENT_ASSIGNEE_CHANGE; - } else { + } elseif (! empty($event_data['changes'])) { $events[] = Task::EVENT_CREATE_UPDATE; $events[] = Task::EVENT_UPDATE; } foreach ($events as $event) { - $this->container['dispatcher']->dispatch($event, new TaskEvent($event_data)); + $this->logger->debug('Event fired: '.$event); + $this->dispatcher->dispatch($event, new TaskEvent($event_data)); } } @@ -82,11 +84,12 @@ class TaskModification extends Base */ public function prepare(array &$values) { - $this->dateParser->convert($values, array('date_due')); - $this->dateParser->convert($values, array('date_started'), true); + $values = $this->dateParser->convert($values, array('date_due')); + $values = $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')); + $this->convertIntegerFields($values, array('priority', 'is_active', 'recurrence_status', 'recurrence_trigger', 'recurrence_factor', 'recurrence_timeframe', 'recurrence_basedate')); $values['date_modification'] = time(); } diff --git a/app/Model/TaskPermission.php b/app/Model/TaskPermission.php index 4bbe6d1d..fac2153e 100644 --- a/app/Model/TaskPermission.php +++ b/app/Model/TaskPermission.php @@ -2,6 +2,8 @@ namespace Kanboard\Model; +use Kanboard\Core\Security\Role; + /** * Task permission model * @@ -20,7 +22,7 @@ class TaskPermission extends Base */ public function canRemoveTask(array $task) { - if ($this->userSession->isAdmin() || $this->projectPermission->isManager($task['project_id'], $this->userSession->getId())) { + if ($this->userSession->isAdmin() || $this->projectUserRole->getUserRole($task['project_id'], $this->userSession->getId()) === Role::PROJECT_MANAGER) { return true; } elseif (isset($task['creator_id']) && $task['creator_id'] == $this->userSession->getId()) { return true; diff --git a/app/Model/TaskPosition.php b/app/Model/TaskPosition.php index da363cb3..4c9928d7 100644 --- a/app/Model/TaskPosition.php +++ b/app/Model/TaskPosition.php @@ -32,7 +32,6 @@ class TaskPosition extends Base $task = $this->taskFinder->getById($task_id); - // Ignore closed tasks if ($task['is_active'] == Task::STATUS_CLOSED) { return true; } @@ -167,7 +166,12 @@ class TaskPosition extends Base return false; } - return true; + $now = time(); + + return $this->db->table(Task::TABLE)->eq('id', $task_id)->update(array( + 'date_moved' => $now, + 'date_modification' => $now, + )); } /** @@ -221,11 +225,14 @@ class TaskPosition extends Base ); if ($task['swimlane_id'] != $new_swimlane_id) { - $this->container['dispatcher']->dispatch(Task::EVENT_MOVE_SWIMLANE, new TaskEvent($event_data)); + $this->logger->debug('Event fired: '.Task::EVENT_MOVE_SWIMLANE); + $this->dispatcher->dispatch(Task::EVENT_MOVE_SWIMLANE, new TaskEvent($event_data)); } elseif ($task['column_id'] != $new_column_id) { - $this->container['dispatcher']->dispatch(Task::EVENT_MOVE_COLUMN, new TaskEvent($event_data)); + $this->logger->debug('Event fired: '.Task::EVENT_MOVE_COLUMN); + $this->dispatcher->dispatch(Task::EVENT_MOVE_COLUMN, new TaskEvent($event_data)); } elseif ($task['position'] != $new_position) { - $this->container['dispatcher']->dispatch(Task::EVENT_MOVE_POSITION, new TaskEvent($event_data)); + $this->logger->debug('Event fired: '.Task::EVENT_MOVE_POSITION); + $this->dispatcher->dispatch(Task::EVENT_MOVE_POSITION, new TaskEvent($event_data)); } } } diff --git a/app/Model/TaskStatus.php b/app/Model/TaskStatus.php index a5199ed9..2b902815 100644 --- a/app/Model/TaskStatus.php +++ b/app/Model/TaskStatus.php @@ -62,6 +62,32 @@ class TaskStatus extends Base } /** + * Close multiple tasks + * + * @access public + * @param array $task_ids + */ + public function closeMultipleTasks(array $task_ids) + { + foreach ($task_ids as $task_id) { + $this->close($task_id); + } + } + + /** + * Close all tasks within a column/swimlane + * + * @access public + * @param integer $swimlane_id + * @param integer $column_id + */ + public function closeTasksBySwimlaneAndColumn($swimlane_id, $column_id) + { + $task_ids = $this->db->table(Task::TABLE)->eq('swimlane_id', $swimlane_id)->eq('column_id', $column_id)->findAllByColumn('id'); + $this->closeMultipleTasks($task_ids); + } + + /** * Common method to change the status of task * * @access private @@ -87,10 +113,8 @@ class TaskStatus extends Base )); if ($result) { - $this->container['dispatcher']->dispatch( - $event, - new TaskEvent(array('task_id' => $task_id) + $this->taskFinder->getById($task_id)) - ); + $this->logger->debug('Event fired: '.$event); + $this->dispatcher->dispatch($event, new TaskEvent(array('task_id' => $task_id) + $this->taskFinder->getById($task_id))); } return $result; diff --git a/app/Model/TaskValidator.php b/app/Model/TaskValidator.php deleted file mode 100644 index 683cb0b1..00000000 --- a/app/Model/TaskValidator.php +++ /dev/null @@ -1,246 +0,0 @@ -<?php - -namespace Kanboard\Model; - -use SimpleValidator\Validator; -use SimpleValidator\Validators; - -/** - * Task validator model - * - * @package model - * @author Frederic Guillot - */ -class TaskValidator extends Base -{ - /** - * Common validation rules - * - * @access private - * @return array - */ - private function commonValidationRules() - { - return array( - new Validators\Integer('id', t('This value must be an integer')), - new Validators\Integer('project_id', t('This value must be an integer')), - new Validators\Integer('column_id', t('This value must be an integer')), - new Validators\Integer('owner_id', t('This value must be an integer')), - new Validators\Integer('creator_id', t('This value must be an integer')), - new Validators\Integer('score', t('This value must be an integer')), - new Validators\Integer('category_id', t('This value must be an integer')), - new Validators\Integer('swimlane_id', t('This value must be an integer')), - new Validators\Integer('recurrence_child', t('This value must be an integer')), - new Validators\Integer('recurrence_parent', t('This value must be an integer')), - new Validators\Integer('recurrence_factor', t('This value must be an integer')), - new Validators\Integer('recurrence_timeframe', t('This value must be an integer')), - new Validators\Integer('recurrence_basedate', t('This value must be an integer')), - new Validators\Integer('recurrence_trigger', t('This value must be an integer')), - 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\MaxLength('reference', t('The maximum length is %d characters', 50), 50), - new Validators\Date('date_due', 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')), - ); - } - - /** - * Validate task creation - * - * @access public - * @param array $values Form values - * @return array $valid, $errors [0] = Success or not, [1] = List of errors - */ - public function validateCreation(array $values) - { - $rules = array( - new Validators\Required('project_id', t('The project is required')), - new Validators\Required('title', t('The title is required')), - ); - - $v = new Validator($values, array_merge($rules, $this->commonValidationRules())); - - return array( - $v->execute(), - $v->getErrors() - ); - } - - /** - * Validate description creation - * - * @access public - * @param array $values Form values - * @return array $valid, $errors [0] = Success or not, [1] = List of errors - */ - public function validateDescriptionCreation(array $values) - { - $rules = array( - new Validators\Required('id', t('The id is required')), - ); - - $v = new Validator($values, array_merge($rules, $this->commonValidationRules())); - - return array( - $v->execute(), - $v->getErrors() - ); - } - - /** - * Validate edit recurrence - * - * @access public - * @param array $values Form values - * @return array $valid, $errors [0] = Success or not, [1] = List of errors - */ - public function validateEditRecurrence(array $values) - { - $rules = array( - new Validators\Required('id', t('The id is required')), - ); - - $v = new Validator($values, array_merge($rules, $this->commonValidationRules())); - - return array( - $v->execute(), - $v->getErrors() - ); - } - - - /** - * Validate task modification (form) - * - * @access public - * @param array $values Form values - * @return array $valid, $errors [0] = Success or not, [1] = List of errors - */ - public function validateModification(array $values) - { - $rules = array( - new Validators\Required('id', t('The id is required')), - new Validators\Required('title', t('The title is required')), - ); - - $v = new Validator($values, array_merge($rules, $this->commonValidationRules())); - - return array( - $v->execute(), - $v->getErrors() - ); - } - - /** - * Validate task modification (Api) - * - * @access public - * @param array $values Form values - * @return array $valid, $errors [0] = Success or not, [1] = List of errors - */ - public function validateApiModification(array $values) - { - $rules = array( - new Validators\Required('id', t('The id is required')), - ); - - $v = new Validator($values, array_merge($rules, $this->commonValidationRules())); - - return array( - $v->execute(), - $v->getErrors() - ); - } - - /** - * Validate assignee change - * - * @access public - * @param array $values Form values - * @return array $valid, $errors [0] = Success or not, [1] = List of errors - */ - public function validateAssigneeModification(array $values) - { - $rules = array( - new Validators\Required('id', t('The id is required')), - new Validators\Required('project_id', t('The project is required')), - new Validators\Required('owner_id', t('This value is required')), - ); - - $v = new Validator($values, array_merge($rules, $this->commonValidationRules())); - - return array( - $v->execute(), - $v->getErrors() - ); - } - - /** - * Validate category change - * - * @access public - * @param array $values Form values - * @return array $valid, $errors [0] = Success or not, [1] = List of errors - */ - public function validateCategoryModification(array $values) - { - $rules = array( - new Validators\Required('id', t('The id is required')), - new Validators\Required('project_id', t('The project is required')), - new Validators\Required('category_id', t('This value is required')), - - ); - - $v = new Validator($values, array_merge($rules, $this->commonValidationRules())); - - return array( - $v->execute(), - $v->getErrors() - ); - } - - /** - * Validate project modification - * - * @access public - * @param array $values Form values - * @return array $valid, $errors [0] = Success or not, [1] = List of errors - */ - public function validateProjectModification(array $values) - { - $rules = array( - new Validators\Required('id', t('The id is required')), - new Validators\Required('project_id', t('The project is required')), - ); - - $v = new Validator($values, array_merge($rules, $this->commonValidationRules())); - - return array( - $v->execute(), - $v->getErrors() - ); - } - - /** - * Validate time tracking modification (form) - * - * @access public - * @param array $values Form values - * @return array $valid, $errors [0] = Success or not, [1] = List of errors - */ - public function validateTimeModification(array $values) - { - $rules = array( - new Validators\Required('id', t('The id is required')), - ); - - $v = new Validator($values, array_merge($rules, $this->commonValidationRules())); - - return array( - $v->execute(), - $v->getErrors() - ); - } -} diff --git a/app/Model/Transition.php b/app/Model/Transition.php index b1f8f678..aa76d58f 100644 --- a/app/Model/Transition.php +++ b/app/Model/Transition.php @@ -76,8 +76,8 @@ class Transition extends Base ->eq('task_id', $task_id) ->desc('date') ->join(User::TABLE, 'id', 'user_id') - ->join(Board::TABLE.' as src', 'id', 'src_column_id', self::TABLE, 'src') - ->join(Board::TABLE.' as dst', 'id', 'dst_column_id', self::TABLE, 'dst') + ->join(Column::TABLE.' as src', 'id', 'src_column_id', self::TABLE, 'src') + ->join(Column::TABLE.' as dst', 'id', 'dst_column_id', self::TABLE, 'dst') ->findAll(); } @@ -118,8 +118,8 @@ class Transition extends Base ->desc('date') ->join(Task::TABLE, 'id', 'task_id') ->join(User::TABLE, 'id', 'user_id') - ->join(Board::TABLE.' as src', 'id', 'src_column_id', self::TABLE, 'src') - ->join(Board::TABLE.' as dst', 'id', 'dst_column_id', self::TABLE, 'dst') + ->join(Column::TABLE.' as src', 'id', 'src_column_id', self::TABLE, 'src') + ->join(Column::TABLE.' as dst', 'id', 'dst_column_id', self::TABLE, 'dst') ->findAll(); } diff --git a/app/Model/User.php b/app/Model/User.php index 6e7e94e0..2d87d35b 100644 --- a/app/Model/User.php +++ b/app/Model/User.php @@ -3,10 +3,8 @@ namespace Kanboard\Model; use PicoDb\Database; -use SimpleValidator\Validator; -use SimpleValidator\Validators; -use Kanboard\Core\Session; -use Kanboard\Core\Security; +use Kanboard\Core\Security\Token; +use Kanboard\Core\Security\Role; /** * User model @@ -43,6 +41,18 @@ class User extends Base } /** + * Return true if the user is active + * + * @access public + * @param integer $user_id User id + * @return boolean + */ + public function isActive($user_id) + { + return $this->db->table(self::TABLE)->eq('id', $user_id)->eq('is_active', 1)->exists(); + } + + /** * Get query to fetch all users * * @access public @@ -50,21 +60,7 @@ class User extends Base */ public function getQuery() { - return $this->db - ->table(self::TABLE) - ->columns( - 'id', - 'username', - 'name', - 'email', - 'is_admin', - 'is_project_admin', - 'is_ldap_user', - 'notifications_enabled', - 'google_id', - 'github_id', - 'twofactor_activated' - ); + return $this->db->table(self::TABLE); } /** @@ -91,7 +87,7 @@ class User extends Base $this->db ->table(User::TABLE) ->eq('id', $user_id) - ->eq('is_admin', 1) + ->eq('role', Role::APP_ADMIN) ->exists(); } @@ -111,48 +107,17 @@ class User extends Base * Get a specific user by the Google id * * @access public - * @param string $google_id Google unique id + * @param string $column + * @param string $id * @return array|boolean */ - public function getByGoogleId($google_id) + public function getByExternalId($column, $id) { - if (empty($google_id)) { + if (empty($id)) { return false; } - return $this->db->table(self::TABLE)->eq('google_id', $google_id)->findOne(); - } - - /** - * Get a specific user by the Github id - * - * @access public - * @param string $github_id Github user id - * @return array|boolean - */ - public function getByGithubId($github_id) - { - if (empty($github_id)) { - return false; - } - - return $this->db->table(self::TABLE)->eq('github_id', $github_id)->findOne(); - } - - /** - * Get a specific user by the Gitlab id - * - * @access public - * @param string $gitlab_id Gitlab user id - * @return array|boolean - */ - public function getByGitlabId($gitlab_id) - { - if (empty($gitlab_id)) { - return false; - } - - return $this->db->table(self::TABLE)->eq('gitlab_id', $gitlab_id)->findOne(); + return $this->db->table(self::TABLE)->eq($column, $id)->findOne(); } /** @@ -172,7 +137,7 @@ class User extends Base * * @access public * @param string $username Username - * @return array + * @return integer */ public function getIdByUsername($username) { @@ -240,9 +205,9 @@ class User extends Base * @param boolean $prepend Prepend "All users" * @return array */ - public function getList($prepend = false) + public function getActiveUsersList($prepend = false) { - $users = $this->db->table(self::TABLE)->columns('id', 'username', 'name')->findAll(); + $users = $this->db->table(self::TABLE)->eq('is_active', 1)->columns('id', 'username', 'name')->findAll(); $listing = $this->prepareList($users); if ($prepend) { @@ -289,7 +254,7 @@ class User extends Base } $this->removeFields($values, array('confirmation', 'current_password')); - $this->resetFields($values, array('is_admin', 'is_ldap_user', 'is_project_admin', 'disable_login_form')); + $this->resetFields($values, array('is_ldap_user', 'disable_login_form')); $this->convertNullFields($values, array('gitlab_id')); $this->convertIntegerFields($values, array('gitlab_id')); } @@ -312,7 +277,7 @@ class User extends Base * * @access public * @param array $values Form values - * @return array + * @return boolean */ public function update(array $values) { @@ -320,14 +285,38 @@ class User extends Base $result = $this->db->table(self::TABLE)->eq('id', $values['id'])->update($values); // If the user is connected refresh his session - if (Session::isOpen() && $this->userSession->getId() == $values['id']) { - $this->userSession->refresh(); + if ($this->userSession->getId() == $values['id']) { + $this->userSession->initialize($this->getById($this->userSession->getId())); } return $result; } /** + * Disable a specific user + * + * @access public + * @param integer $user_id + * @return boolean + */ + public function disable($user_id) + { + return $this->db->table(self::TABLE)->eq('id', $user_id)->update(array('is_active' => 0)); + } + + /** + * Enable a specific user + * + * @access public + * @param integer $user_id + * @return boolean + */ + public function enable($user_id) + { + return $this->db->table(self::TABLE)->eq('id', $user_id)->update(array('is_active' => 1)); + } + + /** * Remove a specific user * * @access public @@ -355,10 +344,10 @@ class User extends Base // All private projects are removed $project_ids = $db->table(Project::TABLE) - ->eq('is_private', 1) - ->eq(ProjectPermission::TABLE.'.user_id', $user_id) - ->join(ProjectPermission::TABLE, 'project_id', 'id') - ->findAllByColumn(Project::TABLE.'.id'); + ->eq('is_private', 1) + ->eq(ProjectUserRole::TABLE.'.user_id', $user_id) + ->join(ProjectUserRole::TABLE, 'project_id', 'id') + ->findAllByColumn(Project::TABLE.'.id'); if (! empty($project_ids)) { $db->table(Project::TABLE)->in('id', $project_ids)->remove(); @@ -383,7 +372,7 @@ class User extends Base return $this->db ->table(self::TABLE) ->eq('id', $user_id) - ->save(array('token' => Security::generateToken())); + ->save(array('token' => Token::getToken())); } /** @@ -400,200 +389,4 @@ class User extends Base ->eq('id', $user_id) ->save(array('token' => '')); } - - /** - * Get the number of failed login for the user - * - * @access public - * @param string $username - * @return integer - */ - public function getFailedLogin($username) - { - return (int) $this->db->table(self::TABLE)->eq('username', $username)->findOneColumn('nb_failed_login'); - } - - /** - * Reset to 0 the counter of failed login - * - * @access public - * @param string $username - * @return boolean - */ - public function resetFailedLogin($username) - { - return $this->db->table(self::TABLE)->eq('username', $username)->update(array('nb_failed_login' => 0, 'lock_expiration_date' => 0)); - } - - /** - * Increment failed login counter - * - * @access public - * @param string $username - * @return boolean - */ - public function incrementFailedLogin($username) - { - return $this->db->execute('UPDATE '.self::TABLE.' SET nb_failed_login=nb_failed_login+1 WHERE username=?', array($username)) !== false; - } - - /** - * Check if the account is locked - * - * @access public - * @param string $username - * @return boolean - */ - public function isLocked($username) - { - return $this->db->table(self::TABLE) - ->eq('username', $username) - ->neq('lock_expiration_date', 0) - ->gte('lock_expiration_date', time()) - ->exists(); - } - - /** - * Lock the account for the specified duration - * - * @access public - * @param string $username Username - * @param integer $duration Duration in minutes - * @return boolean - */ - public function lock($username, $duration = 15) - { - return $this->db->table(self::TABLE)->eq('username', $username)->update(array('lock_expiration_date' => time() + $duration * 60)); - } - - /** - * Common validation rules - * - * @access private - * @return array - */ - private function commonValidationRules() - { - return array( - new Validators\MaxLength('username', t('The maximum length is %d characters', 50), 50), - 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_project_admin', t('This value must be an integer')), - new Validators\Integer('is_ldap_user', t('This value must be an integer')), - ); - } - - /** - * Common password validation rules - * - * @access private - * @return array - */ - private function commonPasswordValidationRules() - { - return array( - new Validators\Required('password', t('The password is required')), - new Validators\MinLength('password', t('The minimum length is %d characters', 6), 6), - new Validators\Required('confirmation', t('The confirmation is required')), - new Validators\Equals('password', 'confirmation', t('Passwords don\'t match')), - ); - } - - /** - * Validate user creation - * - * @access public - * @param array $values Form values - * @return array $valid, $errors [0] = Success or not, [1] = List of errors - */ - public function validateCreation(array $values) - { - $rules = array( - new Validators\Required('username', t('The username is required')), - ); - - 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(), - $v->getErrors() - ); - } - - /** - * Validate user modification - * - * @access public - * @param array $values Form values - * @return array $valid, $errors [0] = Success or not, [1] = List of errors - */ - public function validateModification(array $values) - { - $rules = array( - new Validators\Required('id', t('The user id is required')), - new Validators\Required('username', t('The username is required')), - ); - - $v = new Validator($values, array_merge($rules, $this->commonValidationRules())); - - return array( - $v->execute(), - $v->getErrors() - ); - } - - /** - * Validate user API modification - * - * @access public - * @param array $values Form values - * @return array $valid, $errors [0] = Success or not, [1] = List of errors - */ - public function validateApiModification(array $values) - { - $rules = array( - new Validators\Required('id', t('The user id is required')), - ); - - $v = new Validator($values, array_merge($rules, $this->commonValidationRules())); - - return array( - $v->execute(), - $v->getErrors() - ); - } - - /** - * Validate password modification - * - * @access public - * @param array $values Form values - * @return array $valid, $errors [0] = Success or not, [1] = List of errors - */ - public function validatePasswordModification(array $values) - { - $rules = array( - new Validators\Required('id', t('The user id is required')), - new Validators\Required('current_password', t('The current password is required')), - ); - - $v = new Validator($values, array_merge($rules, $this->commonPasswordValidationRules())); - - if ($v->execute()) { - - // Check password - if ($this->authentication->authenticate($this->session['user']['username'], $values['current_password'])) { - return array(true, array()); - } else { - return array(false, array('current_password' => array(t('Wrong password')))); - } - } - - return array(false, $v->getErrors()); - } } diff --git a/app/Model/UserFilter.php b/app/Model/UserFilter.php new file mode 100644 index 00000000..ff546e96 --- /dev/null +++ b/app/Model/UserFilter.php @@ -0,0 +1,80 @@ +<?php + +namespace Kanboard\Model; + +/** + * User Filter + * + * @package model + * @author Frederic Guillot + */ +class UserFilter extends Base +{ + /** + * Search query + * + * @access private + * @var string + */ + private $input; + + /** + * Query + * + * @access protected + * @var \PicoDb\Table + */ + protected $query; + + /** + * Initialize filter + * + * @access public + * @param string $input + * @return UserFilter + */ + public function create($input) + { + $this->query = $this->db->table(User::TABLE); + $this->input = $input; + return $this; + } + + /** + * Filter users by name or username + * + * @access public + * @return UserFilter + */ + public function filterByUsernameOrByName() + { + $this->query->beginOr() + ->ilike('username', '%'.$this->input.'%') + ->ilike('name', '%'.$this->input.'%') + ->closeOr(); + + return $this; + } + + /** + * Get all results of the filter + * + * @access public + * @return array + */ + public function findAll() + { + return $this->query->findAll(); + } + + /** + * Get the PicoDb query + * + * @access public + * @return \PicoDb\Table + */ + public function getQuery() + { + return $this->query; + } +} diff --git a/app/Model/UserImport.php b/app/Model/UserImport.php index 3c9e7a57..0ec4e802 100644 --- a/app/Model/UserImport.php +++ b/app/Model/UserImport.php @@ -4,6 +4,7 @@ namespace Kanboard\Model; use SimpleValidator\Validator; use SimpleValidator\Validators; +use Kanboard\Core\Security\Role; use Kanboard\Core\Csv; /** @@ -36,7 +37,7 @@ class UserImport extends Base 'email' => 'Email', 'name' => 'Full Name', 'is_admin' => 'Administrator', - 'is_project_admin' => 'Project Administrator', + 'is_manager' => 'Manager', 'is_ldap_user' => 'Remote User', ); } @@ -75,10 +76,21 @@ class UserImport extends Base { $row['username'] = strtolower($row['username']); - foreach (array('is_admin', 'is_project_admin', 'is_ldap_user') as $field) { + foreach (array('is_admin', 'is_manager', 'is_ldap_user') as $field) { $row[$field] = Csv::getBooleanValue($row[$field]); } + if ($row['is_admin'] == 1) { + $row['role'] = Role::APP_ADMIN; + } elseif ($row['is_manager'] == 1) { + $row['role'] = Role::APP_MANAGER; + } else { + $row['role'] = Role::APP_USER; + } + + unset($row['is_admin']); + unset($row['is_manager']); + $this->removeEmptyFields($row, array('password', 'email', 'name')); return $row; @@ -98,8 +110,6 @@ class UserImport extends Base new Validators\Unique('username', t('The username must be unique'), $this->db->getConnection(), User::TABLE, 'id'), new Validators\MinLength('password', t('The minimum length is %d characters', 6), 6), new Validators\Email('email', t('Email address invalid')), - new Validators\Integer('is_admin', t('This value must be an integer')), - new Validators\Integer('is_project_admin', t('This value must be an integer')), new Validators\Integer('is_ldap_user', t('This value must be an integer')), )); diff --git a/app/Model/UserLocking.php b/app/Model/UserLocking.php new file mode 100644 index 00000000..67e4c244 --- /dev/null +++ b/app/Model/UserLocking.php @@ -0,0 +1,103 @@ +<?php + +namespace Kanboard\Model; + +/** + * User Locking Model + * + * @package model + * @author Frederic Guillot + */ +class UserLocking extends Base +{ + /** + * Get the number of failed login for the user + * + * @access public + * @param string $username + * @return integer + */ + public function getFailedLogin($username) + { + return (int) $this->db->table(User::TABLE) + ->eq('username', $username) + ->findOneColumn('nb_failed_login'); + } + + /** + * Reset to 0 the counter of failed login + * + * @access public + * @param string $username + * @return boolean + */ + public function resetFailedLogin($username) + { + return $this->db->table(User::TABLE) + ->eq('username', $username) + ->update(array( + 'nb_failed_login' => 0, + 'lock_expiration_date' => 0, + )); + } + + /** + * Increment failed login counter + * + * @access public + * @param string $username + * @return boolean + */ + public function incrementFailedLogin($username) + { + return $this->db->table(User::TABLE) + ->eq('username', $username) + ->increment('nb_failed_login', 1); + } + + /** + * Check if the account is locked + * + * @access public + * @param string $username + * @return boolean + */ + public function isLocked($username) + { + return $this->db->table(User::TABLE) + ->eq('username', $username) + ->neq('lock_expiration_date', 0) + ->gte('lock_expiration_date', time()) + ->exists(); + } + + /** + * Lock the account for the specified duration + * + * @access public + * @param string $username Username + * @param integer $duration Duration in minutes + * @return boolean + */ + public function lock($username, $duration = 15) + { + return $this->db->table(User::TABLE) + ->eq('username', $username) + ->update(array( + 'lock_expiration_date' => time() + $duration * 60 + )); + } + + /** + * Return true if the captcha must be shown + * + * @access public + * @param string $username + * @param integer $tries + * @return boolean + */ + public function hasCaptcha($username, $tries = BRUTEFORCE_CAPTCHA) + { + return $this->getFailedLogin($username) >= $tries; + } +} diff --git a/app/Model/UserMention.php b/app/Model/UserMention.php new file mode 100644 index 00000000..97a4e419 --- /dev/null +++ b/app/Model/UserMention.php @@ -0,0 +1,61 @@ +<?php + +namespace Kanboard\Model; + +use Kanboard\Event\GenericEvent; + +/** + * User Mention + * + * @package model + * @author Frederic Guillot + */ +class UserMention extends Base +{ + /** + * Get list of mentioned users + * + * @access public + * @param string $content + * @return array + */ + public function getMentionedUsers($content) + { + $users = array(); + + if (preg_match_all('/@([^\s]+)/', $content, $matches)) { + $users = $this->db->table(User::TABLE) + ->columns('id', 'username', 'name', 'email', 'language') + ->eq('notifications_enabled', 1) + ->neq('id', $this->userSession->getId()) + ->in('username', array_unique($matches[1])) + ->findAll(); + } + + return $users; + } + + /** + * Fire events for user mentions + * + * @access public + * @param string $content + * @param string $eventName + * @param GenericEvent $event + */ + public function fireEvents($content, $eventName, GenericEvent $event) + { + if (empty($event['project_id'])) { + $event['project_id'] = $this->taskFinder->getProjectId($event['task_id']); + } + + $users = $this->getMentionedUsers($content); + + foreach ($users as $user) { + if ($this->projectPermission->isMember($event['project_id'], $user['id'])) { + $event['mention'] = $user; + $this->dispatcher->dispatch($eventName, $event); + } + } + } +} diff --git a/app/Model/UserNotification.php b/app/Model/UserNotification.php index 3d98ebe9..e8a967ac 100644 --- a/app/Model/UserNotification.php +++ b/app/Model/UserNotification.php @@ -21,18 +21,12 @@ class UserNotification extends Base */ public function sendNotifications($event_name, array $event_data) { - $logged_user_id = $this->userSession->isLogged() ? $this->userSession->getId() : 0; - $users = $this->getUsersWithNotificationEnabled($event_data['task']['project_id'], $logged_user_id); - - if (! empty($users)) { - foreach ($users as $user) { - if ($this->userNotificationFilter->shouldReceiveNotification($user, $event_data)) { - $this->sendUserNotification($user, $event_name, $event_data); - } - } + $users = $this->getUsersWithNotificationEnabled($event_data['task']['project_id'], $this->userSession->getId()); - // Restore locales - $this->config->setupTranslations(); + foreach ($users as $user) { + if ($this->userNotificationFilter->shouldReceiveNotification($user, $event_data)) { + $this->sendUserNotification($user, $event_name, $event_data); + } } } @@ -58,6 +52,9 @@ class UserNotification extends Base foreach ($this->userNotificationType->getSelectedTypes($user['id']) as $type) { $this->userNotificationType->getType($type)->notifyUser($user, $event_name, $event_data); } + + // Restore locales + $this->config->setupTranslations(); } /** @@ -74,7 +71,17 @@ class UserNotification extends Base return $this->getEverybodyWithNotificationEnabled($exclude_user_id); } - return $this->getProjectMembersWithNotificationEnabled($project_id, $exclude_user_id); + $users = array(); + $members = $this->getProjectUserMembersWithNotificationEnabled($project_id, $exclude_user_id); + $groups = $this->getProjectGroupMembersWithNotificationEnabled($project_id, $exclude_user_id); + + foreach (array_merge($members, $groups) as $user) { + if (! isset($users[$user['id']])) { + $users[$user['id']] = $user; + } + } + + return array_values($users); } /** @@ -145,17 +152,17 @@ class UserNotification extends Base } /** - * Get a list of project members with notification enabled + * Get a list of group members with notification enabled * * @access private * @param integer $project_id Project id * @param integer $exclude_user_id User id to exclude * @return array */ - private function getProjectMembersWithNotificationEnabled($project_id, $exclude_user_id) + private function getProjectUserMembersWithNotificationEnabled($project_id, $exclude_user_id) { return $this->db - ->table(ProjectPermission::TABLE) + ->table(ProjectUserRole::TABLE) ->columns(User::TABLE.'.id', User::TABLE.'.username', User::TABLE.'.name', User::TABLE.'.email', User::TABLE.'.language', User::TABLE.'.notifications_filter') ->join(User::TABLE, 'id', 'user_id') ->eq('project_id', $project_id) @@ -164,6 +171,19 @@ class UserNotification extends Base ->findAll(); } + private function getProjectGroupMembersWithNotificationEnabled($project_id, $exclude_user_id) + { + return $this->db + ->table(ProjectGroupRole::TABLE) + ->columns(User::TABLE.'.id', User::TABLE.'.username', User::TABLE.'.name', User::TABLE.'.email', User::TABLE.'.language', User::TABLE.'.notifications_filter') + ->join(GroupMember::TABLE, 'group_id', 'group_id', ProjectGroupRole::TABLE) + ->join(User::TABLE, 'id', 'user_id', GroupMember::TABLE) + ->eq(ProjectGroupRole::TABLE.'.project_id', $project_id) + ->eq(User::TABLE.'.notifications_enabled', '1') + ->neq(User::TABLE.'.id', $exclude_user_id) + ->findAll(); + } + /** * Get a list of project members with notification enabled * diff --git a/app/Model/UserSession.php b/app/Model/UserSession.php deleted file mode 100644 index 1778114e..00000000 --- a/app/Model/UserSession.php +++ /dev/null @@ -1,177 +0,0 @@ -<?php - -namespace Kanboard\Model; - -/** - * User Session - * - * @package model - * @author Frederic Guillot - */ -class UserSession extends Base -{ - /** - * Update user session information - * - * @access public - * @param array $user User data - */ - public function refresh(array $user = array()) - { - if (empty($user)) { - $user = $this->user->getById($this->userSession->getId()); - } - - if (isset($user['password'])) { - unset($user['password']); - } - - if (isset($user['twofactor_secret'])) { - unset($user['twofactor_secret']); - } - - $user['id'] = (int) $user['id']; - $user['is_admin'] = (bool) $user['is_admin']; - $user['is_project_admin'] = (bool) $user['is_project_admin']; - $user['is_ldap_user'] = (bool) $user['is_ldap_user']; - $user['twofactor_activated'] = (bool) $user['twofactor_activated']; - - $this->session['user'] = $user; - } - - /** - * Return true if the user has validated the 2FA key - * - * @access public - * @return bool - */ - public function check2FA() - { - return isset($this->session['2fa_validated']) && $this->session['2fa_validated'] === true; - } - - /** - * Return true if the user has 2FA enabled - * - * @access public - * @return bool - */ - public function has2FA() - { - return isset($this->session['user']['twofactor_activated']) && $this->session['user']['twofactor_activated'] === true; - } - - /** - * Return true if the logged user is admin - * - * @access public - * @return bool - */ - public function isAdmin() - { - return isset($this->session['user']['is_admin']) && $this->session['user']['is_admin'] === true; - } - - /** - * Return true if the logged user is project admin - * - * @access public - * @return bool - */ - public function isProjectAdmin() - { - return isset($this->session['user']['is_project_admin']) && $this->session['user']['is_project_admin'] === true; - } - - /** - * Get the connected user id - * - * @access public - * @return integer - */ - public function getId() - { - return isset($this->session['user']['id']) ? (int) $this->session['user']['id'] : 0; - } - - /** - * Check is the user is connected - * - * @access public - * @return bool - */ - public function isLogged() - { - 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; - } - - /** - * Set comments sorting - * - * @access public - * @param string $order - */ - public function setCommentSorting($order) - { - $this->session['comment_sorting'] = $order; - } - - /** - * Get comments sorting direction - * - * @access public - * @return string - */ - public function getCommentSorting() - { - return $this->session['comment_sorting'] ?: 'ASC'; - } -} |