diff options
Diffstat (limited to 'includes/class.backend.php')
-rw-r--r-- | includes/class.backend.php | 1869 |
1 files changed, 1869 insertions, 0 deletions
diff --git a/includes/class.backend.php b/includes/class.backend.php new file mode 100644 index 0000000..f440db9 --- /dev/null +++ b/includes/class.backend.php @@ -0,0 +1,1869 @@ +<?php +/** + * Flyspray + * + * Backend class + * + * This script contains reusable functions we use to modify + * various things in the Flyspray database tables. + * + * @license http://opensource.org/licenses/lgpl-license.php Lesser GNU Public License + * @package flyspray + * @author Tony Collins, Florian Schmitz + */ + +if (!defined('IN_FS')) { + die('Do not access this file directly.'); +} + +abstract class Backend +{ + /** + * Adds the user $user_id to the notifications list of $tasks + * @param integer $user_id + * @param array $tasks + * @param bool $do Force execution independent of user permissions + * @access public + * @return bool + * @version 1.0 + */ + public static function add_notification($user_id, $tasks, $do = false) + { + global $db, $user; + + settype($tasks, 'array'); + + $user_id = Flyspray::validUserId($user_id); + + if (!$user_id || !count($tasks)) { + return false; + } + + $sql = $db->query(' SELECT * + FROM {tasks} + WHERE ' . substr(str_repeat(' task_id = ? OR ', count($tasks)), 0, -3), + $tasks); + + while ($row = $db->fetchRow($sql)) { + // -> user adds himself + if ($user->id == $user_id) { + if (!$user->can_view_task($row) && !$do) { + continue; + } + // -> user is added by someone else + } else { + if (!$user->perms('manage_project', $row['project_id']) && !$do) { + continue; + } + } + + $notif = $db->query('SELECT notify_id + FROM {notifications} + WHERE task_id = ? and user_id = ?', + array($row['task_id'], $user_id)); + + if (!$db->countRows($notif)) { + $db->query('INSERT INTO {notifications} (task_id, user_id) + VALUES (?,?)', array($row['task_id'], $user_id)); + Flyspray::logEvent($row['task_id'], 9, $user_id); + } + } + + return (bool) $db->countRows($sql); + } + + + /** + * Removes a user $user_id from the notifications list of $tasks + * @param integer $user_id + * @param array $tasks + * @access public + * @return void + * @version 1.0 + */ + + public static function remove_notification($user_id, $tasks) + { + global $db, $user; + + settype($tasks, 'array'); + + if (!count($tasks)) { + return; + } + + $sql = $db->query(' SELECT * + FROM {tasks} + WHERE ' . substr(str_repeat(' task_id = ? OR ', count($tasks)), 0, -3), + $tasks); + + while ($row = $db->fetchRow($sql)) { + // -> user removes himself + if ($user->id == $user_id) { + if (!$user->can_view_task($row)) { + continue; + } + // -> user is removed by someone else + } else { + if (!$user->perms('manage_project', $row['project_id'])) { + continue; + } + } + + $db->query('DELETE FROM {notifications} + WHERE task_id = ? AND user_id = ?', + array($row['task_id'], $user_id)); + if ($db->affectedRows()) { + Flyspray::logEvent($row['task_id'], 10, $user_id); + } + } + } + + + /** + * Assigns one or more $tasks only to a user $user_id + * @param integer $user_id + * @param array $tasks + * @access public + * @return void + * @version 1.0 + */ + public static function assign_to_me($user_id, $tasks) + { + global $db, $notify; + + $user = $GLOBALS['user']; + if ($user_id != $user->id) { + $user = new User($user_id); + } + + settype($tasks, 'array'); + if (!count($tasks)) { + return; + } + + $sql = $db->query(' SELECT * + FROM {tasks} + WHERE ' . substr(str_repeat(' task_id = ? OR ', count($tasks)), 0, -3), + $tasks); + + while ($row = $db->fetchRow($sql)) { + if (!$user->can_take_ownership($row)) { + continue; + } + + $db->query('DELETE FROM {assigned} + WHERE task_id = ?', + array($row['task_id'])); + + $db->query('INSERT INTO {assigned} + (task_id, user_id) + VALUES (?,?)', + array($row['task_id'], $user->id)); + + if ($db->affectedRows()) { + $current_proj = new Project($row['project_id']); + Flyspray::logEvent($row['task_id'], 19, $user->id, implode(' ', Flyspray::getAssignees($row['task_id']))); + $notify->create(NOTIFY_OWNERSHIP, $row['task_id'], null, null, NOTIFY_BOTH, $current_proj->prefs['lang_code']); + } + + if ($row['item_status'] == STATUS_UNCONFIRMED || $row['item_status'] == STATUS_NEW) { + $db->query('UPDATE {tasks} SET item_status = 3 WHERE task_id = ?', array($row['task_id'])); + Flyspray::logEvent($row['task_id'], 3, 3, 1, 'item_status'); + } + } + } + + /** + * Adds a user $user_id to the assignees of one or more $tasks + * @param integer $user_id + * @param array $tasks + * @param bool $do Force execution independent of user permissions + * @access public + * @return void + * @version 1.0 + */ + public static function add_to_assignees($user_id, $tasks, $do = false) + { + global $db, $notify; + + settype($tasks, 'array'); + + $user = $GLOBALS['user']; + if ($user_id != $user->id) { + $user = new User($user_id); + } + + settype($tasks, 'array'); + if (!count($tasks)) { + return; + } + + $sql = $db->query(' SELECT * + FROM {tasks} + WHERE ' . substr(str_repeat(' task_id = ? OR ', count($tasks)), 0, -3), + $tasks); + + while ($row = $db->fetchRow($sql)) { + if (!$user->can_add_to_assignees($row) && !$do) { + continue; + } + + $db->replace('{assigned}', array('user_id'=> $user->id, 'task_id'=> $row['task_id']), array('user_id','task_id')); + + if ($db->affectedRows()) { + $current_proj = new Project($row['project_id']); + Flyspray::logEvent($row['task_id'], 29, $user->id, implode(' ', Flyspray::getAssignees($row['task_id']))); + $notify->create(NOTIFY_ADDED_ASSIGNEES, $row['task_id'], null, null, NOTIFY_BOTH, $current_proj->prefs['lang_code']); + } + + if ($row['item_status'] == STATUS_UNCONFIRMED || $row['item_status'] == STATUS_NEW) { + $db->query('UPDATE {tasks} SET item_status = 3 WHERE task_id = ?', array($row['task_id'])); + Flyspray::logEvent($row['task_id'], 3, 3, 1, 'item_status'); + } + } + } + + /** + * Adds a vote from $user_id to the task $task_id + * @param integer $user_id + * @param integer $task_id + * @access public + * @return bool + * @version 1.0 + */ + public static function add_vote($user_id, $task_id) + { + global $db; + + $user = $GLOBALS['user']; + if ($user_id != $user->id) { + $user = new User($user_id); + } + + $task = Flyspray::getTaskDetails($task_id); + + if (!$task) { + return false; + } + + if ($user->can_vote($task) > 0) { + + if($db->query("INSERT INTO {votes} (user_id, task_id, date_time) + VALUES (?,?,?)", array($user->id, $task_id, time()))) { + // TODO: Log event in a later version. + return true; + } + } + return false; + } + + /** + * Removes a vote from $user_id to the task $task_id + * @param integer $user_id + * @param integer $task_id + * @access public + * @return bool + * @version 1.0 + */ + public static function remove_vote($user_id, $task_id) + { + global $db; + + $user = $GLOBALS['user']; + if ($user_id != $user->id) { + $user = new User($user_id); + } + + $task = Flyspray::getTaskDetails($task_id); + + if (!$task) { + return false; + } + + if ($user->can_vote($task) == -2) { + + if($db->query("DELETE FROM {votes} WHERE user_id = ? and task_id = ?", + array($user->id, $task_id))) { + // TODO: Log event in a later version. + return true; + } + } + return false; + } + + /** + * Adds a comment to $task + * @param array $task + * @param string $comment_text + * @param integer $time for synchronisation with other functions + * @access public + * @return bool + * @version 1.0 + */ + public static function add_comment($task, $comment_text, $time = null) + { + global $conf, $db, $user, $notify, $proj; + + if (!($user->perms('add_comments', $task['project_id']) && (!$task['is_closed'] || $user->perms('comment_closed', $task['project_id'])))) { + return false; + } + + if($conf['general']['syntax_plugin'] != 'dokuwiki'){ + $purifierconfig = HTMLPurifier_Config::createDefault(); + $purifier = new HTMLPurifier($purifierconfig); + $comment_text = $purifier->purify($comment_text); + } + + if (!is_string($comment_text) || !strlen($comment_text)) { + return false; + } + + $time = !is_numeric($time) ? time() : $time ; + + $db->query('INSERT INTO {comments} + (task_id, date_added, last_edited_time, user_id, comment_text) + VALUES ( ?, ?, ?, ?, ? )', + array($task['task_id'], $time, $time, $user->id, $comment_text)); + $cid = $db->Insert_ID(); + Backend::upload_links($task['task_id'], $cid); + Flyspray::logEvent($task['task_id'], 4, $cid); + + if (Backend::upload_files($task['task_id'], $cid)) { + $notify->create(NOTIFY_COMMENT_ADDED, $task['task_id'], 'files', null, NOTIFY_BOTH, $proj->prefs['lang_code']); + } else { + $notify->create(NOTIFY_COMMENT_ADDED, $task['task_id'], null, null, NOTIFY_BOTH, $proj->prefs['lang_code']); + } + + + return true; + } + + /** + * Upload files for a comment or a task + * @param integer $task_id + * @param integer $comment_id if it is 0, the files will be attached to the task itself + * @param string $source name of the file input + * @access public + * @return bool + * @version 1.0 + */ + public static function upload_files($task_id, $comment_id = 0, $source = 'userfile') + { + global $db, $notify, $conf, $user; + + $task = Flyspray::getTaskDetails($task_id); + + if (!$user->perms('create_attachments', $task['project_id'])) { + return false; + } + + $res = false; + + if (!isset($_FILES[$source]['error'])) { + return false; + } + + foreach ($_FILES[$source]['error'] as $key => $error) { + if ($error != UPLOAD_ERR_OK) { + continue; + } + + + $fname = substr($task_id . '_' . md5(uniqid(mt_rand(), true)), 0, 30); + $path = BASEDIR .'/attachments/'. $fname ; + + $tmp_name = $_FILES[$source]['tmp_name'][$key]; + + // Then move the uploaded file and remove exe permissions + if(!@move_uploaded_file($tmp_name, $path)) { + //upload failed. continue + continue; + } + + @chmod($path, 0644); + $res = true; + + // Use a different MIME type + $fileparts = explode( '.', $_FILES[$source]['name'][$key]); + $extension = end($fileparts); + if (isset($conf['attachments'][$extension])) { + $_FILES[$source]['type'][$key] = $conf['attachments'][$extension]; + //actually, try really hard to get the real filetype, not what the browser reports. + } elseif($type = Flyspray::check_mime_type($path)) { + $_FILES[$source]['type'][$key] = $type; + }// we can try even more, however, far too much code is needed. + + $db->query("INSERT INTO {attachments} + ( task_id, comment_id, file_name, + file_type, file_size, orig_name, + added_by, date_added) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + array($task_id, $comment_id, $fname, + $_FILES[$source]['type'][$key], + $_FILES[$source]['size'][$key], + $_FILES[$source]['name'][$key], + $user->id, time())); + $attid = $db->insert_ID(); + Flyspray::logEvent($task_id, 7, $attid, $_FILES[$source]['name'][$key]); + } + + return $res; + } + + public static function upload_links($task_id, $comment_id = 0, $source = 'userlink') + { + global $db, $user; + + $task = Flyspray::getTaskDetails($task_id); + + if (!$user->perms('create_attachments', $task['project_id'])) { + return false; + } + + if (!isset($_POST[$source])) { + return false; + } + + $res = false; + foreach($_POST[$source] as $text) { + $text = filter_var($text, FILTER_SANITIZE_URL); + + if( preg_match( '/^\s*(javascript:|data:)/', $text)){ + continue; + } + + if(empty($text)) { + continue; + } + + $res = true; + + // Insert into database + $db->query("INSERT INTO {links} (task_id, comment_id, url, added_by, date_added) VALUES (?, ?, ?, ?, ?)", + array($task_id, $comment_id, $text, $user->id, time())); + // TODO: Log event in a later version. + } + + return $res; + } + + /** + * Delete one or more attachments of a task or comment + * @param array $attachments + * @access public + * @return void + * @version 1.0 + */ + public static function delete_files($attachments) + { + global $db, $user; + + settype($attachments, 'array'); + if (!count($attachments)) { + return; + } + + $sql = $db->query(' SELECT t.*, a.* + FROM {attachments} a + LEFT JOIN {tasks} t ON t.task_id = a.task_id + WHERE ' . substr(str_repeat(' attachment_id = ? OR ', count($attachments)), 0, -3), + $attachments); + + while ($task = $db->fetchRow($sql)) { + if (!$user->perms('delete_attachments', $task['project_id'])) { + continue; + } + + $db->query('DELETE FROM {attachments} WHERE attachment_id = ?', + array($task['attachment_id'])); + @unlink(BASEDIR . '/attachments/' . $task['file_name']); + Flyspray::logEvent($task['task_id'], 8, $task['orig_name']); + } + } + + public static function delete_links($links) + { + global $db, $user; + + settype($links, 'array'); + + if(!count($links)) { + return; + } + + $sql = $db->query('SELECT t.*, l.* FROM {links} l LEFT JOIN {tasks} t ON t.task_id = l.task_id WHERE '.substr(str_repeat('link_id = ? OR ', count($links)), 0, -3), $links); + + //Delete from database + while($task = $db->fetchRow($sql)) { + if (!$user->perms('delete_attachments', $task['project_id'])) { + continue; + } + + $db->query('DELETE FROM {links} WHERE link_id = ?', array($task['link_id'])); + // TODO: Log event in a later version. + } + } + + /** + * Cleans a username (length, special chars, spaces) + * @param string $user_name + * @access public + * @return string + */ + public static function clean_username($user_name) + { + // Limit length + $user_name = substr(trim($user_name), 0, 32); + // Remove doubled up spaces and control chars + $user_name = preg_replace('![\x00-\x1f\s]+!u', ' ', $user_name); + // Strip special chars + return utf8_keepalphanum($user_name); + } + + public static function getAdminAddresses() { + global $db; + + $emails = array(); + $jabbers = array(); + $onlines = array(); + + $sql = $db->query('SELECT DISTINCT u.user_id, u.email_address, u.jabber_id, + u.notify_online, u.notify_type, u.notify_own, u.lang_code + FROM {users} u + JOIN {users_in_groups} ug ON u.user_id = ug.user_id + JOIN {groups} g ON g.group_id = ug.group_id + WHERE g.is_admin = 1 AND u.account_enabled = 1'); + + Notifications::assignRecipients($db->fetchAllArray($sql), $emails, $jabbers, $onlines); + + return array($emails, $jabbers, $onlines); + } + + public static function getProjectManagerAddresses($project_id) { + global $db; + + $emails = array(); + $jabbers = array(); + $onlines = array(); + + $sql = $db->query('SELECT DISTINCT u.user_id, u.email_address, u.jabber_id, + u.notify_online, u.notify_type, u.notify_own, u.lang_code + FROM {users} u + JOIN {users_in_groups} ug ON u.user_id = ug.user_id + JOIN {groups} g ON g.group_id = ug.group_id + WHERE g.manage_project = 1 AND g.project_id = ? AND u.account_enabled = 1', + array($project_id)); + + Notifications::assignRecipients($db->fetchAllArray($sql), $emails, $jabbers, $onlines); + + return array($emails, $jabbers, $onlines); + } + /** + * Creates a new user + * @param string $user_name + * @param string $password + * @param string $real_name + * @param string $jabber_id + * @param string $email + * @param integer $notify_type + * @param integer $time_zone + * @param integer $group_in + * @access public + * @return bool false if username is already taken + * @version 1.0 + * @notes This function does not have any permission checks (checked elsewhere) + */ + public static function create_user($user_name, $password, $real_name, $jabber_id, $email, $notify_type, $time_zone, $group_in, $enabled, $oauth_uid = '', $oauth_provider = '', $profile_image = '') + { + global $fs, $db, $notify, $baseurl; + + $user_name = Backend::clean_username($user_name); + + // TODO Handle this whole create_user better concerning return false. Why did it fail? + # 'notassigned' and '-1' are possible filtervalues for advanced task search + if( empty($user_name) || ctype_digit($user_name) || $user_name == '-1' || $user_name=='notassigned' ) { + return false; + } + + // Limit length + $real_name = substr(trim($real_name), 0, 100); + // Remove doubled up spaces and control chars + $real_name = preg_replace('![\x00-\x1f\s]+!u', ' ', $real_name); + + # 'notassigned' and '-1' are possible filtervalues for advanced task search, lets avoid them + if( ctype_digit($real_name) || $real_name == '-1' || $real_name=='notassigned' ) { + return false; + } + + // Check to see if the username is available + $sql = $db->query('SELECT COUNT(*) FROM {users} WHERE user_name = ?', array($user_name)); + + if ($db->fetchOne($sql)) { + return false; + } + + $auto = false; + // Autogenerate a password + if (!$password) { + $auto = true; + $password = substr(md5(uniqid(mt_rand(), true)), 0, mt_rand(8, 12)); + } + + // Check the emails before inserting anything to database. + $emailList = explode(';',$email); + foreach ($emailList as $mail) { //Still need to do: check email + $count = $db->query("SELECT COUNT(*) FROM {user_emails} WHERE email_address = ?",array($mail)); + $count = $db->fetchOne($count); + if ($count > 0) { + Flyspray::show_error("Email address has alredy been taken"); + return false; + } + } + + $db->query("INSERT INTO {users} + ( user_name, user_pass, real_name, jabber_id, profile_image, magic_url, + email_address, notify_type, account_enabled, + tasks_perpage, register_date, time_zone, dateformat, + dateformat_extended, oauth_uid, oauth_provider, lang_code) + VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, 25, ?, ?, ?, ?, ?, ?, ?)", + array($user_name, Flyspray::cryptPassword($password), $real_name, strtolower($jabber_id), + $profile_image, '', strtolower($email), $notify_type, $enabled, time(), $time_zone, '', '', $oauth_uid, $oauth_provider, $fs->prefs['lang_code'])); + + // Get this user's id for the record + $uid = Flyspray::userNameToId($user_name); + + foreach ($emailList as $mail) { + if ($mail != '') { + $db->query("INSERT INTO {user_emails}(id,email_address,oauth_uid,oauth_provider) VALUES (?,?,?,?)", + array($uid,strtolower($mail),$oauth_uid, $oauth_provider)); + } + } + + // Now, create a new record in the users_in_groups table + $db->query('INSERT INTO {users_in_groups} (user_id, group_id) + VALUES (?, ?)', array($uid, $group_in)); + + Flyspray::logEvent(0, 30, serialize(Flyspray::getUserDetails($uid))); + + $varnames = array('iwatch','atome','iopened'); + + $toserialize = array('string' => NULL, + 'type' => array (''), + 'sev' => array (''), + 'due' => array (''), + 'dev' => NULL, + 'cat' => array (''), + 'status' => array ('open'), + 'order' => NULL, + 'sort' => NULL, + 'percent' => array (''), + 'opened' => NULL, + 'search_in_comments' => NULL, + 'search_for_all' => NULL, + 'reported' => array (''), + 'only_primary' => NULL, + 'only_watched' => NULL); + + foreach($varnames as $tmpname) { + if($tmpname == 'iwatch') { + $tmparr = array('only_watched' => '1'); + } elseif ($tmpname == 'atome') { + $tmparr = array('dev'=> $uid); + } elseif($tmpname == 'iopened') { + $tmparr = array('opened'=> $uid); + } + $$tmpname = $tmparr + $toserialize; + } + + // Now give him his default searches + $db->query('INSERT INTO {searches} (user_id, name, search_string, time) + VALUES (?, ?, ?, ?)', + array($uid, L('taskswatched'), serialize($iwatch), time())); + $db->query('INSERT INTO {searches} (user_id, name, search_string, time) + VALUES (?, ?, ?, ?)', + array($uid, L('assignedtome'), serialize($atome), time())); + $db->query('INSERT INTO {searches} (user_id, name, search_string, time) + VALUES (?, ?, ?, ?)', + array($uid, L('tasksireported'), serialize($iopened), time())); + + if ($jabber_id) { + Notifications::jabberRequestAuth($jabber_id); + } + + // Send a user his details (his username might be altered, password auto-generated) + // dont send notifications if the user logged in using oauth + if (!$oauth_provider) { + $recipients = self::getAdminAddresses(); + $newuser = array(); + + // Add the right message here depending on $enabled. + if ($enabled === 0) { + $newuser[0][$email] = array('recipient' => $email, 'lang' => $fs->prefs['lang_code']); + + } else { + $newuser[0][$email] = array('recipient' => $email, 'lang' => $fs->prefs['lang_code']); + } + + // Notify the appropriate users + if ($fs->prefs['notify_registration']) { + $notify->create(NOTIFY_NEW_USER, null, + array($baseurl, $user_name, $real_name, $email, $jabber_id, $password, $auto), + $recipients, NOTIFY_EMAIL); + } + // And also the new user + $notify->create(NOTIFY_OWN_REGISTRATION, null, + array($baseurl, $user_name, $real_name, $email, $jabber_id, $password, $auto), + $newuser, NOTIFY_EMAIL); + } + + // If the account is created as not enabled, no matter what any + // preferences might say or how the registration was made in first + // place, it MUST be first approved by an admin. And a small + // work-around: there's no field for email, so we use reason_given + // for that purpose. + if ($enabled === 0) { + Flyspray::adminRequest(3, 0, 0, $uid, $email); + } + + return true; + } + + /** + * Deletes a user + * @param integer $uid + * @access public + * @return bool + * @version 1.0 + */ + public static function delete_user($uid) + { + global $db, $user; + + if (!$user->perms('is_admin')) { + return false; + } + + $userDetails = Flyspray::getUserDetails($uid); + + if (is_file(BASEDIR.'/avatars/'.$userDetails['profile_image'])) { + unlink(BASEDIR.'/avatars/'.$userDetails['profile_image']); + } + + $tables = array('users', 'users_in_groups', 'searches', 'notifications', 'assigned', 'votes', 'effort'); + # FIXME Deleting a users effort without asking when user is deleted may not be wanted in every situation. + # For example for billing a project and the deleted user worked for a project. + # The better solution is to just deactivate the user, but maybe there are cases a user MUSt be deleted from the database. + # Move that effort to an 'anonymous users' effort if the effort(s) was legal and should be measured for project(s)? + foreach ($tables as $table) { + if (!$db->query('DELETE FROM ' .'{' . $table .'}' . ' WHERE user_id = ?', array($uid))) { + return false; + } + } + + if (!empty($userDetails['profile_image']) && is_file(BASEDIR.'/avatars/'.$userDetails['profile_image'])) { + unlink(BASEDIR.'/avatars/'.$userDetails['profile_image']); + } + + $db->query('DELETE FROM {registrations} WHERE email_address = ?', + array($userDetails['email_address'])); + + $db->query('DELETE FROM {user_emails} WHERE id = ?', + array($uid)); + + $db->query('DELETE FROM {reminders} WHERE to_user_id = ? OR from_user_id = ?', + array($uid, $uid)); + + // for the unusual situuation that a user ID is re-used, make sure that the new user doesn't + // get permissions for a task automatically + $db->query('UPDATE {tasks} SET opened_by = 0 WHERE opened_by = ?', array($uid)); + + Flyspray::logEvent(0, 31, serialize($userDetails)); + + return true; + } + + + /** + * Deletes a project + * @param integer $pid + * @param integer $move_to to which project contents of the project are moved + * @access public + * @return bool + * @version 1.0 + */ + public static function delete_project($pid, $move_to = 0) + { + global $db, $user; + + if (!$user->perms('manage_project', $pid)) { + return false; + } + + // Delete all project's tasks related information + if (!$move_to) { + $task_ids = $db->query('SELECT task_id FROM {tasks} WHERE project_id = ' . intval($pid)); + $task_ids = $db->fetchCol($task_ids); + // What was supposed to be in tables field_values, notification_threads + // and redundant, they do not exist in database? + $tables = array('admin_requests', 'assigned', 'attachments', 'comments', + 'dependencies', 'related', 'history', + 'notifications', + 'reminders', 'votes'); + foreach ($tables as $table) { + if ($table == 'related') { + $stmt = $db->dblink->prepare('DELETE FROM ' . $db->dbprefix . $table . ' WHERE this_task = ? OR related_task = ? '); + } else { + $stmt = $db->dblink->prepare('DELETE FROM ' . $db->dbprefix . $table . ' WHERE task_id = ?'); + } + foreach ($task_ids as $id) { + $db->dblink->execute($stmt, ($table == 'related') ? array($id, $id) : array($id)); + } + } + } + + // unset category of tasks because we don't move categories + if ($move_to) { + $db->query('UPDATE {tasks} SET product_category = 0 WHERE project_id = ?', array($pid)); + } + + $tables = array('list_category', 'list_os', 'list_resolution', 'list_tasktype', + 'list_status', 'list_version', 'admin_requests', + 'cache', 'projects', 'tasks'); + + foreach ($tables as $table) { + if ($move_to && $table !== 'projects' && $table !== 'list_category') { + // Having a unique index in most list_* tables prevents + // doing just a simple update, if the list item already + // exists in target project, so we have to update existing + // tasks to use the one in target project. Something similar + // should be done when moving a single task to another project. + // Consider making this a separate function that can be used + // for that purpose too, if possible. + if (strpos($table, 'list_') === 0) { + list($type, $name) = explode('_', $table); + $sql = $db->query('SELECT ' . $name . '_id, ' . $name . '_name + FROM {' . $table . '} + WHERE project_id = ?', + array($pid)); + $rows = $db->fetchAllArray($sql); + foreach ($rows as $row) { + $sql = $db->query('SELECT ' . $name . '_id + FROM {' . $table . '} + WHERE project_id = ? AND '. $name . '_name = ?', + array($move_to, $row[$name .'_name'])); + $new_id = $db->fetchOne($sql); + if ($new_id) { + switch ($name) { + case 'os'; + $column = 'operating_system'; + break; + case 'resolution'; + $column = 'resolution_reason'; + break; + case 'tasktype'; + $column = 'task_type'; + break; + case 'status'; + $column = 'item_status'; + break; + case 'version'; + // Questionable what to do with this one. 1.0 could + // have been still future in the old project and + // already past in the new one... + $column = 'product_version'; + break; + } + if (isset($column)) { + $db->query('UPDATE {tasks} + SET ' . $column . ' = ? + WHERE ' . $column . ' = ?', + array($new_id, $row[$name . '_id'])); + $db->query('DELETE FROM {' . $table . '} + WHERE ' . $name . '_id = ?', + array($row[$name . '_id'])); + } + } + } + } + $base_sql = 'UPDATE {' . $table . '} SET project_id = ?'; + $sql_params = array($move_to, $pid); + } else { + $base_sql = 'DELETE FROM {' . $table . '}'; + $sql_params = array($pid); + } + + if (!$db->query($base_sql . ' WHERE project_id = ?', $sql_params)) { + return false; + } + } + + // groups are only deleted, not moved (it is likely + // that the destination project already has all kinds + // of groups which are also used by the old project) + $sql = $db->query('SELECT group_id FROM {groups} WHERE project_id = ?', array($pid)); + while ($row = $db->fetchRow($sql)) { + $db->query('DELETE FROM {users_in_groups} WHERE group_id = ?', array($row['group_id'])); + } + $sql = $db->query('DELETE FROM {groups} WHERE project_id = ?', array($pid)); + + //we have enough reasons .. the process is OK. + return true; + } + + /** + * Adds a reminder to a task + * @param integer $task_id + * @param string $message + * @param integer $how_often send a reminder every ~ seconds + * @param integer $start_time time when the reminder starts + * @param $user_id the user who is reminded. by default (null) all users assigned to the task are reminded. + * @access public + * @return bool + * @version 1.0 + */ + public static function add_reminder($task_id, $message, $how_often, $start_time, $user_id = null) + { + global $user, $db; + $task = Flyspray::getTaskDetails($task_id); + + if (!$user->perms('manage_project', $task['project_id'])) { + return false; + } + + if (is_null($user_id)) { + // Get all users assigned to a task + $user_id = Flyspray::getAssignees($task_id); + } else { + $user_id = array(Flyspray::validUserId($user_id)); + if (!reset($user_id)) { + return false; + } + } + + foreach ($user_id as $id) { + $sql = $db->replace('{reminders}', + array('task_id'=> $task_id, 'to_user_id'=> $id, + 'from_user_id' => $user->id, 'start_time' => $start_time, + 'how_often' => $how_often, 'reminder_message' => $message), + array('task_id', 'to_user_id', 'how_often', 'reminder_message')); + if(!$sql) { + // query has failed :( + return false; + } + } + // 2 = no record has found and was INSERT'ed correclty + if (isset($sql) && $sql == 2) { + Flyspray::logEvent($task_id, 17, $task_id); + } + return true; + } + + /** + * Adds a new task + * @param array $args array containing all task properties. unknown properties will be ignored + * @access public + * @return integer the task ID on success + * @version 1.0 + * @notes $args is POST data, bad..bad user.. + */ + public static function create_task($args) + { + global $conf, $db, $user, $proj; + + if (!isset($args)) return 0; + + // these are the POST variables that the user MUST send, if one of + // them is missing or if one of them is empty, then we have to abort + $requiredPostArgs = array('item_summary', 'project_id');//modify: made description not required + foreach ($requiredPostArgs as $required) { + if (empty($args[$required])) return 0; + } + + $notify = new Notifications(); + if ($proj->id != $args['project_id']) { + $proj = new Project($args['project_id']); + } + + if (!$user->can_open_task($proj)) { + return 0; + } + + // first populate map with default values + $sql_args = array( + 'project_id' => $proj->id, + 'date_opened' => time(), + 'last_edited_time' => time(), + 'opened_by' => intval($user->id), + 'percent_complete' => 0, + 'mark_private' => 0, + 'supertask_id' => 0, + 'closedby_version' => 0, + 'closure_comment' => '', + 'task_priority' => 2, + 'due_date' => 0, + 'anon_email' => '', + 'item_status'=> STATUS_UNCONFIRMED + ); + + // POST variables the user is ALLOWED to provide + $allowedPostArgs = array( + 'task_type', 'product_category', 'product_version', + 'operating_system', 'task_severity', 'estimated_effort', + 'supertask_id', 'item_summary', 'detailed_desc' + ); + // these POST variables the user is only ALLOWED to provide if he got the permissions + if ($user->perms('modify_all_tasks')) { + $allowedPostArgs[] = 'closedby_version'; + $allowedPostArgs[] = 'task_priority'; + $allowedPostArgs[] = 'due_date'; + $allowedPostArgs[] = 'item_status'; + } + if ($user->perms('manage_project')) { + $allowedPostArgs[] = 'mark_private'; + } + // now copy all over all POST variables the user is ALLOWED to provide + // (but only if they are not empty) + foreach ($allowedPostArgs as $allowed) { + if (!empty($args[$allowed])) { + $sql_args[$allowed] = $args[$allowed]; + } + } + + // Process the due_date + if ( isset($args['due_date']) && ($due_date = $args['due_date']) || ($due_date = 0) ) { + $due_date = Flyspray::strtotime($due_date); + } + + $sql_params[] = 'mark_private'; + $sql_values[] = intval($user->perms('manage_project') && isset($args['mark_private']) && $args['mark_private'] == '1'); + + $sql_params[] = 'due_date'; + $sql_values[] = $due_date; + + $sql_params[] = 'closure_comment'; + $sql_values[] = ''; + + // Process estimated effort + $estimated_effort = 0; + if ($proj->prefs['use_effort_tracking'] && isset($sql_args['estimated_effort'])) { + if (($estimated_effort = effort::editStringToSeconds($sql_args['estimated_effort'], $proj->prefs['hours_per_manday'], $proj->prefs['estimated_effort_format'])) === FALSE) { + Flyspray::show_error(L('invalideffort')); + $estimated_effort = 0; + } + $sql_args['estimated_effort'] = $estimated_effort; + } + + // Token for anonymous users + $token = ''; + if ($user->isAnon()) { + if (empty($args['anon_email'])) { + return 0; + } + $token = md5(function_exists('openssl_random_pseudo_bytes') ? + openssl_random_pseudo_bytes(32) : + uniqid(mt_rand(), true)); + $sql_args['task_token'] = $token; + $sql_args['anon_email'] = $args['anon_email']; + } + + // ensure all variables are in correct format + if (!empty($sql_args['due_date'])) { + $sql_args['due_date'] = Flyspray::strtotime($sql_args['due_date']); + } + if (isset($sql_args['mark_private'])) { + $sql_args['mark_private'] = intval($sql_args['mark_private'] == '1'); + } + + # dokuwiki syntax plugin filters on output + if($conf['general']['syntax_plugin'] != 'dokuwiki' && isset($sql_args['detailed_desc']) ){ + $purifierconfig = HTMLPurifier_Config::createDefault(); + $purifier = new HTMLPurifier($purifierconfig); + $sql_args['detailed_desc'] = $purifier->purify($sql_args['detailed_desc']); + } + + // split keys and values into two separate arrays + $sql_keys = array(); + $sql_values = array(); + foreach ($sql_args as $key => $value) { + $sql_keys[] = $key; + $sql_values[] = $value; + } + + /* + * TODO: At least with PostgreSQL, this has caused the sequence to be + * out of sync with reality. Must be fixed in upgrade process. Check + * what's the situation with MySQL. (It's fine, it updates the value even + * if the column was manually adjusted. Remove this whole block later.) + $result = $db->query('SELECT MAX(task_id)+1 + FROM {tasks}'); + $task_id = $db->fetchOne($result); + $task_id = $task_id ? $task_id : 1; + */ + //now, $task_id is always the first element of $sql_values + #array_unshift($sql_keys, 'task_id'); + #array_unshift($sql_values, $task_id); + + $sql_keys_string = join(', ', $sql_keys); + $sql_placeholder = $db->fill_placeholders($sql_values); + + $result = $db->query("INSERT INTO {tasks} + ($sql_keys_string) + VALUES ($sql_placeholder)", $sql_values); + $task_id=$db->insert_ID(); + + Backend::upload_links($task_id); + + // create tags + if (isset($args['tags'])) { + $tagList = explode(';', $args['tags']); + $tagList = array_map('strip_tags', $tagList); + $tagList = array_map('trim', $tagList); + $tagList = array_unique($tagList); # avoid duplicates for inputs like: "tag1;tag1" or "tag1; tag1<p></p>" + foreach ($tagList as $tag){ + if ($tag == ''){ + continue; + } + + # old tag feature + #$result2 = $db->query("INSERT INTO {tags} (task_id, tag) VALUES (?,?)",array($task_id,$tag)); + + # new tag feature. let's do it in 2 steps, it is getting too complicated to make it cross database compatible, drawback is possible (rare) race condition (use transaction?) + $res=$db->query("SELECT tag_id FROM {list_tag} WHERE (project_id=0 OR project_id=?) AND tag_name LIKE ? ORDER BY project_id", array($proj->id,$tag) ); + if($t=$db->fetchRow($res)){ + $tag_id=$t['tag_id']; + } else{ + if( $proj->prefs['freetagging']==1){ + # add to taglist of the project + $db->query("INSERT INTO {list_tag} (project_id,tag_name) VALUES (?,?)", array($proj->id,$tag)); + $tag_id=$db->insert_ID(); + } else{ + continue; + } + }; + $db->query("INSERT INTO {task_tag}(task_id,tag_id) VALUES(?,?)", array($task_id, $tag_id) ); + } + } + + // Log the assignments and send notifications to the assignees + if (isset($args['rassigned_to']) && is_array($args['rassigned_to'])) + { + // Convert assigned_to and store them in the 'assigned' table + foreach ($args['rassigned_to'] as $val) + { + $db->replace('{assigned}', array('user_id'=> $val, 'task_id'=> $task_id), array('user_id','task_id')); + } + // Log to task history + Flyspray::logEvent($task_id, 14, implode(' ', $args['rassigned_to'])); + + // Notify the new assignees what happened. This obviously won't happen if the task is now assigned to no-one. + $notify->create(NOTIFY_NEW_ASSIGNEE, $task_id, null, $notify->specificAddresses($args['rassigned_to']), NOTIFY_BOTH, $proj->prefs['lang_code']); + } + + // Log that the task was opened + Flyspray::logEvent($task_id, 1); + + $result = $db->query('SELECT * + FROM {list_category} + WHERE category_id = ?', + array($args['product_category'])); + $cat_details = $db->fetchRow($result); + + // We need to figure out who is the category owner for this task + if (!empty($cat_details['category_owner'])) { + $owner = $cat_details['category_owner']; + } + else { + // check parent categories + $result = $db->query('SELECT * + FROM {list_category} + WHERE lft < ? AND rgt > ? AND project_id = ? + ORDER BY lft DESC', + array($cat_details['lft'], $cat_details['rgt'], $cat_details['project_id'])); + while ($row = $db->fetchRow($result)) { + // If there's a parent category owner, send to them + if (!empty($row['category_owner'])) { + $owner = $row['category_owner']; + break; + } + } + } + + if (!isset($owner)) { + $owner = $proj->prefs['default_cat_owner']; + } + + if ($owner) { + if ($proj->prefs['auto_assign'] && ($args['item_status'] == STATUS_UNCONFIRMED || $args['item_status'] == STATUS_NEW)) { + Backend::add_to_assignees($owner, $task_id, true); + } + Backend::add_notification($owner, $task_id, true); + } + + // Reminder for due_date field + if (!empty($sql_args['due_date'])) { + Backend::add_reminder($task_id, L('defaultreminder') . "\n\n" . createURL('details', $task_id), 2*24*60*60, time()); + } + + // Create the Notification + if (Backend::upload_files($task_id)) { + $notify->create(NOTIFY_TASK_OPENED, $task_id, 'files', null, NOTIFY_BOTH, $proj->prefs['lang_code']); + } else { + $notify->create(NOTIFY_TASK_OPENED, $task_id, null, null, NOTIFY_BOTH, $proj->prefs['lang_code']); + } + + // If the reporter wanted to be added to the notification list + if (isset($args['notifyme']) && $args['notifyme'] == '1' && $user->id != $owner) { + Backend::add_notification($user->id, $task_id, true); + } + + if ($user->isAnon()) { + $anonuser = array(); + $anonuser[$email] = array('recipient' => $args['anon_email'], 'lang' => $fs->prefs['lang_code']); + $recipients = array($anonuser); + $notify->create(NOTIFY_ANON_TASK, $task_id, $token, + $recipients, NOTIFY_EMAIL, $proj->prefs['lang_code']); + } + + return array($task_id, $token); + } + + /** + * Closes a task + * @param integer $task_id + * @param integer $reason + * @param string $comment + * @param bool $mark100 + * @access public + * @return bool + * @version 1.0 + */ + public static function close_task($task_id, $reason, $comment, $mark100 = true) + { + global $db, $notify, $user, $proj; + $task = Flyspray::getTaskDetails($task_id); + + if (!$user->can_close_task($task)) { + return false; + } + + if ($task['is_closed']) { + return false; + } + + $db->query('UPDATE {tasks} + SET date_closed = ?, closed_by = ?, closure_comment = ?, + is_closed = 1, resolution_reason = ?, last_edited_time = ?, + last_edited_by = ? + WHERE task_id = ?', + array(time(), $user->id, $comment, $reason, time(), $user->id, $task_id)); + + if ($mark100) { + $db->query('UPDATE {tasks} SET percent_complete = 100 WHERE task_id = ?', + array($task_id)); + + Flyspray::logEvent($task_id, 3, 100, $task['percent_complete'], 'percent_complete'); + } + + $notify->create(NOTIFY_TASK_CLOSED, $task_id, null, null, NOTIFY_BOTH, $proj->prefs['lang_code']); + Flyspray::logEvent($task_id, 2, $reason, $comment); + + // If there's an admin request related to this, close it + $db->query('UPDATE {admin_requests} + SET resolved_by = ?, time_resolved = ? + WHERE task_id = ? AND request_type = ?', + array($user->id, time(), $task_id, 1)); + + // duplicate + if ($reason == RESOLUTION_DUPLICATE) { + preg_match("/\b(?:FS#|bug )(\d+)\b/", $comment, $dupe_of); + if (count($dupe_of) >= 2) { + $existing = $db->query('SELECT * FROM {related} WHERE this_task = ? AND related_task = ? AND is_duplicate = 1', + array($task_id, $dupe_of[1])); + + if ($existing && $db->countRows($existing) == 0) { + $db->query('INSERT INTO {related} (this_task, related_task, is_duplicate) VALUES(?, ?, 1)', + array($task_id, $dupe_of[1])); + } + Backend::add_vote($task['opened_by'], $dupe_of[1]); + } + } + + return true; + } + + /** + * Returns an array of tasks (respecting pagination) and an ID list (all tasks) + * @param array $args + * @param array $visible + * @param integer $offset + * @param integer $comment + * @param bool $perpage + * @access public + * @return array + * @version 1.0 + */ + public static function get_task_list($args, $visible, $offset = 0, $perpage = 20) { + global $fs, $proj, $db, $user, $conf; + /* build SQL statement {{{ */ + // Original SQL courtesy of Lance Conry http://www.rhinosw.com/ + $where = $sql_params = array(); + + // echo '<pre>' . print_r($visible, true) . '</pre>'; + // echo '<pre>' . print_r($args, true) . '</pre>'; + // PostgreSQL LIKE searches are by default case sensitive, + // so we use ILIKE instead. For other databases, in our case + // only MySQL/MariaDB, LIKE is good for our purposes. + $LIKEOP = 'LIKE'; + if ($db->dblink->dataProvider == 'postgres') { + $LIKEOP = 'ILIKE'; + } + + $select = ''; + $groupby = 't.task_id, '; + $cgroupbyarr = array(); + + // Joins absolutely needed for user viewing rights + $from = ' {tasks} t +-- All tasks have a project! +JOIN {projects} p ON t.project_id = p.project_id'; + + // Not needed for anonymous users + if (!$user->isAnon()) { +$from .= ' -- Global group always exists +JOIN ({groups} gpg + JOIN {users_in_groups} gpuig ON gpg.group_id = gpuig.group_id AND gpuig.user_id = ? +) ON gpg.project_id = 0 +-- Project group might exist or not. +LEFT JOIN ({groups} pg + JOIN {users_in_groups} puig ON pg.group_id = puig.group_id AND puig.user_id = ? +) ON pg.project_id = t.project_id'; + $sql_params[] = $user->id; + $sql_params[] = $user->id; + } + + // Keep this always, could also used for showing assigned users for a task. + // Keeps the overall logic somewhat simpler. + $from .= ' LEFT JOIN {assigned} ass ON t.task_id = ass.task_id'; + $from .= ' LEFT JOIN {task_tag} tt ON t.task_id = tt.task_id'; + $cfrom = $from; + + // Seems resution name really is needed... + $select .= 'lr.resolution_name, '; + $from .= ' LEFT JOIN {list_resolution} lr ON t.resolution_reason = lr.resolution_id '; + $groupby .= 'lr.resolution_name, '; + + // Otherwise, only join tables which are really necessary to speed up the db-query + if (array_get($args, 'type') || in_array('tasktype', $visible)) { + $select .= ' lt.tasktype_name, '; + $from .= ' +LEFT JOIN {list_tasktype} lt ON t.task_type = lt.tasktype_id '; + $groupby .= ' lt.tasktype_id, '; + } + + if (array_get($args, 'status') || in_array('status', $visible)) { + $select .= ' lst.status_name, '; + $from .= ' +LEFT JOIN {list_status} lst ON t.item_status = lst.status_id '; + $groupby .= ' lst.status_id, '; + } + + if (array_get($args, 'cat') || in_array('category', $visible)) { + $select .= ' lc.category_name AS category_name, '; + $from .= ' +LEFT JOIN {list_category} lc ON t.product_category = lc.category_id '; + $groupby .= 'lc.category_id, '; + } + + if (in_array('votes', $visible)) { + $select .= ' (SELECT COUNT(vot.vote_id) FROM {votes} vot WHERE vot.task_id = t.task_id) AS num_votes, '; + } + + $maxdatesql = ' GREATEST(COALESCE((SELECT max(c.date_added) FROM {comments} c WHERE c.task_id = t.task_id), 0), t.date_opened, t.date_closed, t.last_edited_time) '; + $search_for_changes = in_array('lastedit', $visible) || array_get($args, 'changedto') || array_get($args, 'changedfrom'); + if ($search_for_changes) { + $select .= ' GREATEST(COALESCE((SELECT max(c.date_added) FROM {comments} c WHERE c.task_id = t.task_id), 0), t.date_opened, t.date_closed, t.last_edited_time) AS max_date, '; + $cgroupbyarr[] = 't.task_id'; + } + + if (array_get($args, 'search_in_comments')) { + $from .= ' +LEFT JOIN {comments} c ON t.task_id = c.task_id '; + $cfrom .= ' +LEFT JOIN {comments} c ON t.task_id = c.task_id '; + $cgroupbyarr[] = 't.task_id'; + } + + if (in_array('comments', $visible)) { + $select .= ' (SELECT COUNT(cc.comment_id) FROM {comments} cc WHERE cc.task_id = t.task_id) AS num_comments, '; + } + + if (in_array('reportedin', $visible)) { + $select .= ' lv.version_name AS product_version_name, '; + $from .= ' +LEFT JOIN {list_version} lv ON t.product_version = lv.version_id '; + $groupby .= 'lv.version_id, '; + } + + if (array_get($args, 'opened') || in_array('openedby', $visible)) { + $select .= ' uo.real_name AS opened_by_name, '; + $from .= ' +LEFT JOIN {users} uo ON t.opened_by = uo.user_id '; + $groupby .= 'uo.user_id, '; + if (array_get($args, 'opened')) { + $cfrom .= ' +LEFT JOIN {users} uo ON t.opened_by = uo.user_id '; + } + } + + if (array_get($args, 'closed')) { + $select .= ' uc.real_name AS closed_by_name, '; + $from .= ' +LEFT JOIN {users} uc ON t.closed_by = uc.user_id '; + $groupby .= 'uc.user_id, '; + $cfrom .= ' +LEFT JOIN {users} uc ON t.closed_by = uc.user_id '; + } + + if (array_get($args, 'due') || in_array('dueversion', $visible)) { + $select .= ' lvc.version_name AS closedby_version_name, '; + $from .= ' +LEFT JOIN {list_version} lvc ON t.closedby_version = lvc.version_id '; + $groupby .= 'lvc.version_id, lvc.list_position, '; + } + + if (in_array('os', $visible)) { + $select .= ' los.os_name AS os_name, '; + $from .= ' +LEFT JOIN {list_os} los ON t.operating_system = los.os_id '; + $groupby .= 'los.os_id, '; + } + + if (in_array('attachments', $visible)) { + $select .= ' (SELECT COUNT(attc.attachment_id) FROM {attachments} attc WHERE attc.task_id = t.task_id) AS num_attachments, '; + } + + if (array_get($args, 'has_attachment')) { + $where[] = 'EXISTS (SELECT 1 FROM {attachments} att WHERE t.task_id = att.task_id)'; + } + # 20150213 currently without recursive subtasks! + if (in_array('effort', $visible)) { + $select .= ' (SELECT SUM(ef.effort) FROM {effort} ef WHERE t.task_id = ef.task_id) AS effort, '; + } + + if (array_get($args, 'dev') || in_array('assignedto', $visible)) { + # not every db system has this feature out of box + if($conf['database']['dbtype']=='mysqli' || $conf['database']['dbtype']=='mysql'){ + $select .= ' GROUP_CONCAT(DISTINCT u.user_name ORDER BY u.user_id) AS assigned_to_name, '; + $select .= ' GROUP_CONCAT(DISTINCT u.user_id ORDER BY u.user_id) AS assignedids, '; + $select .= ' GROUP_CONCAT(DISTINCT u.profile_image ORDER BY u.user_id) AS assigned_image, '; + } elseif( $conf['database']['dbtype']=='pgsql'){ + $select .= " array_to_string(array_agg(u.user_name ORDER BY u.user_id), ',') AS assigned_to_name, "; + $select .= " array_to_string(array_agg(CAST(u.user_id as text) ORDER BY u.user_id), ',') AS assignedids, "; + $select .= " array_to_string(array_agg(u.profile_image ORDER BY u.user_id), ',') AS assigned_image, "; + } else{ + $select .= ' MIN(u.user_name) AS assigned_to_name, '; + $select .= ' (SELECT COUNT(assc.user_id) FROM {assigned} assc WHERE assc.task_id = t.task_id) AS num_assigned, '; + } + // assigned table is now always included in join + $from .= ' +LEFT JOIN {users} u ON ass.user_id = u.user_id '; + $groupby .= 'ass.task_id, '; + if (array_get($args, 'dev')) { + $cfrom .= ' +LEFT JOIN {users} u ON ass.user_id = u.user_id '; + $cgroupbyarr[] = 't.task_id'; + $cgroupbyarr[] = 'ass.task_id'; + } + } + + # not every db system has this feature out of box, it is not standard sql + if($conf['database']['dbtype']=='mysqli' || $conf['database']['dbtype']=='mysql'){ + #$select .= ' GROUP_CONCAT(DISTINCT tg.tag_name ORDER BY tg.list_position) AS tags, '; + $select .= ' GROUP_CONCAT(DISTINCT tg.tag_id ORDER BY tg.list_position) AS tagids, '; + #$select .= ' GROUP_CONCAT(DISTINCT tg.class ORDER BY tg.list_position) AS tagclass, '; + } elseif($conf['database']['dbtype']=='pgsql'){ + #$select .= " array_to_string(array_agg(tg.tag_name ORDER BY tg.list_position), ',') AS tags, "; + $select .= " array_to_string(array_agg(CAST(tg.tag_id as text) ORDER BY tg.list_position), ',') AS tagids, "; + #$select .= " array_to_string(array_agg(tg.class ORDER BY tg.list_position), ',') AS tagclass, "; + } else{ + # unsupported groupconcat or we just do not know how write it for the other databasetypes in this section + #$select .= ' MIN(tg.tag_name) AS tags, '; + #$select .= ' (SELECT COUNT(tt.tag_id) FROM {task_tag} tt WHERE tt.task_id = t.task_id) AS tagnum, '; + $select .= ' MIN(tg.tag_id) AS tagids, '; + #$select .= " '' AS tagclass, "; + } + // task_tag join table is now always included in join + $from .= ' +LEFT JOIN {list_tag} tg ON tt.tag_id = tg.tag_id '; + $groupby .= 'tt.task_id, '; + $cfrom .= ' +LEFT JOIN {list_tag} tg ON tt.tag_id = tg.tag_id '; + $cgroupbyarr[] = 't.task_id'; + $cgroupbyarr[] = 'tt.task_id'; + + + # use preparsed task description cache for dokuwiki when possible + if($conf['general']['syntax_plugin']=='dokuwiki' && FLYSPRAY_USE_CACHE==true){ + $select.=' MIN(cache.content) desccache, '; + $from.=' +LEFT JOIN {cache} cache ON t.task_id=cache.topic AND cache.type=\'task\' '; + } else { + $select .= 'NULL AS desccache, '; + } + + if (array_get($args, 'only_primary')) { + $where[] = 'NOT EXISTS (SELECT 1 FROM {dependencies} dep WHERE dep.dep_task_id = t.task_id)'; + } + + # feature FS#1600 + if (array_get($args, 'only_blocker')) { + $where[] = 'EXISTS (SELECT 1 FROM {dependencies} dep WHERE dep.dep_task_id = t.task_id)'; + } + + if (array_get($args, 'only_blocked')) { + $where[] = 'EXISTS (SELECT 1 FROM {dependencies} dep WHERE dep.task_id = t.task_id)'; + } + + # feature FS#1599 + if (array_get($args, 'only_unblocked')) { + $where[] = 'NOT EXISTS (SELECT 1 FROM {dependencies} dep WHERE dep.task_id = t.task_id)'; + } + + if (array_get($args, 'hide_subtasks')) { + $where[] = 't.supertask_id = 0'; + } + + if (array_get($args, 'only_watched')) { + $where[] = 'EXISTS (SELECT 1 FROM {notifications} fsn WHERE t.task_id = fsn.task_id AND fsn.user_id = ?)'; + $sql_params[] = $user->id; + } + + if ($proj->id) { + $where[] = 't.project_id = ?'; + $sql_params[] = $proj->id; + } else { + if (!$user->isAnon()) { // Anon-case handled later. + $allowed = array(); + foreach($fs->projects as $p) { + $allowed[] = $p['project_id']; + } + if(count($allowed)>0){ + $where[] = 't.project_id IN (' . implode(',', $allowed). ')'; + }else{ + $where[] = '0 = 1'; # always empty result + } + } + } + + // process users viewing rights, if not anonymous + if (!$user->isAnon()) { + $where[] = ' +( -- Begin block where users viewing rights are checked. + -- Case everyone can see all project tasks anyway and task not private + (t.mark_private = 0 AND p.others_view = 1) + OR + -- Case admin or project manager, can see any task, even private + (gpg.is_admin = 1 OR gpg.manage_project = 1 OR pg.is_admin = 1 OR pg.manage_project = 1) + OR + -- Case allowed to see all tasks, but not private + ((gpg.view_tasks = 1 OR pg.view_tasks = 1) AND t.mark_private = 0) + OR + -- Case allowed to see own tasks (automatically covers private tasks also for this user!) + ((gpg.view_own_tasks = 1 OR pg.view_own_tasks = 1) AND (t.opened_by = ? OR ass.user_id = ?)) + OR + -- Case task is private, but user either opened it or is an assignee + (t.mark_private = 1 AND (t.opened_by = ? OR ass.user_id = ?)) + OR + -- Leave groups tasks as the last one to check. They are the only ones that actually need doing a subquery + -- for checking viewing rights. There\'s a chance that a previous check already matched and the subquery is + -- not executed at all. All this of course depending on how the database query optimizer actually chooses + -- to fetch the results and execute this query... At least it has been given the hint. + + -- Case allowed to see groups tasks, all projects (NOTE: both global and project specific groups accepted here) + -- Strange... do not use OR here with user_id in EXISTS clause, seems to prevent using index with both mysql and + -- postgresql, query times go up a lot. So it\'ll be 2 different EXISTS OR\'ed together. + (gpg.view_groups_tasks = 1 AND t.mark_private = 0 AND ( + EXISTS (SELECT 1 FROM {users_in_groups} WHERE (group_id = pg.group_id OR group_id = gpg.group_id) AND user_id = t.opened_by) + OR + EXISTS (SELECT 1 FROM {users_in_groups} WHERE (group_id = pg.group_id OR group_id = gpg.group_id) AND user_id = ass.user_id) + )) + OR + -- Case allowed to see groups tasks, current project. Only project group allowed here. + (pg.view_groups_tasks = 1 AND t.mark_private = 0 AND ( + EXISTS (SELECT 1 FROM {users_in_groups} WHERE group_id = pg.group_id AND user_id = t.opened_by) + OR + EXISTS (SELECT 1 FROM {users_in_groups} WHERE group_id = pg.group_id AND user_id = ass.user_id) + )) +) -- Rights have been checked +'; + $sql_params[] = $user->id; + $sql_params[] = $user->id; + $sql_params[] = $user->id; + $sql_params[] = $user->id; + } + /// process search-conditions {{{ + $submits = array('type' => 'task_type', 'sev' => 'task_severity', + 'due' => 'closedby_version', 'reported' => 'product_version', + 'cat' => 'product_category', 'status' => 'item_status', + 'percent' => 'percent_complete', 'pri' => 'task_priority', + 'dev' => array('ass.user_id', 'u.user_name', 'u.real_name'), + 'opened' => array('opened_by', 'uo.user_name', 'uo.real_name'), + 'closed' => array('closed_by', 'uc.user_name', 'uc.real_name')); + foreach ($submits as $key => $db_key) { + $type = array_get($args, $key, ($key == 'status') ? 'open' : ''); + settype($type, 'array'); + + if (in_array('', $type)) { + continue; + } + + $temp = ''; + $condition = ''; + foreach ($type as $val) { + // add conditions for the status selection + if ($key == 'status' && $val == 'closed' && !in_array('open', $type)) { + $temp .= ' is_closed = 1 AND'; + } elseif ($key == 'status' && !in_array('closed', $type)) { + $temp .= ' is_closed = 0 AND'; + } + if (is_numeric($val) && !is_array($db_key) && !($key == 'status' && $val == 'closed')) { + $temp .= ' ' . $db_key . ' = ? OR'; + $sql_params[] = $val; + } elseif (is_array($db_key)) { + if ($key == 'dev' && ($val == 'notassigned' || $val == '0' || $val == '-1')) { + $temp .= ' ass.user_id is NULL OR'; + } else { + foreach ($db_key as $singleDBKey) { + if(ctype_digit($val) && strpos($singleDBKey, '_name') === false) { + $temp .= ' ' . $singleDBKey . ' = ? OR'; + $sql_params[] = $val; + } elseif (!ctype_digit($val) && strpos($singleDBKey, '_name') !== false) { + $temp .= ' ' . $singleDBKey . " $LIKEOP ? OR"; + $sql_params[] = '%' . $val . '%'; + } + } + } + } + + // Add the subcategories to the query + if ($key == 'cat') { + $result = $db->query('SELECT * + FROM {list_category} + WHERE category_id = ?', array($val)); + $cat_details = $db->fetchRow($result); + + $result = $db->query('SELECT * + FROM {list_category} + WHERE lft > ? AND rgt < ? AND project_id = ?', array($cat_details['lft'], $cat_details['rgt'], $cat_details['project_id'])); + while ($row = $db->fetchRow($result)) { + $temp .= ' product_category = ? OR'; + $sql_params[] = $row['category_id']; + } + } + } + + if ($temp) { + $where[] = '(' . substr($temp, 0, -3) . ')'; # strip last ' OR' and 'AND' + } + } +/// }}} + + $order_keys = array( + 'id' => 't.task_id', + 'project' => 'project_title', + 'tasktype' => 'tasktype_name', + 'dateopened' => 'date_opened', + 'summary' => 'item_summary', + 'severity' => 'task_severity', + 'category' => 'lc.category_name', + 'status' => 'is_closed, item_status', + 'dueversion' => 'lvc.list_position', + 'duedate' => 'due_date', + 'progress' => 'percent_complete', + 'lastedit' => 'max_date', + 'priority' => 'task_priority', + 'openedby' => 'uo.real_name', + 'reportedin' => 't.product_version', + 'assignedto' => 'u.real_name', + 'dateclosed' => 't.date_closed', + 'os' => 'los.os_name', + 'votes' => 'num_votes', + 'attachments' => 'num_attachments', + 'comments' => 'num_comments', + 'private' => 'mark_private', + 'supertask' => 't.supertask_id', + ); + + // make sure that only columns can be sorted that are visible (and task severity, since it is always loaded) + $order_keys = array_intersect_key($order_keys, array_merge(array_flip($visible), array('severity' => 'task_severity'))); + + // Implementing setting "Default order by" + if (!array_key_exists('order', $args)) { + # now also for $proj->id=0 (allprojects) + $orderBy = $proj->prefs['sorting'][0]['field']; + $sort = $proj->prefs['sorting'][0]['dir']; + if (count($proj->prefs['sorting']) >1){ + $orderBy2 =$proj->prefs['sorting'][1]['field']; + $sort2= $proj->prefs['sorting'][1]['dir']; + } else{ + $orderBy2='severity'; + $sort2='DESC'; + } + } else { + $orderBy = $args['order']; + $sort = $args['sort']; + $orderBy2='severity'; + $sort2='desc'; + } + + // TODO: Fix this! If something is already ordered by task_id, there's + // absolutely no use to even try to order by something else also. + $order_column[0] = $order_keys[Filters::enum(array_get($args, 'order', $orderBy), array_keys($order_keys))]; + $order_column[1] = $order_keys[Filters::enum(array_get($args, 'order2', $orderBy2), array_keys($order_keys))]; + $sortorder = sprintf('%s %s, %s %s, t.task_id ASC', + $order_column[0], + Filters::enum(array_get($args, 'sort', $sort), array('asc', 'desc')), + $order_column[1], + Filters::enum(array_get($args, 'sort2', $sort2), array('asc', 'desc')) + ); + + $having = array(); + $dates = array('duedate' => 'due_date', 'changed' => $maxdatesql, + 'opened' => 'date_opened', 'closed' => 'date_closed'); + foreach ($dates as $post => $db_key) { + $var = ($post == 'changed') ? 'having' : 'where'; + if ($date = array_get($args, $post . 'from')) { + ${$var}[] = '(' . $db_key . ' >= ' . Flyspray::strtotime($date) . ')'; + } + if ($date = array_get($args, $post . 'to')) { + ${$var}[] = '(' . $db_key . ' <= ' . Flyspray::strtotime($date) . ' AND ' . $db_key . ' > 0)'; + } + } + + if (array_get($args, 'string')) { + $words = explode(' ', strtr(array_get($args, 'string'), '()', ' ')); + $comments = ''; + $where_temp = array(); + + if (array_get($args, 'search_in_comments')) { + $comments .= " OR c.comment_text $LIKEOP ?"; + } + if (array_get($args, 'search_in_details')) { + $comments .= " OR t.detailed_desc $LIKEOP ?"; + } + + foreach ($words as $word) { + $word=trim($word); + if($word==''){ + continue; + } + $likeWord = '%' . str_replace('+', ' ', $word) . '%'; + $where_temp[] = "(t.item_summary $LIKEOP ? OR t.task_id = ? $comments)"; + array_push($sql_params, $likeWord, intval($word)); + if (array_get($args, 'search_in_comments')) { + array_push($sql_params, $likeWord); + } + if (array_get($args, 'search_in_details')) { + array_push($sql_params, $likeWord); + } + } + + if(count($where_temp)>0){ + $where[] = '(' . implode((array_get($args, 'search_for_all') ? ' AND ' : ' OR '), $where_temp) . ')'; + } + } + + if ($user->isAnon()) { + $where[] = 't.mark_private = 0 AND p.others_view = 1'; + if(array_key_exists('status', $args)){ + if (in_array('closed', $args['status']) && !in_array('open', $args['status'])) { + $where[] = 't.is_closed = 1'; + } elseif (in_array('open', $args['status']) && !in_array('closed', $args['status'])) { + $where[] = 't.is_closed = 0'; + } + } + } + + $where = (count($where)) ? 'WHERE ' . join(' AND ', $where) : ''; + + // Get the column names of table tasks for the group by statement + if (!strcasecmp($conf['database']['dbtype'], 'pgsql')) { + $groupby .= "p.project_title, p.project_is_active, "; + // Remove this after checking old PostgreSQL docs. + // 1 column from task table should be enough, after + // already grouping by task_id, there's no possibility + // to have anything more in that table to group by. + $groupby .= $db->getColumnNames('{tasks}', 't.task_id', 't.'); + } else { + $groupby = 't.task_id'; + } + + $having = (count($having)) ? 'HAVING ' . join(' AND ', $having) : ''; + + // echo '<pre>' . print_r($args, true) . '</pre>'; + // echo '<pre>' . print_r($cgroupbyarr, true) . '</pre>'; + $cgroupby = count($cgroupbyarr) ? 'GROUP BY ' . implode(',', array_unique($cgroupbyarr)) : ''; + + $sqlcount = "SELECT COUNT(*) FROM (SELECT 1, t.task_id, t.date_opened, t.date_closed, t.last_edited_time + FROM $cfrom + $where + $cgroupby + $having) s"; + $sqltext = "SELECT t.*, $select +p.project_title, p.project_is_active +FROM $from +$where +GROUP BY $groupby +$having +ORDER BY $sortorder"; + + // Very effective alternative with a little bit more work + // and if row_number() can be emulated in mysql. Idea: + // Move every join and other operation not needed in + // the inner clause to select rows to the outer query, + // and do the rest when we already know which rows + // are in the window to show. Got it to run constantly + // under 6000 ms. + /* Leave this for next version, don't have enough time for testing. + $sqlexperiment = "SELECT * FROM ( +SELECT row_number() OVER(ORDER BY task_id) AS rownum, +t.*, $select p.project_title, p.project_is_active FROM $from +$where +GROUP BY $groupby +$having +ORDER BY $sortorder +) +t WHERE rownum BETWEEN $offset AND " . ($offset + $perpage); +*/ + +// echo '<pre>'.print_r($sql_params, true).'</pre>'; # for debugging +// echo '<pre>'.$sqlcount.'</pre>'; # for debugging +// echo '<pre>'.$sqltext.'</pre>'; # for debugging + $sql = $db->query($sqlcount, $sql_params); + $totalcount = $db->fetchOne($sql); + +# 20150313 peterdd: Do not override task_type with tasktype_name until we changed t.task_type to t.task_type_id! We need the id too. + + $sql = $db->query($sqltext, $sql_params, $perpage, $offset); + // $sql = $db->query($sqlexperiment, $sql_params); + $tasks = $db->fetchAllArray($sql); + $id_list = array(); + $limit = array_get($args, 'limit', -1); + $forbidden_tasks_count = 0; + foreach ($tasks as $key => $task) { + $id_list[] = $task['task_id']; + if (!$user->can_view_task($task)) { + unset($tasks[$key]); + $forbidden_tasks_count++; + } + } + +// Work on this is not finished until $forbidden_tasks_count is always zero. +// echo "<pre>$offset : $perpage : $totalcount : $forbidden_tasks_count</pre>"; + return array($tasks, $id_list, $totalcount, $forbidden_tasks_count); +// # end alternative + } + +# end get_task_list +} # end class |