diff options
| author | Marcus Eibrink-Lunzenauer <lunzenauer@elan-ev.de> | 2025-01-15 09:08:37 +0000 |
|---|---|---|
| committer | Marcus Eibrink-Lunzenauer <lunzenauer@elan-ev.de> | 2025-01-15 09:08:37 +0000 |
| commit | 58ca2df83f308e8acf8cddfbae68c3cf6abdd316 (patch) | |
| tree | d9c5f59556525d1e703352dc1ba536096f3e949d /lib/models | |
| parent | 8da661dad2dcefddce9fbb2bbb0e6dd1d1127db0 (diff) | |
Integration von Peer-Review in Courseware
Closes #2484
Merge request studip/studip!3196
Diffstat (limited to 'lib/models')
| -rw-r--r-- | lib/models/Courseware/PeerReview.php | 93 | ||||
| -rw-r--r-- | lib/models/Courseware/PeerReviewProcess.php | 188 | ||||
| -rw-r--r-- | lib/models/Courseware/StructuralElement.php | 4 | ||||
| -rw-r--r-- | lib/models/Courseware/Task.php | 75 | ||||
| -rw-r--r-- | lib/models/Courseware/TaskGroup.php | 31 |
5 files changed, 381 insertions, 10 deletions
diff --git a/lib/models/Courseware/PeerReview.php b/lib/models/Courseware/PeerReview.php new file mode 100644 index 0000000..0a62527 --- /dev/null +++ b/lib/models/Courseware/PeerReview.php @@ -0,0 +1,93 @@ +<?php + +namespace Courseware; + +use Course; +use Statusgruppen; +use User; + +/** + * Courseware's peer review instances. + * + * @since Stud.IP 6.0 + * + * @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 + { + return match($this->reviewer_type) { + 'autor' => $this->reviewer_id === $user->id, + 'group' => \Statusgruppen::isMemberOf($this->reviewer_id, $user->getId()), + }; + } + + public function getReviewer(): User|Statusgruppen + { + return match($this->reviewer_type) { + 'autor' => User::find($this->reviewer_id), + 'group' => Statusgruppen::find($this->reviewer_id), + }; + } + + public function isSubmitter(User $user): bool + { + return match (get_class($this->getSubmitter())) { + Statusgruppen::class => \Statusgruppen::isMemberOf($this->submitter_id, $user->id), + User::class => $this->submitter_id === $user->id + }; + } + + public function getSubmitter(): User|Statusgruppen + { + return User::find($this->submitter_id) + ?? Statusgruppen::find($this->submitter_id); + } +} diff --git a/lib/models/Courseware/PeerReviewProcess.php b/lib/models/Courseware/PeerReviewProcess.php new file mode 100644 index 0000000..ae92698 --- /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 6.0 + */ +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['anonymous']; + } + + 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; + $copy[] = array_shift($copy); + $pairings = array_map(null, $submitters, $copy); + + return array_map(function ($pairing) use ($taskGroup) { + [$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 bf3644c..3f7c569 100644 --- a/lib/models/Courseware/StructuralElement.php +++ b/lib/models/Courseware/StructuralElement.php @@ -285,7 +285,7 @@ class StructuralElement extends \SimpleORMap implements \PrivacyObject, \Feedbac if ($this->range_id === $user->id) { return true; } - + return $this->hasWriteContentApproval($user); case 'course': @@ -420,6 +420,8 @@ class StructuralElement extends \SimpleORMap implements \PrivacyObject, \Feedbac } return $task->userIsASolver($user); + // TODO (mel): Das ist die ursprüngliche Variante, die aber jetzt kompliziert ist. Mit Nico sprechen! + // 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 7842830..5f38ce9 100644 --- a/lib/models/Courseware/Task.php +++ b/lib/models/Courseware/Task.php @@ -2,6 +2,7 @@ namespace Courseware; +use Seminar_User; use User; /** @@ -79,6 +80,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', ]; @@ -123,12 +132,11 @@ class Task extends \SimpleORMap return 1 === (int) $this->submitted; } - /** - * @param \User|\Seminar_User $user - */ - public function canUpdate($user): bool + public function canUpdate(User|Seminar_User $user): bool { - $perm = false; + // TODO (mel): Das ist hier eine Code-Verdopplung gegenüber: + // $this->userIsASolver($user) + // Mit Nico besprechen switch ($this->solver_type) { case 'autor': if ($this->solver_id === $user->id) { @@ -157,10 +165,7 @@ class Task extends \SimpleORMap return $this->getStructuralElement()->hasEditingPermission($user); } - /** - * @param \User|\Seminar_User $user - */ - public function userIsASolver($user): bool + public function userIsASolver(User|Seminar_User $user): bool { switch ($this->solver_type) { case 'autor': @@ -175,6 +180,11 @@ class Task extends \SimpleORMap return false; } + public function userIsAPeerReviewer(User|Seminar_User $user): bool + { + return $this->isPeerReviewed() && $this->isPeerReviewedBy($user); + } + /** * @return \User|\Statusgruppen|null the solver */ @@ -255,6 +265,53 @@ class Task extends \SimpleORMap $this->store(); } + public function isPeerReviewed(): bool + { + return PeerReview::countBySql('task_id = ?', [$this->id]) !== 0; + } + + public function isPeerReviewedBy(User|Seminar_User $user): bool + { + $sql = 'task_id = ? AND reviewer_id = ? AND reviewer_type = "autor"'; + if (PeerReview::countBySql($sql, [$this->id, $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->id]) as $reviewerId) { + if (\Statusgruppen::isMemberOf($reviewerId, $user->id)) { + return true; + } + } + + return false; + } + + public function getPeerReviewProcessessWithReviewsBy(User|Seminar_User $user): array + { + return PeerReviewProcess::findBySql( + 'id IN (?)', + array_unique( + array_merge( + \DBManager::get()->fetchFirst( + 'SELECT DISTINCT process_id FROM cw_peer_reviews WHERE task_id = ? AND reviewer_id = ? AND reviewer_type = "autor"', + [$this->id, $user->id] + ), + array_column( + array_filter( + \DBManager::get()->fetchAll( + 'SELECT process_id, reviewer_id FROM cw_peer_reviews WHERE task_id = ? AND reviewer_type = "group"', + [$this->id] + ), + fn($row) => \Statusgruppen::isMemberOf($row['reviewer_id'], $user->id) + ), + 'process_id' + ) + ) + ) + ); + } + 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); + } + } + } } |
