PHRAS-3768_feedback-report-per-record (#4421)

* add command feedback:report ; bump back to 4.1.8-rc9
WIP OK TO TEST

* aadd dry, log, ... ; move conf ; bump back to 4.1.8-rc8
WIP OK TO TEST

* add command feedback:report ; bump back to 4.1.8-rc9
WIP OK TO TEST

* aadd dry, log, ... ; move conf ; bump back to 4.1.8-rc8
WIP OK TO TEST

* add default (disabled) conf in conf.d

* Update Version.php

bump version made in #4426
This commit is contained in:
jygaulier
2023-11-30 17:26:14 +01:00
committed by GitHub
parent 3f809f5c03
commit 69f3b30ee5
11 changed files with 655 additions and 0 deletions

View File

@@ -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());

View File

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

View File

@@ -0,0 +1,29 @@
<?php
namespace Alchemy\Phrasea\Command\Feedback\Report;
use Twig_Environment;
use Twig_Template;
class Action
{
/** @var Twig_Template[] */
private $template = null;
/**
* @var array
*/
private $action_conf;
public function __construct(Twig_Environment $twig, array $action_conf)
{
$this->action_conf = $action_conf;
$this->template = $twig->createTemplate($action_conf['value']);
}
public function getValue(array $context)
{
return $this->template->render($context);
}
}

View File

@@ -0,0 +1,9 @@
<?php
namespace Alchemy\Phrasea\Command\Feedback\Report;
interface ActionInterface
{
function addAction(array &$actions, array $context);
}

View File

@@ -0,0 +1,10 @@
<?php
namespace Alchemy\Phrasea\Command\Feedback\Report;
use Exception;
class ConfigurationException extends Exception
{
}

View File

@@ -0,0 +1,234 @@
<?php
namespace Alchemy\Phrasea\Command\Feedback\Report;
use Alchemy\Phrasea\Command\Command as phrCommand;
use Alchemy\Phrasea\Core\Configuration\PropertyAccess;
use Alchemy\Phrasea\Model\Repositories\UserRepository;
use appbox;
use Doctrine\DBAL\DBALException;
use Exception;
use PDO;
use Symfony\Component\Console\Formatter\OutputFormatterStyle;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
/**
*
* @license http://opensource.org/licenses/gpl-3.0 GPLv3
* @link www.phraseanet.com
*/
class FeedbackReportCommand extends phrCommand
{
/** @var InputInterface $input */
private $input;
/** @var OutputInterface $output */
private $output;
/** @var GlobalConfiguration */
private $config;
public function configure()
{
$this->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("<error>missing or bad configuration: %s</error>", $e->getMessage()));
return -1;
}
if(!$this->config->isEnabled()) {
$output->writeln(sprintf("<info>configuration is not enabled</info>"));
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() : "<error>unknown</error>",
$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\t<error>unknown databox</error> (ignored)"));
continue;
}
try {
$record = $databox->get_record($row['record_id']);
}
catch(Exception $e) {
$this->output->writeln(sprintf("\t\t<error>unknown record</error> (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("<info>JS : %s</info>", $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));
}
}
}
}

View File

@@ -0,0 +1,181 @@
<?php
namespace Alchemy\Phrasea\Command\Feedback\Report;
use Alchemy\Phrasea\Core\Configuration\PropertyAccess;
use appbox;
use collection;
use databox;
use databox_field;
use Twig_Environment;
Class GlobalConfiguration
{
const CONFIG_DIR = "/config/feedbackreport/";
const CONFIG_FILE = "configuration.yml";
private $configuration = null;
private $actions = []; // ActionInterface[], by sbas_id
private $databoxes = [];
/**
* @var bool
*/
private $dryRun;
/**
* @var Twig_Environment
*/
private $twig;
/**
* @var string
*/
private $reportFormat;
/**
* @param PropertyAccess $conf
* @param Twig_Environment $twig
* @param appbox $appBox
* @param bool $dryRun
* @param string $reportFormat
* @throws ConfigurationException
*/
public function __construct(PropertyAccess $conf, Twig_Environment $twig, appbox $appBox, bool $dryRun, string $reportFormat)
{
$this->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];
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace Alchemy\Phrasea\Command\Feedback\Report;
use Twig_Environment;
class MetadataAction extends Action implements ActionInterface
{
/** @var string */
private $fieldName;
/** @var string */
private $method;
/** @var string */
private $delimiter;
public function __construct(Twig_Environment $twig, string $fieldName, array $action_conf)
{
parent::__construct($twig, $action_conf);
$this->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;
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace Alchemy\Phrasea\Command\Feedback\Report;
use Twig_Environment;
class StatusBitAction extends Action implements ActionInterface
{
private $bit;
public function __construct(Twig_Environment $twig, array $action_conf)
{
parent::__construct($twig, $action_conf);
$bit = (int)($sbit = trim($action_conf['status_bit']));
// already sanitized
// if($bit < 4 || $bit > 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))
];
}
}

View File

@@ -0,0 +1,75 @@
<?php
use Alchemy\Phrasea\Application;
class patch_418RC8PHRAS3768 implements patchInterface
{
/** @var string */
private $release = '4.1.8-rc8';
/** @var array */
private $concern = [base::APPLICATION_BOX];
/**
* {@inheritdoc}
*/
public function get_release()
{
return $this->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 ?
// }
}
}

View File

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