diff --git a/.dockerignore b/.dockerignore index 0c8887c29d..5f7bf09347 100644 --- a/.dockerignore +++ b/.dockerignore @@ -19,6 +19,7 @@ !/bin/developer !/bin/setup !/bin/maintenance +!/bin/report /cache /config/configuration.yml /config/configuration-compiled.php diff --git a/.gitignore b/.gitignore index b741b4c0de..730df1318e 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,7 @@ !/bin/developer !/bin/setup !/bin/maintenance +!/bin/report # Exclude composer composer.phar diff --git a/bin/report b/bin/report new file mode 100755 index 0000000000..4a59aca2b0 --- /dev/null +++ b/bin/report @@ -0,0 +1,32 @@ +#!/usr/bin/env php +getName() . ' ' . $version->getNumber()); + + +$cli->command(new ConnectionsCommand()); +$cli->command(new CountAssetsCommand()); +$cli->command(new CountUsersCommand()); + +$cli->run(); diff --git a/lib/Alchemy/Phrasea/Command/Report/AbstractReportCommand.php b/lib/Alchemy/Phrasea/Command/Report/AbstractReportCommand.php new file mode 100644 index 0000000000..ef906d05bf --- /dev/null +++ b/lib/Alchemy/Phrasea/Command/Report/AbstractReportCommand.php @@ -0,0 +1,175 @@ +addOption('databox_id', null, InputOption::VALUE_REQUIRED, 'the application databox') + ->addOption('email', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY ,'emails to send the report') + ->addOption('dmin', null, InputOption::VALUE_REQUIRED, 'minimum date yyyy-mm-dd') + ->addOption('dmax', null, InputOption::VALUE_REQUIRED, 'maximum date yyyy-mm-dd, until today if not set') + ->addOption('range', null, InputOption::VALUE_REQUIRED, "period range until now eg: '10 days', '2 weeks', '6 months', ' 1 year'") + ; + } + + protected function doExecute(InputInterface $input, OutputInterface $output) + { + $this->setDelivererLocator(new LazyLocator($this->container, 'notification.deliverer')); + + $this->sbasId = $input->getOption('databox_id'); + $this->emails = $input->getOption('email'); + $this->dmin = $input->getOption('dmin'); + $this->dmax = $input->getOption('dmax'); + $this->range = $input->getOption('range'); + + if(!empty($this->range) && (!empty($this->dmin) || !empty($this->dmax))) { + $output->writeln("do not use '--range' with '--dmin' or '--dmax'"); + + return 1; + } + + if (!empty($this->range)) { + $matches = []; + preg_match("/(\d+) (day|week|month|year)s?/i", $this->range, $matches); + $n = count($matches); + + if ($n === 3) { + try { + $this->dmin = (new \DateTime('-' . $matches[0]))->format('Y-m-d'); + } catch (\Exception $e) { + // not happen cause don't match if bad format + $output->writeln("invalid value form '--range' option"); + + return 1; + } + } else { + $output->writeln("invalid value form '--range' option"); + + return 1; + } + } + + if (empty($this->emails)) { + $output->writeln("set '--email' option"); + + return 1; + } + + if (!$this->isDateOk($this->dmin)) { + $output->writeln("invalid value from '--dmin' option"); + + return 1; + } + + if (!empty($this->dmax) && !$this->isDateOk($this->dmax)) { + $output->writeln("invalid value from '--dmax' option"); + + return 1; + } + + $report = $this->getReport($input, $output); + + if (!$report instanceof Report) { + return 1; + } + + $report->setFormat(Report::FORMAT_CSV); + + $absoluteDirectoryPath = \p4string::addEndSlash($this->container['tmp.download.path']) + .'report' . DIRECTORY_SEPARATOR + . date('Ymd'); + + $suffixFileName = "_" . $this->dmin . "_to_"; + $suffixFileName = !empty($this->dmax) ? $suffixFileName . $this->dmax: $suffixFileName . "now"; + + if ($this->isAppboxConnection) { + $absoluteDirectoryPath .= 'appbox'; + } else { + $absoluteDirectoryPath .= 'Sbas' . $this->sbasId; + } + + $report->render($absoluteDirectoryPath, $suffixFileName); + + $filePath = $absoluteDirectoryPath . DIRECTORY_SEPARATOR . $this->normalizeString($report->getName()).$suffixFileName . '.csv'; + + $attachement = new Attachment($filePath); + + foreach ($this->emails as $email) { + $receiver = new Receiver('', $email); + $mail = MailReportConnections::create($this->container, $receiver); + + $this->deliver($mail, false, [$attachement]); + } + + $output->writeln("finish !"); + + return 0; + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @return Report + */ + abstract protected function getReport(InputInterface $input, OutputInterface $output); + + /** + * @param int $sbasId + * @return \databox + */ + protected function findDbOr404($sbasId) + { + $db = $this->container->getApplicationBox()->get_databox(($sbasId)); + if(!$db) { + throw new NotFoundHttpException(sprintf('Databox %s not found', $sbasId)); + } + + return $db; + } + + private function isDateOk($date) + { + $matches = []; + preg_match("/(\d{4}-\d{2}-\d{2})/i", $date, $matches); + $n = count($matches); + if ($n === 2) { + return true; + } + + return false; + } + + private function normalizeString($filename) + { + return (new Slugify())->slugify($filename, '-'); + } +} diff --git a/lib/Alchemy/Phrasea/Command/Report/ConnectionsCommand.php b/lib/Alchemy/Phrasea/Command/Report/ConnectionsCommand.php new file mode 100644 index 0000000000..cebe98856a --- /dev/null +++ b/lib/Alchemy/Phrasea/Command/Report/ConnectionsCommand.php @@ -0,0 +1,58 @@ +setDescription('BETA - Get all connections report') + ->addOption('type', null, InputOption::VALUE_REQUIRED, 'type of report connections, if not defined or empty it is for all connections') + + ->setHelp( + "eg: bin/report connections:all --databox_id 2 --email 'admin@alchemy.fr' --dmin '2022-12-01' --dmax '2023-01-01' --type 'os,nav' \n" + . "\type of report\n" + . "- '' or not defined all connections\n" + . "- 'user' connections by user\n" + . "- 'nav' connections by browser\n" + . "- 'nav,version' connections by browser, version \n" + . "- 'os' connections by OS \n" + . "- 'os,nav' connections by OS, browser \n" + . "- 'os,nav,version' connections by OS, Browser, Version\n" + . "- 'res' connections by Screen Res \n" + ); + } + + public function getReport(InputInterface $input, OutputInterface $output) + { + $type = $input->getOption('type'); + + if (!empty($type) && !in_array($type, self::TYPES)) { + $output->writeln("wrong '--type' option (--help for available value)"); + + return 1; + } + + return + (new ReportConnections( + $this->findDbOr404($this->sbasId), + [ + 'dmin' => $this->dmin, + 'dmax' => $this->dmax, + 'group' => $type, + 'anonymize' => $this->container['conf']->get(['registry', 'modules', 'anonymous-report']) + ] + )) + ->setAppKey($this->container['conf']->get(['main', 'key'])); + } +} diff --git a/lib/Alchemy/Phrasea/Command/Report/CountAssetsCommand.php b/lib/Alchemy/Phrasea/Command/Report/CountAssetsCommand.php new file mode 100644 index 0000000000..e0f9a20991 --- /dev/null +++ b/lib/Alchemy/Phrasea/Command/Report/CountAssetsCommand.php @@ -0,0 +1,63 @@ +setDescription('BETA - Get assets count') + ->addOption('type', null, InputOption::VALUE_REQUIRED, 'type of count assets report ') + + ->setHelp( + "eg: bin/report count:assets --databox_id 2 --email 'admin@alchemy.fr' --dmin '2021-12-01' --dmax '2023-01-01' --type 'added,year,month' \n" + . "\type of report\n" + . "- 'added,year' number of added assets per year\n" + . "- 'added,year,month' number of added assets per year, month\n" + . "- 'downloaded,year' number of downloaded per year \n" + . "- 'downloaded,year,month' number of downloaded per year, month \n" + . "- 'downloaded,year,month,action' number of downloaded per year, month, action (direct download or by email) \n" + . "- 'most-downloaded' The 10 most downloaded assets \n" + ); + } + + /** + * @inheritDoc + */ + protected function getReport(InputInterface $input, OutputInterface $output) + { + $type = $input->getOption('type'); + + if (!empty($type) && !in_array($type, self::TYPES)) { + $output->writeln("wrong '--type' option (--help for available value)"); + + return 1; + } + + return new ReportCountAssets( + $this->findDbOr404($this->sbasId), + [ + 'dmin' => $this->dmin, + 'dmax' => $this->dmax, + 'group' => $type + ] + ); + } +} diff --git a/lib/Alchemy/Phrasea/Command/Report/CountUsersCommand.php b/lib/Alchemy/Phrasea/Command/Report/CountUsersCommand.php new file mode 100644 index 0000000000..d0ed8fe2ce --- /dev/null +++ b/lib/Alchemy/Phrasea/Command/Report/CountUsersCommand.php @@ -0,0 +1,66 @@ +setDescription('BETA - Get users count') + ->addOption('type', null, InputOption::VALUE_REQUIRED, 'type of users count report') + + ->setHelp( + "eg: bin/report count:users --databox_id 2 --email 'admin@alchemy.fr' --dmin '2022-01-01' --dmax '2023-01-01' --type 'added,year,month' \n" + . "\type users count report\n" + . "- 'added,year' number of newly added user per year\n" + . "- 'added,year,month' number of newly added user per year, month\n" + ); + } + + /** + * @inheritDoc + */ + protected function getReport(InputInterface $input, OutputInterface $output) + { + $type = $input->getOption('type'); + $this->isAppboxConnection = true; + + if (!empty($type) && !in_array($type, self::TYPES)) { + $output->writeln("wrong '--type' option (--help for available value)"); + + return 1; + } + + // get just one databox registered to initialize the base class Report + $databoxes = $this->container->getDataboxes(); + + if (count($databoxes) > 0) { + $databox = current($databoxes); + } else { + throw new NotFoundHttpException("NO databox set on this application"); + } + + return new ReportUsers( + $databox, + [ + 'dmin' => $this->dmin, + 'dmax' => $this->dmax, + 'group' => $type + ] + ); + } +} diff --git a/lib/Alchemy/Phrasea/Notification/Mail/MailReportConnections.php b/lib/Alchemy/Phrasea/Notification/Mail/MailReportConnections.php new file mode 100644 index 0000000000..ae9eca08ff --- /dev/null +++ b/lib/Alchemy/Phrasea/Notification/Mail/MailReportConnections.php @@ -0,0 +1,37 @@ +app->trans("mail:: report", [], 'messages', $this->getLocale()); + } + + /** + * @inheritDoc + */ + public function getMessage() + { + return $this->app->trans("mail:: report messages", [], 'messages', $this->getLocale()); + } + + /** + * @inheritDoc + */ + public function getButtonText() + { + } + + /** + * @inheritDoc + */ + public function getButtonURL() + { + } +} diff --git a/lib/Alchemy/Phrasea/Out/Module/Excel.php b/lib/Alchemy/Phrasea/Out/Module/Excel.php index eaaf83a02d..d795db96d0 100644 --- a/lib/Alchemy/Phrasea/Out/Module/Excel.php +++ b/lib/Alchemy/Phrasea/Out/Module/Excel.php @@ -83,4 +83,9 @@ class Excel $this->writer->close(); } + public function getWriter() + { + return $this->writer; + } + } diff --git a/lib/Alchemy/Phrasea/Report/Report.php b/lib/Alchemy/Phrasea/Report/Report.php index 5fe6ed5bf8..b6095f1236 100644 --- a/lib/Alchemy/Phrasea/Report/Report.php +++ b/lib/Alchemy/Phrasea/Report/Report.php @@ -12,6 +12,7 @@ namespace Alchemy\Phrasea\Report; use Alchemy\Phrasea\Application; use Alchemy\Phrasea\Out\Module\Excel; +use Cocur\Slugify\Slugify; abstract class Report @@ -105,14 +106,14 @@ abstract class Report return $this->format; } - public function render() + public function render($absoluteDirectoryPath = null, $suffixFileName = null) { switch($this->format) { //case self::FORMAT_XLS: case self::FORMAT_CSV: case self::FORMAT_ODS: case self::FORMAT_XLSX: - $this->renderAsExcel(); + $this->renderAsExcel($absoluteDirectoryPath, $suffixFileName); break; default: // should not happen since format is checked before @@ -120,25 +121,40 @@ abstract class Report } } - private function renderAsExcel() + private function renderAsExcel($absoluteDirectoryPath = null, $suffixFileName = null) { + $filename = $this->normalizeString($this->getName()) . $suffixFileName; switch($this->format) { //case self::FORMAT_XLS: // $excel = new Excel(Excel::FORMAT_XLS); // header('Content-Type: application/vnd.ms-excel'); // break; case self::FORMAT_XLSX: - $excel = new Excel(Excel::FORMAT_XLSX, $this->getName() . ".xlsx"); + $filename .= ".xlsx"; + $excel = new Excel(Excel::FORMAT_XLSX, $filename); break; case self::FORMAT_ODS: - $excel = new Excel(Excel::FORMAT_ODS, $this->getName() . ".ods"); + $filename .= ".ods"; + $excel = new Excel(Excel::FORMAT_ODS, $filename); break; case self::FORMAT_CSV: default: - $excel = new Excel(Excel::FORMAT_CSV, $this->getName() . ".csv"); + $filename .= ".csv"; + $excel = new Excel(Excel::FORMAT_CSV, $filename); break; } + // override the open to browser by the writer + if (!empty($absoluteDirectoryPath)) { + if (!is_dir($absoluteDirectoryPath)) { + @mkdir($absoluteDirectoryPath, 0777, true); + } + + $filePath = \p4string::addEndSlash($absoluteDirectoryPath) . $filename; + @touch($filePath); + $excel->getWriter()->openToFile($filePath); + } + $excel->addRow($this->getColumnTitles()); $n = 0; @@ -154,4 +170,8 @@ abstract class Report $excel->render(); } + private function normalizeString($filename) + { + return (new Slugify())->slugify($filename, '-'); + } } diff --git a/lib/Alchemy/Phrasea/Report/ReportCountAssets.php b/lib/Alchemy/Phrasea/Report/ReportCountAssets.php new file mode 100644 index 0000000000..9e24dc9fd4 --- /dev/null +++ b/lib/Alchemy/Phrasea/Report/ReportCountAssets.php @@ -0,0 +1,121 @@ +computeVars(); + + return $this->name; + } + + public function getColumnTitles() + { + $this->computeVars(); + + return $this->columnTitles; + } + + public function getKeyName() + { + $this->computeVars(); + + return $this->keyName; + } + + public function getAllRows($callback) + { + $this->computeVars(); + $stmt = $this->databox->get_connection()->executeQuery($this->sql, []); + while (($row = $stmt->fetch())) { + $callback($row); + } + $stmt->closeCursor(); + } + + private function computeVars() + { + if(!is_null($this->name)) { + // vars already computed + return; + } + + $sqlWhereDate = " date>=" . $this->databox->get_connection()->quote($this->parms['dmin']); + + if ($this->parms['dmax']) { + $sqlWhereDate .= " AND date<=" . $this->databox->get_connection()->quote($this->parms['dmax']); + } + + switch($this->parms['group']) { + case 'added,year': + $this->name = "Number of added assets per year"; + $this->columnTitles = ['year', 'nb']; + $this->sql = "SELECT YEAR(date) AS year,COUNT(record_id) AS nb \n" + . "FROM log_docs WHERE action='add' AND " . $sqlWhereDate ."\n" + . "GROUP BY year"; + + break; + case 'added,year,month': + $this->name = "Number of added assets per year, month"; + $this->columnTitles = ['year', 'month', 'nb']; + $this->sql = "SELECT YEAR(date) AS year, MONTH(date) AS month, COUNT(record_id) AS nb \n" + . "FROM log_docs WHERE action='add' AND " . $sqlWhereDate ."\n" + . "GROUP BY year, month"; + + break; + case 'downloaded,year': + $this->name = "Number of downloaded assets per year"; + $this->columnTitles = ['year', 'nb']; + $this->sql = "SELECT YEAR(date) AS year, COUNT(record_id) AS nb \n" + . "FROM log_docs WHERE (action='download' OR action='mail') AND " . $sqlWhereDate ."\n" + . "GROUP BY year"; + + break; + case 'downloaded,year,month': + $this->name = "Number of downloaded assets per year, month"; + $this->columnTitles = ['year', 'month', 'nb']; + $this->sql = "SELECT YEAR(date) AS year, MONTH(date) AS month, COUNT(record_id) AS nb \n" + . "FROM log_docs WHERE (action='download' OR action='mail') AND " . $sqlWhereDate ."\n" + . "GROUP BY year, month"; + + break; + case 'downloaded,year,month,action': + $this->name = "Number of downloaded assets per year, month, action"; + $this->columnTitles = ['action', 'year', 'month', 'nb']; + $this->sql = "SELECT action, YEAR(date) AS year, MONTH(date) AS month, COUNT(record_id) AS nb \n" + . "FROM log_docs WHERE (action='download' OR action='mail') AND " . $sqlWhereDate ."\n" + . "GROUP BY year, month, action"; + + break; + case 'most-downloaded': + $this->name = "Most downloaded assets"; + $this->columnTitles = ['record_id', 'nb']; + $this->sql = "SELECT record_id, COUNT(record_id) AS nb \n" + . "FROM log_docs WHERE (action='download' OR action='mail') AND " . $sqlWhereDate ."\n" + . "GROUP BY record_id ORDER BY nb DESC limit 20"; + + break; + default: + throw new InvalidArgumentException('invalid "group" argument'); + break; + } + } +} diff --git a/lib/Alchemy/Phrasea/Report/ReportDownloads.php b/lib/Alchemy/Phrasea/Report/ReportDownloads.php index 7ff826a18b..d9db4d0529 100644 --- a/lib/Alchemy/Phrasea/Report/ReportDownloads.php +++ b/lib/Alchemy/Phrasea/Report/ReportDownloads.php @@ -80,16 +80,16 @@ class ReportDownloads extends Report switch ($this->parms['group']) { case null: $this->name = "Downloads"; - $this->columnTitles = ['id', 'usrid', 'user', 'fonction', 'societe', 'activite', 'pays', 'date', 'record_id', 'coll_id', 'subdef']; + $this->columnTitles = ['id', 'usrid', 'user', 'fonction', 'societe', 'activite', 'pays', 'date', 'record_id', 'coll_id', 'subdef', 'action']; if($this->parms['anonymize']) { $sql = "SELECT `ld`.`id`, `l`.`usrid`, '-' AS `user`, '-' AS `fonction`, '-' AS `societe`, '-' AS `activite`, '-' AS `pays`,\n" - . " `ld`.`date`, `ld`.`record_id`, `ld`.`coll_id`, `ld`.`final`" + . " `ld`.`date`, `ld`.`record_id`, `ld`.`coll_id`, `ld`.`final`, `ld`.`action`" . " FROM `log_docs` AS `ld` INNER JOIN `log` AS `l` ON `l`.`id`=`ld`.`log_id`\n" . " WHERE {{GlobalFilter}}"; } else { $sql = "SELECT `ld`.`id`, `l`.`usrid`, `l`.`user`, `l`.`fonction`, `l`.`societe`, `l`.`activite`, `l`.`pays`,\n" - . " `ld`.`date`, `ld`.`record_id`, `ld`.`coll_id`, `ld`.`final`" + . " `ld`.`date`, `ld`.`record_id`, `ld`.`coll_id`, `ld`.`final`, `ld`.`action`" . " FROM `log_docs` AS `ld` INNER JOIN `log` AS `l` ON `l`.`id`=`ld`.`log_id`\n" . " WHERE {{GlobalFilter}}"; } @@ -151,7 +151,7 @@ class ReportDownloads extends Report $subdefsToReport = join(',', $subdefsToReport); - $filter = "`action`='download' AND `ld`.`coll_id` IN(" . join(',', $collIds) . ")\n" + $filter = "(`action`='download' OR `action`='mail') AND `ld`.`coll_id` IN(" . join(',', $collIds) . ")\n" . " AND `l`.`usrid`>0\n" . " AND `ld`.`final` IN(" . $subdefsToReport . ")"; diff --git a/lib/Alchemy/Phrasea/Report/ReportUsers.php b/lib/Alchemy/Phrasea/Report/ReportUsers.php new file mode 100644 index 0000000000..c1ec06c6d8 --- /dev/null +++ b/lib/Alchemy/Phrasea/Report/ReportUsers.php @@ -0,0 +1,89 @@ +computeVars(); + + return $this->name; + } + + public function getColumnTitles() + { + $this->computeVars(); + + return $this->columnTitles; + } + + public function getKeyName() + { + $this->computeVars(); + + return $this->keyName; + } + + public function getAllRows($callback) + { + $this->computeVars(); + // use appbox connection + $stmt = $this->databox->get_appbox()->get_connection()->executeQuery($this->sql, []); + while (($row = $stmt->fetch())) { + $callback($row); + } + $stmt->closeCursor(); + } + + private function computeVars() + { + if(!is_null($this->name)) { + // vars already computed + return; + } + $sqlWhereDate = " created>=" . $this->databox->get_appbox()->get_connection()->quote($this->parms['dmin']); + + if ($this->parms['dmax']) { + $sqlWhereDate .= " AND created<=" . $this->databox->get_appbox()->get_connection()->quote($this->parms['dmax']); + } + + switch($this->parms['group']) { + case 'added,year': + $this->name = "Number of newly added user per year"; + $this->columnTitles = ['year', 'nb']; + $this->sql = "SELECT YEAR(created) AS year, COUNT(id) AS nb \n " + . "FROM Users \n" + . "WHERE ". $sqlWhereDate . "\n" + . "GROUP BY year"; + break; + case 'added,year,month': + $this->name = "Number of newly added user per year,month"; + $this->columnTitles = ['year', 'month', 'nb']; + $this->sql = "SELECT YEAR(created) AS year, MONTH(created) AS month, COUNT(id) AS nb \n " + . "FROM Users \n" + . "WHERE ". $sqlWhereDate . "\n" + . "GROUP BY year, month"; + break; + default: + throw new InvalidArgumentException('invalid "group" argument'); + break; + } + } +}