From 19bd2fcb77fc0b1465044e9bca6282e63491eae5 Mon Sep 17 00:00:00 2001 From: Christian Beeznest Date: Fri, 28 Nov 2025 17:29:03 -0500 Subject: [PATCH] Gradebook: Fix LP final item certificate and skill issuance - refs BT#23100 --- public/main/inc/lib/SkillModel.php | 44 ++++----- public/main/inc/lib/SkillRelUserModel.php | 34 ++++--- public/main/lp/learnpath.class.php | 52 ++++++++++- public/main/lp/lp_final_item.php | 109 +++++++++++++++++++--- 4 files changed, 185 insertions(+), 54 deletions(-) diff --git a/public/main/inc/lib/SkillModel.php b/public/main/inc/lib/SkillModel.php index b76c413ae0a..26b24c19992 100644 --- a/public/main/inc/lib/SkillModel.php +++ b/public/main/inc/lib/SkillModel.php @@ -587,6 +587,9 @@ public function addSkillToUser( 'acquired_skill_at' => api_get_utc_datetime(), 'course_id' => (int) $courseId, 'session_id' => $sessionId ? (int) $sessionId : null, + 'validation_status' => 0, + 'argumentation' => '', + 'argumentation_author_id' => 0, ]; $skill_rel_user->save($params); } @@ -1352,37 +1355,36 @@ public function getCoursesBySkill($skillId) * * @return bool Whether the user has the skill return true. Otherwise return false */ - public function userHasSkill($userId, $skillId, $courseId = 0, $sessionId = 0) + public function userHasSkill($userId, $skillId, $courseId = 0, $sessionId = 0): bool { + $userId = (int) $userId; + $skillId = (int) $skillId; $courseId = (int) $courseId; $sessionId = (int) $sessionId; - $whereConditions = [ - 'user_id = ? ' => (int) $userId, - 'AND skill_id = ? ' => (int) $skillId, - ]; + // Base query: user + skill + $sql = "SELECT COUNT(1) AS qty + FROM {$this->table_skill_rel_user} + WHERE user_id = $userId + AND skill_id = $skillId"; + // If course is provided, filter by course and session if ($courseId > 0) { - $whereConditions['AND course_id = ? '] = $courseId; - $whereConditions['AND session_id = ? '] = $sessionId ? $sessionId : null; - } + $sql .= " AND course_id = $courseId"; - $result = Database::select( - 'COUNT(1) AS qty', - $this->table_skill_rel_user, - [ - 'where' => $whereConditions, - ], - 'first' - ); - - if (false != $result) { - if ($result['qty'] > 0) { - return true; + if ($sessionId > 0) { + // Skill linked to a specific session + $sql .= " AND session_id = $sessionId"; + } else { + // Course-level skill, no session (NULL) + $sql .= " AND session_id IS NULL"; } } - return false; + $result = Database::query($sql); + $row = Database::fetch_assoc($result); + + return !empty($row) && (int) $row['qty'] > 0; } /** diff --git a/public/main/inc/lib/SkillRelUserModel.php b/public/main/inc/lib/SkillRelUserModel.php index 602590cf3a3..6a96bf7ea8a 100644 --- a/public/main/inc/lib/SkillRelUserModel.php +++ b/public/main/inc/lib/SkillRelUserModel.php @@ -14,6 +14,10 @@ class SkillRelUserModel extends Model 'acquired_skill_at', 'course_id', 'session_id', + 'acquired_level', + 'validation_status', + 'argumentation', + 'argumentation_author_id', ]; public function __construct() @@ -58,27 +62,27 @@ public function getUserSkills($userId, $courseId = 0, $sessionId = 0) return []; } + $userId = (int) $userId; $courseId = (int) $courseId; - $sessionId = $sessionId ? (int) $sessionId : null; - $whereConditions = [ - 'user_id = ? ' => (int) $userId, - ]; + $sessionId = (int) $sessionId; + + $sql = "SELECT skill_id FROM {$this->table} WHERE user_id = $userId"; if ($courseId > 0) { - $whereConditions['AND course_id = ? '] = $courseId; - $whereConditions['AND session_id = ?'] = $sessionId; + $sql .= " AND course_id = $courseId"; + + if ($sessionId > 0) { + // Skill linked to a specific session + $sql .= " AND session_id = $sessionId"; + } else { + // Course-level skill, no session → match NULL + $sql .= " AND session_id IS NULL"; + } } - $result = Database::select( - 'skill_id', - $this->table, - [ - 'where' => $whereConditions, - ], - 'all' - ); + $result = Database::query($sql); - return $result; + return Database::store_result($result, 'ASSOC'); } /** diff --git a/public/main/lp/learnpath.class.php b/public/main/lp/learnpath.class.php index 795c7cf833f..abc2f8234f7 100644 --- a/public/main/lp/learnpath.class.php +++ b/public/main/lp/learnpath.class.php @@ -8079,6 +8079,11 @@ public function getFinalItemForm() $finalItem = $this->getFinalItem(); $title = ''; + $courseId = api_get_course_int_id(); + $sessionId = api_get_session_id(); + $resourceId = (int) $this->lp_id; + $tableLpItem = Database::get_course_table(TABLE_LP_ITEM); + if ($finalItem) { $title = $finalItem->get_title(); $buttonText = get_lang('Save'); @@ -8111,6 +8116,14 @@ public function getFinalItemForm() ) ); + // Advanced settings: only gradebook category/evaluation selector + if (api_is_allowed_to_edit(null, true)) { + $form->addElement('advanced_settings', 'advanced_params', get_lang('Advanced settings')); + $form->addElement('html', ''); + } + $renderer = $form->defaultRenderer(); $renderer->setElementTemplate(' {label}{element}', 'content_lp_certificate'); @@ -8124,9 +8137,30 @@ public function getFinalItemForm() $form->addHidden('action', 'add_final_item'); $form->addHidden('path', Session::read('pathItem')); $form->addHidden('previous', $this->get_last()); - $form->setDefaults( - ['title' => $title, 'content_lp_certificate' => $content] - ); + + // Default values + $defaults = [ + 'title' => $title, + 'content_lp_certificate' => $content, + ]; + + // Preselect the gradebook category from c_lp_item.ref (only for final_item) + if (api_is_allowed_to_edit(null, true)) { + $sql = "SELECT ref + FROM $tableLpItem + WHERE lp_id = ".(int) $resourceId." + AND item_type = '".Database::escape_string(TOOL_LP_FINAL_ITEM)."' + LIMIT 1"; + $result = Database::query($sql); + if (Database::num_rows($result) > 0) { + $row = Database::fetch_array($result); + if (!empty($row['ref'])) { + $defaults['category_id'] = (int) $row['ref']; + } + } + } + + $form->setDefaults($defaults); if ($form->validate()) { $values = $form->exportValues(); @@ -8160,6 +8194,18 @@ public function getFinalItemForm() } else { $this->edit_document(); } + + // Store the gradebook category id in c_lp_item.ref ONLY for final_item + if (api_is_allowed_to_edit(null, true)) { + $categoryId = isset($values['category_id']) ? (int) $values['category_id'] : 0; + $refValue = $categoryId > 0 ? (string) $categoryId : ''; + + Database::update( + $tableLpItem, + ['ref' => $refValue], + ['lp_id = ? AND item_type = ?' => [$resourceId, TOOL_LP_FINAL_ITEM]] + ); + } } return $form->returnForm(); diff --git a/public/main/lp/lp_final_item.php b/public/main/lp/lp_final_item.php index b10186963d1..6f4dbdb8358 100644 --- a/public/main/lp/lp_final_item.php +++ b/public/main/lp/lp_final_item.php @@ -71,6 +71,8 @@ $lpItemRepo = Container::getLpItemRepository(); $isFinalThere = false; $isFinalDone = false; +$finalItem = null; + try { $finalItem = $lpItemRepo->findOneBy(['lp' => $lpEntity, 'itemType' => TOOL_LP_FINAL_ITEM]); if ($finalItem) { @@ -109,19 +111,80 @@ $courseEntity = api_get_course_entity(); $sessionEntity = api_get_session_entity(); - /* @var GradebookCategory $gbCat */ - $gbCat = $gbRepo->findOneBy(['course' => $courseEntity, 'session' => $sessionEntity]); + // Resolve GradebookCategory using lp_item.ref when item_type = final_item. + // We store the gradebook category id in c_lp_item.ref (string). + $categoryIdFromRef = 0; + + if (!empty($finalItem) && method_exists($finalItem, 'getRef')) { + try { + $refRaw = trim((string) $finalItem->getRef()); + if ($refRaw !== '' && $refRaw !== '0') { + $categoryIdFromRef = (int) $refRaw; + } + } catch (\Throwable $e) { + error_log('[LP_FINAL] Unable to read lp_item.ref for final_item: '.$e->getMessage()); + } + } + + /** @var GradebookCategory|null $gbCat */ + $gbCat = null; + + // 1) First, try the explicit category id stored in c_lp_item.ref. + if ($categoryIdFromRef > 0) { + $gbCat = $gbRepo->find($categoryIdFromRef); + + // Safety check: ensure the referenced category belongs to the same course/session context. + if ($gbCat && $courseEntity) { + $catCourse = $gbCat->getCourse(); + $catSession = $gbCat->getSession(); + + // If course does not match, discard this category and let the fallback logic handle it. + if (!$catCourse || $catCourse->getId() !== $courseEntity->getId()) { + $gbCat = null; + } elseif ($sessionEntity) { + // If we are in a session context, ensure the category session matches. + if ($catSession && $catSession->getId() !== $sessionEntity->getId()) { + $gbCat = null; + } + } + } + } + + // 2) Fallback: keep legacy behaviour (root course/session category). + if (!$gbCat && $courseEntity) { + if ($sessionEntity) { + $gbCat = $gbRepo->findOneBy([ + 'course' => $courseEntity, + 'session' => $sessionEntity, + ]); + } - if (!$gbCat) { - $gbCat = $gbRepo->findOneBy(['course' => $courseEntity, 'session' => null]); + if (!$gbCat) { + $gbCat = $gbRepo->findOneBy([ + 'course' => $courseEntity, + 'session' => null, + ]); + } } if ($gbCat && !api_is_allowed_to_edit() && !api_is_excluded_user_type()) { - $cert = safeGenerateCertificateForCategory($gbCat, $userId); - $downloadBlock = buildCertificateBlock($cert); + // Use legacy Category business object to generate certificate + skills + // for this specific gradebook category. + // NOTE: Category::generateUserCertificate() is expected to know how to + // work with the Doctrine GradebookCategory entity. + $certificate = Category::generateUserCertificate($gbCat, $userId); + if (!empty($certificate)) { + // Build the HTML panel to replace ((certificate)). + $downloadBlock = Category::getDownloadCertificateBlock($certificate); + } + + // Skills: Category::generateUserCertificate() already assigns skills + // to the user for this course/session/category when enabled. + // Here we just render the user's skills panel. $badgeBlock = generateBadgePanel($userId, $courseId, $sessionId); } + // Replace ((certificate)) and ((skill)) tokens in the final-item document. $finalHtml = renderFinalItemDocument($id, $downloadBlock, $badgeBlock); } @@ -140,7 +203,7 @@ function safeGenerateCertificateForCategory(GradebookCategory $category, int $us $sessId = $session ? $session->getId() : 0; $catId = (int) $category->getId(); - // Build certificate content & score + // Build certificate content & score. $gb = GradebookUtils::get_user_certificate_content($userId, $courseId, $sessId); $html = (is_array($gb) && isset($gb['content'])) ? $gb['content'] : ''; $score = isset($gb['score']) ? (float) $gb['score'] : 100.0; @@ -149,23 +212,23 @@ function safeGenerateCertificateForCategory(GradebookCategory $category, int $us $htmlUrl = ''; $pdfUrl = ''; + $cert = null; try { - // Store/refresh as Resource (controlled access; not shown in "My personal files") + // Store/refresh as Resource (controlled access; not shown in "My personal files"). $cert = $certRepo->upsertCertificateResource($catId, $userId, $score, $html); // (Optional) keep metadata (created_at/score). Filename is not required anymore. $certRepo->registerUserInfoAboutCertificate($catId, $userId, $score); - // Build URLs from the Resource layer - // View URL (first resource file assigned to the node – here the HTML we just uploaded) + // Build URLs from the Resource layer. $htmlUrl = $certRepo->getResourceFileUrl($cert); } catch (\Throwable $e) { error_log('[LP_FINAL] register cert error: '.$e->getMessage()); } return [ - 'path_certificate' => (string) ($cert->getPathCertificate() ?? ''), + 'path_certificate' => $cert ? (string) ($cert->getPathCertificate() ?? '') : '', 'html_url' => $htmlUrl, 'pdf_url' => $pdfUrl, ]; @@ -218,6 +281,7 @@ function generateBadgePanel(int $userId, int $courseId, int $sessionId = 0): str if (!$skill) { continue; } + $items .= "
@@ -264,14 +328,25 @@ function renderFinalItemDocument(int $lpItemOrDocId, string $certificateBlock, s $lpItemRepo = Container::getLpItemRepository(); $document = null; - try { $document = $docRepo->find($lpItemOrDocId); } catch (\Throwable $e) {} + + // First, try to use the id directly as a document iid. + try { + $document = $docRepo->find($lpItemOrDocId); + } catch (\Throwable $e) { + // Silence here, we will try the LP item fallback below. + } + + // If not a document iid, try resolving from the LP item path. if (!$document) { try { $lpItem = $lpItemRepo->find($lpItemOrDocId); if ($lpItem) { + // In our case, lp_item.path stores the document iid as string. $document = $docRepo->find((int) $lpItem->getPath()); } - } catch (\Throwable $e) {} + } catch (\Throwable $e) { + // As a last resort, fail quietly and return empty content. + } } if (!$document) { @@ -288,8 +363,12 @@ function renderFinalItemDocument(int $lpItemOrDocId, string $certificateBlock, s $hasCert = str_contains($content, '((certificate))'); $hasSkill = str_contains($content, '((skill))'); - if ($hasCert) { $content = str_replace('((certificate))', $certificateBlock, $content); } - if ($hasSkill) { $content = str_replace('((skill))', $badgeBlock, $content); } + if ($hasCert) { + $content = str_replace('((certificate))', $certificateBlock, $content); + } + if ($hasSkill) { + $content = str_replace('((skill))', $badgeBlock, $content); + } return $content; }