From 86cbac91fd696b314b0ee4284bb6eb3567730e6f Mon Sep 17 00:00:00 2001
From: Henri-Damien LAURENT <hdlaurent@afi-sa.fr>
Date: Thu, 4 Apr 2019 12:47:11 +0200
Subject: [PATCH] dev#88078 : Notification des rendez vous

---
 FEATURES/88078                                |  10 +
 VERSIONS_WIP/88078                            |   1 +
 .../admin/controllers/ErrorController.php     |   1 +
 .../views/scripts/rendez-vous/index.phtml     |  10 +-
 .../rendez-vous/notification-error.phtml      |   1 +
 .../scripts/rendez-vous/notification.phtml    |  23 +
 cosmogramme/sql/patch/patch_370.php           |  22 +
 library/Class/AdminVar.php                    |   6 +-
 library/Class/Batch.php                       |   1 +
 .../Batch/SendRendezVousNotification.php      |  71 ++
 library/Class/Mail.php                        |  13 +-
 library/Class/MailHtml.php                    |  26 +
 library/Class/ModeleFusion.php                |   3 +-
 library/Class/Profil/Skin.php                 |   4 +-
 library/Class/RendezVous.php                  |  46 +-
 library/Class/RendezVous/Notify.php           | 126 ++++
 .../Class/RendezVous/SearchCriteria/Date.php  |   5 +-
 library/Class/RendezVous/UserNotification.php | 158 +++++
 .../RendezVous/UserNotificationReport.php     |  65 ++
 library/Class/TableDescription.php            |  71 +-
 library/Class/TableDescription/RendezVous.php |  37 ++
 .../RendezVousNotification.php                |  47 ++
 .../TableDescription/RendezVousSearch.php     |  29 +
 .../Action/Helper/SearchRendezVous.php        |   2 +-
 .../Controller/Plugin/Manager/RendezVous.php  | 106 +++
 .../Plugin/ResourceDefinition/RendezVous.php  |   4 +-
 .../ZendAfi/Form/Decorator/UserSelection.php  |   9 +-
 .../ZendAfi/View/Helper/Abonne/Abstract.php   |   2 +-
 .../ZendAfi/View/Helper/Admin/ContentNav.php  |   2 +-
 .../View/Helper/Admin/RenderVersionForm.php   |  29 +-
 .../Admin/RendezVousNotificationStatus.php    |  61 ++
 .../View/Helper/Admin/SearchRendezVous.php    |  67 +-
 .../View/Helper/AlbumAudioJsPlayer.php        |   2 +-
 library/ZendAfi/View/Helper/Button.php        |  20 +-
 .../View/Helper/ModeleFusion/Template.php     |   3 +
 .../Template/RendezVousNotification.php       |  27 +
 .../View/Helper/RenderEmptyEnabledTable.php   |  90 ---
 library/ZendAfi/View/Helper/RenderTable.php   |  25 +-
 .../View/Helper/RendezVous/PurgeButton.php    |  37 ++
 library/ZendAfi/View/Helper/TagSuccess.php    |  30 +
 public/admin/css/global.css                   |   5 +
 public/admin/images/picto/meeting_24.png      | Bin 0 -> 2134 bytes
 public/admin/images/picto/meeting_48.png      | Bin 0 -> 2336 bytes
 public/admin/js/user_selection/test.js        |  30 +-
 .../admin/js/user_selection/user_selection.js |  18 +-
 public/admin/skins/bokeh72/colors.css         |   3 +
 public/admin/skins/bokeh72/config.json        |   3 +-
 public/admin/skins/bokeh74/colors.css         |   1 +
 public/admin/skins/bokeh74/config.json        |   3 +-
 public/admin/skins/bokeh74/global.css         |  11 +-
 .../skins/bokeh74/icons/menu/meeting_24.png   | Bin 0 -> 2134 bytes
 .../skins/bokeh74/icons/menu/meeting_48.png   | Bin 0 -> 2336 bytes
 public/admin/skins/retro/colors.css           |   1 +
 public/admin/skins/retro/config.json          |   3 +-
 public/admin/skins/retro/global.css           |   8 +-
 .../skins/retro/icons/menu/meeting_24.png     | Bin 0 -> 2134 bytes
 .../skins/retro/icons/menu/meeting_48.png     | Bin 0 -> 2336 bytes
 .../admin/controllers/ErrorControllerTest.php |   5 +
 tests/bootstrap.php                           |   2 +-
 tests/db/UpgradeDBTest.php                    |  72 ++
 tests/scenarios/Jamendo/JamendoTest.php       |   2 +-
 .../RendezVousAbonneControllerTest.php        |   2 +-
 .../RendezVous/RendezVousAdminTest.php        | 626 +++++++++++++++++-
 .../RendezVous/UsergroupAgendaAdminTest.php   |  13 +-
 64 files changed, 1859 insertions(+), 241 deletions(-)
 create mode 100644 FEATURES/88078
 create mode 100644 VERSIONS_WIP/88078
 create mode 100644 application/modules/admin/views/scripts/rendez-vous/notification-error.phtml
 create mode 100644 application/modules/admin/views/scripts/rendez-vous/notification.phtml
 create mode 100644 cosmogramme/sql/patch/patch_370.php
 create mode 100644 library/Class/Batch/SendRendezVousNotification.php
 create mode 100644 library/Class/MailHtml.php
 create mode 100644 library/Class/RendezVous/Notify.php
 create mode 100644 library/Class/RendezVous/UserNotification.php
 create mode 100644 library/Class/RendezVous/UserNotificationReport.php
 create mode 100644 library/Class/TableDescription/RendezVous.php
 create mode 100644 library/Class/TableDescription/RendezVousNotification.php
 create mode 100644 library/Class/TableDescription/RendezVousSearch.php
 create mode 100644 library/ZendAfi/View/Helper/Admin/RendezVousNotificationStatus.php
 create mode 100644 library/ZendAfi/View/Helper/ModeleFusion/Template/RendezVousNotification.php
 delete mode 100644 library/ZendAfi/View/Helper/RenderEmptyEnabledTable.php
 create mode 100644 library/ZendAfi/View/Helper/RendezVous/PurgeButton.php
 create mode 100644 library/ZendAfi/View/Helper/TagSuccess.php
 create mode 100644 public/admin/images/picto/meeting_24.png
 create mode 100644 public/admin/images/picto/meeting_48.png
 create mode 100644 public/admin/skins/bokeh74/icons/menu/meeting_24.png
 create mode 100644 public/admin/skins/bokeh74/icons/menu/meeting_48.png
 create mode 100644 public/admin/skins/retro/icons/menu/meeting_24.png
 create mode 100644 public/admin/skins/retro/icons/menu/meeting_48.png

diff --git a/FEATURES/88078 b/FEATURES/88078
new file mode 100644
index 00000000000..1c3feaed2c7
--- /dev/null
+++ b/FEATURES/88078
@@ -0,0 +1,10 @@
+        '88078' =>
+            ['Label' => $this->_('Gestion des rendez-vous'),
+             'Desc' => $this->_('Bokeh permet d'établir des agendas de rendez-vous concernant un ou plusieurs participants.'),
+             'Image' => '',
+             'Video' => 'https://youtu.be/imar4izniAY',
+             'Category' => $this->_('Administration'),
+             'Right' => function($feature_description, $user) {return true;},
+             'Wiki' => 'http://wiki.bokeh-library-portal.org/index.php?title=Rendez-vous',
+             'Test' => '',
+             'Date' => '2019-04-04'],
\ No newline at end of file
diff --git a/VERSIONS_WIP/88078 b/VERSIONS_WIP/88078
new file mode 100644
index 00000000000..920b30f459c
--- /dev/null
+++ b/VERSIONS_WIP/88078
@@ -0,0 +1 @@
+ - ticket #88078 : Nouveau module de gestion des rendez-vous.
\ No newline at end of file
diff --git a/application/modules/admin/controllers/ErrorController.php b/application/modules/admin/controllers/ErrorController.php
index 3f19ee3a1cf..1716bc19daf 100644
--- a/application/modules/admin/controllers/ErrorController.php
+++ b/application/modules/admin/controllers/ErrorController.php
@@ -20,6 +20,7 @@
  */
 class Admin_ErrorController extends ZendAfi_Controller_Action {
   public function errorAction() {
+    $this->_response->setHttpResponseCode(500);
     $this->view->titre = $this->_('Une erreur est survenue');
     $this->view->errors = $this->_getParam('error_handler');
     $this->view->database = array_at('dbname',
diff --git a/application/modules/admin/views/scripts/rendez-vous/index.phtml b/application/modules/admin/views/scripts/rendez-vous/index.phtml
index 1b933900d2b..397b3365eed 100644
--- a/application/modules/admin/views/scripts/rendez-vous/index.phtml
+++ b/application/modules/admin/views/scripts/rendez-vous/index.phtml
@@ -1,16 +1,10 @@
 <?php
 echo $this->button_New((new Class_Entity())
                        ->setText($this->_('Ajouter un rendez-vous')));
+
 echo $this->button_Back((new Class_Entity())
                         ->setText($this->_('Retour aux agendas'))
                         ->setUrl($this->url(['module' => 'admin',
                                              'controller' => 'usergroup-agenda'], null, true)));
 
-$description = (new Class_TableDescription('rendez-vous'))
-  ->addColumn($this->_('Date'), 'date')
-  ->addColumn($this->_('Heure de début'), 'formatted_begin_time')
-  ->addColumn($this->_('Heure de fin'), 'formatted_end_time')
-  ->addColumn($this->_('Lieu'), 'location_label')
-  ->addRowAction(function($model) { return $this->renderPluginsActions($model); });
-
-echo $this->renderTable($description, $this->rendezvous);
+echo $this->renderTable(new Class_TableDescription_RendezVous('rendez-vous'), $this->rendezvous);
diff --git a/application/modules/admin/views/scripts/rendez-vous/notification-error.phtml b/application/modules/admin/views/scripts/rendez-vous/notification-error.phtml
new file mode 100644
index 00000000000..563a54b8a2f
--- /dev/null
+++ b/application/modules/admin/views/scripts/rendez-vous/notification-error.phtml
@@ -0,0 +1 @@
+<p><?php echo $this->model->getError(); ?></p>
diff --git a/application/modules/admin/views/scripts/rendez-vous/notification.phtml b/application/modules/admin/views/scripts/rendez-vous/notification.phtml
new file mode 100644
index 00000000000..fdd4eff72c8
--- /dev/null
+++ b/application/modules/admin/views/scripts/rendez-vous/notification.phtml
@@ -0,0 +1,23 @@
+<?php
+
+echo $this->rendezVous_PurgeButton($this->report, $this->_('Vider tout l\'historique'));
+
+$manuals = $this->report->manualOnly();
+
+echo $this->tag('section',
+                $this->tag('h2', $this->_('Manuelles'))
+                . $this->rendezVous_PurgeButton($manuals,
+                                                $this->_('Vider l\'historique des notifications manuelles'),
+                                                Class_RendezVous_UserNotification::MANUAL_TYPE)
+                . $this->renderTable(new Class_TableDescription_RendezVousNotification('notificationsManual'),
+                                     $manuals));
+
+$batches = $this->report->batchOnly();
+
+echo $this->tag('section',
+                $this->tag('h2', $this->_('Automatiques'))
+                . $this->rendezVous_PurgeButton($batches,
+                                                $this->_('Vider l\'historique des notifications automatiques'),
+                                                Class_RendezVous_UserNotification::BATCH_TYPE)
+                . $this->renderTable((new Class_TableDescription_RendezVousNotification('notificationsBatch'))->withoutActions(),
+                                     $batches));
diff --git a/cosmogramme/sql/patch/patch_370.php b/cosmogramme/sql/patch/patch_370.php
new file mode 100644
index 00000000000..29f29ee65b5
--- /dev/null
+++ b/cosmogramme/sql/patch/patch_370.php
@@ -0,0 +1,22 @@
+<?php
+$adapter = Zend_Db_Table_Abstract::getDefaultAdapter();
+try {
+  $adapter->query(
+                  'create table `rendez_vous_user_notification` ('
+                  . '`id` int(11) unsigned not null auto_increment,'
+                  . '`rendez_vous_id` int(11) unsigned not null,'
+                  . '`user_id` int(11) not null,'
+                  . '`created_at` datetime null,'
+                  . '`type` varchar(255) not null,'
+                  . '`status` varchar(255) null,'
+                  . '`error` text null,'
+                  . 'primary key (id),'
+                  . 'key `rendez_vous_id` (`rendez_vous_id`),'
+                  . 'key `user_id` (`user_id`),'
+                  . 'key `status` (`status`),'
+                  . 'key `type`(`type`),'
+                  . 'key `created_at` (`created_at`)'
+                  . ') engine=MyISAM default charset=utf8'
+  );
+
+} catch(Exception $e) {}
diff --git a/library/Class/AdminVar.php b/library/Class/AdminVar.php
index 4c2177fd830..5a007770dc1 100644
--- a/library/Class/AdminVar.php
+++ b/library/Class/AdminVar.php
@@ -471,7 +471,11 @@ class Class_AdminVarLoader extends Storm_Model_Loader {
 
 
   protected function _getRendezVousVars() {
-    return ['ENABLE_RENDEZ_VOUS' => Class_AdminVar_Meta::newOnOff($this->_('Activer la gestion des rendez-vous'))];
+    return ['ENABLE_RENDEZ_VOUS' => Class_AdminVar_Meta::newOnOff($this->_('Activer la gestion des rendez-vous')),
+            'NOTIFICATION_TEMPLATE_RENDEZ_VOUS' => Class_AdminVar_Meta::newEditor($this->_('Modèle utilisé pour les courriels de notifications de rendez-vous. <a class="ardans_help" title="Aide" href="http://wiki.bokeh-library-portal.org/index.php?title=Rendez-vous" target="_blank"><img src="/hdl/public/admin/skins/bokeh74/icons/actions/help_16.png" alt=""></a>'), ['value'=> '<p>Bonjour {user.nom_complet},</p> <p>nous vous rappelons votre rendez-vous <strong> {rendez_vous.agenda_label} &agrave; {rendez_vous.formatted_date} entre {rendez_vous.formatted_begin_time} et {rendez_vous.formatted_end_time} &agrave; {rendez_vous.location_label}</strong>.</p> <br> <p>Nous vous rappelons les conditions particulières suivantes :</p><p> {rendez_vous.comment}</p>']),
+            'NOTIFICATION_DELAY_RENDEZ_VOUS' => Class_AdminVar_Meta::newDefault($this->_('Durée pour la notification de rendez-vous (en jours)'),
+                                                                                ['value' => '3'])
+    ];
   }
 
 
diff --git a/library/Class/Batch.php b/library/Class/Batch.php
index 37d5887d3c7..1901cd757af 100644
--- a/library/Class/Batch.php
+++ b/library/Class/Batch.php
@@ -41,6 +41,7 @@ class Class_BatchLoader extends Storm_Model_Loader {
                         Class_Batch_BuildSiteMap::TYPE => new Class_Batch_BuildSiteMap(),
                         Class_Batch_PremierChapitre::TYPE => new Class_Batch_PremierChapitre(),
                         Class_Batch_NoveltyFacet::TYPE => new Class_Batch_NoveltyFacet(),
+                        Class_Batch_SendRendezVousNotification::TYPE => new Class_Batch_SendRendezVousNotification(),
                         Class_Batch_ExternalAgenda::TYPE => new Class_Batch_ExternalAgenda]);
   }
 
diff --git a/library/Class/Batch/SendRendezVousNotification.php b/library/Class/Batch/SendRendezVousNotification.php
new file mode 100644
index 00000000000..006839a6b48
--- /dev/null
+++ b/library/Class/Batch/SendRendezVousNotification.php
@@ -0,0 +1,71 @@
+<?php
+/**
+ * Copyright (c) 2012-2014, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+
+class Class_Batch_SendRendezVousNotification extends Class_Batch_Abstract {
+  const TYPE = 'RENDEZ_VOUS_NOTIFICATION';
+
+  public function getLabel() {
+    return $this->_('Envoi des notifications de rendez-vous');
+  }
+
+
+  public function isEnabled() {
+    return Class_AdminVar::isRendezVousEnabled();
+  }
+
+
+  public function run() {
+    if (!$delay = (int)Class_AdminVar::get('NOTIFICATION_DELAY_RENDEZ_VOUS'))
+      return;
+
+    $search_string = 'date >= NOW() AND DATEDIFF(date, NOW()) <= ' . $delay;
+    if (!$rendezvous = Class_RendezVous::findAllBy(['where' => $search_string ]))
+      return;
+
+    $body = [];
+
+    foreach($rendezvous as $model) {
+      $report = $model->notifyBatch();
+      $body[] = $this->_generateMail($model, $report);
+    }
+
+    $mailer = new Class_MailHtml();
+    $mailer->mail($mailer->getMailFrom(),
+                  $this->_('Rapport de notifications envoyées pour les rendez-vous'),
+                  '<dl>' . implode($body) . '</dl>');
+  }
+
+
+  protected function _generateMail($rendez_vous, $report) {
+    return sprintf('<dt><a href="%s">%s, %s</a></dt><dd>%s</dd>',
+                   Class_Url::absolute(['module' => 'admin',
+                                        'controller' => 'rendez-vous',
+                                        'action' => 'notification',
+                                        'group_id' => $rendez_vous->getGroupId(),
+                                        'id' => $rendez_vous->getId()], null, true),
+                   $rendez_vous->getAgendaLabel(),
+                   $rendez_vous->getLibelle(),
+                   $report->getActionStatus()
+    );
+  }
+}
diff --git a/library/Class/Mail.php b/library/Class/Mail.php
index c8a262bed76..fb3b43e08f6 100644
--- a/library/Class/Mail.php
+++ b/library/Class/Mail.php
@@ -43,10 +43,11 @@ class Class_Mail {
     $mail = new ZendAfi_Mail('utf8');
     $mail
       ->setSubject($sujet)
-      ->setBodyText($body)
       ->setFrom($this->mail_from)
       ->addTo($destinataire);
 
+    $this->_setBodyIn($body, $mail);
+
     try {
       $mail->send();
       return true;
@@ -56,6 +57,11 @@ class Class_Mail {
   }
 
 
+  protected function _setBodyIn($body, $mail) {
+    $mail->setBodyText($body);
+  }
+
+
   public function sendMail($sujet, $body, $destinataire, $data=false) {
     $error_message = sprintf('%s <br/> %s',
                              $this->_("Les paramètres d'envoi de mails du portail sont incomplets."),
@@ -94,4 +100,9 @@ class Class_Mail {
     $validator = new Zend_Validate_EmailAddress();
     return $validator->isValid($mail);
   }
+
+
+  public function getMailFrom() {
+    return $this->mail_from;
+  }
 }
\ No newline at end of file
diff --git a/library/Class/MailHtml.php b/library/Class/MailHtml.php
new file mode 100644
index 00000000000..f93b5208e9a
--- /dev/null
+++ b/library/Class/MailHtml.php
@@ -0,0 +1,26 @@
+<?php
+/**
+ * Copyright (c) 2012, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+class Class_MailHtml extends Class_Mail {
+  protected function _setBodyIn($body, $mail) {
+    $mail->setBodyHtml($body);
+  }
+}
\ No newline at end of file
diff --git a/library/Class/ModeleFusion.php b/library/Class/ModeleFusion.php
index 478fdc983a8..495eb256728 100644
--- a/library/Class/ModeleFusion.php
+++ b/library/Class/ModeleFusion.php
@@ -100,7 +100,8 @@ class Class_ModeleFusionLoader extends Storm_Model_Loader {
             Class_ModeleFusion::ARTICLES_TEMPLATE => $this->_('Page d\'articles'),
             Class_ModeleFusion::RECORDS_TEMPLATE => $this->_('Resultats de recherche'),
             Class_ModeleFusion::RECORD_TEMPLATE => $this->_('Page de notice'),
-            Class_ModeleFusion::LOANS_TEMPLATE => $this->_('Liste des prêts')];
+            Class_ModeleFusion::LOANS_TEMPLATE => $this->_('Liste des prêts'),
+    ];
   }
 
 
diff --git a/library/Class/Profil/Skin.php b/library/Class/Profil/Skin.php
index 36d629afda8..4ecfaecf45d 100644
--- a/library/Class/Profil/Skin.php
+++ b/library/Class/Profil/Skin.php
@@ -100,7 +100,9 @@ class Class_Profil_Skin {
 
   public function getImageUrl($img) {
     return ($this->imageExists($img)
-            ? $this->getUrl() . self::IMAGE_DIR : URL_SHARED_IMG)  . '/' . $img;
+            ? ($this->getUrl() . self::IMAGE_DIR . '/')
+            : URL_SHARED_IMG)
+      . $img;
   }
 
 
diff --git a/library/Class/RendezVous.php b/library/Class/RendezVous.php
index 76d6fdd8ca1..0b6145903dd 100644
--- a/library/Class/RendezVous.php
+++ b/library/Class/RendezVous.php
@@ -43,6 +43,13 @@ class Class_RendezVous extends Storm_Model_Abstract {
                             'location' => ['model' => 'Class_Lieu',
                                            'referenced_in' => 'location_id']];
 
+  protected $_has_many = ['notifications' => ['model' => 'Class_RendezVous_UserNotification',
+                                              'role' => 'rendez_vous',
+                                              'order' => 'id desc',
+                                              'dependents' => 'delete'],
+
+                          'users' => ['through' => 'agenda']];
+
   protected $_default_attribute_values = ['group_id'=> null,
                                           'location_id' => null,
                                           'date' => null,
@@ -60,7 +67,7 @@ class Class_RendezVous extends Storm_Model_Abstract {
 
 
   public function getFormattedDate() {
-    return strftime('%A %d %B', strtotime($this->getDate()));
+    return strftime('%a %d %B %Y', strtotime($this->getDate()));
   }
 
 
@@ -93,12 +100,19 @@ class Class_RendezVous extends Storm_Model_Abstract {
   }
 
 
+  public function getAgendaUsers() {
+    return $this->hasAgenda()
+      ? $this->getAgenda()->getUsers()
+      : [];
+  }
+
+
   public function validate() {
     $this->check($this->hasAgenda(), $this->_('L\'agenda est obligatoire'));
     $this->checkAttribute('date',
                           $this->hasDate()
                           && (new ZendAfi_Validate_DateFormat())->isValid($this->getDate()),
-                          $this->_('La date doit être au forma JJ/MM/AAAA'));
+                          $this->_('La date doit être au format JJ/MM/AAAA'));
     $this->_validateTime('begin_time', $this->_('L\'heure de début est obligatoire'));
     $this->_validateTime('end_time', $this->_('L\'heure de fin est obligatoire'));
     $this->checkAttribute('end_time', $this->getBeginTime() < $this->getEndTime(),
@@ -154,4 +168,32 @@ class Class_RendezVous extends Storm_Model_Abstract {
 
     return strnatcmp($this->getEndTime(), $other->getEndTime());
   }
+
+
+  public function notifyBatch() {
+    $notify = Class_RendezVous_Notify::newBatchFor($this);
+    return $notify();
+  }
+
+
+  public function notifyManual() {
+    $notify = Class_RendezVous_Notify::newManualFor($this);
+    return $notify();
+  }
+
+
+  public function notifyUserById($user_id) {
+    $notify = Class_RendezVous_Notify::newUniqueUserFor($this, $user_id);
+    return $notify();
+  }
+
+
+  public function getNotificationStatus() {
+    return $this->getNotificationReport()->getStatus();
+  }
+
+
+  public function getNotificationReport() {
+    return new Class_RendezVous_UserNotificationReport($this->getNotifications());
+  }
 }
\ No newline at end of file
diff --git a/library/Class/RendezVous/Notify.php b/library/Class/RendezVous/Notify.php
new file mode 100644
index 00000000000..db10429c0d7
--- /dev/null
+++ b/library/Class/RendezVous/Notify.php
@@ -0,0 +1,126 @@
+<?php
+/**
+ * Copyright (c) 2012-2019, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+abstract class Class_RendezVous_Notify {
+  protected
+    $_type,
+    $_rendez_vous;
+
+  public static function newBatchFor($rendez_vous) {
+    return new Class_RendezVous_NotifyBatch($rendez_vous);
+  }
+
+
+  public static function newManualFor($rendez_vous) {
+    return new Class_RendezVous_NotifyManual($rendez_vous);
+  }
+
+
+  public static function newUniqueUserFor($rendez_vous, $user_id) {
+    return (new Class_RendezVous_NotifyUniqueUser($rendez_vous))
+      ->setUserId($user_id);
+  }
+
+
+  public function __construct($rendez_vous) {
+    $this->_rendez_vous = $rendez_vous;
+  }
+
+
+  public function __invoke() {
+    if (!$users = $this->getAgendaUsers())
+      return new Class_RendezVous_UserNotificationReport([]);
+
+    $notifications = [];
+    foreach ($users as $user) {
+      $notif = Class_RendezVous_UserNotification::newInstance(['rendez_vous' => $this->_rendez_vous,
+                                                               'user' => $user,
+                                                               ]);
+      $method = $this->_getMethod();
+      $notif->$method();
+      $notifications[] = $notif;
+    }
+
+    return new Class_RendezVous_UserNotificationReport($notifications);
+  }
+
+
+  public function getAgendaUsers() {
+    return $this->_rendez_vous->getAgendaUsers();
+  }
+
+
+  protected function _getMethod() {
+    return 'notify' . $this->_type;
+  }
+}
+
+
+
+class Class_RendezVous_NotifyManual extends Class_RendezVous_Notify {
+  protected $_type = Class_RendezVous_UserNotification::MANUAL_TYPE;
+}
+
+
+
+class Class_RendezVous_NotifyBatch extends Class_RendezVous_Notify {
+  protected $_type = Class_RendezVous_UserNotification::BATCH_TYPE;
+
+  public function getAgendaUsers() {
+    $users = parent::getAgendaUsers();
+    return (new Storm_Model_Collection($users))
+      ->select(function($user)
+               {
+                 return !Class_RendezVous_UserNotification::isAlreadyBatchSentFor($user,
+                                                                                  $this->_rendez_vous);
+               })
+      ->getArrayCopy();
+  }
+}
+
+
+
+class Class_RendezVous_NotifyUniqueUser extends Class_RendezVous_Notify {
+  protected
+    $_type = Class_RendezVous_UserNotification::MANUAL_TYPE,
+    $_user_id;
+
+  public function setUserId($user_id) {
+    $this->_user_id = $user_id;
+    return $this;
+  }
+
+
+  public function getAgendaUsers() {
+    if (!$this->_user_id)
+      return [];
+
+    $users = parent::getAgendaUsers();
+
+    return (new Storm_Model_Collection($users))
+      ->select(function($user)
+               {
+                 return $user->getId() == $this->_user_id;
+               })
+      ->getArrayCopy();
+  }
+}
diff --git a/library/Class/RendezVous/SearchCriteria/Date.php b/library/Class/RendezVous/SearchCriteria/Date.php
index f024e0a66b8..948edb41440 100644
--- a/library/Class/RendezVous/SearchCriteria/Date.php
+++ b/library/Class/RendezVous/SearchCriteria/Date.php
@@ -73,9 +73,12 @@ class Class_RendezVous_SearchCriteria_Date extends Class_SearchCriteria_Abstract
 
 
   protected function _filterDate($value) {
-    if (!$value)
+    if (null === $value)
       return;
 
+    if ('' === $value)
+      return '';
+
     if (!(new ZendAfi_Validate_DateFormat())->isValid($value, static::DATE_FORMAT)) {
       $this->_element->addError($this->_('Les dates doivent être au format JJ/MM/AAAA'));
       return;
diff --git a/library/Class/RendezVous/UserNotification.php b/library/Class/RendezVous/UserNotification.php
new file mode 100644
index 00000000000..dbac41b7834
--- /dev/null
+++ b/library/Class/RendezVous/UserNotification.php
@@ -0,0 +1,158 @@
+<?php
+/**
+ * Copyright (c) 2012, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+class Class_RendezVous_UserNotificationLoader extends Storm_Model_Loader {
+  public function isAlreadyBatchSentFor($user, $rendez_vous) {
+    $params = ['status' => Class_RendezVous_UserNotification::SENT_STATUS,
+               'type' => Class_RendezVous_UserNotification::BATCH_TYPE,
+               'user_id' => $user->getId(),
+               'rendez_vous_id'=> $rendez_vous->getId()];
+
+    return 0 < Class_RendezVous_UserNotification::countBy($params);
+  }
+
+
+  public function isKnownType($type) {
+    return in_array($type, [Class_RendezVous_UserNotification::BATCH_TYPE,
+                            Class_Rendezvous_Usernotification::MANUAL_TYPE]);
+  }
+}
+
+
+
+class Class_RendezVous_UserNotification extends Storm_Model_Abstract {
+  use Trait_Translator, Trait_TimeSource;
+
+  const
+    BATCH_TYPE = 'Batch',
+    MANUAL_TYPE = 'Manual',
+    SENT_STATUS = 'sent',
+    ERROR_STATUS = 'error',
+    NOMAIL_STATUS = 'nomail';
+
+  protected $_table_name = 'rendez_vous_user_notification';
+  protected $_loader_class = 'Class_RendezVous_UserNotificationLoader';
+  protected $_belongs_to = ['user' => ['model' => 'Class_Users'],
+                            'rendez_vous' => ['model' => 'Class_RendezVous']];
+
+  protected $_default_attribute_values = ['rendez_vous_id'=> null,
+                                          'user_id' => null,
+                                          'created_at' => null,
+                                          'status' => null,
+                                          'type'=> null,
+                                          'error' => null];
+
+  public function getUserName() {
+    return $this->hasUser()
+      ? $this->getUser()->getNomComplet()
+      : '';
+  }
+
+
+  public function getUserMail() {
+    return $this->hasUser()
+      ? $this->getUser()->getMail()
+      : '';
+  }
+
+
+  public function notifyBatch() {
+    return $this->setType(static::BATCH_TYPE)->_send();
+  }
+
+
+  public function notifyManual() {
+    return $this->setType(static::MANUAL_TYPE)->_send();
+  }
+
+
+  public function beforeSave() {
+    if ($this->isNew())
+      $this->setCreatedAt($this->getCurrentDateTime());
+  }
+
+
+  public function _send() {
+    $modelfusion = (new Class_ModeleFusion())
+      ->setContenu(Class_AdminVar::getValueOrDefault('NOTIFICATION_TEMPLATE_RENDEZ_VOUS'));
+
+    $body = $modelfusion->setDataSource(['rendez_vous' => $this->getRendezVous(),
+                                         'user' => $this->getUser()])
+                        ->getContenuFusionne();
+
+    $subject = $this->getRendezVous()->getAgendaLabel()
+      . " " . $this->getRendezVous()->getLibelle();
+
+    $mailer = new Class_MailHtml();
+    if (!$mailer->isMailValid($this->getUser()->getMail()))
+      return $this->_setNotificationStatus(static::NOMAIL_STATUS);
+
+    return $this->_setNotificationStatus($this->_sendMail($mailer, $subject, $body));
+  }
+
+
+  protected function _sendMail($mailer, $subject, $body) {
+    if (true === $result = $mailer->mail($this->getUser()->getMail(), $subject, $body))
+      return  static::SENT_STATUS;
+
+    $this->setError($result);
+    return static::ERROR_STATUS;
+  }
+
+
+  private function _setNotificationStatus($status) {
+    $this->setStatus($status)->save();
+    return $this;
+  }
+
+
+  public function renderErrorMessage(){
+    if (!$user = $this->getUser())
+      return;
+
+    return $user->getNomComplet() . ' - '.$user->getMail(). " : ". $this->getError();
+  }
+
+
+  public function isSent() {
+    return (static::SENT_STATUS == $this->getStatus());
+  }
+
+
+  public function isError(){
+    return (static::ERROR_STATUS == $this->getStatus());
+  }
+
+
+  public function isNomail() {
+    return (static::NOMAIL_STATUS == $this->getStatus());
+  }
+
+
+  public function isManual() {
+    return static::MANUAL_TYPE == $this->getType();
+  }
+
+
+  public function isBatch() {
+    return static::BATCH_TYPE == $this->getType();
+  }
+}
diff --git a/library/Class/RendezVous/UserNotificationReport.php b/library/Class/RendezVous/UserNotificationReport.php
new file mode 100644
index 00000000000..3b7a0c18ee2
--- /dev/null
+++ b/library/Class/RendezVous/UserNotificationReport.php
@@ -0,0 +1,65 @@
+<?php
+/**
+ * Copyright (c) 2012-2019, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class Class_RendezVous_UserNotificationReport extends Storm_Model_Collection{
+  use Trait_Translator;
+
+
+  public function getActionStatus() {
+    return $this->_messageFor($this);
+  }
+
+
+  public function getStatus() {
+    return implode('<br/>',
+                   [$this->_('Manuelles : %s', $this->_messageFor($this->manualOnly())),
+                    $this->_('Automatiques : %s', $this->_messageFor($this->batchOnly()))]);
+  }
+
+
+  protected function _messageFor($notifications) {
+    if ($notifications->isEmpty())
+      return $this->_('Aucune');
+
+    $message = $this->_('Envoyées à %d/%d',
+                        $notifications->select('isSent')->count(),
+                        $notifications->count());
+
+    if ($without_mail = $notifications->select('isNomail')->count())
+      $message .= $this->_(', %d participant.e.s sans mail', $without_mail);
+
+    if ($with_error = $notifications->select('isError')->count())
+      $message .= $this->_(', %d erreur ',$with_error);
+
+    return $message;
+  }
+
+
+  public function manualOnly() {
+    return $this->select('isManual');
+  }
+
+
+  public function batchOnly() {
+    return $this->select('isBatch');
+  }
+}
diff --git a/library/Class/TableDescription.php b/library/Class/TableDescription.php
index 4530f32727c..2fc6119a374 100644
--- a/library/Class/TableDescription.php
+++ b/library/Class/TableDescription.php
@@ -31,12 +31,14 @@ class Class_TableDescription {
 
   protected
     $_columns,
+    $_actions,
     $_id,
     $_pager = false,
     $_sorter = self::SORT_CLIENT,
     $_order,
     $_classes = [],
-    $_with_order_callback;
+    $_with_order_callback,
+    $_empty_message;
 
 
   public function __construct($id, $with_order_callback=null) {
@@ -170,6 +172,15 @@ class Class_TableDescription {
   }
 
 
+  public function addRowPluginsActions() {
+    return $this
+      ->addRowAction(['canvas_callback' => function($model, $canvas)
+                      {
+                        return $canvas->getView()->renderPluginsActions($model);
+                      }]);
+  }
+
+
   public function numberOfColumns() {
     return $this->_columns->count();
   }
@@ -194,6 +205,32 @@ class Class_TableDescription {
                               ['data-order' => $data_order]);
     };
   }
+
+
+  public function onEmptyShowMessage($message) {
+    $this->_empty_message = $message;
+    return $this;
+  }
+
+
+  public function getEmptyMessage() {
+    return $this->_empty_message
+      ? $this->_empty_message
+      : $this->_('Aucune donnée');
+  }
+
+
+  public function withoutActions() {
+    $instance = new static($this->_id, $this->_with_order_callback);
+    $instance->_actions = null;
+    $instance->_columns = $instance->_columns
+      ->reject(function($column)
+               {
+                 return $column instanceof Class_TableDescription_ColumnForActions;
+               });
+
+    return $instance;
+  }
 }
 
 
@@ -211,6 +248,13 @@ class Class_TableDescription_Columns {
   }
 
 
+  public function reject($callback) {
+    $instance = new static();
+    $instance->_columns->addAll($this->_columns->reject($callback));
+    return $instance;
+  }
+
+
   public function count() {
     return $this->_columns->count();
   }
@@ -343,6 +387,15 @@ class Class_TableDescription_ColumnForAttribute extends Class_TableDescription_C
   }
 
 
+  public function getOptionsParams() {
+    $options = parent::getOptionsParams();
+    if (!$this->_sortable)
+      $options['data-sorter'] = 'false';
+
+    return $options;
+  }
+
+
   public function renderModelOn($model, $canvas) {
     return $canvas->renderContent($model->callGetterByAttributeName($this->_attribute));
   }
@@ -361,9 +414,8 @@ class Class_TableDescription_ColumnForCallback extends Class_TableDescription_Co
 
 
   public function renderModelOn($model, $canvas) {
-    return $canvas->renderContent(call_user_func($this->_callback,
-                                                 $model,
-                                                 $this->_attribute));
+    return $canvas
+      ->renderContent(call_user_func($this->_callback, $model, $this->_attribute, $canvas));
   }
 }
 
@@ -408,6 +460,9 @@ abstract class Class_TableDescription_ActionAbstract {
     if (is_a($description, 'Closure'))
       return new Class_TableDescription_ActionCallback($description);
 
+    if (isset($description['canvas_callback']) && is_callable($description['canvas_callback']))
+      return new Class_TableDescription_ActionCanvasCallback($description['canvas_callback']);
+
     if (isset($description['content']) && is_callable($description['content']))
       return new Class_TableDescription_ActionWithContentCallback($description['action'],
                                                                   $description['content']);
@@ -439,6 +494,14 @@ class Class_TableDescription_ActionCallback extends Class_TableDescription_Actio
 
 
 
+class Class_TableDescription_ActionCanvasCallback extends Class_TableDescription_ActionCallback {
+  public function renderModelOn($model, $canvas) {
+    return $canvas->renderContent(call_user_func($this->_callback, $model, $canvas));
+  }
+}
+
+
+
 
 class Class_TableDescription_ActionWithContentCallback extends Class_TableDescription_ActionAbstract {
   protected
diff --git a/library/Class/TableDescription/RendezVous.php b/library/Class/TableDescription/RendezVous.php
new file mode 100644
index 00000000000..fc6c0491aac
--- /dev/null
+++ b/library/Class/TableDescription/RendezVous.php
@@ -0,0 +1,37 @@
+<?php
+/**
+ * Copyright (c) 2012-2019, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class Class_TableDescription_RendezVous extends Class_TableDescription {
+  public function init() {
+    $this->addColumn($this->_('Date'), ['attribute' => 'formatted_date',
+                                        'sort_attribute' => 'date'])
+         ->addColumn($this->_('Début'), ['attribute' => 'formatted_begin_time',
+                                         'sort_attribute' => 'begin_time'])
+         ->addColumn($this->_('Fin'), ['attribute' => 'formatted_end_time',
+                                       'sort_attribute' => 'end_time'])
+         ->addColumn($this->_('Lieu'), ['attribute' => 'location_label',
+                                        'sort_attribute' => 'location_id'])
+         ->addColumn($this->_('Notifications'), ['attribute' => 'notification_status',
+                                                 'sortable' => false])
+         ->addRowPluginsActions();
+  }
+}
diff --git a/library/Class/TableDescription/RendezVousNotification.php b/library/Class/TableDescription/RendezVousNotification.php
new file mode 100644
index 00000000000..e037298406f
--- /dev/null
+++ b/library/Class/TableDescription/RendezVousNotification.php
@@ -0,0 +1,47 @@
+<?php
+/**
+ * Copyright (c) 2012-2019, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class Class_TableDescription_RendezVousNotification extends Class_TableDescription {
+  public function init() {
+    $this
+      ->addColumn($this->_('Participant'), 'user_name')
+      ->addColumn($this->_('Date'), 'created_at')
+      ->addColumn($this->_('Statut'),
+                  function($model, $attrib, $canvas)
+                  {
+                    return $canvas->getView()->rendezVousNotificationStatus($model);
+                  })
+
+      ->addRowAction([ 'url' => ['action'=>'notification-send',
+                                 'notification_id'  => '%s'],
+                      'icon' => 'mail',
+                      'label' => $this->_('Renvoyer')])
+
+      ->addRowAction(['url' => ['action'=>'notification-delete',
+                                'notification_id'  => '%s'],
+                      'icon' => 'delete',
+                      'label' => $this->_('Supprimer')])
+
+      ->onEmptyShowMessage($this->_('Aucune pour l\'instant'))
+      ;
+  }
+}
diff --git a/library/Class/TableDescription/RendezVousSearch.php b/library/Class/TableDescription/RendezVousSearch.php
new file mode 100644
index 00000000000..be0dbcbf465
--- /dev/null
+++ b/library/Class/TableDescription/RendezVousSearch.php
@@ -0,0 +1,29 @@
+<?php
+/**
+ * Copyright (c) 2012-2019, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class Class_TableDescription_RendezVousSearch extends Class_TableDescription_RendezVous {
+  public function init() {
+    $this->addColumn($this->_('Agenda'), ['attribute' => 'agenda_label',
+                                          'sort_attribute' => 'group_id']);
+    parent::init();
+  }
+}
diff --git a/library/ZendAfi/Controller/Action/Helper/SearchRendezVous.php b/library/ZendAfi/Controller/Action/Helper/SearchRendezVous.php
index 08e4b52b938..7bc013dd97e 100644
--- a/library/ZendAfi/Controller/Action/Helper/SearchRendezVous.php
+++ b/library/ZendAfi/Controller/Action/Helper/SearchRendezVous.php
@@ -33,7 +33,7 @@ class ZendAfi_Controller_Action_Helper_SearchRendezVous
 
   /**
    * @param $action_params array
-   * @param $criteria Class_User_SearchCriteria
+   * @param $criteria Class_RendezVous_SearchCriteria
    */
   public function searchRendezVous($action_params, $criteria) {
     if (!$action_params)
diff --git a/library/ZendAfi/Controller/Plugin/Manager/RendezVous.php b/library/ZendAfi/Controller/Plugin/Manager/RendezVous.php
index 77f60d185e5..d1293c2c3ae 100644
--- a/library/ZendAfi/Controller/Plugin/Manager/RendezVous.php
+++ b/library/ZendAfi/Controller/Plugin/Manager/RendezVous.php
@@ -37,10 +37,24 @@ class ZendAfi_Controller_Plugin_Manager_RendezVous
              'icon' => 'edit',
              'label' => $this->_('Modifier "%s"', $model->getLibelle())],
 
+            ['url' => '/admin/rendez-vous/notify/group_id/'. $model->getAgenda()->getId() . '/id/%s',
+             'label' => $this->_('Envoyer les notifications pour "%s"', $model->getLibelle()),
+             'icon' => 'mail',
+             'anchorOptions' => $this->_confirm($this->_('Envoyer les notifications pour "%s"',
+                                                         $model->getLibelle()) . ' ?'),
+            ],
+
+            ['url' => '/admin/rendez-vous/notification/group_id/'. $model->getAgenda()->getId() . '/id/%s',
+             'label' => $this->_('Historique des notifications pour "%s"',
+                                 $model->getLibelle()),
+             'icon' => 'view',
+            ],
+
             ['url' => '/admin/rendez-vous/duplicate/group_id/' . $model->getAgenda()->getId() . '/id/%s',
              'icon' => 'copy',
              'label' => $this->_('Dupliquer "%s"', $model->getLibelle())],
 
+
             ['url' => '/admin/rendez-vous/delete/group_id/'. $model->getAgenda()->getId() . '/id/%s',
              'label' => $this->_('Supprimer "%s"', $model->getLibelle()),
              'icon' => 'delete',
@@ -104,6 +118,98 @@ class ZendAfi_Controller_Plugin_Manager_RendezVous
   }
 
 
+  public function notifyAction() {
+    if ($this->_response->isRedirect())
+      return;
+
+    if (!$model = $this->_findModel()) {
+      $this->_redirectToReferer();
+      return;
+    }
+
+    $report = $model->notifyManual();
+    $this->_helper->notify($report->getActionStatus());
+    $this->_redirectToReferer();
+  }
+
+
+  public function notificationAction() {
+    if ($this->_response->isRedirect())
+      return;
+
+    if (!$model = $this->_findModel()) {
+      $this->_redirectToReferer();
+      return;
+    }
+
+    $this->_view->titre = $this->_('Notifications pour le rendez-vous %s',
+                                   $model->getLibelle());
+
+    $this->_view->report = $model->getNotificationReport();
+  }
+
+
+  public function notificationDeleteAction() {
+    if ($this->_response->isRedirect())
+      return;
+
+    if ($model = Class_RendezVous_UserNotification::find($this->_getParam('notification_id', 0)))
+      $model->delete();
+
+    $this->_helper->notify($this->_('Historique de notification supprimé'));
+    return $this->_redirectToReferer();
+  }
+
+
+  public function notificationSendAction() {
+    if ($this->_response->isRedirect())
+      return;
+
+    if (!$model = $this->_findModel()) {
+      $this->_redirectToReferer();
+      return;
+    }
+
+    if (!$previous = Class_RendezVous_UserNotification::find($this->_getParam('notification_id', 0))) {
+      $this->_helper->notify($this->_('Impossible de renvoyer une notification inconnue'));
+      return $this->_redirectToReferer();
+    }
+
+    $report = $model->notifyUserById($previous->getUserId());
+    $this->_helper->notify($report->getActionStatus());
+
+    return $this->_redirectToReferer();
+  }
+
+
+  public function notificationPurgeAction() {
+    if ($this->_response->isRedirect())
+      return;
+
+    if (!$model = $this->_findModel()) {
+      $this->_redirectToReferer();
+      return;
+    }
+
+    $params = ['rendez_vous_id' => $model->getId()];
+    if (($type = $this->_getParam('type'))
+        && Class_RendezVous_UserNotification::isKnownType($type))
+      $params['type'] = $type;
+
+    $count_deleted = Class_RendezVous_UserNotification::deleteBy($params);
+    $this->_helper->notify($this->_("%s notifications supprimées", $count_deleted));
+    $this->_redirectToReferer();
+  }
+
+
+  public function notificationErrorAction() {
+    $this->_view->titre = $this->_('Détails de l\'erreur');
+    $this->_view->model = Class_RendezVous_UserNotification::find((int)$this->_getParam('id'));
+    if (!$this->_view->model)
+      throw new Zend_Controller_Action_Exception($this->_('Désolé, cette page n\'existe pas'), 404);
+  }
+
+
   protected function _getNewModel() {
     return parent::_getNewModel()->setAgenda($this->_user_group);
   }
diff --git a/library/ZendAfi/Controller/Plugin/ResourceDefinition/RendezVous.php b/library/ZendAfi/Controller/Plugin/ResourceDefinition/RendezVous.php
index f6d3a1e759a..d3eb21ffdb8 100644
--- a/library/ZendAfi/Controller/Plugin/ResourceDefinition/RendezVous.php
+++ b/library/ZendAfi/Controller/Plugin/ResourceDefinition/RendezVous.php
@@ -31,7 +31,9 @@ class ZendAfi_Controller_Plugin_ResourceDefinition_RendezVous
 
             'actions' => ['index' => ['title' => $this->_('Gestion des rendez-vous pour "%s"')],
                           'add' => ['title' => $this->_('Ajouter un rendez-vous pour "%s"')],
-                          'edit' => ['title' => $this->_('Modifier le rendez-vous "%s"')]],
+                          'edit' => ['title' => $this->_('Modifier le rendez-vous "%s"')],
+                          'notify' => ['title' => $this->_('Notifier par email pour le rendez-vous "%s"')]
+            ],
 
             'messages' => ['successful_add' => $this->_('Rendez-vous %s ajouté'),
                            'successful_save' => $this->_('Rendez-vous %s modifié'),
diff --git a/library/ZendAfi/Form/Decorator/UserSelection.php b/library/ZendAfi/Form/Decorator/UserSelection.php
index 7dcd6e374df..bd38a2150ca 100644
--- a/library/ZendAfi/Form/Decorator/UserSelection.php
+++ b/library/ZendAfi/Form/Decorator/UserSelection.php
@@ -37,7 +37,6 @@ add_message:' . json_encode($this->_element->getAddMessage()). ',
 delete_message:' . json_encode($this->_element->getDeleteMessage()). '})');
 
     $description = (new Class_TableDescription_Users('current_user_selection_' . $this->_element->getName()))
-      ->setSorterNone()
       ->addRowAction(function($model) use($view)
                      {
                        return $view->renderModelActions($model,
@@ -50,7 +49,8 @@ delete_message:' . json_encode($this->_element->getDeleteMessage()). '})');
                                                          'data-userid' => $model->getId(),
                                                          'data-username' => $model->getNomComplet()]]
                                    ]);
-                     });
+                     })
+      ->onEmptyShowMessage($this->_element->getEmptyMessage());
 
     return $content
       . $view->tag('div',
@@ -59,9 +59,8 @@ delete_message:' . json_encode($this->_element->getDeleteMessage()). '})');
                    . $view->button_New((new Class_Entity)
                                      ->setText($this->_element->getButtonLabel())
                                      ->setAttribs(['onclick' => 'return false;']))
-                   . $view->renderEmptyEnabledTable($description,
-                                                    $this->_element->getModels(),
-                                                    $this->_element->getEmptyMessage())
+                   . $view->renderTable($description,
+                                        $this->_element->getModels())
                    ,
                    ['id' => 'user_selection_' . $this->_element->getId()]);
   }
diff --git a/library/ZendAfi/View/Helper/Abonne/Abstract.php b/library/ZendAfi/View/Helper/Abonne/Abstract.php
index 360d67db5a2..9762d36ff9b 100644
--- a/library/ZendAfi/View/Helper/Abonne/Abstract.php
+++ b/library/ZendAfi/View/Helper/Abonne/Abstract.php
@@ -25,7 +25,7 @@ abstract class ZendAfi_View_Helper_Abonne_Abstract extends ZendAfi_View_Helper_B
     if ($icone && $url && $icon) {
       $html = $this->view->tagAnchor($url,
                                      $this->view->tagImg(Class_Profil::getCurrentProfil()
-                                                         ->getUrlImage('/abonnes/'.$icon.'.png'),
+                                                         ->getUrlImage('abonnes/'.$icon.'.png'),
                                                          ['alt' => $icon]) . $html,
                                      array_merge(['class' => $icone], $attribs));
     }
diff --git a/library/ZendAfi/View/Helper/Admin/ContentNav.php b/library/ZendAfi/View/Helper/Admin/ContentNav.php
index ce93b2bffcc..e763e735787 100644
--- a/library/ZendAfi/View/Helper/Admin/ContentNav.php
+++ b/library/ZendAfi/View/Helper/Admin/ContentNav.php
@@ -68,7 +68,7 @@ class ZendAfi_View_Helper_Admin_ContentNav extends ZendAfi_View_Helper_BaseHelpe
                     ['newsletters',           $this->_("Lettres d'information"),   '/admin/newsletter'],
                     ['trainings',             $this->_('Activités'),               '/admin/activity'],
                     ['places',                $this->_('Lieux'),                   '/admin/lieu'],
-                    ['trainings',              $this->_('Rendez-vous'),             '/admin/usergroup-agenda'],
+                    ['meeting',              $this->_('Rendez-vous'),             '/admin/usergroup-agenda'],
                     ['filebrowser',           $this->_('Explorateur de fichiers'), '/admin/file-manager'],
                    ]);
   }
diff --git a/library/ZendAfi/View/Helper/Admin/RenderVersionForm.php b/library/ZendAfi/View/Helper/Admin/RenderVersionForm.php
index 5a5a46233ae..b84f3d39a10 100644
--- a/library/ZendAfi/View/Helper/Admin/RenderVersionForm.php
+++ b/library/ZendAfi/View/Helper/Admin/RenderVersionForm.php
@@ -140,18 +140,18 @@ $('#" . $form->getId() . "').find('fieldset').each(function(i, elem) {
       ->setText($this->_('Rétablir'))
       ->setImage($this->view->tagImg(Class_Admin_Skin::current()
                                      ->getIconUrl('actions', 'rollback'),
-                                     ['style' => 'filter: invert();']))
-      ->setAttribs(['disabled' => 'disabled',
-                    'title' => $this->_('Aucune différence avec les donnnées actuelles')]);
+                                     ['style' => 'filter: invert();']));
 
-    if (!$this->_isModified())
+    if (!$this->_isModified()) {
+      $apply->setAttribs(['disabled' => 'disabled',
+                          'title' => $this->_('Aucune différence avec les donnnées actuelles')]);
       return $this->view->Button($apply);
-
+    }
 
     $apply
       ->setUrl($url)
-      ->setAttribs($this->_confirmAttribsFor($url,
-                                             $this->_('Êtes-vous sur de vouloir rétablir cette version ?')));
+      ->setConfirm($this->_('Êtes-vous sur de vouloir rétablir cette version ?'))
+      ;
 
     return $this->view->Button($apply);
   }
@@ -164,23 +164,10 @@ $('#" . $form->getId() . "').find('fieldset').each(function(i, elem) {
     $delete = (new Class_Entity())
       ->setText($this->_('Supprimer'))
       ->setUrl($url)
-      ->setAttribs($this->_confirmAttribsFor($url,
-                                             $this->_('Êtes-vous sur de vouloir supprimer cette version de l\\\'historique ?')))
+      ->setConfirm($this->_('Êtes-vous sur de vouloir supprimer cette version de l\\\'historique ?'))
       ->setImage($this->view->tagImg(Class_Admin_Skin::current()
                                      ->getIconUrl('buttons', 'remove')));
 
     return $this->view->Button($delete);
   }
-
-
-  protected function _confirmAttribsFor($url, $message) {
-    $action = $this->view->isPopup()
-      ? "opacDialogClose(); opacDialogFromUrl('" . $url ."');"
-      : "window.location.href='" . $url ."'";
-
-    return
-      ['onclick' => sprintf("if (confirm('%s')) { %s }; return false;",
-                            htmlspecialchars($message),
-                            $action)];
-  }
 }
\ No newline at end of file
diff --git a/library/ZendAfi/View/Helper/Admin/RendezVousNotificationStatus.php b/library/ZendAfi/View/Helper/Admin/RendezVousNotificationStatus.php
new file mode 100644
index 00000000000..aa302d37f92
--- /dev/null
+++ b/library/ZendAfi/View/Helper/Admin/RendezVousNotificationStatus.php
@@ -0,0 +1,61 @@
+<?php
+/**
+ * Copyright (c) 2012-2019, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class ZendAfi_View_Helper_Admin_RendezVousNotificationStatus extends ZendAfi_View_Helper_BaseHelper {
+  protected $_status_renderers =
+    [
+     Class_RendezVous_UserNotification::SENT_STATUS => '_renderSent',
+     Class_RendezVous_UserNotification::ERROR_STATUS => '_renderError',
+     Class_RendezVous_UserNotification::NOMAIL_STATUS => '_renderNoMail'
+    ];
+
+
+  public function rendezVousNotificationStatus($notif) {
+    return isset($this->_status_renderers[$notif->getStatus()])
+      ? call_user_func([$this, $this->_status_renderers[$notif->getStatus()]],
+                       $notif)
+      : $notif->getStatus();
+  }
+
+
+  protected function _renderSent($notif) {
+    return $this->view->tagSuccess($this->_('Envoyée'));
+  }
+
+
+  protected function _renderError($notif) {
+    return $this->view->tagError($this->_('Erreur'))
+      . ' '
+      . $this->view->tagAnchor(['module' => 'admin',
+                                'controller' => 'rendez-vous',
+                                'action' => 'notification-error',
+                                'id' => $notif->getId()],
+                               $this->_('En savoir plus'),
+                               ['data-popup' => 'true']);
+  }
+
+
+  protected function _renderNoMail($notif) {
+    return $this->view->tagError($this->_('Pas de mail'));
+  }
+
+}
diff --git a/library/ZendAfi/View/Helper/Admin/SearchRendezVous.php b/library/ZendAfi/View/Helper/Admin/SearchRendezVous.php
index b7ce9088453..89af2681183 100644
--- a/library/ZendAfi/View/Helper/Admin/SearchRendezVous.php
+++ b/library/ZendAfi/View/Helper/Admin/SearchRendezVous.php
@@ -21,17 +21,15 @@
 
 
 class ZendAfi_View_Helper_Admin_SearchRendezVous extends ZendAfi_View_Helper_BaseHelper {
-  protected $users, $total, $params;
+  protected
+    $_context;
 
   public function searchRendezVous($context) {
-    $this->models = $context->getModels();
-    $this->total = $context->getTotal();
-    $this->page = $context->getPage();
-    $this->params = $context->getParams();
-    $form = $context->getForm();
+    $this->_context = $context;
+    $total = $context->getTotal();
 
     return
-      $this->view->renderForm($form,
+      $this->view->renderForm($context->getForm(),
                               [$this->view->button((new Class_Entity())
                                                    ->setText($this->_('Rechercher'))
                                                    ->setImage($this->view->tagImg(Class_Admin_Skin::current()
@@ -43,48 +41,36 @@ class ZendAfi_View_Helper_Admin_SearchRendezVous extends ZendAfi_View_Helper_Bas
                                                                  'class' => 'search',
                                                                  'title' => $this->_('Lancer la recherche')]))]) .
 
-      $this->_tag('p', $this->view->_plural($this->total,
+      $this->_tag('p', $this->view->_plural($total,
                                             'Auncun rendez-vous trouvé',
                                             '%d rendez-vous trouvé',
                                             '%d rendez-vous trouvés',
-                                            $this->total)) .
+                                            $total)) .
       $this->_getTable();
   }
 
 
   protected function _getTable() {
-    $pager = $this->view->Pager($this->total,
+    $pager = $this->view->Pager($this->_context->getTotal(),
                                 20,
-                                $this->page,
-                                array_merge($this->params, ['page' => null]));
+                                $this->_context->getPage(),
+                                array_merge($this->_context->getParams(), ['page' => null]));
 
-    $description = (new Class_TableDescription('rendez-vous',
-                                               function($label, $attribute)
-                                               {
-                                                 return $this->_orderAnchor($label, $attribute);
-                                               }))
-      ->addColumn($this->_('Date'), ['attribute' => 'formatted_date',
-                                     'sort_attribute' => 'date'])
-      ->addColumn($this->_('Heure de début'), ['attribute' => 'formatted_begin_time',
-                                               'sort_attribute' => 'begin_time'])
-      ->addColumn($this->_('Heure de fin'), ['attribute' => 'formatted_end_time',
-                                             'sort_attribute' => 'end_time'])
-      ->addColumn($this->_('Lieu'), ['attribute' => 'location_label',
-                                     'sort_attribute' => 'location_id'])
-      ->addColumn($this->_('Agenda'),
-                  ['attribute' => 'agenda_label',
-                   'sort_attribute' => 'group_id'])
-      ->addRowAction(function($model) { return $this->view->renderPluginsActions($model); })
+    $description = (new Class_TableDescription_RendezVousSearch('rendez-vous',
+                                                                function($label, $attribute)
+                                                                {
+                                                                  return $this->_orderAnchor($label, $attribute);
+                                                                }))
       ->setSorterServer();
 
     return $pager
-      . $this->view->renderTable($description, $this->models)
+      . $this->view->renderTable($description, $this->_context->getModels())
       . $pager;
   }
 
 
   protected function _orderAnchor($label, $attribute) {
-    $order = $this->params['search_order'];
+    $order = $this->_context->getParams()['search_order'];
     $order_param = $attribute;
 
     if((0 === strpos($order, $attribute)) && (false === strpos($order, 'desc')))
@@ -94,12 +80,17 @@ class ZendAfi_View_Helper_Admin_SearchRendezVous extends ZendAfi_View_Helper_Bas
       ? str_replace(' ', '_', 'order_' . $order_param)
       : '';
 
-    return function($view) use($order_param, $label, $data_order) {
-      return $view->tagAnchor(array_merge(array_filter($this->params),
-                                          ['search_order' => $order_param,
-                                           'page' => null]),
-                             $label,
-                             ['data-order' => $data_order]);
-    };
+    $url = $this->view->url(['module' => 'admin',
+                             'controller' => $this->_context->getRequest()->getControllerName(),
+                             'action' => $this->_context->getRequest()->getActionName()],
+                            null, true)
+      . '?'
+      . http_build_query(array_merge($this->_context->getParams(),
+                                     ['search_order' => $order_param,
+                                      'page' => null]))
+      ;
+    $html = $this->view->tagAnchor($url, $label, ['data-order' => $data_order]);
+
+    return function() use($html) { return $html; };
   }
 }
\ No newline at end of file
diff --git a/library/ZendAfi/View/Helper/AlbumAudioJsPlayer.php b/library/ZendAfi/View/Helper/AlbumAudioJsPlayer.php
index aed11564219..9f2562ec3ef 100644
--- a/library/ZendAfi/View/Helper/AlbumAudioJsPlayer.php
+++ b/library/ZendAfi/View/Helper/AlbumAudioJsPlayer.php
@@ -124,7 +124,7 @@ $(function() {
     return $this->tag('a',
                       $this->tag('img',
                                  '',
-                                 ['src' => URL_SHARED_IMG.'/cc/'.$cc_icon,
+                                 ['src' => URL_SHARED_IMG . 'cc/'.$cc_icon,
                                   'height' => '15px',
                                   'style' => 'vertical-align:middle; margin-left: 10px']),
                       ['href' => 'https://creativecommons.org/licenses/'.strtolower($license).'/deed.fr',
diff --git a/library/ZendAfi/View/Helper/Button.php b/library/ZendAfi/View/Helper/Button.php
index f4c518a3e73..7add27273ed 100644
--- a/library/ZendAfi/View/Helper/Button.php
+++ b/library/ZendAfi/View/Helper/Button.php
@@ -43,10 +43,13 @@ class ZendAfi_View_Helper_Button extends ZendAfi_View_Helper_BaseHelper {
     $button->setAttribs(array_merge($button->getAttribs(),
                                     ['data-url' => $button->getUrl()]));
 
-    if(isset($button->getAttribs()['onclick']))
+    if (isset($button->getAttribs()['onclick']))
       return $this;
 
-    if($this->view->isPopup())
+    if ($button->getConfirm())
+      return $this->_defaultsWithConfirm($button);
+
+    if ($this->view->isPopup())
       return $this;
 
     $button->setAttribs(array_merge($button->getAttribs(),
@@ -56,6 +59,19 @@ class ZendAfi_View_Helper_Button extends ZendAfi_View_Helper_BaseHelper {
   }
 
 
+  protected function _defaultsWithConfirm($button) {
+    $action = $this->view->isPopup()
+      ? "opacDialogClose(); opacDialogFromUrl('" . $button->getUrl() ."');"
+      : "window.location.href='" . $button->getUrl() ."'";
+
+    $button->setAttribs(array_merge($button->getAttribs(),
+                                    ['onclick' => sprintf("if (confirm('%s')) { %s }; return false;",
+                                                          str_replace(['\'', '"'], '\\\'', $button->getConfirm()),
+                                                          $action)]));
+    return $this;
+  }
+
+
   protected function _setDefaultElement($button) {
     if(!$button->getElement())
       $button->setElement('button');
diff --git a/library/ZendAfi/View/Helper/ModeleFusion/Template.php b/library/ZendAfi/View/Helper/ModeleFusion/Template.php
index 81cb2038b82..3c8a1e7d43e 100644
--- a/library/ZendAfi/View/Helper/ModeleFusion/Template.php
+++ b/library/ZendAfi/View/Helper/ModeleFusion/Template.php
@@ -57,6 +57,9 @@ class ZendAfi_View_Helper_ModeleFusion_Template extends ZendAfi_View_Helper_Base
     if($template == Class_ModeleFusion::LOANS_TEMPLATE)
       return $this->view->ModeleFusion_Template_Loans();
 
+    if ($template == Class_ModeleFusion::RENDEZ_VOUS_NOTIFICATION_TEMPLATE)
+      return $this->view->ModeleFusion_Template_RendezVousNotification();
+
     return '';
   }
 
diff --git a/library/ZendAfi/View/Helper/ModeleFusion/Template/RendezVousNotification.php b/library/ZendAfi/View/Helper/ModeleFusion/Template/RendezVousNotification.php
new file mode 100644
index 00000000000..63497dc8868
--- /dev/null
+++ b/library/ZendAfi/View/Helper/ModeleFusion/Template/RendezVousNotification.php
@@ -0,0 +1,27 @@
+<?php
+/**
+ * Copyright (c) 2012-2017, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class ZendAfi_View_Helper_ModeleFusion_Template_RendezVousNotification extends ZendAfi_View_Helper_ModeleFusion_Template {
+  public function ModeleFusion_Template_RendezVousNotification() {
+    return Class_AdminVars::get('NOTIFICATION_TEMPLATE_RENDEZ_VOUS');
+  }
+}
\ No newline at end of file
diff --git a/library/ZendAfi/View/Helper/RenderEmptyEnabledTable.php b/library/ZendAfi/View/Helper/RenderEmptyEnabledTable.php
deleted file mode 100644
index 1a12461b0b9..00000000000
--- a/library/ZendAfi/View/Helper/RenderEmptyEnabledTable.php
+++ /dev/null
@@ -1,90 +0,0 @@
-<?php
-/**
- * Copyright (c) 2012-2017, Agence Française Informatique (AFI). All rights reserved.
- *
- * BOKEH is free software; you can redistribute it and/or modify
- * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
- * the Free Software Foundation.
- *
- * There are special exceptions to the terms and conditions of the AGPL as it
- * is applied to this software (see README file).
- *
- * BOKEH is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
- *
- * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
- * along with BOKEH; if not, write to the Free Software
- * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
- */
-
-
-class ZendAfi_View_Helper_RenderEmptyEnabledTable extends ZendAfi_View_Helper_RenderTable {
-  protected $_empty_message;
-
-  /**
-   * @param description Class_TableDescription
-   * @param grouped_models Class_TableDescription_Models or Array
-   */
-  public function renderEmptyEnabledTable($description, $grouped_models=[], $empty_messsage=null) {
-    $this->_empty_message = $empty_messsage ? $empty_messsage : $this->_('Pas de données');
-    $grouped_models = (is_array($grouped_models) || is_a($grouped_models, 'ArrayObject'))
-      ? new Class_TableDescription_Models($grouped_models)
-      : $grouped_models;
-
-
-    $classes = ['models'];
-
-    if($description->isSorterClient()) {
-      $classes [] = 'tablesorter';
-      Class_ScriptLoader::getInstance()->loadTableSorter($description->getPager());
-    }
-
-    $classes = array_merge($classes, $description->getClasses());
-
-    $pager = $this->_renderPager($description);
-
-    return
-      $pager
-      . $this->_tag('table',
-                  $this->_head($description)
-                  .$this->_body($description, $grouped_models),
-                  ['id' => $description->getId(),
-                   'class' => implode(' ', array_filter($classes))])
-      . $pager;
-  }
-
-
-  protected function _body($description, $grouped_models) {
-    return $this->view
-      ->renderTable_EmptyEnabledBody($description, $grouped_models, $this->_empty_message);
-  }
-}
-
-
-
-class ZendAfi_View_Helper_RenderTable_EmptyEnabledBody
-  extends ZendAfi_View_Helper_RenderTable_Body {
-
-  public function renderTable_EmptyEnabledBody($description, $grouped_models, $empty_message) {
-    $this->_description = $description;
-    $this->_html = $this->_renderEmptyRow($description, $empty_message, !$grouped_models->isEmpty());
-
-    $grouped_models->renderOn($this);
-    return $this->_tag('tbody', $this->_html);
-  }
-
-
-  protected function _renderEmptyRow($description, $empty_message, $has_row) {
-    $attribs = [];
-    if ($has_row)
-      $attribs['style'] = 'display:none;';
-
-    return $this->_tag('tr',
-                       $this->_tag('td', $empty_message,
-                                   ['colspan' => $description->numberOfColumns(),
-                                    'style' => 'text-align:center;']),
-                       $attribs);
-  }
-}
\ No newline at end of file
diff --git a/library/ZendAfi/View/Helper/RenderTable.php b/library/ZendAfi/View/Helper/RenderTable.php
index 8583c8db183..344cf9e13b9 100644
--- a/library/ZendAfi/View/Helper/RenderTable.php
+++ b/library/ZendAfi/View/Helper/RenderTable.php
@@ -26,19 +26,10 @@ class ZendAfi_View_Helper_RenderTable extends ZendAfi_View_Helper_BaseHelper {
    * @param grouped_models Class_TableDescription_Models or Array
    */
   public function renderTable($description, $grouped_models) {
-    if(!$grouped_models)
-      return '';
-
-    if(is_array($grouped_models) && (!array_filter($grouped_models)))
-      return '';
-
     $grouped_models = (is_array($grouped_models) || is_a($grouped_models, 'ArrayObject'))
       ? new Class_TableDescription_Models($grouped_models)
       : $grouped_models;
 
-    if($grouped_models->isEmpty())
-      return '';
-
     $classes = ['models'];
 
     if($description->isSorterClient()) {
@@ -56,7 +47,8 @@ class ZendAfi_View_Helper_RenderTable extends ZendAfi_View_Helper_BaseHelper {
                   $this->_head($description)
                   .$this->_body($description, $grouped_models),
                   ['id' => $description->getId(),
-                   'class' => implode(' ', array_filter($classes))])
+                   'class' => implode(' ', array_filter($classes)),
+                   'data-emptymessage' => $description->getEmptyMessage()])
       . $pager;
   }
 
@@ -152,9 +144,18 @@ class ZendAfi_View_Helper_RenderTable_Body extends ZendAfi_View_Helper_BaseHelpe
 
     $this->_html = '';
     $grouped_models->renderOn($this);
-    return $this->_tag('tbody',
-                       $this->_html);
+    if (!$this->_html)
+      $this->_html = $this->_emptyMessageRow($description);
+
+    return $this->_tag('tbody', $this->_html);
+  }
 
+
+  protected function _emptyMessageRow($description) {
+    return $this->_tag('tr',
+                       $this->_tag('td', $description->getEmptyMessage(),
+                                   ['colspan' => $description->numberOfColumns()]),
+                       ['class' => 'empty']);
   }
 
 
diff --git a/library/ZendAfi/View/Helper/RendezVous/PurgeButton.php b/library/ZendAfi/View/Helper/RendezVous/PurgeButton.php
new file mode 100644
index 00000000000..fd11ba79f1f
--- /dev/null
+++ b/library/ZendAfi/View/Helper/RendezVous/PurgeButton.php
@@ -0,0 +1,37 @@
+<?php
+/**
+ * Copyright (c) 2012-2019, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class ZendAfi_View_Helper_RendezVous_PurgeButton extends ZendAfi_View_Helper_BaseHelper{
+  public function rendezVous_PurgeButton($report, $label, $notification_type = null) {
+    if ($report->isEmpty())
+      return '';
+
+
+   return  $this->view->Button((new Class_Entity())
+                                ->setUrl($this->view->url(array_filter(['action' => 'notification-purge',
+                                                                  'type' => $notification_type])))
+                                 ->setText($label)
+                                 ->setImage($this->view->tagImg(Class_Admin_Skin::current()
+                                                          ->getIconUrl('buttons', 'delete')))
+                                 ->setConfirm($label . ' ?'));
+  }
+}
diff --git a/library/ZendAfi/View/Helper/TagSuccess.php b/library/ZendAfi/View/Helper/TagSuccess.php
new file mode 100644
index 00000000000..50e1d121d1c
--- /dev/null
+++ b/library/ZendAfi/View/Helper/TagSuccess.php
@@ -0,0 +1,30 @@
+<?php
+/**
+ * Copyright (c) 2012-2017, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class ZendAfi_View_Helper_TagSuccess extends ZendAfi_View_Helper_BaseHelper {
+  public function tagSuccess($message) {
+    return $this->_tag('p',
+                       $message,
+                       ['class' => 'success',
+                        'title' => $this->_('Succès : "%s"', $message)]);
+  }
+}
\ No newline at end of file
diff --git a/public/admin/css/global.css b/public/admin/css/global.css
index 32fbdea0b13..aa0c5b1bab3 100644
--- a/public/admin/css/global.css
+++ b/public/admin/css/global.css
@@ -414,6 +414,11 @@ div#permalink {
     font-weight:bold;
 }
 
+.success {
+    color: #59E625;
+    font-weight:bold;
+}
+
 .notice {
     color: #3366FF;
     font-weight:bold;
diff --git a/public/admin/images/picto/meeting_24.png b/public/admin/images/picto/meeting_24.png
new file mode 100644
index 0000000000000000000000000000000000000000..3cc2eb9c093af61adb9b9b525474ec01243af22a
GIT binary patch
literal 2134
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
D1Rk>`

literal 0
HcmV?d00001

diff --git a/public/admin/images/picto/meeting_48.png b/public/admin/images/picto/meeting_48.png
new file mode 100644
index 0000000000000000000000000000000000000000..defffa540637ee0b5a2d2be4de46c5f2254e47c5
GIT binary patch
literal 2336
zcmV+*3E%dKP)<h;3K|Lk000e1NJLTq002Y)002Y?1^@s6I1`hy0008xdQ@0+Qek%>
zaB^>EX>4U6ba`-PAZ2)IW&i+q+U=K7vg;-chTmC5mw+UM#B#8KneL#=?+@lUC%L(u
z>6t$CA!cH*EDP~}GD$-J>(3GXz(GYgBsI?^=ZGVfRJdZ`@j9-aVp`{Qq_pqRvpw85
z7$!k4*JbY8zrwDM2W<P(@oW!v?l`^!?HgYKWzNhPw4LM*Bz)OvUx&iipyqGO*&neL
z;&y($P6jcW!U&DH3rW1r*Gz&eq)0`da3(RH7Q&d9h2@QY;g;@=fcNM{zj$uDJmk0n
zeJv}$=(O#m@AX?Meb0#ao|(UAWO7gOi+*D?AERHZt0@P_mG`2)ma;~0JRqm6$1<AX
zqZr$21<hlPCxmZU>Jj3Bi$V;o2PJA$X;5GNNheZF+!=B+^U1Po4*3h83rd=e23r81
zCJquO$O+7R=R#}VGUvl&j+q<5lL^5HPdv5oYWQh;3N*$nbE4?CR>+HI5h9IAZl2)<
zAT+k7y7Z-Rb@BC>U<Cwq$?RBQjmH(@P<qssEY1=&z%|9I<jL9&0EsZSA{kV4qL@jP
zGDTxHBIxMgQ;|7JoevNo4ayB!auUga<2>iaGe*mol`k{47l2U7VnBlz2(VJpl<~ef
zN~i`EO{$vJ)U{~Ml2g{4v*o!@RTE35md(tqSatE_>e<cRi`T+Mu#IUbxmfX1O0Af*
zrixe<{uPFU4>{7IM?UQEqa5`_`JCz0GoN<)S<c$HNy8QpT5i_7l~y}-lu}P!d+ye~
zmtF^IZOBMNM;<nOlu>VLo9Z*QS91SMjW#u2pr)TasKIRZ6@un=qLUejaUu}6NdN`S
zlUa01i6^<qEEbj^qKqtc!f7;#fne&yI_PHiAop8t0qXu+Zv2j1nCSisa$%zTM($T`
zZ>Y7pwp&?%=359&u|o=8KR9%+>~(%G{iqK=eee_L6X+A@6X+A@6X+A@{~2iD$3wy2
zslNd;<dLKYZjn*|00D$)LqkwWLqi}?Qcp%nOho_yc$|HaJxIe)6opUIN>wUS>>%Qh
zp*mR*6>-!m6rn<>6<T#LdFdZCX-HCB90k{cgFlN^2N!2u9b5%L@CU@j)k)DsO1!Tr
zw21M+<$av@&f&iM0HI!HniUuaG~G5+iMW`_u8P4|1Q13KRaj=0F(*k$c&@K|`1pPo
z<5}MK{#<=(&SHR1B%Wo4X%lY{PjA`==Y8S`E6OVIIq{fD4H7?cU2*x1bD_xs&y1Mq
z)I4#7SS)m~(!s1~YQ$5-QB~6^U&y$ua^B*sm8-1PCx2lmr>`t?ooW~fEMf@~L@21D
zgfeWzXxB-xkfQUrhkvN)m&m1%s{}@l1yrCxZu-Ih;P-5;{N#k26p8~~FOKsu0t9z~
zM$K`)j~%CR0{EYSE4}Tn)Pb2#(raxkdIa=u0~gnAP1yr3cYwhsLpJ56<fjtydEotw
zz9|a~+ycF8?%Z1EIDG)pG^^wdaBv8W7ASk&<K5kzbNlyBYkofmq;h;|i~}wJ000JJ
zOGiWi{{a60|De66lK=n!32;bRa{vGf6951U69E94oEQKA00(qQO+^Re1Q7`s6xJ~!
z9RL6XE=fc|RCwC$n_p;-Q5?rV`y*!ljZBC_Dco@(#+ouNrX-6L%7r4A=1-&+ErbjA
zN(p0j!^JjIq>ZbUap8tU*hbC2{AqaS;{Cnt>^RTg_kG^?dFFiUwC9}XoagsE-<{u|
z^E>AS3kwU2{9+<-2lxV<{SV}NVjnQ1l*OY-ZgGe))3thKo(Rc{++u41EFqQ<OGp+d
z^|-$Ts0GFXPk}a<zI%Yl5xk>cxdXs0pg$_I&HyRJ&0UoCM$P@zNc&5zJy#`&+^;}=
z6e_bDXaI_owlVVeyVm!cc0bn$B6k9C1y~ftG}Zr8J#udz@Lt-w<b^7M-x1A_Fmli5
znvg+<m)j}#Cjm>9|IWpT$OtR<POb@Yda)nCexOW=7NaI3jNE(qA|z9O0`DSJlWgUN
zCZsgvxUs;@0)z>X%e8@-CBzbva~tvz=uW`5tj!l8J+hvOJBF;vSQ`=#8=_+sP~~#}
zv~oQc*yhr=8F;PK54rU10y+z+k4{lvqeqmeqQ;|dO@IiwF^hM$Dq{&Lpbd#DYPHzu
zzbE9bRT*nT3L6GTmVL?+B|tTB4tPX*+hI^jzqFkNR!iSeOGp`TnBhdTpVAK;WH|LW
zY6#f^ydiw*@ijj8Yr=S$`BMB?`wC#0OW$q}7g-E6$;>d}kIaHzsm~&vfYw!I4$uOe
z1dfs3fJ;Q^u9I=yVO$%qT?p4vA@|xH#$F5KOyWSuNkjj~Vl4flTJkyibDEMy$QDE2
z7GQh`6)y*FXwOOko0CFFnZbazFmj(6sc!}LlHSvGLlMwwFtp>6K*(W(nwVzB5v5Ou
z&zbT>d-j326H;P`Gpb#@S*Fh~pWmgwPoM6Q;ugABG4ixwJMh3IVhtKT+y`ze^%=m5
zY~zqi0aAHh8T%fszEP`Rjeztkz<Nz)XErJmB_1hqe>=n(8QgPGRX0qPfG#txE6ysj
zK)LSECeh=90kKminT~{{G&h(psE77?Bb4)2xt`*O5Ou(EqC4+V6H&@S^**<K#3`PH
zTquyrd{(Y!<=}axz&eJHWMwd0x&GvbkW;zJQ3k|w<+{?ts6Gqu+z-n2G)De#;JF{S
zJ1DMc7qB77lnV&mXg*Zpf)bz(xa=_YsHnw4m*=O%8?`EJb=pLD80XaGYRv|;M<Am^
zlTj6CLQ2Hl`U%~`V2vSkkA0&3ApaiREziMo%lY>}asvrKnJmrK7r$Xx{s)0+`Tr`&
z^1sliXXFnLhUA0FL(Ys=LxN}%2}6@1(0URum|KJS-2QPAh+@(SVN(R!4(yPHnvQ7_
zh;=xOZ6?7xmYHpcl*7mUA$`=lTgp3aC9A8+2}a5G?Pi0a4N_hu(PnsI9O)crUjQQd
z#I^eNCPfKVqm`2BS}FOA3S}wqLXti=CE=99!otELPxuRiW5$KAc$88A0000<MNUMn
GLSTZJzgMLI

literal 0
HcmV?d00001

diff --git a/public/admin/js/user_selection/test.js b/public/admin/js/user_selection/test.js
index f072e012f63..1b7b9c52482 100644
--- a/public/admin/js/user_selection/test.js
+++ b/public/admin/js/user_selection/test.js
@@ -32,17 +32,16 @@ window.opacDialogClose = function() {};
 
 var empty_content = '<input type="hidden" name="recipients" id="recipients" value="">\
 <button></button>\
-<table id="current_user_selection_recipients">\
+<table id="current_user_selection_recipients" data-emptymessage="Aucun destinataire">\
   <tbody>\
-    <tr><td colspan="7">Aucun destinataire</td></tr>\
+    <tr class="empty"><td colspan="7">Aucun destinataire</td></tr>\
   </tbody>\
 </table>';
 
 var not_empty_content = '<input type="hidden" name="recipients" id="recipients" value="888">\
 <button></button>\
-<table id="current_user_selection_recipients">\
+<table id="current_user_selection_recipients" data-emptymessage="Aucun destinataire">\
   <tbody>\
-    <tr style="display:none;"><td colspan="7">Aucun destinataire</td></tr>\
     <tr>\
       <td>Romain</td>\
       <td>Gary</td>\
@@ -97,20 +96,20 @@ function get_empty_insertion_point() {
 test('simple add', function () {
   var insertion_point = get_empty_insertion_point();
   
+  equal($(insertion_point.find("table")[0]).attr('data-emptymessage'), "Aucun destinataire", "Empty message data is present");
   equal($._data(insertion_point.find("button")[0],'events').click.length, 1,
         'button is clickable in ' + insertion_point.html());
   
   equal(on_open_listeners.length, 1, 'opacDialogRegisterOnOpen has been called');
 
   $('a.user_add_action').first().click();
-
+  
   equal(insertion_point.find('input[id="recipients"]').val(), 999, "value has been set by opacDialogRegisterOnOpen") ;
 
-  equal($(insertion_point.find("td")[0]).is(':visible'), false, "First row is hidden");
-  equal($(insertion_point.find("td")[1]).html(), "Emile", "Firstname has been updated");
-  equal($(insertion_point.find("td")[2]).html(), "Ajar", "Name has been updated");
-  equal($(insertion_point.find("td")[3]).html(), "Elbe", "Library has been updated");
-  equal($(insertion_point.find("td")[4]).find('img[src="/delete.png"]').length, 1, "icon delete is loaded");
+  equal($(insertion_point.find("td")[0]).html(), "Emile", "Firstname has been updated");
+  equal($(insertion_point.find("td")[1]).html(), "Ajar", "Name has been updated");
+  equal($(insertion_point.find("td")[2]).html(), "Elbe", "Library has been updated");
+  equal($(insertion_point.find("td")[3]).find('img[src="/delete.png"]').length, 1, "icon delete is loaded");
   equal($._data(insertion_point.find("div.actions a")[0],'events').click.length, 1,
         'remove action is clickable in ' + insertion_point.html());
 });
@@ -121,7 +120,7 @@ test('add twice', function () {
   $('a.user_add_action').first().click();
   $('a.user_add_action').first().click();
 
-  equal(insertion_point.find("tr").length, 2,
+  equal(insertion_point.find("tr").length, 1,
 	'When double click on a user, it doesnot add a line');
   equal($(insertion_point.find("input:hidden")).val(),"999",
 	"value has no been added in input");
@@ -147,15 +146,14 @@ test('multiuser', function () {
 
   $('a.user_add_action').click();
 
-  equal($(insertion_point.find('tr')).length, 3, '3 lines in table as expected');
-  equal($(insertion_point.find('tr')[1]).find('td:first-child').html(), "Emile", "Emile has been selected");
-  equal($(insertion_point.find('tr')[2]).find('td:first-child').html(), "Romain", "Romain has been selected");
-  equal($(insertion_point.find("tr").first()).is(':visible'), false, "First row is NOT visible");
+  equal($(insertion_point.find('tr')).length, 2, '2 lines in table as expected');
+  equal($(insertion_point.find('tr')[0]).find('td:first-child').html(), "Emile", "Emile has been selected");
+  equal($(insertion_point.find('tr')[1]).find('td:first-child').html(), "Romain", "Romain has been selected");
   equal(insertion_point.find('input[id="recipients"]').val(), '999-888', "All the recipients id are stored in input recipients");
 
   insertion_point.find("div.actions a").last().click();
 
-  equal($(insertion_point.find('tr')).length,2, '2 lines in table as expected after deletion');
+  equal($(insertion_point.find('tr')).length, 1, '1 line in table as expected after deletion');
   equal(insertion_point.find('input[id="recipients"]').val(), '999', 'recipients contains only 999'); 
 });
 
diff --git a/public/admin/js/user_selection/user_selection.js b/public/admin/js/user_selection/user_selection.js
index f6e9be5c12d..a569d8c3948 100644
--- a/public/admin/js/user_selection/user_selection.js
+++ b/public/admin/js/user_selection/user_selection.js
@@ -57,13 +57,15 @@
       ids.push(id);
       set_current_ids(ids);
 
-      var row = $(this).parents('tr').first().clone();
+      var row = $(this).closest('tr').clone();
       
       get_table_rows().parent().append(row);
-      get_table_rows().first().hide();
+      get_table_rows().remove('.empty');
 
       prepare_row_action_delete(row);
+      update_table_sorter();
     }
+
     
     function del_user_row(e) {
       e.preventDefault();
@@ -74,10 +76,13 @@
       var ids = get_current_ids();
       set_current_ids(ids.filter(function(item) { return item != id; }));
 
+      var table = $(this).closest('table');
       $(this).parents('tr').first().remove();
+      update_table_sorter();
+      if (get_table_rows().length > 0)
+	return;
 
-      if (get_table_rows().length == 1)
-        get_table_rows().first().show();
+      table.find('tbody').append($('<tr class="empty"><td colspan="' + table.find('th').length + '">' + table.data('emptymessage') + '</td></tr>'));
     }
 
 
@@ -85,6 +90,11 @@
       return self.find('#' + table_id + ' tbody tr');
     }
 
+
+    function update_table_sorter() {
+      self.find('#' + table_id).trigger('update');
+    }
+
     
     function getNomComplet(node) {
       var myline = $(node).parents('tr').first();
diff --git a/public/admin/skins/bokeh72/colors.css b/public/admin/skins/bokeh72/colors.css
index 7176818a1f4..9bfa97c07f1 100644
--- a/public/admin/skins/bokeh72/colors.css
+++ b/public/admin/skins/bokeh72/colors.css
@@ -18,7 +18,10 @@
 
     --error-text: #F00;
     --error-background: #F00;
+    --success-text: #48D514;
     --success-background: #59E625;
+    --notice-text: #3366FF;
+    --warning-text: #FF9900;
 
     --bokeh-event: #0050C7;
     --bokeh-event-highlight: #6AA5FF;
diff --git a/public/admin/skins/bokeh72/config.json b/public/admin/skins/bokeh72/config.json
index 27476d09ee8..41a3cc44d57 100644
--- a/public/admin/skins/bokeh72/config.json
+++ b/public/admin/skins/bokeh72/config.json
@@ -66,7 +66,8 @@
 
     "books": "../../images/picto/books.png",
     "tag": "../../images/picto/tag_blue.png",
-    "suggestion": "../../images/picto/traductions_16.png"
+    "suggestion": "../../images/picto/traductions_16.png",
+    "meeting": "../../images/picto/meeting_24.png"
   },
 
   "actions":
diff --git a/public/admin/skins/bokeh74/colors.css b/public/admin/skins/bokeh74/colors.css
index 2c4ff7cfc2e..51d6a32b719 100644
--- a/public/admin/skins/bokeh74/colors.css
+++ b/public/admin/skins/bokeh74/colors.css
@@ -18,6 +18,7 @@
 
     --error-text: #F00;
     --error-background: #F00;
+    --success-text: #48D514;
     --success-background: #59E625;
     --notice-text: #3366FF;
     --warning-text: #FF9900;
diff --git a/public/admin/skins/bokeh74/config.json b/public/admin/skins/bokeh74/config.json
index 4a8dc9fa2f2..01e9f770ac8 100644
--- a/public/admin/skins/bokeh74/config.json
+++ b/public/admin/skins/bokeh74/config.json
@@ -69,7 +69,8 @@
 
     "books": "icons/menu/books_24.png",
     "tag": "icons/menu/tag_24.png",
-    "suggestion": "icons/menu/suggestion_achat_24.png"
+    "suggestion": "icons/menu/suggestion_achat_24.png",
+    "meeting": "icons/menu/meeting_24.png"
   },
 
   "actions":
diff --git a/public/admin/skins/bokeh74/global.css b/public/admin/skins/bokeh74/global.css
index 7ff73d51c06..c9fff349496 100755
--- a/public/admin/skins/bokeh74/global.css
+++ b/public/admin/skins/bokeh74/global.css
@@ -17,11 +17,16 @@ body .error * {
 }
 
 body .notice {
-    color: var(--notice-text)
+    color: var(--notice-text);
+}
+
+body .success {
+    font-weight: bold;
+    color: var(--success-text);
 }
 
 body .warning {
-    color: var(--warning-text)
+    color: var(--warning-text);
 }
 
 .modules a,
@@ -224,7 +229,7 @@ a {
     font-weight: bold;
 }
 
-td > p.error {
+td > p.error, td > p.notice, td > p.success, td > p.warning {
     padding: 0;
     margin: 0;
 }
diff --git a/public/admin/skins/bokeh74/icons/menu/meeting_24.png b/public/admin/skins/bokeh74/icons/menu/meeting_24.png
new file mode 100644
index 0000000000000000000000000000000000000000..3cc2eb9c093af61adb9b9b525474ec01243af22a
GIT binary patch
literal 2134
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
D1Rk>`

literal 0
HcmV?d00001

diff --git a/public/admin/skins/bokeh74/icons/menu/meeting_48.png b/public/admin/skins/bokeh74/icons/menu/meeting_48.png
new file mode 100644
index 0000000000000000000000000000000000000000..defffa540637ee0b5a2d2be4de46c5f2254e47c5
GIT binary patch
literal 2336
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
GLSTZJzgMLI

literal 0
HcmV?d00001

diff --git a/public/admin/skins/retro/colors.css b/public/admin/skins/retro/colors.css
index 2c4ff7cfc2e..51d6a32b719 100644
--- a/public/admin/skins/retro/colors.css
+++ b/public/admin/skins/retro/colors.css
@@ -18,6 +18,7 @@
 
     --error-text: #F00;
     --error-background: #F00;
+    --success-text: #48D514;
     --success-background: #59E625;
     --notice-text: #3366FF;
     --warning-text: #FF9900;
diff --git a/public/admin/skins/retro/config.json b/public/admin/skins/retro/config.json
index d3a4ea1c3e0..140ff78b5c2 100644
--- a/public/admin/skins/retro/config.json
+++ b/public/admin/skins/retro/config.json
@@ -69,7 +69,8 @@
 
     "books": "icons/menu/books_24.png",
     "tag": "icons/menu/tag_24.png",
-    "suggestion": "icons/menu/suggestion_achat_24.png"
+    "suggestion": "icons/menu/suggestion_achat_24.png",
+    "meeting": "icons/menu/meeting_24.png"
   },
 
   "actions":
diff --git a/public/admin/skins/retro/global.css b/public/admin/skins/retro/global.css
index 34f03321a49..255ed3fc694 100755
--- a/public/admin/skins/retro/global.css
+++ b/public/admin/skins/retro/global.css
@@ -809,11 +809,15 @@ body .error * {
 }
 
 body .notice {
-    color: var(--notice-text)
+    color: var(--notice-text);
+}
+
+body .success {
+    color: var(--success-text);
 }
 
 body .warning {
-    color: var(--warning-text)
+    color: var(--warning-text);
 }
 
 .toggle_video {
diff --git a/public/admin/skins/retro/icons/menu/meeting_24.png b/public/admin/skins/retro/icons/menu/meeting_24.png
new file mode 100644
index 0000000000000000000000000000000000000000..3cc2eb9c093af61adb9b9b525474ec01243af22a
GIT binary patch
literal 2134
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
D1Rk>`

literal 0
HcmV?d00001

diff --git a/public/admin/skins/retro/icons/menu/meeting_48.png b/public/admin/skins/retro/icons/menu/meeting_48.png
new file mode 100644
index 0000000000000000000000000000000000000000..defffa540637ee0b5a2d2be4de46c5f2254e47c5
GIT binary patch
literal 2336
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
GLSTZJzgMLI

literal 0
HcmV?d00001

diff --git a/tests/application/modules/admin/controllers/ErrorControllerTest.php b/tests/application/modules/admin/controllers/ErrorControllerTest.php
index 061be8843e8..159e8443bab 100644
--- a/tests/application/modules/admin/controllers/ErrorControllerTest.php
+++ b/tests/application/modules/admin/controllers/ErrorControllerTest.php
@@ -43,4 +43,9 @@ class Admin_ErrorControllerTest extends AbstractControllerTestCase {
   public function actionShouldBeError() {
     $this->assertAction('error');
   }
+
+  /** @test */
+  public function httpStatusShouldError500() {
+    $this->assertResponseCode(500);
+  }
 }
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
index 985f82c0234..0fc5bc15fe1 100644
--- a/tests/bootstrap.php
+++ b/tests/bootstrap.php
@@ -53,7 +53,7 @@ $parts = array_reverse($parts);
 
 defineConstant("BASE_URL", "/" . $parts[1]);
 defineConstant("URL_IMG", BASE_URL . "/public/opac/skins/original/images/");
-defineConstant("URL_SHARED_IMG", BASE_URL . "/public/opac/images");
+defineConstant("URL_SHARED_IMG", BASE_URL . "/public/opac/images/");
 
 $_SERVER['SERVER_NAME'] = 'localhost';
 $_SERVER['SERVER_PORT'] = '80';
diff --git a/tests/db/UpgradeDBTest.php b/tests/db/UpgradeDBTest.php
index c2eeb498dd2..ef50e8f87ee 100644
--- a/tests/db/UpgradeDBTest.php
+++ b/tests/db/UpgradeDBTest.php
@@ -153,6 +153,25 @@ abstract class UpgradeDBTestCase extends PHPUnit_Framework_TestCase {
   }
 
 
+  protected function assertPrimary($table, $name) {
+    $keys = [];
+    try {
+      foreach($this->query(sprintf('show keys in `%s` where Key_name="PRIMARY"', $table))->fetchAll() as $row) {
+        if ($name == $row['Column_name'])
+          return true;
+
+        $keys[] = $row;
+      }
+    } catch (Exception $e) {}
+
+    $message = sprintf('Failed asserting that "%s" table CONTAINS a PRIMARY on column "%s".',
+                       $table, $name)
+      . "\n" .  json_encode($keys, JSON_PRETTY_PRINT);
+
+    $this->fail($message);
+  }
+
+
   protected function assertNotIndex($table, $name, $message = '') {
     $message = $message
       ? $message
@@ -2585,3 +2604,56 @@ class UpgradeDB_369_Test extends UpgradeDBTestCase {
     $this->assertIndex('rendez_vous', $key);
   }
 }
+
+
+
+class UpgradeDB_370_Test extends UpgradeDBTestCase {
+  public function prepare() {
+    $this->dropTable('rendez_vous_user_notification');
+  }
+
+
+  /** @test */
+  public function tableRendezVousShouldExists() {
+    $this->assertTable('rendez_vous_user_notification');
+  }
+
+
+  public function notNullableFields() {
+    return [['rendez_vous_id'], ['user_id'], ['id'],['type']];
+  }
+
+
+  /** @test @dataProvider notNullableFields */
+  public function notNullableFieldShouldExists($field) {
+    $this->assertFieldNotNullable('rendez_vous_user_notification', $field);
+  }
+
+
+  public function nullableFields() {
+    return [['created_at'], ['status'], ['error']];
+  }
+
+
+  /** @test @dataProvider nullableFields */
+  public function nullableFieldShouldExists($field) {
+    $this->assertFieldNullable('rendez_vous_user_notification', $field);
+  }
+
+
+  public function keys() {
+    return [['rendez_vous_id'], ['user_id'], ['status'], ['type'],['created_at']];
+  }
+
+
+  /** @test @dataProvider keys */
+  public function keyShouldExists($key) {
+    $this->assertIndex('rendez_vous_user_notification', $key);
+  }
+
+
+  /** @test */
+  public function shouldHavePrimaryId() {
+    $this->assertPrimary('rendez_vous_user_notification', 'id');
+  }
+}
diff --git a/tests/scenarios/Jamendo/JamendoTest.php b/tests/scenarios/Jamendo/JamendoTest.php
index 6a82ec26f6c..a98d791003b 100644
--- a/tests/scenarios/Jamendo/JamendoTest.php
+++ b/tests/scenarios/Jamendo/JamendoTest.php
@@ -317,7 +317,7 @@ class JamendoRenderAlbumInnefectiveEveningTest extends ViewHelperTestCase {
     $album  = (new Class_WebService_BibNumerique_Jamendo_Album($json->results[2]))->import();
 
     $helper = new ZendAfi_View_Helper_RenderAlbum();
-    $helper->setView(new ZendAfi_Controller_Action_Helper_View());
+    $helper->setView($this->view);
     $this->_html = $helper->renderAlbum($album);
   }
 
diff --git a/tests/scenarios/RendezVous/RendezVousAbonneControllerTest.php b/tests/scenarios/RendezVous/RendezVousAbonneControllerTest.php
index f1a3f482c82..311af10fd6e 100644
--- a/tests/scenarios/RendezVous/RendezVousAbonneControllerTest.php
+++ b/tests/scenarios/RendezVous/RendezVousAbonneControllerTest.php
@@ -116,7 +116,7 @@ class RendezVousAbonneControllerEnabledTest extends RendezVousAbonneControllerTe
   /** @test */
   public function rendezVousActionShouldDisplayRendezVous() {
     $this->dispatch('/opac/abonne/rendez-vous');
-    $this->assertXPathContentContains('//td', 'mardi 19 mars', $this->_response->getBody());
+    $this->assertXPathContentContains('//td', 'mar. 19 mars 2019');
     $this->assertXPathContentContains('//td', '09h15');
     $this->assertXPathContentContains('//td', '10h30');
     $this->assertXPathContentContains('//td', 'Bellevue');
diff --git a/tests/scenarios/RendezVous/RendezVousAdminTest.php b/tests/scenarios/RendezVous/RendezVousAdminTest.php
index a4cabb04877..272fcff3d7c 100644
--- a/tests/scenarios/RendezVous/RendezVousAdminTest.php
+++ b/tests/scenarios/RendezVous/RendezVousAdminTest.php
@@ -20,7 +20,10 @@
  */
 
 abstract class RendezVousAdminTestCase extends Admin_AbstractControllerTestCase {
-  protected $_storm_default_to_volatile = true;
+  protected
+    $_storm_default_to_volatile = true,
+    $_agenda,
+    $_mock_transport;
 
   public function setUp() {
     parent::setUp();
@@ -33,6 +36,8 @@ abstract class RendezVousAdminTestCase extends Admin_AbstractControllerTestCase
     $user = $this->fixture('Class_Users',
                            ['id' => 34,
                             'login' => 'adminportail',
+                            'nom' => 'KENOBI',
+                            'prenom' => 'Obiwan',
                             'password' => 's3cr3t \o/',
                             'role_level' => ZendAfi_Acl_AdminControllerRoles::MODO_PORTAIL,
                             'user_groups' => [$group]]);
@@ -40,13 +45,31 @@ abstract class RendezVousAdminTestCase extends Admin_AbstractControllerTestCase
 
     ZendAfi_Auth::getInstance()->logUser($user);
 
-    $this->fixture('Class_UserGroup', ['id' => 43,
-                                       'model_class'=> 'Class_RendezVous',
-                                       'libelle' => "MonSuperAgenda"]);
+    $palpa = $this->fixture('Class_Users',
+                            ['id' => 999,
+                             'login' => 'PALPATIN',
+                             'prenom' => 'Jean-Marc',
+                             'password' => 'TheOne',
+                             'mail' => 'palpa@deathstar.gal',
+                             'role_level' => ZendAfi_Acl_AdminControllerRoles::INVITE]);
+
+    $ethiopian = $this->fixture('Class_Users',
+                                ['id' => 737,
+                                 'login' => 'ETHIO',
+                                 'prenom' => 'pian',
+                                 'password' => 'RulezH4x0r',
+                                 'mail' => '',
+                                 'role_level' => ZendAfi_Acl_AdminControllerRoles::INVITE]);
+
+    $this->_agenda = $this->fixture('Class_UserGroup',
+                                    ['id' => 43,
+                                     'model_class'=> 'Class_RendezVous',
+                                     'libelle' => 'MonSuperAgenda',
+                                     'users' => [$palpa, $ethiopian]]);
 
     $this->fixture('Class_RendezVous',
                    ['id' => 4,
-                    'agenda' => Class_UserGroup::find(43),
+                    'agenda' => $this->_agenda,
                     'location' => $this->fixture('Class_Lieu',
                                                  ['id' => 8,
                                                   'libelle' => 'Bellevue']),
@@ -54,6 +77,70 @@ abstract class RendezVousAdminTestCase extends Admin_AbstractControllerTestCase
                     'begin_time' => '09:15',
                     'end_time' => '10:30',
                     'comment' => 'with Arnaud']);
+
+    $this->fixture('Class_RendezVous',
+                   ['id' => 5,
+                    'agenda' => $this->_agenda,
+                    'location' => $this->fixture('Class_Lieu',
+                                                 ['id' => 8,
+                                                  'libelle' => 'Bellevue']),
+                    'date' => '2019-04-09',
+                    'begin_time' => '09:15',
+                    'end_time' => '10:30',
+                    'comment' => 'with Arnaud']);
+
+    $this->fixture('Class_RendezVous_UserNotification',
+                   ['id' => 88,
+                    'rendez_vous' => Class_RendezVous::find(5),
+                    'user' => $palpa,
+                    'type' => 'Manual',
+                    'created_at' => '2018-12-03 08:34:33',
+                    'status' => 'sent']);
+
+    $this->fixture('Class_RendezVous_UserNotification',
+                   ['id' => 89,
+                    'rendez_vous' => Class_RendezVous::find(5),
+                    'user' => $palpa,
+                    'type' => 'Manual',
+                    'created_at' => '2018-12-04 08:34:33',
+                    'status' => 'error',
+                    'error' => 'It failed!!']);
+
+    $this->fixture('Class_RendezVous_UserNotification',
+                   ['id' => 91,
+                    'rendez_vous' => Class_RendezVous::find(5),
+                    'user' => $palpa,
+                    'type' => 'Batch',
+                    'created_at' => '2018-12-05 08:34:33',
+                    'status' => 'sent',
+                    'error' => '']);
+
+    $this->fixture('Class_RendezVous_UserNotification',
+                   ['id' => 90,
+                    'rendez_vous' => Class_RendezVous::find(5),
+                    'user' => $ethiopian,
+                    'type' => 'Manual',
+                    'created_at' => '2018-12-04 08:34:33',
+                    'status' => 'nomail',
+                    'error' => 'It failed!!']);
+
+    $this->fixture('Class_RendezVous_UserNotification',
+                   ['id' => 92,
+                    'rendez_vous' => Class_RendezVous::find(5),
+                    'user' => null,
+                    'type' => 'Manual',
+                    'created_at' => '2018-12-04 08:34:33',
+                    'status' => 'pouet',
+                    'error' => 'It failed!!']);
+
+    $this->_mock_transport = new MockMailTransport();
+    Zend_Mail::setDefaultTransport($this->_mock_transport);
+  }
+
+
+  public function tearDown(){
+    $this->_mock_transport =null;
+    parent::tearDown();
   }
 }
 
@@ -104,7 +191,7 @@ class RendezVousAdminAddActionTest extends RendezVousAdminTestCase {
   public function begintimeShouldBePresentStartAt8EndAt18() {
     $this->assertXPath('//select[@name="begin_time"]//option[1][@value="08:00"]');
     $this->assertXPath('//select[@name="begin_time"]//option[last()][@value="18:00"]');
-  }
+ }
 
 
   /** @test */
@@ -136,7 +223,7 @@ class RendezVousAdminAddActionInvalidPostTest extends RendezVousAdminTestCase {
 
   /** @test */
   public function dateShouldHaveError() {
-    $this->assertXPathContentContains('//li', 'La date doit être au forma JJ/MM/AAAA');
+    $this->assertXPathContentContains('//li', 'La date doit être au format JJ/MM/AAAA',$this->_response->getBody());
   }
 
 
@@ -166,13 +253,13 @@ class RendezVousAdminAddActionValidPostTest extends RendezVousAdminTestCase {
                          'location_id' => 0,
                          'comment' => 'My super commen']);
 
-    $this->_model = Class_RendezVous::find(5);
+    $this->_model = Class_RendezVous::find(6);
   }
 
 
   /** @test */
   public function shouldRedirectToEdit() {
-    $this->assertRedirectContains('/admin/rendez-vous/edit/group_id/43/id/5');
+    $this->assertRedirectContains('/admin/rendez-vous/edit/group_id/43/id/6');
   }
 
 
@@ -190,7 +277,7 @@ class RendezVousAdminAddActionValidPostTest extends RendezVousAdminTestCase {
 
   /** @test */
   public function libelleShouldBeBeautiful() {
-    $this->assertEquals('Le mardi 19 mars de 14h15 à 15h30', $this->_model->getLibelle());
+    $this->assertEquals('Le mar. 19 mars 2019 de 14h15 à 15h30', $this->_model->getLibelle());
   }
 }
 
@@ -226,7 +313,7 @@ class RendezVousAdminIndexActionTest extends RendezVousAdminTestCase {
 
   /** @test */
   public function dateShouldBePresent() {
-    $this->assertXPathContentContains('//table[@id="rendez-vous"]//td', '2019-03-19');
+    $this->assertXPathContentContains('//table[@id="rendez-vous"]//td', 'mar. 19 mars 2019');
   }
 
 
@@ -248,6 +335,33 @@ class RendezVousAdminIndexActionTest extends RendezVousAdminTestCase {
   }
 
 
+  /** @test */
+  public function notificationStatusNoneShouldBePresent() {
+    $this->assertXPathContentContains('//table[@id="rendez-vous"]//td', 'Aucun');
+  }
+
+
+  /** @test */
+  public function notificationStatusShouldContainsGlobalStatus() {
+    $this->assertXPathContentContains('//table[@id="rendez-vous"]//td', 'Manuelles : Envoyées à 1/4');
+    $this->assertXPathContentContains('//table[@id="rendez-vous"]//td', 'Automatiques : Envoyées à 1/1');
+  }
+
+
+  /** @test */
+  public function notificationStatusShouldContains1ParticipantWithoutMail() {
+    $this->assertXPathContentContains('//table[@id="rendez-vous"]//td',
+                                      '1 participant.e.s sans mail');
+  }
+
+
+  /** @test */
+  public function notificationStatusShouldContains1erreur() {
+    $this->assertXPathContentContains('//table[@id="rendez-vous"]//td',
+                                      '1 erreur');
+  }
+
+
   /** @test */
   public function editLinkShouldBePresent() {
     $this->assertXPath('//a[contains(@href, "/admin/rendez-vous/edit/group_id/43/id/4")]');
@@ -310,6 +424,54 @@ class RendezVousAdminEditActionTest extends RendezVousAdminTestCase {
   public function commentShouldBeWithArnaud() {
     $this->assertXPathContentContains('//textarea[@name="comment"]', 'with Arnaud');
   }
+
+
+  /** @test */
+  public function actionNotifyShouldBePresent() {
+    $this->assertXPath('//a[contains(@href,"rendez-vous/notify/group_id/43/id/4")]');
+  }
+}
+
+
+
+class RendezVousAdminNotifyActionTest extends RendezVousAdminTestCase {
+  public function setUp(){
+    parent::setUp();
+    $_SERVER['HTTP_REFERER'] = '/admin/rendez-vous/group_id/43';
+    $this->dispatch('/admin/rendez-vous/notify/group_id/43/id/4');
+  }
+
+
+  public function tearDown() {
+    unset($_SERVER['HTTP_REFERER']);
+    parent::tearDown();
+  }
+
+
+  /** @test */
+  public function shouldRedirectToReferer() {
+    $this->assertRedirectTo($_SERVER['HTTP_REFERER']);
+  }
+
+
+  /** @test */
+  public function manualUserNotificationShouldBeCreatedAndSent() {
+    $notifs = new Storm_Model_Collection(Class_RendezVous::find(4)->getNotifications());
+    $count_sent = $notifs->select('isManual')
+                         ->select('isSent')
+                         ->count();
+    $this->assertEquals(1, $count_sent);
+    $count_nomail = $notifs->select('isManual')
+                         ->select('isNomail')
+                         ->count();
+    $this->assertEquals(1, $count_nomail);
+  }
+
+
+  /** @test */
+  public function notifyShouldContainsUneNotificationEnvoyee() {
+    $this->assertFlashMessengerContentContains('Envoyées à 1/2, 1 participant.e.s sans mail');
+  }
 }
 
 
@@ -370,15 +532,15 @@ class RendezVousAdminDuplicateActionValidPostTest extends RendezVousAdminTestCas
                          'begin_time' => '14:15',
                          'end_time' => '15:30',
                          'location_id' => 0,
-                         'comment' => 'My super commen']);
+                         'comment' => 'My super comment']);
 
-    $this->_model = Class_RendezVous::find(5);
+    $this->_model = Class_RendezVous::find(6);
   }
 
 
   /** @test */
   public function shouldRedirectToEdit() {
-    $this->assertRedirectContains('/admin/rendez-vous/edit/group_id/43/id/5');
+    $this->assertRedirectContains('/admin/rendez-vous/edit/group_id/43/id/6');
   }
 
 
@@ -396,7 +558,7 @@ class RendezVousAdminDuplicateActionValidPostTest extends RendezVousAdminTestCas
 
   /** @test */
   public function libelleShouldBeBeautiful() {
-    $this->assertEquals('Le mardi 19 mars de 14h15 à 15h30', $this->_model->getLibelle());
+    $this->assertEquals('Le mar. 19 mars 2019 de 14h15 à 15h30', $this->_model->getLibelle());
   }
 }
 
@@ -419,4 +581,436 @@ class RendezVousAdminDeleteActionTest extends RendezVousAdminTestCase {
   public function shouldRedirectToMonSuperAgendaSRendezVous() {
     $this->assertRedirectContains('/admin/rendez-vous/index/group_id/43');
   }
-}
\ No newline at end of file
+}
+
+
+
+class RendezVousAdminNotificationsActionTest extends RendezVousAdminTestCase {
+  public function setUp() {
+    parent::setUp();
+    $this->dispatch('/admin/rendez-vous/notification/group_id/43/id/5');
+  }
+
+
+  /** @test */
+  public function PurgeAllActionShouldBePresentInManualTable() {
+    $this->assertXPath('//button[contains(@data-url,"rendez-vous/notification-purge")]');
+  }
+
+
+  /** @test */
+  public function titleShouldBeNotifications() {
+    $this->assertXPathContentContains('//h1', 'Notifications pour le rendez-vous');
+  }
+
+
+  /** @test */
+  public function userNameShouldBePresent() {
+    $this->assertXPathContentContains('//table[@id="notificationsManual"]//td', 'Jean-Marc');
+  }
+
+
+  /** @test */
+  public function statusShouldBeErreur() {
+    $this->assertXPathContentContains('//table[@id="notificationsManual"]//td/p', 'Erreur');
+    $this->assertXPathContentContains('//table[@id="notificationsManual"]//td/a[contains(@href, "/rendez-vous/notification-error/group_id/43/id/89")][@data-popup="true"]',
+                                      'En savoir plus',
+                                      $this->_response->getBody());
+  }
+
+
+  /** @test */
+  public function statusShouldBeEnvoyée() {
+    $this->assertXPathContentContains('//table[@id="notificationsManual"]//td/p[@class="success"]',
+                                      'Envoyée');
+  }
+
+
+  /** @test */
+  public function statusShouldBePasDeMail() {
+    $this->assertXPathContentContains('//table[@id="notificationsManual"]//td/p[@class="error"]',
+                                      'Pas de mail');
+  }
+
+
+  /** @test */
+  public function statusShouldBePouet() {
+    $this->assertXPathContentContains('//table[@id="notificationsManual"]//td', 'pouet');
+  }
+
+
+  /** @test */
+  public function dateShouldBePresent() {
+    $this->assertXPathContentContains('//table[@id="notificationsManual"]//td', '2018-12-04');
+  }
+
+
+  /** @test */
+  public function batchTypeShouldNotBePresentInManualTable() {
+    $this->assertNotXPathContentContains('//table[@id="notificationsManual"]//td',
+                                         '2018-12-05');
+  }
+
+
+  /** @test */
+  public function notifyUserActionShouldNotBePresentInManualTable() {
+    $this->assertXPath('//table[@id="notificationsManual"]//td//a[contains(@href,"rendez-vous/notification-send")]');
+  }
+
+
+  /** @test */
+  public function deleteNotifyActionShouldNotBePresentInManualTable() {
+    $this->assertXPath('//table[@id="notificationsManual"]//td//a[contains(@href,"rendez-vous/notification-delete")]');
+  }
+
+
+  /** @test */
+  public function manualSectionShouldBePresent() {
+    $this->assertXPathContentContains('//section//h2', 'Manuelles');
+  }
+
+
+  /** @test */
+  public function batchSectionShouldBePresent() {
+    $this->assertXPathContentContains('//section//h2', 'Automatiques');
+  }
+
+
+  /** @test */
+  public function batchDateShouldBePresent() {
+    $this->assertXPathContentContains('//table[@id="notificationsBatch"]//td', '2018-12-05');
+  }
+
+
+  /** @test */
+  public function manualTypeShouldNotBePresentInBatchTable() {
+    $this->assertNotXPathContentContains('//table[@id="notificationsBatch"]//td', '2018-12-04');
+  }
+
+
+  /** @test */
+  public function notifyUserActionShouldBePresentInManualTable() {
+    $this->assertXPath('//table[@id="notificationsManual"]//td//a[contains(@href,"rendez-vous/notification-send")]');
+  }
+
+
+  /** @test */
+  public function deleteNotifyActionShouldBePresentInManualTable() {
+    $this->assertXPath('//table[@id="notificationsManual"]//td//a[contains(@href,"rendez-vous/notification-delete")]');
+  }
+
+
+/** @test */
+  public function notifyUserActionShouldNotBePresentInBatchTable() {
+    $this->assertNotXPath('//table[@id="notificationsBatch"]//td//a[contains(@href,"rendez-vous/notification-send")]');
+  }
+
+
+  /** @test */
+  public function deleteNotifyActionShouldNotBePresentInBatchTable() {
+    $this->assertNotXPath('//table[@id="notificationsBatch"]//td//a[contains(@href,"rendez-vous/notification-delete")]');
+  }
+}
+
+
+
+class RendezVousAdminNotificationErrorActionTest extends RendezVousAdminTestCase {
+  /** @test */
+  public function withErrorNotificationAPShouldContainsItFailed() {
+    $this->dispatch('/admin/rendez-vous/notification-error/id/89');
+    $this->assertXPathContentContains('//p', 'It failed');
+  }
+
+
+  /** @test */
+  public function withUnknownNotificationResponseShouldBe500() {
+    $this->dispatch('/admin/rendez-vous/notification-error/id/666', false);
+    $this->assertResponseCode(500);
+  }
+}
+
+
+
+class RendezVousAdminNotificationPurgeActionTest extends RendezVousAdminTestCase {
+  public function setUp() {
+    parent::setUp();
+    $this->dispatch('/admin/rendez-vous/notification-purge/group_id/43/id/5');
+  }
+
+
+  /** @test */
+  public function purgeAllActionShouldDeleteAllNotificationsOfRendezVous5() {
+    $this->assertEquals(0, Class_RendezVous_UserNotification::countBy(['rendez_vous_id' => 5]));
+  }
+
+
+  /** @test */
+  public function purgeAllActionShouldNotifyFiveNotificationsDeleted() {
+    $this->assertFlashMessengerContentContains("5 notifications supprimées");
+  }
+}
+
+
+
+class RendezVousAdminNotificationDeleteActionTest extends RendezVousAdminTestCase {
+  /** @test */
+  public function notificationId91ShouldBeDeleted() {
+    $this->dispatch('/admin/rendez-vous/notification-delete/group_id/43/id/5/notification_id/91');
+    $this->assertEquals(null, Class_RendezVous_UserNotification::find(91));
+  }
+}
+
+
+
+class RendezVousAdminNotificationSendActionTest extends RendezVousAdminTestCase {
+  public function setUp() {
+    parent::setUp();
+    $this->dispatch('/admin/rendez-vous/notification-send/group_id/43/id/5/notification_id/90');
+  }
+
+
+  /** @test */
+  public function notificationId90ShouldBeSent() {
+    $this->assertEquals(2, Class_RendezVous_UserNotification::countBy(['rendez_vous_id' => 5,
+                                                                       'user_id' => 737]));
+  }
+
+
+  /** @test */
+  public function flashMessengerShouldContainsZeroOfOne() {
+    $this->assertFlashMessengerContentContains("Envoyées à 0/1");
+  }
+}
+
+
+
+abstract class RendezVousNotificationTestCase extends RendezvousAdminTestCase {
+  public function setUp(){
+    parent::setUp();
+
+    Class_AdminVar::set('NOTIFICATION_TEMPLATE_RENDEZ_VOUS','<p>Cher {user.nom_complet},</p> <p>nous vous rappelons votre rendez-vous <strong> {rendez_vous.agenda_label} le {rendez_vous.formatted_date} entre {rendez_vous.formatted_begin_time} et {rendez_vous.formatted_end_time} sis &agrave; {rendez_vous.location_label}</strong>.</p> <br> <p>Nous vous rappelons les conditions particulières suivantes :</p><p> {rendez_vous.comment}</p>');
+
+    $rendezvous = Class_RendezVous::find(4);
+    $user = Class_Users::find(34);
+
+    Class_AdminVar::set('NOTIFICATION_DELAY_RENDEZ_VOUS',2);
+
+    $user1 = $this->fixture('Class_Users',
+                           ['id' => 35,
+                            'login' => 'lukeskywalker',
+                            'mail' => 'eyeinthesky@floyd.com',
+                            'password'=>'test2',
+                            'nom' => 'SKYWALKER',
+                            'prenom' => 'Luke']);
+
+    $this->fixture('Class_RendezVous_UserNotification',
+                   ['id' => 160,
+                    'rendez_vous_id'=> 4,
+                    'user_id'=> 35,
+                    'status'=>'sent',
+                    'type'=>'Manual',
+                    'created_at'=>'2018-09-12 08:00'
+                   ]);
+
+    $user2 = $this->fixture('Class_Users',
+                           ['id' => 36,
+                            'login' => 'leiaorgana',
+                            'nom' => 'ORGANA',
+                            'prenom' => 'Leia',
+                            'password' => 'test',
+                           ]);
+
+    $this->_agenda->users = [$user1, $user2];
+  }
+
+
+  public function tearDown(){
+    class_AdminVar::set('NOTIFICATION_TEMPLATE_RENDEZ_VOUS','');
+    parent::tearDown();
+  }
+}
+
+
+
+class RendezVousAdminMailContentTest extends RendezVousNotificationTestCase {
+  protected $_render_lettre;
+
+  public function setUp(){
+    parent::setUp();
+
+    $rendezvous = Class_RendezVous::find(4);
+    $user = Class_Users::find(34);
+
+    $this->_render_lettre = (new Class_ModeleFusion())
+      ->setContenu(Class_AdminVar::get('NOTIFICATION_TEMPLATE_RENDEZ_VOUS'))
+      ->setDataSource(['rendez_vous' => $rendezvous,
+                       'user' => $user])
+      ->getContenuFusionne();
+  }
+
+
+  /** @test */
+  public function rendezVousTemplateRenderedshouldContainsKENOBI(){
+    $this->assertContains("Obiwan KENOBI",$this->_render_lettre);
+  }
+
+
+  /** @test */
+  public function rendezVousTemplateRenderedShouldContainsMonSuperAgenda() {
+    $this->assertContains("MonSuperAgenda", $this->_render_lettre);
+  }
+
+
+  /** @test */
+  public function rendezVousTemplateRenderedShouldContainsMardi19Mars() {
+    $this->assertContains("mar. 19 mars 2019", $this->_render_lettre);
+  }
+
+
+  /** @test */
+  public function rendezVousTemplateRenderedShouldContainsBellevue() {
+    $this->assertContains("Bellevue", $this->_render_lettre);
+  }
+
+
+  /** @test */
+  public function rendezVousTemplateRenderedShouldContains09h15() {
+    $this->assertContains("09h15", $this->_render_lettre);
+  }
+
+
+  /** @test */
+  public function rendezVousTemplateRenderedShouldContains10h30() {
+    $this->assertContains("10h30", $this->_render_lettre);
+  }
+
+
+  /** @test */
+  public function rendezVousTemplateRenderedShouldContainsComment() {
+    $this->assertContains("with Arnaud", $this->_render_lettre);
+  }
+}
+
+
+
+class RendezVousSendNotificationTest extends RendezVousNotificationTestCase {
+  protected $_sent_mails;
+
+  public function setUp(){
+    parent::setUp();
+
+    Class_RendezVous_UserNotification::setTimeSource(new TimeSourceForTest('2019-03-17 14:14:14'));
+    $this->onLoaderOfModel('Class_RendezVous');
+
+    (new Class_Batch_SendRendezVousNotification)->run();
+    $this->_sent_mails = $this->_mock_transport->getSentMails();
+  }
+
+
+  public function tearDown() {
+    Class_RendezVous_UserNotification::setTimeSource(null);
+    parent::tearDown();
+  }
+
+
+  /** @test */
+  public function shouldHaveCalledFindAllByWithDelay2() {
+    $this->assertContains('DATEDIFF(date, NOW()) <= 2',
+                          Class_RendezVous::getFirstAttributeForLastCallOn('findAllBy')['where']);
+  }
+
+
+  /** @test */
+  public function batchSendRendezVousShouldHaveSentOneEmail() {
+    $this->assertEquals(3, $this->_mock_transport->count());
+  }
+
+
+  /** @test */
+  public function batchSendRendezVousSentMailShouldContainsLukeSKYWALKER() {
+    $this->assertContains('Luke SKYWALKER', $this->_sent_mails[1]->getBodyHtml(true));
+  }
+
+
+  /** @test */
+  public function lukeShouldHaveOneUserNotification() {
+    $notif = Class_RendezVous_UserNotification::findfirstBy(['user_id' => 35,
+                                                             'type' => 'Batch']);
+    $this->assertEquals('2019-03-17 14:14:14', $notif->getCreatedAt());
+  }
+
+
+  /** @test */
+  public function leiaShouldHaveUserNotificationWithStatusnomail() {
+    $notification = Class_RendezVous_UserNotification::findFirstBy(['user_id' => 36]);
+    $this->assertNotNull($notification);
+    $this->assertEquals('nomail', $notification->getStatus());
+  }
+
+
+  /** @test */
+  public function batchSendRendezVousSentReportSubjectShouldBeRapportdeNotifications() {
+    $this->assertContains('Rapport de notifications envoyées pour les rendez-vous',
+                          $this->_sent_mails[2]->getSubject());
+  }
+
+
+  /** @test */
+  public function batchSendRendezVousSentReportBodyShouldContainsMonSuperAgendaEnvoyées1Sur2() {
+    $html = quoted_printable_decode($this->_sent_mails[2]->getBodyHtml(true));
+    $this->assertContains('MonSuperAgenda, Le mar. 19 mars 2019 de 09h15 à 10h30', $html);
+    $this->assertContains("Envoyées à 1/2", $html);
+    $this->assertContains('/admin/rendez-vous/notification/group_id/43/id/5', $html);
+  }
+}
+
+
+
+class RendezVousSendNotificationBatchTwiceTest extends RendezVousNotificationTestCase {
+  protected $_sent_mail;
+
+  public function setUp(){
+    parent::setUp();
+
+    $this->fixture('Class_RendezVous_UserNotification',
+                   ['id' => 67,
+                    'status' => 'sent',
+                    'type' => 'Batch',
+                    'user_id' => 35,
+                    'created_at'=>'2019-03-02 08:00:00',
+                    'rendez_vous_id' => 4]);
+
+    $this->fixture('Class_RendezVous_UserNotification',
+                   ['id' => 68,
+                    'status' => 'sent',
+                    'type' => 'Batch',
+                    'user_id' => 35,
+                    'created_at'=>'2019-03-02 08:00:00',
+                    'rendez_vous_id' => 5]);
+
+    Class_RendezVous_UserNotification::setTimeSource(new TimeSourceForTest('2019-03-17 14:14:14'));
+
+    $this->_mock_transport = new MockMailTransport;
+    (new Class_Batch_SendRendezVousNotification)->run();
+  }
+
+
+  public function tearDown() {
+    Class_RendezVous_UserNotification::setTimeSource(null);
+    parent::tearDown();
+  }
+
+
+  /** @test */
+  public function numberOfSentMailShouldBeZero() {
+    $this->assertEquals(0, $this->_mock_transport->count());
+  }
+
+
+  /** @test */
+  public function numberOfNotificationsShouldStayAtTwo() {
+    $this->assertEquals(2,
+                        Class_RendezVous_UserNotification::countBy(['user_id' => 35,
+                                                                    'type'=>'Batch']));
+  }
+}
diff --git a/tests/scenarios/RendezVous/UsergroupAgendaAdminTest.php b/tests/scenarios/RendezVous/UsergroupAgendaAdminTest.php
index 22ad48c7f17..8cb2d0e6192 100644
--- a/tests/scenarios/RendezVous/UsergroupAgendaAdminTest.php
+++ b/tests/scenarios/RendezVous/UsergroupAgendaAdminTest.php
@@ -230,7 +230,7 @@ class UsergroupAgendaAdminAllTest extends UsergroupAgendaAdminModoPortailLoggedT
 
   /** @test */
   public function dateShouldBePresent() {
-    $this->assertXPathContentContains('//table[@id="rendez-vous"]//td', 'mardi 19 mars');
+    $this->assertXPathContentContains('//table[@id="rendez-vous"]//td', 'mar. 19 mars 2019');
   }
 
 
@@ -370,6 +370,15 @@ class UsergroupAgendaAdminAllSearchTest extends UsergroupAgendaAdminModoPortailL
   }
 
 
+  /** @test */
+  public function emptyDatesShouldBeKeptInParams() {
+    $this->dispatchWithQuery(['search_date_end' => '', 'search_date_start' => '']);
+    $this->assertXPath('//th//a[contains(@href, "search_date_start")][contains(@href, "search_date_end")]');
+    $this->assertXPath('//input[@name="search_date_start"][@value=""]');
+    $this->assertXPath('//input[@name="search_date_end"][@value=""]');
+  }
+
+
   /** @test */
   public function userContentShouldFilterByArcadiaUserGroup() {
     $this->dispatchWithQuery(['search_user' => 'Arcadia']);
@@ -456,7 +465,7 @@ class UsergroupAgendaAdminAddActionTest extends UsergroupAgendaAdminModoPortailL
 
   /** @test */
   public function currentUsersTableShouldContainAucunParticipant() {
-    $this->assertXPathContentContains('//table[@id="current_user_selection_users"]//td',
+    $this->assertXPathContentContains('//table[@id="current_user_selection_users"][contains(@data-emptymessage, "Aucun participant")]//td',
                                       "Aucun participant",
                                       $this->_response->getBody());
   }
-- 
GitLab