diff options
| author | Marcus Eibrink-Lunzenauer <lunzenauer@elan-ev.de> | 2023-11-14 11:57:16 +0100 |
|---|---|---|
| committer | Marcus Eibrink-Lunzenauer <lunzenauer@elan-ev.de> | 2024-07-09 09:19:01 +0200 |
| commit | 62cc5d1f509b245159ffcbd0dbd08ab389e51615 (patch) | |
| tree | 84070ab147fdfa4ecb26767f42de7d1374a304c1 /lib | |
| parent | 2aa22a3decc515ef19681e3fbb303e395bfef6d4 (diff) | |
Add Peer Review on top of feature/better-tasks.feature/peerreview-6
Diffstat (limited to 'lib')
27 files changed, 1718 insertions, 6 deletions
diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php index e9a0a01..e43e6a7 100644 --- a/lib/classes/JsonApi/RouteMap.php +++ b/lib/classes/JsonApi/RouteMap.php @@ -563,6 +563,22 @@ class RouteMap $group->delete('/courseware-clipboards/{id}', Routes\Courseware\ClipboardsDelete::class); $group->post('/courseware-clipboards/{id}/insert', Routes\Courseware\ClipboardsInsert::class); + + $group->get('/courseware-peer-review-processes', Routes\Courseware\PeerReview\ProcessesIndex::class); + $group->get('/courseware-peer-review-processes/{id}', Routes\Courseware\PeerReview\ProcessesShow::class); + $group->get('/courseware-peer-review-processes/{id}/peer-reviews', Routes\Courseware\PeerReview\ReviewsOfProcessesIndex::class); + + $group->patch('/courseware-peer-review-processes/{id}', Routes\Courseware\PeerReview\ProcessesUpdate::class); + $group->delete('/courseware-peer-review-processes/{id}', Routes\Courseware\PeerReview\ProcessesDelete::class); + + $group->post('/courseware-peer-review-processes', Routes\Courseware\PeerReview\ProcessesCreate::class); + + $group->get('/courses/{id}/courseware-peer-reviews', Routes\Courseware\PeerReview\ReviewsIndex::class); + $group->get('/courseware-tasks/{id}/peer-reviews', Routes\Courseware\PeerReview\ReviewsByTaskIndex::class); + + $group->post('/courseware-peer-reviews', Routes\Courseware\PeerReview\ReviewsCreate::class); + $group->patch('/courseware-peer-reviews/{id}', Routes\Courseware\PeerReview\ReviewsUpdate::class); + $group->delete('/courseware-peer-reviews/{id}', Routes\Courseware\PeerReview\ReviewsDelete::class); } private function addAuthenticatedFilesRoutes(RouteCollectorProxy $group): void diff --git a/lib/classes/JsonApi/Routes/Courseware/Authority.php b/lib/classes/JsonApi/Routes/Courseware/Authority.php index 2acf83e..b5bee4a 100644 --- a/lib/classes/JsonApi/Routes/Courseware/Authority.php +++ b/lib/classes/JsonApi/Routes/Courseware/Authority.php @@ -8,6 +8,8 @@ use Courseware\BlockFeedback; use Courseware\Clipboard; use Courseware\Container; use Courseware\Instance; +use Courseware\PeerReview; +use Courseware\PeerReviewProcess; use Courseware\StructuralElement; use Courseware\StructuralElementComment; use Courseware\StructuralElementFeedback; @@ -324,7 +326,8 @@ class Authority public static function canShowTask(User $user, Task $resource): bool { - return self::canUpdateTask($user, $resource); + return ($resource->isPeerReviewed() && $resource->isPeerReviewedBy($user)) || + self::canUpdateTask($user, $resource); } public static function canIndexTasks(User $user): bool @@ -584,4 +587,109 @@ class Authority return $resource->user_id === $user->id; } + public static function canIndexPeerReviewProcesses(User $user): bool + { + return (bool) $user; + } + + public static function canShowPeerReviewProcess(User $user, PeerReviewProcess $process): bool + { + return $GLOBALS['perm']->have_studip_perm('user', $process->task_group['seminar_id'], $user->getId()); + } + + public static function canCreatePeerReviewProcesses(User $user, TaskGroup $taskGroup): bool + { + return $GLOBALS['perm']->have_studip_perm('tutor', $taskGroup['seminar_id'], $user->getId()); + } + + public static function canUpdatePeerReviewProcess(User $user, PeerReviewProcess $process): bool + { + return self::canCreatePeerReviewProcesses($user, $process->task_group); + } + + public static function canDeletePeerReviewProcess(User $user, PeerReviewProcess $process): bool + { + return self::canCreatePeerReviewProcess($user, $process->task_group); + } + + public static function canIndexPeerReviews(User $user) + { + // TODO: Reicht das? Werden die in der Route gefiltert? Brauchen das nur Lehrende? + return (bool) $user; + } + + public static function canShowPeerReview(User $user, PeerReview $review): bool + { + $cid = $review->process->task_group['seminar_id']; + if ($GLOBALS['perm']->have_studip_perm('tutor', $cid, $user->getId())) { + return true; + } + + return $review->isReviewer($user) || + ($review->isSubmitter($user) && $review->process->getCurrentState() === PeerReviewProcess::STATE_AFTER); + } + + public static function canShowPeerReviewReviewer(User $user, PeerReview $review): bool + { + $cid = $review->process->task_group['seminar_id']; + if ($GLOBALS['perm']->have_studip_perm('tutor', $cid, $user->getId())) { + return true; + } + + if ($review->isReviewer($user)) { + return true; + } + + return $review->isSubmitter($user) && !$review->isAnonymous(); + } + + public static function canShowPeerReviewSubmitter(User $user, PeerReview $review): bool + { + $cid = $review->process->task_group['seminar_id']; + if ($GLOBALS['perm']->have_studip_perm('tutor', $cid, $user->getId())) { + return true; + } + + if ($review->isSubmitter($user)) { + return true; + } + + return $review->isReviewer($user) && !$review->isAnonymous(); + } + + public static function canShowPeerReviewAssessment(User $user, PeerReview $review): bool + { + if ($review->isReviewer($user)) { + return true; + } + + $isTutor = $GLOBALS['perm']->have_studip_perm( + 'tutor', + $review->process->task_group['seminar_id'], + $user->getId() + ); + + return ($isTutor || $review->isSubmitter($user)) && + $review->process->getCurrentState() === PeerReviewProcess::STATE_AFTER; + } + + public static function canIndexReviewsOfProcesses(User $user, PeerReviewProcess $process): bool + { + return self::canShowPeerReviewProcess($user, $process); + } + + public static function canUpdatePeerReview(User $user, PeerReview $review): bool + { + return $review->process->getCurrentState() === PeerReviewProcess::STATE_ACTIVE && $review->isReviewer($user); + } + + public static function canCreatePeerReviews(User $user, PeerReviewProcess $process): bool + { + return self::canCreatePeerReviewProcesses($user, $process->task_group); + } + + public static function canDeletePeerReview(User $user, PeerReview $review): bool + { + return self::canCreatePeerReviews($user, $review->process); + } } diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesCreate.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesCreate.php new file mode 100644 index 0000000..3de5832 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesCreate.php @@ -0,0 +1,125 @@ +<?php + +namespace JsonApi\Routes\Courseware\PeerReview; + +use Courseware\PeerReviewProcess; +use Courseware\TaskGroup; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\JsonApiController; +use JsonApi\Routes\Courseware\Authority; +use JsonApi\Routes\TimestampTrait; +use JsonApi\Routes\ValidationTrait; +use JsonApi\Schemas\Courseware\PeerReviewProcess as PeerReviewProcessSchema; +use JsonApi\Schemas\Courseware\TaskGroup as TaskGroupSchema; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Create a PeerReviewProcess. + * + * @SuppressWarnings(PHPMD.StaticAccess) + */ +class ProcessesCreate extends JsonApiController +{ + use TimestampTrait; + use ValidationTrait; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * + * @param array $args + * + * @return Response + */ + public function __invoke(Request $request, Response $response, $args) + { + $json = $this->validate($request); + $taskGroup = $this->getTaskGroupFromJson($json); + $user = $this->getUser($request); + + if (!Authority::canCreatePeerReviewProcesses($user, $taskGroup)) { + throw new AuthorizationFailedException(); + } + + $process = $this->create($user, $json); + + return $this->getCreatedResponse($process); + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameters) + * + * @param array $json + * @param mixed $data + * + * @return string|void + */ + protected function validateResourceDocument($json, $data) + { + if (!self::arrayHas($json, 'data')) { + return 'Missing `data` member at document´s top level.'; + } + if (PeerReviewProcessSchema::TYPE !== self::arrayGet($json, 'data.type')) { + return 'Invalid `type` of document´s `data`.'; + } + if (self::arrayHas($json, 'data.id')) { + return 'New document must not have an `id`.'; + } + + if (!self::arrayHas($json, 'data.attributes.configuration')) { + return 'Missing `configuration` attribute.'; + } + + if (!self::arrayHas($json, 'data.attributes.review-start')) { + return 'Missing `review-start` attribute.'; + } + $startDate = self::arrayGet($json, 'data.attributes.review-start'); + if (!self::isValidTimestamp($startDate)) { + return '`review-start` is not an ISO 8601 timestamp.'; + } + + if (!self::arrayHas($json, 'data.attributes.review-end')) { + return 'Missing `review-end` attribute.'; + } + $endDate = self::arrayGet($json, 'data.attributes.review-end'); + if (!self::isValidTimestamp($endDate)) { + return '`review-end` is not an ISO 8601 timestamp.'; + } + + if (!self::arrayHas($json, 'data.relationships.task-group')) { + return 'Missing `task-group` relationship.'; + } + if (!$this->getTaskGroupFromJson($json)) { + return 'Invalid `task-group` relationship.'; + } + } + + private function getTaskGroupFromJson(array $json): ?TaskGroup + { + if (!$this->validateResourceObject($json, 'data.relationships.task-group', TaskGroupSchema::TYPE)) { + return null; + } + $resourceId = self::arrayGet($json, 'data.relationships.task-group.data.id'); + + return TaskGroup::find($resourceId); + } + + private function create(\User $user, array $json): PeerReviewProcess + { + $taskGroup = $this->getTaskGroupFromJson($json); + $startDate = self::fromISO8601(self::arrayGet($json, 'data.attributes.review-start')); + $endDate = self::fromISO8601(self::arrayGet($json, 'data.attributes.review-end')); + $configuration = self::arrayGet($json, 'data.attributes.configuration'); + + /** @var PeerReviewProcess $process */ + $process = PeerReviewProcess::create([ + 'task_group_id' => $taskGroup->getId(), + 'owner_id' => $user->getId(), + 'configuration' => $configuration, + 'review_start' => $startDate->getTimestamp(), + 'review_end' => $endDate->getTimestamp(), + ]); + + return $process; + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesDelete.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesDelete.php new file mode 100644 index 0000000..b9ba42e --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesDelete.php @@ -0,0 +1,39 @@ +<?php + +namespace JsonApi\Routes\Courseware\PeerReview; + +use Courseware\PeerPreviewProcess; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\JsonApiController; +use JsonApi\Routes\Courseware\Authority; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Delete one PeerPreviewProcess. + */ +class ProcessesDelete extends JsonApiController +{ + /** + * @param array $args + * @return Response + * + * @SuppressWarnings(PHPMD.StaticAccess) + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + /** @var ?PeerPreviewProcess $resource */ + $resource = PeerPreviewProcess::find($args['id']); + if (!$resource) { + throw new RecordNotFoundException(); + } + if (!Authority::canDeletePeerReviewProcess($this->getUser($request), $resource)) { + throw new AuthorizationFailedException(); + } + $resource->delete(); + + return $this->getCodeResponse(204); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesIndex.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesIndex.php new file mode 100644 index 0000000..42bee8a --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesIndex.php @@ -0,0 +1,108 @@ +<?php + +namespace JsonApi\Routes\Courseware\PeerReview; + +use Course; +use Courseware\PeerReviewProcess; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\BadRequestException; +use JsonApi\JsonApiController; +use JsonApi\Routes\Courses\Authority as CoursesAuthority; +use JsonApi\Routes\Courseware\Authority; +use JsonApi\Schemas\Courseware\PeerReviewProcess as ProcessSchema; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use User; + +/** + * Displays all visible PeerReviewProcesses. + * + * @SuppressWarnings(PHPMD.LongVariable) + * @SuppressWarnings(PHPMD.StaticAccess) + */ +class ProcessesIndex extends JsonApiController +{ + protected $allowedFilteringParameters = ['cid']; + + protected $allowedIncludePaths = [ + ProcessSchema::REL_COURSE, + ProcessSchema::REL_OWNER, + ProcessSchema::REL_TASK_GROUP, + ]; + + protected $allowedPagingParameters = ['offset', 'limit']; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * + * @param array $args + * + * @return Response + */ + public function __invoke(Request $request, Response $response, $args) + { + $user = $this->getUser($request); + $filtering = $this->getQueryParameters()->getFilteringParameters() ?: []; + + $this->validateFilters($filtering); + $this->authorize($user, $filtering); + + $resources = empty($filtering) ? $this->findAllProcesses($user) : $this->filterProcesses($user, $filtering); + + return $this->getPaginatedContentResponse( + array_slice($resources, ...$this->getOffsetAndLimit()), + count($resources) + ); + } + + /** + * @throws BadRequestException + */ + private function validateFilters(array $filtering): void + { + if (isset($filtering['cid']) && !Course::exists($filtering['cid'])) { + throw new BadRequestException('Could not find a course matching this `filter[cid]`.'); + } + } + + /** + * @throws AuthorizationFailedException + */ + private function authorize(User $user, array $filtering): void + { + if (!Authority::canIndexPeerReviewProcesses($user)) { + throw new AuthorizationFailedException(); + } + + if (isset($filtering['cid'])) { + if ( + !CoursesAuthority::canShowCourse( + $user, + Course::find($filtering['cid']), + CoursesAuthority::SCOPE_EXTENDED + ) + ) { + throw new AuthorizationFailedException(); + } + } + } + + private function findAllProcesses(User $user): iterable + { + return PeerReviewProcess::findByUser($user); + } + + private function filterProcesses(User $user, array $filtering): iterable + { + if (isset($filtering['cid'])) { + /** @var ?\Course $course */ + $course = \Course::find($filtering['cid']); + + return array_filter(PeerReviewProcess::findByCourse($course), function ($process) use ($user) { + return Authority::canShowPeerReviewProcess($user, $process); + }); + } + + return []; + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesShow.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesShow.php new file mode 100644 index 0000000..3d90421 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesShow.php @@ -0,0 +1,49 @@ +<?php + +namespace JsonApi\Routes\Courseware\PeerReview; + +use Course; +use Courseware\PeerReviewProcess; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\Routes\Courseware\Authority; +use JsonApi\Schemas\Courseware\PeerReviewProcess as ProcessSchema; +use JsonApi\JsonApiController; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use User; + +/** + * Displays one PeerReviewProcess. + * + * @SuppressWarnings(PHPMD.LongVariable) + * @SuppressWarnings(PHPMD.StaticAccess) + */ +class ProcessesShow extends JsonApiController +{ + protected $allowedIncludePaths = [ + ProcessSchema::REL_COURSE, + ProcessSchema::REL_OWNER, + ProcessSchema::REL_TASK_GROUP, + ]; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @param array $args + * @return Response + */ + public function __invoke(Request $request, Response $response, $args) + { + /** @var ?\Courseware\PeerReviewProcess $resource */ + $resource = PeerReviewProcess::find($args['id']); + if (!$resource) { + throw new RecordNotFoundException(); + } + + if (!Authority::canShowPeerReviewProcess($this->getUser($request), $resource)) { + throw new AuthorizationFailedException(); + } + + return $this->getContentResponse($resource); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesUpdate.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesUpdate.php new file mode 100644 index 0000000..5a0519e --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesUpdate.php @@ -0,0 +1,122 @@ +<?php + +namespace JsonApi\Routes\Courseware\PeerReview; + +use Courseware\PeerReviewProcess; +use Courseware\TaskGroup; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\Routes\Courseware\Authority; +use JsonApi\Schemas\Courseware\PeerReviewProcess as ProcessSchema; +use JsonApi\JsonApiController; +use JsonApi\Routes\TimestampTrait; +use JsonApi\Routes\ValidationTrait; +use JsonApi\Schemas\Courseware\PeerReviewProcess as PeerReviewProcessSchema; +use JsonApi\Schemas\Courseware\TaskGroup as TaskGroupSchema; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use User; + +/** + * Updates one PeerReviewProcess. + * + * @SuppressWarnings(PHPMD.LongVariable) + * @SuppressWarnings(PHPMD.StaticAccess) + */ +class ProcessesUpdate extends JsonApiController +{ + use TimestampTrait; + use ValidationTrait; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @param array $args + * @return Response + */ + public function __invoke(Request $request, Response $response, $args) + { + /** @var ?\Courseware\PeerReviewProcess $resource */ + $resource = PeerReviewProcess::find($args['id']); + if (!$resource) { + throw new RecordNotFoundException(); + } + $json = $this->validate($request, $resource); + $user = $this->getUser($request); + if (!Authority::canUpdatePeerReviewProcess($user, $resource)) { + throw new AuthorizationFailedException(); + } + + $process = $this->update($user, $resource, $json); + + return $this->getContentResponse($process); + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameters) + * + * @param array $json + * @param mixed $data + * + * @return string|void + */ + protected function validateResourceDocument($json, $data) + { + if (!self::arrayHas($json, 'data')) { + return 'Missing `data` member at document´s top level.'; + } + if (PeerReviewProcessSchema::TYPE !== self::arrayGet($json, 'data.type')) { + return 'Invalid `type` of document´s `data`.'; + } + + if (!self::arrayHas($json, 'data.attributes.configuration')) { + return 'Missing `configuration` attribute.'; + } + + if (!self::arrayHas($json, 'data.attributes.review-start')) { + return 'Missing `review-start` attribute.'; + } + $startDate = self::arrayGet($json, 'data.attributes.review-start'); + if (!self::isValidTimestamp($startDate)) { + return '`review-start` is not an ISO 8601 timestamp.'; + } + + if (!self::arrayHas($json, 'data.attributes.review-end')) { + return 'Missing `review-end` attribute.'; + } + $endDate = self::arrayGet($json, 'data.attributes.review-end'); + if (!self::isValidTimestamp($endDate)) { + return '`review-end` is not an ISO 8601 timestamp.'; + } + + if (self::arrayHas($json, 'data.relationships.task-group')) { + if (!$this->getTaskGroupFromJson($json)) { + return 'Invalid `task-group` relationship.'; + } + } + } + + private function getTaskGroupFromJson(array $json): ?TaskGroup + { + if (!$this->validateResourceObject($json, 'data.relationships.task-group', TaskGroupSchema::TYPE)) { + return null; + } + $resourceId = self::arrayGet($json, 'data.relationships.task-group.data.id'); + + return TaskGroup::find($resourceId); + } + + private function update(User $user, PeerReviewProcess $process, array $json): PeerReviewProcess + { + $startDate = self::fromISO8601(self::arrayGet($json, 'data.attributes.review-start')); + $endDate = self::fromISO8601(self::arrayGet($json, 'data.attributes.review-end')); + $configuration = self::arrayGet($json, 'data.attributes.configuration'); + + $process->review_start = $startDate->getTimestamp(); + $process->review_end = $endDate->getTimestamp(); + $process->configuration = $configuration; + + $process->store(); + + return $process; + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsByTaskIndex.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsByTaskIndex.php new file mode 100644 index 0000000..fc3376f --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsByTaskIndex.php @@ -0,0 +1,80 @@ +<?php + +namespace JsonApi\Routes\Courseware\PeerReview; + +use Courseware\PeerReview; +use Courseware\PeerReviewProcess; +use Courseware\Task; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\BadRequestException; +use JsonApi\JsonApiController; +use JsonApi\Routes\Courses\Authority as CoursesAuthority; +use JsonApi\Routes\Courseware\Authority; +use JsonApi\Schemas\Courseware\PeerReview as PeerReviewSchema; +use JsonApi\Schemas\Courseware\Task as TaskSchema; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use User; + +/** + * Displays all PeerReviews of a course. + * + * @SuppressWarnings(PHPMD.LongVariable) + * @SuppressWarnings(PHPMD.StaticAccess) + */ +class ReviewsByTaskIndex extends JsonApiController +{ + protected $allowedIncludePaths = [ + PeerReviewSchema::REL_PROCESS, + PeerReviewSchema::REL_REVIEWER, + PeerReviewSchema::REL_SUBMITTER, + PeerReviewSchema::REL_TASK, + PeerReviewSchema::REL_TASK . '.' . TaskSchema::REL_STRUCTURAL_ELEMENT, + PeerReviewSchema::REL_TASK . '.' . TaskSchema::REL_TASK_GROUP, + ]; + + protected $allowedPagingParameters = ['offset', 'limit']; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * + * @param array $args + * + * @return Response + */ + public function __invoke(Request $request, Response $response, $args) + { + /** @var ?Task $task */ + $task = Task::find($args['id']); + if (!$task) { + throw new RecordNotFoundException(); + } + + $user = $this->getUser($request); + $this->authorize($user); + + $resources = $this->findPeerReviews($task, $user); + + return $this->getPaginatedContentResponse( + $resources->limit(...$this->getOffsetAndLimit()), + count($resources) + ); + } + + /** + * @throws AuthorizationFailedException + */ + private function authorize(User $user): void + { + if (!Authority::canIndexPeerReviews($user)) { + throw new AuthorizationFailedException(); + } + } + + private function findPeerReviews(Task $task, User $user): iterable + { + return $task->peer_reviews->filter(function ($peerReview) use ($user) { + return Authority::canShowPeerReview($user, $peerReview); + }); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsCreate.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsCreate.php new file mode 100644 index 0000000..26e566d --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsCreate.php @@ -0,0 +1,184 @@ +<?php + +namespace JsonApi\Routes\Courseware\PeerReview; + +use Courseware\PeerReview; +use Courseware\PeerReviewProcess; +use Courseware\Task; +use Courseware\TaskGroup; +use InvalidArgumentException; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\JsonApiController; +use JsonApi\Routes\Courseware\Authority; +use JsonApi\Routes\TimestampTrait; +use JsonApi\Routes\ValidationTrait; +use JsonApi\Schemas\Courseware\PeerReview as PeerReviewSchema; +use JsonApi\Schemas\Courseware\PeerReviewProcess as PeerReviewProcessSchema; +use JsonApi\Schemas\StatusGroup as StatusGroupSchema; +use JsonApi\Schemas\User as UserSchema; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use Statusgruppen; +use User; + +/** + * Create a PeerReview. + * + * @SuppressWarnings(PHPMD.StaticAccess) + */ +class ReviewsCreate extends JsonApiController +{ + use ValidationTrait; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * + * @param array $args + * + * @return Response + */ + public function __invoke(Request $request, Response $response, $args) + { + $json = $this->validate($request); + $process = $this->getProcessFromJson($json); + $user = $this->getUser($request); + + if (!Authority::canCreatePeerReviews($user, $process)) { + throw new AuthorizationFailedException(); + } + + $resource = $this->create($json); + + return $this->getCreatedResponse($resource); + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameters) + * + * @param array $json + * @param mixed $data + * + * @return string|void + */ + protected function validateResourceDocument($json, $data) + { + if (!self::arrayHas($json, 'data')) { + return 'Missing `data` member at document´s top level.'; + } + if (PeerReviewSchema::TYPE !== self::arrayGet($json, 'data.type')) { + return 'Invalid `type` of document´s `data`.'; + } + if (self::arrayHas($json, 'data.id')) { + return 'New document must not have an `id`.'; + } + + // process + if (!self::arrayHas($json, 'data.relationships.process')) { + return 'Missing `process` relationship.'; + } + if (!$this->getProcessFromJson($json)) { + return 'Invalid `process` relationship.'; + } + + // submitter + if (!self::arrayHas($json, 'data.relationships.submitter')) { + return 'Missing `submitter` relationship.'; + } + if (!$this->getSubmitterFromJson($json)) { + return 'Invalid `submitter` relationship.'; + } + + // reviewer + if (!self::arrayHas($json, 'data.relationships.reviewer')) { + return 'Missing `reviewer` relationship.'; + } + if (!$this->getReviewerFromJson($json)) { + return 'Invalid `reviewer` relationship.'; + } + } + + private function create(array $json): PeerReview + { + $process = $this->getProcessFromJson($json); + $reviewer = $this->getReviewerFromJson($json); + $submitter = $this->getSubmitterFromJson($json); + + $task = $process['task_group']->findTaskBySolver($submitter); + $reviewerType = $this->getReviewerType($reviewer); + + /** @var PeerReview $review */ + $review = PeerReview::create([ + 'process_id' => $process->id, + 'task_id' => $task->id, + 'submitter_id' => $submitter->id, + 'reviewer_id' => $reviewer->id, + 'reviewer_type' => $reviewerType, + ]); + + return $review; + } + + /** + * @return User|Statusgruppen|null + */ + private function getActorFromJson(array $json, string $relation) + { + $relationship = 'data.relationships.' . $relation; + if ( + !( + $this->validateResourceObject($json, $relationship, UserSchema::TYPE) || + $this->validateResourceObject($json, $relationship, StatusGroupSchema::TYPE) + ) + ) { + return null; + } + $resourceId = self::arrayGet($json, $relationship . '.data.id'); + + switch (self::arrayGet($json, $relationship . '.data.type')) { + case UserSchema::TYPE: + return User::find($resourceId); + case StatusGroupSchema::TYPE: + return Statusgruppen::find($resourceId); + } + + throw new InvalidArgumentException(); + } + + private function getProcessFromJson(array $json): ?PeerReviewProcess + { + if (!$this->validateResourceObject($json, 'data.relationships.process', PeerReviewProcessSchema::TYPE)) { + return null; + } + $resourceId = self::arrayGet($json, 'data.relationships.process.data.id'); + + return PeerReviewProcess::find($resourceId); + } + + /** + * @return User|Statusgruppen|null + */ + private function getReviewerFromJson(array $json) + { + return $this->getActorFromJson($json, 'reviewer'); + } + + private function getReviewerType($reviewer): string + { + if ($reviewer instanceof User) { + return 'autor'; + } + if ($reviewer instanceof Statusgruppen) { + return 'group'; + } + + throw new InvalidArgumentException(); + } + + /** + * @return User|Statusgruppen|null + */ + private function getSubmitterFromJson(array $json) + { + return $this->getActorFromJson($json, 'submitter'); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsDelete.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsDelete.php new file mode 100644 index 0000000..2b7edd6 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsDelete.php @@ -0,0 +1,39 @@ +<?php + +namespace JsonApi\Routes\Courseware\PeerReview; + +use Courseware\PeerReview; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\JsonApiController; +use JsonApi\Routes\Courseware\Authority; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Delete one PeerPreview. + */ +class ReviewsDelete extends JsonApiController +{ + /** + * @param array $args + * @return Response + * + * @SuppressWarnings(PHPMD.StaticAccess) + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + /** @var ?PeerReview $resource */ + $resource = PeerReview::find($args['id']); + if (!$resource) { + throw new RecordNotFoundException(); + } + if (!Authority::canDeletePeerReview($this->getUser($request), $resource)) { + throw new AuthorizationFailedException(); + } + $resource->delete(); + + return $this->getCodeResponse(204); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsIndex.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsIndex.php new file mode 100644 index 0000000..91c7d58 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsIndex.php @@ -0,0 +1,80 @@ +<?php + +namespace JsonApi\Routes\Courseware\PeerReview; + +use Course; +use Courseware\PeerReview; +use Courseware\PeerReviewProcess; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\BadRequestException; +use JsonApi\JsonApiController; +use JsonApi\Routes\Courses\Authority as CoursesAuthority; +use JsonApi\Routes\Courseware\Authority; +use JsonApi\Schemas\Courseware\PeerReview as PeerReviewSchema; +use JsonApi\Schemas\Courseware\Task as TaskSchema; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use User; + +/** + * Displays all PeerReviews of a course. + * + * @SuppressWarnings(PHPMD.LongVariable) + * @SuppressWarnings(PHPMD.StaticAccess) + */ +class ReviewsIndex extends JsonApiController +{ + protected $allowedIncludePaths = [ + PeerReviewSchema::REL_PROCESS, + PeerReviewSchema::REL_REVIEWER, + PeerReviewSchema::REL_SUBMITTER, + PeerReviewSchema::REL_TASK, + PeerReviewSchema::REL_TASK . '.' . TaskSchema::REL_STRUCTURAL_ELEMENT, + PeerReviewSchema::REL_TASK . '.' . TaskSchema::REL_TASK_GROUP, + ]; + + protected $allowedPagingParameters = ['offset', 'limit']; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * + * @param array $args + * + * @return Response + */ + public function __invoke(Request $request, Response $response, $args) + { + /** @var ?Course $course */ + $course = Course::find($args['id']); + if (!$course) { + throw new RecordNotFoundException(); + } + + $user = $this->getUser($request); + $this->authorize($user); + + $resources = $this->findPeerReviews($course, $user); + + return $this->getPaginatedContentResponse( + array_slice($resources, ...$this->getOffsetAndLimit()), + count($resources) + ); + } + + /** + * @throws AuthorizationFailedException + */ + private function authorize(User $user): void + { + if (!Authority::canIndexPeerReviews($user)) { + throw new AuthorizationFailedException(); + } + } + + private function findPeerReviews(Course $course, User $user): iterable + { + return array_filter(PeerReview::findByCourse($course), function ($peerReview) use ($user) { + return Authority::canShowPeerReview($user, $peerReview); + }); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsOfProcessesIndex.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsOfProcessesIndex.php new file mode 100644 index 0000000..2a0b04d --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsOfProcessesIndex.php @@ -0,0 +1,77 @@ +<?php + +namespace JsonApi\Routes\Courseware\PeerReview; + +use Course; +use Courseware\PeerReview; +use Courseware\PeerReviewProcess; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\BadRequestException; +use JsonApi\JsonApiController; +use JsonApi\Routes\Courses\Authority as CoursesAuthority; +use JsonApi\Routes\Courseware\Authority; +use JsonApi\Schemas\Courseware\PeerReview as PeerReviewSchema; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use User; + +/** + * Displays all visible PeerReviewProcesses. + * + * @SuppressWarnings(PHPMD.LongVariable) + * @SuppressWarnings(PHPMD.StaticAccess) + */ +class ReviewsOfProcessesIndex extends JsonApiController +{ + protected $allowedIncludePaths = [ + PeerReviewSchema::REL_PROCESS, + PeerReviewSchema::REL_REVIEWER, + PeerReviewSchema::REL_SUBMITTER, + PeerReviewSchema::REL_TASK, + ]; + + protected $allowedPagingParameters = ['offset', 'limit']; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * + * @param array $args + * + * @return Response + */ + public function __invoke(Request $request, Response $response, $args) + { + /** @var ?PeerReviewProcess $process */ + $process = PeerReviewProcess::find($args['id']); + if (!$process) { + throw new RecordNotFoundException(); + } + + $user = $this->getUser($request); + $this->authorize($user, $process); + + $resources = $this->findReviews($user, $process); + + return $this->getPaginatedContentResponse( + $resources->limit(...$this->getOffsetAndLimit()), + count($resources) + ); + } + + /** + * @throws AuthorizationFailedException + */ + private function authorize(User $user, PeerReviewProcess $process): void + { + if (!Authority::canIndexReviewsOfProcesses($user, $process)) { + throw new AuthorizationFailedException(); + } + } + + private function findReviews(User $user, PeerReviewProcess $process): iterable + { + return $process->peer_reviews->filter(function ($peerReview) use ($user) { + return Authority::canShowPeerReview($user, $peerReview); + }); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsUpdate.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsUpdate.php new file mode 100644 index 0000000..cf3c600 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsUpdate.php @@ -0,0 +1,82 @@ +<?php + +namespace JsonApi\Routes\Courseware\PeerReview; + +use Courseware\PeerReview; +use Courseware\TaskGroup; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\JsonApiController; +use JsonApi\Routes\Courseware\Authority; +use JsonApi\Routes\TimestampTrait; +use JsonApi\Routes\ValidationTrait; +use JsonApi\Schemas\Courseware\PeerReview as PeerReviewSchema; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use User; + +/** + * Updates one PeerReview. + * + * @SuppressWarnings(PHPMD.LongVariable) + * @SuppressWarnings(PHPMD.StaticAccess) + */ +class ReviewsUpdate extends JsonApiController +{ + use TimestampTrait; + use ValidationTrait; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @param array $args + * @return Response + */ + public function __invoke(Request $request, Response $response, $args) + { + $resource = PeerReview::find($args['id']); + if (!$resource) { + throw new RecordNotFoundException(); + } + $json = $this->validate($request, $resource); + $user = $this->getUser($request); + if (!Authority::canUpdatePeerReview($user, $resource)) { + throw new AuthorizationFailedException(); + } + + $review = $this->update($resource, $json); + + return $this->getContentResponse($review); + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameters) + * + * @param array $json + * @param mixed $data + * + * @return string|void + */ + protected function validateResourceDocument($json, $data) + { + if (!self::arrayHas($json, 'data')) { + return 'Missing `data` member at document´s top level.'; + } + if (PeerReviewSchema::TYPE !== self::arrayGet($json, 'data.type')) { + return 'Invalid `type` of document´s `data`.'; + } + + if (!self::arrayHas($json, 'data.attributes.assessment')) { + return 'Missing `assessment` attribute.'; + } + + // TODO: validate assessment + } + + private function update(PeerReview $review, array $json): PeerReview + { + $review->assessment = self::arrayGet($json, 'data.attributes.assessment'); + $review->store(); + + return $review; + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/TaskGroupsShow.php b/lib/classes/JsonApi/Routes/Courseware/TaskGroupsShow.php index c8ebb86..ff3fba4 100644 --- a/lib/classes/JsonApi/Routes/Courseware/TaskGroupsShow.php +++ b/lib/classes/JsonApi/Routes/Courseware/TaskGroupsShow.php @@ -18,6 +18,7 @@ class TaskGroupsShow extends JsonApiController protected $allowedIncludePaths = [ TaskGroupSchema::REL_COURSE, TaskGroupSchema::REL_LECTURER, + TaskGroupSchema::REL_PEER_REVIEW_PROCESSES, TaskGroupSchema::REL_SOLVERS, TaskGroupSchema::REL_TARGET, TaskGroupSchema::REL_TASK_TEMPLATE, diff --git a/lib/classes/JsonApi/Routes/Courseware/TasksIndex.php b/lib/classes/JsonApi/Routes/Courseware/TasksIndex.php index 26a021c..9952437 100644 --- a/lib/classes/JsonApi/Routes/Courseware/TasksIndex.php +++ b/lib/classes/JsonApi/Routes/Courseware/TasksIndex.php @@ -25,6 +25,7 @@ class TasksIndex extends JsonApiController TaskSchema::REL_STRUCTURAL_ELEMENT, TaskSchema::REL_TASK_GROUP, TaskSchema::REL_TASK_GROUP . '.' . TaskGroupSchema::REL_LECTURER, + TaskSchema::REL_TASK_GROUP . '.' . TaskGroupSchema::REL_PEER_REVIEW_PROCESSES, ]; /** diff --git a/lib/classes/JsonApi/Routes/Courseware/TasksShow.php b/lib/classes/JsonApi/Routes/Courseware/TasksShow.php index 619e7ea..419f950 100644 --- a/lib/classes/JsonApi/Routes/Courseware/TasksShow.php +++ b/lib/classes/JsonApi/Routes/Courseware/TasksShow.php @@ -5,6 +5,7 @@ namespace JsonApi\Routes\Courseware; use Courseware\Task; use JsonApi\Errors\AuthorizationFailedException; use JsonApi\Errors\RecordNotFoundException; +use JsonApi\Schemas\Courseware\PeerReview as PeerReviewSchema; use JsonApi\Schemas\Courseware\Task as TaskSchema; use JsonApi\Schemas\Courseware\TaskGroup as TaskGroupSchema; use JsonApi\JsonApiController; @@ -18,10 +19,13 @@ class TasksShow extends JsonApiController { protected $allowedIncludePaths = [ TaskSchema::REL_FEEDBACK, + TaskSchema::REL_PEER_REVIEWS, + TaskSchema::REL_PEER_REVIEWS . '.' . PeerReviewSchema::REL_PROCESS, TaskSchema::REL_SOLVER, TaskSchema::REL_STRUCTURAL_ELEMENT, TaskSchema::REL_TASK_GROUP, TaskSchema::REL_TASK_GROUP . '.' . TaskGroupSchema::REL_LECTURER, + TaskSchema::REL_TASK_GROUP . '.' . TaskGroupSchema::REL_PEER_REVIEW_PROCESSES, ]; /** diff --git a/lib/classes/JsonApi/SchemaMap.php b/lib/classes/JsonApi/SchemaMap.php index ff5040d..411757d 100644 --- a/lib/classes/JsonApi/SchemaMap.php +++ b/lib/classes/JsonApi/SchemaMap.php @@ -66,6 +66,8 @@ class SchemaMap \Courseware\Clipboard::class => Schemas\Courseware\Clipboard::class, \Courseware\Container::class => Schemas\Courseware\Container::class, \Courseware\Instance::class => Schemas\Courseware\Instance::class, + \Courseware\PeerReview::class => Schemas\Courseware\PeerReview::class, + \Courseware\PeerReviewProcess::class => Schemas\Courseware\PeerReviewProcess::class, \Courseware\PublicLink::class => Schemas\Courseware\PublicLink::class, \Courseware\StructuralElement::class => Schemas\Courseware\StructuralElement::class, \Courseware\StructuralElementComment::class => Schemas\Courseware\StructuralElementComment::class, diff --git a/lib/classes/JsonApi/Schemas/Courseware/PeerReview.php b/lib/classes/JsonApi/Schemas/Courseware/PeerReview.php new file mode 100644 index 0000000..0d2503c --- /dev/null +++ b/lib/classes/JsonApi/Schemas/Courseware/PeerReview.php @@ -0,0 +1,99 @@ +<?php + +namespace JsonApi\Schemas\Courseware; + +use JsonApi\Routes\Courseware\Authority; +use JsonApi\Schemas\SchemaProvider; +use Neomerx\JsonApi\Contracts\Schema\ContextInterface; +use Neomerx\JsonApi\Schema\Link; + +class PeerReview extends SchemaProvider +{ + public const TYPE = 'courseware-peer-reviews'; + + public const REL_PROCESS = 'process'; + public const REL_REVIEWER = 'reviewer'; + public const REL_SUBMITTER = 'submitter'; + public const REL_TASK = 'task'; + + /** + * {@inheritdoc} + */ + public function getId($resource): ?string + { + return $resource->id; + } + + /** + * {@inheritdoc} + * @SuppressWarnings(PHPMD.StaticAccess) + */ + public function getAttributes($resource, ContextInterface $context): iterable + { + $user = $this->currentUser; + $assessment = null; + if ($resource->assessment && Authority::canShowPeerReviewAssessment($user, $resource)) { + $assessment = $resource->assessment->getIterator(); + } + return [ + 'assessment' => $assessment, + 'mkdate' => date('c', $resource['mkdate']), + 'chdate' => date('c', $resource['chdate']), + ]; + } + + /** + * {@inheritdoc} + * @SuppressWarnings(PHPMD.StaticAccess) + */ + public function getRelationships($resource, ContextInterface $context): iterable + { + $relationships = []; + + $relationships[self::REL_PROCESS] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($resource->process), + ], + self::RELATIONSHIP_DATA => $resource->process, + ]; + + $user = $this->currentUser; + + if (Authority::canShowPeerReviewReviewer($user, $resource)) { + $reviewer = $resource->getReviewer(); + $relationships[self::REL_REVIEWER] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($reviewer), + ], + self::RELATIONSHIP_DATA => $reviewer, + ]; + } else { + $relationships[self::REL_REVIEWER] = [ + self::RELATIONSHIP_DATA => null, + ]; + } + + if (Authority::canShowPeerReviewSubmitter($user, $resource)) { + $submitter = $resource->getSubmitter(); + $relationships[self::REL_SUBMITTER] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($submitter), + ], + self::RELATIONSHIP_DATA => $submitter, + ]; + } else { + $relationships[self::REL_SUBMITTER] = [ + self::RELATIONSHIP_DATA => null, + ]; + } + + $relationships[self::REL_TASK] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($resource->task), + ], + self::RELATIONSHIP_DATA => $resource->task, + ]; + + return $relationships; + } +} diff --git a/lib/classes/JsonApi/Schemas/Courseware/PeerReviewProcess.php b/lib/classes/JsonApi/Schemas/Courseware/PeerReviewProcess.php new file mode 100644 index 0000000..0eca67c --- /dev/null +++ b/lib/classes/JsonApi/Schemas/Courseware/PeerReviewProcess.php @@ -0,0 +1,77 @@ +<?php + +namespace JsonApi\Schemas\Courseware; + +use JsonApi\Schemas\SchemaProvider; +use Neomerx\JsonApi\Contracts\Schema\ContextInterface; +use Neomerx\JsonApi\Schema\Link; + +class PeerReviewProcess extends SchemaProvider +{ + const TYPE = 'courseware-peer-review-processes'; + + const REL_COURSE = 'course'; + const REL_OWNER = 'owner'; + const REL_PEER_REVIEWS = 'reviews'; + const REL_TASK_GROUP = 'task-group'; + + /** + * {@inheritdoc} + */ + public function getId($resource): ?string + { + return $resource->id; + } + + /** + * {@inheritdoc} + */ + public function getAttributes($resource, ContextInterface $context): iterable + { + return [ + 'configuration' => $resource['configuration']->getIterator(), + 'review-start' => date('c', $resource['review_start']), + 'review-end' => date('c', $resource['review_end']), + 'mkdate' => date('c', $resource['mkdate']), + 'chdate' => date('c', $resource['chdate']), + ]; + } + + /** + * {@inheritdoc} + */ + public function getRelationships($resource, ContextInterface $context): iterable + { + $relationships = []; + + $course = $resource->getCourse(); + $relationships[self::REL_COURSE] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($course), + ], + self::RELATIONSHIP_DATA => $course, + ]; + + $relationships[self::REL_OWNER] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($resource->owner), + ], + self::RELATIONSHIP_DATA => $resource->owner, + ]; + + $relationships[self::REL_PEER_REVIEWS] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->getRelationshipRelatedLink($resource, self::REL_PEER_REVIEWS), + ], + ]; + + $relationships[self::REL_TASK_GROUP] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($resource->task_group), + ], + self::RELATIONSHIP_DATA => $resource->task_group, + ]; + + return $relationships; + } +} diff --git a/lib/classes/JsonApi/Schemas/Courseware/Task.php b/lib/classes/JsonApi/Schemas/Courseware/Task.php index 81c7a0d..cf41e62 100644 --- a/lib/classes/JsonApi/Schemas/Courseware/Task.php +++ b/lib/classes/JsonApi/Schemas/Courseware/Task.php @@ -13,6 +13,7 @@ class Task extends SchemaProvider const TYPE = 'courseware-tasks'; const REL_FEEDBACK = 'task-feedback'; + const REL_PEER_REVIEWS = 'peer-reviews'; const REL_SOLVER = 'solver'; const REL_STRUCTURAL_ELEMENT = 'structural-element'; const REL_TASK_GROUP = 'task-group'; @@ -30,12 +31,15 @@ class Task extends SchemaProvider */ public function getAttributes($resource, ContextInterface $context): iterable { + $user = $this->currentUser; + return [ 'progress' => (float) $resource->getTaskProgress(), 'submission-date' => date('c', $resource['submission_date']), 'submitted' => (bool) $resource['submitted'], 'renewal' => empty($resource['renewal']) ? null : (string) $resource['renewal'], 'renewal-date' => date('c', $resource['renewal_date']), + 'can-peer-review' => $resource->userIsAPeerReviewer($user), 'mkdate' => date('c', $resource['mkdate']), 'chdate' => date('c', $resource['chdate']), ]; @@ -58,13 +62,18 @@ class Task extends SchemaProvider ] : [self::RELATIONSHIP_DATA => null]; - $solver = $resource->getSolver(); - $relationships[self::REL_SOLVER] = $solver + $relationships = $this->addPeerReviews( + $relationships, + $resource, + $this->shouldInclude($context, self::REL_PEER_REVIEWS) + ); + + $relationships[self::REL_SOLVER] = $resource['solver_id'] ? [ self::RELATIONSHIP_LINKS => [ - Link::RELATED => $this->createLinkToResource($solver), + Link::RELATED => $this->createLinkToResource($resource->solver), ], - self::RELATIONSHIP_DATA => $solver, + self::RELATIONSHIP_DATA => $resource->solver, ] : [self::RELATIONSHIP_DATA => null]; @@ -86,4 +95,30 @@ class Task extends SchemaProvider return $relationships; } + + /** + * @SuppressWarnings(PHPMD.StaticAccess) + */ + private function addPeerReviews(array $relationships, TaskModel $resource, bool $includeData): array + { + $relationships[self::REL_PEER_REVIEWS] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->getRelationshipRelatedLink($resource, self::REL_PEER_REVIEWS), + ], + ]; + + if ($includeData) { + $data = []; + $user = $this->currentUser; + if ($resource->isPeerReviewedBy($this->currentUser)) { + $data = $resource->peer_reviews->filter(function ($review) use ($user) { + return CoursewareAuthority::canShowPeerReview($user, $review); + }); + } + + $relationships[self::REL_PEER_REVIEWS][self::RELATIONSHIP_DATA] = $data; + } + + return $relationships; + } } diff --git a/lib/classes/JsonApi/Schemas/Courseware/TaskGroup.php b/lib/classes/JsonApi/Schemas/Courseware/TaskGroup.php index c950671..6870664 100644 --- a/lib/classes/JsonApi/Schemas/Courseware/TaskGroup.php +++ b/lib/classes/JsonApi/Schemas/Courseware/TaskGroup.php @@ -15,6 +15,7 @@ class TaskGroup extends SchemaProvider const REL_COURSE = 'course'; const REL_LECTURER = 'lecturer'; + const REL_PEER_REVIEW_PROCESSES = 'peer-review-processes'; const REL_SOLVERS = 'solvers'; const REL_TARGET = 'target'; const REL_TASK_TEMPLATE = 'task-template'; @@ -68,6 +69,8 @@ class TaskGroup extends SchemaProvider ] : [self::RELATIONSHIP_DATA => null]; + $relationships = $this->addPeerReviewProcessesRelationship($relationships, $resource, $context); + $relationships[self::REL_SOLVERS] = [ self::RELATIONSHIP_DATA => $resource->getSolvers(), ]; @@ -104,4 +107,19 @@ class TaskGroup extends SchemaProvider return $relationships; } + + private function addPeerReviewProcessesRelationship(iterable $relationships, TaskGroupModel $resource, ContextInterface $context): iterable + { + $relationships[self::REL_PEER_REVIEW_PROCESSES] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->getRelationshipRelatedLink($resource, self::REL_PEER_REVIEW_PROCESSES), + ], + ]; + + if ($this->shouldInclude($context, self::REL_PEER_REVIEW_PROCESSES)) { + $relationships[self::REL_PEER_REVIEW_PROCESSES][self::RELATIONSHIP_DATA] = $resource->peer_review_processes; + } + + return $relationships; + } } diff --git a/lib/models/Courseware/PeerReview.php b/lib/models/Courseware/PeerReview.php new file mode 100644 index 0000000..08ae2be --- /dev/null +++ b/lib/models/Courseware/PeerReview.php @@ -0,0 +1,100 @@ +<?php + +namespace Courseware; + +use Course; +use DBManager; +use Statusgruppen; +use User; + +/** + * Courseware's peer review instances. + * + * @since Stud.IP 5.5 + * + * @SuppressWarnings(PHPMD.StaticAccess) + */ +class PeerReview extends \SimpleORMap +{ + protected static function configure($config = []) + { + $config['db_table'] = 'cw_peer_reviews'; + + $config['serialized_fields']['assessment'] = 'JSONArrayObject'; + + $config['belongs_to']['process'] = [ + 'class_name' => PeerReviewProcess::class, + 'foreign_key' => 'process_id', + ]; + $config['belongs_to']['task'] = [ + 'class_name' => Task::class, + 'foreign_key' => 'task_id', + ]; + $config['belongs_to']['submitter'] = [ + 'class_name' => User::class, + 'foreign_key' => 'submitter_id', + ]; + $config['belongs_to']['reviewer'] = [ + 'class_name' => User::class, + 'foreign_key' => 'reviewer_id', + ]; + + parent::configure($config); + } + + public static function findByCourse(Course $course): iterable + { + $collections = []; + foreach (PeerReviewProcess::findByCourse($course) as $process) { + $collections[] = $process->getPeerReviews()->getArrayCopy(); + } + + return array_flatten($collections); + } + + public function getCourse(): Course + { + return $this->process->getCourse(); + } + + public function isAnonymous(): bool + { + return $this->process->isAnonymous(); + } + + public function isReviewer(User $user): bool + { + switch ($this->reviewer_type) { + case 'autor': + return $this->reviewer_id === $user->getId(); + case 'group': + return \Statusgruppen::isMemberOf($this->reviewer_id, $user->getId()); + } + } + + public function getReviewer(): User|Statusgruppen + { + switch ($this->reviewer_type) { + case 'autor': + return User::find($this->reviewer_id); + case 'group': + return Statusgruppen::find($this->reviewer_id); + } + } + + public function isSubmitter(User $user): bool + { + return $this->submitter_id === $user->id; + } + + public function getSubmitter(): User|Statusgruppen + { + $user = User::find($this->submitter_id); + if ($user) { + return $user; + } + + $statusGroup = Statusgruppen::find($this->submitter_id); + return $statusGroup; + } +} diff --git a/lib/models/Courseware/PeerReviewProcess.php b/lib/models/Courseware/PeerReviewProcess.php new file mode 100644 index 0000000..51c3c84 --- /dev/null +++ b/lib/models/Courseware/PeerReviewProcess.php @@ -0,0 +1,188 @@ +<?php + +namespace Courseware; + +use Course; +use DBManager; +use SimpleORMapCollection; +use User; + +/** + * A PeerReviewProcess groups a set of PeerReviews. + * + * @SuppressWarnings(PHPMD.StaticAccess) + * + * @since Stud.IP 5.5 + */ +class PeerReviewProcess extends \SimpleORMap +{ + public const DEFAULT_DURATION = 7; + + public const STATE_BEFORE = 'before'; + public const STATE_ACTIVE = 'active'; + public const STATE_AFTER = 'after'; + + protected static function configure($config = []) + { + $config['db_table'] = 'cw_peer_review_processes'; + + $config['serialized_fields']['configuration'] = 'JSONArrayObject'; + + $config['belongs_to']['task_group'] = [ + 'class_name' => TaskGroup::class, + 'foreign_key' => 'task_group_id', + ]; + $config['belongs_to']['owner'] = [ + 'class_name' => User::class, + 'foreign_key' => 'owner_id', + ]; + + $config['additional_fields']['peer_reviews'] = [ + 'get' => 'getPeerReviews', + 'set' => false, + ]; + + $config['has_many']['_peer_reviews'] = [ + 'class_name' => PeerReview::class, + 'assoc_foreign_key' => 'process_id', + 'on_delete' => 'delete', + 'on_store' => 'store', + 'order_by' => 'ORDER BY mkdate', + ]; + + parent::configure($config); + } + + public static function findByCourse(Course $course): iterable + { + return self::findBySQL('task_group_id IN (?) ORDER BY mkdate', [ + DBManager::get()->fetchFirst('SELECT id FROM `cw_task_groups` WHERE seminar_id = ?', [$course->getId()]), + ]); + } + + public static function findByUser(User $user): iterable + { + return self::findMany( + DBManager::get()->fetchFirst( + 'SELECT id FROM cw_peer_review_processes + WHERE task_group_id IN ( + SELECT id FROM cw_task_groups + WHERE cw_task_groups.seminar_id IN ( + SELECT seminar_id FROM seminar_user WHERE user_id = ?))', + [$user->getId()] + ) + ); + } + + public function getCourse(): Course + { + return $this->task_group->course; + } + + public function getPeerReviews(): SimpleORMapCollection + { + $this->checkAutomaticPairing(); + + return SimpleORMapCollection::createFromArray( + PeerReview::findBySql('process_id = ? ORDER BY mkdate', [$this->getId()]) + ); + } + + public function getDuration(): int + { + if (!isset($this->configuration['duration'])) { + return self::DEFAULT_DURATION; + } + + return (int) $this->configuration['duration']; + } + + public function isAnonymous(): bool + { + if (!isset($this->configuration['anonymous'])) { + return true; + } + + return (bool) $this->configuration['automaticPairing']; + } + + public function isAutomaticPairing(): bool + { + if (!isset($this->configuration['automaticPairing'])) { + return true; + } + + return (bool) $this->configuration['automaticPairing']; + } + + public function getCurrentState(int $date = null): string + { + if (is_null($date)) { + $date = time(); + } + + if ($this->review_end < $date) { + return self::STATE_AFTER; + } + + if ($date < $this->review_start) { + return self::STATE_BEFORE; + } + + return self::STATE_ACTIVE; + } + + public function checkAutomaticPairing(): void + { + if ($this->isAutomaticPairing() && !$this->paired_at) { + $now = time(); + if ($now > $this->review_start) { + $this->createAutomaticPairings(); + $this->content['paired_at'] = $now; + $this->content_db['paired_at'] = $now; + $stmt = \DBManager::get()->prepare( + 'UPDATE `' . $this->db_table() . '` SET `paired_at` = ? WHERE id = ?' + ); + $stmt->execute([$now, $this->getId()]); + } + } + } + + public function createAutomaticPairings(): iterable + { + $taskGroup = $this->task_group; + $submitters = $taskGroup->getSubmitters(); + + if (count($submitters) < 2) { + return []; + } + + shuffle($submitters); + $copy = $submitters; + array_push($copy, array_shift($copy)); + $pairings = array_map(null, $submitters, $copy); + + return array_map(function ($pairing) use ($taskGroup) { + list($submitter, $reviewer) = $pairing; + $task = $taskGroup->findTaskBySolver($submitter); + + return PeerReview::create([ + 'process_id' => $this->getId(), + 'task_id' => $task->getId(), + 'submitter_id' => $submitter->getId(), + 'reviewer_id' => $reviewer->getId(), + 'reviewer_type' => $reviewer instanceof User ? 'autor' : 'group', + ]); + }, $pairings); + } + + public function rescheduleTo(int $newStartDate): void + { + $newEndDate = $newStartDate + $this->getDuration() * (24 * 60 * 60); + $this->setData([ + "review_start" => $newStartDate, + "review_end" => $newEndDate, + ]); + $this->store(); + } +} diff --git a/lib/models/Courseware/StructuralElement.php b/lib/models/Courseware/StructuralElement.php index 9b68ea2..f15a706 100644 --- a/lib/models/Courseware/StructuralElement.php +++ b/lib/models/Courseware/StructuralElement.php @@ -391,7 +391,7 @@ class StructuralElement extends \SimpleORMap implements \PrivacyObject, \Feedbac return true; } - return $task->userIsASolver($user); + return $task->userIsASolver($user) || $task->userIsAPeerReviewer($user); } if ($this->canEdit($user)) { diff --git a/lib/models/Courseware/Task.php b/lib/models/Courseware/Task.php index d409676..3d82b64 100644 --- a/lib/models/Courseware/Task.php +++ b/lib/models/Courseware/Task.php @@ -78,6 +78,14 @@ class Task extends \SimpleORMap 'foreign_key' => 'feedback_id', ]; + $config['has_many']['peer_reviews'] = [ + 'class_name' => PeerReview::class, + 'assoc_foreign_key' => 'task_id', + 'on_delete' => 'delete', + 'on_store' => 'store', + 'order_by' => 'ORDER BY mkdate', + ]; + $config['additional_fields']['solver'] = [ 'get' => 'getSolver', 'set' => false, @@ -162,6 +170,14 @@ class Task extends \SimpleORMap } /** + * @param \User|\Seminar_User $user + */ + public function userIsAPeerReviewer($user): bool + { + return $this->isPeerReviewed() && $this->isPeerReviewedBy($user); + } + + /** * @return \User|\Statusgruppen|null the solver */ public function getSolver() @@ -235,6 +251,31 @@ class Task extends \SimpleORMap $this->store(); } + public function isPeerReviewed(): bool + { + return PeerReview::countBySql('task_id = ?', [$this->getId()]) !== 0; + } + + /** + * @param \User|\Seminar_User $user + */ + public function isPeerReviewedBy($user): bool + { + $sql = 'task_id = ? AND reviewer_id = ? AND reviewer_type = "autor"'; + if (PeerReview::countBySql($sql, [$this->getId(), $user->id]) !== 0) { + return true; + } + + $sql = 'SELECT reviewer_id FROM cw_peer_reviews WHERE task_id = ? AND reviewer_type = "group"'; + foreach (\DBManager::get()->fetchFirst($sql, [$this->getId()]) as $reviewerId) { + if (\Statusgruppen::isMemberOf($reviewerId, $user->id)) { + return true; + } + } + + return false; + } + private function getStructuralElementProgress(StructuralElement $structural_element): float { $containers = Container::findBySQL('structural_element_id = ?', [intval($structural_element->id)]); diff --git a/lib/models/Courseware/TaskGroup.php b/lib/models/Courseware/TaskGroup.php index 6902cb3..626e7cc 100644 --- a/lib/models/Courseware/TaskGroup.php +++ b/lib/models/Courseware/TaskGroup.php @@ -30,6 +30,7 @@ use User; * @property \Course $course belongs_to \Course * @property \Courseware\StructuralElement $target belongs_to Courseware\StructuralElement * @property \SimpleORMapCollection $tasks has_many Courseware\Task + * @property \SimpleORMapCollection $peer_review_processes has_many Courseware\PeerReviewProcess * * @SuppressWarnings(PHPMD.StaticAccess) */ @@ -62,6 +63,16 @@ class TaskGroup extends \SimpleORMap implements \PrivacyObject 'order_by' => 'ORDER BY mkdate', ]; + $config['has_many']['peer_review_processes'] = [ + 'class_name' => PeerReviewProcess::class, + 'assoc_foreign_key' => 'task_group_id', + 'on_delete' => 'delete', + 'on_store' => 'store', + 'order_by' => 'ORDER BY mkdate', + ]; + + $config['registered_callbacks']['after_store'][] = 'cbAfterStore'; + parent::configure($config); } @@ -109,6 +120,11 @@ class TaskGroup extends \SimpleORMap implements \PrivacyObject ); } + public function hasPeerReviewProcesses(): bool + { + return PeerReviewProcess::countBySql('task_group_id = ?', [$this->getId()]) > 0; + } + /** * Returns the task of this TaskGroup given to $solver. * @@ -130,4 +146,19 @@ class TaskGroup extends \SimpleORMap implements \PrivacyObject return empty($row) ? null : Task::find($row['id']); } + public function cbAfterStore(): void + { + if ($this->isFieldDirty('end_date')) { + $this->reschedulePeerReviewProcesses(); + } + } + + private function reschedulePeerReviewProcesses(): void + { + if ($this->hasPeerReviewProcesses()) { + foreach ($this->peer_review_processes as $process) { + $process->rescheduleTo($this->end_date); + } + } + } } diff --git a/lib/modules/CoursewareModule.php b/lib/modules/CoursewareModule.php index 8deeda7..de85ddb 100644 --- a/lib/modules/CoursewareModule.php +++ b/lib/modules/CoursewareModule.php @@ -71,6 +71,12 @@ class CoursewareModule extends CorePlugin implements SystemPlugin, StudipModule 'tasks', new Navigation(_('Aufgaben'), 'dispatch.php/course/courseware/tasks?cid=' . $courseId) ); + if (!$GLOBALS['perm']->have_studip_perm('tutor', $courseId)) { + $navigation->addSubNavigation( + 'peer-review', + new Navigation(_('Peer-Reviews'), 'dispatch.php/course/courseware/tasks/peer-review-processes?cid=' . $courseId) + ); + } $navigation->addSubNavigation( 'comments', new Navigation(_('Kommentare und Anmerkungen'), 'dispatch.php/course/courseware/comments_overview?cid=' . $courseId) |
