From 96dc595add8b454842f0b4087ab491bba2608f60 Mon Sep 17 00:00:00 2001 From: Angel Fernando Quiroz Campos <1697880+AngelFQC@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:37:20 -0500 Subject: [PATCH 1/4] Gradebook: Add customizable grading modes and forum participation scoring - refs BT#23462 --- .../gradebook/CalculationModeSelector.vue | 69 +++++++ .../gradebook/ForumParticipationItemForm.vue | 120 ++++++++++++ assets/vue/services/gradebookService.js | 63 ++++++ public/main/gradebook/gradebook_add_cat.php | 4 + public/main/gradebook/gradebook_add_link.php | 5 + public/main/gradebook/gradebook_edit_cat.php | 4 + public/main/gradebook/gradebook_edit_link.php | 5 + .../gradebook/lib/be/abstractlink.class.php | 33 ++++ .../main/gradebook/lib/be/category.class.php | 36 +++- .../lib/be/forumparticipationlink.class.php | 183 ++++++++++++++++++ .../gradebook/lib/be/linkfactory.class.php | 3 + .../main/gradebook/lib/fe/catform.class.php | 14 ++ .../lib/fe/linkaddeditform.class.php | 30 +++ public/main/inc/lib/api.lib.php | 1 + src/CoreBundle/Entity/GradebookCategory.php | 24 ++- src/CoreBundle/Entity/GradebookLink.php | 77 ++++++++ .../Enums/GradebookCalculationMode.php | 24 +++ .../Schema/V200/Version20260612120000.php | 45 +++++ 18 files changed, 733 insertions(+), 7 deletions(-) create mode 100644 assets/vue/components/gradebook/CalculationModeSelector.vue create mode 100644 assets/vue/components/gradebook/ForumParticipationItemForm.vue create mode 100644 public/main/gradebook/lib/be/forumparticipationlink.class.php create mode 100644 src/CoreBundle/Enums/GradebookCalculationMode.php create mode 100644 src/CoreBundle/Migrations/Schema/V200/Version20260612120000.php 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..b7dbced675e --- /dev/null +++ b/assets/vue/components/gradebook/ForumParticipationItemForm.vue @@ -0,0 +1,120 @@ + + + diff --git a/assets/vue/services/gradebookService.js b/assets/vue/services/gradebookService.js index 81746870ce7..1c79095928e 100644 --- a/assets/vue/services/gradebookService.js +++ b/assets/vue/services/gradebookService.js @@ -39,4 +39,67 @@ 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 is 1 so the item contributes its raw points. + return await baseService.post("/api/gradebook_links", { + type: 11, + refId: threadId, + course: `/api/courses/${courseId}`, + category: `/api/gradebook_categories/${categoryId}`, + weight: 1, + 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..0d16b80e0d9 100644 --- a/public/main/gradebook/gradebook_add_link.php +++ b/public/main/gradebook/gradebook_add_link.php @@ -99,6 +99,11 @@ $link->set_min_score(api_float_val($addvalues['min_score'])); } + if (LINK_FORUM_PARTICIPATION == $link->get_type()) { + $link->set_points_one(isset($addvalues['points_one']) ? api_float_val($addvalues['points_one']) : null); + $link->set_points_many(isset($addvalues['points_many']) ? api_float_val($addvalues['points_many']) : null); + } + 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..dd337d7481e 100644 --- a/public/main/gradebook/gradebook_edit_link.php +++ b/public/main/gradebook/gradebook_edit_link.php @@ -63,6 +63,11 @@ 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()) { + $link->set_points_one(isset($values['points_one']) ? api_float_val($values['points_one']) : null); + $link->set_points_many(isset($values['points_many']) ? api_float_val($values['points_many']) : null); + } $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..41c81f085db --- /dev/null +++ b/public/main/gradebook/lib/be/forumparticipationlink.class.php @@ -0,0 +1,183 @@ + 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); + $pointsMany = (float) ($this->get_points_many() ?? 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 = $pointsMany; + } + + // max = 1 so that, with weight = 1 in POINTS_SUM, the item contributes its raw points. + return [$score, 1]; + } + + 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() + { + return ''; + } + + 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..df721b27fa9 100644 --- a/public/main/gradebook/lib/fe/linkaddeditform.class.php +++ b/public/main/gradebook/lib/fe/linkaddeditform.class.php @@ -142,6 +142,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('Points awarded when the student posted two or more messages in the thread.'), + ], + true, + ['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/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'); + } +} From ec0f82cae016fb61d6971f1bde3f78071888b291 Mon Sep 17 00:00:00 2001 From: Angel Fernando Quiroz Campos <1697880+AngelFQC@users.noreply.github.com> Date: Mon, 15 Jun 2026 17:52:44 -0500 Subject: [PATCH 2/4] Gradebook: refine forum participation item, add import command, i18n and test - refs BT#23462 Model the forum participation item by its maximum points so the gradebook display stays within 0-100% and, in POINTS_SUM mode, the item contributes the earned points: - ForumParticipationLink::calc_score now returns [score, pointsMany] (max = pointsMany) instead of [score, 1]. - The link weight is set to pointsMany in the add/edit pages, the migration command and the Vue service; the manual weight field is hidden for this type. Also: - Add ImportCustomGradingRubricsCommand to import the client rubrics seed as native POINTS_SUM categories (course resolved by code, refs validated). - Register the new strings in messages.pot / messages.en_US.po and the Vue master locale (en_US.json). - Add GradebookCalculationModeTest covering the calculation_mode default and the pointsOne/pointsMany round-trip. Co-Authored-By: Claude Opus 4.8 (1M context) --- assets/locales/en_US.json | 8 + .../gradebook/ForumParticipationItemForm.vue | 2 + assets/vue/services/gradebookService.js | 5 +- public/main/gradebook/gradebook_add_link.php | 11 +- public/main/gradebook/gradebook_edit_link.php | 12 +- .../lib/be/forumparticipationlink.class.php | 8 +- .../lib/fe/linkaddeditform.class.php | 33 +- .../ImportCustomGradingRubricsCommand.php | 333 ++++++++++++++++++ .../Entity/GradebookCalculationModeTest.php | 82 +++++ translations/messages.en_US.po | 36 ++ translations/messages.pot | 36 ++ 11 files changed, 541 insertions(+), 25 deletions(-) create mode 100644 src/CoreBundle/Command/ImportCustomGradingRubricsCommand.php create mode 100644 tests/CoreBundle/Entity/GradebookCalculationModeTest.php 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/ForumParticipationItemForm.vue b/assets/vue/components/gradebook/ForumParticipationItemForm.vue index b7dbced675e..ab289ff21d3 100644 --- a/assets/vue/components/gradebook/ForumParticipationItemForm.vue +++ b/assets/vue/components/gradebook/ForumParticipationItemForm.vue @@ -104,6 +104,8 @@ async function submit() { await gradebookService.updateForumParticipationLink(props.link.id, { pointsOne: String(form.pointsOne), pointsMany: String(form.pointsMany), + // Keep weight in sync with pointsMany (the item's max points). + weight: Number(form.pointsMany), }) } else { await gradebookService.createForumParticipationLink({ diff --git a/assets/vue/services/gradebookService.js b/assets/vue/services/gradebookService.js index 1c79095928e..7859766da3f 100644 --- a/assets/vue/services/gradebookService.js +++ b/assets/vue/services/gradebookService.js @@ -72,13 +72,14 @@ export default { * @returns {Promise} The created gradebook link resource. */ async createForumParticipationLink({ threadId, courseId, categoryId, pointsOne, pointsMany }) { - // 11 = LINK_FORUM_PARTICIPATION. Weight is 1 so the item contributes its raw points. + // 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: 1, + weight: Number(pointsMany), pointsOne: String(pointsOne), pointsMany: String(pointsMany), }) diff --git a/public/main/gradebook/gradebook_add_link.php b/public/main/gradebook/gradebook_add_link.php index 0d16b80e0d9..fd248567240 100644 --- a/public/main/gradebook/gradebook_add_link.php +++ b/public/main/gradebook/gradebook_add_link.php @@ -93,15 +93,20 @@ $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()) { - $link->set_points_one(isset($addvalues['points_one']) ? api_float_val($addvalues['points_one']) : null); - $link->set_points_many(isset($addvalues['points_many']) ? api_float_val($addvalues['points_many']) : null); + $pointsOne = isset($addvalues['points_one']) ? api_float_val($addvalues['points_one']) : null; + $pointsMany = isset($addvalues['points_many']) ? api_float_val($addvalues['points_many']) : null; + $link->set_points_one($pointsOne); + $link->set_points_many($pointsMany); + // The item's max points (pointsMany) is also its weight in POINTS_SUM. + $link->set_weight((float) $pointsMany); + } else { + $link->set_weight($addvalues['weight_mask']); } if ($link->needs_max()) { diff --git a/public/main/gradebook/gradebook_edit_link.php b/public/main/gradebook/gradebook_edit_link.php index dd337d7481e..4f590841ebd 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']); @@ -65,8 +63,14 @@ } if (LINK_FORUM_PARTICIPATION == $link->get_type()) { - $link->set_points_one(isset($values['points_one']) ? api_float_val($values['points_one']) : null); - $link->set_points_many(isset($values['points_many']) ? api_float_val($values['points_many']) : null); + $pointsOne = isset($values['points_one']) ? api_float_val($values['points_one']) : null; + $pointsMany = isset($values['points_many']) ? api_float_val($values['points_many']) : null; + $link->set_points_one($pointsOne); + $link->set_points_many($pointsMany); + // The item's max points (pointsMany) is also its weight in POINTS_SUM. + $link->set_weight((float) $pointsMany); + } else { + $link->set_weight($values['weight_mask']); } $link->save(); diff --git a/public/main/gradebook/lib/be/forumparticipationlink.class.php b/public/main/gradebook/lib/be/forumparticipationlink.class.php index 41c81f085db..006f47ad46d 100644 --- a/public/main/gradebook/lib/be/forumparticipationlink.class.php +++ b/public/main/gradebook/lib/be/forumparticipationlink.class.php @@ -78,6 +78,11 @@ public function calc_score($studentId = null, $type = null) $pointsOne = (float) ($this->get_points_one() ?? 0); $pointsMany = (float) ($this->get_points_many() ?? 0); + // The maximum a student can earn here is pointsMany; it is used as the item max so the + // per-item display stays within 0-100% and, with weight = pointsMany in POINTS_SUM, + // the contribution equals the earned points (score/max × weight = score). + $max = $pointsMany > 0 ? $pointsMany : 1.0; + // Aggregate (all students) is not meaningful for a fixed-points item. if (!isset($studentId)) { return [null, null]; @@ -93,8 +98,7 @@ public function calc_score($studentId = null, $type = null) $score = $pointsMany; } - // max = 1 so that, with weight = 1 in POINTS_SUM, the item contributes its raw points. - return [$score, 1]; + return [$score, $max]; } public function needs_name_and_description(): bool diff --git a/public/main/gradebook/lib/fe/linkaddeditform.class.php b/public/main/gradebook/lib/fe/linkaddeditform.class.php index df721b27fa9..dabc58c9746 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( diff --git a/src/CoreBundle/Command/ImportCustomGradingRubricsCommand.php b/src/CoreBundle/Command/ImportCustomGradingRubricsCommand.php new file mode 100644 index 00000000000..ac7c9c9053a --- /dev/null +++ b/src/CoreBundle/Command/ImportCustomGradingRubricsCommand.php @@ -0,0 +1,333 @@ + 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 { + $pointsOne = (string) round((float) ($component['one'] ?? 0), 4); + $pointsMany = (string) round((float) ($component['many'] ?? 0), 4); + $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 item's max points (pointsMany) is also its weight, so in POINTS_SUM the + // contribution equals the earned points (score/max × weight = score). + $link->setWeight((float) $pointsMany); + $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/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..9f5ee1c26ba 100644 --- a/translations/messages.en_US.po +++ b/translations/messages.en_US.po @@ -36481,3 +36481,39 @@ 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." diff --git a/translations/messages.pot b/translations/messages.pot index ee0ed10a382..54eafae2888 100644 --- a/translations/messages.pot +++ b/translations/messages.pot @@ -36398,3 +36398,39 @@ 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 "" From 881955e2f6bd92a4f5b67447a282cf23ba6a5047 Mon Sep 17 00:00:00 2001 From: Angel Fernando Quiroz Campos <1697880+AngelFQC@users.noreply.github.com> Date: Mon, 15 Jun 2026 18:20:04 -0500 Subject: [PATCH 3/4] Gradebook: Improve weight assignment logic and enhance forum participation description --- public/main/gradebook/gradebook_edit_link.php | 5 +++-- .../gradebook/lib/be/forumparticipationlink.class.php | 11 ++++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/public/main/gradebook/gradebook_edit_link.php b/public/main/gradebook/gradebook_edit_link.php index 4f590841ebd..5c831faef50 100644 --- a/public/main/gradebook/gradebook_edit_link.php +++ b/public/main/gradebook/gradebook_edit_link.php @@ -68,10 +68,11 @@ $link->set_points_one($pointsOne); $link->set_points_many($pointsMany); // The item's max points (pointsMany) is also its weight in POINTS_SUM. - $link->set_weight((float) $pointsMany); + $final_weight = (float) $pointsMany; } else { - $link->set_weight($values['weight_mask']); + $final_weight = $values['weight_mask']; } + $link->set_weight($final_weight); $link->save(); //Update weight for attendance diff --git a/public/main/gradebook/lib/be/forumparticipationlink.class.php b/public/main/gradebook/lib/be/forumparticipationlink.class.php index 006f47ad46d..ce2bbf399ce 100644 --- a/public/main/gradebook/lib/be/forumparticipationlink.class.php +++ b/public/main/gradebook/lib/be/forumparticipationlink.class.php @@ -125,7 +125,16 @@ public function get_name() public function get_description() { - return ''; + $one = $this->get_points_one(); + $many = $this->get_points_many(); + + if (null === $one && null === $many) { + return ''; + } + + // Surfaces both scoring values to the teacher, since the weight column only shows pointsMany. + return get_lang('Points for one message').': '.api_float_val($one) + .' · '.get_lang('Points for two or more messages').': '.api_float_val($many); } public function is_valid_link(): bool From 18e9deb65570c4fca62a44734ae9b535edfa0fd3 Mon Sep 17 00:00:00 2001 From: Angel Fernando Quiroz Campos <1697880+AngelFQC@users.noreply.github.com> Date: Mon, 15 Jun 2026 18:53:40 -0500 Subject: [PATCH 4/4] Gradebook: Refactor forum participation scoring logic to derive weights from max points --- public/main/gradebook/gradebook_add_link.php | 11 ++-- public/main/gradebook/gradebook_edit_link.php | 12 +++-- .../lib/be/forumparticipationlink.class.php | 51 +++++++++++++++---- .../lib/fe/linkaddeditform.class.php | 4 +- .../ImportCustomGradingRubricsCommand.php | 14 +++-- translations/messages.en_US.po | 3 ++ translations/messages.pot | 3 ++ 7 files changed, 73 insertions(+), 25 deletions(-) diff --git a/public/main/gradebook/gradebook_add_link.php b/public/main/gradebook/gradebook_add_link.php index fd248567240..5133c2d23fa 100644 --- a/public/main/gradebook/gradebook_add_link.php +++ b/public/main/gradebook/gradebook_add_link.php @@ -99,12 +99,15 @@ } if (LINK_FORUM_PARTICIPATION == $link->get_type()) { - $pointsOne = isset($addvalues['points_one']) ? api_float_val($addvalues['points_one']) : null; - $pointsMany = isset($addvalues['points_many']) ? api_float_val($addvalues['points_many']) : null; + $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); - // The item's max points (pointsMany) is also its weight in POINTS_SUM. - $link->set_weight((float) $pointsMany); + // Weight is derived from the points by ForumParticipationLink::get_weight(). } else { $link->set_weight($addvalues['weight_mask']); } diff --git a/public/main/gradebook/gradebook_edit_link.php b/public/main/gradebook/gradebook_edit_link.php index 5c831faef50..ab191260fd0 100644 --- a/public/main/gradebook/gradebook_edit_link.php +++ b/public/main/gradebook/gradebook_edit_link.php @@ -63,12 +63,16 @@ } if (LINK_FORUM_PARTICIPATION == $link->get_type()) { - $pointsOne = isset($values['points_one']) ? api_float_val($values['points_one']) : null; - $pointsMany = isset($values['points_many']) ? api_float_val($values['points_many']) : null; + $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); - // The item's max points (pointsMany) is also its weight in POINTS_SUM. - $final_weight = (float) $pointsMany; + // Weight is derived from the points by ForumParticipationLink::get_weight(). + $final_weight = $link->get_weight(); } else { $final_weight = $values['weight_mask']; } diff --git a/public/main/gradebook/lib/be/forumparticipationlink.class.php b/public/main/gradebook/lib/be/forumparticipationlink.class.php index ce2bbf399ce..d88e0435f48 100644 --- a/public/main/gradebook/lib/be/forumparticipationlink.class.php +++ b/public/main/gradebook/lib/be/forumparticipationlink.class.php @@ -76,12 +76,8 @@ public function has_results(): bool public function calc_score($studentId = null, $type = null) { $pointsOne = (float) ($this->get_points_one() ?? 0); - $pointsMany = (float) ($this->get_points_many() ?? 0); - - // The maximum a student can earn here is pointsMany; it is used as the item max so the - // per-item display stays within 0-100% and, with weight = pointsMany in POINTS_SUM, - // the contribution equals the earned points (score/max × weight = score). - $max = $pointsMany > 0 ? $pointsMany : 1.0; + $max = $this->getMaxPoints(); + $max = $max > 0 ? $max : 1.0; // Aggregate (all students) is not meaningful for a fixed-points item. if (!isset($studentId)) { @@ -95,12 +91,43 @@ public function calc_score($studentId = null, $type = null) } elseif (1 === $count) { $score = $pointsOne; } else { - $score = $pointsMany; + $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; @@ -132,9 +159,13 @@ public function get_description() return ''; } - // Surfaces both scoring values to the teacher, since the weight column only shows pointsMany. - return get_lang('Points for one message').': '.api_float_val($one) - .' · '.get_lang('Points for two or more messages').': '.api_float_val($many); + // 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 diff --git a/public/main/gradebook/lib/fe/linkaddeditform.class.php b/public/main/gradebook/lib/fe/linkaddeditform.class.php index dabc58c9746..a7aa8ebdecd 100644 --- a/public/main/gradebook/lib/fe/linkaddeditform.class.php +++ b/public/main/gradebook/lib/fe/linkaddeditform.class.php @@ -164,9 +164,9 @@ public function __construct( 'points_many', [ get_lang('Points for two or more messages'), - get_lang('Points awarded when the student posted two or more messages in the thread.'), + get_lang('Optional. If left empty, one or more messages award the points for one message.'), ], - true, + false, ['size' => '4', 'maxlength' => '8'] ); $this->addRule('points_many', get_lang('Only numbers'), 'numeric'); diff --git a/src/CoreBundle/Command/ImportCustomGradingRubricsCommand.php b/src/CoreBundle/Command/ImportCustomGradingRubricsCommand.php index ac7c9c9053a..3d6e076bbb9 100644 --- a/src/CoreBundle/Command/ImportCustomGradingRubricsCommand.php +++ b/src/CoreBundle/Command/ImportCustomGradingRubricsCommand.php @@ -256,8 +256,12 @@ private function createForumItems( array &$skipped, bool $dryRun ): int { - $pointsOne = (string) round((float) ($component['one'] ?? 0), 4); - $pointsMany = (string) round((float) ($component['many'] ?? 0), 4); + $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) { @@ -273,9 +277,9 @@ private function createForumItems( $link->setRefId($threadId); $link->setCourse($course); $link->setCategory($category); - // The item's max points (pointsMany) is also its weight, so in POINTS_SUM the - // contribution equals the earned points (score/max × weight = score). - $link->setWeight((float) $pointsMany); + // 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); diff --git a/translations/messages.en_US.po b/translations/messages.en_US.po index 9f5ee1c26ba..3196857c848 100644 --- a/translations/messages.en_US.po +++ b/translations/messages.en_US.po @@ -36517,3 +36517,6 @@ 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 54eafae2888..f3719848c77 100644 --- a/translations/messages.pot +++ b/translations/messages.pot @@ -36434,3 +36434,6 @@ 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 ""