diff --git a/assets/locales/en_US.json b/assets/locales/en_US.json index d86ab1fac91..9ea72beab5e 100644 --- a/assets/locales/en_US.json +++ b/assets/locales/en_US.json @@ -1,4 +1,12 @@ { + "Calculation mode": "Calculation mode", + "Weighted average": "Weighted average", + "Points sum": "Points sum", + "Each item weight is treated as its maximum points; the grade is the sum of points, not normalized.": "Each item weight is treated as its maximum points; the grade is the sum of points, not normalized.", + "The grade is the weighted average of items, normalized by the sum of weights.": "The grade is the weighted average of items, normalized by the sum of weights.", + "Forum thread": "Forum thread", + "Points for one message": "Points for one message", + "Points for two or more messages": "Points for two or more messages", "Agenda": "Agenda", "Documents": "Documents", "Groups": "Groups", diff --git a/assets/vue/components/gradebook/CalculationModeSelector.vue b/assets/vue/components/gradebook/CalculationModeSelector.vue new file mode 100644 index 00000000000..4bb33dceabe --- /dev/null +++ b/assets/vue/components/gradebook/CalculationModeSelector.vue @@ -0,0 +1,69 @@ + + + diff --git a/assets/vue/components/gradebook/ForumParticipationItemForm.vue b/assets/vue/components/gradebook/ForumParticipationItemForm.vue new file mode 100644 index 00000000000..ab289ff21d3 --- /dev/null +++ b/assets/vue/components/gradebook/ForumParticipationItemForm.vue @@ -0,0 +1,122 @@ + + + diff --git a/assets/vue/services/gradebookService.js b/assets/vue/services/gradebookService.js index 81746870ce7..7859766da3f 100644 --- a/assets/vue/services/gradebookService.js +++ b/assets/vue/services/gradebookService.js @@ -39,4 +39,68 @@ export default { async getDefaultCertificate(courseId) { return await baseService.get(`${API_BASE}/default_certificate/${courseId}`) }, + + /** + * Updates the calculation mode (weighted_average | points_sum) of a gradebook category. + * @param {number|string} categoryId The numeric id of the gradebook category. + * @param {string} calculationMode The target calculation mode. + * @returns {Promise} The updated category resource. + */ + async updateCalculationMode(categoryId, calculationMode) { + return await baseService.put(`/api/gradebook_categories/${categoryId}`, { calculationMode }) + }, + + /** + * Fetches the gradebook links of a category. + * @param {number|string} categoryId The numeric id of the gradebook category. + * @returns {Promise} The list of gradebook links. + */ + async getLinks(categoryId) { + return await baseService.getCollection("/api/gradebook_links", { + category: `/api/gradebook_categories/${categoryId}`, + }) + }, + + /** + * Creates a forum participation gradebook item. + * @param {Object} payload The link payload. + * @param {number} payload.threadId The forum thread id used as ref_id. + * @param {number} payload.courseId The course id. + * @param {number|string} payload.categoryId The gradebook category id. + * @param {number} payload.pointsOne Points awarded for exactly one message. + * @param {number} payload.pointsMany Points awarded for two or more messages. + * @returns {Promise} The created gradebook link resource. + */ + async createForumParticipationLink({ threadId, courseId, categoryId, pointsOne, pointsMany }) { + // 11 = LINK_FORUM_PARTICIPATION. Weight equals pointsMany (the item's max points) so in + // points_sum the contribution equals the earned points. + return await baseService.post("/api/gradebook_links", { + type: 11, + refId: threadId, + course: `/api/courses/${courseId}`, + category: `/api/gradebook_categories/${categoryId}`, + weight: Number(pointsMany), + pointsOne: String(pointsOne), + pointsMany: String(pointsMany), + }) + }, + + /** + * Updates a forum participation gradebook item. + * @param {number|string} linkId The numeric id of the gradebook link. + * @param {Object} payload The fields to update (pointsOne, pointsMany, refId). + * @returns {Promise} The updated gradebook link resource. + */ + async updateForumParticipationLink(linkId, payload) { + return await baseService.put(`/api/gradebook_links/${linkId}`, payload) + }, + + /** + * Deletes a gradebook link. + * @param {number|string} linkId The numeric id of the gradebook link. + * @returns {Promise} + */ + async deleteLink(linkId) { + return await baseService.delete(`/api/gradebook_links/${linkId}`) + }, } diff --git a/public/main/gradebook/gradebook_add_cat.php b/public/main/gradebook/gradebook_add_cat.php index fe28c5f0c9b..3385818356f 100644 --- a/public/main/gradebook/gradebook_add_cat.php +++ b/public/main/gradebook/gradebook_add_cat.php @@ -71,6 +71,10 @@ $cat->set_parent_id($values['hid_parent_id']); $cat->set_weight($values['weight']); + if (isset($values['calculation_mode'])) { + $cat->setCalculationMode($values['calculation_mode']); + } + if (isset($values['generate_certificates'])) { $cat->setGenerateCertificates(true); } else { diff --git a/public/main/gradebook/gradebook_add_link.php b/public/main/gradebook/gradebook_add_link.php index 210ac262082..5133c2d23fa 100644 --- a/public/main/gradebook/gradebook_add_link.php +++ b/public/main/gradebook/gradebook_add_link.php @@ -93,12 +93,25 @@ $parent_cat = Category::load($addvalues['select_gradebook']); $global_weight = $category[0]->get_weight(); - $link->set_weight($addvalues['weight_mask']); if (isset($addvalues['min_score']) && $addvalues['min_score'] !== '') { $link->set_min_score(api_float_val($addvalues['min_score'])); } + if (LINK_FORUM_PARTICIPATION == $link->get_type()) { + $pointsOne = isset($addvalues['points_one']) && '' !== $addvalues['points_one'] + ? api_float_val($addvalues['points_one']) + : null; + $pointsMany = isset($addvalues['points_many']) && '' !== $addvalues['points_many'] + ? api_float_val($addvalues['points_many']) + : null; + $link->set_points_one($pointsOne); + $link->set_points_many($pointsMany); + // Weight is derived from the points by ForumParticipationLink::get_weight(). + } else { + $link->set_weight($addvalues['weight_mask']); + } + if ($link->needs_max()) { $link->set_max($addvalues['max']); } diff --git a/public/main/gradebook/gradebook_edit_cat.php b/public/main/gradebook/gradebook_edit_cat.php index 9acdccb1df3..7c03da922fe 100644 --- a/public/main/gradebook/gradebook_edit_cat.php +++ b/public/main/gradebook/gradebook_edit_cat.php @@ -54,6 +54,10 @@ $cat->set_parent_id($values['hid_parent_id']); $cat->set_weight($values['weight']); + if (isset($values['calculation_mode'])) { + $cat->setCalculationMode($values['calculation_mode']); + } + if (isset($values['generate_certificates'])) { $cat->setGenerateCertificates($values['generate_certificates']); } else { diff --git a/public/main/gradebook/gradebook_edit_link.php b/public/main/gradebook/gradebook_edit_link.php index 5453d2492d8..ab191260fd0 100644 --- a/public/main/gradebook/gradebook_edit_link.php +++ b/public/main/gradebook/gradebook_edit_link.php @@ -53,8 +53,6 @@ if ($form->validate()) { $values = $form->exportValues(); $parent_cat = Category::load($values['select_gradebook']); - $final_weight = $values['weight_mask']; - $link->set_weight($final_weight); if (!empty($values['select_gradebook'])) { $link->set_category_id($values['select_gradebook']); @@ -63,6 +61,22 @@ if (isset($values['min_score']) && $values['min_score'] !== '') { $link->set_min_score(api_float_val($values['min_score'])); } + + if (LINK_FORUM_PARTICIPATION == $link->get_type()) { + $pointsOne = isset($values['points_one']) && '' !== $values['points_one'] + ? api_float_val($values['points_one']) + : null; + $pointsMany = isset($values['points_many']) && '' !== $values['points_many'] + ? api_float_val($values['points_many']) + : null; + $link->set_points_one($pointsOne); + $link->set_points_many($pointsMany); + // Weight is derived from the points by ForumParticipationLink::get_weight(). + $final_weight = $link->get_weight(); + } else { + $final_weight = $values['weight_mask']; + } + $link->set_weight($final_weight); $link->save(); //Update weight for attendance diff --git a/public/main/gradebook/lib/be/abstractlink.class.php b/public/main/gradebook/lib/be/abstractlink.class.php index 90765acc16a..1c23c83186a 100644 --- a/public/main/gradebook/lib/be/abstractlink.class.php +++ b/public/main/gradebook/lib/be/abstractlink.class.php @@ -34,6 +34,9 @@ abstract class AbstractLink implements GradebookItem protected ?float $min_score = null; + protected ?float $points_one = null; + protected ?float $points_many = null; + /** * Constructor. */ @@ -53,6 +56,26 @@ public function set_min_score(?float $minScore): void $this->min_score = ($minScore === null) ? null : api_float_val($minScore); } + public function get_points_one(): ?float + { + return $this->points_one; + } + + public function set_points_one(?float $pointsOne): void + { + $this->points_one = ($pointsOne === null) ? null : api_float_val($pointsOne); + } + + public function get_points_many(): ?float + { + return $this->points_many; + } + + public function set_points_many(?float $pointsMany): void + { + $this->points_many = ($pointsMany === null) ? null : api_float_val($pointsMany); + } + /** * @return bool */ @@ -429,6 +452,8 @@ public function add(): int ->setCategory($category) ->setCourse(api_get_course_entity($this->course_id)) ->setMinScore($this->get_min_score()) + ->setPointsOne(null !== $this->get_points_one() ? (string) $this->get_points_one() : null) + ->setPointsMany(null !== $this->get_points_many() ? (string) $this->get_points_many() : null) ; $em->persist($link); $em->flush(); @@ -480,6 +505,8 @@ public function save() ->setWeight($this->get_weight()) ->setVisible($this->is_visible()) ->setMinScore($this->get_min_score()) + ->setPointsOne(null !== $this->get_points_one() ? (string) $this->get_points_one() : null) + ->setPointsMany(null !== $this->get_points_many() ? (string) $this->get_points_many() : null) ; $em->persist($link); @@ -810,6 +837,12 @@ private static function create_objects_from_sql_result(\Doctrine\DBAL\Result $re if (array_key_exists('min_score', $data)) { $link->set_min_score($data['min_score'] !== null ? (float) $data['min_score'] : null); } + if (array_key_exists('points_one', $data)) { + $link->set_points_one($data['points_one'] !== null ? (float) $data['points_one'] : null); + } + if (array_key_exists('points_many', $data)) { + $link->set_points_many($data['points_many'] !== null ? (float) $data['points_many'] : null); + } //session id should depend on the category --> $data['category_id'] $session_id = api_get_session_id(); diff --git a/public/main/gradebook/lib/be/category.class.php b/public/main/gradebook/lib/be/category.class.php index 8e08fa93be8..fa2b8e07c66 100644 --- a/public/main/gradebook/lib/be/category.class.php +++ b/public/main/gradebook/lib/be/category.class.php @@ -4,6 +4,7 @@ use Chamilo\CoreBundle\Entity\GradebookCategory; use Chamilo\CoreBundle\Enums\ActionIcon; +use Chamilo\CoreBundle\Enums\GradebookCalculationMode; use Chamilo\CoreBundle\Framework\Container; use ChamiloSession as Session; @@ -41,6 +42,7 @@ class Category implements GradebookItem private $gradeBooksToValidateInDependence; private $locked; private int $allowSkillsBySubcategory; + private string $calculationMode; /** * Consctructor. @@ -64,6 +66,7 @@ public function __construct() $this->documentId = 0; $this->minimumToValidate = null; $this->allowSkillsBySubcategory = 1; + $this->calculationMode = GradebookCalculationMode::WEIGHTED_AVERAGE->value; } /** @@ -74,6 +77,17 @@ public function get_id() return $this->id; } + public function getCalculationMode(): string + { + return $this->calculationMode; + } + + public function setCalculationMode(?string $calculationMode): void + { + $this->calculationMode = GradebookCalculationMode::tryFrom((string) $calculationMode)?->value + ?? GradebookCalculationMode::WEIGHTED_AVERAGE->value; + } + /** * @return string */ @@ -574,6 +588,9 @@ public function add() ); } $category->setAllowSkillsBySubcategory((int) $this->allowSkillsBySubcategory); + $category->setCalculationMode( + GradebookCalculationMode::tryFrom($this->calculationMode) ?? GradebookCalculationMode::WEIGHTED_AVERAGE + ); $category->setLocked(0); $em->persist($category); @@ -678,6 +695,9 @@ public function save() } $category->setAllowSkillsBySubcategory((int) $this->allowSkillsBySubcategory); + $category->setCalculationMode( + GradebookCalculationMode::tryFrom($this->calculationMode) ?? GradebookCalculationMode::WEIGHTED_AVERAGE + ); $em->persist($category); $em->flush(); @@ -1097,6 +1117,13 @@ public function calc_score( } } + // In POINTS_SUM mode each weight is the item's max points; the category grade is the + // raw points sum (Σ score/max × weight) and is NOT normalized by Σweight. Every + // downstream consumer computes num/den*100, so returning den=100 yields exactly $ressum. + $scoreDenominator = GradebookCalculationMode::POINTS_SUM->value === $this->calculationMode + ? 100 + : $weightsum; + switch ($type) { case 'best': arsort($totalScorePerStudent); @@ -1118,12 +1145,12 @@ public function calc_score( if ($cacheAvailable) { $cacheItem = $cache->getItem($key); - $cacheItem->set([$ressum, $weightsum]); + $cacheItem->set([$ressum, $scoreDenominator]); $cache->save($cacheItem); } - return [$ressum, $weightsum]; + return [$ressum, $scoreDenominator]; //break; case 'ranking': // category ranking is calculated in gradebook_data_generator.class.php @@ -1135,12 +1162,12 @@ public function calc_score( default: if ($cacheAvailable) { $cacheItem = $cache->getItem($key); - $cacheItem->set([$ressum, $weightsum]); + $cacheItem->set([$ressum, $scoreDenominator]); $cache->save($cacheItem); } - return [$ressum, $weightsum]; + return [$ressum, $scoreDenominator]; } } @@ -2752,6 +2779,7 @@ private static function create_category_objects_from_sql_result(?Doctrine\DBAL\R $cat->setGenerateCertificates($data['generate_certificates']); $cat->setIsRequirement($data['is_requirement']); $cat->setAllowSkillBySubCategory($data['allow_skills_by_subcategory'] ?? 1); + $cat->setCalculationMode($data['calculation_mode'] ?? null); $cat->setMinimumToValidate(isset($data['minimum_to_validate']) ? $data['minimum_to_validate'] : null); $cat->setGradeBooksToValidateInDependence(isset($data['gradebooks_to_validate_in_dependence']) ? $data['gradebooks_to_validate_in_dependence'] : null); $cat->setDocumentId($data['document_id']); diff --git a/public/main/gradebook/lib/be/forumparticipationlink.class.php b/public/main/gradebook/lib/be/forumparticipationlink.class.php new file mode 100644 index 00000000000..d88e0435f48 --- /dev/null +++ b/public/main/gradebook/lib/be/forumparticipationlink.class.php @@ -0,0 +1,227 @@ + 0, exactly 1 -> pointsOne, 2 or more -> pointsMany. + * + * Designed to be used inside a POINTS_SUM gradebook category: calc_score() + * returns [points, 1] and the link weight is expected to be 1, so the item + * contributes its fixed points (pointsOne/pointsMany) directly. + */ +class ForumParticipationLink extends AbstractLink +{ + private $forum_thread_table; + + public function __construct() + { + parent::__construct(); + $this->set_type(LINK_FORUM_PARTICIPATION); + } + + public function get_type_name() + { + return get_lang('Forum participation'); + } + + public function is_allowed_to_change_name(): bool + { + return false; + } + + /** + * List of forum threads available to attach this link to. + * + * @return array 2-dimensional array - every element contains 2 subelements (id, name) + */ + public function get_all_links() + { + if (empty($this->getCourseId())) { + return []; + } + + $repo = Container::getForumThreadRepository(); + $course = api_get_course_entity($this->getCourseId()); + $session = api_get_session_entity($this->get_session_id()); + + $qb = $repo->findAllByCourse($course, $session); + /** @var CForumThread[] $threads */ + $threads = $qb->getQuery()->getResult(); + + $cats = []; + foreach ($threads as $thread) { + $cats[] = [$thread->getIid(), $thread->getTitle()]; + } + + return $cats; + } + + public function has_results(): bool + { + return $this->countPosts(null) > 0; + } + + /** + * @param int $studentId + * @param string $type + * + * @return array|null + */ + public function calc_score($studentId = null, $type = null) + { + $pointsOne = (float) ($this->get_points_one() ?? 0); + $max = $this->getMaxPoints(); + $max = $max > 0 ? $max : 1.0; + + // Aggregate (all students) is not meaningful for a fixed-points item. + if (!isset($studentId)) { + return [null, null]; + } + + $count = $this->countPosts((int) $studentId); + + if (0 === $count) { + $score = 0.0; + } elseif (1 === $count) { + $score = $pointsOne; + } else { + $score = $this->getEffectiveMany(); + } + + return [$score, $max]; + } + + /** + * The weight is derived from the points so it always matches them, instead of relying on + * the separately stored weight column (which could drift out of sync). Used by the gradebook + * display (Weight column) and by the category calculation in POINTS_SUM. + * + * @return float + */ + public function get_weight() + { + return $this->getMaxPoints(); + } + + /** + * Highest award the item can give: pointsMany when set, otherwise pointsOne. + */ + private function getMaxPoints(): float + { + return max((float) ($this->get_points_one() ?? 0), $this->getEffectiveMany()); + } + + /** + * Points for two or more messages, falling back to pointsOne when no 2+ bonus is configured. + */ + private function getEffectiveMany(): float + { + $pointsOne = (float) ($this->get_points_one() ?? 0); + $pointsMany = (float) ($this->get_points_many() ?? 0); + + return $pointsMany > 0 ? $pointsMany : $pointsOne; + } + + public function needs_name_and_description(): bool + { + return false; + } + + public function needs_max(): bool + { + return false; + } + + public function needs_results(): bool + { + return false; + } + + public function get_name() + { + $thread = $this->getThread(); + + return $thread ? $thread->getTitle() : ''; + } + + public function get_description() + { + $one = $this->get_points_one(); + $many = $this->get_points_many(); + + if (null === $one && null === $many) { + return ''; + } + + // Surfaces the scoring values to the teacher, since the weight column only shows the max. + $description = get_lang('Points for one message').': '.api_float_val($one); + if (null !== $many && (float) $many > 0) { + $description .= ' · '.get_lang('Points for two or more messages').': '.api_float_val($many); + } + + return $description; + } + + public function is_valid_link(): bool + { + return null !== $this->getThread(); + } + + public function get_link() + { + $thread = $this->getThread(); + if (null === $thread) { + return ''; + } + + $forumId = $thread->getForum() ? $thread->getForum()->getIid() : 0; + + return api_get_path(WEB_CODE_PATH).'forum/viewthread.php?'. + api_get_cidreq_params($this->getCourseId(), $this->get_session_id()). + '&thread='.$this->get_ref_id().'&gradebook=view&forum='.$forumId; + } + + public function get_icon_name(): string + { + return 'forum'; + } + + /** + * Count visible posts authored by a student in the linked thread. + * Passing null counts every student's posts. + */ + private function countPosts(?int $studentId): int + { + $table = Database::get_course_table(TABLE_FORUM_POST); + $threadId = $this->get_ref_id(); + + $sql = "SELECT COUNT(iid) AS number + FROM $table + WHERE thread_id = $threadId + AND visible = 1"; + if (null !== $studentId) { + $sql .= ' AND poster_id = '.$studentId; + } + + $result = Database::query($sql); + $row = Database::fetch_array($result); + + return (int) ($row['number'] ?? 0); + } + + private function getThread(): ?CForumThread + { + $refId = $this->get_ref_id(); + if (empty($refId)) { + return null; + } + + return Container::getForumThreadRepository()->find($refId); + } +} diff --git a/public/main/gradebook/lib/be/linkfactory.class.php b/public/main/gradebook/lib/be/linkfactory.class.php index cc0f77b7089..966cded604b 100644 --- a/public/main/gradebook/lib/be/linkfactory.class.php +++ b/public/main/gradebook/lib/be/linkfactory.class.php @@ -90,6 +90,8 @@ public static function create($type) return new LearnpathLink(); case LINK_FORUM_THREAD: return new ForumThreadLink(); + case LINK_FORUM_PARTICIPATION: + return new ForumParticipationLink(); case LINK_ATTENDANCE: return new AttendanceLink(); case LINK_SURVEY: @@ -114,6 +116,7 @@ public static function get_all_types() LINK_STUDENTPUBLICATION, LINK_LEARNPATH, LINK_FORUM_THREAD, + LINK_FORUM_PARTICIPATION, LINK_ATTENDANCE, LINK_SURVEY, ]; diff --git a/public/main/gradebook/lib/fe/catform.class.php b/public/main/gradebook/lib/fe/catform.class.php index 3151da71304..5f6b10868d2 100644 --- a/public/main/gradebook/lib/fe/catform.class.php +++ b/public/main/gradebook/lib/fe/catform.class.php @@ -2,6 +2,7 @@ /* For licensing terms, see /license.txt */ +use Chamilo\CoreBundle\Enums\GradebookCalculationMode; use Chamilo\CourseBundle\Entity\CDocument; /** @@ -181,6 +182,7 @@ protected function build_editing_form() 'generate_certificates' => $this->category_object->getGenerateCertificates(), 'is_requirement' => $this->category_object->getIsRequirement(), 'allow_skills_by_subcategory' => $this->category_object->getAllowSkillBySubCategory() ? 1 : 0, + 'calculation_mode' => $this->category_object->getCalculationMode(), ] ); @@ -255,6 +257,18 @@ private function build_basic_form() ['value' => $value, 'maxlength' => '5'] ); + $this->addSelect( + 'calculation_mode', + [ + get_lang('Calculation mode'), + get_lang('Weighted average normalizes by the sum of weights; points sum treats each weight as maximum points and adds them up without normalizing.'), + ], + [ + GradebookCalculationMode::WEIGHTED_AVERAGE->value => get_lang('Weighted average'), + GradebookCalculationMode::POINTS_SUM->value => get_lang('Points sum'), + ] + ); + $skillsDefaults = []; $allowSkillEdit = api_is_platform_admin() || api_is_drh(); diff --git a/public/main/gradebook/lib/fe/linkaddeditform.class.php b/public/main/gradebook/lib/fe/linkaddeditform.class.php index d23ce493eb8..a7aa8ebdecd 100644 --- a/public/main/gradebook/lib/fe/linkaddeditform.class.php +++ b/public/main/gradebook/lib/fe/linkaddeditform.class.php @@ -105,20 +105,25 @@ public function __construct( } } - $this->addFloat( - 'weight_mask', - [ - get_lang('Weight'), - null, - ' [0 .. '.$category_object[0]->get_weight( - ).'] ', - ], - true, - [ - 'size' => '4', - 'maxlength' => '5', - ] - ); + // Forum participation derives its weight from pointsMany (its maximum points), + // so the manual weight field is hidden for that type. + $isForumParticipation = LINK_FORUM_PARTICIPATION == $link->get_type(); + if (!$isForumParticipation) { + $this->addFloat( + 'weight_mask', + [ + get_lang('Weight'), + null, + ' [0 .. '.$category_object[0]->get_weight( + ).'] ', + ], + true, + [ + 'size' => '4', + 'maxlength' => '5', + ] + ); + } // ELEMENT: min_score $this->addFloat( @@ -142,6 +147,36 @@ public function __construct( 0 ); + // ELEMENTS: forum participation points (only for the forum participation link type) + if (LINK_FORUM_PARTICIPATION == $link->get_type()) { + $this->addFloat( + 'points_one', + [ + get_lang('Points for one message'), + get_lang('Points awarded when the student posted exactly one message in the thread.'), + ], + true, + ['size' => '4', 'maxlength' => '8'] + ); + $this->addRule('points_one', get_lang('Only numbers'), 'numeric'); + + $this->addFloat( + 'points_many', + [ + get_lang('Points for two or more messages'), + get_lang('Optional. If left empty, one or more messages award the points for one message.'), + ], + false, + ['size' => '4', 'maxlength' => '8'] + ); + $this->addRule('points_many', get_lang('Only numbers'), 'numeric'); + + if (self::TYPE_EDIT == $form_type) { + $defaults['points_one'] = $link->get_points_one(); + $defaults['points_many'] = $link->get_points_many(); + } + } + $this->addElement('hidden', 'weight'); if (self::TYPE_EDIT == $form_type) { diff --git a/public/main/inc/lib/api.lib.php b/public/main/inc/lib/api.lib.php index ed5123f9da0..6bd1906545e 100644 --- a/public/main/inc/lib/api.lib.php +++ b/public/main/inc/lib/api.lib.php @@ -389,6 +389,7 @@ define('LINK_SURVEY', 8); define('LINK_HOTPOTATOES', 9); define('LINK_PORTFOLIO', 10); +define('LINK_FORUM_PARTICIPATION', 11); // Score display types constants define('SCORE_DIV', 1); // X / Y diff --git a/src/CoreBundle/Command/ImportCustomGradingRubricsCommand.php b/src/CoreBundle/Command/ImportCustomGradingRubricsCommand.php new file mode 100644 index 00000000000..3d6e076bbb9 --- /dev/null +++ b/src/CoreBundle/Command/ImportCustomGradingRubricsCommand.php @@ -0,0 +1,337 @@ + 2.0 migration. Resource references (exerciseId, parentId, + * evaluationId, thread ids) are taken from the seed as 2.x identifiers and + * validated for existence; anything not found is logged and skipped instead of + * creating a dangling link. Always run --dry-run first and confirm the report. + */ +#[AsCommand( + name: 'chamilo:import-custom-grading-rubrics', + description: 'Import the custom grading rubrics seed as native POINTS_SUM gradebook categories.', +)] +class ImportCustomGradingRubricsCommand extends Command +{ + private const LINK_EXERCISE = 1; + private const LINK_STUDENTPUBLICATION = 3; + private const LINK_FORUM_PARTICIPATION = 11; + + public function __construct( + private readonly EntityManagerInterface $em, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->addOption('file', null, InputOption::VALUE_REQUIRED, 'Path to the seed JSON file.') + ->addOption('owner-id', null, InputOption::VALUE_REQUIRED, 'User id used as the category owner.', '1') + ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Do not persist anything, only report.') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $file = (string) $input->getOption('file'); + if ('' === $file || !is_file($file)) { + $io->error(\sprintf('Seed file not found: %s', $file)); + + return Command::FAILURE; + } + + $dryRun = (bool) $input->getOption('dry-run'); + $ownerId = (int) $input->getOption('owner-id'); + + $owner = $this->em->getRepository(User::class)->find($ownerId); + if (null === $owner) { + $io->error(\sprintf('Owner user #%d not found.', $ownerId)); + + return Command::FAILURE; + } + + $data = json_decode((string) file_get_contents($file), true); + if (!\is_array($data)) { + $io->error('Could not decode the seed JSON.'); + + return Command::FAILURE; + } + + $courseRepo = $this->em->getRepository(Course::class); + $createdCategories = 0; + $createdItems = 0; + $skipped = []; + + foreach ($data as $courseCode => $course) { + $courseCode = (string) $courseCode; + + /** @var Course|null $courseEntity */ + $courseEntity = $courseRepo->findOneBy(['code' => $courseCode]); + if (null === $courseEntity) { + $skipped[] = \sprintf('Course "%s" not found by code; whole rubric skipped.', $courseCode); + + continue; + } + + $category = new GradebookCategory(); + $category->setTitle(\sprintf('%s rubric', $courseCode)); + $category->setUser($owner); + $category->setCourse($courseEntity); + $category->setWeight(100.0); + $category->setVisible(true); + $category->setCalculationMode(GradebookCalculationMode::POINTS_SUM); + + if (!$dryRun) { + $this->em->persist($category); + } + $createdCategories++; + + foreach (($course['components'] ?? []) as $component) { + $items = $this->buildItems($component, $courseEntity, $category, $courseCode, $skipped, $dryRun); + $createdItems += $items; + } + } + + if (!$dryRun) { + $this->em->flush(); + } + + foreach ($skipped as $message) { + $io->warning($message); + } + + $io->success(\sprintf( + '%s: %d categories, %d items (%d issues).', + $dryRun ? 'Dry-run' : 'Imported', + $createdCategories, + $createdItems, + \count($skipped) + )); + + return Command::SUCCESS; + } + + /** + * Builds the native gradebook items for a single seed component. + * + * @param array $component + * @param string[] $skipped Collects human-readable skip reasons (by reference) + * + * @return int Number of items created + */ + private function buildItems( + array $component, + Course $course, + GradebookCategory $category, + string $courseCode, + array &$skipped, + bool $dryRun + ): int { + $type = (string) ($component['type'] ?? ''); + + return match ($type) { + 'exercise' => $this->createLink( + self::LINK_EXERCISE, + (int) ($component['exerciseId'] ?? 0), + $this->weightToPoints($component['weight'] ?? 0), + CQuiz::class, + $course, + $category, + $courseCode, + $skipped, + $dryRun + ), + 'assignment' => $this->createLink( + self::LINK_STUDENTPUBLICATION, + (int) ($component['parentId'] ?? 0), + $this->weightToPoints($component['weight'] ?? 0), + CStudentPublication::class, + $course, + $category, + $courseCode, + $skipped, + $dryRun + ), + 'gradebook_result' => $this->createEvaluation( + (int) ($component['evaluationId'] ?? 0), + $this->weightToPoints($component['weight'] ?? 0), + $course, + $category, + $dryRun + ), + 'forum' => $this->createForumItems($component, $course, $category, $courseCode, $skipped, $dryRun), + default => $this->skip($skipped, \sprintf('Course "%s": unknown component type "%s".', $courseCode, $type)), + }; + } + + /** + * Migration rule: points weight = multiplier × 100 (e.g. 0.20 -> 20). + */ + private function weightToPoints(mixed $multiplier): float + { + return round((float) $multiplier * 100, 4); + } + + /** + * Creates a native gradebook link of the given type after validating the referenced resource exists. + * + * @param class-string $resourceClass + * @param string[] $skipped + */ + private function createLink( + int $type, + int $refId, + float $weight, + string $resourceClass, + Course $course, + GradebookCategory $category, + string $courseCode, + array &$skipped, + bool $dryRun + ): int { + if ($refId <= 0 || null === $this->em->getRepository($resourceClass)->find($refId)) { + return $this->skip( + $skipped, + \sprintf('Course "%s": %s #%d not found, link skipped.', $courseCode, $resourceClass, $refId) + ); + } + + $link = new GradebookLink(); + $link->setType($type); + $link->setRefId($refId); + $link->setCourse($course); + $link->setCategory($category); + $link->setWeight($weight); + $link->setVisible(1); + $link->setLocked(0); + + if (!$dryRun) { + $this->em->persist($link); + } + + return 1; + } + + /** + * Creates one ForumParticipationLink per thread in the component, all sharing the same one/many points. + * + * @param array $component + * @param string[] $skipped + */ + private function createForumItems( + array $component, + Course $course, + GradebookCategory $category, + string $courseCode, + array &$skipped, + bool $dryRun + ): int { + $pointsOneValue = round((float) ($component['one'] ?? 0), 4); + $pointsManyValue = round((float) ($component['many'] ?? 0), 4); + $pointsOne = (string) $pointsOneValue; + $pointsMany = $pointsManyValue > 0 ? (string) $pointsManyValue : null; + // Weight is the highest award (pointsMany, or pointsOne when no 2+ bonus is set). + $weight = max($pointsOneValue, $pointsManyValue); + $created = 0; + + foreach (($component['threads'] ?? []) as $threadId) { + $threadId = (int) $threadId; + if ($threadId <= 0 || null === $this->em->getRepository(CForumThread::class)->find($threadId)) { + $this->skip($skipped, \sprintf('Course "%s": forum thread #%d not found, skipped.', $courseCode, $threadId)); + + continue; + } + + $link = new GradebookLink(); + $link->setType(self::LINK_FORUM_PARTICIPATION); + $link->setRefId($threadId); + $link->setCourse($course); + $link->setCategory($category); + // The highest award is the item's weight, so in POINTS_SUM the contribution + // equals the earned points (score/max × weight = score). + $link->setWeight($weight); + $link->setVisible(1); + $link->setLocked(0); + $link->setPointsOne($pointsOne); + $link->setPointsMany($pointsMany); + + if (!$dryRun) { + $this->em->persist($link); + } + $created++; + } + + return $created; + } + + /** + * Creates a native gradebook evaluation. The score scale assumed for the points weight is 0-100 + * (max = 100), so score/max × weight reproduces the client's "score × multiplier" arithmetic. + */ + private function createEvaluation( + int $evaluationId, + float $weight, + Course $course, + GradebookCategory $category, + bool $dryRun + ): int { + $evaluation = new GradebookEvaluation(); + $evaluation->setTitle(\sprintf('Evaluation %d', $evaluationId)); + $evaluation->setCourse($course); + $evaluation->setCategory($category); + $evaluation->setWeight($weight); + $evaluation->setMax(100.0); + $evaluation->setVisible(1); + $evaluation->setType('evaluation'); + $evaluation->setLocked(0); + $evaluation->setCreatedAt(new DateTime()); + + if (!$dryRun) { + $this->em->persist($evaluation); + } + + return 1; + } + + /** + * Records a skip reason and returns 0 (no item created). + * + * @param string[] $skipped + */ + private function skip(array &$skipped, string $message): int + { + $skipped[] = $message; + + return 0; + } +} diff --git a/src/CoreBundle/Entity/GradebookCategory.php b/src/CoreBundle/Entity/GradebookCategory.php index 9e0168de8e6..bd390fcbf1f 100644 --- a/src/CoreBundle/Entity/GradebookCategory.php +++ b/src/CoreBundle/Entity/GradebookCategory.php @@ -14,6 +14,7 @@ use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; +use Chamilo\CoreBundle\Enums\GradebookCalculationMode; use Chamilo\CoreBundle\Traits\CourseTrait; use Chamilo\CoreBundle\Traits\UserTrait; use Chamilo\CourseBundle\Entity\CDocument; @@ -29,9 +30,9 @@ operations: [ new Get(security: "is_granted('ROLE_USER')"), new GetCollection(security: "is_granted('ROLE_USER')"), - new Post(security: "is_granted('ROLE_TEACHER')"), - new Put(security: "is_granted('ROLE_TEACHER')"), - new Delete(security: "is_granted('ROLE_TEACHER')"), + new Post(security: "is_granted('ROLE_ADMIN') or is_granted('ROLE_CURRENT_COURSE_TEACHER')"), + new Put(security: "is_granted('ROLE_ADMIN') or is_granted('ROLE_CURRENT_COURSE_TEACHER')"), + new Delete(security: "is_granted('ROLE_ADMIN') or is_granted('ROLE_CURRENT_COURSE_TEACHER')"), ], normalizationContext: [ 'groups' => ['gradebookCategory:read'], @@ -159,6 +160,11 @@ class GradebookCategory #[ORM\Column(name: 'allow_skills_by_subcategory', type: 'integer', nullable: true, options: ['default' => 1])] protected ?int $allowSkillsBySubcategory; + #[Assert\NotNull] + #[Groups(['gradebookCategory:read', 'gradebookCategory:write'])] + #[ORM\Column(name: 'calculation_mode', type: 'string', length: 32, nullable: false, enumType: GradebookCalculationMode::class, options: ['default' => GradebookCalculationMode::WEIGHTED_AVERAGE->value])] + protected GradebookCalculationMode $calculationMode = GradebookCalculationMode::WEIGHTED_AVERAGE; + public function __construct() { $this->comments = new ArrayCollection(); @@ -563,4 +569,16 @@ public function setAllowSkillsBySubcategory($allowSkillsBySubcategory) return $this; } + + public function getCalculationMode(): GradebookCalculationMode + { + return $this->calculationMode; + } + + public function setCalculationMode(GradebookCalculationMode $calculationMode): self + { + $this->calculationMode = $calculationMode; + + return $this; + } } diff --git a/src/CoreBundle/Entity/GradebookLink.php b/src/CoreBundle/Entity/GradebookLink.php index f4fa142bd0a..dcefce573e3 100644 --- a/src/CoreBundle/Entity/GradebookLink.php +++ b/src/CoreBundle/Entity/GradebookLink.php @@ -6,15 +6,44 @@ namespace Chamilo\CoreBundle\Entity; +use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; +use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\Put; use Chamilo\CoreBundle\Traits\CourseTrait; use DateTime; use Doctrine\ORM\Mapping as ORM; use Gedmo\Mapping\Annotation as Gedmo; +use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Validator\Constraints as Assert; #[ORM\Table(name: 'gradebook_link')] #[ORM\Index(name: 'idx_gl_cat', columns: ['category_id'])] #[ORM\Entity] +#[ApiResource( + operations: [ + new Get(security: "is_granted('ROLE_USER')"), + new GetCollection(security: "is_granted('ROLE_USER')"), + new Post(security: "is_granted('ROLE_ADMIN') or is_granted('ROLE_CURRENT_COURSE_TEACHER')"), + new Put(security: "is_granted('ROLE_ADMIN') or is_granted('ROLE_CURRENT_COURSE_TEACHER')"), + new Delete(security: "is_granted('ROLE_ADMIN') or is_granted('ROLE_CURRENT_COURSE_TEACHER')"), + ], + normalizationContext: [ + 'groups' => ['gradebookLink:read'], + ], + denormalizationContext: [ + 'groups' => ['gradebookLink:write'], + ], + security: "is_granted('ROLE_USER')", +)] +#[ApiFilter(SearchFilter::class, properties: [ + 'category' => 'exact', + 'course' => 'exact', +])] class GradebookLink { use CourseTrait; @@ -22,20 +51,25 @@ class GradebookLink #[ORM\Column(name: 'id', type: 'integer')] #[ORM\Id] #[ORM\GeneratedValue] + #[Groups(['gradebookLink:read'])] protected ?int $id = null; #[Assert\NotBlank] + #[Groups(['gradebookLink:read', 'gradebookLink:write'])] #[ORM\Column(name: 'type', type: 'integer', nullable: false)] protected int $type; #[Assert\NotBlank] + #[Groups(['gradebookLink:read', 'gradebookLink:write'])] #[ORM\Column(name: 'ref_id', type: 'integer', nullable: false)] protected int $refId; + #[Groups(['gradebookLink:read', 'gradebookLink:write'])] #[ORM\ManyToOne(targetEntity: Course::class, inversedBy: 'gradebookLinks')] #[ORM\JoinColumn(name: 'c_id', referencedColumnName: 'id', onDelete: 'CASCADE')] protected Course $course; + #[Groups(['gradebookLink:read', 'gradebookLink:write'])] #[ORM\ManyToOne(targetEntity: GradebookCategory::class, inversedBy: 'links')] #[ORM\JoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'CASCADE')] protected GradebookCategory $category; @@ -44,6 +78,7 @@ class GradebookLink #[ORM\Column(name: 'created_at', type: 'datetime', nullable: false)] protected DateTime $createdAt; + #[Groups(['gradebookLink:read', 'gradebookLink:write'])] #[ORM\Column(name: 'weight', type: 'float', precision: 10, scale: 0, nullable: false)] protected float $weight; @@ -70,6 +105,24 @@ class GradebookLink #[ORM\Column(name: 'min_score', type: 'float', precision: 6, scale: 2, nullable: true)] protected ?float $minScore = null; + /** + * Points awarded when the student posted exactly one message in the thread + * (only used by forum participation links). + */ + #[Assert\PositiveOrZero] + #[Groups(['gradebookLink:read', 'gradebookLink:write'])] + #[ORM\Column(name: 'points_one', type: 'decimal', precision: 7, scale: 4, nullable: true)] + protected ?string $pointsOne = null; + + /** + * Points awarded when the student posted two or more messages in the thread + * (only used by forum participation links). + */ + #[Assert\PositiveOrZero] + #[Groups(['gradebookLink:read', 'gradebookLink:write'])] + #[ORM\Column(name: 'points_many', type: 'decimal', precision: 7, scale: 4, nullable: true)] + protected ?string $pointsMany = null; + public function __construct() { $this->locked = 0; @@ -276,4 +329,28 @@ public function setMinScore(?float $minScore): self return $this; } + + public function getPointsOne(): ?string + { + return $this->pointsOne; + } + + public function setPointsOne(?string $pointsOne): self + { + $this->pointsOne = $pointsOne; + + return $this; + } + + public function getPointsMany(): ?string + { + return $this->pointsMany; + } + + public function setPointsMany(?string $pointsMany): self + { + $this->pointsMany = $pointsMany; + + return $this; + } } diff --git a/src/CoreBundle/Enums/GradebookCalculationMode.php b/src/CoreBundle/Enums/GradebookCalculationMode.php new file mode 100644 index 00000000000..3822c432a8f --- /dev/null +++ b/src/CoreBundle/Enums/GradebookCalculationMode.php @@ -0,0 +1,24 @@ +connection->createSchemaManager(); + + $categoryColumns = $sm->listTableColumns('gradebook_category'); + if (!isset($categoryColumns['calculation_mode'])) { + $this->addSql( + "ALTER TABLE gradebook_category ADD calculation_mode VARCHAR(32) DEFAULT 'weighted_average' NOT NULL" + ); + } + + $linkColumns = $sm->listTableColumns('gradebook_link'); + if (!isset($linkColumns['points_one'])) { + $this->addSql('ALTER TABLE gradebook_link ADD points_one NUMERIC(7, 4) DEFAULT NULL'); + } + if (!isset($linkColumns['points_many'])) { + $this->addSql('ALTER TABLE gradebook_link ADD points_many NUMERIC(7, 4) DEFAULT NULL'); + } + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE gradebook_category DROP calculation_mode'); + $this->addSql('ALTER TABLE gradebook_link DROP points_one'); + $this->addSql('ALTER TABLE gradebook_link DROP points_many'); + } +} diff --git a/tests/CoreBundle/Entity/GradebookCalculationModeTest.php b/tests/CoreBundle/Entity/GradebookCalculationModeTest.php new file mode 100644 index 00000000000..750ee95bb76 --- /dev/null +++ b/tests/CoreBundle/Entity/GradebookCalculationModeTest.php @@ -0,0 +1,82 @@ +assertSame(GradebookCalculationMode::WEIGHTED_AVERAGE, $category->getCalculationMode()); + } + + public function testPointsSumAndForumPointsRoundTrip(): void + { + $em = $this->getEntityManager(); + $course = $this->createCourse('points-sum'); + $owner = $this->createUser('rubric-owner'); + + $category = (new GradebookCategory()) + ->setTitle('rubric') + ->setUser($owner) + ->setCourse($course) + ->setWeight(100.00) + ->setVisible(true) + ->setCalculationMode(GradebookCalculationMode::POINTS_SUM) + ; + $this->assertHasNoEntityViolations($category); + + $forumLink = (new GradebookLink()) + ->setType(11) // LINK_FORUM_PARTICIPATION + ->setRefId(1) + ->setCourse($course) + ->setCategory($category) + ->setWeight(1.00) + ->setVisible(1) + ->setLocked(0) + ->setPointsOne('1.5000') + ->setPointsMany('2.1400') + ; + $this->assertHasNoEntityViolations($forumLink); + + $category->getLinks()->add($forumLink); + + $em->persist($category); + $em->persist($forumLink); + $em->flush(); + + $categoryId = $category->getId(); + $linkId = $forumLink->getId(); + + // Force a real reload from the database, not the identity-map instance. + $em->clear(); + + $reloadedCategory = $em->getRepository(GradebookCategory::class)->find($categoryId); + $reloadedLink = $em->getRepository(GradebookLink::class)->find($linkId); + + $this->assertSame(GradebookCalculationMode::POINTS_SUM, $reloadedCategory->getCalculationMode()); + $this->assertSame('1.5000', $reloadedLink->getPointsOne()); + $this->assertSame('2.1400', $reloadedLink->getPointsMany()); + } +} diff --git a/translations/messages.en_US.po b/translations/messages.en_US.po index 857b4abaa55..3196857c848 100644 --- a/translations/messages.en_US.po +++ b/translations/messages.en_US.po @@ -36481,3 +36481,42 @@ msgstr "Moderated" msgid "View threads" msgstr "View threads" + +msgid "Calculation mode" +msgstr "Calculation mode" + +msgid "Weighted average" +msgstr "Weighted average" + +msgid "Points sum" +msgstr "Points sum" + +msgid "Weighted average normalizes by the sum of weights; points sum treats each weight as maximum points and adds them up without normalizing." +msgstr "Weighted average normalizes by the sum of weights; points sum treats each weight as maximum points and adds them up without normalizing." + +msgid "Each item weight is treated as its maximum points; the grade is the sum of points, not normalized." +msgstr "Each item weight is treated as its maximum points; the grade is the sum of points, not normalized." + +msgid "The grade is the weighted average of items, normalized by the sum of weights." +msgstr "The grade is the weighted average of items, normalized by the sum of weights." + +msgid "Forum participation" +msgstr "Forum participation" + +msgid "Forum thread" +msgstr "Forum thread" + +msgid "Points for one message" +msgstr "Points for one message" + +msgid "Points for two or more messages" +msgstr "Points for two or more messages" + +msgid "Points awarded when the student posted exactly one message in the thread." +msgstr "Points awarded when the student posted exactly one message in the thread." + +msgid "Points awarded when the student posted two or more messages in the thread." +msgstr "Points awarded when the student posted two or more messages in the thread." + +msgid "Optional. If left empty, one or more messages award the points for one message." +msgstr "Optional. If left empty, one or more messages award the points for one message." diff --git a/translations/messages.pot b/translations/messages.pot index ee0ed10a382..f3719848c77 100644 --- a/translations/messages.pot +++ b/translations/messages.pot @@ -36398,3 +36398,42 @@ msgstr "" msgid "View threads" msgstr "" + +msgid "Calculation mode" +msgstr "" + +msgid "Weighted average" +msgstr "" + +msgid "Points sum" +msgstr "" + +msgid "Weighted average normalizes by the sum of weights; points sum treats each weight as maximum points and adds them up without normalizing." +msgstr "" + +msgid "Each item weight is treated as its maximum points; the grade is the sum of points, not normalized." +msgstr "" + +msgid "The grade is the weighted average of items, normalized by the sum of weights." +msgstr "" + +msgid "Forum participation" +msgstr "" + +msgid "Forum thread" +msgstr "" + +msgid "Points for one message" +msgstr "" + +msgid "Points for two or more messages" +msgstr "" + +msgid "Points awarded when the student posted exactly one message in the thread." +msgstr "" + +msgid "Points awarded when the student posted two or more messages in the thread." +msgstr "" + +msgid "Optional. If left empty, one or more messages award the points for one message." +msgstr ""