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 79a3fbe0cb8..76a4853233f 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); } @@ -114,9 +130,13 @@ public function getMany($slimRequest, $response, $args) $userGroupDao = DAORegistry::getDAO('UserGroupDAO'); /** @var UserGroupDAO $userGroupDao */ $userGroups = $userGroupDao->getByContextId($context->getId())->toArray(); + /** @var GenreDAO $genreDao */ + $genreDao = DAORegistry::getDAO('GenreDAO'); + $genres = $genreDao->getByContextId($context->getId())->toArray(); + return $response->withJson([ 'itemsMax' => Repo::submission()->getCount($collector->limit(null)->offset(null)), - 'items' => Repo::submission()->getSchemaMap()->mapManyToSubmissionsList($submissions, $userGroups), + 'items' => Repo::submission()->getSchemaMap()->mapManyToSubmissionsList($submissions, $userGroups, $genres), ], 200); } @@ -232,4 +252,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 7a9053c2bc3..25a1081a93d 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/submissions/PKPSubmissionFileHandler.inc.php b/api/v1/submissions/PKPSubmissionFileHandler.inc.php index 7ee91365ab3..31df2e687b3 100644 --- a/api/v1/submissions/PKPSubmissionFileHandler.inc.php +++ b/api/v1/submissions/PKPSubmissionFileHandler.inc.php @@ -14,7 +14,10 @@ * */ +use APP\core\Application; +use APP\core\Services; use APP\facades\Repo; +use PKP\db\DAORegistry; use PKP\file\FileManager; use PKP\handler\APIHandler; use PKP\security\authorization\ContextAccessPolicy; @@ -23,6 +26,8 @@ use PKP\security\authorization\SubmissionFileAccessPolicy; use PKP\security\Role; use PKP\services\PKPSchemaService; +use PKP\submission\GenreDAO; +use PKP\submission\reviewRound\ReviewRoundDAO; use PKP\submissionFile\SubmissionFile; class PKPSubmissionFileHandler extends APIHandler @@ -59,6 +64,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' => [ [ @@ -210,7 +220,7 @@ public function getMany($slimRequest, $response, $args) $items = Repo::submissionFile() ->getSchemaMap() - ->summarizeMany($files); + ->summarizeMany($files, $this->getFileGenres()); $data = [ 'itemsMax' => $files->count(), @@ -235,7 +245,7 @@ public function get($slimRequest, $response, $args) $data = Repo::submissionFile() ->getSchemaMap() - ->map($submissionFile); + ->map($submissionFile, $this->getFileGenres()); return $response->withJson($data, 200); } @@ -353,7 +363,7 @@ public function add($slimRequest, $response, $args) $data = Repo::submissionFile() ->getSchemaMap() - ->map($submissionFile); + ->map($submissionFile, $this->getFileGenres()); return $response->withJson($data, 200); } @@ -433,7 +443,76 @@ public function edit($slimRequest, $response, $args) $data = Repo::submissionFile() ->getSchemaMap() - ->map($submissionFile); + ->map($submissionFile, $this->getFileGenres()); + + 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) + { + $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, Repo::submissionFiles()->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, Repo::submissionFiles()->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 = Repo::submissionFiles()->copy( + $submissionFile, + $toFileStage, + $reviewRoundId + ); + + $newSubmissionFile = Repo::submissionFiles()->get($newSubmissionFileId); + + $data = Repo::submissionFiles() + ->getSchemaMap() + ->map($newSubmissionFile, $this->getFileGenres()); return $response->withJson($data, 200); } @@ -453,13 +532,25 @@ public function delete($slimRequest, $response, $args) $data = Repo::submissionFile() ->getSchemaMap() - ->map($submissionFile); + ->map($submissionFile, $this->getFileGenres()); Repo::submissionFile()->delete($submissionFile); return $response->withJson($data, 200); } + /** + * Helper method to get the file genres for the current context + * + * @return Genre[] + */ + protected function getFileGenres(): array + { + /** @var GenreDAO $genreDao */ + $genreDao = DAORegistry::getDAO('GenreDAO'); + return $genreDao->getByContextId($this->getRequest()->getContext()->getId())->toArray(); + } + /** * Helper method to get the appropriate response when an error * has occurred during a file upload diff --git a/api/v1/submissions/PKPSubmissionHandler.inc.php b/api/v1/submissions/PKPSubmissionHandler.inc.php index ce10badc1c9..05505be8d13 100644 --- a/api/v1/submissions/PKPSubmissionHandler.inc.php +++ b/api/v1/submissions/PKPSubmissionHandler.inc.php @@ -15,18 +15,22 @@ */ 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 PKP\core\Core; use PKP\db\DAORegistry; - +use PKP\decision\Type; use PKP\handler\APIHandler; 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; @@ -65,6 +69,7 @@ class PKPSubmissionHandler extends APIHandler 'deleteContributor', 'editContributor', 'saveContributorsOrder', + 'addDecision', ]; /** @var array Handlers that must be authorized to write to a publication */ @@ -162,6 +167,11 @@ public function __construct() 'handler' => [$this, 'addContributor'], 'roles' => [Role::ROLE_ID_MANAGER, Role::ROLE_ID_SUB_EDITOR, Role::ROLE_ID_ASSISTANT, Role::ROLE_ID_AUTHOR], ], + [ + 'pattern' => $this->getEndpointPattern() . '/{submissionId:\d+}/decisions', + 'handler' => [$this, 'addDecision'], + 'roles' => [Role::ROLE_ID_MANAGER, Role::ROLE_ID_SUB_EDITOR], + ], ], 'PUT' => [ [ @@ -237,6 +247,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); } @@ -255,10 +269,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]); @@ -280,9 +290,13 @@ public function getMany($slimRequest, $response, $args) $userGroupDao = DAORegistry::getDAO('UserGroupDAO'); /** @var UserGroupDAO $userGroupDao */ $userGroups = $userGroupDao->getByContextId($context->getId())->toArray(); + /** @var GenreDAO $genreDao */ + $genreDao = DAORegistry::getDAO('GenreDAO'); + $genres = $genreDao->getByContextId($context->getId())->toArray(); + return $response->withJson([ 'itemsMax' => Repo::submission()->getCount($collector->limit(null)->offset(null)), - 'items' => Repo::submission()->getSchemaMap()->summarizeMany($submissions, $userGroups), + 'items' => Repo::submission()->getSchemaMap()->summarizeMany($submissions, $userGroups, $genres), ], 200); } @@ -387,7 +401,11 @@ public function get($slimRequest, $response, $args) $userGroupDao = DAORegistry::getDAO('UserGroupDAO'); /** @var UserGroupDAO $userGroupDao */ $userGroups = $userGroupDao->getByContextId($submission->getData('contextId'))->toArray(); - return $response->withJson(Repo::submission()->getSchemaMap()->map($submission, $userGroups), 200); + /** @var GenreDAO $genreDao */ + $genreDao = DAORegistry::getDAO('GenreDAO'); + $genres = $genreDao->getByContextId($submission->getData('contextId'))->toArray(); + + return $response->withJson(Repo::submission()->getSchemaMap()->map($submission, $userGroups, $genres), 200); } /** @@ -405,11 +423,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'); } @@ -438,7 +451,11 @@ public function add($slimRequest, $response, $args) $userGroupDao = DAORegistry::getDAO('UserGroupDAO'); /** @var UserGroupDAO $userGroupDao */ $userGroups = $userGroupDao->getByContextId($submission->getData('contextId'))->toArray(); - return $response->withJson(Repo::submission()->getSchemaMap()->map($submission, $userGroups), 200); + /** @var GenreDAO $genreDao */ + $genreDao = DAORegistry::getDAO('GenreDAO'); + $genres = $genreDao->getByContextId($submission->getData('contextId'))->toArray(); + + return $response->withJson(Repo::submission()->getSchemaMap()->map($submission, $userGroups, $genres), 200); } /** @@ -459,11 +476,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(); @@ -489,7 +501,11 @@ public function edit($slimRequest, $response, $args) $userGroupDao = DAORegistry::getDAO('UserGroupDAO'); /** @var UserGroupDAO $userGroupDao */ $userGroups = $userGroupDao->getByContextId($submission->getData('contextId'))->toArray(); - return $response->withJson(Repo::submission()->getSchemaMap()->map($submission, $userGroups), 200); + /** @var GenreDAO $genreDao */ + $genreDao = DAORegistry::getDAO('GenreDAO'); + $genres = $genreDao->getByContextId($submission->getData('contextId'))->toArray(); + + return $response->withJson(Repo::submission()->getSchemaMap()->map($submission, $userGroups, $genres), 200); } /** @@ -512,7 +528,11 @@ public function delete($slimRequest, $response, $args) $userGroupDao = DAORegistry::getDAO('UserGroupDAO'); /** @var UserGroupDAO $userGroupDao */ $userGroups = $userGroupDao->getByContextId($submission->getData('contextId'))->toArray(); - $submissionProps = Repo::submission()->getSchemaMap()->map($submission, $userGroups); + /** @var GenreDAO $genreDao */ + $genreDao = DAORegistry::getDAO('GenreDAO'); + $genres = $genreDao->getByContextId($submission->getData('contextId'))->toArray(); + + $submissionProps = Repo::submission()->getSchemaMap()->map($submission, $userGroups, $genres); Repo::submission()->delete($submission); @@ -537,7 +557,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'); } @@ -622,8 +642,12 @@ public function getPublication($slimRequest, $response, $args) $userGroupDao = DAORegistry::getDAO('UserGroupDAO'); /** @var UserGroupDAO $userGroupDao */ $userGroups = $userGroupDao->getByContextId($submission->getData('contextId'))->toArray(); + /** @var GenreDAO $genreDao */ + $genreDao = DAORegistry::getDAO('GenreDAO'); + $genres = $genreDao->getByContextId($submission->getData('contextId'))->toArray(); + return $response->withJson( - Repo::publication()->getSchemaMap($submission, $userGroups)->map($publication), + Repo::publication()->getSchemaMap($submission, $userGroups, $genres)->map($publication), 200 ); } @@ -673,8 +697,12 @@ public function addPublication($slimRequest, $response, $args) $userGroupDao = DAORegistry::getDAO('UserGroupDAO'); /** @var UserGroupDAO $userGroupDao */ $userGroups = $userGroupDao->getByContextId($submission->getData('contextId'))->toArray(); + /** @var GenreDAO $genreDao */ + $genreDao = DAORegistry::getDAO('GenreDAO'); + $genres = $genreDao->getByContextId($submission->getData('contextId'))->toArray(); + return $response->withJson( - Repo::publication()->getSchemaMap($submission, $userGroups)->map($publication), + Repo::publication()->getSchemaMap($submission, $userGroups, $genres)->map($publication), 200 ); } @@ -728,8 +756,12 @@ public function versionPublication($slimRequest, $response, $args) $userGroupDao = DAORegistry::getDAO('UserGroupDAO'); /** @var UserGroupDAO $userGroupDao */ $userGroups = $userGroupDao->getByContextId($submission->getData('contextId'))->toArray(); + /** @var GenreDAO $genreDao */ + $genreDao = DAORegistry::getDAO('GenreDAO'); + $genres = $genreDao->getByContextId($submission->getData('contextId'))->toArray(); + return $response->withJson( - Repo::publication()->getSchemaMap($submission, $userGroups)->map($publication), + Repo::publication()->getSchemaMap($submission, $userGroups, $genres)->map($publication), 200 ); } @@ -798,8 +830,12 @@ public function editPublication($slimRequest, $response, $args) $userGroupDao = DAORegistry::getDAO('UserGroupDAO'); /** @var UserGroupDAO $userGroupDao */ $userGroups = $userGroupDao->getByContextId($submission->getData('contextId'))->toArray(); + /** @var GenreDAO $genreDao */ + $genreDao = DAORegistry::getDAO('GenreDAO'); + $genres = $genreDao->getByContextId($submission->getData('contextId'))->toArray(); + return $response->withJson( - Repo::publication()->getSchemaMap($submission, $userGroups)->map($publication), + Repo::publication()->getSchemaMap($submission, $userGroups, $genres)->map($publication), 200 ); } @@ -857,8 +893,12 @@ public function publishPublication($slimRequest, $response, $args) $userGroupDao = DAORegistry::getDAO('UserGroupDAO'); /** @var UserGroupDAO $userGroupDao */ $userGroups = $userGroupDao->getByContextId($submission->getData('contextId'))->toArray(); + /** @var GenreDAO $genreDao */ + $genreDao = DAORegistry::getDAO('GenreDAO'); + $genres = $genreDao->getByContextId($submission->getData('contextId'))->toArray(); + return $response->withJson( - Repo::publication()->getSchemaMap($submission, $userGroups)->map($publication), + Repo::publication()->getSchemaMap($submission, $userGroups, $genres)->map($publication), 200 ); } @@ -896,8 +936,12 @@ public function unpublishPublication($slimRequest, $response, $args) $userGroupDao = DAORegistry::getDAO('UserGroupDAO'); /** @var UserGroupDAO $userGroupDao */ $userGroups = $userGroupDao->getByContextId($submission->getData('contextId'))->toArray(); + /** @var GenreDAO $genreDao */ + $genreDao = DAORegistry::getDAO('GenreDAO'); + $genres = $genreDao->getByContextId($submission->getData('contextId'))->toArray(); + return $response->withJson( - Repo::publication()->getSchemaMap($submission, $userGroups)->map($publication), + Repo::publication()->getSchemaMap($submission, $userGroups, $genres)->map($publication), 200 ); } @@ -935,14 +979,17 @@ public function deletePublication($slimRequest, $response, $args) $userGroupDao = DAORegistry::getDAO('UserGroupDAO'); /** @var UserGroupDAO $userGroupDao */ $userGroups = $userGroupDao->getByContextId($submission->getData('contextId'))->toArray(); - $output = Repo::publication()->getSchemaMap($submission, $userGroups)->map($publication); + /** @var GenreDAO $genreDao */ + $genreDao = DAORegistry::getDAO('GenreDAO'); + $genres = $genreDao->getByContextId($submission->getData('contextId'))->toArray(); + + $output = Repo::publication()->getSchemaMap($submission, $userGroups, $genres)->map($publication); Repo::publication()->delete($publication); return $response->withJson($output, 200); } - /** * Get one of a publication's contributors * @@ -1238,4 +1285,45 @@ public function saveContributorsOrder($slimRequest, $response, $args) return $response->withJson($publication->getId()); } + + /** + * 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 9cb7f7508b6..cccddcefe47 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 c8eac0bd067..246842a9ef2 100644 --- a/classes/announcement/Repository.inc.php +++ b/classes/announcement/Repository.inc.php @@ -135,7 +135,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 1fea5153970..c595dcd28e2 100644 --- a/classes/author/Repository.inc.php +++ b/classes/author/Repository.inc.php @@ -144,7 +144,7 @@ public function validate($author, $props, $allowedLocales, $primaryLocale) $errors = []; 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..4cbb87c87b0 --- /dev/null +++ b/classes/components/fileAttachers/ReviewFiles.inc.php @@ -0,0 +1,96 @@ + $files */ + public iterable $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, iterable $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 = []; + /** @var SubmissionFile $file */ + 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' => Repo::submissionFiles()->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 05e1f095d29..47e9e40e024 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 bool 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 4148c6d66cb..d90e1bc93bf 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 c8bccddb626..d63e4561a13 100644 --- a/classes/components/forms/context/PKPEmailSetupForm.inc.php +++ b/classes/components/forms/context/PKPEmailSetupForm.inc.php @@ -15,6 +15,7 @@ namespace PKP\components\forms\context; use PKP\components\forms\FieldHTML; +use PKP\components\forms\FieldOptions; use PKP\components\forms\FieldRichTextarea; use PKP\components\forms\FieldText; use PKP\components\forms\FormComponent; @@ -41,21 +42,32 @@ public function __construct($action, $locales, $context) $this->action = $action; $this->locales = $locales; + $this->addField(new FieldOptions('notifyAllAuthors', [ + 'label' => __('manager.setup.notifyAllAuthors'), + 'description' => __('manager.setup.notifyAllAuthors.description'), + 'type' => 'radio', + 'options' => [ + ['value' => true, 'label' => __('manager.setup.notifyAllAuthors.allAuthors')], + ['value' => false, 'label' => __('manager.setup.notifyAllAuthors.assignedAuthors')], + ], + 'value' => $context->getData('notifyAllAuthors'), + ])); + $this->addField(new FieldRichTextarea('emailSignature', [ 'label' => __('manager.setup.emailSignature'), '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}', ] ])); - $this->buildEnveloperSenderField($context); + $this->addEnveloperSenderField($context); } /** @@ -64,7 +76,7 @@ public function __construct($action, $locales, $context) * @param Context $context Journal or Press to change settings for * */ - protected function buildEnveloperSenderField($context) + protected function addEnveloperSenderField($context) { $canEnvelopeSender = \Config::getVar('email', 'allow_envelope_sender'); 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..4e671d7cc94 --- /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' => Decision::PENDING_REVISIONS, + 'label' => __('editor.review.NotifyAuthorRevisions'), + ], + [ + 'value' => Decision::RESUBMIT, + 'label' => __('editor.review.NotifyAuthorResubmit'), + ], + ], + 'value' => 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..6fc60867eb6 --- /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' => Decision::RECOMMEND_PENDING_REVISIONS, + 'label' => __('editor.review.NotifyAuthorRevisions.recommendation'), + ], + [ + 'value' => Decision::RECOMMEND_RESUBMIT, + 'label' => __('editor.review.NotifyAuthorResubmit.recommendation'), + ], + ], + 'value' => Decision::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 aace431bfcf..7fc8130c429 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/controllers/modals/editorDecision/PKPEditorDecisionHandler.inc.php b/classes/controllers/modals/editorDecision/PKPEditorDecisionHandler.inc.php deleted file mode 100644 index 3d8ea7442b4..00000000000 --- a/classes/controllers/modals/editorDecision/PKPEditorDecisionHandler.inc.php +++ /dev/null @@ -1,641 +0,0 @@ -_getReviewRoundOps(); - $this->addPolicy(new ReviewRoundRequiredPolicy($request, $args, 'reviewRoundId', $reviewRoundOps)); - - if (!parent::authorize($request, $args, $roleAssignments)) { - return false; - } - - // Prevent editors who are also assigned as authors from accessing the - // review stage operations - $operation = $request->getRouter()->getRequestedOp($request); - if (in_array($operation, $reviewRoundOps)) { - $userAccessibleStages = $this->getAuthorizedContextObject(ASSOC_TYPE_ACCESSIBLE_WORKFLOW_STAGES); - foreach ($userAccessibleStages as $stageId => $roles) { - if (in_array(Role::ROLE_ID_AUTHOR, $roles)) { - return false; - } - } - } - - return true; - } - - /** - * @copydoc PKPHandler::initialize() - */ - public function initialize($request) - { - AppLocale::requireComponents( - LOCALE_COMPONENT_APP_COMMON, - LOCALE_COMPONENT_APP_EDITOR, - LOCALE_COMPONENT_APP_SUBMISSION, - LOCALE_COMPONENT_PKP_EDITOR, - LOCALE_COMPONENT_PKP_SUBMISSION - ); - } - - - // - // Public handler actions - // - /** - * Start a new review round - * - * @param array $args - * @param PKPRequest $request - * - * @return string Serialized JSON object - */ - public function newReviewRound($args, $request) - { - return $this->_initiateEditorDecision($args, $request, 'NewReviewRoundForm'); - } - - /** - * Jump from submission to external review - * - * @param array $args - * @param PKPRequest $request - * - * @return string Serialized JSON object - */ - public function externalReview($args, $request) - { - return $this->_initiateEditorDecision($args, $request, 'InitiateExternalReviewForm'); - } - - /** - * Start a new review round in external review, bypassing internal - * - * @param array $args - * @param PKPRequest $request - * - * @return string Serialized JSON object - */ - public function saveExternalReview($args, $request) - { - assert($this->getAuthorizedContextObject(ASSOC_TYPE_WORKFLOW_STAGE) == WORKFLOW_STAGE_ID_SUBMISSION); - $workflowStageDao = DAORegistry::getDAO('WorkflowStageDAO'); /** @var WorkflowStageDAO $workflowStageDao */ - return $this->_saveEditorDecision( - $args, - $request, - 'InitiateExternalReviewForm', - $workflowStageDao::WORKFLOW_STAGE_PATH_EXTERNAL_REVIEW, - EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_EXTERNAL_REVIEW - ); - } - - /** - * Show a save review form (responsible for decline submission modals when not in review stage) - * - * @param array $args - * @param PKPRequest $request - * - * @return string Serialized JSON object - */ - public function sendReviews($args, $request) - { - return $this->_initiateEditorDecision($args, $request, 'SendReviewsForm'); - } - - /** - * Show a save review form (responsible for request revisions, - * resubmit for review, and decline submission modals in review stages). - * We need this because the authorization in review stages is different - * when not in review stages (need to authorize review round id). - * - * @param array $args - * @param PKPRequest $request - * - * @return string Serialized JSON object - */ - public function sendReviewsInReview($args, $request) - { - return $this->_initiateEditorDecision($args, $request, 'SendReviewsForm'); - } - - /** - * Save the send review form when user is not in review stage. - * - * @param array $args - * @param PKPRequest $request - * - * @return string Serialized JSON object - */ - public function saveSendReviews($args, $request) - { - return $this->_saveEditorDecision($args, $request, 'SendReviewsForm'); - } - - /** - * Save the send review form when user is in review stages. - * - * @param array $args - * @param PKPRequest $request - * - * @return string Serialized JSON object - */ - public function saveSendReviewsInReview($args, $request) - { - return $this->_saveEditorDecision($args, $request, 'SendReviewsForm'); - } - - /** - * Show a promote form (responsible for accept submission modals outside review stage) - * - * @param array $args - * @param PKPRequest $request - * - * @return string Serialized JSON object - */ - public function promote($args, $request) - { - return $this->_initiateEditorDecision($args, $request, 'PromoteForm'); - } - - /** - * Show a promote form (responsible for external review and accept submission modals - * in review stages). We need this because the authorization for promoting in review - * stages is different when not in review stages (need to authorize review round id). - * - * @param array $args - * @param PKPRequest $request - * - * @return string Serialized JSON object - */ - public function promoteInReview($args, $request) - { - return $this->_initiateEditorDecision($args, $request, 'PromoteForm'); - } - - /** - * Save the send review form - * - * @param array $args - * @param PKPRequest $request - * - * @return string Serialized JSON object - */ - public function savePromote($args, $request) - { - return $this->_saveGeneralPromote($args, $request); - } - - /** - * Save the send review form (same case of the - * promoteInReview() method, see description there). - * - * @param array $args - * @param PKPRequest $request - * - * @return string Serialized JSON object - */ - public function savePromoteInReview($args, $request) - { - return $this->_saveGeneralPromote($args, $request); - } - - /** - * Show a revert decline form. - * - * @param array $args - * @param PKPRequest $request - * - * @return string Serialized JSON object - */ - public function revertDecline($args, $request) - { - return $this->_initiateEditorDecision($args, $request, 'RevertDeclineForm'); - } - - /** - * Save the revert decline form. - * - * @param array $args - * @param PKPRequest $request - * - * @return string Serialized JSON object - */ - public function saveRevertDecline($args, $request) - { - return $this->_saveEditorDecision($args, $request, 'RevertDeclineForm'); - } - - /** - * Import all free-text/review form reviews to paste into message - * - * @param array $args - * @param PKPRequest $request - * - * @return JSONMessage JSON object - */ - public function importPeerReviews($args, $request) - { - // Retrieve the authorized submission. - $submission = $this->getAuthorizedContextObject(ASSOC_TYPE_SUBMISSION); - - // Retrieve the current review round. - $reviewRound = $this->getAuthorizedContextObject(ASSOC_TYPE_REVIEW_ROUND); - - // Retrieve peer reviews. - $reviewAssignmentDao = DAORegistry::getDAO('ReviewAssignmentDAO'); /** @var ReviewAssignmentDAO $reviewAssignmentDao */ - $submissionCommentDao = DAORegistry::getDAO('SubmissionCommentDAO'); /** @var SubmissionCommentDAO $submissionCommentDao */ - $reviewFormResponseDao = DAORegistry::getDAO('ReviewFormResponseDAO'); /** @var ReviewFormResponseDAO $reviewFormResponseDao */ - $reviewFormElementDao = DAORegistry::getDAO('ReviewFormElementDAO'); /** @var ReviewFormElementDAO $reviewFormElementDao */ - - $reviewAssignments = $reviewAssignmentDao->getBySubmissionId($submission->getId(), $reviewRound->getId()); - $reviewIndexes = $reviewAssignmentDao->getReviewIndexesForRound($submission->getId(), $reviewRound->getId()); - AppLocale::requireComponents(LOCALE_COMPONENT_PKP_SUBMISSION); - - $body = ''; - $textSeparator = '------------------------------------------------------'; - foreach ($reviewAssignments as $reviewAssignment) { - // If the reviewer has completed the assignment, then import the review. - if ($reviewAssignment->getDateCompleted() != null) { - // Get the comments associated with this review assignment - $submissionComments = $submissionCommentDao->getSubmissionComments($submission->getId(), SubmissionComment::COMMENT_TYPE_PEER_REVIEW, $reviewAssignment->getId()); - - $body .= "

${textSeparator}
"; - // If it is an open review, show reviewer's name. - if ($reviewAssignment->getReviewMethod() == SUBMISSION_REVIEW_METHOD_OPEN) { - $body .= $reviewAssignment->getReviewerFullName() . "
\n"; - } else { - $body .= __('submission.comments.importPeerReviews.reviewerLetter', ['reviewerLetter' => PKPString::enumerateAlphabetically($reviewIndexes[$reviewAssignment->getId()])]) . "
\n"; - } - - while ($comment = $submissionComments->next()) { - // If the comment is viewable by the author, then add the comment. - if ($comment->getViewable()) { - $body .= PKPString::stripUnsafeHtml($comment->getComments()); - } - } - - // Add reviewer recommendation - $recommendation = $reviewAssignment->getLocalizedRecommendation(); - $body .= __('submission.recommendation', ['recommendation' => $recommendation]) . "
\n"; - - $body .= "
${textSeparator}

"; - - if ($reviewFormId = $reviewAssignment->getReviewFormId()) { - $reviewId = $reviewAssignment->getId(); - - - $reviewFormElements = $reviewFormElementDao->getByReviewFormId($reviewFormId); - if (!$submissionComments) { - $body .= "${textSeparator}
"; - - $body .= __('submission.comments.importPeerReviews.reviewerLetter', ['reviewerLetter' => PKPString::enumerateAlphabetically($reviewIndexes[$reviewAssignment->getId()])]) . '

'; - } - while ($reviewFormElement = $reviewFormElements->next()) { - if (!$reviewFormElement->getIncluded()) { - continue; - } - - $body .= PKPString::stripUnsafeHtml($reviewFormElement->getLocalizedQuestion()); - $reviewFormResponse = $reviewFormResponseDao->getReviewFormResponse($reviewId, $reviewFormElement->getId()); - - if ($reviewFormResponse) { - $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) { - $body .= ''; - } else { - $body .= '
' . PKPString::stripUnsafeHtml($possibleResponses[$reviewFormResponse->getValue()]) . '
'; - } - $body .= '
'; - } else { - $body .= '
' . nl2br(htmlspecialchars($reviewFormResponse->getValue())) . '
'; - } - } - } - $body .= "${textSeparator}

"; - } - } - } - - // Notify the user. - $notificationMgr = new NotificationManager(); - $user = $request->getUser(); - $notificationMgr->createTrivialNotification($user->getId(), PKPNotification::NOTIFICATION_TYPE_SUCCESS, ['contents' => __('editor.review.reviewsAdded')]); - - return new JSONMessage(true, empty($body) ? __('editor.review.noReviews') : $body); - } - - /** - * Show the editor recommendation form - * - * @param array $args - * @param PKPRequest $request - * - * @return JSONMessage - */ - public function sendRecommendation($args, $request) - { - // Retrieve the authorized submission, stage id and review round. - $submission = $this->getAuthorizedContextObject(ASSOC_TYPE_SUBMISSION); - $stageId = $this->getAuthorizedContextObject(ASSOC_TYPE_WORKFLOW_STAGE); - assert(in_array($stageId, $this->_getReviewStages())); - $reviewRound = $this->getAuthorizedContextObject(ASSOC_TYPE_REVIEW_ROUND); - assert($reviewRound instanceof \PKP\submission\reviewRound\ReviewRound); - - // Form handling - $editorRecommendationForm = new RecommendationForm($submission, $stageId, $reviewRound); - $editorRecommendationForm->initData(); - return new JSONMessage(true, $editorRecommendationForm->fetch($request)); - } - - /** - * Show the editor recommendation form - * - * @param array $args - * @param PKPRequest $request - * - * @return JSONMessage - */ - public function saveRecommendation($args, $request) - { - // Retrieve the authorized submission, stage id and review round. - $submission = $this->getAuthorizedContextObject(ASSOC_TYPE_SUBMISSION); - $stageId = $this->getAuthorizedContextObject(ASSOC_TYPE_WORKFLOW_STAGE); - assert(in_array($stageId, $this->_getReviewStages())); - $reviewRound = $this->getAuthorizedContextObject(ASSOC_TYPE_REVIEW_ROUND); - assert($reviewRound instanceof \PKP\submission\reviewRound\ReviewRound); - - // Form handling - $editorRecommendationForm = new RecommendationForm($submission, $stageId, $reviewRound); - $editorRecommendationForm->readInputData(); - if ($editorRecommendationForm->validate()) { - $editorRecommendationForm->execute(); - $json = new JSONMessage(true); - $json->setGlobalEvent('decisionActionUpdated'); - return $json; - } - return new JSONMessage(false); - } - - - // - // Protected helper methods - // - /** - * Get operations that need a review round id policy. - * - * @return array - */ - protected function _getReviewRoundOps() - { - return ['promoteInReview', 'savePromoteInReview', 'newReviewRound', 'saveNewReviewRound', 'sendReviewsInReview', 'saveSendReviewsInReview', 'importPeerReviews', 'sendRecommendation', 'saveRecommendation']; - } - - /** - * Get the fully-qualified import name for the given form name. - * - * @param string $formName Class name for the desired form. - * - * @return string - */ - protected function _resolveEditorDecisionForm($formName) - { - switch ($formName) { - case 'EditorDecisionWithEmailForm': - case 'NewReviewRoundForm': - case 'PromoteForm': - case 'SendReviewsForm': - case 'RevertDeclineForm': - return "lib.pkp.controllers.modals.editorDecision.form.${formName}"; - default: - assert(false); - } - } - - /** - * Get an instance of an editor decision form. - * - * @param string $formName - * @param int $decision - * - * @return EditorDecisionForm - */ - protected function _getEditorDecisionForm($formName, $decision) - { - // Retrieve the authorized submission. - $submission = $this->getAuthorizedContextObject(ASSOC_TYPE_SUBMISSION); - // Retrieve the stage id - $stageId = $this->getAuthorizedContextObject(ASSOC_TYPE_WORKFLOW_STAGE); - - import($this->_resolveEditorDecisionForm($formName)); - if (in_array($stageId, $this->_getReviewStages())) { - $reviewRound = $this->getAuthorizedContextObject(ASSOC_TYPE_REVIEW_ROUND); - $editorDecisionForm = new $formName($submission, $decision, $stageId, $reviewRound); - // We need a different save operation in review stages to authorize - // the review round object. - if ($editorDecisionForm instanceof PromoteForm) { - $editorDecisionForm->setSaveFormOperation('savePromoteInReview'); - } elseif ($editorDecisionForm instanceof SendReviewsForm) { - $editorDecisionForm->setSaveFormOperation('saveSendReviewsInReview'); - } - } else { - $editorDecisionForm = new $formName($submission, $decision, $stageId); - } - - if ($editorDecisionForm instanceof $formName) { - return $editorDecisionForm; - } else { - assert(false); - return null; - } - } - - /** - * Initiate an editor decision. - * - * @param array $args - * @param PKPRequest $request - * @param string $formName Name of form to call - * - * @return JSONMessage JSON object - */ - protected function _initiateEditorDecision($args, $request, $formName) - { - // Retrieve the decision - $decision = (int)$request->getUserVar('decision'); - - // Form handling - $editorDecisionForm = $this->_getEditorDecisionForm($formName, $decision); - $editorDecisionForm->initData(); - - return new JSONMessage(true, $editorDecisionForm->fetch($request)); - } - - /** - * Save an editor decision. - * - * @param array $args - * @param PKPRequest $request - * @param string $formName Name of form to call - * @param string $redirectOp A workflow stage operation to - * redirect to if successful (if any). - * @param null|mixed $decision - * - * @return JSONMessage JSON object - */ - protected function _saveEditorDecision($args, $request, $formName, $redirectOp = null, $decision = null) - { - // Retrieve the authorized submission. - $submission = $this->getAuthorizedContextObject(ASSOC_TYPE_SUBMISSION); - // Retrieve the decision - if (is_null($decision)) { - $decision = (int)$request->getUserVar('decision'); - } - - $editorDecisionForm = $this->_getEditorDecisionForm($formName, $decision); - $editorDecisionForm->readInputData(); - if ($editorDecisionForm->validate()) { - $editorDecisionForm->execute(); - - // Get a list of author user IDs - $authorUserIds = []; - $stageAssignmentDao = DAORegistry::getDAO('StageAssignmentDAO'); /** @var StageAssignmentDAO $stageAssignmentDao */ - $submitterAssignments = $stageAssignmentDao->getBySubmissionAndRoleId($submission->getId(), Role::ROLE_ID_AUTHOR); - while ($assignment = $submitterAssignments->next()) { - $authorUserIds[] = $assignment->getUserId(); - } - // De-duplicate assignments - $authorUserIds = array_unique($authorUserIds); - - // Update editor decision and pending revisions notifications. - $notificationMgr = new NotificationManager(); - $editorDecisionNotificationType = $this->_getNotificationTypeByEditorDecision($decision); - $notificationTypes = array_merge([$editorDecisionNotificationType], $this->_getReviewNotificationTypes()); - $notificationMgr->updateNotification( - $request, - $notificationTypes, - $authorUserIds, - ASSOC_TYPE_SUBMISSION, - $submission->getId() - ); - - // Update submission notifications - $submissionNotificationsToUpdate = [ - EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_ACCEPT => [ - PKPNotification::NOTIFICATION_TYPE_ASSIGN_COPYEDITOR, - PKPNotification::NOTIFICATION_TYPE_AWAITING_COPYEDITS - ], - EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_SEND_TO_PRODUCTION => [ - PKPNotification::NOTIFICATION_TYPE_ASSIGN_COPYEDITOR, - PKPNotification::NOTIFICATION_TYPE_AWAITING_COPYEDITS, - PKPNotification::NOTIFICATION_TYPE_ASSIGN_PRODUCTIONUSER, - PKPNotification::NOTIFICATION_TYPE_AWAITING_REPRESENTATIONS, - ], - ]; - $notificationMgr = new NotificationManager(); - if (array_key_exists($decision, $submissionNotificationsToUpdate)) { - $notificationMgr->updateNotification( - $request, - $submissionNotificationsToUpdate[$decision], - null, - ASSOC_TYPE_SUBMISSION, - $submission->getId() - ); - } - - if ($redirectOp) { - $dispatcher = $this->getDispatcher(); - $redirectUrl = $dispatcher->url($request, PKPApplication::ROUTE_PAGE, null, 'workflow', $redirectOp, [$submission->getId()]); - return $request->redirectUrlJson($redirectUrl); - } else { - if (in_array($decision, [EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_DECLINE, EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_INITIAL_DECLINE, EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_REVERT_DECLINE])) { - $dispatcher = $this->getDispatcher(); - $redirectUrl = $dispatcher->url($request, PKPApplication::ROUTE_PAGE, null, 'workflow', 'access', [$submission->getId()]); - return $request->redirectUrlJson($redirectUrl); - } else { - // Needed to update review round status notifications. - return \PKP\db\DAO::getDataChangedEvent(); - } - } - } else { - return new JSONMessage(false); - } - } - - /** - * Get review-related stage IDs. - * - * @return array - */ - protected function _getReviewStages() - { - assert(false); - } - - /** - * Get review-related decision notifications. - * - * @return array - */ - protected function _getReviewNotificationTypes() - { - assert(false); // Subclasses to override - } -} - -if (!PKP_STRICT_MODE) { - class_alias('\PKP\controllers\modals\editorDecision\PKPEditorDecisionHandler', '\PKPEditorDecisionHandler'); -} diff --git a/classes/controllers/modals/editorDecision/form/EditorDecisionForm.inc.php b/classes/controllers/modals/editorDecision/form/EditorDecisionForm.inc.php deleted file mode 100644 index 9936cf2c3cc..00000000000 --- a/classes/controllers/modals/editorDecision/form/EditorDecisionForm.inc.php +++ /dev/null @@ -1,240 +0,0 @@ -_submission = $submission; - $this->_stageId = $stageId; - $this->_reviewRound = $reviewRound; - $this->_decision = $decision; - - // Validation checks for this form - $this->addCheck(new \PKP\form\validation\FormValidatorPost($this)); - $this->addCheck(new \PKP\form\validation\FormValidatorCSRF($this)); - } - - // - // Getters and Setters - // - /** - * Get the decision - * - * @return int - */ - public function getDecision() - { - return $this->_decision; - } - - /** - * Get the submission - * - * @return Submission - */ - public function getSubmission() - { - return $this->_submission; - } - - /** - * Get the stage Id - * - * @return int - */ - public function getStageId() - { - return $this->_stageId; - } - - /** - * Get the review round object. - * - * @return ReviewRound - */ - public function getReviewRound() - { - return $this->_reviewRound; - } - - // - // Overridden template methods from Form - // - /** - * @see Form::readInputData() - */ - public function readInputData() - { - $this->readUserVars(['selectedFiles']); - parent::initData(); - } - - - /** - * @copydoc Form::fetch() - * - * @param null|mixed $template - */ - public function fetch($request, $template = null, $display = false) - { - $submission = $this->getSubmission(); - - $reviewRound = $this->getReviewRound(); - if ($reviewRound instanceof \PKP\submission\reviewRound\ReviewRound) { - $this->setData('reviewRoundId', $reviewRound->getId()); - } - - $this->setData('stageId', $this->getStageId()); - - $templateMgr = TemplateManager::getManager($request); - $stageDecisions = (new EditorDecisionActionsManager())->getStageDecisions($request->getContext(), $submission, $this->getStageId()); - $templateMgr->assign([ - 'decisionData' => $stageDecisions[$this->getDecision()], - 'submissionId' => $submission->getId(), - 'submission' => $submission, - ]); - - return parent::fetch($request, $template, $display); - } - - - // - // Private helper methods - // - /** - * Initiate a new review round and add selected files - * to it. Also saves the new round to the submission. - * - * @param Submission $submission - * @param int $stageId One of the WORKFLOW_STAGE_ID_* constants. - * @param Request $request - * @param int $status One of the REVIEW_ROUND_STATUS_* constants. - * - * @return $newRound integer The round number of the new review round. - */ - public function _initiateReviewRound($submission, $stageId, $request, $status = null) - { - - // If we already have review round for this stage, - // we create a new round after the last one. - $reviewRoundDao = DAORegistry::getDAO('ReviewRoundDAO'); /** @var ReviewRoundDAO $reviewRoundDao */ - $lastReviewRound = $reviewRoundDao->getLastReviewRoundBySubmissionId($submission->getId(), $stageId); - if ($lastReviewRound) { - $newRound = $lastReviewRound->getRound() + 1; - } else { - // If we don't have any review round, we create the first one. - $newRound = 1; - } - - // Create a new review round. - $reviewRound = $reviewRoundDao->build($submission->getId(), $stageId, $newRound, $status); - - // Check for a notification already in place for the current review round. - $notificationDao = DAORegistry::getDAO('NotificationDAO'); /** @var NotificationDAO $notificationDao */ - $notificationFactory = $notificationDao->getByAssoc( - ASSOC_TYPE_REVIEW_ROUND, - $reviewRound->getId(), - null, - PKPNotification::NOTIFICATION_TYPE_REVIEW_ROUND_STATUS, - $submission->getContextId() - ); - - // Create round status notification if there is no notification already. - if (!$notificationFactory->next()) { - $notificationMgr = new NotificationManager(); - $notificationMgr->createNotification( - $request, - null, - PKPNotification::NOTIFICATION_TYPE_REVIEW_ROUND_STATUS, - $submission->getContextId(), - ASSOC_TYPE_REVIEW_ROUND, - $reviewRound->getId(), - Notification::NOTIFICATION_LEVEL_NORMAL - ); - } - - // Add the selected files to the new round. - $fileStage = $stageId == WORKFLOW_STAGE_ID_INTERNAL_REVIEW - ? SubmissionFile::SUBMISSION_FILE_INTERNAL_REVIEW_FILE - : SubmissionFile::SUBMISSION_FILE_REVIEW_FILE; - - foreach (['selectedFiles', 'selectedAttachments'] as $userVar) { - $selectedFiles = $this->getData($userVar); - if (is_array($selectedFiles)) { - foreach ($selectedFiles as $fileId) { - $oldSubmissionFile = Repo::submissionFile() - ->get($fileId); - $oldSubmissionFile->setData('fileStage', $fileStage); - $oldSubmissionFile->setData('sourceSubmissionFileId', $fileId); - $oldSubmissionFile->setData('assocType', null); - $oldSubmissionFile->setData('assocId', null); - - $submissionFileId = Repo::submissionFile() - ->add($oldSubmissionFile); - - Repo::submissionFile() - ->dao - ->assignRevisionToReviewRound( - $submissionFileId, - $reviewRound - ); - } - } - } - - return $newRound; - } -} - -if (!PKP_STRICT_MODE) { - class_alias('\PKP\controllers\modals\editorDecision\form\EditorDecisionForm', '\EditorDecisionForm'); -} diff --git a/classes/core/EntityDAO.inc.php b/classes/core/EntityDAO.inc.php index b69456c5c66..136bbbee1ac 100644 --- a/classes/core/EntityDAO.inc.php +++ b/classes/core/EntityDAO.inc.php @@ -112,22 +112,24 @@ public function fromRow(object $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; } @@ -151,7 +153,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; @@ -193,57 +195,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(); + } } } @@ -260,9 +264,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 95ca8a9b422..e5f84bcef9e 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; @@ -466,7 +467,6 @@ public function getDAOMap() 'ControlledVocabEntryDAO' => 'PKP\controlledVocab\ControlledVocabEntryDAO', 'DataObjectTombstoneDAO' => 'PKP\tombstone\DataObjectTombstoneDAO', 'DataObjectTombstoneSettingsDAO' => 'PKP\tombstone\DataObjectTombstoneSettingsDAO', - 'EditDecisionDAO' => 'PKP\submission\EditDecisionDAO', 'FilterDAO' => 'PKP\filter\FilterDAO', 'FilterGroupDAO' => 'PKP\filter\FilterGroupDAO', 'GenreDAO' => 'PKP\submission\GenreDAO', diff --git a/classes/core/PKPRequest.inc.php b/classes/core/PKPRequest.inc.php index e91508e1cc9..048779c0b75 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) { @@ -772,11 +773,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/core/PKPString.inc.php b/classes/core/PKPString.inc.php index 08019b36f73..74740ba8e30 100644 --- a/classes/core/PKPString.inc.php +++ b/classes/core/PKPString.inc.php @@ -504,18 +504,6 @@ public static function uncamelize($string) return strtolower_codesafe(implode('-', $words[0])); } - /** - * Get a letter $steps places after 'A' - * - * @param int $steps - * - * @return string Letter - */ - public static function enumerateAlphabetically($steps) - { - return chr(ord('A') + $steps); - } - /** * Create a new UUID (version 4) * diff --git a/classes/decision/Collector.inc.php b/classes/decision/Collector.inc.php new file mode 100644 index 00000000000..8e284ba1690 --- /dev/null +++ b/classes/decision/Collector.inc.php @@ -0,0 +1,134 @@ +dao = $dao; + } + + /** + * Filter decisions by these decision types + * + * @param int[] $decisionTypes One of the Decision::* constants + */ + public function filterByDecisionTypes(array $decisionTypes): self + { + $this->decisionTypes = $decisionTypes; + return $this; + } + + /** + * Filter decisions taken by one or more editors] + * + * @param int[] $editorIds + */ + public function filterByEditorIds(array $editorIds): self + { + $this->editorIds = $editorIds; + return $this; + } + + /** + * Filter decisions taken in one or more reviewRoundIds + * + * @param int[] $reviewRoundIds The review round number, such as first or + * second round of reviews. NOT the unique review round id. + */ + public function filterByReviewRoundIds(array $reviewRoundIds): self + { + $this->reviewRoundIds = $reviewRoundIds; + return $this; + } + + /** + * Filter decisions taken in one or more rounds + * + * @param int[] $rounds The review round number, such as first or + * second round of reviews. 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 + * + * @param int[] $stageIds One or more WORKFLOW_STAGE_ID_ constants + */ + public function filterByStageIds(array $stageIds): self + { + $this->stageIds = $stageIds; + return $this; + } + + /** + * Filter decisions taken for one or more submission ids + * + * @param int[] $submissionIds + */ + 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->decisionTypes), function ($q) { + $q->whereIn('decision', $this->decisionTypes); + }) + ->when(!is_null($this->editorIds), function ($q) { + $q->whereIn('editor_id', $this->editorIds); + }) + ->when(!is_null($this->reviewRoundIds), function ($q) { + $q->whereIn('review_round_id', $this->reviewRoundIds); + }) + ->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..b0ccc9f9085 --- /dev/null +++ b/classes/decision/DAO.inc.php @@ -0,0 +1,145 @@ + '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); + } + + /** + * Reassign all decisions from one editor to another + */ + public function reassignDecisions(int $fromEditorId, int $toEditorId) + { + DB::table($this->table) + ->where('editor_id', '=', $fromEditorId) + ->update(['editor_id' => $toEditorId]); + } +} diff --git a/classes/decision/Decision.inc.php b/classes/decision/Decision.inc.php new file mode 100644 index 00000000000..44d1ed3fea6 --- /dev/null +++ b/classes/decision/Decision.inc.php @@ -0,0 +1,70 @@ +getTypes() as $type) { + if ($type->getDecision() === $this->getData('decision')) { + return $type; + } + } + throw new Exception('Decision exists with an unknown type. Decision: ' . $this->getData('decisions')); + } +} + +if (!PKP_STRICT_MODE) { + define('SUBMISSION_EDITOR_DECISION_INITIAL_DECLINE', Decision::INITIAL_DECLINE); + define('SUBMISSION_EDITOR_DECISION_SKIP_REVIEW', Decision::SKIP_REVIEW); + define('SUBMISSION_EDITOR_RECOMMEND_ACCEPT', Decision::RECOMMEND_ACCEPT); + define('SUBMISSION_EDITOR_RECOMMEND_PENDING_REVISIONS', Decision::RECOMMEND_PENDING_REVISIONS); + define('SUBMISSION_EDITOR_RECOMMEND_RESUBMIT', Decision::RECOMMEND_RESUBMIT); + define('SUBMISSION_EDITOR_RECOMMEND_DECLINE', Decision::RECOMMEND_DECLINE); + define('SUBMISSION_EDITOR_DECISION_REVERT_DECLINE', Decision::REVERT_DECLINE); + define('SUBMISSION_EDITOR_DECISION_REVERT_INITIAL_DECLINE', Decision::REVERT_INITIAL_DECLINE); + define('SUBMISSION_EDITOR_DECISION_BACK_TO_COPYEDITING', Decision::BACK_TO_COPYEDITING); + define('SUBMISSION_EDITOR_DECISION_BACK_TO_REVIEW', Decision::BACK_TO_REVIEW); + define('SUBMISSION_EDITOR_DECISION_BACK_TO_SUBMISSION_FROM_COPYEDITING', Decision::BACK_TO_SUBMISSION_FROM_COPYEDITING); + define('SUBMISSION_EDITOR_DECISION_SEND_TO_PRODUCTION', Decision::SEND_TO_PRODUCTION); +} diff --git a/classes/decision/Repository.inc.php b/classes/decision/Repository.inc.php new file mode 100644 index 00000000000..71ac6b9948d --- /dev/null +++ b/classes/decision/Repository.inc.php @@ -0,0 +1,487 @@ +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 DECISION::* constant + */ + public function getType(int $decision): ?Type + { + return $this->getTypes()->first(function (Type $type) use ($decision) { + return $type->getDecision() === $decision; + }); + } + + /** + * Find the most recent revisions decision that is still active. An active + * decision is one that is not overriden by any other decision. + */ + public function getActivePendingRevisionsDecision(int $submissionId, int $stageId, int $decision = Decision::PENDING_REVISIONS): ?Decision + { + $postReviewDecisions = [Decision::SEND_TO_PRODUCTION]; + $revisionDecisions = [Decision::PENDING_REVISIONS, Decision::RESUBMIT]; + if (!in_array($decision, $revisionDecisions)) { + return null; + } + + $revisionsDecisions = $this->getMany( + $this->getCollector() + ->filterBySubmissionIds([$submissionId]) + ); + // Most recent decision first + $revisionsDecisions = $revisionsDecisions->reverse(); + + $pendingRevisionDecision = null; + foreach ($revisionsDecisions as $revisionDecision) { + if (in_array($revisionDecision->getData('decision'), $postReviewDecisions)) { + // Decisions at later stages do not override the pending revisions one. + continue; + } elseif ($revisionDecision->getData('decision') == $decision) { + if ($revisionDecision->getData('stageId') == $stageId) { + $pendingRevisionDecision = $revisionDecision; + // Only the last pending revisions decision is relevant. + break; + } else { + // Both internal and external pending revisions decisions are + // valid at the same time. Continue to search. + continue; + } + } else { + break; + } + } + + + return $pendingRevisionDecision; + } + + /** + * Have any submission files been uploaded to the revision file stage since + * this decision was taken? + */ + public function revisionsUploadedSinceDecision(Decision $decision, int $submissionId): bool + { + $stageId = $decision->getData('stageId'); + $round = $decision->getData('round'); + $sentRevisions = false; + + $reviewRoundDao = DAORegistry::getDAO('ReviewRoundDAO'); /** @var ReviewRoundDAO $reviewRoundDao */ + $reviewRound = $reviewRoundDao->getReviewRound($submissionId, $stageId, $round); + + $submissionFileCollector = Repo::submissionFiles() + ->getCollector() + ->filterByReviewRoundIds([$reviewRound->getId()]) + ->filterByFileStages([SubmissionFile::SUBMISSION_FILE_REVIEW_REVISION]); + + $submissionFiles = Repo::submissionFiles()->getMany($submissionFileCollector); + + foreach ($submissionFiles as $submissionFile) { + if ($submissionFile->getData('updatedAt') > $decision->getData('dateDecided')) { + $sentRevisions = true; + break; + } + } + + return $sentRevisions; + + return true; + } + + /** + * 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, [ + Decision::RECOMMEND_ACCEPT, + Decision::RECOMMEND_DECLINE, + Decision::RECOMMEND_PENDING_REVISIONS, + Decision::RECOMMEND_RESUBMIT, + ]); + } + + protected function getRoundByReviewRoundId(int $reviewRoundId): int + { + $reviewRoundDao = DAORegistry::getDAO('ReviewRoundDAO'); /** @var ReviewRoundDAO $reviewRoundDao */ + $reviewRound = $reviewRoundDao->getById($reviewRoundId); + return $reviewRound->getData('round'); + } + + /** + * Update notifications controlled by the NotificationManager + */ + protected function updateNotifications(Decision $decision, Type $type, Submission $submission) + { + $notificationMgr = new NotificationManager(); + + // Update editor decision and pending revisions notifications. + $notificationTypes = $this->getReviewNotificationTypes(); + if ($editorDecisionNotificationType = $notificationMgr->getNotificationTypeByEditorDecision($decision)) { + array_unshift($notificationTypes, $editorDecisionNotificationType); + } + + $authorIds = []; + /** @var StageAssignmentDAO $stageAssignmentDao */ + $stageAssignmentDao = DAORegistry::getDAO('StageAssignmentDAO'); + $result = $stageAssignmentDao->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 types related to a review stage + * + * @return int[] One or more of the Notification::NOTIFICATION_TYPE_ constants + */ + abstract protected function getReviewNotificationTypes(): array; + + /** + * Get additional notifications to be updated on a submission + * + * @return int[] One or more of the Notification::NOTIFICATION_TYPE_ constants + */ + protected function getSubmissionNotificationTypes(Decision $decision): array + { + switch ($decision->getData('decision')) { + case Decision::ACCEPT: + return [ + Notification::NOTIFICATION_TYPE_ASSIGN_COPYEDITOR, + Notification::NOTIFICATION_TYPE_AWAITING_COPYEDITS + ]; + case Decision::SEND_TO_PRODUCTION: + return [ + Notification::NOTIFICATION_TYPE_ASSIGN_COPYEDITOR, + Notification::NOTIFICATION_TYPE_AWAITING_COPYEDITS, + Notification::NOTIFICATION_TYPE_ASSIGN_PRODUCTIONUSER, + Notification::NOTIFICATION_TYPE_AWAITING_REPRESENTATIONS, + ]; + } + return []; + } +} 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..a92dfa912b0 --- /dev/null +++ b/classes/decision/Type.inc.php @@ -0,0 +1,523 @@ + $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 = Repo::submissionFiles()->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 + ); + } + } + + /** + * Helper method to get the file genres for a context + */ + protected function getFileGenres(int $contextId): array + { + /** @var GenreDAO $genreDao */ + $genreDao = DAORegistry::getDAO('GenreDAO'); + return $genreDao->getByContextId($contextId)->toArray(); + } +} 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..3a8344c3e18 --- /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..5bd898307b0 --- /dev/null +++ b/classes/decision/steps/Email.inc.php @@ -0,0 +1,129 @@ + */ + 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')) { + $emailTemplate = Repo::emailTemplate()->getByKey($context->getId(), $this->mailable->defaultEmailTemplateKey); + if ($emailTemplate) { + $emailTemplates->add($emailTemplate); + } + } + + 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..b1b676c2753 --- /dev/null +++ b/classes/decision/steps/PromoteFiles.inc.php @@ -0,0 +1,73 @@ +submission = $submission; + $this->to = $to; + + $this->genres = $genres; + } + + /** + * Add a list of files that can be copied to the next stage + */ + public function addFileList(string $name, Collector $collector): self + { + $files = Repo::submissionFiles() + ->getSchemaMap() + ->summarizeMany(Repo::submissionFiles()->getMany($collector), $this->genres); + + $this->lists[] = [ + 'name' => $name, + 'files' => $files->values(), + ]; + + return $this; + } + + public function getState(): stdClass + { + $config = parent::getState(); + $config->to = $this->to; + $config->selected = []; + $config->lists = $this->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..702b840dd1f --- /dev/null +++ b/classes/decision/types/Accept.inc.php @@ -0,0 +1,209 @@ + $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, + $context + ); + $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, + $this->getFileGenres($context->getId()) + ))->addFileList( + __('editor.submission.revisions'), + Repo::submissionFiles() + ->getCollector() + ->filterBySubmissionIds([$submission->getId()]) + ->filterByFileStages([SubmissionFile::SUBMISSION_FILE_REVIEW_REVISION]) + ->filterByReviewRoundIds([$reviewRound->getId()]) + )); + + return $workflow; + } +} diff --git a/classes/decision/types/BackToCopyediting.inc.php b/classes/decision/types/BackToCopyediting.inc.php new file mode 100644 index 00000000000..d4810a9f86d --- /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, + $context + ); + 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..07e7cd4c09d --- /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, + $context + ); + 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..ce3930d31d2 --- /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, + $context + ); + 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..c6edb003577 --- /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, + $context + ); + $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..36aa76e05b9 --- /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, + $context + ); + 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..537909d5a81 --- /dev/null +++ b/classes/decision/types/NewExternalReviewRound.inc.php @@ -0,0 +1,170 @@ + $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, + $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, $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, + $this->getFileGenres($context->getId()) + ))->addFileList( + __('editor.submission.revisions'), + Repo::submissionFiles() + ->getCollector() + ->filterBySubmissionIds([$submission->getId()]) + ->filterByFileStages([SubmissionFile::SUBMISSION_FILE_REVIEW_REVISION]) + ->filterByReviewRoundIds([$reviewRound->getId()]) + )); + + return $workflow; + } +} diff --git a/classes/decision/types/RecommendAccept.inc.php b/classes/decision/types/RecommendAccept.inc.php new file mode 100644 index 00000000000..9e4cab98369 --- /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, + $context + ); + $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..f4016c72471 --- /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, + $context + ); + $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..318f3b24367 --- /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, + $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, $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..0a92b4486d1 --- /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, + $context + ); + 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..e98574a6313 --- /dev/null +++ b/classes/decision/types/SendExternalReview.inc.php @@ -0,0 +1,169 @@ + $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, + $context + ); + 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, + $this->getFileGenres($context->getId()) + ))->addFileList( + __('submission.submit.submissionFiles'), + Repo::submissionFiles() + ->getCollector() + ->filterBySubmissionIds([$submission->getId()]) + ->filterByFileStages([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..076211abdb0 --- /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, + $context + ); + 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..9f8747e9ed6 --- /dev/null +++ b/classes/decision/types/SkipReview.inc.php @@ -0,0 +1,176 @@ + $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, + $context + ); + 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, + $this->getFileGenres($context->getId()) + ))->addFileList( + __('submission.submit.submissionFiles'), + Repo::submissionFiles() + ->getCollector() + ->filterBySubmissionIds([$submission->getId()]) + ->filterByFileStages([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..7aaecfcd215 --- /dev/null +++ b/classes/decision/types/traits/InExternalReviewRound.inc.php @@ -0,0 +1,160 @@ + + */ + 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 = Repo::submissionFiles()->getMany( + Repo::submissionFiles() + ->getCollector() + ->filterBySubmissionIds([$submission->getId()]) + ->filterByAssoc(Application::ASSOC_TYPE_REVIEW_ASSIGNMENT, 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..39132be5cd3 --- /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..80286b07d4a --- /dev/null +++ b/classes/decision/types/traits/IsRecommendation.inc.php @@ -0,0 +1,199 @@ + $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 = Repo::submissionFiles()->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()); + Repo::submissionFiles()->add($newSubmissionFile); + } 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 = Repo::submissionFiles()->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(), + ]); + Repo::submissionFiles()->add($submissionFile); + } +} diff --git a/classes/decision/types/traits/NotifyAuthors.inc.php b/classes/decision/types/traits/NotifyAuthors.inc.php new file mode 100644 index 00000000000..cb146bc716a --- /dev/null +++ b/classes/decision/types/traits/NotifyAuthors.inc.php @@ -0,0 +1,146 @@ +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, Context $context) + { + $recipients = array_map(function ($userId) { + return Repo::user()->get($userId); + }, $this->getAssignedAuthorIds($submission)); + + $mailable = $this->addEmailDataToMailable($mailable, $editor, $email); + + Mail::send($mailable->recipients($recipients)); + + /** @var SubmissionEmailLogDAO $submissionEmailLogDao */ + $submissionEmailLogDao = DAORegistry::getDAO('SubmissionEmailLogDAO'); + $submissionEmailLogDao->logMailable( + SubmissionEmailLogEntry::SUBMISSION_EMAIL_EDITOR_NOTIFY_AUTHOR, + $mailable, + $submission, + $editor + ); + + if ($context->getData('notifyAllAuthors')) { + $authors = $submission->getCurrentPublication()->getData('authors'); + $assignedAuthorEmails = array_map(function (User $user) { + return $user->getEmail(); + }, $recipients); + $mailable = new DecisionNotifyOtherAuthors($context, $submission); + $emailTemplate = Repo::emailTemplate()->getByKey($context->getId(), $mailable::EMAIL_KEY); + $mailable + ->sender($editor) + ->subject($email->subject) + ->body($emailTemplate->getLocalizedData('body')) + ->addData([ + $mailable::MESSAGE_TO_SUBMITTING_AUTHOR => $email->body, + ]); + foreach ($authors as $author) { + if (!$author->getEmail() || in_array($author->getEmail(), $assignedAuthorEmails)) { + continue; + } + $mailable->to($author->getEmail(), $author->getFullName()); + Mail::send($mailable); + } + } + } + + /** + * 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 = Repo::submissionFiles()->getIds( + Repo::submissionFiles() + ->getCollector() + ->filterBySubmissionIds([$submission->getId()]) + ->filterByReviewRoundIds([$reviewRoundId]) + ->filterByFileStages([SubmissionFile::SUBMISSION_FILE_REVIEW_ATTACHMENT]) + ); + + foreach ($reviewAttachmentIds->intersect($submissionFileIds) as $sharedFileId) { + $submissionFile = Repo::submissionFiles()->get($sharedFileId); + Repo::submissionFiles()->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..643b8b63dbd --- /dev/null +++ b/classes/decision/types/traits/NotifyReviewers.inc.php @@ -0,0 +1,109 @@ +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) + { + /** @var DecisionNotifyReviewer $mailable */ + $mailable = $this->addEmailDataToMailable($mailable, $editor, $email); + + /** @var User[] $recipients */ + $recipients = array_map(function ($userId) { + return Repo::user()->get($userId); + }, $email->to); + + foreach ($recipients as $recipient) { + Mail::send($mailable->recipients([$recipient])); + + // Update the ReviewAssignment to indicate the reviewer has been acknowledged + /** @var ReviewAssignmentDAO $reviewAssignmentDao */ + $reviewAssignmentDao = DAORegistry::getDAO('ReviewAssignmentDAO'); + $reviewAssignment = $reviewAssignmentDao->getReviewAssignment($mailable->getDecision()->getData('reviewRoundId'), $recipient->getId()); + if ($reviewAssignment) { + $reviewAssignment->setDateAcknowledged(Core::getCurrentDate()); + $reviewAssignment->stampModified(); + $reviewAssignment->setUnconsidered(ReviewAssignment::REVIEW_ASSIGNMENT_NOT_UNCONSIDERED); + $reviewAssignmentDao->updateObject($reviewAssignment); + } + } + + 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 3607a242ec5..6c28f3dcde8 100644 --- a/classes/emailTemplate/Repository.inc.php +++ b/classes/emailTemplate/Repository.inc.php @@ -15,12 +15,13 @@ use APP\emailTemplate\DAO; 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 PKP\validation\ValidatorFactory; -use PKP\facades\Repo; class Repository { @@ -57,7 +58,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 +144,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/facades/Repo.inc.php b/classes/facades/Repo.inc.php index e357501cc2b..15df39c5653 100644 --- a/classes/facades/Repo.inc.php +++ b/classes/facades/Repo.inc.php @@ -27,6 +27,7 @@ use PKP\announcement\Repository as AnnouncementRepository; use PKP\author\Repository as AuthorRepository; use PKP\category\Repository as CategoryRepository; +use PKP\decision\Repository as DecisionRepository; use PKP\emailTemplate\Repository as EmailTemplateRepository; use PKP\submissionFile\Repository as SubmissionFileRepository; @@ -42,6 +43,11 @@ public static function author(): AuthorRepository return app(AuthorRepository::class); } + public static function decision(): DecisionRepository + { + return app()->make(DecisionRepository::class); + } + public static function emailTemplate(): EmailTemplateRepository { return app(EmailTemplateRepository::class); diff --git a/classes/handler/APIHandler.inc.php b/classes/handler/APIHandler.inc.php index 31f28cea362..30e887c1fc5 100644 --- a/classes/handler/APIHandler.inc.php +++ b/classes/handler/APIHandler.inc.php @@ -397,6 +397,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 29363b6d9c6..990bc780082 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 { /** @@ -55,6 +60,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 d90bd8558d7..cda98523038 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 ff357315452..75479b1d9ea 100644 --- a/classes/mail/Mailable.inc.php +++ b/classes/mail/Mailable.inc.php @@ -27,23 +27,33 @@ namespace PKP\mail; +use APP\core\Services; +use APP\decision\Decision; +use APP\facades\Repo; 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\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 +62,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 +75,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 +113,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 +146,7 @@ public function setData(?string $locale = null) /** * @param string $localeKey + * * @throws BadMethodCallException */ public function locale($localeKey) @@ -142,18 +157,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 +178,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 +215,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 +255,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 +281,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 +302,7 @@ public static function getDataDescriptions() : array // No special treatment for others $descriptions = array_merge( $descriptions, - $map[$class]::getDescription() + $map[$class]::descriptions() ); } @@ -291,7 +312,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 +324,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 +342,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 = Repo::submissionFiles()->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..93922280659 --- /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..363b1acc9a7 --- /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..0e331268cec --- /dev/null +++ b/classes/mail/mailables/DecisionInitialDeclineNotifyAuthor.inc.php @@ -0,0 +1,44 @@ +decision = $decision; + parent::__construct(func_get_args()); + } + + public function getDecision(): Decision + { + return $this->decision; + } + + public static function getDataDescriptions(): array + { + $variables = parent::getDataDescriptions(); + $variables[self::DECISION_DESCRIPTION] = __('mailable.decision.notifyReviewer.variable.decisionDescription'); + return $variables; + } + + public function setData(?string $locale = null) + { + parent::setData($locale); + $this->viewData[self::DECISION_DESCRIPTION] = $this->getDecisionDescription($locale); + } + + /** + * Get a description of the decision to use as an email variable + */ + protected function getDecisionDescription(?string $locale = null): string + { + switch ($this->decision->getData('decision')) { + case Decision::ACCEPT: return __('mailable.decision.notifyReviewer.variable.decisionDescription.accept', [], $locale); + case Decision::DECLINE: return __('mailable.decision.notifyReviewer.variable.decisionDescription.decline', [], $locale); + case Decision::PENDING_REVISIONS: return __('mailable.decision.notifyReviewer.variable.decisionDescription.pendingRevisions', [], $locale); + case Decision::RESUBMIT: return __('mailable.decision.notifyReviewer.variable.decisionDescription.resubmit', [], $locale); + default: return ''; + } + } +} diff --git a/classes/mail/mailables/DecisionRequestRevisionsNotifyAuthor.inc.php b/classes/mail/mailables/DecisionRequestRevisionsNotifyAuthor.inc.php new file mode 100644 index 00000000000..e918e71459c --- /dev/null +++ b/classes/mail/mailables/DecisionRequestRevisionsNotifyAuthor.inc.php @@ -0,0 +1,57 @@ + $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..94157ddbfa2 --- /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..e480bd96410 --- /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..5658e9c988d --- /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 51fea26f762..285397d945b 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 260f0eb6db4..723194b523e 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..4aac1dddbe5 100644 --- a/classes/mail/variables/ContextEmailVariable.inc.php +++ b/classes/mail/variables/ContextEmailVariable.inc.php @@ -21,12 +21,13 @@ 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'; + public const SUBMISSIONS_URL = 'submissionsUrl'; protected Context $context; @@ -39,9 +40,9 @@ public function __construct(Context $context) } /** - * @copydoc Variable::description() + * @copydoc Variable::descriptions() */ - protected static function description() : array + public static function descriptions(): array { return [ @@ -49,6 +50,7 @@ protected static function description() : array self::PRINCIPAL_CONTACT_SIGNATURE => __('emailTemplate.variable.context.principalContactSignature'), self::CONTACT_EMAIL => __('emailTemplate.variable.context.contactEmail'), self::PASSWORD_LOST_URL => __('emailTemplate.variable.context.passwordLostUrl'), + self::SUBMISSIONS_URL => __('emailTemplate.variable.context.passwordLostUrl'), ]; } @@ -63,15 +65,16 @@ public function values(string $locale): array self::PRINCIPAL_CONTACT_SIGNATURE => $this->getPrincipalContactSignature($locale), self::CONTACT_EMAIL => $this->context->getData('contactEmail'), self::PASSWORD_LOST_URL => $this->getPasswordLostUrl(), + self::SUBMISSIONS_URL => $this->getSubmissionsUrl(), ]; } - 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,8 +84,22 @@ 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'); } + + /** + * URL to the submissions lists + */ + protected function getSubmissionsUrl(): string + { + $request = PKPApplication::get()->getRequest(); + return $request->getDispatcher()->url( + $request, + PKPApplication::ROUTE_PAGE, + null, + 'submissions', + ); + } } diff --git a/classes/mail/variables/DecisionEmailVariable.inc.php b/classes/mail/variables/DecisionEmailVariable.inc.php new file mode 100644 index 00000000000..e17702b409b --- /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 02de3327836..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 = 'recipientName'; - const RECIPIENT_USERNAME = 'recipientUsername'; + 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 5e1d278982c..a0525e5374d 100644 --- a/classes/mail/variables/SiteEmailVariable.inc.php +++ b/classes/mail/variables/SiteEmailVariable.inc.php @@ -19,9 +19,9 @@ class SiteEmailVariable extends Variable { - const SITE_TITLE = 'siteTitle'; - const SITE_CONTACT = 'siteContactName'; - const SITE_EMAIL = 'siteContactEmail'; + public const SITE_TITLE = 'siteTitle'; + public const SITE_CONTACT = 'siteContactName'; + public const SITE_EMAIL = 'siteContactEmail'; protected Site $site; @@ -31,9 +31,9 @@ public function __construct(Site $site) } /** - * @copydoc Variable::description() + * @copydoc Variable::descriptions() */ - protected static function description(): array + public static function descriptions(): array { return [ @@ -48,7 +48,7 @@ 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), 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 306d72cd026..e4ca680d964 100644 --- a/classes/mail/variables/SubmissionEmailVariable.inc.php +++ b/classes/mail/variables/SubmissionEmailVariable.inc.php @@ -15,10 +15,13 @@ namespace PKP\mail\variables; +use APP\facades\Repo; use APP\publication\Publication; use APP\submission\Submission; use PKP\author\Author; use PKP\core\PKPApplication; +use PKP\db\DAORegistry; +use PKP\security\Role; class SubmissionEmailVariable extends Variable { @@ -27,6 +30,7 @@ class SubmissionEmailVariable extends Variable public const SUBMISSION_ABSTRACT = 'submissionAbstract'; public const AUTHORS_SHORT = 'authorsShort'; public const AUTHORS = 'authors'; + public const SUBMITTING_AUTHOR_NAME = 'submittingAuthorName'; public const SUBMISSION_URL = 'submissionUrl'; protected Submission $submission; @@ -42,9 +46,9 @@ public function __construct(Submission $submission) } /** - * @copydoc Variable::description() + * @copydoc Variable::descriptions() */ - protected static function description(): array + public static function descriptions(): array { return [ @@ -53,6 +57,7 @@ protected static function description(): array self::SUBMISSION_ABSTRACT => __('emailTemplate.variable.submission.submissionAbstract'), self::AUTHORS_SHORT => __('emailTemplate.variable.submission.authorsShort'), self::AUTHORS => __('emailTemplate.variable.submission.authors'), + self::SUBMITTING_AUTHOR_NAME => __('emailTemplate.variable.submission.submittingAuthorName'), self::SUBMISSION_URL => __('emailTemplate.variable.submission.submissionUrl'), ]; } @@ -65,10 +70,11 @@ 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_SHORT => $this->currentPublication->getShortAuthorString($locale), self::AUTHORS => $this->getAuthorsFull($locale), + self::SUBMITTING_AUTHOR_NAME => $this->getSubmittingAuthorName($locale), self::SUBMISSION_URL => $this->getSubmissionUrl(), ]; } @@ -83,7 +89,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); } /** @@ -98,8 +104,38 @@ protected function getSubmissionUrl(): string null, 'workflow', 'index', - [$this->submission->getId(), - $this->submission->getData('stageId')] + [ + $this->submission->getId(), + $this->submission->getData('stageId'), + ] ); } + + /** + * The name(s) of authors assigned as participants to the + * submission workflow. + * + * Usually this is the submitting author. + */ + protected function getSubmittingAuthorName(string $locale): string + { + $authorNames = []; + $alreadyCollected = []; // Prevent duplicate names for each stage assignment + /** @var StageAssignmentDAO $stageAssignmentDao */ + $stageAssignmentDao = DAORegistry::getDAO('StageAssignmentDAO'); + $result = $stageAssignmentDao->getBySubmissionAndRoleId($this->submission->getId(), Role::ROLE_ID_AUTHOR); + /** @var StageAssignment $stageAssignment */ + while ($stageAssignment = $result->next()) { + $userId = (int) $stageAssignment->getUserId(); + if (in_array($userId, $alreadyCollected)) { + continue; + } + $alreadyCollected[] = $userId; + $user = Repo::user()->get($userId); + if ($user) { + $authorNames[] = $user->getFullName(true, false, $locale); + } + } + return join(__('common.commaListSeparator'), $authorNames); + } } 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/notification/PKPNotificationManager.inc.php b/classes/notification/PKPNotificationManager.inc.php index 3be923e5cae..5e47e76ad2a 100644 --- a/classes/notification/PKPNotificationManager.inc.php +++ b/classes/notification/PKPNotificationManager.inc.php @@ -18,8 +18,10 @@ namespace PKP\notification; use APP\core\Application; +use APP\decision\Decision; use APP\facades\Repo; use APP\i18n\AppLocale; +use APP\notification\Notification; use APP\template\TemplateManager; use PKP\core\PKPApplication; use PKP\db\DAORegistry; @@ -424,6 +426,50 @@ public function getNotificationSettingsMap() ]; } + /** + * Get the stage-level notification type constants for editorial decisions + * + * @return int[] + */ + public function getDecisionStageNotifications(): array + { + return [ + PKPNotification::NOTIFICATION_TYPE_EDITOR_ASSIGNMENT_SUBMISSION, + PKPNotification::NOTIFICATION_TYPE_EDITOR_ASSIGNMENT_EXTERNAL_REVIEW, + PKPNotification::NOTIFICATION_TYPE_EDITOR_ASSIGNMENT_EDITING, + PKPNotification::NOTIFICATION_TYPE_EDITOR_ASSIGNMENT_PRODUCTION + ]; + } + + /** + * Get the notification type for each editor decision + * + * @return int One of the Notification::NOTIFICATION_TYPE_ constants + */ + public function getNotificationTypeByEditorDecision(Decision $decision): ?int + { + switch ($decision->getData('decision')) { + case Decision::ACCEPT: + return Notification::NOTIFICATION_TYPE_EDITOR_DECISION_ACCEPT; + case Decision::EXTERNAL_REVIEW: + return Notification::NOTIFICATION_TYPE_EDITOR_DECISION_EXTERNAL_REVIEW; + case Decision::PENDING_REVISIONS: + return Notification::NOTIFICATION_TYPE_EDITOR_DECISION_PENDING_REVISIONS; + case Decision::RESUBMIT: + return Notification::NOTIFICATION_TYPE_EDITOR_DECISION_RESUBMIT; + case Decision::NEW_ROUND: + return Notification::NOTIFICATION_TYPE_EDITOR_DECISION_NEW_ROUND; + case Decision::DECLINE: + case Decision::INITIAL_DECLINE: + return Notification::NOTIFICATION_TYPE_EDITOR_DECISION_DECLINE; + case Decision::REVERT_DECLINE: + return Notification::NOTIFICATION_TYPE_EDITOR_DECISION_REVERT_DECLINE; + case Decision::SEND_TO_PRODUCTION: + return Notification::NOTIFICATION_TYPE_EDITOR_DECISION_SEND_TO_PRODUCTION; + } + return null; + } + // // Protected methods // diff --git a/classes/notification/managerDelegate/PendingRevisionsNotificationManager.inc.php b/classes/notification/managerDelegate/PendingRevisionsNotificationManager.inc.php index d09ccd8f64b..6fc32545dd4 100644 --- a/classes/notification/managerDelegate/PendingRevisionsNotificationManager.inc.php +++ b/classes/notification/managerDelegate/PendingRevisionsNotificationManager.inc.php @@ -15,11 +15,11 @@ namespace PKP\notification\managerDelegate; +use APP\decision\Decision; use APP\facades\Repo; use APP\i18n\AppLocale; use APP\notification\Notification; -use APP\workflow\EditorDecisionActionsManager; use PKP\db\DAORegistry; use PKP\notification\NotificationManagerDelegate; use PKP\notification\PKPNotification; @@ -109,12 +109,11 @@ public function updateNotification($request, $userIds, $assocType, $assocId) } $expectedStageId = $stageData['id']; - $editDecisionDao = DAORegistry::getDAO('EditDecisionDAO'); /** @var EditDecisionDAO $editDecisionDao */ - $pendingRevisionDecision = $editDecisionDao->findValidPendingRevisionsDecision($submissionId, $expectedStageId, EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_PENDING_REVISIONS); + $pendingRevisionDecision = Repo::decision()->getActivePendingRevisionsDecision($submissionId, $expectedStageId, Decision::PENDING_REVISIONS); $removeNotifications = false; if ($pendingRevisionDecision) { - if ($editDecisionDao->responseExists($pendingRevisionDecision, $submissionId)) { + if (Repo::decision()->revisionsUploadedSinceDecision($pendingRevisionDecision, $submissionId)) { // Some user already uploaded a revision. Flag to delete any existing notification. $removeNotifications = true; } else { diff --git a/classes/observers/events/DecisionAdded.inc.php b/classes/observers/events/DecisionAdded.inc.php new file mode 100644 index 00000000000..6670c96dcf5 --- /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/observers/events/DiscussionMessageSent.inc.php b/classes/observers/events/DiscussionMessageSent.inc.php deleted file mode 100644 index c7d82618040..00000000000 --- a/classes/observers/events/DiscussionMessageSent.inc.php +++ /dev/null @@ -1,44 +0,0 @@ -query = $query; - $this->context = $context; - $this->submission = $submission; - $this->formEmailData = $formData; - } -} diff --git a/classes/observers/listeners/MailDiscussionMessage.inc.php b/classes/observers/listeners/MailDiscussionMessage.inc.php deleted file mode 100644 index cd6c0431b63..00000000000 --- a/classes/observers/listeners/MailDiscussionMessage.inc.php +++ /dev/null @@ -1,68 +0,0 @@ -formEmailData->getRecipients($event->context->getId()); - foreach ($users as $user) { - // Check if user is unsubscribed - $notificationSubscriptionSettings = $notificationSubscriptionSettingsDao->getNotificationSubscriptionSettings( - NotificationSubscriptionSettingsDAO::BLOCKED_EMAIL_NOTIFICATION_KEY, - $user->getId(), - (int) $event->context->getId() - ); - if (in_array(PKPNotification::NOTIFICATION_TYPE_NEW_QUERY, $notificationSubscriptionSettings)) { - return; - } - - $submission = $event->submission; - $sender = $event->formEmailData->getSender(); - - $mailable = new \PKP\mail\mailables\MailDiscussionMessage($event->context, $submission); - $emailTemplate = $mailable->getTemplate($event->context->getId()); - - $mailable->addData(array_merge( - [ - 'siteTitle' => $mailable->viewData['journalName'], - ], - $event->formEmailData->getVariables($user->getId()) - )); - - $mailable - ->body($emailTemplate->getLocalizedData('body')) - ->subject($emailTemplate->getLocalizedData('subject')) - ->sender($sender) - ->recipients([$user]) - ->replyTo($event->context->getContactEmail(), $event->context->getContactName()); - - Mail::send($mailable); - } - } -} diff --git a/classes/payment/QueuedPaymentDAO.inc.php b/classes/payment/QueuedPaymentDAO.inc.php index 699cdfa6fb1..3d1c7c81a46 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 1c8f93085c6..3e24da6fa51 100644 --- a/classes/publication/PKPPublication.inc.php +++ b/classes/publication/PKPPublication.inc.php @@ -219,9 +219,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 35a2b5e3ca2..ab61d3ec021 100644 --- a/classes/publication/Repository.inc.php +++ b/classes/publication/Repository.inc.php @@ -32,6 +32,7 @@ use PKP\log\SubmissionLog; use PKP\plugins\HookRegistry; use PKP\services\PKPSchemaService; +use PKP\submission\Genre; use PKP\submission\PKPSubmission; use PKP\validation\ValidatorFactory; @@ -105,14 +106,18 @@ public function getCollector(): Collector /** * Get an instance of the map class for mapping * publications to their schema + * + * @param UserGroup[] $userGroups + * @param Genre[] $genres */ - public function getSchemaMap(Submission $submission, array $userGroups): maps\Schema + public function getSchemaMap(Submission $submission, array $userGroups, array $genres): maps\Schema { return app('maps')->withExtensions( $this->schemaMap, [ 'submission' => $submission, 'userGroups' => $userGroups, + 'genres' => $genres, ] ); } @@ -223,7 +228,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/publication/maps/Schema.inc.php b/classes/publication/maps/Schema.inc.php index e4370299fe2..5ee37b3c311 100644 --- a/classes/publication/maps/Schema.inc.php +++ b/classes/publication/maps/Schema.inc.php @@ -22,14 +22,16 @@ use Illuminate\Support\Enumerable; use PKP\context\Context; use PKP\db\DAORegistry; +use PKP\security\UserGroup; use PKP\services\PKPSchemaService; +use PKP\submission\Genre; class Schema extends \PKP\core\maps\Schema { - /** @var Enumerable */ + /** */ public Enumerable $collection; - /** @var string */ + /** */ public string $schema = PKPSchemaService::SCHEMA_PUBLICATION; /** @var Submission */ @@ -38,14 +40,18 @@ class Schema extends \PKP\core\maps\Schema /** @var bool */ public $anonymize; - /** @var array The user groups for this context. */ + /** @var UserGroup[] The user groups for this context. */ public $userGroups; - public function __construct(Submission $submission, array $userGroups, Request $request, Context $context, PKPSchemaService $schemaService) + /** @var Genre[] The file genres for this context. */ + public array $genres; + + public function __construct(Submission $submission, array $userGroups, array $genres, Request $request, Context $context, PKPSchemaService $schemaService) { parent::__construct($request, $context, $schemaService); $this->submission = $submission; $this->userGroups = $userGroups; + $this->genres = $genres; } /** diff --git a/classes/query/Query.inc.php b/classes/query/Query.inc.php index b1f3168808d..8dd900abaeb 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 9e0da0ff0b4..f205acded4c 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/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/EditorDecisionAccessPolicy.inc.php b/classes/security/authorization/EditorDecisionAccessPolicy.inc.php deleted file mode 100644 index 047338aa891..00000000000 --- a/classes/security/authorization/EditorDecisionAccessPolicy.inc.php +++ /dev/null @@ -1,46 +0,0 @@ -addPolicy(new WorkflowStageAccessPolicy($request, $args, $roleAssignments, $submissionParameterName, $stageId, PKPApplication::WORKFLOW_TYPE_EDITORIAL)); - - // An editor decision can only be made if there is an editor assigned to the stage - $this->addPolicy(new ManagerRequiredPolicy($request)); - } -} - -if (!PKP_STRICT_MODE) { - class_alias('\PKP\security\authorization\EditorDecisionAccessPolicy', '\EditorDecisionAccessPolicy'); -} 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/ManagerRequiredPolicy.inc.php b/classes/security/authorization/internal/ManagerRequiredPolicy.inc.php deleted file mode 100644 index a388124a3a3..00000000000 --- a/classes/security/authorization/internal/ManagerRequiredPolicy.inc.php +++ /dev/null @@ -1,72 +0,0 @@ -_request = $request; - } - - // - // Implement template methods from AuthorizationPolicy - // - /** - * @see AuthorizationPolicy::effect() - */ - public function effect() - { - // Get the submission - $submission = $this->getAuthorizedContextObject(ASSOC_TYPE_SUBMISSION); - if (!$submission instanceof Submission) { - return AuthorizationPolicy::AUTHORIZATION_DENY; - } - - // Get the stage - $stageId = $this->getAuthorizedContextObject(ASSOC_TYPE_WORKFLOW_STAGE); - if (!is_numeric($stageId)) { - return AuthorizationPolicy::AUTHORIZATION_DENY; - } - - $stageAssignmentDao = DAORegistry::getDAO('StageAssignmentDAO'); /** @var StageAssignmentDAO $stageAssignmentDao */ - if ($stageAssignmentDao->editorAssignedToStage($submission->getId(), $stageId)) { - return AuthorizationPolicy::AUTHORIZATION_PERMIT; - } else { - return AuthorizationPolicy::AUTHORIZATION_DENY; - } - } -} - -if (!PKP_STRICT_MODE) { - class_alias('\PKP\security\authorization\internal\ManagerRequiredPolicy', '\ManagerRequiredPolicy'); -} diff --git a/classes/security/authorization/internal/SubmissionFileRequestedRevisionRequiredPolicy.inc.php b/classes/security/authorization/internal/SubmissionFileRequestedRevisionRequiredPolicy.inc.php index 3b3716699ed..21ac06bd0bb 100644 --- a/classes/security/authorization/internal/SubmissionFileRequestedRevisionRequiredPolicy.inc.php +++ b/classes/security/authorization/internal/SubmissionFileRequestedRevisionRequiredPolicy.inc.php @@ -16,10 +16,12 @@ namespace PKP\security\authorization\internal; -use APP\workflow\EditorDecisionActionsManager; +use APP\decision\Decision; +use APP\facades\Repo; use PKP\db\DAORegistry; use PKP\security\authorization\AuthorizationPolicy; +use PKP\submission\reviewRound\ReviewRound; use PKP\submissionFile\SubmissionFile; class SubmissionFileRequestedRevisionRequiredPolicy extends SubmissionFileBaseAccessPolicy @@ -61,7 +63,14 @@ public function effect() if (!$reviewRound instanceof ReviewRound) { return AuthorizationPolicy::AUTHORIZATION_DENY; } - if (!(new EditorDecisionActionsManager())->getEditorTakenActionInReviewRound($request->getContext(), $reviewRound, [EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_PENDING_REVISIONS])) { + $countRevisionDecisions = Repo::decision()->getCount( + Repo::decision() + ->getCollector() + ->filterBySubmissionIds([$submissionFile->getData('submissionId)')]) + ->filterByReviewRoundIds([$reviewRound->getId()]) + ->filterByDecisionTypes([Decision::PENDING_REVISIONS]) + ); + if (!$countRevisionDecisions) { return AuthorizationPolicy::AUTHORIZATION_DENY; } @@ -79,14 +88,19 @@ public function effect() $reviewRoundDao = DAORegistry::getDAO('ReviewRoundDAO'); /** @var ReviewRoundDAO $reviewRoundDao */ // 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()); - if (empty($reviewRoundDecisions)) { + $reviewRoundDecisions = Repo::decision()->getMany( + Repo::decision() + ->getCollector() + ->filterBySubmissionIds([$submissionFile->getData('submissionId')]) + ->filterByStageIds([$reviewRound->getStageId()]) + ->filterByReviewRoundIds([$reviewRound->getId()]) + ); + if ($reviewRoundDecisions->isEmpty()) { return AuthorizationPolicy::AUTHORIZATION_DENY; } - $lastEditorDecision = array_pop($reviewRoundDecisions); - if ($lastEditorDecision['decision'] != EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_PENDING_REVISIONS) { + $lastEditorDecision = $reviewRoundDecisions->last(); + if ($lastEditorDecision->getData('decision') != Decision::PENDING_REVISIONS) { return AuthorizationPolicy::AUTHORIZATION_DENY; } diff --git a/classes/security/authorization/internal/SubmissionFileStageAccessPolicy.inc.php b/classes/security/authorization/internal/SubmissionFileStageAccessPolicy.inc.php index b5a5235ca13..3213aa3e85e 100644 --- a/classes/security/authorization/internal/SubmissionFileStageAccessPolicy.inc.php +++ b/classes/security/authorization/internal/SubmissionFileStageAccessPolicy.inc.php @@ -16,8 +16,8 @@ namespace PKP\security\authorization\internal; +use APP\decision\Decision; use APP\facades\Repo; -use APP\workflow\EditorDecisionActionsManager; use PKP\db\DAORegistry; use PKP\security\authorization\AuthorizationPolicy; use PKP\security\authorization\SubmissionFileAccessPolicy; @@ -103,18 +103,21 @@ public function effect() $reviewRoundDao = DAORegistry::getDAO('ReviewRoundDAO'); /** @var ReviewRoundDAO $reviewRoundDao */ $reviewRound = $reviewRoundDao->getLastReviewRoundBySubmissionId($submission->getId(), $reviewStage); if ($reviewRound) { - $editDecisionDao = DAORegistry::getDAO('EditDecisionDAO'); /** @var EditDecisionDAO $editDecisionDao */ - $decisions = $editDecisionDao->getEditorDecisions($submission->getId(), $reviewRound->getStageId(), $reviewRound->getRound()); - if (!empty($decisions)) { - foreach ($decisions as $decision) { - if ($decision['decision'] == EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_ACCEPT - || $decision['decision'] == EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_PENDING_REVISIONS - || $decision['decision'] == EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_NEW_ROUND - || $decision['decision'] == EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_RESUBMIT) { - $assignedFileStages[] = $this->_fileStage; - break; - } - } + $countDecisions = Repo::decision()->getCount( + Repo::decision() + ->getCollector() + ->filterBySubmissionIds([$submission->getId()]) + ->filterByStageIds([$reviewRound->getStageId()]) + ->filterByReviewRoundIds([$reviewRound->getId()]) + ->filterByDecisionTypes([ + Decision::ACCEPT, + Decision::PENDING_REVISIONS, + Decision::NEW_ROUND, + Decision::RESUBMIT + ]) + ); + if ($countDecisions) { + $assignedFileStages[] = $this->_fileStage; } } } diff --git a/classes/services/PKPContextService.inc.php b/classes/services/PKPContextService.inc.php index 43b579c44c6..25140d8a313 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 b7a4e119e63..91c95f47b98 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'; @@ -304,7 +307,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); } /** @@ -404,34 +407,13 @@ public function addPropValidationRules($rules, $ruleKey, $propSchema) * ], * bar: ['Error message'], * ] - * - * @param \Illuminate\Support\MessageBag $errorBag - * @param object $schema 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 ac12a9dcb80..18787bd64dd 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/services/PKPStatsEditorialService.inc.php b/classes/services/PKPStatsEditorialService.inc.php index d206495e6bd..c6605e36b5d 100644 --- a/classes/services/PKPStatsEditorialService.inc.php +++ b/classes/services/PKPStatsEditorialService.inc.php @@ -16,7 +16,7 @@ namespace PKP\services; -use APP\workflow\EditorDecisionActionsManager; +use APP\decision\Decision; class PKPStatsEditorialService { @@ -32,9 +32,9 @@ public function getOverview($args = []) \AppLocale::requireComponents(LOCALE_COMPONENT_PKP_MANAGER, LOCALE_COMPONENT_APP_MANAGER); $received = $this->countSubmissionsReceived($args); - $accepted = $this->countByDecisions(EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_ACCEPT, $args); - $declinedDesk = $this->countByDecisions(EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_INITIAL_DECLINE, $args); - $declinedReview = $this->countByDecisions(EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_DECLINE, $args); + $accepted = $this->countByDecisions(Decision::ACCEPT, $args); + $declinedDesk = $this->countByDecisions(Decision::INITIAL_DECLINE, $args); + $declinedReview = $this->countByDecisions(Decision::DECLINE, $args); $declined = $declinedDesk + $declinedReview; // Calculate the acceptance/decline rates @@ -58,9 +58,9 @@ public function getOverview($args = []) // within the date range that were accepted or declined. This // excludes submissions that were made within the date range but // have not yet been accepted or declined. - $acceptedForSubmissionDate = $this->countByDecisionsForSubmittedDate(EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_ACCEPT, $args); - $declinedDeskForSubmissionDate = $this->countByDecisionsForSubmittedDate(EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_INITIAL_DECLINE, $args); - $declinedReviewForSubmissionDate = $this->countByDecisionsForSubmittedDate(EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_DECLINE, $args); + $acceptedForSubmissionDate = $this->countByDecisionsForSubmittedDate(Decision::ACCEPT, $args); + $declinedDeskForSubmissionDate = $this->countByDecisionsForSubmittedDate(Decision::INITIAL_DECLINE, $args); + $declinedReviewForSubmissionDate = $this->countByDecisionsForSubmittedDate(Decision::DECLINE, $args); $totalDecidedForSubmissionDate = $acceptedForSubmissionDate + $declinedDeskForSubmissionDate + $declinedReviewForSubmissionDate; // Never divide by 0 @@ -80,8 +80,8 @@ public function getOverview($args = []) // Calculate the number of days it took for most submissions to // receive decisions $firstDecisionDays = $this->getDaysToDecisions([], $args); - $acceptDecisionDays = $this->getDaysToDecisions([EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_SEND_TO_PRODUCTION, EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_ACCEPT], $args); - $declineDecisionDays = $this->getDaysToDecisions([EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_DECLINE, EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_INITIAL_DECLINE], $args); + $acceptDecisionDays = $this->getDaysToDecisions($this->getAcceptedDecisions(), $args); + $declineDecisionDays = $this->getDaysToDecisions($this->getDeclinedDecisions(), $args); $firstDecisionDaysRate = empty($firstDecisionDays) ? 0 : $this->calculateDaysToDecisionRate($firstDecisionDays, 0.8); $acceptDecisionDaysRate = empty($acceptDecisionDays) ? 0 : $this->calculateDaysToDecisionRate($acceptDecisionDays, 0.8); $declineDecisionDaysRate = empty($declineDecisionDays) ? 0 : $this->calculateDaysToDecisionRate($declineDecisionDays, 0.8); @@ -209,10 +209,10 @@ public function getAverages($args = []) // Editorial decisions (accepted and declined) $decisionsList = [ - 'submissionsAccepted' => [EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_ACCEPT], - 'submissionsDeclined' => [EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_INITIAL_DECLINE, EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_DECLINE], - 'submissionsDeclinedDeskReject' => [EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_INITIAL_DECLINE], - 'submissionsDeclinedPostReview' => [EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_DECLINE], + 'submissionsAccepted' => [Decision::ACCEPT], + 'submissionsDeclined' => [Decision::INITIAL_DECLINE, Decision::DECLINE], + 'submissionsDeclinedDeskReject' => [Decision::INITIAL_DECLINE], + 'submissionsDeclinedPostReview' => [Decision::DECLINE], ]; $yearlyDecisions = []; foreach ($decisionsList as $key => $decisions) { @@ -461,4 +461,42 @@ protected function getQueryBuilder($args = []) return $qb; } + + /** + * Get the decisions that indicate a submission has been accepted + * + * Decision::SEND_TO_PRODUCTION is included + * in order to catch submissions that do not have an accept decision recorded, but have + * still made it to the production stage. Once a SEND_TO_PRODUCTION decision has been + * recorded, we assume the submission has been accepted for the purposes of statistics. + * + * This list only applies to editorial statistics. This method should not be used to + * identify acceptance decisions for any other purpose. + * + * @return int[] SUBMISSION_EDITOR_DECISION_ constants + */ + protected function getAcceptedDecisions(): array + { + return [ + Decision::ACCEPT, + Decision::SKIP_REVIEW, + Decision::SEND_TO_PRODUCTION, + ]; + } + + /** + * Get the decisions that indicate a submission has been declined + * + * This distinction only applies to editorial statistics. This method should not be used to + * identify declined decisions for any other purpose. + * + * @return int[] SUBMISSION_EDITOR_DECISION_ constants + */ + protected function getDeclinedDecisions(): array + { + return [ + Decision::DECLINE, + Decision::INITIAL_DECLINE, + ]; + } } diff --git a/classes/services/queryBuilders/PKPStatsEditorialQueryBuilder.inc.php b/classes/services/queryBuilders/PKPStatsEditorialQueryBuilder.inc.php index be26ce5a52c..375fe8f8bff 100644 --- a/classes/services/queryBuilders/PKPStatsEditorialQueryBuilder.inc.php +++ b/classes/services/queryBuilders/PKPStatsEditorialQueryBuilder.inc.php @@ -16,7 +16,7 @@ namespace PKP\services\queryBuilders; -use APP\workflow\EditorDecisionActionsManager; +use APP\decision\Decision; use Illuminate\Support\Facades\DB; @@ -157,8 +157,8 @@ public function countByDecisions($decisions, $forSubmittedDate = false) // exclude submissions where the status doesn't match the // decisions we are looking for. $declineDecisions = [ - EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_DECLINE, - EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_INITIAL_DECLINE + Decision::DECLINE, + Decision::INITIAL_DECLINE ]; if (count(array_intersect($declineDecisions, $decisions))) { $q->where('s.status', '=', PKPSubmission::STATUS_DECLINED); @@ -330,8 +330,8 @@ public function getDecisionsDates($decisions) // exclude submissions where the status doesn't match the // decisions we are looking for. $declineDecisions = [ - EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_DECLINE, - EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_INITIAL_DECLINE + Decision::DECLINE, + Decision::INITIAL_DECLINE ]; if (count(array_intersect($declineDecisions, $decisions))) { $q->where('s.status', '=', PKPSubmission::STATUS_DECLINED); diff --git a/classes/stageAssignment/StageAssignmentDAO.inc.php b/classes/stageAssignment/StageAssignmentDAO.inc.php index 337643cdc93..178f0d061b6 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 ffd74233ad6..68252b443a2 100644 --- a/classes/submission/DAO.inc.php +++ b/classes/submission/DAO.inc.php @@ -247,8 +247,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 deleted file mode 100644 index 8e783999a1e..00000000000 --- a/classes/submission/EditDecisionDAO.inc.php +++ /dev/null @@ -1,219 +0,0 @@ -update( - sprintf( - 'INSERT INTO edit_decisions - (submission_id, review_round_id, stage_id, round, editor_id, decision, date_decided) - VALUES (?, ?, ?, ?, ?, ?, %s)', - $this->datetimeToDB($editorDecision['dateDecided']) - ), - [ - (int) $submissionId, - $reviewRound instanceof ReviewRound ? (int) $reviewRound->getId() : 0, - $reviewRound instanceof ReviewRound ? $reviewRound->getStageId() : (int) $stageId, - $reviewRound instanceof ReviewRound ? (int) $reviewRound->getRound() : REVIEW_ROUND_NONE, - (int) $editorDecision['editorId'], - $editorDecision['decision'] - ] - ); - } - } - - /** - * Delete editing decisions by submission ID. - * - * @param int $submissionId - */ - public function deleteDecisionsBySubmissionId($submissionId) - { - return $this->update( - 'DELETE FROM edit_decisions WHERE submission_id = ?', - [(int) $submissionId] - ); - } - - /** - * Get the editor decisions for a review round of a submission. - * - * @param int $submissionId Submission ID - * @param int $stageId Optional STAGE_ID_... - * @param int $round Optional review round number - * @param int $editorId 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) - { - $params = [(int) $submissionId]; - if ($stageId) { - $params[] = (int) $stageId; - } - if ($round) { - $params[] = (int) $round; - } - if ($editorId) { - $params[] = (int) $editorId; - } - - $result = $this->retrieve( - 'SELECT edit_decision_id, editor_id, decision, - date_decided, review_round_id, stage_id, round - FROM edit_decisions - WHERE submission_id = ? - ' . ($stageId ? ' AND stage_id = ?' : '') . ' - ' . ($round ? ' AND round = ?' : '') . ' - ' . ($editorId ? ' AND editor_id = ?' : '') . ' - ORDER BY date_decided ASC', - $params - ); - - $decisions = []; - foreach ($result as $row) { - $decisions[] = [ - 'editDecisionId' => $row->edit_decision_id, - 'reviewRoundId' => $row->review_round_id, - 'stageId' => $row->stage_id, - 'round' => $row->round, - 'editorId' => $row->editor_id, - 'decision' => $row->decision, - 'dateDecided' => $this->datetimeFromDB($row->date_decided) - ]; - } - return $decisions; - } - - /** - * Transfer the decisions for an editor. - * - * @param int $oldUserId - * @param int $newUserId - */ - public function transferEditorDecisions($oldUserId, $newUserId) - { - $this->update( - 'UPDATE edit_decisions SET editor_id = ? WHERE editor_id = ?', - [(int) $newUserId, (int) $oldUserId] - ); - } - - /** - * Find any still valid pending revisions decision for the passed - * submission id. A valid decision is one that is not overriden by any - * other decision. - * - * @param int $submissionId - * @param int $expectedStageId - * @param int $revisionDecision SUBMISSION_EDITOR_DECISION_PENDING_REVISIONS or SUBMISSION_EDITOR_DECISION_RESUBMIT - * - * @return mixed array or null - */ - public function findValidPendingRevisionsDecision($submissionId, $expectedStageId, $revisionDecision = EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_PENDING_REVISIONS) - { - $postReviewDecisions = [EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_SEND_TO_PRODUCTION]; - $revisionDecisions = [EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_PENDING_REVISIONS, EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_RESUBMIT]; - if (!in_array($revisionDecision, $revisionDecisions)) { - return null; - } - - $editDecisionDao = DAORegistry::getDAO('EditDecisionDAO'); /** @var EditDecisionDAO $editDecisionDao */ - $editorDecisions = $editDecisionDao->getEditorDecisions($submissionId); - $workingDecisions = array_reverse($editorDecisions); - $pendingRevisionDecision = null; - - foreach ($workingDecisions as $decision) { - if (in_array($decision['decision'], $postReviewDecisions)) { - // Decisions at later stages do not override the pending revisions one. - continue; - } elseif ($decision['decision'] == $revisionDecision) { - if ($decision['stageId'] == $expectedStageId) { - $pendingRevisionDecision = $decision; - // Only the last pending revisions decision is relevant. - break; - } else { - // Both internal and external pending revisions decisions are - // valid at the same time. Continue to search. - continue; - } - } else { - break; - } - } - - return $pendingRevisionDecision; - } - - /** - * Find any file upload that's a revision and can be considered as - * a pending revisions decision response. - * - * @param array $decision - * @param int $submissionId - * - * @return bool - */ - public function responseExists($decision, $submissionId) - { - $stageId = $decision['stageId']; - $round = $decision['round']; - $sentRevisions = false; - - $reviewRoundDao = DAORegistry::getDAO('ReviewRoundDAO'); /** @var ReviewRoundDAO $reviewRoundDao */ - $reviewRound = $reviewRoundDao->getReviewRound($submissionId, $stageId, $round); - - $submissionFileCollector = Repo::submissionFile() - ->getCollector() - ->filterByReviewRoundIds([$reviewRound->getId()]) - ->filterByFileStages([SubmissionFile::SUBMISSION_FILE_REVIEW_REVISION]); - - $submissionFilesIterator = Repo::submissionFile()->getMany($submissionFileCollector); - - foreach ($submissionFilesIterator as $submissionFile) { - if ($submissionFile->getData('updatedAt') > $decision['dateDecided']) { - $sentRevisions = true; - break; - } - } - - return $sentRevisions; - } -} - -if (!PKP_STRICT_MODE) { - class_alias('\PKP\submission\EditDecisionDAO', '\EditDecisionDAO'); -} diff --git a/classes/submission/Repository.inc.php b/classes/submission/Repository.inc.php index bd61a5a554a..f62c7d5c760 100644 --- a/classes/submission/Repository.inc.php +++ b/classes/submission/Repository.inc.php @@ -278,7 +278,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]); @@ -365,7 +365,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(); } @@ -377,7 +377,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); } @@ -389,7 +389,7 @@ public function delete(Submission $submission) $this->dao->delete($submission); - HookRegistry::call('Submission::delete', [&$submission]); + HookRegistry::call('Submission::delete', [$submission]); } /** @@ -405,70 +405,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); } } @@ -548,4 +521,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/action/EditorAction.inc.php b/classes/submission/action/EditorAction.inc.php index e0939c5f3fe..4cd8b8c5f24 100644 --- a/classes/submission/action/EditorAction.inc.php +++ b/classes/submission/action/EditorAction.inc.php @@ -16,11 +16,9 @@ namespace PKP\submission\action; use APP\facades\Repo; -use APP\i18n\AppLocale; use APP\notification\Notification; use APP\notification\NotificationManager; use APP\submission\Submission; -use APP\workflow\EditorDecisionActionsManager; use Illuminate\Mail\Mailable; use Illuminate\Support\Facades\Mail; @@ -55,74 +53,6 @@ public function __construct() // // Actions. // - /** - * Records an editor's submission decision. - * - * @param PKPRequest $request - * @param Submission $submission - * @param int $decision - * @param array $decisionLabels array(SUBMISSION_EDITOR_DECISION_... or SUBMISSION_EDITOR_RECOMMEND_... => editor.submission.decision....) - * @param ReviewRound $reviewRound optional Current review round that user is taking the decision, if any. - * @param int $stageId optional - * @param bool $recommendation optional - */ - public function recordDecision($request, $submission, $decision, $decisionLabels, $reviewRound = null, $stageId = null, $recommendation = false) - { - $stageAssignmentDao = DAORegistry::getDAO('StageAssignmentDAO'); /** @var StageAssignmentDAO $stageAssignmentDao */ - - // Define the stage and round data. - if (!is_null($reviewRound)) { - $stageId = $reviewRound->getStageId(); - } else { - if ($stageId == null) { - // We consider that the decision is being made for the current - // submission stage. - $stageId = $submission->getStageId(); - } - } - - $editorAssigned = $stageAssignmentDao->editorAssignedToStage( - $submission->getId(), - $stageId - ); - - // Sanity checks - if (!$editorAssigned || !isset($decisionLabels[$decision])) { - return false; - } - - $user = $request->getUser(); - $editorDecision = [ - 'editDecisionId' => null, - 'editorId' => $user->getId(), - 'decision' => $decision, - 'dateDecided' => date(Core::getCurrentDate()) - ]; - - $result = $editorDecision; - if (!HookRegistry::call('EditorAction::recordDecision', [&$submission, &$editorDecision, &$result, &$recommendation])) { - // Record the new decision - $editDecisionDao = DAORegistry::getDAO('EditDecisionDAO'); /** @var EditDecisionDAO $editDecisionDao */ - $editDecisionDao->updateEditorDecision($submission->getId(), $editorDecision, $stageId, $reviewRound); - - // Set a new submission status if necessary - if ($decision == EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_DECLINE || $decision == EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_INITIAL_DECLINE) { - Repo::submission()->edit($submission, ['status' => Submission::STATUS_DECLINED]); - $submission = Repo::submission()->get($submission->getId()); - } elseif ($submission->getStatus() == PKPSubmission::STATUS_DECLINED) { - Repo::submission()->edit($submission, ['status' => Submission::STATUS_QUEUED]); - $submission = Repo::submission()->get($submission->getId()); - } - - // Add log entry - AppLocale::requireComponents(LOCALE_COMPONENT_APP_COMMON, LOCALE_COMPONENT_APP_EDITOR); - $eventType = $recommendation ? PKPSubmissionEventLogEntry::SUBMISSION_LOG_EDITOR_RECOMMENDATION : PKPSubmissionEventLogEntry::SUBMISSION_LOG_EDITOR_DECISION; - $logKey = $recommendation ? 'log.editor.recommendation' : 'log.editor.decision'; - SubmissionLog::logEvent($request, $submission, $eventType, $logKey, ['editorName' => $user->getFullName(), 'submissionId' => $submission->getId(), 'decision' => __($decisionLabels[$decision])]); - } - return $result; - } - /** * Assigns a reviewer to a submission. @@ -262,17 +192,6 @@ public function setDueDates($request, $submission, $reviewAssignment, $reviewDue } } - /** - * Increment a submission's workflow stage. - * - * @param Submission $submission - * @param int $newStage One of the WORKFLOW_STAGE_* constants. - */ - public function incrementWorkflowStage($submission, $newStage) - { - Repo::submission()->edit($submission, ['stageId' => $newStage]); - } - protected function createMail( PKPSubmission $submission, ReviewAssignment $reviewAssignment, diff --git a/classes/submission/form/PKPSubmissionSubmitStep2Form.inc.php b/classes/submission/form/PKPSubmissionSubmitStep2Form.inc.php index 9b00e208f58..39db172a9ce 100644 --- a/classes/submission/form/PKPSubmissionSubmitStep2Form.inc.php +++ b/classes/submission/form/PKPSubmissionSubmitStep2Form.inc.php @@ -17,6 +17,7 @@ use APP\core\Application; use APP\facades\Repo; +use APP\i18n\AppLocale; use APP\template\TemplateManager; use PKP\core\PKPApplication; @@ -55,6 +56,7 @@ public function fetch($request, $template = null, $display = false) $fileUploadApiUrl = ''; $submissionFiles = []; + $submissionLocale = AppLocale::getLocale(); if ($this->submission) { $fileUploadApiUrl = $request->getDispatcher()->url( $request, @@ -64,17 +66,17 @@ public function fetch($request, $template = null, $display = false) ); $submissionFileForm = new \PKP\components\forms\submission\PKPSubmissionFileForm($fileUploadApiUrl, $genres); - $submissionFilesCollector = Repo::submissionFile() + $collector = Repo::submissionFile() ->getCollector() ->filterBySubmissionIds([$this->submission->getId()]) ->filterByFileStages([SubmissionFile::SUBMISSION_FILE_SUBMISSION]); - $submissionFilesIterator = Repo::submissionFile() - ->getMany($submissionFilesCollector); + $submissionFiles = Repo::submissionFile() + ->getMany($collector); $submissionFiles = Repo::submissionFile() ->getSchemaMap() - ->summarizeMany($submissionFilesIterator); + ->summarizeMany($submissionFiles, $genres); } $templateMgr = TemplateManager::getManager($request); @@ -127,6 +129,7 @@ public function fetch($request, $template = null, $display = false) 'dropzoneDictMaxFilesExceeded' => __('form.dropzone.dictMaxFilesExceeded'), ], 'otherLabel' => __('about.other'), + 'primaryLocale' => $request->getContext()->getPrimaryLocale(), 'removeConfirmLabel' => __('submission.submit.removeConfirm'), 'stageId' => WORKFLOW_STAGE_ID_SUBMISSION, 'title' => __('submission.files'), diff --git a/classes/submission/form/PKPSubmissionSubmitStep4Form.inc.php b/classes/submission/form/PKPSubmissionSubmitStep4Form.inc.php index 44b45654392..4f7b82e7c29 100644 --- a/classes/submission/form/PKPSubmissionSubmitStep4Form.inc.php +++ b/classes/submission/form/PKPSubmissionSubmitStep4Form.inc.php @@ -19,7 +19,6 @@ use APP\facades\Repo; use APP\notification\Notification; use APP\notification\NotificationManager; -use APP\workflow\EditorDecisionActionsManager; use PKP\core\Core; use PKP\db\DAORegistry; @@ -148,7 +147,7 @@ public function execute(...$functionArgs) // Update assignment notifications $notificationManager->updateNotification( $request, - (new EditorDecisionActionsManager())->getStageNotifications(), + $notificationManager->getDecisionStageNotifications(), null, ASSOC_TYPE_SUBMISSION, $this->submission->getId() diff --git a/classes/submission/maps/Schema.inc.php b/classes/submission/maps/Schema.inc.php index 982364daa2a..d79a030450c 100644 --- a/classes/submission/maps/Schema.inc.php +++ b/classes/submission/maps/Schema.inc.php @@ -25,7 +25,9 @@ use PKP\db\DAORegistry; use PKP\plugins\HookRegistry; use PKP\plugins\PluginRegistry; +use PKP\security\UserGroup; use PKP\services\PKPSchemaService; +use PKP\submission\Genre; use PKP\submission\reviewAssignment\ReviewAssignment; use PKP\submissionFile\SubmissionFile; @@ -37,9 +39,12 @@ class Schema extends \PKP\core\maps\Schema /** @copydoc \PKP\core\maps\Schema::$schema */ public string $schema = PKPSchemaService::SCHEMA_SUBMISSION; - /** @var array The user groups for this context. */ + /** @var UserGroup[] The user groups for this context. */ public array $userGroups; + /** @var Genre[] The genres for this context. */ + public array $genres; + public function __construct(Request $request, Context $context, PKPSchemaService $schemaService) { AppLocale::requireComponents( @@ -87,10 +92,14 @@ protected function getSubmissionsListProps(): array * Map a submission * * Includes all properties in the submission schema. + * + * @param UserGroup[] $userGroups The user groups in this context + * @param Genre[] $genres The file genres in this context */ - public function map(Submission $item, array $userGroups): array + public function map(Submission $item, array $userGroups, array $genres): array { $this->userGroups = $userGroups; + $this->genres = $genres; return $this->mapByProperties($this->getProps(), $item); } @@ -98,10 +107,14 @@ public function map(Submission $item, array $userGroups): array * Summarize a submission * * Includes properties with the apiSummary flag in the submission schema. + * + * @param UserGroup[] $userGroups The user groups in this context + * @param Genres[] $genres The file genres in this context */ - public function summarize(Submission $item, array $userGroups): array + public function summarize(Submission $item, array $userGroups, array $genres): array { $this->userGroups = $userGroups; + $this->genres = $genres; return $this->mapByProperties($this->getSummaryProps(), $item); } @@ -109,13 +122,17 @@ public function summarize(Submission $item, array $userGroups): array * Map a collection of Submissions * * @see self::map + * + * @param UserGroup[] $userGroups The user groups in this context + * @param Genres[] $genres The file genres in this context */ - public function mapMany(Enumerable $collection, array $userGroups): Enumerable + public function mapMany(Enumerable $collection, array $userGroups, array $genres): Enumerable { $this->collection = $collection; $this->userGroups = $userGroups; + $this->genres = $genres; return $collection->map(function ($item) { - return $this->map($item, $this->userGroups); + return $this->map($item, $this->userGroups, $this->genres); }); } @@ -123,22 +140,30 @@ public function mapMany(Enumerable $collection, array $userGroups): Enumerable * Summarize a collection of Submissions * * @see self::summarize + * + * @param UserGroup[] $userGroups The user groups in this context + * @param Genres[] $genres The file genres in this context */ - public function summarizeMany(Enumerable $collection, array $userGroups): Enumerable + public function summarizeMany(Enumerable $collection, array $userGroups, array $genres): Enumerable { $this->collection = $collection; $this->userGroups = $userGroups; + $this->genres = $genres; return $collection->map(function ($item) { - return $this->summarize($item, $this->userGroups); + return $this->summarize($item, $this->userGroups, $this->genres); }); } /** * Map a submission with extra properties for the submissions list + * + * @param UserGroup[] $userGroups The user groups in this context + * @param Genres[] $genres The file genres in this context */ - public function mapToSubmissionsList(Submission $item, array $userGroups): array + public function mapToSubmissionsList(Submission $item, array $userGroups, array $genres): array { $this->userGroups = $userGroups; + $this->genres = $genres; return $this->mapByProperties($this->getSubmissionsListProps(), $item); } @@ -146,13 +171,17 @@ public function mapToSubmissionsList(Submission $item, array $userGroups): array * Map a collection of submissions with extra properties for the submissions list * * @see self::map + * + * @param UserGroup[] $userGroups The user groups in this context + * @param Genres[] $genres The file genres in this context */ - public function mapManyToSubmissionsList(Enumerable $collection, array $userGroups): Enumerable + public function mapManyToSubmissionsList(Enumerable $collection, array $userGroups, array $genres): Enumerable { $this->collection = $collection; $this->userGroups = $userGroups; + $this->genres = $genres; return $collection->map(function ($item) { - return $this->mapToSubmissionsList($item, $this->userGroups); + return $this->mapToSubmissionsList($item, $this->userGroups, $this->genres); }); } @@ -213,7 +242,7 @@ protected function mapByProperties(array $props, Submission $submission): array ); break; case 'publications': - $output[$prop] = Repo::publication()->getSchemaMap($submission, $this->userGroups) + $output[$prop] = Repo::publication()->getSchemaMap($submission, $this->userGroups, $this->genres) ->summarizeMany($submission->getData('publications'), $anonymize); break; case 'reviewAssignments': diff --git a/classes/submission/reviewAssignment/ReviewAssignment.inc.php b/classes/submission/reviewAssignment/ReviewAssignment.inc.php index 5dec95ecf0d..867b80d5618 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 40226d676e1..1413c9e277c 100644 --- a/classes/submission/reviewRound/ReviewRound.inc.php +++ b/classes/submission/reviewRound/ReviewRound.inc.php @@ -20,13 +20,13 @@ namespace PKP\submission\reviewRound; -use APP\workflow\EditorDecisionActionsManager; - +use APP\decision\Decision; +use APP\facades\Repo; use PKP\db\DAORegistry; class ReviewRound extends \PKP\core\DataObject { - // The first four statuses are set explicitly by EditorDecisions, which override + // The first four statuses are set explicitly by Decisions, which override // the current status. public const REVIEW_ROUND_STATUS_REVISIONS_REQUESTED = 1; public const REVIEW_ROUND_STATUS_RESUBMIT_FOR_REVIEW = 2; @@ -149,18 +149,14 @@ public function setStatus($status) */ public function determineStatus() { - // Check if revisions requested or received, if this is latest review round and then check files - $roundStatus = $this->getStatus(); - // If revisions have been requested, check to see if any have been // submitted 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 = Repo::decision()->getActivePendingRevisionsDecision($this->getSubmissionId(), $this->getStageId(), Decision::PENDING_REVISIONS); if ($pendingRevisionDecision) { - if ($editDecisionDao->responseExists($pendingRevisionDecision, $this->getSubmissionId())) { + if (Repo::decision()->revisionsUploadedSinceDecision($pendingRevisionDecision, $this->getSubmissionId())) { return self::REVIEW_ROUND_STATUS_REVISIONS_SUBMITTED; } } @@ -171,11 +167,10 @@ public function determineStatus() // submitted 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 = Repo::decision()->getActivePendingRevisionsDecision($this->getSubmissionId(), $this->getStageId(), Decision::RESUBMIT); if ($pendingRevisionDecision) { - if ($editDecisionDao->responseExists($pendingRevisionDecision, $this->getSubmissionId())) { + if (Repo::decision()->revisionsUploadedSinceDecision($pendingRevisionDecision, $this->getSubmissionId())) { return self::REVIEW_ROUND_STATUS_RESUBMIT_FOR_REVIEW_SUBMITTED; } } @@ -196,7 +191,6 @@ public function determineStatus() // Determine the round status by looking at the recommendOnly editor assignment statuses $stageAssignmentDao = DAORegistry::getDAO('StageAssignmentDAO'); /** @var StageAssignmentDAO $stageAssignmentDao */ - $editDecisionDao = DAORegistry::getDAO('EditDecisionDAO'); /** @var EditDecisionDAO $editDecisionDao */ $pendingRecommendations = false; $recommendationsFinished = true; $recommendationsReady = false; @@ -205,9 +199,15 @@ public function determineStatus() if ($editorsStageAssignment->getRecommendOnly()) { $pendingRecommendations = true; // Get recommendation from the assigned recommendOnly editor - $editorId = $editorsStageAssignment->getUserId(); - $editorRecommendations = $editDecisionDao->getEditorDecisions($this->getSubmissionId(), $this->getStageId(), $this->getRound(), $editorId); - if (empty($editorRecommendations)) { + $decisions = Repo::decision()->getCount( + Repo::decision() + ->getCollector() + ->filterBySubmissionIds([$this->getSubmissionId()]) + ->filterByStageIds([$this->getStageId()]) + ->filterByReviewRoundIds([$this->getId()]) + ->filterByEditorIds([$editorsStageAssignment->getUserId()]) + ); + if (!$decisions) { $recommendationsFinished = false; } else { $recommendationsReady = true; diff --git a/classes/submission/reviewRound/ReviewRoundDAO.inc.php b/classes/submission/reviewRound/ReviewRoundDAO.inc.php index ca7266ff61f..98f9040f047 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/Repository.inc.php b/classes/submissionFile/Repository.inc.php index 8843a9183ae..b212c348170 100644 --- a/classes/submissionFile/Repository.inc.php +++ b/classes/submissionFile/Repository.inc.php @@ -41,17 +41,13 @@ class Repository { - /** @var DAO $dao */ - public $dao; + public DAO $dao; + public string $schemaMap = Schema::class; + protected Request $request; + protected PKPSchemaService $schemaService; - /** @var string $schemaMap The name of the class to map this entity to its schemaa */ - public $schemaMap = Schema::class; - - /** @var Request $request */ - protected $request; - - /** @var PKPSchemaService $schemaService */ - protected $schemaService; + /** @var array $reviewFileStages The file stages that are part of a review workflow stage */ + public array $reviewFileStages = []; public function __construct(DAO $dao, Request $request, PKPSchemaService $schemaService) { @@ -496,6 +492,27 @@ public function edit( ); } + /** + * Copy a submission file to another stage + * + * @return int ID of the new submission file + */ + public function copy(SubmissionFile $submissionFile, int $toFileStage, ?int $reviewRoundId = null): int + { + $newSubmissionFile = clone $submissionFile; + $newSubmissionFile->setData('fileStage', $toFileStage); + $newSubmissionFile->setData('sourceSubmissionFileId', $submissionFile->getId()); + $newSubmissionFile->setData('assocType', null); + $newSubmissionFile->setData('assocId', null); + + if ($reviewRoundId) { + $newSubmissionFile->setData('assocType', Application::ASSOC_TYPE_REVIEW_ROUND); + $newSubmissionFile->setData('assocId', $reviewRoundId); + } + + return Repo::submissionFiles()->add($newSubmissionFile); + } + /** @copydoc DAO::delete() */ public function delete(SubmissionFile $submissionFile): void { diff --git a/classes/submissionFile/SubmissionFile.inc.php b/classes/submissionFile/SubmissionFile.inc.php index 842b9c4418f..2e4f293af14 100644 --- a/classes/submissionFile/SubmissionFile.inc.php +++ b/classes/submissionFile/SubmissionFile.inc.php @@ -16,7 +16,6 @@ namespace PKP\submissionFile; use APP\i18n\AppLocale; -use PKP\core\PKPApplication; // Define the file stage identifiers. @@ -37,6 +36,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/submissionFile/maps/Schema.inc.php b/classes/submissionFile/maps/Schema.inc.php index bb55a49479d..c75a5654754 100644 --- a/classes/submissionFile/maps/Schema.inc.php +++ b/classes/submissionFile/maps/Schema.inc.php @@ -14,28 +14,42 @@ namespace PKP\submissionFile\maps; use APP\core\Application; +use APP\core\Request; use APP\core\Services; use APP\facades\Repo; use Illuminate\Support\Enumerable; +use PKP\context\Context; use PKP\core\maps\Schema as BaseSchema; use PKP\services\PKPSchemaService; +use PKP\submission\Genre; use PKP\submissionFile\SubmissionFile; class Schema extends BaseSchema { - /** @var Enumerable */ + /** */ public Enumerable $collection; - /** @var string */ + /** */ public string $schema = PKPSchemaService::SCHEMA_SUBMISSION_FILE; + /** @var Genre[] File genres in this context */ + public array $genres; + + public function __construct(Request $request, Context $context, PKPSchemaService $schemaService) + { + parent::__construct($request, $context, $schemaService); + } + /** * Map a submission file * * Includes all properties in the submission file schema. + * + * @param Genre[] $genres */ - public function map(SubmissionFile $item): array + public function map(SubmissionFile $item, array $genres): array { + $this->genres = $genres; return $this->mapByProperties($this->getProps(), $item); } @@ -43,9 +57,12 @@ public function map(SubmissionFile $item): array * Summarize a submission file * * Includes properties with the apiSummary flag in the submission file schema. + * + * @param Genre[] $genres */ - public function summarize(SubmissionFile $item): array + public function summarize(SubmissionFile $item, array $genres): array { + $this->genres = $genres; return $this->mapByProperties($this->getSummaryProps(), $item); } @@ -53,12 +70,14 @@ public function summarize(SubmissionFile $item): array * Map a collection of submission files * * @see self::map + * + * @param Genre[] $genres */ - public function mapMany(Enumerable $collection): Enumerable + public function mapMany(Enumerable $collection, array $genres): Enumerable { $this->collection = $collection; - return $collection->map(function ($item) { - return $this->map($item); + return $collection->map(function ($item) use ($genres) { + return $this->map($item, $genres); }); } @@ -66,12 +85,14 @@ public function mapMany(Enumerable $collection): Enumerable * Summarize a collection of submission files * * @see self::summarize + * + * @param Genre[] $genres */ - public function summarizeMany(Enumerable $collection): Enumerable + public function summarizeMany(Enumerable $collection, array $genres): Enumerable { $this->collection = $collection; - return $collection->map(function ($item) { - return $this->summarize($item); + return $collection->map(function ($item) use ($genres) { + return $this->summarize($item, $genres); }); } @@ -101,7 +122,7 @@ protected function mapByProperties(array $props, SubmissionFile $item): array $dependentFiles = Repo::submissionFile()->getMany($collector); - $output[$prop] = $this->summarizeMany($dependentFiles)->values(); + $output[$prop] = $this->summarizeMany($dependentFiles, $this->genres)->values(); continue; } @@ -112,6 +133,25 @@ protected function mapByProperties(array $props, SubmissionFile $item): array continue; } + if ($prop === 'genreIsPrimary') { + $array = array_filter($this->genres, function ($genre) { + /** @var Genre $genre */ + return !$genre->getSupplementary() && !$genre->getDependent(); + }); + $output[$prop] = !empty($array); + continue; + } + + if ($prop === 'genreName') { + foreach ($this->genres as $genre) { + if ($genre->getId() === $item->getData('genreId')) { + $output[$prop] = $genre->getData('name'); + break; + } + } + continue; + } + if ($prop === 'revisions') { $files = []; diff --git a/classes/template/PKPTemplateManager.inc.php b/classes/template/PKPTemplateManager.inc.php index b42556ec359..f60234ce781 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', + ], ]); /** diff --git a/classes/user/Repository.inc.php b/classes/user/Repository.inc.php index d523548565a..c7c6d97b762 100644 --- a/classes/user/Repository.inc.php +++ b/classes/user/Repository.inc.php @@ -318,8 +318,7 @@ public function mergeUsers($oldUserId, $newUserId) $noteDao->updateObject($note); } - $editDecisionDao = DAORegistry::getDAO('EditDecisionDAO'); /** @var EditDecisionDAO $editDecisionDao */ - $editDecisionDao->transferEditorDecisions($oldUserId, $newUserId); + Repo::decision()->dao->reassignDecisions($oldUserId, $newUserId); $reviewAssignmentDao = DAORegistry::getDAO('ReviewAssignmentDAO'); /** @var ReviewAssignmentDAO $reviewAssignmentDao */ foreach ($reviewAssignmentDao->getByUserId($oldUserId) as $reviewAssignment) { diff --git a/classes/user/User.inc.php b/classes/user/User.inc.php index a38aed47dc6..7a9faa2edae 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 009c67c62a4..c51ac6533f4 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; @@ -37,10 +38,8 @@ class ValidatorFactory * @param array $props The properties to validate * @param array $rules The validation rules * @param array $messages Error messages - * - * @return Illuminate\Validation\Validator */ - 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 deleted file mode 100644 index 42e8c71523a..00000000000 --- a/classes/workflow/PKPEditorDecisionActionsManager.inc.php +++ /dev/null @@ -1,191 +0,0 @@ -_submissionStageDecisions($submission, $stageId, $makeDecision); - break; - case WORKFLOW_STAGE_ID_EXTERNAL_REVIEW: - $result = $this->_externalReviewStageDecisions($context, $submission, $makeDecision); - break; - case WORKFLOW_STAGE_ID_EDITING: - $result = $this->_editorialStageDecisions($makeDecision); - break; - default: - assert(false); - } - HookRegistry::call( - 'EditorAction::modifyDecisionOptions', - [$context, $submission, $stageId, &$makeDecision, &$result] - ); - return $result; - } - - /** - * Get an associative array matching editor recommendation codes with locale strings. - * (Includes default '' => "Choose One" string.) - * - * @param int $stageId - * - * @return array recommendation => localeString - */ - public function getRecommendationOptions($stageId) - { - return [ - '' => 'common.chooseOne', - self::SUBMISSION_EDITOR_RECOMMEND_PENDING_REVISIONS => 'editor.submission.decision.requestRevisions', - self::SUBMISSION_EDITOR_RECOMMEND_RESUBMIT => 'editor.submission.decision.resubmit', - self::SUBMISSION_EDITOR_RECOMMEND_ACCEPT => 'editor.submission.decision.accept', - self::SUBMISSION_EDITOR_RECOMMEND_DECLINE => 'editor.submission.decision.decline', - ]; - } - - /** - * Define and return editor decisions for the submission stage. - * If the user cannot make decisions i.e. if it is a recommendOnly user, - * the user can only send the submission to the review stage, and neither - * acept nor decline the submission. - * - * @param Submission $submission - * @param int $stageId WORKFLOW_STAGE_ID_... - * @param bool $makeDecision If the user can make decisions - * - * @return array - */ - protected function _submissionStageDecisions($submission, $stageId, $makeDecision = true) - { - $decisions = [ - EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_EXTERNAL_REVIEW => [ - 'operation' => 'externalReview', - 'name' => 'externalReview', - 'title' => 'editor.submission.decision.sendExternalReview', - 'toStage' => 'editor.review', - ] - ]; - if ($makeDecision) { - if ($stageId == WORKFLOW_STAGE_ID_SUBMISSION) { - $decisions = $decisions + [ - EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_ACCEPT => [ - 'name' => 'accept', - 'operation' => 'promote', - 'title' => 'editor.submission.decision.skipReview', - 'toStage' => 'submission.copyediting', - ], - ]; - } - - if ($submission->getStatus() == PKPSubmission::STATUS_QUEUED) { - $decisions = $decisions + [ - self::SUBMISSION_EDITOR_DECISION_INITIAL_DECLINE => [ - 'name' => 'decline', - 'operation' => 'sendReviews', - 'title' => 'editor.submission.decision.decline', - ], - ]; - } - if ($submission->getStatus() == PKPSubmission::STATUS_DECLINED) { - $decisions = $decisions + [ - self::SUBMISSION_EDITOR_DECISION_REVERT_DECLINE => [ - 'name' => 'revert', - 'operation' => 'revertDecline', - 'title' => 'editor.submission.decision.revertDecline', - ], - ]; - } - } - return $decisions; - } - - /** - * Define and return editor decisions for the editorial stage. - * Currently it does not matter if the user cannot make decisions - * i.e. if it is a recommendOnly user for this stage. - * - * @param bool $makeDecision If the user cannot make decisions - * - * @return array - */ - protected function _editorialStageDecisions($makeDecision = true) - { - return [ - EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_SEND_TO_PRODUCTION => [ - 'operation' => 'promote', - 'name' => 'sendToProduction', - 'title' => 'editor.submission.decision.sendToProduction', - 'toStage' => 'submission.production', - ], - ]; - } - - /** - * Get the stage-level notification type constants. - * - * @return array - */ - public function getStageNotifications() - { - return [ - PKPNotification::NOTIFICATION_TYPE_EDITOR_ASSIGNMENT_SUBMISSION, - PKPNotification::NOTIFICATION_TYPE_EDITOR_ASSIGNMENT_EXTERNAL_REVIEW, - PKPNotification::NOTIFICATION_TYPE_EDITOR_ASSIGNMENT_EDITING, - PKPNotification::NOTIFICATION_TYPE_EDITOR_ASSIGNMENT_PRODUCTION - ]; - } -} - -if (!PKP_STRICT_MODE) { - class_alias('\PKP\workflow\PKPEditorDecisionActionsManager', '\PKPEditorDecisionActionsManager'); - foreach ([ - 'SUBMISSION_EDITOR_DECISION_INITIAL_DECLINE', - 'SUBMISSION_EDITOR_RECOMMEND_ACCEPT', - 'SUBMISSION_EDITOR_RECOMMEND_PENDING_REVISIONS', - 'SUBMISSION_EDITOR_RECOMMEND_RESUBMIT', - 'SUBMISSION_EDITOR_RECOMMEND_DECLINE', - 'SUBMISSION_EDITOR_DECISION_REVERT_DECLINE', - ] as $constantName) { - if (!defined($constantName)) { - define($constantName, constant('\PKPEditorDecisionActionsManager::' . $constantName)); - } - } -} diff --git a/controllers/grid/eventLog/SubmissionEventLogGridHandler.inc.php b/controllers/grid/eventLog/SubmissionEventLogGridHandler.inc.php index 8eece18c345..79994de097b 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/grid/files/attachment/EditorSelectableReviewAttachmentsGridHandler.inc.php b/controllers/grid/files/attachment/EditorSelectableReviewAttachmentsGridHandler.inc.php deleted file mode 100644 index 2b59e4eaa9d..00000000000 --- a/controllers/grid/files/attachment/EditorSelectableReviewAttachmentsGridHandler.inc.php +++ /dev/null @@ -1,66 +0,0 @@ -addRoleAssignment( - [Role::ROLE_ID_MANAGER, Role::ROLE_ID_SUB_EDITOR, Role::ROLE_ID_ASSISTANT], - ['fetchGrid', 'fetchRow'] - ); - - // Set the grid title. - $this->setTitle('grid.reviewAttachments.send.title'); - } - - /** - * @copydoc GridHandler::isDataElementSelected() - */ - public function isDataElementSelected($gridDataElement) - { - $file = $gridDataElement['submissionFile']; - switch ($file->getFileStage()) { - case SubmissionFile::SUBMISSION_FILE_ATTACHMENT: return true; - case SubmissionFile::SUBMISSION_FILE_REVIEW_FILE: return false; - } - return $file->getViewable(); - } - - /** - * @copydoc SelectableFileListGridHandler::getSelectName() - */ - public function getSelectName() - { - return 'selectedAttachments'; - } -} diff --git a/controllers/grid/files/copyedit/SelectableCopyeditFilesGridHandler.inc.php b/controllers/grid/files/copyedit/SelectableCopyeditFilesGridHandler.inc.php deleted file mode 100644 index 675ea596056..00000000000 --- a/controllers/grid/files/copyedit/SelectableCopyeditFilesGridHandler.inc.php +++ /dev/null @@ -1,56 +0,0 @@ -addRoleAssignment( - [Role::ROLE_ID_MANAGER, Role::ROLE_ID_SUB_EDITOR, Role::ROLE_ID_ASSISTANT], - ['fetchGrid', 'fetchRow'] - ); - - // Set the grid title. - $this->setTitle('submission.copyedited'); - } - - // - // Implemented methods from GridHandler. - // - /** - * @copydoc GridHandler::isDataElementSelected() - */ - public function isDataElementSelected($gridDataElement) - { - return true; - } -} diff --git a/controllers/grid/files/final/SelectableFinalDraftFilesGridHandler.inc.php b/controllers/grid/files/final/SelectableFinalDraftFilesGridHandler.inc.php deleted file mode 100644 index 72533ffaf64..00000000000 --- a/controllers/grid/files/final/SelectableFinalDraftFilesGridHandler.inc.php +++ /dev/null @@ -1,54 +0,0 @@ -addRoleAssignment( - [Role::ROLE_ID_MANAGER, Role::ROLE_ID_SUB_EDITOR, Role::ROLE_ID_ASSISTANT], - ['fetchGrid', 'fetchRow'] - ); - - // Set the grid title. - $this->setTitle('submission.finalDraft'); - } - - // - // Implemented methods from GridHandler. - // - /** - * @copydoc GridHandler::isDataElementSelected() - */ - public function isDataElementSelected($gridDataElement) - { - return false; - } -} diff --git a/controllers/grid/files/review/SelectableReviewRevisionsGridHandler.inc.php b/controllers/grid/files/review/SelectableReviewRevisionsGridHandler.inc.php deleted file mode 100644 index 83d16b4fa4e..00000000000 --- a/controllers/grid/files/review/SelectableReviewRevisionsGridHandler.inc.php +++ /dev/null @@ -1,56 +0,0 @@ -addRoleAssignment( - [Role::ROLE_ID_MANAGER, Role::ROLE_ID_SUB_EDITOR, Role::ROLE_ID_ASSISTANT], - ['fetchGrid', 'fetchRow'] - ); - - // Set the grid information. - $this->setTitle('editor.submission.revisions'); - } - - // - // Implemented methods from GridHandler. - // - /** - * @copydoc GridHandler::isDataElementSelected() - */ - public function isDataElementSelected($gridDataElement) - { - return true; - } -} diff --git a/controllers/grid/files/submission/SelectableSubmissionDetailsFilesGridHandler.inc.php b/controllers/grid/files/submission/SelectableSubmissionDetailsFilesGridHandler.inc.php deleted file mode 100644 index db97fcb8b6d..00000000000 --- a/controllers/grid/files/submission/SelectableSubmissionDetailsFilesGridHandler.inc.php +++ /dev/null @@ -1,45 +0,0 @@ -addRoleAssignment( - [Role::ROLE_ID_MANAGER, Role::ROLE_ID_SUB_EDITOR, Role::ROLE_ID_ASSISTANT], - ['fetchGrid', 'fetchRow'] - ); - - // Set the grid title. - $this->setTitle('submission.submit.submissionFiles'); - } -} diff --git a/controllers/grid/queries/QueriesGridHandler.inc.php b/controllers/grid/queries/QueriesGridHandler.inc.php index 7d16a27be07..7bdab3c9d65 100644 --- a/controllers/grid/queries/QueriesGridHandler.inc.php +++ b/controllers/grid/queries/QueriesGridHandler.inc.php @@ -15,7 +15,9 @@ use APP\facades\Repo; use APP\notification\NotificationManager; +use APP\submission\Submission; use APP\template\TemplateManager; +use Illuminate\Support\Facades\Mail; use PKP\controllers\grid\feature\OrderGridItemsFeature; use PKP\controllers\grid\GridColumn; use PKP\controllers\grid\GridHandler; @@ -24,10 +26,10 @@ use PKP\linkAction\LinkAction; use PKP\linkAction\request\AjaxModal; use PKP\linkAction\request\RemoteActionConfirmationModal; -use PKP\mail\FormEmailData; +use PKP\mail\mailables\MailDiscussionMessage; use PKP\mail\SubmissionMailTemplate; +use PKP\notification\NotificationSubscriptionSettingsDAO; use PKP\notification\PKPNotification; -use PKP\observers\events\DiscussionMessageSent; use PKP\security\authorization\QueryAccessPolicy; use PKP\security\authorization\QueryWorkflowStageAccessPolicy; @@ -642,8 +644,18 @@ public function updateQuery($args, $request) unset($added[$key]); } - $formData = new FormEmailData(); + $mailable = new MailDiscussionMessage($request->getContext(), $this->getSubmission()); + $emailTemplate = $mailable->getTemplate($request->getContext()->getId()); + $mailable + ->body($emailTemplate->getLocalizedData('body')) + ->subject($emailTemplate->getLocalizedData('subject')) + ->sender($currentUser); + + /** @var NotificationSubscriptionSettingsDAO $notificationSubscriptionSettingsDAO */ + $notificationSubscriptionSettingsDao = DAORegistry::getDAO('NotificationSubscriptionSettingsDAO'); foreach ($added as $userId) { + $user = Repo::user()->get((int) $userId); + $notification = $notificationMgr->createNotification( $request, $userId, @@ -655,7 +667,18 @@ public function updateQuery($args, $request) null, true ); - $formData->addVariables([$userId => [ + + // Check if the user is unsubscribed + $notificationSubscriptionSettings = $notificationSubscriptionSettingsDao->getNotificationSubscriptionSettings( + NotificationSubscriptionSettingsDAO::BLOCKED_EMAIL_NOTIFICATION_KEY, + $user->getId(), + $request->getContext()->getId() + ); + if (in_array(PKPNotification::NOTIFICATION_TYPE_NEW_QUERY, $notificationSubscriptionSettings)) { + continue; + } + + $mailable->addData([ 'notificationContents' => $notificationMgr->getNotificationContents($request, $notification), 'url' => $notificationMgr->getNotificationUrl($request, $notification), 'unsubscribeLink' => @@ -664,28 +687,14 @@ public function updateQuery($args, $request) '\'>' . __('notification.unsubscribeNotifications') . '' - ]]); - } + ]); - $assocSubmission = Repo::submission()->get($query->getAssocId()); - - try { - event(new DiscussionMessageSent( - $query, - $request->getContext(), - $assocSubmission, - $formData->setRecipientIds($added)->setSenderId($request->getUser()->getId()) - )); - } catch (Swift_TransportException $e) { - $notificationMgr->createTrivialNotification( - $currentUser->getId(), - PKPNotification::NOTIFICATION_TYPE_ERROR, - ['contents' => __('email.compose.error')] - ); - trigger_error($e->getMessage(), E_USER_WARNING); + $mailable->recipients([$user]); + + Mail::send($mailable); } - return \PKP\db\DAO::getDataChangedEvent($query->getId()); } + // If this was new (placeholder) query that didn't validate, remember whether or not // we need to delete it on cancellation. if ($request->getUserVar('wasNew')) { diff --git a/controllers/grid/users/stageParticipant/StageParticipantGridHandler.inc.php b/controllers/grid/users/stageParticipant/StageParticipantGridHandler.inc.php index 1d8aabff071..ef996674880 100644 --- a/controllers/grid/users/stageParticipant/StageParticipantGridHandler.inc.php +++ b/controllers/grid/users/stageParticipant/StageParticipantGridHandler.inc.php @@ -20,7 +20,6 @@ use APP\facades\Repo; use APP\log\SubmissionEventLogEntry; use APP\notification\NotificationManager; -use APP\workflow\EditorDecisionActionsManager; use PKP\controllers\grid\CategoryGridHandler; use PKP\controllers\grid\GridColumn; use PKP\core\JSONMessage; @@ -356,7 +355,7 @@ public function saveParticipant($args, $request) if ($userGroup->getRoleId() == Role::ROLE_ID_MANAGER) { $notificationMgr->updateNotification( $request, - (new EditorDecisionActionsManager())->getStageNotifications(), + $notificationMgr->getDecisionStageNotifications(), null, ASSOC_TYPE_SUBMISSION, $submission->getId() @@ -419,7 +418,7 @@ public function deleteParticipant($args, $request) $notificationMgr = new NotificationManager(); $notificationMgr->updateNotification( $request, - (new EditorDecisionActionsManager())->getStageNotifications(), + $notificationMgr->getDecisionStageNotifications(), null, ASSOC_TYPE_SUBMISSION, $submission->getId() diff --git a/controllers/modals/editorDecision/form/EditorDecisionWithEmailForm.inc.php b/controllers/modals/editorDecision/form/EditorDecisionWithEmailForm.inc.php deleted file mode 100644 index c02636055b5..00000000000 --- a/controllers/modals/editorDecision/form/EditorDecisionWithEmailForm.inc.php +++ /dev/null @@ -1,394 +0,0 @@ -_saveFormOperation; - } - - /** - * Set the operation to save this form. - * - * @param string $saveFormOperation - */ - public function setSaveFormOperation($saveFormOperation) - { - $this->_saveFormOperation = $saveFormOperation; - } - - // - // Implement protected template methods from Form - // - /** - * @see Form::initData() - * - * @param array $actionLabels - */ - public function initData($actionLabels = []) - { - $request = Application::get()->getRequest(); - $context = $request->getContext(); - $router = $request->getRouter(); - $dispatcher = $router->getDispatcher(); - - $submission = $this->getSubmission(); - $user = $request->getUser(); - - $emailKeys = [ - EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_ACCEPT => 'EDITOR_DECISION_ACCEPT', - EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_DECLINE => 'EDITOR_DECISION_DECLINE', - EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_INITIAL_DECLINE => 'EDITOR_DECISION_INITIAL_DECLINE', - EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_EXTERNAL_REVIEW => 'EDITOR_DECISION_SEND_TO_EXTERNAL', - EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_RESUBMIT => 'EDITOR_DECISION_RESUBMIT', - EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_PENDING_REVISIONS => 'EDITOR_DECISION_REVISIONS', - EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_SEND_TO_PRODUCTION => 'EDITOR_DECISION_SEND_TO_PRODUCTION', - ]; - - $email = new SubmissionMailTemplate($submission, $emailKeys[$this->getDecision()]); - - $submissionUrl = $dispatcher->url($request, PKPApplication::ROUTE_PAGE, null, 'authorDashboard', 'submission', $submission->getId()); - $email->assignParams([ - 'authors' => $submission->getAuthorString(), - 'submissionUrl' => $submissionUrl, - ]); - $email->replaceParams(); - - // If we are in review stage we need a review round. - $reviewRound = $this->getReviewRound(); - if (is_a($reviewRound, 'ReviewRound')) { - $this->setData('reviewRoundId', $reviewRound->getId()); - } - - $data = [ - 'submissionId' => $submission->getId(), - 'decision' => $this->getDecision(), - 'authors' => $submission->getAuthorString(), - 'personalMessage' => $email->getBody(), - 'actionLabel' => $actionLabels[$this->getDecision()], - 'bccReviewers' => [] - ]; - foreach ($data as $key => $value) { - $this->setData($key, $value); - } - - return parent::initData(); - } - - /** - * @copydoc Form::readInputData() - */ - public function readInputData() - { - $this->readUserVars(['personalMessage', 'selectedAttachments', 'skipEmail', 'selectedLibraryFiles', 'bccReviewers']); - parent::readInputData(); - } - - /** - * @copydoc EditorDecisionForm::fetch() - * - * @param null|mixed $template - */ - public function fetch($request, $template = null, $display = false) - { - AppLocale::requireComponents(LOCALE_COMPONENT_PKP_REVIEWER); - $templateMgr = TemplateManager::getManager($request); - - // On the review stage, determine if any reviews are available for import - $stageId = $this->getStageId(); - if ($stageId == WORKFLOW_STAGE_ID_INTERNAL_REVIEW || $stageId == WORKFLOW_STAGE_ID_EXTERNAL_REVIEW) { - $reviewsAvailable = false; - $submission = $this->getSubmission(); - $reviewRound = $this->getReviewRound(); - $reviewAssignmentDao = DAORegistry::getDAO('ReviewAssignmentDAO'); /** @var ReviewAssignmentDAO $reviewAssignmentDao */ - $reviewAssignments = $reviewAssignmentDao->getBySubmissionId($submission->getId(), $reviewRound->getId()); - - $reviewers = []; - /** @var \PKP\submission\reviewAssignment\ReviewAssignment $reviewAssignment */ - foreach ($reviewAssignments as $reviewAssignment) { - if ($reviewAssignment->getDateCompleted() != null) { - $reviewsAvailable = true; - } - if (in_array($reviewAssignment->getStatus(), [REVIEW_ASSIGNMENT_STATUS_COMPLETE, REVIEW_ASSIGNMENT_STATUS_RECEIVED, REVIEW_ASSIGNMENT_STATUS_THANKED])) { - $reviewers[$reviewAssignment->getReviewerId()] = $reviewAssignment->getReviewerFullName(); - } - } - - $templateMgr->assign([ - 'reviewsAvailable' => $reviewsAvailable, - 'reviewers' => $reviewers - ]); - - // Retrieve a URL to fetch the reviews - if ($reviewsAvailable) { - $router = $request->getRouter(); - $this->setData( - 'peerReviewUrl', - $router->url( - $request, - null, - null, - 'importPeerReviews', - null, - [ - 'submissionId' => $submission->getId(), - 'stageId' => $stageId, - 'reviewRoundId' => $reviewRound->getId() - ] - ) - ); - } - } - - // When this form is being used in review stages, we need a different - // save operation to allow the EditorDecisionHandler authorize the review - // round object. - if ($this->getSaveFormOperation()) { - $templateMgr = TemplateManager::getManager($request); - $templateMgr->assign('saveFormOperation', $this->getSaveFormOperation()); - } - - $templateMgr->assign('allowedVariables', $this->_getAllowedVariables($request)); - $templateMgr->assign('allowedVariablesType', $this->_getAllowedVariablesType()); - - return parent::fetch($request, $template, $display); - } - - - // - // Private helper methods - // - /** - * Retrieve the last review round and update it with the new status. - * - * The review round status is typically set according to the statuses of its - * ReviewAssignments. This method overrides that status and sets a new one - * based on an EditorDecision. - * - * @param Submission $submission - * @param int $status One of the REVIEW_ROUND_STATUS_* constants. - * @param null|mixed $reviewRound - */ - public function _updateReviewRoundStatus($submission, $status, $reviewRound = null) - { - $reviewRoundDao = DAORegistry::getDAO('ReviewRoundDAO'); /** @var ReviewRoundDAO $reviewRoundDao */ - if (!$reviewRound) { - $reviewRound = $reviewRoundDao->getLastReviewRoundBySubmissionId($submission->getId()); - } - - // If we don't have a review round, it's because the submission is being - // accepted without starting any of the review stages. In that case we - // do nothing. - if (is_a($reviewRound, 'ReviewRound')) { - $reviewRoundDao->updateStatus($reviewRound, $status); - } - } - - /** - * Sends an email with a personal message and the selected - * review attachements to the author. Also marks review attachments - * selected by the editor as "viewable" for the author. - * - * @param Submission $submission - * @param string $emailKey An email template. - * @param PKPRequest $request - */ - public function _sendReviewMailToAuthor($submission, $emailKey, $request) - { - // Send personal message to author. - $email = new SubmissionMailTemplate($submission, $emailKey, null, null, null, false); - $email->setBody($this->getData('personalMessage')); - - // Get submission authors in the same way as for the email template form, - // that editor sees. This also ensures that the recipient list is not empty. - $authors = Repo::author()->getSubmissionAuthors($submission, true); - foreach ($authors as $author) { - $email->addRecipient($author->getEmail(), $author->getFullName()); - } - - DAORegistry::getDAO('SubmissionEmailLogDAO'); // Load constants - $email->setEventType(SubmissionEmailLogEntry::SUBMISSION_EMAIL_EDITOR_NOTIFY_AUTHOR); - - // Get review round. - $reviewRound = $this->getReviewRound(); - - if (is_a($reviewRound, 'ReviewRound')) { - // Retrieve review indexes. - $reviewAssignmentDao = DAORegistry::getDAO('ReviewAssignmentDAO'); /** @var ReviewAssignmentDAO $reviewAssignmentDao */ - - $reviewAssignments = $reviewAssignmentDao->getBySubmissionId($submission->getId(), $reviewRound->getId()); - $reviewers = []; - /** @var \PKP\submission\reviewAssignment\ReviewAssignment $reviewAssignment */ - foreach ($reviewAssignments as $reviewAssignment) { - if (in_array($reviewAssignment->getStatus(), [REVIEW_ASSIGNMENT_STATUS_COMPLETE, REVIEW_ASSIGNMENT_STATUS_RECEIVED, REVIEW_ASSIGNMENT_STATUS_THANKED])) { - $reviewers[] = $reviewAssignment->getReviewerId(); - } - } - - foreach (array_intersect($reviewers, (array) $this->getData('bccReviewers')) as $reviewerId) { - $user = Repo::user()->get($reviewerId); - if ($user && !$user->getDisabled()) { - $email->addBcc($user->getEmail(), $user->getFullName()); - } - } - - $reviewIndexes = $reviewAssignmentDao->getReviewIndexesForRound($submission->getId(), $reviewRound->getId()); - assert(is_array($reviewIndexes)); - - // Add a review index for review attachments not associated with - // a review assignment (i.e. attachments uploaded by the editor). - $lastIndex = end($reviewIndexes); - $reviewIndexes[-1] = $lastIndex + 1; - - // Attach the selected reviewer attachments to the email. - $selectedAttachments = $this->getData('selectedAttachments'); - if (is_array($selectedAttachments)) { - foreach ($selectedAttachments as $submissionFileId) { - - // Retrieve the submission file. - $submissionFile = Repo::submissionFile()->get($submissionFileId); - assert(is_a($submissionFile, 'SubmissionFile')); - - // Check the association information. - if ($submissionFile->getData('assocType') == ASSOC_TYPE_REVIEW_ASSIGNMENT) { - // The review attachment has been uploaded by a reviewer. - $reviewAssignmentId = $submissionFile->getData('assocId'); - assert(is_numeric($reviewAssignmentId)); - } else { - // The review attachment has been uploaded by the editor. - $reviewAssignmentId = -1; - } - - // Identify the corresponding review index. - assert(isset($reviewIndexes[$reviewAssignmentId])); - $reviewIndex = $reviewIndexes[$reviewAssignmentId]; - assert(!is_null($reviewIndex)); - - // Add the attachment to the email. - $path = rtrim(Config::getVar('files', 'files_dir'), '/') . '/' . $submissionFile->getData('path'); - $email->addAttachment( - $path, - PKPString::enumerateAlphabetically($reviewIndex) . '-' . $submissionFile->getLocalizedData('name') - ); - - // Update submission file to set viewable as true, so author - // can view the file on their submission summary page. - Repo::submissionFile()->edit( - $submissionFile, - [ - 'viewable' => true - ] - ); - } - } - } - - // Attach the selected Library files as attachments to the email. - $libraryFileDao = DAORegistry::getDAO('LibraryFileDAO'); /** @var LibraryFileDAO $libraryFileDao */ - $selectedLibraryFilesAttachments = $this->getData('selectedLibraryFiles'); - if (is_array($selectedLibraryFilesAttachments)) { - foreach ($selectedLibraryFilesAttachments as $fileId) { - // Retrieve the Library file. - $libraryFile = $libraryFileDao->getById($fileId); - assert(is_a($libraryFile, 'LibraryFile')); - - $libraryFileManager = new LibraryFileManager($libraryFile->getContextId()); - - // Add the attachment to the email. - $email->addAttachment($libraryFile->getFilePath(), $libraryFile->getOriginalFileName()); - } - } - - // Send the email. - if (!$this->getData('skipEmail')) { - $router = $request->getRouter(); - $dispatcher = $router->getDispatcher(); - $context = $request->getContext(); - $user = $request->getUser(); - $email->assignParams([ - 'submissionUrl' => $dispatcher->url($request, PKPApplication::ROUTE_PAGE, null, 'authorDashboard', 'submission', $submission->getId()), - 'journalName' => $context->getLocalizedName(), - 'authors' => $submission->getAuthorString(), - 'signature' => $user->getContactSignature(), - ]); - if (!$email->send($request)) { - $notificationMgr = new NotificationManager(); - $notificationMgr->createTrivialNotification($request->getUser()->getId(), PKPNotification::NOTIFICATION_TYPE_ERROR, ['contents' => __('email.compose.error')]); - } - } - } - - /** - * Get a list of allowed email template variables. - * - * @param PKPRequest $request Request object - * - * @return array - */ - public function _getAllowedVariables($request) - { - $router = $request->getRouter(); - $dispatcher = $router->getDispatcher(); - $submission = $this->getSubmission(); - $user = $request->getUser(); - return [ - 'submissionUrl' => __('common.url'), - 'journalName' => $request->getContext()->getLocalizedName(), - 'signature' => strip_tags($user->getContactSignature(), '
'), - 'submissionTitle' => strip_tags($submission->getLocalizedTitle()), - 'authors' => strip_tags($submission->getAuthorString()), - ]; - } - - /** - * Get a list of allowed email template variables type. - * - * @return array - */ - public function _getAllowedVariablesType() - { - return [ - 'journalName' => INSERT_TAG_VARIABLE_TYPE_PLAIN_TEXT, - 'signature' => INSERT_TAG_VARIABLE_TYPE_PLAIN_TEXT, - 'submissionTitle' => INSERT_TAG_VARIABLE_TYPE_PLAIN_TEXT, - 'authors' => INSERT_TAG_VARIABLE_TYPE_PLAIN_TEXT, - ]; - } -} diff --git a/controllers/modals/editorDecision/form/InitiateReviewForm.inc.php b/controllers/modals/editorDecision/form/InitiateReviewForm.inc.php deleted file mode 100644 index fbb31854f91..00000000000 --- a/controllers/modals/editorDecision/form/InitiateReviewForm.inc.php +++ /dev/null @@ -1,70 +0,0 @@ -getRequest(); - - // Retrieve the submission. - $submission = $this->getSubmission(); - - // Record the decision. - $actionLabels = (new EditorDecisionActionsManager())->getActionLabels($request->getContext(), $submission, $this->getStageId(), [$this->_decision]); - $editorAction = new EditorAction(); - $editorAction->recordDecision($request, $submission, $this->_decision, $actionLabels); - - // Move to the internal review stage. - $editorAction->incrementWorkflowStage($submission, $this->_getStageId()); - - // Create an initial internal review round. - $this->_initiateReviewRound($submission, $this->_getStageId(), $request, ReviewRound::REVIEW_ROUND_STATUS_PENDING_REVIEWERS); - } -} diff --git a/controllers/modals/editorDecision/form/NewReviewRoundForm.inc.php b/controllers/modals/editorDecision/form/NewReviewRoundForm.inc.php deleted file mode 100644 index 69c0e34046c..00000000000 --- a/controllers/modals/editorDecision/form/NewReviewRoundForm.inc.php +++ /dev/null @@ -1,94 +0,0 @@ -getRequest(); - - // Retrieve the submission. - $submission = $this->getSubmission(); - - // Get this form decision actions labels. - $actionLabels = (new EditorDecisionActionsManager())->getActionLabels($request->getContext(), $submission, $this->getStageId(), $this->_getDecisions()); - - // Record the decision. - $reviewRound = $this->getReviewRound(); - $editorAction = new EditorAction(); - $editorAction->recordDecision($request, $submission, EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_NEW_ROUND, $actionLabels, $reviewRound); - - // Update the review round status. - $reviewRoundDao = DAORegistry::getDAO('ReviewRoundDAO'); /** @var ReviewRoundDAO $reviewRoundDao */ - $reviewRoundDao->updateStatus($reviewRound, ReviewRound::REVIEW_ROUND_STATUS_PENDING_REVIEWERS); - - // Create a new review round. - $newRound = $this->_initiateReviewRound( - $submission, - $submission->getStageId(), - $request, - ReviewRound::REVIEW_ROUND_STATUS_PENDING_REVIEWERS - ); - - parent::execute(...$functionArgs); - - return $newRound; - } - - // - // Private functions - // - /** - * Get this form decisions. - * - * @return array - */ - public function _getDecisions() - { - return [ - EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_NEW_ROUND - ]; - } -} diff --git a/controllers/modals/editorDecision/form/PromoteForm.inc.php b/controllers/modals/editorDecision/form/PromoteForm.inc.php deleted file mode 100644 index 05b3ad1b50b..00000000000 --- a/controllers/modals/editorDecision/form/PromoteForm.inc.php +++ /dev/null @@ -1,235 +0,0 @@ -_getDecisions())) { - fatalError('Invalid decision!'); - } - - $this->setSaveFormOperation('savePromote'); - - parent::__construct( - $submission, - $decision, - $stageId, - 'controllers/modals/editorDecision/form/promoteForm.tpl', - $reviewRound - ); - - AppLocale::requireComponents(LOCALE_COMPONENT_PKP_MANAGER); - } - - - // - // Implement protected template methods from Form - // - /** - * @copydoc EditorDecisionWithEmailForm::initData() - */ - public function initData($actionLabels = []) - { - $request = Application::get()->getRequest(); - $actionLabels = (new EditorDecisionActionsManager())->getActionLabels($request->getContext(), $this->getSubmission(), $this->getStageId(), $this->_getDecisions()); - - $this->setData('stageId', $this->getStageId()); - - // If payments are enabled for this stage/form, default to requiring them - $this->setData('requestPayment', true); - - return parent::initData($actionLabels); - } - - /** - * @copydoc Form::readInputData() - */ - public function readInputData() - { - $this->readUserVars(['requestPayment']); - parent::readInputData(); - } - - /** - * @copydoc Form::execute() - */ - public function execute(...$functionParams) - { - parent::execute(...$functionParams); - - $request = Application::get()->getRequest(); - - // Retrieve the submission. - $submission = $this->getSubmission(); - - // Get this form decision actions labels. - $actionLabels = (new EditorDecisionActionsManager())->getActionLabels($request->getContext(), $submission, $this->getStageId(), $this->_getDecisions()); - - // Record the decision. - $reviewRound = $this->getReviewRound(); - $decision = $this->getDecision(); - $editorAction = new EditorAction(); - $editorAction->recordDecision($request, $submission, $decision, $actionLabels, $reviewRound); - - // Identify email key and status of round. - switch ($decision) { - case EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_ACCEPT: - $emailKey = 'EDITOR_DECISION_ACCEPT'; - $status = ReviewRound::REVIEW_ROUND_STATUS_ACCEPTED; - - $this->_updateReviewRoundStatus($submission, $status, $reviewRound); - - // Move to the editing stage. - $editorAction->incrementWorkflowStage($submission, WORKFLOW_STAGE_ID_EDITING); - - - $selectedFiles = $this->getData('selectedFiles'); - if (is_array($selectedFiles)) { - foreach ($selectedFiles as $submissionFileId) { - $submissionFile = Repo::submissionFile()->get($submissionFileId); - $newSubmissionFile = clone $submissionFile; - $newSubmissionFile->setData('fileStage', SubmissionFile::SUBMISSION_FILE_FINAL); - $newSubmissionFile->setData('sourceSubmissionFileId', $submissionFile->getId()); - $newSubmissionFile->setData('assocType', null); - $newSubmissionFile->setData('assocId', null); - - $newSubmissionFileId = Repo::submissionFile()->add($newSubmissionFile); - - $newSubmissionFile = Repo::submissionFile()->get($newSubmissionFileId); - } - } - - // Send email to the author. - $this->_sendReviewMailToAuthor($submission, $emailKey, $request); - break; - - case EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_EXTERNAL_REVIEW: - $emailKey = 'EDITOR_DECISION_SEND_TO_EXTERNAL'; - $status = ReviewRound::REVIEW_ROUND_STATUS_SENT_TO_EXTERNAL; - - $this->_updateReviewRoundStatus($submission, $status, $reviewRound); - - // Move to the external review stage. - $editorAction->incrementWorkflowStage($submission, WORKFLOW_STAGE_ID_EXTERNAL_REVIEW); - - // Create an initial external review round. - $this->_initiateReviewRound($submission, WORKFLOW_STAGE_ID_EXTERNAL_REVIEW, $request, ReviewRound::REVIEW_ROUND_STATUS_PENDING_REVIEWERS); - - // Send email to the author. - $this->_sendReviewMailToAuthor($submission, $emailKey, $request); - break; - case EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_SEND_TO_PRODUCTION: - $emailKey = 'EDITOR_DECISION_SEND_TO_PRODUCTION'; - // FIXME: this is copy-pasted from above, save the FILE_GALLEY. - - // Move to the editing stage. - $editorAction->incrementWorkflowStage($submission, WORKFLOW_STAGE_ID_PRODUCTION); - - $selectedFiles = $this->getData('selectedFiles'); - if (is_array($selectedFiles)) { - foreach ($selectedFiles as $submissionFileId) { - $submissionFile = Repo::submissionFile()->get($submissionFileId); - $newSubmissionFile = clone $submissionFile; - $newSubmissionFile->setData('fileStage', SubmissionFile::SUBMISSION_FILE_PRODUCTION_READY); - $newSubmissionFile->setData('sourceSubmissionFileId', $submissionFile->getId()); - $newSubmissionFile->setData('assocType', null); - $newSubmissionFile->setData('assocId', null); - $newSubmissionFileId = Repo::submissionFile()->add($newSubmissionFile); - - $newSubmissionFile = Repo::submissionFile()->get($newSubmissionFileId); - } - } - // Send email to the author. - $this->_sendReviewMailToAuthor($submission, $emailKey, $request); - break; - default: - throw new Exception('Unsupported decision!'); - } - - if ($this->getData('requestPayment')) { - $context = $request->getContext(); - $stageDecisions = (new EditorDecisionActionsManager())->getStageDecisions($context, $submission, $this->getStageId()); - $decisionData = $stageDecisions[$decision]; - if (isset($decisionData['paymentType'])) { - $paymentType = $decisionData['paymentType']; - - // Queue a payment. - $paymentManager = Application::getPaymentManager($context); - $queuedPayment = $paymentManager->createQueuedPayment($request, $paymentType, $request->getUser()->getId(), $submission->getId(), $decisionData['paymentAmount'], $decisionData['paymentCurrency']); - $paymentManager->queuePayment($queuedPayment); - - // Notify any authors that this needs payment. - $notificationMgr = new NotificationManager(); - $stageAssignmentDao = DAORegistry::getDAO('StageAssignmentDAO'); /** @var StageAssignmentDAO $stageAssignmentDao */ - $stageAssignments = $stageAssignmentDao->getBySubmissionAndRoleId($submission->getId(), Role::ROLE_ID_AUTHOR, null); - $userIds = []; - while ($stageAssignment = $stageAssignments->next()) { - if (!in_array($stageAssignment->getUserId(), $userIds)) { - $notificationMgr->createNotification( - $request, - $stageAssignment->getUserId(), - PKPNotification::NOTIFICATION_TYPE_PAYMENT_REQUIRED, - $context->getId(), - ASSOC_TYPE_QUEUED_PAYMENT, - $queuedPayment->getId(), - Notification::NOTIFICATION_LEVEL_TASK - ); - $userIds[] = $stageAssignment->getUserId(); - } - } - } - } - } - - // - // Private functions - // - /** - * Get this form decisions. - * - * @return array - */ - public function _getDecisions() - { - return [ - EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_EXTERNAL_REVIEW, - EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_ACCEPT, - EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_SEND_TO_PRODUCTION - ]; - } -} diff --git a/controllers/modals/editorDecision/form/RecommendationForm.inc.php b/controllers/modals/editorDecision/form/RecommendationForm.inc.php deleted file mode 100644 index f2c2d208793..00000000000 --- a/controllers/modals/editorDecision/form/RecommendationForm.inc.php +++ /dev/null @@ -1,284 +0,0 @@ -_submission = $submission; - $this->_stageId = $stageId; - $this->_reviewRound = $reviewRound; - - // Validation checks for this form - $this->addCheck(new \PKP\form\validation\FormValidatorPost($this)); - $this->addCheck(new \PKP\form\validation\FormValidatorCSRF($this)); - } - - // - // Getters and Setters - // - /** - * Get the submission - * - * @return Submission - */ - public function getSubmission() - { - return $this->_submission; - } - - /** - * Get the stage Id - * - * @return int - */ - public function getStageId() - { - return $this->_stageId; - } - - /** - * Get the review round object. - * - * @return ReviewRound - */ - public function getReviewRound() - { - return $this->_reviewRound; - } - - // - // Overridden template methods from Form - // - /** - * @copydoc Form::initData() - */ - public function initData() - { - $submission = $this->getSubmission(); - - // Get the decision making editors, the e-mail about the recommendation will be send to - $stageAssignmentDao = DAORegistry::getDAO('StageAssignmentDAO'); /** @var StageAssignmentDAO $stageAssignmentDao */ - $editorsStageAssignments = $stageAssignmentDao->getEditorsAssignedToStage($submission->getId(), $this->getStageId()); - $editorsStr = ''; - $i = 0; - foreach ($editorsStageAssignments as $editorsStageAssignment) { - if (!$editorsStageAssignment->getRecommendOnly()) { - $editorFullName = Repo::user()->get($editorsStageAssignment->getUserId(), true)->getFullName(); - $editorsStr .= ($i == 0) ? $editorFullName : ', ' . $editorFullName; - $i++; - } - } - // Get the editor recommendation e-mail template - $email = new SubmissionMailTemplate($submission, 'EDITOR_RECOMMENDATION'); - $request = Application::get()->getRequest(); - $router = $request->getRouter(); - $dispatcher = $router->getDispatcher(); - $user = $request->getUser(); - $submissionUrl = $dispatcher->url($request, PKPApplication::ROUTE_PAGE, null, 'workflow', 'index', [$submission->getId(), $this->getStageId()]); - $emailParams = [ - 'editors' => $editorsStr, - 'submissionUrl' => $submissionUrl, - ]; - $email->assignParams($emailParams); - $email->replaceParams(); - - // Get the recorded recommendations - $editDecisionDao = DAORegistry::getDAO('EditDecisionDAO'); /** @var EditDecisionDAO $editDecisionDao */ - $editorRecommendations = $editDecisionDao->getEditorDecisions($submission->getId(), $this->getStageId(), null, $user->getId()); - - // Set form data - $recommendationOptions = (new EditorDecisionActionsManager())->getRecommendationOptions($this->getStageId()); - $data = [ - 'submissionId' => $submission->getId(), - 'stageId' => $this->getStageId(), - 'reviewRoundId' => $this->getReviewRound()->getId(), - 'editorRecommendations' => $editorRecommendations, - 'recommendationOptions' => $recommendationOptions, - 'editors' => $editorsStr, - 'personalMessage' => $email->getBody(), - ]; - foreach ($data as $key => $value) { - $this->setData($key, $value); - } - return parent::initData(); - } - - /** - * @copydoc Form::fetch() - * - * @param null|mixed $template - */ - public function fetch($request, $template = null, $display = false) - { - $templateMgr = TemplateManager::getManager($request); - $templateMgr->assign([ - 'allowedVariables' => [ - 'recommendation' => __('editor.submission.recommendation'), - ], - ]); - return parent::fetch($request, $template, $display); - } - - /** - * @copydoc Form::readInputData() - */ - public function readInputData() - { - $this->readUserVars(['recommendation', 'personalMessage', 'skipEmail', 'skipDiscussion']); - parent::readInputData(); - } - - /** - * @copydoc Form::execute() - */ - public function execute(...$functionParams) - { - parent::execute(...$functionParams); - - // Record the recommendation. - $request = Application::get()->getRequest(); - $submission = $this->getSubmission(); - $reviewRound = $this->getReviewRound(); - $recommendation = $this->getData('recommendation'); - - // Record the recommendation - $editorAction = new EditorAction(); - // Get editor action labels needed for the recording - $recommendationOptions = (new EditorDecisionActionsManager())->getRecommendationOptions($this->getStageId()); - $actionLabels = [$recommendation => $recommendationOptions[$recommendation]]; - $editorAction->recordDecision($request, $submission, $recommendation, $actionLabels, $reviewRound, $this->getStageId(), true); - - if (!$this->getData('skipEmail') || !$this->getData('skipDiscussion')) { - $router = $request->getRouter(); - $user = $request->getUser(); - - // Send the email to the decision making editors assigned to this submission. - $email = new SubmissionMailTemplate($submission, 'EDITOR_RECOMMENDATION', null, null, null, false); - $email->setBody($this->getData('personalMessage')); - - $stageAssignmentDao = DAORegistry::getDAO('StageAssignmentDAO'); /** @var StageAssignmentDAO $stageAssignmentDao */ - $editorsStageAssignments = $stageAssignmentDao->getEditorsAssignedToStage($submission->getId(), $this->getStageId()); - foreach ($editorsStageAssignments as $editorsStageAssignment) { - if (!$editorsStageAssignment->getRecommendOnly()) { - $editor = Repo::user()->get($editorsStageAssignment->getUserId()); - if (!$editor) { - continue; - } // Disabled user - $editorFullName = $editor->getFullName(); - $email->addRecipient($editor->getEmail(), $editorFullName); - } - } - - DAORegistry::getDAO('SubmissionEmailLogDAO'); // Load constants - $email->setEventType(SubmissionEmailLogEntry::SUBMISSION_EMAIL_EDITOR_RECOMMEND_NOTIFY); - - $dispatcher = $router->getDispatcher(); - $submissionUrl = $dispatcher->url($request, PKPApplication::ROUTE_PAGE, null, 'workflow', 'index', [$submission->getId(), $this->getStageId()]); - $email->assignParams([ - 'editors' => $this->getData('editors'), - 'signature' => $user->getContactSignature(), - 'submissionUrl' => $submissionUrl, - 'recommendation' => __($recommendationOptions[$recommendation]), - ]); - if (!$this->getData('skipEmail')) { - if (!$email->send($request)) { - $notificationMgr = new NotificationManager(); - $notificationMgr->createTrivialNotification($request->getUser()->getId(), PKPNotification::NOTIFICATION_TYPE_ERROR, ['contents' => __('email.compose.error')]); - } - } - - if (!$this->getData('skipDiscussion')) { - // Create a discussion - $queryDao = DAORegistry::getDAO('QueryDAO'); /** @var QueryDAO $queryDao */ - $query = $queryDao->newDataObject(); - $query->setAssocType(ASSOC_TYPE_SUBMISSION); - $query->setAssocId($submission->getId()); - $query->setStageId($this->getStageId()); - $query->setSequence(REALLY_BIG_NUMBER); - $queryDao->insertObject($query); - $queryDao->resequence(ASSOC_TYPE_SUBMISSION, $submission->getId()); - - // Add the decision making editors as discussion participants - $stageAssignmentDao = DAORegistry::getDAO('StageAssignmentDAO'); /** @var StageAssignmentDAO $stageAssignmentDao */ - $discussionParticipantsIds = []; - $editorsStageAssignments = $stageAssignmentDao->getEditorsAssignedToStage($submission->getId(), $this->getStageId()); - foreach ($editorsStageAssignments as $editorsStageAssignment) { - if (!$editorsStageAssignment->getRecommendOnly()) { - if (!in_array($editorsStageAssignment->getUserId(), $discussionParticipantsIds)) { - $discussionParticipantsIds[] = $editorsStageAssignment->getUserId(); - $queryDao->insertParticipant($query->getId(), $editorsStageAssignment->getUserId()); - } - } - } - - $noteDao = DAORegistry::getDAO('NoteDAO'); /** @var NoteDAO $noteDao */ - $note = $noteDao->newDataObject(); - $note->setAssocType(ASSOC_TYPE_QUERY); - $note->setAssocId($query->getId()); - $email->replaceParams(); - $note->setContents($email->getBody()); - $note->setTitle(__('editor.submission.recommendation')); - $note->setDateCreated(Core::getCurrentDate()); - $note->setDateModified(Core::getCurrentDate()); - $note->setUserId($user->getId()); - $noteDao->insertObject($note); - - // Add task - $notificationMgr = new NotificationManager(); - foreach ($discussionParticipantsIds as $discussionParticipantsId) { - $notificationMgr->createNotification( - $request, - $discussionParticipantsId, - PKPNotification::NOTIFICATION_TYPE_NEW_QUERY, - $request->getContext()->getId(), - ASSOC_TYPE_QUERY, - $query->getId(), - Notification::NOTIFICATION_LEVEL_TASK - ); - } - } - } - } -} diff --git a/controllers/modals/editorDecision/form/RevertDeclineForm.inc.php b/controllers/modals/editorDecision/form/RevertDeclineForm.inc.php deleted file mode 100644 index b2b5a205e2c..00000000000 --- a/controllers/modals/editorDecision/form/RevertDeclineForm.inc.php +++ /dev/null @@ -1,104 +0,0 @@ -setData('decision', $this->getDecision()); - // If we are in review stage we need a review round. - $reviewRound = $this->getReviewRound(); - if (is_a($reviewRound, 'ReviewRound')) { - $this->setData('reviewRoundId', $reviewRound->getId()); - } - return parent::initData(); - } - - /** - * @copydoc Form::execute() - */ - public function execute(...$formParams) - { - parent::execute(...$formParams); - - $request = Application::get()->getRequest(); - - // Retrieve the submission. - $submission = $this->getSubmission(); /** @var Submission $submission */ - - // Record the decision. - $actionLabels = (new EditorDecisionActionsManager())->getActionLabels($request->getContext(), $submission, $this->getStageId(), [$this->getDecision()]); - $editorAction = new EditorAction(); - $editorAction->recordDecision($request, $submission, $this->getDecision(), $actionLabels); - - $submission->setStatus(PKPSubmission::STATUS_QUEUED); // Always return submission to STATUS_QUEUED - - // If we are on a review round, return the round status - // prior to the decline decision - $reviewRoundDao = DAORegistry::getDAO('ReviewRoundDAO'); /** @var ReviewRoundDAO $reviewRoundDao */ - $reviewRound = $reviewRoundDao->getLastReviewRoundBySubmissionId($submission->getId(), $this->getStageId()); - if (is_a($reviewRound, 'ReviewRound')) { - $reviewRound->setStatus(null); - $reviewRoundDao = DAORegistry::getDAO('ReviewRoundDAO'); /** @var ReviewRoundDAO $reviewRoundDao */ - $reviewRoundDao->updateStatus($reviewRound); - } - - Repo::submission()->dao->update($submission); - } - - // - // Private functions - // - /** - * Get this form decisions. - * - * @return array - */ - public function _getDecisions() - { - return [ - EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_REVERT_DECLINE - ]; - } -} diff --git a/controllers/modals/editorDecision/form/SendReviewsForm.inc.php b/controllers/modals/editorDecision/form/SendReviewsForm.inc.php deleted file mode 100644 index 0d3789fc21a..00000000000 --- a/controllers/modals/editorDecision/form/SendReviewsForm.inc.php +++ /dev/null @@ -1,181 +0,0 @@ -_getDecisions())) { - fatalError('Invalid decision!'); - } - - $this->setSaveFormOperation('saveSendReviews'); - - parent::__construct( - $submission, - $decision, - $stageId, - 'controllers/modals/editorDecision/form/sendReviewsForm.tpl', - $reviewRound - ); - } - - - // - // Implement protected template methods from Form - // - /** - * @copydoc EditorDecisionWithEmailForm::initData() - */ - public function initData($actionLabels = []) - { - $request = Application::get()->getRequest(); - $actionLabels = (new EditorDecisionActionsManager())->getActionLabels($request->getContext(), $this->getSubmission(), $this->getStageId(), $this->_getDecisions()); - - return parent::initData($actionLabels); - } - - /** - * @copydoc Form::readInputData() - */ - public function readInputData() - { - $this->readUserVars(['decision']); - parent::readInputData(); - } - - /** - * @copydoc EditorDecisionWithEmailForm::fetch() - * - * @param null|mixed $template - */ - public function fetch($request, $template = null, $display = false) - { - $templateMgr = TemplateManager::getManager($request); - $router = $request->getRouter(); - $dispatcher = $router->getDispatcher(); - $submission = $this->getSubmission(); - $user = $request->getUser(); - - $revisionsEmail = new SubmissionMailTemplate($submission, 'EDITOR_DECISION_REVISIONS'); - $resubmitEmail = new SubmissionMailTemplate($submission, 'EDITOR_DECISION_RESUBMIT'); - - foreach ([$revisionsEmail, $resubmitEmail] as &$email) { - $email->assignParams([ - 'authors' => $submission->getAuthorString(), - 'submissionUrl' => $dispatcher->url($request, PKPApplication::ROUTE_PAGE, null, 'authorDashboard', 'submission', $submission->getId()), - ]); - $email->replaceParams(); - } - - $templateMgr->assign([ - 'revisionsEmail' => $revisionsEmail->getBody(), - 'resubmitEmail' => $resubmitEmail->getBody(), - ]); - - return parent::fetch($request, $template, $display); - } - - /** - * @copydoc Form::execute() - */ - public function execute(...$functionArgs) - { - $request = Application::get()->getRequest(); - - // Retrieve the submission. - $submission = $this->getSubmission(); - - // Get this form decision actions labels. - $actionLabels = (new EditorDecisionActionsManager())->getActionLabels($request->getContext(), $submission, $this->getStageId(), $this->_getDecisions()); - - // Record the decision. - $reviewRound = $this->getReviewRound(); - $decision = $this->getDecision(); - $stageId = $this->getStageId(); - $editorAction = new EditorAction(); - $editorAction->recordDecision($request, $submission, $decision, $actionLabels, $reviewRound, $stageId); - - parent::execute(...$functionArgs); - - // Identify email key and status of round. - switch ($decision) { - case EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_PENDING_REVISIONS: - $emailKey = 'EDITOR_DECISION_REVISIONS'; - $status = ReviewRound::REVIEW_ROUND_STATUS_REVISIONS_REQUESTED; - break; - - case EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_RESUBMIT: - $emailKey = 'EDITOR_DECISION_RESUBMIT'; - $status = ReviewRound::REVIEW_ROUND_STATUS_RESUBMIT_FOR_REVIEW; - break; - - case EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_DECLINE: - $emailKey = 'EDITOR_DECISION_DECLINE'; - $status = ReviewRound::REVIEW_ROUND_STATUS_DECLINED; - break; - - case EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_INITIAL_DECLINE: - $emailKey = 'EDITOR_DECISION_INITIAL_DECLINE'; - $status = ReviewRound::REVIEW_ROUND_STATUS_DECLINED; - break; - - default: - fatalError('Unsupported decision!'); - } - - $this->_updateReviewRoundStatus($submission, $status, $reviewRound); - - // Send email to the author. - $this->_sendReviewMailToAuthor($submission, $emailKey, $request); - } - - // - // Private functions - // - /** - * Get this form decisions. - * - * @return array - */ - public function _getDecisions() - { - return [ - EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_PENDING_REVISIONS, - EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_RESUBMIT, - EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_DECLINE, - EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_INITIAL_DECLINE - ]; - } -} diff --git a/controllers/tab/authorDashboard/AuthorDashboardReviewRoundTabHandler.inc.php b/controllers/tab/authorDashboard/AuthorDashboardReviewRoundTabHandler.inc.php index dddd7e16bc8..160a9e11275 100644 --- a/controllers/tab/authorDashboard/AuthorDashboardReviewRoundTabHandler.inc.php +++ b/controllers/tab/authorDashboard/AuthorDashboardReviewRoundTabHandler.inc.php @@ -16,11 +16,12 @@ // Import the base Handler. import('pages.authorDashboard.AuthorDashboardHandler'); +use APP\core\Application; use APP\notification\Notification; use APP\template\TemplateManager; -use APP\workflow\EditorDecisionActionsManager; use PKP\core\JSONMessage; +use PKP\db\DAORegistry; use PKP\log\SubmissionEmailLogEntry; use PKP\notification\PKPNotification; use PKP\security\authorization\internal\ReviewRoundRequiredPolicy; @@ -78,9 +79,9 @@ public function fetchReviewRoundInfo($args, $request) $this->setupTemplate($request); $templateMgr = TemplateManager::getManager($request); - $reviewRound = $this->getAuthorizedContextObject(ASSOC_TYPE_REVIEW_ROUND); - $submission = $this->getAuthorizedContextObject(ASSOC_TYPE_SUBMISSION); - $stageId = $this->getAuthorizedContextObject(ASSOC_TYPE_WORKFLOW_STAGE); + $reviewRound = $this->getAuthorizedContextObject(Application::ASSOC_TYPE_REVIEW_ROUND); + $submission = $this->getAuthorizedContextObject(Application::ASSOC_TYPE_SUBMISSION); + $stageId = $this->getAuthorizedContextObject(Application::ASSOC_TYPE_WORKFLOW_STAGE); if ($stageId !== WORKFLOW_STAGE_ID_INTERNAL_REVIEW && $stageId !== WORKFLOW_STAGE_ID_EXTERNAL_REVIEW) { fatalError('Invalid Stage Id'); } @@ -91,7 +92,7 @@ public function fetchReviewRoundInfo($args, $request) 'submission' => $submission, 'reviewRoundNotificationRequestOptions' => [ Notification::NOTIFICATION_LEVEL_NORMAL => [ - PKPNotification::NOTIFICATION_TYPE_REVIEW_ROUND_STATUS => [ASSOC_TYPE_REVIEW_ROUND, $reviewRound->getId()]], + PKPNotification::NOTIFICATION_TYPE_REVIEW_ROUND_STATUS => [Application::ASSOC_TYPE_REVIEW_ROUND, $reviewRound->getId()]], Notification::NOTIFICATION_LEVEL_TRIVIAL => [] ], ]); @@ -102,15 +103,16 @@ public function fetchReviewRoundInfo($args, $request) $templateMgr->assign('showReviewerGrid', true); } - // Editor has taken an action and sent an email; Display the email - if ((new EditorDecisionActionsManager())->getEditorTakenActionInReviewRound($request->getContext(), $reviewRound)) { - $submissionEmailLogDao = DAORegistry::getDAO('SubmissionEmailLogDAO'); /** @var SubmissionEmailLogDAO $submissionEmailLogDao */ - $user = $request->getUser(); - $templateMgr->assign([ - 'submissionEmails' => $submissionEmailLogDao->getByEventType($submission->getId(), SubmissionEmailLogEntry::SUBMISSION_EMAIL_EDITOR_NOTIFY_AUTHOR, $user->getId()), - 'showReviewAttachments' => true, - ]); - } + // Display notification emails to the author related to editorial decisions + $submissionEmailLogDao = DAORegistry::getDAO('SubmissionEmailLogDAO'); /** @var SubmissionEmailLogDAO $submissionEmailLogDao */ + $templateMgr->assign([ + 'submissionEmails' => $submissionEmailLogDao->getByEventType( + $submission->getId(), + SubmissionEmailLogEntry::SUBMISSION_EMAIL_EDITOR_NOTIFY_AUTHOR, + $request->getUser()->getId() + ), + 'showReviewAttachments' => true, + ]); return $templateMgr->fetchJson('authorDashboard/reviewRoundInfo.tpl'); } diff --git a/controllers/tab/workflow/PKPWorkflowTabHandler.inc.php b/controllers/tab/workflow/PKPWorkflowTabHandler.inc.php index 60743772c73..9774a93d5cd 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/controllers/modals/editorDecision/form/EditorDecisionFormHandler.js b/js/controllers/modals/editorDecision/form/EditorDecisionFormHandler.js deleted file mode 100644 index 62fbfd50ebc..00000000000 --- a/js/controllers/modals/editorDecision/form/EditorDecisionFormHandler.js +++ /dev/null @@ -1,250 +0,0 @@ -/** - * @defgroup js_controllers_modals_editorDecision_form - */ -/** - * @file js/controllers/modals/editorDecision/form/EditorDecisionFormHandler.js - * - * Copyright (c) 2014-2021 Simon Fraser University - * Copyright (c) 2000-2021 John Willinsky - * Distributed under the GNU GPL v3. For full terms see the file docs/COPYING. - * - * @class EditorDecisionFormHandler - * @ingroup js_controllers_modals_editorDecision_form - * - * @brief Handle editor decision forms. - */ -(function($) { - - /** @type {Object} */ - $.pkp.controllers.modals = $.pkp.controllers.modals || - { editorDecision: {form: { } } }; - - - - /** - * @constructor - * - * @extends $.pkp.controllers.form.AjaxFormHandler - * - * @param {jQueryObject} $form the wrapped HTML form element. - * @param {{ - * peerReviewUrl: string?, - * revisionsEmail: string?, - * resubmitEmail: string? - * }} options form options - */ - $.pkp.controllers.modals.editorDecision.form.EditorDecisionFormHandler = - function($form, options) { - - this.parent($form, options); - - if (options.peerReviewUrl !== null) { - this.peerReviewUrl_ = options.peerReviewUrl; - $('#importPeerReviews', $form).click( - this.callbackWrapper(this.importPeerReviews)); - } - - // Handle revisions, resubmit and decline decision forms - if (options.revisionsEmail !== null) { - this.revisionsEmail_ = options.revisionsEmail; - } - if (options.resubmitEmail !== null) { - this.resubmitEmail_ = options.resubmitEmail; - } - $('#skipEmail-send, #skipEmail-skip, ' + - '#skipDiscussion-send, #skipDiscussion-skip', $form).change( - this.callbackWrapper(this.toggleEmailDisplay)); - $('input[name="decision"]', $form).change( - this.callbackWrapper(this.toggleDecisionEmail)); - - // Handle promotion forms - this.setStep('email'); - var self = this; - $('.promoteForm-step-btn', $form).click(function(e) { - e.preventDefault(); - e.stopPropagation(); - var step = $(e.target).data('step'); - self.setStep(/** @type {string} */ (step)); - }); - }; - $.pkp.classes.Helper.inherits( - $.pkp.controllers.modals.editorDecision.form.EditorDecisionFormHandler, - $.pkp.controllers.form.AjaxFormHandler); - - - // - // Private properties - // - /** - * The URL of the "fetch peer reviews" operation. - * @private - * @type {?string} - */ - $.pkp.controllers.modals.editorDecision.form.EditorDecisionFormHandler. - peerReviewUrl_ = null; - - - /** - * The content of the revisions requested email. - * @private - * @type {?string} - */ - $.pkp.controllers.modals.editorDecision.form.EditorDecisionFormHandler. - revisionsEmail_ = null; - - - /** - * The content of the resubmit for review email. - * @private - * @type {?string} - */ - $.pkp.controllers.modals.editorDecision.form.EditorDecisionFormHandler. - resubmitEmail_ = null; - - - // - // Public methods - // - /** - * Retrieve reviews from the server. - * - * @param {HTMLElement} button The "import reviews" button. - * @param {Event} event The click event. - * @return {boolean} Return false to abort normal click event. - */ - $.pkp.controllers.modals.editorDecision.form.EditorDecisionFormHandler. - prototype.importPeerReviews = function(button, event) { - - $.getJSON(this.peerReviewUrl_, this.callbackWrapper(this.insertPeerReviews)); - return false; - }; - - - /** - * Insert the peer reviews that have been returned from the server - * into the form. - * - * @param {Object} ajaxOptions The options that were passed into - * the AJAX call. - * @param {Object} jsonData The data returned from the server. - */ - $.pkp.controllers.modals.editorDecision.form.EditorDecisionFormHandler. - prototype.insertPeerReviews = function(ajaxOptions, jsonData) { - - var processedJsonData = this.handleJson(jsonData), - $form = this.getHtmlElement(), - $textArea = $('textarea[id^="personalMessage"]', $form), - editor = tinyMCE.get(/** @type {string} */ ($textArea.attr('id'))), - currentContent = editor.getContent(); - - if (processedJsonData !== false) { - // Add the peer review text to the personal message to the author. - editor.setContent( - currentContent + processedJsonData.content + '
'); - } - - // Present any new notifications to the user. - this.trigger('notifyUser', [this.getHtmlElement()]); - }; - - - /** - * Show or hide the email depending on the `skipEmail` setting - */ - $.pkp.controllers.modals.editorDecision.form.EditorDecisionFormHandler. - prototype.toggleEmailDisplay = function() { - var $emailDiv = $('#sendReviews-emailContent'), - $self = this.getHtmlElement(), - sendEmail = false, - createDiscussion = false, - $discussionToggles, - $attachementDiv = $('#libraryFileAttachments'); - - $('#skipEmail-send, #skipEmail-skip', $self).each(function() { - if ($(this).attr('id') === 'skipEmail-send' && $(this).prop('checked')) { - sendEmail = true; - } else if ($(this).attr('id') === 'skipEmail-skip' && - $(this).prop('checked')) { - sendEmail = false; - } - }); - - $discussionToggles = $('#skipDiscussion-send, #skipDiscussion-skip', $self); - if ($discussionToggles.length) { - $discussionToggles.each(function() { - if ($(this).attr('id') === 'skipDiscussion-send' && - $(this).prop('checked')) { - createDiscussion = true; - } else if ($(this).attr('id') === 'skipDiscussion-skip' && - $(this).prop('checked')) { - createDiscussion = false; - } - }); - } - - if (!sendEmail && !createDiscussion) { - $emailDiv.fadeOut(); - $attachementDiv.fadeOut(); - } else { - $emailDiv.fadeIn(); - $attachementDiv.fadeIn(); - } - }; - - - /** - * Update the email content depending on which decision was selected. - * - * Only used in the request revisions modal to choose between two decisions. - */ - $.pkp.controllers.modals.editorDecision.form.EditorDecisionFormHandler. - prototype.toggleDecisionEmail = function() { - var emailContent = '', - isEmailDivVisible = $('#skipEmail-send').prop('checked'), - $emailDiv = $('#sendReviews-emailContent'), - textareaId = $('textarea[id^="personalMessage"]').attr('id'), - self = this; - - $('input[name="decision"]').each(function() { - if ($(this).attr('id') === 'decisionRevisions' && - $(this).prop('checked')) { - emailContent = self.revisionsEmail_; - } else if ($(this).attr('id') === 'decisionResubmit' && - $(this).prop('checked')) { - emailContent = self.resubmitEmail_; - } - }); - - tinyMCE.get(/** @type {string} */ (textareaId)).setContent(emailContent); - - if (isEmailDivVisible) { - $emailDiv.hide().fadeIn(); - } - }; - - - /** - * Display the requested step of the form - * - * Only used on promotion forms. - * - * @param {string} step Name of the step to display - */ - $.pkp.controllers.modals.editorDecision.form.EditorDecisionFormHandler. - prototype.setStep = function(step) { - var emailStepContent = - $('#promoteForm-step1, .promoteForm-step-btn[data-step="files"]'), - filesStepContent = $('#promoteForm-step2, #promoteForm-complete-btn,' + - ' .promoteForm-step-btn[data-step="email"]'); - - if (step === 'files') { - filesStepContent.show(); - emailStepContent.hide(); - } else { - emailStepContent.show(); - filesStepContent.hide(); - } - }; - - -}(jQuery)); 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 f4dff1dfa49..9c9c983ef86 100644 --- a/locale/en_US/manager.po +++ b/locale/en_US/manager.po @@ -727,6 +727,18 @@ msgstr "Notification of Author Submission" msgid "manager.setup.notifications.copySpecifiedAddress" msgstr "Send a copy to this email address" +msgid "manager.setup.notifyAllAuthors" +msgstr "Notify All Authors" + +msgid "manager.setup.notifyAllAuthors.description" +msgstr "Who should receive a notification email when an editorial decision is recorded?" + +msgid "manager.setup.notifyAllAuthors.allAuthors" +msgstr "Send an email notification to all authors of the submission." + +msgid "manager.setup.notifyAllAuthors.assignedAuthors" +msgstr "Only send an email to authors assigned to the submission workflow. Usually, this is the submitting author." + msgid "manager.setup.submissionsNotifications" msgstr "Notifications" @@ -2091,9 +2103,27 @@ 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.context.submissionsUrl" +msgstr "The URL to view all of a user's assigned submissions. + +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" @@ -2145,6 +2175,9 @@ msgstr "The full names of the authors" msgid "emailTemplate.variable.submission.submissionUrl" msgstr "The URL to the submission in the editorial backend" +msgid "emailTemplate.variable.submission.submittingAuthorName" +msgstr "The names of the authors assigned to the submission workflow. Usually this is the submitting author" + msgid "mailable.mailDiscussionMessage.name" msgstr "New Discussion Message" @@ -2175,3 +2208,120 @@ 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.notifyOtherAuthors.name" +msgstr "Notify Other Authors" + +msgid "mailable.decision.notifyOtherAuthors.description" +msgstr "This email is sent to notify authors of a submission who are not assigned as participants that a decision has been made. Usually these are all others except the submitting author." + +msgid "mailable.decision.notifyOtherAuthors.variable.message.description" +msgstr "A copy of the email message that was sent to the submitting author" + +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.notifyReviewer.variable.decisionDescription" +msgstr "A short description of this decision that is intended to be shared in an email notification sent to reviewers about this decision." + +msgid "mailable.decision.notifyReviewer.variable.decisionDescription.accept" +msgstr "We have chosen to accept this submission without revisions." + +msgid "mailable.decision.notifyReviewer.variable.decisionDescription.decline" +msgstr "We have chosen to decline this submission." + +msgid "mailable.decision.notifyReviewer.variable.decisionDescription.pendingRevisions" +msgstr "We have invited the authors to submit revisions." + +msgid "mailable.decision.notifyReviewer.variable.decisionDescription.resubmit" +msgstr "We have invited the authors to submit a revised version for further review." + +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 a125da37d81..5a09a13c154 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." @@ -1334,27 +1337,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" @@ -1370,9 +1550,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" @@ -1385,6 +1628,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" @@ -1394,15 +1640,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 3ce1152d1b2..0098f819ef8 100644 --- a/pages/authorDashboard/PKPAuthorDashboardHandler.inc.php +++ b/pages/authorDashboard/PKPAuthorDashboardHandler.inc.php @@ -13,10 +13,10 @@ * @brief Handle requests for the author dashboard. */ +use APP\decision\Decision; use APP\facades\Repo; use APP\handler\Handler; use APP\template\TemplateManager; -use APP\workflow\EditorDecisionActionsManager; use Illuminate\Support\Enumerable; use PKP\log\SubmissionEmailLogEntry; @@ -166,9 +166,6 @@ public function setupTemplate($request) $stageNotifications[$stageId] = false; } - $editDecisionDao = DAORegistry::getDAO('EditDecisionDAO'); /** @var EditDecisionDAO $editDecisionDao */ - $stageDecisions = $editDecisionDao->getEditorDecisions($submission->getId()); - // Add an upload revisions button when in the review stage // and the last decision is to request revisions $uploadFileUrl = ''; @@ -177,15 +174,20 @@ public function setupTemplate($request) $reviewRoundDao = DAORegistry::getDAO('ReviewRoundDAO'); /** @var ReviewRoundDAO $reviewRoundDao */ $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()); - if (!empty($editorDecisions)) { - $lastDecision = end($editorDecisions)['decision']; + $editorDecisions = Repo::decision()->getMany( + Repo::decision() + ->getCollector() + ->filterBySubmissionIds([$submission->getId()]) + ->filterByStageIds([$submission->getData('stageId')]) + ->filterByReviewRoundIds([$lastReviewRound->getId()]) + ); + if (!$editorDecisions->isEmpty()) { + $lastDecision = $editorDecisions->last(); $revisionDecisions = [ - EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_PENDING_REVISIONS, - EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_RESUBMIT + Decision::PENDING_REVISIONS, + Decision::RESUBMIT ]; - if (in_array($lastDecision, $revisionDecisions)) { + if (in_array($lastDecision->getData('decision'), $revisionDecisions)) { $actionArgs['submissionId'] = $submission->getId(); $actionArgs['stageId'] = $submission->getData('stageId'); $actionArgs['uploaderRoles'] = Role::ROLE_ID_AUTHOR; diff --git a/pages/dashboard/DashboardHandler.inc.php b/pages/dashboard/DashboardHandler.inc.php index 4b1bb7efcf2..17cae3dafa3 100644 --- a/pages/dashboard/DashboardHandler.inc.php +++ b/pages/dashboard/DashboardHandler.inc.php @@ -104,7 +104,11 @@ public function index($args, $request) $userGroupDao = DAORegistry::getDAO('UserGroupDAO'); /** @var UserGroupDAO $userGroupDao */ $userGroups = $userGroupDao->getByContextId($context->getId())->toArray(); - $items = Repo::submission()->getSchemaMap()->mapManyToSubmissionsList($items, $userGroups); + /** @var GenreDAO $genreDao */ + $genreDao = DAORegistry::getDAO('GenreDAO'); + $genres = $genreDao->getByContextId($context->getId())->toArray(); + + $items = Repo::submission()->getSchemaMap()->mapManyToSubmissionsList($items, $userGroups, $genres); $myQueueListPanel = new \APP\components\listPanels\SubmissionsListPanel( SUBMISSIONS_LIST_MY_QUEUE, diff --git a/pages/decision/PKPDecisionHandler.inc.php b/pages/decision/PKPDecisionHandler.inc.php new file mode 100644 index 00000000000..74c6636686c --- /dev/null +++ b/pages/decision/PKPDecisionHandler.inc.php @@ -0,0 +1,241 @@ +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; + } + + $this->addPolicy(new ContextAccessPolicy($request, $roleAssignments)); + $this->addPolicy(new SubmissionRequiredPolicy($request, $args, 'submissionId')); + $this->addPolicy(new DecisionWritePolicy($request, $args, (int) $request->getUserVar('decision'), $request->getUser())); + + 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, + ]); + + $this->decisionType = $this->getAuthorizedContextObject(Application::ASSOC_TYPE_DECISION_TYPE); + $this->submission = $this->getAuthorizedContextObject(Application::ASSOC_TYPE_SUBMISSION); + + // Don't allow a decision unless the submission is at the correct stage + if ($this->submission->getData('stageId') !== $this->decisionType->getStageId()) { + $request->getDispatcher()->handle404(); + } + + // Don't allow a decision in a review stage unless there is a valid review round + if (in_array($this->decisionType->getStageId(), [WORKFLOW_STAGE_ID_INTERNAL_REVIEW, WORKFLOW_STAGE_ID_EXTERNAL_REVIEW])) { + $reviewRoundId = (int) $request->getUserVar('reviewRoundId'); + if (!$reviewRoundId) { + $request->getDispatcher()->handle404(); + } + $reviewRoundDao = DAORegistry::getDAO('ReviewRoundDAO'); /** @var ReviewRoundDAO $reviewRoundDao */ + $this->reviewRound = $reviewRoundDao->getById($reviewRoundId); + if (!$this->reviewRound || $this->reviewRound->getSubmissionId() !== $this->submission->getId()) { + $request->getDispatcher()->handle404(); + } + } + + // Don't allow a recommendation unless at least one deciding editor exists + if (Repo::decision()->isRecommendation($this->decisionType->getDecision())) { + /** @var StageAssignmentDAO $stageAssignmentDao */ + $stageAssignmentDao = DAORegistry::getDAO('StageAssignmentDAO'); + $assignedEditorIds = $stageAssignmentDao->getDecidingEditorIds($this->submission->getId(), $this->decisionType->getStageId()); + if (!$assignedEditorIds) { + $request->getDispatcher()->handle404(); + } + } + + $workflow = $this->decisionType->getWorkflow( + $this->submission, + $context, + $request->getUser(), + $this->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($this->submission), + 'emailTemplatesApiUrl' => $dispatcher->url( + $request, + Application::ROUTE_API, + $context->getData('urlPath'), + 'emailTemplates' + ), + 'fileGenres' => $this->getFileGenres($context), + 'keepWorkingLabel' => __('common.keepWorking'), + 'reviewRoundId' => $this->reviewRound ? $this->reviewRound->getId() : null, + 'stepErrorMessage' => __('editor.decision.stepError'), + 'stageId' => $this->submission->getStageId(), + 'submissionUrl' => $dispatcher->url( + $request, + Application::ROUTE_PAGE, + $context->getData('urlPath'), + 'workflow', + 'access', + [$this->submission->getId()] + ), + 'submissionApiUrl' => $dispatcher->url( + $request, + Application::ROUTE_API, + $context->getData('urlPath'), + 'submissions/' . $this->submission->getId() + ), + 'viewSubmissionLabel' => __('submission.list.viewSubmission'), + 'workflow' => $workflow->getState(), + ]); + + $templateMgr->assign([ + 'breadcrumbs' => $this->getBreadcrumb($this->submission, $context, $request, $dispatcher), + 'decisionType' => $this->decisionType, + 'pageWidth' => TemplateManager::PAGE_WIDTH_WIDE, + 'reviewRound' => $this->reviewRound, + 'submission' => $this->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 67ea50234fe..5b660f54c4c 100644 --- a/pages/workflow/PKPWorkflowHandler.inc.php +++ b/pages/workflow/PKPWorkflowHandler.inc.php @@ -13,18 +13,20 @@ * @brief Handle requests for the submssion workflow. */ +use APP\decision\Decision; 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; @@ -196,6 +198,10 @@ public function index($args, $request) $canAccessEditorialHistory = true; } + /** @var GenreDAO $genreDao */ + $genreDao = DAORegistry::getDAO('GenreDAO'); + $genres = $genreDao->getByContextId($submission->getData('contextId'))->toArray(); + $supportedSubmissionLocales = $submissionContext->getSupportedSubmissionLocales(); $localeNames = AppLocale::getAllLocales(); $locales = array_map(function ($localeKey) use ($localeNames) { @@ -205,6 +211,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()); $contributorApiUrl = $request->getDispatcher()->url( @@ -221,6 +228,17 @@ public function index($args, $request) 'submissions/' . $submission->getId() . '/publications' ); + $decisionUrl = $request->url( + $submissionContext->getData('urlPath'), + 'decision', + 'record', + $submission->getId(), + [ + 'decision' => '__decision__', + 'reviewRoundId' => '__reviewRoundId__', + ] + ); + $editorialHistoryUrl = $request->getDispatcher()->url( $request, PKPApplication::ROUTE_COMPONENT, @@ -258,6 +276,8 @@ public function index($args, $request) $publicationLicenseForm = new PKP\components\forms\publication\PKPPublicationLicenseForm($latestPublicationApiUrl, $locales, $latestPublication, $submissionContext, $authorUserGroups); $titleAbstractForm = new PKP\components\forms\publication\PKPTitleAbstractForm($latestPublicationApiUrl, $locales, $latestPublication); $contributorForm = new PKP\components\forms\publication\PKPContributorForm($contributorApiUrl, $locales, $submissionContext); + $selectRevisionDecisionForm = new PKP\components\forms\decision\SelectRevisionDecisionForm(); + $selectRevisionRecommendationForm = new PKP\components\forms\decision\SelectRevisionRecommendationForm(); $authorItems = []; foreach ($latestPublication->getData('authors') as $contributor) { @@ -289,6 +309,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 @@ -308,7 +330,7 @@ public function index($args, $request) }); // Get full details of the working publication and the current publication - $mapper = Repo::publication()->getSchemaMap($submission, $authorUserGroups); + $mapper = Repo::publication()->getSchemaMap($submission, $authorUserGroups, $genres); $workingPublicationProps = $mapper->map($submission->getLatestPublication()); $currentPublicationProps = $submission->getLatestPublication()->getId() === $submission->getCurrentPublication()->getId() ? $workingPublicationProps @@ -319,12 +341,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(), $contributorsListPanel->id => $contributorsListPanel->getConfig(), + $citationsForm->id => $citationsForm->getConfig(), + $publicationLicenseForm->id => $publicationLicenseForm->getConfig(), + $titleAbstractForm->id => $titleAbstractForm->getConfig(), + $selectRevisionDecisionForm->id => $selectRevisionDecisionForm->getConfig(), + $selectRevisionRecommendationForm->id => $selectRevisionRecommendationForm->getConfig(), ], 'currentPublication' => $currentPublicationProps, + 'decisionUrl' => $decisionUrl, 'editorialHistoryUrl' => $editorialHistoryUrl, 'publicationFormIds' => [ FORM_CITATIONS, @@ -340,6 +365,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, @@ -519,6 +545,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 */ @@ -531,10 +558,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) { @@ -542,7 +568,7 @@ public function editorDecisionActions($args, $request) if (!$editorsStageAssignment->getRecommendOnly()) { $makeDecision = true; } else { - $recommendOnly = true; + $makeRecommendation = true; } } } @@ -550,7 +576,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()) { @@ -558,28 +584,33 @@ 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 = Repo::decision()->getMany( + Repo::decision() + ->getCollector() + ->filterBySubmissionIds([$submission->getId()]) + ->filterByStageIds([$stageId]) + ->filterByReviewRoundIds([$reviewRound->getId()]) + ->filterByEditorIds([$user->getId()]) + ); // Get the last recommendation foreach ($editorDecisions as $editorDecision) { - if (array_key_exists($editorDecision['decision'], $recommendationOptions)) { + if (Repo::decision()->isRecommendation($editorDecision->getData('decision'))) { if ($lastRecommendation) { - if ($editorDecision['dateDecided'] >= $lastRecommendation['dateDecided']) { + if ($editorDecision->getData('dateDecided') >= $lastRecommendation->getData('dateDecided')) { $lastRecommendation = $editorDecision; } } else { @@ -588,79 +619,45 @@ public function editorDecisionActions($args, $request) } } if ($lastRecommendation) { - $lastRecommendation = __($recommendationOptions[$lastRecommendation['decision']]); + $lastRecommendation = $this->getRecommendationLabel($lastRecommendation->getData('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 = Repo::decision()->getMany( + Repo::decision() + ->getCollector() + ->filterBySubmissionIds([$submission->getId()]) + ->filterByStageIds([$stageId]) + ->filterByReviewRoundIds([$reviewRound->getId()]) + ); // Get all recommendations $recommendations = []; foreach ($editorDecisions as $editorDecision) { - if (array_key_exists($editorDecision['decision'], $recommendationOptions)) { - if (array_key_exists($editorDecision['editorId'], $recommendations)) { - if ($editorDecision['dateDecided'] >= $recommendations[$editorDecision['editorId']]['dateDecided']) { - $recommendations[$editorDecision['editorId']] = ['dateDecided' => $editorDecision['dateDecided'], 'decision' => $editorDecision['decision']]; + if (Repo::decision()->isRecommendation($editorDecision->getData('decision'))) { + if (array_key_exists($editorDecision->getData('editorId'), $recommendations)) { + if ($editorDecision->getData('dateDecided') >= $recommendations[$editorDecision->getData('editorId')]['dateDecided']) { + $recommendations[$editorDecision->getData('editorId')] = ['dateDecided' => $editorDecision->getData('dateDecided'), 'decision' => $editorDecision->getData('decision')]; } } else { - $recommendations[$editorDecision['editorId']] = ['dateDecided' => $editorDecision['dateDecided'], 'decision' => $editorDecision['decision']]; + $recommendations[$editorDecision->getData('editorId')] = ['dateDecided' => $editorDecision->getData('dateDecided'), 'decision' => $editorDecision->getData('decision')]; } } } - $i = 0; + $allRecommendations = []; foreach ($recommendations as $recommendation) { - $allRecommendations .= $i == 0 ? __($recommendationOptions[$recommendation['decision']]) : __('common.commaListSeparator') . __($recommendationOptions[$recommendation['decision']]); - $i++; + $allRecommendations[] = $this->getRecommendationLabel($recommendation['decision']); } + $allRecommendations = join(__('common.commaListSeparator'), $allRecommendations); } } - // 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()) { @@ -694,14 +691,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, ]); @@ -823,6 +840,25 @@ protected function notificationOptionsByStage($user, $stageId, $contextId) return false; } + /** + * Get a label for a recommendation decision type + */ + protected function getRecommendationLabel(int $decisionType): string + { + switch ($decisionType) { + case Decision::RECOMMEND_PENDING_REVISIONS: + return 'editor.submission.decision.requestRevisions'; + case Decision::RECOMMEND_RESUBMIT: + return 'editor.submission.decision.resubmit'; + case Decision::RECOMMEND_ACCEPT: + return 'editor.submission.decision.accept'; + case Decision::RECOMMEND_DECLINE: + return 'editor.submission.decision.decline'; + } + + throw new Exception('Could not find label for unknown recommendation type.'); + } + // // Abstract protected methods. @@ -846,4 +882,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/context.json b/schemas/context.json index bc432cb58bb..ad9f00e769c 100644 --- a/schemas/context.json +++ b/schemas/context.json @@ -415,6 +415,14 @@ "nullable" ] }, + "notifyAllAuthors": { + "type": "boolean", + "default": true, + "description": "When enabled, all authors of a submission will receive a notification when an editorial decision is made regarding a submission. When disabled, only authors assigned to the submission will be notified.", + "validation": [ + "nullable" + ] + }, "numAnnouncementsHomepage": { "type": "integer", "validation": [ 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..54ceb2f1214 100644 --- a/schemas/submissionFile.json +++ b/schemas/submissionFile.json @@ -126,6 +126,17 @@ "type": "integer", "apiSummary": true }, + "genreIsPrimary": { + "type": "boolean", + "apiSummary": true, + "readOnly": true + }, + "genreName": { + "type": "string", + "multilingual": true, + "apiSummary": 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/modals/editorDecision/form/bccReviewers.tpl b/templates/controllers/modals/editorDecision/form/bccReviewers.tpl deleted file mode 100644 index 7c685784169..00000000000 --- a/templates/controllers/modals/editorDecision/form/bccReviewers.tpl +++ /dev/null @@ -1,21 +0,0 @@ -{** - * templates/controllers/modals/editorDecision/form/bccReviewers.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. - * - * Checkboxes to define which reviewer should receive a bcc copy of the message. - * - *} - -{if count($reviewers)} - {fbvFormSection title="submission.comments.sendToReviewers"} - {translate key="submission.comments.sendCopyToReviewers"} -
    - {foreach from=$reviewers item="name" key="id"} - {fbvElement type="checkbox" id="bccReviewers[]" value=$id checked=in_array($id, $selected) label=$name|escape translate=false} - {/foreach} -
- {/fbvFormSection} -{/if} diff --git a/templates/controllers/modals/editorDecision/form/initiateExternalReviewForm.tpl b/templates/controllers/modals/editorDecision/form/initiateExternalReviewForm.tpl deleted file mode 100644 index bf66f2aa2e8..00000000000 --- a/templates/controllers/modals/editorDecision/form/initiateExternalReviewForm.tpl +++ /dev/null @@ -1,29 +0,0 @@ -{** - * templates/controllers/modals/editorDecision/form/initiateExternalReviewForm.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. - * - * Form used to initiate the first review round. - * - *} - - -
-

{translate key="editor.submission.externalReviewDescription"}

- - {csrf} - - - - - {capture assign=filesForReviewUrl}{url router=\PKP\core\PKPApplication::ROUTE_COMPONENT component="grid.files.submission.SelectableSubmissionDetailsFilesGridHandler" op="fetchGrid" submissionId=$submissionId stageId=$stageId escape=false}{/capture} - {load_url_in_div id="filesForReviewGrid" url=$filesForReviewUrl} - {fbvFormButtons submitText="editor.submission.decision.sendExternalReview"} -
diff --git a/templates/controllers/modals/editorDecision/form/newReviewRoundForm.tpl b/templates/controllers/modals/editorDecision/form/newReviewRoundForm.tpl deleted file mode 100644 index c727d5698c7..00000000000 --- a/templates/controllers/modals/editorDecision/form/newReviewRoundForm.tpl +++ /dev/null @@ -1,32 +0,0 @@ -{** - * templates/controllers/modals/editorDecision/form/newReviewRoundForm.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. - * - * Form used to create a new review round (after the first round) - * - *} - - -
-

{translate key="editor.submission.newRoundDescription"}

- - {csrf} - - - - - - - {capture assign=newRoundRevisionsUrl}{url router=\PKP\core\PKPApplication::ROUTE_COMPONENT component="grid.files.review.SelectableReviewRevisionsGridHandler" op="fetchGrid" submissionId=$submissionId stageId=$stageId reviewRoundId=$reviewRoundId escape=false}{/capture} - {load_url_in_div id="newRoundRevisionsGrid" url=$newRoundRevisionsUrl} - - {fbvFormButtons submitText="editor.submission.createNewRound"} -
diff --git a/templates/controllers/modals/editorDecision/form/promoteForm.tpl b/templates/controllers/modals/editorDecision/form/promoteForm.tpl deleted file mode 100644 index d9da478304f..00000000000 --- a/templates/controllers/modals/editorDecision/form/promoteForm.tpl +++ /dev/null @@ -1,131 +0,0 @@ -{** - * templates/controllers/modals/editorDecision/form/promoteForm.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. - * - * Form used to send reviews to author - * - *} - - - -
- {csrf} - - - - - -
- {if array_key_exists('help', $decisionData)} -

{translate key=$decisionData.help}

- {/if} - - {capture assign="sendEmailLabel"}{translate key="editor.submissionReview.sendEmail" authorName=$authorName|escape}{/capture} - {if $skipEmail} - {assign var="skipEmailSkip" value=true} - {else} - {assign var="skipEmailSend" value=true} - {/if} - {fbvFormSection title="common.sendEmail"} -
    - {fbvElement type="radio" id="skipEmail-send" name="skipEmail" value="0" checked=$skipEmailSend label=$sendEmailLabel translate=false} - {fbvElement type="radio" id="skipEmail-skip" name="skipEmail" value="1" checked=$skipEmailSkip label="editor.submissionReview.skipEmail"} -
- {/fbvFormSection} - -
- {* Message to author textarea *} - {fbvFormSection for="personalMessage"} - {fbvElement type="textarea" name="personalMessage" id="personalMessage" value=$personalMessage rich=true variables=$allowedVariables variablesType=$allowedVariablesType} - {/fbvFormSection} - - {* Button to add reviews to the email automatically *} - {if $reviewsAvailable} - {fbvFormSection} - - - {translate key="submission.comments.addReviews"} - - {/fbvFormSection} - {/if} - - {if isset($reviewers)} - {include file="controllers/modals/editorDecision/form/bccReviewers.tpl" - reviewers=$reviewers - selected=$bccReviewers - } - {/if} -
- - {if $decisionData.paymentType} - {fbvFormSection title="common.payment"} -
    - {fbvElement type="radio" id="requestPayment-request" name="requestPayment" value="1" checked=$requestPayment|compare:1 label=$decisionData.requestPaymentText translate=false} - {fbvElement type="radio" id="requestPayment-waive" name="requestPayment" value="0" checked=$requestPayment|compare:0 label=$decisionData.waivePaymentText translate=false} -
- {/fbvFormSection} - {/if} - - {** Some decisions can be made before review is initiated (i.e. no attachments). **} - {if $reviewRoundId} -
- {capture assign=reviewAttachmentsGridUrl}{url router=\PKP\core\PKPApplication::ROUTE_COMPONENT component="grid.files.attachment.EditorSelectableReviewAttachmentsGridHandler" op="fetchGrid" submissionId=$submissionId stageId=$stageId reviewRoundId=$reviewRoundId escape=false}{/capture} - {load_url_in_div id="reviewAttachmentsGridContainer" url=$reviewAttachmentsGridUrl} -
- {/if} - -
- {capture assign=libraryAttachmentsGridUrl}{url router=\PKP\core\PKPApplication::ROUTE_COMPONENT component="grid.files.SelectableLibraryFileGridHandler" op="fetchGrid" submissionId=$submissionId escape=false}{/capture} - {capture assign=libraryAttachmentsGrid}{load_url_in_div id="libraryFilesAttachmentsGridContainer" url=$libraryAttachmentsGridUrl}{/capture} - {include file="controllers/extrasOnDemand.tpl" - id="libraryFileAttachmentsExtras" - widgetWrapper="#libraryFileAttachments" - moreDetailsText="settings.libraryFiles.public.selectLibraryFiles" - lessDetailsText="settings.libraryFiles.public.selectLibraryFiles" - extraContent=$libraryAttachmentsGrid - } -
-
- -
- {capture assign="stageName"}{translate key=$decisionData.toStage}{/capture} -

{translate key="editor.submission.decision.selectFiles" stageName=$stageName}

- {* Show a different grid depending on whether we're in review or before the review stage *} - {if $stageId == $smarty.const.WORKFLOW_STAGE_ID_SUBMISSION} - {capture assign=filesToPromoteGridUrl}{url router=\PKP\core\PKPApplication::ROUTE_COMPONENT component="grid.files.submission.SelectableSubmissionDetailsFilesGridHandler" op="fetchGrid" submissionId=$submissionId stageId=$stageId escape=false}{/capture} - {elseif $reviewRoundId} - {** a set $reviewRoundId var implies we are INTERNAL_REVIEW or EXTERNAL_REVIEW **} - {capture assign=filesToPromoteGridUrl}{url router=\PKP\core\PKPApplication::ROUTE_COMPONENT component="grid.files.review.SelectableReviewRevisionsGridHandler" op="fetchGrid" submissionId=$submissionId stageId=$stageId reviewRoundId=$reviewRoundId escape=false}{/capture} - {elseif $stageId == $smarty.const.WORKFLOW_STAGE_ID_EDITING} - {capture assign=filesToPromoteGridUrl}{url router=\PKP\core\PKPApplication::ROUTE_COMPONENT component="grid.files.copyedit.SelectableCopyeditFilesGridHandler" op="fetchGrid" submissionId=$submissionId stageId=$stageId escape=false}{/capture} - {capture assign=draftFilesToPromoteGridUrl}{url router=\PKP\core\PKPApplication::ROUTE_COMPONENT component="grid.files.final.SelectableFinalDraftFilesGridHandler" op="fetchGrid" submissionId=$submissionId stageId=$stageId escape=false}{/capture} - {load_url_in_div id="draftFilesToPromoteGridUrl" url=$draftFilesToPromoteGridUrl} - {/if} - {load_url_in_div id="filesToPromoteGrid" url=$filesToPromoteGridUrl} -
- - {fbvFormSection class="formButtons form_buttons"} - - - {fbvElement type="submit" class="submitFormButton pkp_button_primary" id="promoteForm-complete-btn" label="editor.submissionReview.recordDecision"} - - {assign var=cancelButtonId value="cancelFormButton"|concat:"-"|uniqid} - {translate key="common.cancel"} - {/fbvFormSection} -
diff --git a/templates/controllers/modals/editorDecision/form/recommendationForm.tpl b/templates/controllers/modals/editorDecision/form/recommendationForm.tpl deleted file mode 100644 index 773ea5bfa22..00000000000 --- a/templates/controllers/modals/editorDecision/form/recommendationForm.tpl +++ /dev/null @@ -1,72 +0,0 @@ -{** - * templates/controllers/modals/editorDecision/form/recommendationForm.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. - * - * Form used to send the editor recommendation - * - *} - - - -
- {csrf} - - - - - {if !empty($editorRecommendations)} - {fbvFormSection label="editor.submission.recordedRecommendations"} - {foreach from=$editorRecommendations item=editorRecommendation} -
- {translate key="submission.round" round=$editorRecommendation.round} ({$editorRecommendation.dateDecided|date_format:$datetimeFormatShort}): {translate key=$recommendationOptions[$editorRecommendation.decision]} -
- {/foreach} - {/fbvFormSection} - {/if} - - {fbvFormSection label="editor.submission.recommendation" description=$description|default:"editor.submission.recommendation.description"} - {fbvElement type="select" id="recommendation" name="recommendation" from=$recommendationOptions selected=$recommendation size=$fbvStyles.size.MEDIUM required=$required|default:true disabled=$readOnly} - {/fbvFormSection} - - {capture assign="sendEmailLabel"}{translate key="editor.submissionReview.sendEmail.editors" editorNames=$editors}{/capture} - {if $skipEmail} - {assign var="skipEmailSkip" value=true} - {else} - {assign var="skipEmailSend" value=true} - {/if} - {fbvFormSection title="editor.submissionReview.recordRecommendation.notifyEditors"} -
    - {fbvElement type="radio" id="skipEmail-send" name="skipEmail" value="0" checked=$skipEmailSend label=$sendEmailLabel translate=false} - {fbvElement type="radio" id="skipEmail-skip" name="skipEmail" value="1" checked=$skipEmailSkip label="editor.submissionReview.skipEmail"} -
- {/fbvFormSection} - - {if $skipDiscussion} - {assign var="skipDiscussionSkip" value=true} - {else} - {assign var="skipDiscussionSend" value=true} - {/if} - {fbvFormSection} -
    - {fbvElement type="radio" id="skipDiscussion-send" name="skipDiscussion" value="0" checked=$skipDiscussionSend label="editor.submissionReview.recordRecommendation.createDiscussion"} - {fbvElement type="radio" id="skipDiscussion-skip" name="skipDiscussion" value="1" checked=$skipDiscussionSkip label="editor.submissionReview.recordRecommendation.skipDiscussion"} -
- {/fbvFormSection} - -
- {fbvFormSection for="personalMessage"} - {fbvElement type="textarea" name="personalMessage" id="personalMessage" value=$personalMessage rich=true variables=$allowedVariables variablesType=$allowedVariablesType} - {/fbvFormSection} -
- - {fbvFormButtons submitText="editor.submissionReview.recordRecommendation"} -
diff --git a/templates/controllers/modals/editorDecision/form/revertDeclineForm.tpl b/templates/controllers/modals/editorDecision/form/revertDeclineForm.tpl deleted file mode 100644 index 31cdcbf1849..00000000000 --- a/templates/controllers/modals/editorDecision/form/revertDeclineForm.tpl +++ /dev/null @@ -1,30 +0,0 @@ -{** - * templates/controllers/modals/editorDecision/form/revertDeclineForm.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. - * - * Form used revert a decline decision - * - *} - - -
- {csrf} - - - - - - -

{translate key="editor.submission.revertDeclineDescription"}

- - {fbvFormButtons submitText="editor.submission.decision.revertDecline"} - -
\ No newline at end of file diff --git a/templates/controllers/modals/editorDecision/form/sendReviewsForm.tpl b/templates/controllers/modals/editorDecision/form/sendReviewsForm.tpl deleted file mode 100644 index 7a470835090..00000000000 --- a/templates/controllers/modals/editorDecision/form/sendReviewsForm.tpl +++ /dev/null @@ -1,101 +0,0 @@ -{** - * templates/controllers/modals/editorDecision/form/sendReviewsForm.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 Form used to send reviews to author - * - * @uses $revisionsEmail string Email body for requesting revisions that don't - * require another round of review. - * @uses $resubmitEmail string Email body for asking the author to resubmit for - * another round of review. - *} - - -
- {csrf} - - - - - {* Set the decision or allow the decision to be selected *} - {if $decision != \APP\workflow\EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_PENDING_REVISIONS && $decision != \APP\workflow\EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_RESUBMIT} - - {else} - {if $decision == \APP\workflow\EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_PENDING_REVISIONS} - {assign var="checkedRevisions" value="1"} - {elseif $decision == \APP\workflow\EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_RESUBMIT} - {assign var="checkedResubmit" value="1"} - {/if} - {fbvFormSection title="editor.review.newReviewRound"} -
    - {fbvElement type="radio" id="decisionRevisions" name="decision" value=\APP\workflow\EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_PENDING_REVISIONS checked=$checkedRevisions label="editor.review.NotifyAuthorRevisions"} - {fbvElement type="radio" id="decisionResubmit" name="decision" value=\APP\workflow\EditorDecisionActionsManager::SUBMISSION_EDITOR_DECISION_RESUBMIT checked=$checkedResubmit label="editor.review.NotifyAuthorResubmit"} -
- {/fbvFormSection} - {/if} - - {capture assign="sendEmailLabel"}{translate key="editor.submissionReview.sendEmail" authorName=$authorName|escape}{/capture} - {if $skipEmail} - {assign var="skipEmailSkip" value=true} - {else} - {assign var="skipEmailSend" value=true} - {/if} - {fbvFormSection title="common.sendEmail"} -
    - {fbvElement type="radio" id="skipEmail-send" name="skipEmail" value="0" checked=$skipEmailSend label=$sendEmailLabel translate=false} - {fbvElement type="radio" id="skipEmail-skip" name="skipEmail" value="1" checked=$skipEmailSkip label="editor.submissionReview.skipEmail"} -
- {/fbvFormSection} - -
- {* Message to author textarea *} - {fbvFormSection for="personalMessage"} - {fbvElement type="textarea" name="personalMessage" id="personalMessage" value=$personalMessage rich=true variables=$allowedVariables variablesType=$allowedVariablesType} - {/fbvFormSection} - - {* Button to add reviews to the email automatically *} - {if $reviewsAvailable} - {fbvFormSection} - - - {translate key="submission.comments.addReviews"} - - {/fbvFormSection} - {/if} - - {if isset($reviewers)} - {include file="controllers/modals/editorDecision/form/bccReviewers.tpl" - reviewers=$reviewers - selected=$bccReviewers - } - {/if} -
- - {** Some decisions can be made before review is initiated (i.e. no attachments). **} - {if $reviewRoundId} -
- {capture assign=reviewAttachmentsGridUrl}{url router=\PKP\core\PKPApplication::ROUTE_COMPONENT component="grid.files.attachment.EditorSelectableReviewAttachmentsGridHandler" op="fetchGrid" submissionId=$submissionId stageId=$stageId reviewRoundId=$reviewRoundId escape=false}{/capture} - {load_url_in_div id="reviewAttachmentsGridContainer" url=$reviewAttachmentsGridUrl} -
- {/if} - - {fbvFormButtons submitText="editor.submissionReview.recordDecision"} -
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..2017d18cd2f --- /dev/null +++ b/templates/decision/record.tpl @@ -0,0 +1,189 @@ +{** + * 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}); -
    +