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 @@
+
+
+
+
+
+ {{ helpText }}
+
+
+
+
+
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 ""