first commit

This commit is contained in:
CHIEFSOFT\ameye
2024-06-08 17:09:23 -04:00
commit df3a033196
17887 changed files with 8637778 additions and 0 deletions
@@ -0,0 +1,825 @@
<?php
/**
* @file classes/services/PKPContextService.php
*
* Copyright (c) 2014-2021 Simon Fraser University
* Copyright (c) 2000-2021 John Willinsky
* Distributed under the GNU GPL v3. For full terms see the file docs/COPYING.
*
* @class PKPContextService
*
* @ingroup services
*
* @brief Helper class that encapsulates business logic for contexts (journals
* and presses)
*/
namespace PKP\services;
use APP\core\Application;
use APP\core\Request;
use APP\core\Services;
use APP\facades\Repo;
use APP\file\PublicFileManager;
use APP\services\queryBuilders\ContextQueryBuilder;
use Illuminate\Contracts\Validation\Validator;
use PKP\announcement\AnnouncementTypeDAO;
use PKP\context\Context;
use PKP\context\ContextDAO;
use PKP\core\Core;
use PKP\core\PKPApplication;
use PKP\config\Config;
use PKP\db\DAORegistry;
use PKP\db\DAOResultFactory;
use PKP\db\DAOResultIterator;
use PKP\db\DBResultRange;
use PKP\facades\Locale;
use PKP\file\ContextFileManager;
use PKP\file\FileManager;
use PKP\file\TemporaryFile;
use PKP\file\TemporaryFileManager;
use PKP\navigationMenu\NavigationMenuDAO;
use PKP\navigationMenu\NavigationMenuItemDAO;
use PKP\plugins\Hook;
use PKP\plugins\PluginRegistry;
use PKP\plugins\PluginSettingsDAO;
use PKP\reviewForm\ReviewFormDAO;
use PKP\security\Role;
use PKP\security\RoleDAO;
use PKP\services\interfaces\EntityPropertyInterface;
use PKP\services\interfaces\EntityReadInterface;
use PKP\services\interfaces\EntityWriteInterface;
use PKP\submission\GenreDAO;
use PKP\validation\ValidatorFactory;
abstract class PKPContextService implements EntityPropertyInterface, EntityReadInterface, EntityWriteInterface
{
/**
* @var array List of file directories to create on installation. Use %d to
* use the context ID in a file path.
*/
public $installFileDirs;
/**
* @var array The file directory where context files are stored. Expects
* `journals` or `presses`.
*/
public $contextsFileDirName;
/**
* @copydoc \PKP\services\interfaces\EntityReadInterface::get()
*/
public function get($contextId)
{
return Application::getContextDAO()->getById($contextId);
}
/**
* @copydoc \PKP\services\interfaces\EntityReadInterface::getCount()
*/
public function getCount($args = [])
{
return $this->getQueryBuilder($args)->getCount();
}
/**
* @copydoc \PKP\services\interfaces\EntityReadInterface::getIds()
*/
public function getIds($args = [])
{
return $this->getQueryBuilder($args)->getIds();
}
/**
* Get a summary of context information limited, filtered
* and sorted by $args.
*
* This is faster than getMany if you don't need to
* retrieve all the context settings. It returns the
* data from the main table and the name of the context
* in its primary locale.
*
* @see self::getMany()
*
* @return array
*/
public function getManySummary($args = [])
{
return $this->getQueryBuilder($args)->getManySummary();
}
/**
* Get a collection of Context objects limited, filtered
* and sorted by $args
*
* @param array $args {
*
* @option bool isEnabled
* @option int userId
* @option string searchPhrase
* @option int count
* @option int offset
* }
*
* @return DAOResultIterator<Context>
*/
public function getMany($args = [])
{
$range = null;
if (isset($args['count'])) {
$range = new DBResultRange($args['count'], null, $args['offset'] ?? 0);
}
// Pagination is handled by the DAO, so don't pass count and offset
// arguments to the QueryBuilder.
if (isset($args['count'])) {
unset($args['count']);
}
if (isset($args['offset'])) {
unset($args['offset']);
}
$contextListQO = $this->getQueryBuilder($args)->getQuery();
$contextDao = Application::getContextDAO();
$result = $contextDao->retrieveRange($contextListQO->toSql(), $contextListQO->getBindings(), $range);
$queryResults = new DAOResultFactory($result, $contextDao, '_fromRow');
return $queryResults->toIterator();
}
/**
* @copydoc \PKP\services\interfaces\EntityReadInterface::getMax()
*/
public function getMax($args = [])
{
// Don't accept args to limit the results
if (isset($args['count'])) {
unset($args['count']);
}
if (isset($args['offset'])) {
unset($args['offset']);
}
return $this->getQueryBuilder($args)->getCount();
}
/**
* @copydoc \PKP\services\interfaces\EntityReadInterface::getQueryBuilder()
*
* @return ContextQueryBuilder
*/
public function getQueryBuilder($args = [])
{
$defaultArgs = [
'isEnabled' => null,
'userId' => null,
'searchPhrase' => null,
];
$args = array_merge($defaultArgs, $args);
$contextListQB = new ContextQueryBuilder();
$contextListQB
->filterByIsEnabled($args['isEnabled'])
->filterByUserId($args['userId'])
->searchPhrase($args['searchPhrase']);
Hook::call('Context::getMany::queryBuilder', [&$contextListQB, $args]);
return $contextListQB;
}
/**
* @copydoc \PKP\services\interfaces\EntityPropertyInterface::getProperties()
*
* @param null|mixed $args
*/
public function getProperties($context, $props, $args = null)
{
$slimRequest = $args['slimRequest'];
$request = $args['request'];
$dispatcher = $request->getDispatcher();
$values = [];
foreach ($props as $prop) {
switch ($prop) {
case 'url':
$values[$prop] = $dispatcher->url(
$request,
PKPApplication::ROUTE_PAGE,
$context->getPath()
);
break;
case '_href':
$values[$prop] = null;
if (!empty($slimRequest)) {
$route = $slimRequest->getAttribute('route');
$values[$prop] = $dispatcher->url(
$args['request'],
PKPApplication::ROUTE_API,
$context->getData('urlPath'),
'contexts/' . $context->getId()
);
}
break;
default:
$values[$prop] = $context->getData($prop);
break;
}
}
$supportedLocales = empty($args['supportedLocales']) ? $context->getSupportedFormLocales() : $args['supportedLocales'];
$values = Services::get('schema')->addMissingMultilingualValues(PKPSchemaService::SCHEMA_CONTEXT, $values, $supportedLocales);
Hook::call('Context::getProperties', [&$values, $context, $props, $args]);
ksort($values);
return $values;
}
/**
* @copydoc \PKP\services\interfaces\EntityPropertyInterface::getSummaryProperties()
*
* @param null|mixed $args
*/
public function getSummaryProperties($context, $args = null)
{
$props = Services::get('schema')->getSummaryProps(PKPSchemaService::SCHEMA_CONTEXT);
return $this->getProperties($context, $props, $args);
}
/**
* @copydoc \PKP\services\interfaces\EntityPropertyInterface::getFullProperties()
*
* @param null|mixed $args
*/
public function getFullProperties($context, $args = null)
{
$props = Services::get('schema')->getFullProps(PKPSchemaService::SCHEMA_CONTEXT);
return $this->getProperties($context, $props, $args);
}
/**
* @copydoc \PKP\services\entityProperties\EntityWriteInterface::validate()
*/
public function validate($action, $props, $allowedLocales, $primaryLocale)
{
$schemaService = Services::get('schema'); /** @var PKPSchemaService $schemaService */
$validator = ValidatorFactory::make(
$props,
$schemaService->getValidationRules(PKPSchemaService::SCHEMA_CONTEXT, $allowedLocales),
[
'urlPath.regex' => __('admin.contexts.form.pathAlphaNumeric'),
'primaryLocale.regex' => __('validator.localeKey'),
'supportedFormLocales.regex' => __('validator.localeKey'),
'supportedLocales.regex' => __('validator.localeKey'),
'supportedSubmissionLocales.*.regex' => __('validator.localeKey'),
]
);
// Check required fields
ValidatorFactory::required(
$validator,
$action,
$schemaService->getRequiredProps(PKPSchemaService::SCHEMA_CONTEXT),
$schemaService->getMultilingualProps(PKPSchemaService::SCHEMA_CONTEXT),
$allowedLocales,
$primaryLocale
);
// Check for input from disallowed locales
ValidatorFactory::allowedLocales($validator, $schemaService->getMultilingualProps(PKPSchemaService::SCHEMA_CONTEXT), $allowedLocales);
// Ensure that a urlPath, if provided, does not already exist
$validator->after(function ($validator) use ($action, $props) {
if (strlen($props['urlPath'] ?? '') && !$validator->errors()->get('urlPath')) {
$contextDao = Application::getContextDAO();
$contextWithPath = $contextDao->getByPath($props['urlPath']);
if ($contextWithPath) {
if (!($action === EntityWriteInterface::VALIDATE_ACTION_EDIT
&& isset($props['id'])
&& (int) $contextWithPath->getId() === $props['id'])) {
$validator->errors()->add('urlPath', __('admin.contexts.form.pathExists'));
}
}
}
});
// Ensure that a urlPath is not 0, because this will cause router problems
$validator->after(function ($validator) use ($props) {
if (strlen($props['urlPath'] ?? '') && !$validator->errors()->get('urlPath') && $props['urlPath'] == '0') {
$validator->errors()->add('urlPath', __('admin.contexts.form.pathRequired'));
}
});
// Ensure that the primary locale is one of the supported locales
$validator->after(function ($validator) use ($action, $props, $allowedLocales) {
if (isset($props['primaryLocale']) && !$validator->errors()->get('primaryLocale')) {
// Check against a new supported locales prop
if (isset($props['supportedLocales'])) {
$newSupportedLocales = (array) $props['supportedLocales'];
if (!in_array($props['primaryLocale'], $newSupportedLocales)) {
$validator->errors()->add('primaryLocale', __('admin.contexts.form.primaryLocaleNotSupported'));
}
// Or check against the $allowedLocales
} elseif (!in_array($props['primaryLocale'], $allowedLocales)) {
$validator->errors()->add('primaryLocale', __('admin.contexts.form.primaryLocaleNotSupported'));
}
}
});
// Ensure that the supported locales are supported by the site
$validator->after(function ($validator) use ($action, $props) {
$siteSupportedLocales = Application::get()->getRequest()->getSite()->getData('supportedLocales');
$localeProps = ['supportedLocales', 'supportedFormLocales', 'supportedSubmissionLocales'];
foreach ($localeProps as $localeProp) {
if (isset($props[$localeProp]) && !$validator->errors()->get($localeProp)) {
$unsupportedLocales = array_diff($props[$localeProp], $siteSupportedLocales);
if (!empty($unsupportedLocales)) {
$validator->errors()->add($localeProp, __('api.contexts.400.localesNotSupported', ['locales' => join(__('common.commaListSeparator'), $unsupportedLocales)]));
}
}
}
});
// If a new file has been uploaded, check that the temporary file exists and
// the current user owns it
$user = Application::get()->getRequest()->getUser();
ValidatorFactory::temporaryFilesExist(
$validator,
['favicon', 'homepageImage', 'pageHeaderLogoImage', 'styleSheet'],
['favicon', 'homepageImage', 'pageHeaderLogoImage'],
$props,
$allowedLocales,
$user ? $user->getId() : null
);
// If sidebar blocks are passed, ensure the block plugin exists and is
// enabled
$validator->after(function ($validator) use ($props) {
if (!empty($props['sidebar']) && !$validator->errors()->get('sidebar')) {
$plugins = PluginRegistry::loadCategory('blocks', true);
foreach ($props['sidebar'] as $pluginName) {
if (empty($plugins[$pluginName])) {
$validator->errors()->add('sidebar', __('manager.setup.layout.sidebar.invalidBlock', ['name' => $pluginName]));
}
}
}
});
// Ensure the theme plugin is installed and enabled
$validator->after(function ($validator) use ($props) {
if (!empty($props['themePluginPath']) && !$validator->errors()->get('themePluginPath')) {
$plugins = PluginRegistry::loadCategory('themes', true);
$found = false;
foreach ($plugins as $plugin) {
if ($props['themePluginPath'] === $plugin->getDirName()) {
$found = true;
break;
}
}
if (!$found) {
$validator->errors()->add('themePluginPath', __('manager.setup.theme.notFound'));
}
}
});
// Transforming copySubmissionAckAddress from CSV to array
$validator->after(function ($validator) use ($props) {
if (!isset($props['copySubmissionAckAddress'])) {
return;
}
$emails = explode(',', $props['copySubmissionAckAddress']);
if ($emails === []) {
return;
}
foreach ($emails as $currentEmail) {
$value = trim($currentEmail);
$emailValidator = ValidatorFactory::make(
['value' => $value],
['value' => ['email_or_localhost']]
);
if ($emailValidator->fails()) {
$validator->errors()->add('copySubmissionAckAddress', __('manager.setup.notifications.copySubmissionAckAddress.invalid'));
break;
}
}
});
// Only allow admins to modify which user groups are disabled for bulk emails
if (!empty($props['disableBulkEmailUserGroups'])) {
$user = Application::get()->getRequest()->getUser();
$validator->after(function ($validator) use ($user) {
$roleDao = DAORegistry::getDAO('RoleDAO'); /** @var RoleDAO $roleDao */
if (!$roleDao->userHasRole(PKPApplication::CONTEXT_ID_NONE, $user->getId(), Role::ROLE_ID_SITE_ADMIN)) {
$validator->errors()->add('disableBulkEmailUserGroups', __('admin.settings.disableBulkEmailRoles.adminOnly'));
}
});
}
// Disallow empty DOI Prefix when enableDois is true
if (isset($props[Context::SETTING_ENABLE_DOIS]) || isset($props[Context::SETTING_DOI_PREFIX])) {
$context = Application::get()->getRequest()->getContext();
$validator->after(function ($validator) use ($context, $props) {
$enableDois = $props[Context::SETTING_ENABLE_DOIS] ?? $context->getData(Context::SETTING_ENABLE_DOIS);
if ($enableDois && empty($props[Context::SETTING_DOI_PREFIX])) {
$validator->errors()->add(Context::SETTING_DOI_PREFIX, __('doi.manager.settings.doiPrefix.required'));
}
});
}
if ($validator->fails()) {
$errors = $schemaService->formatValidationErrors($validator->errors());
}
Hook::call('Context::validate', [&$errors, $action, $props, $allowedLocales, $primaryLocale]);
return $errors;
}
/**
* @copydoc \PKP\services\entityProperties\EntityWriteInterface::add()
*/
public function add($context, $request)
{
$site = $request->getSite();
$currentUser = $request->getUser();
$contextDao = Application::getContextDAO();
if (!$context->getData('primaryLocale')) {
$context->setData('primaryLocale', $site->getPrimaryLocale());
}
if (!$context->getData('supportedLocales')) {
$context->setData('supportedLocales', $site->getSupportedLocales());
}
// Specify values needed to render default locale strings
$localeParams = [
'submissionGuidelinesUrl' => $request->getDispatcher()->url(
$request,
Application::ROUTE_PAGE,
$context->getPath(),
'about',
'submissions'
),
'indexUrl' => $request->getIndexUrl(),
'primaryLocale' => $context->getData('primaryLocale'),
'contextName' => $context->getData('name', $context->getPrimaryLocale()),
'contextPath' => $context->getData('urlPath'),
'contextUrl' => $request->getDispatcher()->url(
$request,
PKPApplication::ROUTE_PAGE,
$context->getPath()
),
];
// Allow plugins to extend the $localeParams for new property defaults
Hook::call('Context::defaults::localeParams', [&$localeParams, $context, $request]);
$context = Services::get('schema')->setDefaults(
PKPSchemaService::SCHEMA_CONTEXT,
$context,
$context->getData('supportedLocales'),
$context->getData('primaryLocale'),
$localeParams
);
if (!$context->getData('supportedFormLocales')) {
$context->setData('supportedFormLocales', [$context->getData('primaryLocale')]);
}
if (!$context->getData('supportedSubmissionLocales')) {
$context->setData('supportedSubmissionLocales', [$context->getData('primaryLocale')]);
}
$contextDao->insertObject($context);
$contextDao->resequence();
$context = $this->get($context->getId());
// Move uploaded files into place and update the settings
$supportedLocales = $context->getSupportedFormLocales();
$fileUploadProps = ['favicon', 'homepageImage', 'pageHeaderLogoImage'];
$params = [];
foreach ($fileUploadProps as $fileUploadProp) {
$value = $context->getData($fileUploadProp);
if (empty($value)) {
continue;
}
foreach ($supportedLocales as $localeKey) {
if (!array_key_exists($localeKey, $value)) {
continue;
}
$value[$localeKey] = $this->_saveFileParam($context, $value[$localeKey], $fileUploadProp, $currentUser->getId(), $localeKey, true);
}
$params[$fileUploadProp] = $value;
}
if (!empty($params['styleSheet'])) {
$params['styleSheet'] = $this->_saveFileParam($context, $params['styleSheet'], 'styleSheet', $currentUser->getId());
}
$context = $this->edit($context, $params, $request);
$genreDao = DAORegistry::getDAO('GenreDAO'); /** @var GenreDAO $genreDao */
$genreDao->installDefaults($context->getId(), $context->getData('supportedLocales'));
Repo::userGroup()->installSettings($context->getId(), 'registry/userGroups.xml');
$managerUserGroup = Repo::userGroup()->getByRoleIds([Role::ROLE_ID_MANAGER], $context->getId(), true)->firstOrFail();
Repo::userGroup()->assignUserToGroup($currentUser->getId(), $managerUserGroup->getId());
$fileManager = new FileManager();
foreach ($this->installFileDirs as $dir) {
$fileManager->mkdir(sprintf($dir, $this->contextsFileDirName, $context->getId()));
}
$navigationMenuDao = DAORegistry::getDAO('NavigationMenuDAO'); /** @var NavigationMenuDAO $navigationMenuDao */
$navigationMenuDao->installSettings($context->getId(), 'registry/navigationMenus.xml');
Repo::emailTemplate()->dao->installAlternateEmailTemplates($context->getId());
// Load all plugins so they can hook in and add their installation settings
PluginRegistry::loadAllPlugins();
Hook::call('Context::add', [&$context, $request]);
return $context;
}
/**
* @copydoc \PKP\services\entityProperties\EntityWriteInterface::edit()
*/
public function edit($context, $params, $request)
{
$contextDao = Application::getContextDao();
// Move uploaded files into place and update the params
$userId = $request->getUser() ? $request->getUser()->getId() : null;
$supportedLocales = $context->getSupportedFormLocales();
$fileUploadParams = ['favicon', 'homepageImage', 'pageHeaderLogoImage'];
foreach ($fileUploadParams as $fileUploadParam) {
if (!array_key_exists($fileUploadParam, $params)) {
continue;
}
foreach ($supportedLocales as $localeKey) {
if (!array_key_exists($localeKey, $params[$fileUploadParam])) {
continue;
}
$params[$fileUploadParam][$localeKey] = $this->_saveFileParam($context, $params[$fileUploadParam][$localeKey], $fileUploadParam, $userId, $localeKey, true);
}
}
if (array_key_exists('styleSheet', $params)) {
$params['styleSheet'] = $this->_saveFileParam($context, $params['styleSheet'], 'styleSheet', $userId);
}
$newContext = $contextDao->newDataObject();
$newContext->_data = array_merge($context->_data, $params);
Hook::call('Context::edit', [&$newContext, $context, $params, $request]);
$contextDao->updateObject($newContext);
$newContext = $this->get($newContext->getId());
return $newContext;
}
/**
* @copydoc \PKP\services\entityProperties\EntityWriteInterface::delete()
*/
public function delete($context)
{
Hook::call('Context::delete::before', [&$context]);
$announcementTypeDao = DAORegistry::getDAO('AnnouncementTypeDAO'); /** @var AnnouncementTypeDAO $announcementTypeDao */
$announcementTypeDao->deleteByContextId($context->getId());
Repo::userGroup()->deleteByContextId($context->getId());
$genreDao = DAORegistry::getDAO('GenreDAO'); /** @var GenreDAO $genreDao */
$genreDao->deleteByContextId($context->getId());
Repo::announcement()->deleteMany(
Repo::announcement()
->getCollector()
->filterByContextIds([$context->getId()])
);
if (Config::getVar('features', 'highlights')) {
Repo::highlight()
->getCollector()
->filterByContextIds([$context->getId()])
->deleteMany();
}
Repo::institution()->deleteMany(
Repo::institution()
->getCollector()
->filterByContextIds([$context->getId()])
);
Repo::emailTemplate()->restoreDefaults($context->getId());
$pluginSettingsDao = DAORegistry::getDAO('PluginSettingsDAO'); /** @var PluginSettingsDAO $pluginSettingsDao */
$pluginSettingsDao->deleteByContextId($context->getId());
$reviewFormDao = DAORegistry::getDAO('ReviewFormDAO'); /** @var ReviewFormDAO $reviewFormDao */
$reviewFormDao->deleteByAssoc($context->getAssocType(), $context->getId());
$navigationMenuDao = DAORegistry::getDAO('NavigationMenuDAO'); /** @var NavigationMenuDAO $navigationMenuDao */
$navigationMenuDao->deleteByContextId($context->getId());
$navigationMenuItemDao = DAORegistry::getDAO('NavigationMenuItemDAO'); /** @var NavigationMenuItemDAO $navigationMenuItemDao */
$navigationMenuItemDao->deleteByContextId($context->getId());
$contextFileManager = new ContextFileManager($context->getId());
$contextFileManager->rmtree($contextFileManager->getBasePath());
$contextDao = Application::getContextDao();
$contextDao->deleteObject($context);
Hook::call('Context::delete', [&$context]);
}
/**
* Restore default values for context settings in a specific local
*
* Updates multilingual values of a context, restoring default values in a
* specific context. This may be useful when a new language has been added
* after a context has been created, or when translations change and a journal
* wants to take advantage of the new values.
*
* @param Context $context The context to restore default values for
* @param Request $request
* @param string $locale Locale key to restore defaults for. Example: `en`
*/
public function restoreLocaleDefaults($context, $request, $locale)
{
Locale::installLocale($locale);
// Specify values needed to render default locale strings
$localeParams = [
'indexUrl' => $request->getIndexUrl(),
'contextPath' => $context->getData('urlPath'),
'journalPath' => $context->getData('urlPath'), // DEPRECATED
'primaryLocale' => $context->getData('primaryLocale'),
'journalName' => $context->getData('name', $locale), // DEPRECATED
'contextName' => $context->getData('name', $locale),
'contextUrl' => $request->getDispatcher()->url(
$request,
PKPApplication::ROUTE_PAGE,
$context->getPath()
),
];
// Allow plugins to extend the $localeParams for new property defaults
Hook::call('Context::restoreLocaleDefaults::localeParams', [&$localeParams, $context, $request, $locale]);
$localeDefaults = Services::get('schema')->getLocaleDefaults(PKPSchemaService::SCHEMA_CONTEXT, $locale, $localeParams);
$params = [];
foreach ($localeDefaults as $paramName => $value) {
$params[$paramName] = array_merge(
(array) $context->getData($paramName),
[$locale => $localeDefaults[$paramName]]
);
}
return $this->edit($context, $params, $request);
}
/**
* Move a temporary file to the context's public directory
*
* @param Context $context
* @param TemporaryFile $temporaryFile
* @param string $fileNameBase Unique identifier to use for the filename. The
* Extension and locale will be appended.
* @param int $userId ID of the user who uploaded the temporary file
* @param string $localeKey Example: en. Leave empty for a file that is
* not localized.
*
* @return string|boolean The new filename or false on failure
*/
public function moveTemporaryFile($context, $temporaryFile, $fileNameBase, $userId, $localeKey = '')
{
$publicFileManager = new PublicFileManager();
$temporaryFileManager = new TemporaryFileManager();
$fileName = $fileNameBase;
if ($localeKey) {
$fileName .= '_' . $localeKey;
}
$extension = $publicFileManager->getDocumentExtension($temporaryFile->getFileType());
if (!$extension) {
$extension = $publicFileManager->getImageExtension($temporaryFile->getFileType());
}
$fileName .= $extension;
$result = $publicFileManager->copyContextFile(
$context->getId(),
$temporaryFile->getFilePath(),
$fileName
);
if (!$result) {
return false;
}
$temporaryFileManager->deleteById($temporaryFile->getId(), $userId);
return $fileName;
}
/**
* Checks if a context exists
*/
public function exists(int $id): bool
{
/** @var ContextDAO $contextDao */
$contextDao = Application::getContextDao();
return $contextDao->exists($id);
}
/**
* Handle a context setting for an uploaded file
*
* - Moves the temporary file to the public directory
* - Resets the param value to what is expected to be stored in the db
* - If a null value is passed, deletes any existing file
*
* This method is protected because all operations which edit contexts should
* go through the add and edit methods in order to ensure that
* the appropriate hooks are fired.
*
* @param Context $context The context being edited
* @param mixed $value The param value to be saved. Contains the temporary
* file ID if a new file has been uploaded.
* @param string $settingName The name of the setting to save, typically used
* in the filename.
* @param int $userId ID of the user who owns the temporary file
* @param string $localeKey Optional. Used in the filename for multilingual
* properties.
* @param bool $isImage Optional. For image files which return alt text,
* width, height, etc in the param value.
*
* @return string|array|null New param value or null on failure
*/
protected function _saveFileParam($context, $value, $settingName, $userId, $localeKey = '', $isImage = false)
{
$temporaryFileManager = new TemporaryFileManager();
// If the value is null, clean up any existing file in the system
if (is_null($value)) {
$setting = $context->getData($settingName, $localeKey);
if ($setting) {
$fileName = $isImage ? $setting['uploadName'] : $setting;
$publicFileManager = new PublicFileManager();
$publicFileManager->removeContextFile($context->getId(), $fileName);
}
return null;
}
// Check if there is something to upload
if (empty($value['temporaryFileId'])) {
return $value;
}
$temporaryFile = $temporaryFileManager->getFile((int) $value['temporaryFileId'], $userId);
$fileName = $this->moveTemporaryFile($context, $temporaryFile, $settingName, $userId, $localeKey);
if ($fileName) {
// Get the details for image uploads
if ($isImage) {
$publicFileManager = new PublicFileManager();
$filePath = $publicFileManager->getContextFilesPath($context->getId());
[$width, $height] = getimagesize($filePath . '/' . $fileName);
$altText = !empty($value['altText']) ? $value['altText'] : '';
return [
'name' => $temporaryFile->getOriginalFileName(),
'uploadName' => $fileName,
'width' => $width,
'height' => $height,
'dateUploaded' => Core::getCurrentDate(),
'altText' => $altText,
];
} else {
return [
'name' => $temporaryFile->getOriginalFileName(),
'uploadName' => $fileName,
'dateUploaded' => Core::getCurrentDate(),
];
}
}
return false;
}
}
+287
View File
@@ -0,0 +1,287 @@
<?php
/**
* @file classes/services/PKPFileService.php
*
* Copyright (c) 2014-2021 Simon Fraser University
* Copyright (c) 2000-2021 John Willinsky
* Distributed under the GNU GPL v3. For full terms see the file docs/COPYING.
*
* @class PKPFileService
*
* @ingroup services
*
* @brief Helper class that encapsulates business logic for publications
*/
namespace PKP\services;
use APP\core\Application;
use Exception;
use finfo;
use Illuminate\Support\Facades\DB;
use League\Flysystem\Filesystem;
use League\Flysystem\Local\LocalFilesystemAdapter;
use League\Flysystem\UnixVisibility\PortableVisibilityConverter;
use PKP\config\Config;
use PKP\core\PKPString;
use PKP\file\FileManager;
use PKP\plugins\Hook;
class PKPFileService
{
private const FALLBACK_MIME_TYPE = 'application/octet-stream';
/** @var Filesystem */
public $fs;
/**
* Initialize and configure flysystem
*/
public function __construct()
{
$umask = Config::getVar('files', 'umask', 0022);
$adapter = new LocalFilesystemAdapter(
Config::getVar('files', 'files_dir'),
PortableVisibilityConverter::fromArray([
'file' => [
'public' => FileManager::FILE_MODE_MASK & ~$umask,
'private' => FileManager::FILE_MODE_MASK & ~$umask,
],
'dir' => [
'public' => FileManager::DIRECTORY_MODE_MASK & ~$umask,
'private' => FileManager::DIRECTORY_MODE_MASK & ~$umask,
]
]),
LOCK_EX,
LocalFilesystemAdapter::DISALLOW_LINKS
);
Hook::call('File::adapter', [&$adapter, $this]);
$this->fs = new Filesystem($adapter);
}
/**
* Get a file by its id
*
* @param int $id
*
* @return object
*/
public function get($id)
{
$file = DB::table('files')
->where('file_id', '=', $id)
->select(['file_id as id', 'path', 'mimetype'])
->first();
return $file;
}
/**
* Add a file
*
* @param string $from absolute path to file
* @param string $to relative path in file dir
*
* @return int file id
*/
public function add($from, $to)
{
$stream = fopen($from, 'r+');
if (!$stream) {
throw new Exception("Unable to copy {$from} to {$to}.");
}
$this->fs->writeStream($to, $stream);
if (is_resource($stream)) {
fclose($stream);
}
try {
$mimetype = $this->fs->mimeType($to);
} catch (Exception $e) {
// When a very good mime-type cannot be guessed, FlySystem emits an Exception
$mimetype = (new finfo(FILEINFO_MIME_TYPE))->file($to) ?: static::FALLBACK_MIME_TYPE;
}
// Check and override ambiguous mime types based on file extension
if ($extension = pathinfo($to, PATHINFO_EXTENSION)) {
$checkAmbiguous = strtolower($extension . ':' . $mimetype);
if (array_key_exists($checkAmbiguous, $extensionsMap = PKPString::getAmbiguousExtensionsMap())) {
$mimetype = $extensionsMap[$checkAmbiguous];
}
}
return DB::table('files')->insertGetId([
'path' => $to,
'mimetype' => $mimetype,
], 'file_id');
}
/**
* Delete an uploaded file
*
* @param int $id
*/
public function delete($id)
{
$file = $this->get($id);
if (!$file) {
throw new Exception("Unable to locate file {$id}.");
}
$path = $file->path;
if ($this->fs->has($path)) {
try {
$this->fs->delete($path);
} catch (Exception $e) {
throw new Exception("Unable to delete file {$id} at {$path}.");
}
}
DB::table('files')
->where('file_id', '=', $file->id)
->delete();
}
/**
* Download a file
*
* This method sends a HTTP response and ends the request handling.
* No code will run after this method is called.
*
* @param int $fileId File ID
* @param string $filename Filename to give to the downloaded file
* @param bool $inline Whether to stream the file to the browser
*/
public function download($fileId, $filename, $inline = false)
{
$file = $this->get($fileId);
$dispatcher = Application::get()->getRequest()->getDispatcher();
if (!$file) {
$dispatcher->handle404();
}
$path = $file->path;
if (!$this->fs->has($path)) {
$dispatcher->handle404();
}
if (Hook::call('File::download', [$file, &$filename, $inline])) {
return;
}
// Stream the file to the end user.
$mimetype = $file->mimetype ?? 'application/octet-stream';
$filesize = $this->fs->fileSize($path);
$encodedFilename = urlencode($filename);
header("Content-Type: {$mimetype}");
header("Content-Length: {$filesize}");
header('Accept-Ranges: none');
header('Content-Disposition: ' . ($inline ? 'inline' : 'attachment') . ";filename=\"{$encodedFilename}\";filename*=UTF-8''{$encodedFilename}");
header('Cache-Control: private'); // Workarounds for IE weirdness
header('Pragma: public');
fpassthru($this->fs->readStream($path));
exit;
}
/**
* Convert a filename into a consistent format with the correct extension
*
* @param string $path Path to the file
* @param string $filename Source filename to sanitize
*
* @return string
*/
public function formatFilename($path, $filename)
{
$newFilename = $filename;
# pattern extended to also capture captures .tar.gz extensions
if (preg_match('/(\\.\\w{1,3})?\\.\\w+$/', $path, $extension)) {
# If $newFilename has no/not the correct extension: Append extension
if (strcasecmp(substr($newFilename, (strlen($extension[0]) * -1)), $extension[0]) != 0) {
$newFilename .= $extension[0];
}
}
Hook::call('File::formatFilename', [&$newFilename, $path, $filename]);
return $newFilename;
}
/**
* Get document type based on the mimetype
*
* @param string $mimetype
*
* @return string One of the FileManager::DOCUMENT_TYPE_ constants
*/
public function getDocumentType($mimetype)
{
switch ($mimetype) {
case 'application/pdf':
case 'application/x-pdf':
case 'text/pdf':
case 'text/x-pdf':
return FileManager::DOCUMENT_TYPE_PDF;
case 'application/msword':
case 'application/word':
case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
return FileManager::DOCUMENT_TYPE_WORD;
case 'application/excel':
case 'application/vnd.ms-excel':
case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
return FileManager::DOCUMENT_TYPE_EXCEL;
case 'text/html':
return FileManager::DOCUMENT_TYPE_HTML;
case 'application/zip':
case 'application/x-zip':
case 'application/x-zip-compressed':
case 'application/x-compress':
case 'application/x-compressed':
case 'multipart/x-zip':
return FileManager::DOCUMENT_TYPE_ZIP;
case 'application/epub':
case 'application/epub+zip':
return FileManager::DOCUMENT_TYPE_EPUB;
case 'image/gif':
case 'image/jpeg':
case 'image/pjpeg':
case 'image/png':
case 'image/x-png':
case 'image/vnd.microsoft.icon':
case 'image/x-icon':
case 'image/x-ico':
case 'image/ico':
return FileManager::DOCUMENT_TYPE_IMAGE;
case 'application/x-shockwave-flash':
case 'video/x-flv':
case 'application/x-flash-video':
case 'flv-application/octet-stream':
case 'video/mpeg':
case 'video/quicktime':
case 'video/mp4':
return FileManager::DOCUMENT_TYPE_VIDEO;
case 'audio/mpeg':
case 'audio/x-aiff':
case 'audio/x-wav':
return FileManager::DOCUMENT_TYPE_AUDIO;
default:
return FileManager::DOCUMENT_TYPE_DEFAULT;
}
}
/**
* Get a pretty file size string
*
* Examples: 82B, 12KB, 2MB, 2GB
*
* @param int $size File size in bytes
*
* @return string
*/
public function getNiceFileSize($size)
{
$niceFileSizeUnits = ['B', 'KB', 'MB', 'GB'];
for ($i = 0; $i < 4 && $size > 1024; $i++) {
$size >>= 10;
}
return $size . $niceFileSizeUnits[$i];
}
}
@@ -0,0 +1,686 @@
<?php
/**
* @file classes/services/PKPNavigationMenuService.php
*
* Copyright (c) 2014-2021 Simon Fraser University
* Copyright (c) 2000-2021 John Willinsky
* Distributed under the GNU GPL v3. For full terms see the file docs/COPYING.
*
* @class PKPNavigationMenuService
*
* @ingroup services
*
* @brief Helper class that encapsulates NavigationMenu business logic
*/
namespace PKP\services;
use APP\core\Application;
use APP\template\TemplateManager;
use PKP\cache\FileCache;
use PKP\core\PKPApplication;
use PKP\db\DAORegistry;
use PKP\facades\Locale;
use PKP\navigationMenu\NavigationMenu;
use PKP\navigationMenu\NavigationMenuDAO;
use PKP\navigationMenu\NavigationMenuItem;
use PKP\navigationMenu\NavigationMenuItemAssignment;
use PKP\navigationMenu\NavigationMenuItemAssignmentDAO;
use PKP\navigationMenu\NavigationMenuItemDAO;
use PKP\pages\navigationMenu\NavigationMenuItemHandler;
use PKP\plugins\Hook;
use PKP\security\Role;
use PKP\security\Validation;
class PKPNavigationMenuService
{
/**
* Return all default navigationMenuItemTypes.
*
* @return array
*/
public function getMenuItemTypes()
{
$types = [
NavigationMenuItem::NMI_TYPE_CUSTOM => [
'title' => __('manager.navigationMenus.customPage'),
'description' => __('manager.navigationMenus.customPage.description'),
],
NavigationMenuItem::NMI_TYPE_REMOTE_URL => [
'title' => __('manager.navigationMenus.remoteUrl'),
'description' => __('manager.navigationMenus.remoteUrl.description'),
],
NavigationMenuItem::NMI_TYPE_ABOUT => [
'title' => __('navigation.about'),
'description' => __('manager.navigationMenus.about.description'),
'conditionalWarning' => __('manager.navigationMenus.about.conditionalWarning'),
],
NavigationMenuItem::NMI_TYPE_EDITORIAL_TEAM => [
'title' => __('about.editorialTeam'),
'description' => __('manager.navigationMenus.editorialTeam.description'),
'conditionalWarning' => __('manager.navigationMenus.editorialTeam.conditionalWarning'),
],
NavigationMenuItem::NMI_TYPE_SUBMISSIONS => [
'title' => __('about.submissions'),
'description' => __('manager.navigationMenus.submissions.description'),
],
NavigationMenuItem::NMI_TYPE_ANNOUNCEMENTS => [
'title' => __('announcement.announcements'),
'description' => __('manager.navigationMenus.announcements.description'),
'conditionalWarning' => __('manager.navigationMenus.announcements.conditionalWarning'),
],
NavigationMenuItem::NMI_TYPE_USER_LOGIN => [
'title' => __('navigation.login'),
'description' => __('manager.navigationMenus.login.description'),
'conditionalWarning' => __('manager.navigationMenus.loggedIn.conditionalWarning'),
],
NavigationMenuItem::NMI_TYPE_USER_REGISTER => [
'title' => __('navigation.register'),
'description' => __('manager.navigationMenus.register.description'),
'conditionalWarning' => __('manager.navigationMenus.loggedIn.conditionalWarning'),
],
NavigationMenuItem::NMI_TYPE_USER_DASHBOARD => [
'title' => __('navigation.dashboard'),
'description' => __('manager.navigationMenus.dashboard.description'),
'conditionalWarning' => __('manager.navigationMenus.loggedOut.conditionalWarning'),
],
NavigationMenuItem::NMI_TYPE_USER_PROFILE => [
'title' => __('common.viewProfile'),
'description' => __('manager.navigationMenus.profile.description'),
'conditionalWarning' => __('manager.navigationMenus.loggedOut.conditionalWarning'),
],
NavigationMenuItem::NMI_TYPE_ADMINISTRATION => [
'title' => __('navigation.admin'),
'description' => __('manager.navigationMenus.administration.description'),
'conditionalWarning' => __('manager.navigationMenus.loggedOut.conditionalWarning'),
],
NavigationMenuItem::NMI_TYPE_USER_LOGOUT => [
'title' => __('user.logOut'),
'description' => __('manager.navigationMenus.logOut.description'),
'conditionalWarning' => __('manager.navigationMenus.loggedOut.conditionalWarning'),
],
NavigationMenuItem::NMI_TYPE_CONTACT => [
'title' => __('about.contact'),
'description' => __('manager.navigationMenus.contact.description'),
'conditionalWarning' => __('manager.navigationMenus.contact.conditionalWarning'),
],
NavigationMenuItem::NMI_TYPE_SEARCH => [
'title' => __('common.search'),
'description' => __('manager.navigationMenus.search.description'),
],
NavigationMenuItem::NMI_TYPE_PRIVACY => [
'title' => __('manager.setup.privacyStatement'),
'description' => __('manager.navigationMenus.privacyStatement.description'),
'conditionalWarning' => __('manager.navigationMenus.privacyStatement.conditionalWarning'),
],
];
Hook::call('NavigationMenus::itemTypes', [&$types]);
return $types;
}
/**
* Return all custom edit navigationMenuItemTypes Templates.
*
* @return array
*/
public function getMenuItemCustomEditTemplates()
{
$templates = [
NavigationMenuItem::NMI_TYPE_CUSTOM => [
'template' => 'core:controllers/grid/navigationMenus/customNMIType.tpl',
],
NavigationMenuItem::NMI_TYPE_REMOTE_URL => [
'template' => 'core:controllers/grid/navigationMenus/remoteUrlNMIType.tpl',
],
];
Hook::call('NavigationMenus::itemCustomTemplates', [&$templates]);
return $templates;
}
/**
* Callback for display menu item functionality
*/
public function getDisplayStatus(&$navigationMenuItem, &$navigationMenu)
{
$request = Application::get()->getRequest();
$dispatcher = $request->getDispatcher();
$templateMgr = TemplateManager::getManager($request);
$isUserLoggedIn = Validation::isLoggedIn();
$isUserLoggedInAs = Validation::loggedInAs();
$context = $request->getContext();
$currentUser = $request->getUser();
$contextId = $context ? $context->getId() : \PKP\core\PKPApplication::CONTEXT_ID_NONE;
// Transform an item title if the title includes a {$variable}
$this->transformNavMenuItemTitle($templateMgr, $navigationMenuItem);
$menuItemType = $navigationMenuItem->getType();
// Conditionally hide some items
switch ($menuItemType) {
case NavigationMenuItem::NMI_TYPE_ANNOUNCEMENTS:
$navigationMenuItem->setIsDisplayed(
($context && $context->getData('enableAnnouncements'))
|| (!$context && $request->getSite()->getData('enableAnnouncements'))
);
break;
case NavigationMenuItem::NMI_TYPE_EDITORIAL_TEAM:
$navigationMenuItem->setIsDisplayed($context && $context->getLocalizedData('editorialTeam'));
break;
case NavigationMenuItem::NMI_TYPE_CONTACT:
$navigationMenuItem->setIsDisplayed($context && ($context->getData('mailingAddress') || $context->getData('contactName')));
break;
case NavigationMenuItem::NMI_TYPE_USER_REGISTER:
$navigationMenuItem->setIsDisplayed(!$isUserLoggedIn && !($context && $context->getData('disableUserReg')));
break;
case NavigationMenuItem::NMI_TYPE_USER_LOGIN:
$navigationMenuItem->setIsDisplayed(!$isUserLoggedIn);
break;
case NavigationMenuItem::NMI_TYPE_USER_LOGOUT:
case NavigationMenuItem::NMI_TYPE_USER_PROFILE:
case NavigationMenuItem::NMI_TYPE_USER_DASHBOARD:
$navigationMenuItem->setIsDisplayed($isUserLoggedIn);
break;
case NavigationMenuItem::NMI_TYPE_ADMINISTRATION:
$navigationMenuItem->setIsDisplayed($isUserLoggedIn && $currentUser->hasRole([Role::ROLE_ID_SITE_ADMIN], PKPApplication::CONTEXT_SITE));
break;
case NavigationMenuItem::NMI_TYPE_PRIVACY:
$navigationMenuItem->setIsDisplayed($context && $context->getLocalizedData('privacyStatement'));
break;
}
if ($navigationMenuItem->getIsDisplayed()) {
// Adjust some titles
switch ($menuItemType) {
case NavigationMenuItem::NMI_TYPE_USER_LOGOUT:
if ($isUserLoggedInAs) {
$userName = $request->getUser() ? ' ' . $request->getUser()->getUserName() : '';
$navigationMenuItem->setTitle(__('user.logOutAs', ['username' => $userName]), Locale::getLocale());
}
break;
case NavigationMenuItem::NMI_TYPE_USER_DASHBOARD:
$templateMgr->assign('navigationMenuItem', $navigationMenuItem);
if ($currentUser->hasRole([Role::ROLE_ID_MANAGER, Role::ROLE_ID_ASSISTANT, Role::ROLE_ID_REVIEWER, Role::ROLE_ID_AUTHOR], $contextId) || $currentUser->hasRole([Role::ROLE_ID_SITE_ADMIN], PKPApplication::CONTEXT_SITE)) {
$displayTitle = $templateMgr->fetch('frontend/components/navigationMenus/dashboardMenuItem.tpl');
$navigationMenuItem->setTitle($displayTitle, Locale::getLocale());
}
break;
}
// Set the URL
switch ($menuItemType) {
case NavigationMenuItem::NMI_TYPE_ANNOUNCEMENTS:
$navigationMenuItem->setUrl($dispatcher->url(
$request,
PKPApplication::ROUTE_PAGE,
null,
'announcement',
null,
null
));
break;
case NavigationMenuItem::NMI_TYPE_ABOUT:
$navigationMenuItem->setUrl($dispatcher->url(
$request,
PKPApplication::ROUTE_PAGE,
null,
'about',
null,
null
));
break;
case NavigationMenuItem::NMI_TYPE_SUBMISSIONS:
$navigationMenuItem->setUrl($dispatcher->url(
$request,
PKPApplication::ROUTE_PAGE,
null,
'about',
'submissions',
null
));
break;
case NavigationMenuItem::NMI_TYPE_EDITORIAL_TEAM:
$navigationMenuItem->setUrl($dispatcher->url(
$request,
PKPApplication::ROUTE_PAGE,
null,
'about',
'editorialTeam',
null
));
break;
case NavigationMenuItem::NMI_TYPE_CONTACT:
$navigationMenuItem->setUrl($dispatcher->url(
$request,
PKPApplication::ROUTE_PAGE,
null,
'about',
'contact',
null
));
break;
case NavigationMenuItem::NMI_TYPE_USER_LOGOUT:
$navigationMenuItem->setUrl($dispatcher->url(
$request,
PKPApplication::ROUTE_PAGE,
null,
'login',
$isUserLoggedInAs ? 'signOutAsUser' : 'signOut',
null
));
break;
case NavigationMenuItem::NMI_TYPE_USER_PROFILE:
$navigationMenuItem->setUrl($dispatcher->url(
$request,
PKPApplication::ROUTE_PAGE,
null,
'user',
'profile',
null
));
break;
case NavigationMenuItem::NMI_TYPE_ADMINISTRATION:
$navigationMenuItem->setUrl($dispatcher->url(
$request,
PKPApplication::ROUTE_PAGE,
'index',
'admin',
'index',
null
));
break;
case NavigationMenuItem::NMI_TYPE_USER_DASHBOARD:
if ($currentUser->hasRole([Role::ROLE_ID_MANAGER, Role::ROLE_ID_ASSISTANT, Role::ROLE_ID_REVIEWER, Role::ROLE_ID_AUTHOR], $contextId) || $currentUser->hasRole([Role::ROLE_ID_SITE_ADMIN], PKPApplication::CONTEXT_SITE)) {
$navigationMenuItem->setUrl($dispatcher->url(
$request,
PKPApplication::ROUTE_PAGE,
null,
'submissions',
null,
null
));
} else {
$navigationMenuItem->setUrl($dispatcher->url(
$request,
PKPApplication::ROUTE_PAGE,
null,
'user',
'profile',
null
));
}
break;
case NavigationMenuItem::NMI_TYPE_USER_REGISTER:
$navigationMenuItem->setUrl($dispatcher->url(
$request,
PKPApplication::ROUTE_PAGE,
null,
'user',
'register',
null
));
break;
case NavigationMenuItem::NMI_TYPE_USER_LOGIN:
$navigationMenuItem->setUrl($dispatcher->url(
$request,
PKPApplication::ROUTE_PAGE,
null,
'login',
null,
null
));
break;
case NavigationMenuItem::NMI_TYPE_CUSTOM:
if ($navigationMenuItem->getPath()) {
$path = explode('/', $navigationMenuItem->getPath());
$page = array_shift($path);
$op = array_shift($path);
$navigationMenuItem->setUrl($dispatcher->url(
$request,
PKPApplication::ROUTE_PAGE,
null,
$page,
$op,
$path
));
}
break;
case NavigationMenuItem::NMI_TYPE_SEARCH:
$navigationMenuItem->setUrl($dispatcher->url(
$request,
PKPApplication::ROUTE_PAGE,
null,
'search',
null,
null
));
break;
case NavigationMenuItem::NMI_TYPE_PRIVACY:
$navigationMenuItem->setUrl($dispatcher->url(
$request,
PKPApplication::ROUTE_PAGE,
null,
'about',
'privacy',
null
));
break;
case NavigationMenuItem::NMI_TYPE_REMOTE_URL:
$navigationMenuItem->setUrl($navigationMenuItem->getLocalizedRemoteUrl());
break;
}
}
Hook::call('NavigationMenus::displaySettings', [$navigationMenuItem, $navigationMenu]);
$templateMgr->assign('navigationMenuItem', $navigationMenuItem);
}
public function loadMenuTree(&$navigationMenu)
{
/** @var NavigationMenuItemDAO */
$navigationMenuItemDao = DAORegistry::getDAO('NavigationMenuItemDAO');
$items = $navigationMenuItemDao->getByMenuId($navigationMenu->getId())->toArray();
/** @var NavigationMenuItemAssignmentDAO */
$navigationMenuItemAssignmentDao = DAORegistry::getDAO('NavigationMenuItemAssignmentDAO');
$assignments = $navigationMenuItemAssignmentDao->getByMenuId($navigationMenu->getId())
->toArray();
foreach ($assignments as $assignment) {
foreach ($items as $item) {
if ($item->getId() === $assignment->getMenuItemId()) {
$assignment->setMenuItem($item);
break;
}
}
}
// Create an array of parent items and array of child items sorted by
// their parent id as the array key
$navigationMenu->menuTree = [];
$children = [];
foreach ($assignments as $assignment) {
if (!$assignment->getParentId()) {
$navigationMenu->menuTree[] = $assignment;
} else {
if (!isset($children[$assignment->getParentId()])) {
$children[$assignment->getParentId()] = [];
}
$children[$assignment->getParentId()][] = $assignment;
}
}
// Assign child items to parent in array
for ($i = 0; $i < count($navigationMenu->menuTree); $i++) {
$assignmentId = $navigationMenu->menuTree[$i]->getMenuItemId();
if (isset($children[$assignmentId])) {
$navigationMenu->menuTree[$i]->children = $children[$assignmentId];
}
}
/** @var NavigationMenuDAO */
$navigationMenuDao = DAORegistry::getDAO('NavigationMenuDAO');
$cache = $navigationMenuDao->getCache($navigationMenu->getId());
$json = json_encode($navigationMenu);
$cache->setEntireCache($json);
}
/**
* Get a tree of NavigationMenuItems assigned to this menu
*
* @param NavigationMenu $navigationMenu
*
*/
public function getMenuTree(&$navigationMenu)
{
/** @var NavigationMenuDAO */
$navigationMenuDao = DAORegistry::getDAO('NavigationMenuDAO');
/** @var FileCache */
$cache = $navigationMenuDao->getCache($navigationMenu->getId());
if ($cache->cache) {
$navigationMenu = json_decode($cache->cache, true);
$navigationMenu = $this->arrayToObject('NavigationMenu', $navigationMenu);
$this->loadMenuTreeDisplayState($navigationMenu);
return;
}
$this->loadMenuTree($navigationMenu);
$this->loadMenuTreeDisplayState($navigationMenu);
}
private function loadMenuTreeDisplayState(&$navigationMenu)
{
foreach ($navigationMenu->menuTree as $assignment) {
$nmi = $assignment->getMenuItem();
if ($assignment->children) {
foreach ($assignment->children as $childAssignment) {
$childNmi = $childAssignment->getMenuItem();
$this->getDisplayStatus($childNmi, $navigationMenu);
if ($childNmi->getIsDisplayed()) {
$nmi->setIsChildVisible(true);
}
}
}
$this->getDisplayStatus($nmi, $navigationMenu);
}
}
/**
* Helper function to transform the json_decoded cached NavigationMenu object (stdClass) to the actual NavigationMenu object
* Some changes on the NavigationMenu objects must be reflected here
*/
public function arrayToObject($class, $array)
{
if ($class == 'NavigationMenu') {
$obj = new NavigationMenu();
} elseif ($class == 'NavigationMenuItem') {
$obj = new NavigationMenuItem();
} elseif ($class == 'NavigationMenuItemAssignment') {
$obj = new NavigationMenuItemAssignment();
}
foreach ($array as $k => $v) {
if (strlen($k)) {
if (is_array($v) && $k == 'menuTree') {
$treeChildren = [];
foreach ($v as $treeChild) {
array_push($treeChildren, $this->arrayToObject('NavigationMenuItemAssignment', $treeChild));
}
$obj->{$k} = $treeChildren;
} elseif (is_array($v) && $k == 'navigationMenuItem') {
$obj->{$k} = $this->arrayToObject('NavigationMenuItem', $v); //RECURSION
} elseif (is_array($v) && $k == 'children') {
$treeChildren = [];
foreach ($v as $treeChild) {
array_push($treeChildren, $this->arrayToObject('NavigationMenuItemAssignment', $treeChild));
}
$obj->{$k} = $treeChildren;
} else {
$obj->{$k} = $v;
}
}
}
// should call transformNavMenuItemTitle because some
// request don't have all template variables in place
if ($class == 'NavigationMenuItem') {
$templateMgr = TemplateManager::getManager(Application::get()->getRequest());
$this->transformNavMenuItemTitle($templateMgr, $obj);
}
return $obj;
}
/**
* Transform an item title if the title includes a {$variable}
*
* @param TemplateManager $templateMgr
*/
public function transformNavMenuItemTitle($templateMgr, &$navigationMenuItem)
{
$this->setNMITitleLocalized($navigationMenuItem);
$title = $navigationMenuItem->getLocalizedTitle();
$prefix = '{$';
$postfix = '}';
$prefixPos = strpos($title, $prefix);
$postfixPos = strpos($title, $postfix);
if ($prefixPos !== false && $postfixPos !== false && ($postfixPos - $prefixPos) > 0) {
$titleRepl = substr($title, $prefixPos + strlen($prefix), $postfixPos - $prefixPos - strlen($prefix));
$templateReplaceTitle = $templateMgr->getTemplateVars($titleRepl);
if ($templateReplaceTitle) {
$navigationMenuItem->setTitle($templateReplaceTitle, Locale::getLocale());
}
}
}
/**
* Populate the navigationMenuItem and the children properties of the NMIAssignment object
*
* @param NavigationMenuItemAssignment $nmiAssignment The NMIAssignment object passed by reference
*/
public function populateNMIAssignmentContainedObjects(&$nmiAssignment)
{
// Set NMI
/** @var NavigationMenuItemDAO */
$navigationMenuItemDao = DAORegistry::getDAO('NavigationMenuItemDAO');
$nmiAssignment->setMenuItem($navigationMenuItemDao->getById($nmiAssignment->getMenuItemId()));
// Set Children
/** @var NavigationMenuItemAssignmentDAO */
$navigationMenuItemAssignmentDao = DAORegistry::getDAO('NavigationMenuItemAssignmentDAO');
$nmiAssignment->children = $navigationMenuItemAssignmentDao->getByMenuIdAndParentId($nmiAssignment->getMenuId(), $nmiAssignment->getId())
->toArray();
// Recursive call to populate NMI and children properties of NMIAssignment's children
foreach ($nmiAssignment->children as $assignmentChild) {
$this->populateNMIAssignmentContainedObjects($assignmentChild);
}
}
/**
* Returns whether a NM's NMI has a child of a certain NMIType
*
* @param NavigationMenu $navigationMenu The NM to be searched
* @param NavigationMenuItem $navigationMenuItem The NMI to check its children for NMIType
* @param string $nmiType The NMIType
* @param bool $isDisplayed optional. If true the function checks if the found NMI of type $nmiType is displayed.
*
* @return bool Returns true if a NMI of type $nmiType has been found as child of the given $navigationMenuItem.
*/
private function _hasNMTreeNMIAssignmentWithChildOfNMIType($navigationMenu, $navigationMenuItem, $nmiType, $isDisplayed = true)
{
foreach ($navigationMenu->menuTree as $nmiAssignment) {
$nmi = $nmiAssignment->getMenuItem();
if (isset($nmi) && $nmi->getId() == $navigationMenuItem->getId()) {
foreach ($nmiAssignment->children as $childNmiAssignment) {
$childNmi = $childNmiAssignment->getMenuItem();
if (isset($nmi) && $childNmi->getType() == $nmiType) {
if ($isDisplayed) {
return $childNmi->getIsDisplayed();
} else {
return true;
}
}
}
}
}
return false;
}
/**
* Sets the title of a navigation menu item, depending on its title or locale-key
*
* @param NavigationMenuItem $nmi The NMI to set its title
*/
public function setNMITitleLocalized($nmi)
{
if ($nmi) {
if ($localizedTitle = $nmi->getLocalizedTitle()) {
$nmi->setTitle($localizedTitle, Locale::getLocale());
} elseif ($nmi->getTitleLocaleKey() === '{$loggedInUsername}') {
$nmi->setTitle($nmi->getTitleLocaleKey(), Locale::getLocale());
} else {
$nmi->setTitle(__($nmi->getTitleLocaleKey()), Locale::getLocale());
}
}
}
/**
* Sets the title of a navigation menu item, depending on its title or locale-key
*
* @param NavigationMenuItem $nmi The NMI to set its title
*/
public function setAllNMILocalizedTitles($nmi)
{
if ($nmi) {
$supportedFormLocales = Locale::getSupportedFormLocales();
foreach ($supportedFormLocales as $supportedFormLocale => $supportedFormLocaleValue) {
if ($localizedTitle = $nmi->getTitle($supportedFormLocale)) {
$nmi->setTitle($localizedTitle, $supportedFormLocale);
} else {
$nmi->setTitle(__($nmi->getTitleLocaleKey(), [], $supportedFormLocale), $supportedFormLocale);
}
}
}
}
/**
* Callback to be registered from PKPTemplateManager for the LoadHandler hook.
* Used by the Custom NMI to point their URL target to [context]/[path]
*
* @return bool true if the callback has handled the request.
*/
public function _callbackHandleCustomNavigationMenuItems($hookName, $args)
{
$request = Application::get()->getRequest();
$page = & $args[0];
$op = & $args[1];
$handler = & $args[3];
// Construct a path to look for
$path = $page;
if ($op !== 'index') {
$path .= "/{$op}";
}
if ($arguments = $request->getRequestedArgs()) {
$path .= '/' . implode('/', $arguments);
}
// Look for a static page with the given path
/** @var NavigationMenuItemDAO */
$navigationMenuItemDao = DAORegistry::getDAO('NavigationMenuItemDAO');
$context = $request->getContext();
$contextId = $context ? $context->getId() : \PKP\core\PKPApplication::CONTEXT_ID_NONE;
$customNMI = $navigationMenuItemDao->getByPath($contextId, $path);
// Check if a custom NMI with the requested path exists
if ($customNMI) {
// Trick the handler into dealing with it normally
$page = 'pages';
$op = 'view';
// It is -- attach the custom NMI handler.
$handler = new NavigationMenuItemHandler($customNMI);
return true;
}
return false;
}
}
@@ -0,0 +1,636 @@
<?php
/**
* @file classes/services/PKPSchemaService.php
*
* Copyright (c) 2014-2021 Simon Fraser University
* Copyright (c) 2000-2021 John Willinsky
* Distributed under the GNU GPL v3. For full terms see the file docs/COPYING.
*
* @class PKPSchemaService
*
* @ingroup services
*
* @brief Helper class for loading schemas, using them to sanitize and
* validate objects, and installing default data.
*/
namespace PKP\services;
use Exception;
use Illuminate\Support\Arr;
use Illuminate\Support\MessageBag;
use PKP\core\DataObject;
use PKP\plugins\Hook;
/**
* @template T of DataObject
*/
class PKPSchemaService
{
public const SCHEMA_ANNOUNCEMENT = 'announcement';
public const SCHEMA_AUTHOR = 'author';
public const SCHEMA_CATEGORY = 'category';
public const SCHEMA_CONTEXT = 'context';
public const SCHEMA_DOI = 'doi';
public const SCHEMA_DECISION = 'decision';
public const SCHEMA_EMAIL_TEMPLATE = 'emailTemplate';
public const SCHEMA_GALLEY = 'galley';
public const SCHEMA_HIGHLIGHT = 'highlight';
public const SCHEMA_INSTITUTION = 'institution';
public const SCHEMA_ISSUE = 'issue';
public const SCHEMA_PUBLICATION = 'publication';
public const SCHEMA_REVIEW_ASSIGNMENT = 'reviewAssignment';
public const SCHEMA_REVIEW_ROUND = 'reviewRound';
public const SCHEMA_SECTION = 'section';
public const SCHEMA_SITE = 'site';
public const SCHEMA_SUBMISSION = 'submission';
public const SCHEMA_SUBMISSION_FILE = 'submissionFile';
public const SCHEMA_USER = 'user';
public const SCHEMA_USER_GROUP = 'userGroup';
public const SCHEMA_EVENT_LOG = 'eventLog';
/** @var array cache of schemas that have been loaded */
private $_schemas = [];
/**
* Get a schema
*
* - Loads the schema file and transforms it into an object
* - Passes schema through hook
* - Returns pre-loaded schemas on request
*
* @param string $schemaName One of the SCHEMA_... constants
* @param bool $forceReload Optional. Compile the schema again from the
* source files, bypassing any cached version.
*
* @return object
*/
public function get($schemaName, $forceReload = false)
{
if (!$forceReload && array_key_exists($schemaName, $this->_schemas)) {
return $this->_schemas[$schemaName];
}
$schemaFile = sprintf('%s/lib/pkp/schemas/%s.json', BASE_SYS_DIR, $schemaName);
if (file_exists($schemaFile)) {
$schema = json_decode(file_get_contents($schemaFile));
if (!$schema) {
throw new Exception('Schema failed to decode. This usually means it is invalid JSON. Requested: ' . $schemaFile . '. Last JSON error: ' . json_last_error());
}
} else {
// allow plugins to create a custom schema and load it via hook
$schema = new \stdClass();
}
// Merge an app-specific schema file if it exists
$appSchemaFile = sprintf('%s/schemas/%s.json', BASE_SYS_DIR, $schemaName);
if (file_exists($appSchemaFile)) {
$appSchema = json_decode(file_get_contents($appSchemaFile));
if (!$appSchema) {
throw new Exception('Schema failed to decode. This usually means it is invalid JSON. Requested: ' . $appSchemaFile . '. Last JSON error: ' . json_last_error());
}
$schema = $this->merge($schema, $appSchema);
}
Hook::call('Schema::get::' . $schemaName, [&$schema]);
$this->_schemas[$schemaName] = $schema;
return $schema;
}
/**
* Merge two schemas
*
* Merges the properties of two schemas, updating the title, description,
* and properties definitions.
*
* If both schemas contain definitions for the same property, the property
* definition in the additional schema will override the base schema.
*
* @param object $baseSchema The base schema
* @param object $additionalSchema The additional schema properties to apply
* to $baseSchema.
*
* @return object
*/
public function merge($baseSchema, $additionalSchema)
{
$newSchema = clone $baseSchema;
if (!empty($additionalSchema->title)) {
$newSchema->title = $additionalSchema->title;
}
if (!empty($additionalSchema->description)) {
$newSchema->description = $additionalSchema->description;
}
if (!empty($additionalSchema->required)) {
$required = property_exists($baseSchema, 'required')
? $baseSchema->required
: [];
$newSchema->required = array_unique(array_merge($required, $additionalSchema->required));
}
if (!empty($additionalSchema->properties)) {
if (empty($newSchema->properties)) {
$newSchema->properties = new \stdClass();
}
foreach ($additionalSchema->properties as $propName => $propSchema) {
$newSchema->properties->{$propName} = $propSchema;
}
}
return $newSchema;
}
/**
* Get the summary properties of a schema
*
* Gets the properties of a schema which are considered part of the summary
* view presented in an API.
*
* @param string $schemaName One of the SCHEMA_... constants
*
* @return array List of property names
*/
public function getSummaryProps($schemaName)
{
$schema = $this->get($schemaName);
$props = [];
foreach ($schema->properties as $propName => $propSchema) {
if (!empty($propSchema->apiSummary) && empty($propSchema->writeOnly)) {
$props[] = $propName;
}
}
return $props;
}
/**
* Get all properties of a schema
*
* Gets the complete list of properties of a schema which are considered part
* of the full view presented in an API.
*
* @param string $schemaName One of the SCHEMA_... constants
*
* @return array List of property names
*/
public function getFullProps($schemaName)
{
$schema = $this->get($schemaName);
$propNames = [];
foreach ($schema->properties as $propName => $propSchema) {
if (empty($propSchema->writeOnly)) {
$propNames[] = $propName;
}
}
return $propNames;
}
/**
* Get required properties of a schema
*
* @param string $schemaName One of the SCHEMA_... constants
*
* @return array List of property names
*/
public function getRequiredProps($schemaName)
{
$schema = $this->get($schemaName);
if (!empty($schema->required)) {
return $schema->required;
}
return [];
}
/**
* Get multilingual properties of a schema
*
* @param string $schemaName One of the SCHEMA_... constants
*
* @return array List of property names
*/
public function getMultilingualProps($schemaName)
{
$schema = $this->get($schemaName);
$multilingualProps = [];
foreach ($schema->properties as $propName => $propSchema) {
if (!empty($propSchema->multilingual)) {
$multilingualProps[] = $propName;
}
}
return $multilingualProps;
}
/**
* Sanitize properties according to a schema
*
* This method coerces properties to their appropriate type, and strips out
* properties that are not specified in the schema.
*
* @param string $schemaName One of the SCHEMA_... constants
* @param array $props Properties to be sanitized
*
* @return array The sanitized props
*/
public function sanitize($schemaName, $props)
{
$schema = $this->get($schemaName);
$cleanProps = [];
foreach ($props as $propName => $propValue) {
if (empty($schema->properties->{$propName})
|| empty($schema->properties->{$propName}->type)
|| !empty($schema->properties->{$propName}->readOnly)) {
continue;
}
$propSchema = $schema->properties->{$propName};
if (!empty($propSchema->multilingual)) {
$values = [];
foreach ((array) $propValue as $localeKey => $localeValue) {
$values[$localeKey] = $this->coerce($localeValue, $propSchema->type, $propSchema);
}
if (!empty($values)) {
$cleanProps[$propName] = $values;
}
} else {
$cleanProps[$propName] = $this->coerce($propValue, $propSchema->type, $propSchema);
}
}
return $cleanProps;
}
/**
* Coerce a value to a variable type
*
* It will leave null values alone.
*
* @param string $type boolean, integer, number, string, array, object
* @param object $schema A schema defining this property
*
* @return mixed The value coerced to type
*/
public function coerce($value, $type, $schema)
{
if (is_null($value)) {
return $value;
}
switch ($type) {
case 'boolean':
return (bool) $value;
case 'integer':
return (int) $value;
case 'number':
return (float) $value;
case 'string':
if (is_object($value) || is_array($value)) {
$value = serialize($value);
}
return (string) $value;
case 'array':
$newArray = [];
if (is_array($schema->items)) {
foreach ($schema->items as $i => $itemSchema) {
$newArray[$i] = $this->coerce($value[$i], $itemSchema->type, $itemSchema);
}
} elseif (is_array($value)) {
foreach ($value as $i => $v) {
$newArray[$i] = $this->coerce($v, $schema->items->type, $schema->items);
}
} else {
$newArray[] = serialize($value);
}
return $newArray;
case 'object':
$newObject = []; // we handle JSON objects as assoc arrays in PHP
foreach ($schema->properties as $propName => $propSchema) {
if (!isset($value[$propName]) || !empty($propSchema->readOnly)) {
continue;
}
$newObject[$propName] = $this->coerce($value[$propName], $propSchema->type, $propSchema);
}
return $newObject;
}
throw new Exception('Requested variable coercion for a type that was not recognized: ' . $type);
}
/**
* Get the validation rules for the properties of a schema
*
* These validation rules are returned in a format that is ready to be passed
* into ValidatorFactory::make().
*
* @param string $schemaName One of the SCHEMA_... constants
* @param array $allowedLocales List of allowed locale keys.
*
* @return array List of validation rules for each property
*/
public function getValidationRules($schemaName, $allowedLocales)
{
$schema = $this->get($schemaName);
$rules = [];
foreach ($schema->properties as $propName => $propSchema) {
if (!empty($propSchema->multilingual)) {
foreach ($allowedLocales as $localeKey) {
$rules = $this->addPropValidationRules($rules, $propName . '.' . $localeKey, $propSchema);
}
} else {
$rules = $this->addPropValidationRules($rules, $propName, $propSchema);
}
}
return $rules;
}
/**
* Compile the validation rules for a single property's schema
*
* @param object $propSchema The property schema
*
* @return array List of Laravel-formatted validation rules
*/
public function addPropValidationRules($rules, $ruleKey, $propSchema)
{
if (!empty($propSchema->readOnly)) {
return $rules;
}
switch ($propSchema->type) {
case 'boolean':
case 'integer':
case 'numeric':
case 'string':
$rules[$ruleKey] = [$propSchema->type];
if (!empty($propSchema->validation)) {
$rules[$ruleKey] = array_merge($rules[$ruleKey], $propSchema->validation);
}
break;
case 'array':
if ($propSchema->items->type === 'object') {
$rules = $this->addPropValidationRules($rules, $ruleKey . '.*', $propSchema->items);
} else {
$rules[$ruleKey] = ['array'];
if (!empty($propSchema->validation)) {
$rules[$ruleKey] = array_merge($rules[$ruleKey], $propSchema->validation);
}
$rules[$ruleKey . '.*'] = [$propSchema->items->type];
if (!empty($propSchema->items->validation)) {
$rules[$ruleKey . '.*'] = array_merge($rules[$ruleKey . '.*'], $propSchema->items->validation);
}
}
break;
case 'object':
foreach ($propSchema->properties ?? [] as $subPropName => $subPropSchema) {
$rules = $this->addPropValidationRules($rules, $ruleKey . '.' . $subPropName, $subPropSchema);
}
break;
}
return $rules;
}
/**
* Format validation errors
*
* This method receives a (Laravel) MessageBag object and formats an error
* array to match the entity's schema. It converts Laravel's dot notation for
* objects and arrays:
*
* [
* foo.en: ['Error message'],
* foo.fr_CA: ['Error message'],
* bar.0.baz: ['Error message'],
* ]
*
* Into an assoc array, collapsing subproperty errors into their parent prop:
*
* [
* foo: [
* en: ['Error message'],
* fr_CA: ['Error message'],
* ],
* bar: ['Error message'],
* ]
*/
public function formatValidationErrors(MessageBag $errorBag): array
{
$formatted = [];
foreach ($errorBag->getMessages() as $ruleKey => $messages) {
Arr::set($formatted, $ruleKey, $messages);
}
return $formatted;
}
/**
* Set default values for an object
*
* Get default values from an object's schema and set them for the passed
* object.
*
* localeParams are used to populate translation strings where default values
* rely on them. For example, a locale string like the following:
*
* "This email was sent on behalf of {$contextName}."
*
* Will expect a $localeParams value like this:
*
* ['contextName' => 'Journal of Public Knowledge']
*
* @param string $schemaName One of the SCHEMA_... constants
* @param T $object The object to be modified
* @param array $supportedLocales List of locale keys that should receive
* default content. Example: ['en', 'fr_CA']
* @param string $primaryLocale Example: `en`
* @param array $localeParams Key/value params for the translation strings
*
* @return T
*/
public function setDefaults($schemaName, $object, $supportedLocales, $primaryLocale, $localeParams = [])
{
$schema = $this->get($schemaName);
foreach ($schema->properties as $propName => $propSchema) {
// Don't override existing values
if (!is_null($object->getData($propName))) {
continue;
}
if (!property_exists($propSchema, 'default') && !property_exists($propSchema, 'defaultLocaleKey')) {
continue;
}
if (!empty($propSchema->multilingual)) {
$value = [];
foreach ($supportedLocales as $localeKey) {
$value[$localeKey] = $this->getDefault($propSchema, $localeParams, $localeKey);
}
} else {
$value = $this->getDefault($propSchema, $localeParams, $primaryLocale);
}
$object->setData($propName, $value);
}
return $object;
}
/**
* Get the default values for a specific locale
*
* @param string $schemaName One of the SCHEMA_... constants
* @param string $locale The locale key to get values for. Example: `en`
* @param array $localeParams Key/value params for the translation strings
*
* @return array Key/value of property defaults for the specified locale
*/
public function getLocaleDefaults($schemaName, $locale, $localeParams)
{
$schema = $this->get($schemaName);
$defaults = [];
foreach ($schema->properties as $propName => $propSchema) {
if (empty($propSchema->multilingual) || empty($propSchema->defaultLocaleKey)) {
continue;
}
$defaults[$propName] = $this->getDefault($propSchema, $localeParams, $locale);
}
return $defaults;
}
/**
* Get a default value for a property based on the schema
*
* @param object $propSchema The schema definition for this property
* @param array|null $localeParams Optional. Key/value params for the translation strings
* @param string|null $localeKey Optional. The locale to translate into
*
* @return mixed Will return null if no default value is available
*/
public function getDefault($propSchema, $localeParams = null, $localeKey = null)
{
$localeParams ??= [];
switch ($propSchema->type) {
case 'boolean':
case 'integer':
case 'number':
case 'string':
if (property_exists($propSchema, 'default')) {
return $propSchema->default;
} elseif (property_exists($propSchema, 'defaultLocaleKey')) {
return __($propSchema->defaultLocaleKey, $localeParams, $localeKey);
}
break;
case 'array':
$value = [];
foreach ($propSchema->default as $default) {
$itemSchema = $propSchema->items;
$itemSchema->default = $default;
$value[] = $this->getDefault($itemSchema, $localeParams, $localeKey);
}
return $value;
case 'object':
$value = [];
foreach ($propSchema->properties ?? [] as $subPropName => $subPropSchema) {
if (!property_exists($propSchema->default, $subPropName)) {
continue;
}
$defaultSubProp = $propSchema->default->{$subPropName};
// If a prop is expected to be a string but the default value is an
// object with a `defaultLocaleKey` property, then we render that
// translation. Otherwise, we assign the values as-is and do not
// recursively check for nested objects/arrays inside of objects.
if ($subPropSchema->type === 'string' && is_object($defaultSubProp) && property_exists($defaultSubProp, 'defaultLocaleKey')) {
$value[$subPropName] = __($defaultSubProp->defaultLocaleKey, $localeParams, $localeKey);
} else {
$value[$subPropName] = $defaultSubProp;
}
}
return $value;
}
return null;
}
/**
* Add multilingual props for missing values
*
* This method will take a set of property values and add empty entries for
* any locales that are missing. Given the following:
*
* $values = [
* 'title' => [
* 'en' => 'The Journal of Public Knowledge',
* ]
* ]
*
* If the locales en and fr_CA are requested, it will return the following:
*
* $values = [
* 'title' => [
* 'en' => 'The Journal of Public Knowledge',
* 'fr_CA' => '',
* ]
* ]
*
* This is primarily used to ensure API responses present a consistent data
* structure regardless of which properties have values.
*
* @param string $schemaName One of the SCHEMA_... constants
* @param array $values Key/value list of entity properties
*
* @return array
*/
public function addMissingMultilingualValues($schemaName, $values, $localeKeys)
{
$schema = $this->get($schemaName);
$multilingualProps = $this->getMultilingualProps($schemaName);
foreach ($values as $key => $value) {
if (!in_array($key, $multilingualProps)) {
continue;
}
foreach ($localeKeys as $localeKey) {
if (is_array($value) && array_key_exists($localeKey, $value)) {
continue;
}
switch ($schema->properties->{$key}->type) {
case 'string':
$values[$key][$localeKey] = '';
break;
case 'array':
$values[$key][$localeKey] = [];
break;
default:
$values[$key][$localeKey] = null;
}
}
}
return $values;
}
}
if (!PKP_STRICT_MODE) {
class_alias('\PKP\services\PKPSchemaService', '\PKPSchemaService');
foreach ([
'SCHEMA_ANNOUNCEMENT',
'SCHEMA_AUTHOR',
'SCHEMA_CONTEXT',
'SCHEMA_EMAIL_TEMPLATE',
'SCHEMA_GALLEY',
'SCHEMA_ISSUE',
'SCHEMA_PUBLICATION',
'SCHEMA_REVIEW_ASSIGNMENT',
'SCHEMA_REVIEW_ROUND',
'SCHEMA_SECTION',
'SCHEMA_SITE',
'SCHEMA_SUBMISSION',
'SCHEMA_SUBMISSION_FILE',
'SCHEMA_USER',
'SCHEMA_USER_GROUP',
] as $constantName) {
if (!defined($constantName)) {
define($constantName, constant('PKPSchemaService::' . $constantName));
}
}
}
+338
View File
@@ -0,0 +1,338 @@
<?php
/**
* @file classes/services/PKPSiteService.php
*
* Copyright (c) 2014-2021 Simon Fraser University
* Copyright (c) 2000-2021 John Willinsky
* Distributed under the GNU GPL v3. For full terms see the file docs/COPYING.
*
* @class PKPSiteService
*
* @ingroup services
*
* @brief Helper class that encapsulates business logic for the overall site
*/
namespace PKP\services;
use APP\core\Application;
use APP\core\Request;
use APP\core\Services;
use APP\file\PublicFileManager;
use PKP\core\Core;
use PKP\db\DAORegistry;
use PKP\file\TemporaryFile;
use PKP\file\TemporaryFileManager;
use PKP\plugins\Hook;
use PKP\plugins\PluginRegistry;
use PKP\services\interfaces\EntityPropertyInterface;
use PKP\services\interfaces\EntityWriteInterface;
use PKP\site\Site;
use PKP\site\SiteDAO;
use PKP\validation\ValidatorFactory;
class PKPSiteService implements EntityPropertyInterface
{
/**
* @copydoc \PKP\services\interfaces\EntityPropertyInterface::getProperties()
*
* @param null|mixed $args
*/
public function getProperties($site, $props, $args = null)
{
$request = $args['request'];
$router = $request->getRouter();
$dispatcher = $request->getDispatcher();
$values = [];
foreach ($props as $prop) {
$values[$prop] = $site->getData($prop);
}
$values = Services::get('schema')->addMissingMultilingualValues(PKPSchemaService::SCHEMA_SITE, $values, $site->getSupportedLocales());
Hook::call('Site::getProperties', [&$values, $site, $props, $args]);
ksort($values);
return $values;
}
/**
* @copydoc \PKP\services\interfaces\EntityPropertyInterface::getSummaryProperties()
*
* @param null|mixed $args
*/
public function getSummaryProperties($site, $args = null)
{
return $this->getFullProperties($site, $args);
}
/**
* @copydoc \PKP\services\interfaces\EntityPropertyInterface::getFullProperties()
*
* @param null|mixed $args
*/
public function getFullProperties($site, $args = null)
{
$props = Services::get('schema')->getFullProps(PKPSchemaService::SCHEMA_SITE);
return $this->getProperties($site, $props, $args);
}
/**
* Validate the properties of a site
*
* Passes the properties through the SchemaService to validate them, and
* performs any additional checks needed to validate a site.
*
* This does NOT authenticate the current user to perform the action.
*
* @param array $props The data to validate
* @param array $allowedLocales Which locales are allowed for this context
* @param string $primaryLocale
*
* @return array List of error messages. The array keys are property names
*/
public function validate($props, $allowedLocales, $primaryLocale)
{
$schemaService = Services::get('schema');
$validator = ValidatorFactory::make(
$props,
$schemaService->getValidationRules(PKPSchemaService::SCHEMA_SITE, $allowedLocales),
[
'primaryLocale.regex' => __('validator.localeKey'),
'supportedLocales.regex' => __('validator.localeKey'),
]
);
// Check required fields
ValidatorFactory::required(
$validator,
EntityWriteInterface::VALIDATE_ACTION_EDIT,
$schemaService->getRequiredProps(PKPSchemaService::SCHEMA_PUBLICATION),
$schemaService->getMultilingualProps(PKPSchemaService::SCHEMA_PUBLICATION),
$allowedLocales,
$primaryLocale
);
// Check for input from disallowed locales
ValidatorFactory::allowedLocales(
$validator,
$schemaService->getMultilingualProps(PKPSchemaService::SCHEMA_SITE),
$allowedLocales
);
// If a new file has been uploaded, check that the temporary file exists and
// the current user owns it
$user = Application::get()->getRequest()->getUser();
ValidatorFactory::temporaryFilesExist(
$validator,
['pageHeaderTitleImage', 'styleSheet'],
['pageHeaderTitleImage'],
$props,
$allowedLocales,
$user ? $user->getId() : null
);
// If sidebar blocks are passed, ensure the block plugin exists and is
// enabled
$validator->after(function ($validator) use ($props) {
if (!empty($props['sidebar']) && !$validator->errors()->get('sidebar')) {
$plugins = PluginRegistry::loadCategory('blocks', true);
foreach ($props['sidebar'] as $pluginName) {
if (empty($plugins[$pluginName])) {
$validator->errors()->add('sidebar', __('manager.setup.layout.sidebar.invalidBlock', ['name' => $pluginName]));
}
}
}
});
// Ensure the theme plugin is installed and enabled
$validator->after(function ($validator) use ($props) {
if (!empty($props['themePluginPath']) && !$validator->errors()->get('themePluginPath')) {
$plugins = PluginRegistry::loadCategory('themes', true);
$found = false;
foreach ($plugins as $plugin) {
if ($props['themePluginPath'] === $plugin->getDirName()) {
$found = true;
break;
}
}
if (!$found) {
$validator->errors()->add('themePluginPath', __('manager.setup.theme.notFound'));
}
}
});
$validator->after(function ($validator) use ($props) {
if (!empty($props['isSiteSushiPlatform']) && $props['isSiteSushiPlatform']) {
if (empty($props['sushiPlatformID'])) {
$validator->errors()->add('sushiPlatformID', __('admin.settings.statistics.sushiPlatform.sushiPlatformID.required'));
}
}
});
if ($validator->fails()) {
$errors = $schemaService->formatValidationErrors($validator->errors());
}
Hook::call('Site::validate', [&$errors, $props, $allowedLocales, $primaryLocale]);
return $errors;
}
/**
* Edit the site
*
* This does not check if the user is authorized to edit the site, or validate or sanitize
* the new content.
*
* @param Site $site The context to edit
* @param array $params Key/value array of new data
* @param Request $request
*
* @return Site
*/
public function edit($site, $params, $request)
{
$siteDao = DAORegistry::getDAO('SiteDAO'); /** @var SiteDAO $siteDao */
// Move uploaded files into place and update the params
$userId = $request->getUser() ? $request->getUser()->getId() : null;
$supportedLocales = $site->getSupportedLocales();
if (array_key_exists('pageHeaderTitleImage', $params)) {
foreach ($supportedLocales as $localeKey) {
if (!array_key_exists($localeKey, $params['pageHeaderTitleImage'])) {
continue;
}
$params['pageHeaderTitleImage'][$localeKey] = $this->_saveFileParam($site, $params['pageHeaderTitleImage'][$localeKey], 'pageHeaderTitleImage', $userId, $localeKey, true);
}
}
if (array_key_exists('styleSheet', $params)) {
$params['styleSheet'] = $this->_saveFileParam($site, $params['styleSheet'], 'styleSheet', $userId);
}
$newSite = $siteDao->newDataObject();
$newSite->_data = array_merge($site->_data, $params);
Hook::call('Site::edit', [&$newSite, $site, $params, $request]);
$siteDao->updateObject($newSite);
$newSite = $siteDao->getSite();
return $newSite;
}
/**
* Move a temporary file to the site's public directory
*
* @param Site $context
* @param TemporaryFile $temporaryFile
* @param string $fileNameBase Unique identifier to use for the filename. The
* Extension and locale will be appended.
* @param int $userId ID of the user who uploaded the temporary file
* @param string $localeKey Example: en. Leave empty for a file that is
* not localized.
*
* @return string|boolean The new filename or false on failure
*/
public function moveTemporaryFile($site, $temporaryFile, $fileNameBase, $userId, $localeKey = '')
{
$publicFileManager = new PublicFileManager();
$temporaryFileManager = new TemporaryFileManager();
$fileName = $fileNameBase;
if ($localeKey) {
$fileName .= '_' . $localeKey;
}
$extension = $publicFileManager->getDocumentExtension($temporaryFile->getFileType());
if (!$extension) {
$extension = $publicFileManager->getImageExtension($temporaryFile->getFileType());
}
$fileName .= $extension;
if (!$publicFileManager->copyFile($temporaryFile->getFilePath(), $publicFileManager->getSiteFilesPath() . '/' . $fileName)) {
return false;
}
$temporaryFileManager->deleteById($temporaryFile->getId(), $userId);
return $fileName;
}
/**
* Handle a site setting for an uploaded file
*
* - Moves the temporary file to the public directory
* - Resets the param value to what is expected to be stored in the db
*
* This method is protected because all operations which edit the site should
* go through the editSite method in order to ensure that the appropriate hooks are fired.
*
* @param Site $site The site being edited
* @param mixed $value The param value to be saved. Contains the temporary
* file ID if a new file has been uploaded.
* @param string $settingName The name of the setting to save, typically used
* in the filename.
* @param int $userId ID of the user who owns the temporary file
* @param string $localeKey Optional. Used in the filename for multilingual
* properties.
* @param bool $isImage Optional. For image files which return alt text,
* width, height, etc in the param value.
*
* @return string|array|null New param value or null on failure
*/
protected function _saveFileParam($site, $value, $settingName, $userId, $localeKey = '', $isImage = false)
{
$temporaryFileManager = new TemporaryFileManager();
// If the value is null, clean up any existing file in the system
if (is_null($value)) {
$setting = $site->getData($settingName, $localeKey);
if ($setting) {
$fileName = $isImage ? $setting['uploadName'] : $setting;
$publicFileManager = new PublicFileManager();
$publicFileManager->removeSiteFile($fileName);
}
return null;
}
// Check if there is something to upload
if (empty($value['temporaryFileId'])) {
return $value;
}
$temporaryFile = $temporaryFileManager->getFile((int) $value['temporaryFileId'], $userId);
$fileName = $this->moveTemporaryFile($site, $temporaryFile, $settingName, $userId, $localeKey);
if ($fileName) {
// Get the details for image uploads
if ($isImage) {
$publicFileManager = new PublicFileManager();
[$width, $height] = getimagesize($publicFileManager->getSiteFilesPath() . '/' . $fileName);
$altText = !empty($value['altText']) ? $value['altText'] : '';
return [
'originalFilename' => $temporaryFile->getOriginalFileName(),
'uploadName' => $fileName,
'width' => $width,
'height' => $height,
'dateUploaded' => Core::getCurrentDate(),
'altText' => $altText,
];
} else {
return [
'originalFilename' => $temporaryFile->getOriginalFileName(),
'uploadName' => $fileName,
'dateUploaded' => Core::getCurrentDate(),
];
}
}
return false;
}
}
@@ -0,0 +1,107 @@
<?php
/**
* @file classes/services/PKPStatsContextService.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 PKPStatsContextService
*
* @ingroup services
*
* @brief Helper class that encapsulates context statistics business logic
*/
namespace PKP\services;
use APP\statistics\StatisticsHelper;
use PKP\services\queryBuilders\PKPStatsContextQueryBuilder;
class PKPStatsContextService
{
use PKPStatsServiceTrait;
/**
* Get a count of all contexts with stats that match the request arguments
*/
public function getCount(array $args): int
{
$defaultArgs = $this->getDefaultArgs();
$args = array_merge($defaultArgs, $args);
unset($args['count']);
unset($args['offset']);
$metricsQB = $this->getQueryBuilder($args);
return $metricsQB->getContextIds()->get()->count();
}
/**
* Get the contexts with total stats that match the request arguments
*/
public function getTotals(array $args): array
{
$defaultArgs = $this->getDefaultArgs();
$args = array_merge($defaultArgs, $args);
$metricsQB = $this->getQueryBuilder($args);
$groupBy = [StatisticsHelper::STATISTICS_DIMENSION_CONTEXT_ID];
$metricsQB = $metricsQB->getSum($groupBy);
$orderDirection = $args['orderDirection'] === StatisticsHelper::STATISTICS_ORDER_ASC ? 'asc' : 'desc';
$metricsQB->orderBy(StatisticsHelper::STATISTICS_METRIC, $orderDirection);
return $metricsQB->get()->toArray();
}
/**
* Get the total views for a context.
*/
public function getTotal(int $contextId, ?string $dateStart, ?string $dateEnd): int
{
$defaultArgs = $this->getDefaultArgs();
$args = [
'contextIds' => [$contextId],
'dateStart' => $dateStart ?? $defaultArgs['dateStart'],
'dateEnd' => $dateEnd ?? $defaultArgs['dateEnd'],
];
$metricsQB = $this->getQueryBuilder($args);
$metrics = $metricsQB->getSum([])->value('metric');
return $metrics ? $metrics : 0;
;
}
/**
* Get default parameters
*/
public function getDefaultArgs(): array
{
return [
'dateStart' => StatisticsHelper::STATISTICS_EARLIEST_DATE,
'dateEnd' => date('Y-m-d', strtotime('yesterday')),
];
}
/**
* Get a QueryBuilder object with the passed args
*/
public function getQueryBuilder(array $args = []): PKPStatsContextQueryBuilder
{
$statsQB = new PKPStatsContextQueryBuilder();
$statsQB
->before($args['dateEnd'])
->after($args['dateStart']);
if (!empty($args['contextIds'])) {
$statsQB->filterByContexts($args['contextIds']);
}
if (isset($args['count'])) {
$statsQB->limit($args['count']);
if (isset($args['offset'])) {
$statsQB->offset($args['offset']);
}
}
return $statsQB;
}
}
@@ -0,0 +1,557 @@
<?php
/**
* @file classes/services/PKPStatsEditorialService.php
*
* Copyright (c) 2014-2021 Simon Fraser University
* Copyright (c) 2000-2021 John Willinsky
* Distributed under the GNU GPL v3. For full terms see the file docs/COPYING.
*
* @class PKPStatsEditorialService
*
* @ingroup services
*
* @brief Helper class that encapsulates business logic for getting
* editorial stats
*/
namespace PKP\services;
use APP\decision\Decision;
use PKP\plugins\Hook;
class PKPStatsEditorialService
{
/**
* Get overview of key editorial stats
*
* @param array $args See self::getQueryBuilder()
*
* @return array
*/
public function getOverview($args = [])
{
$received = $this->countSubmissionsReceived($args);
$accepted = $this->countByDecisions($this->getAcceptedDecisions(), $args);
$submissionsPublished = $this->countSubmissionsPublished($args);
$submissionsInProgress = $this->countSubmissionsInProgress($args);
$submissionsImported = $this->countSubmissionsImported($args);
$submissionsSkipped = $submissionsInProgress + $submissionsImported;
$declinedDesk = $this->countByDecisions(Decision::INITIAL_DECLINE, $args);
$declinedReview = $this->countByDecisions(Decision::DECLINE, $args);
$declined = $declinedDesk + $declinedReview;
// Calculate the acceptance/decline rates
if (!$received) {
// Never divide by 0
$acceptanceRate = 0;
$declineRate = 0;
$declinedDeskRate = 0;
$declinedReviewRate = 0;
} elseif (empty($args['dateStart']) && empty($args['dateEnd'])) {
$acceptanceRate = $accepted / $received;
$declineRate = $declined / $received;
$declinedDeskRate = $declinedDesk / $received;
$declinedReviewRate = $declinedReview / $received;
} else {
// To calculate the acceptance/decline rates within a date range
// we must collect the total number of all submissions made within
// that date range which have received a decision. The acceptance
// rate is the number of submissions made within the date range
// that were accepted divided by the number of submissions made
// within the date range that were accepted or declined. This
// excludes submissions that were made within the date range but
// have not yet been accepted or declined.
$acceptedForSubmissionDate = $this->countByDecisionsForSubmittedDate(Decision::ACCEPT, $args);
$declinedDeskForSubmissionDate = $this->countByDecisionsForSubmittedDate(Decision::INITIAL_DECLINE, $args);
$declinedReviewForSubmissionDate = $this->countByDecisionsForSubmittedDate(Decision::DECLINE, $args);
$totalDecidedForSubmissionDate = $acceptedForSubmissionDate + $declinedDeskForSubmissionDate + $declinedReviewForSubmissionDate;
// Never divide by 0
if (!$totalDecidedForSubmissionDate) {
$acceptanceRate = 0;
$declineRate = 0;
$declinedDeskRate = 0;
$declinedReviewRate = 0;
} else {
$acceptanceRate = $acceptedForSubmissionDate / $totalDecidedForSubmissionDate;
$declineRate = ($declinedDeskForSubmissionDate + $declinedReviewForSubmissionDate) / $totalDecidedForSubmissionDate;
$declinedDeskRate = $declinedDeskForSubmissionDate / $totalDecidedForSubmissionDate;
$declinedReviewRate = $declinedReviewForSubmissionDate / $totalDecidedForSubmissionDate;
}
}
// Calculate the number of days it took for most submissions to
// receive decisions
$firstDecisionDays = $this->getDaysToDecisions([], $args);
$acceptDecisionDays = $this->getDaysToDecisions($this->getAcceptedDecisions(), $args);
$declineDecisionDays = $this->getDaysToDecisions($this->getDeclinedDecisions(), $args);
$firstDecisionDaysRate = empty($firstDecisionDays) ? 0 : $this->calculateDaysToDecisionRate($firstDecisionDays, 0.8);
$acceptDecisionDaysRate = empty($acceptDecisionDays) ? 0 : $this->calculateDaysToDecisionRate($acceptDecisionDays, 0.8);
$declineDecisionDaysRate = empty($declineDecisionDays) ? 0 : $this->calculateDaysToDecisionRate($declineDecisionDays, 0.8);
$overview = [
[
'key' => 'submissionsReceived',
'name' => 'stats.name.submissionsReceived',
'value' => $received,
],
[
'key' => 'submissionsAccepted',
'name' => 'stats.name.submissionsAccepted',
'value' => $accepted,
],
[
'key' => 'submissionsDeclined',
'name' => 'stats.name.submissionsDeclined',
'value' => $declined,
],
[
'key' => 'submissionsDeclinedDeskReject',
'name' => 'stats.name.submissionsDeclinedDeskReject',
'value' => $declinedDesk,
],
[
'key' => 'submissionsDeclinedPostReview',
'name' => 'stats.name.submissionsDeclinedPostReview',
'value' => $declinedReview,
],
[
'key' => 'submissionsPublished',
'name' => 'stats.name.submissionsPublished',
'value' => $submissionsPublished,
],
[
'key' => 'submissionsSkipped',
'name' => 'stats.name.submissionsSkipped',
'value' => $submissionsSkipped,
],
[
'key' => 'submissionsInProgress',
'name' => 'stats.name.submissionsInProgress',
'value' => $submissionsInProgress,
],
[
'key' => 'submissionsImported',
'name' => 'stats.name.submissionsImported',
'value' => $submissionsImported,
],
[
'key' => 'daysToDecision',
'name' => 'stats.name.daysToDecision',
'value' => $firstDecisionDaysRate,
],
[
'key' => 'daysToAccept',
'name' => 'stats.name.daysToAccept',
'value' => $acceptDecisionDaysRate,
],
[
'key' => 'daysToReject',
'name' => 'stats.name.daysToReject',
'value' => $declineDecisionDaysRate,
],
[
'key' => 'acceptanceRate',
'name' => 'stats.name.acceptanceRate',
'value' => round($acceptanceRate, 2),
],
[
'key' => 'declineRate',
'name' => 'stats.name.declineRate',
'value' => round($declineRate, 2),
],
[
'key' => 'declinedDeskRate',
'name' => 'stats.name.declinedDeskRate',
'value' => round($declinedDeskRate, 2),
],
[
'key' => 'declinedReviewRate',
'name' => 'stats.name.declinedReviewRate',
'value' => round($declinedReviewRate, 2),
],
];
Hook::call('EditorialStats::overview', [&$overview, $args]);
return $overview;
}
/**
* Get the yearly averages of key editorial stats
*
* Averages are calculated over full years. If no dateStart and
* dateEnd are passed, it will determine the first and last
* full years during which the activity occurred. This means that
* if the first submission was received in October 2017 and the
* last submission was received in the current calendar year, only
* submissions from 2018 up until the end of the previous calendar
* year will be used to calculate the average.
*
* This method does not yet support getting averages for date ranges.
*
* @see https://github.com/pkp/pkp-lib/issues/4844#issuecomment-554011922
*
* @param array $args See self::getQueryBuilder(). No date range supported
*
* @return array
*/
public function getAverages($args = [])
{
unset($args['dateStart']);
unset($args['dateEnd']);
// Submissions received
$received = -1;
$receivedDates = $this->getQueryBuilder($args)->getSubmissionsReceivedDates();
if (empty($receivedDates[0])) {
$received = 0;
} else {
$yearStart = ((int) substr($receivedDates[0], 0, 4)) + 1;
$yearEnd = (int) substr($receivedDates[1], 0, 4);
if ($yearEnd >= date('Y')) {
$yearEnd--;
}
$years = ($yearEnd - $yearStart) + 1;
if ($years) {
$argsReceived = array_merge(
$args,
[
'dateStart' => sprintf('%s-01-01', $yearStart),
'dateEnd' => sprintf('%s-12-31', $yearEnd),
]
);
$received = round($this->countSubmissionsReceived($argsReceived) / $years);
}
}
// Editorial decisions (accepted and declined)
$decisionsList = [
'submissionsAccepted' => [Decision::ACCEPT],
'submissionsDeclined' => [Decision::INITIAL_DECLINE, Decision::DECLINE],
'submissionsDeclinedDeskReject' => [Decision::INITIAL_DECLINE],
'submissionsDeclinedPostReview' => [Decision::DECLINE],
];
$yearlyDecisions = [];
foreach ($decisionsList as $key => $decisions) {
$yearly = -1;
$dates = $this->getQueryBuilder($args)->getDecisionsDates($decisions);
if (empty($dates[0])) {
$yearly = 0;
} else {
$yearStart = ((int) substr($dates[0], 0, 4)) + 1;
$yearEnd = (int) substr($dates[1], 0, 4);
if ($yearEnd >= date('Y')) {
$yearEnd--;
}
$years = ($yearEnd - $yearStart) + 1;
if ($years) {
$argsYearly = array_merge(
$args,
[
'dateStart' => sprintf('%s-01-01', $yearStart),
'dateEnd' => sprintf('%s-12-31', $yearEnd),
]
);
$yearly = round($this->countByDecisions($decisions, $argsYearly) / $years);
}
}
$yearlyDecisions[$key] = $yearly;
}
// Submissions published
$published = -1;
$publishedDates = $this->getQueryBuilder($args)->getPublishedDates();
if (empty($publishedDates[0])) {
$published = 0;
} else {
$yearStart = ((int) substr($publishedDates[0], 0, 4)) + 1;
$yearEnd = (int) substr($publishedDates[1], 0, 4);
if ($yearEnd >= date('Y')) {
$yearEnd--;
}
$years = ($yearEnd - $yearStart) + 1;
if ($years) {
$argsPublished = array_merge(
$args,
[
'dateStart' => sprintf('%s-01-01', $yearStart),
'dateEnd' => sprintf('%s-12-31', $yearEnd),
]
);
$published = round($this->countSubmissionsPublished($argsPublished) / $years);
}
}
$averages = array_merge(
['submissionsReceived' => $received],
$yearlyDecisions,
['submissionsPublished' => $published]
);
Hook::call('EditorialStats::averages', [&$averages, $args]);
return $averages;
}
/**
* Get a count of the number of submissions that have been received
*
* Any date restrictions will be applied to the submission date, so it
* will only count submissions completed within the date range.
*
* @param array $args See self::getQueryBuilder()
*
* @return int
*/
public function countSubmissionsReceived($args = [])
{
return $this->getQueryBuilder($args)->countSubmissionsReceived();
}
/**
* Get a count of the number of submissions that have been published
*
* Any date restrictions will be applied to the initial publication date,
* so it will only count submissions published within the date range.
*
* @param array $args See self::getQueryBuilder()
*
* @return int
*/
public function countSubmissionsPublished($args = [])
{
return $this->getQueryBuilder($args)->countPublished();
}
/**
* Get a count of the submissions receiving one or more editorial decisions
*
* Any date restrictions will be applied to the decision, so it will only
* count decisions that occurred within the date range.
*
* @param int|array $decisions One or more Decision::*
* @param array $args See self::getQueryBuilder()
*
* @return int
*/
public function countByDecisions($decisions, $args = [])
{
return $this->getQueryBuilder($args)->countByDecisions((array) $decisions);
}
/**
* Get a count of the submissions receiving one or more editorial decisions
*
* Any date restrictions will be applied to the submission date, so it will
* only count submissions made within the date range which eventually received
* one of the decisions.
*
* @param int|array $decisions One or more Decision::*
* @param array $args See self::getQueryBuilder()
*
* @return int
*/
public function countByDecisionsForSubmittedDate($decisions, $args = [])
{
return $this->getQueryBuilder($args)->countByDecisions((array) $decisions, true);
}
/**
* Get a count of the submissions with one or more statuses
*
* Date restrictions will not be applied. It will return the count of
* all submissions with the passed statuses.
*
* @param int|array $statuses One or more PKPSubmission::STATUS_*
* @param array $args See self::getQueryBuilder()
*
* @return int
*/
public function countByStatus($statuses, $args = [])
{
return $this->getQueryBuilder($args)->countByStatus((array) $statuses);
}
/**
* Get a count of the submissions which are skipped by the other statistics
*
* Date restrictions will not be applied. It will return the count of
* all skipped submissions.
*
* @param array $args See self::getQueryBuilder()
*/
public function countSubmissionsSkipped(array $args = []): int
{
return $this->getQueryBuilder($args)->countSkipped();
}
/**
* Get a count of the submissions which are incomplete
*
* Date restrictions will not be applied. It will return the count of
* all incomplete submissions.
*
* @param array $args See self::getQueryBuilder()
*
* @return int
*/
public function countSubmissionsInProgress($args = [])
{
return $this->getQueryBuilder($args)->countInProgress();
}
/**
* Get a count of the submissions which are imported
*
* Date restrictions will not be applied. It will return the count of
* all imported submissions.
*
* @param array $args See self::getQueryBuilder()
*
* @return int
*/
public function countSubmissionsImported($args = [])
{
return $this->getQueryBuilder($args)->countImported();
}
/**
* Get a count of the active submissions in one or more stages
*
* Date restrictions will not be applied. It will return the count of
* all submissions with the passed statuses.
*
* @param int|array $stages One or more WORKFLOW_STAGE_ID_*
* @param array $args See self::getQueryBuilder()
*
* @return int
*/
public function countActiveByStages($stages, $args = [])
{
return $this->getQueryBuilder($args)->countActiveByStages((array) $stages);
}
/**
* Get the number of days it took for each submission to reach
* one or more editorial decisions
*
* Any date restrictions will be applied to the submission date, so it will
* only return the days to a decision for submissions that were made within
* the selected date range.
*
* @param int|array $decisions One or more Decision::*
* @param array $args See self::getQueryBuilder()
*
* @return array
*/
public function getDaysToDecisions($decisions, $args = [])
{
return $this->getQueryBuilder($args)->getDaysToDecisions((array) $decisions);
}
/**
* Get the average number of days to reach one or more editorial decisions
*
* Any date restrictions will be applied to the submission date, so it will
* only average the days to a decision for submissions that were made within
* the selected date range.
*
* @param int|array $decisions One or more Decision::*
* @param array $args See self::getQueryBuilder()
*
* @return int
*/
public function getAverageDaysToDecisions($decisions, $args = [])
{
return ceil($this->getQueryBuilder($args)->getAverageDaysToDecisions((array) $decisions));
}
/**
* A helper function to calculate the number of days it took reach an
* editorial decision on a given portion of submission decisions
*
* This can be used to answer questions like how many days it took for
* a decision to be reached in 80% of submissions.
*
* For example, if passed an array of [5, 8, 10, 20] and a percentage of
* .75, it would return 10 since 75% of the array values are 10 or less.
*
* @param array $days An array of integers representing the dataset of
* days to reach a decision.
* @param float $percentage The percentage of the dataset that must be
* included in the rate. 75% = 0.75
*
* @return int The number of days X% of submissions received the decision
*/
public function calculateDaysToDecisionRate($days, $percentage)
{
sort($days);
$arrayPart = array_slice($days, 0, ceil(count($days) * $percentage));
return end($arrayPart) ?? 0;
}
/**
* Get a QueryBuilder object with the passed args
*
* @param array{dateStart:string,dateEnd:string,contextIds:array|int,sectionIds:array|int $args
*/
protected function getQueryBuilder($args = [])
{
$qb = new \APP\services\queryBuilders\StatsEditorialQueryBuilder();
if (!empty($args['dateStart'])) {
$qb->after($args['dateStart']);
}
if (!empty($args['dateEnd'])) {
$qb->before($args['dateEnd']);
}
if (!empty($args['contextIds'])) {
$qb->filterByContexts($args['contextIds']);
}
Hook::call('Stats::editorial::queryBuilder', [&$qb, $args]);
return $qb;
}
/**
* Get the decisions that indicate a submission has been accepted
*
* Decision::SEND_TO_PRODUCTION is included
* in order to catch submissions that do not have an accept decision recorded, but have
* still made it to the production stage. Once a SEND_TO_PRODUCTION decision has been
* recorded, we assume the submission has been accepted for the purposes of statistics.
*
* This list only applies to editorial statistics. This method should not be used to
* identify acceptance decisions for any other purpose.
*
* @return int[] Decision::* constants
*/
protected function getAcceptedDecisions(): array
{
return [
Decision::ACCEPT,
Decision::SKIP_EXTERNAL_REVIEW,
Decision::SEND_TO_PRODUCTION,
];
}
/**
* Get the decisions that indicate a submission has been declined
*
* This distinction only applies to editorial statistics. This method should not be used to
* identify declined decisions for any other purpose.
*
* @return int[] Decision::* constants
*/
protected function getDeclinedDecisions(): array
{
return [
Decision::DECLINE,
Decision::INITIAL_DECLINE,
];
}
}
@@ -0,0 +1,176 @@
<?php
/**
* @file classes/services/PKPStatsGeoService.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 PKPStatsGeoService
*
* @ingroup services
*
* @brief Helper class that encapsulates geographic statistics business logic
*/
namespace PKP\services;
use APP\services\queryBuilders\StatsGeoQueryBuilder;
use APP\statistics\StatisticsHelper;
class PKPStatsGeoService
{
use PKPStatsServiceTrait;
/**
* Get a count of all countries, regions or cities with stats that match the request arguments
*
* @param string $scale Possible values are:
* StatisticsHelper::STATISTICS_DIMENSION_CITY
* StatisticsHelper::STATISTICS_DIMENSION_REGION
* StatisticsHelper::STATISTICS_DIMENSION_COUNTRY
*/
public function getCount(array $args, string $scale): int
{
$defaultArgs = $this->getDefaultArgs();
$args = array_merge($defaultArgs, $args);
unset($args['count']);
unset($args['offset']);
$metricsQB = $this->getQueryBuilder($args);
$groupBy = [];
if ($scale == StatisticsHelper::STATISTICS_DIMENSION_CITY) {
$groupBy = [StatisticsHelper::STATISTICS_DIMENSION_COUNTRY, StatisticsHelper::STATISTICS_DIMENSION_REGION, StatisticsHelper::STATISTICS_DIMENSION_CITY];
} elseif ($scale == StatisticsHelper::STATISTICS_DIMENSION_REGION) {
$groupBy = [StatisticsHelper::STATISTICS_DIMENSION_COUNTRY, StatisticsHelper::STATISTICS_DIMENSION_REGION];
} elseif ($scale == StatisticsHelper::STATISTICS_DIMENSION_COUNTRY) {
$groupBy = [StatisticsHelper::STATISTICS_DIMENSION_COUNTRY];
}
return $metricsQB->getGeoData($groupBy)->get()->count();
}
/**
* Get the countries, regions or cities with total stats that match the request arguments
*
* @param string $scale Possible values are:
* StatisticsHelper::STATISTICS_DIMENSION_CITY
* StatisticsHelper::STATISTICS_DIMENSION_REGION
* StatisticsHelper::STATISTICS_DIMENSION_COUNTRY
*/
public function getTotals(array $args, string $scale): array
{
$defaultArgs = $this->getDefaultArgs();
$args = array_merge($defaultArgs, $args);
$metricsQB = $this->getQueryBuilder($args);
$groupBy = [];
if ($scale == StatisticsHelper::STATISTICS_DIMENSION_CITY) {
$groupBy = [StatisticsHelper::STATISTICS_DIMENSION_COUNTRY, StatisticsHelper::STATISTICS_DIMENSION_REGION, StatisticsHelper::STATISTICS_DIMENSION_CITY];
} elseif ($scale == StatisticsHelper::STATISTICS_DIMENSION_REGION) {
$groupBy = [StatisticsHelper::STATISTICS_DIMENSION_COUNTRY, StatisticsHelper::STATISTICS_DIMENSION_REGION];
} elseif ($scale == StatisticsHelper::STATISTICS_DIMENSION_COUNTRY) {
$groupBy = [StatisticsHelper::STATISTICS_DIMENSION_COUNTRY];
}
$metricsQB = $metricsQB->getSum($groupBy);
$orderDirection = $args['orderDirection'] === StatisticsHelper::STATISTICS_ORDER_ASC ? 'asc' : 'desc';
$metricsQB->orderBy(StatisticsHelper::STATISTICS_METRIC, $orderDirection);
return $metricsQB->get()->toArray();
}
/**
* Get default parameters
*/
public function getDefaultArgs(): array
{
return [
'dateStart' => StatisticsHelper::STATISTICS_EARLIEST_DATE,
'dateEnd' => date('Y-m-d', strtotime('yesterday')),
// Require a context to be specified to prevent unwanted data leakage
// if someone forgets to specify the context.
'contextIds' => [\PKP\core\PKPApplication::CONTEXT_ID_NONE],
];
}
/**
* Get a QueryBuilder object with the passed args
*/
public function getQueryBuilder($args = []): StatsGeoQueryBuilder
{
$statsQB = new StatsGeoQueryBuilder();
$statsQB
->filterByContexts($args['contextIds'])
->before($args['dateEnd'])
->after($args['dateStart']);
if (!empty(($args['pkpSectionIds']))) {
$statsQB->filterByPKPSections($args['pkpSectionIds']);
}
if (!empty($args['submissionIds'])) {
$statsQB->filterBySubmissions($args['submissionIds']);
}
if (!empty($args['countries'])) {
$statsQB->filterByCountries($args['countries']);
}
if (!empty($args['regions'])) {
$statsQB->filterByRegions($args['regions']);
}
if (!empty($args['cities'])) {
$statsQB->filterByCities($args['cities']);
}
if (isset($args['count'])) {
$statsQB->limit($args['count']);
if (isset($args['offset'])) {
$statsQB->offset($args['offset']);
}
}
return $statsQB;
}
/**
* Do usage stats data already exist for the given month
*
* @param string $month Month in the form YYYYMM
*/
public function monthExists(string $month): bool
{
$statsQB = new StatsGeoQueryBuilder();
return $statsQB->monthExists($month);
}
/**
* Delete daily usage metrics for a month
*
* @param string $month Month in the form YYYYMM
*/
public function deleteDailyMetrics(string $month): void
{
$statsQB = new StatsGeoQueryBuilder();
$statsQB->deleteDailyMetrics($month);
}
/**
* Delete monthly usage metrics for a month
*
* @param string $month Month in the form YYYYMM
*/
public function deleteMonthlyMetrics(string $month): void
{
$statsQB = new StatsGeoQueryBuilder();
$statsQB->deleteMonthlyMetrics($month);
}
/**
* Aggregate daily usage metrics by a month
*
* @param string $month Month in the form YYYYMM
*/
public function addMonthlyMetrics(string $month): void
{
$statsQB = new StatsGeoQueryBuilder();
$statsQB->addMonthlyMetrics($month);
}
}
@@ -0,0 +1,289 @@
<?php
/**
* @file classes/services/PKPStatsPublicationService.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 PKPStatsPublicationService
*
* @ingroup services
*
* @brief Helper class that encapsulates publication statistics business logic
*/
namespace PKP\services;
use APP\core\Application;
use APP\services\queryBuilders\StatsPublicationQueryBuilder;
use PKP\plugins\Hook;
use PKP\statistics\PKPStatisticsHelper;
abstract class PKPStatsPublicationService
{
use PKPStatsServiceTrait;
/**
* A callback to be used with array_filter() to return
* records for a PDF file.
*/
public function filterRecordPdf(object $record): bool
{
return $record->assoc_type == Application::ASSOC_TYPE_SUBMISSION_FILE && $record->file_type == PKPStatisticsHelper::STATISTICS_FILE_TYPE_PDF;
}
/**
* A callback to be used with array_filter() to return
* records for a HTML file.
*/
public function filterRecordHtml(object $record): bool
{
return $record->assoc_type == Application::ASSOC_TYPE_SUBMISSION_FILE && $record->file_type == PKPStatisticsHelper::STATISTICS_FILE_TYPE_HTML;
}
/**
* A callback to be used with array_filter() to return
* records for Other (than PDF and HTML) file.
*/
public function filterRecordOther(object $record): bool
{
return $record->assoc_type == Application::ASSOC_TYPE_SUBMISSION_FILE && $record->file_type == PKPStatisticsHelper::STATISTICS_FILE_TYPE_OTHER;
}
/**
* A callback to be used with array_filter() to return
* records for abstract.
*/
public function filterRecordAbstract(object $record): bool
{
return $record->assoc_type == Application::ASSOC_TYPE_SUBMISSION;
}
/**
* A callback to be used with array_filter() to return
* records for supplementary file.
*/
public function filterRecordSuppFile(object $record): bool
{
return $record->assoc_type == Application::ASSOC_TYPE_SUBMISSION_FILE_COUNTER_OTHER;
}
/**
* Get a count of all submissions with stats that match the request arguments
*/
public function getCount(array $args): int
{
$defaultArgs = $this->getDefaultArgs();
$args = array_merge(
$defaultArgs,
['assocTypes' => [Application::ASSOC_TYPE_SUBMISSION, Application::ASSOC_TYPE_SUBMISSION_FILE]],
$args
);
unset($args['count']);
unset($args['offset']);
$metricsQB = $this->getQueryBuilder($args);
Hook::call('StatsPublication::getCount::queryBuilder', [&$metricsQB, $args]);
return $metricsQB->getSubmissionIds()->get()->count();
}
/**
* Get the submissions with total stats that match the request arguments
*/
public function getTotals(array $args): array
{
$defaultArgs = $this->getDefaultArgs();
$args = array_merge(
$defaultArgs,
['assocTypes' => [Application::ASSOC_TYPE_SUBMISSION, Application::ASSOC_TYPE_SUBMISSION_FILE],
'orderDirection' => PKPStatisticsHelper::STATISTICS_ORDER_DESC],
$args
);
$metricsQB = $this->getQueryBuilder($args);
Hook::call('StatsPublication::getTotals::queryBuilder', [&$metricsQB, $args]);
$groupBy = [PKPStatisticsHelper::STATISTICS_DIMENSION_SUBMISSION_ID];
$metricsQB = $metricsQB->getSum($groupBy);
$orderDirection = $args['orderDirection'] === PKPStatisticsHelper::STATISTICS_ORDER_ASC ? 'asc' : 'desc';
$metricsQB->orderBy(PKPStatisticsHelper::STATISTICS_METRIC, $orderDirection);
return $metricsQB->get()->toArray();
}
/**
* Get metrics by type (abstract, pdf, html, other) for a submission
* Assumes that the submission ID is provided in parameters
*/
public function getTotalsByType(int $submissionId, int $contextId, ?string $dateStart, ?string $dateEnd): array
{
$defaultArgs = $this->getDefaultArgs();
$args = [
'submissionIds' => [$submissionId],
'contextIds' => [$contextId],
'dateStart' => $dateStart ?? $defaultArgs['dateStart'],
'dateEnd' => $dateEnd ?? $defaultArgs['dateEnd'],
'assocTypes' => [Application::ASSOC_TYPE_SUBMISSION, Application::ASSOC_TYPE_SUBMISSION_FILE, Application::ASSOC_TYPE_SUBMISSION_FILE_COUNTER_OTHER]
];
$metricsQB = $this->getQueryBuilder($args);
Hook::call('StatsPublication::getTotalsByType::queryBuilder', [&$metricsQB, $args]);
// get abstract, pdf, html and other views for the submission
$groupBy = [PKPStatisticsHelper::STATISTICS_DIMENSION_ASSOC_TYPE, PKPStatisticsHelper::STATISTICS_DIMENSION_FILE_TYPE];
$metricsQB = $metricsQB->getSum($groupBy);
$metricsByType = $metricsQB->get()->toArray();
$abstractViews = $pdfViews = $htmlViews = $otherViews = $suppFileViews = 0;
$abstractRecord = array_filter($metricsByType, [$this, 'filterRecordAbstract']);
if (!empty($abstractRecord)) {
$abstractViews = (int) current($abstractRecord)->metric;
}
$pdfRecord = array_filter($metricsByType, [$this, 'filterRecordPdf']);
if (!empty($pdfRecord)) {
$pdfViews = (int) current($pdfRecord)->metric;
}
$htmlRecord = array_filter($metricsByType, [$this, 'filterRecordHtml']);
if (!empty($htmlRecord)) {
$htmlViews = (int) current($htmlRecord)->metric;
}
$otherRecord = array_filter($metricsByType, [$this, 'filterRecordOther']);
if (!empty($otherRecord)) {
$otherViews = (int) current($otherRecord)->metric;
}
$suppFileRecord = array_filter($metricsByType, [$this, 'filterRecordSuppFile']);
if (!empty($suppFileRecord)) {
$suppFileViews = (int) current($suppFileRecord)->metric;
}
return [
'abstract' => $abstractViews,
'pdf' => $pdfViews,
'html' => $htmlViews,
'other' => $otherViews,
'suppFileViews' => $suppFileViews
];
}
/**
* Get a count of all submission files with stats that match the request arguments
*/
public function getFilesCount(array $args): int
{
$defaultArgs = $this->getDefaultArgs();
$args = array_merge(
$defaultArgs,
['assocTypes' => [Application::ASSOC_TYPE_SUBMISSION_FILE, Application::ASSOC_TYPE_SUBMISSION_FILE_COUNTER_OTHER]],
$args
);
unset($args['count']);
unset($args['offset']);
$metricsQB = $this->getQueryBuilder($args);
Hook::call('StatsPublication::getFilesCount::queryBuilder', [&$metricsQB, $args]);
$groupBy = [PKPStatisticsHelper::STATISTICS_DIMENSION_SUBMISSION_ID, PKPStatisticsHelper::STATISTICS_DIMENSION_SUBMISSION_FILE_ID];
$metricsQB = $metricsQB->getSum($groupBy);
return $metricsQB->get()->count();
}
/**
* Get the submission files with total stats that match the request arguments
*/
public function getFilesTotals(array $args): array
{
$defaultArgs = $this->getDefaultArgs();
$args = array_merge(
$defaultArgs,
['assocTypes' => [Application::ASSOC_TYPE_SUBMISSION_FILE, Application::ASSOC_TYPE_SUBMISSION_FILE_COUNTER_OTHER]],
$args
);
$metricsQB = $this->getQueryBuilder($args);
Hook::call('StatsPublication::getFilesTotals::queryBuilder', [&$metricsQB, $args]);
$groupBy = [PKPStatisticsHelper::STATISTICS_DIMENSION_SUBMISSION_ID, PKPStatisticsHelper::STATISTICS_DIMENSION_SUBMISSION_FILE_ID, PKPStatisticsHelper::STATISTICS_DIMENSION_ASSOC_TYPE];
$metricsQB = $metricsQB->getSum($groupBy);
$orderDirection = $args['orderDirection'] === PKPStatisticsHelper::STATISTICS_ORDER_ASC ? 'asc' : 'desc';
$metricsQB->orderBy(PKPStatisticsHelper::STATISTICS_METRIC, $orderDirection);
return $metricsQB->get()->toArray();
}
/**
* Get default parameters
*/
public function getDefaultArgs(): array
{
return [
'dateStart' => PKPStatisticsHelper::STATISTICS_EARLIEST_DATE,
'dateEnd' => date('Y-m-d', strtotime('yesterday')),
// Require a context to be specified to prevent unwanted data leakage
// if someone forgets to specify the context.
'contextIds' => [\PKP\core\PKPApplication::CONTEXT_ID_NONE],
];
}
/**
* Consider/add application specific QB filters
*/
protected function getAppSpecificFilters(StatsPublicationQueryBuilder &$statsQB, array $args = []): void
{
}
/**
* Get a QueryBuilder object with the passed args
*/
public function getQueryBuilder(array $args = []): StatsPublicationQueryBuilder
{
$statsQB = new StatsPublicationQueryBuilder();
$statsQB
->filterByContexts($args['contextIds'])
->before($args['dateEnd'])
->after($args['dateStart']);
if (!empty(($args['pkpSectionIds']))) {
$statsQB->filterByPKPSections($args['pkpSectionIds']);
}
if (!empty(($args['submissionIds']))) {
$statsQB->filterBySubmissions($args['submissionIds']);
}
if (!empty($args['assocTypes'])) {
$statsQB->filterByAssocTypes($args['assocTypes']);
}
if (!empty($args['fileTypes'])) {
$statsQB->filterByFileTypes(($args['fileTypes']));
}
if (!empty(($args['representationIds']))) {
$statsQB->filterByRepresentations($args['representationIds']);
}
if (!empty(($args['submissionFileIds']))) {
$statsQB->filterBySubmissionFiles($args['submissionFileIds']);
}
$this->getAppSpecificFilters($statsQB, $args);
if (isset($args['count'])) {
$statsQB->limit($args['count']);
if (isset($args['offset'])) {
$statsQB->offset($args['offset']);
}
}
Hook::call('StatsPublication::queryBuilder', [&$statsQB, $args]);
return $statsQB;
}
}
@@ -0,0 +1,122 @@
<?php
/**
* @file classes/services/PKPStatsServiceTrait.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 PKPStatsServiceTrait
*
* @ingroup services
*
* @brief Helper class that encapsulates publication statistics business logic
*/
namespace PKP\services;
use APP\core\Application;
use PKP\core\PKPString;
use PKP\plugins\Hook;
use PKP\statistics\PKPStatisticsHelper;
trait PKPStatsServiceTrait
{
/**
* Get the sum of a set of metrics broken down by day or month
*
* @param string $timelineInterval PKPStatisticsHelper::STATISTICS_DIMENSION_MONTH or PKPStatisticsHelper::STATISTICS_DIMENSION_DAY
* @param array $args Filter the records to include. See self::getQueryBuilder()
*
*/
public function getTimeline(string $timelineInterval, array $args = []): array
{
$defaultArgs = $this->getDefaultArgs();
$args = array_merge($defaultArgs, $args);
$timelineQB = $this->getQueryBuilder($args);
Hook::call('Stats::getTimeline::queryBuilder', [&$timelineQB, $args]);
$orderDirection = 'asc';
if (array_key_exists('orderDirection', $args)) {
$orderDirection = strtolower($args['orderDirection']);
}
$timelineQO = $timelineQB
->getSum([$timelineInterval])
->orderBy($timelineInterval, $orderDirection);
$result = $timelineQO->get();
$dateValues = [];
foreach ($result as $row) {
$date = $row->$timelineInterval;
if ($timelineInterval === PKPStatisticsHelper::STATISTICS_DIMENSION_MONTH) {
$date = substr($date, 0, 7);
}
$dateValues[$date] = $row->metric;
}
$timeline = $this->getEmptyTimelineIntervals($args['dateStart'], $args['dateEnd'], $timelineInterval);
$timeline = array_map(function ($entry) use ($dateValues) {
foreach ($dateValues as $date => $value) {
if ($entry['date'] === $date) {
$entry['value'] = $value;
break;
}
}
return $entry;
}, $timeline);
return $timeline;
}
/**
* Get all time segments (months or days) between the start and end date
* with empty values.
*
* @param string $timelineInterval PKPStatisticsHelper::STATISTICS_DIMENSION_MONTH or PKPStatisticsHelper::STATISTICS_DIMENSION_DAY
*
* @return array of time segments in ASC order
*/
public function getEmptyTimelineIntervals(string $startDate, string $endDate, string $timelineInterval): array
{
if ($timelineInterval === PKPStatisticsHelper::STATISTICS_DIMENSION_MONTH) {
$dateFormat = 'Y-m';
$labelFormat = 'F Y';
$interval = 'P1M';
} elseif ($timelineInterval === PKPStatisticsHelper::STATISTICS_DIMENSION_DAY) {
$dateFormat = 'Y-m-d';
$labelFormat = PKPString::convertStrftimeFormat(Application::get()->getRequest()->getContext()->getLocalizedDateFormatLong());
$interval = 'P1D';
}
$startDate = new \DateTime($startDate);
$endDate = new \DateTime($endDate);
$timelineIntervals = [];
while ($startDate->format($dateFormat) <= $endDate->format($dateFormat)) {
$timelineIntervals[] = [
'date' => $startDate->format($dateFormat),
'label' => date($labelFormat, $startDate->getTimestamp()),
'value' => 0,
];
$startDate->add(new \DateInterval($interval));
}
return $timelineIntervals;
}
/**
* Get column names for the timeline CSV report
*/
public function getTimelineReportColumnNames(): array
{
return [
__('common.date'),
__('common.label'),
__('stats.total')
];
}
}
@@ -0,0 +1,108 @@
<?php
/**
* @file classes/services/PKPStatsSushiService.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 PKPStatsSushiService
*
* @ingroup services
*
* @brief Helper class that encapsulates COUNTER R5 SUSHI statistics business logic
*/
namespace PKP\services;
use APP\core\Application;
use PKP\db\DAORegistry;
use PKP\services\queryBuilders\PKPStatsSushiQueryBuilder;
use PKP\site\VersionDAO;
class PKPStatsSushiService
{
/**
* Get a QueryBuilder object with the passed args
*/
public function getQueryBuilder(array $args = []): PKPStatsSushiQueryBuilder
{
$statsQB = new PKPStatsSushiQueryBuilder();
$statsQB
->filterByContexts($args['contextIds'])
->filterByInstitution((int) $args['institutionId'])
->before($args['dateEnd'])
->after($args['dateStart']);
if (!empty($args['yearsOfPublication'])) {
$statsQB->filterByYOP($args['yearsOfPublication']);
}
if (!empty($args['submissionIds'])) {
$statsQB->filterBySubmissions($args['submissionIds']);
}
return $statsQB;
}
/**
* Do usage stats data already exist for the given month
*
* @param string $month Month in the form YYYYMM
*/
public function monthExists(string $month): bool
{
$statsQB = new PKPStatsSushiQueryBuilder();
return $statsQB->monthExists($month);
}
/**
* Get earliest date, the COUNTER R5 (introduced in the release 3.4.0.) counting started at
* The start date is determined in classes/migration/upgrade/v3_4_0/I8508_ConvertCurrentLogFile.php
*
* @return string Date formatted as Y-m-d
*/
public function getEarliestDate(): string
{
$siteSettingsDate = Application::get()->getRequest()->getSite()->getData('counterR5StartDate');
if (isset($siteSettingsDate)) {
return $siteSettingsDate;
}
/** @var VersionDAO */
$versionDao = DAORegistry::getDAO('VersionDAO');
return $versionDao->getInstallationDate(3400);
}
/**
* Delete daily usage metrics for a month
*
* @param string $month Month in the form YYYYMM
*/
public function deleteDailyMetrics(string $month): void
{
$statsQB = new PKPStatsSushiQueryBuilder();
$statsQB->deleteDailyMetrics($month);
}
/**
* Delete monthly usage metrics for a month
*
* @param string $month Month in the form YYYYMM
*/
public function deleteMonthlyMetrics(string $month): void
{
$statsQB = new PKPStatsSushiQueryBuilder();
$statsQB->deleteMonthlyMetrics($month);
}
/**
* Aggregate daily usage metrics by a month
*
* @param string $month Month in the form YYYYMM
*/
public function addMonthlyMetrics(string $month): void
{
$statsQB = new PKPStatsSushiQueryBuilder();
$statsQB->addMonthlyMetrics($month);
}
}
@@ -0,0 +1,58 @@
<?php
/**
* @file classes/services/interfaces/EntityPropertyInterface.php
*
* Copyright (c) 2014-2021 Simon Fraser University
* Copyright (c) 2000-2021 John Willinsky
* Distributed under the GNU GPL v3. For full terms see the file docs/COPYING.
*
* @class EntityPropertyInterface
*
* @ingroup services_interfaces
*
* @brief An interface describing the methods a service class will implement to
* convert an entity into an assoc array of properties. These methods are
* typically evoked when producing a response to an API request.
*/
namespace PKP\services\interfaces;
interface EntityPropertyInterface
{
/**
* Returns the values for the requested list of properties
*
* @param object $entity The object to convert
* @param array $props The properties to include in the result
* @param array $args Additional variable which may be required
* $args['request'] PKPRequest Required
* $args['slimRequest'] SlimRequest
*
* @return array
*/
public function getProperties($entity, $props, $args = null);
/**
* Returns summary properties for an entity
*
* @param object $entity The object to convert
* @param array $args Additional variables which may be required
* $args['request'] PKPRequest Required
* $args['slimRequest'] SlimRequest
*
* @return array
*/
public function getSummaryProperties($entity, $args = null);
/**
* Returns full properties for an entity
*
* @param object $entity The object to convert
* @param array $args Additional variable which may be required
* $args['request'] PKPRequest Required
* $args['slimRequest'] SlimRequest
*
* @return array
*/
public function getFullProperties($entity, $args = null);
}
@@ -0,0 +1,83 @@
<?php
/**
* @file classes/services/interfaces/EntityReadInterface.php
*
* Copyright (c) 2014-2021 Simon Fraser University
* Copyright (c) 2000-2021 John Willinsky
* Distributed under the GNU GPL v3. For full terms see the file docs/COPYING.
*
* @class EntityReadInterface
*
* @ingroup services_interfaces
*
* @brief An interface describing the methods a service class will implement to
* get one object or a collection of objects.
*/
namespace PKP\services\interfaces;
use Illuminate\Database\Query\Builder;
interface EntityReadInterface
{
/**
* Get one object of the entity type by its ID
*
* @param int $id
*
* @return object
*/
public function get($id);
/**
* Get a count of the number of objects matching $args
*
* @param array $args Assoc array describing which rows should be counted
*
* @return int
*/
public function getCount($args = []);
/**
* Get a list of ids matching $args
*
* @param array $args Assoc array describing which ids should be retrieved
*
* @return array
*/
public function getIds($args = []);
/**
* Get a collection of objects limited, filtered and sorted by $args
*
* @param array $args Assoc array describing which objects should be retrieved
*
* @return \Iterator
*/
public function getMany($args = []);
/**
* Get the max count of objects matching $args
*
* This method is identical to `self::getCount()` except that any pagination
* arguments such as `count` or `offset` will be ignored.
*
* Usually, this is used with `self::getMany()` to return the total number of
* items available according to the selection criteria.
*
* @param array $args Assoc array describing which objects should be counted
*
* @return int
*/
public function getMax($args = []);
/**
* Get a QueryBuilder for this entity configured
* according to the $args passed
*
* @param array $args Assoc array describing how the querybuilder should be
* configured.
*
* @return Builder
*/
public function getQueryBuilder($args = []);
}
@@ -0,0 +1,87 @@
<?php
/**
* @file classes/services/interfaces/EntityWriteInterface.php
*
* Copyright (c) 2014-2021 Simon Fraser University
* Copyright (c) 2000-2021 John Willinsky
* Distributed under the GNU GPL v3. For full terms see the file docs/COPYING.
*
* @class EntityWriteInterface
*
* @ingroup services_interfaces
*
* @brief An interface describing the methods a service class will implement to
* validate, add, edit and delete an object.
*/
namespace PKP\services\interfaces;
interface EntityWriteInterface
{
// The type of action against which data should be validated. When adding an
// entity, required properties must be present and not empty.
public const VALIDATE_ACTION_ADD = 'add';
public const VALIDATE_ACTION_EDIT = 'edit';
/**
* Validate the properties of an object
*
* Passes the properties through the SchemaService to validate them, and
* performs any additional checks needed to validate the entity.
*
* This does NOT authenticate the current user to perform the action.
*
* @param string $action The type of action required (add/edit). One of the
* VALIDATE_ACTION_... constants.
* @param array $props The data to validate
* @param array $allowedLocales Which locales are allowed for this entity
* @param string $primaryLocale
*
* @return array List of error messages. The array keys are property names
*/
public function validate($action, $props, $allowedLocales, $primaryLocale);
/**
* Add a new object
*
* This does not check if the user is authorized to add the object, or
* validate or sanitize this object.
*
* @param object $object
* @param \APP\core\Request $request
*
* @return object
*/
public function add($object, $request);
/**
* Edit an object
*
* This does not check if the user is authorized to edit the object, or
* validate or sanitize the new object values.
*
* @param object $object
* @param array $params Key/value array of new data
* @param \APP\core\Request $request
*
* @return object
*/
public function edit($object, $params, $request);
/**
* Delete an object
*
* This does not check if the user is authorized to delete the object or if
* the object exists.
*
* @param object $object
*
* @return bool
*/
public function delete($object);
}
if (!PKP_STRICT_MODE) {
define('VALIDATE_ACTION_ADD', EntityWriteInterface::VALIDATE_ACTION_ADD);
define('VALIDATE_ACTION_EDIT', EntityWriteInterface::VALIDATE_ACTION_EDIT);
}
@@ -0,0 +1,192 @@
<?php
/**
* @file classes/services/QueryBuilders/PKPContextQueryBuilder.php
*
* Copyright (c) 2014-2021 Simon Fraser University
* Copyright (c) 2000-2021 John Willinsky
* Distributed under the GNU GPL v3. For full terms see the file docs/COPYING.
*
* @class PKPContextQueryBuilder
*
* @ingroup query_builders
*
* @brief Base class for context (journals/presses) list query builder
*/
namespace PKP\services\queryBuilders;
use Illuminate\Support\Facades\DB;
use PKP\plugins\Hook;
use PKP\security\Role;
use PKP\services\queryBuilders\interfaces\EntityQueryBuilderInterface;
abstract class PKPContextQueryBuilder implements EntityQueryBuilderInterface
{
/** @var string The database name for this context: `journals` or `presses` */
protected $db;
/** @var string The database name for this context's settings: `journal_settings` or `press_settings` */
protected $dbSettings;
/** @var string The column name for a context ID: `journal_id` or `press_id` */
protected $dbIdColumn;
/** @var ?bool enabled or disabled contexts */
protected $isEnabled = null;
/** @var ?int Filter contexts by whether or not this user can access it when logged in */
protected $userId;
/** @var ?string search phrase */
protected $searchPhrase = null;
/** @var string[] Selected columns */
protected $columns = [];
/**
* Set isEnabled filter
*
* @param ?bool $isEnabled
*
* @return \PKP\services\queryBuilders\PKPContextQueryBuilder
*/
public function filterByIsEnabled($isEnabled)
{
$this->isEnabled = $isEnabled;
return $this;
}
/**
* Set userId filter
*
* The user id can access contexts where they are assigned to
* a user group. If the context is disabled, they must be
* assigned to ROLE_ID_MANAGER user group.
*
* @param ?int $userId
*
* @return \PKP\services\queryBuilders\PKPContextQueryBuilder
*/
public function filterByUserId($userId)
{
$this->userId = $userId;
return $this;
}
/**
* Set query search phrase
*
* @param ?string $phrase
*
* @return \PKP\services\queryBuilders\PKPContextQueryBuilder
*/
public function searchPhrase($phrase)
{
$this->searchPhrase = $phrase;
return $this;
}
/**
* @copydoc PKP\services\queryBuilders\interfaces\EntityQueryBuilderInterface::getCount()
*/
public function getCount()
{
return $this
->getQuery()
->select('c.' . $this->dbIdColumn)
->get()
->count();
}
/**
* @copydoc PKP\services\queryBuilders\interfaces\EntityQueryBuilderInterface::getIds()
*/
public function getIds()
{
return $this
->getQuery()
->select('c.' . $this->dbIdColumn)
->pluck('c.' . $this->dbIdColumn)
->toArray();
}
/**
* Get the name and basic data for a set of contexts
*
* This returns data from the main table and the name
* of the context in its primary locale.
*
* @return array
*/
public function getManySummary()
{
return $this
->getQuery()
->select([
'c.' . $this->dbIdColumn . ' as id',
'c.enabled',
'cst.setting_value as name',
'c.path as urlPath',
'c.seq',
])
->leftJoin($this->dbSettings . ' as cst', function ($q) {
$q->where('cst.' . $this->dbIdColumn, '=', DB::raw('c.' . $this->dbIdColumn))
->where('cst.setting_name', '=', 'name')
->where('cst.locale', '=', DB::raw('c.primary_locale'));
})
->orderBy('c.seq')
->get()
->toArray();
}
/**
* @copydoc PKP\services\queryBuilders\interfaces\EntityQueryBuilderInterface::getQuery()
*/
public function getQuery()
{
$this->columns[] = 'c.*';
$q = DB::table($this->db . ' as c');
if (!empty($this->isEnabled)) {
$q->where('c.enabled', '=', 1);
} elseif ($this->isEnabled === false) {
$q->where('c.enabled', '!=', 1);
}
// Filter for user id if present
$q->when(!empty($this->userId), function ($q) {
$q->whereIn('c.' . $this->dbIdColumn, function ($q) {
$q->select('context_id')
->from('user_groups')
->where(function ($q) {
$q->where('role_id', '=', Role::ROLE_ID_MANAGER)
->orWhere('c.enabled', '=', 1);
})
->whereIn('user_group_id', function ($q) {
$q->select('user_group_id')
->from('user_user_groups')
->where('user_id', '=', $this->userId);
});
});
});
// search phrase
$q->when($this->searchPhrase !== null, function ($query) {
$words = explode(' ', $this->searchPhrase);
foreach ($words as $word) {
$query->whereIn('c.' . $this->dbIdColumn, function ($query) use ($word) {
return $query->select($this->dbIdColumn)
->from($this->dbSettings)
->whereIn('setting_name', ['description', 'acronym', 'abbreviation'])
->where(DB::raw('LOWER(setting_value)'), 'LIKE', DB::raw("CONCAT('%', LOWER(?), '%')"))->addBinding($word);
});
}
});
// Add app-specific query statements
Hook::call('Context::getContexts::queryObject', [&$q, $this]);
$q->select($this->columns);
return $q;
}
}
@@ -0,0 +1,61 @@
<?php
/**
* @file classes/services/queryBuilders/PKPStatsContextQueryBuilder.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 PKPStatsContextQueryBuilder
*
* @ingroup query_builders
*
* @brief Helper class to construct a query to fetch context stats records from the
* metrics_context table.
*/
namespace PKP\services\queryBuilders;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
use PKP\plugins\Hook;
use PKP\statistics\PKPStatisticsHelper;
class PKPStatsContextQueryBuilder extends PKPStatsQueryBuilder
{
/**
* Get contexts IDs
*/
public function getContextIds(): Builder
{
return $this->_getObject()
->select([PKPStatisticsHelper::STATISTICS_DIMENSION_CONTEXT_ID])
->distinct();
}
/**
* @copydoc PKPStatsQueryBuilder::_getObject()
*/
protected function _getObject(): Builder
{
$q = DB::table('metrics_context');
if (!empty($this->contextIds)) {
$q->whereIn(PKPStatisticsHelper::STATISTICS_DIMENSION_CONTEXT_ID, $this->contextIds);
}
$q->whereBetween(PKPStatisticsHelper::STATISTICS_DIMENSION_DATE, [$this->dateStart, $this->dateEnd]);
if ($this->limit > 0) {
$q->limit($this->limit);
if ($this->offset > 0) {
$q->offset($this->offset);
}
}
Hook::call('StatsContext::queryObject', [&$q, $this]);
return $q;
}
}
@@ -0,0 +1,501 @@
<?php
/**
* @file classes/services/QueryBuilders/PKPStatsEditorialQueryBuilder.php
*
* Copyright (c) 2014-2021 Simon Fraser University
* Copyright (c) 2000-2021 John Willinsky
* Distributed under the GNU GPL v3. For full terms see the file docs/COPYING.
*
* @class PKPStatsEditorialQueryBuilder
*
* @ingroup query_builders
*
* @brief Helper class to construct a query to fetch stats records from the
* metrics table.
*/
namespace PKP\services\queryBuilders;
use APP\facades\Repo;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
use PKP\config\Config;
use PKP\decision\DecisionType;
use PKP\plugins\Hook;
use PKP\submission\PKPSubmission;
abstract class PKPStatsEditorialQueryBuilder
{
/** @var array Return stats for activity in these contexts */
protected $contextIds = [];
/** @var string Return stats for activity before this date */
protected $dateEnd;
/** @var string Return stats for activity after this date */
protected $dateStart;
/** @var array Return stats for activity in these sections (series in OMP) */
protected $sectionIds = [];
/** @var string The table column name for section IDs (OJS) or series IDs (OMP) */
public $sectionIdsColumn;
/**
* Set the contexts to return activity for
*
* @param array|int $contextIds
*
* @return \PKP\services\queryBuilders\PKPStatsEditorialQueryBuilder
*/
public function filterByContexts($contextIds)
{
$this->contextIds = is_array($contextIds) ? $contextIds : [$contextIds];
return $this;
}
/**
* Set the section ids to include activity for. This is stored under
* the section_id db column but in OMP refers to seriesIds.
*
* @param array|int $sectionIds
*
* @return \PKP\services\queryBuilders\PKPStatsEditorialQueryBuilder
*/
public function filterBySections($sectionIds)
{
$this->sectionIds = is_array($sectionIds) ? $sectionIds : [$sectionIds];
return $this;
}
/**
* Set the date to get activity before
*
* @param string $dateEnd YYYY-MM-DD
*
* @return \PKP\services\queryBuilders\PKPStatsEditorialQueryBuilder
*/
public function before($dateEnd)
{
$this->dateEnd = $dateEnd;
return $this;
}
/**
* Set the date to get activity after
*
* @param string $dateStart YYYY-MM-DD
*
* @return \PKP\services\queryBuilders\PKPStatsEditorialQueryBuilder
*/
public function after($dateStart)
{
$this->dateStart = $dateStart;
return $this;
}
/**
* Get the count of submissions received
*
* @return int
*/
public function countSubmissionsReceived()
{
$q = $this->_getObject();
if ($this->dateStart) {
$q->where('s.date_submitted', '>=', $this->dateStart);
}
if ($this->dateEnd) {
$q->where('s.date_submitted', '<=', $this->dateEnd);
}
return $q->count();
}
/**
* Get the count of submissions that have received one or more
* editor decisions
*
* @param array $decisions One or more Decision::*
* @param bool $forSubmittedDate How date restrictions should be applied.
* A false value will count the number of submissions with an editorial
* decision within the date range. A true value will count the number of
* submissions received within the date range which eventually received
* an editorial decision.
*
* @return int
*/
public function countByDecisions($decisions, $forSubmittedDate = false)
{
$q = $this->_getObject();
$q->leftJoin('edit_decisions as ed', 's.submission_id', '=', 'ed.submission_id')
->whereIn('ed.decision', $decisions);
if ($forSubmittedDate) {
if ($this->dateStart) {
$q->where('s.date_submitted', '>=', $this->dateStart);
}
if ($this->dateEnd) {
// Include date time values up to the end of the day
$dateTime = new \DateTime($this->dateEnd);
$dateTime->add(new \DateInterval('P1D'));
$q->where('s.date_submitted', '<', $dateTime->format('Y-m-d'));
}
} else {
if ($this->dateStart) {
$q->where('ed.date_decided', '>=', $this->dateStart);
}
if ($this->dateEnd) {
// Include date time values up to the end of the day
$dateTime = new \DateTime($this->dateEnd);
$dateTime->add(new \DateInterval('P1D'));
$q->where('ed.date_decided', '<', $dateTime->format('Y-m-d'));
}
}
// Ensure that the decisions being counted have not been
// reversed. For example, a submission may have been accepted
// and then later declined. We check the current status to
// exclude submissions where the status doesn't match the
// decisions we are looking for.
$declineDecisions = array_map(function (DecisionType $decisionType) {
return $decisionType->getDecision();
}, Repo::decision()->getDeclineDecisionTypes());
if (count(array_intersect($declineDecisions, $decisions))) {
$q->where('s.status', '=', PKPSubmission::STATUS_DECLINED);
} else {
$q->where('s.status', '!=', PKPSubmission::STATUS_DECLINED);
}
$q->select(DB::raw('COUNT(DISTINCT s.submission_id) as count'));
return $q->get()->first()->count;
}
/**
* Get the count of submissions by one or more status
*
* @param int|array $status One or more of PKPSubmission::STATUS_*
*
* @return int
*/
public function countByStatus($status)
{
return $this->_getObject()
->whereIn('s.status', (array) $status)
->count();
}
/**
* Get the count of active submissions by one or more stages
*
* @param array $stages One or more of WORKFLOW_STAGE_ID_*
*
* @return int
*/
public function countActiveByStages($stages)
{
return $this->_getObject()
->where('s.status', '=', PKPSubmission::STATUS_QUEUED)
->whereIn('s.stage_id', $stages)
->count();
}
/**
* Get the count of published submissions
*
* @return int
*/
public function countPublished()
{
$q = $this->_getObject()
->where('s.status', '=', PKPSubmission::STATUS_PUBLISHED);
// Only match against the publication date of a
// submission's first published publication so
// that updated versions are excluded.
if ($this->dateStart || $this->dateEnd) {
$q->leftJoin('publications as p', function ($q) {
$q->where('p.publication_id', function ($q) {
$q->from('publications as p2')
->where('p2.submission_id', '=', DB::raw('s.submission_id'))
->where('p2.status', '=', PKPSubmission::STATUS_PUBLISHED)
->orderBy('p2.date_published', 'ASC')
->limit(1)
->select('p2.publication_id');
});
});
if ($this->dateStart) {
$q->where('p.date_published', '>=', $this->dateStart);
}
if ($this->dateEnd) {
$q->where('p.date_published', '<=', $this->dateEnd);
}
}
return $q->count();
}
/**
* Get the number of days to reach a particular editor decision
*
* This list includes any completed submission which has received
* one of the editor decisions.
*
* @param array $decisions One or more Decision::*
*
* @return array Days between submission and the first decision in
* the list of requested submissions
*/
public function getDaysToDecisions($decisions)
{
$q = $this->_getDaysToDecisionsObject($decisions);
$dateDiff = $this->_dateDiff('ed.date_decided', 's.date_submitted');
$q->select(DB::raw($dateDiff . ' as time'));
return $q->pluck('time')->toArray();
}
/**
* Get the average number of days to reach a particular
* editor decision
*
* This average includes any completed submission which has received
* one of the editor decisions.
*
* @param array $decisions One or more Decision::*
*
* @return float Average days between submission and the first decision
* in the list of requested submissions
*/
public function getAverageDaysToDecisions($decisions)
{
$q = $this->_getDaysToDecisionsObject($decisions);
$dateDiff = $this->_dateDiff('ed.date_decided', 's.date_submitted');
$q->select(DB::raw('AVG(' . $dateDiff . ') as average'));
return $q->get()->first()->average;
}
/**
* Get the first and last date of submissions received
*
* @return array [min, max]
*/
public function getSubmissionsReceivedDates()
{
$q = $this->_getObject();
return [$q->min('s.date_submitted'), $q->max('s.date_submitted')];
}
/**
* Get the first and last date of submissions published
*
* @return array [min, max]
*/
public function getPublishedDates()
{
$q = $this->_getObject()
->where('s.status', '=', PKPSubmission::STATUS_PUBLISHED)
// Only match against the publication date of a
// submission's first published publication so
// that updated versions are excluded.
->leftJoin('publications as p', function ($q) {
$q->where('p.publication_id', function ($q) {
$q->from('publications as p2')
->where('p2.submission_id', '=', DB::raw('s.submission_id'))
->where('p2.status', '=', PKPSubmission::STATUS_PUBLISHED)
->orderBy('p2.date_published', 'ASC')
->limit(1)
->select('p2.publication_id');
});
});
return [$q->min('p.date_published'), $q->max('p.date_published')];
}
/**
* Get the first and last date that an editorial decision was made
*
* @param array $decisions One or more Decision::*
*
* @return array [min, max]
*/
public function getDecisionsDates($decisions)
{
$q = $this->_getObject();
$q->leftJoin('edit_decisions as ed', 's.submission_id', '=', 'ed.submission_id')
->whereIn('ed.decision', $decisions);
// Ensure that the decisions being counted have not been
// reversed. For example, a submission may have been accepted
// and then later declined. We check the current status to
// exclude submissions where the status doesn't match the
// decisions we are looking for.
$declineDecisions = array_map(function (DecisionType $decisionType) {
return $decisionType->getDecision();
}, Repo::decision()->getDeclineDecisionTypes());
if (count(array_intersect($declineDecisions, $decisions))) {
$q->where('s.status', '=', PKPSubmission::STATUS_DECLINED);
} else {
$q->where('s.status', '!=', PKPSubmission::STATUS_DECLINED);
}
return [$q->min('ed.date_decided'), $q->max('ed.date_decided')];
}
/**
* Generate a base query object with context and section filters.
*/
protected function _getBaseQuery(): Builder
{
$q = DB::table('submissions as s');
if (!empty($this->contextIds)) {
$q->whereIn('s.context_id', $this->contextIds);
}
if (!empty($this->sectionIds)) {
$q->leftJoin('publications as ps', 's.current_publication_id', '=', 'ps.publication_id')
->whereIn("ps.{$this->sectionIdsColumn}", $this->sectionIds)
->whereNotNull('ps.publication_id');
}
// First publication included to flag imported submissions through heuristics
$q->leftJoin(
'publications as pi',
fn (Builder $q) => $q->where(
'pi.publication_id',
fn (Builder $q) => $q->from('publications as pi2')
->whereColumn('pi2.submission_id', '=', 's.submission_id')
->where('pi2.status', '=', PKPSubmission::STATUS_PUBLISHED)
->orderBy('pi2.date_published', 'ASC')
->limit(1)
->select('pi2.publication_id')
)
);
return $q;
}
/**
* Generate a query object based on the configured conditions.
* Incomplete and imported submissions are excluded.
*
* The dateStart and dateEnd filters are not handled here because
* the dates must be applied differently for each set of data.
*/
protected function _getObject(): Builder
{
$q = $this->_getBaseQuery();
// Exclude incomplete submissions
$q->where('s.submission_progress', '=', 0);
// Exclude submissions when the date_submitted is later
// than the first date_published. This prevents imported
// submissions from being counted in editorial stats.
$q->where(
fn (Builder $q) => $q->whereNull('pi.date_published')
->orWhere(DB::raw('CAST(s.date_submitted AS DATE)'), '<=', DB::raw('pi.date_published'))
);
Hook::call('Stats::editorial::queryObject', [&$q, $this]);
return $q;
}
/**
* Generate a query object to get a submission's first
* decision of the requested decision types
*
* Pass an empty $decisions array to return the number of days to
* _any_ decision.
*
* @param array $decisions One or more Decision::*
*
* @return Builder
*/
protected function _getDaysToDecisionsObject($decisions)
{
$q = $this->_getObject();
$q->leftJoin('edit_decisions as ed', function ($q) use ($decisions) {
$q->where('ed.edit_decision_id', function ($q) use ($decisions) {
$q->from('edit_decisions as ed2')
->where('ed2.submission_id', '=', DB::raw('s.submission_id'));
if (!empty($decisions)) {
$q->whereIn('ed2.decision', $decisions);
}
$q->orderBy('ed2.date_decided', 'ASC')
->limit(1)
->select('ed2.edit_decision_id');
});
});
$q->whereNotNull('ed.submission_id')
->whereNotNull('s.date_submitted');
if ($this->dateStart) {
$q->where('s.date_submitted', '>=', $this->dateStart);
}
if ($this->dateEnd) {
// Include date time values up to the end of the day
$dateTime = new \DateTime($this->dateEnd);
$dateTime->add(new \DateInterval('P1D'));
$q->where('s.date_submitted', '<=', $dateTime->format('Y-m-d'));
}
return $q;
}
/**
* Get the count of imported submissions.
* Not counted by static::countSubmissionsReceived().
*
* @return int
*/
public function countImported()
{
return $this->_getBaseQuery()
->where(DB::raw('CAST(s.date_submitted AS DATE)'), '>', DB::raw('pi.date_published'))
->when($this->dateStart, fn (Builder $q) => $q->where('s.date_submitted', '>=', $this->dateStart))
->when($this->dateEnd, fn (Builder $q) => $q->where('s.date_submitted', '<=', $this->dateEnd))
->count();
}
/**
* Get the count of incomplete submissions.
* Not counted by static::countSubmissionsReceived().
*
* @return int
*/
public function countInProgress()
{
return $this->_getBaseQuery()
->where('s.submission_progress', '<>', 0)
->when($this->dateStart, fn (Builder $q) => $q->where('s.date_submitted', '>=', $this->dateStart))
->when($this->dateEnd, fn (Builder $q) => $q->where('s.date_submitted', '<=', $this->dateEnd))
->count();
}
/**
* Get the count of submissions skipped by the other statistics
*/
public function countSkipped(): int
{
return $this->countInProgress() + $this->countImported();
}
/**
* Retrieves a suitable diff by days clause according to the active database driver
*
* @return string
*/
private function _dateDiff(string $leftDate, string $rightDate)
{
switch (Config::getVar('database', 'driver')) {
case 'mysql':
case 'mysqli':
return 'DATEDIFF(' . $leftDate . ',' . $rightDate . ')';
}
return "DATE_PART('day', " . $leftDate . ' - ' . $rightDate . ')';
}
}
@@ -0,0 +1,266 @@
<?php
/**
* @file classes/services/queryBuilders/PKPStatsGeoQueryBuilder.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 PKPStatsGeoQueryBuilder
*
* @ingroup query_builders
*
* @brief Helper class to construct a query to fetch geographic stats records from the
* metrics_submission_geo_monthly table.
*/
namespace PKP\services\queryBuilders;
use APP\statistics\StatisticsHelper;
use APP\submission\Submission;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
use PKP\config\Config;
use PKP\plugins\Hook;
abstract class PKPStatsGeoQueryBuilder extends PKPStatsQueryBuilder
{
/** Include records for these sections/series */
protected array $pkpSectionIds = [];
/** Include records for these submissions */
protected array $submissionIds = [];
/** Include records for these countries */
protected array $countries = [];
/** Include records for these regions */
protected array $regions = [];
/** Include records for these cities */
protected array $cities = [];
/** Application specific name of the section column */
abstract public function getSectionColumn(): string;
/**
* Set the sections/series to get records for
*/
public function filterByPKPSections(array $pkpSectionIds): self
{
$this->pkpSectionIds = $pkpSectionIds;
return $this;
}
/**
* Set the submission to get records for
*/
public function filterBySubmissions(array $submissionIds): self
{
$this->submissionIds = $submissionIds;
return $this;
}
/**
* Set the countries to get records for
*/
public function filterByCountries(array $countries): self
{
$this->countries = $countries;
return $this;
}
/**
* Set the regions to get records for
*/
public function filterByRegions(array $regions): self
{
$this->regions = $regions;
return $this;
}
/**
* Set the cities to get records for
*/
public function filterByCities(array $cities): self
{
$this->cities = $cities;
return $this;
}
/**
* Get Geo data
*/
public function getGeoData(array $groupBy): Builder
{
return $this->_getObject()
->select($groupBy)
->groupBy($groupBy);
}
/**
* @copydoc PKPStatsQueryBuilder::getSum()
*/
public function getSum(array $groupBy = []): Builder
{
$q = $this->_getObject();
// Build the select and group by clauses.
if (!empty($groupBy)) {
$q->select($groupBy);
$q->groupBy($groupBy);
}
$q->addSelect(DB::raw('SUM(metric) AS metric'));
$q->addSelect(DB::raw('SUM(metric_unique) AS metric_unique'));
return $q;
}
/**
* Consider/add application specific queries
*/
protected function _getAppSpecificQuery(Builder &$q): void
{
}
/**
* @copydoc PKPStatsQueryBuilder::_getObject()
*/
protected function _getObject(): Builder
{
// consider only monthly DB table
$q = DB::table('metrics_submission_geo_monthly');
if (!empty($this->contextIds)) {
$q->whereIn(StatisticsHelper::STATISTICS_DIMENSION_CONTEXT_ID, $this->contextIds);
}
if (!empty($this->submissionIds)) {
$q->whereIn(StatisticsHelper::STATISTICS_DIMENSION_SUBMISSION_ID, $this->submissionIds);
}
if (!empty($this->countries)) {
$q->whereIn(StatisticsHelper::STATISTICS_DIMENSION_COUNTRY, $this->countries);
}
if (!empty($this->regions)) {
// get first region (so that we can use where and then orWhere query)
$fistCountryRegionCode = array_shift($this->regions);
// regions must be in a form countryCode-regionCode
[$country, $region] = explode('-', $fistCountryRegionCode);
$q->where(function ($q) use ($country, $region) {
$q->where(StatisticsHelper::STATISTICS_DIMENSION_COUNTRY, $country)
->where(StatisticsHelper::STATISTICS_DIMENSION_REGION, $region);
});
foreach ($this->regions as $countryRegioncode) {
// regions must be in a form countryCode-regionCode
[$country, $region] = explode('-', $countryRegioncode);
$q->orWhere(function ($q) use ($country, $region) {
$q->where(StatisticsHelper::STATISTICS_DIMENSION_COUNTRY, $country)
->where(StatisticsHelper::STATISTICS_DIMENSION_REGION, $region);
});
}
}
if (!empty($this->cities)) {
// get first city (so that we can use where and then orWhere query)
$fistCountryRegionCity = array_shift($this->cities);
// cities must be in a form countryCode-regionCode-cityName
[$country, $region, $city] = explode('-', $fistCountryRegionCity);
$q->where(function ($q) use ($country, $region, $city) {
$q->where(StatisticsHelper::STATISTICS_DIMENSION_COUNTRY, $country)
->where(StatisticsHelper::STATISTICS_DIMENSION_REGION, $region)
->where(StatisticsHelper::STATISTICS_DIMENSION_CITY, 'like', $city . '%');
});
foreach ($this->cities as $countryRegionCity) {
// cities must be in a form countryCode-regionCode-cityName
[$country, $region, $city] = explode('-', $countryRegionCity);
$q->orWhere(function ($q) use ($country, $region, $city) {
$q->where(StatisticsHelper::STATISTICS_DIMENSION_COUNTRY, $country)
->where(StatisticsHelper::STATISTICS_DIMENSION_REGION, $region)
->where(StatisticsHelper::STATISTICS_DIMENSION_CITY, 'like', $city . '%');
});
}
}
$q->whereBetween(StatisticsHelper::STATISTICS_DIMENSION_MONTH, [date_format(date_create($this->dateStart), 'Ym'), date_format(date_create($this->dateEnd), 'Ym')]);
if (!empty($this->pkpSectionIds)) {
$sectionColumn = 'p.' . $this->getSectionColumn();
$sectionSubmissionIds = DB::table('publications as p')->select('p.submission_id')->distinct()
->from('publications as p')
->where('p.status', Submission::STATUS_PUBLISHED)
->whereIn($sectionColumn, $this->pkpSectionIds);
$q->joinSub($sectionSubmissionIds, 'ss', function ($join) {
$join->on('metrics_submission_geo_monthly.' . StatisticsHelper::STATISTICS_DIMENSION_SUBMISSION_ID, '=', 'ss.submission_id');
});
}
$this->_getAppSpecificQuery($q);
if ($this->limit > 0) {
$q->limit($this->limit);
if ($this->offset > 0) {
$q->offset($this->offset);
}
}
Hook::call('StatsGeo::queryObject', [&$q, $this]);
return $q;
}
/**
* Do usage stats data already exist for the given month
*
* @param string $month Month in the form YYYYMM
*/
public function monthExists(string $month): bool
{
return DB::table('metrics_submission_geo_monthly')
->where(StatisticsHelper::STATISTICS_DIMENSION_MONTH, $month)->exists();
}
/**
* Delete daily usage metrics for a month
*
* @param string $month Month in the form YYYYMM
*/
public function deleteDailyMetrics(string $month): void
{
// Construct the SQL part depending on the DB
$monthFormatSql = "DATE_FORMAT(date, '%Y%m')";
if (substr(Config::getVar('database', 'driver'), 0, strlen('postgres')) === 'postgres') {
$monthFormatSql = "to_char(date, 'YYYYMM')";
}
DB::table('metrics_submission_geo_daily')->where(DB::raw($monthFormatSql), '=', $month)->delete();
}
/**
* Delete monthly usage metrics for a month
*
* @param string $month Month in the form YYYYMM
*/
public function deleteMonthlyMetrics(string $month): void
{
DB::table('metrics_submission_geo_monthly')->where('month', $month)->delete();
}
/**
* Aggregate daily usage metrics by a month
*
* @param string $month Month in the form YYYYMM
*/
public function addMonthlyMetrics(string $month): void
{
// Construct the SQL part depending on the DB
$monthFormatSql = "CAST(DATE_FORMAT(gd.date, '%Y%m') AS UNSIGNED)";
if (substr(Config::getVar('database', 'driver'), 0, strlen('postgres')) === 'postgres') {
$monthFormatSql = "to_char(gd.date, 'YYYYMM')::integer";
}
$selectSubmissionGeoDaily = DB::table('metrics_submission_geo_daily as gd')
->select(DB::raw("gd.context_id, gd.submission_id, COALESCE(gd.country, ''), COALESCE(gd.region, ''), COALESCE(gd.city, ''), {$monthFormatSql} as gdmonth, SUM(gd.metric), SUM(gd.metric_unique)"))
->whereRaw("{$monthFormatSql} = ?", [$month])
->groupBy(DB::raw('gd.context_id, gd.submission_id, gd.country, gd.region, gd.city, gdmonth'));
DB::table('metrics_submission_geo_monthly')->insertUsing(['context_id', 'submission_id', 'country', 'region', 'city', 'month', 'metric', 'metric_unique'], $selectSubmissionGeoDaily);
}
}
@@ -0,0 +1,192 @@
<?php
/**
* @file classes/services/queryBuilders/PKPStatsPublicationQueryBuilder.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 PKPStatsPublicationQueryBuilder
*
* @ingroup query_builders
*
* @brief Helper class to construct a query to fetch stats records from the
* metrics_submission table.
*/
namespace PKP\services\queryBuilders;
use APP\core\Application;
use APP\submission\Submission;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
use PKP\plugins\Hook;
use PKP\statistics\PKPStatisticsHelper;
abstract class PKPStatsPublicationQueryBuilder extends PKPStatsQueryBuilder
{
/**
*Include records for one of these object types:
* Application::ASSOC_TYPE_SUBMISSION, Application::ASSOC_TYPE_SUBMISSION_FILE, Application::ASSOC_TYPE_SUBMISSION_FILE_COUNTER_OTHER
*/
protected array $assocTypes = [];
/** Include records for these file types: PKPStatisticsHelper::STATISTICS_FILE_TYPE_* */
protected array $fileTypes = [];
/** Include records for these sections/series */
protected array $pkpSectionIds = [];
/** Include records for these submissions */
protected array $submissionIds = [];
/** Include records for these representations (galley or publication format) */
protected array $representationIds = [];
/** Include records for these submission files */
protected array $submissionFileIds = [];
/** Application specific name of the section column */
abstract public function getSectionColumn(): string;
/**
* Set the sections/series to get records for
*/
public function filterByPKPSections(array $pkpSectionIds): self
{
$this->pkpSectionIds = $pkpSectionIds;
return $this;
}
/**
* Set the submissions to get records for
*/
public function filterBySubmissions(array $submissionIds): self
{
$this->submissionIds = $submissionIds;
return $this;
}
/**
* Set the representations to get records for
*/
public function filterByRepresentations(array $representationIds): self
{
$this->representationIds = $representationIds;
return $this;
}
/**
* Set the files to get records for
*/
public function filterBySubmissionFiles(array $submissionFileIds): self
{
$this->submissionFileIds = $submissionFileIds;
return $this;
}
/**
* Set the assocTypes to get records for
*/
public function filterByAssocTypes(array $assocTypes): self
{
$this->assocTypes = $assocTypes;
return $this;
}
/**
* Set the galley file type to get records for
*/
public function filterByFileTypes(array $fileTypes): self
{
$this->fileTypes = $fileTypes;
return $this;
}
/**
* Get submission IDs
*/
public function getSubmissionIds(): Builder
{
return $this->_getObject()
->select(['metrics_submission.' . PKPStatisticsHelper::STATISTICS_DIMENSION_SUBMISSION_ID])
->distinct();
}
/**
* @copydoc PKPStatsQueryBuilder::getSum()
*/
public function getSum(array $groupBy = []): Builder
{
$groupBy = array_map(function ($column) {
return $column == PKPStatisticsHelper::STATISTICS_DIMENSION_SUBMISSION_ID ? 'metrics_submission.' . $column : $column;
}, $groupBy);
return parent::getSum($groupBy);
}
/**
* Consider/add application specific queries
*/
protected function _getAppSpecificQuery(Builder &$q): void
{
}
/**
* @copydoc PKPStatsQueryBuilder::_getObject()
*/
protected function _getObject(): Builder
{
$q = DB::table('metrics_submission');
if (!empty($this->contextIds)) {
$q->whereIn(PKPStatisticsHelper::STATISTICS_DIMENSION_CONTEXT_ID, $this->contextIds);
}
if (!empty($this->submissionIds)) {
$q->whereIn('metrics_submission.' . PKPStatisticsHelper::STATISTICS_DIMENSION_SUBMISSION_ID, $this->submissionIds);
}
if (!empty($this->assocTypes)) {
$q->whereIn(PKPStatisticsHelper::STATISTICS_DIMENSION_ASSOC_TYPE, $this->assocTypes);
}
if (!empty($this->fileTypes)) {
$q->whereIn(PKPStatisticsHelper::STATISTICS_DIMENSION_FILE_TYPE, $this->fileTypes);
}
if (!empty($this->representationIds)) {
$q->whereIn(PKPStatisticsHelper::STATISTICS_DIMENSION_REPRESENTATION_ID, $this->representationIds);
}
if (!empty($this->submissionFileIds)) {
$q->whereIn(PKPStatisticsHelper::STATISTICS_DIMENSION_SUBMISSION_FILE_ID, $this->submissionFileIds);
}
$q->whereBetween(PKPStatisticsHelper::STATISTICS_DIMENSION_DATE, [$this->dateStart, $this->dateEnd]);
if (!empty($this->pkpSectionIds)) {
$sectionColumn = 'p.' . $this->getSectionColumn();
$sectionSubmissionIds = DB::table('publications as p')->select('p.submission_id')->distinct()
->from('publications as p')
->where('p.status', Submission::STATUS_PUBLISHED)
->whereIn($sectionColumn, $this->pkpSectionIds);
$q->joinSub($sectionSubmissionIds, 'ss', function ($join) {
$join->on('metrics_submission.' . PKPStatisticsHelper::STATISTICS_DIMENSION_SUBMISSION_ID, '=', 'ss.submission_id');
});
}
$this->_getAppSpecificQuery($q);
if ($this->limit > 0) {
$q->limit($this->limit);
if ($this->offset > 0) {
$q->offset($this->offset);
}
}
Hook::call('StatsPublication::queryObject', [&$q, $this]);
return $q;
}
}
@@ -0,0 +1,164 @@
<?php
/**
* @file classes/services/queryBuilders/PKPStatsQueryBuilder.php
*
* Copyright (c) 2014-2021 Simon Fraser University
* Copyright (c) 2000-2021 John Willinsky
* Distributed under the GNU GPL v3. For full terms see the file docs/COPYING.
*
* @class PKPStatsQueryBuilder
*
* @ingroup query_builders
*
* @brief Base class for statistics query builders.
*/
namespace PKP\services\queryBuilders;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
use PKP\config\Config;
use PKP\statistics\PKPStatisticsHelper;
abstract class PKPStatsQueryBuilder
{
/** Include records for these contexts */
protected array $contextIds = [];
/** Include records from this date or before. Default: yesterday's date */
protected string $dateEnd;
/** Include records from this date or after. Default: PKPStatisticsHelper::STATISTICS_EARLIEST_DATE */
protected string $dateStart;
/** The count of records to return */
protected int $limit = 0;
/** The offset of records to return */
protected int $offset = 0;
/**
* Set the contexts to get records for
*/
public function filterByContexts(array $contextIds): self
{
$this->contextIds = $contextIds;
return $this;
}
/**
* Set the date before which to get records
*
* @param string $dateEnd YYYY-MM-DD
*
*/
public function before(string $dateEnd): self
{
$this->dateEnd = $dateEnd;
return $this;
}
/**
* Set the date after which to get records
*
* @param string $dateStart YYYY-MM-DD
*
*/
public function after(string $dateStart): self
{
$this->dateStart = $dateStart;
return $this;
}
/**
* Set the count of records to return
*/
public function limit(int $limit): self
{
$this->limit = $limit;
return $this;
}
/**
* Set the offset of records to return
*/
public function offset(int $offset): self
{
$this->offset = $offset;
return $this;
}
/**
* Get the sum of all matching records
*
* Use this method to get the total X views. Pass a
* $groupBy argument to get the total X views for each
* object, grouped by one or more columns.
*
* @param array $groupBy One or more columns to group by
*
*/
public function getSum(array $groupBy = []): Builder
{
$selectColumns = $groupBy;
$selectColumns = $this->getSelectColumns($selectColumns);
$q = $this->_getObject();
// Build the select and group by clauses.
if (!empty($selectColumns)) {
$q->select($selectColumns);
if (!empty($groupBy)) {
$q->groupBy($groupBy);
}
}
$q->addSelect(DB::raw('SUM(metric) AS metric'));
return $q;
}
/**
* Generate a query object based on the configured conditions.
*
* Public methods should call this method to set up the query
* object and apply any additional selection, grouping and
* ordering conditions.
*/
abstract protected function _getObject(): Builder;
/**
* Get appropriate SQL code for columns in the select part of the query
*/
protected function getSelectColumns(array $selectColumns): array
{
if (!in_array(PKPStatisticsHelper::STATISTICS_DIMENSION_YEAR, $selectColumns)
&& !in_array(PKPStatisticsHelper::STATISTICS_DIMENSION_MONTH, $selectColumns)
&& !in_array(PKPStatisticsHelper::STATISTICS_DIMENSION_DAY, $selectColumns)) {
return $selectColumns;
}
foreach ($selectColumns as $i => $selectColumn) {
if ($selectColumn == PKPStatisticsHelper::STATISTICS_DIMENSION_YEAR) {
if (substr(Config::getVar('database', 'driver'), 0, strlen('postgres')) === 'postgres') {
// date_trunc: Values of type date are cast automatically to timestamp. So cast them back to date.
$selectColumns[$i] = DB::raw("date_trunc('year', date)::timestamp::date AS year");
} else {
$selectColumns[$i] = DB::raw("date_format(date, '%Y-01-01') AS year");
}
break;
} elseif ($selectColumn == PKPStatisticsHelper::STATISTICS_DIMENSION_MONTH) {
if (substr(Config::getVar('database', 'driver'), 0, strlen('postgres')) === 'postgres') {
// date_trunc: Values of type date are cast automatically to timestamp. So cast them back to date.
$selectColumns[$i] = DB::raw("date_trunc('month', date)::timestamp::date AS month");
} else {
$selectColumns[$i] = DB::raw("date_format(date, '%Y-%m-01') AS month");
}
break;
} elseif ($selectColumn == PKPStatisticsHelper::STATISTICS_DIMENSION_DAY) {
$selectColumns[$i] = DB::raw('date AS day');
break;
}
}
return $selectColumns;
}
}
@@ -0,0 +1,236 @@
<?php
/**
* @file classes/services/queryBuilders/PKPStatsSushiQueryBuilder.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 PKPStatsSushiQueryBuilder
*
* @ingroup query_builders
*
* @brief Helper class to construct a query to fetch COUNTER stats records from the
* metrics_counter_submission_monthly or metrics_counter_submission_institution_monthly table.
*/
namespace PKP\services\queryBuilders;
use APP\statistics\StatisticsHelper;
use APP\submission\Submission;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
use PKP\config\Config;
use PKP\plugins\Hook;
class PKPStatsSushiQueryBuilder extends PKPStatsQueryBuilder
{
/** Include records for the submissions that have these years of publications (YOP) */
protected array $yearsOfPublication = [];
/**Include records for these submissions */
protected array $submissionIds = [];
/**Include records for this institution */
protected int $institutionId = 0;
/**
* Set the year of publication (YOP) of submissions to get records for
*/
public function filterByYOP(array $yearsOfPublication): self
{
$this->yearsOfPublication = $yearsOfPublication;
return $this;
}
/**
* Set the submissions to get records for
*/
public function filterBySubmissions(array $submissionIds): self
{
$this->submissionIds = $submissionIds;
return $this;
}
/**
* Set the institution to get records for
*/
public function filterByInstitution(int $institutionId): self
{
$this->institutionId = $institutionId;
return $this;
}
/**
* @copydoc PKPStatsQueryBuilder::getSum()
*/
public function getSum(array $groupBy = []): Builder
{
$selectColumns = $groupBy;
$q = $this->_getObject();
// consider YOP
if (in_array('YOP', $selectColumns)) {
// left join the table publications, if the filter is not set i.e. the left join is not considered yet in _getObject()
if (empty($this->yearsOfPublication)) {
$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)
->where('p2.submission_id', '=', DB::raw('m.submission_id'));
});
});
}
foreach ($selectColumns as $i => $selectColumn) {
if ($selectColumn == 'YOP') {
if (substr(Config::getVar('database', 'driver'), 0, strlen('postgres')) === 'postgres') {
$selectColumns[$i] = DB::raw('EXTRACT(YEAR FROM p.date_published) as "YOP"');
} else {
$selectColumns[$i] = DB::raw('YEAR(STR_TO_DATE(p.date_published, "%Y-%m-%d")) as YOP');
}
break;
}
}
}
// Build the select and group by clauses.
if (!empty($selectColumns)) {
$q->select($selectColumns);
if (!empty($groupBy)) {
$q->groupBy($groupBy);
}
}
$counterMetricsColumns = StatisticsHelper::getCounterMetricsColumns();
foreach ($counterMetricsColumns as $counterMetricsColumn) {
$q->addSelect(DB::raw("SUM({$counterMetricsColumn}) AS {$counterMetricsColumn}"));
}
return $q;
}
/**
* @copydoc PKPStatsQueryBuilder::_getObject()
*/
protected function _getObject(): Builder
{
if ($this->institutionId === 0) {
$q = DB::table('metrics_counter_submission_monthly as m');
} else {
$q = DB::table('metrics_counter_submission_institution_monthly as m');
}
if (!empty($this->yearsOfPublication)) {
$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)
->where('p2.submission_id', '=', DB::raw('m.submission_id'));
});
});
foreach ($this->yearsOfPublication as $yop) {
if (preg_match('/\d{4}/', $yop)) {
if (substr(Config::getVar('database', 'driver'), 0, strlen('postgres')) === 'postgres') {
$q->where(DB::raw('EXTRACT(YEAR FROM p.date_published)'), '=', $yop);
} else {
$q->where(DB::raw('YEAR(STR_TO_DATE(p.date_published, "%Y-%m-%d"))'), '=', $yop);
}
} elseif (preg_match('/\d{4}-\d{4}/', $yop)) {
$years = explode('-', $yop);
if (substr(Config::getVar('database', 'driver'), 0, strlen('postgres')) === 'postgres') {
$q->whereBetween(DB::raw('EXTRACT(YEAR FROM p.date_published)'), $years);
} else {
$q->whereBetween(DB::raw('YEAR(STR_TO_DATE(p.date_published, "%Y-%m-%d"))'), $years);
}
}
}
}
if (!empty($this->contextIds)) {
$q->whereIn('m.' . StatisticsHelper::STATISTICS_DIMENSION_CONTEXT_ID, $this->contextIds);
}
if (!empty($this->submissionIds)) {
$q->whereIn('m.' . StatisticsHelper::STATISTICS_DIMENSION_SUBMISSION_ID, $this->submissionIds);
}
$q->whereBetween('m.' . StatisticsHelper::STATISTICS_DIMENSION_MONTH, [date_format(date_create($this->dateStart), 'Ym'), date_format(date_create($this->dateEnd), 'Ym')]);
Hook::call('StatsSushi::queryObject', [&$q, $this]);
return $q;
}
/**
* Do usage stats data already exist for the given month
* Consider only the table metrics_counter_submission_monthly, because
* it always contains data, while metrics_counter_submission_institution_monthly
* could not contain data.
*
* @param string $month Month in the form YYYYMM
*/
public function monthExists(string $month): bool
{
return DB::table('metrics_counter_submission_monthly as m')
->where(StatisticsHelper::STATISTICS_DIMENSION_MONTH, $month)->exists();
}
/**
* Delete daily usage stats for a month
*
* @param string $month Month in the form YYYYMM
*/
public function deleteDailyMetrics(string $month): void
{
// Construct the SQL part depending on the DB
$monthFormatSql = "DATE_FORMAT(date, '%Y%m')";
if (substr(Config::getVar('database', 'driver'), 0, strlen('postgres')) === 'postgres') {
$monthFormatSql = "to_char(date, 'YYYYMM')";
}
DB::table('metrics_counter_submission_daily')->where(DB::raw($monthFormatSql), '=', $month)->delete();
DB::table('metrics_counter_submission_institution_daily')->where(DB::raw($monthFormatSql), '=', $month)->delete();
}
/**
* Delete monthly usage metrics for a month
*
* @param string $month Month in the form YYYYMM
*/
public function deleteMonthlyMetrics(string $month): void
{
DB::table('metrics_counter_submission_monthly')->where('month', $month)->delete();
DB::table('metrics_counter_submission_institution_monthly')->where('month', $month)->delete();
}
/**
* Aggregate daily usage metrics by a month
*
* @param string $month Month in the form YYYYMM
*/
public function addMonthlyMetrics(string $month): void
{
// Construct the SQL part depending on the DB
$monthFormatSql = "CAST(DATE_FORMAT(csd.date, '%Y%m') AS UNSIGNED)";
if (substr(Config::getVar('database', 'driver'), 0, strlen('postgres')) === 'postgres') {
$monthFormatSql = "to_char(csd.date, 'YYYYMM')::integer";
}
// Get the application specific metrics columns
$counterMetricsColumns = StatisticsHelper::getCounterMetricsColumns();
// SQL part for the select sub-statement creates the SUM for each metrics column, and then connects them with ','
$selectSql = implode(', ', array_map(fn ($value): string => 'SUM(csd.' . $value . ')', $counterMetricsColumns));
$selectSubmissionDaily = DB::table('metrics_counter_submission_daily as csd')
->select(DB::raw("csd.context_id, csd.submission_id, {$monthFormatSql} as csdmonth, {$selectSql}"))
->whereRaw("{$monthFormatSql} = ?", [$month])
->groupBy(DB::raw('csd.context_id, csd.submission_id, csdmonth'));
DB::table('metrics_counter_submission_monthly')->insertUsing(array_merge(['context_id', 'submission_id', 'month'], $counterMetricsColumns), $selectSubmissionDaily);
$selectSubmissionInstitutionDaily = DB::table('metrics_counter_submission_institution_daily as csd')
->select(DB::raw("csd.context_id, csd.submission_id, csd.institution_id, {$monthFormatSql} as csdmonth, {$selectSql}"))
->whereRaw("{$monthFormatSql} = ?", [$month])
->groupBy(DB::raw('csd.context_id, csd.submission_id, csd.institution_id, csdmonth'));
DB::table('metrics_counter_submission_institution_monthly')->insertUsing(array_merge(['context_id', 'submission_id', 'institution_id', 'month'], $counterMetricsColumns), $selectSubmissionInstitutionDaily);
}
}
@@ -0,0 +1,89 @@
<?php
/**
* @file classes/services/queryBuilders/interfaces/EntityQueryBuilderInterface.php
*
* Copyright (c) 2014-2021 Simon Fraser University
* Copyright (c) 2000-2021 John Willinsky
* Distributed under the GNU GPL v3. For full terms see the file docs/COPYING.
*
* @class EntityQueryBuilderInterface
*
* @ingroup services_query_builders
*
* @brief An interface that defines required methods for
* a QueryBuilder that retrieves one of the application's
* entities.
*/
namespace PKP\services\queryBuilders\interfaces;
use Illuminate\Database\Query\Builder;
interface EntityQueryBuilderInterface
{
/**
* Get a count of the number of rows that match the select
* conditions configured in this query builder.
*
* @return int
*/
public function getCount();
/**
* Get a list of ids that match the select conditions
* configured in this query builder.
*
* @return array
*/
public function getIds();
/**
* Get a query builder with the applied select, where and
* join clauses based on builder's configuration
*
* This returns an instance of Laravel's query builder.
*
* Call the `get` method on a query builder to return an array
* of matching rows.
*
* ```php
* $qb = new \PKP\services\queryBuilders\PublicationQueryBuilder();
* $result = $qb
* ->filterByContextIds(1)
* ->getQuery()
* ->get();
* ```
*
* Or use the query builder to retrieve objects from a DAO.
* This example retrieves the first 20 matching Publications.
*
* ```php
* $qo = $qb
* ->filterByContextIds(1)
* ->getQuery();
* $result = DAORegistry::getDAO('ReviewRoundDAO')->retrieveRange(
* $qo->toSql(),
* $qo->getBindings(),
* new DBResultRange(20, null, 0);
* );
* $queryResults = new DAOResultFactory($result, $reviewRoundDao, '_fromRow');
* $iteratorOfObjects = $queryResults->toIterator();
* ```
*
* Laravel's other query builder methods, such as `first`
* and `pluck`, can also be used.
*
* ```
* $qb = new \PKP\services\queryBuilders\PublicationQueryBuilder();
* $result = $qb
* ->filterByContextIds(1)
* ->getQuery()
* ->first();
* ```
*
* See: https://laravel.com/docs/5.5/queries
*
* @return Builder
*/
public function getQuery();
}