<?php
/**
* @file classes/sushi/CounterR5Report.php
*
* Copyright (c) 2022 Simon Fraser University
* Copyright (c) 2022 John Willinsky
* Distributed under the GNU GPL v3. For full terms see the file docs/COPYING.
*
* @class CounterR5Report
*
* @ingroup sushi
*
* @brief Base class for COUNTER R5 reports
*
*/
namespace PKP\sushi;
use APP\core\Application;
use APP\core\Services;
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
{
/** The access method */
public const ACCESS_METHOD = 'Regular';
/** Access type */
public const ACCESS_TYPE = 'OA_Gold';
/** ID of the context the report is for. */
public Context $context;
/** Platform name, either context or site name (if configured so in the site settings). */
public string $platformName;
/** The platform ID is configured in the site settings. Uses the context path if no platform ID is configured. */
public string $platformId;
/** The customer ID is the ID of the institutional record in this context. */
public int $customerId;
/** Name of the institutional record in this context. */
public string $institutionName;
/** Available institution IDs (the ID of the institutional record and ROR). */
public ?array $institutionIds;
/** The requested begin date */
public string $beginDate;
/** The requested end date */
public string $endDate;
/** Requested metric types */
public array $metricTypes = [
'Total_Item_Investigations',
'Unique_Item_Investigations',
'Total_Item_Requests',
'Unique_Item_Requests'
];
/** Requested Year of Publication (YOP) */
public array $yearsOfPublication = [];
/** Warnings displayed in the report header. */
public array $warnings = [];
/** List of all filters requested and applied that will be displayed in the report header. */
protected array $filters = [];
/** List of all attributes requested and applied that will be displayed in the report header. */
protected array $attributes = [];
/** Additional columns/elements to include in the report. */
protected array $attributesToShow = [];
/** The granularity of the usage data to include in the report. */
protected string $granularity = 'Month';
/**
* Get report name defined by COUNTER.
*/
abstract public function getName(): string;
/**
* Get report ID defined by COUNTER.
*/
abstract public function getID(): string;
/**
* Get report release.
*/
public function getRelease(): string
{
return '5';
}
/**
* Get report description.
*/
abstract public function getDescription(): string;
/**
* Get API path defined by COUNTER for this report.
*/
abstract public function getAPIPath(): string;
/**
* Get request parameters supported by this report.
*/
abstract public function getSupportedParams(): array;
/**
* Get filters supported by this report.
*/
abstract public function getSupportedFilters(): array;
/**
* Get attributes supported by this report.
*/
abstract public function getSupportedAttributes(): array;
/**
* Get used filters that will be displayed in the report header.
*/
public function getFilters(): array
{
return $this->filters;
}
/**
* Get used attributes that will be displayed in the report header.
*/
public function getAttributes(): array
{
return $this->attributes;
}
/**
* Set filters based on the requested parameters.
*/
public function setFilters(array $filters): void
{
$this->filters = $filters;
foreach ($filters as $filter) {
switch ($filter['Name']) {
case 'Metric_Type':
$this->metricTypes = explode('|', $filter['Value']);
break;
}
}
}
/**
* Set attributes based on the requested parameters.
*/
public function setAttributes(array $attributes): void
{
$this->attributes = $attributes;
foreach ($attributes as $attribute) {
switch ($attribute['Name']) {
case 'Attributes_To_Show':
$this->attributesToShow = explode('|', $attribute['Value']);
break;
case 'granularity':
$this->granularity = $attribute['Value'];
break;
}
}
}
/** 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;
}
/**
* Process report parameters
*
* @throws SushiException
*/
public function processReportParams($request, $params): void
{
$this->context = $request->getContext();
$this->setPlatform($request->getSite());
$this->checkRequiredParams($params);
$this->checkCustomerId($params);
$this->checkDate($params);
$this->checkSupportedParams($params);
$this->checkFilters($params);
$this->checkAttributes($params);
}
/**
* Set the platform name and ID
*/
protected function setPlatform($site): void
{
$platformName = $this->context->getName($this->context->getPrimaryLocale());
$platformId = $this->context->getPath();
if ($site->getData('isSiteSushiPlatform')) {
if ($site->getData('title')) {
$platformName = $site->getTitle($site->getPrimaryLocale());
}
$platformId = $site->getData('sushiPlatformID');
}
$this->platformName = $platformName;
$this->platformId = $platformId;
}
/**
* Check if the required parameter are provided
*
* @throws SushiException
*/
protected function checkRequiredParams($params): void
{
$missingRequiredParams = [];
if (!isset($params['customer_id'])) {
$missingRequiredParams[] = 'customer_id';
}
if (!isset($params['begin_date'])) {
$missingRequiredParams[] = 'begin_date';
}
if (!isset($params['end_date'])) {
$missingRequiredParams[] = 'end_date';
}
if (!empty($missingRequiredParams)) {
throw new SushiException(
'Insufficient Information to Process Request',
1030,
'Fatal',
__('sushi.exception.1030.missing', ['params' => implode(__('common.commaListSeparator'), $missingRequiredParams)]),
400
);
}
}
/**
* Check if the customer ID is valid
*
* @throws SushiException
*/
protected function checkCustomerId($params): void
{
$institutionName = $institutionId = null;
$customerId = $params['customer_id'];
if (is_numeric($customerId)) {
if ($customerId == 0) {
$institutionName = 'The World';
} else {
$institution = Repo::institution()->get($customerId);
if (isset($institution) && $institution->getContextId() == $this->context->getId()) {
$institutionId = [];
$institutionName = $institution->getLocalizedName();
$ror = $institution->getROR();
if (isset($ror)) {
$institutionId[] = ['Type' => 'ROR', 'Value' => $ror];
}
$institutionId[] = ['Type' => 'Proprietary', 'Value' => $this->platformId . ':' . $customerId];
}
}
}
if (!isset($institutionName)) {
throw new SushiException(
'Insufficient Information to Process Request',
1030,
'Fatal',
__('sushi.exception.1030.invalid', ['params' => 'customer_id']),
400
);
}
$this->customerId = $customerId;
$this->institutionName = $institutionName;
if (isset($institutionId)) {
$this->institutionIds = $institutionId;
}
}
/**
* 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.
*/
public static function getEarliestDate(): string
{
$context = Application::get()->getRequest()->getContext();
$statsService = Services::get('sushiStats');
$counterR5StartDate = $statsService->getEarliestDate();
$firstDatePublished = Repo::publication()->getDateBoundaries(
Repo::publication()
->getCollector()
->filterByContextIds([$context->getId()])
)->min_date_published;
$earliestDate = strtotime($firstDatePublished) > strtotime($counterR5StartDate) ? $firstDatePublished : $counterR5StartDate;
$earliestDate = date('Y-m-01', strtotime($earliestDate . ' + 1 months'));
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'];
$invalidDateErrorMessages = [];
// validate if begin_date and end_date in the format Y-m-d or Y-m
if ((!$this->validateDate($beginDate) && !$this->validateDate($beginDate, 'Y-m')) ||
(!$this->validateDate($endDate) && !$this->validateDate($endDate, 'Y-m'))) {
$invalidDateErrorMessages[] = __('sushi.exception.3020.dateFormat');
}
// validate if begin_date is after the end_date, or
// if it is the current of future month i.e. later than the lastDate
if (strtotime($beginDate) >= strtotime($endDate) ||
strtotime($beginDate) > strtotime($lastDate)) {
$invalidDateErrorMessages[] = __('sushi.exception.3020.dateRange');
}
if (!empty($invalidDateErrorMessages)) {
throw new SushiException(
'Invalid Date Arguments',
3020,
'Error',
implode('. ', $invalidDateErrorMessages),
400
);
}
// check for warnings
if (strtotime($endDate) > strtotime($lastDate)) {
$this->addWarning([
'Code' => 3031,
'Severity' => 'Warning',
'Message' => 'Usage Not Ready for Requested Dates',
'Data' => __('sushi.exception.3031', ['beginDate' => $beginDate, 'endDate' => $endDate, 'lastDate' => $lastDate])
]);
$endDate = $lastDate;
}
if (strtotime($beginDate) < strtotime($earliestDate)) {
$this->addWarning([
'Code' => 3032,
'Severity' => 'Warning',
'Message' => 'Usage No Longer Available for Requested Dates',
'Data' => __('sushi.exception.3032', ['beginDate' => $beginDate, 'endDate' => $endDate, 'earliestDate' => $earliestDate])
]);
$beginDate = $earliestDate;
}
// check if requested dates are in the middle of a month, if their format is YYYY-MM-DD
if ($this->validateDate($beginDate) || $this->validateDate($endDate)) {
$beginDay = date('d', strtotime($beginDate));
$endDay = date('d', strtotime($endDate));
$lastDayOfEndMonth = date('t', strtotime($endDate));
if ($beginDay != '01' || $endDay != $lastDayOfEndMonth) {
$this->addWarning([
'Code' => 1,
'Severity' => 'Warning',
'Message' => 'Wrong Requested Dates',
'Data' => __('sushi.exception.1', ['beginDate' => $beginDate, 'endDate' => $endDate])
]);
}
}
$this->beginDate = date_format(date_create($beginDate), 'Y-m-01');
$this->endDate = date_format(date_create($endDate), 'Y-m-t');
}
/**
* Check if there are other, not recognized parameters in this context/for this report
*/
protected function checkSupportedParams($params): void
{
$supportedParameters = $this->getSupportedParams();
$unsupportedParameters = array_diff(array_keys($params), $supportedParameters);
if (!empty($unsupportedParameters)) {
$this->addWarning([
'Code' => 3050,
'Severity' => 'Warning',
'Message' => 'Parameter Not Recognized in this Context',
'Data' => __('sushi.exception.3050', ['params' => implode(__('common.commaListSeparator'), $unsupportedParameters)])
]);
}
}
/**
* Check required filters
*/
protected function checkFilters($params): void
{
$filters = [
['Name' => 'Begin_Date', 'Value' => $this->beginDate],
['Name' => 'End_Date', 'Value' => $this->endDate],
];
$unsupportedFilterParams = [];
$supportedFilters = $this->getSupportedFilters();
foreach ($supportedFilters as $supportedFilter) {
if (isset($params[$supportedFilter['param']])) {
$requestedFilterValues = explode('|', $params[$supportedFilter['param']]);
$validFilters = array_intersect($requestedFilterValues, $supportedFilter['supportedValues']);
if (!empty($validFilters)) {
$filters[] = ['Name' => $supportedFilter['name'], 'Value' => implode('|', $validFilters)];
}
if ($supportedFilter['name'] == 'YOP') {
$unsupportedYOP = $validYOP = [];
foreach ($requestedFilterValues as $yopValue) {
if (!preg_match('/\d{4}|\d{4}-\d{4}/', $yopValue)) {
$unsupportedYOP[] = $yopValue;
} else {
$validYOP[] = $yopValue;
}
}
if (!empty($unsupportedYOP)) {
$unsupportedFilterParams[] = $supportedFilter['param'] . '=' . implode('|', $unsupportedYOP);
}
if (!empty($validYOP)) {
$filters[] = ['Name' => $supportedFilter['name'], 'Value' => implode('|', $validYOP)];
}
} elseif ($supportedFilter['name'] == 'Item_Id') {
$itemId = array_shift($requestedFilterValues);
if (!is_numeric($itemId)) {
$this->addWarning([
'Code' => 2,
'Severity' => 'Warning',
'Message' => 'Invalid Item_Id',
'Data' => __('sushi.exception.2', ['itemId' => $itemId])
]);
} else {
$filters[] = ['Name' => $supportedFilter['name'], 'Value' => $itemId];
if (!empty($requestedFilterValues)) {
$this->addWarning([
'Code' => 3,
'Severity' => 'Warning',
'Message' => 'Wrong Item_Id Value',
'Data' => __('sushi.exception.3', ['itemIdValues' => implode('|', $requestedFilterValues)])
]);
}
}
} else {
$unsupportedFilterValues = array_diff($requestedFilterValues, $supportedFilter['supportedValues']);
if (!empty($unsupportedFilterValues)) {
$unsupportedFilterParams[] = $supportedFilter['param'] . '=' . implode('|', $unsupportedFilterValues);
}
}
}
}
$this->setFilters($filters);
// The Platform filter is only intended in cases where there is a single endpoint for multiple platforms.
// This can be omitted if the service provides report data for only one platform.
// Thus we will not consider it in the filter list we provide in the response, but will use an exception
// if it is provided in the request and different that this platform name.
if (isset($params['platform']) && $params['platform'] != $this->platformName) {
$unsupportedFilterParams[] = 'platform=' . $params['platform'];
}
if (!empty($unsupportedFilterParams)) {
$this->addWarning([
'Code' => 3060,
'Severity' => 'Warning',
'Message' => 'Invalid ReportFilter Value',
'Data' => __('sushi.exception.3060', ['filterValues' => implode(__('common.commaListSeparator'), $unsupportedFilterParams)])
]);
}
}
/**
* Check required attributes
*/
protected function checkAttributes($params): void
{
$attributes = $unsupportedAttributeParams = [];
$supportedAttributes = $this->getSupportedAttributes();
foreach ($supportedAttributes as $supportedAttribute) {
if (isset($params[$supportedAttribute['param']])) {
$requestedAttributeValues = explode('|', $params[$supportedAttribute['param']]);
$unsupportedAttributeValues = array_diff($requestedAttributeValues, $supportedAttribute['supportedValues']);
if (!empty($unsupportedAttributeValues)) {
$unsupportedAttributeParams[] = $supportedAttribute['param'] . '=' . implode('|', $unsupportedAttributeValues);
}
$validAttributes = array_intersect($requestedAttributeValues, $supportedAttribute['supportedValues']);
if (!empty($validAttributes)) {
$attributes[] = ['Name' => $supportedAttribute['name'], 'Value' => implode('|', $validAttributes)];
}
}
}
if (!empty($unsupportedAttributeParams)) {
$this->addWarning([
'Code' => 3062,
'Severity' => 'Warning',
'Message' => 'Invalid ReportAttribute Value',
'Data' => __('sushi.exception.3062', ['attributeValues' => implode(__('common.commaListSeparator'), $unsupportedAttributeParams)])
]);
}
// even if attributes are empty (e.g. for standard views), call setAttribute so that the predefined attributes can be set
$this->setAttributes($attributes);
}
/**
* Get report header
*/
public function getReportHeader(): array
{
$reportHeader = [
'Created' => date('Y-m-d\TH:i:s\Z', time()),
'Created_By' => $this->platformName,
'Customer_ID' => (string) $this->customerId,
'Report_ID' => $this->getID(),
'Release' => $this->getRelease(),
'Report_Name' => $this->getName(),
'Institution_Name' => $this->institutionName,
];
if (!empty($this->institutionIds)) {
$reportHeader['Institution_ID'] = $this->institutionIds;
}
$reportHeader['Report_Filters'] = $this->getFilters();
if (!empty($this->getAttributes())) {
$reportHeader['Report_Attributes'] = $this->getAttributes();
}
if (!empty($this->warnings)) {
$reportHeader['Exceptions'] = $this->warnings;
}
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
*/
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',
]),
];
}
}
|