diff --git a/api/v1/stats/sushi/PKPStatsSushiController.php b/api/v1/stats/sushi/PKPStatsSushiController.php
index c2c9e6961b0..8c9d08dcd1b 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,8 @@
use PKP\security\Role;
use PKP\sushi\CounterR5Report;
use PKP\sushi\SushiException;
+use PKP\validation\ValidatorFactory;
+use Symfony\Component\HttpFoundation\StreamedResponse;
class PKPStatsSushiController extends PKPBaseController
{
@@ -243,7 +246,7 @@ protected function getReportList(): array
* COUNTER 'Platform Usage' [PR_P1].
* A customizable report summarizing activity across the Platform (journal, press, or server).
*/
- public function getReportsPR(Request $illuminateRequest): JsonResponse
+ public function getReportsPR(Request $illuminateRequest): JsonResponse|StreamedResponse
{
return $this->getReportResponse(new PR(), $illuminateRequest);
}
@@ -252,17 +255,98 @@ public function getReportsPR(Request $illuminateRequest): JsonResponse
* COUNTER 'Platform Master Report' [PR].
* This is a Standard View of the Platform Master Report that presents usage for the overall Platform broken down by Metric_Type
*/
- public function getReportsPR1(Request $illuminateRequest): JsonResponse
+ public function getReportsPR1(Request $illuminateRequest): JsonResponse|StreamedResponse
{
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
+ protected function getReportResponse(CounterR5Report $report, Request $illuminateRequest): JsonResponse|StreamedResponse
{
$params = $illuminateRequest->query();
+ //$responseTSV = str_contains($illuminateRequest->getHeaderLine('Accept'), PKPRoutingProvider::RESPONSE_TSV['mime']) ? true : false;
+ $responseTSV = $illuminateRequest->accepts(PKPRoutingProvider::RESPONSE_TSV['mime']);
+
+ if ($responseTSV) {
+ $errors = $this->_validateUserInput($report, $params);
+ if (!empty($errors)) {
+ return response()->json($errors, 400);
+ }
+ }
try {
$report->processReportParams($this->getRequest(), $params);
@@ -270,6 +354,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()->withFile($report, [], count($reportItems));
+ }
+
$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..0063056af1e
--- /dev/null
+++ b/classes/components/listPanels/PKPCounterReportsListPanel.php
@@ -0,0 +1,52 @@
+ $this->apiUrl,
+ '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..0760ccd4118 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..69d51fa5f13 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,48 @@ 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([
+ 'pageInitConfig' => [
+ $counterReportsListPanel->id => $counterReportsListPanel->getConfig(),
+ 'usageNotPossible' => $lastDate <= $earliestDate,
+ ],
+ ]);
+ $templateMgr->assign([
+ 'pageComponent' => 'Page',
+ 'pageTitle' => __('manager.statistics.counterR5Reports'),
+ 'pageWidth' => TemplateManager::PAGE_WIDTH_FULL,
+ ]);
+ $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..0509d82d2f6
--- /dev/null
+++ b/templates/stats/counterReports.tpl
@@ -0,0 +1,14 @@
+{**
+ * 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"}
+
+{/block}