diff --git a/bin/console b/bin/console
index b25e1c964a..655736a864 100755
--- a/bin/console
+++ b/bin/console
@@ -57,6 +57,7 @@ use Alchemy\Phrasea\Command\Task\TaskState;
use Alchemy\Phrasea\Command\Task\TaskStop;
use Alchemy\Phrasea\Command\Thesaurus\FindConceptsCommand;
use Alchemy\Phrasea\Command\Thesaurus\Translator\TranslateCommand;
+use Alchemy\Phrasea\Command\Feedback\Report\FeedbackReportCommand;
use Alchemy\Phrasea\Command\UpgradeDBDatas;
use Alchemy\Phrasea\Command\User\UserApplicationsCommand;
use Alchemy\Phrasea\Command\User\UserCreateCommand;
@@ -180,6 +181,7 @@ $cli->command(new QueryParseCommand());
$cli->command(new QuerySampleCommand());
$cli->command(new FindConceptsCommand());
$cli->command(new TranslateCommand());
+$cli->command(new FeedbackReportCommand());
$cli->command(new WorkerExecuteCommand());
$cli->command(new WorkerHeartbeatCommand());
diff --git a/config/configuration.sample.yml b/config/configuration.sample.yml
index 3562d2f678..506da5fb94 100644
--- a/config/configuration.sample.yml
+++ b/config/configuration.sample.yml
@@ -417,3 +417,27 @@ order-manager:
download-hd:
expiration-days: null
expiration-override: false
+
+feedback-report:
+ enabled: false
+ actions:
+ action_unvoted:
+ # if any participant has not voted, set the "incomplete" icon
+ status_bit: 8
+ value: '{% if vote.votes_unvoted > 0 %} 1 {% else %} 0 {% endif %}'
+
+ # because records not involved in a vote should not display a red or green flag, we need 2 sb
+ action_red:
+ # if _any_ vote is "no", set the red flag
+ status_bit: 9
+ value: '{% if vote.votes_no > 0 %} 1 {% else %} 0 {% endif %}'
+ action_green:
+ # if _all_ votes are "yes" (=no vote is "no"), set the green flag
+ status_bit: 10
+ value: '{% if vote.votes_no == 0 %} 1 {% else %} 0 {% endif %}'
+
+ action_log:
+ metadata: 'Validations'
+ method: "prepend"
+ delimiter: "\n"
+ value: 'Vote initated on {{ vote.created }} by {{ initiator ? initiator.getEmail() : "?" }} expired {{ vote.expired }} : {{ vote.voters_count }} participants, {{ vote.votes_unvoted }} unvoted, {{ vote.votes_no }} "no", {{ vote.votes_yes}} "yes".'
diff --git a/lib/Alchemy/Phrasea/Command/Feedback/Report/Action.php b/lib/Alchemy/Phrasea/Command/Feedback/Report/Action.php
new file mode 100644
index 0000000000..46ec02ecbb
--- /dev/null
+++ b/lib/Alchemy/Phrasea/Command/Feedback/Report/Action.php
@@ -0,0 +1,29 @@
+action_conf = $action_conf;
+ $this->template = $twig->createTemplate($action_conf['value']);
+ }
+
+ public function getValue(array $context)
+ {
+ return $this->template->render($context);
+ }
+
+}
diff --git a/lib/Alchemy/Phrasea/Command/Feedback/Report/ActionInterface.php b/lib/Alchemy/Phrasea/Command/Feedback/Report/ActionInterface.php
new file mode 100644
index 0000000000..3388a89c2f
--- /dev/null
+++ b/lib/Alchemy/Phrasea/Command/Feedback/Report/ActionInterface.php
@@ -0,0 +1,9 @@
+setName('feedback:report')
+ ->setDescription('Report ended feedback results (votes) on records (set status-bits)')
+ ->addOption('report', null, InputOption::VALUE_REQUIRED, "Report output format (all|condensed)", "all")
+ ->addOption('dry', null, InputOption::VALUE_NONE, "list translations but don't apply.", null)
+ ->setHelp("")
+ ;
+ }
+
+ /**
+ * @param InputInterface $input
+ * @param OutputInterface $output
+ * @return int
+ * @throws DBALException
+ * @throws \Throwable
+ */
+ protected function doExecute(InputInterface $input, OutputInterface $output)
+ {
+ // add cool styles
+ $style = new OutputFormatterStyle('black', 'yellow'); // , array('bold'));
+ $output->getFormatter()->setStyle('warning', $style);
+
+ $this->input = $input;
+ $this->output = $output;
+
+ // config must be ok
+ //
+ try {
+ $this->config = new GlobalConfiguration(
+ $this->getConf(),
+ $this->container['twig'],
+ $this->container['phraseanet.appbox'],
+ $input->getOption('dry'),
+ $input->getOption('report')
+ );
+ }
+ catch(Exception $e) {
+ $output->writeln(sprintf("missing or bad configuration: %s", $e->getMessage()));
+
+ return -1;
+ }
+
+ if(!$this->config->isEnabled()) {
+ $output->writeln(sprintf("configuration is not enabled"));
+
+ return 0;
+ }
+
+
+ $appbox = $this->getAppBox();
+
+ $sql_update = "UPDATE `BasketElements` SET `vote_expired` = :expired WHERE `id` = :id";
+ $stmt_update = $appbox->get_connection()->prepare($sql_update);
+
+ $sql_select = "SELECT * FROM (
+ SELECT q1.*,
+ COUNT(bp.id) AS `voters_count`,
+ SUM(IF(ISNULL(`agreement`), 1 , 0)) AS `votes_unvoted`,
+ SUM(IF((`agreement`=0), 1, 0)) AS `votes_no`,
+ SUM(IF((`agreement`=1), 1, 0)) AS `votes_yes`
+ FROM (
+ SELECT SUBSTRING_INDEX(GROUP_CONCAT(b.`id` ORDER BY `vote_expires` DESC), ',', 1) AS `basket_id`,
+ b.`vote_created` AS `created`, b.`vote_initiator_id`,
+ MAX(b.`vote_expires`) AS `expired`, be.`id` AS `be_id`, be.`vote_expired` AS `be_vote_expired`,
+ be.`sbas_id`, be.`record_id`, CONCAT(be.`sbas_id`, '_', be.`record_id`) AS `sbid_rid`
+ FROM `BasketElements` AS be INNER JOIN `Baskets` AS b ON b.`id`=be.`basket_id`
+ WHERE b.`vote_expires` < NOW()
+ GROUP BY `sbid_rid`
+ ) AS q1
+ INNER JOIN `BasketParticipants` AS bp ON bp.`basket_id`=q1.`basket_id`
+ LEFT JOIN `BasketElementVotes` AS bv ON bv.`participant_id`=bp.`id` AND bv.`basket_element_id`=`be_id`
+ GROUP BY q1.`sbid_rid`
+ HAVING ISNULL(`be_vote_expired`) OR `expired` > `be_vote_expired`
+) AS q2 ORDER BY basket_id, record_id";
+
+ $last_basket_id = null;
+ $condensed = null;
+ $vote_initiator = null;
+ $stmt_select = $appbox->get_connection()->query($sql_select);
+ while ($row = $stmt_select->fetch(PDO::FETCH_ASSOC)) {
+ if($row['basket_id'] !== $last_basket_id) {
+ $this->outputCondensed($condensed);
+ $condensed = [
+ 'voters_count' => $row['voters_count'],
+ 'records_count' => 0,
+ 'votes_unvoted' => 0,
+ 'votes_no' => 0,
+ 'votes_yes' => 0,
+ ];
+
+ $vote_initiator = $this->findUser($row['vote_initiator_id']);
+
+ $this->output->writeln(sprintf("basket: %s, initated on %s by %s (%s), expired %s",
+ $last_basket_id = $row['basket_id'],
+ $row['created'],
+ $row['vote_initiator_id'],
+ $vote_initiator ? $vote_initiator->getEmail() : "unknown",
+ $row['expired'])
+ );
+ }
+ if($this->config->getReportFormat() === 'all') {
+ $this->output->writeln(sprintf("\tdatabox: %s, record id: %s", $row['sbas_id'], $row['record_id']));
+ }
+
+ if( ($databox = $this->config->getDatabox($row['sbas_id'])) === null) {
+ $this->output->writeln(sprintf("\t\tunknown databox (ignored)"));
+ continue;
+ }
+
+ try {
+ $record = $databox->get_record($row['record_id']);
+ }
+ catch(Exception $e) {
+ $this->output->writeln(sprintf("\t\tunknown record (ignored)"));
+ continue;
+ }
+
+ $condensed['records_count']++;
+ foreach(['votes_unvoted', 'votes_no', 'votes_yes'] as $k) {
+ if($this->config->getReportFormat() !== 'condensed') {
+ $this->output->writeln(sprintf("\t\t%s: %s", $k, $row[$k]));
+ }
+ $condensed[$k] += $row[$k];
+ }
+
+ $setMetasActions = [];
+ foreach($this->config->getActions($databox) as $action) {
+ $action->addAction(
+ $setMetasActions,
+ [
+ 'initiator' => $vote_initiator,
+ 'vote' => $row,
+ ]
+ );
+ }
+
+ if(count($setMetasActions) > 0) {
+ $jsActions = json_encode($setMetasActions, JSON_PRETTY_PRINT);
+ if($this->output->getVerbosity() >= OutputInterface::VERBOSITY_VERY_VERBOSE) {
+ $this->output->writeln(sprintf("JS : %s", $jsActions));
+ }
+
+ if(!$this->config->isDryRun()) {
+ $record->setMetadatasByActions(json_decode($jsActions));
+ }
+ }
+ if(!$this->config->isDryRun()) {
+ $stmt_update->execute([
+ ':expired' => $row['expired'],
+ ':id' => $row['be_id']
+ ]);
+ }
+ }
+ $this->outputCondensed($condensed);
+ $stmt_select->closeCursor();
+
+ return 0;
+ }
+
+ /**
+ * @return appbox
+ */
+ private function getAppBox(): appbox
+ {
+ return $this->container['phraseanet.appbox'];
+ }
+
+ /**
+ * @return PropertyAccess
+ */
+ protected function getConf()
+ {
+ return $this->container['conf'];
+ }
+
+ private function findUser($user_id)
+ {
+ /** @var UserRepository $repo */
+ $repo = $this->container['repo.users'];
+ try {
+ return $repo->find($user_id);
+ }
+ catch (Exception $e) {
+ return null;
+ }
+ }
+
+ /**
+ * @param array|null $condensed
+ * @return void
+ */
+ private function outputCondensed($condensed)
+ {
+ if($condensed !== null && $this->config->getReportFormat() === 'condensed') {
+ foreach($condensed as $k => $v) {
+ $this->output->writeln(sprintf("\t%s: %s", $k, $v));
+ }
+ }
+ }
+
+}
diff --git a/lib/Alchemy/Phrasea/Command/Feedback/Report/GlobalConfiguration.php b/lib/Alchemy/Phrasea/Command/Feedback/Report/GlobalConfiguration.php
new file mode 100644
index 0000000000..ac4bc8f98f
--- /dev/null
+++ b/lib/Alchemy/Phrasea/Command/Feedback/Report/GlobalConfiguration.php
@@ -0,0 +1,181 @@
+twig = $twig;
+ $this->configuration = $conf->get(['feedback-report'], ['enabled' => false, 'actions' => []]);
+ $this->dryRun = $dryRun;
+ $this->reportFormat = $reportFormat;
+
+ if($this->isEnabled()) {
+ // sanitize sb
+ foreach ($this->configuration['actions'] as $action_name => $action_conf) {
+ if (array_key_exists('status_bit', $action_conf)) {
+ $bit = (int)($sbit = trim($action_conf['status_bit']));
+ if ($bit < 4 || $bit > 31) {
+ throw new ConfigurationException(sprintf("bad status bit (%s)", $sbit));
+ }
+ }
+ }
+ // nb: "metadata" cannot be sanitized because validity depends on databox, and a basket may contain records from many dbx.
+ // unknown field will be ignored during actions creation.
+ }
+
+ // list databoxes and collections to access by id or by name
+ $this->databoxes = [];
+ foreach ($appBox->get_databoxes() as $databox) {
+ $sbas_id = $databox->get_sbas_id();
+ $sbas_name = $databox->get_dbname();
+ $this->databoxes[$sbas_id] = [
+ 'dbox' => $databox,
+ 'collections' => [],
+ 'fields' => [],
+ ];
+ $this->databoxes[$sbas_name] = &$this->databoxes[$sbas_id];
+ // list all collections
+ foreach ($databox->get_collections() as $collection) {
+ $coll_id = $collection->get_coll_id();
+ $coll_name = $collection->get_name();
+ $this->databoxes[$sbas_id]['collections'][$coll_id] = $collection;
+ $this->databoxes[$sbas_id]['collections'][$coll_name] = &$this->databoxes[$sbas_id]['collections'][$coll_id];
+ }
+ // list all fields
+ /** @var databox_field $dbf */
+ foreach($databox->get_meta_structure() as $dbf) {
+ $field_id = $dbf->get_id();
+ $field_name = $dbf->get_name();
+ $this->databoxes[$sbas_id]['fields'][$field_id] = $dbf;
+ $this->databoxes[$sbas_id]['fields'][$field_name] = &$this->databoxes[$sbas_id]['fields'][$field_id];
+ }
+ }
+ }
+
+ /**
+ * @param string|int $sbasIdOrName
+ * @return databox|null
+ */
+ public function getDatabox($sbasIdOrName)
+ {
+ return isset($this->databoxes[$sbasIdOrName]) ? $this->databoxes[$sbasIdOrName]['dbox'] : null;
+ }
+
+ /**
+ * @param string|int $sbasIdOrName
+ * @param string|int $collIdOrName
+ * @return collection|null
+ */
+ public function getCollection($sbasIdOrName, $collIdOrName)
+ {
+ return $this->databoxes[$sbasIdOrName]['collections'][$collIdOrName] ?? null;
+ }
+
+ /**
+ * @param string|int $sbasIdOrName
+ * @return databox_field[]|null
+ */
+ public function getFields($sbasIdOrName)
+ {
+ return $this->databoxes[$sbasIdOrName] ?? null;
+ }
+
+ /**
+ * @param string|int $sbasIdOrName
+ * @return databox_field|null
+ */
+ public function getField($sbasIdOrName, $fieldIdOrName)
+ {
+ return $this->databoxes[$sbasIdOrName]['fields'][$fieldIdOrName] ?? null;
+ }
+
+ /**
+ * @return bool
+ */
+ public function isDryRun(): bool
+ {
+ return $this->dryRun;
+ }
+
+ /**
+ * @return bool
+ */
+ public function isEnabled(): bool
+ {
+ return !!$this->configuration['enabled'];
+ }
+
+ /**
+ * @return string
+ */
+ public function getReportFormat(): string
+ {
+ return $this->reportFormat;
+ }
+
+
+ /**
+ * @return ActionInterface[]
+ */
+ public function getActions(\databox $databox): array
+ {
+ $sbas_id = $databox->get_sbas_id();
+ if(!array_key_exists($sbas_id, $this->actions)) {
+ $this->actions[$sbas_id] = [];
+
+ foreach($this->configuration['actions'] as $action_name => $action_conf) {
+ if(array_key_exists('status_bit', $action_conf)) {
+ $this->actions[$sbas_id][] = new StatusBitAction($this->twig, $action_conf);
+ }
+ else if(array_key_exists('metadata', $action_conf)) {
+ if(($f = $this->getField($databox->get_sbas_id(), $action_conf['metadata'])) !== null) {
+ $this->actions[$sbas_id][] = new MetadataAction($this->twig, $f->get_name(), $action_conf);
+ }
+ }
+ }
+ }
+
+ return $this->actions[$sbas_id];
+ }
+}
diff --git a/lib/Alchemy/Phrasea/Command/Feedback/Report/MetadataAction.php b/lib/Alchemy/Phrasea/Command/Feedback/Report/MetadataAction.php
new file mode 100644
index 0000000000..09e5b7a346
--- /dev/null
+++ b/lib/Alchemy/Phrasea/Command/Feedback/Report/MetadataAction.php
@@ -0,0 +1,42 @@
+fieldName = $fieldName;
+ $this->method = array_key_exists('method', $action_conf) ? $action_conf['method'] : '';
+ $this->delimiter = array_key_exists('delimiter', $action_conf) ? $action_conf['delimiter'] : '';
+ }
+
+ public function addAction(array &$actions, array $context)
+ {
+ if(!array_key_exists('metadatas', $actions)) {
+ $actions['metadatas'] = [];
+ }
+ $action = [
+ "field_name" => $this->fieldName,
+ "value" => trim($this->getValue($context))
+ ];
+ if($this->method !== '') {
+ $action['method'] = $this->method;
+ }
+ if($this->delimiter !== '') {
+ $action['delimiter'] = $this->delimiter;
+ }
+ $actions['metadatas'][] = $action;
+ }
+}
diff --git a/lib/Alchemy/Phrasea/Command/Feedback/Report/StatusBitAction.php b/lib/Alchemy/Phrasea/Command/Feedback/Report/StatusBitAction.php
new file mode 100644
index 0000000000..a2e1df8545
--- /dev/null
+++ b/lib/Alchemy/Phrasea/Command/Feedback/Report/StatusBitAction.php
@@ -0,0 +1,32 @@
+ 31) {
+// throw new ConfigurationException(sprintf("bad status bit (%s)", $sbit));
+// }
+ $this->bit = $bit;
+ }
+
+ public function addAction(array &$actions, array $context)
+ {
+ if(!array_key_exists('status', $actions)) {
+ $actions['status'] = [];
+ }
+ $actions['status'][] = [
+ "bit" => $this->bit,
+ "state" => !!trim($this->getValue($context))
+ ];
+ }
+}
diff --git a/lib/classes/patch/418RC8PHRAS3768.php b/lib/classes/patch/418RC8PHRAS3768.php
new file mode 100644
index 0000000000..03e2a52def
--- /dev/null
+++ b/lib/classes/patch/418RC8PHRAS3768.php
@@ -0,0 +1,75 @@
+release;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getDoctrineMigrations()
+ {
+ return [];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function require_all_upgrades()
+ {
+ return false;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function concern()
+ {
+ return $this->concern;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function apply(base $base, Application $app)
+ {
+ if ($base->get_base_type() === base::DATA_BOX) {
+ $this->patch_databox($base, $app);
+ }
+ elseif ($base->get_base_type() === base::APPLICATION_BOX) {
+ $this->patch_appbox($base, $app);
+ }
+
+ return true;
+ }
+
+ private function patch_databox(databox $databox, Application $app)
+ {
+ }
+
+ private function patch_appbox(base $appbox, Application $app)
+ {
+ $cnx = $appbox->get_connection();
+ $sql = "ALTER TABLE `BasketElements` ADD `vote_expired` DATETIME NULL, ADD INDEX `vote_expired` (`vote_expired`)";
+// try {
+ $cnx->exec($sql);
+// }
+// catch (\Exception $e) {
+ // the field already exist ?
+// }
+ }
+}
diff --git a/lib/conf.d/configuration.yml b/lib/conf.d/configuration.yml
index 2f90b5bf4f..ce87fe5310 100644
--- a/lib/conf.d/configuration.yml
+++ b/lib/conf.d/configuration.yml
@@ -438,3 +438,20 @@ externalservice:
Console_logger_enabled_environments: [test]
+feedback-report:
+ enabled: false
+ actions:
+ action_unvoted:
+ status_bit: 8
+ value: '{% if vote.votes_unvoted > 0 %} 1 {% else %} 0 {% endif %}'
+ action_red:
+ status_bit: 9
+ value: '{% if vote.votes_no > 0 %} 1 {% else %} 0 {% endif %}'
+ action_green:
+ status_bit: 10
+ value: '{% if vote.votes_no == 0 %} 1 {% else %} 0 {% endif %}'
+ action_log:
+ metadata: Validations
+ method: prepend
+ delimiter: "\n"
+ value: 'Vote initated on {{ vote.created }} by {{ initiator ? initiator.getEmail() : "?" }} expired {{ vote.expired }} : {{ vote.voters_count }} participants, {{ vote.votes_unvoted }} unvoted, {{ vote.votes_no }} "no", {{ vote.votes_yes}} "yes".'