diff --git a/api/v1/peerReviews/peerReviewController.php b/api/v1/peerReviews/peerReviewController.php new file mode 100644 index 00000000000..a9d5be6a059 --- /dev/null +++ b/api/v1/peerReviews/peerReviewController.php @@ -0,0 +1,262 @@ +addPolicy(new PublicReviewsEnabledPolicy($request->getContext())); + return parent::authorize($request, $args, $roleAssignments); + } + + /** + * @inheritdoc + */ + public function getHandlerPath(): string + { + return 'peerReviews'; + } + + /** + * @inheritdoc + */ + public function getRouteGroupMiddleware(): array + { + return [ + 'has.context', + ]; + } + + /** + * @inheritdoc + */ + public function getGroupRoutes(): void + { + Route::prefix('open/publications')->group(function () { + Route::get('/', $this->getManyOpenReviews(...)) + ->name('peerReviews.getManyOpenReviews'); + + Route::get('summary', $this->getManyPublicationReviewSummaries(...)) + ->name('peerReviews.publication.summary.getMany'); + + Route::get('{publicationId}', $this->getOpenReview(...)) + ->name('peerReviews.get') + ->whereNumber('publicationId'); + + Route::get('{publicationId}/summary', $this->getPublicationReviewSummary(...)) + ->name('peerReviews.publication.summary.get') + ->whereNumber('publicationId'); + }); + + Route::prefix('open/submissions')->group(function () { + Route::get('{submissionId}/summary', $this->getSubmissionPeerReviewSummary(...)) + ->name('peerReviews.open.submissions.summary.get') + ->whereNumber('submissionId'); + + Route::get('summary', $this->getManySubmissionPeerReviewSummary(...)) + ->name('eerReviews.open.submissions.summary.getMany'); + }); + } + /** + * Get peer review for a list of publications + * Filters available via query params: + * ``` + * publicationIds(array, required) - publication IDs to retrieve peer review data for. + * ``` + */ + public function getManyOpenReviews(Request $illuminateRequest): JsonResponse + { + $publicationIdsRaw = paramToArray($illuminateRequest->query('publicationIds', [])); + $publicationIds = []; + + foreach ($publicationIdsRaw as $id) { + if (!filter_var($id, FILTER_VALIDATE_INT)) { + return response()->json([ + 'error' => __('api.publication.400.invalidPublicationId', ['publicationId' => $id]) + ], Response::HTTP_BAD_REQUEST); + } + + $publicationIds[] = (int)$id; + } + + $publications = Repo::publication()->getCollector() + ->filterByPublicationIds($publicationIds) + ->getMany(); + + if ($publications->count() != count($publicationIds)) { + return response()->json([ + 'error' => __('api.404.resourceNotFound'), + ], Response::HTTP_NOT_FOUND); + } + + return response()->json( + Repo::publication()->getPeerReviews($publications->all()), + Response::HTTP_OK + ); + } + + /** + * Get peer review for a publication by ID + */ + public function getOpenReview(Request $illuminateRequest): JsonResponse + { + $publicationId = (int)$illuminateRequest->route('publicationId'); + $publication = Repo::publication()->get($publicationId); + + if (!$publication) { + return response()->json([ + 'error' => __('api.404.resourceNotFound'), + ], Response::HTTP_NOT_FOUND); + } + + return response()->json( + Repo::publication()->getPeerReviews([$publication])->first(), + Response::HTTP_OK + ); + } + + /** + * Get peer review summary by publication ID + */ + public function getPublicationReviewSummary(Request $illuminateRequest): JsonResponse + { + $publicationId = (int)$illuminateRequest->route('publicationId'); + $publication = Repo::publication()->get($publicationId); + + if (!$publication) { + return response()->json([ + 'error' => __('api.404.resourceNotFound'), + ], Response::HTTP_NOT_FOUND); + } + + return response()->json( + new PublicationPeerReviewSummaryResource($publication), + Response::HTTP_OK + ); + } + + /** + * Get peer review summaries for a list of publication IDs + */ + public function getManyPublicationReviewSummaries(Request $illuminateRequest): JsonResponse + { + $publicationIdsRaw = paramToArray($illuminateRequest->query('publicationIds', [])); + $publicationIds = []; + + foreach ($publicationIdsRaw as $id) { + if (!filter_var($id, FILTER_VALIDATE_INT)) { + return response()->json([ + 'error' => __('api.publication.400.invalidPublicationId', ['publicationId' => $id]) + ], Response::HTTP_BAD_REQUEST); + } + + $publicationIds[] = (int)$id; + } + + $publications = Repo::publication()->getCollector() + ->filterByPublicationIds($publicationIds) + ->getMany(); + + if ($publications->count() != count($publicationIds)) { + return response()->json([ + 'error' => __('api.404.resourceNotFound'), + ], Response::HTTP_NOT_FOUND); + } + + return response()->json( + PublicationPeerReviewSummaryResource::collection($publications), + Response::HTTP_OK + ); + } + + /** + * Get peer review summary by submission ID. + */ + public function getSubmissionPeerReviewSummary(Request $illuminateRequest): JsonResponse + { + $request = Application::get()->getRequest(); + $context = $request->getContext(); + + $submissionId = (int)$illuminateRequest->route('submissionId'); + $submission = Repo::submission()->get($submissionId, $context->getId()); + + if (!$submission) { + return response()->json([ + 'error' => __('api.404.resourceNotFound'), + ], Response::HTTP_NOT_FOUND); + } + + return response()->json( + new SubmissionPeerReviewSummaryResource($submission), + Response::HTTP_OK + ); + } + + /** + * Get peer review summaries for a list of submission IDs + */ + public function getManySubmissionPeerReviewSummary(Request $illuminateRequest): JsonResponse + { + $submissionIdsRaw = paramToArray($illuminateRequest->query('submissionIds', [])); + $submissionIds = []; + + $request = Application::get()->getRequest(); + $context = $request->getContext(); + + foreach ($submissionIdsRaw as $id) { + if (!filter_var($id, FILTER_VALIDATE_INT)) { + return response()->json([ + 'error' => __('api.submission.400.invalidSubmissionId', ['submissionId' => $id]) + ], Response::HTTP_BAD_REQUEST); + } + + $submissionIds[] = (int)$id; + } + + $submissions = Repo::submission()->getCollector() + ->filterByContextIds([$context->getId()]) + ->filterBySubmissionIds($submissionIds)->getMany(); + + if ($submissions->count() != count($submissionIds)) { + return response()->json([ + 'error' => __('api.404.resourceNotFound'), + ], Response::HTTP_NOT_FOUND); + } + + return response()->json( + SubmissionPeerReviewSummaryResource::collection($submissions), + Response::HTTP_OK + ); + } +} diff --git a/api/v1/peerReviews/resources/BasePeerReviewResource.php b/api/v1/peerReviews/resources/BasePeerReviewResource.php new file mode 100644 index 00000000000..9a80cbb6843 --- /dev/null +++ b/api/v1/peerReviews/resources/BasePeerReviewResource.php @@ -0,0 +1,91 @@ +groupBy(fn (ReviewAssignment $ra) => $ra->getReviewRoundId()) + ->map( + fn ($assignments) => + $assignments->filter(fn (ReviewAssignment $ra) => !!$ra->getDateCompleted()) + ) + ->sortKeys(); + + return $this->getSummaryCountForReviewerRecommendation($reviewAssignmentsGroupedByRoundId, $context); + } + + /** + * Get the summary count for reviews. + */ + private function getSummaryCountForReviewerRecommendation(Enumerable $reviewAssignmentsGroupedByRoundId, Context $context): array + { + $responses = collect(); + + $availableRecommendationsGroupedByType = ReviewerRecommendation::withContextId($context->getId())->get(); + + foreach ($reviewAssignmentsGroupedByRoundId as $reviews) { + /** @var ReviewAssignment $review */ + foreach ($reviews as $review) { + // For each review in each round, record the reviewer's decision, overriding any decision from previous rounds, keeping their latest recommendation + $responses->put( + $review->getReviewerId(), + $availableRecommendationsGroupedByType->get($review->getReviewerRecommendationId())->type, + ); + } + } + + return $this->buildSummaryCount($responses->countBy(), $context); + } + + /** + * Tally review recommendations for each Recommendation type + */ + private function buildSummaryCount(Enumerable $reviewerResponseCount, Context $context): array + { + $summary = []; + + $recTypes = ReviewerRecommendation::withContextId($context->getId()) + ->get() + ->groupBy('type'); + $recommendationTypeLabels = Repo::reviewerRecommendation()->getRecommendationTypeLabels(); + foreach ($recTypes as $typeId => $recommendation) { + $summary[] = [ + 'recommendationTypeId' => $typeId, + 'recommendationTypeLabel' => $recommendationTypeLabels[$typeId], + 'count' => $reviewerResponseCount->get($typeId, 0), + ]; + } + return $summary; + } +} diff --git a/api/v1/publicationPeerReviews/resources/PublicationPeerReviewResource.php b/api/v1/peerReviews/resources/PublicationPeerReviewResource.php similarity index 86% rename from api/v1/publicationPeerReviews/resources/PublicationPeerReviewResource.php rename to api/v1/peerReviews/resources/PublicationPeerReviewResource.php index c9ffbb52280..ec3869ca9cd 100644 --- a/api/v1/publicationPeerReviews/resources/PublicationPeerReviewResource.php +++ b/api/v1/peerReviews/resources/PublicationPeerReviewResource.php @@ -1,7 +1,7 @@ resource; + $publicationReviewsData = $this->getPublicationPeerReview($publication); return [ 'publicationId' => $publication->getId(), 'datePublished' => $publication->getData('datePublished'), - 'reviewRounds' => $this->getPublicationPeerReview($publication) + 'reviewRounds' => $publicationReviewsData->get('roundsData'), + 'reviewerRecommendationsSummary' => $publicationReviewsData->get('reviewerRecommendationsSummary'), ]; } - - /** * Get public peer review data for a publication. * @@ -54,6 +53,8 @@ public function toArray(?\Illuminate\Http\Request $request = null) */ private function getPublicationPeerReview(Publication $publication): Enumerable { + $results = collect(); + // Check up the tree on source IDs $allAssociatedPublicationIds = Repo::publication()->getWithSourcePublicationsIds([$publication->getId()]); @@ -63,14 +64,25 @@ private function getPublicationPeerReview(Publication $publication): Enumerable $context = app()->get('context')->get( Repo::submission()->get($publication->getData('submissionId'))->getData('contextId') ); + $hasMultipleRounds = $reviewRounds->getCount() > 1; $roundsData = collect(); - while ($reviewRound = $reviewRounds->next()) { - $assignments = Repo::reviewAssignment() - ->getCollector() - ->filterByReviewRoundIds([$reviewRound->getData('id')]) - ->getMany(); + $reviewRoundsKeyedById = collect($reviewRounds->toArray())->keyBy(fn ($item) => $item->getId()); + $roundIds = $reviewRoundsKeyedById->keys()->all(); + unset($reviewRounds); + + $reviewAssignments = Repo::reviewAssignment() + ->getCollector() + ->filterByReviewRoundIds($roundIds) + ->getMany(); + + $reviewsGroupedByRoundId = $reviewAssignments + ->groupBy(fn (ReviewAssignment $ra) => $ra->getReviewRoundId()) + ->sortKeys(); + + foreach ($reviewsGroupedByRoundId as $roundId => $assignments) { + $reviewRound = $reviewRoundsKeyedById->get($roundId); $roundDisplayText = $hasMultipleRounds ? __('publication.versionStringWithRound', [ 'versionString' => $publication->getData('versionString'), @@ -86,7 +98,10 @@ private function getPublicationPeerReview(Publication $publication): Enumerable ]); } - return $roundsData; + + $results->put('roundsData', $roundsData); + $results->put('reviewerRecommendationsSummary', $this->getReviewerRecommendationsSummary($reviewAssignments, $context)); + return $results; } /** diff --git a/api/v1/peerReviews/resources/PublicationPeerReviewSummaryResource.php b/api/v1/peerReviews/resources/PublicationPeerReviewSummaryResource.php new file mode 100644 index 00000000000..e03c10701b1 --- /dev/null +++ b/api/v1/peerReviews/resources/PublicationPeerReviewSummaryResource.php @@ -0,0 +1,58 @@ +resource; + + $submission = Repo::submission()->get($publication->getData('submissionId')); + $contextDao = Application::getContextDAO(); + /** @var Context $context */ + $context = $contextDao->getById($submission->getData('contextId')); + + $allAssociatedPublicationIds = Repo::publication()->getWithSourcePublicationsIds([ $publication->getId()])->all(); + + // Include reviews from the Publication's Source Publication so that reviews that are to be copied forward are accounted for. + $reviewAssignments = Repo::reviewAssignment()->getCollector() + ->filterByPublicationIds($allAssociatedPublicationIds) + ->getMany(); + + $currentPublication = $submission->getCurrentPublication(); + $publishedPublications = $submission->getPublishedPublications(); + + return [ + 'publicationId' => $publication->getId(), + 'reviewerRecommendations' => $this->getReviewerRecommendationsSummary($reviewAssignments, $context), + // Number of published versions of the publication's submission + 'submissionPublishedVersionsCount' => count($publishedPublications), + // Latest published publication for the submission associated with this publication + 'submissionCurrentVersion' => $currentPublication ? [ + 'title' => $currentPublication->getLocalizedTitle(), + 'datePublished' => $currentPublication->getData('datePublished'), + ] : null + ]; + } +} diff --git a/api/v1/peerReviews/resources/SubmissionPeerReviewSummaryResource.php b/api/v1/peerReviews/resources/SubmissionPeerReviewSummaryResource.php new file mode 100644 index 00000000000..821375b986b --- /dev/null +++ b/api/v1/peerReviews/resources/SubmissionPeerReviewSummaryResource.php @@ -0,0 +1,49 @@ +resource; + $reviewAssignments = Repo::reviewAssignment()->getCollector() + ->filterBySubmissionIds([$submission->getId()]) + ->getMany(); + + $contextDao = Application::getContextDAO(); + /** @var Context $context */ + $context = $contextDao->getById($submission->getData('contextId')); + + $currentPublication = $submission->getCurrentPublication(); + return [ + 'submissionId' => $submission->getId(), + 'reviewerRecommendations' => $this->getReviewerRecommendationsSummary($reviewAssignments, $context), + 'submissionPublishedVersionsCount' => count($submission->getPublishedPublications()), + 'submissionCurrentVersion' => $currentPublication ? [ + 'title' => $currentPublication->getLocalizedTitle(), + 'datePublished' => $currentPublication->getData('datePublished'), + ] : null, + ]; + } +} diff --git a/api/v1/publicationPeerReviews/PublicationPeerReviewController.php b/api/v1/publicationPeerReviews/PublicationPeerReviewController.php deleted file mode 100644 index 7e3a7b6cb31..00000000000 --- a/api/v1/publicationPeerReviews/PublicationPeerReviewController.php +++ /dev/null @@ -1,127 +0,0 @@ -addPolicy(new PublicReviewsEnabledPolicy($request->getContext())); - return parent::authorize($request, $args, $roleAssignments); - } - - /** - * @inheritdoc - */ - public function getHandlerPath(): string - { - return 'publicationPeerReviews'; - } - - /** - * @inheritdoc - */ - public function getRouteGroupMiddleware(): array - { - return [ - 'has.context', - ]; - } - - /** - * @inheritdoc - */ - public function getGroupRoutes(): void - { - Route::prefix('open')->group(function () { - Route::get('/', $this->getManyOpenReviews(...)) - ->name('publicationPeerReviews.getManyOpenReviews'); - - Route::get('{publicationId}', $this->getOpenReview(...)) - ->name('publicationPeerReviews.get') - ->whereNumber('publicationId'); - }); - } - - /** - * Get peer review for a list of publications - * Filters available via query params: - * ``` - * publicationIds(array, required) - publication IDs to retrieve peer review data for. - * ``` - */ - public function getManyOpenReviews(Request $illuminateRequest): JsonResponse - { - $publicationIdsRaw = paramToArray($illuminateRequest->query('publicationIds', [])); - $publicationIds = []; - - foreach ($publicationIdsRaw as $id) { - if (!filter_var($id, FILTER_VALIDATE_INT)) { - return response()->json([ - 'error' => __('api.publication.400.invalidPublicationId', ['publicationId' => $id]) - ], Response::HTTP_BAD_REQUEST); - } - - $publicationIds[] = (int)$id; - } - - $publications = Repo::publication()->getCollector() - ->filterByPublicationIds($publicationIds) - ->getMany(); - - if ($publications->count() != count($publicationIds)) { - return response()->json([ - 'error' => __('api.404.resourceNotFound'), - ], Response::HTTP_NOT_FOUND); - } - - return response()->json( - Repo::publication()->getPeerReviews($publications->all()), - Response::HTTP_OK - ); - } - - /** - * Get peer review for a publication by ID - */ - public function getOpenReview(Request $illuminateRequest): JsonResponse - { - $publicationId = (int)$illuminateRequest->route('publicationId'); - $publication = Repo::publication()->get($publicationId); - - if (!$publication) { - return response()->json([ - 'error' => __('api.404.resourceNotFound'), - ], Response::HTTP_NOT_FOUND); - } - - return response()->json( - Repo::publication()->getPeerReviews([$publication])->first(), - Response::HTTP_OK - ); - } -} diff --git a/classes/publication/Repository.php b/classes/publication/Repository.php index 1409a9ad00c..8f909d7cb7d 100644 --- a/classes/publication/Repository.php +++ b/classes/publication/Repository.php @@ -24,7 +24,7 @@ use APP\submission\Submission; use Illuminate\Support\Collection; use Illuminate\Support\Enumerable; -use PKP\API\v1\publicationPeerReviews\resources\PublicationPeerReviewResource; +use PKP\API\v1\peerReviews\resources\PublicationPeerReviewResource; use PKP\context\Context; use PKP\core\Core; use PKP\core\PKPApplication; diff --git a/classes/submission/reviewAssignment/Collector.php b/classes/submission/reviewAssignment/Collector.php index eff56311555..fc6f5865f13 100644 --- a/classes/submission/reviewAssignment/Collector.php +++ b/classes/submission/reviewAssignment/Collector.php @@ -58,6 +58,7 @@ class Collector implements CollectorInterface, ViewsCount public ?string $orderByContextIdDirection = null; public bool $orderBySubmissionId = false; public ?string $orderBySubmissionIdDirection = null; + public ?array $publicationIds = null; public function __construct(DAO $dao) { @@ -239,6 +240,14 @@ public function filterByReviewFormIds(?array $reviewFormIds): static return $this; } + /** + * Filter review assignments by associated publication IDs. + */ + public function filterByPublicationIds(?array $publicationIds): static + { + $this->publicationIds = $publicationIds; + return $this; + } /** * Filter by recommendations */ @@ -324,6 +333,17 @@ public function getQueryBuilder(): Builder $q->whereIn('ra.submission_id', $this->submissionIds) ); + $q->when( + $this->publicationIds !== null, + fn (Builder $q) => $q + ->whereExists( + fn (Builder $sq) => $sq + ->from('review_rounds AS rr') + ->whereColumn('rr.review_round_id', 'ra.review_round_id') + ->whereIn('rr.publication_id', $this->publicationIds) + ) + ); + $q->when($this->isLastReviewRound || $this->isIncomplete, function (Builder $q) { $q // Aggregating data regarding latest review round and stage. For OMP the latest round isn't equal to the round with the highest number per submission diff --git a/classes/submission/reviewAssignment/Repository.php b/classes/submission/reviewAssignment/Repository.php index 2a74f0fca54..806ae64284f 100644 --- a/classes/submission/reviewAssignment/Repository.php +++ b/classes/submission/reviewAssignment/Repository.php @@ -1,4 +1,5 @@