482 lines
18 KiB
PHP
482 lines
18 KiB
PHP
<?php
|
|
/**
|
|
* @file classes/decision/Repository.php
|
|
*
|
|
* Copyright (c) 2014-2022 Simon Fraser University
|
|
* Copyright (c) 2000-2022 John Willinsky
|
|
* Distributed under the GNU GPL v3. For full terms see the file docs/COPYING.
|
|
*
|
|
* @class Repository
|
|
*
|
|
* @brief A repository to find and manage editorial decisions.
|
|
*/
|
|
|
|
namespace PKP\decision;
|
|
|
|
use APP\core\Application;
|
|
use APP\core\Request;
|
|
use APP\core\Services;
|
|
use APP\decision\Decision;
|
|
use APP\facades\Repo;
|
|
use APP\notification\Notification;
|
|
use APP\notification\NotificationManager;
|
|
use APP\submission\Submission;
|
|
use Exception;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\App;
|
|
use PKP\context\Context;
|
|
use PKP\core\Core;
|
|
use PKP\core\PKPApplication;
|
|
use PKP\db\DAORegistry;
|
|
use PKP\log\event\PKPSubmissionEventLogEntry;
|
|
use PKP\log\SubmissionLog;
|
|
use PKP\observers\events\DecisionAdded;
|
|
use PKP\plugins\Hook;
|
|
use PKP\security\Role;
|
|
use PKP\security\Validation;
|
|
use PKP\services\PKPSchemaService;
|
|
use PKP\stageAssignment\StageAssignment;
|
|
use PKP\stageAssignment\StageAssignmentDAO;
|
|
use PKP\submission\reviewRound\ReviewRoundDAO;
|
|
use PKP\submissionFile\SubmissionFile;
|
|
use PKP\validation\ValidatorFactory;
|
|
|
|
abstract class Repository
|
|
{
|
|
/** @var DAO $dao */
|
|
public $dao;
|
|
|
|
/** @var string $schemaMap The name of the class to map this entity to its schemaa */
|
|
public $schemaMap = maps\Schema::class;
|
|
|
|
/** @var Request $request */
|
|
protected $request;
|
|
|
|
/** @var PKPSchemaService<Decision> $schemaService */
|
|
protected $schemaService;
|
|
|
|
|
|
public function __construct(DAO $dao, Request $request, PKPSchemaService $schemaService)
|
|
{
|
|
$this->dao = $dao;
|
|
$this->request = $request;
|
|
$this->schemaService = $schemaService;
|
|
}
|
|
|
|
/** @copydoc DAO::newDataObject() */
|
|
public function newDataObject(array $params = []): Decision
|
|
{
|
|
$object = $this->dao->newDataObject();
|
|
if (!empty($params)) {
|
|
$object->setAllData($params);
|
|
}
|
|
return $object;
|
|
}
|
|
|
|
/** @copydoc DAO::get() */
|
|
public function get(int $id, int $submissionId = null): ?Decision
|
|
{
|
|
return $this->dao->get($id, $submissionId);
|
|
}
|
|
|
|
/** @copydoc DAO::exists() */
|
|
public function exists(int $id, int $submissionId = null): bool
|
|
{
|
|
return $this->dao->exists($id, $submissionId);
|
|
}
|
|
|
|
/** @copydoc DAO::getCollector() */
|
|
public function getCollector(): Collector
|
|
{
|
|
return App::make(Collector::class);
|
|
}
|
|
|
|
/**
|
|
* Get an instance of the map class for mapping
|
|
* decisions to their schema
|
|
*/
|
|
public function getSchemaMap(): maps\Schema
|
|
{
|
|
return app('maps')->withExtensions($this->schemaMap);
|
|
}
|
|
|
|
/**
|
|
* Validate properties for a decision
|
|
*
|
|
* Perform validation checks on data used to add a decision. It is not
|
|
* possible to edit a decision.
|
|
*
|
|
* @param array $props A key/value array with the new data to validate
|
|
* @param Submission $submission The submission for this decision
|
|
*
|
|
* @return array A key/value array with validation errors. Empty if no errors
|
|
*/
|
|
public function validate(array $props, DecisionType $decisionType, Submission $submission, Context $context): array
|
|
{
|
|
// Return early if no valid decision type exists
|
|
if (!isset($props['decision']) || $props['decision'] !== $decisionType->getDecision()) {
|
|
return ['decision' => [__('editor.submission.workflowDecision.typeInvalid')]];
|
|
}
|
|
|
|
// Return early if an invalid submission ID is passed
|
|
if (!isset($props['submissionId']) || $props['submissionId'] !== $submission->getId()) {
|
|
return ['submissionId' => [__('editor.submission.workflowDecision.submissionInvalid')]];
|
|
}
|
|
|
|
$validator = ValidatorFactory::make(
|
|
$props,
|
|
$this->schemaService->getValidationRules($this->dao->schema, []),
|
|
);
|
|
|
|
// Check required
|
|
ValidatorFactory::required(
|
|
$validator,
|
|
null,
|
|
$this->schemaService->getRequiredProps($this->dao->schema),
|
|
$this->schemaService->getMultilingualProps($this->dao->schema),
|
|
[],
|
|
''
|
|
);
|
|
|
|
$validator->after(function ($validator) use ($props, $decisionType, $submission, $context) {
|
|
// The decision stage id must match the decision type's stage id
|
|
// and the submission's current workflow stage
|
|
if ($props['stageId'] !== $decisionType->getStageId()
|
|
|| $props['stageId'] !== $submission->getData('stageId')) {
|
|
$validator->errors()->add('decision', __('editor.submission.workflowDecision.invalidStage'));
|
|
}
|
|
|
|
// The editorId must match an existing editor
|
|
if (isset($props['editorId'])) {
|
|
$user = Repo::user()->get((int) $props['editorId']);
|
|
if (!$user) {
|
|
$validator->errors()->add('editorId', __('editor.submission.workflowDecision.invalidEditor'));
|
|
}
|
|
}
|
|
|
|
// A recommendation can not be made if the submission does not
|
|
// have at least one assigned editor who can make a decision
|
|
if ($this->isRecommendation($decisionType->getDecision())) {
|
|
/** @var StageAssignmentDAO $stageAssignmentDao */
|
|
$stageAssignmentDao = DAORegistry::getDAO('StageAssignmentDAO');
|
|
$assignedEditorIds = $stageAssignmentDao->getDecidingEditorIds($submission->getId(), $decisionType->getStageId());
|
|
if (!$assignedEditorIds) {
|
|
$validator->errors()->add('decision', __('editor.submission.workflowDecision.requiredDecidingEditor'));
|
|
}
|
|
}
|
|
|
|
// Validate the review round
|
|
if (isset($props['reviewRoundId'])) {
|
|
// The decision must be taken during a review stage
|
|
if (!$decisionType->isInReview() && !$validator->errors()->get('reviewRoundId')) {
|
|
$validator->errors()->add('reviewRoundId', __('editor.submission.workflowDecision.invalidReviewRoundStage'));
|
|
}
|
|
|
|
// The review round must exist and be related to the correct submission.
|
|
if (!$validator->errors()->get('reviewRoundId')) {
|
|
$reviewRoundDao = DAORegistry::getDAO('ReviewRoundDAO'); /** @var ReviewRoundDAO $reviewRoundDao */
|
|
$reviewRound = $reviewRoundDao->getById($props['reviewRoundId']);
|
|
if (!$reviewRound) {
|
|
$validator->errors()->add('reviewRoundId', __('editor.submission.workflowDecision.invalidReviewRound'));
|
|
} elseif ($reviewRound->getSubmissionId() !== $submission->getId()) {
|
|
$validator->errors()->add('reviewRoundId', __('editor.submission.workflowDecision.invalidReviewRoundSubmission'));
|
|
}
|
|
}
|
|
} elseif ($decisionType->isInReview()) {
|
|
$validator->errors()->add('reviewRoundId', __('editor.submission.workflowDecision.requiredReviewRound'));
|
|
}
|
|
|
|
// Allow the decision type to add validation checks
|
|
$decisionType->validate($props, $submission, $context, $validator, isset($reviewRound) ? $reviewRound->getId() : null);
|
|
});
|
|
|
|
$errors = [];
|
|
|
|
if ($validator->fails()) {
|
|
$errors = $this->schemaService->formatValidationErrors($validator->errors());
|
|
}
|
|
|
|
Hook::call('Decision::validate', [&$errors, $props]);
|
|
|
|
return $errors;
|
|
}
|
|
|
|
/**
|
|
* Record an editorial decision
|
|
*/
|
|
public function add(Decision $decision): int
|
|
{
|
|
// Actions are handled separately from the decision object
|
|
$actions = $decision->getData('actions') ?? [];
|
|
$decision->unsetData('actions');
|
|
|
|
// Set the review round automatically from the review round id
|
|
if ($decision->getData('reviewRoundId')) {
|
|
$decision->setData('round', $this->getRoundByReviewRoundId($decision->getData('reviewRoundId')));
|
|
}
|
|
$decision->setData('dateDecided', Core::getCurrentDate());
|
|
$id = $this->dao->insert($decision);
|
|
Hook::call('Decision::add', [$decision]);
|
|
|
|
$decision = $this->get($id);
|
|
|
|
$decisionType = $decision->getDecisionType();
|
|
$submission = Repo::submission()->get($decision->getData('submissionId'));
|
|
$editor = Repo::user()->get($decision->getData('editorId'));
|
|
$decision = $this->get($decision->getId());
|
|
$context = Application::get()->getRequest()->getContext();
|
|
if (!$context || $context->getId() !== $submission->getData('contextId')) {
|
|
$context = Services::get('context')->get($submission->getData('contextId'));
|
|
}
|
|
|
|
// Log the decision
|
|
$eventLog = Repo::eventLog()->newDataObject([
|
|
'assocType' => PKPApplication::ASSOC_TYPE_SUBMISSION,
|
|
'assocId' => $submission->getId(),
|
|
'eventType' => $this->isRecommendation($decisionType->getDecision())
|
|
? PKPSubmissionEventLogEntry::SUBMISSION_LOG_EDITOR_RECOMMENDATION
|
|
: PKPSubmissionEventLogEntry::SUBMISSION_LOG_EDITOR_DECISION,
|
|
'userId' => Validation::loggedInAs() ?? $this->request->getUser()?->getId(),
|
|
'message' => $decisionType->getLog(),
|
|
'isTranslated' => false,
|
|
'dateLogged' => Core::getCurrentDate()
|
|
]);
|
|
Repo::eventLog()->add($eventLog);
|
|
|
|
// Allow the decision type to perform additional actions
|
|
$decisionType->runAdditionalActions($decision, $submission, $editor, $context, $actions);
|
|
|
|
try {
|
|
event(new DecisionAdded(
|
|
$decision,
|
|
$decisionType,
|
|
$submission,
|
|
$editor,
|
|
$context,
|
|
$actions
|
|
));
|
|
} catch (Exception $e) {
|
|
error_log($e->getMessage());
|
|
error_log($e->getTraceAsString());
|
|
}
|
|
|
|
$this->updateNotifications($decision, $decisionType, $submission);
|
|
|
|
return $id;
|
|
}
|
|
|
|
/**
|
|
* Delete all decisions by the submission ID
|
|
*/
|
|
public function deleteBySubmissionId(int $submissionId)
|
|
{
|
|
$decisionIds = $this->getCollector()
|
|
->filterBySubmissionIds([$submissionId])
|
|
->getIds();
|
|
|
|
foreach ($decisionIds as $decisionId) {
|
|
$this->dao->deleteById($decisionId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get a decision type by the DECISION::* constant
|
|
*/
|
|
public function getDecisionType(int $decision): ?DecisionType
|
|
{
|
|
$decision = $this->getDecisionTypes()->first(function (DecisionType $decisionType) use ($decision) {
|
|
return $decisionType->getDecision() === $decision;
|
|
});
|
|
|
|
return $decision ?? null;
|
|
}
|
|
|
|
/**
|
|
* Find the most recent revisions decision that is still active. An active
|
|
* decision is one that is not overriden by any other decision.
|
|
*/
|
|
public function getActivePendingRevisionsDecision(int $submissionId, int $stageId, int $decision = Decision::PENDING_REVISIONS): ?Decision
|
|
{
|
|
$postReviewDecisions = [Decision::SEND_TO_PRODUCTION];
|
|
$revisionDecisions = [Decision::PENDING_REVISIONS, Decision::RESUBMIT];
|
|
if (!in_array($decision, $revisionDecisions)) {
|
|
return null;
|
|
}
|
|
|
|
$revisionsDecisions = $this->getCollector()
|
|
->filterBySubmissionIds([$submissionId])
|
|
->getMany();
|
|
|
|
// Most recent decision first
|
|
$revisionsDecisions = $revisionsDecisions->reverse();
|
|
|
|
$pendingRevisionDecision = null;
|
|
foreach ($revisionsDecisions as $revisionDecision) {
|
|
if (in_array($revisionDecision->getData('decision'), $postReviewDecisions)) {
|
|
// Decisions at later stages do not override the pending revisions one.
|
|
continue;
|
|
} elseif ($revisionDecision->getData('decision') == $decision) {
|
|
if ($revisionDecision->getData('stageId') == $stageId) {
|
|
$pendingRevisionDecision = $revisionDecision;
|
|
// Only the last pending revisions decision is relevant.
|
|
break;
|
|
} else {
|
|
// Both internal and external pending revisions decisions are
|
|
// valid at the same time. Continue to search.
|
|
continue;
|
|
}
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
|
|
return $pendingRevisionDecision;
|
|
}
|
|
|
|
/**
|
|
* Have any submission files been uploaded to the revision file stage since
|
|
* this decision was taken?
|
|
*/
|
|
public function revisionsUploadedSinceDecision(Decision $decision, int $submissionId): bool
|
|
{
|
|
$stageId = $decision->getData('stageId');
|
|
$round = $decision->getData('round');
|
|
$sentRevisions = false;
|
|
|
|
$reviewRoundDao = DAORegistry::getDAO('ReviewRoundDAO'); /** @var ReviewRoundDAO $reviewRoundDao */
|
|
$reviewRound = $reviewRoundDao->getReviewRound($submissionId, $stageId, $round);
|
|
|
|
$submissionFiles = Repo::submissionFile()
|
|
->getCollector()
|
|
->filterByReviewRoundIds([$reviewRound->getId()])
|
|
->filterByFileStages([SubmissionFile::SUBMISSION_FILE_REVIEW_REVISION])
|
|
->getMany();
|
|
|
|
foreach ($submissionFiles as $submissionFile) {
|
|
if ($submissionFile->getData('updatedAt') > $decision->getData('dateDecided')) {
|
|
$sentRevisions = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return $sentRevisions;
|
|
}
|
|
|
|
/**
|
|
* Get a list of all the decision types available
|
|
*
|
|
* @return Collection<int,DecisionType>
|
|
*/
|
|
abstract public function getDecisionTypes(): Collection;
|
|
|
|
/**
|
|
* Get a list of the decline decision types
|
|
*
|
|
* @return DecisionType[]
|
|
*/
|
|
abstract public function getDeclineDecisionTypes(): array;
|
|
|
|
/**
|
|
* Get a list of the decision types that a recommending user is
|
|
* allowed to make given a submission stage id.
|
|
*
|
|
* @return DecisionType[]
|
|
*/
|
|
abstract public function getDecisionTypesMadeByRecommendingUsers(int $stageId): array;
|
|
|
|
/**
|
|
* Is the given decision a recommendation?
|
|
*/
|
|
public function isRecommendation(int $decision): bool
|
|
{
|
|
return in_array($decision, [
|
|
Decision::RECOMMEND_ACCEPT,
|
|
Decision::RECOMMEND_DECLINE,
|
|
Decision::RECOMMEND_PENDING_REVISIONS,
|
|
Decision::RECOMMEND_RESUBMIT,
|
|
]);
|
|
}
|
|
|
|
protected function getRoundByReviewRoundId(int $reviewRoundId): int
|
|
{
|
|
$reviewRoundDao = DAORegistry::getDAO('ReviewRoundDAO'); /** @var ReviewRoundDAO $reviewRoundDao */
|
|
$reviewRound = $reviewRoundDao->getById($reviewRoundId);
|
|
return $reviewRound->getData('round');
|
|
}
|
|
|
|
/**
|
|
* Update notifications controlled by the NotificationManager
|
|
*/
|
|
protected function updateNotifications(Decision $decision, DecisionType $decisionType, Submission $submission)
|
|
{
|
|
$notificationMgr = new NotificationManager();
|
|
|
|
// Update editor decision and pending revisions notifications.
|
|
$notificationTypes = $this->getReviewNotificationTypes();
|
|
if ($editorDecisionNotificationType = $notificationMgr->getNotificationTypeByEditorDecision($decision)) {
|
|
array_unshift($notificationTypes, $editorDecisionNotificationType);
|
|
}
|
|
|
|
$authorIds = [];
|
|
/** @var StageAssignmentDAO $stageAssignmentDao */
|
|
$stageAssignmentDao = DAORegistry::getDAO('StageAssignmentDAO');
|
|
$result = $stageAssignmentDao->getBySubmissionAndRoleIds($submission->getId(), [Role::ROLE_ID_AUTHOR], $decisionType->getStageId());
|
|
/** @var StageAssignment $stageAssignment */
|
|
while ($stageAssignment = $result->next()) {
|
|
$authorIds[] = (int) $stageAssignment->getUserId();
|
|
}
|
|
|
|
$notificationMgr->updateNotification(
|
|
Application::get()->getRequest(),
|
|
$notificationTypes,
|
|
$authorIds,
|
|
Application::ASSOC_TYPE_SUBMISSION,
|
|
$submission->getId()
|
|
);
|
|
|
|
// Update submission notifications
|
|
$submissionNotificationTypes = $this->getSubmissionNotificationTypes($decision);
|
|
if (count($submissionNotificationTypes)) {
|
|
$notificationMgr->updateNotification(
|
|
Application::get()->getRequest(),
|
|
$submissionNotificationTypes,
|
|
null,
|
|
Application::ASSOC_TYPE_SUBMISSION,
|
|
$submission->getId()
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the notification types related to a review stage
|
|
*
|
|
* @return int[] One or more of the Notification::NOTIFICATION_TYPE_ constants
|
|
*/
|
|
abstract protected function getReviewNotificationTypes(): array;
|
|
|
|
/**
|
|
* Get additional notifications to be updated on a submission
|
|
*
|
|
* @return int[] One or more of the Notification::NOTIFICATION_TYPE_ constants
|
|
*/
|
|
protected function getSubmissionNotificationTypes(Decision $decision): array
|
|
{
|
|
switch ($decision->getData('decision')) {
|
|
case Decision::ACCEPT:
|
|
return [
|
|
Notification::NOTIFICATION_TYPE_ASSIGN_COPYEDITOR,
|
|
Notification::NOTIFICATION_TYPE_AWAITING_COPYEDITS
|
|
];
|
|
case Decision::SEND_TO_PRODUCTION:
|
|
return [
|
|
Notification::NOTIFICATION_TYPE_ASSIGN_COPYEDITOR,
|
|
Notification::NOTIFICATION_TYPE_AWAITING_COPYEDITS,
|
|
Notification::NOTIFICATION_TYPE_ASSIGN_PRODUCTIONUSER,
|
|
Notification::NOTIFICATION_TYPE_AWAITING_REPRESENTATIONS,
|
|
];
|
|
}
|
|
return [];
|
|
}
|
|
}
|