diff --git a/assets/vue/components/course/CatalogueCourseCard.vue b/assets/vue/components/course/CatalogueCourseCard.vue index 8c637531bf9..acc36a9f208 100644 --- a/assets/vue/components/course/CatalogueCourseCard.vue +++ b/assets/vue/components/course/CatalogueCourseCard.vue @@ -119,6 +119,19 @@ {{ course.extra_fields?.[field.variable] ?? "-" }} +
+ {{ $t("Students") }}: + {{ course.nb_students }} / {{ course.max_students }} + ({{ $t("Full") }}) +
+
{ const subscribing = ref(false) const subscribeToCourse = async () => { if (!props.currentUserId) { - showErrorNotification("You must be logged in to subscribe to a course.") + showErrorNotification($t("You must be logged in to subscribe to a course.")) return } try { subscribing.value = true + const maxUsers = props.course.max_students ?? 0 + const nbUsers = props.course.nb_students ?? 0 + + // Global limit validation (includes teachers and students) + if (maxUsers > 0 && nbUsers >= maxUsers) { + showErrorNotification( + $t("This course has reached the maximum number of users ({nb}/{max}).", { + nb: nbUsers, + max: maxUsers, + }) + ) + return + } + const useAutoSession = platformConfigStore.getSetting("catalog.course_subscription_in_user_s_session") === "true" @@ -278,7 +305,7 @@ const subscribeToCourse = async () => { }) } - showSuccessNotification("You have successfully subscribed to this course.") + showSuccessNotification($t("You have successfully subscribed to this course.")) await router.push({ name: "CourseHome", @@ -289,7 +316,7 @@ const subscribeToCourse = async () => { }) } catch (e) { console.error("Subscription error:", e) - showErrorNotification("Failed to subscribe to the course.") + showErrorNotification($t("Failed to subscribe to the course.")) } finally { subscribing.value = false } diff --git a/public/main/admin/subscribe_user2course.php b/public/main/admin/subscribe_user2course.php index 8a9d10f158c..bdb79cdbed9 100644 --- a/public/main/admin/subscribe_user2course.php +++ b/public/main/admin/subscribe_user2course.php @@ -34,18 +34,29 @@ $htmlHeadXtra[] = ''; // displaying the header -Display :: display_header($tool_name); +Display::display_header($tool_name); $link_add_group = ''. Display::getMdiIcon(ObjectIcon::MULTI_ELEMENT, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Enrolment by classes')).get_lang('Enrolment by classes').''; echo Display::toolbarAction('subscribe', [$link_add_group]); +/** + * We show this once at the top so admins are aware before selecting anything. + */ +$__globalLimit = (int) api_get_setting('platform.hosting_limit_users_per_course'); // 0 => disabled +if ($__globalLimit > 0) { + echo Display::return_message( + sprintf('A global limit of %d users applies to every course (teachers included).', $__globalLimit), + 'warning' + ); +} + $form = new FormValidator('subscribe_user2course'); $form->addElement('header', '', $tool_name); $form->display(); @@ -56,7 +67,7 @@ function validate_filter() { $new_field_list = []; if (is_array($extra_field_list)) { foreach ($extra_field_list as $extra_field) { - // if is enabled to filter and is a "" or "tag" type if (1 == $extra_field[8] && ExtraField::FIELD_TYPE_SELECT == $extra_field[2]) { $new_field_list[] = [ 'name' => $extra_field[3], @@ -80,39 +91,91 @@ function validate_filter() { /* React on POSTed request */ if (isset($_POST['form_sent']) && $_POST['form_sent']) { - $form_sent = $_POST['form_sent']; + $form_sent = (int) $_POST['form_sent']; $users = isset($_POST['UserList']) && is_array($_POST['UserList']) ? $_POST['UserList'] : []; $courses = isset($_POST['CourseList']) && is_array($_POST['CourseList']) ? $_POST['CourseList'] : []; $first_letter_user = Database::escape_string($_POST['firstLetterUser']); $first_letter_course = Database::escape_string($_POST['firstLetterCourse']); foreach ($users as $key => $value) { - $users[$key] = intval($value); + $users[$key] = (int) $value; } - if (1 == $form_sent) { - if (0 == count($users) || 0 == count($courses)) { + if (1 === $form_sent) { + if (0 === count($users) || 0 === count($courses)) { echo Display::return_message(get_lang('You must select at least one user and one course'), 'error'); } else { $errorDrh = 0; + $successCount = 0; + $skippedFull = 0; + foreach ($courses as $course_code) { + $courseInfo = api_get_course_info($course_code); + if (empty($courseInfo)) { + // Defensive log + Display::addFlash(Display::return_message('Course not found: '.$course_code, 'warning')); + continue; + } + + // Enforce global limit here as well, to avoid needless subscribe calls + if ($__globalLimit > 0) { + $limitState = _compute_course_limit_state_by_real_id($courseInfo['real_id'], $__globalLimit); + if ($limitState['full']) { + // Avoid looping users for a known-full course, provide a single message and skip + Display::addFlash(Display::return_message( + sprintf('Course "%s" is full (%d/%d). Skipping subscriptions for this course.', + $courseInfo['title'], $limitState['current'], $limitState['limit'] + ), + 'warning' + )); + $skippedFull++; + continue; + } + } + foreach ($users as $user_id) { $user = api_get_user_info($user_id); if (DRH != $user['status']) { - $courseInfo = api_get_course_info($course_code); - CourseManager::subscribeUser($user_id, $courseInfo['real_id']); + $result = CourseManager::subscribeUser($user_id, $courseInfo['real_id']); + + if (is_array($result)) { + // Expected keys: ok(bool), message(string) + if (isset($result['message'])) { + Display::addFlash( + Display::return_message($result['message'], !empty($result['ok']) ? 'normal' : 'warning') + ); + } else { + // assume ok by presence of array + $successCount++; + } + } else { + if ($result === true) { + $successCount++; + } + } + } else { $errorDrh = 1; } } } - if (0 == $errorDrh) { + // Summaries + if ($successCount > 0) { echo Display::return_message( - get_lang('The selected users are subscribed to the selected course'), + sprintf(get_lang('The selected users are subscribed to the selected course').' (%d %s)', $successCount, get_lang('operations')), 'confirm' ); - } else { + } + + if ($skippedFull > 0) { + echo Display::return_message( + sprintf('%d course(s) skipped because they are full.', $skippedFull), + 'warning' + ); + } + + if (1 === $errorDrh) { echo Display::return_message( get_lang( 'Human resources managers should not be registered to courses. The corresponding users you selected have not been subscribed.' @@ -130,8 +193,7 @@ function validate_filter() { $result = Database::query($sql); $num_row = Database::fetch_array($result); if ($num_row['nb_users'] > 1000) { - //if there are too much users to gracefully handle with the HTML select list, - // assign a default filter on users names + // If there are too many users, default filter to "A" to keep lists light $first_letter_user = 'A'; } unset($result); @@ -152,7 +214,7 @@ function validate_filter() { $use_extra_fields = true; if (ExtraField::FIELD_TYPE_TAG == $fieldtype) { $extra_field_result[] = UserManager::get_extra_user_data_by_tags( - intval($_POST['field_id']), + (int) $_POST['field_id'], $_POST[$varname] ); } else { @@ -183,14 +245,12 @@ function validate_filter() { if (is_array($final_result) && count($final_result) > 0) { $where_filter = " AND u.id IN ('".implode("','", $final_result)."') "; } else { - //no results $where_filter = " AND u.id = -1"; } } else { if (is_array($final_result) && count($final_result) > 0) { $where_filter = " AND id IN ('".implode("','", $final_result)."') "; } else { - //no results $where_filter = " AND id = -1"; } } @@ -255,117 +315,166 @@ function validate_filter() { $db_courses = Database::store_result($result); unset($result); ?> -
- 0) { - echo '

'.get_lang('Filter users').'

'; - foreach ($new_field_list as $new_field) { - echo $new_field['name']; - $varname = 'field_'.$new_field['variable']; - $fieldtype = $new_field['type']; - echo '  + + 0) { + echo '
'; + echo '

'.get_lang('Filter users').'

'; + echo '
'; + foreach ($new_field_list as $new_field) { + echo ''; + $varname = 'field_'.$new_field['variable']; + $fieldtype = $new_field['type']; + + echo ''; + $extraHidden = ExtraField::FIELD_TYPE_TAG == $fieldtype ? '' : ''; + echo $extraHidden; } + echo '
'; + echo '
'; + echo ''; + echo '
'; + echo '
'; } - echo ''; - $extraHidden = ExtraField::FIELD_TYPE_TAG == $fieldtype ? '' : ''; - echo $extraHidden; - echo '  '; } - echo ''; - echo '

'; + ?> + +
+ + +
+ + +
+ : + +
+ + +
+ + +
+ +
+ + +
+ + +
+ : + +
+ + +
+ +
+ + +
+ max(0, $globalLimit), 'current' => 0, 'seatsLeft' => $globalLimit, 'full' => false]; } + return _compute_course_limit_state_by_real_id((int) $info['real_id'], $globalLimit); } -?> - - - - - - - - - - - - -
- -

- : - -
  - : -

- : - -
- - - - - -
- - 0) { + $sqlCount = "SELECT COUNT(*) AS total + FROM ".Database::get_main_table(TABLE_MAIN_COURSE_USER)." + WHERE c_id = $courseRealId + AND relation_type <> ".COURSE_RELATION_TYPE_RRHH; + $row = Database::fetch_array(Database::query($sqlCount), 'ASSOC'); + $current = (int) ($row['total'] ?? 0); + } + $seatsLeft = max(0, $globalLimit - $current); + return [ + 'limit' => $globalLimit, + 'current' => $current, + 'seatsLeft' => $seatsLeft, + 'full' => $globalLimit > 0 && $current >= $globalLimit, + ]; +} diff --git a/public/main/inc/lib/course.lib.php b/public/main/inc/lib/course.lib.php index a9c45985bc2..b4daac66354 100644 --- a/public/main/inc/lib/course.lib.php +++ b/public/main/inc/lib/course.lib.php @@ -799,76 +799,95 @@ public static function subscribeUser( $status = STUDENT, $sessionId = 0, $userCourseCategoryId = 0, - $checkTeacherPermission = true + $checkTeacherPermission = true, + array $opts = [] ) { - $userId = (int) $userId; - $status = (int) $status; + $wantResult = !empty($opts['result']); // false => return bool (legacy) + $emitFlash = !array_key_exists('flash', $opts) ? true : (bool)$opts['flash']; // default: true + $sendEmails = !array_key_exists('emails', $opts) ? true : (bool)$opts['emails']; // default: true - if (empty($userId) || empty($courseId)) { - return false; - } + // Small helper to output consistently + $out = static function (bool $ok, string $code, string $msg, string $flashType = 'normal') use ($wantResult, $emitFlash) { + // Show flash only when enabled (avoid CLI noise or JSON UIs) + if ($emitFlash) { + Display::addFlash(Display::return_message($msg, $flashType)); + } + return $wantResult ? ['ok' => $ok, 'code' => $code, 'message' => $msg] : $ok; + }; - $course = api_get_course_entity($courseId); + $userId = (int) $userId; + $courseId = (int) $courseId; + $status = (int) $status; - if (null === $course) { - Display::addFlash(Display::return_message(get_lang('This course doesn\'t exist'), 'warning')); + if ($userId <= 0 || $courseId <= 0) { + return $out(false, 'INVALID_ARGS', get_lang('Invalid parameters'), 'warning'); + } - return false; + $course = api_get_course_entity($courseId); + if (!$course) { + return $out(false, 'COURSE_NOT_FOUND', get_lang("This course doesn't exist"), 'warning'); } $user = api_get_user_entity($userId); - - if (null === $user) { - Display::addFlash(Display::return_message(get_lang('This user doesn\'t exist'), 'warning')); - - return false; + if (!$user) { + return $out(false, 'USER_NOT_FOUND', get_lang("This user doesn't exist"), 'warning'); } $courseCode = $course->getCode(); $userCourseCategoryId = (int) $userCourseCategoryId; - $sessionId = empty($sessionId) ? api_get_session_id() : (int) $sessionId; - $status = STUDENT === $status || COURSEMANAGER === $status ? $status : STUDENT; - - if (!empty($sessionId)) { - SessionManager::subscribe_users_to_session_course( - [$userId], - $sessionId, - $courseCode + $sessionId = $sessionId ? (int) $sessionId : (int) api_get_session_id(); + $status = (STUDENT === $status || COURSEMANAGER === $status) ? $status : STUDENT; + + if ($sessionId > 0) { + SessionManager::subscribe_users_to_session_course([$userId], $sessionId, $courseCode); + $msg = sprintf( + get_lang('User %s has been registered to course %s'), + UserManager::formatUserFullName($user, true), + $course->getTitle() ); + return $out(true, 'SESSION_SUBSCRIBED_OK', $msg, 'normal'); + } - return true; - } else { - // Check whether the user has not been already subscribed to the course. - $sql = "SELECT * FROM ".Database::get_main_table(TABLE_MAIN_COURSE_USER)." - WHERE - user_id = $userId AND - relation_type <> ".COURSE_RELATION_TYPE_RRHH." AND - c_id = $courseId - "; - if (Database::num_rows(Database::query($sql)) > 0) { - Display::addFlash(Display::return_message(get_lang('Already registered in course'), 'warning')); + // Already subscribed? + $sql = "SELECT 1 + FROM ".Database::get_main_table(TABLE_MAIN_COURSE_USER)." + WHERE user_id = $userId + AND relation_type <> ".COURSE_RELATION_TYPE_RRHH." + AND c_id = $courseId"; + if (Database::num_rows(Database::query($sql)) > 0) { + return $out(false, 'ALREADY_SUBSCRIBED', get_lang('Already registered in course'), 'warning'); + } - return false; + // Check course allows subscription (non-admin teachers) + if ($checkTeacherPermission && !api_is_course_admin()) { + if (SUBSCRIBE_NOT_ALLOWED === (int) $course->getSubscribe()) { + return $out(false, 'SUBSCRIPTION_FORBIDDEN', get_lang('Subscription not allowed'), 'warning'); } + } - if ($checkTeacherPermission && !api_is_course_admin()) { - // Check in advance whether subscription is allowed or not for this course. - if (SUBSCRIBE_NOT_ALLOWED === (int) $course->getSubscribe()) { - Display::addFlash(Display::return_message(get_lang('Subscription not allowed'), 'warning')); - - return false; - } + // Global limit (includes teachers) - platform.hosting_limit_users_per_course + $globalLimit = (int) api_get_setting('platform.hosting_limit_users_per_course'); // 0 => disabled + if ($globalLimit > 0) { + $sqlCount = "SELECT COUNT(*) AS total + FROM ".Database::get_main_table(TABLE_MAIN_COURSE_USER)." + WHERE c_id = $courseId + AND relation_type <> ".COURSE_RELATION_TYPE_RRHH; + $res = Database::query($sqlCount); + $row = Database::fetch_array($res, 'ASSOC'); + $current = (int) ($row['total'] ?? 0); + if ($current >= $globalLimit) { + return $out(false, 'GLOBAL_LIMIT_REACHED', get_lang('The maximum number of users for this course has been reached.'), 'warning'); } + } - if (STUDENT === $status) { - // Check if max students per course extra field is set - $extraFieldValue = new ExtraFieldValue('course'); - $value = $extraFieldValue->get_values_by_handler_and_field_variable( - $courseId, - 'max_subscribed_students' - ); - if (!empty($value) && isset($value['value']) && '' !== $value['value']) { - $maxStudents = (int) $value['value']; + // Per-course student limit (only if NO global limit) + if ($status === STUDENT && $globalLimit <= 0) { + $efv = new ExtraFieldValue('course'); + $val = $efv->get_values_by_handler_and_field_variable($courseId, 'max_subscribed_students'); + if (!empty($val) && isset($val['value']) && $val['value'] !== '') { + $maxStudents = (int) $val['value']; + if ($maxStudents > 0) { + // With $count=true returns number of students $count = self::get_user_list_from_course_code( $courseCode, 0, @@ -878,89 +897,58 @@ public static function subscribeUser( true, false ); - if ($count >= $maxStudents) { - Display::addFlash( - Display::return_message( - get_lang( - 'The maximum number of student has already been reached, it is not possible to subscribe more student.' - ), - 'warning' - ) + return $out( + false, + 'COURSE_LIMIT_REACHED', + get_lang('The maximum number of student has already been reached, it is not possible to subscribe more student.'), + 'warning' ); - - return false; } } } + } - $maxSort = api_max_sort_value(0, $userId) + 1; - - $insertId = self::insertUserInCourse( - $user, - $course, - ['status' => $status, 'sort' => $maxSort, 'user_course_cat' => $userCourseCategoryId] - ); - - if ($insertId) { - Display::addFlash( - Display::return_message( - sprintf( - get_lang('User %s has been registered to course %s'), - UserManager::formatUserFullName($user, true), - $course->getTitle() - ) - ) - ); - - $sendToStudent = (int) api_get_course_setting('email_alert_student_on_manual_subscription', $course); - if (1 === $sendToStudent) { - $subject = get_lang('You have been enrolled in the course').' '.$course->getTitle(); - $message = sprintf( - get_lang('Hello %s, you have been enrolled in the course %s.'), - UserManager::formatUserFullName($user, true), - $course->getTitle() - ); - - MessageManager::send_message_simple( - $userId, - $subject, - $message, - api_get_user_id(), - false, - true - ); - } - - $send = (int) api_get_course_setting('email_alert_to_teacher_on_new_user_in_course', $course); + // Insert subscription + $maxSort = api_max_sort_value(0, $userId) + 1; + $insertId = self::insertUserInCourse( + $user, + $course, + ['status' => $status, 'sort' => $maxSort, 'user_course_cat' => $userCourseCategoryId] + ); - if (1 === $send) { - self::email_to_tutor( - $userId, - $courseId, - false - ); - } elseif (2 === $send) { - self::email_to_tutor( - $userId, - $courseId, - true - ); - } + if (!$insertId) { + return $out(false, 'INSERT_FAILED', get_lang('Unexpected error while subscribing the user'), 'warning'); + } - $subscribe = (int) api_get_course_setting('subscribe_users_to_forum_notifications', $course); - if (1 === $subscribe) { - /*$forums = get_forums(0, true, $sessionId); - foreach ($forums as $forum) { - set_notification('forum', $forum->getIid(), false, $userInfo, $courseInfo); - }*/ - } + // Success + optional emails + $okMsg = sprintf( + get_lang('User %s has been registered to course %s'), + UserManager::formatUserFullName($user, true), + $course->getTitle() + ); - return true; + if ($sendEmails) { + $sendToStudent = (int) api_get_course_setting('email_alert_student_on_manual_subscription', $course); + if (1 === $sendToStudent) { + $subject = get_lang('You have been enrolled in the course').' '.$course->getTitle(); + $message = sprintf( + get_lang('Hello %s, you have been enrolled in the course %s.'), + UserManager::formatUserFullName($user, true), + $course->getTitle() + ); + MessageManager::send_message_simple($userId, $subject, $message, api_get_user_id(), false, true); } - return false; + $send = (int) api_get_course_setting('email_alert_to_teacher_on_new_user_in_course', $course); + if (1 === $send) { + self::email_to_tutor($userId, $courseId, false); + } elseif (2 === $send) { + self::email_to_tutor($userId, $courseId, true); + } } + + return $out(true, 'COURSE_SUBSCRIBED_OK', $okMsg, 'normal'); } /** diff --git a/public/main/user/subscribe_user.php b/public/main/user/subscribe_user.php index 5ba89e758dd..2659bf0b8fc 100644 --- a/public/main/user/subscribe_user.php +++ b/public/main/user/subscribe_user.php @@ -64,24 +64,41 @@ if (COURSEMANAGER === $type) { if (!empty($sessionId)) { $message = $userInfo['complete_name_with_username'].' '.get_lang('has been registered to your course'); - SessionManager::set_coach_to_course_session( + $ok = SessionManager::set_coach_to_course_session( $_REQUEST['user_id'], $sessionId, $courseInfo['real_id'] ); - Display::addFlash(Display::return_message($message)); + Display::addFlash( + Display::return_message($ok ? $message : get_lang('Unexpected error while subscribing the user'), $ok ? 'normal' : 'warning') + ); } else { - CourseManager::subscribeUser( + $res = CourseManager::subscribeUser( $_REQUEST['user_id'], $courseInfo['real_id'], - COURSEMANAGER + COURSEMANAGER, + 0, + 0, + true, + ['result' => true, 'flash' => false, 'emails' => true] // UI handles flash here ); + if (is_array($res) && isset($res['message'])) { + Display::addFlash(Display::return_message($res['message'], !empty($res['ok']) ? 'normal' : 'warning')); + } } } else { - CourseManager::subscribeUser( + $res = CourseManager::subscribeUser( $_REQUEST['user_id'], - $courseInfo['real_id'] + $courseInfo['real_id'], + STUDENT, + 0, + 0, + true, + ['result' => true, 'flash' => false, 'emails' => true] ); + if (is_array($res) && isset($res['message'])) { + Display::addFlash(Display::return_message($res['message'], !empty($res['ok']) ? 'normal' : 'warning')); + } } } header('Location:'.api_get_path(WEB_CODE_PATH).'user/user.php?'.api_get_cidreq().'&type='.$type); @@ -93,6 +110,8 @@ case 'subscribe': if (is_array($_POST['user'])) { $isSuscribe = []; + $errorMessages = []; + foreach ($_POST['user'] as $index => $user_id) { $userInfo = api_get_user_info($user_id); if ($userInfo) { @@ -106,12 +125,45 @@ ); if ($result) { $isSuscribe[] = $message; + } else { + $errorMessages[] = $userInfo['complete_name_with_username'].': '.get_lang('Unexpected error while subscribing the user'); } } else { - CourseManager::subscribeUser($user_id, $courseInfo['real_id'], COURSEMANAGER); + // NEW: use structured result when available + $res = CourseManager::subscribeUser( + $user_id, + $courseInfo['real_id'], + COURSEMANAGER, + 0, + 0, + true, + ['result' => true, 'flash' => false, 'emails' => true] + ); + if (is_array($res)) { + if (!empty($res['ok'])) { + $isSuscribe[] = $res['message']; + } else { + $errorMessages[] = $res['message'] ?? get_lang('Unexpected error while subscribing the user'); + } + } } } else { - CourseManager::subscribeUser($user_id, $courseInfo['real_id']); + $res = CourseManager::subscribeUser( + $user_id, + $courseInfo['real_id'], + STUDENT, + 0, + 0, + true, + ['result' => true, 'flash' => false, 'emails' => true] + ); + if (is_array($res)) { + if (!empty($res['ok'])) { + $isSuscribe[] = $res['message']; + } else { + $errorMessages[] = $res['message'] ?? get_lang('Unexpected error while subscribing the user'); + } + } } } } @@ -121,11 +173,15 @@ Display::addFlash(Display::return_message($info)); } } + if (!empty($errorMessages)) { + $errHtml = implode('
', array_map('Security::remove_XSS', $errorMessages)); + Display::addFlash(Display::return_message($errHtml, 'warning')); + } } header('Location:'.api_get_path(WEB_CODE_PATH).'user/user.php?'.api_get_cidreq().'&type='.$type); exit; - break; + break; } } @@ -173,6 +229,15 @@ Display::display_header($tool_name, 'User'); +// Global limit banner (teachers included) so teachers see it before trying +$__globalLimit = (int) api_get_setting('platform.hosting_limit_users_per_course'); // 0 => disabled +if ($__globalLimit > 0) { + echo Display::return_message( + sprintf(get_lang('A global limit of %d users applies to this course (teachers included).'), $__globalLimit), + 'warning' + ); +} + // Build search-form switch ($type) { case STUDENT: @@ -494,9 +559,12 @@ function get_user_data($from, $number_of_items, $column, $direction) ON u.id = cu.user_id AND c_id = $courseId AND - session_id = $sessionId - INNER JOIN $tbl_url_rel_user as url_rel_user - ON (url_rel_user.user_id = u.id) "; + session_id = $sessionId "; + + if (api_is_multiple_url_enabled()) { + $sql .= " INNER JOIN $tbl_url_rel_user as url_rel_user + ON (url_rel_user.user_id = u.id) "; + } // applying the filter of the additional user profile fields if (isset($_GET['subscribe_user_filter_value']) && @@ -519,7 +587,9 @@ function get_user_data($from, $number_of_items, $column, $direction) $teacherRoleFilter AND (u.official_code <> 'ADMIN' OR u.official_code IS NULL) "; } - $sql .= " AND access_url_id = $url_access_id"; + if (api_is_multiple_url_enabled()) { + $sql .= " AND access_url_id = $url_access_id"; + } } else { // adding a teacher NOT through a session $sql = "SELECT $select_fields diff --git a/src/CoreBundle/Controller/CatalogueController.php b/src/CoreBundle/Controller/CatalogueController.php index aaa317496ff..7c221de3fb3 100644 --- a/src/CoreBundle/Controller/CatalogueController.php +++ b/src/CoreBundle/Controller/CatalogueController.php @@ -42,7 +42,7 @@ public function __construct( ) {} #[Route('/courses-list', name: 'chamilo_core_catalogue_courses_list', methods: ['GET'])] - public function listCourses(): JsonResponse + public function listCourses(SettingsManager $settingsManager): JsonResponse { $user = $this->userHelper->getCurrent(); $accessUrl = $this->accessUrlHelper->getCurrent(); @@ -72,13 +72,23 @@ public function listCourses(): JsonResponse $courses = array_values($visibleCourses); } - $data = array_map(function (Course $course) { + // Retrieve global user-per-course limit + $maxUsersPerCourse = (int) $settingsManager->getSetting('platform.hosting_limit_users_per_course', true); + + // --- [B] Map data for response --- + $data = array_map(function (Course $course) use ($maxUsersPerCourse) { + // Count *all* users (students + teachers + tutors + HR) + $nbUsers = $course->getUsers()->count(); + return [ 'id' => $course->getId(), 'code' => $course->getCode(), 'title' => $course->getTitle(), 'description' => $course->getDescription(), 'visibility' => $course->getVisibility(), + 'nb_users' => $nbUsers, + 'max_users' => $maxUsersPerCourse, + 'is_full' => $maxUsersPerCourse > 0 && $nbUsers >= $maxUsersPerCourse, ]; }, $courses); diff --git a/src/CoreBundle/Entity/Course.php b/src/CoreBundle/Entity/Course.php index dbfd9b40134..35dda3e8ae8 100644 --- a/src/CoreBundle/Entity/Course.php +++ b/src/CoreBundle/Entity/Course.php @@ -377,6 +377,15 @@ class Course extends AbstractResource implements ResourceInterface, ResourceWith #[Groups(['course:read'])] public bool $subscribed = false; + #[Groups(['course:read'])] + public ?int $nb_students = null; + + #[Groups(['course:read'])] + public ?int $max_students = null; + + #[Groups(['course:read'])] + public bool $is_full = false; + #[SerializedName('allowSelfSignup')] #[Groups(['course:read','session:read'])] public function getAllowSelfSignup(): bool diff --git a/src/CoreBundle/Repository/CourseRelUserRepository.php b/src/CoreBundle/Repository/CourseRelUserRepository.php index 461c28fb3b9..91d930b24b6 100644 --- a/src/CoreBundle/Repository/CourseRelUserRepository.php +++ b/src/CoreBundle/Repository/CourseRelUserRepository.php @@ -57,4 +57,69 @@ public function countTaughtCoursesForUser(User $user): int ->getQuery() ->getSingleScalarResult(); } + + /** + * Count all subscriptions (any status) for the given course ID. + * Optionally exclude a specific relationType (e.g., RRHH). + * + * @param int $courseId + * @param int|null $excludeRelationType If provided, rows with this relationType are excluded + */ + public function countAllByCourseId(int $courseId, ?int $excludeRelationType = null): int + { + $qb = $this->createQueryBuilder('cru') + ->select('COUNT(cru.id)') + ->innerJoin('cru.course', 'c') + ->andWhere('c.id = :cid') + ->setParameter('cid', $courseId); + + if (null !== $excludeRelationType) { + $qb->andWhere('cru.relationType <> :rt') + ->setParameter('rt', $excludeRelationType); + } + + return (int) $qb->getQuery()->getSingleScalarResult(); + } + + /** + * Compute a capacity snapshot for UI (limit/used/left/is_full). + * This only considers the global hosting limit and an optional per-course limit + * already computed by caller. A limit of 0 means "no limit". + * + * @param int $courseId + * @param int|null $perCourseLimit Optional course-level limit (e.g., extra field). If provided, the effective + * limit will be min(global, per-course). If null, only global limit is used. + * + * @return array{limit:int, used:int, left:int|null, is_full:bool} + */ + public function getCapacitySnapshot(int $courseId, ?int $perCourseLimit = null): array + { + // Read global limit (0 means unlimited) + $global = (int) api_get_setting('hosting_limit_users_per_course'); + + // Effective limit resolution rule: + // - if per-course limit is provided and > 0, use min(global, per-course) when global > 0, else per-course + // - else use global as-is (0 => unlimited) + $limit = 0; + if ($perCourseLimit && $perCourseLimit > 0) { + $limit = $global > 0 ? min($global, $perCourseLimit) : $perCourseLimit; + } else { + $limit = $global; + } + + if ($limit <= 0) { + return ['limit' => 0, 'used' => 0, 'left' => null, 'is_full' => false]; + } + + // Exclude RRHH relation type if available (keeps legacy behavior) + $exclude = \defined('COURSE_RELATION_TYPE_RRHH') ? COURSE_RELATION_TYPE_RRHH : null; + $used = $this->countAllByCourseId($courseId, $exclude); + + return [ + 'limit' => $limit, + 'used' => $used, + 'left' => max(0, $limit - $used), + 'is_full' => $used >= $limit, + ]; + } } diff --git a/src/CoreBundle/State/PublicCatalogueCourseStateProvider.php b/src/CoreBundle/State/PublicCatalogueCourseStateProvider.php index 330b1cfbe58..f99a67ea89e 100644 --- a/src/CoreBundle/State/PublicCatalogueCourseStateProvider.php +++ b/src/CoreBundle/State/PublicCatalogueCourseStateProvider.php @@ -37,6 +37,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $user = $this->tokenStorage->getToken()?->getUser(); $isAuthenticated = \is_object($user); + // Check if the public catalogue is visible for anonymous users if (!$isAuthenticated) { $showCatalogue = 'false' !== $this->settingsManager->getSetting('catalog.course_catalog_published', true); if (!$showCatalogue) { @@ -60,6 +61,8 @@ public function provide(Operation $operation, array $uriVariables = [], array $c /** @var AccessUrl $accessUrl */ $accessUrl = $this->accessUrlRepository->findOneBy(['url' => $host]) ?? $this->accessUrlRepository->find(1); + + // Retrieve all courses visible under this URL $courses = $this->courseRepository->createQueryBuilder('c') ->innerJoin('c.urls', 'url_rel') ->andWhere('url_rel.url = :accessUrl') @@ -71,20 +74,17 @@ public function provide(Operation $operation, array $uriVariables = [], array $c ->getResult() ; - if (!$onlyShowMatching && !$onlyShowCoursesWithCategory) { - if ($isAuthenticated) { - foreach ($courses as $course) { - if ($course instanceof Course) { - $course->subscribed = $course->hasSubscriptionByUser($user); - } - } - } + // Global hosting limit (includes all users, not only students) + $maxUsersPerCourse = (int) $this->settingsManager->getSetting('platform.hosting_limit_users_per_course', true); - return $courses; - } + $filteredCourses = []; - $filtered = []; foreach ($courses as $course) { + if (!$course instanceof Course) { + continue; + } + + // Apply "show_in_catalogue" and category filters $passesExtraField = true; $passesCategory = true; @@ -101,15 +101,26 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $passesCategory = $course->getCategories()->count() > 0; } - if ($passesExtraField && $passesCategory) { - if ($isAuthenticated && $course instanceof Course) { - $course->subscribed = $course->hasSubscriptionByUser($user); - } + if (!$passesExtraField || !$passesCategory) { + continue; + } - $filtered[] = $course; + // Compute user subscription info + if ($isAuthenticated) { + $course->subscribed = $course->hasSubscriptionByUser($user); } + + // Count ALL users in the course (students + teachers + tutors + HR) + $nbUsers = $course->getUsers()->count(); + + // Expose computed fields for API serialization + $course->nb_students = $nbUsers; + $course->max_students = $maxUsersPerCourse; + $course->is_full = $maxUsersPerCourse > 0 && $nbUsers >= $maxUsersPerCourse; + + $filteredCourses[] = $course; } - return $filtered; + return $filteredCourses; } }