diff --git a/README.md b/README.md index b5578962dcc..18811ed3d79 100755 --- a/README.md +++ b/README.md @@ -23,12 +23,25 @@ We assume you already have: - yarn +4.x - https://yarnpkg.com/getting-started/install - Node >= v18+ (lts) - https://github.com/nodesource/distributions/blob/master/README.md - Configuring a virtualhost in a domain, not in a sub folder inside a domain. -- A working LAMP/WAMP server with PHP 8.1+ +- A working LAMP/WAMP server with PHP 8.2+ ### Software stack install (Ubuntu) -You will need PHP8+ and NodeJS v18+ to run Chamilo 2. -On a fresh Ubuntu 22.04, you can prepare your server by issuing an apt command like the following with sudo (or as root, but not recommended for security reasons): +You will need PHP8.2+ and NodeJS v18+ to run Chamilo 2. + +On Ubuntu 24.04+, the following should take care of most dependencies. + +~~~~ +sudo apt update +sudo apt -y upgrade +sudo apt install apache2 libapache2-mod-php mariadb-client mariadb-server redis php-pear php-{apcu,bcmath,cli,curl,dev,gd,intl,mbstring,mysql,redis,soap,xml,zip} git unzip +sudo mysql +mysql> GRANT ALL PRIVILEGES ON chamilo.* TO chamilo@localhost IDENTIFIED BY '{password}'; +mysql> exit +~~~~ +(replace 'chamilo' by the database name and user you want, and '{password}' by a more secure password) + +On older Ubuntu versions (like 22.04), you have to install PHP through third-party sources: ~~~~ sudo apt update @@ -36,50 +49,63 @@ sudo apt -y upgrade sudo apt -y install ca-certificates curl gnupg software-properties-common sudo add-apt-repository ppa:ondrej/php sudo apt update -sudo apt install apache2 libapache2-mod-php8.1 mariadb-client mariadb-server php-pear php8.1-{dev,gd,curl,intl,mysql,mbstring,zip,xml,cli,apcu,bcmath,soap} git unzip +sudo apt install apache2 libapache2-mod-php8.3 mariadb-client mariadb-server redis php-pear php8.3-{apcu,bcmath,cli,curl,dev,gd,intl,mbstring,mysql,redis,soap,xml,zip} git unzip ~~~~ +(replace 'chamilo' by the database name and user you want, and '{password}' by a more secure password) + +#### NodeJS, Yarn, Composer + If you already have nodejs installed, check the version with `node -v` -Otherwise, install node 18 or above: -* following the instructions here: https://deb.nodesource.com/node_20.x/. - The following lines use a static version of those instructions, so probably not very sustainable over time +Otherwise, install node 18 or above. + +The following lines use a static version from https://deb.nodesource.com/ (not very sustainable over time). ~~~~ cd ~ -curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg -NODE_MAJOR=20 -echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | sudo tee /etc/apt/sources.list.d/nodesource.list -apt update && apt -y install nodejs +curl -fsSL https://deb.nodesource.com/setup_20.x | sudo bash - +sudo apt-get install -y nodejs ~~~~ -* Other option to install nodejs is by using NVM (Node Version Manager). You can install it following the instructions [here](https://github.com/nvm-sh/nvm#installing-and-updating). - Then, you can install the node version required. Preferably, the LTS version. + +Another option to install nodejs is by using NVM (Node Version Manager). You can install it following the instructions [here](https://github.com/nvm-sh/nvm#installing-and-updating). +You can install the desired node version (preferably, the LTS version): ~~~~ sudo nvm install --lts sudo nvm use --lts ~~~~ + With NodeJS installed, you must enable corepack and then continue with the requirements ~~~~ sudo corepack enable cd ~ -# follow the instructions at https://getcomposer.org/download/ +~~~~ + +Follow the instructions at https://getcomposer.org/download/ to get Composer, then: +~~~~ sudo mv composer.phar /usr/local/bin/composer -# optionally, you might want this: +~~~~ + +#### Apache tweaks + +Optionally, you might want this to enable a series of Apache modules, but only ``rewrite` is 100% necessary: +~~~~ sudo apt install libapache2-mod-xsendfile sudo a2enmod rewrite ssl headers expires sudo systemctl restart apache2 ~~~~ When your system is all set, you can use the following: - ~~~~ cd /var/www -git clone https://github.com/chamilo/chamilo-lms.git chamilo2 -cd chamilo2 +git clone https://github.com/chamilo/chamilo-lms.git chamilo +cd chamilo composer install -# not recommended to do this as the root user! -# when asked whether you want to execute the recipes or install plugins for some of the components, -# you can safely type 'n' (for 'no'). +~~~~ + +We do not recommend running composer as the root user! +When asked whether you want to execute the recipes or install plugins for some of the components, you can safely type 'n' (for 'no'). +~~~~ yarn set version stable -# delete yarn.lock as it might contain restrictive packages from a different context +# delete yarn.lock if present, as it might contain restrictive packages from a different context yarn up yarn install yarn dev @@ -89,35 +115,59 @@ sudo chown -R www-data: var/ .env config/ ~~~~ In your web server configuration, ensure you allow for the interpretation of .htaccess (`AllowOverride all` and `Require all granted`), and point the `DocumentRoot` to the `public/` subdirectory. +Finally, make sure your PHP config points at Redis for sessions management. +This should look similar to this very short excerpt (in your Apache vhost block): +~~~~ + + ServerName my.chamilo.net + RewriteEngine On + RedirectMatch 302 (.*) https://my.chamilo.net$1 + + + DocumentRoot /var/www/chamilo/public/ + ServerName my.chamilo.net + # Error logging rules... + # SSL rules... + RewriteEngine On + + AllowOverride All + Require all granted + + + Require all denied + + php_value session.cookie_httponly 1 + php_admin_value session.save_handler "redis" + php_admin_value session.save_path "tcp://127.0.0.1:6379" + +~~~~ +Don't forget to reload your Apache configuration after each change: +~~~~ +sudo systemctl reload apache2 +~~~~ ### Web installer -Once the above is ready, enter the **main/install/index.php** and follow the UI instructions (database, admin user settings, etc). +Once the above is ready, use your browser to load the URL you have defined for your host, e.g. https://my.chamilo.net (this should redirect you to `main/install/index.php`) and follow the UI instructions (database, admin user settings, etc). After the web install process, change the permissions back to a reasonably safe state: ~~~~ chown -R root .env config/ ~~~~ -## Quick update +## Quick update for development/testing purposes If you have already installed it and just want to update it from Git, do: ~~~~ git pull composer install - -# Database update php bin/console doctrine:schema:update --force --complete - -# Clean Symfony cache php bin/console cache:clear - -# js/css update yarn install yarn dev ~~~~ -Note for developers in pre-alpha stage: the doctrine command will try to update +Note for developers in alpha stage: the doctrine command will try to update your database schema to the expected database schema in a fresh installation. This is not always perfect, as Doctrine will take the fastest route to do this. For example, if you have a migration to rename a table (which would apply just @@ -131,63 +181,87 @@ php bin/console doctrine:migrations:execute "Chamilo\CoreBundle\Migrations\Schem ``` This will respect the migration logic and do the required data processing. -This will update the JS (yarn) and PHP (composer) dependencies in the public/build folder. +The commands above will update the JS (yarn) in public/build/ and PHP (composer) dependencies in vendor/. -Sometimes there are conflicts with existing files so to avoid those here are some hints : -- for composer errors you can remove the vendor folder and composer.lock file -- for yarn erros you can remove yarn.lock .yarn/cache/* node_modules/* -- when opening Chamilo, it does not load, then you can delete var/cache/* +Sometimes there are conflicts with existing files so, to avoid those, here are some hints : +- for composer errors, you can remove the vendor folder and composer.lock file +- for yarn errors, you can remove yarn.lock .yarn/cache/* node_modules/* +- when opening Chamilo, if the page does not load, then you might want to delete var/cache/* ### Refresh configuration settings -In case you believe some settings in Chamilo might not have been processed correctly based on an incomplete migration -or a migration that was added after you installed your development version of Chamilo, the URL /admin/settings_sync is -built to try and fix that automatically by updating PHP classes based on the database state. -This issue rarely happens, though. +In case you believe some settings in Chamilo might not have been processed +correctly based on an incomplete migration or a migration that was added +after you installed your development version of Chamilo, the +/admin/settings_sync URL is built to try and fix that automatically by updating +PHP classes based on the database state. This issue rarely happens, though. ## Quick re-install -If you have it installed in a dev environment and feel like you should clean it up completely (might be necessary after changes to the database), you can do so by: +If you have it installed in a dev environment and feel like you should clean it +up completely (might be necessary after changes to the database), you can do so +by: * Removing the `.env` file -* Load the {url}/main/install/index.php script again +* Loading the {url}/main/install/index.php page again -The database should be automatically destroyed, table by table. In some extreme cases (a previous version created a table that is not necessary anymore and creates issues), you might want to clean it completely by just dropping it, but this shouldn't be necessary most of the time. +The database should be automatically destroyed, table by table. In some extreme +cases (a previous version created a table that is not necessary anymore and +creates issues), you might want to clean it completely by just dropping the +database, but this shouldn't be necessary most of the time. -If, for some reason, you have issues with either composer or yarn, a good first step is to delete completely the `vendor/` folder (for composer) or the `node_modules/` folder (for yarn). +If, for some reason, you have issues with either composer or yarn, a good first +step is to delete completely the `vendor/` folder (for composer) or the +`node_modules/` folder (for yarn). ## Development setup (Dev environment, stable environment not yet available) -If you are a developer and want to contribute to Chamilo in the current development branch (not stable yet), -then please follow the instructions below. Please bear in mind that the development version is NOT COMPLETE at this time, -and many features are just not working yet. This is because we are working on root components that require massive changes to the structure of the code, files and database. As such, to get a working version, you might need to completely uninstall and re-install from time to time. You've been warned. +If you are a developer and want to contribute to Chamilo in the current +development branch (not stable yet), then please follow the instructions below. +Please bear in mind that the development version is NOT STABLE at this time, +and many features are just not working yet. This is because we are working on +root components that require massive changes to the structure of the code, +files and database. As such, to get a working version, you might need to +completely uninstall and re-install from time to time. You've been warned. -First, apply the procedure described here: [Managing CSS and JavaScript in Chamilo](assets/README.md) (in particular, make sure you follow the given links to install all the necessary components on your computer). +First, apply the procedure described here: +[Managing CSS and JavaScript in Chamilo](assets/README.md) (in particular, +make sure you follow the given links to install all the necessary components +on your computer). -Then make sure your database supports large prefixes (see [this Stack Overflow thread](https://stackoverflow.com/questions/43379717/how-to-enable-large-index-in-mariadb-10/43403017#43403017) if you use MySQL < 5.7 or MariaDB < 10.2.2). +Then make sure your database supports large prefixes +(see [this Stack Overflow thread](https://stackoverflow.com/questions/43379717/how-to-enable-large-index-in-mariadb-10/43403017#43403017) +if you use MySQL < 5.7 or MariaDB < 10.2.2). -Load the (your-domain)/main/install/index.php URL to start the installer (which is very similar to the installer in previous versions). -If the installer is pure-HTML and doesn't appear with a clean layout, that's because you didn't follow these instructions carefully. +Load the (your-domain)/main/install/index.php URL to start the installer (which +is very similar to the installer in previous versions). + +If the installer is pure-HTML and doesn't appear with a clean layout, that's +probably because you didn't follow these instructions carefully. Go back to the beginning of this section and try again. -If you want hot reloading for assets use the command `yarn run encore dev-server`. This will refresh automatically -your assets when you modify them under `assets/vue`. Access your chamilo instance as usual. In the background, this will serve -assets from a custom server on http://localhost:8080. Do not access this url directly since -[Encore](https://symfony.com/doc/current/frontend.html#webpack-encore) is in charge of changing url assets as needed. +If you want hot reloading for assets use the command `yarn run encore dev-server`. +This will refresh automatically your assets when you modify them under +`assets/vue`. Access your chamilo instance as usual. In the background, this +will serve assets from a custom server on http://localhost:8080. Do not access +this url directly since [Encore](https://symfony.com/doc/current/frontend.html#webpack-encore) +is in charge of changing url assets as needed. -### Supporting PHP 7.4 and 8.1 in parallel +### Supporting PHP 7.4 and 8.3 in parallel -You might want to support PHP 8.1 (for Chamilo 2) and PHP 7.4 (for all other things) on the same server simultaneously. On Ubuntu, you could do it this way: +You might want to support PHP 8.3 (for Chamilo 2) and PHP 7.4 (for all other +things) on the same server simultaneously. On Ubuntu, you could do it this way: ``` sudo add-apt-repository ppa:ondrej/php sudo apt update -sudo apt install php8.1 libapache2-mod-php7.4 php8.1-{modules} php7.4-{modules} -sudo apt remove libapache2-mod-php8.1 php7.4-fpm +sudo apt install php8.3 libapache2-mod-php7.4 php8.3-{modules} php7.4-{modules} +sudo apt remove libapache2-mod-php8.3 php7.4-fpm sudo a2enmod proxy_fcgi sudo vim /etc/apache2/sites-available/[your-chamilo2-vhost].conf ``` -In the vhost configuration, make sure you set PHP 8.1 FPM to answer this single vhost by adding, somewhere between your `` tags, the following: +In the vhost configuration, make sure you set PHP 8.3 FPM to answer this single +vhost by adding, somewhere between your `` tags, the following: ``` @@ -195,7 +269,7 @@ In the vhost configuration, make sure you set PHP 8.1 FPM to answer this single SetEnvIfNoCase ^Authorization$ "(.+)" HTTP_AUTHORIZATION=$1 - SetHandler "proxy:unix:/run/php/php8.1-fpm.sock|fcgi://localhost" + SetHandler "proxy:unix:/run/php/php8.3-fpm.sock|fcgi://localhost" Require all denied @@ -212,19 +286,29 @@ Then exit and restart Apache: sudo systemctl restart apache2 ``` -Finally, remember that PHP settings will have to be changed in /etc/php/8.1/fpm/php.ini and you will have to reload php8.1-fpm to take those config changes into account. +Finally, remember that PHP settings will have to be changed in +`/etc/php/8.3/fpm/php.ini` and you will have to reload `php8.3-fpm` to take +those config changes into account. ``` -sudo systemctl reload php8.1-fpm +sudo systemctl reload php8.3-fpm ``` -When using 2 versions, you will also have issues when calling `composer update`, as this one needs to be called by the relevant PHP version. +When using 2 versions, you will also have issues when calling +`composer update`, as this one needs to be called by the relevant PHP version. This can be done like so: ``` -/usr/bin/php8.1 /usr/local/bin/composer update +/usr/bin/php8.3 /usr/local/bin/composer update or, for Chamilo 1.11 /usr/bin/php7.4 /usr/local/bin/composer update ``` -If your default php-cli uses PHP7.4 (see `ln -s /etc/alternatives/php`), you might have issues running with a so-called `platform_check.php` script when running `composer update` anyway. This is because this script doesn't user the proper launch context, and you might need to change your default settings on Ubuntu (i.e. change the link /etc/alternatives/php to point to the other php version) before launching `composer update`. You can always revert that operation later on if you need to go back to work on Chamilo 1.11 and Composer complains again. +If your default php-cli uses PHP7.4 (see `ln -s /etc/alternatives/php`), +you might have issues running with a so-called `platform_check.php` script +when running `composer update` anyway. This is because this script doesn't +user the proper launch context, and you might need to change your default +settings on Ubuntu (i.e. change the link /etc/alternatives/php to point to +the other php version) before launching `composer update`. You can always +revert that operation later on if you need to go back to work on Chamilo 1.11 +and Composer complains again. ### git hooks @@ -236,26 +320,31 @@ following commands can be used. ## Changes from 1.x * in general, the main/ folder has been moved to public/main/ +* a big part of the frontend has been migrated to VueJS + Tailwind CSS * app/Resources/public/assets moved to public/assets * main/inc/lib/javascript moved to public/js * main/img/ moved to public/img * main/template/default moved to src/CoreBundle/Resources/views * src/Chamilo/XXXBundle moved to src/CoreBundle or src/CourseBundle * bin/doctrine.php removed use bin/console doctrine:xyz options -* Plugin images, css and js libs are loaded inside the public/plugins folder +* Plugin images, CSS and JS libs are loaded inside the public/plugins folder (composer update copies the content inside plugin_name/public inside web/plugins/plugin_name -* Plugins templates use asset() function instead of using "_p.web_plugin" -* Remove main/inc/local.inc.php -* Translations managed through Gettext +* Plugins templates use the ``asset()` function instead of using "_p.web_plugin" +* `main/inc/local.inc.php` has been removed +* Translations are managed through Gettext Libraries -* Integration with Symfony 5 +* Integration with Symfony 6 * PHPMailer replaced with Symfony Mailer -* bower replaced by [yarn](https://yarnpkg.com) +* Bower replaced by [yarn](https://yarnpkg.com) ## JWT Authentication +This version of Chamilo allows you to use a JWT (token) to use the Chamilo API +more securely. In order to use it, you will have to generate a JWT token as +follows. + * Run ```shell php bin/console lexik:jwt:generate-keypair @@ -290,8 +379,8 @@ If you want to submit new features or patches to Chamilo 2, please follow the Github contribution guide https://guides.github.com/activities/contributing-to-open-source/ and our [CONTRIBUTING.md](CONTRIBUTING.md) file. In short, we ask you to send us Pull Requests based on a branch that you create -with this purpose into your repository forked from the original Chamilo repository. +with this purpose into your repository, forked from the original Chamilo repository (`master` branch). ## Documentation -For more information on Chamilo, visit https://campus.chamilo.org/documentation/index.html +For more information on Chamilo, visit https://2.chamilo.org/documentation/index.html diff --git a/assets/vue/composables/language.js b/assets/vue/composables/language.js new file mode 100644 index 00000000000..b098553de45 --- /dev/null +++ b/assets/vue/composables/language.js @@ -0,0 +1,22 @@ +export function useLanguage() { + const defaultLanguage = { originalName: "English", isocode: "en" } + + /** + * @type {{originalName: string, isocode: string}[]} + */ + const languageList = window.languages || [defaultLanguage] + + /** + * @param {string} isoCode + * @returns {{originalName: string, isocode: string}|undefined} + */ + function findByIsoCode(isoCode) { + return languageList.find((language) => isoCode === language.isocode) + } + + return { + defaultLanguage, + languageList, + findByIsoCode, + } +} diff --git a/assets/vue/router/account.js b/assets/vue/router/account.js index 42a8d52147f..9dfd192095f 100644 --- a/assets/vue/router/account.js +++ b/assets/vue/router/account.js @@ -1,14 +1,14 @@ export default { - path: '/account', + path: "/account", meta: { requiresAuth: true }, - name: 'account', - component: () => import('../components/course/Layout.vue'), + name: "account", + component: () => import("../components/course/Layout.vue"), children: [ { - name: 'AccountHome', - path: 'home', - component: () => import('../views/account/Home.vue'), - meta: {requiresAuth: true}, + name: "AccountHome", + path: "home", + component: () => import("../views/account/Home.vue"), + meta: { requiresAuth: true }, }, - ] -}; + ], +} diff --git a/assets/vue/router/admin.js b/assets/vue/router/admin.js index eaaf86b4945..78d42192b15 100644 --- a/assets/vue/router/admin.js +++ b/assets/vue/router/admin.js @@ -1,21 +1,20 @@ export default { - path: '/admin', - name: 'admin', - meta: { requiresAuth: true, showBreadcrumb: true }, - component: () => import('../components/admin/AdminLayout.vue'), - children: [ - { - path: '', - name: 'AdminIndex', - meta: { requiresAdmin: true, requiresSessionAdmin: true, showBreadcrumb: false }, - component: () => import('../views/admin/AdminIndex.vue'), - }, - { - name: 'AdminConfigurationColors', - path: 'configuration/colors', - meta: { requiresAdmin: true, requiresSessionAdmin: true, showBreadcrumb: true }, - component: () => import('../views/admin/AdminConfigureColors.vue'), - } - ], -}; - + path: "/admin", + name: "admin", + meta: { requiresAuth: true, showBreadcrumb: true }, + component: () => import("../components/admin/AdminLayout.vue"), + children: [ + { + path: "", + name: "AdminIndex", + meta: { requiresAdmin: true, requiresSessionAdmin: true, showBreadcrumb: false }, + component: () => import("../views/admin/AdminIndex.vue"), + }, + { + name: "AdminConfigurationColors", + path: "configuration/colors", + meta: { requiresAdmin: true, requiresSessionAdmin: true, showBreadcrumb: true }, + component: () => import("../views/admin/AdminConfigureColors.vue"), + }, + ], +} diff --git a/assets/vue/router/assignments.js b/assets/vue/router/assignments.js index fc1fc9ca426..19243c17cac 100644 --- a/assets/vue/router/assignments.js +++ b/assets/vue/router/assignments.js @@ -25,4 +25,4 @@ export default { props: true, }, ], -}; +} diff --git a/assets/vue/router/cataloguecourses.js b/assets/vue/router/cataloguecourses.js index 663c9af4ce1..29c97e03bc3 100644 --- a/assets/vue/router/cataloguecourses.js +++ b/assets/vue/router/cataloguecourses.js @@ -1,6 +1,6 @@ export default { - path: '/catalogue/courses', - name: 'CatalogueCourses', - meta: { requiresAdmin: true, requiresSessionAdmin: true }, - component: () => import('../views/course/CatalogueCourses.vue') -}; + path: "/catalogue/courses", + name: "CatalogueCourses", + meta: { requiresAdmin: true, requiresSessionAdmin: true }, + component: () => import("../views/course/CatalogueCourses.vue"), +} diff --git a/assets/vue/router/cataloguesessions.js b/assets/vue/router/cataloguesessions.js index 3f9fd1a86a4..336307b201f 100644 --- a/assets/vue/router/cataloguesessions.js +++ b/assets/vue/router/cataloguesessions.js @@ -1,6 +1,6 @@ export default { - path: '/catalogue/sessions', - name: 'CatalogueSessions', - meta: { requiresAdmin: true, requiresSessionAdmin: true }, - component: () => import('../views/course/CatalogueSessions.vue') -}; + path: "/catalogue/sessions", + name: "CatalogueSessions", + meta: { requiresAdmin: true, requiresSessionAdmin: true }, + component: () => import("../views/course/CatalogueSessions.vue"), +} diff --git a/assets/vue/router/ccalendarevent.js b/assets/vue/router/ccalendarevent.js index 81446f34904..888ab20a66b 100644 --- a/assets/vue/router/ccalendarevent.js +++ b/assets/vue/router/ccalendarevent.js @@ -1,25 +1,25 @@ export default { - path: '/resources/ccalendarevent', + path: "/resources/ccalendarevent", meta: { requiresAuth: true }, - name: 'ccalendarevent', - redirect: { name: 'CCalendarEventList' }, - component: () => import('../components/ccalendarevent/CCalendarEventLayout.vue'), + name: "ccalendarevent", + redirect: { name: "CCalendarEventList" }, + component: () => import("../components/ccalendarevent/CCalendarEventLayout.vue"), children: [ { - name: 'CCalendarEventShow', - path: 'show', - component: () => import('../views/ccalendarevent/CCalendarEventShow.vue') + name: "CCalendarEventShow", + path: "show", + component: () => import("../views/ccalendarevent/CCalendarEventShow.vue"), }, { - name: 'CCalendarEventCreate', - path: 'new', - component: () => import('../views/ccalendarevent/CCalendarEventCreate.vue') + name: "CCalendarEventCreate", + path: "new", + component: () => import("../views/ccalendarevent/CCalendarEventCreate.vue"), }, { - name: 'CCalendarEventList', - path: '', - component: () => import('../views/ccalendarevent/CCalendarEventList.vue'), - props: (route) => ({ type: route.query.type }) - } - ] -}; + name: "CCalendarEventList", + path: "", + component: () => import("../views/ccalendarevent/CCalendarEventList.vue"), + props: (route) => ({ type: route.query.type }), + }, + ], +} diff --git a/assets/vue/router/course.js b/assets/vue/router/course.js index b11a138d060..da0e5ee7461 100644 --- a/assets/vue/router/course.js +++ b/assets/vue/router/course.js @@ -1,29 +1,29 @@ export default { - path: '/resources/courses', + path: "/resources/courses", meta: { requiresAuth: true }, - name: 'courses', - component: () => import('../components/course/Layout.vue'), - redirect: { name: 'CourseList' }, + name: "courses", + component: () => import("../components/course/Layout.vue"), + redirect: { name: "CourseList" }, children: [ { - name: 'CourseList', - path: '', - component: () => import('../views/course/List.vue') + name: "CourseList", + path: "", + component: () => import("../views/course/List.vue"), }, { - name: 'CourseCreate', - path: 'new', - component: () => import('../views/course/Create.vue') + name: "CourseCreate", + path: "new", + component: () => import("../views/course/Create.vue"), }, { - name: 'CourseUpdate', - path: ':id/edit', - component: () => import('../views/course/Update.vue') + name: "CourseUpdate", + path: ":id/edit", + component: () => import("../views/course/Update.vue"), }, { - name: 'CourseShow', - path: ':id', - component: () => import('../views/course/Show.vue') - } - ] -}; + name: "CourseShow", + path: ":id", + component: () => import("../views/course/Show.vue"), + }, + ], +} diff --git a/assets/vue/router/coursecategory.js b/assets/vue/router/coursecategory.js index e76ba8da9b7..ef2ab0758c1 100644 --- a/assets/vue/router/coursecategory.js +++ b/assets/vue/router/coursecategory.js @@ -1,29 +1,29 @@ export default { - path: '/resources/course_categories', + path: "/resources/course_categories", meta: { requiresAuth: true }, - name: 'course_categories', - component: () => import('../components/coursecategory/Layout.vue'), - redirect: { name: 'CourseCategoryList' }, + name: "course_categories", + component: () => import("../components/coursecategory/Layout.vue"), + redirect: { name: "CourseCategoryList" }, children: [ { - name: 'CourseCategoryList', - path: '', - component: () => import('../views/coursecategory/List.vue') + name: "CourseCategoryList", + path: "", + component: () => import("../views/coursecategory/List.vue"), }, { - name: 'CourseCategoryCreate', - path: 'new', - component: () => import('../views/coursecategory/Create.vue') + name: "CourseCategoryCreate", + path: "new", + component: () => import("../views/coursecategory/Create.vue"), }, { - name: 'CourseCategoryUpdate', - path: ':id/edit', - component: () => import('../views/coursecategory/Update.vue') + name: "CourseCategoryUpdate", + path: ":id/edit", + component: () => import("../views/coursecategory/Update.vue"), }, { - name: 'CourseCategoryShow', - path: ':id', - component: () => import('../views/coursecategory/Show.vue') - } - ] -}; + name: "CourseCategoryShow", + path: ":id", + component: () => import("../views/coursecategory/Show.vue"), + }, + ], +} diff --git a/assets/vue/router/ctoolintro.js b/assets/vue/router/ctoolintro.js index 929b591ec0d..9bba071e97c 100644 --- a/assets/vue/router/ctoolintro.js +++ b/assets/vue/router/ctoolintro.js @@ -1,25 +1,25 @@ export default { - path: '/resources/ctoolintro/', + path: "/resources/ctoolintro/", meta: { requiresAuth: true, showBreadcrumb: true }, - name: 'ctoolintro', - component: () => import('../components/ctoolintro/Layout.vue'), - redirect: { name: 'ToolIntroList' }, + name: "ctoolintro", + component: () => import("../components/ctoolintro/Layout.vue"), + redirect: { name: "ToolIntroList" }, children: [ { - name: 'ToolIntroCreate', - path: 'new/:courseTool', - component: () => import('../views/ctoolintro/Create.vue') + name: "ToolIntroCreate", + path: "new/:courseTool", + component: () => import("../views/ctoolintro/Create.vue"), }, { - name: 'ToolIntroUpdate', + name: "ToolIntroUpdate", //path: ':id/edit', - path: 'edit', - component: () => import('../views/ctoolintro/Update.vue') + path: "edit", + component: () => import("../views/ctoolintro/Update.vue"), }, { - name: 'ToolIntroShow', - path: '', - component: () => import('../views/ctoolintro/Show.vue') - } - ] -}; + name: "ToolIntroShow", + path: "", + component: () => import("../views/ctoolintro/Show.vue"), + }, + ], +} diff --git a/assets/vue/router/documents.js b/assets/vue/router/documents.js index 0306507e0b0..c8d5b5c2dd4 100644 --- a/assets/vue/router/documents.js +++ b/assets/vue/router/documents.js @@ -1,55 +1,55 @@ export default { - path: '/resources/document/:node/', + path: "/resources/document/:node/", meta: { requiresAuth: true, showBreadcrumb: true }, - name: 'documents', - component: () => import('../components/layout/SimpleRouterViewLayout.vue'), - redirect: { name: 'DocumentsList' }, + name: "documents", + component: () => import("../components/layout/SimpleRouterViewLayout.vue"), + redirect: { name: "DocumentsList" }, children: [ { - name: 'DocumentsList', - path: '', - component: () => import('../views/documents/DocumentsList.vue') + name: "DocumentsList", + path: "", + component: () => import("../views/documents/DocumentsList.vue"), }, { - name: 'DocumentsCreate', - path: 'new', - component: () => import('../views/documents/Create.vue') + name: "DocumentsCreate", + path: "new", + component: () => import("../views/documents/Create.vue"), }, { - name: 'DocumentsCreateFile', - path: 'create', - component: () => import('../views/documents/CreateFile.vue') + name: "DocumentsCreateFile", + path: "create", + component: () => import("../views/documents/CreateFile.vue"), }, { - name: 'DocumentsUploadFile', - path: 'upload', - component: () => import('../views/documents/DocumentsUpload.vue') + name: "DocumentsUploadFile", + path: "upload", + component: () => import("../views/documents/DocumentsUpload.vue"), }, { - name: 'DocumentsUpdate', + name: "DocumentsUpdate", //path: ':id/edit', - path: 'edit', - component: () => import('../views/documents/Update.vue') + path: "edit", + component: () => import("../views/documents/Update.vue"), }, { - name: 'DocumentsUpdateFile', + name: "DocumentsUpdateFile", //path: ':id/edit', - path: 'edit_file', - component: () => import('../views/documents/UpdateFile.vue') + path: "edit_file", + component: () => import("../views/documents/UpdateFile.vue"), }, { - name: 'DocumentsShow', - path: 'show', - component: () => import('../views/documents/DocumentShow.vue') + name: "DocumentsShow", + path: "show", + component: () => import("../views/documents/DocumentShow.vue"), }, { - name: 'DocumentForHtmlEditor', - path: 'manager', - component: () => import('../views/documents/DocumentForHtmlEditor.vue'), + name: "DocumentForHtmlEditor", + path: "manager", + component: () => import("../views/documents/DocumentForHtmlEditor.vue"), meta: { - layout: 'Empty', - showBreadcrumb: false - } + layout: "Empty", + showBreadcrumb: false, + }, }, - ] -}; + ], +} diff --git a/assets/vue/router/filemanager.js b/assets/vue/router/filemanager.js index 0748fe3b92e..ded0c5e8da0 100644 --- a/assets/vue/router/filemanager.js +++ b/assets/vue/router/filemanager.js @@ -1,25 +1,25 @@ export default { - path: '/resources/filemanager', + path: "/resources/filemanager", meta: { requiresAuth: true }, - component: () => import('../components/filemanager/Layout.vue'), + component: () => import("../components/filemanager/Layout.vue"), children: [ { - path: 'personal_list/:node?', - name: 'FileManagerList', - component: () => import('../views/filemanager/List.vue'), + path: "personal_list/:node?", + name: "FileManagerList", + component: () => import("../views/filemanager/List.vue"), meta: { emptyLayout: true }, }, { - name: 'FileManagerUploadFile', - path: 'upload', - component: () => import('../views/filemanager/Upload.vue'), + name: "FileManagerUploadFile", + path: "upload", + component: () => import("../views/filemanager/Upload.vue"), meta: { emptyLayout: true }, }, { - name: 'CourseDocumentsUploadFile', - path: '/course-upload', + name: "CourseDocumentsUploadFile", + path: "/course-upload", meta: { emptyLayout: true }, - component: () => import('../views/documents/DocumentsUpload.vue') + component: () => import("../views/documents/DocumentsUpload.vue"), }, ], -}; +} diff --git a/assets/vue/router/index.js b/assets/vue/router/index.js index caf74d496e9..86e51c5cae0 100644 --- a/assets/vue/router/index.js +++ b/assets/vue/router/index.js @@ -46,7 +46,6 @@ import catalogueSessions from "./cataloguesessions" import { customVueTemplateEnabled } from "../config/env" import { useCourseSettings } from "../store/courseSettingStore" import { checkIsAllowedToEdit, useUserSessionSubscription } from "../composables/userPermissions" -import { usePlatformConfig } from "../store/platformConfig" const router = createRouter({ history: createWebHistory(), @@ -140,25 +139,20 @@ const router = createRouter({ } // Exercise auto-launch - const platformConfigStore = usePlatformConfig() - const isExerciseAutoLaunchEnabled = - "true" === platformConfigStore.getSetting("exercise.allow_exercise_auto_launch") - if (isExerciseAutoLaunchEnabled) { - const exerciseAutoLaunch = parseInt(courseSettingsStore.getSetting("enable_exercise_auto_launch"), 10) || 0 - if (exerciseAutoLaunch === 2) { + const exerciseAutoLaunch = parseInt(courseSettingsStore.getSetting("enable_exercise_auto_launch"), 10) || 0 + if (exerciseAutoLaunch === 2) { + sessionStorage.setItem(autoLaunchKey, "true") + window.location.href = + `/main/exercise/exercise.php?cid=${courseId}` + (sessionId ? `&sid=${sessionId}` : "") + return false + } else if (exerciseAutoLaunch === 1) { + const exerciseId = await courseService.getAutoLaunchExerciseId(courseId, sessionId) + if (exerciseId) { sessionStorage.setItem(autoLaunchKey, "true") window.location.href = - `/main/exercise/exercise.php?cid=${courseId}` + (sessionId ? `&sid=${sessionId}` : "") + `/main/exercise/overview.php?exerciseId=${exerciseId}&cid=${courseId}` + + (sessionId ? `&sid=${sessionId}` : "") return false - } else if (exerciseAutoLaunch === 1) { - const exerciseId = await courseService.getAutoLaunchExerciseId(courseId, sessionId) - if (exerciseId) { - sessionStorage.setItem(autoLaunchKey, "true") - window.location.href = - `/main/exercise/overview.php?exerciseId=${exerciseId}&cid=${courseId}` + - (sessionId ? `&sid=${sessionId}` : "") - return false - } } } diff --git a/assets/vue/router/message.js b/assets/vue/router/message.js index d8e90f3b4aa..6b991037b64 100644 --- a/assets/vue/router/message.js +++ b/assets/vue/router/message.js @@ -1,24 +1,24 @@ export default { - path: '/resources/messages', + path: "/resources/messages", meta: { requiresAuth: true }, - name: 'messages', - component: () => import('../components/message/MessageLayout.vue'), - redirect: { name: 'MessageList' }, + name: "messages", + component: () => import("../components/message/MessageLayout.vue"), + redirect: { name: "MessageList" }, children: [ { - name: 'MessageList', - path: '', - component: () => import('../views/message/MessageList.vue') + name: "MessageList", + path: "", + component: () => import("../views/message/MessageList.vue"), }, { - name: 'MessageCreate', - path: 'new', - component: () => import('../views/message/MessageCreate.vue') + name: "MessageCreate", + path: "new", + component: () => import("../views/message/MessageCreate.vue"), }, { - name: 'MessageReply', - path: 'reply', - component: () => import('../views/message/MessageReply.vue') + name: "MessageReply", + path: "reply", + component: () => import("../views/message/MessageReply.vue"), }, /*{ name: 'MessageUpdate', @@ -26,10 +26,10 @@ export default { component: () => import('../views/message/Update.vue') },*/ { - name: 'MessageShow', + name: "MessageShow", //path: ':id', - path: 'show', - component: () => import('../views/message/MessageShow.vue') - } - ] -}; + path: "show", + component: () => import("../views/message/MessageShow.vue"), + }, + ], +} diff --git a/assets/vue/router/page.js b/assets/vue/router/page.js index b2347d307cf..b634a297324 100644 --- a/assets/vue/router/page.js +++ b/assets/vue/router/page.js @@ -1,36 +1,36 @@ export default { - path: '/resources/pages', + path: "/resources/pages", meta: { requiresAuth: true }, - name: 'pages', - component: () => import('../components/page/Layout.vue'), - redirect: { name: 'PageList' }, + name: "pages", + component: () => import("../components/page/Layout.vue"), + redirect: { name: "PageList" }, children: [ { - name: 'PageList', - path: '', - component: () => import('../views/page/List.vue') + name: "PageList", + path: "", + component: () => import("../views/page/List.vue"), }, { - name: 'PageCreate', - path: 'new', - component: () => import('../views/page/Create.vue') + name: "PageCreate", + path: "new", + component: () => import("../views/page/Create.vue"), }, { - name: 'PageUpdate', + name: "PageUpdate", //path: ':id/edit', - path: 'edit', - component: () => import('../views/page/Update.vue') + path: "edit", + component: () => import("../views/page/Update.vue"), }, { - name: 'PageShow', + name: "PageShow", //path: ':id', - path: 'show', - component: () => import('../views/page/Show.vue') + path: "show", + component: () => import("../views/page/Show.vue"), }, { - name: 'PageEditorDemo', - path: 'editor-demo', - component: () => import('../views/page/EditorDemo.vue') + name: "PageEditorDemo", + path: "editor-demo", + component: () => import("../views/page/EditorDemo.vue"), }, - ] -}; + ], +} diff --git a/assets/vue/router/personalfile.js b/assets/vue/router/personalfile.js index 74b471ee286..3b42472fd1e 100644 --- a/assets/vue/router/personalfile.js +++ b/assets/vue/router/personalfile.js @@ -1,42 +1,42 @@ export default { - path: '/resources/personal_files', + path: "/resources/personal_files", meta: { requiresAuth: true }, - name: 'personal_files', - component: () => import('../views/personalfile/Home.vue'), + name: "personal_files", + component: () => import("../views/personalfile/Home.vue"), children: [ { - name: 'personal_files', - path: ':node/', - component: () => import('../components/personalfile/Layout.vue'), - redirect: { name: 'PersonalFileList' }, + name: "personal_files", + path: ":node/", + component: () => import("../components/personalfile/Layout.vue"), + redirect: { name: "PersonalFileList" }, children: [ { - name: 'PersonalFileList', - path: '', - component: () => import('../views/personalfile/List.vue') + name: "PersonalFileList", + path: "", + component: () => import("../views/personalfile/List.vue"), }, { - name: 'PersonalFileUploadFile', - path: 'upload', - component: () => import('../views/personalfile/Upload.vue') + name: "PersonalFileUploadFile", + path: "upload", + component: () => import("../views/personalfile/Upload.vue"), }, { - name: 'PersonalFileShared', - path: 'shared', - component: () => import('../views/personalfile/Shared.vue') + name: "PersonalFileShared", + path: "shared", + component: () => import("../views/personalfile/Shared.vue"), }, { - name: 'PersonalFileUpdate', + name: "PersonalFileUpdate", //path: ':id/edit', - path: 'edit_file', - component: () => import('../views/personalfile/Update.vue') + path: "edit_file", + component: () => import("../views/personalfile/Update.vue"), }, { - name: 'PersonalFileShow', - path: 'show', - component: () => import('../views/personalfile/Show.vue') - } - ] + name: "PersonalFileShow", + path: "show", + component: () => import("../views/personalfile/Show.vue"), + }, + ], }, - ] -}; + ], +} diff --git a/assets/vue/router/social.js b/assets/vue/router/social.js index 4e2c6a1d495..94e61081d01 100644 --- a/assets/vue/router/social.js +++ b/assets/vue/router/social.js @@ -1,18 +1,18 @@ export default { - path: '/social', + path: "/social", meta: { requiresAuth: true }, - name: 'Social', - component: () => import('../views/social/SocialLayout.vue'), + name: "Social", + component: () => import("../views/social/SocialLayout.vue"), children: [ { - name: 'SocialWall', - path: ':filterType?', - component: () => import('../views/social/SocialWall.vue') + name: "SocialWall", + path: ":filterType?", + component: () => import("../views/social/SocialWall.vue"), }, { - name: 'SocialSearch', - path: 'search', - component: () => import('../views/social/SocialSearch.vue') - } - ] + name: "SocialSearch", + path: "search", + component: () => import("../views/social/SocialSearch.vue"), + }, + ], } diff --git a/assets/vue/router/terms.js b/assets/vue/router/terms.js index 8469649549c..fdef744f555 100644 --- a/assets/vue/router/terms.js +++ b/assets/vue/router/terms.js @@ -1,23 +1,23 @@ export default { - path: '/resources/terms-conditions', + path: "/resources/terms-conditions", meta: { requiresAuth: true }, - name: 'TermsConditions', - component: () => import('../views/terms/TermsLayout.vue'), + name: "TermsConditions", + component: () => import("../views/terms/TermsLayout.vue"), children: [ { - name: 'TermsConditionsList', - path: '', - component: () => import('../views/terms/TermsList.vue') + name: "TermsConditionsList", + path: "", + component: () => import("../views/terms/TermsList.vue"), }, { - name: 'TermsConditionsEdit', - path: 'edit', - component: () => import('../views/terms/TermsEdit.vue') + name: "TermsConditionsEdit", + path: "edit", + component: () => import("../views/terms/TermsEdit.vue"), }, { - name: 'TermsConditionsView', - path: 'view', - component: () => import('../views/terms/Terms.vue') - } - ] + name: "TermsConditionsView", + path: "view", + component: () => import("../views/terms/Terms.vue"), + }, + ], } diff --git a/assets/vue/router/user.js b/assets/vue/router/user.js index 40d8cca352a..ac67352669d 100644 --- a/assets/vue/router/user.js +++ b/assets/vue/router/user.js @@ -1,19 +1,19 @@ export default { - path: '/resources/users', + path: "/resources/users", meta: { requiresAuth: true }, - name: 'users', - component: () => import('../components/user/Layout.vue'), + name: "users", + component: () => import("../components/user/Layout.vue"), children: [ { - name: 'UserGroupShow', + name: "UserGroupShow", //path: ':id', - path: 'show', - component: () => import('../views/usergroup/Show.vue') + path: "show", + component: () => import("../views/usergroup/Show.vue"), }, { - name: 'PersonalData', - path: 'personal_data', - component: () => import('../views/user/PersonalData.vue') + name: "PersonalData", + path: "personal_data", + component: () => import("../views/user/PersonalData.vue"), }, - ] -}; + ], +} diff --git a/assets/vue/router/usergroup.js b/assets/vue/router/usergroup.js index 4e7cac812c9..044b5bc5164 100644 --- a/assets/vue/router/usergroup.js +++ b/assets/vue/router/usergroup.js @@ -1,37 +1,37 @@ export default { - path: '/resources/usergroups', + path: "/resources/usergroups", meta: { requiresAuth: true }, - name: 'usergroups', - component: () => import('../components/usergroup/Layout.vue'), - redirect: { name: 'UserGroupList' }, + name: "usergroups", + component: () => import("../components/usergroup/Layout.vue"), + redirect: { name: "UserGroupList" }, children: [ { - name: 'UserGroupList', - path: '', - component: () => import('../views/usergroup/List.vue') + name: "UserGroupList", + path: "", + component: () => import("../views/usergroup/List.vue"), }, { - name: 'UserGroupShow', - path: 'show/:group_id?', - component: () => import('../views/usergroup/Show.vue'), - props: true + name: "UserGroupShow", + path: "show/:group_id?", + component: () => import("../views/usergroup/Show.vue"), + props: true, }, { - name: 'UserGroupSearch', - path: 'search', - component: () => import('../views/usergroup/Search.vue'), + name: "UserGroupSearch", + path: "search", + component: () => import("../views/usergroup/Search.vue"), }, { - name: 'UserGroupInvite', - path: 'invite/:group_id?', - component: () => import('../views/usergroup/Invite.vue'), - props: true + name: "UserGroupInvite", + path: "invite/:group_id?", + component: () => import("../views/usergroup/Invite.vue"), + props: true, }, { - name: 'UserGroupDiscussions', - path: 'show/:group_id/discussions/:discussion_id', - component: () => import('../components/usergroup/GroupDiscussionTopics.vue'), - props: true - } - ] -}; + name: "UserGroupDiscussions", + path: "show/:group_id/discussions/:discussion_id", + component: () => import("../components/usergroup/GroupDiscussionTopics.vue"), + props: true, + }, + ], +} diff --git a/assets/vue/router/userreluser.js b/assets/vue/router/userreluser.js index 63f9c5c2854..882822f4347 100644 --- a/assets/vue/router/userreluser.js +++ b/assets/vue/router/userreluser.js @@ -1,29 +1,29 @@ export default { - path: '/resources/friends', + path: "/resources/friends", meta: { requiresAuth: true }, - name: 'friends', - component: () => import('../components/userreluser/Layout.vue'), - redirect: { name: 'UserGroupList' }, + name: "friends", + component: () => import("../components/userreluser/Layout.vue"), + redirect: { name: "UserGroupList" }, children: [ { - name: 'UserRelUserList', - path: '', - component: () => import('../views/userreluser/UserRelUserList.vue') + name: "UserRelUserList", + path: "", + component: () => import("../views/userreluser/UserRelUserList.vue"), }, { - name: 'UserRelUserAdd', - path: 'add', - component: () => import('../views/userreluser/UserRelUserAdd.vue') + name: "UserRelUserAdd", + path: "add", + component: () => import("../views/userreluser/UserRelUserAdd.vue"), }, { - name: 'UserRelUserSearch', - path: 'search', - component: () => import('../views/userreluser/UserRelUserSearch.vue') + name: "UserRelUserSearch", + path: "search", + component: () => import("../views/userreluser/UserRelUserSearch.vue"), }, { - name: 'Invitations', - path: 'invitations', - component: () => import('../views/userreluser/Invitations.vue') - } - ] -}; + name: "Invitations", + path: "invitations", + component: () => import("../views/userreluser/Invitations.vue"), + }, + ], +} diff --git a/assets/vue/services/courseService.js b/assets/vue/services/courseService.js index 1f6e10737bc..6f49cb3a54c 100644 --- a/assets/vue/services/courseService.js +++ b/assets/vue/services/courseService.js @@ -4,6 +4,12 @@ import baseService from "./baseService" export default { find: baseService.get, + /** + * @param {Object} searchParams + * @returns {Promise<{totalItems, items}>} + */ + listAll: async (searchParams = {}) => await baseService.getCollection("/api/courses", searchParams), + /** * @param {number} cid * @param {object} params diff --git a/assets/vue/services/trackCourseRankingService.js b/assets/vue/services/trackCourseRankingService.js new file mode 100644 index 00000000000..3561bf62879 --- /dev/null +++ b/assets/vue/services/trackCourseRankingService.js @@ -0,0 +1,26 @@ +import baseService from "./baseService" + +/** + * @param {string} courseIri + * @param {number} urlId + * @param {number} sessionId + * @param {number} totalScore + * @returns {Promise} + */ +export async function saveRanking({ courseIri, urlId, sessionId, totalScore }) { + return await baseService.post("/api/track_course_rankings", { + totalScore, + course: courseIri, + urlId, + sessionId, + }) +} + +/** + * @param {string} iri + * @param {number} totalScore + * @returns {Promise} + */ +export async function updateRanking({ iri, totalScore }) { + return await baseService.put(iri, { totalScore }) +} diff --git a/assets/vue/views/course/CatalogueCourses.vue b/assets/vue/views/course/CatalogueCourses.vue index 6d609e90a4c..1d03746d6e8 100644 --- a/assets/vue/views/course/CatalogueCourses.vue +++ b/assets/vue/views/course/CatalogueCourses.vue @@ -175,15 +175,15 @@ v-else-if="data.visibility === 2 && !isUserInCourse(data)" :label="$t('Not subscribed')" class="btn btn--primary text-white" - icon="pi pi-times" disabled + icon="pi pi-times" /> @@ -195,8 +195,6 @@ '; $actions = Display::toolbarAction('toolbar', [$actions1, $actions3.$actions4.$actions2]); - if (isset($_GET['session_id']) && !empty($_GET['session_id'])) { - // Create a sortable table with the course data filtered by session - $table = new SortableTable( - 'courses', - 'get_number_of_courses', - 'get_course_data_by_session', - 2 - ); - } else { - // Create a sortable table with the course data - $table = new SortableTable( - 'courses', - 'get_number_of_courses', - 'get_course_data', - 2, - 20, - 'ASC', - 'course-list' - ); - } + // Create a sortable table with the course data + $table = new SortableTable( + 'courses', + 'get_number_of_courses', + 'get_course_data', + 2, + 20, + 'ASC', + 'course-list' + ); $parameters = []; if (isset($_GET['keyword'])) { diff --git a/public/main/exercise/exercise.class.php b/public/main/exercise/exercise.class.php index 6cb3a544f6b..020e4ebb755 100644 --- a/public/main/exercise/exercise.class.php +++ b/public/main/exercise/exercise.class.php @@ -8810,12 +8810,6 @@ public static function exerciseGridResource( $keyword = Database::escape_string($keyword); $learnpath_id = isset($_REQUEST['learnpath_id']) ? (int) $_REQUEST['learnpath_id'] : null; $learnpath_item_id = isset($_REQUEST['learnpath_item_id']) ? (int) $_REQUEST['learnpath_item_id'] : null; - $autoLaunchAvailable = false; - if (1 == api_get_course_setting('enable_exercise_auto_launch') && - ('true' === api_get_setting('exercise.allow_exercise_auto_launch')) - ) { - $autoLaunchAvailable = true; - } $courseId = $course->getId(); $tableRows = []; @@ -9067,21 +9061,19 @@ public static function exerciseGridResource( } // Auto launch - if ($autoLaunchAvailable) { - $autoLaunch = $exercise->getAutoLaunch(); - if (empty($autoLaunch)) { - $actions .= Display::url( - Display::getMdiIcon('rocket-launch', 'ch-tool-icon-disabled', null, ICON_SIZE_SMALL, get_lang('Enable')), - 'exercise.php?'.api_get_cidreq( - ).'&action=enable_launch&sec_token='.$token.'&exerciseId='.$exerciseId - ); - } else { - $actions .= Display::url( - Display::getMdiIcon('rocket-launch', 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Disable')), - 'exercise.php?'.api_get_cidreq( - ).'&action=disable_launch&sec_token='.$token.'&exerciseId='.$exerciseId - ); - } + $autoLaunch = $exercise->getAutoLaunch(); + if (empty($autoLaunch)) { + $actions .= Display::url( + Display::getMdiIcon('rocket-launch', 'ch-tool-icon-disabled', null, ICON_SIZE_SMALL, get_lang('Enable')), + 'exercise.php?'.api_get_cidreq( + ).'&action=enable_launch&sec_token='.$token.'&exerciseId='.$exerciseId + ); + } else { + $actions .= Display::url( + Display::getMdiIcon('rocket-launch', 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Disable')), + 'exercise.php?'.api_get_cidreq( + ).'&action=disable_launch&sec_token='.$token.'&exerciseId='.$exerciseId + ); } // Export diff --git a/public/main/exercise/global_multiple_answer.class.php b/public/main/exercise/global_multiple_answer.class.php index 5ba0b4dbf54..8ec96bfcd6c 100644 --- a/public/main/exercise/global_multiple_answer.class.php +++ b/public/main/exercise/global_multiple_answer.class.php @@ -128,7 +128,7 @@ public function createAnswersForm($form) $form->addHtmlEditor( 'comment['.$i.']', null, - true, + false, false, [ 'ToolbarSet' => 'TestProposedAnswer', diff --git a/public/main/inc/ajax/session.ajax.php b/public/main/inc/ajax/session.ajax.php index 6c7dd8bd37b..137ca44dcde 100644 --- a/public/main/inc/ajax/session.ajax.php +++ b/public/main/inc/ajax/session.ajax.php @@ -66,7 +66,7 @@ foreach ($sessions as $session) { $list['items'][] = [ 'id' => $session['id'], - 'text' => $session['name'], + 'text' => $session['title'], ]; } diff --git a/public/main/inc/lib/TicketManager.php b/public/main/inc/lib/TicketManager.php index 16a8dc0efec..6b97975569a 100644 --- a/public/main/inc/lib/TicketManager.php +++ b/public/main/inc/lib/TicketManager.php @@ -7,9 +7,12 @@ use Chamilo\CoreBundle\Entity\TicketMessageAttachment; use Chamilo\CoreBundle\Entity\TicketPriority; use Chamilo\CoreBundle\Entity\TicketProject; +use Chamilo\CoreBundle\Entity\TicketRelUser; use Chamilo\CoreBundle\Entity\TicketStatus; use Chamilo\CoreBundle\Entity\User; +use Chamilo\CoreBundle\Entity\ValidationToken; use Chamilo\CoreBundle\Framework\Container; +use Chamilo\CoreBundle\ServiceHelper\ValidationTokenHelper; use Chamilo\CourseBundle\Entity\CLp; use Symfony\Component\HttpFoundation\File\UploadedFile; use Chamilo\CoreBundle\Component\Utils\ObjectIcon; @@ -389,6 +392,7 @@ public static function add( $ticketId = Database::insert($table_support_tickets, $params); if ($ticketId) { + self::subscribeUserToTicket($ticketId, $currentUserId); $ticket_code = 'A'.str_pad($ticketId, 11, '0', STR_PAD_LEFT); $titleCreated = sprintf( get_lang('Ticket %s created'), @@ -554,11 +558,11 @@ public static function add( // Send notification to all users if (!empty($usersInCategory)) { foreach ($usersInCategory as $data) { - if ($data['user_id']) { + if ($data['user_id'] && $data['user_id'] !== $currentUserId) { self::sendNotification( $ticketId, - $subject, - $message, + $titleCreated, + $helpDeskMessage, $data['user_id'] ); } @@ -711,6 +715,10 @@ public static function insertMessage( } } } + + if (!self::isUserSubscribedToTicket($ticketId, $userId)) { + self::subscribeUserToTicket($ticketId, $userId); + } } return true; @@ -1275,7 +1283,7 @@ public static function get_ticket_detail_by_id($ticketId) $userInfo = api_get_user_info($row['sys_insert_user_id']); $row['user_url'] = ' '.$userInfo['complete_name'].''; - $ticket['usuario'] = $userInfo; + $ticket['user'] = $userInfo; $ticket['ticket'] = $row; } @@ -1354,15 +1362,8 @@ public static function update_message_status($ticketId, $userId) /** * Send notification to a user through the internal messaging system. - * - * @param int $ticketId - * @param string $title - * @param string $message - * @param int $onlyToUserId - * - * @return bool */ - public static function sendNotification($ticketId, $title, $message, $onlyToUserId = 0) + public static function sendNotification($ticketId, $title, $message, $onlyToUserId = 0, $debug = false) { $ticketInfo = self::get_ticket_detail_by_id($ticketId); @@ -1371,77 +1372,99 @@ public static function sendNotification($ticketId, $title, $message, $onlyToUser } $assignedUserInfo = api_get_user_info($ticketInfo['ticket']['assigned_last_user']); - $requestUserInfo = $ticketInfo['usuario']; + $requestUserInfo = $ticketInfo['user']; $ticketCode = $ticketInfo['ticket']['code']; $status = $ticketInfo['ticket']['status']; $priority = $ticketInfo['ticket']['priority']; + $creatorId = $ticketInfo['ticket']['sys_insert_user_id']; // Subject $titleEmail = "[$ticketCode] $title"; // Content - $href = api_get_path(WEB_CODE_PATH).'ticket/ticket_details.php?ticket_id='.$ticketId; + $href = api_get_path(WEB_CODE_PATH) . 'ticket/ticket_details.php?ticket_id=' . $ticketId; $ticketUrl = Display::url($ticketCode, $href); - $messageEmail = get_lang('Ticket number').": $ticketUrl "; - $messageEmail .= get_lang('Status').": $status "; - $messageEmail .= get_lang('Priority').": $priority "; - $messageEmail .= ''; - $messageEmail .= $message; + $messageEmailBase = get_lang('Ticket number') . ": $ticketUrl "; + $messageEmailBase .= get_lang('Status') . ": $status "; + $messageEmailBase .= get_lang('Priority') . ": $priority "; + $messageEmailBase .= ''; + $messageEmailBase .= $message; + $currentUserId = api_get_user_id(); - $attachmentList = []; - $attachments = self::getTicketMessageAttachmentsByTicketId($ticketId); - if (!empty($attachments)) { - /** @var TicketMessageAttachment $attachment */ - foreach ($attachments as $attachment) { - //$attachment->get + $recipients = []; + + if (!empty($onlyToUserId) && $currentUserId != $onlyToUserId) { + $recipients[$onlyToUserId] = $onlyToUserId; + } else { + if ( + $requestUserInfo && + $currentUserId != $requestUserInfo['id'] && + self::isUserSubscribedToTicket($ticketId, $requestUserInfo['id']) + ) { + $recipients[$requestUserInfo['id']] = $requestUserInfo['complete_name_with_username']; } - } - if (!empty($onlyToUserId)) { - // Send only to specific user - if ($currentUserId != $onlyToUserId) { - MessageManager::send_message_simple( - $onlyToUserId, - $titleEmail, - $messageEmail, - 0, - false, - false, - true, - $attachmentList - ); + if ($assignedUserInfo && $currentUserId != $assignedUserInfo['id']) { + $recipients[$assignedUserInfo['id']] = $assignedUserInfo['complete_name_with_username']; } - } else { - // Send to assigned user and to author - if ($requestUserInfo && $currentUserId != $requestUserInfo['id']) { - MessageManager::send_message_simple( - $requestUserInfo['id'], - $titleEmail, - $messageEmail, - 0, - false, - false, - false, - $attachmentList - ); + + $followers = self::getFollowers($ticketId); + /* @var User $follower */ + foreach ($followers as $follower) { + if ( + $follower->getId() !== $currentUserId && + ( + $follower->getId() !== $creatorId || + self::isUserSubscribedToTicket($ticketId, $follower->getId()) + ) + ) { + $recipients[$follower->getId()] = $follower->getFullname(); + } } + } - if ($assignedUserInfo && - $requestUserInfo['id'] != $assignedUserInfo['id'] && - $currentUserId != $assignedUserInfo['id'] - ) { - MessageManager::send_message_simple( - $assignedUserInfo['id'], - $titleEmail, - $messageEmail, - 0, - false, - false, - false, - $attachmentList - ); + if ($debug) { + echo ""; + echo "Title: $titleEmail\n"; + echo "Message Preview:\n\n"; + + foreach ($recipients as $recipientId => $recipientName) { + $unsubscribeLink = self::generateUnsubscribeLink($ticketId, $recipientId); + $finalMessageEmail = $messageEmailBase; + $finalMessageEmail .= ''; + $finalMessageEmail .= '' . get_lang('To unsubscribe from notifications, click here') . ': '; + $finalMessageEmail .= '' . $unsubscribeLink . ''; + + echo "------------------------------------\n"; + echo "Recipient: $recipientName (User ID: $recipientId)\n"; + echo "Message:\n$finalMessageEmail\n"; + echo "------------------------------------\n\n"; } + + echo ""; + exit; + } + + foreach ($recipients as $recipientId => $recipientName) { + $unsubscribeLink = self::generateUnsubscribeLink($ticketId, $recipientId); + + $finalMessageEmail = $messageEmailBase; + $finalMessageEmail .= ''; + $finalMessageEmail .= '' . get_lang('To unsubscribe from notifications, click here') . ': '; + $finalMessageEmail .= '' . $unsubscribeLink . ''; + + MessageManager::send_message_simple( + $recipientId, + $titleEmail, + $finalMessageEmail, + 0, + false, + false, + false + ); } + + return true; } /** @@ -2498,4 +2521,94 @@ public static function getAllowedRolesFromProject(int $projectId): array return $roleMap[$roleId] ?? "$roleId"; }, $roleIds); } + + /** + * Subscribes a user to a ticket. + */ + public static function subscribeUserToTicket(int $ticketId, int $userId): void + { + $em = Database::getManager(); + $ticket = $em->getRepository(Ticket::class)->find($ticketId); + $user = $em->getRepository(User::class)->find($userId); + + if ($ticket && $user) { + $repository = $em->getRepository(TicketRelUser::class); + $repository->subscribeUserToTicket($user, $ticket); + + Event::addEvent( + 'ticket_subscribe', + 'ticket_event', + ['user_id' => $userId, 'ticket_id' => $ticketId, 'action' => 'subscribe'] + ); + } + } + + /** + * Unsubscribes a user from a ticket. + */ + public static function unsubscribeUserFromTicket(int $ticketId, int $userId): void + { + $em = Database::getManager(); + $ticket = $em->getRepository(Ticket::class)->find($ticketId); + $user = $em->getRepository(User::class)->find($userId); + + if ($ticket && $user) { + $repository = $em->getRepository(TicketRelUser::class); + $repository->unsubscribeUserFromTicket($user, $ticket); + + Event::addEvent( + 'ticket_unsubscribe', + 'ticket_event', + ['user_id' => $userId, 'ticket_id' => $ticketId, 'action' => 'unsubscribe'] + ); + } + } + + /** + * Checks if a user is subscribed to a ticket. + */ + public static function isUserSubscribedToTicket(int $ticketId, int $userId): bool + { + $em = Database::getManager(); + $ticket = $em->getRepository(Ticket::class)->find($ticketId); + $user = $em->getRepository(User::class)->find($userId); + + if ($ticket && $user) { + $repository = $em->getRepository(TicketRelUser::class); + return $repository->isUserSubscribedToTicket($user, $ticket); + } + + return false; + } + + /** + * Retrieves the followers of a ticket. + */ + public static function getFollowers($ticketId): array + { + $em = Database::getManager(); + $repository = $em->getRepository(TicketRelUser::class); + $ticket = $em->getRepository(Ticket::class)->find($ticketId); + + $followers = $repository->findBy(['ticket' => $ticket]); + + $users = []; + foreach ($followers as $follower) { + $users[] = $follower->getUser(); + } + + return $users; + } + + /** + * Generates an unsubscribe link for a ticket. + */ + public static function generateUnsubscribeLink($ticketId, $userId): string + { + $token = new ValidationToken(ValidationTokenHelper::TYPE_TICKET, $ticketId); + Database::getManager()->persist($token); + Database::getManager()->flush(); + + return api_get_path(WEB_PATH).'validate/ticket/'.$token->getHash().'?user_id='.$userId; + } } diff --git a/public/main/inc/lib/message.lib.php b/public/main/inc/lib/message.lib.php index 2d1024abd98..7c73fb33a4c 100644 --- a/public/main/inc/lib/message.lib.php +++ b/public/main/inc/lib/message.lib.php @@ -352,6 +352,8 @@ public static function send_message( if ($sendEmail) { $notification = new Notification(); $sender_info = api_get_user_info($user_sender_id); + $baseUrl = $baseUrl ?? api_get_path(WEB_PATH); + $content = self::processRelativeLinks($content, $baseUrl); // add file attachment additional attributes $attachmentAddedByMail = []; @@ -422,6 +424,20 @@ public static function send_message( return false; } + /** + * Converts relative URLs in href and src attributes to absolute URLs. + */ + private static function processRelativeLinks(string $content, string $baseUrl): string + { + return preg_replace_callback( + '/(href|src)="(\/[^"]*)"/', + function ($matches) use ($baseUrl) { + return $matches[1] . '="' . rtrim($baseUrl, '/') . $matches[2] . '"'; + }, + $content + ); + } + /** * @param int $receiverUserId * @param string $subject diff --git a/public/main/inc/lib/sessionmanager.lib.php b/public/main/inc/lib/sessionmanager.lib.php index 68f23e8a0bb..baa11b1194b 100644 --- a/public/main/inc/lib/sessionmanager.lib.php +++ b/public/main/inc/lib/sessionmanager.lib.php @@ -5589,13 +5589,10 @@ public static function importCSV( } } - $userList[] = $user_id; - // Insert new users. - $sql = "INSERT IGNORE INTO $tbl_session_user SET - user_id = '$user_id', - session_id = '$session_id', - registered_at = '".api_get_utc_datetime()."'"; - Database::query($sql); + $userEntity = api_get_user_entity($user_id); + $sessionEntity = api_get_session_entity($session_id); + $sessionEntity->addUserInSession(Session::STUDENT, $userEntity); + if ($debug) { $logger->debug("Adding User #$user_id ($user) to session #$session_id"); } diff --git a/public/main/ticket/ticket_details.php b/public/main/ticket/ticket_details.php index 0f3e2c89360..5bb0c04fe95 100644 --- a/public/main/ticket/ticket_details.php +++ b/public/main/ticket/ticket_details.php @@ -133,7 +133,7 @@ class: "controls" api_not_allowed(true); } $projectId = (int) $ticket['ticket']['project_id']; -$userIsAllowInProject = TicketManager::userIsAllowInProject($projectId); +$userIsAllowInProject = true; //TicketManager::userIsAllowInProject($projectId); $allowEdition = $ticket['ticket']['assigned_last_user'] == $user_id || $ticket['ticket']['sys_insert_user_id'] == $user_id @@ -146,6 +146,24 @@ class: "controls" } } +if (isset($_GET['action'])) { + $action = $_GET['action']; + + switch ($action) { + case 'subscribe': + TicketManager::subscribeUserToTicket($ticket_id, $user_id); + Display::addFlash(Display::return_message(get_lang('Subscribed successfully'))); + header('Location: '.api_get_self().'?ticket_id='.$ticket_id); + exit; + + case 'unsubscribe': + TicketManager::unsubscribeUserFromTicket($ticket_id, $user_id); + Display::addFlash(Display::return_message(get_lang('Unsubscribed successfully'))); + header('Location: '.api_get_self().'?ticket_id='.$ticket_id); + exit; + } +} + $title = 'Ticket #'.$ticket['ticket']['code']; if ($allowEdition && isset($_REQUEST['close'])) { @@ -331,18 +349,37 @@ class: "controls" } } +$isSubscribed = TicketManager::isUserSubscribedToTicket($ticket_id, $user_id); +if ($isSubscribed) { + $subscribeAction = Display::url( + Display::getMdiIcon(ActionIcon::DELETE, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('Unsubscribe')), + api_get_self().'?ticket_id='.$ticket_id.'&action=unsubscribe', + ['title' => get_lang('Unsubscribe')] + ); +} else { + $subscribeAction = Display::url( + Display::getMdiIcon(ActionIcon::ADD, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('Subscribe')), + api_get_self().'?ticket_id='.$ticket_id.'&action=subscribe', + ['title' => get_lang('Subscribe')] + ); +} + +$actions = [ + Display::url( + Display::getMdiIcon(ActionIcon::BACK, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('Tickets')), + api_get_path(WEB_CODE_PATH).'ticket/tickets.php?project_id='.$projectId + ), + $subscribeAction, +]; + Display::display_header(); -$actions = Display::url( - Display::getMdiIcon(ActionIcon::BACK, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('Tickets')), - api_get_path(WEB_CODE_PATH).'ticket/tickets.php?project_id='.$projectId -); -echo Display::toolbarAction('ticket', [$actions]); +echo Display::toolbarAction('ticket', $actions); $bold = ''; if (TicketManager::STATUS_CLOSE == $ticket['ticket']['status_id']) { $bold = 'style = "font-weight: bold;"'; } -$senderData = get_lang('added by').' '.$ticket['usuario']['complete_name_with_message_link']; +$senderData = get_lang('added by').' '.$ticket['user']['complete_name_with_message_link']; echo ''; echo ' diff --git a/src/CoreBundle/Controller/CourseController.php b/src/CoreBundle/Controller/CourseController.php index 6d034b1e199..7013fcc2897 100644 --- a/src/CoreBundle/Controller/CourseController.php +++ b/src/CoreBundle/Controller/CourseController.php @@ -894,59 +894,57 @@ private function autoLaunch(): void } } - if ('true' === api_get_setting('exercise.allow_exercise_auto_launch')) { - $exerciseAutoLaunch = (int) api_get_course_setting('enable_exercise_auto_launch'); - if (2 === $exerciseAutoLaunch) { - if ($allowAutoLaunchForCourseAdmins) { - if (empty($autoLaunchWarning)) { - $autoLaunchWarning = get_lang( - 'TheExerciseAutoLaunchSettingIsONStudentsWillBeRedirectToTheExerciseList' - ); - } - } else { - // Redirecting to the document - $url = api_get_path(WEB_CODE_PATH).'exercise/exercise.php?'.api_get_cidreq(); - header(\sprintf('Location: %s', $url)); - - exit; + $exerciseAutoLaunch = (int) api_get_course_setting('enable_exercise_auto_launch'); + if (2 === $exerciseAutoLaunch) { + if ($allowAutoLaunchForCourseAdmins) { + if (empty($autoLaunchWarning)) { + $autoLaunchWarning = get_lang( + 'TheExerciseAutoLaunchSettingIsONStudentsWillBeRedirectToTheExerciseList' + ); } - } elseif (1 === $exerciseAutoLaunch) { - if ($allowAutoLaunchForCourseAdmins) { - if (empty($autoLaunchWarning)) { - $autoLaunchWarning = get_lang( - 'TheExerciseAutoLaunchSettingIsONStudentsWillBeRedirectToAnSpecificExercise' - ); - } - } else { - // Redirecting to an exercise - $table = Database::get_course_table(TABLE_QUIZ_TEST); - $condition = ''; - if (!empty($session_id)) { - $condition = api_get_session_condition($session_id); - $sql = "SELECT iid FROM {$table} - WHERE c_id = {$course_id} AND autolaunch = 1 {$condition} - LIMIT 1"; - $result = Database::query($sql); - // If we found nothing in the session we just called the session_id = 0 autolaunch - if (0 === Database::num_rows($result)) { - $condition = ''; - } - } + } else { + // Redirecting to the document + $url = api_get_path(WEB_CODE_PATH).'exercise/exercise.php?'.api_get_cidreq(); + header(\sprintf('Location: %s', $url)); + exit; + } + } elseif (1 === $exerciseAutoLaunch) { + if ($allowAutoLaunchForCourseAdmins) { + if (empty($autoLaunchWarning)) { + $autoLaunchWarning = get_lang( + 'TheExerciseAutoLaunchSettingIsONStudentsWillBeRedirectToAnSpecificExercise' + ); + } + } else { + // Redirecting to an exercise + $table = Database::get_course_table(TABLE_QUIZ_TEST); + $condition = ''; + if (!empty($session_id)) { + $condition = api_get_session_condition($session_id); $sql = "SELECT iid FROM {$table} WHERE c_id = {$course_id} AND autolaunch = 1 {$condition} LIMIT 1"; $result = Database::query($sql); - if (Database::num_rows($result) > 0) { - $row = Database::fetch_array($result); - $exerciseId = $row['iid']; - $url = api_get_path(WEB_CODE_PATH). - 'exercise/overview.php?exerciseId='.$exerciseId.'&'.api_get_cidreq(); - header(\sprintf('Location: %s', $url)); - - exit; + // If we found nothing in the session we just called the session_id = 0 autolaunch + if (0 === Database::num_rows($result)) { + $condition = ''; } } + + $sql = "SELECT iid FROM {$table} + WHERE c_id = {$course_id} AND autolaunch = 1 {$condition} + LIMIT 1"; + $result = Database::query($sql); + if (Database::num_rows($result) > 0) { + $row = Database::fetch_array($result); + $exerciseId = $row['iid']; + $url = api_get_path(WEB_CODE_PATH). + 'exercise/overview.php?exerciseId='.$exerciseId.'&'.api_get_cidreq(); + header(\sprintf('Location: %s', $url)); + + exit; + } } } diff --git a/src/CoreBundle/Controller/OAuth2/AzureProviderController.php b/src/CoreBundle/Controller/OAuth2/AzureProviderController.php index 3ca7bd2f791..be863aa4167 100644 --- a/src/CoreBundle/Controller/OAuth2/AzureProviderController.php +++ b/src/CoreBundle/Controller/OAuth2/AzureProviderController.php @@ -23,4 +23,4 @@ public function connect( #[Route('/connect/azure/check', name: 'chamilo.oauth2_azure_check')] public function connectCheck(): void {} -} \ No newline at end of file +} diff --git a/src/CoreBundle/Controller/PlatformConfigurationController.php b/src/CoreBundle/Controller/PlatformConfigurationController.php index 91915bc79bd..c54e5b64509 100644 --- a/src/CoreBundle/Controller/PlatformConfigurationController.php +++ b/src/CoreBundle/Controller/PlatformConfigurationController.php @@ -91,7 +91,6 @@ public function list(SettingsManager $settingsManager): Response 'document.students_download_folders', 'social.hide_social_groups_block', 'course.show_course_duration', - 'exercise.allow_exercise_auto_launch', ]; $user = $this->userHelper->getCurrent(); diff --git a/src/CoreBundle/Controller/ValidationTokenController.php b/src/CoreBundle/Controller/ValidationTokenController.php index c18f81000c0..ca32103f8b7 100644 --- a/src/CoreBundle/Controller/ValidationTokenController.php +++ b/src/CoreBundle/Controller/ValidationTokenController.php @@ -6,14 +6,21 @@ namespace Chamilo\CoreBundle\Controller; +use Chamilo\CoreBundle\Entity\User; use Chamilo\CoreBundle\Entity\ValidationToken; +use Chamilo\CoreBundle\Repository\Node\UserRepository; +use Chamilo\CoreBundle\Repository\TicketRelUserRepository; +use Chamilo\CoreBundle\Repository\TicketRepository; use Chamilo\CoreBundle\Repository\TrackEDefaultRepository; use Chamilo\CoreBundle\Repository\ValidationTokenRepository; use Chamilo\CoreBundle\ServiceHelper\ValidationTokenHelper; +use InvalidArgumentException; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; -use Symfony\Component\Security\Core\Security; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; #[Route('/validate')] class ValidationTokenController extends AbstractController @@ -22,15 +29,22 @@ public function __construct( private readonly ValidationTokenHelper $validationTokenHelper, private readonly ValidationTokenRepository $tokenRepository, private readonly TrackEDefaultRepository $trackEDefaultRepository, - private readonly Security $security + private readonly TicketRepository $ticketRepository, + private readonly UserRepository $userRepository, + private readonly TicketRelUserRepository $ticketRelUserRepository, + private readonly TokenStorageInterface $tokenStorage, + private readonly RequestStack $requestStack ) {} - #[Route('/{type}/{hash}', name: 'validate_token')] + #[Route('/{type}/{hash}', name: 'chamilo_core_validate_token')] public function validate(string $type, string $hash): Response { + $userId = $this->requestStack->getCurrentRequest()->query->get('user_id'); + $userId = null !== $userId ? (int) $userId : null; + $token = $this->tokenRepository->findOneBy([ 'type' => $this->validationTokenHelper->getTypeId($type), - 'hash' => $hash + 'hash' => $hash, ]); if (!$token) { @@ -38,7 +52,7 @@ public function validate(string $type, string $hash): Response } // Process the action related to the token type - $this->processAction($token); + $this->processAction($token, $userId); // Remove the used token $this->tokenRepository->remove($token, true); @@ -46,6 +60,12 @@ public function validate(string $type, string $hash): Response // Register the token usage event $this->registerTokenUsedEvent($token); + if ('ticket' === $type) { + $ticketId = $token->getResourceId(); + + return $this->redirect('/main/ticket/ticket_details.php?ticket_id='.$ticketId); + } + return $this->render('@ChamiloCore/Validation/success.html.twig', [ 'type' => $type, ]); @@ -61,59 +81,63 @@ public function testGenerateToken(string $type, int $resourceId): Response $validationLink = $this->generateUrl('validate_token', [ 'type' => $type, 'hash' => $token->getHash(), - ], \Symfony\Component\Routing\Generator\UrlGeneratorInterface::ABSOLUTE_URL); + ], UrlGeneratorInterface::ABSOLUTE_URL); return new Response("Generated token: {$token->getHash()}Validation link: {$validationLink}"); } - private function processAction(ValidationToken $token): void + /** + * Processes the action associated with the given token type. + */ + private function processAction(ValidationToken $token, ?int $userId): void { switch ($token->getType()) { - case 1: // Assuming 1 is for 'ticket' - $this->processTicketValidation($token); - break; - case 2: // Assuming 2 is for 'user' - // Implement user validation logic here + case ValidationTokenHelper::TYPE_TICKET: + $this->unsubscribeUserFromTicket($token->getResourceId(), $userId); + break; + default: - throw new \InvalidArgumentException('Unrecognized token type'); + throw new InvalidArgumentException('Unrecognized token type'); } } - private function processTicketValidation(ValidationToken $token): void + /** + * Unsubscribes a user from a ticket. + */ + private function unsubscribeUserFromTicket(int $ticketId, ?int $userId): void { - $ticketId = $token->getResourceId(); + if (!$userId) { + throw $this->createAccessDeniedException('User not authenticated.'); + } - // Simulate ticket validation logic - // Here you would typically check if the ticket exists and is valid - // For now, we'll just print a message to simulate this - // Replace this with your actual ticket validation logic - $ticketValid = $this->validateTicket($ticketId); + $ticket = $this->ticketRepository->find($ticketId); + $user = $this->userRepository->find($userId); - if (!$ticketValid) { - throw new \RuntimeException('Invalid ticket.'); + if ($ticket && $user) { + $this->ticketRelUserRepository->unsubscribeUserFromTicket($user, $ticket); + $this->trackEDefaultRepository->registerTicketUnsubscribeEvent($ticketId, $userId); + } else { + throw $this->createNotFoundException('Ticket or User not found.'); } - - // If the ticket is valid, you can mark it as used or perform other actions - // For example, update the ticket status in the database - // $this->ticketRepository->markAsUsed($ticketId); } - private function validateTicket(int $ticketId): bool + /** + * Registers the usage event of a validation token. + */ + private function registerTokenUsedEvent(ValidationToken $token): void { - // Here you would implement the logic to check if the ticket is valid. - // This is a placeholder function to simulate validation. - - // For testing purposes, let's assume all tickets are valid. - // In a real implementation, you would query your database or service. - - return true; // Assume the ticket is valid for now + $userId = $this->getUserId(); + $this->trackEDefaultRepository->registerTokenUsedEvent($token, $userId); } - private function registerTokenUsedEvent(ValidationToken $token): void + /** + * Retrieves the current authenticated user's ID. + */ + private function getUserId(): ?int { - $user = $this->security->getUser(); - $userId = $user?->getId(); - $this->trackEDefaultRepository->registerTokenUsedEvent($token, $userId); + $user = $this->tokenStorage->getToken()?->getUser(); + + return $user instanceof User ? $user->getId() : null; } } diff --git a/src/CoreBundle/DataFixtures/SettingsCurrentFixtures.php b/src/CoreBundle/DataFixtures/SettingsCurrentFixtures.php index 01e0da4fb43..f07bd4f8993 100644 --- a/src/CoreBundle/DataFixtures/SettingsCurrentFixtures.php +++ b/src/CoreBundle/DataFixtures/SettingsCurrentFixtures.php @@ -2403,11 +2403,6 @@ public static function getNewConfigurationSettings(): array 'title' => 'Allow teachers to edit tests in learning paths', 'comment' => 'By default, Chamilo prevents you from editing tests that are included inside a learning path. This is to avoid changes that would affect learners (past and future) differently regarding the results and/or progress in the learning path. This option allows teachers to bypass this restriction.', ], - [ - 'name' => 'allow_exercise_auto_launch', - 'title' => 'Allow tests auto-launch', - 'comment' => 'The auto-launch feature allows the teacher to set an exercise to open immediately upon accessing the course homepage. Enable this option and click on the rocket icon in the list of tests to enable.', - ], [ 'name' => 'allow_exercise_categories', 'title' => 'Enable test categories', diff --git a/src/CoreBundle/DataTransformer/SkillTreeNodeTransformer.php b/src/CoreBundle/DataTransformer/SkillTreeNodeTransformer.php index c2be88acccb..c8e0eaa9a07 100644 --- a/src/CoreBundle/DataTransformer/SkillTreeNodeTransformer.php +++ b/src/CoreBundle/DataTransformer/SkillTreeNodeTransformer.php @@ -35,7 +35,7 @@ public function transform($object, string $to, array $context = []) } $skillNode->children = $object->getChildSkills() - ->map(fn(Skill $childSkill) => $this->transform($childSkill, $to, $context)) + ->map(fn (Skill $childSkill) => $this->transform($childSkill, $to, $context)) ->toArray() ; diff --git a/src/CoreBundle/Entity/Session.php b/src/CoreBundle/Entity/Session.php index 4185ef89371..494670ebf30 100644 --- a/src/CoreBundle/Entity/Session.php +++ b/src/CoreBundle/Entity/Session.php @@ -260,7 +260,7 @@ class Session implements ResourceWithAccessUrlInterface, Stringable 'session:write', ])] #[ORM\Column(name: 'show_description', type: 'boolean', nullable: true)] - protected ?bool $showDescription; + protected ?bool $showDescription = false; #[Groups(['session:read', 'session:write', 'user_subscriptions:sessions'])] #[ORM\Column(name: 'duration', type: 'integer', nullable: true)] @@ -826,7 +826,19 @@ public function addGeneralCoach(User $coach): self public function addUserInSession(int $relationType, User $user): self { - $sessionRelUser = (new SessionRelUser())->setUser($user)->setRelationType($relationType); + foreach ($this->getUsers() as $existingSubscription) { + if ( + $existingSubscription->getUser()->getId() === $user->getId() + && $existingSubscription->getRelationType() === $relationType + ) { + return $this; + } + } + + $sessionRelUser = (new SessionRelUser()) + ->setUser($user) + ->setRelationType($relationType) + ; $this->addUserSubscription($sessionRelUser); return $this; @@ -1001,8 +1013,12 @@ public function removeCourse(Course $course): bool * If user status in session is student, then increase number of course users. * Status example: Session::STUDENT. */ - public function addUserInCourse(int $status, User $user, Course $course): SessionRelCourseRelUser + public function addUserInCourse(int $status, User $user, Course $course): ?SessionRelCourseRelUser { + if ($this->hasUserInCourse($user, $course, $status)) { + return null; + } + $userRelCourseRelSession = (new SessionRelCourseRelUser()) ->setCourse($course) ->setUser($user) @@ -1014,7 +1030,9 @@ public function addUserInCourse(int $status, User $user, Course $course): Sessio if (self::STUDENT === $status) { $sessionCourse = $this->getCourseSubscription($course); - $sessionCourse->setNbrUsers($sessionCourse->getNbrUsers() + 1); + if ($sessionCourse) { + $sessionCourse->setNbrUsers($sessionCourse->getNbrUsers() + 1); + } } return $userRelCourseRelSession; diff --git a/src/CoreBundle/Entity/Skill.php b/src/CoreBundle/Entity/Skill.php index 0459659e397..c80ee3b7d61 100644 --- a/src/CoreBundle/Entity/Skill.php +++ b/src/CoreBundle/Entity/Skill.php @@ -38,7 +38,7 @@ uriTemplate: '/skills/tree.{_format}', paginationEnabled: false, normalizationContext: [ - 'groups' => ['skill:tree:read'] + 'groups' => ['skill:tree:read'], ], output: SkillTreeNode::class, provider: SkillTreeStateProvider::class @@ -467,7 +467,7 @@ public function getChildSkills(): Collection { return $this ->getSkills() - ->map(fn(SkillRelSkill $skillRelSkill): Skill => $skillRelSkill->getSkill()) + ->map(fn (SkillRelSkill $skillRelSkill): Skill => $skillRelSkill->getSkill()) ; } diff --git a/src/CoreBundle/Entity/SkillProfile.php b/src/CoreBundle/Entity/SkillProfile.php index 66088426aa3..989f21f9bad 100644 --- a/src/CoreBundle/Entity/SkillProfile.php +++ b/src/CoreBundle/Entity/SkillProfile.php @@ -7,11 +7,6 @@ namespace Chamilo\CoreBundle\Entity; 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 Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; @@ -65,9 +60,6 @@ public function setTitle(string $title): self return $this; } - /** - * Get title. - */ public function getTitle(): string { return $this->title; @@ -80,17 +72,11 @@ public function setDescription(string $description): self return $this; } - /** - * Get description. - */ public function getDescription(): string { return $this->description; } - /** - * Get id. - */ public function getId(): ?int { return $this->id; diff --git a/src/CoreBundle/Entity/TicketRelUser.php b/src/CoreBundle/Entity/TicketRelUser.php index 6eef8f7cdec..50a4e20d1ae 100644 --- a/src/CoreBundle/Entity/TicketRelUser.php +++ b/src/CoreBundle/Entity/TicketRelUser.php @@ -6,11 +6,12 @@ namespace Chamilo\CoreBundle\Entity; +use Chamilo\CoreBundle\Repository\TicketRelUserRepository; use Chamilo\CoreBundle\Traits\UserTrait; use Doctrine\ORM\Mapping as ORM; #[ORM\Table(name: 'ticket_rel_user')] -#[ORM\Entity] +#[ORM\Entity(repositoryClass: TicketRelUserRepository::class)] class TicketRelUser { use UserTrait; diff --git a/src/CoreBundle/Entity/ValidationToken.php b/src/CoreBundle/Entity/ValidationToken.php index 922ba8acf17..2acc1e50186 100644 --- a/src/CoreBundle/Entity/ValidationToken.php +++ b/src/CoreBundle/Entity/ValidationToken.php @@ -7,6 +7,7 @@ namespace Chamilo\CoreBundle\Entity; use Chamilo\CoreBundle\Repository\ValidationTokenRepository; +use DateTime; use Doctrine\ORM\Mapping as ORM; /** @@ -32,14 +33,14 @@ class ValidationToken protected string $hash; #[ORM\Column(type: 'datetime')] - protected \DateTime $createdAt; + protected DateTime $createdAt; public function __construct(int $type, int $resourceId) { $this->type = $type; $this->resourceId = $resourceId; $this->hash = hash('sha256', uniqid((string) rand(), true)); - $this->createdAt = new \DateTime(); + $this->createdAt = new DateTime(); } public function getId(): ?int @@ -55,6 +56,7 @@ public function getType(): int public function setType(int $type): self { $this->type = $type; + return $this; } @@ -66,6 +68,7 @@ public function getResourceId(): int public function setResourceId(int $resourceId): self { $this->resourceId = $resourceId; + return $this; } @@ -74,14 +77,15 @@ public function getHash(): string return $this->hash; } - public function getCreatedAt(): \DateTime + public function getCreatedAt(): DateTime { return $this->createdAt; } - public function setCreatedAt(\DateTime $createdAt): self + public function setCreatedAt(DateTime $createdAt): self { $this->createdAt = $createdAt; + return $this; } @@ -91,6 +95,7 @@ public function setCreatedAt(\DateTime $createdAt): self public static function generateLink(int $type, int $resourceId): string { $token = new self($type, $resourceId); - return '/validate/' . $type . '/' . $token->getHash(); + + return '/validate/'.$type.'/'.$token->getHash(); } } diff --git a/src/CoreBundle/Form/DataTransformer/ResourceToIdentifierTransformer.php b/src/CoreBundle/Form/DataTransformer/ResourceToIdentifierTransformer.php index 737fdfb0422..1dc781f365d 100644 --- a/src/CoreBundle/Form/DataTransformer/ResourceToIdentifierTransformer.php +++ b/src/CoreBundle/Form/DataTransformer/ResourceToIdentifierTransformer.php @@ -7,8 +7,6 @@ use Doctrine\Persistence\ObjectRepository; use Symfony\Component\Form\DataTransformerInterface; use Symfony\Component\Form\Exception\TransformationFailedException; -use Symfony\Component\PropertyAccess\PropertyAccess; -use Webmozart\Assert\Assert; /** * @template-implements DataTransformerInterface @@ -31,7 +29,7 @@ public function transform($value) return null; } - if (is_object($value) && method_exists($value, 'getId')) { + if (\is_object($value) && method_exists($value, 'getId')) { return $value; } @@ -48,17 +46,13 @@ public function reverseTransform($value) return null; } - if (is_object($value) && method_exists($value, 'getId')) { + if (\is_object($value) && method_exists($value, 'getId')) { return $value; } $resource = $this->repository->find($value); if (null === $resource) { - throw new TransformationFailedException(sprintf( - 'Object "%s" with identifier "%s" does not exist.', - $this->repository->getClassName(), - $value - )); + throw new TransformationFailedException(\sprintf('Object "%s" with identifier "%s" does not exist.', $this->repository->getClassName(), $value)); } return $resource; diff --git a/src/CoreBundle/Migrations/Schema/V200/Version20241230175100.php b/src/CoreBundle/Migrations/Schema/V200/Version20241230175100.php new file mode 100644 index 00000000000..edbc30656e4 --- /dev/null +++ b/src/CoreBundle/Migrations/Schema/V200/Version20241230175100.php @@ -0,0 +1,69 @@ +getForeignKeyName('c_lp_category_rel_user', 'user_id'); + + if ($foreignKeyName) { + $this->addSql("ALTER TABLE c_lp_category_rel_user DROP FOREIGN KEY `$foreignKeyName`"); + } + + // Add the updated foreign key + $this->addSql(' + ALTER TABLE c_lp_category_rel_user + ADD CONSTRAINT FK_83D35829A76ED395 + FOREIGN KEY (user_id) + REFERENCES user(id) + ON DELETE SET NULL + ON UPDATE CASCADE + '); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE c_lp_category_rel_user DROP FOREIGN KEY FK_83D35829A76ED395'); + + $this->addSql(' + ALTER TABLE c_lp_category_rel_user + ADD CONSTRAINT c_lp_category_rel_user_ibfk_1 + FOREIGN KEY (user_id) + REFERENCES user(id) + ON DELETE SET NULL + '); + } + + private function getForeignKeyName(string $tableName, string $columnName): ?string + { + $query = " + SELECT CONSTRAINT_NAME + FROM information_schema.KEY_COLUMN_USAGE + WHERE TABLE_NAME = :tableName + AND COLUMN_NAME = :columnName + AND TABLE_SCHEMA = DATABASE() + "; + + $result = $this->connection->fetchOne($query, [ + 'tableName' => $tableName, + 'columnName' => $columnName, + ]); + + return $result ?: null; + } +} diff --git a/src/CoreBundle/Repository/Node/UserRepository.php b/src/CoreBundle/Repository/Node/UserRepository.php index 805f9687318..81251aa4d31 100644 --- a/src/CoreBundle/Repository/Node/UserRepository.php +++ b/src/CoreBundle/Repository/Node/UserRepository.php @@ -144,178 +144,195 @@ public function getRootUser(): User public function deleteUser(User $user, bool $destroy = false): void { - $em = $this->getEntityManager(); - $em->getConnection()->beginTransaction(); + $connection = $this->getEntityManager()->getConnection(); + $connection->beginTransaction(); try { if ($destroy) { $fallbackUser = $this->getFallbackUser(); if ($fallbackUser) { - $this->reassignUserResourcesToFallback($user, $fallbackUser); - $em->flush(); - } - - foreach ($user->getGroups() as $group) { - $user->removeGroup($group); + $this->reassignUserResourcesToFallbackSQL($user, $fallbackUser, $connection); } - if ($user->getResourceNode()) { - $em->remove($user->getResourceNode()); - } - - $em->remove($user); + // Remove group relationships + $connection->executeStatement( + 'DELETE FROM usergroup_rel_user WHERE user_id = :userId', + ['userId' => $user->getId()] + ); + + // Remove resource node if exists + $connection->executeStatement( + 'DELETE FROM resource_node WHERE id = :nodeId', + ['nodeId' => $user->getResourceNode()->getId()] + ); + + // Remove the user itself + $connection->executeStatement( + 'DELETE FROM user WHERE id = :userId', + ['userId' => $user->getId()] + ); } else { - $user->setActive(User::SOFT_DELETED); - $em->persist($user); + // Soft delete the user + $connection->executeStatement( + 'UPDATE user SET active = :softDeleted WHERE id = :userId', + ['softDeleted' => User::SOFT_DELETED, 'userId' => $user->getId()] + ); } - $em->flush(); - $em->getConnection()->commit(); + $connection->commit(); } catch (Exception $e) { - $em->getConnection()->rollBack(); - + $connection->rollBack(); throw $e; } } - protected function reassignUserResourcesToFallback(User $userToDelete, User $fallbackUser): void + /** + * Reassigns resources and related data from a deleted user to a fallback user in the database. + */ + protected function reassignUserResourcesToFallbackSQL(User $userToDelete, User $fallbackUser, $connection): void { - $em = $this->getEntityManager(); - - $userResourceNodes = $em->getRepository(ResourceNode::class)->findBy(['creator' => $userToDelete]); - foreach ($userResourceNodes as $resourceNode) { - $resourceNode->setCreator($fallbackUser); - $em->persist($resourceNode); - } - - $childResourceNodes = $em->getRepository(ResourceNode::class)->findBy(['parent' => $userToDelete->getResourceNode()]); - foreach ($childResourceNodes as $childNode) { - $fallbackUserResourceNode = $fallbackUser->getResourceNode(); - if ($fallbackUserResourceNode) { - $childNode->setParent($fallbackUserResourceNode); - } else { - $childNode->setParent(null); - } - $em->persist($childNode); - } - - $relations = [ - ['bundle' => 'CoreBundle', 'entity' => 'AccessUrlRelUser', 'field' => 'user', 'type' => 'object', 'action' => 'delete'], - ['bundle' => 'CoreBundle', 'entity' => 'Admin', 'field' => 'user', 'type' => 'object', 'action' => 'delete'], - ['bundle' => 'CoreBundle', 'entity' => 'AttemptFeedback', 'field' => 'user', 'type' => 'object', 'action' => 'convert'], - ['bundle' => 'CoreBundle', 'entity' => 'Chat', 'field' => 'toUser', 'type' => 'int', 'action' => 'convert'], - ['bundle' => 'CoreBundle', 'entity' => 'ChatVideo', 'field' => 'toUser', 'type' => 'int', 'action' => 'convert'], - ['bundle' => 'CoreBundle', 'entity' => 'CourseRelUser', 'field' => 'user', 'type' => 'object', 'action' => 'delete'], - ['bundle' => 'CoreBundle', 'entity' => 'CourseRelUserCatalogue', 'field' => 'user', 'type' => 'object', 'action' => 'delete'], - ['bundle' => 'CoreBundle', 'entity' => 'CourseRequest', 'field' => 'user', 'type' => 'object', 'action' => 'convert'], - ['bundle' => 'CourseBundle', 'entity' => 'CAttendanceResult', 'field' => 'user', 'type' => 'object', 'action' => 'delete'], - ['bundle' => 'CourseBundle', 'entity' => 'CAttendanceResultComment', 'field' => 'userId', 'type' => 'int', 'action' => 'convert'], - ['bundle' => 'CourseBundle', 'entity' => 'CAttendanceSheet', 'field' => 'user', 'type' => 'object', 'action' => 'delete'], - ['bundle' => 'CourseBundle', 'entity' => 'CAttendanceSheetLog', 'field' => 'user', 'type' => 'object', 'action' => 'delete'], - ['bundle' => 'CourseBundle', 'entity' => 'CChatConnected', 'field' => 'userId', 'type' => 'int', 'action' => 'delete'], - ['bundle' => 'CourseBundle', 'entity' => 'CDropboxCategory', 'field' => 'userId', 'type' => 'int', 'action' => 'convert'], - ['bundle' => 'CourseBundle', 'entity' => 'CDropboxFeedback', 'field' => 'authorUserId', 'type' => 'int', 'action' => 'convert'], - ['bundle' => 'CourseBundle', 'entity' => 'CDropboxPerson', 'field' => 'userId', 'type' => 'int', 'action' => 'convert'], - ['bundle' => 'CourseBundle', 'entity' => 'CDropboxPost', 'field' => 'destUserId', 'type' => 'int', 'action' => 'convert'], - ['bundle' => 'CourseBundle', 'entity' => 'CForumMailcue', 'field' => 'userId', 'type' => 'int', 'action' => 'delete'], - ['bundle' => 'CourseBundle', 'entity' => 'CForumNotification', 'field' => 'userId', 'type' => 'int', 'action' => 'delete'], - ['bundle' => 'CourseBundle', 'entity' => 'CForumPost', 'field' => 'user', 'type' => 'object', 'action' => 'convert'], - ['bundle' => 'CourseBundle', 'entity' => 'CForumThread', 'field' => 'user', 'type' => 'object', 'action' => 'convert'], - ['bundle' => 'CourseBundle', 'entity' => 'CForumThreadQualify', 'field' => 'user', 'type' => 'object', 'action' => 'convert'], - ['bundle' => 'CourseBundle', 'entity' => 'CForumThreadQualifyLog', 'field' => 'userId', 'type' => 'int', 'action' => 'convert'], - ['bundle' => 'CourseBundle', 'entity' => 'CGroupRelTutor', 'field' => 'user', 'type' => 'object', 'action' => 'convert'], - ['bundle' => 'CourseBundle', 'entity' => 'CGroupRelUser', 'field' => 'user', 'type' => 'object', 'action' => 'convert'], - ['bundle' => 'CourseBundle', 'entity' => 'CLpCategoryRelUser', 'field' => 'user', 'type' => 'object', 'action' => 'delete'], - ['bundle' => 'CourseBundle', 'entity' => 'CLpRelUser', 'field' => 'user', 'type' => 'object', 'action' => 'delete'], - ['bundle' => 'CourseBundle', 'entity' => 'CLpView', 'field' => 'user', 'type' => 'object', 'action' => 'delete'], - ['bundle' => 'CourseBundle', 'entity' => 'CStudentPublicationComment', 'field' => 'user', 'type' => 'object', 'action' => 'delete'], - ['bundle' => 'CourseBundle', 'entity' => 'CStudentPublicationRelUser', 'field' => 'user', 'type' => 'object', 'action' => 'delete'], - ['bundle' => 'CourseBundle', 'entity' => 'CSurveyInvitation', 'field' => 'user', 'type' => 'object', 'action' => 'convert'], - ['bundle' => 'CourseBundle', 'entity' => 'CWiki', 'field' => 'userId', 'type' => 'int', 'action' => 'convert'], - ['bundle' => 'CourseBundle', 'entity' => 'CWikiMailcue', 'field' => 'userId', 'type' => 'int', 'action' => 'delete'], - ['bundle' => 'CoreBundle', 'entity' => 'ExtraFieldSavedSearch', 'field' => 'user', 'type' => 'object', 'action' => 'delete'], - ['bundle' => 'CoreBundle', 'entity' => 'GradebookCategory', 'field' => 'user', 'type' => 'object', 'action' => 'convert'], - ['bundle' => 'CoreBundle', 'entity' => 'GradebookCertificate', 'field' => 'user', 'type' => 'object', 'action' => 'delete'], - ['bundle' => 'CoreBundle', 'entity' => 'GradebookComment', 'field' => 'user', 'type' => 'object', 'action' => 'convert'], - ['bundle' => 'CoreBundle', 'entity' => 'GradebookLinkevalLog', 'field' => 'user', 'type' => 'object', 'action' => 'delete'], - ['bundle' => 'CoreBundle', 'entity' => 'GradebookResult', 'field' => 'user', 'type' => 'object', 'action' => 'delete'], - ['bundle' => 'CoreBundle', 'entity' => 'GradebookResultLog', 'field' => 'user', 'type' => 'object', 'action' => 'delete'], - ['bundle' => 'CoreBundle', 'entity' => 'GradebookScoreLog', 'field' => 'user', 'type' => 'object', 'action' => 'delete'], - ['bundle' => 'CoreBundle', 'entity' => 'Message', 'field' => 'sender', 'type' => 'object', 'action' => 'convert'], - ['bundle' => 'CoreBundle', 'entity' => 'MessageRelUser', 'field' => 'receiver', 'type' => 'object', 'action' => 'delete'], - ['bundle' => 'CoreBundle', 'entity' => 'MessageTag', 'field' => 'user', 'type' => 'object', 'action' => 'delete'], - ['bundle' => 'CoreBundle', 'entity' => 'Notification', 'field' => 'destUserId', 'type' => 'int', 'action' => 'delete'], - ['bundle' => 'CoreBundle', 'entity' => 'PageCategory', 'field' => 'creator', 'type' => 'object', 'action' => 'convert'], - // ['bundle' => 'CoreBundle', 'entity' => 'PersonalAgenda', 'field' => 'user', 'type' => 'object', 'action' => 'delete'], - ['bundle' => 'CoreBundle', 'entity' => 'Portfolio', 'field' => 'user', 'type' => 'object', 'action' => 'convert'], - ['bundle' => 'CoreBundle', 'entity' => 'PortfolioCategory', 'field' => 'user', 'type' => 'object', 'action' => 'convert'], - ['bundle' => 'CoreBundle', 'entity' => 'PortfolioComment', 'field' => 'author', 'type' => 'object', 'action' => 'convert'], - ['bundle' => 'CoreBundle', 'entity' => 'ResourceComment', 'field' => 'author', 'type' => 'object', 'action' => 'convert'], - ['bundle' => 'CoreBundle', 'entity' => 'SequenceValue', 'field' => 'user', 'type' => 'object', 'action' => 'convert'], - ['bundle' => 'CoreBundle', 'entity' => 'SessionRelCourseRelUser', 'field' => 'user', 'type' => 'object', 'action' => 'delete'], - ['bundle' => 'CoreBundle', 'entity' => 'SessionRelUser', 'field' => 'user', 'type' => 'object', 'action' => 'delete'], - ['bundle' => 'CoreBundle', 'entity' => 'SkillRelItemRelUser', 'field' => 'user', 'type' => 'object', 'action' => 'delete'], - ['bundle' => 'CoreBundle', 'entity' => 'SkillRelUser', 'field' => 'user', 'type' => 'object', 'action' => 'delete'], - ['bundle' => 'CoreBundle', 'entity' => 'SkillRelUserComment', 'field' => 'feedbackGiver', 'type' => 'object', 'action' => 'delete'], - ['bundle' => 'CoreBundle', 'entity' => 'SocialPost', 'field' => 'sender', 'type' => 'object', 'action' => 'convert'], - ['bundle' => 'CoreBundle', 'entity' => 'SocialPost', 'field' => 'userReceiver', 'type' => 'object', 'action' => 'convert'], - ['bundle' => 'CoreBundle', 'entity' => 'SocialPostAttachment', 'field' => 'insertUserId', 'type' => 'int', 'action' => 'convert'], - ['bundle' => 'CoreBundle', 'entity' => 'SocialPostFeedback', 'field' => 'user', 'type' => 'object', 'action' => 'convert'], - ['bundle' => 'CoreBundle', 'entity' => 'Templates', 'field' => 'user', 'type' => 'object', 'action' => 'convert'], - ['bundle' => 'CoreBundle', 'entity' => 'TicketAssignedLog', 'field' => 'user', 'type' => 'object', 'action' => 'convert'], - ['bundle' => 'CoreBundle', 'entity' => 'TicketCategory', 'field' => 'insertUserId', 'type' => 'int', 'action' => 'convert'], - ['bundle' => 'CoreBundle', 'entity' => 'TicketCategoryRelUser', 'field' => 'user', 'type' => 'object', 'action' => 'delete'], - ['bundle' => 'CoreBundle', 'entity' => 'TicketMessage', 'field' => 'insertUserId', 'type' => 'int', 'action' => 'convert'], - ['bundle' => 'CoreBundle', 'entity' => 'TicketMessageAttachment', 'field' => 'lastEditUserId', 'type' => 'int', 'action' => 'convert'], - ['bundle' => 'CoreBundle', 'entity' => 'TicketPriority', 'field' => 'insertUserId', 'type' => 'int', 'action' => 'convert'], - ['bundle' => 'CoreBundle', 'entity' => 'TicketProject', 'field' => 'insertUserId', 'type' => 'int', 'action' => 'convert'], - ['bundle' => 'CoreBundle', 'entity' => 'TicketProject', 'field' => 'lastEditUserId', 'type' => 'int', 'action' => 'convert'], - ['bundle' => 'CoreBundle', 'entity' => 'TrackEAccess', 'field' => 'accessUserId', 'type' => 'int', 'action' => 'delete'], - ['bundle' => 'CoreBundle', 'entity' => 'TrackEAccessComplete', 'field' => 'user', 'type' => 'object', 'action' => 'delete'], - ['bundle' => 'CoreBundle', 'entity' => 'TrackEAttempt', 'field' => 'user', 'type' => 'object', 'action' => 'delete'], - ['bundle' => 'CoreBundle', 'entity' => 'TrackECourseAccess', 'field' => 'user', 'type' => 'object', 'action' => 'delete'], - ['bundle' => 'CoreBundle', 'entity' => 'TrackEDefault', 'field' => 'defaultUserId', 'type' => 'int', 'action' => 'convert'], - ['bundle' => 'CoreBundle', 'entity' => 'TrackEDownloads', 'field' => 'downUserId', 'type' => 'int', 'action' => 'delete'], - ['bundle' => 'CoreBundle', 'entity' => 'TrackEExercise', 'field' => 'user', 'type' => 'object', 'action' => 'delete'], - ['bundle' => 'CoreBundle', 'entity' => 'TrackEExerciseConfirmation', 'field' => 'user', 'type' => 'object', 'action' => 'delete'], - ['bundle' => 'CoreBundle', 'entity' => 'TrackEHotpotatoes', 'field' => 'exeUserId', 'type' => 'int', 'action' => 'delete'], - ['bundle' => 'CoreBundle', 'entity' => 'TrackEHotspot', 'field' => 'hotspotUserId', 'type' => 'int', 'action' => 'delete'], - ['bundle' => 'CoreBundle', 'entity' => 'TrackELastaccess', 'field' => 'accessUserId', 'type' => 'int', 'action' => 'delete'], - ['bundle' => 'CoreBundle', 'entity' => 'TrackELinks', 'field' => 'linksUserId', 'type' => 'int', 'action' => 'delete'], - ['bundle' => 'CoreBundle', 'entity' => 'TrackELogin', 'field' => 'user', 'type' => 'object', 'action' => 'delete'], - ['bundle' => 'CoreBundle', 'entity' => 'TrackEOnline', 'field' => 'loginUserId', 'type' => 'int', 'action' => 'delete'], - ['bundle' => 'CoreBundle', 'entity' => 'TrackEUploads', 'field' => 'uploadUserId', 'type' => 'int', 'action' => 'delete'], - ['bundle' => 'CoreBundle', 'entity' => 'UsergroupRelUser', 'field' => 'user', 'type' => 'object', 'action' => 'convert'], - ['bundle' => 'CoreBundle', 'entity' => 'UserRelTag', 'field' => 'user', 'type' => 'object', 'action' => 'delete'], - ['bundle' => 'CoreBundle', 'entity' => 'UserRelUser', 'field' => 'user', 'type' => 'object', 'action' => 'delete'], - ]; + // Update resource nodes created by the user + $connection->executeStatement( + 'UPDATE resource_node SET creator_id = :fallbackUserId WHERE creator_id = :userId', + ['fallbackUserId' => $fallbackUser->getId(), 'userId' => $userToDelete->getId()] + ); + + // Update child resource nodes + $connection->executeStatement( + 'UPDATE resource_node SET parent_id = :fallbackParentId WHERE parent_id = :userParentId', + [ + 'fallbackParentId' => $fallbackUser->getResourceNode()?->getId(), + 'userParentId' => $userToDelete->getResourceNode()->getId(), + ] + ); + + // Relations to update or delete + $relations = $this->getRelations(); foreach ($relations as $relation) { - $entityClass = 'Chamilo\\'.$relation['bundle'].'\Entity\\'.$relation['entity']; - $repository = $em->getRepository($entityClass); - $records = $repository->findBy([$relation['field'] => $userToDelete]); - - foreach ($records as $record) { - $setter = 'set'.ucfirst($relation['field']); - if ('delete' === $relation['action']) { - $em->remove($record); - } elseif (method_exists($record, $setter)) { - $valueToSet = 'object' === $relation['type'] ? $fallbackUser : $fallbackUser->getId(); - $record->{$setter}($valueToSet); - if (method_exists($record, 'getResourceFiles')) { - foreach ($record->getResourceFiles() as $resourceFile) { - if (!$em->contains($resourceFile)) { - $em->persist($resourceFile); - } - } - } - $em->persist($record); - } + $table = $relation['table']; + $field = $relation['field']; + $action = $relation['action']; + + if ($action === 'delete') { + $connection->executeStatement( + "DELETE FROM $table WHERE $field = :userId", + ['userId' => $userToDelete->getId()] + ); + } elseif ($action === 'update') { + $connection->executeStatement( + "UPDATE $table SET $field = :fallbackUserId WHERE $field = :userId", + [ + 'fallbackUserId' => $fallbackUser->getId(), + 'userId' => $userToDelete->getId(), + ] + ); } } + } - $em->flush(); + /** + * Retrieves a list of database table relations and their corresponding actions + * to handle user resource reassignment or deletion. + */ + protected function getRelations(): array + { + return [ + ['table' => 'access_url_rel_user', 'field' => 'user_id', 'action' => 'delete'], + ['table' => 'admin', 'field' => 'user_id', 'action' => 'delete'], + ['table' => 'attempt_feedback', 'field' => 'user_id', 'action' => 'update'], + ['table' => 'chat', 'field' => 'to_user', 'action' => 'update'], + ['table' => 'chat_video', 'field' => 'to_user', 'action' => 'update'], + ['table' => 'course_rel_user', 'field' => 'user_id', 'action' => 'delete'], + ['table' => 'course_rel_user_catalogue', 'field' => 'user_id', 'action' => 'delete'], + ['table' => 'course_request', 'field' => 'user_id', 'action' => 'update'], + ['table' => 'c_attendance_result', 'field' => 'user_id', 'action' => 'delete'], + ['table' => 'c_attendance_result_comment', 'field' => 'user_id', 'action' => 'update'], + ['table' => 'c_attendance_sheet', 'field' => 'user_id', 'action' => 'delete'], + ['table' => 'c_attendance_sheet_log', 'field' => 'lastedit_user_id', 'action' => 'delete'], + ['table' => 'c_chat_connected', 'field' => 'user_id', 'action' => 'delete'], + ['table' => 'c_dropbox_category', 'field' => 'user_id', 'action' => 'update'], + ['table' => 'c_dropbox_feedback', 'field' => 'author_user_id', 'action' => 'update'], + ['table' => 'c_dropbox_person', 'field' => 'user_id', 'action' => 'update'], + ['table' => 'c_dropbox_post', 'field' => 'dest_user_id', 'action' => 'update'], + ['table' => 'c_forum_mailcue', 'field' => 'user_id', 'action' => 'delete'], + ['table' => 'c_forum_notification', 'field' => 'user_id', 'action' => 'delete'], + ['table' => 'c_forum_post', 'field' => 'poster_id', 'action' => 'update'], + ['table' => 'c_forum_thread', 'field' => 'thread_poster_id', 'action' => 'update'], + ['table' => 'c_forum_thread_qualify', 'field' => 'user_id', 'action' => 'update'], + ['table' => 'c_forum_thread_qualify_log', 'field' => 'user_id', 'action' => 'update'], + ['table' => 'c_group_rel_tutor', 'field' => 'user_id', 'action' => 'update'], + ['table' => 'c_group_rel_user', 'field' => 'user_id', 'action' => 'update'], + ['table' => 'c_lp_category_rel_user', 'field' => 'user_id', 'action' => 'delete'], + ['table' => 'c_lp_rel_user', 'field' => 'user_id', 'action' => 'delete'], + ['table' => 'c_lp_view', 'field' => 'user_id', 'action' => 'delete'], + ['table' => 'c_student_publication_comment', 'field' => 'user_id', 'action' => 'delete'], + ['table' => 'c_student_publication_rel_user', 'field' => 'user_id', 'action' => 'delete'], + ['table' => 'c_survey_invitation', 'field' => 'user_id', 'action' => 'update'], + ['table' => 'c_wiki', 'field' => 'user_id', 'action' => 'update'], + ['table' => 'c_wiki_mailcue', 'field' => 'user_id', 'action' => 'delete'], + ['table' => 'extra_field_saved_search', 'field' => 'user_id', 'action' => 'delete'], + ['table' => 'gradebook_category', 'field' => 'user_id', 'action' => 'update'], + ['table' => 'gradebook_certificate', 'field' => 'user_id', 'action' => 'delete'], + ['table' => 'gradebook_comment', 'field' => 'user_id', 'action' => 'update'], + ['table' => 'gradebook_linkeval_log', 'field' => 'user_id_log', 'action' => 'delete'], + ['table' => 'gradebook_result', 'field' => 'user_id', 'action' => 'delete'], + ['table' => 'gradebook_result_log', 'field' => 'user_id', 'action' => 'delete'], + ['table' => 'gradebook_score_log', 'field' => 'user_id', 'action' => 'delete'], + ['table' => 'message', 'field' => 'user_sender_id', 'action' => 'update'], + ['table' => 'message_rel_user', 'field' => 'user_id', 'action' => 'delete'], + ['table' => 'message_tag', 'field' => 'user_id', 'action' => 'delete'], + ['table' => 'notification', 'field' => 'dest_user_id', 'action' => 'delete'], + ['table' => 'page_category', 'field' => 'creator_id', 'action' => 'update'], + ['table' => 'portfolio', 'field' => 'user_id', 'action' => 'update'], + ['table' => 'portfolio_category', 'field' => 'user_id', 'action' => 'update'], + ['table' => 'portfolio_comment', 'field' => 'author_id', 'action' => 'update'], + ['table' => 'resource_comment', 'field' => 'author_id', 'action' => 'update'], + ['table' => 'sequence_value', 'field' => 'user_id', 'action' => 'update'], + ['table' => 'session_rel_course_rel_user', 'field' => 'user_id', 'action' => 'delete'], + ['table' => 'session_rel_user', 'field' => 'user_id', 'action' => 'delete'], + ['table' => 'skill_rel_item_rel_user', 'field' => 'user_id', 'action' => 'delete'], + ['table' => 'skill_rel_user', 'field' => 'user_id', 'action' => 'delete'], + ['table' => 'skill_rel_user_comment', 'field' => 'feedback_giver_id', 'action' => 'delete'], + ['table' => 'social_post', 'field' => 'sender_id', 'action' => 'update'], + ['table' => 'social_post', 'field' => 'user_receiver_id', 'action' => 'update'], + ['table' => 'social_post_attachments', 'field' => 'sys_insert_user_id', 'action' => 'update'], + ['table' => 'social_post_attachments', 'field' => 'sys_lastedit_user_id', 'action' => 'update'], + ['table' => 'social_post_feedback', 'field' => 'user_id', 'action' => 'update'], + ['table' => 'templates', 'field' => 'user_id', 'action' => 'update'], + ['table' => 'ticket_assigned_log', 'field' => 'user_id', 'action' => 'update'], + ['table' => 'ticket_assigned_log', 'field' => 'sys_insert_user_id', 'action' => 'update'], + ['table' => 'ticket_category', 'field' => 'sys_insert_user_id', 'action' => 'update'], + ['table' => 'ticket_category', 'field' => 'sys_lastedit_user_id', 'action' => 'update'], + ['table' => 'ticket_category_rel_user', 'field' => 'user_id', 'action' => 'delete'], + ['table' => 'ticket_message', 'field' => 'sys_insert_user_id', 'action' => 'update'], + ['table' => 'ticket_message', 'field' => 'sys_lastedit_user_id', 'action' => 'update'], + ['table' => 'ticket_message_attachments', 'field' => 'sys_insert_user_id', 'action' => 'update'], + ['table' => 'ticket_message_attachments', 'field' => 'sys_lastedit_user_id', 'action' => 'update'], + ['table' => 'ticket_priority', 'field' => 'sys_insert_user_id', 'action' => 'update'], + ['table' => 'ticket_priority', 'field' => 'sys_lastedit_user_id', 'action' => 'update'], + ['table' => 'ticket_project', 'field' => 'sys_insert_user_id', 'action' => 'update'], + ['table' => 'ticket_project', 'field' => 'sys_lastedit_user_id', 'action' => 'update'], + ['table' => 'track_e_access', 'field' => 'access_user_id', 'action' => 'delete'], + ['table' => 'track_e_access_complete', 'field' => 'user_id', 'action' => 'delete'], + ['table' => 'track_e_attempt', 'field' => 'user_id', 'action' => 'delete'], + ['table' => 'track_e_course_access', 'field' => 'user_id', 'action' => 'delete'], + ['table' => 'track_e_default', 'field' => 'default_user_id', 'action' => 'update'], + ['table' => 'track_e_downloads', 'field' => 'down_user_id', 'action' => 'delete'], + ['table' => 'track_e_exercises', 'field' => 'exe_user_id', 'action' => 'delete'], + ['table' => 'track_e_exercise_confirmation', 'field' => 'user_id', 'action' => 'delete'], + ['table' => 'track_e_hotpotatoes', 'field' => 'exe_user_id', 'action' => 'delete'], + ['table' => 'track_e_hotspot', 'field' => 'hotspot_user_id', 'action' => 'delete'], + ['table' => 'track_e_lastaccess', 'field' => 'access_user_id', 'action' => 'delete'], + ['table' => 'track_e_links', 'field' => 'links_user_id', 'action' => 'delete'], + ['table' => 'track_e_login', 'field' => 'login_user_id', 'action' => 'delete'], + ['table' => 'track_e_online', 'field' => 'login_user_id', 'action' => 'delete'], + ['table' => 'track_e_uploads', 'field' => 'upload_user_id', 'action' => 'delete'], + ['table' => 'usergroup_rel_user', 'field' => 'user_id', 'action' => 'update'], + ['table' => 'user_rel_tag', 'field' => 'user_id', 'action' => 'delete'], + ['table' => 'user_rel_user', 'field' => 'user_id', 'action' => 'delete'], + ]; } public function getFallbackUser(): ?User diff --git a/src/CoreBundle/Repository/TicketRelUserRepository.php b/src/CoreBundle/Repository/TicketRelUserRepository.php new file mode 100644 index 00000000000..5ef0fe002cd --- /dev/null +++ b/src/CoreBundle/Repository/TicketRelUserRepository.php @@ -0,0 +1,56 @@ +getEntityManager(); + + $existingSubscription = $this->findOneBy(['user' => $user, 'ticket' => $ticket]); + + if (!$existingSubscription) { + $subscription = new TicketRelUser($user, $ticket, true); + $em->persist($subscription); + $em->flush(); + } + } + + public function unsubscribeUserFromTicket(User $user, Ticket $ticket): void + { + $em = $this->getEntityManager(); + + $subscription = $this->findOneBy(['user' => $user, 'ticket' => $ticket]); + + if ($subscription) { + $em->remove($subscription); + $em->flush(); + } + } + + public function isUserSubscribedToTicket(User $user, Ticket $ticket): bool + { + return (bool) $this->findOneBy(['user' => $user, 'ticket' => $ticket]); + } + + public function getTicketFollowers(Ticket $ticket): array + { + return $this->findBy(['ticket' => $ticket]); + } +} diff --git a/src/CoreBundle/Repository/TrackEDefaultRepository.php b/src/CoreBundle/Repository/TrackEDefaultRepository.php index bfaecf3b056..9ab7d588480 100644 --- a/src/CoreBundle/Repository/TrackEDefaultRepository.php +++ b/src/CoreBundle/Repository/TrackEDefaultRepository.php @@ -75,10 +75,28 @@ public function registerTokenUsedEvent(ValidationToken $token, ?int $userId = nu $event = new TrackEDefault(); $event->setDefaultUserId($userId ?? 0); $event->setCId(null); - $event->setDefaultDate(new \DateTime()); + $event->setDefaultDate(new DateTime()); $event->setDefaultEventType('VALIDATION_TOKEN_USED'); $event->setDefaultValueType('validation_token'); - $event->setDefaultValue(\json_encode(['hash' => $token->getHash()])); + $event->setDefaultValue(json_encode(['hash' => $token->getHash()])); + $event->setSessionId(null); + + $this->_em->persist($event); + $this->_em->flush(); + } + + /** + * Registers a specific event such as ticket unsubscribe. + */ + public function registerTicketUnsubscribeEvent(int $ticketId, int $userId): void + { + $event = new TrackEDefault(); + $event->setDefaultUserId($userId); + $event->setCId($ticketId); + $event->setDefaultDate(new DateTime()); + $event->setDefaultEventType('ticket_unsubscribe'); + $event->setDefaultValueType('ticket_event'); + $event->setDefaultValue(json_encode(['user_id' => $userId, 'ticket_id' => $ticketId, 'action' => 'unsubscribe'])); $event->setSessionId(null); $this->_em->persist($event); diff --git a/src/CoreBundle/Security/Authenticator/OAuth2/AzureAuthenticator.php b/src/CoreBundle/Security/Authenticator/OAuth2/AzureAuthenticator.php index d57a88e77cd..354ba06e5c4 100644 --- a/src/CoreBundle/Security/Authenticator/OAuth2/AzureAuthenticator.php +++ b/src/CoreBundle/Security/Authenticator/OAuth2/AzureAuthenticator.php @@ -59,25 +59,19 @@ protected function userLoader(AccessToken $accessToken): User $me = $provider->get('/me', $accessToken); if (empty($me['mail'])) { - throw new UnauthorizedHttpException( - 'The mail field is empty in Azure AD and is needed to set the organisation email for this user.' - ); + throw new UnauthorizedHttpException('The mail field is empty in Azure AD and is needed to set the organisation email for this user.'); } if (empty($me['mailNickname'])) { - throw new UnauthorizedHttpException( - 'The mailNickname field is empty in Azure AD and is needed to set the unique username for this user.' - ); + throw new UnauthorizedHttpException('The mailNickname field is empty in Azure AD and is needed to set the unique username for this user.'); } if (empty($me['objectId'])) { - throw new UnauthorizedHttpException( - 'The id field is empty in Azure AD and is needed to set the unique Azure ID for this user.' - ); + throw new UnauthorizedHttpException('The id field is empty in Azure AD and is needed to set the unique Azure ID for this user.'); } $userId = $this->azureHelper->registerUser($me); return $this->userRepository->find($userId); } -} \ No newline at end of file +} diff --git a/src/CoreBundle/Service/CourseService.php b/src/CoreBundle/Service/CourseService.php index 6dd04a10d71..ddfe254ad60 100644 --- a/src/CoreBundle/Service/CourseService.php +++ b/src/CoreBundle/Service/CourseService.php @@ -647,9 +647,9 @@ public function useTemplateAsBasisIfRequired($courseCode, $courseTemplate): void $cr->set_file_option(); $cr->restore($courseCode); } catch (Exception $e) { - error_log('Error during course template application: ' . $e->getMessage()); + error_log('Error during course template application: '.$e->getMessage()); } catch (Throwable $t) { - error_log('Unexpected error during course template application: ' . $t->getMessage()); + error_log('Unexpected error during course template application: '.$t->getMessage()); } } } diff --git a/src/CoreBundle/ServiceHelper/AuthenticationConfigHelper.php b/src/CoreBundle/ServiceHelper/AuthenticationConfigHelper.php index 270b8bd9d1f..89509ae067a 100644 --- a/src/CoreBundle/ServiceHelper/AuthenticationConfigHelper.php +++ b/src/CoreBundle/ServiceHelper/AuthenticationConfigHelper.php @@ -50,7 +50,7 @@ public function getEnabledProviders(?AccessUrl $url = null): array $enabledProviders[] = [ 'name' => $providerName, 'title' => $providerParams['title'] ?? u($providerName)->title(), - 'url' => $this->urlGenerator->generate(sprintf("chamilo.oauth2_%s_start", $providerName)), + 'url' => $this->urlGenerator->generate(\sprintf('chamilo.oauth2_%s_start', $providerName)), ]; } } @@ -77,7 +77,7 @@ private function getProvidersForUrl(?AccessUrl $url): array public function getProviderOptions(string $providerType, array $config): array { - $defaults = match($providerType) { + $defaults = match ($providerType) { 'generic' => [ 'clientId' => $config['client_id'], 'clientSecret' => $config['client_secret'], @@ -126,6 +126,6 @@ public function getProviderOptions(string $providerType, array $config): array ], }; - return array_filter($defaults, fn($value) => $value !== null); + return array_filter($defaults, fn ($value) => null !== $value); } } diff --git a/src/CoreBundle/ServiceHelper/AzureAuthenticatorHelper.php b/src/CoreBundle/ServiceHelper/AzureAuthenticatorHelper.php index 8fa1a11563e..d25200d9f37 100644 --- a/src/CoreBundle/ServiceHelper/AzureAuthenticatorHelper.php +++ b/src/CoreBundle/ServiceHelper/AzureAuthenticatorHelper.php @@ -195,4 +195,4 @@ private function formatUserData( $extra, ]; } -} \ No newline at end of file +} diff --git a/src/CoreBundle/ServiceHelper/ValidationTokenHelper.php b/src/CoreBundle/ServiceHelper/ValidationTokenHelper.php index b04549a61af..a25cc348df5 100644 --- a/src/CoreBundle/ServiceHelper/ValidationTokenHelper.php +++ b/src/CoreBundle/ServiceHelper/ValidationTokenHelper.php @@ -8,10 +8,15 @@ use Chamilo\CoreBundle\Entity\ValidationToken; use Chamilo\CoreBundle\Repository\ValidationTokenRepository; +use InvalidArgumentException; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; class ValidationTokenHelper { + // Define constants for the types + public const TYPE_TICKET = 1; + public const TYPE_USER = 2; + public function __construct( private readonly ValidationTokenRepository $tokenRepository, private readonly UrlGeneratorInterface $urlGenerator, @@ -22,6 +27,7 @@ public function generateLink(int $type, int $resourceId): string $token = new ValidationToken($type, $resourceId); $this->tokenRepository->save($token, true); + // Generate a validation link with the token's hash return $this->urlGenerator->generate('validate_token', [ 'type' => $this->getTypeString($type), 'hash' => $token->getHash(), @@ -31,18 +37,18 @@ public function generateLink(int $type, int $resourceId): string public function getTypeId(string $type): int { return match ($type) { - 'ticket' => 1, - 'user' => 2, - default => throw new \InvalidArgumentException('Unrecognized validation type'), + 'ticket' => self::TYPE_TICKET, + 'user' => self::TYPE_USER, + default => throw new InvalidArgumentException('Unrecognized validation type'), }; } private function getTypeString(int $type): string { return match ($type) { - 1 => 'ticket', - 2 => 'user', - default => throw new \InvalidArgumentException('Unrecognized validation type'), + self::TYPE_TICKET => 'ticket', + self::TYPE_USER => 'user', + default => throw new InvalidArgumentException('Unrecognized validation type'), }; } } diff --git a/src/CoreBundle/Settings/ExerciseSettingsSchema.php b/src/CoreBundle/Settings/ExerciseSettingsSchema.php index 33d2bea02fa..74b36a65643 100644 --- a/src/CoreBundle/Settings/ExerciseSettingsSchema.php +++ b/src/CoreBundle/Settings/ExerciseSettingsSchema.php @@ -44,7 +44,6 @@ public function buildSettings(AbstractSettingsBuilder $builder): void 'hide_free_question_score' => 'false', 'hide_user_info_in_quiz_result' => 'false', 'exercise_attempts_report_show_username' => 'false', - 'allow_exercise_auto_launch' => 'false', 'disable_clean_exercise_results_for_teachers' => 'true', 'show_exercise_question_certainty_ribbon_result' => 'false', 'quiz_results_answers_report' => 'false', @@ -121,7 +120,6 @@ public function buildForm(FormBuilderInterface $builder): void ->add('hide_free_question_score', YesNoType::class) ->add('hide_user_info_in_quiz_result', YesNoType::class) ->add('exercise_attempts_report_show_username', YesNoType::class) - ->add('allow_exercise_auto_launch', YesNoType::class) ->add('disable_clean_exercise_results_for_teachers', YesNoType::class) ->add('show_exercise_question_certainty_ribbon_result', YesNoType::class) ->add('quiz_results_answers_report', YesNoType::class) diff --git a/src/CoreBundle/Settings/SettingsManager.php b/src/CoreBundle/Settings/SettingsManager.php index 7dfb268a697..b4db2b78315 100644 --- a/src/CoreBundle/Settings/SettingsManager.php +++ b/src/CoreBundle/Settings/SettingsManager.php @@ -12,7 +12,6 @@ use Doctrine\ORM\EntityManager; use Doctrine\ORM\EntityRepository; use InvalidArgumentException; -use Sylius\Bundle\SettingsBundle\Event\SettingsEvent; use Sylius\Bundle\SettingsBundle\Manager\SettingsManagerInterface; use Sylius\Bundle\SettingsBundle\Model\Settings; use Sylius\Bundle\SettingsBundle\Model\SettingsInterface; @@ -21,7 +20,6 @@ use Sylius\Bundle\SettingsBundle\Schema\SettingsBuilder; use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpFoundation\RequestStack; -use Symfony\Component\Validator\ConstraintViolationListInterface; use Symfony\Component\Validator\Exception\ValidatorException; use const ARRAY_FILTER_USE_KEY; @@ -994,7 +992,7 @@ private function fixCategory($variable, $defaultCategory) private function transformToString($value): string { - if (is_array($value)) { + if (\is_array($value)) { return implode(',', $value); } @@ -1002,11 +1000,11 @@ private function transformToString($value): string return (string) $value->getId(); } - if (is_bool($value)) { + if (\is_bool($value)) { return $value ? 'true' : 'false'; } - if (is_null($value)) { + if (null === $value) { return ''; } diff --git a/src/CoreBundle/Tool/ToolChain.php b/src/CoreBundle/Tool/ToolChain.php index ad6ae441e6d..876723e6d3c 100644 --- a/src/CoreBundle/Tool/ToolChain.php +++ b/src/CoreBundle/Tool/ToolChain.php @@ -177,10 +177,10 @@ public function addToolsInCourse(Course $course): Course continue; } - $visibility = in_array($tool->getTitle(), $activeToolsOnCreate, true); + $visibility = \in_array($tool->getTitle(), $activeToolsOnCreate, true); $linkVisibility = $visibility ? ResourceLink::VISIBILITY_PUBLISHED : ResourceLink::VISIBILITY_DRAFT; - if (in_array($tool->getTitle(), ['course_setting', 'course_maintenance'])) { + if (\in_array($tool->getTitle(), ['course_setting', 'course_maintenance'])) { $linkVisibility = ResourceLink::VISIBILITY_DRAFT; } diff --git a/src/CourseBundle/Entity/CLpCategoryRelUser.php b/src/CourseBundle/Entity/CLpCategoryRelUser.php index de65336ac83..3de11033bdd 100644 --- a/src/CourseBundle/Entity/CLpCategoryRelUser.php +++ b/src/CourseBundle/Entity/CLpCategoryRelUser.php @@ -30,8 +30,8 @@ class CLpCategoryRelUser implements Stringable protected CLpCategory $category; #[ORM\ManyToOne(targetEntity: User::class)] - #[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'SET NULL')] - protected User $user; + #[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')] + protected ?User $user = null; public function __toString(): string { diff --git a/tests/CoreBundle/Repository/SkillRepositoryTest.php b/tests/CoreBundle/Repository/SkillRepositoryTest.php index d5ada8e9a2b..5a4a3a0d58e 100644 --- a/tests/CoreBundle/Repository/SkillRepositoryTest.php +++ b/tests/CoreBundle/Repository/SkillRepositoryTest.php @@ -9,8 +9,8 @@ use Chamilo\CoreBundle\Entity\Asset; use Chamilo\CoreBundle\Entity\GradebookCategory; use Chamilo\CoreBundle\Entity\Level; -use Chamilo\CoreBundle\Entity\SkillLevelProfile; use Chamilo\CoreBundle\Entity\Skill; +use Chamilo\CoreBundle\Entity\SkillLevelProfile; use Chamilo\CoreBundle\Entity\SkillProfile; use Chamilo\CoreBundle\Entity\SkillRelCourse; use Chamilo\CoreBundle\Entity\SkillRelGradebook;
"; + echo "Title: $titleEmail\n"; + echo "Message Preview:\n\n"; + + foreach ($recipients as $recipientId => $recipientName) { + $unsubscribeLink = self::generateUnsubscribeLink($ticketId, $recipientId); + $finalMessageEmail = $messageEmailBase; + $finalMessageEmail .= ''; + $finalMessageEmail .= '' . get_lang('To unsubscribe from notifications, click here') . ': '; + $finalMessageEmail .= '' . $unsubscribeLink . ''; + + echo "------------------------------------\n"; + echo "Recipient: $recipientName (User ID: $recipientId)\n"; + echo "Message:\n$finalMessageEmail\n"; + echo "------------------------------------\n\n"; } + + echo "