diff --git a/api/v1/stats/sushi/PKPStatsSushiController.php b/api/v1/stats/sushi/PKPStatsSushiController.php index c2c9e6961b0..86906df9f6c 100644 --- a/api/v1/stats/sushi/PKPStatsSushiController.php +++ b/api/v1/stats/sushi/PKPStatsSushiController.php @@ -26,6 +26,7 @@ use Illuminate\Support\Facades\Route; use PKP\core\PKPBaseController; use PKP\core\PKPRequest; +use PKP\core\PKPRoutingProvider; use PKP\security\authorization\ContextRequiredPolicy; use PKP\security\authorization\PolicySet; use PKP\security\authorization\RoleBasedHandlerOperationPolicy; @@ -33,6 +34,7 @@ use PKP\security\Role; use PKP\sushi\CounterR5Report; use PKP\sushi\SushiException; +use PKP\validation\ValidatorFactory; class PKPStatsSushiController extends PKPBaseController { @@ -257,12 +259,92 @@ public function getReportsPR1(Request $illuminateRequest): JsonResponse return $this->getReportResponse(new PR_P1(), $illuminateRequest); } + /** Validate user input for TSV reports */ + protected function _validateUserInput(CounterR5Report $report, array $params): array + { + $request = $this->getRequest(); + $context = $request->getContext(); + $earliestDate = CounterR5Report::getEarliestDate(); + $lastDate = CounterR5Report::getLastDate(); + $submissionIds = Repo::submission()->getCollector()->filterByContextIds([$context->getId()])->getIds()->implode(','); + + $rules = [ + 'begin_date' => [ + 'regex:/^\d{4}-\d{2}(-\d{2})?$/', + 'after_or_equal:' . $earliestDate, + 'before_or_equal:end_date', + ], + 'end_date' => [ + 'regex:/^\d{4}-\d{2}(-\d{2})?$/', + 'before_or_equal:' . $lastDate, + 'after_or_equal:begin_date', + ], + 'item_id' => [ + // TO-ASK: shell this rather be just validation for positive integer? + 'in:' . $submissionIds, + ], + 'yop' => [ + 'regex:/^\d{4}((\||-)\d{4})*$/', + ], + ]; + $reportId = $report->getID(); + if (in_array($reportId, ['PR', 'TR', 'IR'])) { + $rules['metric_type'] = ['required']; + } + + $errors = []; + $validator = ValidatorFactory::make( + $params, + $rules, + [ + 'begin_date.regex' => __( + 'manager.statistics.counterR5Report.settings.wrongDateFormat' + ), + 'end_date.regex' => __( + 'manager.statistics.counterR5Report.settings.wrongDateFormat' + ), + 'begin_date.after_or_equal' => __( + 'stats.dateRange.invalidStartDateMin' + ), + 'end_date.before_or_equal' => __( + 'stats.dateRange.invalidEndDateMax' + ), + 'begin_date.before_or_equal' => __( + 'stats.dateRange.invalidDateRange' + ), + 'end_date.after_or_equal' => __( + 'stats.dateRange.invalidDateRange' + ), + 'item_id.*' => __( + 'manager.statistics.counterR5Report.settings.wrongItemId' + ), + 'yop.regex' => __( + 'manager.statistics.counterR5Report.settings.wrongYOPFormat' + ), + ] + ); + + if ($validator->fails()) { + $errors = $validator->errors()->getMessages(); + } + + return $errors; + } + /** * Get the requested report */ protected function getReportResponse(CounterR5Report $report, Request $illuminateRequest): JsonResponse { $params = $illuminateRequest->query(); + $responseTSV = str_contains($illuminateRequest->getHeaderLine('Accept'), PKPRoutingProvider::RESPONSE_TSV) ? true : false; + + if ($responseTSV) { + $errors = $this->_validateUserInput($report, $params); + if (!empty($errors)) { + return response()->json($errors, 400); + } + } try { $report->processReportParams($this->getRequest(), $params); @@ -270,6 +352,27 @@ protected function getReportResponse(CounterR5Report $report, Request $illuminat return response()->json($e->getResponseData(), $e->getHttpStatusCode()); } + if ($responseTSV) { + $reportHeader = $report->getTSVReportHeader(); + $reportColumnNames = $report->getTSVColumnNames(); + $reportItems = $report->getTSVReportItems(); + // consider 3030 error (no usage available) + $key = array_search('3030', array_column($report->warnings, 'Code')); + if ($key !== false) { + $error = $report->warnings[$key]['Code'] . ':' . $report->warnings[$key]['Message'] . '(' . $report->warnings[$key]['Data'] . ')'; + foreach ($reportHeader as &$headerRow) { + if (in_array('Exceptions', $headerRow)) { + $headerRow[1] = + $headerRow[1] == '' ? + $error : + $headerRow[1] . ';' . $error; + } + } + } + $report = array_merge($reportHeader, [['']], $reportColumnNames, $reportItems); + return response()->withCSV($report, [], count($reportItems), PKPRoutingProvider::RESPONSE_TSV); + } + $reportHeader = $report->getReportHeader(); $reportItems = $report->getReportItems(); diff --git a/classes/components/forms/counter/PKPCounterReportForm.php b/classes/components/forms/counter/PKPCounterReportForm.php new file mode 100644 index 00000000000..03e67208131 --- /dev/null +++ b/classes/components/forms/counter/PKPCounterReportForm.php @@ -0,0 +1,66 @@ +action = $action; + $this->locales = $locales; + + $this->addPage(['id' => 'default', 'submitButton' => ['label' => __('common.download')]]); + $this->addGroup(['id' => 'default', 'pageId' => 'default']); + + $this->setReportFields(); + } + + public function getConfig() + { + $config = parent::getConfig(); + $config['reportFields'] = array_map(function ($reportFields) { + return array_map(function ($reportField) { + $field = $this->getFieldConfig($reportField); + $field['groupId'] = 'default'; + return $field; + }, $reportFields); + }, $this->reportFields); + + return $config; + } +} diff --git a/classes/components/listPanels/PKPCounterReportsListPanel.php b/classes/components/listPanels/PKPCounterReportsListPanel.php new file mode 100644 index 00000000000..6f39edc2e12 --- /dev/null +++ b/classes/components/listPanels/PKPCounterReportsListPanel.php @@ -0,0 +1,53 @@ + $this->apiUrl, + 'editCounterReportLabel' => __('manager.statistics.counterR5Report.settings'), + 'form' => $this->form->getConfig(), + 'usagePossible' => $lastDate > $earliestDate, + ] + ); + return $config; + } +} diff --git a/classes/services/queryBuilders/PKPStatsSushiQueryBuilder.php b/classes/services/queryBuilders/PKPStatsSushiQueryBuilder.php index 8bc79818fec..61cc5a47ac5 100644 --- a/classes/services/queryBuilders/PKPStatsSushiQueryBuilder.php +++ b/classes/services/queryBuilders/PKPStatsSushiQueryBuilder.php @@ -76,7 +76,10 @@ public function getSum(array $groupBy = []): Builder $q->leftJoin('publications as p', function ($q) { $q->on('p.submission_id', '=', 'm.submission_id') ->whereIn('p.publication_id', function ($q) { - $q->selectRaw('MIN(p2.publication_id)')->from('publications as p2')->where('p2.status', Submission::STATUS_PUBLISHED); + $q->selectRaw('MIN(p2.publication_id)') + ->from('publications as p2') + ->where('p2.status', Submission::STATUS_PUBLISHED) + ->where('p2.submission_id', '=', DB::raw('m.submission_id')); }); }); } @@ -123,7 +126,10 @@ protected function _getObject(): Builder $q->leftJoin('publications as p', function ($q) { $q->on('p.submission_id', '=', 'm.submission_id') ->whereIn('p.publication_id', function ($q) { - $q->selectRaw('MIN(p2.publication_id)')->from('publications as p2')->where('p2.status', Submission::STATUS_PUBLISHED); + $q->selectRaw('MIN(p2.publication_id)') + ->from('publications as p2') + ->where('p2.status', Submission::STATUS_PUBLISHED) + ->where('p2.submission_id', '=', DB::raw('m.submission_id')); }); }); foreach ($this->yearsOfPublication as $yop) { diff --git a/classes/sushi/CounterR5Report.php b/classes/sushi/CounterR5Report.php index c7c02aa4a48..4a5dd35835f 100644 --- a/classes/sushi/CounterR5Report.php +++ b/classes/sushi/CounterR5Report.php @@ -17,8 +17,14 @@ namespace PKP\sushi; +use APP\core\Application; use APP\facades\Repo; +use DateInterval; +use DatePeriod; use DateTime; +use Exception; +use PKP\components\forms\FieldSelect; +use PKP\components\forms\FieldText; use PKP\context\Context; abstract class CounterR5Report @@ -171,11 +177,16 @@ public function setAttributes(array $attributes): void } } - /** - * Get report items - */ + /** Get report items */ abstract public function getReportItems(): array; + /** Get report items prepared for TSV report */ + abstract public function getTSVReportItems(): array; + + /** Get TSV report column names */ + abstract public function getTSVColumnNames(): array; + + /** Add a warning */ protected function addWarning(array $exception): void { $this->warnings[] = $exception; @@ -292,25 +303,45 @@ protected function checkCustomerId($params): void } /** - * Validate the date parameters (begin_date, end_date) - * - * @throws SushiException + * Get the first month the usage data is available for COUNTER R5 reports. + * It is either: + * the next month of the COUNTER R5 start, or + * this journal's first publication date. */ - protected function checkDate($params): void + public static function getEarliestDate(): string { - // get the first month the usage data is available for COUNTER R5, it is either: - // the next month of the COUNTER R5 start, or - // this journal's first publication date. + $context = Application::get()->getRequest()->getContext(); $statsService = app()->get('sushiStats'); $counterR5StartDate = $statsService->getEarliestDate(); $firstDatePublished = Repo::publication()->getDateBoundaries( Repo::publication() ->getCollector() - ->filterByContextIds([$this->context->getId()]) + ->filterByContextIds([$context->getId()]) )->min_date_published; $earliestDate = strtotime($firstDatePublished) > strtotime($counterR5StartDate) ? $firstDatePublished : $counterR5StartDate; $earliestDate = date('Y-m-01', strtotime($earliestDate . ' + 1 months')); - $lastDate = date('Y-m-d', strtotime('last day of previous month')); // get the last month in the DB table + return $earliestDate; + } + + /** + * Get the last possible date COUNTER R5 reports could exist for. + * This is the last day of the previous month, + * because the all stats for the previous month should be already compiled. + */ + public static function getLastDate(): string + { + return date('Y-m-d', strtotime('last day of previous month')); + } + + /** + * Validate the date parameters (begin_date, end_date) + * + * @throws SushiException + */ + protected function checkDate($params): void + { + $earliestDate = self::getEarliestDate(); + $lastDate = self::getLastDate(); $beginDate = $params['begin_date']; $endDate = $params['end_date']; @@ -531,6 +562,78 @@ public function getReportHeader(): array return $reportHeader; } + /** Get report header for TSV reports */ + public function getTSVReportHeader(): array + { + $institutionIds = []; + if (isset($this->institutionIds)) { + foreach ($this->institutionIds as $institutionId) { + if ($institutionId['Type'] == 'Proprietary') { + $institutionIds[] = $institutionId['Value']; + } else { + $institutionIds[] = $institutionId['Type'] . ':' . $institutionId['Value']; + } + } + } + $reportHeaderInstitutionId = !empty($institutionIds) ? implode(';', $institutionIds) : ''; + $reportHeaderMetricTypes = $beginDate = $endDate = ''; + $reportHeaderFilters = $reportHeaderAttributes = []; + foreach ($this->filters as $filter) { + switch ($filter['Name']) { + case ('Metric_Type'): + $reportHeaderMetricTypes = implode(';', explode('|', $filter['Value'])); + break; + case ('Begin_Date'): + $beginDate = $filter['Name'] . '=' . $filter['Value']; + break; + case ('End_Date'): + $endDate = $filter['Name'] . '=' . $filter['Value']; + break; + default: + $reportHeaderFilters[] = $filter['Name'] . '=' . $filter['Value']; + } + } + foreach ($this->attributes as $attribute) { + if ($attribute['Name'] == 'granularity') { + $excludeMonthlyDetails = $attribute['Value'] == 'Month' ? 'False' : 'True'; + $reportHeaderAttributes[] = 'Exclude_Monthly_Details' . '=' . $excludeMonthlyDetails; + } else { + $reportHeaderAttributes[] = $attribute['Name'] . '=' . $attribute['Value']; + } + } + + $exceptions = []; + foreach ($this->warnings as $warning) { + $exceptions[] = $warning['Code'] . ':' . $warning['Message'] . '(' . $warning['Data'] . ')'; + } + + $reportHeader = [ + ['Report_Name', $this->getName()], + ['Report_ID', $this->getID()], + ['Release', $this->getRelease()], + ['Institution_Name', $this->institutionName], + ['Institution_ID', $reportHeaderInstitutionId], + ['Metric_Types', $reportHeaderMetricTypes], + ['Report_Filters', implode(';', $reportHeaderFilters)], + ['Report_Attributes', implode(';', $reportHeaderAttributes)], + ['Exceptions', implode(';', $exceptions)], + ['Reporting_Period', $beginDate . ';' . $endDate], + ['Created', date('Y-m-d\TH:i:s\Z', time())], + ['Created_By', $this->platformName], + ]; + return $reportHeader; + } + + /** Get monthly period */ + protected function getMonthlyDatePeriod(): DatePeriod + { + // every month for the given period needs to be considered + $start = new DateTime($this->beginDate); + $end = new DateTime($this->endDate); + $interval = DateInterval::createFromDateString('1 month'); + return new DatePeriod($start, $interval, $end); + } + /** * Validate date, check if the date is a valid date and in requested format */ @@ -539,4 +642,51 @@ protected function validateDate(string $date, string $format = 'Y-m-d'): bool $d = DateTime::createFromFormat($format, $date); return $d && $d->format($format) === $date; } + + /** + * Get report form fields common to all reports + */ + public static function getCommonReportSettingsFormFields(): array + { + $context = Application::get()->getRequest()->getContext(); + $institutions = Repo::institution()->getCollector() + ->filterByContextIds([$context->getId()]) + ->getMany(); + + $institutionOptions = [['value' => '0', 'label' => 'The World']]; + foreach ($institutions as $institution) { + $institutionOptions[] = ['value' => $institution->getId(), 'label' => $institution->getLocalizedName()]; + } + + $earliestDate = self::getEarliestDate(); + $lastDate = self::getLastDate(); + + return [ + new FieldText('begin_date', [ + 'label' => __('manager.statistics.counterR5Report.settings.startDate'), + 'description' => __('manager.statistics.counterR5Report.settings.date.startDate.description', ['earliestDate' => $earliestDate]), + 'size' => 'small', + 'isMultilingual' => false, + 'isRequired' => true, + 'value' => $earliestDate, + 'groupId' => 'default', + ]), + new FieldText('end_date', [ + 'label' => __('manager.statistics.counterR5Report.settings.endDate'), + 'description' => __('manager.statistics.counterR5Report.settings.date.endDate.description', ['lastDate' => $lastDate]), + 'size' => 'small', + 'isMultilingual' => false, + 'isRequired' => true, + 'value' => $lastDate, + 'groupId' => 'default', + ]), + new FieldSelect('customer_id', [ + 'label' => __('manager.statistics.counterR5Report.settings.customerId'), + 'options' => $institutionOptions, + 'value' => '0', + 'isRequired' => true, + 'groupId' => 'default', + ]), + ]; + } } diff --git a/classes/template/PKPTemplateManager.php b/classes/template/PKPTemplateManager.php index d21963db271..69946dc19fe 100644 --- a/classes/template/PKPTemplateManager.php +++ b/classes/template/PKPTemplateManager.php @@ -1166,6 +1166,11 @@ public function setupBackendPage() 'name' => __('manager.users'), 'url' => $router->url($request, null, 'stats', 'users', ['users']), 'isCurrent' => $router->getRequestedPage($request) === 'stats' && $router->getRequestedOp($request) === 'users', + ], + 'counterR5' => [ + 'name' => __('manager.statistics.counterR5'), + 'url' => $router->url($request, null, 'stats', 'counterR5', 'counterR5'), + 'isCurrent' => $router->getRequestedPage($request) === 'stats' && $router->getRequestedOp($request) === 'counterR5', ] ] ]; diff --git a/locale/en/manager.po b/locale/en/manager.po index 54e1e87cfb6..833ffff4e2f 100644 --- a/locale/en/manager.po +++ b/locale/en/manager.po @@ -849,6 +849,66 @@ msgstr "Whether or not to restrict access to the API endpoints for COUNTER SUSHI msgid "manager.settings.statistics.publicSushiApi.public" msgstr "Make the COUNTER SUSHI statistics publicly available" +msgid "manager.statistics.counterR5" +msgstr "Counter R5" + +msgid "manager.statistics.counterR5Reports" +msgstr "Counter R5 Reports" + +msgid "manager.statistics.counterR5Reports.description" +msgstr "See COUNTER 5.0.3 documentation for more information about each report." + +msgid "manager.statistics.counterR5Reports.usageNotPossible" +msgstr "There are no COUNTER R5 usage statistics available yet." + +msgid "manager.statistics.counterR5Report.settings" +msgstr "Report Settings" + +msgid "manager.statistics.counterR5Report.settings.startDate" +msgstr "Start Date" + +msgid "manager.statistics.counterR5Report.settings.date.startDate.description" +msgstr "Date should be in format YYYY-MM-DD or YYYY-MM. Earliest possible date is {$earliestDate}." + +msgid "manager.statistics.counterR5Report.settings.endDate" +msgstr "End Date" + +msgid "manager.statistics.counterR5Report.settings.date.endDate.description" +msgstr "Date should be in format YYYY-MM-DD or YYYY-MM. Last possible date is {$lastDate}." + +msgid "manager.statistics.counterR5Report.settings.wrongDateFormat" +msgstr "The date format is not valid." + +msgid "manager.statistics.counterR5Report.settings.customerId" +msgstr "Customer ID" + +msgid "manager.statistics.counterR5Report.settings.metricType" +msgstr "Metric Type" + +msgid "manager.statistics.counterR5Report.settings.attributesToShow" +msgstr "Attributes To Show" + +msgid "manager.statistics.counterR5Report.settings.yop" +msgstr "Year Of Publication" + +msgid "manager.statistics.counterR5Report.settings.date.yop.description" +msgstr "A list or range of years of publication to return in response in format of yyyy|yyyy|yyyy-yyyy." + +msgid "manager.statistics.counterR5Report.settings.wrongYOPFormat" +msgstr "YOP format is not valid." + +msgid "manager.statistics.counterR5Report.settings.itemId" +msgstr "Submission ID" + +msgid "manager.statistics.counterR5Report.settings.wrongItemId" +msgstr "The submission ID does not exist." + +msgid "manager.statistics.counterR5Report.settings.includeParentDetails" +msgstr "Include Parent Details" + +msgid "manager.statistics.counterR5Report.settings.excludeMonthlyDetails" +msgstr "Exclude Monthly Details" + msgid "manager.statistics.reports" msgstr "Reports" diff --git a/pages/stats/PKPStatsHandler.php b/pages/stats/PKPStatsHandler.php index 897d27d5f66..79582831d14 100644 --- a/pages/stats/PKPStatsHandler.php +++ b/pages/stats/PKPStatsHandler.php @@ -27,6 +27,7 @@ use PKP\security\authorization\ContextAccessPolicy; use PKP\security\Role; use PKP\statistics\PKPStatisticsHelper; +use PKP\sushi\CounterR5Report; class PKPStatsHandler extends Handler { @@ -41,7 +42,7 @@ public function __construct() parent::__construct(); $this->addRoleAssignment( [Role::ROLE_ID_SITE_ADMIN, Role::ROLE_ID_MANAGER, Role::ROLE_ID_SUB_EDITOR], - ['editorial', 'publications', 'context', 'users', 'reports'] + ['editorial', 'publications', 'context', 'users', 'reports', 'counterR5'] ); } @@ -430,6 +431,46 @@ public function context($args, $request) $templateMgr->display('stats/context.tpl'); } + /** + * Display list of available COUNTER R5 reports + */ + public function counterR5(array $args, Request $request): void + { + $templateMgr = TemplateManager::getManager($request); + $this->setupTemplate($request); + + $apiUrl = $request->getDispatcher()->url($request, PKPApplication::ROUTE_API, $request->getContext()->getPath(), 'stats/sushi'); + + $context = $request->getContext(); + $locales = $context->getSupportedFormLocaleNames(); + $locales = array_map(fn (string $locale, string $name) => ['key' => $locale, 'label' => $name], array_keys($locales), $locales); + + $counterReportForm = new \APP\components\forms\counter\CounterReportForm($apiUrl, $locales); + + $counterReportsListPanel = new \PKP\components\listPanels\PKPCounterReportsListPanel( + 'counterReportsListPanel', + __('manager.statistics.counterR5Reports'), + [ + 'apiUrl' => $apiUrl, + 'form' => $counterReportForm, + ] + ); + + $earliestDate = CounterR5Report::getEarliestDate(); + $lastDate = CounterR5Report::getLastDate(); + + $templateMgr->setState([ + 'components' => [ + $counterReportsListPanel->id => $counterReportsListPanel->getConfig(), + ], + ]); + $templateMgr->assign([ + 'pageComponent' => 'CounterReportsPage', + 'usagePossible' => $lastDate > $earliestDate, + ]); + $templateMgr->display('stats/counterReports.tpl'); + } + /** * Display users stats * diff --git a/templates/stats/counterReports.tpl b/templates/stats/counterReports.tpl new file mode 100644 index 00000000000..ce5b9027ed2 --- /dev/null +++ b/templates/stats/counterReports.tpl @@ -0,0 +1,28 @@ +{** + * templates/stats/counterReports.tpl + * + * Copyright (c) 2024 Simon Fraser University + * Copyright (c) 2024 John Willinsky + * Distributed under the GNU GPL v3. For full terms see the file docs/COPYING. + * + * @brief Set up and download COUNTER R5 TSV reports + *} +{extends file="layouts/backend.tpl"} + +{block name="page"} +
{translate key="manager.statistics.counterR5Reports.description"}
+ {if !$usagePossible} +