diff --git a/bin/console b/bin/console
index 744c7c7959..cbcb1b4a74 100755
--- a/bin/console
+++ b/bin/console
@@ -40,6 +40,7 @@ use Alchemy\Phrasea\Command\Plugin\AddPlugin;
use Alchemy\Phrasea\Command\Plugin\RemovePlugin;
use Alchemy\Phrasea\Command\CheckConfig;
use Alchemy\Phrasea\Command\SearchEngine\MappingUpdateCommand;
+use Alchemy\Phrasea\Command\SendValidationRemindersCommand;
use Alchemy\Phrasea\Command\Setup\H264ConfigurationDumper;
use Alchemy\Phrasea\Command\Setup\H264MappingGenerator;
use Alchemy\Phrasea\Command\Setup\XSendFileConfigurationDumper;
@@ -169,6 +170,8 @@ $cli->command(new WorkerExecuteCommand());
$cli->command(new WorkerRunServiceCommand());
$cli->command(new WorkerShowConfigCommand());
+$cli->command(new SendValidationRemindersCommand());
+
$cli->loadPlugins();
$cli->run();
diff --git a/lib/Alchemy/Phrasea/Command/Command.php b/lib/Alchemy/Phrasea/Command/Command.php
index e7acfabd44..9ce36b2824 100644
--- a/lib/Alchemy/Phrasea/Command/Command.php
+++ b/lib/Alchemy/Phrasea/Command/Command.php
@@ -114,14 +114,17 @@ abstract class Command extends SymfoCommand implements CommandInterface
*/
public function getFormattedDuration($seconds)
{
- $duration = ceil($seconds) . ' seconds';
-
- if ($duration > 60) {
- $duration = round($duration / 60, 1) . ' minutes';
- } elseif ($duration > 3600) {
- $duration = round($duration / (60 * 60), 1) . ' hours';
- } elseif ($duration > (24 * 60 * 60)) {
- $duration = round($duration / (24 * 60 * 60), 1) . ' days';
+ if ($seconds > (24 * 3600)) {
+ $duration = round($seconds / (24 * 3600), 1) . ' days';
+ }
+ elseif ($seconds > 3600) {
+ $duration = round($seconds / (3600), 1) . ' hours';
+ }
+ elseif ($seconds > 60) {
+ $duration = round($seconds / 60, 1) . ' minutes';
+ }
+ else {
+ $duration = ceil($seconds) . ' seconds';
}
return $duration;
diff --git a/lib/Alchemy/Phrasea/Command/SendValidationRemindersCommand.php b/lib/Alchemy/Phrasea/Command/SendValidationRemindersCommand.php
new file mode 100644
index 0000000000..1ef28bfbc3
--- /dev/null
+++ b/lib/Alchemy/Phrasea/Command/SendValidationRemindersCommand.php
@@ -0,0 +1,351 @@
+setDescription('Send validation reminders. (experimental)');
+ $this->addOption('dry',null, InputOption::VALUE_NONE,'dry run, list but don\'t act');
+ $this->addOption('now', null,InputArgument::OPTIONAL, 'fake today');
+ $this->addOption('days', null,InputArgument::OPTIONAL, 'overwrite validation-reminder-days');
+ }
+
+
+ /**
+ * sanity check the cmd line options
+ *
+ * @return bool
+ */
+ protected function sanitizeArgs()
+ {
+ $r = true;
+
+ // --dry
+ $this->dry = $this->input->getOption('dry') ? true : false;
+
+ // --now
+ if(($v = $this->input->getOption('now')) !== null) {
+ try {
+ $this->now = new DateTime($v);
+ }
+ catch (Exception $e) {
+ $this->output->writeln(sprintf('bad --date "%s"', $v));
+ $r = false;
+ }
+ }
+ else {
+ $this->now = new DateTime();
+ }
+
+ // --days
+ if(($v = $this->input->getOption('days')) !== null) {
+ if(($this->days = (int)$v) <= 0) {
+ $this->output->writeln(sprintf('--days must be > 0 (bad value "%s")', $v));
+ $r = false;
+ }
+ }
+ else {
+ $this->days = (int)$this->getConf()->get(['registry', 'actions', 'validation-reminder-days']);
+ }
+
+ return $r;
+ }
+
+ protected function doExecute(InputInterface $input, OutputInterface $output)
+ {
+ $this->input = $input;
+ $this->output = $output;
+
+ $this->setDelivererLocator(new LazyLocator($this->container, 'notification.deliverer'));
+
+ if(!$this->sanitizeArgs()) {
+ return -1;
+ }
+
+ $date_to = clone($this->now);
+ $interval = sprintf('P%dD', $this->days);
+ try {
+ $date_to->add(new DateInterval($interval));
+ }
+ catch(Exception $e) {
+ $this->output->writeln(sprintf('Bad interval "%s" ?', $interval));
+ return -1;
+ }
+
+ if($this->dry) {
+ $this->output->writeln('dry mode : emails will NOT be sent');
+ }
+
+ $output->writeln(sprintf('from "%s" to "%s" (days=%d), ', $this->now->format(self::DATE_FMT), $date_to->format(self::DATE_FMT), $this->days));
+
+ $fmt = ' participant: %-11s user: %-10s %s token: %-10s ';
+ //$output->writeln(sprintf($fmt, 'session', 'basket', 'participant', 'user', 'token', 'email'));
+
+ $last_session = null;
+ foreach ($this->getValidationParticipantRepository()->findNotConfirmedAndNotRemindedParticipantsByExpireDate($date_to, $this->now) as $participant) {
+ $validationSession = $participant->getSession();
+ $basket = $validationSession->getBasket();
+
+ // change session ? display header
+ if($validationSession->getId() !== $last_session) {
+ try {
+ $basket_name = $basket->getName();
+ }
+ catch(Exception $e) {
+ // basket not found ?
+ $basket_name = '?';
+ }
+
+ try {
+ $initiator_email = $validationSession->getInitiator()->getEmail();
+ }
+ catch(Exception $e) {
+ // initiator user not found ?
+ $initiator_email = '?';
+ }
+
+ $output->writeln('');
+ $output->writeln(sprintf('session_id: %s (created %s by "%s", expire %s), basket_id: %s ("%s")',
+ $validationSession->getId(),
+ $validationSession->getCreated()->format(self::DATE_FMT),
+ $initiator_email,
+ $validationSession->getExpires()->format(self::DATE_FMT),
+ $basket->getId(),
+ $basket_name
+ ));
+
+ $last_session = $validationSession->getId();
+ }
+
+ // now display participant
+ $can_send = true;
+
+ // fu..ing doctrine : we can get user id if user does not exists ! we must try to hydrate to get an exception !
+ $user = $participant->getUser(); // always ok !
+ try {
+ $str_email = $user->getEmail(); // force to hydrate
+ }
+ catch (Exception $e) {
+ $str_email = 'user not found';
+ $can_send = false;
+ }
+
+ // find the token if exists
+ // nb : a validation may have not generated tokens if forcing auth was required upon creation
+ $token = null;
+ try {
+ $token = $this->getTokenRepository()->findValidationToken($basket, $user);
+ if($token) {
+ $str_token = sprintf('%s (cre. %s, exp. %s)',
+ $this->dotdot($token->getValue(), 10),
+ $token->getCreated()->format(self::DATE_FMT),
+ $token->getExpiration()->format(self::DATE_FMT)
+ );
+ }
+ else {
+ $str_token = '(no token))'; // token not found
+ }
+ }
+ catch (Exception $e) {
+ // not unique token ? should not happen
+ $str_token = sprintf('%s', $e->getMessage());
+ $can_send = false;
+ }
+
+ $output->writeln(sprintf($fmt,
+ $this->dotdot($participant->getId(), 10),
+ $this->dotdot($user->getId(), 10),
+ $this->dotdot($str_email, 30, 'left', '"', '"'),
+ $str_token
+ )
+ );
+
+ if(!$can_send) {
+ continue;
+ }
+
+ if(!is_null($token)) {
+ $url = $this->container->url('lightbox_validation', ['basket' => $basket->getId(), 'LOG' => $token->getValue()]);
+ }
+ else {
+ $url = $this->container->url('lightbox_validation', ['basket' => $basket->getId()]);
+ }
+
+ // $this->dispatch(PhraseaEvents::VALIDATION_REMINDER, new ValidationEvent($participant, $basket, $url));
+ $this->doRemind($participant, $basket, $url);
+ }
+
+ $this->getEntityManager()->flush();
+
+ return 0;
+ }
+
+ /**
+ * format a string to a specified length
+ *
+ * @param string $s
+ * @param int $l
+ * @param string $align 'left' or 'right'
+ * @param string $pfx prefix to add, e.g '("'
+ * @param string $sfx suffix to add, e.g '")'
+ * @return string
+ */
+ private function dotdot($s, $l, $align='left', $pfx='', $sfx='')
+ {
+ $l -= (strlen($pfx) + strlen($sfx));
+ if(strlen($s) > $l) {
+ $s = $pfx . substr($s, 0, $l-1) . "\xE2\x80\xA6" . $sfx;
+ }
+ else {
+ $spc = str_repeat(' ', $l-strlen($s));
+ $s = $align=='left' ? ($pfx . $s . $sfx . $spc) : ($spc . $pfx . $s . $sfx);
+ }
+
+ return $s;
+ }
+
+ private function doRemind(ValidationParticipant $participant, Basket $basket, $url)
+ {
+ $params = [
+ 'from' => $basket->getValidation()->getInitiator()->getId(),
+ 'to' => $participant->getUser()->getId(),
+ 'ssel_id' => $basket->getId(),
+ 'url' => $url,
+ ];
+
+ $datas = json_encode($params);
+
+ $mailed = false;
+
+ $user_from = $basket->getValidation()->getInitiator();
+ $user_to = $participant->getUser();
+
+ if ($this->shouldSendNotificationFor($participant->getUser(), 'eventsmanager_notify_validationreminder')) {
+ $readyToSend = false;
+ $title = $receiver = $emitter = null;
+ try {
+ $title = $basket->getName();
+
+ $receiver = Receiver::fromUser($user_to);
+ $emitter = Emitter::fromUser($user_from);
+
+ $readyToSend = true;
+ }
+ catch (Exception $e) {
+ // no-op
+ }
+
+ if ($readyToSend) {
+ $this->output->writeln(sprintf(' -> remind "%s" from "%s" to "%s"', $title, $receiver->getEmail(), $emitter->getEmail()));
+ if(!$this->dry) {
+ // for real
+ $mail = MailInfoValidationReminder::create($this->container, $receiver, $emitter);
+ $mail->setButtonUrl($params['url']);
+ $mail->setTitle($title);
+
+ $this->deliver($mail);
+ $mailed = true;
+
+ $participant->setReminded(new DateTime('now'));
+ $this->getEntityManager()->persist($participant);
+ }
+ }
+ }
+
+ return $this->container['events-manager']->notify($params['to'], 'eventsmanager_notify_validationreminder', $datas, $mailed);
+ }
+
+
+
+ /**
+ * @return EntityManagerInterface
+ */
+ private function getEntityManager()
+ {
+ return $this->container['orm.em'];
+ }
+
+ /**
+ * @return PropertyAccess
+ */
+ protected function getConf()
+ {
+ return $this->container['conf'];
+ }
+
+ /**
+ * @return ValidationParticipantRepository
+ */
+ private function getValidationParticipantRepository()
+ {
+ return $this->container['repo.validation-participants'];
+ }
+
+ /**
+ * @return TokenRepository
+ */
+ private function getTokenRepository()
+ {
+ return $this->container['repo.tokens'];
+ }
+
+ /**
+ * @param User $user
+ * @param $type
+ * @return mixed
+ */
+ protected function shouldSendNotificationFor(User $user, $type)
+ {
+ return $this->container['settings']->getUserNotificationSetting($user, $type);
+ }
+}
\ No newline at end of file
diff --git a/lib/Alchemy/Phrasea/Controller/Root/LoginController.php b/lib/Alchemy/Phrasea/Controller/Root/LoginController.php
index ac9e517848..862b5ffd84 100644
--- a/lib/Alchemy/Phrasea/Controller/Root/LoginController.php
+++ b/lib/Alchemy/Phrasea/Controller/Root/LoginController.php
@@ -597,6 +597,9 @@ class LoginController extends Controller
$date = new DateTime('+' . (int) $this->getConf()->get(['registry', 'actions', 'validation-reminder-days']) . ' days');
$manager = $this->getEntityManager();
+ /*
+ * PHRAS-3214_validation-tokens-refacto : This code is moved to console command "SendValidationRemindersCommand.php"
+ *
foreach ($this->getValidationParticipantRepository()->findNotConfirmedAndNotRemindedParticipantsByExpireDate($date) as $participant) {
$validationSession = $participant->getSession();
$basket = $validationSession->getBasket();
@@ -625,6 +628,7 @@ class LoginController extends Controller
}
$manager->flush();
+ */
$session = $this->getAuthenticator()->openAccount($user);
diff --git a/lib/Alchemy/Phrasea/Core/Event/Subscriber/ValidationSubscriber.php b/lib/Alchemy/Phrasea/Core/Event/Subscriber/ValidationSubscriber.php
index 232b03b8d0..9f788c115b 100644
--- a/lib/Alchemy/Phrasea/Core/Event/Subscriber/ValidationSubscriber.php
+++ b/lib/Alchemy/Phrasea/Core/Event/Subscriber/ValidationSubscriber.php
@@ -108,6 +108,9 @@ class ValidationSubscriber extends AbstractNotificationSubscriber
return $this->app['events-manager']->notify($params['to'], 'eventsmanager_notify_validationdone', $datas, $mailed);
}
+ /*
+ * PHRAS-3214_validation-tokens-refacto : This code is moved to console command "SendValidationRemindersCommand.php"
+ *
public function onRemind(ValidationEvent $event)
{
$params = [
@@ -150,13 +153,15 @@ class ValidationSubscriber extends AbstractNotificationSubscriber
return $this->app['events-manager']->notify($params['to'], 'eventsmanager_notify_validationreminder', $datas, $mailed);
}
+ */
public static function getSubscribedEvents()
{
return [
PhraseaEvents::VALIDATION_CREATE => 'onCreate',
PhraseaEvents::VALIDATION_DONE => 'onFinish',
- PhraseaEvents::VALIDATION_REMINDER => 'onRemind',
+ // PHRAS-3214_validation-tokens-refacto : This code is moved to console command "SendValidationRemindersCommand.php"
+ // PhraseaEvents::VALIDATION_REMINDER => 'onRemind',
];
}
}
diff --git a/lib/Alchemy/Phrasea/Model/Repositories/ValidationParticipantRepository.php b/lib/Alchemy/Phrasea/Model/Repositories/ValidationParticipantRepository.php
index 6374df2953..4fd55a75de 100644
--- a/lib/Alchemy/Phrasea/Model/Repositories/ValidationParticipantRepository.php
+++ b/lib/Alchemy/Phrasea/Model/Repositories/ValidationParticipantRepository.php
@@ -12,6 +12,7 @@
namespace Alchemy\Phrasea\Model\Repositories;
use Alchemy\Phrasea\Model\Entities\ValidationParticipant;
+use DateTime;
use Doctrine\ORM\EntityRepository;
use Doctrine\DBAL\Types\Type;
@@ -21,10 +22,12 @@ class ValidationParticipantRepository extends EntityRepository
/**
* Retrieve all not reminded participants where the validation has not expired
*
- * @param $expireDate The expiration Date
+ * @param $expireDate DateTime The expiration Date
+ * @param $today DateTime fake "today" to allow to get past/future events
+ * (used by SendValidationRemindersCommand.php to debug with --dry)
* @return ValidationParticipant[]
*/
- public function findNotConfirmedAndNotRemindedParticipantsByExpireDate(\DateTime $expireDate)
+ public function findNotConfirmedAndNotRemindedParticipantsByExpireDate(DateTime $expireDate, DateTime $today=null)
{
$dql = '
SELECT p, s
@@ -33,10 +36,14 @@ class ValidationParticipantRepository extends EntityRepository
JOIN s.basket b
WHERE p.is_confirmed = 0
AND p.reminded IS NULL
- AND s.expires < :date AND s.expires > CURRENT_TIMESTAMP()';
+ AND s.expires < :date AND s.expires > ' . ($today===null ? 'CURRENT_TIMESTAMP()' : ':today');
- return $this->_em->createQuery($dql)
- ->setParameter('date', $expireDate, Type::DATETIME)
- ->getResult();
+ $q = $this->_em->createQuery($dql)
+ ->setParameter('date', $expireDate, Type::DATETIME);
+ if($today !== null) {
+ $q->setParameter('today', $today, Type::DATETIME);
+ }
+
+ return $q->getResult();
}
}
diff --git a/tests/Alchemy/Tests/Phrasea/Controller/Root/LoginTest.php b/tests/Alchemy/Tests/Phrasea/Controller/Root/LoginTest.php
index b17d31de7c..39bb699910 100644
--- a/tests/Alchemy/Tests/Phrasea/Controller/Root/LoginTest.php
+++ b/tests/Alchemy/Tests/Phrasea/Controller/Root/LoginTest.php
@@ -2076,9 +2076,11 @@ class LoginTest extends \PhraseanetAuthenticatedWebTestCase
->disableOriginalConstructor()
->getMock();
+ /*
$repo->expects($participants ? $this->once() : $this->never())
->method('findNotConfirmedAndNotRemindedParticipantsByExpireDate')
->will($this->returnValue([]));
+ */
$app['repo.validation-participants'] = $repo;
}