<?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 [];
}
}
|