diff --git a/api/v1/_library/PKPLibraryHandler.inc.php b/api/v1/_library/PKPLibraryHandler.inc.php new file mode 100644 index 00000000000..475237b8a69 --- /dev/null +++ b/api/v1/_library/PKPLibraryHandler.inc.php @@ -0,0 +1,145 @@ +_handlerPath = '_library'; + $this->_endpoints = [ + 'GET' => [ + [ + 'pattern' => $this->getEndpointPattern(), + 'handler' => [$this, 'getLibrary'], + 'roles' => [Role::ROLE_ID_SITE_ADMIN, Role::ROLE_ID_MANAGER, Role::ROLE_ID_SUB_EDITOR], + ], + ], + ]; + parent::__construct(); + + } + + /** + * @copydoc PKPHandler::authorize + */ + public function authorize($request, &$args, $roleAssignments) + { + $rolePolicy = new PolicySet(PolicySet::COMBINING_PERMIT_OVERRIDES); + + foreach ($roleAssignments as $role => $operations) { + $rolePolicy->addPolicy(new RoleBasedHandlerOperationPolicy($request, $role, $operations)); + } + $this->addPolicy($rolePolicy); + + if ($request->getUserVar('includeSubmissionId')) { + $this->addPolicy(new SubmissionAccessPolicy($request, $args, $roleAssignments, 'includeSubmissionId')); + } + + return parent::authorize($request, $args, $roleAssignments); + } + + /** + * Get a list of all files in the library + * + * @param array $args arguments + * + * @return APIResponse + */ + public function getLibrary(ServerRequestInterface $slimRequest, APIResponse $response, array $args) + { + /** @var LibraryFileDAO $libraryFileDao */ + $libraryFileDao = DAORegistry::getDAO('LibraryFileDAO'); + $submission = $this->getAuthorizedContextObject(Application::ASSOC_TYPE_SUBMISSION); + $context = $this->getRequest()->getContext(); + $contextId = $context->getId(); + $libraryFileManager = new LibraryFileManager($contextId); + + $files = []; + + $params = $slimRequest->getQueryParams(); + if (isset($params['includeSubmissionId'])) { + /** @var DAOResultFactory $result */ + $result = $libraryFileDao->getBySubmissionId($submission->getId()); + /** @var LibraryFile $file */ + while ($file = $result->next()) { + $files[] = $this->fileToResponse($file, $libraryFileManager); + } + } + + /** @var DAOResultFactory $result */ + $result = $libraryFileDao->getByContextId($contextId); + /** @var LibraryFile $file */ + while ($file = $result->next()) { + $files[] = $this->fileToResponse($file, $libraryFileManager); + } + + return $response->withJson([ + 'items' => $files, + 'itemsMax' => count($files), + ], 200); + } + + /** + * Convert a file object to the JSON response object + */ + protected function fileToResponse(LibraryFile $file, LibraryFileManager $libraryFileManager): array + { + $request = Application::get()->getRequest(); + + $urlArgs = [ + 'libraryFileId' => $file->getId(), + ]; + if ($file->getSubmissionId()) { + $urlArgs['submissionId'] = $file->getSubmissionId(); + } + + return [ + 'id' => $file->getId(), + 'filename' => $file->getServerFileName(), + 'name' => $file->getName(null), + 'mimetype' => $file->getFileType(), + 'documentType' => Services::get('file')->getDocumentType($file->getFileType()), + 'submissionId' => $file->getSubmissionId() ?? 0, + 'type' => $file->getType(), + 'typeName' => __($libraryFileManager->getTitleKeyFromType($file->getType())), + 'url' => $request->getDispatcher()->url( + $request, + Application::ROUTE_COMPONENT, + null, + 'api.file.FileApiHandler', + 'downloadLibraryFile', + null, + $urlArgs + ), + ]; + } +} diff --git a/api/v1/_submissions/PKPBackendSubmissionsHandler.inc.php b/api/v1/_submissions/PKPBackendSubmissionsHandler.inc.php index 5dfd74f10a5..c6af0aeb267 100644 --- a/api/v1/_submissions/PKPBackendSubmissionsHandler.inc.php +++ b/api/v1/_submissions/PKPBackendSubmissionsHandler.inc.php @@ -22,8 +22,9 @@ use PKP\plugins\HookRegistry; use PKP\security\authorization\ContextAccessPolicy; - +use PKP\security\authorization\SubmissionAccessPolicy; use PKP\security\Role; +use Slim\Http\Response; abstract class PKPBackendSubmissionsHandler extends APIHandler { @@ -50,6 +51,15 @@ public function __construct() Role::ROLE_ID_ASSISTANT, ], ], + [ + 'pattern' => "{$rootPattern}/{submissionId:\d+}/reviewRound", + 'handler' => [$this, 'getReviewRound'], + 'roles' => [ + Role::ROLE_ID_SITE_ADMIN, + Role::ROLE_ID_MANAGER, + Role::ROLE_ID_SUB_EDITOR, + ], + ], ], 'DELETE' => [ [ @@ -72,6 +82,12 @@ public function __construct() public function authorize($request, &$args, $roleAssignments) { $this->addPolicy(new ContextAccessPolicy($request, $roleAssignments)); + + $routeName = $this->getSlimRequest()->getAttribute('route')->getName(); + if (in_array($routeName, ['delete', 'getReviewRound'])) { + $this->addPolicy(new SubmissionAccessPolicy($request, $args, $roleAssignments)); + } + return parent::authorize($request, $args, $roleAssignments); } @@ -232,4 +248,41 @@ public function delete($slimRequest, $response, $args) return $response->withJson(true); } + + /** + * Get the current review round for this submission + * + * @param $slimRequest Request Slim request object + * @param $response Response object + * @param array $args arguments + */ + public function getReviewRound($slimRequest, $response, $args): Response + { + $request = $this->getRequest(); + $context = $request->getContext(); + $submission = $this->getAuthorizedContextObject(Application::ASSOC_TYPE_SUBMISSION); + + if (!$submission) { + return $response->withStatus(404)->withJsonError('api.404.resourceNotFound'); + } + + if ($context->getId() != $submission->getContextId()) { + return $response->withStatus(403)->withJsonError('api.submissions.400.wrongContext'); + } + + /** @var ReviewRoundDAO */ + $reviewRoundDao = DAORegistry::getDAO('ReviewRoundDAO'); + $reviewRound = $reviewRoundDao->getLastReviewRoundBySubmissionId($submission->getId()); + if (!$reviewRound) { + return $response->withStatus(404)->withJsonError('api.404.resourceNotFound'); + } + + return $response->withJson([ + 'id' => $reviewRound->getId(), + 'submissionId' => $reviewRound->getSubmissionId(), + 'stageId' => $reviewRound->getStageId(), + 'round' => $reviewRound->getRound(), + 'status' => $reviewRound->getStatus(), + ], 200); + } } diff --git a/api/v1/emailTemplates/PKPEmailTemplateHandler.inc.php b/api/v1/emailTemplates/PKPEmailTemplateHandler.inc.php index 71543f92bcf..e932f19ff65 100644 --- a/api/v1/emailTemplates/PKPEmailTemplateHandler.inc.php +++ b/api/v1/emailTemplates/PKPEmailTemplateHandler.inc.php @@ -1,6 +1,6 @@ $this->getEndpointPattern(), 'handler' => [$this, 'getMany'], - 'roles' => $roles, + 'roles' => array_merge($roles, [Role::ROLE_ID_SUB_EDITOR, ROLE::ROLE_ID_ASSISTANT]), ], [ 'pattern' => $this->getEndpointPattern() . '/{key}', 'handler' => [$this, 'get'], - 'roles' => $roles, + 'roles' => array_merge($roles, [Role::ROLE_ID_SUB_EDITOR, ROLE::ROLE_ID_ASSISTANT]), ], ], 'POST' => [ diff --git a/api/v1/mailables/PKPMailableHandler.inc.php b/api/v1/mailables/PKPMailableHandler.inc.php new file mode 100644 index 00000000000..8a40439066c --- /dev/null +++ b/api/v1/mailables/PKPMailableHandler.inc.php @@ -0,0 +1,77 @@ +_handlerPath = 'mailables'; + $roles = [Role::ROLE_ID_SITE_ADMIN, Role::ROLE_ID_MANAGER, Role::ROLE_ID_SUB_EDITOR, Role::ROLE_ID_ASSISTANT]; + $this->_endpoints = [ + 'GET' => [ + [ + 'pattern' => $this->getEndpointPattern() . '/{class}', + 'handler' => [$this, 'get'], + 'roles' => $roles, + ], + ], + ]; + parent::__construct(); + } + + /** + * @copydoc PKPHandler::authorize + */ + public function authorize($request, &$args, $roleAssignments) + { + $rolePolicy = new PolicySet(PolicySet::COMBINING_PERMIT_OVERRIDES); + + // This endpoint is not available at the site-wide level + $this->addPolicy(new ContextRequiredPolicy($request)); + + foreach ($roleAssignments as $role => $operations) { + $rolePolicy->addPolicy(new RoleBasedHandlerOperationPolicy($request, $role, $operations)); + } + $this->addPolicy($rolePolicy); + + return parent::authorize($request, $args, $roleAssignments); + } + + /** + * Get a single mailable + * + * @param $slimRequest Request Slim request object + * @param $response Response object + * @param array $args arguments + * + * @return Response + */ + public function get($slimRequest, $response, $args) + { + // TODO: Obviously this is not safe and shouldn't be used!!!! + $mailable = App::make(str_replace('-', '\\', $args['class'])); + + return $response->withJson($mailable->getEmailTemplates(), 200); + } +} diff --git a/api/v1/submissions/PKPSubmissionFileHandler.inc.php b/api/v1/submissions/PKPSubmissionFileHandler.inc.php index 35a2317579f..8d9cf1c3ae1 100644 --- a/api/v1/submissions/PKPSubmissionFileHandler.inc.php +++ b/api/v1/submissions/PKPSubmissionFileHandler.inc.php @@ -15,6 +15,9 @@ */ use APP\facades\Repo; +use APP\core\Application; +use APP\core\Services; +use PKP\db\DAORegistry; use PKP\file\FileManager; use PKP\handler\APIHandler; use PKP\security\authorization\ContextAccessPolicy; @@ -24,6 +27,7 @@ use PKP\security\Role; use PKP\services\PKPSchemaService; use PKP\submissionFile\SubmissionFile; +use PKP\submission\reviewRound\ReviewRoundDAO; class PKPSubmissionFileHandler extends APIHandler { @@ -59,6 +63,11 @@ public function __construct() 'handler' => [$this, 'edit'], 'roles' => [Role::ROLE_ID_MANAGER, Role::ROLE_ID_SUB_EDITOR, Role::ROLE_ID_ASSISTANT, Role::ROLE_ID_AUTHOR], ], + [ + 'pattern' => $this->getEndpointPattern() . '/{submissionFileId:\d+}/copy', + 'handler' => [$this, 'copy'], + 'roles' => [Role::ROLE_ID_MANAGER, Role::ROLE_ID_SUB_EDITOR], + ], ], 'DELETE' => [ [ @@ -438,6 +447,78 @@ public function edit($slimRequest, $response, $args) return $response->withJson($data, 200); } + /** + * Copy a submission file to another file stage + * + * @param \Slim\Http\Request $slimRequest + * @param APIResponse $response + * @param array $args arguments + * + * @return Response + */ + public function copy($slimRequest, $response, $args) + { + $request = $this->getRequest(); + $submission = $this->getAuthorizedContextObject(ASSOC_TYPE_SUBMISSION); + $submissionFile = $this->getAuthorizedContextObject(ASSOC_TYPE_SUBMISSION_FILE); + + $params = $slimRequest->getParsedBody(); + if (empty($params['toFileStage'])) { + return $response->withStatus(400)->withJsonError('api.submissionFiles.400.noFileStageId'); + } + + $toFileStage = (int) $params['toFileStage']; + + if (!in_array($toFileStage, Services::get('submissionFile')->getFileStages())) { + return $response->withStatus(400)->withJsonError('api.submissionFiles.400.invalidFileStage'); + } + + // Expect a review round id when copying to a review stage, or use the latest + // round in that stage by default + $reviewRoundId = null; + if (in_array($toFileStage, Services::get('submissionFile')->reviewFileStages)) { + if (!empty($params['reviewRoundId'])) { + $reviewRoundId = (int) $params['reviewRoundId']; + /** @var ReviewRoundDAO $reviewRoundDao */ + $reviewRoundDao = DAORegistry::getDAO('ReviewRoundDAO'); + $reviewRound = $reviewRoundDao->getById($reviewRoundId); + if (!$reviewRound || $reviewRound->getSubmissionId() != $submission->getId()) { + return $response->withStatus(400)->withJsonError('api.submissionFiles.400.reviewRoundSubmissionNotMatch'); + } + } else { + // Use the latest review round of the appropriate stage + $stageId = in_array($toFileStage, SubmissionFile::INTERNAL_REVIEW_STAGES) + ? WORKFLOW_STAGE_ID_INTERNAL_REVIEW + : WORKFLOW_STAGE_ID_EXTERNAL_REVIEW; + /** @var ReviewRoundDAO $reviewRoundDao */ + $reviewRoundDao = DAORegistry::getDAO('ReviewRoundDAO'); + $reviewRound = $reviewRoundDao->getLastReviewRoundBySubmissionId($submission->getId(), $stageId); + if ($reviewRound) { + $reviewRoundId = $reviewRound->getId(); + } + } + if ($reviewRoundId === null) { + return $response->withStatus(400)->withJsonError('api.submissionFiles.400.reviewRoundIdRequired'); + } + } + + $newSubmissionFileId = Services::get('submissionFile')->copy( + $submissionFile, + $toFileStage, + $reviewRoundId + ); + + $newSubmissionFile = Services::get('submissionFile')->get($newSubmissionFileId); + + $data = Services::get('submissionFile')->getFullProperties($newSubmissionFile, [ + 'request' => $request, + 'slimRequest' => $slimRequest, + 'submission' => $submission, + ]); + + return $response->withJson($data, 200); + } + /** * Delete a submission file * diff --git a/api/v1/submissions/PKPSubmissionHandler.inc.php b/api/v1/submissions/PKPSubmissionHandler.inc.php index b888c0165f2..42104dcfe21 100644 --- a/api/v1/submissions/PKPSubmissionHandler.inc.php +++ b/api/v1/submissions/PKPSubmissionHandler.inc.php @@ -15,19 +15,23 @@ */ use APP\core\Application; +use APP\core\Request; use APP\core\Services; use APP\facades\Repo; use APP\i18n\AppLocale; use APP\notification\Notification; use APP\notification\NotificationManager; use APP\submission\Collector; +use APP\submission\Submission; +use Illuminate\Support\Collection; +use PKP\core\Core; use PKP\db\DAORegistry; - +use PKP\decision\Type; use PKP\handler\APIHandler; -use PKP\mail\mailables\MailDiscussionMessage; use PKP\notification\PKPNotification; use PKP\plugins\HookRegistry; use PKP\security\authorization\ContextAccessPolicy; +use PKP\security\authorization\DecisionWritePolicy; use PKP\security\authorization\PublicationWritePolicy; use PKP\security\authorization\StageRolePolicy; use PKP\security\authorization\SubmissionAccessPolicy; @@ -60,6 +64,7 @@ class PKPSubmissionHandler extends APIHandler 'publishPublication', 'unpublishPublication', 'deletePublication', + 'addDecision', ]; /** @var array Handlers that must be authorized to write to a publication */ @@ -138,6 +143,11 @@ public function __construct() 'handler' => [$this, 'versionPublication'], 'roles' => [Role::ROLE_ID_MANAGER, Role::ROLE_ID_SUB_EDITOR, Role::ROLE_ID_ASSISTANT], ], + [ + 'pattern' => $this->getEndpointPattern() . '/{submissionId:\d+}/decisions', + 'handler' => [$this, 'addDecision'], + 'roles' => [Role::ROLE_ID_MANAGER, Role::ROLE_ID_SUB_EDITOR], + ], ], 'PUT' => [ [ @@ -198,6 +208,10 @@ public function authorize($request, &$args, $roleAssignments) $this->addPolicy(new StageRolePolicy($this->productionStageAccessRoles, WORKFLOW_STAGE_ID_PRODUCTION, false)); } + if ($routeName === 'addDecision') { + $this->addPolicy(new DecisionWritePolicy($request, $args, (int) $request->getUserVar('decision'), $request->getUser())); + } + return parent::authorize($request, $args, $roleAssignments); } @@ -216,10 +230,6 @@ public function getMany($slimRequest, $response, $args) $currentUser = $request->getUser(); $context = $request->getContext(); - if (!$context) { - return $response->withStatus(404)->withJsonError('api.404.resourceNotFound'); - } - $collector = $this->getSubmissionCollector($slimRequest->getQueryParams()); HookRegistry::call('API::submissions::params', [$collector, $slimRequest]); @@ -366,11 +376,6 @@ public function add($slimRequest, $response, $args) $request = $this->getRequest(); - // Don't allow submissions to be added via the site-wide API - if (!$request->getContext()) { - return $response->withStatus(400)->withJsonError('api.submissions.403.contextRequired'); - } - if ($request->getContext()->getData('disableSubmissions')) { return $response->withStatus(403)->withJsonError('author.submit.notAccepting'); } @@ -420,11 +425,6 @@ public function edit($slimRequest, $response, $args) return $response->withStatus(404)->withJsonError('api.404.resourceNotFound'); } - // Don't allow submissions to be added via the site-wide API - if (!$request->getContext()) { - return $response->withStatus(403)->withJsonError('api.submissions.403.contextRequired'); - } - $params = $this->convertStringsToSchema(PKPSchemaService::SCHEMA_SUBMISSION, $slimRequest->getParsedBody()); $params['id'] = $submission->getId(); $params['contextId'] = $request->getContext()->getId(); @@ -498,7 +498,7 @@ public function getParticipants($slimRequest, $response, $args) $submission = $this->getAuthorizedContextObject(Application::ASSOC_TYPE_SUBMISSION); $stageId = $args['stageId'] ?? null; - if (!$submission) { + if (!$submission || $submission->getData('contextId') !== $context->getId()) { return $response->withStatus(404)->withJsonError('api.404.resourceNotFound'); } @@ -902,4 +902,45 @@ public function deletePublication($slimRequest, $response, $args) return $response->withJson($output, 200); } + + /** + * Record an editorial decision for a submission, such as + * a decision to accept or reject the submission, request + * revisions, or send it to another stage. + * + * @param $slimRequest Request Slim request object + * @param $response Response object + * @param array $args arguments + * + * @return Response + */ + public function addDecision($slimRequest, $response, $args) + { + AppLocale::requireComponents([LOCALE_COMPONENT_APP_EDITOR, LOCALE_COMPONENT_PKP_EDITOR]); + $request = $this->getRequest(); /** @var Request $request */ + $submission = $this->getAuthorizedContextObject(Application::ASSOC_TYPE_SUBMISSION); /** @var Submission $submission */ + $type = $this->getAuthorizedContextObject(Application::ASSOC_TYPE_DECISION_TYPE); /** @var Type $type */ + + if ($submission->getData('status') === Submission::STATUS_PUBLISHED) { + return $response->withStatus(403)->withJsonError('api.decisions.403.alreadyPublished'); + } + + $params = $this->convertStringsToSchema(PKPSchemaService::SCHEMA_DECISION, $slimRequest->getParsedBody()); + $params['submissionId'] = $submission->getId(); + $params['dateDecided'] = Core::getCurrentDate(); + $params['editorId'] = $request->getUser()->getId(); + $params['stageId'] = $type->getStageId(); + + $errors = Repo::decision()->validate($params, $type, $submission, $request->getContext()); + + if (!empty($errors)) { + return $response->withStatus(400)->withJson($errors); + } + + $decision = Repo::decision()->newDataObject($params); + $decisionId = Repo::decision()->add($decision); + $decision = Repo::decision()->get($decisionId); + + return $response->withJson(Repo::decision()->getSchemaMap()->map($decision), 200); + } } diff --git a/api/v1/temporaryFiles/PKPTemporaryFilesHandler.inc.php b/api/v1/temporaryFiles/PKPTemporaryFilesHandler.inc.php index 5a797734fa8..ae0a243fa2c 100644 --- a/api/v1/temporaryFiles/PKPTemporaryFilesHandler.inc.php +++ b/api/v1/temporaryFiles/PKPTemporaryFilesHandler.inc.php @@ -12,6 +12,7 @@ * @brief Handle API requests to upload a file and receive a temporary file ID. */ +use APP\core\Services; use PKP\file\TemporaryFileManager; use PKP\handler\APIHandler; use PKP\security\authorization\PolicySet; @@ -115,7 +116,12 @@ public function uploadFile($slimRequest, $response, $args) return $response->withStatus(400)->withJsonError('api.files.400.uploadFailed'); } - return $this->getResponse($response->withJson(['id' => $uploadedFile->getId()])); + return $this->getResponse($response->withJson([ + 'id' => $uploadedFile->getId(), + 'name' => $uploadedFile->getData('originalFileName'), + 'mimetype' => $uploadedFile->getData('filetype'), + 'documentType' => Services::get('file')->getDocumentType($uploadedFile->getData('filetype')), + ])); } /** diff --git a/classes/announcement/Repository.inc.php b/classes/announcement/Repository.inc.php index b5d29478640..f1668c08de2 100644 --- a/classes/announcement/Repository.inc.php +++ b/classes/announcement/Repository.inc.php @@ -136,7 +136,7 @@ public function validate(?Announcement $object, array $props, array $allowedLoca $errors = []; if ($validator->fails()) { - $errors = $this->schemaService->formatValidationErrors($validator->errors(), $this->schemaService->get($this->dao->schema), $allowedLocales); + $errors = $this->schemaService->formatValidationErrors($validator->errors()); } HookRegistry::call('Announcement::validate', [&$errors, $object, $props, $allowedLocales, $primaryLocale]); diff --git a/classes/author/Repository.inc.php b/classes/author/Repository.inc.php index 3eba3bf1176..124941b0f98 100644 --- a/classes/author/Repository.inc.php +++ b/classes/author/Repository.inc.php @@ -145,7 +145,7 @@ public function validate($author, $props, $allowedLocales, $primaryLocale) }); if ($validator->fails()) { - $errors = $schemaService->formatValidationErrors($validator->errors(), $schemaService->get(PKPSchemaService::SCHEMA_AUTHOR), $allowedLocales); + $errors = $schemaService->formatValidationErrors($validator->errors()); } HookRegistry::call('Author::validate', [$errors, $author, $props, $allowedLocales, $primaryLocale]); diff --git a/classes/components/fileAttachers/BaseAttacher.inc.php b/classes/components/fileAttachers/BaseAttacher.inc.php new file mode 100644 index 00000000000..9c76941375d --- /dev/null +++ b/classes/components/fileAttachers/BaseAttacher.inc.php @@ -0,0 +1,50 @@ +label = $label; + $this->description = $description; + $this->button = $button; + } + + /** + * Compile the initial state for this file attacher + */ + public function getState(): array + { + return [ + 'component' => $this->component, + 'label' => $this->label, + 'description' => $this->description, + 'button' => $this->button, + ]; + } +} diff --git a/classes/components/fileAttachers/FileStage.inc.php b/classes/components/fileAttachers/FileStage.inc.php new file mode 100644 index 00000000000..dcfa7bb5bfe --- /dev/null +++ b/classes/components/fileAttachers/FileStage.inc.php @@ -0,0 +1,78 @@ +context = $context; + $this->submission = $submission; + } + + /** + * Add a submission file stage that can be used for attachments + */ + public function withFileStage(int $fileStage, string $label, ?ReviewRound $reviewRound = null): self + { + $queryParams = ['fileStages' => [$fileStage]]; + if ($reviewRound) { + $queryParams['reviewRoundIds'] = [$reviewRound->getId()]; + } + $this->fileStages[] = [ + 'label' => $label, + 'queryParams' => $queryParams, + ]; + return $this; + } + + /** + * Compile the props for this file attacher + */ + public function getState(): array + { + $props = parent::getState(); + + $request = Application::get()->getRequest(); + $props['submissionFilesApiUrl'] = $request->getDispatcher()->url( + $request, + Application::ROUTE_API, + $this->context->getData('urlPath'), + 'submissions/' . $this->submission->getId() . '/files' + ); + + $props['fileStages'] = $this->fileStages; + + return $props; + } +} diff --git a/classes/components/fileAttachers/Library.inc.php b/classes/components/fileAttachers/Library.inc.php new file mode 100644 index 00000000000..1684c3eca5f --- /dev/null +++ b/classes/components/fileAttachers/Library.inc.php @@ -0,0 +1,63 @@ +context = $context; + $this->submission = $submission; + } + + /** + * Compile the props for this file attacher + */ + public function getState(): array + { + $props = parent::getState(); + + $request = Application::get()->getRequest(); + $props['downloadLabel'] = __('common.download'); + $props['libraryApiUrl'] = $request->getDispatcher()->url( + $request, + Application::ROUTE_API, + $this->context->getData('urlPath'), + '_library' + ); + if ($this->submission) { + $props['includeSubmissionId'] = $this->submission->getId(); + } + + return $props; + } +} diff --git a/classes/components/fileAttachers/ReviewFiles.inc.php b/classes/components/fileAttachers/ReviewFiles.inc.php new file mode 100644 index 00000000000..5c8bb0a25d6 --- /dev/null +++ b/classes/components/fileAttachers/ReviewFiles.inc.php @@ -0,0 +1,93 @@ + $files */ + public array $files; + + /** @var array $reviewAssignments */ + public array $reviewAssignments; + + /** + * Initialize this file attacher + * + * @param string $label The label to display for this file attacher + * @param string $description A description of this file attacher + * @param string $button The label for the button to activate this file attacher + */ + public function __construct(string $label, string $description, string $button, array $files, array $reviewAssignments, Context $context) + { + parent::__construct($label, $description, $button); + $this->files = $files; + $this->reviewAssignments = $reviewAssignments; + $this->context = $context; + } + + /** + * Compile the props for this file attacher + */ + public function getState(): array + { + $props = parent::getState(); + $props['downloadLabel'] = __('common.download'); + $props['files'] = $this->getFilesState(); + + return $props; + } + + protected function getFilesState(): array + { + $request = Application::get()->getRequest(); + + $files = []; + foreach ($this->files as $file) { + if (!isset($this->reviewAssignments[$file->getData('assocId')])) { + throw new Exception('Tried to add review file attachment from unknown review assignment.'); + } + $files[] = [ + 'id' => $file->getId(), + 'name' => $file->getData('name'), + 'documentType' => Services::get('file')->getDocumentType($file->getData('documentType')), + 'reviewerName' => $this->reviewAssignments[$file->getData('assocId')]->getReviewerFullName(), + 'url' => $request->getDispatcher()->url( + $request, + Application::ROUTE_COMPONENT, + $this->context->getData('urlPath'), + 'api.file.FileApiHandler', + 'downloadFile', + null, + [ + 'submissionFileId' => $file->getId(), + 'submissionId' => $file->getData('submissionId'), + 'stageId' => Services::get('submissionFile')->getWorkflowStageId($file), + ] + ), + ]; + } + + return $files; + } +} diff --git a/classes/components/fileAttachers/Upload.inc.php b/classes/components/fileAttachers/Upload.inc.php new file mode 100644 index 00000000000..d3f16310724 --- /dev/null +++ b/classes/components/fileAttachers/Upload.inc.php @@ -0,0 +1,70 @@ +context = $context; + } + + /** + * Compile the props for this file attacher + */ + public function getState(): array + { + $props = parent::getState(); + + $request = Application::get()->getRequest(); + $props['temporaryFilesApiUrl'] = $request->getDispatcher()->url( + $request, + Application::ROUTE_API, + $this->context->getData('urlPath'), + 'temporaryFiles' + ); + $props['dropzoneOptions'] = [ + 'maxFilesize' => Application::getIntMaxFileMBs(), + 'timeout' => ini_get('max_execution_time') ? ini_get('max_execution_time') * 1000 : 0, + 'dropzoneDictDefaultMessage' => __('form.dropzone.dictDefaultMessage'), + 'dropzoneDictFallbackMessage' => __('form.dropzone.dictFallbackMessage'), + 'dropzoneDictFallbackText' => __('form.dropzone.dictFallbackText'), + 'dropzoneDictFileTooBig' => __('form.dropzone.dictFileTooBig'), + 'dropzoneDictInvalidFileType' => __('form.dropzone.dictInvalidFileType'), + 'dropzoneDictResponseError' => __('form.dropzone.dictResponseError'), + 'dropzoneDictCancelUpload' => __('form.dropzone.dictCancelUpload'), + 'dropzoneDictUploadCanceled' => __('form.dropzone.dictUploadCanceled'), + 'dropzoneDictCancelUploadConfirmation' => __('form.dropzone.dictCancelUploadConfirmation'), + 'dropzoneDictRemoveFile' => __('form.dropzone.dictRemoveFile'), + 'dropzoneDictMaxFilesExceeded' => __('form.dropzone.dictMaxFilesExceeded'), + ]; + + return $props; + } +} diff --git a/classes/components/forms/FieldRichTextarea.inc.php b/classes/components/forms/FieldRichTextarea.inc.php index 92876a30420..ac0a38d52ef 100644 --- a/classes/components/forms/FieldRichTextarea.inc.php +++ b/classes/components/forms/FieldRichTextarea.inc.php @@ -14,8 +14,6 @@ namespace PKP\components\forms; -use APP\core\Application; - class FieldRichTextarea extends Field { /** @copydoc Field::$component */ @@ -30,9 +28,6 @@ class FieldRichTextarea extends Field /** @var array Optional. A key/value list of content that can be inserted from a TinyMCE button. */ public $preparedContent; - /** @var boolean Whether the $preparedContent properties should be replaced in the field's initial value. */ - public $renderPreparedContent = false; - /** @var string Optional. A preset size option. */ public $size; @@ -67,7 +62,6 @@ public function getConfig() $config['preparedContent'] = $this->preparedContent; } $config['insertPreparedContentLabel'] = __('common.insert'); - $config['renderPreparedContent'] = $this->renderPreparedContent; if (!empty($this->size)) { $config['size'] = $this->size; } @@ -80,9 +74,6 @@ public function getConfig() $config['wordCountLabel'] = __('publication.wordCount'); } - // Load TinyMCE skin - $config['skinUrl'] = Application::get()->getRequest()->getBaseUrl() . '/lib/ui-library/public/styles/tinymce'; - return $config; } } diff --git a/classes/components/forms/FormComponent.inc.php b/classes/components/forms/FormComponent.inc.php index 143f056ded1..c0c01ad8f1e 100644 --- a/classes/components/forms/FormComponent.inc.php +++ b/classes/components/forms/FormComponent.inc.php @@ -16,12 +16,20 @@ namespace PKP\components\forms; use Exception; +use PKP\plugins\HookRegistry; define('FIELD_POSITION_BEFORE', 'before'); define('FIELD_POSITION_AFTER', 'after'); class FormComponent { + /** + * @var string An $action value that will emit an event + * when the form is submitted, instead of sending a + * HTTP request + */ + public const ACTION_EMIT = 'emit'; + /** @var string A unique ID for this form */ public $id = ''; @@ -40,6 +48,9 @@ class FormComponent /** @var array List of groups in this form. */ public $groups = []; + /** @var array List of hiddden fields in this form. */ + public $hiddenFields = []; + /** @var array List of pages in this form. */ public $pages = []; @@ -245,6 +256,14 @@ public function addToPosition($id, $list, $item, $position) ); } + /** + * Add a hidden field to this form + */ + public function addHiddenField(string $name, $value) + { + $this->hiddenFields[$name] = $value; + } + /** * Retrieve the configuration data to be used when initializing this * handler on the frontend @@ -253,11 +272,11 @@ public function addToPosition($id, $list, $item, $position) */ public function getConfig() { - if (empty($this->id) || empty($this->method) || empty($this->action)) { + if (empty($this->id) || empty($this->action) || ($this->action !== self::ACTION_EMIT && empty($this->method))) { throw new Exception('FormComponent::getConfig() was called but one or more required property is missing: id, method, action.'); } - \HookRegistry::call('Form::config::before', $this); + HookRegistry::call('Form::config::before', $this); // Add a default page/group if none exist if (!$this->groups) { @@ -289,6 +308,7 @@ public function getConfig() 'action' => $this->action, 'fields' => $fieldsConfig, 'groups' => $this->groups, + 'hiddenFields' => (object) $this->hiddenFields, 'pages' => $this->pages, 'primaryLocale' => \AppLocale::getPrimaryLocale(), 'visibleLocales' => $visibleLocales, diff --git a/classes/components/forms/context/PKPEmailSetupForm.inc.php b/classes/components/forms/context/PKPEmailSetupForm.inc.php index 52f856baed5..afe8ed584e0 100644 --- a/classes/components/forms/context/PKPEmailSetupForm.inc.php +++ b/classes/components/forms/context/PKPEmailSetupForm.inc.php @@ -46,12 +46,12 @@ public function __construct($action, $locales, $context) 'tooltip' => __('manager.setup.emailSignature.description'), 'value' => $context->getData('emailSignature'), 'preparedContent' => [ - 'contextName' => $context->getLocalizedName(), - 'senderName' => __('email.senderName'), - 'senderEmail' => __('email.senderEmail'), - 'mailingAddress' => htmlspecialchars(nl2br($context->getData('mailingAddress'))), - 'contactEmail' => htmlspecialchars($context->getData('contactEmail')), - 'contactName' => htmlspecialchars($context->getData('contactName')), + 'contextName' => '{$contextName}', + 'senderName' => '{$senderName}', + 'senderEmail' => '{$senderEmail}', + 'mailingAddress' => '{$mailingAddress}', + 'contactEmail' => '{$contactEmail}', + 'contactName' => '{$contactName}', ] ])); diff --git a/classes/components/forms/decision/RequestPaymentDecisionForm.inc.php b/classes/components/forms/decision/RequestPaymentDecisionForm.inc.php new file mode 100644 index 00000000000..498a8c69005 --- /dev/null +++ b/classes/components/forms/decision/RequestPaymentDecisionForm.inc.php @@ -0,0 +1,56 @@ +addField(new FieldOptions('requestPayment', [ + 'label' => __('common.payment'), + 'type' => 'radio', + 'options' => [ + [ + 'value' => true, + 'label' => __( + 'payment.requestPublicationFee', + ['feeAmount' => $context->getData('publicationFee') . ' ' . $context->getData('currency')] + ), + ], + [ + 'value' => false, + 'label' => __('payment.waive'), + ], + ], + 'value' => true, + 'groupId' => 'default', + ])); + } +} diff --git a/classes/components/forms/decision/SelectRevisionDecisionForm.inc.php b/classes/components/forms/decision/SelectRevisionDecisionForm.inc.php new file mode 100644 index 00000000000..37cd9670c77 --- /dev/null +++ b/classes/components/forms/decision/SelectRevisionDecisionForm.inc.php @@ -0,0 +1,61 @@ +addField(new FieldOptions('decision', [ + 'label' => __('editor.review.newReviewRound'), + 'type' => 'radio', + 'options' => [ + [ + 'value' => EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_PENDING_REVISIONS, + 'label' => __('editor.review.NotifyAuthorRevisions'), + ], + [ + 'value' => EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_RESUBMIT, + 'label' => __('editor.review.NotifyAuthorResubmit'), + ], + ], + 'value' => EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_PENDING_REVISIONS, + 'groupId' => 'default', + ])) + ->addGroup([ + 'id' => 'default', + 'pageId' => 'default', + ]) + ->addPage([ + 'id' => 'default', + 'submitButton' => ['label' => __('help.next')] + ]); + } +} diff --git a/classes/components/forms/decision/SelectRevisionRecommendationForm.inc.php b/classes/components/forms/decision/SelectRevisionRecommendationForm.inc.php new file mode 100644 index 00000000000..51401b21f65 --- /dev/null +++ b/classes/components/forms/decision/SelectRevisionRecommendationForm.inc.php @@ -0,0 +1,61 @@ +addField(new FieldOptions('decision', [ + 'label' => __('editor.review.newReviewRound'), + 'type' => 'radio', + 'options' => [ + [ + 'value' => EditorDecisionActionsManager::SUBMISSION_EDITOR_RECOMMEND_PENDING_REVISIONS, + 'label' => __('editor.review.NotifyAuthorRevisions.recommendation'), + ], + [ + 'value' => EditorDecisionActionsManager::SUBMISSION_EDITOR_RECOMMEND_RESUBMIT, + 'label' => __('editor.review.NotifyAuthorResubmit.recommendation'), + ], + ], + 'value' => EditorDecisionActionsManager::SUBMISSION_EDITOR_RECOMMEND_PENDING_REVISIONS, + 'groupId' => 'default', + ])) + ->addGroup([ + 'id' => 'default', + 'pageId' => 'default', + ]) + ->addPage([ + 'id' => 'default', + 'submitButton' => ['label' => __('help.next')] + ]); + } +} diff --git a/classes/context/Context.inc.php b/classes/context/Context.inc.php index fbdcef64ea4..8af32ac2ceb 100644 --- a/classes/context/Context.inc.php +++ b/classes/context/Context.inc.php @@ -256,7 +256,7 @@ public function getLocalizedFavicon() * * @return array */ - public function getSupportedFormLocales() + public function getSupportedFormLocales(): ?array { return $this->getData('supportedFormLocales'); } diff --git a/classes/core/EntityDAO.inc.php b/classes/core/EntityDAO.inc.php index 2629bc0beca..1b0bbcd0960 100644 --- a/classes/core/EntityDAO.inc.php +++ b/classes/core/EntityDAO.inc.php @@ -113,22 +113,24 @@ public function fromRow(stdClass $row): DataObject } } - $rows = DB::table($this->settingsTable) - ->where($this->primaryKeyColumn, '=', $row->{$this->primaryKeyColumn}) - ->get(); - - $rows->each(function ($row) use ($object, $schema) { - if (!empty($schema->properties->{$row->setting_name})) { - $object->setData( - $row->setting_name, - $this->convertFromDB( - $row->setting_value, - $schema->properties->{$row->setting_name}->type - ), - empty($row->locale) ? null : $row->locale - ); - } - }); + if ($this->settingsTable) { + $rows = DB::table($this->settingsTable) + ->where($this->primaryKeyColumn, '=', $row->{$this->primaryKeyColumn}) + ->get(); + + $rows->each(function ($row) use ($object, $schema) { + if (!empty($schema->properties->{$row->setting_name})) { + $object->setData( + $row->setting_name, + $this->convertFromDB( + $row->setting_value, + $schema->properties->{$row->setting_name}->type + ), + empty($row->locale) ? null : $row->locale + ); + } + }); + } return $object; } @@ -152,7 +154,7 @@ protected function _insert(DataObject $object): int $object->setId((int) DB::getPdo()->lastInsertId()); // Add additional properties to settings table if they exist - if (count($sanitizedProps) !== count($primaryDbProps)) { + if ($this->settingsTable && count($sanitizedProps) !== count($primaryDbProps)) { foreach ($schema->properties as $propName => $propSchema) { if (!isset($sanitizedProps[$propName]) || array_key_exists($propName, $this->primaryTableColumns)) { continue; @@ -194,57 +196,59 @@ protected function _update(DataObject $object) ->where($this->primaryKeyColumn, '=', $object->getId()) ->update($primaryDbProps); - $deleteSettings = []; - foreach ($schema->properties as $propName => $propSchema) { - if (array_key_exists($propName, $this->primaryTableColumns)) { - continue; - } elseif (!isset($sanitizedProps[$propName])) { - $deleteSettings[] = $propName; - continue; - } - if (!empty($propSchema->multilingual)) { - foreach ($sanitizedProps[$propName] as $localeKey => $localeValue) { - // Delete rows with a null value - if (is_null($localeValue)) { - DB::table($this->settingsTable) - ->where($this->primaryKeyColumn, '=', $object->getId()) - ->where('setting_name', '=', $propName) - ->where('locale', '=', $localeKey) - ->delete(); - } else { - DB::table($this->settingsTable) - ->updateOrInsert( - [ - $this->primaryKeyColumn => $object->getId(), - 'locale' => $localeKey, - 'setting_name' => $propName, - ], - [ - 'setting_value' => $this->convertToDB($localeValue, $schema->properties->{$propName}->type), - ] - ); + if ($this->settingsTable) { + $deleteSettings = []; + foreach ($schema->properties as $propName => $propSchema) { + if (array_key_exists($propName, $this->primaryTableColumns)) { + continue; + } elseif (!isset($sanitizedProps[$propName])) { + $deleteSettings[] = $propName; + continue; + } + if (!empty($propSchema->multilingual)) { + foreach ($sanitizedProps[$propName] as $localeKey => $localeValue) { + // Delete rows with a null value + if (is_null($localeValue)) { + DB::table($this->settingsTable) + ->where($this->primaryKeyColumn, '=', $object->getId()) + ->where('setting_name', '=', $propName) + ->where('locale', '=', $localeKey) + ->delete(); + } else { + DB::table($this->settingsTable) + ->updateOrInsert( + [ + $this->primaryKeyColumn => $object->getId(), + 'locale' => $localeKey, + 'setting_name' => $propName, + ], + [ + 'setting_value' => $this->convertToDB($localeValue, $schema->properties->{$propName}->type), + ] + ); + } } + } else { + DB::table($this->settingsTable) + ->updateOrInsert( + [ + $this->primaryKeyColumn => $object->getId(), + 'locale' => '', + 'setting_name' => $propName, + ], + [ + 'setting_value' => $this->convertToDB($sanitizedProps[$propName], $schema->properties->{$propName}->type), + ] + ); } - } else { - DB::table($this->settingsTable) - ->updateOrInsert( - [ - $this->primaryKeyColumn => $object->getId(), - 'locale' => '', - 'setting_name' => $propName, - ], - [ - 'setting_value' => $this->convertToDB($sanitizedProps[$propName], $schema->properties->{$propName}->type), - ] - ); } - } - if (count($deleteSettings)) { - DB::table($this->settingsTable) - ->where($this->primaryKeyColumn, '=', $object->getId()) - ->whereIn('setting_name', $deleteSettings) - ->delete(); + if (count($deleteSettings)) { + DB::table($this->settingsTable) + ->where($this->primaryKeyColumn, '=', $object->getId()) + ->whereIn('setting_name', $deleteSettings) + ->delete(); + } } } @@ -261,9 +265,11 @@ protected function _delete(DataObject $object) */ public function deleteById(int $id) { - DB::table($this->settingsTable) - ->where($this->primaryKeyColumn, '=', $id) - ->delete(); + if ($this->settingsTable) { + DB::table($this->settingsTable) + ->where($this->primaryKeyColumn, '=', $id) + ->delete(); + } DB::table($this->table) ->where($this->primaryKeyColumn, '=', $id) ->delete(); diff --git a/classes/core/PKPApplication.inc.php b/classes/core/PKPApplication.inc.php index c80c2f28e8e..519f44c47cc 100644 --- a/classes/core/PKPApplication.inc.php +++ b/classes/core/PKPApplication.inc.php @@ -122,6 +122,7 @@ abstract class PKPApplication implements iPKPApplicationInfoProvider public const ASSOC_TYPE_PUBLICATION = 0x010000c; public const ASSOC_TYPE_ACCESSIBLE_FILE_STAGES = 0x010000d; public const ASSOC_TYPE_NONE = 0x010000e; + public const ASSOC_TYPE_DECISION_TYPE = 0x010000f; // Constant used in UsageStats for submission files that are not full texts public const ASSOC_TYPE_SUBMISSION_FILE_COUNTER_OTHER = 0x0000213; diff --git a/classes/core/PKPRequest.inc.php b/classes/core/PKPRequest.inc.php index 0f017de324f..54a5934342a 100644 --- a/classes/core/PKPRequest.inc.php +++ b/classes/core/PKPRequest.inc.php @@ -17,9 +17,11 @@ use APP\facades\Repo; use PKP\config\Config; +use PKP\context\Context; use PKP\db\DAORegistry; use PKP\plugins\HookRegistry; use PKP\session\SessionManager; +use PKP\site\Site; class PKPRequest { @@ -561,9 +563,8 @@ public function isRestfulUrlsEnabled() /** * Get site data. * - * @return Site */ - public function &getSite() + public function &getSite(): Site { $site = & Registry::get('site', true, null); if ($site === null) { @@ -773,11 +774,9 @@ public function redirect($context = null, $page = null, $op = null, $path = null /** * Get the current "context" (press/journal/etc) object. * - * @return Context - * * @see PKPPageRouter::getContext() */ - public function &getContext() + public function &getContext(): ?Context { return $this->_delegateToRouter('getContext'); } diff --git a/classes/decision/Collector.inc.php b/classes/decision/Collector.inc.php new file mode 100644 index 00000000000..b591fe3f845 --- /dev/null +++ b/classes/decision/Collector.inc.php @@ -0,0 +1,108 @@ +dao = $dao; + } + + /** + * Filter decisions taken by one or more editors + */ + public function filterByEditorIds(array $editorIds): self + { + $this->editorIds = $editorIds; + return $this; + } + + /** + * Filter decisions taken in one or more rounds + * + * This refers to the round number, such as a first or second round + * of reviews. It is not the unique review round id. + */ + public function filterByRounds(array $rounds): self + { + $this->rounds = $rounds; + return $this; + } + + /** + * Filter decisions taken in one or more workflow stages + * + * Expects an array of WORKFLOW_STAGE_ID_ constants. + */ + public function filterByStageIds(array $stageIds): self + { + $this->stageIds = $stageIds; + return $this; + } + + /** + * Filter decisions taken for one or more submission ids + */ + public function filterBySubmissionIds(array $submissionIds): self + { + $this->submissionIds = $submissionIds; + return $this; + } + + /** + * @copydoc CollectorInterface::getQueryBuilder() + */ + public function getQueryBuilder(): Builder + { + $qb = DB::table($this->dao->table . ' as ed') + ->when(!is_null($this->editorIds), function ($q) { + $q->whereIn('editor_id', $this->editorIds); + }) + ->when(!is_null($this->rounds), function ($q) { + $q->whereIn('round', $this->rounds); + }) + ->when(!is_null($this->stageIds), function ($q) { + $q->whereIn('stage_id', $this->stageIds); + }) + ->when(!is_null($this->submissionIds), function ($q) { + $q->whereIn('submission_id', $this->submissionIds); + }) + ->orderBy('date_decided', 'asc'); + + HookRegistry::call('Decision::Collector', [&$qb, $this]); + + return $qb; + } +} diff --git a/classes/decision/DAO.inc.php b/classes/decision/DAO.inc.php new file mode 100644 index 00000000000..b207329fae3 --- /dev/null +++ b/classes/decision/DAO.inc.php @@ -0,0 +1,133 @@ + 'edit_decision_id', + 'dateDecided' => 'date_decided', + 'decision' => 'decision', + 'editorId' => 'editor_id', + 'reviewRoundId' => 'review_round_id', + 'round' => 'round', + 'stageId' => 'stage_id', + 'submissionId' => 'submission_id', + ]; + + /** + * Instantiate a new DataObject + */ + public function newDataObject(): Decision + { + return App::make(Decision::class); + } + + /** + * @copydoc EntityDAO::get() + */ + public function get(int $id): ?Decision + { + return parent::get($id); + } + + /** + * Get the number of decisions matching the configured query + */ + public function getCount(Collector $query): int + { + return $query + ->getQueryBuilder() + ->count(); + } + + /** + * Get a list of ids matching the configured query + */ + public function getIds(Collector $query): Collection + { + return $query + ->getQueryBuilder() + ->select('ed.' . $this->primaryKeyColumn) + ->pluck('ed.' . $this->primaryKeyColumn); + } + + /** + * Get a collection of decisions matching the configured query + */ + public function getMany(Collector $query): LazyCollection + { + $rows = $query + ->getQueryBuilder() + ->select(['ed.*']) + ->get(); + + return LazyCollection::make(function () use ($rows) { + foreach ($rows as $row) { + yield $this->fromRow($row); + } + }); + } + + /** + * @copydoc EntityDAO::fromRow() + */ + public function fromRow(stdClass $row): Decision + { + return parent::fromRow($row); + } + + /** + * @copydoc EntityDAO::insert() + */ + public function insert(Decision $decision): int + { + return parent::_insert($decision); + } + + /** + * @copydoc EntityDAO::update() + */ + public function update(Decision $decision) + { + parent::_update($decision); + } + + /** + * @copydoc EntityDAO::delete() + */ + public function delete(Decision $decision) + { + parent::_delete($decision); + } +} diff --git a/classes/decision/Decision.inc.php b/classes/decision/Decision.inc.php new file mode 100644 index 00000000000..e7da41a1005 --- /dev/null +++ b/classes/decision/Decision.inc.php @@ -0,0 +1,41 @@ +getTypes() as $type) { + if ($type->getDecision() === $this->getData('decision')) { + return $type; + } + } + throw new Exception('Decision exists with an unknown type. Decision: ' . $this->getData('decisions')); + } +} diff --git a/classes/decision/Repository.inc.php b/classes/decision/Repository.inc.php new file mode 100644 index 00000000000..cf20cb172e5 --- /dev/null +++ b/classes/decision/Repository.inc.php @@ -0,0 +1,421 @@ +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): ?Decision + { + return $this->dao->get($id); + } + + /** @copydoc DAO::getCount() */ + public function getCount(Collector $query): int + { + return $this->dao->getCount($query); + } + + /** @copydoc DAO::getIds() */ + public function getIds(Collector $query): Collection + { + return $this->dao->getIds($query); + } + + /** @copydoc DAO::getMany() */ + public function getMany(Collector $query): LazyCollection + { + return $this->dao->getMany($query); + } + + /** @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, Type $type, Submission $submission, Context $context): array + { + AppLocale::requireComponents( + LOCALE_COMPONENT_PKP_EDITOR, + LOCALE_COMPONENT_APP_EDITOR + ); + + // Return early if no valid decision type exists + if (!isset($props['decision']) || $props['decision'] !== $type->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, $type, $submission, $context) { + + // The decision stage id must match the decision type's stage id + // and the submission's current workflow stage + if ($props['stageId'] !== $type->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($type->getDecision())) { + /** @var StageAssignmentDAO $stageAssignmentDao */ + $stageAssignmentDao = DAORegistry::getDAO('StageAssignmentDAO'); + $assignedEditorIds = $stageAssignmentDao->getDecidingEditorIds($submission->getId(), $type->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 (!$type->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 ($type->isInReview()) { + $validator->errors()->add('reviewRoundId', __('editor.submission.workflowDecision.requiredReviewRound')); + } + + // Allow the decision type to add validation checks + $type->validate($props, $submission, $context, $validator, isset($reviewRound) ? $reviewRound->getId() : null); + }); + + $errors = []; + + if ($validator->fails()) { + $errors = $this->schemaService->formatValidationErrors($validator->errors()); + } + + HookRegistry::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); + HookRegistry::call('Decision::add', [$decision]); + + $decision = $this->get($id); + + $type = $decision->getType(); + $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 + AppLocale::requireComponents(LOCALE_COMPONENT_PKP_SUBMISSION, LOCALE_COMPONENT_APP_SUBMISSION); + SubmissionLog::logEvent( + $this->request, + $submission, + $this->isRecommendation($type->getDecision()) + ? PKPSubmissionEventLogEntry::SUBMISSION_LOG_EDITOR_RECOMMENDATION + : PKPSubmissionEventLogEntry::SUBMISSION_LOG_EDITOR_DECISION, + $type->getLog(), + [ + 'editorId' => $editor->getId(), + 'editorName' => $editor->getFullName(), + 'submissionId' => $decision->getData('submissionId'), + 'decision' => $type->getDecision(), + ] + ); + + // Allow the decision type to perform additional actions + $type->callback($decision, $submission, $editor, $context, $actions); + + try { + event(new DecisionAdded( + $decision, + $type, + $submission, + $editor, + $context, + $actions + )); + } catch (Exception $e) { + error_log($e->getMessage()); + error_log($e->getTraceAsString()); + } + + $this->updateNotifications($decision, $type, $submission); + + return $id; + } + + /** + * Delete all decisions by the submission ID + */ + public function deleteBySubmissionId(int $submissionId) + { + $decisionIds = $this->getIds( + $this->getCollector() + ->filterBySubmissionIds([$submissionId]) + ); + foreach ($decisionIds as $decisionId) { + $this->dao->deleteById($decisionId); + } + } + + /** + * Get a decision type by the SUBMISSION_EDITOR_DECISION_ + * constant + */ + public function getType(int $decision): ?Type + { + return $this->getTypes()->first(function (Type $type) use ($decision) { + return $type->getDecision() === $decision; + }); + } + + /** + * Get a list of all the decision types available + * + * @return Collection + */ + abstract public function getTypes(): Collection; + + /** + * Is the given decision a recommendation? + */ + public function isRecommendation(int $decision): bool + { + return in_array($decision, [ + EditorDecisionActionsManager::SUBMISSION_EDITOR_RECOMMEND_ACCEPT, + EditorDecisionActionsManager::SUBMISSION_EDITOR_RECOMMEND_DECLINE, + EditorDecisionActionsManager::SUBMISSION_EDITOR_RECOMMEND_PENDING_REVISIONS, + EditorDecisionActionsManager::SUBMISSION_EDITOR_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, Type $type, Submission $submission) + { + $notificationMgr = new NotificationManager(); + + // Update editor decision and pending revisions notifications. + $notificationTypes = $this->getReviewNotificationTypes(); + if ($editorDecisionNotificationType = $this->getNotificationTypeByEditorDecision($decision)) { + array_unshift($notificationTypes, $editorDecisionNotificationType); + } + + $authorIds = []; + /** @var StageAssignmentDAO $stageAssignmentDao */ + $stageAssignmentDao = DAORegistry::getDAO('StageAssignmentDAO'); + $result = $stageAssignmentDao->getBySubmissionAndRoleId($submission->getId(), Role::ROLE_ID_AUTHOR, $type->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 type related to an editorial decision + * + * @return int One of the Notification::NOTIFICATION_TYPE_ constants + */ + abstract protected function getNotificationTypeByEditorDecision(Decision $decision): ?int; + + /** + * 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 + * + * @param int $decision One of the EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_ constants + * + * @return int[] One or more of the Notification::NOTIFICATION_TYPE_ constants + */ + protected function getSubmissionNotificationTypes(Decision $decision): array + { + switch ($decision->getData('decision')) { + case EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_ACCEPT: + return [ + Notification::NOTIFICATION_TYPE_ASSIGN_COPYEDITOR, + Notification::NOTIFICATION_TYPE_AWAITING_COPYEDITS + ]; + case EditorDecisionActionsManager::SUBMISSION_EDITOR_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 []; + } +} diff --git a/classes/decision/Step.inc.php b/classes/decision/Step.inc.php new file mode 100644 index 00000000000..e66af782d12 --- /dev/null +++ b/classes/decision/Step.inc.php @@ -0,0 +1,51 @@ +id = $id; + $this->name = $name; + $this->description = $description; + } + + /** + * Compile initial state data to pass to the frontend + */ + public function getState(): stdClass + { + $config = new stdClass(); + $config->id = $this->id; + $config->type = $this->type; + $config->name = $this->name; + $config->description = $this->description; + $config->errors = new stdClass(); + + return $config; + } +} diff --git a/classes/decision/Type.inc.php b/classes/decision/Type.inc.php new file mode 100644 index 00000000000..47a4b6b31cd --- /dev/null +++ b/classes/decision/Type.inc.php @@ -0,0 +1,513 @@ + $this->getDecision(), + ]; + if ($this->isInReview()) { + if (!$reviewRoundId) { + throw new Exception('Can not get URL to the ' . get_class($this) . ' decision without a review round id.'); + } + $args['reviewRoundId'] = $reviewRoundId; + } + return $request->getDispatcher()->url( + $request, + Application::ROUTE_PAGE, + $context, + 'decision', + 'record', + $submission->getId(), + $args + ); + } + + /** + * Is this decision in a review workflow stage? + */ + public function isInReview(): bool + { + return in_array( + $this->getStageId(), + [ + WORKFLOW_STAGE_ID_INTERNAL_REVIEW, + WORKFLOW_STAGE_ID_EXTERNAL_REVIEW + ] + ); + } + + /** + * Validate this decision + * + * The default decision properties will already be validated. Use + * this method to validate data for this decision's actions, or + * to apply any additional restrictions for this decision. + */ + public function validate(array $props, Submission $submission, Context $context, Validator $validator, ?int $reviewRoundId = null) + { + // No validation checks are performed by default + } + + /** + * A callback method that is fired when a decision + * of this type is recorded + * + * @see Repository::add() + * + * @param array $actions Actions handled by the decision type + */ + public function callback(Decision $decision, Submission $submission, User $editor, Context $context, array $actions) + { + if ($this->getNewStatus()) { + Repo::submission()->updateStatus($submission, $this->getNewStatus()); + } + + if ($this->getNewStageId()) { + $submission->setData('stageId', $this->getNewStageId()); + Repo::submission()->dao->update($submission); + + // Create a new review round if there is not an existing round + // when promoting to a review stage, or reset the review round + // status if one already exists + if (in_array($this->getNewStageId(), [WORKFLOW_STAGE_ID_INTERNAL_REVIEW, WORKFLOW_STAGE_ID_EXTERNAL_REVIEW])) { + /** @var ReviewRoundDAO $reviewRoundDao */ + $reviewRoundDao = DAORegistry::getDAO('ReviewRoundDAO'); + $reviewRound = $reviewRoundDao->getLastReviewRoundBySubmissionId($submission->getId(), $this->getNewStageId()); + if (!is_a($reviewRound, ReviewRound::class)) { + $this->createReviewRound($submission, $this->getNewStageId(), 1); + } else { + $reviewRoundDao->updateStatus($reviewRound, null); + } + } + } + + // Change review round status when a decision is taken in a review stage + if ($reviewRoundId = $decision->getData('reviewRoundId')) { + /** @var ReviewRoundDAO $reviewRoundDao */ + $reviewRoundDao = DAORegistry::getDAO('ReviewRoundDAO'); + $reviewRound = $reviewRoundDao->getById($reviewRoundId); + if (is_a($reviewRound, ReviewRound::class)) { + // If the decision type doesn't specify a review round status, recalculate + // it from scratch. In order to do this, we unset the ReviewRound's status + // so the DAO will determine the new status + if (is_null($this->getNewReviewRoundStatus())) { + $reviewRound->setData('status', null); + } + $reviewRoundDao->updateStatus($reviewRound, $this->getNewReviewRoundStatus()); + } + } + } + + /** + * Get the workflow for this decision type + * + * Returns null if this decision type does not use a workflow. + * In such cases the decision can be recorded but does not make + * use of the built-in UI for making the decision + */ + public function getWorkflow(Submission $submission, Context $context, User $editor, ?ReviewRound $reviewRound): ?Workflow + { + return null; + } + + /** + * Get the assigned authors + */ + protected function getAssignedAuthorIds(Submission $submission): array + { + $userIds = []; + /** @var StageAssignmentDAO $stageAssignmentDao */ + $stageAssignmentDao = DAORegistry::getDAO('StageAssignmentDAO'); + $result = $stageAssignmentDao->getBySubmissionAndRoleId($submission->getId(), Role::ROLE_ID_AUTHOR, $this->getStageId()); + /** @var StageAssignment $stageAssignment */ + while ($stageAssignment = $result->next()) { + $userIds[] = (int) $stageAssignment->getUserId(); + } + return $userIds; + } + + /** + * Validate the properties of an email action + * + * @return array Empty if no errors + */ + protected function validateEmailAction(array $emailAction, Submission $submission, array $allowedAttachmentFileStages = []): array + { + $schema = (object) [ + 'attachments' => (object) [ + 'type' => 'array', + 'items' => (object) [ + 'type' => 'object', + ], + ], + 'bcc' => (object) [ + 'type' => 'array', + 'items' => (object) [ + 'type' => 'string', + 'validation' => [ + 'email_or_localhost', + ], + ], + ], + 'body' => (object) [ + 'type' => 'string', + 'validation' => [ + 'required', + ], + ], + 'cc' => (object) [ + 'type' => 'array', + 'items' => (object) [ + 'type' => 'string', + 'validation' => [ + 'email_or_localhost', + ], + ], + ], + 'id' => (object) [ + 'type' => 'string', + 'validation' => [ + 'alpha', + 'required', + ], + ], + 'subject' => (object) [ + 'type' => 'string', + 'validation' => [ + 'required', + ], + ], + 'to' => (object) [ + 'type' => 'array', + 'items' => (object) [ + 'type' => 'integer', + ], + ], + ]; + + $schemaService = App::make(PKPSchemaService::class); + $rules = []; + foreach ($schema as $propName => $propSchema) { + $rules = $schemaService->addPropValidationRules($rules, $propName, $propSchema); + } + + $validator = ValidatorFactory::make( + $emailAction, + $rules, + ); + + if (isset($emailAction['attachments'])) { + $validator->after(function ($validator) use ($emailAction, $submission, $allowedAttachmentFileStages) { + if ($validator->errors()->get('attachments')) { + return; + } + foreach ($emailAction['attachments'] as $attachment) { + $errorMessage = __('email.attachmentNotFound', ['fileName' => $attachment['name'] ?? '']); + if (isset($attachment['temporaryFileId'])) { + $uploaderId = Application::get()->getRequest()->getUser()->getId(); + if (!$this->validateTemporaryFileAttachment($attachment['temporaryFileId'], $uploaderId)) { + $validator->errors()->add('attachments', $errorMessage); + } + } elseif (isset($attachment['submissionFileId'])) { + if (!$this->validateSubmissionFileAttachment((int) $attachment['submissionFileId'], $submission, $allowedAttachmentFileStages)) { + $validator->errors()->add('attachments', $errorMessage); + } + } elseif (isset($attachment['libraryFileId'])) { + if (!$this->validateLibraryAttachment($attachment['libraryFileId'], $submission)) { + $validator->errors()->add('attachments', $errorMessage); + } + } else { + $validator->errors()->add('attachments', $errorMessage); + } + } + }); + } + + $errors = []; + + if ($validator->fails()) { + $errors = $schemaService->formatValidationErrors($validator->errors()); + } + + return $errors; + } + + /** + * Validate a file attachment that has been uploaded by the user + */ + protected function validateTemporaryFileAttachment(string $temporaryFileId, int $uploaderId): bool + { + $temporaryFileManager = new TemporaryFileManager(); + return (bool) $temporaryFileManager->getFile($temporaryFileId, $uploaderId); + } + + /** + * Validate a file attachment from a submission file + * + * @param array $allowedFileStages SubmissionFile::SUBMISSION_FILE_* + */ + protected function validateSubmissionFileAttachment(int $submissionFileId, Submission $submission, array $allowedFileStages): bool + { + $submissionFile = Services::get('submissionFile')->get($submissionFileId); + return $submissionFile + && $submissionFile->getData('submissionId') === $submission->getId() + && in_array($submissionFile->getData('fileStage'), $allowedFileStages); + } + + /** + * Validate a file attachment from a library file + */ + protected function validateLibraryAttachment(int $libraryFileId, Submission $submission): bool + { + /** @var LibraryFileDAO $libraryFileDao */ + $libraryFileDao = DAORegistry::getDAO('LibraryFileDAO'); + $file = $libraryFileDao->getById($libraryFileId, $submission->getData('contextId')); + + if (!$file) { + return false; + } + + return !$file->getSubmissionId() || $file->getSubmissionId() === $submission->getId(); + } + + /** + * Set an error message for invalid recipients + * + * @param array $invalidRecipientIds + */ + protected function setRecipientError(string $actionErrorKey, array $invalidRecipientIds, Validator $validator) + { + $names = array_map(function ($userId) { + $user = Repo::user()->get((int) $userId); + return $user ? $user->getFullName() : $userId; + }, $invalidRecipientIds); + $validator->errors()->add( + $actionErrorKey . '.to', + __( + 'editor.submission.workflowDecision.invalidRecipients', + ['names' => join(__('common.commaListSeparator'), $names)] + ) + ); + } + + /** + * Create a fake decision object as if a decision of this + * type was recorded + * + * This decision object can be passed to a Mailable in order to + * prepare data for email templates. The decision is not saved + * to the database and has no `id` property. + */ + protected function getFakeDecision(Submission $submission, User $editor, ?ReviewRound $reviewRound = null): Decision + { + return Repo::decision()->newDataObject([ + 'dateDecided' => Core::getCurrentDate(), + 'decision' => $this->getDecision(), + 'editorId' => $editor->getId(), + 'reviewRoundId' => $reviewRound ? $reviewRound->getId() : null, + 'round' => $reviewRound ? $reviewRound->getRound() : null, + 'stageId' => $this->getStageId(), + 'submissionId' => $submission->getId(), + ]); + } + + /** + * Convert a decision action to EmailData + */ + protected function getEmailDataFromAction(array $action): EmailData + { + return new EmailData($action); + } + + /** + * Get a Mailable from a decision's action data + * + * Sets the sender, subject, body and attachments. + * + * Does NOT set the recipients. + */ + protected function addEmailDataToMailable(Mailable $mailable, User $sender, EmailData $email): Mailable + { + $mailable + ->sender($sender) + ->bcc($email->bcc) + ->cc($email->cc) + ->subject($email->subject) + ->body($email->body); + + if (!empty($email->attachments)) { + foreach ($email->attachments as $attachment) { + if (isset($attachment[Mailable::ATTACHMENT_TEMPORARY_FILE])) { + $mailable->attachTemporaryFile( + $attachment[Mailable::ATTACHMENT_TEMPORARY_FILE], + $attachment['name'], + $sender->getId() + ); + } elseif (isset($attachment[Mailable::ATTACHMENT_SUBMISSION_FILE])) { + $mailable->attachSubmissionFile( + $attachment[Mailable::ATTACHMENT_SUBMISSION_FILE], + $attachment['name'] + ); + } elseif (isset($attachment[Mailable::ATTACHMENT_LIBRARY_FILE])) { + $mailable->attachLibraryFile( + $attachment[Mailable::ATTACHMENT_LIBRARY_FILE], + $attachment['name'] + ); + } + } + } + + return $mailable; + } + + /** + * Create a review round in a review stage + */ + protected function createReviewRound(Submission $submission, int $stageId, ?int $round = 1) + { + /** @var ReviewRoundDAO $reviewRoundDao */ + $reviewRoundDao = DAORegistry::getDAO('ReviewRoundDAO'); + + $reviewRound = $reviewRoundDao->build( + $submission->getId(), + $stageId, + $round, + ReviewRound::REVIEW_ROUND_STATUS_PENDING_REVIEWERS + ); + + // Create review round status notification + /** @var NotificationDAO $notificationDao */ + $notificationDao = DAORegistry::getDAO('NotificationDAO'); + $notificationFactory = $notificationDao->getByAssoc( + Application::ASSOC_TYPE_REVIEW_ROUND, + $reviewRound->getId(), + null, + Notification::NOTIFICATION_TYPE_REVIEW_ROUND_STATUS, + $submission->getData('contextId') + ); + if (!$notificationFactory->next()) { + $notificationMgr = new NotificationManager(); + $notificationMgr->createNotification( + Application::get()->getRequest(), + null, + Notification::NOTIFICATION_TYPE_REVIEW_ROUND_STATUS, + $submission->getData('contextId'), + Application::ASSOC_TYPE_REVIEW_ROUND, + $reviewRound->getId(), + Notification::NOTIFICATION_LEVEL_NORMAL + ); + } + } +} diff --git a/classes/decision/Workflow.inc.php b/classes/decision/Workflow.inc.php new file mode 100644 index 00000000000..e42a98180dc --- /dev/null +++ b/classes/decision/Workflow.inc.php @@ -0,0 +1,125 @@ +decisionType = $decisionType; + $this->submission = $submission; + $this->context = $context; + if ($reviewRound) { + $this->reviewRound = $reviewRound; + } + } + + /** + * Add a step to the workflow + */ + public function addStep(Step $step) + { + $this->steps[$step->id] = $step; + } + + /** + * Compile initial state data to pass to the frontend + * + * @see DecisionPage.vue + */ + public function getState(): array + { + $state = []; + foreach ($this->steps as $step) { + $state[] = $step->getState(); + } + return $state; + } + + /** + * Get all users assigned to a role in this decision's stage + * + * @param integer $roleId + * + * @return array + */ + public function getStageParticipants(int $roleId): array + { + /** @var StageAssignmentDAO $stageAssignmentDao */ + $stageAssignmentDao = DAORegistry::getDAO('StageAssignmentDAO'); + $userIds = []; + $result = $stageAssignmentDao->getBySubmissionAndRoleId( + $this->submission->getId(), + $roleId, + $this->decisionType->getStageId() + ); + /** @var StageAssignment $stageAssignment */ + while ($stageAssignment = $result->next()) { + $userIds[] = (int) $stageAssignment->getUserId(); + } + $users = []; + foreach (array_unique($userIds) as $authorUserId) { + $users[] = Repo::user()->get($authorUserId); + } + + return $users; + } + + /** + * Get all reviewers who completed a review in this decision's stage + * + * @param array $reviewAssignments + * + * @return array + */ + public function getReviewersFromAssignments(array $reviewAssignments): array + { + $reviewers = []; + foreach ($reviewAssignments as $reviewAssignment) { + $reviewers[] = Repo::user()->get((int) $reviewAssignment->getReviewerId()); + } + return $reviewers; + } + + /** + * Get all assigned editors who can make a decision in this stage + */ + public function getDecidingEditors(): array + { + /** @var StageAssignmentDAO $stageAssignmentDao */ + $stageAssignmentDao = DAORegistry::getDAO('StageAssignmentDAO'); + $userIds = $stageAssignmentDao->getDecidingEditorIds($this->submission->getId(), $this->decisionType->getStageId()); + $users = []; + foreach (array_unique($userIds) as $authorUserId) { + $users[] = Repo::user()->get($authorUserId); + } + + return $users; + } +} diff --git a/classes/decision/maps/Schema.inc.php b/classes/decision/maps/Schema.inc.php new file mode 100644 index 00000000000..d109447181c --- /dev/null +++ b/classes/decision/maps/Schema.inc.php @@ -0,0 +1,70 @@ +mapByProperties($this->getProps(), $item); + } + + /** + * Map a collection of Decisions + * + * @see self::map + */ + public function mapMany(Enumerable $collection): Enumerable + { + $this->collection = $collection; + return $collection->map(function ($item) { + return $this->map($item); + }); + } + + /** + * Map schema properties of a Decision to an assoc array + */ + protected function mapByProperties(array $props, Decision $item): array + { + $output = []; + foreach ($props as $prop) { + switch ($prop) { + case '_href': + $output[$prop] = $this->getApiUrl('submissions/' . $item->getData('submissionId') . '/decisions/' . $item->getId()); + break; + default: + $output[$prop] = $item->getData($prop); + break; + } + } + + ksort($output); + + return $this->withExtensions($output, $item); + } +} diff --git a/classes/decision/steps/Email.inc.php b/classes/decision/steps/Email.inc.php new file mode 100644 index 00000000000..85c4558d122 --- /dev/null +++ b/classes/decision/steps/Email.inc.php @@ -0,0 +1,126 @@ + */ + public array $attachers; + public bool $canChangeTo = false; + public bool $canSkip = true; + public array $locales; + public Mailable $mailable; + /** @var array */ + public array $recipients; + public string $type = 'email'; + + /** + * @param array $recipients One or more User objects who are the recipients of this email + * @param Mailable $mailable The mailable that will be used to send this email + * @param array + */ + public function __construct(string $id, string $name, string $description, array $recipients, Mailable $mailable, array $locales, ?array $attachers = []) + { + parent::__construct($id, $name, $description); + $this->attachers = $attachers; + $this->locales = $locales; + $this->mailable = $mailable; + $this->recipients = $recipients; + } + + /** + * Can the editor change the recipients of this email + */ + public function canChangeTo(bool $value): self + { + $this->canChangeTo = $value; + return $this; + } + + /** + * Can the editor skip this email + */ + public function canSkip(bool $value): self + { + $this->canSkip = $value; + return $this; + } + + public function getState(): stdClass + { + $config = parent::getState(); + $config->attachers = $this->getAttachers(); + $config->canChangeTo = $this->canChangeTo; + $config->canSkip = $this->canSkip; + $config->emailTemplates = $this->getEmailTemplates(); + $config->initialTemplateKey = $this->mailable->defaultEmailTemplateKey; + $config->toOptions = $this->getToOptions(); + + $config->variables = []; + $config->locales = []; + $allLocales = AppLocale::getAllLocales(); + foreach ($this->locales as $locale) { + $config->variables[$locale] = $this->mailable->getData($locale); + $config->locales[] = [ + 'locale' => $locale, + 'name' => $allLocales[$locale], + ]; + } + + return $config; + } + + protected function getToOptions(): array + { + $toOptions = []; + foreach ($this->recipients as $user) { + $toOptions[] = [ + 'value' => $user->getId(), + 'label' => $user->getFullName(), + ]; + } + return $toOptions; + } + + protected function getEmailTemplates(): array + { + $request = Application::get()->getRequest(); + $context = $request->getContext(); + + $emailTemplates = collect(); + if (property_exists($this->mailable, 'defaultEmailTemplateKey')) { + $emailTemplates->add(Repo::emailTemplate()->getByKey($context->getId(), $this->mailable->defaultEmailTemplateKey)); + } + + return Repo::emailTemplate()->getSchemaMap()->mapMany($emailTemplates)->toArray(); + } + + protected function getAttachers(): array + { + $attachers = []; + foreach ($this->attachers as $attacher) { + $attachers[] = $attacher->getState(); + } + return $attachers; + } +} diff --git a/classes/decision/steps/Form.inc.php b/classes/decision/steps/Form.inc.php new file mode 100644 index 00000000000..a00332c7234 --- /dev/null +++ b/classes/decision/steps/Form.inc.php @@ -0,0 +1,46 @@ +form = $form; + } + + public function getState(): stdClass + { + $config = parent::getState(); + $config->form = $this->form->getConfig(); + + // Decision forms shouldn't have submit buttons + // because the step-by-step decision wizard includes + // next/previous buttons + unset($config->form['pages'][0]['submitButton']); + + return $config; + } +} diff --git a/classes/decision/steps/PromoteFiles.inc.php b/classes/decision/steps/PromoteFiles.inc.php new file mode 100644 index 00000000000..d73de9301af --- /dev/null +++ b/classes/decision/steps/PromoteFiles.inc.php @@ -0,0 +1,92 @@ + */ + public array $files; + + /** + * @param integer $to Selected files are copied to this file stage + */ + public function __construct(string $id, string $name, string $description, int $to, Submission $submission) + { + parent::__construct($id, $name, $description); + $this->submission = $submission; + $this->to = $to; + } + + /** + * Add a list of files that can be copied to the next stage + */ + public function addFileList(string $name, int $fileStage, ?ReviewRound $reviewRound = null): self + { + $this->lists[] = [ + 'name' => $name, + 'fileStage' => $fileStage, + 'reviewRound' => $reviewRound, + ]; + return $this; + } + + public function getState(): stdClass + { + $config = parent::getState(); + $config->to = $this->to; + $config->selected = []; + + $lists = []; + $propertyArgs = [ + 'request' => Application::get()->getRequest(), + 'submission' => $this->submission, + ]; + foreach ($this->lists as $list) { + $args = [ + 'submissionIds' => [$this->submission->getId()], + 'fileStages' => [$list['fileStage']], + ]; + if ($list['reviewRound']) { + $args['reviewRoundIds'] = [$list['reviewRound']->getId()]; + } + $filesIterator = Services::get('submissionFile')->getMany($args); + $listFiles = []; + foreach ($filesIterator as $listFile) { + $listFiles[] = Services::get('submissionFile')->getSummaryProperties($listFile, $propertyArgs); + } + $lists[] = [ + 'name' => $list['name'], + 'files' => $listFiles, + ]; + } + + $config->lists = $lists; + + return $config; + } +} diff --git a/classes/decision/types/Accept.inc.php b/classes/decision/types/Accept.inc.php new file mode 100644 index 00000000000..c1a5974560f --- /dev/null +++ b/classes/decision/types/Accept.inc.php @@ -0,0 +1,204 @@ + $submission->getLocalizedFullTitle()]); + } + + public function validate(array $props, Submission $submission, Context $context, Validator $validator, ?int $reviewRoundId = null) + { + // If there is no review round id, a validation error will already have been set + if (!$reviewRoundId) { + return; + } + + parent::validate($props, $submission, $context, $validator, $reviewRoundId); + + foreach ($props['actions'] as $index => $action) { + $actionErrorKey = 'actions.' . $index; + switch ($action['id']) { + case $this->ACTION_PAYMENT: + $this->validatePaymentAction($action, $actionErrorKey, $validator, $context); + break; + case $this->ACTION_NOTIFY_AUTHORS: + $this->validateNotifyAuthorsAction($action, $actionErrorKey, $validator, $submission); + break; + case $this->ACTION_NOTIFY_REVIEWERS: + $this->validateNotifyReviewersAction($action, $actionErrorKey, $validator, $submission, $reviewRoundId); + break; + } + } + } + + public function callback(Decision $decision, Submission $submission, User $editor, Context $context, array $actions) + { + parent::callback($decision, $submission, $editor, $context, $actions); + + foreach ($actions as $action) { + switch ($action['id']) { + case self::ACTION_PAYMENT: + $this->requestPayment($submission, $editor, $context); + break; + case $this->ACTION_NOTIFY_AUTHORS: + $reviewAssignments = $this->getCompletedReviewAssignments($submission->getId(), $decision->getData('reviewRoundId')); + $emailData = $this->getEmailDataFromAction($action); + $this->sendAuthorEmail( + new DecisionAcceptNotifyAuthor($context, $submission, $decision, $reviewAssignments), + $emailData, + $editor, + $submission + ); + $this->shareReviewAttachmentFiles($emailData->attachments, $submission, $decision->getData('reviewRoundId')); + break; + case $this->ACTION_NOTIFY_REVIEWERS: + $this->sendReviewersEmail( + new DecisionNotifyReviewer($context, $submission, $decision), + $this->getEmailDataFromAction($action), + $editor, + $submission + ); + break; + } + } + } + + public function getWorkflow(Submission $submission, Context $context, User $editor, ?ReviewRound $reviewRound): Workflow + { + $workflow = new Workflow($this, $submission, $context, $reviewRound); + + $fakeDecision = $this->getFakeDecision($submission, $editor, $reviewRound); + $fileAttachers = $this->getFileAttachers($submission, $context, $reviewRound); + $reviewAssignments = $this->getCompletedReviewAssignments($submission->getId(), $reviewRound->getId()); + + // Request payment if configured + $paymentManager = Application::getPaymentManager($context); + if ($paymentManager->publicationEnabled()) { + $workflow->addStep($this->getPaymentForm($context)); + } + + $authors = $workflow->getStageParticipants(Role::ROLE_ID_AUTHOR); + if (count($authors)) { + $mailable = new DecisionAcceptNotifyAuthor($context, $submission, $fakeDecision, $reviewAssignments); + $workflow->addStep(new Email( + $this->ACTION_NOTIFY_AUTHORS, + __('editor.submission.decision.notifyAuthors'), + __('editor.submission.decision.accept.notifyAuthorsDescription'), + $authors, + $mailable + ->sender($editor) + ->recipients($authors), + $context->getSupportedFormLocales(), + $fileAttachers + )); + } + + if (count($reviewAssignments)) { + $reviewers = $workflow->getReviewersFromAssignments($reviewAssignments); + $mailable = new DecisionNotifyReviewer($context, $submission, $fakeDecision); + $workflow->addStep((new Email( + $this->ACTION_NOTIFY_REVIEWERS, + __('editor.submission.decision.notifyReviewers'), + __('editor.submission.decision.notifyReviewers.description'), + $reviewers, + $mailable->sender($editor), + $context->getSupportedFormLocales(), + $fileAttachers + ))->canChangeTo(true)); + } + + $workflow->addStep((new PromoteFiles( + 'promoteFilesToCopyediting', + __('editor.submission.selectFiles'), + __('editor.submission.decision.promoteFiles.copyediting'), + SubmissionFile::SUBMISSION_FILE_FINAL, + $submission + ))->addFileList( + __('editor.submission.revisions'), + SubmissionFile::SUBMISSION_FILE_REVIEW_REVISION, + $reviewRound + )); + + return $workflow; + } +} diff --git a/classes/decision/types/BackToCopyediting.inc.php b/classes/decision/types/BackToCopyediting.inc.php new file mode 100644 index 00000000000..bec1d7525c1 --- /dev/null +++ b/classes/decision/types/BackToCopyediting.inc.php @@ -0,0 +1,192 @@ + $submission->getLocalizedFullTitle()]); + } + + public function validate(array $props, Submission $submission, Context $context, Validator $validator, ?int $reviewRoundId = null) + { + parent::validate($props, $submission, $context, $validator, $reviewRoundId); + + foreach ($props['actions'] as $index => $action) { + $actionErrorKey = 'actions.' . $index; + switch ($action['id']) { + case $this->ACTION_NOTIFY_AUTHORS: + $this->validateNotifyAuthorsAction($action, $actionErrorKey, $validator, $submission); + break; + } + } + } + + public function callback(Decision $decision, Submission $submission, User $editor, Context $context, array $actions) + { + parent::callback($decision, $submission, $editor, $context, $actions); + + foreach ($actions as $action) { + switch ($action['id']) { + case $this->ACTION_NOTIFY_AUTHORS: + $this->sendAuthorEmail( + new DecisionBackToCopyeditingNotifyAuthor($context, $submission, $decision), + $this->getEmailDataFromAction($action), + $editor, + $submission + ); + break; + } + } + } + + public function getWorkflow(Submission $submission, Context $context, User $editor, ?ReviewRound $reviewRound): Workflow + { + $workflow = new Workflow($this, $submission, $context); + + $fakeDecision = $this->getFakeDecision($submission, $editor); + $fileAttachers = $this->getFileAttachers($submission, $context); + + $authors = $workflow->getStageParticipants(Role::ROLE_ID_AUTHOR); + if (count($authors)) { + $mailable = new DecisionBackToCopyeditingNotifyAuthor($context, $submission, $fakeDecision); + $workflow->addStep(new Email( + $this->ACTION_NOTIFY_AUTHORS, + __('editor.submission.decision.notifyAuthors'), + __('editor.submission.decision.backToCopyediting.notifyAuthorsDescription'), + $authors, + $mailable + ->sender($editor) + ->recipients($authors), + $context->getSupportedFormLocales(), + $fileAttachers + )); + } + + return $workflow; + } + + /** + * Get the submission file stages that are permitted to be attached to emails + * sent in this decision + * + * @return array + */ + protected function getAllowedAttachmentFileStages(): array + { + return [ + SubmissionFile::SUBMISSION_FILE_PRODUCTION_READY, + ]; + } + + /** + * Get the file attacher components supported for emails in this decision + */ + protected function getFileAttachers(Submission $submission, Context $context): array + { + $attachers = [ + new Upload( + $context, + __('common.upload.addFile'), + __('common.upload.addFile.description'), + __('common.upload.addFile') + ), + ]; + + $attachers[] = (new FileStage( + $context, + $submission, + __('submission.submit.submissionFiles'), + __('email.addAttachment.submissionFiles.submissionDescription'), + __('email.addAttachment.submissionFiles.attach') + )) + ->withFileStage( + SubmissionFile::SUBMISSION_FILE_PRODUCTION_READY, + __('editor.submission.production.productionReadyFiles') + ); + + $attachers[] = new Library( + $context, + $submission + ); + + return $attachers; + } +} diff --git a/classes/decision/types/BackToReview.inc.php b/classes/decision/types/BackToReview.inc.php new file mode 100644 index 00000000000..b90697e47e1 --- /dev/null +++ b/classes/decision/types/BackToReview.inc.php @@ -0,0 +1,192 @@ + $submission->getLocalizedFullTitle()]); + } + + public function validate(array $props, Submission $submission, Context $context, Validator $validator, ?int $reviewRoundId = null) + { + parent::validate($props, $submission, $context, $validator, $reviewRoundId); + + foreach ($props['actions'] as $index => $action) { + $actionErrorKey = 'actions.' . $index; + switch ($action['id']) { + case $this->ACTION_NOTIFY_AUTHORS: + $this->validateNotifyAuthorsAction($action, $actionErrorKey, $validator, $submission); + break; + } + } + } + + public function callback(Decision $decision, Submission $submission, User $editor, Context $context, array $actions) + { + parent::callback($decision, $submission, $editor, $context, $actions); + + foreach ($actions as $action) { + switch ($action['id']) { + case $this->ACTION_NOTIFY_AUTHORS: + $this->sendAuthorEmail( + new DecisionBackToReviewNotifyAuthor($context, $submission, $decision), + $this->getEmailDataFromAction($action), + $editor, + $submission + ); + break; + } + } + } + + public function getWorkflow(Submission $submission, Context $context, User $editor, ?ReviewRound $reviewRound): Workflow + { + $workflow = new Workflow($this, $submission, $context); + + $fakeDecision = $this->getFakeDecision($submission, $editor); + $fileAttachers = $this->getFileAttachers($submission, $context); + + $authors = $workflow->getStageParticipants(Role::ROLE_ID_AUTHOR); + if (count($authors)) { + $mailable = new DecisionBackToReviewNotifyAuthor($context, $submission, $fakeDecision); + $workflow->addStep(new Email( + $this->ACTION_NOTIFY_AUTHORS, + __('editor.submission.decision.notifyAuthors'), + __('editor.submission.decision.backToReview.notifyAuthorsDescription'), + $authors, + $mailable + ->sender($editor) + ->recipients($authors), + $context->getSupportedFormLocales(), + $fileAttachers + )); + } + + return $workflow; + } + + /** + * Get the submission file stages that are permitted to be attached to emails + * sent in this decision + * + * @return array + */ + protected function getAllowedAttachmentFileStages(): array + { + return [ + SubmissionFile::SUBMISSION_FILE_FINAL, + ]; + } + + /** + * Get the file attacher components supported for emails in this decision + */ + protected function getFileAttachers(Submission $submission, Context $context): array + { + $attachers = [ + new Upload( + $context, + __('common.upload.addFile'), + __('common.upload.addFile.description'), + __('common.upload.addFile') + ), + ]; + + $attachers[] = (new FileStage( + $context, + $submission, + __('submission.submit.submissionFiles'), + __('email.addAttachment.submissionFiles.submissionDescription'), + __('email.addAttachment.submissionFiles.attach') + )) + ->withFileStage( + SubmissionFile::SUBMISSION_FILE_FINAL, + __('submission.finalDraft') + ); + + $attachers[] = new Library( + $context, + $submission + ); + + return $attachers; + } +} diff --git a/classes/decision/types/BackToSubmissionFromCopyediting.inc.php b/classes/decision/types/BackToSubmissionFromCopyediting.inc.php new file mode 100644 index 00000000000..a1887f7d460 --- /dev/null +++ b/classes/decision/types/BackToSubmissionFromCopyediting.inc.php @@ -0,0 +1,192 @@ + $submission->getLocalizedFullTitle()]); + } + + public function validate(array $props, Submission $submission, Context $context, Validator $validator, ?int $reviewRoundId = null) + { + parent::validate($props, $submission, $context, $validator, $reviewRoundId); + + foreach ($props['actions'] as $index => $action) { + $actionErrorKey = 'actions.' . $index; + switch ($action['id']) { + case $this->ACTION_NOTIFY_AUTHORS: + $this->validateNotifyAuthorsAction($action, $actionErrorKey, $validator, $submission); + break; + } + } + } + + public function callback(Decision $decision, Submission $submission, User $editor, Context $context, array $actions) + { + parent::callback($decision, $submission, $editor, $context, $actions); + + foreach ($actions as $action) { + switch ($action['id']) { + case $this->ACTION_NOTIFY_AUTHORS: + $this->sendAuthorEmail( + new DecisionBackToSubmissionNotifyAuthor($context, $submission, $decision), + $this->getEmailDataFromAction($action), + $editor, + $submission + ); + break; + } + } + } + + public function getWorkflow(Submission $submission, Context $context, User $editor, ?ReviewRound $reviewRound): Workflow + { + $workflow = new Workflow($this, $submission, $context); + + $fakeDecision = $this->getFakeDecision($submission, $editor); + $fileAttachers = $this->getFileAttachers($submission, $context); + + $authors = $workflow->getStageParticipants(Role::ROLE_ID_AUTHOR); + if (count($authors)) { + $mailable = new DecisionBackToSubmissionNotifyAuthor($context, $submission, $fakeDecision); + $workflow->addStep(new Email( + $this->ACTION_NOTIFY_AUTHORS, + __('editor.submission.decision.notifyAuthors'), + __('editor.submission.decision.backToSubmission.notifyAuthorsDescription'), + $authors, + $mailable + ->sender($editor) + ->recipients($authors), + $context->getSupportedFormLocales(), + $fileAttachers + )); + } + + return $workflow; + } + + /** + * Get the submission file stages that are permitted to be attached to emails + * sent in this decision + * + * @return array + */ + protected function getAllowedAttachmentFileStages(): array + { + return [ + SubmissionFile::SUBMISSION_FILE_FINAL + ]; + } + + /** + * Get the file attacher components supported for emails in this decision + */ + protected function getFileAttachers(Submission $submission, Context $context): array + { + $attachers = [ + new Upload( + $context, + __('common.upload.addFile'), + __('common.upload.addFile.description'), + __('common.upload.addFile') + ), + ]; + + $attachers[] = (new FileStage( + $context, + $submission, + __('submission.submit.submissionFiles'), + __('email.addAttachment.submissionFiles.submissionDescription'), + __('email.addAttachment.submissionFiles.attach') + )) + ->withFileStage( + SubmissionFile::SUBMISSION_FILE_FINAL, + __('submission.finalDraft') + ); + + $attachers[] = new Library( + $context, + $submission + ); + + return $attachers; + } +} diff --git a/classes/decision/types/Decline.inc.php b/classes/decision/types/Decline.inc.php new file mode 100644 index 00000000000..f3b74e9c55c --- /dev/null +++ b/classes/decision/types/Decline.inc.php @@ -0,0 +1,175 @@ + $submission->getLocalizedFullTitle()]); + } + + public function validate(array $props, Submission $submission, Context $context, Validator $validator, ?int $reviewRoundId = null) + { + // If there is no review round id, a validation error will already have been set + if (!$reviewRoundId) { + return; + } + + parent::validate($props, $submission, $context, $validator, $reviewRoundId); + + foreach ($props['actions'] as $index => $action) { + $actionErrorKey = 'actions.' . $index; + switch ($action['id']) { + case $this->ACTION_NOTIFY_AUTHORS: + $this->validateNotifyAuthorsAction($action, $actionErrorKey, $validator, $submission); + break; + case $this->ACTION_NOTIFY_REVIEWERS: + $this->validateNotifyReviewersAction($action, $actionErrorKey, $validator, $submission, $reviewRoundId); + break; + } + } + } + + public function callback(Decision $decision, Submission $submission, User $editor, Context $context, array $actions) + { + parent::callback($decision, $submission, $editor, $context, $actions); + + foreach ($actions as $action) { + switch ($action['id']) { + case $this->ACTION_NOTIFY_AUTHORS: + $reviewAssignments = $this->getCompletedReviewAssignments($submission->getId(), $decision->getData('reviewRoundId')); + $emailData = $this->getEmailDataFromAction($action); + $this->sendAuthorEmail( + new DecisionDeclineNotifyAuthor($context, $submission, $decision, $reviewAssignments), + $emailData, + $editor, + $submission + ); + $this->shareReviewAttachmentFiles($emailData->attachments, $submission, $decision->getData('reviewRoundId')); + break; + case $this->ACTION_NOTIFY_REVIEWERS: + $this->sendReviewersEmail( + new DecisionNotifyReviewer($context, $submission, $decision), + $this->getEmailDataFromAction($action), + $editor, + $submission + ); + break; + } + } + } + + public function getWorkflow(Submission $submission, Context $context, User $editor, ?ReviewRound $reviewRound): Workflow + { + $workflow = new Workflow($this, $submission, $context, $reviewRound); + + $fakeDecision = $this->getFakeDecision($submission, $editor, $reviewRound); + $fileAttachers = $this->getFileAttachers($submission, $context, $reviewRound); + $reviewAssignments = $this->getCompletedReviewAssignments($submission->getId(), $reviewRound->getId()); + + $authors = $workflow->getStageParticipants(Role::ROLE_ID_AUTHOR); + if (count($authors)) { + $mailable = new DecisionDeclineNotifyAuthor($context, $submission, $fakeDecision, $reviewAssignments); + $workflow->addStep(new Email( + $this->ACTION_NOTIFY_AUTHORS, + __('editor.submission.decision.notifyAuthors'), + __('editor.submission.decision.decline.notifyAuthorsDescription'), + $authors, + $mailable + ->sender($editor) + ->recipients($authors), + $context->getSupportedFormLocales(), + $fileAttachers + )); + } + + if (count($reviewAssignments)) { + $reviewers = $workflow->getReviewersFromAssignments($reviewAssignments); + $mailable = new DecisionNotifyReviewer($context, $submission, $fakeDecision); + $workflow->addStep((new Email( + $this->ACTION_NOTIFY_REVIEWERS, + __('editor.submission.decision.notifyReviewers'), + __('editor.submission.decision.notifyReviewers.description'), + $reviewers, + $mailable->sender($editor), + $context->getSupportedFormLocales(), + $fileAttachers + ))->canChangeTo(true)); + } + + return $workflow; + } +} diff --git a/classes/decision/types/InitialDecline.inc.php b/classes/decision/types/InitialDecline.inc.php new file mode 100644 index 00000000000..2e31c164848 --- /dev/null +++ b/classes/decision/types/InitialDecline.inc.php @@ -0,0 +1,138 @@ + $submission->getLocalizedFullTitle()]); + } + + public function validate(array $props, Submission $submission, Context $context, Validator $validator, ?int $reviewRoundId = null) + { + parent::validate($props, $submission, $context, $validator, $reviewRoundId); + + foreach ($props['actions'] as $index => $action) { + $actionErrorKey = 'actions.' . $index; + switch ($action['id']) { + case $this->ACTION_NOTIFY_AUTHORS: + $this->validateNotifyAuthorsAction($action, $actionErrorKey, $validator, $submission); + break; + } + } + } + + public function callback(Decision $decision, Submission $submission, User $editor, Context $context, array $actions) + { + parent::callback($decision, $submission, $editor, $context, $actions); + + foreach ($actions as $action) { + switch ($action['id']) { + case $this->ACTION_NOTIFY_AUTHORS: + $this->sendAuthorEmail( + new DecisionInitialDeclineNotifyAuthor($context, $submission, $decision), + $this->getEmailDataFromAction($action), + $editor, + $submission + ); + break; + } + } + } + + public function getWorkflow(Submission $submission, Context $context, User $editor, ?ReviewRound $reviewRound): Workflow + { + $workflow = new Workflow($this, $submission, $context); + + $fakeDecision = $this->getFakeDecision($submission, $editor); + $fileAttachers = $this->getFileAttachers($submission, $context); + + $authors = $workflow->getStageParticipants(Role::ROLE_ID_AUTHOR); + if (count($authors)) { + $mailable = new DecisionInitialDeclineNotifyAuthor($context, $submission, $fakeDecision); + $workflow->addStep(new Email( + $this->ACTION_NOTIFY_AUTHORS, + __('editor.submission.decision.notifyAuthors'), + __('editor.submission.decision.decline.notifyAuthorsDescription'), + $authors, + $mailable + ->sender($editor) + ->recipients($authors), + $context->getSupportedFormLocales(), + $fileAttachers + )); + } + + return $workflow; + } +} diff --git a/classes/decision/types/NewExternalReviewRound.inc.php b/classes/decision/types/NewExternalReviewRound.inc.php new file mode 100644 index 00000000000..19847533653 --- /dev/null +++ b/classes/decision/types/NewExternalReviewRound.inc.php @@ -0,0 +1,165 @@ + $submission->getLocalizedFullTitle()]); + } + + public function validate(array $props, Submission $submission, Context $context, Validator $validator, ?int $reviewRoundId = null) + { + // If there is no review round id, a validation error will already have been set + if (!$reviewRoundId) { + return; + } + + parent::validate($props, $submission, $context, $validator, $reviewRoundId); + + foreach ($props['actions'] as $index => $action) { + $actionErrorKey = 'actions.' . $index; + switch ($action['id']) { + case $this->ACTION_NOTIFY_AUTHORS: + $this->validateNotifyAuthorsAction($action, $actionErrorKey, $validator, $submission); + break; + } + } + } + + public function callback(Decision $decision, Submission $submission, User $editor, Context $context, array $actions) + { + /** @var ReviewRoundDAO $reviewRoundDao */ + $reviewRoundDao = DAORegistry::getDAO('ReviewRoundDAO'); + /** @var ReviewRound $reviewRound */ + $reviewRound = $reviewRoundDao->getLastReviewRoundBySubmissionId($submission->getId(), $this->getNewStageId()); + $this->createReviewRound($submission, $this->getStageId(), $reviewRound->getRound() + 1); + + parent::callback($decision, $submission, $editor, $context, $actions); + + foreach ($actions as $action) { + switch ($action['id']) { + case $this->ACTION_NOTIFY_AUTHORS: + $this->sendAuthorEmail( + new DecisionNewReviewRoundNotifyAuthor($context, $submission, $decision), + $this->getEmailDataFromAction($action), + $editor, + $submission + ); + break; + } + } + } + + public function getWorkflow(Submission $submission, Context $context, User $editor, ?ReviewRound $reviewRound): Workflow + { + $workflow = new Workflow($this, $submission, $context, $reviewRound); + + $fakeDecision = $this->getFakeDecision($submission, $editor, $reviewRound); + $fileAttachers = $this->getFileAttachers($submission, $context, $reviewRound); + + $authors = $workflow->getStageParticipants(Role::ROLE_ID_AUTHOR); + if (count($authors)) { + $mailable = new DecisionNewReviewRoundNotifyAuthor($context, $submission, $fakeDecision); + $workflow->addStep(new Email( + $this->ACTION_NOTIFY_AUTHORS, + __('editor.submission.decision.notifyAuthors'), + __('editor.submission.decision.newReviewRound.notifyAuthorsDescription'), + $authors, + $mailable + ->sender($editor) + ->recipients($authors), + $context->getSupportedFormLocales(), + $fileAttachers + )); + } + + $workflow->addStep((new PromoteFiles( + 'promoteFilesToReviewRound', + __('editor.submission.selectFiles'), + __('editor.submission.decision.promoteFiles.review'), + SubmissionFile::SUBMISSION_FILE_REVIEW_FILE, + $submission + ))->addFileList( + __('editor.submission.revisions'), + SubmissionFile::SUBMISSION_FILE_REVIEW_REVISION, + $reviewRound + )); + + return $workflow; + } +} diff --git a/classes/decision/types/RecommendAccept.inc.php b/classes/decision/types/RecommendAccept.inc.php new file mode 100644 index 00000000000..5b4822f7182 --- /dev/null +++ b/classes/decision/types/RecommendAccept.inc.php @@ -0,0 +1,71 @@ + $submission->getLocalizedFullTitle()]); + } + + public function validate(array $props, Submission $submission, Context $context, Validator $validator, ?int $reviewRoundId = null) + { + // If there is no review round id, a validation error will already have been set + if (!$reviewRoundId) { + return; + } + + parent::validate($props, $submission, $context, $validator, $reviewRoundId); + + foreach ($props['actions'] as $index => $action) { + $actionErrorKey = 'actions.' . $index; + switch ($action['id']) { + case $this->ACTION_NOTIFY_AUTHORS: + $this->validateNotifyAuthorsAction($action, $actionErrorKey, $validator, $submission); + break; + case $this->ACTION_NOTIFY_REVIEWERS: + $this->validateNotifyReviewersAction($action, $actionErrorKey, $validator, $submission, $reviewRoundId); + break; + } + } + } + + public function callback(Decision $decision, Submission $submission, User $editor, Context $context, array $actions) + { + parent::callback($decision, $submission, $editor, $context, $actions); + + foreach ($actions as $action) { + switch ($action['id']) { + case $this->ACTION_NOTIFY_AUTHORS: + $reviewAssignments = $this->getCompletedReviewAssignments($submission->getId(), $decision->getData('reviewRoundId')); + $emailData = $this->getEmailDataFromAction($action); + $this->sendAuthorEmail( + new DecisionRequestRevisionsNotifyAuthor($context, $submission, $decision, $reviewAssignments), + $emailData, + $editor, + $submission + ); + $this->shareReviewAttachmentFiles($emailData->attachments, $submission, $decision->getData('reviewRoundId')); + break; + case $this->ACTION_NOTIFY_REVIEWERS: + $this->sendReviewersEmail( + new DecisionNotifyReviewer($context, $submission, $decision), + $this->getEmailDataFromAction($action), + $editor, + $submission + ); + break; + } + } + } + + public function getWorkflow(Submission $submission, Context $context, User $editor, ?ReviewRound $reviewRound): Workflow + { + $workflow = new Workflow($this, $submission, $context, $reviewRound); + + $fakeDecision = $this->getFakeDecision($submission, $editor, $reviewRound); + $fileAttachers = $this->getFileAttachers($submission, $context, $reviewRound); + $reviewAssignments = $this->getCompletedReviewAssignments($submission->getId(), $reviewRound->getId()); + + $authors = $workflow->getStageParticipants(Role::ROLE_ID_AUTHOR); + if (count($authors)) { + $mailable = new DecisionRequestRevisionsNotifyAuthor($context, $submission, $fakeDecision, $reviewAssignments); + $workflow->addStep(new Email( + $this->ACTION_NOTIFY_AUTHORS, + __('editor.submission.decision.notifyAuthors'), + __('editor.submission.decision.requestRevisions.notifyAuthorsDescription'), + $authors, + $mailable + ->sender($editor) + ->recipients($authors), + $context->getSupportedFormLocales(), + $fileAttachers + )); + } + + if (count($reviewAssignments)) { + $reviewers = $workflow->getReviewersFromAssignments($reviewAssignments); + $mailable = new DecisionNotifyReviewer($context, $submission, $fakeDecision); + $workflow->addStep((new Email( + $this->ACTION_NOTIFY_REVIEWERS, + __('editor.submission.decision.notifyReviewers'), + __('editor.submission.decision.notifyReviewers.description'), + $reviewers, + $mailable->sender($editor), + $context->getSupportedFormLocales(), + $fileAttachers + ))->canChangeTo(true)); + } + + return $workflow; + } +} diff --git a/classes/decision/types/Resubmit.inc.php b/classes/decision/types/Resubmit.inc.php new file mode 100644 index 00000000000..dcb36ff89ae --- /dev/null +++ b/classes/decision/types/Resubmit.inc.php @@ -0,0 +1,175 @@ + $submission->getLocalizedFullTitle()]); + } + + public function validate(array $props, Submission $submission, Context $context, Validator $validator, ?int $reviewRoundId = null) + { + // If there is no review round id, a validation error will already have been set + if (!$reviewRoundId) { + return; + } + + parent::validate($props, $submission, $context, $validator, $reviewRoundId); + + foreach ($props['actions'] as $index => $action) { + $actionErrorKey = 'actions.' . $index; + switch ($action['id']) { + case $this->ACTION_NOTIFY_AUTHORS: + $this->validateNotifyAuthorsAction($action, $actionErrorKey, $validator, $submission); + break; + case $this->ACTION_NOTIFY_REVIEWERS: + $this->validateNotifyReviewersAction($action, $actionErrorKey, $validator, $submission, $reviewRoundId); + break; + } + } + } + + public function callback(Decision $decision, Submission $submission, User $editor, Context $context, array $actions) + { + parent::callback($decision, $submission, $editor, $context, $actions); + + foreach ($actions as $action) { + switch ($action['id']) { + case $this->ACTION_NOTIFY_AUTHORS: + $reviewAssignments = $this->getCompletedReviewAssignments($submission->getId(), $decision->getData('reviewRoundId')); + $emailData = $this->getEmailDataFromAction($action); + $this->sendAuthorEmail( + new DecisionResubmitNotifyAuthor($context, $submission, $decision, $reviewAssignments), + $this->getEmailDataFromAction($action), + $editor, + $submission + ); + $this->shareReviewAttachmentFiles($emailData->attachments, $submission, $decision->getData('reviewRoundId')); + break; + case $this->ACTION_NOTIFY_REVIEWERS: + $this->sendReviewersEmail( + new DecisionNotifyReviewer($context, $submission, $decision), + $this->getEmailDataFromAction($action), + $editor, + $submission + ); + break; + } + } + } + + public function getWorkflow(Submission $submission, Context $context, User $editor, ?ReviewRound $reviewRound): Workflow + { + $workflow = new Workflow($this, $submission, $context, $reviewRound); + + $fakeDecision = $this->getFakeDecision($submission, $editor, $reviewRound); + $fileAttachers = $this->getFileAttachers($submission, $context, $reviewRound); + $reviewAssignments = $this->getCompletedReviewAssignments($submission->getId(), $reviewRound->getId()); + + $authors = $workflow->getStageParticipants(Role::ROLE_ID_AUTHOR); + if (count($authors)) { + $mailable = new DecisionResubmitNotifyAuthor($context, $submission, $fakeDecision, $reviewAssignments); + $workflow->addStep(new Email( + $this->ACTION_NOTIFY_AUTHORS, + __('editor.submission.decision.notifyAuthors'), + __('editor.submission.decision.requestRevisions.notifyAuthorsDescription'), + $authors, + $mailable + ->sender($editor) + ->recipients($authors), + $context->getSupportedFormLocales(), + $fileAttachers + )); + } + + if (count($reviewAssignments)) { + $reviewers = $workflow->getReviewersFromAssignments($reviewAssignments); + $mailable = new DecisionNotifyReviewer($context, $submission, $fakeDecision); + $workflow->addStep((new Email( + $this->ACTION_NOTIFY_REVIEWERS, + __('editor.submission.decision.notifyReviewers'), + __('editor.submission.decision.notifyReviewers.description'), + $reviewers, + $mailable->sender($editor), + $context->getSupportedFormLocales(), + $fileAttachers + ))->canChangeTo(true)); + } + + return $workflow; + } +} diff --git a/classes/decision/types/RevertDecline.inc.php b/classes/decision/types/RevertDecline.inc.php new file mode 100644 index 00000000000..2272fc5c5bc --- /dev/null +++ b/classes/decision/types/RevertDecline.inc.php @@ -0,0 +1,141 @@ + $submission->getLocalizedFullTitle()]); + } + + public function validate(array $props, Submission $submission, Context $context, Validator $validator, ?int $reviewRoundId = null) + { + // If there is no review round id, a validation error will already have been set + if (!$reviewRoundId) { + return; + } + + foreach ($props['actions'] as $index => $action) { + $actionErrorKey = 'actions.' . $index; + switch ($action['id']) { + case $this->ACTION_NOTIFY_AUTHORS: + $this->validateNotifyAuthorsAction($action, $actionErrorKey, $validator, $submission); + break; + } + } + } + + public function callback(Decision $decision, Submission $submission, User $editor, Context $context, array $actions) + { + parent::callback($decision, $submission, $editor, $context, $actions); + + foreach ($actions as $action) { + switch ($action['id']) { + case $this->ACTION_NOTIFY_AUTHORS: + $this->sendAuthorEmail( + new DecisionRevertDeclineNotifyAuthor($context, $submission, $decision), + $this->getEmailDataFromAction($action), + $editor, + $submission + ); + break; + } + } + } + + public function getWorkflow(Submission $submission, Context $context, User $editor, ?ReviewRound $reviewRound): Workflow + { + $workflow = new Workflow($this, $submission, $context, $reviewRound); + + $fakeDecision = $this->getFakeDecision($submission, $editor, $reviewRound); + $fileAttachers = $this->getFileAttachers($submission, $context, $reviewRound); + + $authors = $workflow->getStageParticipants(Role::ROLE_ID_AUTHOR); + if (count($authors)) { + $mailable = new DecisionRevertDeclineNotifyAuthor($context, $submission, $fakeDecision); + $workflow->addStep(new Email( + $this->ACTION_NOTIFY_AUTHORS, + __('editor.submission.decision.notifyAuthors'), + __('editor.submission.decision.revertDecline.notifyAuthorsDescription'), + $authors, + $mailable + ->sender($editor) + ->recipients($authors), + $context->getSupportedFormLocales(), + $fileAttachers + )); + } + + return $workflow; + } +} diff --git a/classes/decision/types/RevertInitialDecline.inc.php b/classes/decision/types/RevertInitialDecline.inc.php new file mode 100644 index 00000000000..c63a1f72315 --- /dev/null +++ b/classes/decision/types/RevertInitialDecline.inc.php @@ -0,0 +1,138 @@ + $submission->getLocalizedFullTitle()]); + } + + public function validate(array $props, Submission $submission, Context $context, Validator $validator, ?int $reviewRoundId = null) + { + parent::validate($props, $submission, $context, $validator, $reviewRoundId); + + foreach ($props['actions'] as $index => $action) { + $actionErrorKey = 'actions.' . $index; + switch ($action['id']) { + case $this->ACTION_NOTIFY_AUTHORS: + $this->validateNotifyAuthorsAction($action, $actionErrorKey, $validator, $submission); + break; + } + } + } + + public function callback(Decision $decision, Submission $submission, User $editor, Context $context, array $actions) + { + parent::callback($decision, $submission, $editor, $context, $actions); + + foreach ($actions as $action) { + switch ($action['id']) { + case $this->ACTION_NOTIFY_AUTHORS: + $this->sendAuthorEmail( + new DecisionRevertInitialDeclineNotifyAuthor($context, $submission, $decision), + $this->getEmailDataFromAction($action), + $editor, + $submission + ); + break; + } + } + } + + public function getWorkflow(Submission $submission, Context $context, User $editor, ?ReviewRound $reviewRound): Workflow + { + $workflow = new Workflow($this, $submission, $context); + + $fakeDecision = $this->getFakeDecision($submission, $editor); + $fileAttachers = $this->getFileAttachers($submission, $context); + + $authors = $workflow->getStageParticipants(Role::ROLE_ID_AUTHOR); + if (count($authors)) { + $mailable = new DecisionRevertInitialDeclineNotifyAuthor($context, $submission, $fakeDecision); + $workflow->addStep(new Email( + $this->ACTION_NOTIFY_AUTHORS, + __('editor.submission.decision.notifyAuthors'), + __('editor.submission.decision.revertDecline.notifyAuthorsDescription'), + $authors, + $mailable + ->sender($editor) + ->recipients($authors), + $context->getSupportedFormLocales(), + $fileAttachers + )); + } + + return $workflow; + } +} diff --git a/classes/decision/types/SendExternalReview.inc.php b/classes/decision/types/SendExternalReview.inc.php new file mode 100644 index 00000000000..0deb03978ba --- /dev/null +++ b/classes/decision/types/SendExternalReview.inc.php @@ -0,0 +1,161 @@ + $submission->getLocalizedFullTitle()]); + } + + public function validate(array $props, Submission $submission, Context $context, Validator $validator, ?int $reviewRoundId = null) + { + parent::validate($props, $submission, $context, $validator, $reviewRoundId); + + foreach ($props['actions'] as $index => $action) { + $actionErrorKey = 'actions.' . $index; + switch ($action['id']) { + case $this->ACTION_NOTIFY_AUTHORS: + $this->validateNotifyAuthorsAction($action, $actionErrorKey, $validator, $submission); + break; + } + } + } + + public function callback(Decision $decision, Submission $submission, User $editor, Context $context, array $actions) + { + parent::callback($decision, $submission, $editor, $context, $actions); + + foreach ($actions as $action) { + switch ($action['id']) { + case $this->ACTION_NOTIFY_AUTHORS: + $this->sendAuthorEmail( + new DecisionSendExternalReviewNotifyAuthor($context, $submission, $decision), + $this->getEmailDataFromAction($action), + $editor, + $submission + ); + break; + } + } + } + + public function getWorkflow(Submission $submission, Context $context, User $editor, ?ReviewRound $reviewRound): Workflow + { + $workflow = new Workflow($this, $submission, $context); + + $fakeDecision = $this->getFakeDecision($submission, $editor); + $fileAttachers = $this->getFileAttachers($submission, $context); + + $authors = $workflow->getStageParticipants(Role::ROLE_ID_AUTHOR); + if (count($authors)) { + $mailable = new DecisionSendExternalReviewNotifyAuthor($context, $submission, $fakeDecision); + $workflow->addStep(new Email( + $this->ACTION_NOTIFY_AUTHORS, + __('editor.submission.decision.notifyAuthors'), + __('editor.submission.decision.sendExternalReview.notifyAuthorsDescription'), + $authors, + $mailable + ->sender($editor) + ->recipients($authors), + $context->getSupportedFormLocales(), + $fileAttachers + )); + } + + $workflow->addStep((new PromoteFiles( + 'promoteFilesToReview', + __('editor.submission.selectFiles'), + __('editor.submission.decision.promoteFiles.externalReview'), + SubmissionFile::SUBMISSION_FILE_REVIEW_FILE, + $submission + ))->addFileList(__('submission.submit.submissionFiles'), SubmissionFile::SUBMISSION_FILE_SUBMISSION)); + + return $workflow; + } + + /** + * Get the submission file stages that are permitted to be attached to emails + * sent in this decision + * + * @return array + */ + protected function getAllowedAttachmentFileStages(): array + { + return [ + SubmissionFile::SUBMISSION_FILE_SUBMISSION, + ]; + } +} diff --git a/classes/decision/types/SendToProduction.inc.php b/classes/decision/types/SendToProduction.inc.php new file mode 100644 index 00000000000..311737be152 --- /dev/null +++ b/classes/decision/types/SendToProduction.inc.php @@ -0,0 +1,197 @@ + $submission->getLocalizedFullTitle()]); + } + + public function validate(array $props, Submission $submission, Context $context, Validator $validator, ?int $reviewRoundId = null) + { + parent::validate($props, $submission, $context, $validator, $reviewRoundId); + + foreach ($props['actions'] as $index => $action) { + $actionErrorKey = 'actions.' . $index; + switch ($action['id']) { + case $this->ACTION_NOTIFY_AUTHORS: + $this->validateNotifyAuthorsAction($action, $actionErrorKey, $validator, $submission); + break; + } + } + } + + public function callback(Decision $decision, Submission $submission, User $editor, Context $context, array $actions) + { + parent::callback($decision, $submission, $editor, $context, $actions); + + foreach ($actions as $action) { + switch ($action['id']) { + case $this->ACTION_NOTIFY_AUTHORS: + $this->sendAuthorEmail( + new DecisionSendToProductionNotifyAuthor($context, $submission, $decision), + $this->getEmailDataFromAction($action), + $editor, + $submission + ); + break; + } + } + } + + public function getWorkflow(Submission $submission, Context $context, User $editor, ?ReviewRound $reviewRound): Workflow + { + $workflow = new Workflow($this, $submission, $context); + + $fakeDecision = $this->getFakeDecision($submission, $editor); + $fileAttachers = $this->getFileAttachers($submission, $context); + + $authors = $workflow->getStageParticipants(Role::ROLE_ID_AUTHOR); + if (count($authors)) { + $mailable = new DecisionSendToProductionNotifyAuthor($context, $submission, $fakeDecision); + $workflow->addStep(new Email( + $this->ACTION_NOTIFY_AUTHORS, + __('editor.submission.decision.notifyAuthors'), + __('editor.submission.decision.sendToProduction.notifyAuthorsDescription'), + $authors, + $mailable + ->sender($editor) + ->recipients($authors), + $context->getSupportedFormLocales(), + $fileAttachers + )); + } + + return $workflow; + } + + /** + * Get the submission file stages that are permitted to be attached to emails + * sent in this decision + * + * @return array + */ + protected function getAllowedAttachmentFileStages(): array + { + return [ + SubmissionFile::SUBMISSION_FILE_FINAL, + SubmissionFile::SUBMISSION_FILE_COPYEDIT, + ]; + } + + /** + * Get the file attacher components supported for emails in this decision + */ + protected function getFileAttachers(Submission $submission, Context $context): array + { + $attachers = [ + new Upload( + $context, + __('common.upload.addFile'), + __('common.upload.addFile.description'), + __('common.upload.addFile') + ), + ]; + + $attachers[] = (new FileStage( + $context, + $submission, + __('submission.submit.submissionFiles'), + __('email.addAttachment.submissionFiles.submissionDescription'), + __('email.addAttachment.submissionFiles.attach') + )) + ->withFileStage( + SubmissionFile::SUBMISSION_FILE_COPYEDIT, + __('submission.copyedited') + ) + ->withFileStage( + SubmissionFile::SUBMISSION_FILE_FINAL, + __('submission.finalDraft') + ); + + $attachers[] = new Library( + $context, + $submission + ); + + return $attachers; + } +} diff --git a/classes/decision/types/SkipReview.inc.php b/classes/decision/types/SkipReview.inc.php new file mode 100644 index 00000000000..caf43d45616 --- /dev/null +++ b/classes/decision/types/SkipReview.inc.php @@ -0,0 +1,168 @@ + $submission->getLocalizedFullTitle()]); + } + + public function validate(array $props, Submission $submission, Context $context, Validator $validator, ?int $reviewRoundId = null) + { + parent::validate($props, $submission, $context, $validator, $reviewRoundId); + + foreach ($props['actions'] as $index => $action) { + $actionErrorKey = 'actions.' . $index; + switch ($action['id']) { + case self::ACTION_PAYMENT: + $this->validatePaymentAction($action, $actionErrorKey, $validator, $context); + break; + case $this->ACTION_NOTIFY_AUTHORS: + $this->validateNotifyAuthorsAction($action, $actionErrorKey, $validator, $submission); + break; + } + } + } + + public function callback(Decision $decision, Submission $submission, User $editor, Context $context, array $actions) + { + parent::callback($decision, $submission, $editor, $context, $actions); + + foreach ($actions as $action) { + switch ($action['id']) { + case self::ACTION_PAYMENT: + $this->requestPayment($submission, $editor, $context); + break; + case $this->ACTION_NOTIFY_AUTHORS: + $this->sendAuthorEmail( + new DecisionSkipReviewNotifyAuthor($context, $submission, $decision), + $this->getEmailDataFromAction($action), + $editor, + $submission + ); + break; + } + } + } + + public function getWorkflow(Submission $submission, Context $context, User $editor, ?ReviewRound $reviewRound): Workflow + { + $workflow = new Workflow($this, $submission, $context); + + $fakeDecision = $this->getFakeDecision($submission, $editor); + $fileAttachers = $this->getFileAttachers($submission, $context); + + // Request payment if configured + $paymentManager = Application::getPaymentManager($context); + if ($paymentManager->publicationEnabled()) { + $workflow->addStep($this->getPaymentForm($context)); + } + + $authors = $workflow->getStageParticipants(Role::ROLE_ID_AUTHOR); + if (count($authors)) { + $mailable = new DecisionSkipReviewNotifyAuthor($context, $submission, $fakeDecision); + $workflow->addStep(new Email( + $this->ACTION_NOTIFY_AUTHORS, + __('editor.submission.decision.notifyAuthors'), + __('editor.submission.decision.skipReview.notifyAuthorsDescription'), + $authors, + $mailable + ->sender($editor) + ->recipients($authors), + $context->getSupportedFormLocales(), + $fileAttachers + )); + } + + $workflow->addStep((new PromoteFiles( + 'promoteFilesToReview', + __('editor.submission.selectFiles'), + __('editor.submission.decision.promoteFiles.copyediting'), + SubmissionFile::SUBMISSION_FILE_FINAL, + $submission + ))->addFileList(__('submission.submit.submissionFiles'), SubmissionFile::SUBMISSION_FILE_SUBMISSION)); + + return $workflow; + } +} diff --git a/classes/decision/types/traits/InExternalReviewRound.inc.php b/classes/decision/types/traits/InExternalReviewRound.inc.php new file mode 100644 index 00000000000..ccd44d93315 --- /dev/null +++ b/classes/decision/types/traits/InExternalReviewRound.inc.php @@ -0,0 +1,161 @@ + + */ + protected function getCompletedReviewerIds(Submission $submission, int $reviewRoundId): array + { + $userIds = []; + /** @var ReviewAssignmentDAO $reviewAssignmentDao */ + $reviewAssignmentDao = DAORegistry::getDAO('ReviewAssignmentDAO'); + $reviewAssignments = $reviewAssignmentDao->getBySubmissionId( + $submission->getId(), + $reviewRoundId, + $this->getStageId() + ); + foreach ($reviewAssignments as $reviewAssignment) { + if (!in_array($reviewAssignment->getStatus(), ReviewAssignment::REVIEW_COMPLETE_STATUSES)) { + continue; + } + $userIds[] = (int) $reviewAssignment->getReviewerId(); + } + return $userIds; + } + + /** + * Get the submission file stages that are permitted to be attached to emails + * sent in this decision + * + * @return array + */ + protected function getAllowedAttachmentFileStages(): array + { + return [ + SubmissionFile::SUBMISSION_FILE_REVIEW_ATTACHMENT, + SubmissionFile::SUBMISSION_FILE_REVIEW_FILE, + SubmissionFile::SUBMISSION_FILE_REVIEW_REVISION, + ]; + } + + /** + * Get the file attacher components supported for emails in this decision + */ + protected function getFileAttachers(Submission $submission, Context $context, ?ReviewRound $reviewRound = null): array + { + $attachers = [ + new Upload( + $context, + __('common.upload.addFile'), + __('common.upload.addFile.description'), + __('common.upload.addFile') + ), + ]; + + if ($reviewRound) { + /** @var ReviewAssignmentDAO $reviewAssignmentDAO */ + $reviewAssignmentDAO = DAORegistry::getDAO('ReviewAssignmentDAO'); + $reviewAssignments = $reviewAssignmentDAO->getByReviewRoundId($reviewRound->getId()); + $reviewerFiles = []; + if (!empty($reviewAssignments)) { + $reviewerFiles = iterator_to_array( + Services::get('submissionFile')->getMany([ + 'submissionIds' => [$submission->getId()], + 'assocTypes' => [Application::ASSOC_TYPE_REVIEW_ASSIGNMENT], + 'assocIds' => array_keys($reviewAssignments), + ]) + ); + } + $attachers[] = new ReviewFiles( + __('reviewer.submission.reviewFiles'), + __('email.addAttachment.reviewFiles.description'), + __('email.addAttachment.reviewFiles.attach'), + $reviewerFiles, + $reviewAssignments, + $context + ); + } + + $attachers[] = (new FileStage( + $context, + $submission, + __('submission.submit.submissionFiles'), + __('email.addAttachment.submissionFiles.reviewDescription'), + __('email.addAttachment.submissionFiles.attach') + )) + ->withFileStage( + SubmissionFile::SUBMISSION_FILE_REVIEW_REVISION, + __('editor.submission.revisions'), + $reviewRound + )->withFileStage( + SubmissionFile::SUBMISSION_FILE_REVIEW_FILE, + __('reviewer.submission.reviewFiles'), + $reviewRound + ); + + $attachers[] = new Library( + $context, + $submission + ); + + return $attachers; + } + + /** + * Get the completed review assignments for this round + */ + protected function getCompletedReviewAssignments(int $submissionId, int $reviewRoundId): array + { + /** @var ReviewAssignmentDAO $reviewAssignmentDao */ + $reviewAssignmentDao = DAORegistry::getDAO('ReviewAssignmentDAO'); + $reviewAssignments = $reviewAssignmentDao->getBySubmissionId( + $submissionId, + $reviewRoundId, + $this->getStageId() + ); + $completedReviewAssignments = []; + foreach ($reviewAssignments as $reviewAssignment) { + if (in_array($reviewAssignment->getStatus(), ReviewAssignment::REVIEW_COMPLETE_STATUSES)) { + $completedReviewAssignments[] = $reviewAssignment; + } + } + + return $completedReviewAssignments; + } +} diff --git a/classes/decision/types/traits/InSubmissionStage.inc.php b/classes/decision/types/traits/InSubmissionStage.inc.php new file mode 100644 index 00000000000..8a0ecc6edeb --- /dev/null +++ b/classes/decision/types/traits/InSubmissionStage.inc.php @@ -0,0 +1,76 @@ + + */ + protected function getAllowedAttachmentFileStages(): array + { + return [ + SubmissionFile::SUBMISSION_FILE_SUBMISSION, + ]; + } + + /** + * Get the file attacher components supported for emails in this decision + */ + protected function getFileAttachers(Submission $submission, Context $context): array + { + $attachers = [ + new Upload( + $context, + __('common.upload.addFile'), + __('common.upload.addFile.description'), + __('common.upload.addFile') + ), + ]; + + $attachers[] = (new FileStage( + $context, + $submission, + __('submission.submit.submissionFiles'), + __('email.addAttachment.submissionFiles.submissionDescription'), + __('email.addAttachment.submissionFiles.attach') + )) + ->withFileStage( + SubmissionFile::SUBMISSION_FILE_SUBMISSION, + __('submission.submit.submissionFiles') + ); + + $attachers[] = new Library( + $context, + $submission + ); + + return $attachers; + } +} diff --git a/classes/decision/types/traits/IsRecommendation.inc.php b/classes/decision/types/traits/IsRecommendation.inc.php new file mode 100644 index 00000000000..a2d9116e390 --- /dev/null +++ b/classes/decision/types/traits/IsRecommendation.inc.php @@ -0,0 +1,198 @@ + $action) { + switch ($action['id']) { + case $this->ACTION_DISCUSSION: + $errors = $this->validateEmailAction($action, $submission, $this->getAllowedAttachmentFileStages()); + if (count($errors)) { + foreach ($errors as $key => $error) { + $validator->errors()->add('actions.' . $index . '.' . $key, $error); + } + } + break; + } + } + } + + public function callback(Decision $decision, Submission $submission, User $editor, Context $context, array $actions) + { + parent::callback($decision, $submission, $editor, $context, $actions); + + foreach ($actions as $action) { + switch ($action['id']) { + case $this->ACTION_DISCUSSION: + $this->addRecommendationQuery( + $this->getEmailDataFromAction($action), + $submission, + $editor, + $context + ); + break; + } + } + } + + public function getWorkflow(Submission $submission, Context $context, User $editor, ?ReviewRound $reviewRound): Workflow + { + $workflow = new Workflow($this, $submission, $context, $reviewRound); + + $fakeDecision = $this->getFakeDecision($submission, $editor); + $fileAttachers = $this->getFileAttachers($submission, $context, $reviewRound); + $editors = $workflow->getDecidingEditors(); + $reviewAssignments = $this->getCompletedReviewAssignments($submission->getId(), $reviewRound->getId()); + $mailable = new RecommendationNotifyEditors($context, $submission, $fakeDecision, $reviewAssignments); + + $workflow->addStep((new Email( + $this->ACTION_DISCUSSION, + __('editor.submissionReview.recordRecommendation.notifyEditors'), + __('editor.submission.recommend.notifyEditors.description'), + $editors, + $mailable + ->sender($editor) + ->recipients($editors), + $context->getSupportedFormLocales(), + $fileAttachers + ))->canSkip(false)); + + return $workflow; + } + + /** + * Create a query (discussion) among deciding editors + * and add attachments to the head note + * + * @return array + */ + protected function addRecommendationQuery(EmailData $email, Submission $submission, User $editor, Context $context) + { + /** @var QueryDAO $queryDao */ + $queryDao = DAORegistry::getDAO('QueryDAO'); + $queryId = $queryDao->addRecommendationQuery( + $editor->getId(), + $submission->getId(), + $this->getStageId(), + $email->subject, + $email->body + ); + + $query = $queryDao->getById($queryId); + $note = $query->getHeadNote(); + foreach ($email->attachments as $attachment) { + if (isset($attachment[Mailable::ATTACHMENT_TEMPORARY_FILE])) { + $temporaryFileManager = new TemporaryFileManager(); + $temporaryFile = $temporaryFileManager->getFile($attachment[Mailable::ATTACHMENT_TEMPORARY_FILE], $editor->getId()); + if (!$temporaryFile) { + throw new Exception('Could not find temporary file ' . $attachment[Mailable::ATTACHMENT_TEMPORARY_FILE] . ' to attach to the query note.'); + } + $this->addSubmissionFileToNoteFromFilePath( + $temporaryFile->getFilePath(), + $attachment['name'], + $note, + $editor, + $submission, + $context + ); + } elseif (isset($attachment[Mailable::ATTACHMENT_SUBMISSION_FILE])) { + $submissionFile = Services::get('submissionFile')->get($attachment[Mailable::ATTACHMENT_SUBMISSION_FILE]); + if (!$submissionFile || $submissionFile->getData('submissionId') !== $submission->getId()) { + throw new Exception('Could not find submission file ' . $attachment[Mailable::ATTACHMENT_SUBMISSION_FILE] . ' to attach to the query note.'); + } + $newSubmissionFile = clone $submissionFile; + $newSubmissionFile->setData('fileStage', SubmissionFile::SUBMISSION_FILE_QUERY); + $newSubmissionFile->setData('sourceSubmissionFileId', $submissionFile->getId()); + $newSubmissionFile->setData('assocType', Application::ASSOC_TYPE_NOTE); + $newSubmissionFile->setData('assocId', $note->getId()); + Services::get('submissionFile')->add($newSubmissionFile, Application::get()->getRequest()); + } elseif (isset($attachment[Mailable::ATTACHMENT_LIBRARY_FILE])) { + /** @var LibraryFileDAO $libraryFileDao */ + $libraryFileDao = DAORegistry::getDAO('LibraryFileDAO'); + /** @var LibraryFile $file */ + $libraryFile = $libraryFileDao->getById($attachment[Mailable::ATTACHMENT_LIBRARY_FILE]); + if (!$libraryFile) { + throw new Exception('Could not find library file ' . $attachment[Mailable::ATTACHMENT_LIBRARY_FILE] . ' to attach to the query note.'); + } + $this->addSubmissionFileToNoteFromFilePath( + $libraryFile->getFilePath(), + $attachment['name'], + $note, + $editor, + $submission, + $context + ); + } + } + } + + /** + * Helper function to save a file to the file system and then + * use that in a new submission file attached to the query note + */ + protected function addSubmissionFileToNoteFromFilePath(string $filepath, string $filename, Note $note, User $uploader, Submission $submission, Context $context) + { + $extension = pathinfo($filename, PATHINFO_EXTENSION); + $submissionDir = Services::get('submissionFile')->getSubmissionDir($context->getId(), $submission->getId()); + $fileId = Services::get('file')->add( + $filepath, + $submissionDir . '/' . uniqid() . '.' . $extension + ); + /** @var SubmissionFileDAO $submissionFileDao */ + $submissionFileDao = DAORegistry::getDao('SubmissionFileDAO'); + $submissionFile = $submissionFileDao->newDataObject(); + $submissionFile->setAllData([ + 'fileId' => $fileId, + 'name' => [ + AppLocale::getLocale() => $filename + ], + 'fileStage' => SubmissionFile::SUBMISSION_FILE_QUERY, + 'submissionId' => $submission->getId(), + 'uploaderUserId' => $uploader->getId(), + 'assocType' => Application::ASSOC_TYPE_NOTE, + 'assocId' => $note->getId(), + ]); + Services::get('submissionFile')->add($submissionFile, Application::get()->getRequest()); + } +} diff --git a/classes/decision/types/traits/NotifyAuthors.inc.php b/classes/decision/types/traits/NotifyAuthors.inc.php new file mode 100644 index 00000000000..18953301eb7 --- /dev/null +++ b/classes/decision/types/traits/NotifyAuthors.inc.php @@ -0,0 +1,122 @@ +validateEmailAction($action, $submission, $this->getAllowedAttachmentFileStages()); + foreach ($errors as $key => $propErrors) { + foreach ($propErrors as $propError) { + $validator->errors()->add($actionErrorKey . '.' . $key, $propError); + } + } + } + + /** + * Send the email to the author(s) + */ + protected function sendAuthorEmail(Mailable $mailable, EmailData $email, User $editor, Submission $submission) + { + $mailable = $this->addEmailDataToMailable($mailable, $editor, $email); + + $recipients = array_map(function ($userId) { + return Repo::user()->get($userId); + }, $this->getAssignedAuthorIds($submission)); + + Mail::send($mailable->recipients($recipients)); + + /** @var SubmissionEmailLogDAO $submissionEmailLogDao */ + $submissionEmailLogDao = DAORegistry::getDAO('SubmissionEmailLogDAO'); + $submissionEmailLogDao->logMailable( + SubmissionEmailLogEntry::SUBMISSION_EMAIL_EDITOR_NOTIFY_AUTHOR, + $mailable, + $submission, + $editor + ); + } + + /** + * Share reviewer file attachments with author + * + * This method looks in the email attachments for any files in the + * SubmissionFile::SUBMISSION_FILE_REVIEW_ATTACHMENT stage and sets + * their viewable flag to true. This flag makes the file visible to + * the author from the author submission dashboard. + */ + protected function shareReviewAttachmentFiles(array $attachments, Submission $submission, int $reviewRoundId) + { + if (!in_array($this->getStageId(), [WORKFLOW_STAGE_ID_INTERNAL_REVIEW, WORKFLOW_STAGE_ID_EXTERNAL_REVIEW])) { + return; + } + + $submissionFileIds = []; + foreach ($attachments as $attachment) { + if (!isset($attachment['submissionFileId'])) { + continue; + } + $submissionFileIds[] = (int) $attachment['submissionFileId']; + } + + if (empty($submissionFileIds)) { + return; + } + + $reviewAttachmentIds = Services::get('submissionFile')->getIds([ + 'submissionIds' => [$submission->getId()], + 'reviewRoundIds' => [$reviewRoundId], + 'fileStages' => [SubmissionFile::SUBMISSION_FILE_REVIEW_ATTACHMENT], + ]); + + $sharedFileIds = array_intersect($reviewAttachmentIds, $submissionFileIds); + + foreach ($sharedFileIds as $sharedFileId) { + $submissionFile = Services::get('submissionFile')->get($sharedFileId); + Services::get('submissionFile')->edit( + $submissionFile, + ['viewable' => true], + Application::get()->getRequest() + ); + } + } +} diff --git a/classes/decision/types/traits/NotifyReviewers.inc.php b/classes/decision/types/traits/NotifyReviewers.inc.php new file mode 100644 index 00000000000..33f6d9204f4 --- /dev/null +++ b/classes/decision/types/traits/NotifyReviewers.inc.php @@ -0,0 +1,92 @@ +validateEmailAction($action, $submission, $this->getAllowedAttachmentFileStages()); + foreach ($errors as $key => $propErrors) { + foreach ($propErrors as $propError) { + $validator->errors()->add($actionErrorKey . '.' . $key, $propError); + } + } + if (empty($action['to'])) { + $validator->errors()->add($actionErrorKey . '.to', __('validator.required')); + return; + } + $reviewerIds = $this->getCompletedReviewerIds($submission, $reviewRoundId); + $invalidRecipients = array_diff($action['to'], $reviewerIds); + if (count($invalidRecipients)) { + $this->setRecipientError($actionErrorKey, $invalidRecipients, $validator); + } + } + + /** + * Send the email to the reviewers + */ + protected function sendReviewersEmail(DecisionNotifyReviewer $mailable, EmailData $email, User $editor, Submission $submission) + { + $mailable = $this->addEmailDataToMailable($mailable, $editor, $email); + + $recipients = array_map(function ($userId) { + return Repo::user()->get($userId); + }, $email->to); + + foreach ($recipients as $recipient) { + Mail::send($mailable->recipients([$recipient])); + } + + SubmissionLog::logEvent( + Application::get()->getRequest(), + $submission, + SubmissionEventLogEntry::SUBMISSION_LOG_DECISION_EMAIL_SENT, + 'submission.event.decisionReviewerEmailSent', + [ + 'recipientCount' => count($recipients), + 'subject' => $email->subject, + ] + ); + } +} diff --git a/classes/decision/types/traits/RequestPayment.inc.php b/classes/decision/types/traits/RequestPayment.inc.php new file mode 100644 index 00000000000..a9e8e16e333 --- /dev/null +++ b/classes/decision/types/traits/RequestPayment.inc.php @@ -0,0 +1,88 @@ +ACTION_PAYMENT, + __('editor.article.payment.requestPayment'), + '', + new RequestPaymentDecisionForm($context) + ); + } + + /** + * Validate the decision action to request or waive payment + */ + protected function validatePaymentAction(array $action, string $actionErrorKey, Validator $validator, Context $context) + { + $paymentManager = Application::getPaymentManager($context); + if (!$paymentManager->publicationEnabled()) { + $validator->errors()->add($actionErrorKey . '.requestPayment', __('payment.requestPublicationFee.notEnabled')); + } elseif (!isset($action['requestPayment'])) { + $validator->errors()->add($actionErrorKey . '.requestPayment', __('validator.required')); + } + } + + /** + * Request payment from authors + */ + protected function requestPayment(Submission $submission, User $editor, Context $context) + { + $paymentManager = Application::getPaymentManager($context); + $queuedPayment = $paymentManager->createQueuedPayment( + Application::get()->getRequest(), + OJSPaymentManager::PAYMENT_TYPE_PUBLICATION, + $editor->getId(), + $submission->getId(), + $context->getData('publicationFee'), + $context->getData('currency') + ); + $paymentManager->queuePayment($queuedPayment); + + // Notify authors that this needs payment. + $notificationMgr = new NotificationManager(); + $authorIds = $this->getAssignedAuthorIds($submission); + foreach ($authorIds as $authorId) { + $notificationMgr->createNotification( + Application::get()->getRequest(), + $authorId, + Notification::NOTIFICATION_TYPE_PAYMENT_REQUIRED, + $context->getId(), + Application::ASSOC_TYPE_QUEUED_PAYMENT, + $queuedPayment->getId(), + Notification::NOTIFICATION_LEVEL_TASK + ); + } + } +} diff --git a/classes/decisionTempMailable/AcceptedExample.inc.php b/classes/decisionTempMailable/AcceptedExample.inc.php new file mode 100644 index 00000000000..7741783f600 --- /dev/null +++ b/classes/decisionTempMailable/AcceptedExample.inc.php @@ -0,0 +1,58 @@ + 'EDITOR_DECISION_ACCEPT', + 'subject' => [ + 'en_US' => 'Accepted', + ], + 'body' => [ + 'en_US' => 'Dear {$recipientName},

We are delighted to let you know that your submission, {$submissionTitle}, has been accepted for publication.

Sincerely, {$senderName}', + ], + 'isDefault' => true, + ], + [ + 'key' => 'ACCEPTED_CONDITIONAL', + 'subject' => [ + 'en_US' => 'Accepted With Conditions', + ], + 'body' => [ + 'en_US' => 'Dear {$recipientName},

We are ready to accept your submission, {$submissionTitle}, for publication. However, we have some conditions...

Sincerely, {$senderName}', + ], + 'isDefault' => false, + ], + [ + 'key' => 'ACCEPTED_EARLY_PUBLICATION', + 'subject' => [ + 'en_US' => 'Accepted for Early Publication', + ], + 'body' => [ + 'en_US' => 'Dear {$recipientName},

We are delighted to let you know that your submission, {$submissionTitle}, has been accepted for publication. We would like to prepare it for early publication. This means we would like to publish your recent revised version while it undergoes final copyediting.

Sincerely, {$senderName}', + ], + 'isDefault' => false, + ] + ]; + } +} diff --git a/classes/decisionTempMailable/RequestRevisionsExample.inc.php b/classes/decisionTempMailable/RequestRevisionsExample.inc.php new file mode 100644 index 00000000000..2800ea5bf42 --- /dev/null +++ b/classes/decisionTempMailable/RequestRevisionsExample.inc.php @@ -0,0 +1,58 @@ + 'EDITOR_DECISION_REVISIONS', + 'subject' => [ + 'en_US' => 'Request Revisions', + ], + 'body' => [ + 'en_US' => 'Dear {$recipientName},

...', + ], + 'isDefault' => true, + ], + [ + 'key' => 'REVISIONS_RESUBMIT', + 'subject' => [ + 'en_US' => 'Revise and Resubmit', + ], + 'body' => [ + 'en_US' => 'Dear {$recipientName},

...', + ], + 'isDefault' => false, + ], + [ + 'key' => 'REVISIONS_EDITOR_ONLY', + 'subject' => [ + 'en_US' => 'Request Revisions (Editor Only)', + ], + 'body' => [ + 'en_US' => 'Dear {$recipientName},

...', + ], + 'isDefault' => false, + ] + ]; + } +} diff --git a/classes/emailTemplate/Repository.inc.php b/classes/emailTemplate/Repository.inc.php index 7059627cc1f..23652f024a5 100644 --- a/classes/emailTemplate/Repository.inc.php +++ b/classes/emailTemplate/Repository.inc.php @@ -14,13 +14,13 @@ namespace PKP\emailTemplate; use APP\i18n\AppLocale; +use Illuminate\Support\Facades\App; use Illuminate\Support\LazyCollection; use PKP\core\PKPRequest; +use PKP\facades\Repo; use PKP\plugins\HookRegistry; use PKP\services\PKPSchemaService; -use Illuminate\Support\Facades\App; use PKP\validation\ValidatorFactory; -use PKP\facades\Repo; class Repository { @@ -57,7 +57,7 @@ public function getByKey(int $contextId, string $key): ?EmailTemplate } /** @copydoc DAO::getMany() */ - public function getMany(Collector $query): LazyCollection + public function getMany(Collector $query): LazyCollection { return $this->dao->getMany($query); } @@ -143,7 +143,7 @@ public function validate(?EmailTemplate $object, array $props, array $allowedLoc ValidatorFactory::allowedLocales($validator, $this->schemaService->getMultilingualProps($this->dao->schema), $allowedLocales); if ($validator->fails()) { - $errors = $this->schemaService->formatValidationErrors($validator->errors(), $this->schemaService->get($this->dao->schema), $allowedLocales); + $errors = $this->schemaService->formatValidationErrors($validator->errors()); } HookRegistry::call('EmailTemplate::validate', [&$errors, $object, $props, $allowedLocales, $primaryLocale]); diff --git a/classes/handler/APIHandler.inc.php b/classes/handler/APIHandler.inc.php index bbf429c1efa..714369f9927 100644 --- a/classes/handler/APIHandler.inc.php +++ b/classes/handler/APIHandler.inc.php @@ -398,6 +398,12 @@ private function _convertStringsToSchema($value, $type, $schema) break; case 'object': if (is_array($value)) { + // In some cases a property may be defined as an object but it may not + // contain specific details about that object's properties. In these cases, + // leave the properties alone. + if (!property_exists($schema, 'properties')) { + return $value; + } $newObject = []; foreach ($schema->properties as $propName => $propSchema) { if (!isset($value[$propName])) { diff --git a/classes/log/PKPSubmissionEventLogEntry.inc.php b/classes/log/PKPSubmissionEventLogEntry.inc.php index dfab52c99da..4baa8488dd2 100644 --- a/classes/log/PKPSubmissionEventLogEntry.inc.php +++ b/classes/log/PKPSubmissionEventLogEntry.inc.php @@ -31,6 +31,7 @@ class PKPSubmissionEventLogEntry extends EventLogEntry public const SUBMISSION_LOG_EDITOR_DECISION = 0x30000003; public const SUBMISSION_LOG_EDITOR_RECOMMENDATION = 0x30000004; + public const SUBMISSION_LOG_DECISION_EMAIL_SENT = 0x40000020; public const SUBMISSION_LOG_REVIEW_ASSIGN = 0x40000001; public const SUBMISSION_LOG_REVIEW_REINSTATED = 0x40000005; @@ -42,6 +43,7 @@ class PKPSubmissionEventLogEntry extends EventLogEntry public const SUBMISSION_LOG_REVIEW_READY = 0x40000018; public const SUBMISSION_LOG_REVIEW_CONFIRMED = 0x40000019; + // // Getters/setters // diff --git a/classes/log/SubmissionEmailLogDAO.inc.php b/classes/log/SubmissionEmailLogDAO.inc.php index f3fa6d5de10..4f04c20de33 100644 --- a/classes/log/SubmissionEmailLogDAO.inc.php +++ b/classes/log/SubmissionEmailLogDAO.inc.php @@ -17,6 +17,11 @@ namespace PKP\log; +use APP\submission\Submission; +use PKP\core\Core; +use PKP\mail\Mailable; +use PKP\user\User; + class SubmissionEmailLogDAO extends EmailLogDAO { /** @@ -56,6 +61,46 @@ public function getBySubmissionId($submissionId) { return $this->getByAssoc(ASSOC_TYPE_SUBMISSION, $submissionId); } + + /** + * Create a log entry from data in a Mailable class + * + * @param integer $eventType One of the SubmissionEmailLogEntry::SUBMISSION_EMAIL_* constants + * + * @return integer The new log entry id + */ + public function logMailable(int $eventType, Mailable $mailable, Submission $submission, ?User $sender = null): int + { + $entry = $this->newDataObject(); + $entry->setEventType($eventType); + $entry->setAssocId($submission->getId()); + $entry->setDateSent(Core::getCurrentDate()); + $entry->setSenderId($sender ? $sender->getId() : 0); + $entry->setSubject($mailable->subject); + $entry->setBody($mailable->render()); + $entry->setFrom($this->getContactString($mailable->from)); + $entry->setRecipients($this->getContactString($mailable->to)); + $entry->setCcs($this->getContactString($mailable->cc)); + $entry->setBccs($this->getContactString($mailable->bcc)); + + return $this->insertObject($entry); + } + + /** + * Get the from or to data as a string + * + * @param array $addressees Expects Mailable::$to or Mailable::$from + */ + protected function getContactString(array $addressees): string + { + $contactStrings = []; + foreach ($addressees as $addressee) { + $contactStrings[] = isset($addressee['name']) + ? '"' . $addressee['name'] . '" <' . $addressee['address'] . '>' + : $addressee['address']; + } + return join(', ', $contactStrings); + } } if (!PKP_STRICT_MODE) { diff --git a/classes/mail/EmailData.inc.php b/classes/mail/EmailData.inc.php new file mode 100644 index 00000000000..1a6b140b56c --- /dev/null +++ b/classes/mail/EmailData.inc.php @@ -0,0 +1,79 @@ + 1, 'name' => 'example.docx'] + * ['submissionFileId' => 2, 'name' => 'other.pdf'] + * ] + * + * @param array[] + */ + public array $attachments = []; + + /** + * Instantiate an object from an assoc array of request data + * + * @param array $args [ + */ + public function __construct(array $args = []) + { + foreach ($args as $key => $value) { + if (property_exists(EmailData::class, $key)) { + $this->{$key} = $value; + } + } + } +} diff --git a/classes/mail/FormEmailData.inc.php b/classes/mail/FormEmailData.inc.php deleted file mode 100644 index facf494d71c..00000000000 --- a/classes/mail/FormEmailData.inc.php +++ /dev/null @@ -1,99 +0,0 @@ -body = $body; - return $this; - } - - public function setSubject(?string $subject): FormEmailData - { - if ($subject) { - $this->subject = $subject; - } - return $this; - } - - public function skipEmail(bool $skip): FormEmailData - { - $this->skipEmail = $skip; - return $this; - } - - public function setRecipientIds(array $userIds): FormEmailData - { - $this->recipientIds = $userIds; - return $this; - } - - public function setSenderId(int $userId): FormEmailData - { - $this->senderId = $userId; - return $this; - } - - public function addVariables(array $variablesToAdd): FormEmailData - { - $this->variables = $variablesToAdd + $this->variables; - return $this; - } - - public function shouldBeSkipped() - { - return $this->skipEmail; - } - - public function getRecipients(int $contextId): LazyCollection - { - return Repo::user()->getMany( - Repo::user()->getCollector() - ->filterByUserIds($this->recipientIds) - ->filterByContextIds([$contextId]) - ); - } - - public function getSender(): ?User - { - return Repo::user()->get($this->senderId); - } - - public function getVariables(int $userId = null): array - { - return $userId ? $this->variables[$userId] : $this->variables; - } -} diff --git a/classes/mail/Mail.inc.php b/classes/mail/Mail.inc.php index 048196395f4..3a4a645a3e0 100644 --- a/classes/mail/Mail.inc.php +++ b/classes/mail/Mail.inc.php @@ -27,7 +27,6 @@ use PHPMailer\PHPMailer\OAuth; use PHPMailer\PHPMailer\PHPMailer; -use PHPMailer\PHPMailer\SMTP; use PKP\config\Config; use PKP\core\PKPString; diff --git a/classes/mail/Mailable.inc.php b/classes/mail/Mailable.inc.php index c8fc67c664b..3a405596d65 100644 --- a/classes/mail/Mailable.inc.php +++ b/classes/mail/Mailable.inc.php @@ -27,23 +27,32 @@ namespace PKP\mail; +use APP\core\Services; use APP\i18n\AppLocale; +use APP\mail\variables\ContextEmailVariable; use BadMethodCallException; use Exception; use Illuminate\Mail\Mailable as IlluminateMailable; use InvalidArgumentException; +use PKP\config\Config; use PKP\context\Context; -use APP\mail\variables\ContextEmailVariable; +use PKP\context\LibraryFile; +use PKP\context\LibraryFileDAO; +use PKP\db\DAORegistry; +use PKP\decision\Decision; +use PKP\file\TemporaryFileManager; +use PKP\mail\traits\Recipient; +use PKP\mail\traits\Sender; +use PKP\mail\variables\DecisionEmailVariable; use PKP\mail\variables\QueuedPaymentEmailVariable; use PKP\mail\variables\RecipientEmailVariable; use PKP\mail\variables\ReviewAssignmentEmailVariable; use PKP\mail\variables\SenderEmailVariable; use PKP\mail\variables\SiteEmailVariable; -use PKP\mail\variables\StageAssignmentEmailVariable; use PKP\mail\variables\SubmissionEmailVariable; +use PKP\mail\variables\Variable; use PKP\payment\QueuedPayment; use PKP\site\Site; -use PKP\stageAssignment\StageAssignment; use PKP\submission\PKPSubmission; use PKP\submission\reviewAssignment\ReviewAssignment; use ReflectionClass; @@ -52,9 +61,9 @@ class Mailable extends IlluminateMailable { - /** * Name of the variable representing a message object assigned to email templates by Illuminate Mailer by default + * * @var string */ public const DATA_KEY_MESSAGE = 'message'; @@ -65,12 +74,16 @@ class Mailable extends IlluminateMailable public const GROUP_COPYEDITING = 'copyediting'; public const GROUP_PRODUCTION = 'production'; + public const ATTACHMENT_TEMPORARY_FILE = 'temporaryFileId'; + public const ATTACHMENT_SUBMISSION_FILE = 'submissionFileId'; + public const ATTACHMENT_LIBRARY_FILE = 'libraryFileId'; + /** * The email variables handled by this mailable * * @param array */ - protected array $variables = []; + public array $variables = []; /** * One or more groups this mailable should be included in @@ -99,7 +112,7 @@ public function __construct(array $variables = []) /** * Add data for this email */ - public function addData(array $data) : self + public function addData(array $data): self { $this->viewData = array_merge($this->viewData, $data); return $this; @@ -132,6 +145,7 @@ public function setData(?string $locale = null) /** * @param string $localeKey + * * @throws BadMethodCallException */ public function locale($localeKey) @@ -142,18 +156,20 @@ public function locale($localeKey) /** * Use instead of the \Illuminate\Mail\Mailable::view() to compile template message's body + * * @param string $view HTML string with template variables */ - public function body(string $view) : self + public function body(string $view): self { return parent::view($view, []); } /** * Doesn't support Illuminate markdown + * * @throws BadMethodCallException */ - public function markdown($view, array $data = []) : self + public function markdown($view, array $data = []): self { throw new BadMethodCallException('Markdown isn\'t supported'); } @@ -161,26 +177,29 @@ public function markdown($view, array $data = []) : self /** * @return array [self::GROUP_...] workflow stages associated with a mailable */ - public static function getGroupIds() : array + public static function getGroupIds(): array { return static::$groupIds; } /** * Method's implementation is required for Mailable to be sent according to Laravel docs + * * @see \Illuminate\Mail\Mailable::send(), https://laravel.com/docs/7.x/mail#writing-mailables */ - public function build() : self + public function build(): self { return $this; } /** * Allow data to be passed to the subject + * * @param \Illuminate\Mail\Message $message + * * @throws Exception */ - protected function buildSubject($message) : self + protected function buildSubject($message): self { if (!$this->subject) { throw new Exception('Subject isn\'t specified in ' . static::class); @@ -195,25 +214,26 @@ protected function buildSubject($message) : self /** * Returns variables map associated with a specific object, * variables names should be unique + * * @return string[] */ - protected static function templateVariablesMap() : array + protected static function templateVariablesMap(): array { return [ - Site::class => SiteEmailVariable::class, Context::class => ContextEmailVariable::class, + Decision::class => DecisionEmailVariable::class, PKPSubmission::class => SubmissionEmailVariable::class, ReviewAssignment::class => ReviewAssignmentEmailVariable::class, - StageAssignment::class => StageAssignmentEmailVariable::class, QueuedPayment::class => QueuedPaymentEmailVariable::class, + Site::class => SiteEmailVariable::class, ]; } /** * Scans arguments to retrieve variables which can be assigned to the template of the email */ - protected function setupVariables(array $variables) : void + protected function setupVariables(array $variables): void { $map = static::templateVariablesMap(); foreach ($variables as $variable) { @@ -234,7 +254,7 @@ protected function setupVariables(array $variables) : void * * @return array ['variableName' => description] */ - public static function getDataDescriptions() : array + public static function getDataDescriptions(): array { $args = static::getParamsClass(static::getConstructor()); $map = static::templateVariablesMap(); @@ -260,13 +280,13 @@ public static function getDataDescriptions() : array if (array_key_exists(Recipient::class, $traits)) { $descriptions = array_merge( $descriptions, - RecipientEmailVariable::getDescription(), + RecipientEmailVariable::descriptions(), ); } if (array_key_exists(Sender::class, $traits)) { $descriptions = array_merge( $descriptions, - SenderEmailVariable::getDescription(), + SenderEmailVariable::descriptions(), ); } } @@ -281,7 +301,7 @@ public static function getDataDescriptions() : array // No special treatment for others $descriptions = array_merge( $descriptions, - $map[$class]::getDescription() + $map[$class]::descriptions() ); } @@ -291,7 +311,7 @@ public static function getDataDescriptions() : array /** * @see self::getTemplateVarsDescription */ - protected static function getConstructor() : ReflectionMethod + protected static function getConstructor(): ReflectionMethod { $constructor = (new ReflectionClass(static::class))->getConstructor(); if (!$constructor) { @@ -303,9 +323,10 @@ protected static function getConstructor() : ReflectionMethod /** * Retrieves arguments of the specified methods + * * @see self::getTemplateVarsDescription */ - protected static function getParamsClass(ReflectionMethod $method) : array + protected static function getParamsClass(ReflectionMethod $method): array { $params = $method->getParameters(); if (empty($params)) { @@ -320,4 +341,51 @@ protected static function getParamsClass(ReflectionMethod $method) : array } return $params; } + + /** + * Attach a temporary file + */ + public function attachTemporaryFile(string $id, string $name, int $uploaderId): self + { + $temporaryFileManager = new TemporaryFileManager(); + $file = $temporaryFileManager->getFile($id, $uploaderId); + if (!$file) { + throw new Exception('Tried to attach temporary file ' . $id . ' that does not exist.'); + } + $this->attach($file->getFilePath(), ['as' => $name]); + return $this; + } + + /** + * Attach a submission file + */ + public function attachSubmissionFile(int $id, string $name): self + { + $submissionFile = Services::get('submissionFile')->get($id); + if (!$submissionFile) { + throw new Exception('Tried to attach submission file ' . $id . ' that does not exist.'); + } + $file = Services::get('file')->get($submissionFile->getData('fileId')); + $this->attach( + Config::getVar('files', 'files_dir') . '/' . $file->path, + [ + 'as' => $name, + 'mime' => $file->mimetype, + ] + ); + return $this; + } + + /** + * Attach a library file + */ + public function attachLibraryFile(int $id, string $name): self + { + /** @var LibraryFileDAO $libraryFileDao */ + $libraryFileDao = DAORegistry::getDAO('LibraryFileDAO'); + /** @var LibraryFile $file */ + $file = $libraryFileDao->getById($id); + $this->attach($file->getFilePath(), ['as' => $name]); + return $this; + } } diff --git a/classes/mail/Mailer.inc.php b/classes/mail/Mailer.inc.php index bb9ed6ad408..814934aaebb 100644 --- a/classes/mail/Mailer.inc.php +++ b/classes/mail/Mailer.inc.php @@ -33,18 +33,16 @@ class Mailer extends IlluminateMailer { - const OPEN_TAG = '{'; - const CLOSING_TAG = '}'; - const DOLLAR_SIGN_TAG = '$'; - /** * Don't bind Laravel View Service, as it's not implemented + * * @var null */ protected $views = null; /** * Creates new Mailer instance without binding with View + * * @copydoc \Illuminate\Mail\Mailer::__construct() */ public function __construct(string $name, Swift_Mailer $swift, Dispatcher $events = null) @@ -56,12 +54,15 @@ public function __construct(string $name, Swift_Mailer $swift, Dispatcher $event /** * Renders email content into HTML string + * * @param string $view * @param array $data variable => value, 'message' is reserved for the Laravel's Swift Message (Illuminate\Mail\Message) + * * @throws Exception + * * @see \Illuminate\Mail\Mailer::renderView() */ - protected function renderView($view, $data) : string + protected function renderView($view, $data): string { if ($view instanceof Htmlable) { // return HTML without data compiling @@ -77,11 +78,13 @@ protected function renderView($view, $data) : string /** * Compiles email templates by substituting variables with their real values + * * @param string $view text or HTML string * @param array $data variables with their values passes ['variable' => value] - * @throws Exception + * + * @return string compiled string with substitute variables */ - public function compileParams(string $view, array $data) : string + public function compileParams(string $view, array $data): string { // Remove pre-set message template variable assigned by Illuminate Mailer unset($data[Mailable::DATA_KEY_MESSAGE]); @@ -112,8 +115,10 @@ public function send($view, array $data = [], $callback = null) /** * Overrides Illuminate Mailer method to provide additional parameters to the event + * * @param Swift_Message $message * @param array $data + * * @return bool */ protected function shouldSendMessage($message, $data = []) diff --git a/classes/mail/mailables/DecisionAcceptNotifyAuthor.inc.php b/classes/mail/mailables/DecisionAcceptNotifyAuthor.inc.php new file mode 100644 index 00000000000..892cd375864 --- /dev/null +++ b/classes/mail/mailables/DecisionAcceptNotifyAuthor.inc.php @@ -0,0 +1,56 @@ + $reviewAssignments + */ + public function __construct(Context $context, Submission $submission, Decision $decision, array $reviewAssignments) + { + parent::__construct(array_slice(func_get_args(), 0, -1)); + $this->setupReviewerCommentsVariable($reviewAssignments, $submission); + } + + public static function getDataDescriptions(): array + { + $variables = parent::getDataDescriptions(); + return self::addReviewerCommentsDescription($variables); + } +} diff --git a/classes/mail/mailables/DecisionBackToCopyeditingNotifyAuthor.inc.php b/classes/mail/mailables/DecisionBackToCopyeditingNotifyAuthor.inc.php new file mode 100644 index 00000000000..226b634fe90 --- /dev/null +++ b/classes/mail/mailables/DecisionBackToCopyeditingNotifyAuthor.inc.php @@ -0,0 +1,44 @@ + $reviewAssignments + */ + public function __construct(Context $context, Submission $submission, Decision $decision, array $reviewAssignments) + { + parent::__construct(array_slice(func_get_args(), 0, -1)); + $this->setupReviewerCommentsVariable($reviewAssignments, $submission); + } + + public static function getDataDescriptions(): array + { + $variables = parent::getDataDescriptions(); + return self::addReviewerCommentsDescription($variables); + } +} diff --git a/classes/mail/mailables/DecisionInitialDeclineNotifyAuthor.inc.php b/classes/mail/mailables/DecisionInitialDeclineNotifyAuthor.inc.php new file mode 100644 index 00000000000..515b69ed75c --- /dev/null +++ b/classes/mail/mailables/DecisionInitialDeclineNotifyAuthor.inc.php @@ -0,0 +1,44 @@ + $reviewAssignments + */ + public function __construct(Context $context, Submission $submission, Decision $decision, array $reviewAssignments) + { + parent::__construct(array_slice(func_get_args(), 0, -1)); + $this->setupReviewerCommentsVariable($reviewAssignments, $submission); + } + + public static function getDataDescriptions(): array + { + $variables = parent::getDataDescriptions(); + return self::addReviewerCommentsDescription($variables); + } +} diff --git a/classes/mail/mailables/DecisionResubmitNotifyAuthor.inc.php b/classes/mail/mailables/DecisionResubmitNotifyAuthor.inc.php new file mode 100644 index 00000000000..e26ceb54106 --- /dev/null +++ b/classes/mail/mailables/DecisionResubmitNotifyAuthor.inc.php @@ -0,0 +1,56 @@ + $reviewAssignments + */ + public function __construct(Context $context, Submission $submission, Decision $decision, array $reviewAssignments) + { + parent::__construct(array_slice(func_get_args(), 0, -1)); + $this->setupReviewerCommentsVariable($reviewAssignments, $submission); + } + + public static function getDataDescriptions(): array + { + $variables = parent::getDataDescriptions(); + return self::addReviewerCommentsDescription($variables); + } +} diff --git a/classes/mail/mailables/DecisionRevertDeclineNotifyAuthor.inc.php b/classes/mail/mailables/DecisionRevertDeclineNotifyAuthor.inc.php new file mode 100644 index 00000000000..4058fed9b47 --- /dev/null +++ b/classes/mail/mailables/DecisionRevertDeclineNotifyAuthor.inc.php @@ -0,0 +1,44 @@ +getByKey($contextId, self::EMAIL_KEY); } } diff --git a/classes/mail/mailables/MailReviewerAssigned.inc.php b/classes/mail/mailables/MailReviewerAssigned.inc.php index 78ea5963c22..8a487a16788 100644 --- a/classes/mail/mailables/MailReviewerAssigned.inc.php +++ b/classes/mail/mailables/MailReviewerAssigned.inc.php @@ -18,10 +18,10 @@ use PKP\context\Context; use PKP\mail\Configurable; use PKP\mail\Mailable; +use PKP\mail\traits\Recipient; +use PKP\mail\traits\Sender; use PKP\submission\PKPSubmission; use PKP\submission\reviewAssignment\ReviewAssignment; -use PKP\mail\Recipient; -use PKP\mail\Sender; class MailReviewerAssigned extends Mailable { diff --git a/classes/mail/mailables/MailReviewerReinstated.inc.php b/classes/mail/mailables/MailReviewerReinstated.inc.php index 0972f78578a..27a28301808 100644 --- a/classes/mail/mailables/MailReviewerReinstated.inc.php +++ b/classes/mail/mailables/MailReviewerReinstated.inc.php @@ -18,10 +18,10 @@ use PKP\context\Context; use PKP\mail\Configurable; use PKP\mail\Mailable; +use PKP\mail\traits\Recipient; +use PKP\mail\traits\Sender; use PKP\submission\PKPSubmission; use PKP\submission\reviewAssignment\ReviewAssignment; -use PKP\mail\Recipient; -use PKP\mail\Sender; class MailReviewerReinstated extends Mailable { diff --git a/classes/mail/mailables/MailReviewerUnassigned.inc.php b/classes/mail/mailables/MailReviewerUnassigned.inc.php index dbc03e53861..df99e478d19 100644 --- a/classes/mail/mailables/MailReviewerUnassigned.inc.php +++ b/classes/mail/mailables/MailReviewerUnassigned.inc.php @@ -18,8 +18,8 @@ use PKP\context\Context; use PKP\mail\Configurable; use PKP\mail\Mailable; -use PKP\mail\Recipient; -use PKP\mail\Sender; +use PKP\mail\traits\Recipient; +use PKP\mail\traits\Sender; use PKP\submission\PKPSubmission; use PKP\submission\reviewAssignment\ReviewAssignment; diff --git a/classes/mail/mailables/RecommendationNotifyEditors.inc.php b/classes/mail/mailables/RecommendationNotifyEditors.inc.php new file mode 100644 index 00000000000..f7a27f09320 --- /dev/null +++ b/classes/mail/mailables/RecommendationNotifyEditors.inc.php @@ -0,0 +1,56 @@ + $reviewAssignments + */ + public function __construct(Context $context, Submission $submission, Decision $decision, array $reviewAssignments) + { + parent::__construct(array_slice(func_get_args(), 0, -1)); + $this->setupReviewerCommentsVariable($reviewAssignments, $submission); + } + + public static function getDataDescriptions(): array + { + $variables = parent::getDataDescriptions(); + return self::addReviewerCommentsDescription($variables); + } +} diff --git a/classes/mail/mailables/ValidateEmailContext.inc.php b/classes/mail/mailables/ValidateEmailContext.inc.php index 0cd45264ee4..8f0ac7b83df 100644 --- a/classes/mail/mailables/ValidateEmailContext.inc.php +++ b/classes/mail/mailables/ValidateEmailContext.inc.php @@ -16,10 +16,10 @@ namespace PKP\mail\mailables; use PKP\context\Context; +use PKP\emailTemplate\EmailTemplate; use PKP\facades\Repo; use PKP\mail\Mailable; -use PKP\emailTemplate\EmailTemplate; -use PKP\mail\Recipient; +use PKP\mail\traits\Recipient; class ValidateEmailContext extends Mailable { @@ -36,7 +36,7 @@ public function __construct(Context $context) parent::__construct(func_get_args()); } - public function getTemplate(int $contextId) : EmailTemplate + public function getTemplate(int $contextId): EmailTemplate { return Repo::emailTemplate()->getByKey($contextId, self::EMAIL_KEY); } diff --git a/classes/mail/mailables/ValidateEmailSite.inc.php b/classes/mail/mailables/ValidateEmailSite.inc.php index 727b537b4f0..1958eaee15d 100644 --- a/classes/mail/mailables/ValidateEmailSite.inc.php +++ b/classes/mail/mailables/ValidateEmailSite.inc.php @@ -15,11 +15,11 @@ namespace PKP\mail\mailables; +use PKP\emailTemplate\EmailTemplate; use PKP\facades\Repo; use PKP\mail\Mailable; -use PKP\emailTemplate\EmailTemplate; +use PKP\mail\traits\Recipient; use PKP\site\Site; -use PKP\mail\Recipient; class ValidateEmailSite extends Mailable { @@ -36,7 +36,7 @@ public function __construct(Site $site) parent::__construct(func_get_args()); } - public function getTemplate(int $contextId) : EmailTemplate + public function getTemplate(int $contextId): EmailTemplate { return Repo::emailTemplate()->getByKey($contextId, self::EMAIL_KEY); } diff --git a/classes/mail/Recipient.inc.php b/classes/mail/traits/Recipient.inc.php similarity index 71% rename from classes/mail/Recipient.inc.php rename to classes/mail/traits/Recipient.inc.php index 6a7eb5611e6..e50edf29391 100644 --- a/classes/mail/Recipient.inc.php +++ b/classes/mail/traits/Recipient.inc.php @@ -1,7 +1,7 @@ $recipients */ - public function recipients(array $recipients) : Mailable + public function recipients(array $recipients): Mailable { $to = []; foreach ($recipients as $recipient) { @@ -54,6 +57,13 @@ public function recipients(array $recipients) : Mailable 'name' => $recipient->getFullName(), ]; } + + // Override the existing recipient data + $this->to = []; + $this->variables = array_filter($this->variables, function ($variable) { + return !is_a($variable, RecipientEmailVariable::class); + }); + $this->setAddress($to); $this->variables[] = new RecipientEmailVariable($recipients); return $this; diff --git a/classes/mail/traits/ReviewerComments.inc.php b/classes/mail/traits/ReviewerComments.inc.php new file mode 100644 index 00000000000..7155b4cf8dd --- /dev/null +++ b/classes/mail/traits/ReviewerComments.inc.php @@ -0,0 +1,143 @@ + $reviewAssignments + */ + protected function setupReviewerCommentsVariable(array $reviewAssignments, Submission $submission) + { + /** @var SubmissionCommentDAO $submissionCommentDao */ + $submissionCommentDao = DAORegistry::getDAO('SubmissionCommentDAO'); + + $reviewerNumber = 0; + $comments = []; + foreach ($reviewAssignments as $reviewAssignment) { + $reviewerNumber++; + + $submissionComments = $submissionCommentDao->getReviewerCommentsByReviewerId( + $submission->getId(), + $reviewAssignment->getReviewerId(), + $reviewAssignment->getId(), + true + ); + + if ($submissionComments->wasEmpty()) { + continue; + } + + $reviewerIdentity = $reviewAssignment->getReviewMethod() == ReviewAssignment::SUBMISSION_REVIEW_METHOD_OPEN + ? $reviewAssignment->getReviewerFullName() + : __('submission.comments.importPeerReviews.reviewerLetter', ['reviewerLetter' => $reviewerNumber]); + $recommendation = $reviewAssignment->getLocalizedRecommendation(); + + $commentsBody = ''; + /** @var SubmissionComment $comment */ + while ($comment = $submissionComments->next()) { + // If the comment is viewable by the author, then add the comment. + if ($comment->getViewable()) { + $commentsBody .= PKPString::stripUnsafeHtml($comment->getComments()); + } + } + + $comments[] = + '

' + . '' . $reviewerIdentity . '' + . '
' + . __('submission.recommendation', ['recommendation' => $recommendation]) + . '

' + . $commentsBody + . $this->getReviewFormComments($reviewAssignment); + } + + $this->addData([ + 'allReviewersComments' => join('', $comments), + ]); + } + + protected function getReviewFormComments(ReviewAssignment $reviewAssignment): string + { + if (!$reviewAssignment->getReviewFormId()) { + return ''; + } + + /** @var ReviewFormElementDAO $reviewFormElementDao */ + $reviewFormElementDao = DAORegistry::getDAO('ReviewFormElementDAO'); + $reviewFormElements = $reviewFormElementDao->getByReviewFormId($reviewAssignment->getReviewFormId()); + + if ($reviewFormElements->wasEmpty()) { + return ''; + } + + /** @var ReviewFormResponseDAO $reviewFormResponseDao */ + $reviewFormResponseDao = DAORegistry::getDAO('ReviewFormResponseDAO'); + + $comments = ''; + while ($reviewFormElement = $reviewFormElements->next()) { + if (!$reviewFormElement->getIncluded()) { + continue; + } + + /** @var ReviewFormResponse|null $reviewFormResponse */ + $reviewFormResponse = $reviewFormResponseDao->getReviewFormResponse($reviewAssignment->getId(), $reviewFormElement->getId()); + if (!$reviewFormResponse) { + continue; + } + $comments .= PKPString::stripUnsafeHtml($reviewFormElement->getLocalizedQuestion()); + $possibleResponses = $reviewFormElement->getLocalizedPossibleResponses(); + // See issue #2437. + if (in_array($reviewFormElement->getElementType(), [$reviewFormElement::REVIEW_FORM_ELEMENT_TYPE_CHECKBOXES, $reviewFormElement::REVIEW_FORM_ELEMENT_TYPE_RADIO_BUTTONS])) { + ksort($possibleResponses); + $possibleResponses = array_values($possibleResponses); + } + if (in_array($reviewFormElement->getElementType(), $reviewFormElement->getMultipleResponsesElementTypes())) { + if ($reviewFormElement->getElementType() == $reviewFormElement::REVIEW_FORM_ELEMENT_TYPE_CHECKBOXES) { + $comments .= '
    '; + foreach ($reviewFormResponse->getValue() as $value) { + $comments .= '
  • ' . PKPString::stripUnsafeHtml($possibleResponses[$value]) . '
  • '; + } + $comments .= '
'; + } else { + $comments .= '

' . PKPString::stripUnsafeHtml($possibleResponses[$reviewFormResponse->getValue()]) . '

'; + } + } else { + $comments .= '

' . nl2br(htmlspecialchars($reviewFormResponse->getValue())) . '

'; + } + } + + return $comments; + } +} diff --git a/classes/mail/Sender.inc.php b/classes/mail/traits/Sender.inc.php similarity index 78% rename from classes/mail/Sender.inc.php rename to classes/mail/traits/Sender.inc.php index 5ee9a48d317..2508e029f8a 100644 --- a/classes/mail/Sender.inc.php +++ b/classes/mail/traits/Sender.inc.php @@ -1,7 +1,7 @@ setAddress($sender->getEmail(), $sender->getFullName($defaultLocale), 'from'); $this->variables[] = new SenderEmailVariable($sender); diff --git a/classes/mail/variables/ContextEmailVariable.inc.php b/classes/mail/variables/ContextEmailVariable.inc.php index 6ce4341385a..d0f6e23786f 100644 --- a/classes/mail/variables/ContextEmailVariable.inc.php +++ b/classes/mail/variables/ContextEmailVariable.inc.php @@ -21,12 +21,12 @@ class ContextEmailVariable extends Variable { - const CONTEXT_NAME = 'contextName'; - const CONTEXT_URL = 'contextUrl'; - const CONTACT_NAME = 'contactName'; - const PRINCIPAL_CONTACT_SIGNATURE = 'principalContactSignature'; - const CONTACT_EMAIL = 'contactEmail'; - const PASSWORD_LOST_URL = 'passwordLostUrl'; + public const CONTEXT_NAME = 'contextName'; + public const CONTEXT_URL = 'contextUrl'; + public const CONTACT_NAME = 'contactName'; + public const PRINCIPAL_CONTACT_SIGNATURE = 'principalContactSignature'; + public const CONTACT_EMAIL = 'contactEmail'; + public const PASSWORD_LOST_URL = 'passwordLostUrl'; protected Context $context; @@ -39,9 +39,9 @@ public function __construct(Context $context) } /** - * @copydoc Variable::description() + * @copydoc Variable::descriptions() */ - protected static function description() : array + public static function descriptions(): array { return [ @@ -66,12 +66,12 @@ public function values(string $locale): array ]; } - protected function getContextUrl() : string + protected function getContextUrl(): string { return $this->request->getDispatcher()->url($this->request, PKPApplication::ROUTE_PAGE, $this->context->getData('urlPath')); } - protected function getPrincipalContactSignature(string $locale) : string + protected function getPrincipalContactSignature(string $locale): string { return $this->context->getData('contactName') . "\n" @@ -81,7 +81,7 @@ protected function getPrincipalContactSignature(string $locale) : string /** * URL to the lost password page */ - protected function getPasswordLostUrl() : string + protected function getPasswordLostUrl(): string { return $this->request->getDispatcher()->url($this->request, PKPApplication::ROUTE_PAGE, $this->context->getData('urlPath'), 'login', 'lostPassword'); } diff --git a/classes/mail/variables/DecisionEmailVariable.inc.php b/classes/mail/variables/DecisionEmailVariable.inc.php new file mode 100644 index 00000000000..95fb1ceb229 --- /dev/null +++ b/classes/mail/variables/DecisionEmailVariable.inc.php @@ -0,0 +1,75 @@ +decision = $decision; + $this->type = Repo::decision()->getType($decision->getData('decision')); + } + + /** + * @copydoc Variable::descriptions() + */ + public static function descriptions(): array + { + return + [ + self::DECISION => __('emailTemplate.variable.decision.name'), + self::DESCRIPTION => __('emailTemplate.variable.decision.description'), + self::STAGE => __('emailTemplate.variable.decision.stage'), + self::ROUND => __('emailTemplate.variable.decision.round'), + ]; + } + + /** + * @copydoc Variable::values() + */ + public function values(string $locale): array + { + return + [ + self::DECISION => $this->type->getLabel($locale), + self::DESCRIPTION => $this->type->getDescription($locale), + self::STAGE => $this->getStageName($locale), + self::ROUND => (string) $this->decision->getData('round'), + ]; + } + + protected function getStageName(string $locale): string + { + return __( + (string) WorkflowStageDAO::getTranslationKeyFromId($this->decision->getData('stageId')), + [], + $locale + ); + } +} diff --git a/classes/mail/variables/QueuedPaymentEmailVariable.inc.php b/classes/mail/variables/QueuedPaymentEmailVariable.inc.php index 90445977035..d36ed49ad7c 100644 --- a/classes/mail/variables/QueuedPaymentEmailVariable.inc.php +++ b/classes/mail/variables/QueuedPaymentEmailVariable.inc.php @@ -15,15 +15,15 @@ namespace PKP\mail\variables; -use Application; +use APP\core\Application; use PKP\core\PKPServices; use PKP\payment\QueuedPayment; class QueuedPaymentEmailVariable extends Variable { - const ITEM_NAME = 'itemName'; - const ITEM_COST = 'itemCost'; - const ITEM_CURRENCY_CODE = 'itemCurrencyCode'; + public const ITEM_NAME = 'itemName'; + public const ITEM_COST = 'itemCost'; + public const ITEM_CURRENCY_CODE = 'itemCurrencyCode'; protected QueuedPayment $queuedPayment; @@ -33,9 +33,9 @@ public function __construct(QueuedPayment $queuedPayment) } /** - * @copydoc Validation::description() + * @copydoc Validation::descriptions() */ - protected static function description() : array + public static function descriptions(): array { return [ @@ -53,12 +53,12 @@ public function values(string $locale): array return [ self::ITEM_NAME => $this->getItemName(), - self::ITEM_COST => $this->getItemCost(), - self::ITEM_CURRENCY_CODE => $this->getItemCurrencyCode(), + self::ITEM_COST => (string) $this->getItemCost(), + self::ITEM_CURRENCY_CODE => (string) $this->getItemCurrencyCode(), ]; } - protected function getItemName() : string + protected function getItemName(): string { $context = PKPServices::get('context')->get($this->queuedPayment->getContextId()); $paymentManager = Application::getPaymentManager($context); @@ -73,7 +73,7 @@ protected function getItemCost() return $this->queuedPayment->getAmount(); } - protected function getItemCurrencyCode() : ?string + protected function getItemCurrencyCode(): ?string { return $this->queuedPayment->getCurrencyCode(); } diff --git a/classes/mail/variables/RecipientEmailVariable.inc.php b/classes/mail/variables/RecipientEmailVariable.inc.php index eace188550d..a104e705308 100644 --- a/classes/mail/variables/RecipientEmailVariable.inc.php +++ b/classes/mail/variables/RecipientEmailVariable.inc.php @@ -20,26 +20,27 @@ class RecipientEmailVariable extends Variable { - const RECIPIENT_FULL_NAME = 'userFullName'; - const RECIPIENT_USERNAME = 'username'; + public const RECIPIENT_FULL_NAME = 'recipientName'; + public const RECIPIENT_USERNAME = 'recipientUsername'; - protected array $recipients; + /** @var iterable */ + protected iterable $recipients; - public function __construct(array $recipient) + public function __construct(iterable $recipients) { - foreach ($recipient as $user) - { - if (!is_a($user, User::class)) - throw new InvalidArgumentException('recipient array values should be an instances or ancestors of ' . User::class . ', ' . get_class($user) . ' is given'); + foreach ($recipients as $recipient) { + if (!is_a($recipient, User::class)) { + throw new InvalidArgumentException('recipient array values should be an instances or ancestors of ' . User::class . ', ' . get_class($recipient) . ' is given'); + } } - $this->recipients = $recipient; + $this->recipients = $recipients; } /** - * @copydoc Variable::description() + * @copydoc Variable::descriptions() */ - protected static function description(): array + public static function descriptions(): array { return [ @@ -62,14 +63,17 @@ public function values(string $locale): array /** * Array containing full names of recipients in all supported locales separated by a comma + * * @return array [localeKey => fullName] */ protected function getRecipientsFullName(string $locale): string { - $fullNames = array_map(function(User $user) use ($locale) { - return $user->getFullName(true, false, $locale); - }, $this->recipients); - return join(__('common.commaListSeparator'), $fullNames); + $names = []; + foreach ($this->recipients as $recipient) { + $names[] = $recipient->getFullName(true, false, $locale); + } + + return join(__('common.listSeparator'), $names); } /** @@ -77,9 +81,10 @@ protected function getRecipientsFullName(string $locale): string */ protected function getRecipientsUserName(): string { - $userNames = array_map(function (User $user) { - return $user->getData('username'); - }, $this->recipients); - return join(__('common.commaListSeparator'), $userNames); + $userNames = []; + foreach ($this->recipients as $recipient) { + $userNames[] = $recipient->getData('userName'); + } + return join(__('common.listSeparator'), $userNames); } } diff --git a/classes/mail/variables/ReviewAssignmentEmailVariable.inc.php b/classes/mail/variables/ReviewAssignmentEmailVariable.inc.php index 6e08a80e7c7..1ed1ba099de 100644 --- a/classes/mail/variables/ReviewAssignmentEmailVariable.inc.php +++ b/classes/mail/variables/ReviewAssignmentEmailVariable.inc.php @@ -20,9 +20,9 @@ class ReviewAssignmentEmailVariable extends Variable { - const REVIEW_DUE_DATE = 'reviewDueDate'; - const RESPONSE_DUE_DATE = 'responseDueDate'; - const SUBMISSION_REVIEW_URL = 'submissionReviewUrl'; + public const REVIEW_DUE_DATE = 'reviewDueDate'; + public const RESPONSE_DUE_DATE = 'responseDueDate'; + public const SUBMISSION_REVIEW_URL = 'submissionReviewUrl'; /** @var ReviewAssignment $reviewAssignment */ protected $reviewAssignment; @@ -33,9 +33,9 @@ public function __construct(ReviewAssignment $reviewAssignment) } /** - * @copydoc Variable::description() + * @copydoc Variable::descriptions() */ - protected static function description(): array + public static function descriptions(): array { return [ @@ -58,12 +58,12 @@ public function values(string $locale): array ]; } - protected function getReviewDueDate() : string + protected function getReviewDueDate(): string { return $this->reviewAssignment->getDateDue(); } - protected function getResponseDueDate() : string + protected function getResponseDueDate(): string { return $this->reviewAssignment->getDateResponseDue(); } @@ -71,7 +71,7 @@ protected function getResponseDueDate() : string /** * URL of the submission for the assigned reviewer */ - protected function getSubmissionUrl() : string + protected function getSubmissionUrl(): string { $request = PKPApplication::get()->getRequest(); $dispatcher = $request->getDispatcher(); diff --git a/classes/mail/variables/SenderEmailVariable.inc.php b/classes/mail/variables/SenderEmailVariable.inc.php index 9c9b762247f..74bc8a02979 100644 --- a/classes/mail/variables/SenderEmailVariable.inc.php +++ b/classes/mail/variables/SenderEmailVariable.inc.php @@ -16,14 +16,13 @@ namespace PKP\mail\variables; use PKP\core\PKPString; -use PKP\i18n\PKPLocale; use PKP\user\User; class SenderEmailVariable extends Variable { - const SENDER_NAME = 'senderName'; - const SENDER_EMAIL = 'senderEmail'; - const SENDER_CONTACT_SIGNATURE = 'signature'; + public const SENDER_NAME = 'senderName'; + public const SENDER_EMAIL = 'senderEmail'; + public const SENDER_CONTACT_SIGNATURE = 'signature'; protected User $sender; @@ -33,9 +32,9 @@ public function __construct(User $sender) } /** - * @copydoc Variable::description() + * @copydoc Variable::descriptions() */ - protected static function description(): array + public static function descriptions(): array { return [ diff --git a/classes/mail/variables/SiteEmailVariable.inc.php b/classes/mail/variables/SiteEmailVariable.inc.php index 5563438133d..9d195a40112 100644 --- a/classes/mail/variables/SiteEmailVariable.inc.php +++ b/classes/mail/variables/SiteEmailVariable.inc.php @@ -19,8 +19,8 @@ class SiteEmailVariable extends Variable { - const SITE_TITLE = 'siteTitle'; - const SITE_CONTACT = 'siteContactName'; + public const SITE_TITLE = 'siteTitle'; + public const SITE_CONTACT = 'siteContactName'; protected Site $site; @@ -30,9 +30,9 @@ public function __construct(Site $site) } /** - * @copydoc Variable::description() + * @copydoc Variable::descriptions() */ - protected static function description(): array + public static function descriptions(): array { return [ @@ -46,10 +46,10 @@ protected static function description(): array */ public function values(string $locale): array { - return + return [ - self::SITE_TITLE => $this->site->getLocalizedData('title', $locale), - self::SITE_CONTACT => $this->site->getLocalizedData('contactName', $locale), + self::SITE_TITLE => (string) $this->site->getLocalizedData('title', $locale), + self::SITE_CONTACT => (string) $this->site->getLocalizedData('contactName', $locale), ]; } } diff --git a/classes/mail/variables/StageAssignmentEmailVariable.inc.php b/classes/mail/variables/StageAssignmentEmailVariable.inc.php deleted file mode 100644 index d9215b48533..00000000000 --- a/classes/mail/variables/StageAssignmentEmailVariable.inc.php +++ /dev/null @@ -1,76 +0,0 @@ -stageAssignment = $stageAssignment; - } - - /** - * @copydoc Variable::description() - */ - protected static function description(): array - { - return - [ - self::DECISION_MAKING_EDITORS => __('emailTemplate.variable.stageAssignment.editors'), - ]; - } - - /** - * @copydoc Variable::values() - */ - public function values(string $locale): array - { - return - [ - self::DECISION_MAKING_EDITORS => $this->getEditors($locale), - ]; - } - - /** - * Full names of editors associated with an assignment - */ - protected function getEditors(string $locale): string - { - /** @var StageAssignmentDAO $stageAssignmentDao */ - $stageAssignmentDao = DAORegistry::getDAO('StageAssignmentDAO'); - $editorsStageAssignments = $stageAssignmentDao->getEditorsAssignedToStage($this->stageAssignment->getSubmissionId(), $this->stageAssignment->getStageId()); - - $editorNames = []; - foreach ($editorsStageAssignments as $editorsStageAssignment) { - if (!$editorsStageAssignment->getRecommendOnly()) { - $user = Repo::user()->get($editorsStageAssignment->getUserId()); - $editorNames[] = $user->getFullName(true, false, $locale); - } - } - - return join(__('common.commaListSeparator'), $editorNames); - } -} diff --git a/classes/mail/variables/SubmissionEmailVariable.inc.php b/classes/mail/variables/SubmissionEmailVariable.inc.php index 5a419a78a19..537bd332658 100644 --- a/classes/mail/variables/SubmissionEmailVariable.inc.php +++ b/classes/mail/variables/SubmissionEmailVariable.inc.php @@ -42,9 +42,9 @@ public function __construct(Submission $submission) } /** - * @copydoc Variable::description() + * @copydoc Variable::descriptions() */ - protected static function description(): array + public static function descriptions(): array { return [ @@ -52,7 +52,6 @@ protected static function description(): array self::SUBMISSION_ID => __('emailTemplate.variable.submission.submissionId'), self::SUBMISSION_ABSTRACT => __('emailTemplate.variable.submission.submissionAbstract'), self::AUTHORS => __('emailTemplate.variable.submission.authors'), - self::AUTHORS_FULL => __('emailTemplate.variable.submission.authorsFull'), self::SUBMISSION_URL => __('emailTemplate.variable.submission.submissionUrl'), ]; } @@ -65,7 +64,7 @@ public function values(string $locale): array return [ self::SUBMISSION_TITLE => $this->currentPublication->getLocalizedFullTitle($locale), - self::SUBMISSION_ID => $this->submission->getId(), + self::SUBMISSION_ID => (string) $this->submission->getId(), self::SUBMISSION_ABSTRACT => $this->currentPublication->getLocalizedData('abstract', $locale), self::AUTHORS => $this->currentPublication->getShortAuthorString($locale), self::AUTHORS_FULL => $this->getAuthorsFull($locale), @@ -83,7 +82,7 @@ protected function getAuthorsFull(string $locale): string return $author->getFullName(true, false, $locale); }, iterator_to_array($authors)); - return join(__('common.commaListSeparator'), $fullNames); + return join(__('common.commaListSeparator'), $fullNames); } /** @@ -99,7 +98,7 @@ protected function getSubmissionUrl(): string 'workflow', 'index', [$this->submission->getId(), - $this->submission->getData('stageId')] + $this->submission->getData('stageId')] ); } } diff --git a/classes/mail/variables/Variable.inc.php b/classes/mail/variables/Variable.inc.php index 84732595cde..d222e39bd0c 100644 --- a/classes/mail/variables/Variable.inc.php +++ b/classes/mail/variables/Variable.inc.php @@ -15,36 +15,19 @@ namespace PKP\mail\variables; -use InvalidArgumentException; - abstract class Variable { /** * Get descriptions of the variables provided by this class + * * @return string[] */ - abstract protected static function description() : array; + abstract public static function descriptions(): array; /** * Get the value of variables supported by this class + * * @return string[] */ - abstract public function values(string $locale) : array; - - /** - * Get description of all or specific variable - * @param string|null $variableConst - * @return string|string[] - */ - static function getDescription(string $variableConst = null) - { - $description = static::description(); - if (!is_null($variableConst)) { - if (!array_key_exists($variableConst, $description)) { - throw new InvalidArgumentException('Template variable \'' . $variableConst . '\' doesn\'t exist in ' . static::class); - } - return $description[$variableConst]; - } - return $description; - } + abstract public function values(string $locale): array; } diff --git a/classes/migration/upgrade/v3_4_0/I7265_EditorialDecisions.inc.php b/classes/migration/upgrade/v3_4_0/I7265_EditorialDecisions.inc.php new file mode 100644 index 00000000000..c3160777fad --- /dev/null +++ b/classes/migration/upgrade/v3_4_0/I7265_EditorialDecisions.inc.php @@ -0,0 +1,75 @@ +upReviewRounds(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $this->downReviewRounds(); + } + + /** + * Use null instead of 0 for editorial decisions not in review rounds + */ + protected function upReviewRounds() + { + Schema::table('edit_decisions', function (Blueprint $table) { + $table->bigInteger('review_round_id')->nullable()->change(); + $table->bigInteger('round')->nullable()->change(); + }); + + DB::table('edit_decisions') + ->where('review_round_id', '=', 0) + ->orWhere('round', '=', 0) + ->update([ + 'review_round_id' => null, + 'round' => null + ]); + } + + /** + * Restore 0 values instead of null for editorial decisions not in review rounds + */ + protected function downReviewRounds() + { + DB::table('edit_decisions') + ->whereNull('review_round_id') + ->orWhereNull('round') + ->update([ + 'review_round_id' => 0, + 'round' => 0 + ]); + + Schema::table('edit_decisions', function (Blueprint $table) { + $table->bigInteger('review_round_id')->nullable(false)->change(); + $table->bigInteger('round')->nullable(false)->change(); + }); + } +} diff --git a/classes/observers/events/DecisionAdded.inc.php b/classes/observers/events/DecisionAdded.inc.php new file mode 100644 index 00000000000..5ef3f134f7b --- /dev/null +++ b/classes/observers/events/DecisionAdded.inc.php @@ -0,0 +1,65 @@ +actions = $actions; + $this->context = $context; + $this->decision = $decision; + $this->decisionType = $decisionType; + $this->editor = $editor; + $this->submission = $submission; + } +} diff --git a/classes/payment/QueuedPaymentDAO.inc.php b/classes/payment/QueuedPaymentDAO.inc.php index 508d295ae55..52996fd2a4e 100644 --- a/classes/payment/QueuedPaymentDAO.inc.php +++ b/classes/payment/QueuedPaymentDAO.inc.php @@ -19,6 +19,7 @@ namespace PKP\payment; use PKP\core\Core; +use PKP\db\DAORegistry; class QueuedPaymentDAO extends \PKP\db\DAO { diff --git a/classes/publication/PKPPublication.inc.php b/classes/publication/PKPPublication.inc.php index c3f468915f6..c8c32fb9398 100644 --- a/classes/publication/PKPPublication.inc.php +++ b/classes/publication/PKPPublication.inc.php @@ -212,9 +212,9 @@ public function getShortAuthorString($defaultLocale = null) $firstAuthor = $authors->first(); - $str = $firstAuthor->getLocalizedFamilyName(); + $str = $firstAuthor->getLocalizedData('familyName', $defaultLocale); if (!$str) { - $str = $firstAuthor->getLocalizedGivenName(); + $str = $firstAuthor->getLocalizedData('givenName', $defaultLocale); } if ($authors->count() > 1) { diff --git a/classes/publication/Repository.inc.php b/classes/publication/Repository.inc.php index ae70d0ec0f0..85f81f99c7e 100644 --- a/classes/publication/Repository.inc.php +++ b/classes/publication/Repository.inc.php @@ -225,7 +225,7 @@ public function validate(?Publication $publication, array $props, array $allowed ); if ($validator->fails()) { - $errors = $this->schemaService->formatValidationErrors($validator->errors(), $this->schemaService->get($this->dao->schema), $allowedLocales); + $errors = $this->schemaService->formatValidationErrors($validator->errors()); } HookRegistry::call('Publication::validate', [&$errors, $publication, $props, $allowedLocales, $primaryLocale]); diff --git a/classes/query/Query.inc.php b/classes/query/Query.inc.php index 6eaa8be8d7e..3b61d3dfbd9 100644 --- a/classes/query/Query.inc.php +++ b/classes/query/Query.inc.php @@ -18,6 +18,7 @@ namespace PKP\query; use PKP\db\DAORegistry; +use PKP\note\Note; use PKP\note\NoteDAO; class Query extends \PKP\core\DataObject diff --git a/classes/query/QueryDAO.inc.php b/classes/query/QueryDAO.inc.php index e1105839b87..a124ed81c55 100644 --- a/classes/query/QueryDAO.inc.php +++ b/classes/query/QueryDAO.inc.php @@ -17,6 +17,10 @@ namespace PKP\query; +use APP\core\Application; +use APP\notification\Notification; +use APP\notification\NotificationManager; +use PKP\core\Core; use PKP\db\DAORegistry; use PKP\db\DAOResultFactory; use PKP\plugins\HookRegistry; @@ -334,6 +338,61 @@ public function deleteByAssoc($assocType, $assocId) $this->deleteObject($query); } } + + /** + * Add a query when a recommendation (editor decision type) is made + */ + public function addRecommendationQuery(int $recommenderUserId, int $submissionId, int $stageId, string $title, string $content): int + { + $query = $this->newDataObject(); + $query->setAssocType(Application::ASSOC_TYPE_SUBMISSION); + $query->setAssocId($submissionId); + $query->setStageId($stageId); + $query->setSequence(REALLY_BIG_NUMBER); + $this->insertObject($query); + $this->resequence(Application::ASSOC_TYPE_SUBMISSION, $submissionId); + + // Add the decision making editors as discussion participants + $stageAssignmentDao = DAORegistry::getDAO('StageAssignmentDAO'); /** @var StageAssignmentDAO $stageAssignmentDao */ + $discussionParticipantsIds = []; + $editorsStageAssignments = $stageAssignmentDao->getEditorsAssignedToStage($submissionId, $stageId); + foreach ($editorsStageAssignments as $editorsStageAssignment) { + if (!$editorsStageAssignment->getRecommendOnly()) { + if (!in_array($editorsStageAssignment->getUserId(), $discussionParticipantsIds)) { + $discussionParticipantsIds[] = $editorsStageAssignment->getUserId(); + $this->insertParticipant($query->getId(), $editorsStageAssignment->getUserId()); + } + } + } + + // Add the message + $noteDao = DAORegistry::getDAO('NoteDAO'); /** @var NoteDAO $noteDao */ + $note = $noteDao->newDataObject(); + $note->setAssocType(Application::ASSOC_TYPE_QUERY); + $note->setAssocId($query->getId()); + $note->setContents($content); + $note->setTitle($title); + $note->setDateCreated(Core::getCurrentDate()); + $note->setDateModified(Core::getCurrentDate()); + $note->setUserId($recommenderUserId); + $noteDao->insertObject($note); + + // Add task for assigned participants + $notificationMgr = new NotificationManager(); + foreach ($discussionParticipantsIds as $discussionParticipantsId) { + $notificationMgr->createNotification( + Application::get()->getRequest(), + $discussionParticipantsId, + Notification::NOTIFICATION_TYPE_NEW_QUERY, + Application::get()->getRequest()->getContext()->getId(), + Application::ASSOC_TYPE_QUERY, + $query->getId(), + Notification::NOTIFICATION_LEVEL_TASK + ); + } + + return $query->getId(); + } } if (!PKP_STRICT_MODE) { diff --git a/classes/reviewForm/ReviewFormResponse.inc.php b/classes/reviewForm/ReviewFormResponse.inc.php index 001f6995a81..e359476814f 100644 --- a/classes/reviewForm/ReviewFormResponse.inc.php +++ b/classes/reviewForm/ReviewFormResponse.inc.php @@ -67,7 +67,7 @@ public function setReviewFormElementId($reviewFormElementId) /** * Get response value. * - * @return int + * @return int|array */ public function getValue() { @@ -77,7 +77,7 @@ public function getValue() /** * Set response value. * - * @param $value int + * @param $value int|array */ public function setValue($value) { diff --git a/classes/security/authorization/DecisionWritePolicy.inc.php b/classes/security/authorization/DecisionWritePolicy.inc.php new file mode 100644 index 00000000000..676252b9f4b --- /dev/null +++ b/classes/security/authorization/DecisionWritePolicy.inc.php @@ -0,0 +1,36 @@ +addPolicy(new DecisionTypeRequiredPolicy($request, $args, $decision)); + $this->addPolicy(new DecisionStageValidPolicy()); + $this->addPolicy(new DecisionAllowedPolicy($editor)); + } +} + +if (!PKP_STRICT_MODE) { + class_alias('\PKP\security\authorization\DecisionWritePolicy', '\DecisionWritePolicy'); +} diff --git a/classes/security/authorization/internal/DecisionAllowedPolicy.inc.php b/classes/security/authorization/internal/DecisionAllowedPolicy.inc.php new file mode 100644 index 00000000000..9c2e83c13a9 --- /dev/null +++ b/classes/security/authorization/internal/DecisionAllowedPolicy.inc.php @@ -0,0 +1,90 @@ +user = $user; + } + + /** + * @see AuthorizationPolicy::effect() + */ + public function effect() + { + $submission = $this->getAuthorizedContextObject(Application::ASSOC_TYPE_SUBMISSION); + $decisionType = $this->getAuthorizedContextObject(Application::ASSOC_TYPE_DECISION_TYPE); + + $stageAssignmentDao = DAORegistry::getDAO('StageAssignmentDAO'); /** @var StageAssignmentDAO $stageAssignmentDao */ + $result = $stageAssignmentDao->getBySubmissionAndUserIdAndStageId($submission->getId(), $this->user->getId(), $submission->getData('stageId')); + $stageAssignments = $result->toArray(); + if (empty($stageAssignments)) { + $userRoles = $this->getAuthorizedContextObject(Application::ASSOC_TYPE_USER_ROLES); + $canAccessUnassignedSubmission = !empty(array_intersect([Role::ROLE_ID_SITE_ADMIN, Role::ROLE_ID_MANAGER], $userRoles)); + if ($canAccessUnassignedSubmission) { + return AuthorizationPolicy::AUTHORIZATION_PERMIT; + } else { + $this->setAdvice(self::AUTHORIZATION_ADVICE_DENY_MESSAGE, 'editor.submission.workflowDecision.noUnassignedDecisions'); + return AuthorizationPolicy::AUTHORIZATION_DENY; + } + } else { + $userGroupDao = DAORegistry::getDAO('UserGroupDAO'); /** @var UserGroupDAO $userGroupDao */ + $isAllowed = false; + foreach ($stageAssignments as $stageAssignment) { + $userGroup = $userGroupDao->getById($stageAssignment->getUserGroupId()); + if (!in_array($userGroup->getRoleId(), [Role::ROLE_ID_MANAGER, Role::ROLE_ID_SUB_EDITOR])) { + continue; + } + if (Repo::decision()->isRecommendation($decisionType->getDecision()) && $stageAssignment->getRecommendOnly()) { + $isAllowed = true; + } elseif (!$stageAssignment->getRecommendOnly()) { + $isAllowed = true; + } + } + if ($isAllowed) { + return AuthorizationPolicy::AUTHORIZATION_PERMIT; + } + } + + return AuthorizationPolicy::AUTHORIZATION_DENY; + } +} + +if (!PKP_STRICT_MODE) { + class_alias('\PKP\security\authorization\internal\DecisionAllowedPolicy', '\DecisionAllowedPolicy'); +} diff --git a/classes/security/authorization/internal/DecisionStageValidPolicy.inc.php b/classes/security/authorization/internal/DecisionStageValidPolicy.inc.php new file mode 100644 index 00000000000..ec703c6de25 --- /dev/null +++ b/classes/security/authorization/internal/DecisionStageValidPolicy.inc.php @@ -0,0 +1,56 @@ +getAuthorizedContextObject(Application::ASSOC_TYPE_SUBMISSION); + $decisionType = $this->getAuthorizedContextObject(Application::ASSOC_TYPE_DECISION_TYPE); + + if ($submission->getData('stageId') === $decisionType->getStageId()) { + return AuthorizationPolicy::AUTHORIZATION_PERMIT; + } + + return AuthorizationPolicy::AUTHORIZATION_DENY; + } +} + +if (!PKP_STRICT_MODE) { + class_alias('\PKP\security\authorization\internal\DecisionStageValidPolicy', '\DecisionStageValidPolicy'); +} diff --git a/classes/security/authorization/internal/DecisionTypeRequiredPolicy.inc.php b/classes/security/authorization/internal/DecisionTypeRequiredPolicy.inc.php new file mode 100644 index 00000000000..1636c830613 --- /dev/null +++ b/classes/security/authorization/internal/DecisionTypeRequiredPolicy.inc.php @@ -0,0 +1,71 @@ +decision = $decision; + } + + // + // Implement template methods from AuthorizationPolicy + // + /** + * @see DataObjectRequiredPolicy::dataObjectEffect() + */ + public function dataObjectEffect() + { + /** @var Type|null $type */ + $type = Repo::decision()->getTypes()->first(function ($type) { + return $type->getDecision() === $this->decision; + }); + + if (!$type) { + return AuthorizationPolicy::AUTHORIZATION_DENY; + } + + $this->addAuthorizedContextObject(Application::ASSOC_TYPE_DECISION_TYPE, $type); + + return AuthorizationPolicy::AUTHORIZATION_PERMIT; + } +} + +if (!PKP_STRICT_MODE) { + class_alias('\PKP\security\authorization\internal\DecisionTypeRequiredPolicy', '\DecisionTypeRequiredPolicy'); +} diff --git a/classes/security/authorization/internal/SubmissionFileRequestedRevisionRequiredPolicy.inc.php b/classes/security/authorization/internal/SubmissionFileRequestedRevisionRequiredPolicy.inc.php index 0136f165ed6..52371271351 100644 --- a/classes/security/authorization/internal/SubmissionFileRequestedRevisionRequiredPolicy.inc.php +++ b/classes/security/authorization/internal/SubmissionFileRequestedRevisionRequiredPolicy.inc.php @@ -80,7 +80,7 @@ public function effect() // Make sure that the last review round editor decision is request revisions. $editDecisionDao = DAORegistry::getDAO('EditDecisionDAO'); /** @var EditDecisionDAO $editDecisionDao */ - $reviewRoundDecisions = $editDecisionDao->getEditorDecisions($submissionFile->getData('submissionId'), $reviewRound->getStageId(), $reviewRound->getRound()); + $reviewRoundDecisions = $editDecisionDao->getEditorDecisions($submissionFile->getData('submissionId'), $reviewRound->getStageId(), $reviewRound->getId()); if (empty($reviewRoundDecisions)) { return AuthorizationPolicy::AUTHORIZATION_DENY; } diff --git a/classes/security/authorization/internal/SubmissionFileStageAccessPolicy.inc.php b/classes/security/authorization/internal/SubmissionFileStageAccessPolicy.inc.php index cfbf0c6d665..c501c780879 100644 --- a/classes/security/authorization/internal/SubmissionFileStageAccessPolicy.inc.php +++ b/classes/security/authorization/internal/SubmissionFileStageAccessPolicy.inc.php @@ -104,7 +104,7 @@ public function effect() $reviewRound = $reviewRoundDao->getLastReviewRoundBySubmissionId($submission->getId(), $reviewStage); if ($reviewRound) { $editDecisionDao = DAORegistry::getDAO('EditDecisionDAO'); /** @var EditDecisionDAO $editDecisionDao */ - $decisions = $editDecisionDao->getEditorDecisions($submission->getId(), $reviewRound->getStageId(), $reviewRound->getRound()); + $decisions = $editDecisionDao->getEditorDecisions($submission->getId(), $reviewRound->getStageId(), $reviewRound->getId()); if (!empty($decisions)) { foreach ($decisions as $decision) { if ($decision['decision'] == EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_ACCEPT diff --git a/classes/services/PKPContextService.inc.php b/classes/services/PKPContextService.inc.php index 01a2e5bb371..9f3fcf35e01 100644 --- a/classes/services/PKPContextService.inc.php +++ b/classes/services/PKPContextService.inc.php @@ -416,7 +416,7 @@ public function validate($action, $props, $allowedLocales, $primaryLocale) } if ($validator->fails()) { - $errors = $schemaService->formatValidationErrors($validator->errors(), $schemaService->get(PKPSchemaService::SCHEMA_CONTEXT), $allowedLocales); + $errors = $schemaService->formatValidationErrors($validator->errors()); } HookRegistry::call('Context::validate', [&$errors, $action, $props, $allowedLocales, $primaryLocale]); diff --git a/classes/services/PKPSchemaService.inc.php b/classes/services/PKPSchemaService.inc.php index b8152954c14..7453680e6ee 100644 --- a/classes/services/PKPSchemaService.inc.php +++ b/classes/services/PKPSchemaService.inc.php @@ -16,6 +16,8 @@ namespace PKP\services; use Exception; +use Illuminate\Support\Arr; +use Illuminate\Support\MessageBag; use PKP\plugins\HookRegistry; class PKPSchemaService @@ -24,6 +26,7 @@ class PKPSchemaService public const SCHEMA_AUTHOR = 'author'; public const SCHEMA_CATEGORY = 'category'; public const SCHEMA_CONTEXT = 'context'; + public const SCHEMA_DECISION = 'decision'; public const SCHEMA_EMAIL_TEMPLATE = 'emailTemplate'; public const SCHEMA_GALLEY = 'galley'; public const SCHEMA_ISSUE = 'issue'; @@ -305,7 +308,7 @@ public function coerce($value, $type, $schema) } return $newObject; } - fatalError('Requested variable coercion for a type that was not recognized: ' . $type); + throw new Exception('Requested variable coercion for a type that was not recognized: ' . $type); } /** @@ -405,34 +408,13 @@ public function addPropValidationRules($rules, $ruleKey, $propSchema) * ], * bar: ['Error message'], * ] - * - * @param $errorBag \Illuminate\Support\MessageBag - * @param $schema object The entity schema - * - * @return array */ - public function formatValidationErrors($errorBag, $schema, $locales) + public function formatValidationErrors(MessageBag $errorBag): array { - $errors = $errorBag->getMessages(); $formatted = []; - - foreach ($errors as $ruleKey => $messages) { - $ruleKeyParts = explode('.', $ruleKey); - $propName = $ruleKeyParts[0]; - if (!isset($formatted[$propName])) { - $formatted[$propName] = []; - } - if (!empty($schema->properties->{$propName}) && !empty($schema->properties->{$propName}->multilingual)) { - $localeKey = $ruleKeyParts[1]; - if (!isset($formatted[$propName][$localeKey])) { - $formatted[$propName][$localeKey] = []; - } - $formatted[$propName][$localeKey] = array_merge($formatted[$propName][$localeKey], $messages); - } else { - $formatted[$propName] = array_merge($formatted[$propName], $messages); - } + foreach ($errorBag->getMessages() as $ruleKey => $messages) { + Arr::set($formatted, $ruleKey, $messages); } - return $formatted; } diff --git a/classes/services/PKPSiteService.inc.php b/classes/services/PKPSiteService.inc.php index f33f72ab4d4..15f60f02f07 100644 --- a/classes/services/PKPSiteService.inc.php +++ b/classes/services/PKPSiteService.inc.php @@ -164,7 +164,7 @@ public function validate($props, $allowedLocales, $primaryLocale) }); if ($validator->fails()) { - $errors = $schemaService->formatValidationErrors($validator->errors(), $schemaService->get(PKPSchemaService::SCHEMA_SITE), $allowedLocales); + $errors = $schemaService->formatValidationErrors($validator->errors()); } \HookRegistry::call('Site::validate', [&$errors, $props, $allowedLocales, $primaryLocale]); diff --git a/classes/stageAssignment/StageAssignmentDAO.inc.php b/classes/stageAssignment/StageAssignmentDAO.inc.php index 7865ef7c6c0..1e0ed67b4b2 100644 --- a/classes/stageAssignment/StageAssignmentDAO.inc.php +++ b/classes/stageAssignment/StageAssignmentDAO.inc.php @@ -144,6 +144,40 @@ public function editorAssignedToStage($submissionId, $stageId = null) return $row && $row->row_count; } + /** + * Get all assigned editors who can make a decision in a given stage + * + * @return array + */ + public function getDecidingEditorIds(int $submissionId, int $stageId): array + { + $decidingEditorIds = []; + $result = $this->getBySubmissionAndRoleId( + $submissionId, + Role::ROLE_ID_MANAGER, + $stageId + ); + /** @var StageAssignment $stageAssignment */ + while ($stageAssignment = $result->next()) { + if (!$stageAssignment->getRecommendOnly()) { + $decidingEditorIds[] = (int) $stageAssignment->getUserId(); + } + } + $result = $this->getBySubmissionAndRoleId( + $submissionId, + Role::ROLE_ID_SUB_EDITOR, + $stageId + ); + /** @var StageAssignment $stageAssignment */ + while ($stageAssignment = $result->next()) { + if (!$stageAssignment->getRecommendOnly()) { + $decidingEditorIds[] = (int) $stageAssignment->getUserId(); + } + } + + return $decidingEditorIds; + } + /** * Retrieve all assignments by UserGroupId and ContextId * diff --git a/classes/submission/DAO.inc.php b/classes/submission/DAO.inc.php index 7015a4412db..6404225aeb0 100644 --- a/classes/submission/DAO.inc.php +++ b/classes/submission/DAO.inc.php @@ -249,8 +249,7 @@ public function deleteById(int $id) $reviewRoundDao = DAORegistry::getDAO('ReviewRoundDAO'); /** @var ReviewRoundDAO $reviewRoundDao */ $reviewRoundDao->deleteBySubmissionId($id); - $editDecisionDao = DAORegistry::getDAO('EditDecisionDAO'); /** @var EditDecisionDAO $editDecisionDao */ - $editDecisionDao->deleteDecisionsBySubmissionId($id); + Repo::decision()->deleteBySubmissionId($id); $reviewAssignmentDao = DAORegistry::getDAO('ReviewAssignmentDAO'); /** @var ReviewAssignmentDAO $reviewAssignmentDao */ $reviewAssignmentDao->deleteBySubmissionId($id); diff --git a/classes/submission/EditDecisionDAO.inc.php b/classes/submission/EditDecisionDAO.inc.php index 2669d894b97..d4a7dbb27e0 100644 --- a/classes/submission/EditDecisionDAO.inc.php +++ b/classes/submission/EditDecisionDAO.inc.php @@ -72,20 +72,20 @@ public function deleteDecisionsBySubmissionId($submissionId) * * @param $submissionId int Submission ID * @param $stageId int Optional STAGE_ID_... - * @param $round int Optional review round number + * @param $reviewRoundId int Optional review round ID * @param $editorId int Optional editor ID * * @return array List of information on the editor decisions: * editDecisionId, reviewRoundId, stageId, round, editorId, decision, dateDecided */ - public function getEditorDecisions($submissionId, $stageId = null, $round = null, $editorId = null) + public function getEditorDecisions($submissionId, $stageId = null, $reviewRoundId = null, $editorId = null) { $params = [(int) $submissionId]; if ($stageId) { $params[] = (int) $stageId; } - if ($round) { - $params[] = (int) $round; + if ($reviewRoundId) { + $params[] = (int) $reviewRoundId; } if ($editorId) { $params[] = (int) $editorId; @@ -97,7 +97,7 @@ public function getEditorDecisions($submissionId, $stageId = null, $round = null FROM edit_decisions WHERE submission_id = ? ' . ($stageId ? ' AND stage_id = ?' : '') . ' - ' . ($round ? ' AND round = ?' : '') . ' + ' . ($reviewRoundId ? ' AND review_round_id = ?' : '') . ' ' . ($editorId ? ' AND editor_id = ?' : '') . ' ORDER BY date_decided ASC', $params @@ -175,6 +175,7 @@ public function findValidPendingRevisionsDecision($submissionId, $expectedStageI } } + return $pendingRevisionDecision; } diff --git a/classes/submission/Repository.inc.php b/classes/submission/Repository.inc.php index 79aaa1fb004..e44dcdfbb3b 100644 --- a/classes/submission/Repository.inc.php +++ b/classes/submission/Repository.inc.php @@ -279,7 +279,7 @@ public function validate(?Submission $submission, array $props, array $allowedLo }); if ($validator->fails()) { - $errors = $this->schemaService->formatValidationErrors($validator->errors(), $this->schemaService->get(PKPSchemaService::SCHEMA_SUBMISSION), $allowedLocales); + $errors = $this->schemaService->formatValidationErrors($validator->errors()); } HookRegistry::call('Submission::validate', [&$errors, $submission, $props, $allowedLocales, $primaryLocale]); @@ -366,7 +366,7 @@ public function add(Submission $submission, Publication $publication): int $this->edit($submission, ['currentPublicationId' => $publicationId]); - HookRegistry::call('Submission::add', [&$submission]); + HookRegistry::call('Submission::add', [$submission]); return $submission->getId(); } @@ -378,7 +378,7 @@ public function edit(Submission $submission, array $params) $newSubmission->stampLastActivity(); $newSubmission->stampModified(); - HookRegistry::call('Submission::edit', [&$newSubmission, $submission, $params]); + HookRegistry::call('Submission::edit', [$newSubmission, $submission, $params]); $this->dao->update($newSubmission); } @@ -390,7 +390,7 @@ public function delete(Submission $submission) $this->dao->delete($submission); - HookRegistry::call('Submission::delete', [&$submission]); + HookRegistry::call('Submission::delete', [$submission]); } /** @@ -406,70 +406,43 @@ public function deleteByContextId(int $contextId) } /** - * Update a submission's status and current publication id + * Update a submission's status * - * Sets the appropriate status on the submission and updates the - * current publication id, based on all of the submission's + * Changes a submission's status. Or, if no new status is provided, + * sets the appropriate status based on all of the submission's * publications. * - * Used to update the submission status when publications are - * published or deleted, or any other actions which may effect - * the status of the submission. + * This method performs any actions necessary when a submission's + * status changes, such as changing the current publication ID + * and creating or deleting tombstones. */ - public function updateStatus(Submission $submission) + public function updateStatus(Submission $submission, ?int $newStatus = null) { - $status = $newStatus = $submission->getData('status'); - $currentPublicationId = $newCurrentPublicationId = $submission->getData('currentPublicationId'); - $publications = $submission->getData('publications'); /** @var LazyCollection $publications */ - - // If there are no publications, we are probably in the process of deleting a submission. - // To be safe, reset the status and currentPublicationId anyway. - if (!$publications->count()) { - $newStatus = $status == Submission::STATUS_DECLINED - ? Submission::STATUS_DECLINED - : Submission::STATUS_QUEUED; - $newCurrentPublicationId = null; - } else { - - // Get the new current publication after status changes or deletions - // Use the latest published publication or, failing that, the latest publication - $newCurrentPublicationId = $publications->reduce(function ($a, $b) { - return $b->getData('status') === PKPSubmission::STATUS_PUBLISHED && $b->getId() > $a ? $b->getId() : $a; - }, 0); - if (!$newCurrentPublicationId) { - $newCurrentPublicationId = $publications->reduce(function ($a, $b) { - return $a > $b->getId() ? $a : $b->getId(); - }, 0); - } + $status = $submission->getData('status'); + if ($newStatus === null) { + $newStatus = $status; + } - // Declined submissions should remain declined even if their - // publications change - if ($status !== PKPSubmission::STATUS_DECLINED) { - $newStatus = PKPSubmission::STATUS_QUEUED; - foreach ($publications as $publication) { - if ($publication->getData('status') === PKPSubmission::STATUS_PUBLISHED) { - $newStatus = PKPSubmission::STATUS_PUBLISHED; - break; - } - if ($publication->getData('status') === PKPSubmission::STATUS_SCHEDULED) { - $newStatus = PKPSubmission::STATUS_SCHEDULED; - continue; - } - } - } + if ($newStatus === null) { + $newStatus = $this->getStatusByPublications($submission); } HookRegistry::call('Submission::updateStatus', [&$newStatus, $status, $submission]); $updateParams = []; + if ($status !== $newStatus) { - $updateParams['status'] = $newStatus; + $submission->setData('status', $newStatus); } + + $currentPublicationId = $newCurrentPublicationId = $submission->getData('currentPublicationId'); + $newCurrentPublicationId = $this->getCurrentPublicationIdByPublications($submission); if ($currentPublicationId !== $newCurrentPublicationId) { - $updateParams['currentPublicationId'] = $newCurrentPublicationId; + $submission->setData('currentPublicationId', $newCurrentPublicationId); } + if (!empty($updateParams)) { - $this->edit($submission, $updateParams); + $this->dao->update($submission); } } @@ -549,4 +522,67 @@ protected function _canUserAccessUnassignedSubmissions(int $contextId, int $user } return false; } + + /** + * Get the appropriate status of a submission based on the + * statuses of its publications + */ + protected function getStatusByPublications(Submission $submission): int + { + $publications = $submission->getData('publications'); /** @var LazyCollection $publications */ + + // Declined submissions should remain declined regardless of their publications' statuses + if ($submission->getData('status') === Submission::STATUS_DECLINED) { + return Submission::STATUS_DECLINED; + } + + // If there are no publications, we are probably in the process of deleting a submission. + // To be safe, reset the status anyway. + if (!$publications->count()) { + return Submission::STATUS_DECLINED + ? Submission::STATUS_DECLINED + : Submission::STATUS_QUEUED; + } + + $newStatus = Submission::STATUS_QUEUED; + foreach ($publications as $publication) { + if ($publication->getData('status') === Submission::STATUS_PUBLISHED) { + $newStatus = Submission::STATUS_PUBLISHED; + break; + } + if ($publication->getData('status') === Submission::STATUS_SCHEDULED) { + $newStatus = Submission::STATUS_SCHEDULED; + continue; + } + } + + return $newStatus; + } + + /** + * Get the appropriate currentPublicationId for a submission based on the + * statues of its publications + */ + protected function getCurrentPublicationIdByPublications(Submission $submission): ?int + { + $publications = $submission->getData('publications'); /** @var LazyCollection $publications */ + + if (!$publications->count()) { + return null; + } + + // Use the latest published publication + $newCurrentPublicationId = $publications->reduce(function ($a, $b) { + return $b->getData('status') === Submission::STATUS_PUBLISHED && $b->getId() > $a ? $b->getId() : $a; + }, 0); + + // If there is no published publication, use the latest publication + if (!$newCurrentPublicationId) { + $newCurrentPublicationId = $publications->reduce(function ($a, $b) { + return $a > $b->getId() ? $a : $b->getId(); + }, 0); + } + + return $newCurrentPublicationId ?? $submission->getData('currentPublicationId'); + } } diff --git a/classes/submission/reviewAssignment/ReviewAssignment.inc.php b/classes/submission/reviewAssignment/ReviewAssignment.inc.php index 60f210fa9ad..165f6864a7f 100644 --- a/classes/submission/reviewAssignment/ReviewAssignment.inc.php +++ b/classes/submission/reviewAssignment/ReviewAssignment.inc.php @@ -55,6 +55,18 @@ class ReviewAssignment extends \PKP\core\DataObject public const REVIEW_ASSIGNMENT_STATUS_THANKED = 9; // reviewer has been thanked public const REVIEW_ASSIGNMENT_STATUS_CANCELLED = 10; // reviewer cancelled review request + /** + * All review assignment statuses that indicate a + * review was completed + * + * @var array + */ + public const REVIEW_COMPLETE_STATUSES = [ + self::REVIEW_ASSIGNMENT_STATUS_RECEIVED, + self::REVIEW_ASSIGNMENT_STATUS_COMPLETE, + self::REVIEW_ASSIGNMENT_STATUS_THANKED, + ]; + // // Get/set methods // diff --git a/classes/submission/reviewRound/ReviewRound.inc.php b/classes/submission/reviewRound/ReviewRound.inc.php index b52e553acd7..7276df2b09b 100644 --- a/classes/submission/reviewRound/ReviewRound.inc.php +++ b/classes/submission/reviewRound/ReviewRound.inc.php @@ -157,7 +157,7 @@ public function determineStatus() if ($this->getStatus() == self::REVIEW_ROUND_STATUS_REVISIONS_REQUESTED || $this->getStatus() == self::REVIEW_ROUND_STATUS_REVISIONS_SUBMITTED) { // get editor decisions $editDecisionDao = DAORegistry::getDAO('EditDecisionDAO'); /** @var EditDecisionDAO $editDecisionDao */ - $pendingRevisionDecision = $editDecisionDao->findValidPendingRevisionsDecision($this->getSubmissionId(), $this->getStageId(), EditorDecisionActionsManager::SUBMISSION_EDITOR_RECOMMEND_PENDING_REVISIONS); + $pendingRevisionDecision = $editDecisionDao->findValidPendingRevisionsDecision($this->getSubmissionId(), $this->getStageId(), EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_PENDING_REVISIONS); if ($pendingRevisionDecision) { if ($editDecisionDao->responseExists($pendingRevisionDecision, $this->getSubmissionId())) { @@ -172,7 +172,7 @@ public function determineStatus() if ($this->getStatus() == self::REVIEW_ROUND_STATUS_RESUBMIT_FOR_REVIEW || $this->getStatus() == self::REVIEW_ROUND_STATUS_RESUBMIT_FOR_REVIEW_SUBMITTED) { // get editor decisions $editDecisionDao = DAORegistry::getDAO('EditDecisionDAO'); /** @var EditDecisionDAO $editDecisionDao */ - $pendingRevisionDecision = $editDecisionDao->findValidPendingRevisionsDecision($this->getSubmissionId(), $this->getStageId(), EditorDecisionActionsManager::SUBMISSION_EDITOR_RECOMMEND_RESUBMIT); + $pendingRevisionDecision = $editDecisionDao->findValidPendingRevisionsDecision($this->getSubmissionId(), $this->getStageId(), EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_RESUBMIT); if ($pendingRevisionDecision) { if ($editDecisionDao->responseExists($pendingRevisionDecision, $this->getSubmissionId())) { @@ -206,7 +206,7 @@ public function determineStatus() $pendingRecommendations = true; // Get recommendation from the assigned recommendOnly editor $editorId = $editorsStageAssignment->getUserId(); - $editorRecommendations = $editDecisionDao->getEditorDecisions($this->getSubmissionId(), $this->getStageId(), $this->getRound(), $editorId); + $editorRecommendations = $editDecisionDao->getEditorDecisions($this->getSubmissionId(), $this->getStageId(), $this->getId(), $editorId); if (empty($editorRecommendations)) { $recommendationsFinished = false; } else { diff --git a/classes/submission/reviewRound/ReviewRoundDAO.inc.php b/classes/submission/reviewRound/ReviewRoundDAO.inc.php index 0a823a4d00e..1d69ceec71d 100644 --- a/classes/submission/reviewRound/ReviewRoundDAO.inc.php +++ b/classes/submission/reviewRound/ReviewRoundDAO.inc.php @@ -259,6 +259,25 @@ public function getLastReviewRoundBySubmissionId($submissionId, $stageId = null) return $row ? $this->_fromRow($row) : null; } + /** + * Check if submission has a review round (in the given stage id) + */ + public function submissionHasReviewRound(int $submissionId, ?int $stageId = null): bool + { + $params = [(int)$submissionId]; + if ($stageId) { + $params[] = (int) $stageId; + } + $result = $this->retrieve( + 'SELECT review_round_id + FROM review_rounds + WHERE submission_id = ? + ' . ($stageId ? ' AND stage_id = ?' : ''), + $params + ); + return (bool) $result->current(); + } + /** * Get the ID of the last inserted review round. * diff --git a/classes/submissionFile/SubmissionFile.inc.php b/classes/submissionFile/SubmissionFile.inc.php index a3e4633379b..56b13fa18bc 100644 --- a/classes/submissionFile/SubmissionFile.inc.php +++ b/classes/submissionFile/SubmissionFile.inc.php @@ -37,6 +37,16 @@ class SubmissionFile extends \PKP\core\DataObject public const SUBMISSION_FILE_INTERNAL_REVIEW_FILE = 19; public const SUBMISSION_FILE_INTERNAL_REVIEW_REVISION = 20; + public const INTERNAL_REVIEW_STAGES = [ + SubmissionFile::SUBMISSION_FILE_INTERNAL_REVIEW_FILE, + SubmissionFile::SUBMISSION_FILE_INTERNAL_REVIEW_REVISION, + ]; + + public const EXTERNAL_REVIEW_STAGES = [ + SubmissionFile::SUBMISSION_FILE_REVIEW_FILE, + SubmissionFile::SUBMISSION_FILE_REVIEW_REVISION, + ]; + /** * Get a piece of data for this object, localized to the current * locale if possible. diff --git a/classes/template/PKPTemplateManager.inc.php b/classes/template/PKPTemplateManager.inc.php index 5a4d3c2a4a3..4a4ccba5520 100644 --- a/classes/template/PKPTemplateManager.inc.php +++ b/classes/template/PKPTemplateManager.inc.php @@ -40,6 +40,7 @@ use PKP\core\PKPApplication; use PKP\core\Registry; use PKP\db\DAORegistry; +use PKP\file\FileManager; use PKP\form\FormBuilderVocabulary; use PKP\linkAction\LinkAction; @@ -48,6 +49,8 @@ use PKP\plugins\PluginRegistry; use PKP\security\Role; use PKP\security\Validation; +use PKP\submission\Genre; +use PKP\submission\GenreDAO; use Smarty; /* This definition is required by Smarty */ @@ -852,6 +855,29 @@ public function setupBackendPage() 'validator.required' ]); + // Set up the document type icons + $documentTypeIcons = [ + FileManager::DOCUMENT_TYPE_DEFAULT => 'file-o', + FileManager::DOCUMENT_TYPE_AUDIO => 'file-audio-o', + FileManager::DOCUMENT_TYPE_EPUB => 'file-text-o', + FileManager::DOCUMENT_TYPE_EXCEL => 'file-excel-o', + FileManager::DOCUMENT_TYPE_HTML => 'file-code-o', + FileManager::DOCUMENT_TYPE_IMAGE => 'file-image-o', + FileManager::DOCUMENT_TYPE_PDF => 'file-pdf-o', + FileManager::DOCUMENT_TYPE_WORD => 'file-word-o', + FileManager::DOCUMENT_TYPE_VIDEO => 'file-video-o', + FileManager::DOCUMENT_TYPE_ZIP => 'file-archive-o', + ]; + $this->addJavaScript( + 'documentTypeIcons', + 'pkp.documentTypeIcons = ' . json_encode($documentTypeIcons) . ';', + [ + 'priority' => self::STYLE_SEQUENCE_LAST, + 'contexts' => 'backend', + 'inline' => true, + ] + ); + // Register the jQuery script $min = Config::getVar('general', 'enable_minified') ? '.min' : ''; $this->addJavaScript( @@ -919,6 +945,9 @@ public function setupBackendPage() // Set up required state properties $this->setState([ 'menu' => [], + 'tinyMCE' => [ + 'skinUrl' => $request->getBaseUrl() . '/lib/ui-library/public/styles/tinymce', + ], ]); /** @@ -1086,7 +1115,24 @@ public function setupBackendPage() AppLocale::requireComponents([LOCALE_COMPONENT_PKP_MANAGER, LOCALE_COMPONENT_APP_MANAGER]); } + // Load the file genres for the current context + $fileGenres = []; + if ($request->getContext()) { + /** @var GenreDAO $genreDao */ + $genreDao = DAORegistry::getDAO('GenreDAO'); + $result = $genreDao->getByContextId($request->getContext()->getId()); + /** @var Genre $genre */ + while ($genre = $result->next()) { + $fileGenres[] = [ + 'id' => $genre->getId(), + 'name' => $genre->getLocalizedName(), + 'isPrimary' => !$genre->getDependent() && !$genre->getSupplementary(), + ]; + } + } + $this->setState([ + 'fileGenres' => $fileGenres, 'menu' => $menu, 'tasksUrl' => $tasksUrl, 'unreadTasksCount' => $unreadTasksCount, diff --git a/classes/user/User.inc.php b/classes/user/User.inc.php index a997cd397d1..25aed867bb7 100644 --- a/classes/user/User.inc.php +++ b/classes/user/User.inc.php @@ -416,11 +416,14 @@ public function setInlineHelp($inlineHelp) $this->setData('inlineHelp', $inlineHelp); } - public function getContactSignature() + public function getContactSignature(?string $locale = null) { + if ($this->getSignature($locale)) { + return $this->getSignature($locale); + } $signature = htmlspecialchars($this->getFullName()); AppLocale::requireComponents(LOCALE_COMPONENT_PKP_USER); - if ($a = $this->getLocalizedAffiliation()) { + if ($a = $this->$this->getLocalizedData('affiliation', $locale)) { $signature .= '
' . htmlspecialchars($a); } if ($p = $this->getPhone()) { diff --git a/classes/validation/ValidatorFactory.inc.php b/classes/validation/ValidatorFactory.inc.php index c5134cb4d36..3f373c344ca 100644 --- a/classes/validation/ValidatorFactory.inc.php +++ b/classes/validation/ValidatorFactory.inc.php @@ -21,6 +21,7 @@ use Illuminate\Translation\Translator; use Illuminate\Validation\Factory; +use Illuminate\Validation\Validator; use PKP\file\TemporaryFileManager; use Sokil\IsoCodes\IsoCodesFactory; @@ -34,13 +35,11 @@ class ValidatorFactory * necessary dependencies and instantiates Laravel's validation factory, then * calls the `make` method on that factory. * - * @param $props array The properties to validate - * @param $rules array The validation rules - * @param $messages array Error messages - * - * @return Illuminate\Validation\Validator + * @param array $props The properties to validate + * @param array $rules The validation rules + * @param array $messages Error messages */ - public static function make($props, $rules, $messages = []) + public static function make(array $props, array $rules, ?array $messages = []): Validator { // This configures a non-existent translation file, but it is necessary to diff --git a/classes/workflow/PKPEditorDecisionActionsManager.inc.php b/classes/workflow/PKPEditorDecisionActionsManager.inc.php index d7edb1053e1..4c56da1095b 100644 --- a/classes/workflow/PKPEditorDecisionActionsManager.inc.php +++ b/classes/workflow/PKPEditorDecisionActionsManager.inc.php @@ -24,11 +24,16 @@ abstract class PKPEditorDecisionActionsManager { public const SUBMISSION_EDITOR_DECISION_INITIAL_DECLINE = 9; + public const SUBMISSION_EDITOR_DECISION_SKIP_REVIEW = 19; public const SUBMISSION_EDITOR_RECOMMEND_ACCEPT = 11; public const SUBMISSION_EDITOR_RECOMMEND_PENDING_REVISIONS = 12; public const SUBMISSION_EDITOR_RECOMMEND_RESUBMIT = 13; public const SUBMISSION_EDITOR_RECOMMEND_DECLINE = 14; public const SUBMISSION_EDITOR_DECISION_REVERT_DECLINE = 17; + public const SUBMISSION_EDITOR_DECISION_REVERT_INITIAL_DECLINE = 18; + public const SUBMISSION_EDITOR_DECISION_BACK_TO_COPYEDITING = 21; + public const SUBMISSION_EDITOR_DECISION_BACK_TO_REVIEW = 20; + public const SUBMISSION_EDITOR_DECISION_BACK_TO_SUBMISSION_FROM_COPYEDITING = 22; /** * Get the available decisions by stage ID and user making decision permissions, diff --git a/controllers/grid/eventLog/SubmissionEventLogGridHandler.inc.php b/controllers/grid/eventLog/SubmissionEventLogGridHandler.inc.php index 236866c0a73..b845f9617cc 100644 --- a/controllers/grid/eventLog/SubmissionEventLogGridHandler.inc.php +++ b/controllers/grid/eventLog/SubmissionEventLogGridHandler.inc.php @@ -21,6 +21,7 @@ use PKP\controllers\grid\GridColumn; use PKP\controllers\grid\GridHandler; use PKP\core\JSONMessage; +use PKP\core\PKPString; use PKP\log\EmailLogEntry; use PKP\log\EventLogEntry; use PKP\security\authorization\internal\UserAccessibleWorkflowStageRequiredPolicy; @@ -249,16 +250,23 @@ public function viewEmail($args, $request) * * @return string Formatted email */ - public function _formatEmail($emailLogEntry) + public function _formatEmail(EmailLogEntry $emailLogEntry) { - assert($emailLogEntry instanceof EmailLogEntry); - $text = []; $text[] = __('email.from') . ': ' . htmlspecialchars($emailLogEntry->getFrom()); $text[] = __('email.to') . ': ' . htmlspecialchars($emailLogEntry->getRecipients()); + if ($emailLogEntry->getCcs()) { + $text[] = __('email.cc') . ': ' . htmlspecialchars($emailLogEntry->getCcs()); + } + if ($emailLogEntry->getBccs()) { + $text[] = __('email.bcc') . ': ' . htmlspecialchars($emailLogEntry->getBccs()); + } $text[] = __('email.subject') . ': ' . htmlspecialchars($emailLogEntry->getSubject()); - $text[] = $emailLogEntry->getBody(); - return nl2br(PKPString::stripUnsafeHtml(implode(PHP_EOL . PHP_EOL, $text))); + return + '
' + . nl2br(join(PHP_EOL, $text)) . '

' + . PKPString::stripUnsafeHtml($emailLogEntry->getBody()) + . '
'; } } diff --git a/controllers/tab/workflow/PKPWorkflowTabHandler.inc.php b/controllers/tab/workflow/PKPWorkflowTabHandler.inc.php index 7efed8cad68..ad1795e4b9d 100644 --- a/controllers/tab/workflow/PKPWorkflowTabHandler.inc.php +++ b/controllers/tab/workflow/PKPWorkflowTabHandler.inc.php @@ -16,11 +16,11 @@ use APP\handler\Handler; use APP\notification\Notification; use APP\notification\NotificationManager; +use APP\submission\Submission; use APP\template\TemplateManager; -use APP\workflow\EditorDecisionActionsManager; use PKP\core\JSONMessage; -use PKP\linkAction\LinkAction; -use PKP\linkAction\request\AjaxModal; +use PKP\core\PKPApplication; +use PKP\decision\types\NewExternalReviewRound; use PKP\notification\PKPNotification; use PKP\security\authorization\WorkflowStageAccessPolicy; use PKP\security\Role; @@ -71,7 +71,8 @@ public function fetchTab($args, $request) $stageId = $this->getAuthorizedContextObject(ASSOC_TYPE_WORKFLOW_STAGE); $templateMgr->assign('stageId', $stageId); - $submission = $this->getAuthorizedContextObject(ASSOC_TYPE_SUBMISSION); /** @var Submission $submission */ + /** @var Submission $submission */ + $submission = $this->getAuthorizedContextObject(ASSOC_TYPE_SUBMISSION); $templateMgr->assign('submission', $submission); switch ($stageId) { @@ -92,10 +93,7 @@ public function fetchTab($args, $request) // as the current review round tab index, if we have review rounds. if ($lastReviewRound) { $lastReviewRoundNumber = $lastReviewRound->getRound(); - $lastReviewRoundId = $lastReviewRound->getId(); $templateMgr->assign('lastReviewRoundNumber', $lastReviewRoundNumber); - } else { - $lastReviewRoundId = null; } // Add the round information to the template. @@ -103,32 +101,13 @@ public function fetchTab($args, $request) $templateMgr->assign('reviewRoundOp', $this->_identifyReviewRoundOp($stageId)); if ($submission->getStageId() == $selectedStageId && count($reviewRoundsArray) > 0) { - $dispatcher = $request->getDispatcher(); - - $newRoundAction = new LinkAction( - 'newRound', - new AjaxModal( - $dispatcher->url( - $request, - PKPApplication::ROUTE_COMPONENT, - null, - 'modals.editorDecision.EditorDecisionHandler', - 'newReviewRound', - null, - [ - 'submissionId' => $submission->getId(), - 'decision' => EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_NEW_ROUND, - 'stageId' => $selectedStageId, - 'reviewRoundId' => $lastReviewRoundId - ] - ), - __('editor.submission.newRound'), - 'modal_add_item' - ), - __('editor.submission.newRound'), - 'add_item_small' + if ($stageId === WORKFLOW_STAGE_ID_EXTERNAL_REVIEW) { + $newReviewRoundType = new NewExternalReviewRound(); + } + $templateMgr->assign( + 'newRoundUrl', + $newReviewRoundType->getUrl($request, $request->getContext(), $submission, $lastReviewRound->getId()) ); - $templateMgr->assign('newRoundAction', $newRoundAction); } // Render the view. diff --git a/js/controllers/EditorialActionsHandler.js b/js/controllers/EditorialActionsHandler.js index f4bf8963823..e67cdacbce2 100644 --- a/js/controllers/EditorialActionsHandler.js +++ b/js/controllers/EditorialActionsHandler.js @@ -25,6 +25,10 @@ this.parent($element, options); $element.find('.pkp_workflow_change_decision') .click(this.callbackWrapper(this.showActions_)); + $element.find('[data-decision]') + .click(this.callbackWrapper(this.emitRevisionDecision_)); + $element.find('[data-recommendation]') + .click(this.callbackWrapper(this.emitRevisionRecommendation_)); }; $.pkp.classes.Helper.inherits( $.pkp.controllers.EditorialActionsHandler, $.pkp.classes.Handler); @@ -42,7 +46,31 @@ $.pkp.controllers.EditorialActionsHandler.prototype.showActions_ = function(sourceElement, event) { this.getHtmlElement().find('.pkp_workflow_change_decision').hide(); - this.getHtmlElement().find('.pkp_workflow_decided_actions').show(); + this.getHtmlElement().find('.pkp_workflow_decisions_options').removeClass('pkp_workflow_decisions_options_hidden'); + }; + + /** + * Emit an event when a request revisions decision is initiated + * + * @param {HTMLElement} sourceElement The clicked link. + * @param {Event} event The triggered event (click). + */ + $.pkp.controllers.EditorialActionsHandler.prototype.emitRevisionDecision_ = + function(sourceElement, event) { + var $el = $(sourceElement); + pkp.eventBus.$emit('decision:revisions', $el.data('reviewRoundId')); + }; + + /** + * Emit an event when a request revisions recommendation is initiated + * + * @param {HTMLElement} sourceElement The clicked link. + * @param {Event} event The triggered event (click). + */ + $.pkp.controllers.EditorialActionsHandler.prototype.emitRevisionRecommendation_ = + function(sourceElement, event) { + var $el = $(sourceElement); + pkp.eventBus.$emit('recommendation:revisions', $el.data('reviewRoundId')); }; diff --git a/js/load.js b/js/load.js index 6638a2c0155..f7407802a9c 100644 --- a/js/load.js +++ b/js/load.js @@ -18,8 +18,12 @@ import VueScrollTo from 'vue-scrollto'; // Global components of UI Library import Badge from '@/components/Badge/Badge.vue'; import Icon from '@/components/Icon/Icon.vue'; +import Panel from '@/components/Panel/Panel.vue'; +import PanelSection from '@/components/Panel/PanelSection.vue'; import PkpButton from '@/components/Button/Button.vue'; import Spinner from '@/components/Spinner/Spinner.vue'; +import Step from '@/components/Steps/Step.vue'; +import Steps from '@/components/Steps/Steps.vue'; import Tab from '@/components/Tabs/Tab.vue'; import Tabs from '@/components/Tabs/Tabs.vue'; @@ -37,8 +41,12 @@ Vue.mixin(GlobalMixins); // Register global components Vue.component('Badge', Badge); Vue.component('Icon', Icon); +Vue.component('Panel', Panel); +Vue.component('PanelSection', PanelSection); Vue.component('PkpButton', PkpButton); Vue.component('Spinner', Spinner); +Vue.component('Step', Step); +Vue.component('Steps', Steps); Vue.component('Tab', Tab); Vue.component('Tabs', Tabs); diff --git a/locale/en_US/api.po b/locale/en_US/api.po index 3680de59c6d..c9846937f49 100644 --- a/locale/en_US/api.po +++ b/locale/en_US/api.po @@ -23,6 +23,9 @@ msgstr "The API token could not be validated. This may indicate an error in the msgid "api.400.tokenCouldNotBeDecoded" msgstr "The apiToken could not be decoded because of the following error: {$error}" +msgid "api.400.requireEmailSubjectBody" +msgstr "You must provide a subject and body for the email." + msgid "api.files.400.notAllowedCreatedAt" msgstr "It is not possible to change the time this was created." @@ -44,6 +47,9 @@ msgstr "The announcement you requested was not found." msgid "api.contexts.400.localesNotSupported" msgstr "The following locales are not supported: {$locales}." +msgid "api.decisions.403.alreadyPublished" +msgstr "You can not record a decision or recommend a decision for this submission because it has already been published." + msgid "api.emails.400.missingBody" msgstr "You must include an email to be sent." @@ -143,9 +149,15 @@ msgstr "You must specify a review round when requesting files in a review stage. msgid "api.submissionFiles.400.noFileStageId" msgstr "You must provide a file stage." +msgid "api.submissionFiles.400.invalidFileStage" +msgstr "The file stage you provided is not valid." + msgid "api.submissionsFiles.400.noParams" msgstr "No changes could be found in the request to edit this file." +msgid "api.submissionFiles.400.reviewRoundIdRequired" +msgstr "You must provide a review round id when moving a file to this file stage." + msgid "api.submissionFiles.400.reviewRoundSubmissionNotMatch" msgstr "The review round you provided is not part of this submission." diff --git a/locale/en_US/common.po b/locale/en_US/common.po index 391c09fd778..522f3d5c9ac 100644 --- a/locale/en_US/common.po +++ b/locale/en_US/common.po @@ -125,9 +125,18 @@ msgstr "Inactive" msgid "common.add" msgstr "Add" +msgid "common.addCCBCC" +msgstr "Add CC/BCC" + msgid "common.addSelf" msgstr "Add Self" +msgid "common.attachedFiles" +msgstr "Attached Files" + +msgid "common.attachFiles" +msgstr "Attach Files" + msgid "common.name" msgstr "Name" @@ -306,6 +315,9 @@ msgstr "Deleting" msgid "common.deleteSelection" msgstr "Delete Selection" +msgid "common.deselect" +msgstr "Deselect" + msgid "common.designation" msgstr "Designation" @@ -405,6 +417,9 @@ msgstr "Add filter: {$filterTitle}" msgid "common.filterRemove" msgstr "Clear filter: {$filterTitle}" +msgid "common.findTemplate" +msgstr "Find Template" + msgid "common.from" msgstr "From" @@ -468,6 +483,12 @@ msgstr "{$value} or less" msgid "common.lessThanOnly" msgstr "Less than" +msgid "common.loadTemplate" +msgstr "Load a Template" + +msgid "common.keepWorking" +msgstr "Keep Working" + msgid "common.commaListSeparator" msgstr ", " @@ -546,6 +567,9 @@ msgstr "Notified: {$dateNotified}" msgid "common.noMatches" msgstr "No Matches" +msgid "common.numberedMore" +msgstr "{$number} more" + msgid "common.off" msgstr "Off" @@ -702,6 +726,9 @@ msgstr "Saving" msgid "common.search" msgstr "Search" +msgid "common.searching" +msgstr "Searching" + msgid "common.searchQuery" msgstr "Search Query" @@ -729,6 +756,9 @@ msgstr "Select {$name}" msgid "common.sendEmail" msgstr "Send Email" +msgid "common.showAllSteps" +msgstr "Show all steps" + msgid "common.size" msgstr "Size" @@ -753,6 +783,9 @@ msgstr "Subtitle" msgid "common.suggest" msgstr "Suggest" +msgid "common.switchTo" +msgstr "Switch to" + msgid "common.title" msgstr "Title" @@ -807,6 +840,9 @@ msgstr "Add File" msgid "common.upload.addFile" msgstr "Upload File" +msgid "common.upload.addFile.description" +msgstr "Upload a file from your computer." + msgid "common.upload.restore" msgstr "Restore Original" @@ -831,6 +867,9 @@ msgstr "[Nonexistent user]" msgid "common.view" msgstr "View" +msgid "common.viewError" +msgstr "View Error" + msgid "common.viewWithName" msgstr "View {$name}" @@ -900,6 +939,30 @@ msgstr "Upload File" msgid "email.addAttachment" msgstr "Add Attachment" +msgid "email.addAttachment.submissionFiles.attach" +msgstr "Attach Submission Files" + +msgid "email.addAttachment.submissionFiles.submissionDescription" +msgstr "Attach files uploaded by the author in the submission stage." + +msgid "email.addAttachment.submissionFiles.reviewDescription" +msgstr "Attach files uploaded during the submission workflow, such as revisions or files to be reviewed." + +msgid "email.addAttachment.libraryFiles" +msgstr "Library Files" + +msgid "email.addAttachment.libraryFiles.attach" +msgstr "Attach Library Files" + +msgid "email.addAttachment.libraryFiles.description" +msgstr "Attach files from the Submission and Publisher Libraries." + +msgid "email.addAttachment.reviewFiles.attach" +msgstr "Attach Review Files" + +msgid "email.addAttachment.reviewFiles.description" +msgstr "Attach files that were uploaded by reviewers" + msgid "email.addBccRecipient" msgstr "Add BCC" @@ -912,6 +975,9 @@ msgstr "Add Recipient" msgid "email.attachments" msgstr "Attachments" +msgid "email.attachmentNotFound" +msgstr "The file {$fileName} could not be attached." + msgid "email.bcc" msgstr "BCC" diff --git a/locale/en_US/editor.po b/locale/en_US/editor.po index 95403798007..8d686954b63 100644 --- a/locale/en_US/editor.po +++ b/locale/en_US/editor.po @@ -44,6 +44,42 @@ msgstr "Submission accepted for review." msgid "editor.submission.workflowDecision.changeDecision" msgstr "Change decision" +msgid "editor.submission.workflowDecision.disallowedDecision" +msgstr "You do not have permission to record this decision on this submission." + +msgid "editor.submission.workflowDecision.invalidEditor" +msgstr "The editor was not recognized and may not have permission to record a decision on this submission." + +msgid "editor.submission.workflowDecision.invalidRecipients" +msgstr "You can not send an email to the following recipients: {$names}." + +msgid "editor.submission.workflowDecision.invalidReviewRound" +msgstr "This review round could not be found." + +msgid "editor.submission.workflowDecision.invalidReviewRoundStage" +msgstr "A review round was provided but this decision is not taken during a review stage." + +msgid "editor.submission.workflowDecision.invalidReviewRoundSubmission" +msgstr "This review round is not part of this submission." + +msgid "editor.submission.workflowDecision.invalidStage" +msgstr "The submission is not at the appropriate stage of the workflow to take this decision." + +msgid "editor.submission.workflowDecision.noUnassignedDecisions" +msgstr "You must be assigned to this submission in order to record an editorial decision." + +msgid "editor.submission.workflowDecision.requiredReviewRound" +msgstr "A review round id must be provided in order to take this decision." + +msgid "editor.submission.workflowDecision.requiredDecidingEditor" +msgstr "A recommendation can not be made unless an editor is assigned to this stage who can take a final decision." + +msgid "editor.submission.workflowDecision.submissionInvalid" +msgstr "A decision could not be taken on this submission. The submission id is missing or does not match the requested submission." + +msgid "editor.submission.workflowDecision.typeInvalid" +msgstr "This decision could not be found. Please provide a recognized decision type." + msgid "editor.review.notInitiated" msgstr "The review process has not yet been initiated." @@ -161,6 +197,12 @@ msgstr "Revisions will not be subject to a new round of peer reviews." msgid "editor.review.NotifyAuthorResubmit" msgstr "Revisions will be subject to a new round of peer reviews." +msgid "editor.review.NotifyAuthorRevisions.recommendation" +msgstr "Revisions should not be subject to a new round of peer reviews." + +msgid "editor.review.NotifyAuthorResubmit.recommendation" +msgstr "Revisions should be subject to a new round of peer reviews." + msgid "editor.review.dateAccepted" msgstr "Review Acceptance Date" @@ -469,3 +511,27 @@ msgstr "Biography" msgid "reviewer.list.empty" msgstr "No reviewers found" + +msgid "editor.decision.cancelDecision" +msgstr "Cancel Decision" + +msgid "editor.decision.cancelDecision.confirmation" +msgstr "Are you sure you want to cancel this decision?" + +msgid "editor.decision.completeSteps" +msgstr "Complete the following steps to take this decision" + +msgid "editor.decision.dontSkipEmail" +msgstr "Don't skip this email" + +msgid "editor.decision.emailSkipped" +msgstr "This step has been skipped and no email will be sent." + +msgid "editor.decision.recordDecision" +msgstr "Record Decision" + +msgid "editor.decision.skipEmail" +msgstr "Skip this email" + +msgid "editor.decision.stepError" +msgstr "There was a problem with the {$stepName} step." diff --git a/locale/en_US/manager.po b/locale/en_US/manager.po index a956eebde85..c397b654eab 100644 --- a/locale/en_US/manager.po +++ b/locale/en_US/manager.po @@ -2091,9 +2091,25 @@ msgstr "Process failed to parse authors" msgid "plugins.importexport.publication.exportFailed" msgstr "Process failed to parse publications" +msgid "emailTemplate.variable.allReviewersComments" +msgstr "All comments from completed reviews. Reviewer names are hidden for anonymous reviews" + msgid "emailTemplate.variable.context.passwordLostUrl" msgstr "The URL to a page where the user can recover a lost password" + +msgid "emailTemplate.variable.decision.name" +msgstr "The name of the decision that was taken." + +msgid "emailTemplate.variable.decision.description" +msgstr "A description of the decision that was taken." + +msgid "emailTemplate.variable.decision.stage" +msgstr "The stage of the editorial workflow this decision was taken in." + +msgid "emailTemplate.variable.decision.round" +msgstr "The round of review this decision was taken in, if the decision is related to a review stage." + msgid "emailTemplate.variable.recipient.userFullName" msgstr "The full name of the recipient or all recipients" @@ -2172,3 +2188,96 @@ msgstr "Validate Email (Site)" msgid "mailable.validateEmailSite.description" msgstr "This email is automatically sent to a new user when they register with the site when the settings require the email address to be validated." +msgid "mailable.decision.notifyReviewer.name" +msgstr "Reviewer Acknowledgement" + +msgid "mailable.decision.notifyReviewer.description" +msgstr "This email is sent by an Editor to a Reviewer to notify them that a decision has been made regarding a submission that they reviewed." + +msgid "mailable.decision.accept.notifyAuthor.name" +msgstr "Submission Accepted" + +msgid "mailable.decision.accept.notifyAuthor.description" +msgstr "This email notifies the author that their submission has been accepted for publication." + +msgid "mailable.decision.backToCopyediting.notifyAuthor.name" +msgstr "Submission Sent Back to Copyediting" + +msgid "mailable.decision.backToCopyediting.notifyAuthor.description" +msgstr "This email notifies the author that their submission has been sent back to the copyediting stage." + +msgid "mailable.decision.backToReview.notifyAuthor.name" +msgstr "Submission Sent Back to Review" + +msgid "mailable.decision.backToReview.notifyAuthor.description" +msgstr "This email notifies the author that their submission has been sent back to the review stage." + +msgid "mailable.decision.backToSubmission.notifyAuthor.name" +msgstr "Submission Sent Back to Submission" + +msgid "mailable.decision.backToSubmission.notifyAuthor.description" +msgstr "This email notifies the author that their submission has been sent back to the submission stage." + +msgid "mailable.decision.decline.notifyAuthor.name" +msgstr "Submission Declined" + +msgid "mailable.decision.decline.notifyAuthor.description" +msgstr "This email notifies the author that their submission has been declined after peer review." + +msgid "mailable.decision.initialDecline.notifyAuthor.name" +msgstr "Submission Declined" + +msgid "mailable.decision.newReviewRound.notifyAuthor.name" +msgstr "New Review Round Initiated" + +msgid "mailable.decision.newReviewRound.notifyAuthor.description" +msgstr "This email notifies the author that a new round of review is beginning for their submission." + +msgid "mailable.decision.requestRevisions.notifyAuthor.name" +msgstr "Revisions Requested" + +msgid "mailable.decision.requestRevisions.notifyAuthor.description" +msgstr "This email notifies the author of a decision to requests revisions during peer review." + +msgid "mailable.decision.resubmit.notifyAuthor.name" +msgstr "Resubmit for Review" + +msgid "mailable.decision.resubmit.notifyAuthor.description" +msgstr "This email notifies the author of a \"revise and resubmit\" decision regarding their submission." + +msgid "mailable.decision.revertDecline.notifyAuthor.name" +msgstr "Reinstate Declined Submission" + +msgid "mailable.decision.revertDecline.notifyAuthor.description" +msgstr "This email notifies the author that a previous decision to decline their submission after peer review is being reverted." + +msgid "mailable.decision.revertInitialDecline.notifyAuthor.name" +msgstr "Reinstate Submission Declined Without Review" + +msgid "mailable.decision.revertInitialDecline.notifyAuthor.description" +msgstr "This email notifies the author that a previous decision to decline their submission without review is being reverted." + +msgid "mailable.decision.sendExternalReview.notifyAuthor.name" +msgstr "Sent to Review" + +msgid "mailable.decision.sendExternalReview.notifyAuthor.description" +msgstr "This email notifies the author that their submission is being sent to the review stage." + +msgid "mailable.decision.sendToProduction.notifyAuthor.name" +msgstr "Sent to Production" + +msgid "mailable.decision.sendToProduction.notifyAuthor.description" +msgstr "This email notifies the author that their submission is being sent to the production stage." + +msgid "mailable.decision.skipReview.notifyAuthor.name" +msgstr "Review Skipped" + +msgid "mailable.decision.skipReview.notifyAuthor.description" +msgstr "This email notifies the author that their submission is being sent directly to the copyediting stage and will not be peer reviewed." + +msgid "mailable.decision.recommendation.notifyEditors.name" +msgstr "Recommendation Made" + +msgid "mailable.decision.recommendation.notifyEditors.description" +msgstr "This message notifies a senior Editor or Section Editor that an editorial recommendation has been made regarding one of their assigned submissions. This message is used when an editor is only allowed to recommend an editorial decision and requires an authorized editor to record editorial decisions. This option can be selected when assigning participants to a submission." + diff --git a/locale/en_US/submission.po b/locale/en_US/submission.po index 6a98951c37d..fc2522dc1ef 100644 --- a/locale/en_US/submission.po +++ b/locale/en_US/submission.po @@ -575,6 +575,9 @@ msgstr "Are you sure you want to delete this event log entry?" msgid "submission.event.deleteLogEntry" msgstr "Delete Log Entry" +msgid "submission.event.decisionReviewerEmailSent" +msgstr "An email about the decision was sent to {$recipientCount} reviewer(s) with the subject {$subject}." + msgid "submission.event.submissionSubmitted" msgstr "Initial submission completed." @@ -1331,27 +1334,204 @@ msgstr "Are you sure you want the authors of this submission to be able to edit msgid "editor.submission.decision" msgstr "Decision" +msgid "editor.submission.decision.notifyAuthors" +msgstr "Notify Authors" + +msgid "editor.submission.decision.notifyReviewers" +msgstr "Notify Reviewers" + +msgid "editor.submission.decision.notifyReviewers.description" +msgstr "Send an email to the reviewers to thank them for their review and let them know that a decision was taken." + msgid "editor.submission.decision.accept" msgstr "Accept Submission" +msgid "editor.submission.decision.accept.description" +msgstr "This submission will be accepted for publication and sent for copyediting." + +msgid "editor.submission.decision.accept.log" +msgstr "{$editorName} accepted this submission and sent it to the copyediting stage." + +msgid "editor.submission.decision.accept.completed" +msgstr "Submission Accepted" + +msgid "editor.submission.decision.accept.completedDescription" +msgstr "The submission, {$title}, has been accepted for publication and sent to the copyediting stage." + +msgid "editor.submission.decision.accept.notifyAuthorsDescription" +msgstr "Send an email to the authors to let them know that their submission has been accepted for publication." + +msgid "editor.submission.decision.backToSubmission" +msgstr "Back to Submission" + +msgid "editor.submission.decision.backToSubmission.completed" +msgstr "Sent Back to Submission" + +msgid "editor.submission.decision.backToSubmission.completed.description" +msgstr "The submission, {$title}, was sent back to the submission stage." + +msgid "editor.submission.decision.backToSubmission.notifyAuthorsDescription" +msgstr "Send an email to the authors to let them know that their submission is being sent back to the submission stage. Explain why this decision was made and inform the author of what further work will be undertaken before moving the submission forward." + +msgid "editor.submission.decision.backToSubmissionFromCopyediting.description" +msgstr "Revert the decision to accept this submission and send it back to the submission stage." + +msgid "editor.submission.decision.backToSubmissionFromCopyediting.log" +msgstr "{$editorName} reverted the decision to accept this submission and sent it back to the submission stage." + +msgid "editor.submission.decision.decline" +msgstr "Decline Submission" + +msgid "editor.submission.decision.decline.description" +msgstr "This submission will be declined for publication. The peer review stage will be closed and the submission will be archived." + +msgid "editor.submission.decision.decline.log" +msgstr "{$editorName} declined this submission." + +msgid "editor.submission.decision.decline.completed" +msgstr "Submission Declined" + +msgid "editor.submission.decision.decline.completed.description" +msgstr "The submission, {$title}, has been declined and sent to the archives." + +msgid "editor.submission.decision.decline.notifyAuthorsDescription" +msgstr "Send an email to the authors to let them know that their submission has been declined." + +msgid "editor.submission.decision.initialDecline.description" +msgstr "This submission will be declined for publication. No further review will be conducted and the submission will be archived." + +msgid "editor.submission.decision.newReviewRound" +msgstr "New Review Round" + +msgid "editor.submission.decision.newReviewRound.description" +msgstr "Open another round of review for this submission." + +msgid "editor.submission.decision.newReviewRound.log" +msgstr "{$editorName} created a new round of review for this submission." + +msgid "editor.submission.decision.newReviewRound.completed" +msgstr "Review Round Created" + +msgid "editor.submission.decision.newReviewRound.completedDescription" +msgstr "A new round of review has been created for the submission, {$title}." + +msgid "editor.submission.decision.newReviewRound.notifyAuthorsDescription" +msgstr "Send an email to the authors to let them know that their submission has been sent for a new round of review." + +msgid "editor.submission.decision.promoteFiles.copyediting" +msgstr "Select files that should be sent to the copyediting stage." + +msgid "editor.submission.decision.promoteFiles.review" +msgstr "Select files that should be sent for review." + msgid "editor.submission.decision.requestRevisions" msgstr "Request Revisions" +msgid "editor.submission.decision.requestRevisions.description" +msgstr "The author must provide revisions before this submission will be accepted for publication." + +msgid "editor.submission.decision.requestRevisions.log" +msgstr "{$editorName} requested revisions for this submission." + +msgid "editor.submission.decision.requestRevisions.completed" +msgstr "Revisions Requested" + +msgid "editor.submission.decision.requestRevisions.completed.description" +msgstr "Revisions for the submission, {$title}, have been requested." + +msgid "editor.submission.decision.requestRevisions.notifyAuthorsDescription" +msgstr "Send an email to the authors to let them know that revisions will be required before this submission will be accepted for publication. Include all of the details that the author will need in order to revise their submission. Where appropriate, remember to anonymise any reviewer comments." + msgid "editor.submission.decision.resubmit" msgstr "Resubmit for Review" +msgid "editor.submission.decision.resubmit.description" +msgstr "The author must provide revisions that will be sent for another round of review before this submission will be accepted for publication." + +msgid "editor.submission.decision.resubmit.log" +msgstr "{$editorName} requested revisions for this submission that should be sent for another round of review." + +msgid "editor.submission.decision.resubmit.completed" +msgstr "Revisions Requested" + +msgid "editor.submission.decision.resubmit.completed.description" +msgstr "Revisions for the submission, {$title}, have been requested. A decision to send the revisions for another round of reviews was recorded." + +msgid "editor.submission.decision.resubmit.notifyAuthorsDescription" +msgstr "Send an email to the authors to let them know that revisions will be need to be completed and sent for another round of review. Include all of the details that the author will need in order to revise their submission. Where appropriate, remember to anonymise any reviewer comments." + +msgid "editor.submission.decision.sendExternalReview" +msgstr "Send for Review" + +msgid "editor.submission.decision.sendExternalReview.description" +msgstr "This submission is ready to be sent for peer review." + +msgid "editor.submission.decision.sendExternalReview.log" +msgstr "{$editorName} sent this submission to the review stage." + +msgid "editor.submission.decision.sendExternalReview.completed" +msgstr "Sent for Review" + +msgid "editor.submission.decision.sendExternalReview.completed.description" +msgstr "The submission, {$title}, has been sent to the review stage." + msgid "editor.submission.decision.newRound" msgstr "New review round" -msgid "editor.submission.decision.decline" -msgstr "Decline Submission" - msgid "editor.submission.decision.sendToProduction" msgstr "Send To Production" +msgid "editor.submission.decision.sendToProduction.description" +msgstr "Send this submission to the production stage to be prepared for publication." + +msgid "editor.submission.decision.sendToProduction.log" +msgstr "{$editorName} sent this submission to the production stage." + +msgid "editor.submission.decision.sendToProduction.completed" +msgstr "Sent to Production" + +msgid "editor.submission.decision.sendToProduction.completed.description" +msgstr "The submission, {$title}, was sent to the production stage." + +msgid "editor.submission.decision.sendToProduction.notifyAuthorsDescription" +msgstr "Send an email to the authors to let them know that this submission has been sent to the production stage." + +msgid "editor.submission.decision.backToCopyediting" +msgstr "Back to Copyediting" + +msgid "editor.submission.decision.backToCopyediting.description" +msgstr "Send this submission back to the copyediting stage." + +msgid "editor.submission.decision.backToCopyediting.log" +msgstr "{$editorName} sent this submission back to the copyediting stage." + +msgid "editor.submission.decision.backToCopyediting.completed" +msgstr "Sent Back to Copyediting" + +msgid "editor.submission.decision.backToCopyediting.completed.description" +msgstr "The submission, {$title}, was sent back to the copyediting stage." + +msgid "editor.submission.decision.backToCopyediting.notifyAuthorsDescription" +msgstr "Send an email to the authors to let them know that this submission has been sent back to the copyediting stage. Explain why this decision was made and inform the author of what further editing will be required before this submission is ready for production." + msgid "editor.submission.decision.skipReview" msgstr "Accept and Skip Review" +msgid "editor.submission.decision.skipReview.description" +msgstr "Accept this submission for publication and skip the review stage. This decision will send the submission to the copyediting stage." + +msgid "editor.submission.decision.skipReview.log" +msgstr "{$editorName} skipped the review stage and sent this submission to the copyediting stage." + +msgid "editor.submission.decision.skipReview.completed" +msgstr "Skipped Review" + +msgid "editor.submission.decision.skipReview.completed.description" +msgstr "The submission, {$title}, skipped the review stage and has been sent to the copyediting stage." + +msgid "editor.submission.decision.skipReview.notifyAuthorsDescription" +msgstr "Send an email to the authors to let them know that this submission has been accepted for publication and sent to the copyediting stage without review." + msgid "editor.submission.decision.sendInternalReview" msgstr "Send to Internal Review" @@ -1367,9 +1547,72 @@ msgstr "This file proof will no longer be publicly available for download or pur msgid "editor.submission.decision.revertDecline" msgstr "Revert Decline" +msgid "editor.submission.decision.revertDecline.description" +msgstr "Revert a previous decision to decline this submission and return it to the active editorial process." + +msgid "editor.submission.decision.revertDecline.log" +msgstr "{$editorName} reversed the decision to decline this submission." + +msgid "editor.submission.decision.revertDecline.completed" +msgstr "Submission Reactivated" + +msgid "editor.submission.decision.revertDecline.completed.description" +msgstr "The submission, {$title}, is now an active submission in the review stage." + +msgid "editor.submission.decision.revertDecline.notifyAuthorsDescription" +msgstr "Send an email to the authors to let them know that a previous decision to decline this submission has been reverted. Explain why the decision was reverted and let them know whether the submission is expected to undergo further review." + +msgid "editor.submission.decision.revertInitialDecline.completed.description" +msgstr "The submission, {$title}, is now active in the submission stage." + msgid "editor.submission.decision.noDecisionsAvailable" msgstr "Assign an editor to enable the editorial decisions for this stage." +msgid "editor.submission.recommend.notifyEditors.description" +msgstr "Send a message to the deciding editors to let them know the recommendation. Explain why this recommendation was made in response to the recommendations and comments submitted by reviewers." + +msgid "editor.submission.recommend.accept" +msgstr "Recommend Accept" + +msgid "editor.submission.recommend.accept.description" +msgstr "Recommend that this submission be accepted for publication and sent for copyediting." + +msgid "editor.submission.recommend.accept.log" +msgstr "{$editorName} recommended that this submission be accepted and sent for copyediting." + +msgid "editor.submission.recommend.completed" +msgstr "Recommendation Submitted" + +msgid "editor.submission.recommend.completed.description" +msgstr "Your recommendation has been recorded and the deciding editor(s) have been notified." + +msgid "editor.submission.recommend.revisions" +msgstr "Recommend Revisions" + +msgid "editor.submission.recommend.revisions.description" +msgstr "Recommend that revisions be requested from the author before this submission is accepted for publication." + +msgid "editor.submission.recommend.revisions.log" +msgstr "{$editorName} recommended that revisions be requested." + +msgid "editor.submission.recommend.resubmit" +msgstr "Recommend Resubmit for Review" + +msgid "editor.submission.recommend.resubmit.description" +msgstr "Recommend that the author is asked to submit revisions for another round of review." + +msgid "editor.submission.recommend.resubmit.log" +msgstr "{$editorName} recommended that revisions be requested and that these revisions be sent for another round of review." + +msgid "editor.submission.recommend.decline" +msgstr "Recommend Decline" + +msgid "editor.submission.recommend.decline.description" +msgstr "Recommend that the submission be declined for publication." + +msgid "editor.submission.recommend.decline.log" +msgstr "{$editorName} recommended that this submission be declined." + msgid "editor.submission.makeRecommendation" msgstr "Make Recommendation" @@ -1382,6 +1625,9 @@ msgstr "Recommendation: {$recommendation}" msgid "editor.submission.allRecommendations.display" msgstr "Recommendations: {$recommendations}" +msgid "editor.submission.recommendation.noDecidingEditors" +msgstr "You can not make a recommendation until an editor is assigned with permission to record a decision." + msgid "editor.submission.recommendation" msgstr "Recommendation" @@ -1391,15 +1637,6 @@ msgstr "Recommend an editorial decision for this submission." msgid "editor.submission.recordedRecommendations" msgstr "Recorded Recommendations" -msgid "editor.submission.decision.nextButton" -msgstr "Next: Select Files for {$stageName}" - -msgid "editor.submission.decision.selectFiles" -msgstr "Select the files you would like to forward to the {$stageName} stage." - -msgid "editor.submission.decision.previousAuthorNotification" -msgstr "Previous: Author Notification" - msgid "submission.currentStage" msgstr "Current stage" diff --git a/pages/authorDashboard/PKPAuthorDashboardHandler.inc.php b/pages/authorDashboard/PKPAuthorDashboardHandler.inc.php index 009021d618b..1a51ccc7c5c 100644 --- a/pages/authorDashboard/PKPAuthorDashboardHandler.inc.php +++ b/pages/authorDashboard/PKPAuthorDashboardHandler.inc.php @@ -178,7 +178,7 @@ public function setupTemplate($request) $lastReviewRound = $reviewRoundDao->getLastReviewRoundBySubmissionId($submission->getId(), $submission->getData('stageId')); if ($fileStage && is_a($lastReviewRound, 'ReviewRound')) { $editDecisionDao = DAORegistry::getDAO('EditDecisionDAO'); /** @var EditDecisionDAO $editDecisionDao */ - $editorDecisions = $editDecisionDao->getEditorDecisions($submission->getId(), $submission->getData('stageId'), $lastReviewRound->getRound()); + $editorDecisions = $editDecisionDao->getEditorDecisions($submission->getId(), $submission->getData('stageId'), $lastReviewRound->getId()); if (!empty($editorDecisions)) { $lastDecision = end($editorDecisions)['decision']; $revisionDecisions = [ diff --git a/pages/decision/PKPDecisionHandler.inc.php b/pages/decision/PKPDecisionHandler.inc.php new file mode 100644 index 00000000000..14664bb8b53 --- /dev/null +++ b/pages/decision/PKPDecisionHandler.inc.php @@ -0,0 +1,242 @@ +addRoleAssignment( + [Role::ROLE_ID_MANAGER, Role::ROLE_ID_SUB_EDITOR], + ['record'] + ); + } + + /** + * @copydoc PKPHandler::authorize() + */ + public function authorize($request, &$args, $roleAssignments): bool + { + $op = $request->getRouter()->getRequestedOp($request); + + if (!$op || $op !== 'record') { + return false; + } + + $decision = (int) $request->getUserVar('decision'); + + // Get the stage ID from the decision type + /** @var Type $type */ + $type = Repo::decision()->getTypes() + ->first(function (Type $type) use ($decision) { + return $type->getDecision() === $decision; + }); + + if (!$type) { + return false; + } + + $this->decisionType = $type; + + $this->addPolicy(new SubmissionRequiredPolicy($request, $args, 'submissionId')); + $this->addPolicy(new WorkflowStageAccessPolicy($request, $args, $roleAssignments, 'submissionId', $this->decisionType->getStageId(), Application::WORKFLOW_TYPE_EDITORIAL)); + + if (in_array($this->decisionType->getStageId(), [WORKFLOW_STAGE_ID_INTERNAL_REVIEW, WORKFLOW_STAGE_ID_EXTERNAL_REVIEW])) { + $this->addPolicy(new ReviewRoundRequiredPolicy($request, $args)); + } + + return parent::authorize($request, $args, $roleAssignments); + } + + public function record($args, $request) + { + $this->setupTemplate($request); + $dispatcher = $request->getDispatcher(); + $context = $request->getContext(); + AppLocale::requireComponents([ + LOCALE_COMPONENT_PKP_SUBMISSION, + LOCALE_COMPONENT_APP_SUBMISSION, + LOCALE_COMPONENT_PKP_EDITOR, + LOCALE_COMPONENT_APP_EDITOR, + ]); + + $submission = $this->getAuthorizedContextObject(Application::ASSOC_TYPE_SUBMISSION); + $reviewRound = $this->getAuthorizedContextObject(Application::ASSOC_TYPE_REVIEW_ROUND); + + if ($submission->getData('stageId') !== $this->decisionType->getStageId()) { + $request->getDispatcher()->handle404(); + } + + if (Repo::decision()->isRecommendation($this->decisionType->getDecision())) { + /** @var StageAssignmentDAO $stageAssignmentDao */ + $stageAssignmentDao = DAORegistry::getDAO('StageAssignmentDAO'); + $assignedEditorIds = $stageAssignmentDao->getDecidingEditorIds($submission->getId(), $this->decisionType->getStageId()); + if (!$assignedEditorIds) { + $request->getDispatcher()->handle404(); + } + } + + $workflow = $this->decisionType->getWorkflow( + $submission, + $context, + $request->getUser(), + $reviewRound + ); + + $templateMgr = TemplateManager::getManager($request); + $templateMgr->assign('pageComponent', 'DecisionPage'); + + $templateMgr->setState([ + 'abandonDecisionLabel' => __('editor.decision.cancelDecision'), + 'cancelConfirmationPrompt' => __('editor.decision.cancelDecision.confirmation'), + 'decision' => $this->decisionType->getDecision(), + 'decisionCompleteLabel' => $this->decisionType->getCompletedLabel(), + 'decisionCompleteDescription' => $this->decisionType->getCompletedMessage($submission), + 'emailTemplatesApiUrl' => $dispatcher->url( + $request, + Application::ROUTE_API, + $context->getData('urlPath'), + 'emailTemplates' + ), + 'fileGenres' => $this->getFileGenres($context), + 'keepWorkingLabel' => __('common.keepWorking'), + 'reviewRoundId' => $reviewRound ? $reviewRound->getId() : null, + 'stepErrorMessage' => __('editor.decision.stepError'), + 'stageId' => $submission->getStageId(), + 'submissionUrl' => $dispatcher->url( + $request, + Application::ROUTE_PAGE, + $context->getData('urlPath'), + 'workflow', + 'access', + [$submission->getId()] + ), + 'submissionApiUrl' => $dispatcher->url( + $request, + Application::ROUTE_API, + $context->getData('urlPath'), + 'submissions/' . $submission->getId() + ), + 'viewSubmissionLabel' => __('submission.list.viewSubmission'), + 'workflow' => $workflow->getState(), + ]); + + $templateMgr->assign([ + 'breadcrumbs' => $this->getBreadcrumb($submission, $context, $request, $dispatcher), + 'decisionType' => $this->decisionType, + 'pageWidth' => TemplateManager::PAGE_WIDTH_WIDE, + 'reviewRound' => $reviewRound, + 'submission' => $submission, + ]); + + $templateMgr->display('decision/record.tpl'); + } + + protected function getBreadcrumb(Submission $submission, Context $context, Request $request, Dispatcher $dispatcher) + { + $currentPublication = $submission->getCurrentPublication(); + $submissionTitle = Stringy::create( + join( + __('common.commaListSeparator'), + [ + $currentPublication->getShortAuthorString(), + $currentPublication->getLocalizedFullTitle(), + ] + ) + ); + if ($submissionTitle->length() > 50) { + $submissionTitle = $submissionTitle->safeTruncate(50) + ->append('...'); + } + + return [ + [ + 'id' => 'submissions', + 'name' => __('navigation.submissions'), + 'url' => $dispatcher->url( + $request, + Application::ROUTE_PAGE, + $context->getData('urlPath'), + 'submissions' + ), + ], + [ + 'id' => 'submission', + 'name' => $submissionTitle, + 'url' => $dispatcher->url( + $request, + Application::ROUTE_PAGE, + $context->getData('urlPath'), + 'workflow', + 'access', + [$submission->getId()] + ), + ], + [ + 'id' => 'decision', + 'name' => $this->decisionType->getLabel(), + ] + ]; + } + + protected function getFileGenres(Context $context): array + { + $fileGenres = []; + + /** @var GenreDAO $genreDao */ + $genreDao = DAORegistry::getDAO('GenreDAO'); + $genreResults = $genreDao->getEnabledByContextId($context->getId()); + /** @var Genre $genre */ + while ($genre = $genreResults->next()) { + $fileGenres[] = [ + 'id' => $genre->getId(), + 'name' => $genre->getLocalizedName(), + 'isPrimary' => !$genre->getSupplementary() && !$genre->getDependent(), + ]; + } + + return $fileGenres; + } +} diff --git a/pages/workflow/PKPWorkflowHandler.inc.php b/pages/workflow/PKPWorkflowHandler.inc.php index 88c7469d831..fdd88391722 100644 --- a/pages/workflow/PKPWorkflowHandler.inc.php +++ b/pages/workflow/PKPWorkflowHandler.inc.php @@ -15,16 +15,18 @@ use APP\facades\Repo; use APP\handler\Handler; +use APP\i18n\AppLocale; +use APP\submission\Submission; use APP\template\TemplateManager; use APP\workflow\EditorDecisionActionsManager; -use PKP\linkAction\LinkAction; -use PKP\linkAction\request\AjaxModal; +use PKP\db\DAORegistry; +use PKP\decision\Type; use PKP\notification\PKPNotification; use PKP\security\authorization\internal\SubmissionRequiredPolicy; use PKP\security\authorization\internal\UserAccessibleWorkflowStageRequiredPolicy; use PKP\security\authorization\WorkflowStageAccessPolicy; use PKP\security\Role; - +use PKP\stageAssignment\StageAssignmentDAO; use PKP\submission\PKPSubmission; use PKP\workflow\WorkflowStageDAO; @@ -205,6 +207,7 @@ public function index($args, $request) $latestPublication = $submission->getLatestPublication(); $submissionApiUrl = $request->getDispatcher()->url($request, PKPApplication::ROUTE_API, $submissionContext->getData('urlPath'), 'submissions/' . $submission->getId()); + $submissionFileApiUrl = $request->getDispatcher()->url($request, PKPApplication::ROUTE_API, $submissionContext->getData('urlPath'), 'submissions/' . $submission->getId() . '/files'); $latestPublicationApiUrl = $request->getDispatcher()->url($request, PKPApplication::ROUTE_API, $submissionContext->getData('urlPath'), 'submissions/' . $submission->getId() . '/publications/' . $latestPublication->getId()); $contributorsGridUrl = $request->getDispatcher()->url( @@ -220,6 +223,17 @@ public function index($args, $request) ] ); + $decisionUrl = $request->url( + $submissionContext->getData('urlPath'), + 'decision', + 'record', + $submission->getId(), + [ + 'decision' => '__decision__', + 'reviewRoundId' => '__reviewRoundId__', + ] + ); + $editorialHistoryUrl = $request->getDispatcher()->url( $request, PKPApplication::ROUTE_COMPONENT, @@ -256,6 +270,8 @@ public function index($args, $request) $citationsForm = new PKP\components\forms\publication\PKPCitationsForm($latestPublicationApiUrl, $latestPublication); $publicationLicenseForm = new PKP\components\forms\publication\PKPPublicationLicenseForm($latestPublicationApiUrl, $locales, $latestPublication, $submissionContext, $authorUserGroups); $titleAbstractForm = new PKP\components\forms\publication\PKPTitleAbstractForm($latestPublicationApiUrl, $locales, $latestPublication); + $selectRevisionDecisionForm = new PKP\components\forms\decision\SelectRevisionDecisionForm(); + $selectRevisionRecommendationForm = new PKP\components\forms\decision\SelectRevisionRecommendationForm(); // Import constants import('classes.components.forms.publication.PublishForm'); @@ -269,6 +285,8 @@ public function index($args, $request) 'FORM_PUBLICATION_LICENSE' => FORM_PUBLICATION_LICENSE, 'FORM_PUBLISH' => FORM_PUBLISH, 'FORM_TITLE_ABSTRACT' => FORM_TITLE_ABSTRACT, + 'FORM_SELECT_REVISION_DECISION' => FORM_SELECT_REVISION_DECISION, + 'FORM_SELECT_REVISION_RECOMMENDATION' => FORM_SELECT_REVISION_RECOMMENDATION, ]); // Get the submission props without the full publication details. We'll @@ -299,12 +317,15 @@ public function index($args, $request) 'canAccessPublication' => $canAccessPublication, 'canEditPublication' => $canEditPublication, 'components' => [ - FORM_CITATIONS => $citationsForm->getConfig(), - FORM_PUBLICATION_LICENSE => $publicationLicenseForm->getConfig(), - FORM_TITLE_ABSTRACT => $titleAbstractForm->getConfig(), + $citationsForm->id => $citationsForm->getConfig(), + $publicationLicenseForm->id => $publicationLicenseForm->getConfig(), + $titleAbstractForm->id => $titleAbstractForm->getConfig(), + $selectRevisionDecisionForm->id => $selectRevisionDecisionForm->getConfig(), + $selectRevisionRecommendationForm->id => $selectRevisionRecommendationForm->getConfig(), ], 'contributorsGridUrl' => $contributorsGridUrl, 'currentPublication' => $currentPublicationProps, + 'decisionUrl' => $decisionUrl, 'editorialHistoryUrl' => $editorialHistoryUrl, 'publicationFormIds' => [ FORM_CITATIONS, @@ -320,6 +341,7 @@ public function index($args, $request) 'schedulePublicationLabel' => __('editor.submission.schedulePublication'), 'statusLabel' => __('semicolon', ['label' => __('common.status')]), 'submission' => $submissionProps, + 'submissionFileApiUrl' => $submissionFileApiUrl, 'submissionApiUrl' => $submissionApiUrl, 'submissionLibraryLabel' => __('grid.libraryFiles.submission.title'), 'submissionLibraryUrl' => $submissionLibraryUrl, @@ -483,6 +505,7 @@ public function editorDecisionActions($args, $request) // If a review round was specified, include it in the args; // must also check that this is the last round or decisions // cannot be recorded. + $reviewRound = null; if ($reviewRoundId) { $actionArgs['reviewRoundId'] = $reviewRoundId; $reviewRoundDao = DAORegistry::getDAO('ReviewRoundDAO'); /** @var ReviewRoundDAO $reviewRoundDao */ @@ -495,10 +518,9 @@ public function editorDecisionActions($args, $request) // If there is an editor assigned, retrieve stage decisions. $stageAssignmentDao = DAORegistry::getDAO('StageAssignmentDAO'); /** @var StageAssignmentDAO $stageAssignmentDao */ $editorsStageAssignments = $stageAssignmentDao->getEditorsAssignedToStage($submission->getId(), $stageId); - $dispatcher = $request->getDispatcher(); $user = $request->getUser(); - $recommendOnly = $makeDecision = false; + $makeRecommendation = $makeDecision = false; // if the user is assigned several times in an editorial role, check his/her assignments permissions i.e. // if the user is assigned with both possibilities: to only recommend as well as make decision foreach ($editorsStageAssignments as $editorsStageAssignment) { @@ -506,7 +528,7 @@ public function editorDecisionActions($args, $request) if (!$editorsStageAssignment->getRecommendOnly()) { $makeDecision = true; } else { - $recommendOnly = true; + $makeRecommendation = true; } } } @@ -514,7 +536,7 @@ public function editorDecisionActions($args, $request) // If user is not assigned to the submission, // see if the user is manager, and // if the group is recommendOnly - if (!$recommendOnly && !$makeDecision) { + if (!$makeRecommendation && !$makeDecision) { $userGroupDao = DAORegistry::getDAO('UserGroupDAO'); /** @var UserGroupDAO $userGroupDao */ $userGroups = $userGroupDao->getByUserId($user->getId(), $request->getContext()->getId()); while ($userGroup = $userGroups->next()) { @@ -522,23 +544,24 @@ public function editorDecisionActions($args, $request) if (!$userGroup->getRecommendOnly()) { $makeDecision = true; } else { - $recommendOnly = true; + $makeRecommendation = true; } } } } - $editorActions = []; $editorDecisions = []; - $lastRecommendation = $allRecommendations = null; + $lastRecommendation = null; + $allRecommendations = null; + $hasDecidingEditors = false; if (!empty($editorsStageAssignments) && (!$reviewRoundId || ($lastReviewRound && $reviewRoundId == $lastReviewRound->getId()))) { $editDecisionDao = DAORegistry::getDAO('EditDecisionDAO'); /** @var EditDecisionDAO $editDecisionDao */ $recommendationOptions = (new EditorDecisionActionsManager())->getRecommendationOptions($stageId); // If this is a review stage and the user has "recommend only role" if (($stageId == WORKFLOW_STAGE_ID_EXTERNAL_REVIEW || $stageId == WORKFLOW_STAGE_ID_INTERNAL_REVIEW)) { - if ($recommendOnly) { + if ($makeRecommendation) { // Get the made editorial decisions from the current user - $editorDecisions = $editDecisionDao->getEditorDecisions($submission->getId(), $stageId, $reviewRound->getRound(), $user->getId()); + $editorDecisions = $editDecisionDao->getEditorDecisions($submission->getId(), $stageId, $reviewRound->getId(), $user->getId()); // Get the last recommendation foreach ($editorDecisions as $editorDecision) { if (array_key_exists($editorDecision['decision'], $recommendationOptions)) { @@ -554,28 +577,15 @@ public function editorDecisionActions($args, $request) if ($lastRecommendation) { $lastRecommendation = __($recommendationOptions[$lastRecommendation['decision']]); } - // Add the recommend link action. - $editorActions[] = - new LinkAction( - 'recommendation', - new AjaxModal( - $dispatcher->url( - $request, - PKPApplication::ROUTE_COMPONENT, - null, - 'modals.editorDecision.EditorDecisionHandler', - 'sendRecommendation', - null, - $actionArgs - ), - $lastRecommendation ? __('editor.submission.changeRecommendation') : __('editor.submission.makeRecommendation'), - 'review_recommendation' - ), - $lastRecommendation ? __('editor.submission.changeRecommendation') : __('editor.submission.makeRecommendation') - ); + + // At least one deciding editor must be assigned before a recommendation can be made + /** @var StageAssignmentDAO $stageAssignmentDao */ + $stageAssignmentDao = DAORegistry::getDAO('StageAssignmentDAO'); + $decidingEditorIds = $stageAssignmentDao->getDecidingEditorIds($submission->getId(), $stageId); + $hasDecidingEditors = count($decidingEditorIds) > 0; } elseif ($makeDecision) { // Get the made editorial decisions from all users - $editorDecisions = $editDecisionDao->getEditorDecisions($submission->getId(), $stageId, $reviewRound->getRound()); + $editorDecisions = $editDecisionDao->getEditorDecisions($submission->getId(), $stageId, $reviewRound->getId()); // Get all recommendations $recommendations = []; foreach ($editorDecisions as $editorDecision) { @@ -596,35 +606,8 @@ public function editorDecisionActions($args, $request) } } } - // Get the possible editor decisions for this stage - $decisions = (new EditorDecisionActionsManager())->getStageDecisions($request->getContext(), $submission, $stageId, $makeDecision); - // Iterate through the editor decisions and create a link action - // for each decision which as an operation associated with it. - foreach ($decisions as $decision => $action) { - if (empty($action['operation'])) { - continue; - } - $actionArgs['decision'] = $decision; - $editorActions[] = new LinkAction( - $action['name'], - new AjaxModal( - $dispatcher->url( - $request, - PKPApplication::ROUTE_COMPONENT, - null, - 'modals.editorDecision.EditorDecisionHandler', - $action['operation'], - null, - $actionArgs - ), - __($action['title']) - ), - __($action['title']) - ); - } } - $workflowStageDao = DAORegistry::getDAO('WorkflowStageDAO'); /** @var WorkflowStageDAO $workflowStageDao */ $hasSubmissionPassedThisStage = $submission->getStageId() > $stageId; $lastDecision = null; switch ($submission->getStatus()) { @@ -658,14 +641,34 @@ public function editorDecisionActions($args, $request) break; } + $canRecordDecision = + // Only allow decisions to be recorded on the submission's current stage + $submission->getData('stageId') == $stageId + + // Only allow decisions on the latest review round + && (!$lastReviewRound || $lastReviewRound->getId() == $reviewRoundId) + + // At least one deciding editor must be assigned to make a recommendation + && ($makeDecision || $hasDecidingEditors); + // Assign the actions to the template. $templateMgr = TemplateManager::getManager($request); $templateMgr->assign([ - 'editorActions' => $editorActions, + 'canRecordDecision' => $canRecordDecision, + 'decisions' => $this->getStageDecisionTypes($stageId), + 'recommendations' => $this->getStageRecommendationTypes($stageId), + 'primaryDecisions' => $this->getPrimaryDecisionTypes(), + 'warnableDecisions' => $this->getWarnableDecisionTypes(), 'editorsAssigned' => count($editorsStageAssignments) > 0, 'stageId' => $stageId, + 'reviewRoundId' => $reviewRound + ? $reviewRound->getId() + : null, 'lastDecision' => $lastDecision, - 'submissionStatus' => $submission->getStatus(), + 'lastReviewRound' => $lastReviewRound, + 'submission' => $submission, + 'makeRecommendation' => $makeRecommendation, + 'makeDecision' => $makeDecision, 'lastRecommendation' => $lastRecommendation, 'allRecommendations' => $allRecommendations, ]); @@ -810,4 +813,36 @@ abstract protected function getEditorAssignmentNotificationTypeByStageId($stageI * @return string */ abstract protected function _getRepresentationsGridUrl($request, $submission); + + /** + * A helper method to get a list of editor decisions to + * show on the right panel of each stage + * + * @return string[] + */ + abstract protected function getStageDecisionTypes(int $stageId): array; + + /** + * A helper method to get a list of editor recommendations to + * show on the right panel of the review stage + * + * @return string[] + */ + abstract protected function getStageRecommendationTypes(int $stageId): array; + + /** + * Get the editor decision types that should be shown + * as primary buttons (eg - Accept) + * + * @return string[] + */ + abstract protected function getPrimaryDecisionTypes(): array; + + /** + * Get the editor decision types that should be shown + * as warnable buttons (eg - Decline) + * + * @return string[] + */ + abstract protected function getWarnableDecisionTypes(): array; } diff --git a/schemas/decision.json b/schemas/decision.json new file mode 100644 index 00000000000..eb652938e1d --- /dev/null +++ b/schemas/decision.json @@ -0,0 +1,78 @@ +{ + "title": "Editorial Decision", + "description": "An editorial decision such as accept, decline or request revisions.", + "required": [ + "dateDecided", + "decision", + "editorId", + "stageId", + "submissionId" + ], + "properties": { + "_href": { + "type": "string", + "description": "The URL to this decision in the REST API.", + "format": "uri", + "readOnly": true, + "apiSummary": true + }, + "actions": { + "type": "array", + "description": "A list of actions to be taken with this decision, such as sending an email. Each decision supports different actions with different properties. See the examples for support decision actions.", + "writeOnly": true, + "items": { + "type": "object" + } + }, + "dateDecided": { + "type": "string", + "description": "The date the decision was taken.", + "apiSummary": true, + "validation": [ + "date_format:Y-m-d H:i:s" + ] + }, + "decision": { + "type": "integer", + "description": "The decision that was made. One of the `SUBMISSION_EDITOR_DECISION_` constants.", + "apiSummary": true + }, + "editorId": { + "type": "integer", + "description": "The user id of the editor who took the decision.", + "apiSummary": true + }, + "id": { + "type": "integer", + "apiSummary": true, + "readOnly": true + }, + "reviewRoundId": { + "type": "integer", + "description": "The unique id of the review round when this decision was taken. This is a globally unique id. It does not represent whether the decision was taken in the first or second round of reviews for a submission. See `round` below.", + "apiSummary": true, + "validation": [ + "nullable" + ] + }, + "round": { + "type": "integer", + "description": "The sequential review round when this decision was taken. For example, the first, second or third round of review for this submission.", + "apiSummary": true + }, + "stageId": { + "type": "integer", + "description": "The workflow stage when this decision was taken. One of the `WORKFLOW_STAGE_ID_` constants.", + "apiSummary": true, + "validation": [ + "min:1", + "max:5" + ] + }, + "submissionId": { + "type": "integer", + "description": "The decision applies to this submission.", + "apiSummary": true + } + } +} diff --git a/schemas/submissionFile.json b/schemas/submissionFile.json index b31e3121bf9..62548dad3d7 100644 --- a/schemas/submissionFile.json +++ b/schemas/submissionFile.json @@ -126,6 +126,11 @@ "type": "integer", "apiSummary": true }, + "genreName": { + "type": "string", + "multilingual": true, + "readOnly": true + }, "language": { "type": "string", "apiSummary": true, diff --git a/styles/pages/workflow.less b/styles/pages/workflow.less index d08e6e71b35..5221fdac44e 100644 --- a/styles/pages/workflow.less +++ b/styles/pages/workflow.less @@ -92,59 +92,43 @@ } .pkp_workflow_decisions { - margin: 0 0 1rem; - padding-left: 0; - list-style: none; - - a { - &:extend(.pkp_button all); - width: 100%; - text-align: center; - - &.pkp_linkaction_decline { - &:extend(.pkp_button_offset all); - } + margin-bottom: 1rem; - &.pkp_linkaction_externalReview, - &.pkp_linkaction_sendToProduction, - &.pkp_linkaction_schedulePublication, - &.pkp_linkaction_toPublication { - &:extend(.pkp_button_primary all); - } + > * + * { + margin-top: 1rem; } - button { - width: 100%; + ul { + margin: 0; + padding: 0; + list-style: none; } li + li { margin-top: 0.5rem; } + + .pkp_button { + width: 100%; + text-align: center; + } } -.pkp_no_workflow_decisions, -.pkp_workflow_decided, -.pkp_workflow_recommendations { +.pkp_workflow_last_decision, +.pkp_workflow_recommendations, +.pkp_no_workflow_decisions { margin-bottom: 1rem; + border: @bg-border-light; + border-radius: @radius; padding: 1rem; font-size: @font-sml; line-height: @line-sml; - box-shadow: 0 1px 1px rgba(0,0,0,0.2); - border-radius: @radius; - border-top: @grid-border; -} - -.pkp_workflow_decided_actions { - display: none; - - .pkp_controllers_linkAction { - margin-top: 1rem; - } } .pkp_workflow_change_decision { - padding: 0; - margin-top: 0; + display: block; + padding: 0.5rem 0; + margin-top: 0.5rem; background: transparent; border: none; box-shadow: none; @@ -156,6 +140,10 @@ text-align: left; } +.pkp_workflow_decisions_options_hidden { + display: none; +} + .export_actions { padding-left: 2rem; padding-right: 2rem; @@ -383,6 +371,12 @@ } } +// View of a sent email opened from the activity log +.pkp_workflow_email_log_view { + font-size: @font-sml; + line-height: @line-sml; +} + // @todo .pkp_page_header { diff --git a/styles/rtl.less b/styles/rtl.less index 9f0e0bbe5a6..84174586b58 100644 --- a/styles/rtl.less +++ b/styles/rtl.less @@ -140,9 +140,4 @@ body[dir="rtl"] { right: 0; } } - - // Submission workflow - .pkp_workflow_decisions { - padding-right: 0; - } } diff --git a/templates/controllers/tab/workflow/review.tpl b/templates/controllers/tab/workflow/review.tpl index f7175c5c788..d9c9d47020f 100644 --- a/templates/controllers/tab/workflow/review.tpl +++ b/templates/controllers/tab/workflow/review.tpl @@ -19,7 +19,6 @@ {ldelim} {assign var=roundIndex value=$lastReviewRoundNumber-1} selected: {$roundIndex}, - disabled: [{$lastReviewRoundNumber}] {rdelim} ); {rdelim}); @@ -33,9 +32,14 @@ getId() stageId=$reviewRound->getStageId() reviewRoundId=$reviewRound->getId()}">{translate key="submission.round" round=$reviewRound->getRound()} {/foreach} - {if $newRoundAction} + {if $newRoundUrl}
  • - {include file="linkAction/linkAction.tpl" image="add_item" action=$newRoundAction contextId="newRoundTabContainer"} + {translate key="editor.submission.newRound"} +
  • {/if} diff --git a/templates/decision/record.tpl b/templates/decision/record.tpl new file mode 100644 index 00000000000..96bf333d468 --- /dev/null +++ b/templates/decision/record.tpl @@ -0,0 +1,188 @@ +{** + * templates/management/workflow.tpl + * + * Copyright (c) 2014-2021 Simon Fraser University + * Copyright (c) 2003-2021 John Willinsky + * Distributed under the GNU GPL v3. For full terms see the file docs/COPYING. + * + * @brief The workflow settings page. + *} +{extends file="layouts/backend.tpl"} + +{block name="page"} +
    +

    + + +

    +

    + {$decisionType->getDescription()} +

    + + + {{ error }} + + + + + + + + + + + + +
    + +{/block} \ No newline at end of file diff --git a/templates/layouts/backend.tpl b/templates/layouts/backend.tpl index 70967ddebf7..437f7ca8bfa 100644 --- a/templates/layouts/backend.tpl +++ b/templates/layouts/backend.tpl @@ -16,6 +16,10 @@ {load_header context="backend"} {load_stylesheet context="backend"} {load_script context="backend"} + @@ -30,10 +34,10 @@ {rdelim}); -
    +