diff --git a/classes/stageAssignment/StageAssignmentDAO.php b/classes/stageAssignment/StageAssignmentDAO.php index c80a49a4d1d..39bc5853797 100644 --- a/classes/stageAssignment/StageAssignmentDAO.php +++ b/classes/stageAssignment/StageAssignmentDAO.php @@ -19,6 +19,9 @@ namespace PKP\stageAssignment; use APP\facades\Repo; +use Illuminate\Database\Query\Builder; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\LazyCollection; use PKP\core\Core; use PKP\db\DAOResultFactory; use PKP\security\Role; @@ -436,6 +439,58 @@ public function getBaseQueryForAssignmentSelection() return 'SELECT ugs.stage_id AS stage_id, sa.* FROM stage_assignments sa JOIN user_group_stage ugs ON sa.user_group_id = ugs.user_group_id '; } + + /** + * Get active stage assignments by submission IDs + */ + public function getCurrentBySubmissionIds(array $submissionIds, array $roleIds = []): LazyCollection + { + $select = $this->getSelect() + ->join('submissions AS s', 'sa.submission_id', '=', 's.submission_id') + ->when(!empty($roleIds), fn (Builder $q) => $q + ->leftJoin('user_groups AS ug', 'sa.user_group_id', '=', 'ug.user_group_id') + ->whereIn('ug.role_id', $roleIds)) + ->whereIn('sa.submission_id', $submissionIds) + ->whereColumn('ugs.stage_id', 's.stage_id'); + + $rows = $select->get(); + + return LazyCollection::make(function () use ($rows) { + foreach ($rows as $row) { + yield $row->stage_assignment_id => $this->fromBuilderRow($row); + } + }); + } + + /** + * Basic select statement for stage assignments + */ + protected function getSelect(): Builder + { + return DB::table('stage_assignments AS sa') + ->select('sa.*') + ->selectRaw('ugs.stage_id as stage_id') + ->join('user_group_stage AS ugs', 'sa.user_group_id', '=', 'ugs.user_group_id'); + } + + /** + * Get a stage assignment from the results of the QueryBuilder query + */ + protected function fromBuilderRow(\stdClass $row): StageAssignment + { + $stageAssignment = $this->newDataObject(); + + $stageAssignment->setId((int) $row->stage_assignment_id); + $stageAssignment->setSubmissionId((int) $row->submission_id); + $stageAssignment->setUserId((int) $row->user_id); + $stageAssignment->setUserGroupId((int) $row->user_group_id); + $stageAssignment->setDateAssigned($row->date_assigned); + $stageAssignment->setStageId((int) $row->stage_id); + $stageAssignment->setRecommendOnly((bool) $row->recommend_only); + $stageAssignment->setCanChangeMetadata((bool) $row->can_change_metadata); + + return $stageAssignment; + } } if (!PKP_STRICT_MODE) { diff --git a/classes/submission/maps/Schema.php b/classes/submission/maps/Schema.php index bbabb9cffc6..c7296dd8e4a 100644 --- a/classes/submission/maps/Schema.php +++ b/classes/submission/maps/Schema.php @@ -23,11 +23,13 @@ use PKP\plugins\Hook; use PKP\plugins\PluginRegistry; use PKP\query\QueryDAO; +use PKP\security\Role; use PKP\services\PKPSchemaService; use PKP\stageAssignment\StageAssignment; use PKP\stageAssignment\StageAssignmentDAO; use PKP\submission\Genre; use PKP\submission\reviewAssignment\ReviewAssignment; +use PKP\submission\reviewRound\ReviewRound; use PKP\submission\reviewRound\ReviewRoundDAO; use PKP\submissionFile\SubmissionFile; use PKP\userGroup\UserGroup; @@ -47,9 +49,12 @@ class Schema extends \PKP\core\maps\Schema /** @var Genre[] The file genres in this context. */ public array $genres; - /** @var Enumerable The review assignments' data associated with the submission . */ + /** @var Enumerable Review assignments associated with submissions. */ public Enumerable $reviewAssignments; + /** @var Enumerable Stage assignments associated with submissions. */ + public Enumerable $stageAssignments; + /** * Get extra property names used in the submissions list * @@ -65,11 +70,15 @@ protected function getSubmissionsListProps(): array 'currentPublicationId', 'dateLastActivity', 'dateSubmitted', + 'editorAssigned', 'id', 'lastModified', 'publications', 'reviewAssignments', + 'reviewersNotAssigned', 'reviewRounds', + 'revisionsRequested', + 'revisionsSubmitted', 'stageId', 'stageName', 'stages', @@ -82,6 +91,10 @@ protected function getSubmissionsListProps(): array 'urlPublished', ]; + if (!empty($additionalProps = $this->appSpecificProps())) { + $props = array_merge($props, $additionalProps); + } + Hook::call('Submission::getSubmissionsListProps', [&$props]); return $props; @@ -94,12 +107,21 @@ protected function getSubmissionsListProps(): array * * @param LazyCollection $userGroups The user groups in this context * @param Genre[] $genres The file genres in this context + * @param ?Enumerable $reviewAssignments review assignments associated with a submission + * @param ?Enumerable $stageAssignments stage assignments associated with a submission */ - public function map(Submission $item, LazyCollection $userGroups, array $genres, ?Enumerable $reviewAssignments = null): array - { + public function map( + Submission $item, + LazyCollection $userGroups, + array $genres, + ?Enumerable $reviewAssignments = null, + ?Enumerable $stageAssignments = null + ): array { $this->userGroups = $userGroups; $this->genres = $genres; $this->reviewAssignments = $reviewAssignments ?? Repo::reviewAssignment()->getCollector()->filterBySubmissionIds([$item->getId()])->getMany(); + $this->stageAssignments = $stageAssignments ?? $this->getStageAssignmentsBySubmissions(collect([$item]), [Role::ROLE_ID_MANAGER, Role::ROLE_ID_SUB_EDITOR]); + return $this->mapByProperties($this->getProps(), $item); } @@ -110,12 +132,21 @@ public function map(Submission $item, LazyCollection $userGroups, array $genres, * * @param LazyCollection $userGroups The user groups in this context * @param Genre[] $genres The file genres in this context + * @param ?Enumerable $reviewAssignments review assignments associated with a submission + * @param ?Enumerable $stageAssignments stage assignments associated with a submission */ - public function summarize(Submission $item, LazyCollection $userGroups, array $genres, ?Enumerable $reviewAssignments = null): array - { + public function summarize( + Submission $item, + LazyCollection $userGroups, + array $genres, + ?Enumerable $reviewAssignments = null, + ?Enumerable $stageAssignments = null + ): array { $this->userGroups = $userGroups; $this->genres = $genres; $this->reviewAssignments = $reviewAssignments ?? Repo::reviewAssignment()->getCollector()->filterBySubmissionIds([$item->getId()])->getMany(); + $this->stageAssignments = $stageAssignments ?? $this->getStageAssignmentsBySubmissions(collect([$item]), [Role::ROLE_ID_MANAGER, Role::ROLE_ID_SUB_EDITOR]); + return $this->mapByProperties($this->getSummaryProps(), $item); } @@ -133,12 +164,22 @@ public function mapMany(Enumerable $collection, LazyCollection $userGroups, arra $this->userGroups = $userGroups; $this->genres = $genres; $this->reviewAssignments = Repo::reviewAssignment()->getCollector()->filterBySubmissionIds($collection->keys()->toArray())->getMany()->remember(); - $associatedReviewAssignments = $this->reviewAssignments->groupBy(function (ReviewAssignment $reviewAssignment, int $key) { - return $reviewAssignment->getData('submissionId'); - }); - return $collection->map(function ($item) use ($associatedReviewAssignments) { - return $this->map($item, $this->userGroups, $this->genres, $associatedReviewAssignments->get($item->getId())); - }); + + $associatedReviewAssignments = $this->reviewAssignments->groupBy(fn (ReviewAssignment $reviewAssignment, int $key) => + $reviewAssignment->getData('submissionId')); + $associatedStageAssignments = $this->stageAssignments->groupBy(fn (StageAssignment $stageAssignment, int $key) => + $stageAssignment->getData('submissionId')); + + return $collection->map( + fn ($item) => + $this->map( + $item, + $this->userGroups, + $this->genres, + $associatedReviewAssignments->get($item->getId()), + $associatedStageAssignments->get($item->getId()) + ) + ); } /** @@ -155,12 +196,27 @@ public function summarizeMany(Enumerable $collection, LazyCollection $userGroups $this->userGroups = $userGroups; $this->genres = $genres; $this->reviewAssignments = Repo::reviewAssignment()->getCollector()->filterBySubmissionIds($collection->keys()->toArray())->getMany()->remember(); - $associatedReviewAssignments = $this->reviewAssignments->groupBy(function (ReviewAssignment $reviewAssignment, int $key) { - return $reviewAssignment->getData('submissionId'); - }); - return $collection->map(function ($item) use ($associatedReviewAssignments) { - return $this->summarize($item, $this->userGroups, $this->genres, $associatedReviewAssignments->get($item->getId())); - }); + $this->stageAssignments = $this->getStageAssignmentsBySubmissions($collection, [Role::ROLE_ID_MANAGER, Role::ROLE_ID_SUB_EDITOR]); + + $associatedReviewAssignments = $this->reviewAssignments->groupBy( + fn (ReviewAssignment $reviewAssignment, int $key) => + $reviewAssignment->getData('submissionId') + ); + $associatedStageAssignment = $this->stageAssignments->groupBy( + fn (StageAssignment $stageAssignment, int $key) => + $stageAssignment->getData('submissionId') + ); + + return $collection->map( + fn ($item) => + $this->summarize( + $item, + $this->userGroups, + $this->genres, + $associatedReviewAssignments->get($item->getId()), + $associatedStageAssignment->get($item->getId()) + ) + ); } /** @@ -168,13 +224,21 @@ public function summarizeMany(Enumerable $collection, LazyCollection $userGroups * * @param LazyCollection $userGroups The user groups in this context * @param Genre[] $genres The file genres in this context - * @param Collection $reviewAssignments review assignments associated with a submission + * @param ?Enumerable $reviewAssignments review assignments associated with a submission + * @param ?Enumerable $stageAssignments stage assignments associated with a submission */ - public function mapToSubmissionsList(Submission $item, LazyCollection $userGroups, array $genres, ?Enumerable $reviewAssignments = null): array - { + public function mapToSubmissionsList( + Submission $item, + LazyCollection $userGroups, + array $genres, + ?Enumerable $reviewAssignments = null, + ?Enumerable $stageAssignments = null, + ): array { $this->userGroups = $userGroups; $this->genres = $genres; $this->reviewAssignments = $reviewAssignments ?? Repo::reviewAssignment()->getCollector()->filterBySubmissionIds([$item->getId()])->getMany(); + $this->stageAssignments = $stageAssignments ?? $this->getStageAssignmentsBySubmissions(collect([$item]), [Role::ROLE_ID_MANAGER, Role::ROLE_ID_SUB_EDITOR]); + return $this->mapByProperties($this->getSubmissionsListProps(), $item); } @@ -192,12 +256,27 @@ public function mapManyToSubmissionsList(Enumerable $collection, LazyCollection $this->userGroups = $userGroups; $this->genres = $genres; $this->reviewAssignments = Repo::reviewAssignment()->getCollector()->filterBySubmissionIds($collection->keys()->toArray())->getMany()->remember(); - $associatedReviewAssignments = $this->reviewAssignments->groupBy(function (ReviewAssignment $reviewAssignment, int $key) { - return $reviewAssignment->getData('submissionId'); - }); - return $collection->map(function ($item) use ($associatedReviewAssignments) { - return $this->mapToSubmissionsList($item, $this->userGroups, $this->genres, $associatedReviewAssignments->get($item->getId())); - }); + $this->stageAssignments = $this->getStageAssignmentsBySubmissions($collection, [Role::ROLE_ID_MANAGER, Role::ROLE_ID_SUB_EDITOR]); + + $associatedReviewAssignments = $this->reviewAssignments->groupBy( + fn (ReviewAssignment $reviewAssignment, int $key) => + $reviewAssignment->getData('submissionId') + ); + $associatedStageAssignment = $this->stageAssignments->groupBy( + fn (StageAssignment $stageAssignment, int $key) => + $stageAssignment->getData('submissionId') + ); + + return $collection->map( + fn ($item) => + $this->mapToSubmissionsList( + $item, + $this->userGroups, + $this->genres, + $associatedReviewAssignments->get($item->getId()), + $associatedStageAssignment->get($item->getId()) + ) + ); } /** @@ -229,7 +308,10 @@ public function summarizeWithoutPublication(Submission $item): array $props = array_filter($this->getSummaryProps(), function ($prop) { return $prop !== 'publications'; }); + $this->reviewAssignments = Repo::reviewAssignment()->getCollector()->filterBySubmissionIds([$item->getId()])->getMany(); + $this->stageAssignments = $this->getStageAssignmentsBySubmissions(collect([$item]), [Role::ROLE_ID_MANAGER, Role::ROLE_ID_SUB_EDITOR]); + return $this->mapByProperties($props, $item); } @@ -250,20 +332,38 @@ protected function mapByProperties(array $props, Submission $submission): array $anonymize = $currentUserReviewAssignment && $currentUserReviewAssignment->getReviewMethod() === ReviewAssignment::SUBMISSION_REVIEW_METHOD_DOUBLEANONYMOUS; } + $reviewRounds = $this->getReviewRoundsFromSubmission($submission); + $currentReviewRound = $reviewRounds->sortKeys()->last(); /** @var ReviewRound|null $currentReviewRound */ + foreach ($props as $prop) { switch ($prop) { case '_href': $output[$prop] = Repo::submission()->getUrlApi($this->context, $submission->getId()); break; + case 'editorAssigned': + $output[$prop] = $this->getPropertyStageAssignments($this->stageAssignments); + break; case 'publications': $output[$prop] = Repo::publication()->getSchemaMap($submission, $this->userGroups, $this->genres) ->summarizeMany($submission->getData('publications'), $anonymize)->values(); break; + case 'recommendationsIn': + $output[$prop] = $currentReviewRound ? $this->areRecommendationsIn($currentReviewRound, $this->stageAssignments) : null; + break; case 'reviewAssignments': $output[$prop] = $this->getPropertyReviewAssignments($this->reviewAssignments); break; + case 'reviewersNotAssigned': + $output[$prop] = $currentReviewRound && $this->reviewAssignments->count() >= intval($this->context->getData('numReviewersPerSubmission')); + break; case 'reviewRounds': - $output[$prop] = $this->getPropertyReviewRounds($submission); + $output[$prop] = $this->getPropertyReviewRounds($reviewRounds); + break; + case 'revisionsRequested': + $output[$prop] = $currentReviewRound && $currentReviewRound->getData('status') == ReviewRound::REVIEW_ROUND_STATUS_REVISIONS_REQUESTED; + break; + case 'revisionsSubmitted': + $output[$prop] = $currentReviewRound && $currentReviewRound->getData('status') == ReviewRound::REVIEW_ROUND_STATUS_REVISIONS_SUBMITTED; break; case 'stageName': $output[$prop] = __(Application::get()->getWorkflowStageName($submission->getData('stageId'))); @@ -316,6 +416,7 @@ protected function getPropertyReviewAssignments(Enumerable $reviewAssignments): $reviews[] = [ 'id' => (int) $reviewAssignment->getId(), 'isCurrentUserAssigned' => $currentUser->getId() == (int) $reviewAssignment->getReviewerId(), + 'dateAssigned' => $reviewAssignment->getData('dateAssigned'), 'statusId' => (int) $reviewAssignment->getStatus(), 'status' => __($reviewAssignment->getStatusKey()), 'due' => $due, @@ -330,12 +431,11 @@ protected function getPropertyReviewAssignments(Enumerable $reviewAssignments): /** * Get details about the review rounds for a submission + * + * @param Collection $reviewRounds */ - protected function getPropertyReviewRounds(Submission $submission): array + protected function getPropertyReviewRounds(Collection $reviewRounds): array { - $reviewRoundDao = DAORegistry::getDAO('ReviewRoundDAO'); /** @var ReviewRoundDAO $reviewRoundDao */ - $reviewRounds = $reviewRoundDao->getBySubmissionId($submission->getId())->toIterator(); - $rounds = []; foreach ($reviewRounds as $reviewRound) { $rounds[] = [ @@ -491,6 +591,26 @@ public function getPropertyStages(Submission $submission): array return $stages; } + /** + * Check if deciding editors are assigned to the submission + * + * @param Enumerable $stageAssignments + */ + protected function getPropertyStageAssignments(Enumerable $stageAssignments): bool + { + if ($stageAssignments->isEmpty()) { + return false; + } + + foreach ($stageAssignments as $stageAssignment) { + if (!$stageAssignment->getRecommendOnly()) { + return true; + } + } + + return false; + } + protected function getUserGroup(int $userGroupId): ?UserGroup { /** @var UserGroup $userGroup */ @@ -501,4 +621,54 @@ protected function getUserGroup(int $userGroupId): ?UserGroup } return null; } + + /** + * + * @param Enumerable $submissions + * + * @return LazyCollection The collection of stage assignments associated with submissions + */ + protected function getStageAssignmentsBySubmissions(Enumerable $submissions, array $roleIds = []): LazyCollection + { + $submissionIds = $submissions->map(fn (Submission $submission) => $submission->getId())->toArray(); + + $stageAssignmentDao = DAORegistry::getDAO('StageAssignmentDAO'); /** @var StageAssignmentDAO $stageAssignmentDao */ + return $stageAssignmentDao->getCurrentBySubmissionIds($submissionIds, $roleIds)->remember(); + } + + /** + * @return Collection [round => ReviewRound] sorted by the round number + */ + protected function getReviewRoundsFromSubmission(Submission $submission): Collection + { + $reviewRoundDao = DAORegistry::getDAO('ReviewRoundDAO'); /** @var ReviewRoundDAO $reviewRoundDao */ + $reviewRounds = collect(); + foreach ($reviewRoundDao->getBySubmissionId($submission->getId())->toIterator() as $reviewRound) { + $reviewRounds->put($reviewRound->getData('round'), $reviewRound); + } + + return $reviewRounds; + } + + /** + * @return ?bool if there is one or more recommending editors assigned and all recommendations are given return true, + * otherwise returns false (recommendations are not yet given) or null (no recommending editors assigned) + */ + protected function areRecommendationsIn(ReviewRound $currentReviewRound, Enumerable $stageAssignments): ?bool + { + $hasDecidingEditors = $stageAssignments->first(fn (StageAssignment $stageAssignment) => $stageAssignment->getData('recommendOnly')); + if (!$hasDecidingEditors) { + return null; + } + + return $currentReviewRound->getData('status') == ReviewRound::REVIEW_ROUND_STATUS_RECOMMENDATIONS_READY; + } + + /** + * Implement by a child class to add application-specific props to a submission + */ + protected function appSpecificProps(): array + { + return []; + } } diff --git a/schemas/submission.json b/schemas/submission.json index fe4bd266128..9f27900676d 100644 --- a/schemas/submission.json +++ b/schemas/submission.json @@ -48,6 +48,12 @@ "date:Y-m-d H:i:s" ] }, + "editorAssigned": { + "type": "boolean", + "description": "Whether deciding editor has been assigned to the submission.", + "readOnly": true, + "apiSummary": true + }, "id": { "type": "integer", "description": "The id of this submission.", @@ -80,6 +86,15 @@ "$ref": "#/definitions/Publication" } }, + "recommendationsIn": { + "type": "boolean", + "description": "Whether all recommending editors have submitted a recommendation.", + "apiSummary": true, + "readOnly": true, + "validation": [ + "nullable" + ] + }, "reviewRounds": { "type": "array", "description": "A list of review rounds that have been opened for this submission.", @@ -102,6 +117,12 @@ "type": "integer", "readOnly": true }, + "dateAssigned": { + "type": "integer", + "validation": [ + "date|Y-m-d H:i:s" + ] + }, "status": { "type": "integer", "readOnly": true @@ -131,6 +152,24 @@ } } }, + "reviewersNotAssigned": { + "type": "boolean", + "description": "Whether all needed reviewers are assigned to the submission. Related to the `numReviewersPerSubmission` context setting.", + "apiSummary": true, + "readOnly": true + }, + "revisionsRequested": { + "type": "boolean", + "description": "There is a revisions requested decision in the current review round and no revisions have been uploaded to that round.", + "apiSummary": true, + "readOnly": true + }, + "revisionsSubmitted": { + "type": "boolean", + "description": "There is a revisions requested decision in the current review round and 1 or more revision files have been uploaded to that round.", + "apiSummary": true, + "readOnly": true + }, "stageId": { "type": "integer", "description": "The stage of the editorial workflow that this submission is currently in. One of the `WORKFLOW_STAGE_ID_` constants. Default is `WORKFLOW_STAGE_ID_SUBMISSION`.",