diff --git a/bin/console b/bin/console index 3a0904d2b1..a275593dbf 100755 --- a/bin/console +++ b/bin/console @@ -14,6 +14,7 @@ namespace KonsoleKommander; use Alchemy\Phrasea\CLI; use Alchemy\Phrasea\Command\ApplyRightsCommand; use Alchemy\Phrasea\Command\BuildMissingSubdefs; +use Alchemy\Phrasea\Command\ExpiringRights\AlertExpiringRightsCommand; use Alchemy\Phrasea\Command\Record\BuildPermalinks; use Alchemy\Phrasea\Command\Record\BuildSubdefs; use Alchemy\Phrasea\Command\CheckConfig; @@ -194,6 +195,8 @@ $cli->command(new SendValidationRemindersCommand()); $cli->command(new NetworkProxiesTestCommand('network-proxies:test')); +$cli->command(new AlertExpiringRightsCommand()); + $cli->loadPlugins(); $cli->run(); diff --git a/config/configuration.sample.yml b/config/configuration.sample.yml index e89fdd5062..f3290f093e 100644 --- a/config/configuration.sample.yml +++ b/config/configuration.sample.yml @@ -489,3 +489,42 @@ translator: # end of job : change coll status set_status: 10xxxx set_collection: online +expiring-rights: + version: 3 + jobs: + # "I want to alert owners that records have expired" + - rights-expired-owners: + active: false + target: "owners" + databox: "db_with_rights" + collection: [ "Promo", "Selections" ] + expire_field: ExpireDate + prior_notice: -60 + set_status: 01xxxx + alerts: + - method: webhook + recipient: ["bob@a.fr", "joe@b.com"] + + # "I want to alert users who have downloaded that a document rights will expire in 60 days" + - rights-60-downloaders: + active: false + target: "downloaders" + databox: "db_with_rights" + collection: [ "Promo", "Selections" ] + downloaded: [ "document", "preview" ] + expire_field: "ExpirationDate" + prior_notice: -60 + alerts: + - method: "webhook" + + # "I want to alert users who have downloaded that a document rights has expired" + - rights-expired-dowloaders: + active: false + target: "downloaders" + databox: "db_with_rights" + collection: [ "Promo", "Selections" ] + downloaded: [ "document", "preview" ] + expire_field: "ExpirationDate" + prior_notice: 0 + alerts: + - method: "webhook" diff --git a/lib/Alchemy/Phrasea/Application.php b/lib/Alchemy/Phrasea/Application.php index c67ab8613d..d1ef5d8ac1 100644 --- a/lib/Alchemy/Phrasea/Application.php +++ b/lib/Alchemy/Phrasea/Application.php @@ -20,6 +20,7 @@ use Alchemy\Phrasea\Authorization\AuthorizationServiceProvider; use Alchemy\Phrasea\Core\Event\Subscriber\BasketSubscriber; use Alchemy\Phrasea\Core\Event\Subscriber\BridgeSubscriber; +use Alchemy\Phrasea\Core\Event\Subscriber\ExpiringRightsSubscriber; use Alchemy\Phrasea\Core\Event\Subscriber\ExportSubscriber; use Alchemy\Phrasea\Core\Event\Subscriber\FeedEntrySubscriber; use Alchemy\Phrasea\Core\Event\Subscriber\LazaretSubscriber; @@ -27,6 +28,7 @@ use Alchemy\Phrasea\Core\Event\Subscriber\RegistrationSubscriber; use Alchemy\Phrasea\Core\Event\Subscriber\ValidationSubscriber; use Alchemy\Phrasea\Core\Event\Subscriber\WebhookUserEventSubscriber; +use Alchemy\Phrasea\Core\LazyLocator; use Alchemy\Phrasea\Core\MetaProvider\DatabaseMetaProvider; use Alchemy\Phrasea\Core\MetaProvider\HttpStackMetaProvider; use Alchemy\Phrasea\Core\MetaProvider\MediaUtilitiesMetaServiceProvider; @@ -767,6 +769,8 @@ private function setupEventDispatcher() $dispatcher->addSubscriber(new WebhookUserEventSubscriber($app)); } + $dispatcher->addSubscriber(new ExpiringRightsSubscriber($app, new LazyLocator($app, 'phraseanet.appbox'))); + return $dispatcher; }) ); diff --git a/lib/Alchemy/Phrasea/Command/ExpiringRights/AlertExpiringRightsCommand.php b/lib/Alchemy/Phrasea/Command/ExpiringRights/AlertExpiringRightsCommand.php new file mode 100644 index 0000000000..f354aee59d --- /dev/null +++ b/lib/Alchemy/Phrasea/Command/ExpiringRights/AlertExpiringRightsCommand.php @@ -0,0 +1,591 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Alchemy\Phrasea\Command\ExpiringRights; + + +use Alchemy\Phrasea\Command\Command; +use Alchemy\Phrasea\Model\Manipulator\WebhookEventManipulator; +use appbox; +use collection; +use databox; +use Doctrine\DBAL\DBALException; +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; +use function igorw\get_in; + + +class AlertExpiringRightsCommand extends Command +{ + const RIGHT_SHORTENED = 'shortened'; + const RIGHT_EXTENDED = 'extended'; + const RIGHT_EXPIRING = 'expiring'; + + /** @var InputInterface $input */ + private $input; + /** @var OutputInterface $output */ + private $output; + /** @var appbox $appbox */ + private $appbox; + /** @var array $databoxes */ + private $databoxes; + + private $now = null; + + public function configure() + { + $this->setName("workflow:expiring:run") + ->setDescription('alert owners and users of expiring records') + ->addOption('dry', null, InputOption::VALUE_NONE, "Dry run (list alerts but don't insert in webhooks).") + ->addOption('show-sql', null, InputOption::VALUE_NONE, "Show the selection sql.") + ->addOption('dump-webhooks', null, InputOption::VALUE_NONE, "Show the webhooks data.") + ->addOption('now', null, InputOption::VALUE_REQUIRED, "for testing : fake the 'today' date (format=YYYYMMDD).") + // ->setHelp('') + ; + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @return int + */ + protected function doExecute(InputInterface $input, OutputInterface $output) + { + $this->input = $input; + // add cool styles + $style = new OutputFormatterStyle('black', 'yellow'); // , array('bold')); + $output->getFormatter()->setStyle('warning', $style); + $this->output = $output; + + // sanitize parameters + if(($now = $input->getOption('now')) !== null) { + if(preg_match("/^[0-9]{8}$/", $now) === 1) { + $this->now = $now; + } + else { + $this->output->writeln(sprintf("bad format for 'now' (%s) option (must be YYYYMMDD)", $now)); + return -1; + } + } + + $this->appbox = $this->container['phraseanet.appbox']; + + // list databoxes and collections to access by id or by name + $this->databoxes = []; + foreach ($this->appbox->get_databoxes() as $databox) { + $sbas_id = $databox->get_sbas_id(); + $sbas_name = $databox->get_dbname(); + $this->databoxes[$sbas_id] = [ + 'dbox' => $databox, + 'collections' => [] + ]; + $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]; + } + } + + // play jobs + $ret = 0; + foreach ($this->container['conf']->get(['expiring-rights', 'jobs'], []) as $jobname => &$job) { + if($job['active']) { + if (!$this->playJob($jobname, $job)) { + $this->output->writeln(sprintf("job skipped")); + $ret = -1; + } + } + } + + return $ret; + } + + /** + * @param $jobname + * @param $job + * @return bool + * @throws DBALException + */ + private function playJob($jobname, $job) + { + $this->output->writeln(sprintf("\n\n======== Playing job \"%s\" ========\n", $jobname)); + + // ensure that the job syntax is ok + if (!$this->sanitizeJob($job)) { + return false; + } + + switch ($job['target']) { + case "owners": + return $this->playJobOwners($jobname, $job); + break; + case "downloaders": + return $this->playJobDownloaders($jobname, $job); + break; + default: + $this->output->writeln(sprintf("alert>bad target \"%s\" (should be \"owners\" or \"downloaders\")\n", $job['target'])); + break; + } + return false; + } + + private function playJobOwners($jobname, $job) + { + // ensure that the job syntax is ok + if (!$this->sanitizeJobOwners($job)) { + return false; + } + + if (get_in($job, ['active'], false) === false) { + return true; + } + + // build sql where clause + $wheres = []; + + // clause on databox ? + $d = $job['databox']; + if (!is_string($d) && !is_int($d)) { + $this->output->writeln(sprintf("bad databox clause")); + return false; + } + if (!array_key_exists($d, $this->databoxes)) { + $this->output->writeln(sprintf("unknown databox (%s)", $d)); + return false; + } + + // find the sbas_id for the databox of this job + /** @var Databox $dbox */ + $dbox = $this->databoxes[$d]['dbox']; + $sbas_id = $dbox->get_sbas_id(); + + // filter on collections ? + $collList = []; + foreach (get_in($job, ['collection'], []) as $c) { + /** @var collection $coll */ + if (($coll = get_in($this->databoxes[$sbas_id], ['collections', $c])) !== null) { + $collList[] = $dbox->get_connection()->quote($coll->get_coll_id()); + } + } + if (!empty($collList)) { + if(count($collList) === 1) { + $wheres[] = "r.`coll_id`=" . $collList[0]; + } + else { + $wheres[] = "r.`coll_id` IN(" . join(',', $collList) . ")"; + } + } + + // clause on sb (negated) + $mask = get_in($job, ['set_status']); + if ($mask === null) { + $this->output->writeln(sprintf("missing 'set_status' clause")); + return false; + } + $m = preg_replace('/[^0-1]/', 'x', trim($mask)); + if (strlen($m) > 32) { + $this->output->writeln(sprintf("status mask (%s) too long", $mask)); + return false; + } + $mask_xor = str_replace(' ', '0', ltrim(str_replace(array('0', 'x'), array(' ', ' '), $m))); + $mask_and = str_replace(' ', '0', ltrim(str_replace(array('x', '0'), array(' ', '1'), $m))); + if ($mask_xor && $mask_and) { + $wheres[] = '((r.`status` ^ 0b' . $mask_xor . ') & 0b' . $mask_and . ') != 0'; + } elseif ($mask_xor) { + $wheres[] = '(r.`status` ^ 0b' . $mask_xor . ') != 0'; + } elseif ($mask_and) { + $wheres[] = '(r.`status` & 0b' . $mask_and . ') != 0'; + } else { + $this->output->writeln(sprintf("empty status mask")); + return false; + } + // set status + $set_status = "`status`"; + $set_or = str_replace(' ', '0', ltrim(str_replace(array('0', 'x'), array(' ', ' '), $m))); + $set_nand = str_replace(' ', '0', ltrim(str_replace(array('x', '1', '0'), array(' ', ' ', '1'), $m))); + if($set_or) { + $set_status = "(" . $set_status . " | 0b" . $set_or . ")"; + } + if($set_nand) { + $set_status = "(" . $set_status . " & ~0b" . $set_nand . ")"; + } + + // clause on expiration date + // the NOW() can be faked for testing + $expire_field_id = null; + foreach ($dbox->get_meta_structure() as $dbf) { + if ($dbf->get_name() === $job['expire_field']) { + $expire_field_id = $dbf->get_id(); + break; + } + } + if ($expire_field_id === null) { + $this->output->writeln(sprintf("unknown field (%s)", $job['expire_field'])); + return false; + } + $now = $this->now === null ? "NOW()" : $this->appbox->get_connection()->quote($this->now); + $delta = (int)$job['prior_notice']; + if ($delta > 0) { + $value = "(`expire`+INTERVAL " . $delta . " DAY)"; + } + elseif ($delta < 0) { + $value = "(`expire`-INTERVAL " . -$delta . " DAY)"; + } + else { + $value = "`expire`"; + } + + $sql_where = count($wheres) > 0 ? " WHERE " . join(" AND ", $wheres) : ""; + $sql = "SELECT t.*, DATEDIFF(`expire`, " . $now . ") AS 'expire_in' FROM (\n" + . " SELECT r.`record_id`, CAST(CAST(m.`value` AS DATE) AS DATETIME) AS `expire`\n" + . " FROM `record` AS r INNER JOIN `metadatas` AS m\n" + . " ON m.`record_id`=r.`record_id` AND m.`meta_struct_id`=" . $dbox->get_connection()->quote($expire_field_id, PDO::PARAM_INT) . "\n" + . $sql_where + . ") AS t WHERE " . $now . ">=" . $value; + + if ($this->input->getOption('show-sql')) { + $this->output->writeln(sprintf("sql: %s", $sql)); + } + + // play sql + $records = []; + $stmt = $dbox->get_connection()->prepare($sql); + $stmt->execute(); + + if ($this->input->getOption('dry')) { + $this->output->writeln(sprintf("dry mode: updates on %d records NOT executed", $stmt->rowCount())); + } + + while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { + $record = $dbox->get_record($row['record_id']); + $row['collection'] = $record->getCollection()->get_name(); + $row['title'] = $record->get_title(); + $records[] = $row; + + $sql = "UPDATE `record` SET `status`=" . $set_status . " WHERE record_id=" . $dbox->get_connection()->quote($row['record_id']); + if ($this->input->getOption('show-sql')) { + $this->output->writeln(sprintf("sql: %s", $sql)); + } + if (!$this->input->getOption('dry')) { + $dbox->get_connection()->exec($sql); + } + } + $stmt->closeCursor(); + + $n_records = count($records); + if($n_records === 0) { + return true; + } + foreach ($job['alerts'] as $alert) { + $method = get_in($alert, ['method']); + switch($method) { + case "webhook": + $payload = [ + 'job' => $jobname, + 'delta' => $delta, + 'email' => $alert['recipient'], + 'sbas_id' => $dbox->get_sbas_id(), + 'base' => $dbox->get_viewname(), + 'records' => $records + ]; + if ($this->input->getOption('dry')) { + $this->output->writeln( + sprintf( + "dry run : webhook about %d record(s) to [%s] NOT inserted", + $n_records, join(',', $alert['recipient']) + ) + ); + } + else { + $this->output->writeln( + sprintf( + "Inserting webhook about %d record(s) to [%s]", + $n_records, join(',', $alert['recipient']) + ) + ); + /** @var WebhookEventManipulator $manipulator */ + $webhookManipulator = $this->container['manipulator.webhook-event']; + $webhookManipulator->create( + "Expiring.Rights.Records", + "Expiring.Rights", + $payload + ); + } + if($this->input->getOption("dump-webhooks")) { + $this->output->writeln("webhook: \"Expiring.Rights.Records\", \"Expiring.Rights\"\npayload="); + $this->output->writeln(json_encode($payload, JSON_PRETTY_PRINT)); + } + break; + default: + $this->output->writeln(sprintf("bad or undefined alert method (%s), ignored", $method)); + break; + } + } + + return true; + } + + private function playJobDownloaders($jobname, $job) + { + // ensure that the job syntax is ok + if (!$this->sanitizeJobDownloaders($job)) { + return false; + } + + if (get_in($job, ['active'], false) === false) { + return true; + } + + // build sql where clause + $wheres = [ + '`job` = ' . $this->appbox->get_connection()->quote($jobname) + ]; + + // clause on databox + $databox = $job['databox']; + if(!is_string($databox) && !is_int($databox)) { + $this->output->writeln(sprintf("bad databox clause")); + return false; + } + if (!array_key_exists($databox, $this->databoxes)) { + $this->output->writeln(sprintf("unknown databox (%s)", $job['databox'])); + return false; + } + // find the sbas_id for the databox of this job + /** @var Databox $dbox */ + $dbox = $this->databoxes[$databox]['dbox']; + $sbas_id = $dbox->get_sbas_id(); + $wheres[] = "(`sbas_id`=" . $dbox->get_connection()->quote($sbas_id) . ")"; + + $wheres[] = "(ISNULL(`alerted`) OR !ISNULL(`new_expire`))"; + + // clause on expiration date + // the NOW() can be faked for testing + $now = $this->now === null ? "NOW()" : $this->appbox->get_connection()->quote($this->now); + $delta = (int)$job['prior_notice']; + if ($delta > 0) { + $value = "(real_expire+INTERVAL " . $delta . " DAY)"; + } elseif ($delta < 0) { + $value = "(real_expire-INTERVAL " . -$delta . " DAY)"; + } else { + $value = "real_expire"; + } + + // build SELECT sql + $sql_where = join("\n AND", $wheres); + $sql = "SELECT t.*, DATEDIFF(real_expire, " . $now . ") AS 'expire_in' FROM (\n" + . " SELECT *, COALESCE(`expire`, `new_expire`) AS `real_expire` FROM `ExpiringRights`\n" + . " WHERE " . $sql_where . "\n" + . ") AS t\n" + . "WHERE !ISNULL(real_expire) AND " . $now . ">=" . $value . "\n" + . "ORDER BY `user_id`"; + + if($this->input->getOption('show-sql')) { + $this->output->writeln(sprintf("%s", $sql)); + } + + // play sql + $usersById = []; + + $n_records = [ + "all" => 0, + self::RIGHT_EXPIRING => 0, + self::RIGHT_EXTENDED => 0, + self::RIGHT_SHORTENED => 0 + ]; + $stmt = $this->appbox->get_connection()->prepare($sql); + $stmt->execute(); + while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { + $type = null; + if($row['new_expire'] !== null) { + if($row['expire'] === null || $row['alerted'] === null) { + // same thing as new expire + $type = self::RIGHT_EXPIRING; + } + elseif($row['new_expire'] < $row['expire']) { + // shortened + $type = self::RIGHT_SHORTENED; + } + elseif($row['new_expire'] > $row['expire']) { + // extended + $type = self::RIGHT_EXTENDED; + } + else { + // same date (should not happen ?) + $type = self::RIGHT_EXPIRING; + } + } + else { + // new_expire === null + if($row['alerted'] === null) { + $type = self::RIGHT_EXPIRING; + } + else { + // nothing to do (should not happen) + } + } + + if($type !== null) { + $user_id = $row['user_id']; + if(!array_key_exists($user_id, $usersById)) { + $usersById[$user_id] = [ + 'user_id' => $user_id, + 'email' => $row['email'], + 'records' => [] + ]; + } + unset($row['id'], $row['job'], $row['user_id'], $row['email'], $row['sbas_id'], $row['base']); + $row['type'] = $type; + $usersById[$user_id]['records'][] = $row; + + $n_records[$type]++; + } + $n_records['all']++; + } + $stmt->closeCursor(); + + $payload = [ + 'job' => $jobname, + 'sbas_id' => $sbas_id, + 'base' => $dbox->get_viewname(), + 'delta' => $delta, + 'users' => array_values($usersById) + ]; + unset($usersById); + + if($n_records['all'] > 0 && !$this->input->getOption('dry')) { + // build UPDATE sql + $sql = "UPDATE `ExpiringRights` SET expire=COALESCE(new_expire, expire), new_expire=NULL, alerted=" . $now . "\n" + . " WHERE " . $sql_where; + $stmt = $this->appbox->get_connection()->prepare($sql); + $stmt->execute([]); + $this->appbox->get_connection()->exec($sql); + $stmt->closeCursor(); + } + + $this->output->writeln( + sprintf( + "%d records selected (%s expiring, %s shortened, %s extended)", + $n_records['all'], + $n_records[self::RIGHT_EXPIRING], + $n_records[self::RIGHT_SHORTENED], + $n_records[self::RIGHT_EXTENDED] + ) + ); + + if ($n_records['all'] > 0 ) { + foreach ($job['alerts'] as $alert) { + switch ($alert['method']) { + case 'webhook': + if ($this->input->getOption('dry')) { + $this->output->writeln( + sprintf( + "dry run : webhook about %d record(s) NOT inserted", + $n_records['all'] + ) + ); + } + else { + $this->output->writeln( + sprintf( + "webhook about %d record(s) inserted", + $n_records['all'] + ) + ); + /** @var WebhookEventManipulator $manipulator */ + $webhookManipulator = $this->container['manipulator.webhook-event']; + $webhookManipulator->create( + "Expiring.Rights.Downloaded", + "Expiring.Rights", + $payload + ); + } + if($this->input->getOption("dump-webhooks")) { + $this->output->writeln("webhook: \"Expiring.Rights.Downloaded\", \"Expiring.Rights\"\npayload="); + $this->output->writeln(json_encode($payload, JSON_PRETTY_PRINT)); + } + break; + default : + $this->output->writeln(sprintf("unknown alert method \"%s\"", $alert['method'])); + break; + } + } + } + + return true; + } + + // ================================================================================================ + + /** + * check that a yaml->php block is ok against rules + * + * @param array $object + * @param array $rules + * @return bool + */ + private function sanitize(array $object, array $rules) + { + $object_ok = true; + + foreach ($rules as $key => $fsanitize) { + if (!array_key_exists($key, $object) || !($fsanitize($object[$key]))) { + $this->output->writeln(sprintf("missing or bad format setting \"%s\"", $key)); + $object_ok = false; + } + } + + return $object_ok; + } + + /** + * check that a job (first level block) is ok + * + * @param $job + * @return bool + */ + private function sanitizeJob($job) + { + return $this->sanitize( + $job, + [ + 'active' => "is_bool", + 'target' => function($v) {return in_array($v, ['owners', 'downloaders']);}, + 'databox' => "is_string", + 'prior_notice' => 'is_int', + 'expire_field' => 'is_string', + 'alerts' => 'is_array' + ] + ); + } + + private function sanitizeJobOwners($job) + { + return $this->sanitize( + $job, + [ + 'set_status' => "is_string", + ] + ); + } + + private function sanitizeJobDownloaders($job) + { + return true; // sanitizeJob is enough + } + +} diff --git a/lib/Alchemy/Phrasea/Controller/Prod/DownloadController.php b/lib/Alchemy/Phrasea/Controller/Prod/DownloadController.php index 075cecb66b..7fa80cfc72 100644 --- a/lib/Alchemy/Phrasea/Controller/Prod/DownloadController.php +++ b/lib/Alchemy/Phrasea/Controller/Prod/DownloadController.php @@ -67,6 +67,12 @@ public function checkDownload(Request $request) $list['include_report'] = $request->request->get('include_report') === 'INCLUDE_REPORT'; $list['include_businessfields'] = (bool)$request->request->get('businessfields'); + $lst = []; + foreach ($list['files'] as $file) { + $lst[] = $this->getApplicationBox()->get_collection($file['base_id'])->get_databox()->get_sbas_id() . '_' . $file['record_id']; + } + $lst = join(';', $lst); + $token = $this->getTokenManipulator()->createDownloadToken($this->getAuthenticatedUser(), serialize($list)); $this->getDispatcher()->dispatch(PhraseaEvents::EXPORT_CREATE, new ExportEvent( @@ -136,6 +142,12 @@ public function listDownloadAsync(Request $request) $records[sprintf('%s_%s', $sbasId, $file['record_id'])] = $record; } + $lst = []; + foreach ($list['files'] as $file) { + $lst[] = $this->getApplicationBox()->get_collection($file['base_id'])->get_databox()->get_sbas_id() . '_' . $file['record_id']; + } + $lst = join(';', $lst); + $token = $this->getTokenManipulator()->createDownloadToken($this->getAuthenticatedUser(), serialize($list)); $pusher_auth_key =$this->getConf()->get(['download_async', 'enabled'], false) ? $this->getConf()->get(['externalservice', 'pusher', 'auth_key'], '') : null; diff --git a/lib/Alchemy/Phrasea/Core/Event/Subscriber/ExpiringRightsSubscriber.php b/lib/Alchemy/Phrasea/Core/Event/Subscriber/ExpiringRightsSubscriber.php new file mode 100644 index 0000000000..3aa41cb0bf --- /dev/null +++ b/lib/Alchemy/Phrasea/Core/Event/Subscriber/ExpiringRightsSubscriber.php @@ -0,0 +1,223 @@ +app = $app; + $this->appboxLocator = $appboxLocator; + } + + private function getJobsByDb($sbas_id) + { + static $jobsByDb = []; + static $jobsByName = null; + + if($jobsByName === null) { + $jobsByName = []; + foreach ($this->app['conf']->get(['expiring-rights', 'jobs'], []) as $jobname => &$job) { + // nb: we must include inactive jobs so every download is recorded + $job['_c'] = $job['collection']; + unset($job['collection']); + $jobsByName[$jobname] = $job; + } + } + + $sbas_id = (int)$sbas_id; + if(!array_key_exists($sbas_id, $jobsByDb)) { + $jobsByDb[$sbas_id] = []; + /** @var appbox $abox */ + $abox = $this->app['phraseanet.appbox']; + $dbox = $abox->get_databox($sbas_id); + foreach($jobsByName as $jobname => &$job) { + if($job['databox'] === $sbas_id || $job['databox'] === $dbox->get_dbname()) { + // patch the collections filter to have id's and names + if(!array_key_exists('collection', $job)) { + $job['collection'] = []; + foreach ($dbox->get_collections() as $coll) { + if(empty($job['_c']) || + in_array($coll->get_coll_id(), $job['_c'], true) || + in_array($coll->get_name(), $job['_c'], true)) + { + $job['collection'][] = $coll->get_coll_id(); + $job['collection'][] = $coll->get_name(); + } + } + } + $jobsByDb[$sbas_id][$jobname] = &$jobsByName[$jobname]; + } + } + } + + return array_key_exists($sbas_id, $jobsByDb) ? $jobsByDb[$sbas_id] : []; + } + + /** + * when someone downloads, if the base has a declared "expire_field" + * insert an entry in our table + * + * @param ExportEvent $event + * @param string $eventName + */ + public function onExportCreate(ExportEvent $event, $eventName) + { + $user_id = $event->getUser()->getId(); + $email = $event->getUser()->getEmail(); + /** @var appbox $abox */ + $abox = $this->getApplicationBox(); + $stmt = null; + + foreach(explode(';', $event->getList()) as $sbid_rid) { + list($sbas_id, $record_id) = explode('_', $sbid_rid); + + try { + /** @var databox $dbox */ + $dbox = $abox->get_databox($sbas_id); + } + catch (\Exception $e) { + continue; + } + + foreach($this->getJobsByDb($sbas_id) as $jobname => $job) { + if($job['target'] !== "downloaders") { + continue; + } + + $record = $dbox->get_record($record_id); + + // get the expire_field unique value + $expire_value = null; + $expire_field = $job['expire_field']; + $fields = $record->getCaption([$expire_field]); + if (array_key_exists($expire_field, $fields) && count($fields[$expire_field]) === 1) { + try { + $expire_value = (new \DateTime($fields[$expire_field][0]))->format('Y-m-d'); // drop hour, minutes... + } + catch (\Exception $e) { + // bad date format ? set null + } + } + + try { + // insert + if(!$stmt) { + // first sql + $sql = "INSERT INTO `ExpiringRights` (job, downloaded, user_id, email, sbas_id, base, collection, record_id, title, expire)\n" + . " VALUES (:job, NOW(), :user_id, :email, :sbas_id, :base, :collection, :record_id, :title, :expire)"; + $stmt = $abox->get_connection()->prepare($sql); + } + try { + $stmt->execute([ + ':job' => $jobname, + ':user_id' => $user_id, + ':email' => $email, + ':sbas_id' => $sbas_id, + ':base' => $dbox->get_viewname(), // fallback of get_label() because we may not know the current language + ':collection' => $record->getCollection()->get_name(), + ':record_id' => $record_id, + ':title' => $record->get_title(), + ':expire' => $expire_value + ]); + } + catch (\Exception $e) { + // duplicate (same job+user+sbas_id+record_id) ? + } + } + catch (\Exception $e) { + // no-op + } + + } + } + if($stmt) { + $stmt->closeCursor(); + } + } + + /** + * when the "expire_field" is edited, update the new value for every downloaded record + * + * @param RecordEdit $event + * @param string $eventName + * @throws DBALException + */ + public function onRecordEdit(RecordEdit $event, $eventName) + { + $record = $event->getRecord(); + $sbas_id = $record->getDataboxId(); + + // get settings for this databox + foreach($this->getJobsByDb($sbas_id) as $job) { + if($job['target'] !== "downloaders") { + continue; + } + $expire_field = $job['expire_field']; + $expire_value = null; + $record_id = $record->getRecordId(); + $fields = $record->getCaption([$expire_field]); + if (array_key_exists($expire_field, $fields) && count($fields[$expire_field]) > 0) { + $expire_value = $fields[$expire_field][0]; + } + + /** @var appbox $abox */ + $abox = $this->getApplicationBox(); + $sql = "UPDATE `ExpiringRights` SET new_expire = :new_expire\n" + . " WHERE sbas_id = :sbas_id AND record_id = :record_id AND (IFNULL(expire, 0) != IFNULL(:expire, 0) OR !ISNULL(new_expire))"; + $stmt = $abox->get_connection()->prepare($sql); + $stmt->execute($p = [ + ':new_expire' => $expire_value, + ':sbas_id' => $sbas_id, + ':record_id' => $record_id, + ':expire' => $expire_value + ]); + $stmt->closeCursor(); + } + } + + + public static function getSubscribedEvents() + { + return [ + /** @uses onRecordEdit */ + PhraseaEvents::RECORD_EDIT => 'onRecordEdit', + /** @uses onExportCreate */ + PhraseaEvents::EXPORT_CREATE => 'onExportCreate', + ]; + } + + + /** + * @return appbox + */ + private function getApplicationBox() + { + static $appbox = null; + if($appbox === null) { + $callable = $this->appboxLocator; + $appbox = $callable(); + } + return $appbox; + } +} diff --git a/lib/Alchemy/Phrasea/Core/Version.php b/lib/Alchemy/Phrasea/Core/Version.php index 17121523f9..ad0a782b48 100644 --- a/lib/Alchemy/Phrasea/Core/Version.php +++ b/lib/Alchemy/Phrasea/Core/Version.php @@ -17,7 +17,7 @@ class Version * @var string */ - private $number = '4.1.8'; + private $number = '4.1.9'; /** * @var string diff --git a/lib/classes/patch/419PHRAS4078.php b/lib/classes/patch/419PHRAS4078.php new file mode 100644 index 0000000000..bb97c092e7 --- /dev/null +++ b/lib/classes/patch/419PHRAS4078.php @@ -0,0 +1,268 @@ +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) + { + $this->app = $app; + $this->logger = $app['logger']; + + 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) + { + $this->migrateConfig(); + $this->migrateTable($appbox); + } + + const TABLENAME = "_ExpiringRights"; + const TABLENAME3 = "ExpiringRights"; + const CONFIG_DIR = "/config/plugins/expirating-rights-plugin/"; + const CONFIG_FILE = "configuration.yml"; + /** @var Application $app */ + private $app; + /** @var LoggerInterface $logger */ + private $logger; + private $config; + + private function migrateConfig() + { + /** @var PropertyAccess $conf */ + $phrconfig = $this->app['conf']; + + if ($phrconfig->has(['expiring-rights'])) { + return; + } + + // locate the config for the ExpiringRights plugin (v1 or v3) + $config_dir = $this->app['root.path'] . self::CONFIG_DIR; + $config_file = $config_dir . self::CONFIG_FILE; + $piconfig = ['jobs' => []]; + if (file_exists($config_file)) { + try { + $piconfig = Yaml::parse(file_get_contents($config_file)); + } + catch (\Exception $e) { + $piconfig = ['jobs' => []]; + } + + if (array_key_exists('databoxes', $piconfig)) { + // migrate the job settings to v3 + $jobs3 = []; + foreach ($piconfig['jobs'] as $job) { + $jobname = $job['job']; + + // find databox + $found = 0; + foreach ($piconfig['databoxes'] as $db) { + if ($db['databox'] === $job['databox']) { + unset($job['job']); + $jobs3[$jobname] = $job; + $jobs3[$jobname]['target'] = "downloader"; + $jobs3[$jobname]['collection'] = array_key_exists('collection', $db) ? $db['collection'] : []; + $jobs3[$jobname]['downloaded'] = array_key_exists('downloaded', $db) ? $db['downloaded'] : []; + $jobs3[$jobname]['expire_field'] = $db['expire_field']; + $found++; + } + } + + if ($found != 1) { + $msg = sprintf("error migrating job \"%s\": databox not found or not unique", $jobname); + $this->logger->error(sprintf("%s", $msg)); + } + } + + $piconfig = [ + 'version' => 3, + 'jobs' => $jobs3 + ]; + } + + rename($config_file, $config_file . "_bkp"); + } + + $phrconfig->set(['plugins', 'expirating-rights-plugin', 'enabled'], false); + $phrconfig->set(['expiring-rights'], $piconfig); + } + + + private function migrateTable(appbox $appbox) + { + // create the table + $sql = "CREATE TABLE `ExpiringRights` (\n" + . "`id` int(11) unsigned NOT NULL AUTO_INCREMENT,\n" + . "`job` char(128) DEFAULT NULL,\n" + . "`downloaded` datetime DEFAULT NULL,\n" + . "`user_id` int(11) unsigned DEFAULT NULL,\n" + . "`email` char(128) DEFAULT NULL,\n" + . "`sbas_id` int(11) unsigned DEFAULT NULL,\n" + . "`base` char(50) DEFAULT NULL,\n" + . "`collection` char(50) DEFAULT NULL,\n" + . "`record_id` int(11) unsigned DEFAULT NULL,\n" + . "`title` char(200) DEFAULT NULL,\n" + . "`expire` datetime DEFAULT NULL,\n" + . "`new_expire` datetime DEFAULT NULL,\n" + . "`alerted` datetime DEFAULT NULL,\n" + . "PRIMARY KEY (`id`),\n" + . "KEY `job` (`job`),\n" + . "KEY `sbas_id` (`sbas_id`),\n" + . "KEY `expire` (`expire`),\n" + . "KEY `new_expire` (`new_expire`),\n" + . "KEY `alerted` (`alerted`),\n" + . "UNIQUE KEY `unique` (job,user_id,sbas_id,record_id)" + . ") ENGINE=InnoDB CHARSET=utf8;"; + + $stmt = $appbox->get_connection()->prepare($sql); + try { + $stmt->execute(); + } + catch(TableExistsException $e) { + // table v3 already exists, skip migration + return; + } + catch(\Exception $e) { + // fatal + return; + } + $stmt->closeCursor(); + + // migrate data from v-1 ? + try { + $appbox->get_connection()->exec("SELECT `id` FROM `_ExpiringRights` LIMIT 1"); + } + catch (\Exception $e) { + // failed : no table to copy ? + return; + } + + /** @var PropertyAccess $conf */ + $phrconfig = $this->app['conf']; + + if (!$phrconfig->has(['expiring-rights'])) { + return; + } + + // table v3 was just created, insert from table v-1 + $sql = "INSERT INTO `ExpiringRights` (\n" + . " `job`, \n" + . " `alerted`,\n" + . " `base`,\n" + . " `collection`,\n" + . " `downloaded`,\n" + . " `email`,\n" + . " `expire`,\n" + . " `new_expire`,\n" + . " `record_id`,\n" + . " `sbas_id`,\n" + . " `title`,\n" + . " `user_id`)\n" + . "SELECT\n" + . " :job AS `job`,\n" + . " MAX(`alerted`),\n" + . " `base`,\n" + . " `collection`,\n" + . " MAX(`downloaded`),\n" + . " `email`,\n" + . " MAX(`expire`),\n" + . " MAX(`new_expire`),\n" + . " `record_id`,\n" + . " `sbas_id`,\n" + . " `title`,\n" + . " `user_id`\n" + . "FROM `_ExpiringRights` GROUP BY `user_id`, `sbas_id`, `record_id` ORDER BY `id` ASC"; + $stmtCopy = $appbox->get_connection()->prepare($sql); + + $config = $phrconfig->get(['expiring-rights']); + foreach ($config['jobs'] as $jobname => $job) { + + // copy v-1 rows to v3 (add job) + $stmtCopy->execute([':job' => $jobname]); + + // fix alerted too early + $delta = $job['prior_notice']; + if ($delta > 0) { + $value = "(`expire`+INTERVAL " . $delta . " DAY)"; + } elseif ($delta < 0) { + $value = "(`expire`-INTERVAL " . -$delta . " DAY)"; + } else { + $value = "`expire`"; + } + $sql = "UPDATE `ExpiringRights` SET `alerted`=NULL WHERE `job` = :job AND `alerted` < " . $value; + $stmtEarly = $appbox->get_connection()->prepare($sql); + $stmtEarly->execute([':job' => $jobname]); + $stmtEarly->closeCursor(); + } + $stmtCopy->closeCursor(); + + // fix bad date + $sql = "UPDATE `ExpiringRights` SET `expire`=NULL WHERE `expire`='0000-00-00'"; + $stmt = $appbox->get_connection()->prepare($sql); + $stmt->execute([]); + $stmt->closeCursor(); + + // fix useless new_expire + $sql = "UPDATE `ExpiringRights` SET `new_expire`=NULL WHERE `new_expire` = `expire`"; + $stmt = $appbox->get_connection()->prepare($sql); + $stmt->execute([]); + $stmt->closeCursor(); + } + +}