diff --git a/assets/vue/composables/catalogue/catalogueCourseList.js b/assets/vue/composables/catalogue/catalogueCourseList.js index e13f1e1970e..96fef08d5de 100644 --- a/assets/vue/composables/catalogue/catalogueCourseList.js +++ b/assets/vue/composables/catalogue/catalogueCourseList.js @@ -29,7 +29,7 @@ export function useCatalogueCourseList() { isLoading.value = true try { - const { items } = await courseService.listAll() + const items = await courseService.listCatalogueCourses() courses.value = items.map((course) => ({ ...course, diff --git a/assets/vue/services/courseService.js b/assets/vue/services/courseService.js index 6f49cb3a54c..2aff47464b2 100644 --- a/assets/vue/services/courseService.js +++ b/assets/vue/services/courseService.js @@ -170,4 +170,12 @@ export default { return null } }, + /** + * Loads public catalogue courses filtered by access_url and usergroup rules. + * @returns {Promise<{items: Array}>} + */ + listCatalogueCourses: async () => { + const response = await api.get("/catalogue/courses-list") + return response.data + }, } diff --git a/assets/vue/views/course/CatalogueCourses.vue b/assets/vue/views/course/CatalogueCourses.vue index 1152dce0642..2fd28552e61 100644 --- a/assets/vue/views/course/CatalogueCourses.vue +++ b/assets/vue/views/course/CatalogueCourses.vue @@ -190,7 +190,7 @@ const platformConfigStore = usePlatformConfig() const showCourseDuration = "true" === platformConfigStore.getSetting("course.show_course_duration") const isUserInCourse = (course) => { - return course.users.some((user) => user.user.id === securityStore.user.id) + return Array.isArray(course.users) && course.users.some((user) => user.user.id === securityStore.user.id) } load() diff --git a/assets/vue/views/course/CatalogueSessions.vue b/assets/vue/views/course/CatalogueSessions.vue index 12c838807da..7b6998c120b 100644 --- a/assets/vue/views/course/CatalogueSessions.vue +++ b/assets/vue/views/course/CatalogueSessions.vue @@ -115,7 +115,7 @@ class="p-button-sm" > - {{ t("Go to the session") }} + {{ $t("Go to the session") }} @@ -183,7 +183,7 @@ class="p-button-sm" icon="link-external" /> - {{ t("Go to the course") }} + {{ $t("Go to the course") }} @@ -232,14 +232,14 @@ export default { load: function () { this.status = true axios - .get(ENTRYPOINT + "sessions.json") + .get("/catalogue/sessions-list") .then((response) => { this.status = false if (Array.isArray(response.data)) { this.sessions = response.data } }) - .catch(function (error) { + .catch((error) => { console.log(error) }) }, diff --git a/public/main/admin/course_list.php b/public/main/admin/course_list.php index 0cb5043009c..9195bf911c4 100644 --- a/public/main/admin/course_list.php +++ b/public/main/admin/course_list.php @@ -12,7 +12,10 @@ use Chamilo\CoreBundle\Component\Utils\ActionIcon; use Chamilo\CoreBundle\Component\Utils\StateIcon; use Chamilo\CoreBundle\Component\Utils\ToolIcon; +use Chamilo\CoreBundle\Entity\AccessUrl; +use Chamilo\CoreBundle\Entity\CatalogueCourseRelAccessUrlRelUsergroup; use Chamilo\CoreBundle\Framework\Container; +use Chamilo\CoreBundle\Repository\CatalogueCourseRelAccessUrlRelUsergroupRepository; $cidReset = true; @@ -257,6 +260,35 @@ function get_course_data( ] ); + $em = Database::getManager(); + /** @var CatalogueCourseRelAccessUrlRelUsergroupRepository $repo */ + $repo = $em->getRepository(CatalogueCourseRelAccessUrlRelUsergroup::class); + $record = $repo->findOneBy([ + 'course' => $courseId, + 'accessUrl' => api_get_current_access_url_id(), + 'usergroup' => null, + ]); + + $isInCatalogue = null !== $record; + $catalogueUrl = api_get_self().'?toggle_catalogue='.$course['id'].'&sec_token='.Security::getTokenFromSession(); + + $actions[] = Display::url( + Display::getMdiIcon( + $isInCatalogue ? StateIcon::CATALOGUE_OFF : StateIcon::CATALOGUE_ON, + 'ch-tool-icon', + null, + ICON_SIZE_SMALL, + $isInCatalogue ? get_lang('Remove from catalogue') : get_lang('Add to catalogue'), + [ + 'class' => $isInCatalogue ? 'text-warning' : 'text-muted', + ] + ), + $catalogueUrl, + [ + 'title' => $isInCatalogue ? get_lang('Remove from catalogue') : get_lang('Add to catalogue'), + ] + ); + $courseItem = [ $course['col0'], $course['col1'], @@ -340,6 +372,40 @@ function get_course_visibility_icon(int $visibility): string api_location(api_get_self()); } } + +if (isset($_GET['toggle_catalogue']) && Security::check_token('get')) { + $courseId = (int) $_GET['toggle_catalogue']; + $accessUrlId = api_get_current_access_url_id(); + $em = Database::getManager(); + $repo = $em->getRepository(CatalogueCourseRelAccessUrlRelUsergroup::class); + $course = api_get_course_entity($courseId); + $accessUrl = $em->getRepository(AccessUrl::class)->find($accessUrlId); + + if ($course && $accessUrl) { + $record = $repo->findOneBy([ + 'course' => $course, + 'accessUrl' => $accessUrl, + 'usergroup' => null, + ]); + + if ($record) { + $em->remove($record); + Display::addFlash(Display::return_message(get_lang('Removed from catalogue'))); + } else { + $newRel = new CatalogueCourseRelAccessUrlRelUsergroup(); + $newRel->setCourse($course); + $newRel->setAccessUrl($accessUrl); + $newRel->setUsergroup(null); + + $em->persist($newRel); + Display::addFlash(Display::return_message(get_lang('Added to catalogue'), 'success')); + } + + $em->flush(); + } + + api_location(api_get_self()); +} $content = ''; $message = ''; $actions = ''; diff --git a/src/CoreBundle/Component/Utils/StateIcon.php b/src/CoreBundle/Component/Utils/StateIcon.php index 6ef74a786ee..08912f40271 100644 --- a/src/CoreBundle/Component/Utils/StateIcon.php +++ b/src/CoreBundle/Component/Utils/StateIcon.php @@ -62,4 +62,8 @@ enum StateIcon: string case OFFLINE = 'account-off'; // Soft deleted (for a user) case REJECT = 'cancel'; + // Item is visible in the catalogue + case CATALOGUE_ON = 'book-plus'; + // Item is hidden from the catalogue + case CATALOGUE_OFF = 'book-minus-outline'; } diff --git a/src/CoreBundle/Controller/CatalogueController.php b/src/CoreBundle/Controller/CatalogueController.php new file mode 100644 index 00000000000..1a7c7a8f938 --- /dev/null +++ b/src/CoreBundle/Controller/CatalogueController.php @@ -0,0 +1,125 @@ +userHelper->getCurrent(); + $accessUrl = $this->accessUrlHelper->getCurrent(); + + $relRepo = $this->em->getRepository(CatalogueCourseRelAccessUrlRelUsergroup::class); + $userGroupRepo = $this->em->getRepository(UsergroupRelUser::class); + + $relations = $relRepo->findBy(['accessUrl' => $accessUrl]); + + if (empty($relations)) { + $courses = $this->courseRepository->findAll(); + } else { + $userGroups = $userGroupRepo->findBy(['user' => $user]); + $userGroupIds = array_map(fn($ug) => $ug->getUsergroup()->getId(), $userGroups); + + $visibleCourses = []; + + foreach ($relations as $rel) { + $course = $rel->getCourse(); + $usergroup = $rel->getUsergroup(); + + if ($usergroup === null || in_array($usergroup->getId(), $userGroupIds)) { + $visibleCourses[$course->getId()] = $course; + } + } + + $courses = array_values($visibleCourses); + } + + $data = array_map(function (Course $course) { + return [ + 'id' => $course->getId(), + 'code' => $course->getCode(), + 'title' => $course->getTitle(), + 'description' => $course->getDescription(), + 'visibility' => $course->getVisibility(), + ]; + }, $courses); + + return $this->json($data); + } + + #[Route('/sessions-list', name: 'chamilo_core_catalogue_sessions_list', methods: ['GET'])] + public function listSessions(): JsonResponse + { + $user = $this->userHelper->getCurrent(); + $accessUrl = $this->accessUrlHelper->getCurrent(); + + $relRepo = $this->em->getRepository(CatalogueSessionRelAccessUrlRelUsergroup::class); + $userGroupRepo = $this->em->getRepository(UsergroupRelUser::class); + + $relations = $relRepo->findBy(['accessUrl' => $accessUrl]); + + if (empty($relations)) { + $sessions = $this->sessionRepository->findAll(); + } else { + $userGroups = $userGroupRepo->findBy(['user' => $user]); + $userGroupIds = array_map(fn($ug) => $ug->getUsergroup()->getId(), $userGroups); + + $visibleSessions = []; + + foreach ($relations as $rel) { + $session = $rel->getSession(); + $usergroup = $rel->getUsergroup(); + + if ($usergroup === null || in_array($usergroup->getId(), $userGroupIds)) { + $visibleSessions[$session->getId()] = $session; + } + } + + $sessions = array_values($visibleSessions); + } + + $data = array_map(function (Session $session) { + return [ + 'id' => $session->getId(), + 'title' => $session->getTitle(), + 'description' => $session->getDescription(), + 'imageUrl' => $session->getImageUrl(), + 'visibility' => $session->getVisibility(), + 'nbrUsers' => $session->getNbrUsers(), + 'nbrCourses' => $session->getNbrCourses(), + 'startDate' => $session->getAccessStartDate()?->format('Y-m-d'), + 'endDate' => $session->getAccessEndDate()?->format('Y-m-d'), + ]; + }, $sessions); + + return $this->json($data); + } +} diff --git a/src/CoreBundle/Entity/CatalogueCourseRelAccessUrlRelUsergroup.php b/src/CoreBundle/Entity/CatalogueCourseRelAccessUrlRelUsergroup.php new file mode 100644 index 00000000000..4dd62d5c321 --- /dev/null +++ b/src/CoreBundle/Entity/CatalogueCourseRelAccessUrlRelUsergroup.php @@ -0,0 +1,74 @@ +id; + } + + public function getCourse(): Course + { + return $this->course; + } + + public function setCourse(Course $course): self + { + $this->course = $course; + + return $this; + } + + public function getAccessUrl(): AccessUrl + { + return $this->accessUrl; + } + + public function setAccessUrl(AccessUrl $accessUrl): self + { + $this->accessUrl = $accessUrl; + + return $this; + } + + public function getUsergroup(): ?Usergroup + { + return $this->usergroup; + } + + public function setUsergroup(?Usergroup $usergroup): self + { + $this->usergroup = $usergroup; + + return $this; + } +} diff --git a/src/CoreBundle/Entity/CatalogueSessionRelAccessUrlRelUsergroup.php b/src/CoreBundle/Entity/CatalogueSessionRelAccessUrlRelUsergroup.php new file mode 100644 index 00000000000..22631f0502a --- /dev/null +++ b/src/CoreBundle/Entity/CatalogueSessionRelAccessUrlRelUsergroup.php @@ -0,0 +1,71 @@ +id; + } + + public function getSession(): Session + { + return $this->session; + } + + public function setSession(Session $session): self + { + $this->session = $session; + return $this; + } + + public function getAccessUrl(): AccessUrl + { + return $this->accessUrl; + } + + public function setAccessUrl(AccessUrl $accessUrl): self + { + $this->accessUrl = $accessUrl; + + return $this; + } + + public function getUsergroup(): ?Usergroup + { + return $this->usergroup; + } + + public function setUsergroup(?Usergroup $usergroup): self + { + $this->usergroup = $usergroup; + return $this; + } +} diff --git a/src/CoreBundle/Migrations/Schema/V200/Version20250320213000.php b/src/CoreBundle/Migrations/Schema/V200/Version20250320213000.php new file mode 100644 index 00000000000..09e50938f3a --- /dev/null +++ b/src/CoreBundle/Migrations/Schema/V200/Version20250320213000.php @@ -0,0 +1,83 @@ +addSql(' + CREATE TABLE catalogue_course_rel_access_url_rel_usergroup ( + id INT AUTO_INCREMENT NOT NULL, + course_id INT DEFAULT NULL, + access_url_id INT DEFAULT NULL, + usergroup_id INT DEFAULT NULL, + INDEX IDX_37CC1F8E591CC992 (course_id), + INDEX IDX_37CC1F8E73444FD5 (access_url_id), + INDEX IDX_37CC1F8ED2112630 (usergroup_id), + PRIMARY KEY(id) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB ROW_FORMAT = DYNAMIC; + '); + + $this->addSql(' + CREATE TABLE catalogue_session_rel_access_url_rel_usergroup ( + id INT AUTO_INCREMENT NOT NULL, + session_id INT DEFAULT NULL, + access_url_id INT DEFAULT NULL, + usergroup_id INT DEFAULT NULL, + INDEX IDX_B143E63A613FECDF (session_id), + INDEX IDX_B143E63A73444FD5 (access_url_id), + INDEX IDX_B143E63AD2112630 (usergroup_id), + PRIMARY KEY(id) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB ROW_FORMAT = DYNAMIC; + '); + + $this->addSql(' + ALTER TABLE catalogue_course_rel_access_url_rel_usergroup + ADD CONSTRAINT FK_37CC1F8E591CC992 FOREIGN KEY (course_id) REFERENCES course (id) ON DELETE CASCADE; + '); + + $this->addSql(' + ALTER TABLE catalogue_course_rel_access_url_rel_usergroup + ADD CONSTRAINT FK_37CC1F8E73444FD5 FOREIGN KEY (access_url_id) REFERENCES access_url (id) ON DELETE CASCADE; + '); + + $this->addSql(' + ALTER TABLE catalogue_course_rel_access_url_rel_usergroup + ADD CONSTRAINT FK_37CC1F8ED2112630 FOREIGN KEY (usergroup_id) REFERENCES usergroup (id) ON DELETE SET NULL; + '); + + $this->addSql(' + ALTER TABLE catalogue_session_rel_access_url_rel_usergroup + ADD CONSTRAINT FK_B143E63A613FECDF FOREIGN KEY (session_id) REFERENCES session (id) ON DELETE CASCADE; + '); + + $this->addSql(' + ALTER TABLE catalogue_session_rel_access_url_rel_usergroup + ADD CONSTRAINT FK_B143E63A73444FD5 FOREIGN KEY (access_url_id) REFERENCES access_url (id) ON DELETE CASCADE; + '); + + $this->addSql(' + ALTER TABLE catalogue_session_rel_access_url_rel_usergroup + ADD CONSTRAINT FK_B143E63AD2112630 FOREIGN KEY (usergroup_id) REFERENCES usergroup (id) ON DELETE SET NULL; + '); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE IF EXISTS catalogue_session_rel_access_url_rel_usergroup;'); + $this->addSql('DROP TABLE IF EXISTS catalogue_course_rel_access_url_rel_usergroup;'); + } +} diff --git a/src/CoreBundle/Migrations/Schema/V200/Version20250321000100.php b/src/CoreBundle/Migrations/Schema/V200/Version20250321000100.php new file mode 100644 index 00000000000..5e59374aa17 --- /dev/null +++ b/src/CoreBundle/Migrations/Schema/V200/Version20250321000100.php @@ -0,0 +1,95 @@ +entityManager->beginTransaction(); + + try { + if (!$this->tableExists('extra_field') || !$this->tableExists('extra_field_values')) { + return; + } + + /** @var AccessUrlRepository $accessUrlRepo */ + $accessUrlRepo = $this->container->get(AccessUrlRepository::class); + $accessUrlId = $accessUrlRepo->getFirstId(); + + if ($accessUrlId === 0) { + throw new Exception('No AccessUrl found for migration'); + } + + /** @var AccessUrl|null $accessUrl */ + $accessUrl = $this->entityManager->find(AccessUrl::class, $accessUrlId); + if (!$accessUrl) { + throw new Exception('AccessUrl entity not found for ID: ' . $accessUrlId); + } + + $courseRepo = $this->entityManager->getRepository(Course::class); + + $courseIds = $this->connection->fetchFirstColumn(' + SELECT fv.item_id + FROM extra_field_values fv + INNER JOIN extra_field f ON f.id = fv.field_id + WHERE f.item_type = 2 + AND f.variable = "show_in_catalogue" + AND fv.field_value = 1 + '); + + foreach ($courseIds as $courseId) { + $course = $courseRepo->find($courseId); + + if (!$course) { + continue; + } + + $rel = new CatalogueCourseRelAccessUrlRelUsergroup(); + $rel->setAccessUrl($accessUrl); + $rel->setCourse($course); + $rel->setUsergroup(null); + + $this->entityManager->persist($rel); + } + + $this->entityManager->flush(); + $this->entityManager->commit(); + } catch (Exception $e) { + $this->entityManager->rollBack(); + error_log('Migration failed: ' . $e->getMessage()); + } + } + + public function down(Schema $schema): void + { + $this->addSql('DELETE FROM catalogue_course_rel_access_url_rel_usergroup'); + } + + private function tableExists(string $tableName): bool + { + try { + $this->connection->executeQuery("SELECT 1 FROM $tableName LIMIT 1"); + return true; + } catch (Exception $e) { + return false; + } + } +} diff --git a/src/CoreBundle/Repository/CatalogueCourseRelAccessUrlRelUsergroupRepository.php b/src/CoreBundle/Repository/CatalogueCourseRelAccessUrlRelUsergroupRepository.php new file mode 100644 index 00000000000..851440c5589 --- /dev/null +++ b/src/CoreBundle/Repository/CatalogueCourseRelAccessUrlRelUsergroupRepository.php @@ -0,0 +1,63 @@ + visible to all users on that access URL. + * - If usergroup is not NULL => visible only to users belonging to that usergroup. + * - A course can be assigned to multiple usergroups and will appear once if matched. + */ + public function findCourseIdsByAccessUrlAndUsergroups(int $accessUrlId, array $usergroupIds): array + { + $qb = $this->createQueryBuilder('a') + ->select('DISTINCT IDENTITY(a.course) AS course_id') + ->where('a.accessUrl = :accessUrlId') + ->setParameter('accessUrlId', $accessUrlId); + + if (!empty($usergroupIds)) { + $qb->andWhere( + $qb->expr()->orX( + 'a.usergroup IS NULL', + 'a.usergroup IN (:usergroupIds)' + ) + ) + ->setParameter('usergroupIds', $usergroupIds); + } + + return array_column($qb->getQuery()->getResult(), 'course_id'); + } + + /** + * Checks if there are any course visibility rules defined for a given access URL. + * + * If there are no entries, the default behavior is to show all courses. + */ + public function hasRecordsForAccessUrl(int $accessUrlId): bool + { + return $this->createQueryBuilder('a') + ->select('1') + ->where('a.accessUrl = :accessUrlId') + ->setParameter('accessUrlId', $accessUrlId) + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult() !== null; + } +} diff --git a/src/CoreBundle/Repository/CatalogueSessionRelAccessUrlRelUsergroupRepository.php b/src/CoreBundle/Repository/CatalogueSessionRelAccessUrlRelUsergroupRepository.php new file mode 100644 index 00000000000..89e983b2216 --- /dev/null +++ b/src/CoreBundle/Repository/CatalogueSessionRelAccessUrlRelUsergroupRepository.php @@ -0,0 +1,63 @@ + visible to all users on that access URL. + * - If usergroup is not NULL => visible only to users belonging to that usergroup. + * - A session can be assigned to multiple usergroups and will appear once if matched. + */ + public function findSessionIdsByAccessUrlAndUsergroups(int $accessUrlId, array $usergroupIds): array + { + $qb = $this->createQueryBuilder('a') + ->select('DISTINCT IDENTITY(a.session) AS session_id') + ->where('a.accessUrl = :accessUrlId') + ->setParameter('accessUrlId', $accessUrlId); + + if (!empty($usergroupIds)) { + $qb->andWhere( + $qb->expr()->orX( + 'a.usergroup IS NULL', + 'a.usergroup IN (:usergroupIds)' + ) + ) + ->setParameter('usergroupIds', $usergroupIds); + } + + return array_column($qb->getQuery()->getResult(), 'session_id'); + } + + /** + * Checks if there are any session visibility rules defined for a given access URL. + * + * If there are no entries, the default behavior is to show all sessions. + */ + public function hasRecordsForAccessUrl(int $accessUrlId): bool + { + return $this->createQueryBuilder('a') + ->select('1') + ->where('a.accessUrl = :accessUrlId') + ->setParameter('accessUrlId', $accessUrlId) + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult() !== null; + } +}