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".'