first commit
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user