From ded63d21a84811c9e082c0fea0110a1b498265d6 Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Tue, 29 Dec 2015 09:30:36 +0100 Subject: Send notifications on user mentions --- app/Model/Task.php | 1 + 1 file changed, 1 insertion(+) (limited to 'app/Model/Task.php') diff --git a/app/Model/Task.php b/app/Model/Task.php index f1cd094f..7aa9e312 100644 --- a/app/Model/Task.php +++ b/app/Model/Task.php @@ -41,6 +41,7 @@ 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'; /** * Recurrence: status -- cgit v1.2.3 From 32e4a932c801dfa6c52f6e8211a96bdb7849579d Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Wed, 27 Jan 2016 21:45:37 -0500 Subject: Added automatic actions based on a daily event --- ChangeLog | 7 ++ app/Action/Base.php | 2 +- app/Action/TaskCloseNoActivity.php | 95 +++++++++++++++++++++++ app/Action/TaskEmailNoActivity.php | 124 +++++++++++++++++++++++++++++++ app/Console/Cronjob.php | 33 ++++++++ app/Console/TaskTrigger.php | 52 +++++++++++++ app/Core/Event/EventManager.php | 1 + app/Event/GenericEvent.php | 2 +- app/Event/TaskListEvent.php | 11 +++ app/Model/Task.php | 1 + app/ServiceProvider/ActionProvider.php | 4 + app/Template/action/params.php | 15 ++-- doc/analytics.markdown | 7 +- doc/centos-installation.markdown | 2 + doc/cli.markdown | 27 ++++--- doc/cronjob.markdown | 32 ++++++++ doc/debian-installation.markdown | 2 + doc/freebsd-installation.markdown | 7 +- doc/heroku.markdown | 4 +- doc/index.markdown | 1 + doc/installation.markdown | 5 ++ doc/ubuntu-installation.markdown | 2 + doc/windows-apache-installation.markdown | 5 ++ doc/windows-iis-installation.markdown | 6 ++ kanboard | 4 + 25 files changed, 421 insertions(+), 30 deletions(-) create mode 100644 app/Action/TaskCloseNoActivity.php create mode 100644 app/Action/TaskEmailNoActivity.php create mode 100644 app/Console/Cronjob.php create mode 100644 app/Console/TaskTrigger.php create mode 100644 app/Event/TaskListEvent.php create mode 100644 doc/cronjob.markdown (limited to 'app/Model/Task.php') diff --git a/ChangeLog b/ChangeLog index df6a12ed..5fa09689 100644 --- a/ChangeLog +++ b/ChangeLog @@ -6,6 +6,13 @@ New features: * Add project owner (Directly Responsible Individual) * Add configurable task priority * Add Greek translation +* Add automatic actions to close tasks with no activity +* Add automatic actions to send an email when there is no activity on a task +* Regroup all daily background tasks in one command: "cronjob" + +Improvements: + +* Show progress for task links in board tooltips Version 1.0.24 -------------- diff --git a/app/Action/Base.php b/app/Action/Base.php index efc52f04..e8449d0c 100644 --- a/app/Action/Base.php +++ b/app/Action/Base.php @@ -125,7 +125,7 @@ abstract class Base extends \Kanboard\Core\Base $params[] = $key.'='.var_export($value, true); } - return $this->getName().'('.implode('|', $params).'])'; + return $this->getName().'('.implode('|', $params).')'; } /** diff --git a/app/Action/TaskCloseNoActivity.php b/app/Action/TaskCloseNoActivity.php new file mode 100644 index 00000000..59f7f56a --- /dev/null +++ b/app/Action/TaskCloseNoActivity.php @@ -0,0 +1,95 @@ + t('Duration in days') + ); + } + + /** + * Get the required parameter for the event + * + * @access public + * @return string[] + */ + public function getEventRequiredParameters() + { + return array('tasks'); + } + + /** + * Execute the action (close the task) + * + * @access public + * @param array $data Event data dictionary + * @return bool True if the action was executed or false when not executed + */ + public function doAction(array $data) + { + $results = array(); + $max = $this->getParam('duration') * 86400; + + foreach ($data['tasks'] as $task) { + $duration = time() - $task['date_modification']; + + if ($duration > $max) { + $results[] = $this->taskStatus->close($task['id']); + } + } + + return in_array(true, $results, true); + } + + /** + * Check if the event data meet the action condition + * + * @access public + * @param array $data Event data dictionary + * @return bool + */ + public function hasRequiredCondition(array $data) + { + return count($data['tasks']) > 0; + } +} diff --git a/app/Action/TaskEmailNoActivity.php b/app/Action/TaskEmailNoActivity.php new file mode 100644 index 00000000..c5d7a797 --- /dev/null +++ b/app/Action/TaskEmailNoActivity.php @@ -0,0 +1,124 @@ + t('User that will receive the email'), + 'subject' => t('Email subject'), + 'duration' => t('Duration in days'), + ); + } + + /** + * Get the required parameter for the event + * + * @access public + * @return string[] + */ + public function getEventRequiredParameters() + { + return array('tasks'); + } + + /** + * Check if the event data meet the action condition + * + * @access public + * @param array $data Event data dictionary + * @return bool + */ + public function hasRequiredCondition(array $data) + { + return count($data['tasks']) > 0; + } + + /** + * Execute the action (move the task to another column) + * + * @access public + * @param array $data Event data dictionary + * @return bool True if the action was executed or false when not executed + */ + public function doAction(array $data) + { + $results = array(); + $max = $this->getParam('duration') * 86400; + $user = $this->user->getById($this->getParam('user_id')); + + if (! empty($user['email'])) { + foreach ($data['tasks'] as $task) { + $duration = time() - $task['date_modification']; + + if ($duration > $max) { + $results[] = $this->sendEmail($task['id'], $user); + } + } + } + + return in_array(true, $results, true); + } + + /** + * Send email + * + * @access private + * @param integer $task_id + * @param array $user + * @return boolean + */ + private function sendEmail($task_id, array $user) + { + $task = $this->taskFinder->getDetails($task_id); + + $this->emailClient->send( + $user['email'], + $user['name'] ?: $user['username'], + $this->getParam('subject'), + $this->template->render('notification/task_create', array('task' => $task, 'application_url' => $this->config->get('application_url'))) + ); + + return true; + } +} diff --git a/app/Console/Cronjob.php b/app/Console/Cronjob.php new file mode 100644 index 00000000..2b12d93d --- /dev/null +++ b/app/Console/Cronjob.php @@ -0,0 +1,33 @@ +setName('cronjob') + ->setDescription('Execute daily cronjob'); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + foreach ($this->commands as $command) { + $job = $this->getApplication()->find($command); + $job->run(new ArrayInput(array('command' => $command)), new NullOutput()); + } + } +} diff --git a/app/Console/TaskTrigger.php b/app/Console/TaskTrigger.php new file mode 100644 index 00000000..000d215a --- /dev/null +++ b/app/Console/TaskTrigger.php @@ -0,0 +1,52 @@ +setName('trigger:tasks') + ->setDescription('Trigger scheduler event for all tasks'); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + foreach ($this->getProjectIds() as $project_id) { + $tasks = $this->taskFinder->getAll($project_id); + $nb_tasks = count($tasks); + + if ($nb_tasks > 0) { + $output->writeln('Trigger task event: project_id='.$project_id.', nb_tasks='.$nb_tasks); + $this->sendEvent($tasks, $project_id); + } + } + } + + private function getProjectIds() + { + $listeners = $this->dispatcher->getListeners(Task::EVENT_DAILY_CRONJOB); + $project_ids = array(); + + foreach ($listeners as $listener) { + $project_ids[] = $listener[0]->getProjectId(); + } + + return array_unique($project_ids); + } + + private function sendEvent(array &$tasks, $project_id) + { + $event = new TaskListEvent(array('project_id' => $project_id)); + $event->setTasks($tasks); + + $this->dispatcher->dispatch(Task::EVENT_DAILY_CRONJOB, $event); + } +} diff --git a/app/Core/Event/EventManager.php b/app/Core/Event/EventManager.php index 8d76bfcb..162d23e8 100644 --- a/app/Core/Event/EventManager.php +++ b/app/Core/Event/EventManager.php @@ -52,6 +52,7 @@ class EventManager Task::EVENT_CLOSE => t('Closing a task'), Task::EVENT_CREATE_UPDATE => t('Task creation or modification'), Task::EVENT_ASSIGNEE_CHANGE => t('Task assignee change'), + Task::EVENT_DAILY_CRONJOB => t('Daily background job for tasks'), ); $events = array_merge($events, $this->events); diff --git a/app/Event/GenericEvent.php b/app/Event/GenericEvent.php index 1129fd16..94a51479 100644 --- a/app/Event/GenericEvent.php +++ b/app/Event/GenericEvent.php @@ -7,7 +7,7 @@ use Symfony\Component\EventDispatcher\Event as BaseEvent; class GenericEvent extends BaseEvent implements ArrayAccess { - private $container = array(); + protected $container = array(); public function __construct(array $values = array()) { diff --git a/app/Event/TaskListEvent.php b/app/Event/TaskListEvent.php new file mode 100644 index 00000000..9be1a7d9 --- /dev/null +++ b/app/Event/TaskListEvent.php @@ -0,0 +1,11 @@ +container['tasks'] =& $tasks; + } +} diff --git a/app/Model/Task.php b/app/Model/Task.php index 7aa9e312..94b23ec2 100644 --- a/app/Model/Task.php +++ b/app/Model/Task.php @@ -42,6 +42,7 @@ class Task extends Base 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 diff --git a/app/ServiceProvider/ActionProvider.php b/app/ServiceProvider/ActionProvider.php index 0aba29f1..3692f190 100644 --- a/app/ServiceProvider/ActionProvider.php +++ b/app/ServiceProvider/ActionProvider.php @@ -23,12 +23,14 @@ use Kanboard\Action\TaskCloseColumn; use Kanboard\Action\TaskCreation; use Kanboard\Action\TaskDuplicateAnotherProject; use Kanboard\Action\TaskEmail; +use Kanboard\Action\TaskEmailNoActivity; use Kanboard\Action\TaskMoveAnotherProject; use Kanboard\Action\TaskMoveColumnAssigned; use Kanboard\Action\TaskMoveColumnCategoryChange; use Kanboard\Action\TaskMoveColumnUnAssigned; use Kanboard\Action\TaskOpen; use Kanboard\Action\TaskUpdateStartDate; +use Kanboard\Action\TaskCloseNoActivity; /** * Action Provider @@ -63,9 +65,11 @@ class ActionProvider implements ServiceProviderInterface $container['actionManager']->register(new TaskAssignUser($container)); $container['actionManager']->register(new TaskClose($container)); $container['actionManager']->register(new TaskCloseColumn($container)); + $container['actionManager']->register(new TaskCloseNoActivity($container)); $container['actionManager']->register(new TaskCreation($container)); $container['actionManager']->register(new TaskDuplicateAnotherProject($container)); $container['actionManager']->register(new TaskEmail($container)); + $container['actionManager']->register(new TaskEmailNoActivity($container)); $container['actionManager']->register(new TaskMoveAnotherProject($container)); $container['actionManager']->register(new TaskMoveColumnAssigned($container)); $container['actionManager']->register(new TaskMoveColumnCategoryChange($container)); diff --git a/app/Template/action/params.php b/app/Template/action/params.php index dcfaa9cc..a2350dea 100644 --- a/app/Template/action/params.php +++ b/app/Template/action/params.php @@ -15,22 +15,25 @@ text->contains($param_name, 'column_id')): ?> form->label($param_desc, $param_name) ?> - form->select('params['.$param_name.']', $columns_list, $values) ?>
+ form->select('params['.$param_name.']', $columns_list, $values) ?> text->contains($param_name, 'user_id')): ?> form->label($param_desc, $param_name) ?> - form->select('params['.$param_name.']', $users_list, $values) ?>
+ form->select('params['.$param_name.']', $users_list, $values) ?> text->contains($param_name, 'project_id')): ?> form->label($param_desc, $param_name) ?> - form->select('params['.$param_name.']', $projects_list, $values) ?>
+ form->select('params['.$param_name.']', $projects_list, $values) ?> text->contains($param_name, 'color_id')): ?> form->label($param_desc, $param_name) ?> - form->select('params['.$param_name.']', $colors_list, $values) ?>
+ form->select('params['.$param_name.']', $colors_list, $values) ?> text->contains($param_name, 'category_id')): ?> form->label($param_desc, $param_name) ?> - form->select('params['.$param_name.']', $categories_list, $values) ?>
+ form->select('params['.$param_name.']', $categories_list, $values) ?> text->contains($param_name, 'link_id')): ?> form->label($param_desc, $param_name) ?> - form->select('params['.$param_name.']', $links_list, $values) ?>
+ form->select('params['.$param_name.']', $links_list, $values) ?> + text->contains($param_name, 'duration')): ?> + form->label($param_desc, $param_name) ?> + form->number('params['.$param_name.']', $values) ?> form->label($param_desc, $param_name) ?> form->text('params['.$param_name.']', $values) ?> diff --git a/doc/analytics.markdown b/doc/analytics.markdown index 13657b56..d72fc383 100644 --- a/doc/analytics.markdown +++ b/doc/analytics.markdown @@ -62,9 +62,4 @@ This chart show the average lead and cycle time for the last 1000 tasks over tim Those metrics are calculated and recorded every day for the whole project. -Don't forget to run the daily job for statistics calculation -------------------------------------------------------- - -To generate accurate analytic data, you should run the daily cronjob **project daily statistics**. - -[Read the documentation of Kanboard CLI](cli.markdown) +Note: Don't forget to run the [daily cronjob](cronjob.markdown) to have accurate statistics. diff --git a/doc/centos-installation.markdown b/doc/centos-installation.markdown index d0fd6a00..576119b4 100644 --- a/doc/centos-installation.markdown +++ b/doc/centos-installation.markdown @@ -1,6 +1,8 @@ Centos Installation =================== +Note: Some features of Kanboard require that you run [a daily background job](cronjob.markdown). + Centos 7 -------- diff --git a/doc/cli.markdown b/doc/cli.markdown index bcb478dd..9334d84b 100644 --- a/doc/cli.markdown +++ b/doc/cli.markdown @@ -4,7 +4,7 @@ Command Line Interface Kanboard provides a simple command line interface that can be used from any Unix terminal. This tool can be used only on the local machine. -This feature is useful to run commands outside the web server process by example running a huge report. +This feature is useful to run commands outside of the web server processes. Usage ----- @@ -28,6 +28,7 @@ Options: -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug Available commands: + cronjob Execute daily cronjob help Displays help for a command list Lists commands export @@ -42,6 +43,8 @@ Available commands: notification:overdue-tasks Send notifications for overdue tasks projects projects:daily-stats Calculate daily statistics for all projects + trigger + trigger:tasks Trigger scheduler event for all tasks ``` Available commands @@ -116,7 +119,7 @@ Emails will be sent to all users with notifications enabled. You can also display the overdue tasks with the flag `--show`: ```bash -$ ./kanboard notification:overdue-tasks --show +./kanboard notification:overdue-tasks --show +-----+---------+------------+------------+--------------+----------+ | Id | Title | Due date | Project Id | Project name | Assignee | +-----+---------+------------+------------+--------------+----------+ @@ -125,20 +128,22 @@ $ ./kanboard notification:overdue-tasks --show +-----+---------+------------+------------+--------------+----------+ ``` -Cronjob example: - -```bash -# Everyday at 8am we check for due tasks -0 8 * * * cd /path/to/kanboard && ./kanboard notification:overdue-tasks >/dev/null 2>&1 -``` - ### Run daily project stats calculation -You can add a background task to calculate the project statistics every day: +This command calculate the statistics of each project: ```bash -$ ./kanboard projects:daily-stats +./kanboard projects:daily-stats Run calculation for Project #0 Run calculation for Project #1 Run calculation for Project #10 ``` + +### Trigger for tasks + +This command send a "daily cronjob event" to all open tasks of each project. + +```bash +./kanboard trigger:tasks +Trigger task event: project_id=2, nb_tasks=1 +``` diff --git a/doc/cronjob.markdown b/doc/cronjob.markdown new file mode 100644 index 00000000..32f12888 --- /dev/null +++ b/doc/cronjob.markdown @@ -0,0 +1,32 @@ +Background Job Scheduling +========================= + +To work properly, Kanboard requires that a background job run on a daily basis. +Usually on Unix platforms, this process is done by `cron`. + +This background job is necessary for these features: + +- Reports and analytics (calculate daily stats of each projects) +- Send overdue task notifications +- Execute automatic actions connected to the event "Daily background job for tasks" + +Configuration on Unix and Linux platforms +----------------------------------------- + +There are multiple ways to define a cronjob on Unix/Linux operating systems, this example is for Ubuntu 14.04. +The procedure is similar to other systems. + +Edit the crontab of your web server user: + +```bash +sudo crontab -u www-data -e +``` + +Example to execute the daily cronjob at 8am: + +```bash +0 8 * * * cd /path/to/kanboard && ./kanboard cronjob >/dev/null 2>&1 +``` + +Note: the cronjob process must have write access to the database in case you are using Sqlite. +Usually, running the cronjob under the web server user is enough. diff --git a/doc/debian-installation.markdown b/doc/debian-installation.markdown index 147fe452..ec956049 100644 --- a/doc/debian-installation.markdown +++ b/doc/debian-installation.markdown @@ -1,6 +1,8 @@ How to install Kanboard on Debian? ================================== +Note: Some features of Kanboard require that you run [a daily background job](cronjob.markdown). + Debian 8 (Jessie) ----------------- diff --git a/doc/freebsd-installation.markdown b/doc/freebsd-installation.markdown index 84b35ad8..7b36dff1 100644 --- a/doc/freebsd-installation.markdown +++ b/doc/freebsd-installation.markdown @@ -55,7 +55,7 @@ Generally 3 elements have to be installed: Fetch and extract ports... ```bash -$ portsnap fetch +$ portsnap fetch $ portsnap extract ``` @@ -122,6 +122,7 @@ there is no need to install it manually. Please note ----------- -Port is being hosted on [bitbucket](https://bitbucket.org/if0/freebsd-kanboard/). Feel free to comment, +- Port is being hosted on [bitbucket](https://bitbucket.org/if0/freebsd-kanboard/). Feel free to comment, fork and suggest updates! - \ No newline at end of file +- Some features of Kanboard require that you run [a daily background job](cronjob.markdown). + diff --git a/doc/heroku.markdown b/doc/heroku.markdown index 56d79bc9..f145f70e 100644 --- a/doc/heroku.markdown +++ b/doc/heroku.markdown @@ -35,5 +35,5 @@ heroku open Limitations ----------- -The storage of Heroku is ephemeral, that means uploaded files through Kanboard are not persistent after a reboot. -We may want to install a plugin to store your files in a cloud storage provider like [Amazon S3](https://github.com/kanboard/plugin-s3). +- The storage of Heroku is ephemeral, that means uploaded files through Kanboard are not persistent after a reboot. You may want to install a plugin to store your files in a cloud storage provider like [Amazon S3](https://github.com/kanboard/plugin-s3). +- Some features of Kanboard require that you run [a daily background job](cronjob.markdown). diff --git a/doc/index.markdown b/doc/index.markdown index 1e95fe06..7603117b 100644 --- a/doc/index.markdown +++ b/doc/index.markdown @@ -103,6 +103,7 @@ Technical details ### Configuration - [Config file](config.markdown) +- [Background tasks](cronjob.markdown) - [Email configuration](email-configuration.markdown) - [URL rewriting](nice-urls.markdown) diff --git a/doc/installation.markdown b/doc/installation.markdown index b208be8a..dd4283f8 100644 --- a/doc/installation.markdown +++ b/doc/installation.markdown @@ -39,3 +39,8 @@ Security - Don't forget to change the default user/password - Don't allow everybody to access to the directory `data` from the URL. There is already a `.htaccess` for Apache but nothing for Nginx. + +Notes +----- + +- Some features of Kanboard require that you run [a daily background job](cronjob.markdown) diff --git a/doc/ubuntu-installation.markdown b/doc/ubuntu-installation.markdown index cec3ebba..ab4dfe7c 100644 --- a/doc/ubuntu-installation.markdown +++ b/doc/ubuntu-installation.markdown @@ -26,3 +26,5 @@ sudo unzip kanboard-latest.zip sudo chown -R www-data:www-data kanboard/data sudo rm kanboard-latest.zip ``` + +Some features of Kanboard require that you run [a daily background job](cronjob.markdown). diff --git a/doc/windows-apache-installation.markdown b/doc/windows-apache-installation.markdown index 2c8f74e1..27b6812e 100644 --- a/doc/windows-apache-installation.markdown +++ b/doc/windows-apache-installation.markdown @@ -123,3 +123,8 @@ Tested configuration -------------------- - Windows 2008 R2 / Apache 2.4.12 / PHP 5.6.8 + +Notes +----- + +- Some features of Kanboard require that you run [a daily background job](cronjob.markdown). diff --git a/doc/windows-iis-installation.markdown b/doc/windows-iis-installation.markdown index 6206db21..bd4607de 100644 --- a/doc/windows-iis-installation.markdown +++ b/doc/windows-iis-installation.markdown @@ -65,3 +65,9 @@ Tested configurations - Windows 2008 R2 Standard Edition / IIS 7.5 / PHP 5.5.16 - Windows 2012 Standard Edition / IIS 8.5 / PHP 5.3.29 + +Notes +----- + +- Some features of Kanboard require that you run [a daily background job](cronjob.markdown). + diff --git a/kanboard b/kanboard index 73dab4e9..5046181d 100755 --- a/kanboard +++ b/kanboard @@ -13,6 +13,8 @@ use Kanboard\Console\ProjectDailyColumnStatsExport; use Kanboard\Console\TransitionExport; use Kanboard\Console\LocaleSync; use Kanboard\Console\LocaleComparator; +use Kanboard\Console\TaskTrigger; +use Kanboard\Console\Cronjob; $container['dispatcher']->dispatch('app.bootstrap', new Event); @@ -25,4 +27,6 @@ $application->add(new ProjectDailyColumnStatsExport($container)); $application->add(new TransitionExport($container)); $application->add(new LocaleSync($container)); $application->add(new LocaleComparator($container)); +$application->add(new TaskTrigger($container)); +$application->add(new Cronjob($container)); $application->run(); -- cgit v1.2.3 From fc21d3873e3ac63222ad4065a593c715bef4c251 Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sun, 31 Jan 2016 17:46:19 -0500 Subject: When creating a new project, have the possibility to select another project to duplicate --- ChangeLog | 1 + Makefile | 2 +- app/Console/Base.php | 25 +- app/Controller/Project.php | 60 +--- app/Controller/ProjectCreation.php | 126 ++++++++ app/Controller/ProjectEdit.php | 4 +- app/Controller/ProjectPermission.php | 56 ++-- app/Controller/Taskduplication.php | 2 +- app/Core/ExternalLink/ExternalLinkManager.php | 2 - app/Model/ProjectDuplication.php | 140 ++++++--- app/Model/Task.php | 21 ++ app/Model/TaskFinder.php | 17 ++ app/ServiceProvider/AuthenticationProvider.php | 3 +- app/ServiceProvider/RouteProvider.php | 6 +- app/Template/app/layout.php | 6 +- app/Template/gantt/projects.php | 6 - app/Template/header.php | 18 +- app/Template/project/duplicate.php | 6 +- app/Template/project/index.php | 4 - app/Template/project/new.php | 24 -- app/Template/project_creation/create.php | 42 +++ app/Template/project_edit/general.php | 2 +- app/Template/project_user/layout.php | 7 - assets/css/app.css | 2 +- assets/css/src/header.css | 5 + assets/css/src/project.css | 8 + assets/js/app.js | 2 +- assets/js/src/Project.js | 10 + tests/units/Helper/UserHelperTest.php | 12 +- tests/units/Model/ProjectDuplicationTest.php | 383 +++++++++++++++++-------- 30 files changed, 677 insertions(+), 325 deletions(-) create mode 100644 app/Controller/ProjectCreation.php delete mode 100644 app/Template/project/new.php create mode 100644 app/Template/project_creation/create.php create mode 100644 assets/css/src/project.css (limited to 'app/Model/Task.php') diff --git a/ChangeLog b/ChangeLog index 8868d410..25b013dc 100644 --- a/ChangeLog +++ b/ChangeLog @@ -10,6 +10,7 @@ Breaking changes: New features: +* When creating a new project, have the possibility to select another project to duplicate * Add a "Me" button to assignee form element * Add external links for tasks with plugin api * Add project owner (Directly Responsible Individual) diff --git a/Makefile b/Makefile index 5ea356c0..d498487c 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ BUILD_DIR = /tmp -CSS_APP = $(addprefix assets/css/src/, $(addsuffix .css, base links title table form button alert tooltip header board task comment subtask markdown listing activity dashboard pagination popover confirm sidebar responsive dropdown screenshot filters gantt)) +CSS_APP = $(addprefix assets/css/src/, $(addsuffix .css, base links title table form button alert tooltip header board task comment subtask markdown listing activity dashboard pagination popover confirm sidebar responsive dropdown screenshot filters gantt project)) CSS_PRINT = $(addprefix assets/css/src/, $(addsuffix .css, print links table board task comment subtask markdown)) CSS_VENDOR = $(addprefix assets/css/vendor/, $(addsuffix .css, jquery-ui.min jquery-ui-timepicker-addon.min chosen.min fullcalendar.min font-awesome.min c3.min)) diff --git a/app/Console/Base.php b/app/Console/Base.php index 4c5caf73..ac89207d 100644 --- a/app/Console/Base.php +++ b/app/Console/Base.php @@ -11,18 +11,19 @@ use Symfony\Component\Console\Command\Command; * @package console * @author Frederic Guillot * - * @property \Kanboard\Model\Notification $notification - * @property \Kanboard\Model\Project $project - * @property \Kanboard\Model\ProjectPermission $projectPermission - * @property \Kanboard\Model\ProjectAnalytic $projectAnalytic - * @property \Kanboard\Model\ProjectDailyColumnStats $projectDailyColumnStats - * @property \Kanboard\Model\ProjectDailyStats $projectDailyStats - * @property \Kanboard\Model\SubtaskExport $subtaskExport - * @property \Kanboard\Model\OverdueNotification $overdueNotification - * @property \Kanboard\Model\Task $task - * @property \Kanboard\Model\TaskExport $taskExport - * @property \Kanboard\Model\TaskFinder $taskFinder - * @property \Kanboard\Model\Transition $transition + * @property \Kanboard\Model\Notification $notification + * @property \Kanboard\Model\Project $project + * @property \Kanboard\Model\ProjectPermission $projectPermission + * @property \Kanboard\Model\ProjectAnalytic $projectAnalytic + * @property \Kanboard\Model\ProjectDailyColumnStats $projectDailyColumnStats + * @property \Kanboard\Model\ProjectDailyStats $projectDailyStats + * @property \Kanboard\Model\SubtaskExport $subtaskExport + * @property \Kanboard\Model\OverdueNotification $overdueNotification + * @property \Kanboard\Model\Task $task + * @property \Kanboard\Model\TaskExport $taskExport + * @property \Kanboard\Model\TaskFinder $taskFinder + * @property \Kanboard\Model\Transition $transition + * @property \Symfony\Component\EventDispatcher\EventDispatcher $dispatcher */ abstract class Base extends Command { diff --git a/app/Controller/Project.php b/app/Controller/Project.php index ffd62b09..661fd68b 100644 --- a/app/Controller/Project.php +++ b/app/Controller/Project.php @@ -171,14 +171,15 @@ class Project extends Base $project = $this->getProject(); if ($this->request->getStringParam('duplicate') === 'yes') { - $values = array_keys($this->request->getValues()); - if ($this->projectDuplication->duplicate($project['id'], $values) !== false) { + $project_id = $this->projectDuplication->duplicate($project['id'], array_keys($this->request->getValues()), $this->userSession->getId()); + + if ($project_id !== false) { $this->flash->success(t('Project cloned successfully.')); } else { $this->flash->failure(t('Unable to clone this project.')); } - $this->response->redirect($this->helper->url->to('project', 'index')); + $this->response->redirect($this->helper->url->to('project', 'show', array('project_id' => $project_id))); } $this->response->html($this->projectLayout('project/duplicate', array( @@ -240,57 +241,4 @@ class Project extends Base 'title' => t('Project activation') ))); } - - /** - * Display a form to create a new project - * - * @access public - */ - public function create(array $values = array(), array $errors = array()) - { - $is_private = isset($values['is_private']) && $values['is_private'] == 1; - - $this->response->html($this->template->layout('project/new', array( - 'board_selector' => $this->projectUserRole->getActiveProjectsByUser($this->userSession->getId()), - 'values' => $values, - 'errors' => $errors, - 'is_private' => $is_private, - 'title' => $is_private ? t('New private project') : t('New project'), - ))); - } - - /** - * Display a form to create a private project - * - * @access public - */ - public function createPrivate(array $values = array(), array $errors = array()) - { - $values['is_private'] = 1; - $this->create($values, $errors); - } - - /** - * Validate and save a new project - * - * @access public - */ - public function save() - { - $values = $this->request->getValues(); - list($valid, $errors) = $this->projectValidator->validateCreation($values); - - if ($valid) { - $project_id = $this->project->create($values, $this->userSession->getId(), true); - - if ($project_id > 0) { - $this->flash->success(t('Your project have been created successfully.')); - $this->response->redirect($this->helper->url->to('project', 'show', array('project_id' => $project_id))); - } - - $this->flash->failure(t('Unable to create your project.')); - } - - $this->create($values, $errors); - } } diff --git a/app/Controller/ProjectCreation.php b/app/Controller/ProjectCreation.php new file mode 100644 index 00000000..a3154034 --- /dev/null +++ b/app/Controller/ProjectCreation.php @@ -0,0 +1,126 @@ + t('Do not duplicate anything')) + $this->projectUserRole->getActiveProjectsByUser($this->userSession->getId()); + + $this->response->html($this->template->layout('project_creation/create', array( + 'board_selector' => $this->projectUserRole->getActiveProjectsByUser($this->userSession->getId()), + 'values' => $values, + 'errors' => $errors, + 'is_private' => $is_private, + 'projects_list' => $projects_list, + 'title' => $is_private ? t('New private project') : t('New project'), + ))); + } + + /** + * Display a form to create a private project + * + * @access public + */ + public function createPrivate(array $values = array(), array $errors = array()) + { + $values['is_private'] = 1; + $this->create($values, $errors); + } + + /** + * Validate and save a new project + * + * @access public + */ + public function save() + { + $values = $this->request->getValues(); + list($valid, $errors) = $this->projectValidator->validateCreation($values); + + if ($valid) { + $project_id = $this->createOrDuplicate($values); + + if ($project_id > 0) { + $this->flash->success(t('Your project have been created successfully.')); + return $this->response->redirect($this->helper->url->to('project', 'show', array('project_id' => $project_id))); + } + + $this->flash->failure(t('Unable to create your project.')); + } + + $this->create($values, $errors); + } + + /** + * Create or duplicate a project + * + * @access private + * @param array $values + * @return boolean|integer + */ + private function createOrDuplicate(array $values) + { + if ($values['src_project_id'] == 0) { + return $this->createNewProject($values); + } + + return $this->duplicateNewProject($values); + } + + /** + * Save a new project + * + * @access private + * @param array $values + * @return boolean|integer + */ + private function createNewProject(array $values) + { + $project = array( + 'name' => $values['name'], + 'is_private' => $values['is_private'], + ); + + return $this->project->create($project, $this->userSession->getId(), true); + } + + /** + * Creatte from another project + * + * @access private + * @param array $values + * @return boolean|integer + */ + private function duplicateNewProject(array $values) + { + $selection = array(); + + foreach ($this->projectDuplication->getOptionalSelection() as $item) { + if (isset($values[$item]) && $values[$item] == 1) { + $selection[] = $item; + } + } + + return $this->projectDuplication->duplicate( + $values['src_project_id'], + $selection, + $this->userSession->getId(), + $values['name'], + $values['is_private'] == 1 + ); + } +} diff --git a/app/Controller/ProjectEdit.php b/app/Controller/ProjectEdit.php index 0dfc7de3..29793c47 100644 --- a/app/Controller/ProjectEdit.php +++ b/app/Controller/ProjectEdit.php @@ -89,11 +89,11 @@ class ProjectEdit extends Base { if ($redirect === 'edit') { if (isset($values['is_private'])) { - if (! $this->helper->user->hasProjectAccess('project', 'create', $project['id'])) { + if (! $this->helper->user->hasProjectAccess('ProjectCreation', 'create', $project['id'])) { unset($values['is_private']); } } elseif ($project['is_private'] == 1 && ! isset($values['is_private'])) { - if ($this->helper->user->hasProjectAccess('project', 'create', $project['id'])) { + if ($this->helper->user->hasProjectAccess('ProjectCreation', 'create', $project['id'])) { $values += array('is_private' => 0); } } diff --git a/app/Controller/ProjectPermission.php b/app/Controller/ProjectPermission.php index 4434d017..e0e58240 100644 --- a/app/Controller/ProjectPermission.php +++ b/app/Controller/ProjectPermission.php @@ -12,6 +12,24 @@ use Kanboard\Core\Security\Role; */ class ProjectPermission extends Base { + /** + * Permissions are only available for team projects + * + * @access protected + * @param integer $project_id Default project id + * @return array + */ + protected function getProject($project_id = 0) + { + $project = parent::getProject($project_id); + + if ($project['is_private'] == 1) { + $this->forbidden(); + } + + return $project; + } + /** * Show all permissions * @@ -62,6 +80,7 @@ class ProjectPermission extends Base */ public function addUser() { + $project = $this->getProject(); $values = $this->request->getValues(); if ($this->projectUserRole->addUser($values['project_id'], $values['user_id'], $values['role'])) { @@ -70,7 +89,7 @@ class ProjectPermission extends Base $this->flash->failure(t('Unable to update this project.')); } - $this->response->redirect($this->helper->url->to('ProjectPermission', 'index', array('project_id' => $values['project_id']))); + $this->response->redirect($this->helper->url->to('ProjectPermission', 'index', array('project_id' => $project['id']))); } /** @@ -81,19 +100,16 @@ class ProjectPermission extends Base public function removeUser() { $this->checkCSRFParam(); + $project = $this->getProject(); + $user_id = $this->request->getIntegerParam('user_id'); - $values = array( - 'project_id' => $this->request->getIntegerParam('project_id'), - 'user_id' => $this->request->getIntegerParam('user_id'), - ); - - if ($this->projectUserRole->removeUser($values['project_id'], $values['user_id'])) { + if ($this->projectUserRole->removeUser($project['id'], $user_id)) { $this->flash->success(t('Project updated successfully.')); } else { $this->flash->failure(t('Unable to update this project.')); } - $this->response->redirect($this->helper->url->to('ProjectPermission', 'index', array('project_id' => $values['project_id']))); + $this->response->redirect($this->helper->url->to('ProjectPermission', 'index', array('project_id' => $project['id']))); } /** @@ -103,10 +119,10 @@ class ProjectPermission extends Base */ public function changeUserRole() { - $project_id = $this->request->getIntegerParam('project_id'); + $project = $this->getProject(); $values = $this->request->getJson(); - if (! empty($project_id) && ! empty($values) && $this->projectUserRole->changeUserRole($project_id, $values['id'], $values['role'])) { + if (! empty($project) && ! empty($values) && $this->projectUserRole->changeUserRole($project['id'], $values['id'], $values['role'])) { $this->response->json(array('status' => 'ok')); } else { $this->response->json(array('status' => 'error')); @@ -120,19 +136,20 @@ class ProjectPermission extends Base */ public function addGroup() { + $project = $this->getProject(); $values = $this->request->getValues(); if (empty($values['group_id']) && ! empty($values['external_id'])) { $values['group_id'] = $this->group->create($values['name'], $values['external_id']); } - if ($this->projectGroupRole->addGroup($values['project_id'], $values['group_id'], $values['role'])) { + if ($this->projectGroupRole->addGroup($project['id'], $values['group_id'], $values['role'])) { $this->flash->success(t('Project updated successfully.')); } else { $this->flash->failure(t('Unable to update this project.')); } - $this->response->redirect($this->helper->url->to('ProjectPermission', 'index', array('project_id' => $values['project_id']))); + $this->response->redirect($this->helper->url->to('ProjectPermission', 'index', array('project_id' => $project['id']))); } /** @@ -143,19 +160,16 @@ class ProjectPermission extends Base public function removeGroup() { $this->checkCSRFParam(); + $project = $this->getProject(); + $group_id = $this->request->getIntegerParam('group_id'); - $values = array( - 'project_id' => $this->request->getIntegerParam('project_id'), - 'group_id' => $this->request->getIntegerParam('group_id'), - ); - - if ($this->projectGroupRole->removeGroup($values['project_id'], $values['group_id'])) { + if ($this->projectGroupRole->removeGroup($project['id'], $group_id)) { $this->flash->success(t('Project updated successfully.')); } else { $this->flash->failure(t('Unable to update this project.')); } - $this->response->redirect($this->helper->url->to('ProjectPermission', 'index', array('project_id' => $values['project_id']))); + $this->response->redirect($this->helper->url->to('ProjectPermission', 'index', array('project_id' => $project['id']))); } /** @@ -165,10 +179,10 @@ class ProjectPermission extends Base */ public function changeGroupRole() { - $project_id = $this->request->getIntegerParam('project_id'); + $project = $this->getProject(); $values = $this->request->getJson(); - if (! empty($project_id) && ! empty($values) && $this->projectGroupRole->changeGroupRole($project_id, $values['id'], $values['role'])) { + if (! empty($project) && ! empty($values) && $this->projectGroupRole->changeGroupRole($project['id'], $values['id'], $values['role'])) { $this->response->json(array('status' => 'ok')); } else { $this->response->json(array('status' => 'error')); diff --git a/app/Controller/Taskduplication.php b/app/Controller/Taskduplication.php index ae8bfcbc..a41183a7 100644 --- a/app/Controller/Taskduplication.php +++ b/app/Controller/Taskduplication.php @@ -109,7 +109,7 @@ class Taskduplication extends Base private function chooseDestination(array $task, $template) { $values = array(); - $projects_list = $this->projectUserRole->getProjectsByUser($this->userSession->getId(), array(ProjectModel::ACTIVE)); + $projects_list = $this->projectUserRole->getActiveProjectsByUser($this->userSession->getId()); unset($projects_list[$task['project_id']]); diff --git a/app/Core/ExternalLink/ExternalLinkManager.php b/app/Core/ExternalLink/ExternalLinkManager.php index cd3476ca..59f36e54 100644 --- a/app/Core/ExternalLink/ExternalLinkManager.php +++ b/app/Core/ExternalLink/ExternalLinkManager.php @@ -120,8 +120,6 @@ class ExternalLinkManager extends Base */ public function find() { - $provider = null; - if ($this->userInputType === self::TYPE_AUTO) { $provider = $this->findProvider(); } else { diff --git a/app/Model/ProjectDuplication.php b/app/Model/ProjectDuplication.php index f0c66834..16e4f7c2 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 * @@ -11,6 +13,28 @@ namespace Kanboard\Model; */ class ProjectDuplication extends Base { + /** + * Get list of optional models to duplicate + * + * @access public + * @return array + */ + public function getOptionalSelection() + { + return array('category', 'projectPermission', 'action', 'swimlane', 'task'); + } + + /** + * Get list of all possible models to duplicate + * + * @access public + * @return array + */ + public function getPossibleSelection() + { + return array('board', 'category', 'projectPermission', 'action', 'swimlane', 'task'); + } + /** * Get a valid project name for the duplication * @@ -30,79 +54,107 @@ class ProjectDuplication extends Base return $name.$suffix; } - /** - * 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) { $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/Task.php b/app/Model/Task.php index 94b23ec2..38fdd0d5 100644 --- a/app/Model/Task.php +++ b/app/Model/Task.php @@ -199,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/TaskFinder.php b/app/Model/TaskFinder.php index ab290bce..4d673097 100644 --- a/app/Model/TaskFinder.php +++ b/app/Model/TaskFinder.php @@ -179,6 +179,23 @@ class TaskFinder extends Base ->findAll(); } + /** + * 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 * diff --git a/app/ServiceProvider/AuthenticationProvider.php b/app/ServiceProvider/AuthenticationProvider.php index 144abb0e..aaf23083 100644 --- a/app/ServiceProvider/AuthenticationProvider.php +++ b/app/ServiceProvider/AuthenticationProvider.php @@ -128,8 +128,7 @@ class AuthenticationProvider implements ServiceProviderInterface $acl->add('Gantt', array('projects', 'saveProjectDate'), Role::APP_MANAGER); $acl->add('Group', '*', Role::APP_ADMIN); $acl->add('Link', '*', Role::APP_ADMIN); - $acl->add('Project', array('users', 'allowEverybody', 'allow', 'role', 'revoke', 'create'), Role::APP_MANAGER); - $acl->add('ProjectPermission', '*', Role::APP_USER); + $acl->add('ProjectCreation', 'create', Role::APP_MANAGER); $acl->add('Projectuser', '*', Role::APP_MANAGER); $acl->add('Twofactor', 'disable', Role::APP_ADMIN); $acl->add('UserImport', '*', Role::APP_ADMIN); diff --git a/app/ServiceProvider/RouteProvider.php b/app/ServiceProvider/RouteProvider.php index ebe087ae..78c46bc4 100644 --- a/app/ServiceProvider/RouteProvider.php +++ b/app/ServiceProvider/RouteProvider.php @@ -43,10 +43,12 @@ class RouteProvider implements ServiceProviderInterface $container['route']->addRoute('search', 'search', 'index'); $container['route']->addRoute('search/:search', 'search', 'index'); + // ProjectCreation routes + $container['route']->addRoute('project/create', 'ProjectCreation', 'create'); + $container['route']->addRoute('project/create/private', 'ProjectCreation', 'createPrivate'); + // Project routes $container['route']->addRoute('projects', 'project', 'index'); - $container['route']->addRoute('project/create', 'project', 'create'); - $container['route']->addRoute('project/create/private', 'project', 'createPrivate'); $container['route']->addRoute('project/:project_id', 'project', 'show'); $container['route']->addRoute('p/:project_id', 'project', 'show'); $container['route']->addRoute('project/:project_id/customer-filter', 'customfilter', 'index'); diff --git a/app/Template/app/layout.php b/app/Template/app/layout.php index ad1d5a9e..d5bb1d8e 100644 --- a/app/Template/app/layout.php +++ b/app/Template/app/layout.php @@ -1,15 +1,15 @@