summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.htaccess4
-rw-r--r--ChangeLog5
-rw-r--r--app/Action/Base.php11
-rw-r--r--app/Api/Base.php10
-rw-r--r--app/Api/File.php15
-rw-r--r--app/Controller/File.php62
-rw-r--r--app/Core/ObjectStorage/FileStorage.php2
-rw-r--r--app/Core/ObjectStorage/ObjectStorageInterface.php2
-rw-r--r--app/Core/Plugin/Loader.php6
-rw-r--r--app/Helper/Hook.php20
-rw-r--r--app/Integration/SlackWebhook.php63
-rw-r--r--app/Model/Action.php26
-rw-r--r--app/Model/File.php4
-rw-r--r--app/Template/layout.php5
-rw-r--r--app/check_setup.php17
-rw-r--r--app/constants.php3
-rw-r--r--config.default.php3
-rw-r--r--doc/config.markdown8
-rw-r--r--doc/fr/analytics-tasks.markdown24
-rw-r--r--doc/fr/closing-tasks.markdown16
-rw-r--r--doc/fr/create-tasks-by-email.markdown45
-rw-r--r--doc/fr/creating-tasks.markdown27
-rw-r--r--doc/fr/duplicate-move-tasks.markdown58
-rw-r--r--doc/fr/index.markdown45
-rw-r--r--doc/fr/recurring-tasks.markdown24
-rw-r--r--doc/fr/screenshots.markdown26
-rw-r--r--doc/fr/subtasks.markdown43
-rw-r--r--doc/fr/task-links.markdown22
-rw-r--r--doc/fr/time-tracking.markdown44
-rw-r--r--doc/fr/transitions.markdown20
-rw-r--r--doc/installation.markdown9
-rw-r--r--doc/plugins.markdown71
-rw-r--r--tests/units/Helper/HookHelperTest.php36
-rw-r--r--tests/units/Integration/SlackWebhookTest.php377
-rw-r--r--tests/units/Model/ActionTest.php11
-rw-r--r--tests/units/Model/FileTest.php10
36 files changed, 1097 insertions, 77 deletions
diff --git a/.htaccess b/.htaccess
index 0d873f58..d17e5c80 100644
--- a/.htaccess
+++ b/.htaccess
@@ -7,3 +7,7 @@
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [QSA,L]
</IfModule>
+
+<FilesMatch "(kanboard|config.php|config.default.php)">
+ Deny from all
+</FilesMatch>
diff --git a/ChangeLog b/ChangeLog
index 566f501a..3302fae9 100644
--- a/ChangeLog
+++ b/ChangeLog
@@ -29,6 +29,11 @@ Improvements:
* Add abstract storage layer
* Add abstract cache layer
+Others:
+
+* Data directory permissions are not checked anymore
+* Data directory is not mandatory anymore for people that use a remote database and remote object storage
+
Bug fixes:
* Fix typo in template that prevent the Gitlab OAuth link to be displayed
diff --git a/app/Action/Base.php b/app/Action/Base.php
index d0c81d89..c8ff02a4 100644
--- a/app/Action/Base.php
+++ b/app/Action/Base.php
@@ -127,6 +127,17 @@ abstract class Base extends \Core\Base
}
/**
+ * Get project id
+ *
+ * @access public
+ * @return integer
+ */
+ public function getProjectId()
+ {
+ return $this->project_id;
+ }
+
+ /**
* Set an user defined parameter
*
* @access public
diff --git a/app/Api/Base.php b/app/Api/Base.php
index fef36e0c..0287e0ec 100644
--- a/app/Api/Base.php
+++ b/app/Api/Base.php
@@ -54,6 +54,8 @@ abstract class Base extends \Core\Base
else if (! $is_user && ! $is_both_procedure && $is_user_procedure) {
throw new AccessDeniedException('Permission denied');
}
+
+ $this->logger->debug('API call: '.$procedure);
}
public function checkProjectPermission($project_id)
@@ -70,7 +72,7 @@ abstract class Base extends \Core\Base
}
}
- protected function formatTask(array $task)
+ protected function formatTask($task)
{
if (! empty($task)) {
$task['url'] = $this->helper->url->to('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']), '', true);
@@ -80,7 +82,7 @@ abstract class Base extends \Core\Base
return $task;
}
- protected function formatTasks(array $tasks)
+ protected function formatTasks($tasks)
{
if (! empty($tasks)) {
foreach ($tasks as &$task) {
@@ -91,7 +93,7 @@ abstract class Base extends \Core\Base
return $tasks;
}
- protected function formatProject(array $project)
+ protected function formatProject($project)
{
if (! empty($project)) {
$project['url'] = array(
@@ -104,7 +106,7 @@ abstract class Base extends \Core\Base
return $project;
}
- protected function formatProjects(array $projects)
+ protected function formatProjects($projects)
{
if (! empty($projects)) {
foreach ($projects as &$project) {
diff --git a/app/Api/File.php b/app/Api/File.php
index 97aa9d82..ad736ad4 100644
--- a/app/Api/File.php
+++ b/app/Api/File.php
@@ -2,6 +2,8 @@
namespace Api;
+use Core\ObjectStorage\ObjectStorageException;
+
/**
* File API controller
*
@@ -22,16 +24,17 @@ class File extends \Core\Base
public function downloadFile($file_id)
{
- $file = $this->file->getById($file_id);
-
- if (! empty($file)) {
+ try {
- $filename = FILES_DIR.$file['path'];
+ $file = $this->file->getById($file_id);
- if (file_exists($filename)) {
- return base64_encode(file_get_contents($filename));
+ if (! empty($file)) {
+ return base64_encode($this->objectStorage->get($file['path']));
}
}
+ catch (ObjectStorageException $e) {
+ $this->logger->error($e->getMessage());
+ }
return '';
}
diff --git a/app/Controller/File.php b/app/Controller/File.php
index 7b7c75ee..ef90c55a 100644
--- a/app/Controller/File.php
+++ b/app/Controller/File.php
@@ -2,6 +2,8 @@
namespace Controller;
+use Core\ObjectStorage\ObjectStorageException;
+
/**
* File controller
*
@@ -74,15 +76,21 @@ class File extends Base
*/
public function download()
{
- $task = $this->getTask();
- $file = $this->file->getById($this->request->getIntegerParam('file_id'));
+ try {
- if ($file['task_id'] != $task['id']) {
- $this->response->redirect($this->helper->url->to('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])));
- }
+ $task = $this->getTask();
+ $file = $this->file->getById($this->request->getIntegerParam('file_id'));
+
+ if ($file['task_id'] != $task['id']) {
+ $this->response->redirect($this->helper->url->to('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])));
+ }
- $this->response->forceDownload($file['name']);
- $this->objectStorage->passthru($file['path']);
+ $this->response->forceDownload($file['name']);
+ $this->objectStorage->output($file['path']);
+ }
+ catch (ObjectStorageException $e) {
+ $this->logger->error($e->getMessage());
+ }
}
/**
@@ -110,15 +118,21 @@ class File extends Base
*/
public function image()
{
- $task = $this->getTask();
- $file = $this->file->getById($this->request->getIntegerParam('file_id'));
+ try {
- if ($file['task_id'] != $task['id']) {
- $this->response->redirect($this->helper->url->to('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])));
- }
+ $task = $this->getTask();
+ $file = $this->file->getById($this->request->getIntegerParam('file_id'));
- $this->response->contentType($this->file->getImageMimeType($file['name']));
- $this->objectStorage->passthru($file['path']);
+ if ($file['task_id'] != $task['id']) {
+ $this->response->redirect($this->helper->url->to('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])));
+ }
+
+ $this->response->contentType($this->file->getImageMimeType($file['name']));
+ $this->objectStorage->output($file['path']);
+ }
+ catch (ObjectStorageException $e) {
+ $this->logger->error($e->getMessage());
+ }
}
/**
@@ -128,15 +142,21 @@ class File extends Base
*/
public function thumbnail()
{
- $task = $this->getTask();
- $file = $this->file->getById($this->request->getIntegerParam('file_id'));
+ try {
- if ($file['task_id'] != $task['id']) {
- $this->response->redirect($this->helper->url->to('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])));
- }
+ $task = $this->getTask();
+ $file = $this->file->getById($this->request->getIntegerParam('file_id'));
+
+ if ($file['task_id'] != $task['id']) {
+ $this->response->redirect($this->helper->url->to('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])));
+ }
- $this->response->contentType('image/jpeg');
- $this->objectStorage->passthru($this->file->getThumbnailPath($file['path']));
+ $this->response->contentType('image/jpeg');
+ $this->objectStorage->output($this->file->getThumbnailPath($file['path']));
+ }
+ catch (ObjectStorageException $e) {
+ $this->logger->error($e->getMessage());
+ }
}
/**
diff --git a/app/Core/ObjectStorage/FileStorage.php b/app/Core/ObjectStorage/FileStorage.php
index 96478c3a..fa1efe21 100644
--- a/app/Core/ObjectStorage/FileStorage.php
+++ b/app/Core/ObjectStorage/FileStorage.php
@@ -70,7 +70,7 @@ class FileStorage implements ObjectStorageInterface
* @access public
* @param string $key
*/
- public function passthru($key)
+ public function output($key)
{
$filename = $this->path.DIRECTORY_SEPARATOR.$key;
diff --git a/app/Core/ObjectStorage/ObjectStorageInterface.php b/app/Core/ObjectStorage/ObjectStorageInterface.php
index 5440cf2b..180bdf86 100644
--- a/app/Core/ObjectStorage/ObjectStorageInterface.php
+++ b/app/Core/ObjectStorage/ObjectStorageInterface.php
@@ -35,7 +35,7 @@ interface ObjectStorageInterface
* @access public
* @param string $key
*/
- public function passthru($key);
+ public function output($key);
/**
* Move local file to object storage
diff --git a/app/Core/Plugin/Loader.php b/app/Core/Plugin/Loader.php
index 2758f37e..04b2bfff 100644
--- a/app/Core/Plugin/Loader.php
+++ b/app/Core/Plugin/Loader.php
@@ -28,8 +28,8 @@ class Loader extends \Core\Base
*/
public function scan()
{
- if (file_exists(__DIR__.'/../../../plugins')) {
- $dir = new DirectoryIterator(__DIR__.'/../../../plugins');
+ if (file_exists(PLUGINS_DIR)) {
+ $dir = new DirectoryIterator(PLUGINS_DIR);
foreach ($dir as $fileinfo) {
if (! $fileinfo->isDot() && $fileinfo->isDir()) {
@@ -65,7 +65,7 @@ class Loader extends \Core\Base
*/
public function loadSchema($plugin)
{
- $filename = __DIR__.'/../../../plugins/'.$plugin.'/Schema/'.ucfirst(DB_DRIVER).'.php';
+ $filename = PLUGINS_DIR.'/'.$plugin.'/Schema/'.ucfirst(DB_DRIVER).'.php';
if (file_exists($filename)) {
require_once($filename);
diff --git a/app/Helper/Hook.php b/app/Helper/Hook.php
index d7fe3d34..bf879878 100644
--- a/app/Helper/Hook.php
+++ b/app/Helper/Hook.php
@@ -11,6 +11,26 @@ namespace Helper;
class Hook extends \Core\Base
{
/**
+ * Add assets JS or CSS
+ *
+ * @access public
+ * @param string $type
+ * @param string $hook
+ * @param array $variables
+ * @return string
+ */
+ public function asset($type, $hook)
+ {
+ $buffer = '';
+
+ foreach ($this->hook->getListeners($hook) as $file) {
+ $buffer .= $this->helper->asset->$type($file);
+ }
+
+ return $buffer;
+ }
+
+ /**
* Render all attached hooks
*
* @access public
diff --git a/app/Integration/SlackWebhook.php b/app/Integration/SlackWebhook.php
index d238652f..71244739 100644
--- a/app/Integration/SlackWebhook.php
+++ b/app/Integration/SlackWebhook.php
@@ -36,7 +36,7 @@ class SlackWebhook extends \Core\Base
}
$options = $this->projectIntegration->getParameters($project_id);
- return $options['slack_webhook_url'];
+ return isset($options['slack_webhook_url']) ? $options['slack_webhook_url'] : '';
}
/**
@@ -52,14 +52,14 @@ class SlackWebhook extends \Core\Base
if (! empty($channel)) {
return $channel;
- }
+ }
- $options = $this->projectIntegration->getParameters($project_id);
- return $options['slack_webhook_channel'];
+ $options = $this->projectIntegration->getParameters($project_id);
+ return isset($options['slack_webhook_channel']) ? $options['slack_webhook_channel'] : '';
}
/**
- * Send message to the incoming Slack webhook
+ * Send notification to Slack
*
* @access public
* @param integer $project_id Project id
@@ -76,23 +76,52 @@ class SlackWebhook extends \Core\Base
$event['event_name'] = $event_name;
$event['author'] = $this->user->getFullname($this->session['user']);
- $payload = array(
- 'text' => '*['.$project['name'].']* '.str_replace('&quot;', '"', $this->projectActivity->getTitle($event)).(isset($event['task']['title']) ? ' ('.$event['task']['title'].')' : ''),
- 'username' => 'Kanboard',
- 'icon_url' => 'http://kanboard.net/assets/img/favicon.png',
- );
+ $message = '*['.$project['name'].']* ';
+ $message .= str_replace('&quot;', '"', $this->projectActivity->getTitle($event));
+ $message .= isset($event['task']['title']) ? ' ('.$event['task']['title'].')' : '';
if ($this->config->get('application_url')) {
- $payload['text'] .= ' - <'.$this->helper->url->href('task', 'show', array('task_id' => $task_id, 'project_id' => $project_id), false, '', true);
- $payload['text'] .= '|'.t('view the task on Kanboard').'>';
+ $message .= ' - <'.$this->helper->url->href('task', 'show', array('task_id' => $task_id, 'project_id' => $project_id), false, '', true);
+ $message .= '|'.t('view the task on Kanboard').'>';
}
- $channel = $this->getChannel($project_id);
- if (! empty($channel)) {
- $payload['channel'] = $channel;
- }
+ $this->sendMessage($project_id, $message);
+ }
+ }
+
+ /**
+ * Send message to Slack
+ *
+ * @access public
+ * @param integer $project_id
+ * @param string $message
+ */
+ public function sendMessage($project_id, $message)
+ {
+ $payload = array(
+ 'text' => $message,
+ 'username' => 'Kanboard',
+ 'icon_url' => 'http://kanboard.net/assets/img/favicon.png',
+ );
- $this->httpClient->postJson($this->getWebhookUrl($project_id), $payload);
+ $this->sendPayload($project_id, $payload);
+ }
+
+ /**
+ * Send payload to Slack
+ *
+ * @access public
+ * @param integer $project_id
+ * @param array $payload
+ */
+ public function sendPayload($project_id, array $payload)
+ {
+ $channel = $this->getChannel($project_id);
+
+ if (! empty($channel)) {
+ $payload['channel'] = $channel;
}
+
+ $this->httpClient->postJson($this->getWebhookUrl($project_id), $payload);
}
}
diff --git a/app/Model/Action.php b/app/Model/Action.php
index 87058cce..57bd5b0d 100644
--- a/app/Model/Action.php
+++ b/app/Model/Action.php
@@ -31,6 +31,28 @@ class Action extends Base
const TABLE_PARAMS = 'action_has_params';
/**
+ * Extended actions
+ *
+ * @access private
+ * @var array
+ */
+ private $actions = array();
+
+ /**
+ * Extend the list of default actions
+ *
+ * @access public
+ * @param string $className
+ * @param string $description
+ * @return Action
+ */
+ public function extendActions($className, $description)
+ {
+ $this->actions[$className] = $description;
+ return $this;
+ }
+
+ /**
* Return the name and description of available actions
*
* @access public
@@ -62,6 +84,8 @@ class Action extends Base
'TaskAssignColorLink' => t('Change task color when using a specific task link'),
);
+ $values = array_merge($values, $this->actions);
+
asort($values);
return $values;
@@ -296,7 +320,7 @@ class Action extends Base
*/
public function load($name, $project_id, $event)
{
- $className = '\Action\\'.$name;
+ $className = $name{0} !== '\\' ? '\Action\\'.$name : $name;
return new $className($this->container, $project_id, $event);
}
diff --git a/app/Model/File.php b/app/Model/File.php
index 7adab42b..1d44a415 100644
--- a/app/Model/File.php
+++ b/app/Model/File.php
@@ -54,6 +54,10 @@ class File extends Base
$file = $this->getbyId($file_id);
$this->objectStorage->remove($file['path']);
+ if ($file['is_image'] == 1) {
+ $this->objectStorage->remove($this->getThumbnailPath($file['path']));
+ }
+
return $this->db->table(self::TABLE)->eq('id', $file['id'])->remove();
}
catch (ObjectStorageException $e) {
diff --git a/app/Template/layout.php b/app/Template/layout.php
index 49ac2a08..20582952 100644
--- a/app/Template/layout.php
+++ b/app/Template/layout.php
@@ -21,6 +21,9 @@
<?= $this->asset->css('assets/css/print.css', true, 'print') ?>
<?= $this->asset->customCss() ?>
+ <?= $this->hook->asset('css', 'template:layout:css') ?>
+ <?= $this->hook->asset('js', 'template:layout:js') ?>
+
<link rel="icon" type="image/png" href="<?= $this->url->dir() ?>assets/img/favicon.png">
<link rel="apple-touch-icon" href="<?= $this->url->dir() ?>assets/img/touch-icon-iphone.png">
<link rel="apple-touch-icon" sizes="72x72" href="<?= $this->url->dir() ?>assets/img/touch-icon-ipad.png">
@@ -44,7 +47,7 @@
<?= $this->render('header', array(
'title' => $title,
'description' => isset($description) ? $description : '',
- 'board_selector' => $board_selector,
+ 'board_selector' => isset($board_selector) ? $board_selector : array(),
)) ?>
<section class="page">
<?= $this->app->flashMessage() ?>
diff --git a/app/check_setup.php b/app/check_setup.php
index 624b6b34..65f291e5 100644
--- a/app/check_setup.php
+++ b/app/check_setup.php
@@ -29,24 +29,7 @@ if (! extension_loaded('mbstring')) {
die('PHP extension required: mbstring');
}
-// Check if /data is writeable
-if (! is_writable('data')) {
- die('The directory "data" must be writeable by your web server user');
-}
-
// Fix wrong value for arg_separator.output, used by the function http_build_query()
if (ini_get('arg_separator.output') === '&amp;') {
ini_set('arg_separator.output', '&');
}
-
-// Prepare folder for uploaded files
-if (! is_dir(FILES_DIR)) {
- if (! mkdir(FILES_DIR, 0755, true)) {
- die('Unable to create the upload directory: "'.FILES_DIR.'"');
- }
-}
-
-// Check permissions for files folder
-if (! is_writable(FILES_DIR)) {
- die('The directory "'.FILES_DIR.'" must be writeable by your webserver user');
-}
diff --git a/app/constants.php b/app/constants.php
index f25bd903..47e14c9e 100644
--- a/app/constants.php
+++ b/app/constants.php
@@ -4,6 +4,9 @@
defined('DEBUG') or define('DEBUG', false);
defined('DEBUG_FILE') or define('DEBUG_FILE', __DIR__.'/../data/debug.log');
+// Plugin directory
+defined('PLUGINS_DIR') or define('PLUGINS_DIR', __DIR__.'/../plugins');
+
// Application version
defined('APP_VERSION') or define('APP_VERSION', 'master');
diff --git a/config.default.php b/config.default.php
index d3e18b8a..e3c6116d 100644
--- a/config.default.php
+++ b/config.default.php
@@ -10,6 +10,9 @@ define('DEBUG', false);
// Debug file path
define('DEBUG_FILE', __DIR__.'/data/debug.log');
+// Plugins directory
+define('PLUGINS_DIR', 'data/plugins');
+
// Folder for uploaded files, don't forget the trailing slash
define('FILES_DIR', 'data/files/');
diff --git a/doc/config.markdown b/doc/config.markdown
index 5473ef9b..59302b4d 100644
--- a/doc/config.markdown
+++ b/doc/config.markdown
@@ -23,6 +23,14 @@ define('DEBUG_FILE', __DIR__.'/data/debug.log');
All debug information are saved in this file.
If you prefer to send logs to `stdout` or `stderr` replace the value by `php://stdout` or `php://stderr`.
+Plugins folder
+--------------
+
+```php
+// Plugin directory
+define('PLUGINS_DIR', 'data/plugins');
+```
+
Folder for uploaded files
-------------------------
diff --git a/doc/fr/analytics-tasks.markdown b/doc/fr/analytics-tasks.markdown
new file mode 100644
index 00000000..bef7377b
--- /dev/null
+++ b/doc/fr/analytics-tasks.markdown
@@ -0,0 +1,24 @@
+Analytique des tâches
+===================
+
+Chaque tâche possède une section analytique accessible à partir du menu à gauche dans la page des tâches
+
+Lead et cycle time
+-------------------
+
+![Lead and cycle time](http://kanboard.net/screenshots/documentation/task-lead-cycle-time.png)
+
+- Le lead time est la durée entre la création de la tâche et son achèvement (tâche fermée).
+- Le cycle time est la durée entre la date de début et l'achèvement.
+- Si la tâche n’est pas fermée, l’heure courante est utilisée à la place de la date d'achèvement.
+- Si la date de départ n\'est pas spécifiée, le cycle time n'est pas calculé.
+
+Remarque : vous pouvez configurer une action pour définir automatiquement que la date de départ sera le moment ou vous déplacez une tâche vers une colonne de votre choix
+
+Temps passé dans chaque colonne
+---------------------------
+
+![Temps passé dans chaque colonne](http://kanboard.net/screenshots/documentation/time-into-each-column.png)
+
+- Ce graphique montre le temps total passé dans chaque colonne pour la tâche
+- Le temps passé est calculé jusqu’à ce que la tâche soit fermée
diff --git a/doc/fr/closing-tasks.markdown b/doc/fr/closing-tasks.markdown
new file mode 100644
index 00000000..f0dee122
--- /dev/null
+++ b/doc/fr/closing-tasks.markdown
@@ -0,0 +1,16 @@
+Fermer des tâches
+=============
+
+Quand une tâche est fermée, elle n'est plus visible sur le tableau.
+
+Toutefois, vous pouvez toujours accéder à la liste des tâches closes en utilisant la requête **status:closed** dans un formulaire de recherche, ou bien choisissez simplement **Tâches fermées** dans le menu déroulant des filtres.
+
+Il existe deux façons différentes de fermer une tâche, depuis le menu déroulant des tâches sur le tableau :
+
+![Fermer une tâche par le menu déroulant](http://kanboard.net/screenshots/documentation/menu-close-task.png)
+
+…ou bien depuis la barre latérale dans la vue détaillée des tâches
+
+![Fermer une tâche](http://kanboard.net/screenshots/documentation/closing-tasks.png)
+
+Remarque : quand vous fermez une tâche, toutes les sous-tâches qui ne sont pas achevées verront leur statut passer à "Terminé".
diff --git a/doc/fr/create-tasks-by-email.markdown b/doc/fr/create-tasks-by-email.markdown
new file mode 100644
index 00000000..893c6df4
--- /dev/null
+++ b/doc/fr/create-tasks-by-email.markdown
@@ -0,0 +1,45 @@
+Créer des tâches par email
+=====================
+
+Vous pouvez créer des tâches directement en envoyant un message.
+
+Pour le moment, Kanboard fonctionne avec 3 services externes :
+
+- [Mailgun](http://kanboard.net/documentation/mailgun)
+- [Sendgrid](http://kanboard.net/documentation/sendgrid)
+- [Postmark](http://kanboard.net/documentation/postmark)
+
+Ces services gèrent le courrier entrant sans qu'on ait à configurer un serveur SMTP.
+
+À la réception d'un email par l'un de ces services, le message qu'il contenait est transmis et traité automatiquement par Kanboard.
+Toutes les opérations complexes sont prises en charge par ces services.
+
+Processus de réception du courrier entrant
+------------------------
+
+1. Vous envoyez un mail à une adresse spécifique, par exemple **quelquechose+monprojet@inbound.mondomaine.tld**
+2. Votre mail est envoyé sur les serveurs tiers SMTP
+3. Le fournisseur de SMTP appelle Kanboard via un webhook avec le mail en JSON ou aux formats multipart/form-data
+4. Kanboard analyse le mail reçu et crée la tâche dans le bon projet
+
+Remarque : les nouvelles tâches sont automatiquement créées dans la première colonne.
+
+Format du mail
+------------
+
+- La partie locale de l'adresse mail doit utiliser le signe + comme séparateur, par exemple **kanboard+projet123**
+- La chaîne de caractères définie après le signe + doit correspondre à l'identifiant d'un projet, par exemple **projet123** est l'identifiant du projet **Projet 123**
+- le sujet de l'email devient le titre de la tâche
+- Le corps du message devient la description de la tâche (au format Markdown)
+
+Les courriers entrants peuvent être écrits aux formats .txt ou .HTML.
+**Kanboard peut convertir en Markdown les messages écrits en simple HTML**.
+
+Sécurité et prérequis
+-------------------------
+
+- Le webhook de Kanboard est protégé par un jeton aléatoire
+- L'adresse de l'expéditeur doit correspondre à celle d'un utilisateur de Kanboard
+- L'utilisateur de Kanboard doit être un membre du projet
+- Le projet Kanboard doit avoir un identifiant unique, par exemple **MONPROJET**
+
diff --git a/doc/fr/creating-tasks.markdown b/doc/fr/creating-tasks.markdown
new file mode 100644
index 00000000..064ba18f
--- /dev/null
+++ b/doc/fr/creating-tasks.markdown
@@ -0,0 +1,27 @@
+Créer des tâches
+==============
+
+Depuis le tableau, cliquez sur le signe plus + à côté du nom de la colonne :
+
+![Création de tâche à partir du tableau](http://kanboard.net/screenshots/documentation/task-creation-board.png)
+
+Le formulaire de création de tâche apparaît :
+
+![Formulaire de création de tâche](http://kanboard.net/screenshots/documentation/task-creation-form.png)
+
+Le seul champ obligatoire est le titre.
+
+Description des champs :
+
+- **Titre** : le titre de votre tâche, tel qu'il sera affiché sur le tableau.
+- **Description** : vous permet d'ajouter davantage d'informations sur la tâche. Le contenu peut être écrit en [Markdown](http://kanboard.net/documentation/syntax-guide).
+- **Créer une autre tâche** : cochez cette case si vous souhaitez créer une tâche similaire (les champs seront pré-remplis).
+- **Assigné** : la personne qui va travailler sur la tâche.
+- **Catégorie** : une seule catégorie peut être assignée à une tâche.
+- **Colonne** : la colonne dans laquelle la tâche sera créée. La tâche sera positionnée en bas de cette colonne.
+- **Couleur** : Choisissez la couleur de la carte.
+- **Complexité** : utilisée dans la gestion de projet agile (Scrum), la complexité des points d'étape est un nombre qui montre à l'équipe le degré de difficulté de l'avancement du projet. Les utilisateurs se servent souvent des suites de Fibonacci.
+- **Estimation originale** : estimation du nombre d'heures nécessaire pour terminer les tâches.
+- **Date d'échéance** : les tâches dont la date d'échéance est dépassée auront une date d'échéance en rouge et les dates suivantes seront en noir dans le tableau. Plusieurs formats de date sont acceptés, outre le sélecteur de date.
+
+Avec le lien d'aperçu (« Prévisualiser »), vous pouvez voir la description de la tâche convertie depuis la syntaxe Markdown.
diff --git a/doc/fr/duplicate-move-tasks.markdown b/doc/fr/duplicate-move-tasks.markdown
new file mode 100644
index 00000000..6d8e269e
--- /dev/null
+++ b/doc/fr/duplicate-move-tasks.markdown
@@ -0,0 +1,58 @@
+Dupliquer et déplacer des tâches
+========================
+
+Dupliquer une tâche dans le même projet
+--------------------------------------
+
+Allez à la vue des par tâche et choisissez **Dupliquer** sur la gauche.
+
+![Duplication de tâche](http://kanboard.net/screenshots/documentation/task-duplication.png)
+
+Une nouvelle tâche sera créée avec les mêmes propriétés que celles de la tâche originale.
+
+Dupliquer une tâche vers un autre projet
+-----------------------------------
+
+Allez à la vue par tâches et choisissez **Dupliquer dans un autre projet**.
+
+![Duplication d'une tâche dans un autre projet](http://kanboard.net/screenshots/documentation/task-duplication-another-project.png)
+
+Seuls les projets dont vous êtes membre apparaîtront dans le menu déroulant.
+
+Avant de copier les tâches, Kanboard vous demandera les propriétés de la destination qui ne sont pas communes entre les projets source et destination.
+
+Vous devez essentiellement définir :
+
+- La swimlane de destination
+- La colonne
+- La catégorie
+- L'assigné
+
+Déplacer une tâche vers un autre projet
+------------------------------
+
+Allez à la vue par tâches et choisissez **Déplacer vers un autre projet**.
+
+Déplacer vers un autre projet est semblable à l'opération de duplication, vous devez choisir les nouvelles propriétés de la tâche.
+
+Liste des champs dupliqués
+-------------------------
+Voici la liste des champs dupliqués :
+
+- title
+- description
+- date_due
+- color_id
+- project_id
+- column_id
+- owner_id
+- score
+- category_id
+- time_estimated
+- swimlane_id
+- recurrence_status
+- recurrence_trigger
+- recurrence_factor
+- recurrence_timeframe
+- recurrence_basedate
+
diff --git a/doc/fr/index.markdown b/doc/fr/index.markdown
new file mode 100644
index 00000000..d06ca53b
--- /dev/null
+++ b/doc/fr/index.markdown
@@ -0,0 +1,45 @@
+Documentation
+=============
+
+Utiliser Kanboard
+-----------------
+
+### Introduction
+
+- [Qu'est-ce que Kanban ?](what-is-kanban.markdown)
+- [Comparons Kanban aux Todo listes et à Scrum](kanban-vs-todo-and-scrum.markdown)
+- [Exemples d'utilisation](usage-examples.markdown)
+
+### Utiliser un tableau
+
+- [Vues Tableau, Agenda et Liste](project-views.markdown)
+- [Mode Replié et Déplié](board-collapsed-expanded.markdown)
+- [Défilement horizontal et mode compact](board-horizontal-scrolling-and-compact-view.markdown)
+- [Afficher ou cacher des colonnes dans le tableau](board-show-hide-columns.markdown)
+
+### Travailler avec les projets
+
+- [Créer des projets](creating-projects.markdown)
+- [Modifier des projets](editing-projects.markdown)
+- [Partager des tableaux et des tâches](sharing-projects.markdown)
+- [Actions automatiques](automatic-actions.markdown)
+- [Permissions des projets](project-permissions.markdown)
+- [Swimlanes](swimlanes.markdown)
+- [Calendriers](calendar.markdown)
+- [Analytique](analytics.markdown)
+- [Diagramme de Gantt pour les tâches](gantt-chart-tasks.markdown)
+- [Diagramme de Gantt pour tous les projets](gantt-chart-projects.markdown)
+
+### Travailler avec les tâches
+
+- [Créer des tâches](creating-tasks.markdown)
+- [Fermer des tâches](closing-tasks.markdown)
+- [Dupliquer et déplacer des tâches](duplicate-move-tasks.markdown)
+- [Ajouter des captures d'écran](screenshots.markdown)
+- [Liens entre les tâches](task-links.markdown)
+- [Transitions](transitions.markdown)
+- [Suivi du temps](time-tracking.markdown)
+- [Tâches récurrentes](recurring-tasks.markdown)
+- [Créer des tâches par email](create-tasks-by-email.markdown)
+- [Sous-tâches](subtasks.markdown)
+- [Analytique des tâches](analytics-tasks.markdown)
diff --git a/doc/fr/recurring-tasks.markdown b/doc/fr/recurring-tasks.markdown
new file mode 100644
index 00000000..98759a98
--- /dev/null
+++ b/doc/fr/recurring-tasks.markdown
@@ -0,0 +1,24 @@
+Tâches récurrentes
+===============
+
+Pour convenir à ma méthodologie de Kanban, les tâches récurrentes ne sont pas basées sur une date mais sur les évènements du tableau.
+
+- Les tâches récurrentes sont dupliquée dans la première colonne du tableau quand les évènements sélectionnés se produisent
+- La date d'échéance peut être automatiquement recalculée
+- Chaque tâche enregistre l'identifiant de tâche de la tâche parente qui l'a créée et la tâche enfant qui a été créée.
+
+Configuration
+-------------
+
+Allez à la page de vue par tâches ou utilisez le menu déroulant du tableau, puis choisissez **Modifier la récurrence**.
+
+![Tâche récurrente](http://kanboard.net/screenshots/documentation/recurring-tasks.png)
+
+il existe trois façons de déclencher la création d'une nouvelle tâche récurrente :
+
+- Déplacer une tâche depuis la première colonne
+- Déplacer une tâche vers la dernière colonne
+- Fermer la tâche
+
+Les dates d'échéance, si elles concernent la tâche courante, peuvent être recalculées en fonction d'un nombre donné de jours, mois ou années.
+La date de base pour le calcul de la nouvelle date d'échéance peut être soit la date d'échéance existante, soit la date de l'action.
diff --git a/doc/fr/screenshots.markdown b/doc/fr/screenshots.markdown
new file mode 100644
index 00000000..d591d2a4
--- /dev/null
+++ b/doc/fr/screenshots.markdown
@@ -0,0 +1,26 @@
+Ajouter des captures d'écran
+==================
+
+Vous pouvez copier-coller des images directement dans Kanboard pour gagner du temps.
+Ces images sont mises en ligne en tant que pièces jointes à une tâche.
+
+Ceci est particulièrement utile pour prendre des captures d'écran, quand il faut par exemple décrire un problème.
+
+Vous pouvez ajouter directement des captures depuis le tableau en cliquant sur le menu déroulant ou sur la page de visualisation des tâches.
+
+![La capture d'écran dans le menu déroulant](http://kanboard.net/screenshots/documentation/dropdown-screenshot.png)
+
+Pour ajouter une nouvelle image, prenez votre capture et collez-la avec CTRL+V ou Command+V:
+
+![Page de capture](http://kanboard.net/screenshots/documentation/task-screenshot.png)
+
+Avec Mac OS X, vous pouvez utiliser les raccourcis suivants pour prendre des captures d'écran :
+
+- Command-Control-Maj-3 : prend une capture de l'écran entier et l'enregistre dans le presse-papiers
+- Command-Control-Maj-4, puis choix d'une zone : prend une capture d'une zone définie et l'enregistre dans le presse-papiers
+- Command-Control-Maj-4, puis touche espace, puis clic sur une fenêtre : prend une capture d'une fenêtre et l'enregistre dans le presse-papiers
+
+Il existe plusieurs applications tierces qui peuvent être utilisées pour prendre des captures d'écran avec des annotations et un choix de formes.
+
+**Remarque : cette fonctionnalité n'est pas disponible sur tous les navigateurs.** Elle n'existe pas pour Safari en raison de ce bug : https://bugs.webkit.org/show_bug.cgi?id=49141
+
diff --git a/doc/fr/subtasks.markdown b/doc/fr/subtasks.markdown
new file mode 100644
index 00000000..8c9de47c
--- /dev/null
+++ b/doc/fr/subtasks.markdown
@@ -0,0 +1,43 @@
+Sous-tâches
+========
+
+Les sous-tâches sont utiles pour se partager le travail que représente une tâche.
+
+Chaque sous-tâche :
+
+- peut être assignée à un membre du projet
+- a trois différents statuts : **À faire**, **En cours**, **Terminé**
+- dispose d'informations sur le temps de travail : **temps passé** et **temps estimé**
+- est classée en fonction de sa position
+
+Créer des sous-tâches
+-----------------
+
+Depuis la vue par tâche, cliquez sur **Ajouter une sous-tâche** dans le panneau latéral.
+
+![Ajouter une sous-tâche](http://kanboard.net/screenshots/documentation/add-subtask.png)
+
+Vous pouvez aussi ajouter rapidement une sous-tâche en saisissant seulement son titre :
+
+![Add a subtask from the task view](http://kanboard.net/screenshots/documentation/add-subtask-shortcut.png)
+
+Modifier le statut d'une sous-tâche
+---------------------
+
+Quand vous cliquez sur le titre d'une sous-tâche son statut change :
+
+![Sous-tâche en cours](http://kanboard.net/screenshots/documentation/subtask-status-inprogress.png)
+
+L'icône devant le titre est mise à jour en fonction du statut.
+
+![Sous-tâche effectuée](http://kanboard.net/screenshots/documentation/subtask-status-done.png)
+
+Remarque : quand la tâche est fermée, toutes les sous-tâches voient leur statut passer à **Terminé**.
+
+Chrono des sous-tâches
+-------------
+
+- À chaque fois qu'une sous-tâche est en cours de réalisation, le chronomètre est également démarré. Il peut être lancé et interrompu à tout moment.
+- Le chronomètre enregistre automatiquement le temps passé sur la sous-tâche. Vous pouvez aussi modifier manuellement la valeur du temps passé dans le champ adéquat quand vous modifiez une sous-tâche.
+- Le temps passé est arrondi au quart d'heure le plus proche. Cette information est enregistrée dans un tableau distinct.
+- Le temps passé à la tâche ainsi que le temps estimé sont automatiquement mis à jour en fonction de la somme de toutes les sous-tâches.
diff --git a/doc/fr/task-links.markdown b/doc/fr/task-links.markdown
new file mode 100644
index 00000000..f05e5d67
--- /dev/null
+++ b/doc/fr/task-links.markdown
@@ -0,0 +1,22 @@
+Liens entre les tâches
+==========
+
+Les tâches peuvent être liées ensemble avec des relations prédéfinies.
+
+![Task Links](http://kanboard.net/screenshots/documentation/task-links.png)
+
+Les relations établies par défaut sont les suivantes :
+
+- **fait référence à**
+- **bloque** | est bloqué par
+- **est bloqué par** | bloque
+- **duplique** | est dupliqué par
+- **est dupliqué par** | duplique
+- **est un enfant de** | est un parent de
+- **est un parent de** | est un enfant de
+- **vise les étapes importantes** | est une étape importante de
+- **est une étape importante de** | vise les étapes importantes
+- **correctifs** | est réglé par
+- **est réglé par** | correctifs
+
+Ces étiquettes peuvent être modifiées dans les paramètres de l'application.
diff --git a/doc/fr/time-tracking.markdown b/doc/fr/time-tracking.markdown
new file mode 100644
index 00000000..0b722c63
--- /dev/null
+++ b/doc/fr/time-tracking.markdown
@@ -0,0 +1,44 @@
+Suivi du temps
+=============
+
+Les information de la feuille de suivi du temps peuvent être définies au niveau des tâches ou des sous-tâches
+
+Suivi de temps des tâches
+------------------
+
+![Suivi de temps des tâches ](http://kanboard.net/screenshots/documentation/task-time-tracking.png)
+
+Les tâches ont deux champs:
+
+- Temps estimé
+- Temps passé
+
+Ces valeurs représentent des heures de travail et doivent être entrées manuellement.
+
+
+Suivi de temps des sous-tâches
+---------------------
+
+![Suivi de temps des sous-tâches](http://kanboard.net/screenshots/documentation/subtask-time-tracking.png)
+
+Les sous-tâches ont aussi les champs "temps passé" et "temps estimé"
+
+Lorsque vous changez la valeur de ces champs, **le suivi des tâches est mis à jour automatiquement et devient la somme des sous-tâches**.
+
+Kanboard enregistre le temps entre chaque changement de statut des sous-tâches dans une table séparée
+
+- Changer le statut de la sous-tâche de **à faire** à **en cours** marque le temps de début
+- Changer le statut de la sous-tâche de **en cours** à **à faire** marque le temps de fin mais aussi met à jour le temps passé sur la sous-tâche et la tâche
+
+La répartition de tous les enregistrements est visible sur la page de la tâche
+
+![Feuille de suivi du temps pour les tâches](http://kanboard.net/screenshots/documentation/task-timesheet.png)
+
+Pour chaque sous-tâche, le chrono peut être à tout moment arrêté/démarré
+
+![Chrono des sous-tâches](http://kanboard.net/screenshots/documentation/subtask-timer.png)
+
+- Le chrono ne dépend pas du statut de la sous-tâche
+- Chaque fois que vous démarrez le chrono, un nouvel enregistrement est créé dans la table de suivi des temps
+- Chaque fois que vous arrêtez l'horloge, la date de fin est enregistrée dans la table de suivi des temps
+- Le temps passé est arrondi au quart d’heure le plus proche
diff --git a/doc/fr/transitions.markdown b/doc/fr/transitions.markdown
new file mode 100644
index 00000000..72106a64
--- /dev/null
+++ b/doc/fr/transitions.markdown
@@ -0,0 +1,20 @@
+Transitions entre les tâches
+================
+
+Les transitions enregistrent tous les mouvements des tâches entre les colonnes
+
+![Transitions](http://kanboard.net/screenshots/documentation/transitions.png)
+
+Depuis la page des tâches, vous pouvez accéder à ces informations:
+
+- Date de l'action
+- Colonne d'origine
+- Colonne de destination
+- Exécuter (Pour l'utilisateur qui a déplacé la tâche)
+- Temps passé sur la colonne d’origine
+
+Les données de transition entre les tâches peuvent aussi être exportées depuis la page des paramètres du projet
+
+![Transitions Export](http://kanboard.net/screenshots/documentation/transitions-export.png)
+
+Pour la période spécifiée, vous allez générer un fichier CSV que vous pouvez utiliser avec n’importe quel tableur
diff --git a/doc/installation.markdown b/doc/installation.markdown
index 53e7095b..30a2916c 100644
--- a/doc/installation.markdown
+++ b/doc/installation.markdown
@@ -20,7 +20,14 @@ From the archive (stable version)
6. Start to use the software
7. Don't forget to change your password!
-Note: The folder data is the location where Kanboard stores uploaded files as well as the Sqlite database.
+The data folder is used to store:
+
+- Sqlite database: `db.sqlite`
+- Debug file: `debug.log` (if debug mode enabled)
+- Uploaded files: `files/*`
+- Image thumbnails: `files/thumbnails/*`
+
+People who are using a remote database (Mysql/Postgresql) and a remote file storage (Aws S3 or similar) don't necessary needs to have a persistent local data folder or to change the permissions.
From the repository (development version)
-----------------------------------------
diff --git a/doc/plugins.markdown b/doc/plugins.markdown
index cccda796..1127a636 100644
--- a/doc/plugins.markdown
+++ b/doc/plugins.markdown
@@ -154,6 +154,34 @@ List of merge hooks:
- `$start` Calendar start date (string, ISO-8601 format)
- `$end` Calendar end date (string, ISO-8601 format)
+Asset Hooks
+-----------
+
+Asset hooks can be used to add easily a new stylesheet or a new javascript file in the layout. You can use this feature to create a theme and override all Kanboard default styles.
+
+Example to add a new stylesheet:
+
+```php
+<?php
+
+namespace Plugin\Css;
+
+use Core\Plugin\Base;
+
+class Plugin extends Base
+{
+ public function initialize()
+ {
+ $this->hook->on('template:layout:css', 'plugins/Css/skin.css');
+ }
+}
+```
+
+List of asset Hooks:
+
+- `template:layout:css`
+- `template:layout:js`
+
Template hooks
--------------
@@ -223,10 +251,47 @@ $this->on('session.bootstrap', function($container) {
- The first argument is the event name
- The second argument is a PHP callable function (closure or class method)
+Extend Automatic Actions
+------------------------
+
+To define a new automatic action with a plugin, you just need to call the method `extendActions()` from the class `Model\Action`, here an example:
+
+```php
+<?php
+
+namespace Plugin\AutomaticAction;
+
+use Core\Plugin\Base;
+
+class Plugin extends Base
+{
+ public function initialize()
+ {
+ $this->action->extendActions(
+ '\Plugin\AutomaticAction\Action\SendSlackMessage', // Use absolute namespace
+ t('Send a message to Slack when the task color change')
+ );
+ }
+}
+```
+
+- The first argument of the method `extendActions()` is the action class with the complete namespace path. **The namespace path must starts with a backslash** otherwise Kanboard will not be able to load your class.
+- The second argument is the description of your automatic action.
+
+The automatic action class must inherits from the class `Action\Base` and implements all abstract methods:
+
+- `getCompatibleEvents()`
+- `getActionRequiredParameters()`
+- `getEventRequiredParameters()`
+- `doAction(array $data)`
+- `hasRequiredCondition(array $data)`
+
+For more details you should take a look to existing automatic actions or this [plugin example](https://github.com/kanboard/plugin-example-automatic-action).
+
Extend ACL
----------
-Kanboard use a custom access list for privilege separations. Your extension can add new rules:
+Kanboard use an access list for privilege separations. Your extension can add new rules:
```php
$this->acl->extend('project_manager_acl', array('mycontroller' => '*'));
@@ -337,4 +402,6 @@ Examples of plugins
- [Budget planning](https://github.com/kanboard/plugin-budget)
- [User timetable](https://github.com/kanboard/plugin-timetable)
- [Subtask Forecast](https://github.com/kanboard/plugin-subtask-forecast)
-- [Theme plugin sample](https://github.com/kanboard/plugin-example-theme)
+- [Automatic Action example](https://github.com/kanboard/plugin-example-automatic-action)
+- [Theme plugin example](https://github.com/kanboard/plugin-example-theme)
+- [CSS plugin example](https://github.com/kanboard/plugin-example-css)
diff --git a/tests/units/Helper/HookHelperTest.php b/tests/units/Helper/HookHelperTest.php
index 6661c90b..7745c674 100644
--- a/tests/units/Helper/HookHelperTest.php
+++ b/tests/units/Helper/HookHelperTest.php
@@ -37,4 +37,38 @@ class HookHelperTest extends Base
$h->attach('test', 'tpl2');
$this->assertEquals('tpl1_contenttpl2_content', $h->render('test'));
}
-} \ No newline at end of file
+
+ public function testAssetHooks()
+ {
+ $this->container['helper']->asset = $this
+ ->getMockBuilder('\Helper\Asset')
+ ->setConstructorArgs(array($this->container))
+ ->setMethods(array('css', 'js'))
+ ->getMock();
+
+ $this->container['helper']
+ ->asset
+ ->expects($this->at(0))
+ ->method('css')
+ ->with(
+ $this->equalTo('skin.css')
+ )
+ ->will($this->returnValue('<link rel="stylesheet" href="skin.css"></link>'));
+
+ $this->container['helper']
+ ->asset
+ ->expects($this->at(1))
+ ->method('js')
+ ->with(
+ $this->equalTo('skin.js')
+ )
+ ->will($this->returnValue('<script src="skin.js"></script>'));
+
+ $h = new Hook($this->container);
+ $h->attach('test1', 'skin.css');
+ $h->attach('test2', 'skin.js');
+
+ $this->assertContains('<link rel="stylesheet" href="skin.css"></link>', $h->asset('css', 'test1'));
+ $this->assertContains('<script src="skin.js"></script>', $h->asset('js', 'test2'));
+ }
+}
diff --git a/tests/units/Integration/SlackWebhookTest.php b/tests/units/Integration/SlackWebhookTest.php
new file mode 100644
index 00000000..3b9a3c1b
--- /dev/null
+++ b/tests/units/Integration/SlackWebhookTest.php
@@ -0,0 +1,377 @@
+<?php
+
+require_once __DIR__.'/../Base.php';
+
+use Integration\SlackWebhook;
+use Model\Project;
+use Model\Task;
+
+class SlackWebhookTest extends Base
+{
+ public function testIsActivatedFromGlobalConfig()
+ {
+ $slack = new SlackWebhook($this->container);
+
+ $this->container['config'] = $this
+ ->getMockBuilder('\Model\Config')
+ ->setConstructorArgs(array($this->container))
+ ->setMethods(array(
+ 'get',
+ ))
+ ->getMock();
+
+ $this->container['config']
+ ->expects($this->once())
+ ->method('get')
+ ->with($this->equalTo('integration_slack_webhook'))
+ ->will($this->returnValue(1));
+
+ $this->assertTrue($slack->isActivated(1));
+ }
+
+ public function testIsActivatedFromProjectConfig()
+ {
+ $slack = new SlackWebhook($this->container);
+
+ $this->container['config'] = $this
+ ->getMockBuilder('\Model\Config')
+ ->setConstructorArgs(array($this->container))
+ ->setMethods(array(
+ 'get',
+ ))
+ ->getMock();
+
+ $this->container['projectIntegration'] = $this
+ ->getMockBuilder('\Model\ProjectIntegration')
+ ->setConstructorArgs(array($this->container))
+ ->setMethods(array(
+ 'hasValue',
+ ))
+ ->getMock();
+
+ $this->container['config']
+ ->expects($this->once())
+ ->method('get')
+ ->with($this->equalTo('integration_slack_webhook'))
+ ->will($this->returnValue(0));
+
+ $this->container['projectIntegration']
+ ->expects($this->once())
+ ->method('hasValue')
+ ->with(
+ $this->equalTo(1),
+ $this->equalTo('slack'),
+ $this->equalTo(1)
+ )
+ ->will($this->returnValue(true));
+
+ $this->assertTrue($slack->isActivated(1));
+ }
+
+ public function testIsNotActivated()
+ {
+ $slack = new SlackWebhook($this->container);
+
+ $this->container['config'] = $this
+ ->getMockBuilder('\Model\Config')
+ ->setConstructorArgs(array($this->container))
+ ->setMethods(array(
+ 'get',
+ ))
+ ->getMock();
+
+ $this->container['projectIntegration'] = $this
+ ->getMockBuilder('\Model\ProjectIntegration')
+ ->setConstructorArgs(array($this->container))
+ ->setMethods(array(
+ 'hasValue',
+ ))
+ ->getMock();
+
+ $this->container['config']
+ ->expects($this->once())
+ ->method('get')
+ ->with($this->equalTo('integration_slack_webhook'))
+ ->will($this->returnValue(0));
+
+ $this->container['projectIntegration']
+ ->expects($this->once())
+ ->method('hasValue')
+ ->with(
+ $this->equalTo(1),
+ $this->equalTo('slack'),
+ $this->equalTo(1)
+ )
+ ->will($this->returnValue(false));
+
+ $this->assertFalse($slack->isActivated(1));
+ }
+
+ public function testGetChannelFromGlobalConfig()
+ {
+ $slack = new SlackWebhook($this->container);
+
+ $this->container['config'] = $this
+ ->getMockBuilder('\Model\Config')
+ ->setConstructorArgs(array($this->container))
+ ->setMethods(array(
+ 'get',
+ ))
+ ->getMock();
+
+ $this->container['config']
+ ->expects($this->once())
+ ->method('get')
+ ->with($this->equalTo('integration_slack_webhook_channel'))
+ ->will($this->returnValue('mychannel'));
+
+ $this->assertEquals('mychannel', $slack->getChannel(1));
+ }
+
+ public function testGetChannelFromProjectConfig()
+ {
+ $slack = new SlackWebhook($this->container);
+
+ $this->container['config'] = $this
+ ->getMockBuilder('\Model\Config')
+ ->setConstructorArgs(array($this->container))
+ ->setMethods(array(
+ 'get',
+ ))
+ ->getMock();
+
+ $this->container['projectIntegration'] = $this
+ ->getMockBuilder('\Model\ProjectIntegration')
+ ->setConstructorArgs(array($this->container))
+ ->setMethods(array(
+ 'getParameters',
+ ))
+ ->getMock();
+
+ $this->container['config']
+ ->expects($this->once())
+ ->method('get')
+ ->with($this->equalTo('integration_slack_webhook_channel'))
+ ->will($this->returnValue(''));
+
+ $this->container['projectIntegration']
+ ->expects($this->once())
+ ->method('getParameters')
+ ->with($this->equalTo(1))
+ ->will($this->returnValue(array('slack_webhook_channel' => 'my_project_channel')));
+
+ $this->assertEquals('my_project_channel', $slack->getChannel(1));
+ }
+
+ public function testGetWebhoookUrlFromGlobalConfig()
+ {
+ $slack = new SlackWebhook($this->container);
+
+ $this->container['config'] = $this
+ ->getMockBuilder('\Model\Config')
+ ->setConstructorArgs(array($this->container))
+ ->setMethods(array(
+ 'get',
+ ))
+ ->getMock();
+
+ $this->container['config']
+ ->expects($this->at(0))
+ ->method('get')
+ ->with($this->equalTo('integration_slack_webhook'))
+ ->will($this->returnValue(1));
+
+ $this->container['config']
+ ->expects($this->at(1))
+ ->method('get')
+ ->with($this->equalTo('integration_slack_webhook_url'))
+ ->will($this->returnValue('url'));
+
+ $this->assertEquals('url', $slack->getWebhookUrl(1));
+ }
+
+ public function testGetWebhookUrlFromProjectConfig()
+ {
+ $slack = new SlackWebhook($this->container);
+
+ $this->container['config'] = $this
+ ->getMockBuilder('\Model\Config')
+ ->setConstructorArgs(array($this->container))
+ ->setMethods(array(
+ 'get',
+ ))
+ ->getMock();
+
+ $this->container['projectIntegration'] = $this
+ ->getMockBuilder('\Model\ProjectIntegration')
+ ->setConstructorArgs(array($this->container))
+ ->setMethods(array(
+ 'getParameters',
+ ))
+ ->getMock();
+
+ $this->container['config']
+ ->expects($this->once())
+ ->method('get')
+ ->with($this->equalTo('integration_slack_webhook'))
+ ->will($this->returnValue(0));
+
+ $this->container['projectIntegration']
+ ->expects($this->once())
+ ->method('getParameters')
+ ->with($this->equalTo(1))
+ ->will($this->returnValue(array('slack_webhook_url' => 'my_project_url')));
+
+ $this->assertEquals('my_project_url', $slack->getWebhookUrl(1));
+ }
+
+ public function testSendPayloadWithChannel()
+ {
+ $this->container['httpClient'] = $this
+ ->getMockBuilder('\Core\HttpClient')
+ ->setConstructorArgs(array($this->container))
+ ->setMethods(array(
+ 'postJson',
+ ))
+ ->getMock();
+
+ $slack = $this
+ ->getMockBuilder('\Integration\SlackWebhook')
+ ->setConstructorArgs(array($this->container))
+ ->setMethods(array(
+ 'getChannel',
+ 'getWebhookUrl',
+ ))
+ ->getMock();
+
+ $slack
+ ->expects($this->at(0))
+ ->method('getChannel')
+ ->with(
+ $this->equalTo(1)
+ )
+ ->will($this->returnValue('mychannel'));
+
+ $slack
+ ->expects($this->at(1))
+ ->method('getWebhookUrl')
+ ->with(
+ $this->equalTo(1)
+ )
+ ->will($this->returnValue('url'));
+
+ $this->container['httpClient']
+ ->expects($this->once())
+ ->method('postJson')
+ ->with(
+ $this->equalTo('url'),
+ $this->equalTo(array('text' => 'test', 'channel' => 'mychannel'))
+ );
+
+ $slack->sendPayload(1, array('text' => 'test'));
+ }
+
+ public function testSendPayloadWithoutChannel()
+ {
+ $this->container['httpClient'] = $this
+ ->getMockBuilder('\Core\HttpClient')
+ ->setConstructorArgs(array($this->container))
+ ->setMethods(array(
+ 'postJson',
+ ))
+ ->getMock();
+
+ $slack = $this
+ ->getMockBuilder('\Integration\SlackWebhook')
+ ->setConstructorArgs(array($this->container))
+ ->setMethods(array(
+ 'getChannel',
+ 'getWebhookUrl',
+ ))
+ ->getMock();
+
+ $slack
+ ->expects($this->at(0))
+ ->method('getChannel')
+ ->with(
+ $this->equalTo(1)
+ )
+ ->will($this->returnValue(''));
+
+ $slack
+ ->expects($this->at(1))
+ ->method('getWebhookUrl')
+ ->with(
+ $this->equalTo(1)
+ )
+ ->will($this->returnValue('url'));
+
+ $this->container['httpClient']
+ ->expects($this->once())
+ ->method('postJson')
+ ->with(
+ $this->equalTo('url'),
+ $this->equalTo(array('text' => 'test'))
+ );
+
+ $slack->sendPayload(1, array('text' => 'test'));
+ }
+
+ public function testSendMessage()
+ {
+ $message = 'test';
+
+ $payload = array(
+ 'text' => $message,
+ 'username' => 'Kanboard',
+ 'icon_url' => 'http://kanboard.net/assets/img/favicon.png',
+ );
+
+ $slack = $this
+ ->getMockBuilder('\Integration\SlackWebhook')
+ ->setConstructorArgs(array($this->container))
+ ->setMethods(array(
+ 'sendPayload',
+ ))
+ ->getMock();
+
+ $slack
+ ->expects($this->once())
+ ->method('sendPayload')
+ ->with(
+ $this->equalTo(1),
+ $this->equalTo($payload)
+ );
+
+ $slack->sendMessage(1, $message);
+ }
+
+ public function testNotify()
+ {
+ $message = '*[foobar]* FooBar created the task #1 (task #1)';
+
+ $this->container['session']['user'] = array('username' => 'foobar', 'name' => 'FooBar');
+
+ $p = new Project($this->container);
+ $this->assertEquals(1, $p->create(array('name' => 'foobar')));
+ $this->assertTrue($this->container['config']->save(array('integration_slack_webhook' => 1)));
+
+ $slack = $this
+ ->getMockBuilder('\Integration\SlackWebhook')
+ ->setConstructorArgs(array($this->container))
+ ->setMethods(array(
+ 'sendMessage',
+ ))
+ ->getMock();
+
+ $slack
+ ->expects($this->once())
+ ->method('sendMessage')
+ ->with(
+ $this->equalTo(1),
+ $this->equalTo($message)
+ );
+
+ $slack->notify(1, 1, Task::EVENT_CREATE, array('task' => array('id' => 1, 'title' => 'task #1')));
+ }
+}
diff --git a/tests/units/Model/ActionTest.php b/tests/units/Model/ActionTest.php
index 9034679b..66b2cfe3 100644
--- a/tests/units/Model/ActionTest.php
+++ b/tests/units/Model/ActionTest.php
@@ -27,6 +27,17 @@ class ActionTest extends Base
$this->assertEquals('TaskLogMoveAnotherColumn', key($actions));
}
+ public function testExtendActions()
+ {
+ $a = new Action($this->container);
+ $a->extendActions('MyClass', 'Description');
+
+ $actions = $a->getAvailableActions();
+ $this->assertNotEmpty($actions);
+ $this->assertContains('Description', $actions);
+ $this->assertArrayHasKey('MyClass', $actions);
+ }
+
public function testGetEvents()
{
$a = new Action($this->container);
diff --git a/tests/units/Model/FileTest.php b/tests/units/Model/FileTest.php
index e7520c89..d1ad7248 100644
--- a/tests/units/Model/FileTest.php
+++ b/tests/units/Model/FileTest.php
@@ -227,7 +227,7 @@ class FileTest extends Base
->expects($this->at(1))
->method('remove')
->with(
- $this->equalTo('/tmp/foo1')
+ $this->equalTo('thumbnails//tmp/foo2')
)
->will($this->returnValue(true));
@@ -235,6 +235,14 @@ class FileTest extends Base
->expects($this->at(2))
->method('remove')
->with(
+ $this->equalTo('/tmp/foo1')
+ )
+ ->will($this->returnValue(true));
+
+ $this->container['objectStorage']
+ ->expects($this->at(3))
+ ->method('remove')
+ ->with(
$this->equalTo('/tmp/foo3')
)
->will($this->returnValue(true));