From 56741bccaae3b6902d65d64825787b9d64567aab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emmanuel=20Beno=C3=AEt?= Date: Mon, 6 Feb 2012 16:38:11 +0100 Subject: [PATCH] Task assignment Tasks can be assigned to users. An user may decide to "claim" a task directly, which will assign the task to him. Otherwise, it is possible to set some arbitrary user as the assignee or remove the assignee completely through the edition form. Marking a task as completed will remove the assignee, while re-activating a task will assign it to the user who re-activated it. Also, fixed a bug which allowed a completed task to be edited. --- database/create-tables.sql | 1 + database/tasks-functions.sql | 56 ++++++++++--- database/tasks-view.sql | 6 +- includes/t-basics/dao_users.inc.php | 4 +- includes/t-data/dao_tasks.inc.php | 22 +++-- includes/t-tasks/controllers.inc.php | 40 ++++++++- includes/t-tasks/package.inc.php | 1 + includes/t-tasks/page_controllers.inc.php | 23 +++++- includes/t-tasks/pages.inc.php | 1 + includes/t-tasks/views.inc.php | 94 +++++++++------------- site/icons.png | Bin 3625 -> 3682 bytes site/style.css | 12 +++ 12 files changed, 180 insertions(+), 80 deletions(-) diff --git a/database/create-tables.sql b/database/create-tables.sql index 7d2e702..7cccf82 100644 --- a/database/create-tables.sql +++ b/database/create-tables.sql @@ -77,6 +77,7 @@ CREATE TABLE tasks ( task_description TEXT NOT NULL, task_added TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT now(), user_id INT NOT NULL REFERENCES users(user_id) ON UPDATE NO ACTION ON DELETE CASCADE, + user_id_assigned INT REFERENCES users(user_id) ON UPDATE NO ACTION ON DELETE SET NULL , PRIMARY KEY(task_id) ); diff --git a/database/tasks-functions.sql b/database/tasks-functions.sql index fdf4d93..df9ea6a 100644 --- a/database/tasks-functions.sql +++ b/database/tasks-functions.sql @@ -32,12 +32,17 @@ BEGIN RETURN 1; END; + UPDATE tasks SET user_id_assigned = NULL WHERE task_id = t_id; + INSERT INTO notes ( task_id , user_id , note_text ) VALUES ( t_id , u_id , n_text ); RETURN 0; END; $finish_task$ LANGUAGE plpgsql; +REVOKE EXECUTE ON FUNCTION finish_task( INT , INT , TEXT ) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION finish_task( INT , INT , TEXT ) TO :webapp_user; + -- Restart a task CREATE OR REPLACE FUNCTION restart_task( t_id INT , u_id INT , n_text TEXT ) @@ -50,31 +55,62 @@ BEGIN IF NOT FOUND THEN RETURN 1; END IF; + UPDATE tasks SET user_id_assigned = u_id + WHERE task_id = t_id; INSERT INTO notes ( task_id , user_id , note_text ) VALUES ( t_id , u_id , n_text ); RETURN 0; END; $restart_task$ LANGUAGE plpgsql; +REVOKE EXECUTE ON FUNCTION restart_task( INT , INT , TEXT ) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION restart_task( INT , INT , TEXT ) TO :webapp_user; + -- Update a task -CREATE OR REPLACE FUNCTION update_task( t_id INT , p_id INT , t_title TEXT , t_description TEXT , t_priority INT ) +CREATE OR REPLACE FUNCTION update_task( t_id INT , p_id INT , t_title TEXT , t_description TEXT , t_priority INT , t_assignee INT ) RETURNS INT STRICT VOLATILE SECURITY INVOKER AS $update_task$ BEGIN - UPDATE tasks SET item_id = p_id , task_title = t_title , - task_description = t_description , - task_priority = t_priority - WHERE task_id = t_id; + PERFORM 1 + FROM tasks + LEFT OUTER JOIN completed_tasks + USING( task_id ) + WHERE task_id = t_id AND completed_task_time IS NULL + FOR UPDATE OF tasks; + IF NOT FOUND THEN + RETURN 4; + END IF; + + BEGIN + IF t_assignee <= 0 THEN + t_assignee := NULL; + END IF; + BEGIN + UPDATE tasks SET user_id_assigned = t_assignee WHERE task_id = t_id; + EXCEPTION + WHEN foreign_key_violation THEN + RAISE EXCEPTION 'bad user'; + END; + UPDATE tasks SET item_id = p_id , task_title = t_title , + task_description = t_description , + task_priority = t_priority + WHERE task_id = t_id; + EXCEPTION + WHEN unique_violation THEN + RETURN 1; + WHEN foreign_key_violation THEN + RETURN 2; + WHEN raise_exception THEN + RETURN 3; + END; + RETURN 0; -EXCEPTION - WHEN unique_violation THEN - RETURN 1; - WHEN foreign_key_violation THEN - RETURN 2; END; $update_task$ LANGUAGE plpgsql; +REVOKE EXECUTE ON FUNCTION update_task( INT , INT , TEXT , TEXT , INT , INT ) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION update_task( INT , INT , TEXT , TEXT , INT , INT ) TO :webapp_user; diff --git a/database/tasks-view.sql b/database/tasks-view.sql index 7f1f0e7..d1aeae5 100644 --- a/database/tasks-view.sql +++ b/database/tasks-view.sql @@ -10,14 +10,16 @@ CREATE VIEW tasks_list t.task_description AS description, t.task_added AS added_at, u1.user_view_name AS added_by, ct.completed_task_time AS completed_at, - u2.user_view_name AS completed_by , + u2.user_view_name AS assigned_to , + u3.user_view_name AS completed_by , t.task_priority AS priority , bd.bad_deps AS missing_dependencies , mtd.trans_missing AS total_missing_dependencies FROM tasks t INNER JOIN users_view u1 ON u1.user_id = t.user_id LEFT OUTER JOIN completed_tasks ct ON ct.task_id = t.task_id - LEFT OUTER JOIN users_view u2 ON u2.user_id = ct.user_id + LEFT OUTER JOIN users_view u2 ON u2.user_id = t.user_id_assigned + LEFT OUTER JOIN users_view u3 ON u3.user_id = ct.user_id LEFT OUTER JOIN ( SELECT td.task_id , COUNT(*) AS bad_deps FROM task_dependencies td diff --git a/includes/t-basics/dao_users.inc.php b/includes/t-basics/dao_users.inc.php index e390af1..89cd505 100644 --- a/includes/t-basics/dao_users.inc.php +++ b/includes/t-basics/dao_users.inc.php @@ -38,8 +38,8 @@ class Dao_Users public function getUsers( ) { return $this->query( - 'SELECT user_id , user_display_name , user_email ' - . 'FROM users ' + 'SELECT user_id , user_display_name , user_email , user_view_name ' + . 'FROM users_view ' . 'ORDER BY LOWER( user_email )' )->execute( ); } diff --git a/includes/t-data/dao_tasks.inc.php b/includes/t-data/dao_tasks.inc.php index ebda39c..03dd460 100644 --- a/includes/t-data/dao_tasks.inc.php +++ b/includes/t-data/dao_tasks.inc.php @@ -76,12 +76,14 @@ class DAO_Tasks 'SELECT t.task_id AS id, t.task_title AS title, t.item_id AS item ,' . 't.task_description AS description, t.task_added AS added_at, ' . 'u1.user_view_name AS added_by, ct.completed_task_time AS completed_at, ' - . 'u2.user_view_name AS completed_by, t.user_id AS uid , ' + . 'u2.user_view_name AS assigned_to , u2.user_id AS assigned_id , ' + . 'u3.user_view_name AS completed_by, t.user_id AS uid , ' . 't.task_priority AS priority ' . 'FROM tasks t ' . 'INNER JOIN users_view u1 ON u1.user_id = t.user_id ' . 'LEFT OUTER JOIN completed_tasks ct ON ct.task_id = t.task_id ' - . 'LEFT OUTER JOIN users_view u2 ON u2.user_id = ct.user_id ' + . 'LEFT OUTER JOIN users_view u2 ON u2.user_id = t.user_id_assigned ' + . 'LEFT OUTER JOIN users_view u3 ON u3.user_id = ct.user_id ' . 'WHERE t.task_id = $1' )->execute( $id ); if ( empty( $result ) ) { return null; @@ -182,10 +184,10 @@ class DAO_Tasks ->execute( $task , $_SESSION[ 'uid' ] , $noteText ); } - public function updateTask( $id , $item , $title , $priority , $description ) + public function updateTask( $id , $item , $title , $priority , $description , $assignee ) { - $result = $this->query( 'SELECT update_task( $1 , $2 , $3 , $4 , $5 ) AS error' ) - ->execute( $id , $item , $title , $description , $priority ); + $result = $this->query( 'SELECT update_task( $1 , $2 , $3 , $4 , $5 , $6 ) AS error' ) + ->execute( $id , $item , $title , $description , $priority , $assignee ); return $result[0]->error; } @@ -236,4 +238,14 @@ class DAO_Tasks ->execute( $from , $to ); } + public function assignTaskTo( $task , $user ) + { + $this->query( + 'UPDATE tasks _task SET user_id_assigned = $2 ' + . 'FROM tasks _task2 ' + . 'LEFT OUTER JOIN completed_tasks _completed ' + . 'USING ( task_id ) ' + . 'WHERE _task2.task_id = _task.task_id AND _completed.task_id IS NULL AND _task.task_id = $1' + )->execute( $task , $user ); + } } diff --git a/includes/t-tasks/controllers.inc.php b/includes/t-tasks/controllers.inc.php index 2db9ff5..dac7b23 100644 --- a/includes/t-tasks/controllers.inc.php +++ b/includes/t-tasks/controllers.inc.php @@ -71,10 +71,16 @@ class Ctrl_TaskDetails if ( $this->task->completed_by === null ) { $box->addButton( BoxButton::create( 'Edit task' , 'tasks/edit?id=' . $this->task->id ) ->setClass( 'icon edit' ) ); + if ( $tasks->canFinish( $this->task ) ) { $box->addButton( BoxButton::create( 'Mark as completed' , 'tasks/finish?id=' . $this->task->id ) ->setClass( 'icon stop' ) ); - }; + } + + if ( $this->task->assigned_id !== $_SESSION[ 'uid' ] ) { + $box->addButton( BoxButton::create( 'Claim task' , 'tasks/claim?id=' . $this->task->id ) + ->setClass( 'icon claim' ) ); + } } else { if ( $tasks->canRestart( $this->task ) ) { $box->addButton( BoxButton::create( 'Re-activate' , 'tasks/restart?id=' . $this->task->id ) @@ -228,10 +234,12 @@ class Ctrl_EditTask $name = $this->form->field( 'title' ); $priority = $this->form->field( 'priority' ); $description = $this->form->field( 'description' ); + $assignee = $this->form->field( 'assigned-to' ); $error = Loader::DAO( 'tasks' )->updateTask( (int) $id->value( ) , (int) $item->value( ) , $name->value( ) , - (int) $priority->value( ) , $description->value( ) ); + (int) $priority->value( ) , $description->value( ) , + (int) $assignee->value( ) ); switch ( $error ) { @@ -239,11 +247,15 @@ class Ctrl_EditTask return true; case 1: - $name->putError( "A task already uses this title for this item." ); + $name->putError( 'A task already uses this title for this item.' ); break; case 2: - $item->putError( "This item has been deleted." ); + $item->putError( 'This item has been deleted.' ); + break; + + case 3: + $assignee->putError( 'This user has been deleted.' ); break; default: @@ -406,3 +418,23 @@ class Ctrl_DependencyDelete return true; } } + + +class Ctrl_TaskClaim + extends Controller +{ + + public function handle( Page $page ) + { + try { + $id = (int) $this->getParameter( 'id' ); + } catch ( ParameterException $e ) { + return 'tasks'; + } + + $dao = Loader::DAO( 'tasks' ); + $dao->assignTaskTo( $id , $_SESSION[ 'uid' ] ); + return 'tasks/view?id=' . $id; + } + +} diff --git a/includes/t-tasks/package.inc.php b/includes/t-tasks/package.inc.php index 1f495b0..dd908b3 100644 --- a/includes/t-tasks/package.inc.php +++ b/includes/t-tasks/package.inc.php @@ -28,6 +28,7 @@ $package[ 'ctrls' ][] = 'edit_task_form'; $package[ 'ctrls' ][] = 'edit_task'; $package[ 'ctrls' ][] = 'task_dependencies'; $package[ 'ctrls' ][] = 'task_details'; +$package[ 'ctrls' ][] = 'task_claim'; $package[ 'ctrls' ][] = 'task_notes'; $package[ 'ctrls' ][] = 'toggle_task'; $package[ 'ctrls' ][] = 'view_task'; diff --git a/includes/t-tasks/page_controllers.inc.php b/includes/t-tasks/page_controllers.inc.php index 645d364..70c7c01 100644 --- a/includes/t-tasks/page_controllers.inc.php +++ b/includes/t-tasks/page_controllers.inc.php @@ -233,6 +233,9 @@ class Ctrl_EditTaskForm if ( $task === null ) { return 'tasks'; } + if ( $task->completed_at !== null ) { + return 'tasks/view?id=' . $id; + } $page->setTitle( $task->title . ' (task)' ); @@ -253,6 +256,8 @@ class Ctrl_EditTaskForm ->setDescription( 'Description:' ) ->setMandatory( false ) ->setDefaultValue( $task->description ) ) + ->addField( $this->createAssigneeSelector( ) + ->setDefaultValue( $task->assigned_id ) ) ->addController( Loader::Ctrl( 'edit_task' ) ) ->controller( ); } @@ -263,7 +268,7 @@ class Ctrl_EditTaskForm $select = Loader::Create( 'Field' , 'item' , 'select' ) ->setDescription( 'On item:' ); - $items = Loader::DAO( 'items' )->getTreeList( ); + $items = Loader::DAO( 'items' )->getTreeList( ); foreach ( $items as $item ) { $name = '-' . str_repeat( '--' , $item->depth ) . ' ' . $item->name; $select->addOption( $item->id , $name ); @@ -271,6 +276,22 @@ class Ctrl_EditTaskForm return $select; } + + + private function createAssigneeSelector( ) + { + $select = Loader::Create( 'Field' , 'assigned-to' , 'select' ) + ->setDescription( 'Assigned to:' ) + ->setMandatory( false ); + $select->addOption( '' , '(unassigned task)' ); + + $users = Loader::DAO( 'users' )->getUsers( ); + foreach ( $users as $user ) { + $select->addOption( $user->user_id , $user->user_view_name ); + } + + return $select; + } } diff --git a/includes/t-tasks/pages.inc.php b/includes/t-tasks/pages.inc.php index a13bc88..bf81799 100644 --- a/includes/t-tasks/pages.inc.php +++ b/includes/t-tasks/pages.inc.php @@ -9,6 +9,7 @@ class Page_TasksTasks parent::__construct( array( '' => 'all_tasks' , 'add' => 'add_task_form' , + 'claim' => 'task_claim' , 'delete' => 'delete_task_form' , 'edit' => 'edit_task_form' , 'finish' => array( 'toggle_task' , false ) , diff --git a/includes/t-tasks/views.inc.php b/includes/t-tasks/views.inc.php index f141473..08b9584 100644 --- a/includes/t-tasks/views.inc.php +++ b/includes/t-tasks/views.inc.php @@ -51,20 +51,6 @@ abstract class View_TasksBase return $result; } - protected abstract function generateItem( $task ); -} - - -class View_AllTasks - extends View_TasksBase -{ - - public function __construct( $tasks ) - { - parent::__construct( ); - $this->tasks = $tasks; - } - protected function generateItem( $task ) { $cell = array( ); @@ -72,8 +58,7 @@ class View_AllTasks ->appendElement( HTML::make( 'a' ) ->setAttribute( 'href' , $this->base . '/tasks/view?id=' . $task->id ) ->appendText( $task->title ) ) ); - - array_push( $cell , HTML::make( 'dd' )->append( $this->formatPlaceLineage( $task->item ) ) ); + $cell = array_merge( $cell , $this->generateSpecificLines( $task ) ); $addedAt = strtotime( $task->added_at ); $addedAtDate = date( 'd/m/o' , $addedAt ); @@ -95,6 +80,11 @@ class View_AllTasks foreach ( $cell as $entry ) { $entry->setAttribute( 'class' , 'missing-deps' ); } + } elseif ( $task->assigned_to !== null ) { + array_push( $cell , HTML::make( 'dd' )->appendText( 'Assigned to ' . $task->assigned_to ) ); + foreach ( $cell as $entry ) { + $entry->setAttribute( 'class' , 'assigned' ); + } } elseif ( $task->completed_by !== null ) { $completedAt = strtotime( $task->completed_at ); $completedAtDate = date( 'd/m/o' , $completedAt ); @@ -110,6 +100,25 @@ class View_AllTasks return $cell; } + protected abstract function generateSpecificLines( $task ); +} + + +class View_AllTasks + extends View_TasksBase +{ + + public function __construct( $tasks ) + { + parent::__construct( ); + $this->tasks = $tasks; + } + + protected function generateSpecificLines( $task ) + { + return array( HTML::make( 'dd' )->append( $this->formatPlaceLineage( $task->item ) ) ); + } + private function formatPlaceLineage( $item ) { $item = Loader::DAO( 'items' )->get( $item ); @@ -142,47 +151,9 @@ class View_Tasks } - protected function generateItem( $task ) + protected function generateSpecificLines( $task ) { - $cell = array( ); - array_push( $cell , HTML::make( 'dt' ) - ->appendElement( HTML::make( 'a' ) - ->setAttribute( 'href' , $this->base . '/tasks/view?id=' . $task->id ) - ->appendText( $task->title ) ) ); - - $addedAt = strtotime( $task->added_at ); - $addedAtDate = date( 'd/m/o' , $addedAt ); - $addedAtTime = date( 'H:i:s' , $addedAt ); - array_push( $cell , - HTML::make( 'dd' )->appendText( "Added $addedAtDate at $addedAtTime by {$task->added_by}" ) ); - - if ( $task->missing_dependencies !== null ) { - if ( $task->missing_dependencies > 1 ) { - $end = 'ies'; - } else { - $end = 'y'; - } - array_push( $cell , - $md = HTML::make( 'dd' )->appendText( "{$task->missing_dependencies} missing dependenc$end" ) ); - if ( $task->total_missing_dependencies != $task->missing_dependencies ) { - $md->appendText( " ({$task->total_missing_dependencies} when counting transitive dependencies)" ); - } - - foreach ( $cell as $entry ) { - $entry->setAttribute( 'class' , 'missing-deps' ); - } - } elseif ( $task->completed_by !== null ) { - $completedAt = strtotime( $task->completed_at ); - $completedAtDate = date( 'd/m/o' , $completedAt ); - $completedAtTime = date( 'H:i:s' , $completedAt ); - array_push( $cell , HTML::make( 'dd' )->appendText( - "Completed $completedAtDate at $completedAtTime by {$task->completed_by}" ) ); - foreach ( $cell as $entry ) { - $entry->setAttribute( 'class' , 'completed' ); - } - } - - return $cell; + return array( ); } } @@ -225,6 +196,17 @@ class View_TaskDetails ->appendElement( HTML::make( 'dd' ) ->appendText( Loader::DAO( 'tasks' ) ->translatePriority( $this->task->priority ) ) ); + + if ( $this->task->assigned_to === null ) { + $list->appendElement( HTML::make( 'dt' ) + ->setAttribute( 'class' , 'unassigned-task' ) + ->appendText( 'Unassigned!' ) ); + } else { + $list->appendElement( HTML::make( 'dt' ) + ->appendText( 'Assigned to:' ) ) + ->appendElement( HTML::make( 'dd' ) + ->appendText( $this->task->assigned_to ) ); + } } else { $list->appendElement( HTML::make( 'dt' ) ->appendText( 'Completed:' ) ) diff --git a/site/icons.png b/site/icons.png index 071aee176a52e274bf5569bb073081069d6aec1d..b45d755d6a7a3e17a8ef9070c041643712b61335 100644 GIT binary patch delta 3619 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 delta 3561 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