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;
+ }
+ }
+}