17 Commits

Author SHA1 Message Date
xesmyd ac648ef29d Upgrade 1-11.38 2026-03-30 14:10:30 +02:00
xesmyd f2a7e6d1fc upgrade 2025-08-14 22:51:34 +02:00
xesmyd cc0600450b upgrade 2025-08-14 22:47:59 +02:00
xesmyd 59bd7154fb upgrade
Behat tests 1.11.x 🐞 / PHP 7.4 Test on ubuntu-latest (push) Has been cancelled
PHP-CS-Fixer / composer_install (7.4) (push) Has been cancelled
2025-08-14 22:47:23 +02:00
xesmyd 0f518027b4 upgrade 2025-08-14 22:46:17 +02:00
xesmyd 791cb748ab upgrade 2025-08-14 22:44:47 +02:00
xesmyd 8ce45119b6 upgrade 2025-08-14 22:41:49 +02:00
xesmyd 2de81ccc46 upgrade 2025-08-14 22:40:35 +02:00
xesmyd 5403f346e3 upgrade 2025-08-14 22:39:38 +02:00
xesmyd 3641e93527 upgrade 2025-08-14 22:37:50 +02:00
xesmyd fb6d5d5926 upgrade 2025-08-14 22:37:11 +02:00
xesmyd 1c9e806972 upgrade 2025-08-14 22:36:35 +02:00
xesmyd f00a2c32bb upgrade 2025-08-14 22:35:49 +02:00
xesmyd 03baefa35c bin upgrade 2025-08-14 22:34:55 +02:00
xesmyd 2fe1c43b3e upgrade app 2025-08-14 22:33:03 +02:00
xesmyd d862a535e5 limpieza antes de upgrade 2025-08-14 22:25:42 +02:00
xesmyd ca1a529e28 Actualización 2025-04-10 13:59:00 +02:00
25368 changed files with 101805 additions and 2211517 deletions
+55
View File
@@ -0,0 +1,55 @@
# Cache directories
app/cache/*
!app/cache/.gitkeep
!app/cache/.htaccess
app/logs/*
!app/logs/.gitkeep
# Chamilo configuration
/app/config/parameters.yml
/app/config/add_course.conf.php
/app/config/auth.conf.php
/app/config/course_info.conf.php
/app/config/events.conf.php
/app/config/mail.conf.php
/app/config/profile.conf.php
/app/config/configuration.php
# Courses
app/courses/*
!app/courses/proxy.php
# Home
app/home/*
# Upload content
app/upload/*
.php_cs.cache
# Logs and databases #
*.log
# IDE settings
.idea
.idea/*
.idea/dictionaries/*
.idea/cssxfire.xml
*.orig
nbproject/*
# Plugins config files
plugin/bbb/config.vm.php
# Cron temp files
main/cron/incoming/*
plugin/vchamilo/templates/*
# Stuff updated through composer - Remove just before release
/vendor
web/
node_modules
yarn.lock
+13 -17
View File
@@ -28,30 +28,28 @@ RewriteRule ^courses/([^/]+)/index.php$ main/course_home/course_home.php?cDir=$1
# Rewrite everything in the scorm folder of a course to the download script
# except JS, CSS and some image files, which can be served directly
RewriteRule ^courses/([^/]+)/scorm/(.*([\.js|\.css|\.png|\.jpg|\.jpeg|\.gif]))$ app/courses/$1/scorm/$2 [QSA,L]
RewriteRule ^courses/([^/]+)/scorm/(.*\.(js|css|png|jpg|jpeg|gif))$ app/courses/$1/scorm/$2 [QSA,L]
RewriteRule ^courses/([^/]+)/scorm/(.*)$ main/document/download_scorm.php?doc_url=/$2&cDir=$1 [QSA,L]
# Rewrite everything in the document folder of a course to the download script
# Except certificate resources, which might need to be accessible publicly to all
RewriteRule ^courses/([^/]+)/document/certificates/(.*)$ app/courses/$1/document/certificates/$2 [QSA,L]
# Note : since version 2.4.38-3 of Apache a security fix had a side effect that made redirection with space not to work.
# Note : since version 2.4.38-3 of Apache a security fix had a side effect that broke redirections with spaces.
# To fix this issue we did not have a common syntaxis but it work with one of those 2 options :
# changing at the end of the following line [QSA,L] for [QSA,L,B=\x20?] or for "[QSA,L,B= ?,BNP]"
RewriteRule ^courses/([^/]+)/document/(.*)$ main/document/download.php?doc_url=/$2&cDir=$1 [QSA,L]
# changing at the end of the following line [QSA,L] for [QSA,L,B=\x20?] or for "[QSA,L,B= ?,BNP]" (with the quotes)
# We have opted to use the latter by default. If you are encountering "Not found" issues when entering course homepages,
# you might want to try either of the other 2 forms.
RewriteRule ^courses/([^/]+)/document/(.*)$ main/document/download.php?doc_url=/$2&cDir=$1 "[QSA,L,B= ?,BNP]"
# Optimize load of custom per-course icons in courses (avoid download_uploaded_files.php)
RewriteRule ^courses/([^/]+)/upload/course_home_icons/(.*([\.js|\.css|\.png|\.jpg|\.jpeg|\.gif]))$ app/courses/$1/upload/course_home_icons/$2 [QSA,L]
RewriteRule ^courses/([^/]+)/upload/course_home_icons/(.*\.(js|css|png|jpg|jpeg|gif))$ app/courses/$1/upload/course_home_icons/$2 [QSA,L]
# Course upload files
# Note : since version 2.4.38-3 of Apache a security fix had a side effect that made redirection with space not to work.
# To fix this issue we did not have a common syntaxis but it work with one of those 2 options :
# changing at the end of the following line [QSA,L] for [QSA,L,B=\x20?] or for "[QSA,L,B= ?,BNP]"
RewriteRule ^courses/([^/]+)/upload/([^/]+)/(.*)$ main/document/download_uploaded_files.php?code=$1&type=$2&file=$3 [QSA,L]
# See note on line 37
RewriteRule ^courses/([^/]+)/upload/([^/]+)/(.*)$ main/document/download_uploaded_files.php?code=$1&type=$2&file=$3 "[QSA,L,B= ?,BNP]"
# Rewrite everything in the work folder
# Note : since version 2.4.38-3 of Apache a security fix had a side effect that made redirection with space not to work.
# To fix this issue we did not have a common syntaxis but it work with one of those 2 options :
# changing at the end of the following line [QSA,L] for [QSA,L,B=\x20?] or for "[QSA,L,B= ?,BNP]"
RewriteRule ^courses/([^/]+)/work/(.*)$ main/work/download.php?file=work/$2&cDir=$1 [QSA,L]
# See note on line 37
RewriteRule ^courses/([^/]+)/work/(.*)$ main/work/download.php?file=work/$2&cDir=$1 "[QSA,L,B= ?,BNP]"
RewriteRule ^courses/([^/]+)/course-pic85x85.png$ main/inc/ajax/course.ajax.php?a=get_course_image&code=$1&image=course_image_source [QSA,L]
RewriteRule ^courses/([^/]+)/course-pic.png$ main/inc/ajax/course.ajax.php?a=get_course_image&code=$1&image=course_image_large_source [QSA,L]
@@ -87,10 +85,8 @@ RewriteRule ^service/(\d{1,})$ plugin/buycourses/src/service_information.php?ser
RewriteRule ^lti/os$ plugin/ims_lti/outcome_service.php [L]
# Deny direct access to user my files
# Note : since version 2.4.38-3 of Apache a security fix had a side effect that made redirection with space not to work.
# To fix this issue we did not have a common syntaxis but it work with one of those 2 options :
# changing at the end of the following line [QSA,L] for [QSA,L,B=\x20?] or for "[QSA,L,B= ?,BNP]"
RewriteRule ^app/upload/users/([^/]+)/([^/]+)/my_files/(.*)$ main/social/download_my_files.php?user_id=$2&file=$3 [QSA,L]
# See note on line 37
RewriteRule ^app/upload/users/([^/]+)/([^/]+)/my_files/(.*)$ main/social/download_my_files.php?user_id=$2&file=$3 "[QSA,L,B= ?,BNP]"
# Deny access
RewriteRule ^(tests|.git) - [F,L,NC]
+1 -1
View File
@@ -204,4 +204,4 @@ 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.
# Documentation
For more information on Chamilo, visit https://1.11.chamilo.org/documentation/index.html
For more information on Chamilo, visit https://11.chamilo.org/documentation/index.html
+17
View File
@@ -9615,6 +9615,23 @@ ul.dropdown-menu.inner > li > a {
padding-bottom: 133.33%;
}
.dropdown-menu li a + .dropdown-menu {
display: block;
left: 0;
top: 0;
box-shadow: none;
margin-top: 0;
border: 0;
position: relative;
padding: 0;
}
.dropdown-menu li a + .dropdown-menu li a {
padding-top: 6px;
padding-bottom: 6px;
padding-right: 9px;
}
/* CSS Responsive */
@media (min-width: 1025px) and (max-width: 1200px) {
.sidebar-scorm {
+5 -6
View File
@@ -48,7 +48,6 @@
"ext-zlib": "*",
"angelfqc/vimeo-api": "2.0.6",
"apereo/phpcas": "^1.6",
"brumann/polyfill-unserialize": "^1.0",
"chamilo/pclzip": "~2.8",
"clue/graph": "~0.9.0",
"culqi/culqi-php": "1.3.4",
@@ -62,14 +61,14 @@
"enshrined/svg-sanitize": "^0.16.0",
"essence/essence": "2.6.1",
"ezyang/htmlpurifier": "~4.9",
"facebook/php-sdk-v4": "~5.0",
"facebook/graph-sdk": "^5.7",
"firebase/php-jwt": "~5.0",
"gedmo/doctrine-extensions": "~2.3",
"graphp/algorithms": "~0.8.0",
"graphp/graphviz": "~0.2.0",
"guzzlehttp/guzzle": "~6.0",
"h5p/h5p-core": "*",
"imagine/imagine": "0.6.3",
"ircmaxell/password-compat": "~1.0.4",
"jbroadway/urlify": "1.1.0-stable",
"jeroendesloovere/vcard": "~1.7",
@@ -79,7 +78,7 @@
"knplabs/gaufrette": "~0.3",
"knplabs/knp-components": "~1.3",
"league/csv": "~8.0",
"media-alchemyst/media-alchemyst": "~0.5",
"symfony/process": "~3.0|~4.0",
"michelf/php-markdown": "~1.7",
"monolog/monolog": "~1.0",
"mpdf/mpdf": "^8.0",
@@ -93,8 +92,8 @@
"php-xapi/repository-api": "dev-master as 0.3.1",
"php-xapi/repository-doctrine": "dev-master",
"php-xapi/symfony-serializer": "2.1.0 as 2.0",
"phpmailer/phpmailer": "~6.1",
"phpoffice/phpexcel": "~1.8",
"phpmailer/phpmailer": "^6.8",
"phpoffice/phpspreadsheet": "^1.28",
"phpoffice/phpword": "~0.14",
"phpseclib/phpseclib": "^2.0",
"robrichards/xmlseclibs": "3.0.*",
Generated
+1573 -1797
View File
File diff suppressed because it is too large Load Diff
+738
View File
@@ -110,6 +110,723 @@
</table>
<div class="version" aria-label="1.11.38">
<a id="1.11.38"></a>
<h1>Chamilo 1.11.38 - Pontorson, 23/03/2026</h1>
<h3>Release notes - summary</h3>
<p>Chamilo 1.11.38 is a security and bugfix release on top of 1.11.36. For any significant change, please check the 1.11.30 release notes.</p>
<h3>Release name</h3>
<p><a href="https://fr.wikipedia.org/wiki/Pontorson_(commune_d%C3%A9l%C3%A9gu%C3%A9e)">Pontorson</a> is a former commune in the Manche department in Normandy, France. It is known as the gateway to the Mont Saint-Michel bay.</p>
<h3>Security fixes</h3>
<ul aria-live="off">
<li>[2026-03-23] (<a href="https://github.com/chamilo/chamilo-lms/commit/22b1cb1c609b643765c88654155aba27070c927e">22b1cb1c</a>) Security: Improve XML parsing by adding LIBXML_NONET and better error handling to prevent XXE attacks</li>
<li>[2026-03-23] (<a href="https://github.com/chamilo/chamilo-lms/commit/e7400dd840586ae134b286d0a2374f3d269a9a9d">e7400dd8</a>) Security: Replace weak API key generation with cryptographically secure random keys using random_bytes</li>
<li>[2026-03-23] (<a href="https://github.com/chamilo/chamilo-lms/commit/750a45312a0d5c3ad60dbfbd0d959ca40be4a18c">750a4531</a> - <a href="https://github.com/chamilo/chamilo-lms/security/advisories/GHSA-f27g-66gq-g7v2">GHSA-f27g-66gq-g7v2</a>) Security: Fix weak password recovery token generation - deterministic SHA1 replaced by cryptographic random token with expiry</li>
<li>[2026-03-23] (<a href="https://github.com/chamilo/chamilo-lms/commit/4a119f93abbfba6fe833580f2463c8d4afa500c2">4a119f93</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/6078">GH#6078</a>) Security: Restrict non-admin users from accessing GET_USER_INFO_FROM_USERNAME REST API action</li>
<li>[2026-03-23] (<a href="https://github.com/chamilo/chamilo-lms/commit/4efb5ee8ed849ca147ca1fe7472ef7b98db17bff">4efb5ee8</a>) Security: Avoid information disclosure through direct access to .tpl template files</li>
<li>[2026-03-23] (<a href="https://github.com/chamilo/chamilo-lms/commit/0acf8a196307c66c049f97f5ff76cf21c4a08127">0acf8a19</a>) Security: Restrict non-admin users from modifying admin-only user fields in REST API</li>
<li>[2026-03-23] (<a href="https://github.com/chamilo/chamilo-lms/commit/9748f1ffbdb8b6dc84c0e0591c9d3c1d92e21c00">9748f1ff</a>) Security: Add filtering of .pht files to prevent extension-based upload filter bypass</li>
<li>[2026-03-23] (<a href="https://github.com/chamilo/chamilo-lms/commit/6331d051b4468deb5830c01d1e047c5e5cf2c74f">6331d051</a> - <a href="https://github.com/chamilo/chamilo-lms/security/advisories/GHSA-3rv7-9fhx-j654">GHSA-3rv7-9fhx-j654</a>) Security: Fix IDOR in learning path progress saving endpoint</li>
<li>[2026-03-23] (<a href="https://github.com/chamilo/chamilo-lms/commit/d3355d7873c7e5b907c5fa84cbd5d9b62ed33e51">d3355d78</a>) Security: Remove chained unauthenticated RCE in main/install/ scripts</li>
<li>[2026-03-23] (<a href="https://github.com/chamilo/chamilo-lms/commit/02f82ba627fd9831b2bb7b01df3a2d3cdf44784a">02f82ba6</a>) Security: Remove configuration file update vector in install scripts</li>
<li>[2026-03-23] (<a href="https://github.com/chamilo/chamilo-lms/commit/5b0531d0c84fa0cca7f8a5e2f416fc009591e17a">5b0531d0</a>) Security: Remove chained RCE in main/install/ scripts via $GLOBALS injection and unsanitized config writes</li>
<li>[2026-03-12] (<a href="https://github.com/chamilo/chamilo-lms/commit/8cbe660de267f2b6ed625433bdfcf38dee8752b4">8cbe660d</a>) Security: Remove unused updateSound method from exercise.class.php</li>
<li>[2026-03-12] (<a href="https://github.com/chamilo/chamilo-lms/commit/b005b3d3e76cf6eafc03e15ac445ceff089551c0">b005b3d3</a>) Security: Validate and sanitize page parameter in session course edit to prevent unauthorized redirects</li>
<li>[2026-03-12] (<a href="https://github.com/chamilo/chamilo-lms/commit/63e1e6d3d717bd537c7c61719416da35aaa658dd">63e1e6d3</a>) Security: Strengthen evaluation editing logic by adding course ownership and ID validation</li>
<li>[2026-03-12] (<a href="https://github.com/chamilo/chamilo-lms/commit/3b03306d1a0301a81b9284e86893b27f518ab151">3b03306d</a>) Security: Add evaluation ID validation in gradebook result operations to prevent unauthorized actions</li>
<li>[2026-03-12] (<a href="https://github.com/chamilo/chamilo-lms/commit/3597b19b73d73d681e4fb503285e9bbfe71714bf">3597b19b</a>) Security: Sanitize shell command inputs using escapeshellarg to prevent command injection</li>
<li>[2026-03-09] (<a href="https://github.com/chamilo/chamilo-lms/commit/ea6b7b7e90580c9b01dc4bcafe4ad737061e0ead">ea6b7b7e</a>) Security: Add URL safety checks to prevent SSRF attacks</li>
<li>[2026-03-09] (<a href="https://github.com/chamilo/chamilo-lms/commit/4dddcc19d36119da27b7c49eb84a035800abae78">4dddcc19</a>) Security: Prevent path traversal attempts in HotPotatoes exercises</li>
<li>[2026-03-08] (<a href="https://github.com/chamilo/chamilo-lms/commit/f968082b991d08a71718d5a75ecfef0c98e67ec9">f968082b</a>) Security: Add additional safety warning to configuration.php setting 'plugin_upload_enable'</li>
</ul>
<h3>Notable new Features</h3>
<h4>For end-users, teachers and Chamilo admins</h4>
These features are immediately available to users through the web interface.<br />
<ul aria-live="off">
<li>No notable new feature</li>
</ul>
<h4>For developers and sysadmins</h4>
Although most features here will be used by teachers or Chamilo admins, they require sysadmin privileges to enable them on the server.
<ul aria-live="off">
<li>[2026-03-09] (<a href="https://github.com/chamilo/chamilo-lms/commit/ce0192c62e48c9d9474d915c541b3274844afbf9">ce0192c6</a>) Learnpath: Deprecate and disable AICC support functionality</li>
</ul>
<h3>Improvements (minor features) and debug</h3>
In reverse chronological order...
<ul aria-live="off">
<li>[2026-03-14] (<a href="https://github.com/chamilo/chamilo-lms/commit/b20b1f950195007e170f54a6a599cd0646f70c40">b20b1f95</a> - <a href="https://task.beeznest.com/issues/21977">BT#21977</a>) Course: Fix Moodle export titles, visibility and embedded images</li>
<li>[2026-03-11] (<a href="https://github.com/chamilo/chamilo-lms/commit/2ab28ec5a61b7eed34c0e4222dff03dcdfc5e6fa">2ab28ec5</a> - <a href="https://task.beeznest.com/issues/23292">BT#23292</a>) Announcement: Fix destination group select to only have the current session groups</li>
<li>[2026-03-09] (<a href="https://github.com/chamilo/chamilo-lms/commit/2b85c8c9822b035a37ffcb7f2559e15eabb3a778">2b85c8c9</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/7673">GH#7673</a>) Social: Fix missing session_id parameter and add language management</li>
<li>[2026-03-05] (<a href="https://github.com/chamilo/chamilo-lms/commit/cc1a341c9fcdc4e4e377980c2c880d77181e7929">cc1a341c</a> - <a href="https://task.beeznest.com/issues/21977">BT#21977</a>) Course: Improve Moodle export for quiz questions and embedded files</li>
</ul>
<h3>Stylesheets and theming</h3>
<ul aria-live="off">
<li>No notable style change</li>
</ul>
<h3>Web services</h3>
<ul aria-live="off">
<li>No notable change</li>
</ul>
<h3>Removals</h3>
<ul aria-live="off">
<li>AICC/HACP support has been deprecated and disabled (see features above)</li>
</ul>
<h3>Known issues</h3>
<ul aria-live="off">
<li>No notable known issue</li>
</ul>
</div>
<div class="version" aria-label="1.11.36">
<a id="1.11.36"></a>
<h1>Chamilo 1.11.36 - Penzance, 08/03/2026</h1>
<h3>Release notes - summary</h3>
<p>Chamilo 1.11.36 is mostly a security release on top of 1.11.34. For any significant change, please check the 1.11.30 release notes.</p>
<h3>Release name</h3>
<p><a href="https://grokipedia.com/page/Penzance">Penzance</a> is a coastal town, civil parish, and port in Cornwall, England, located on the Penwith peninsula as the largest town in West Cornwall and the westernmost major settlement on the British mainland.</p>
<h3>Security fixes</h3>
<ul aria-live="off">
<li>[2026-03-06] (<a href="https://github.com/chamilo/chamilo-lms/commit/31b20f4123343efd774be790d49924bebc52bbd4">31b20f41</a>) Security: Fix authenticated XSS in session categories See advisory GHSA-qg5f-gq95-9vhq reported by @elliSzAt</li>
<li>[2026-03-06] (<a href="https://github.com/chamilo/chamilo-lms/commit/ede59ad0a0814c0cfa0c2cad029863cf8e54343b">ede59ad0</a>) Security: Fix authenticated XSS in session categories See advisory GHSA-qg5f-gq95-9vhq reported by @elliSzAt</li>
<li>[2026-03-06] (<a href="https://github.com/chamilo/chamilo-lms/commit/6ecb6ace3e827b147a2221730679c2a39e79b451">6ecb6ace</a>) Security: Fix authenticated XSS in session categories - reported by @elliSzAt</li>
<li>[2026-03-06] (<a href="https://github.com/chamilo/chamilo-lms/commit/f2045b746d4b4beae77fa3bebce201557a79a590">f2045b74</a>) Security: Prevent net-null double filtering of dates in statistics. Avoids possible SQL injection by admin role - Reported by @elliSzAt</li>
<li>[2026-03-06] (<a href="https://github.com/chamilo/chamilo-lms/commit/5530d8b4502a27e84d7ea291bcf74ff729564b1e">5530d8b4</a>) Security: Fix user enumeration vulnerability on password reminder page - reported by Joshua Chan (@popcorn94)</li>
<li>[2026-03-06] (<a href="https://github.com/chamilo/chamilo-lms/commit/99e7358fb429e8ff221aea71ec17df95c7bf403e">99e7358f</a>) Security: Fix authenticated RCE in plugin - Reported by @DhiyaneshDK</li>
<li>[2026-03-06] (<a href="https://github.com/chamilo/chamilo-lms/commit/f968082b991d08a71718d5a75ecfef0c98e67ec9">f968082b</a>) Security: Add additional safety warning to configuration.php setting 'plugin_upload_enable' as it bears considerable risks</li>
</ul>
<h3>Notable new Features</h3>
<h4>For end-users, teachers and Chamilo admins</h4>
These features are immediately available to users through the web interface.<br />
<ul aria-live="off">
<li>[2026-03-05] (<a href="https://github.com/chamilo/chamilo-lms/commit/5d17107450c3f8ce0bf0bd42c5b11dd1d5651015">5d171074</a> - <a href="https://task.beeznest.com/issues/23282">BT#23282</a>) Admin: Add updates to track_e_access, track_e_access_complete (if it exists) and track_e_downloads to user_move_stats.php</li>
<li>[2026-03-05] (<a href="https://github.com/chamilo/chamilo-lms/commit/f7a7203a0f4a80fcec562429931805a2706dc10d">f7a7203a</a> - <a href="https://task.beeznest.com/issues/23282">BT#23282</a>) Admin: Add unsubscription of user from previous session in user_move_stats.php if there was only one course in that session</li>
</ul>
<h4>For developers and sysadmins</h4>
Although most features here will be used by teachers or Chamilo admins, they require sysadmin privileges to enable them on the server.
<ul aria-live="off">
</ul>
<h3>Improvements (minor features) and debug</h3>
In reverse chronological order...
<ul aria-live="off">
<li>[2026-03-06] (<a href="https://github.com/chamilo/chamilo-lms/commit/41d285db2ac989fbc40b074c34eb13d9f51831e3">41d285db</a> - <a href="https://task.beeznest.com/issues/23289">BT#23289</a>) Forum: Fix quotes in SQL query</li>
<li>[2026-03-06] (<a href="https://github.com/chamilo/chamilo-lms/commit/94679333b33093a168c431a029d4bc1ea90abb20">94679333</a> - <a href="https://task.beeznest.com/issues/23289">BT#23289</a>) Fix image path conversion to calculate size with `api_getimagesize` function</li>
<li>[2026-03-06] (<a href="https://github.com/chamilo/chamilo-lms/commit/7667a4ee04b94b9795e7fee441f06ee59940cfe8">7667a4ee</a>) Increase session category list pagination limit from 1 to 20</li>
<li>[2026-03-06] (<a href="https://github.com/chamilo/chamilo-lms/commit/80e1a18c981057f975cf5ef72828c3b7b2df5c93">80e1a18c</a> - <a href="https://task.beeznest.com/issues/23269">BT#23269</a>) Internal: Fix excel export after update of the generation library</li>
<li>[2026-03-05] (<a href="https://github.com/chamilo/chamilo-lms/commit/f8d8a9b7be8f523c190186f63b739e80a14186e8">f8d8a9b7</a> - <a href="https://task.beeznest.com/issues/23219">BT#23219</a>) Learnpath: Fix class name casing for `learnpath` instantiations</li>
<li>[2026-03-05] (<a href="https://github.com/chamilo/chamilo-lms/commit/20ac2896d2fd69d82a03bf00e2403856957503ac">20ac2896</a> - <a href="https://task.beeznest.com/issues/23282">BT#23282</a>) Script: Add script to fix bad user session data move</li>
<li>[2026-03-03] (<a href="https://github.com/chamilo/chamilo-lms/commit/e7cf4803875b48c9376076efadf2f6e516b45cbe">e7cf4803</a> - <a href="https://task.beeznest.com/issues/22393">BT#22393</a>) Exercise: fix radar feedback that was not showing on the result page</li>
<li>[2026-03-03] (<a href="https://github.com/chamilo/chamilo-lms/commit/3ea5aab0cc584c197c08f2c4fa08b9a42d3fcab0">3ea5aab0</a> - <a href="https://task.beeznest.com/issues/22393">BT#22393</a>) Exercise: fix radar feedback that was not enabling attempt access</li>
</ul>
<h3>Stylesheets and theming</h3>
<ul aria-live="off">
<li>No notable style change</li>
</ul>
<h3>Web services</h3>
<ul aria-live="off">
<li>No notable change</li>
</ul>
<h3>Removals</h3>
<ul aria-live="off">
<li>No notable removal</li>
</ul>
<h3>Known issues</h3>
<ul aria-live="off">
<li>No notable known issue</li>
</ul>
</div>
<div class="version" aria-label="1.11.34">
<a id="1.11.34"></a>
<h1>Chamilo 1.11.34 - Cassis, 28/02/2026</h1>
<h3>Release notes - summary</h3>
<p>Chamilo 1.11.34 is mostly a security release on top of 1.11.32. For any significant change, please check the 1.11.30 release notes.</p>
<h3>Release name</h3>
<p><a href="https://en.wikipedia.org/wiki/Cassis">Cassis</a> is a commune situated east of Marseille in the department of Bouches-du-Rhône in the Provence-Alpes-Côte d'Azur region, whose coastline is known in English as the French Riviera, in Southern France.</p>
<h3>Security fixes</h3>
<ul aria-live="off">
<li>[2025-08-15] (<a href="https://github.com/chamilo/chamilo-lms/commit/a6ce8fb3dc88525c1fcfc1a1e24dacb4ea033684">a6ce8fb3</a>) Security: Replace $_REQUEST with HttpFoundation\Request class and remove XSS in assign, issued, and issued_all pages See advisory GHSA-cchj-3qmf-82j5</li>
<li>[2025-08-12] (<a href="https://github.com/chamilo/chamilo-lms/commit/74e4fa299ddf91aafedb4788ac6d98383f0b67c3">74e4fa29</a>) Security: Exercise: Filter XSS when showing teacher comment</li>
<li>[2025-08-11] (<a href="https://github.com/chamilo/chamilo-lms/commit/5b9a68bbdb93a03f01d16aadcfb44a3d8488566c">5b9a68bb</a>) Security: Filter SVG files uploaded from social network See advisory GHSA-2vq2-826h-6hp6</li>
<li>[2025-08-08] (<a href="https://github.com/chamilo/chamilo-lms/commit/8c74517a460f412c3e6c44368b0838ebcd031419">8c74517a</a>) Security: Restrict category title updates to the current user on the category sorting page See advisory GHSA-x3h9-h7qf-wwrf</li>
<li>[2025-08-08] (<a href="https://github.com/chamilo/chamilo-lms/commit/71c35dedcfd4d1ea2fec4d77cdd29c32f5dc668c">71c35ded</a>) Security: Replace $_REQUEST with HttpFoundation\Request class on the category sorting page See advisory GHSA-x3h9-h7qf-wwrf</li>
<li>[2025-09-01] (<a href="https://github.com/chamilo/chamilo-lms/commit/b21663fdf79cfd5cdba4d217133f296037d83670">b21663fd</a>) Security: Course description: Remove XSS when showing title See advisory GHSA-p32q-6gh3-3gcv</li>
<li>[2025-09-12] (<a href="https://github.com/chamilo/chamilo-lms/commit/46d60597af923b5f5a7ae2190db7b6f70dd0fde8">46d60597</a>) Security: Blog: Add token validation for visibility and delete actions See advisory GHSA-rpj6-p9m5-q637</li>
<li>[2025-09-22] (<a href="https://github.com/chamilo/chamilo-lms/commit/172e9fad41a3783a910bc658a6fdab75bc378d9f">172e9fad</a>) Security: Exercise: remove XSS when showing feedback See advisory GHSA-59h4-34mx-m67m</li>
<li>[2026-01-23] (<a href="https://github.com/chamilo/chamilo-lms/commit/d96de9e75fa30b4b58d1383824e054e6c26aaa94">d96de9e7</a>) Security: Fix image upload vulnerability by re-encoding images See advisory GHSA-4pc3-4w2v-vwx8</li>
<li>[2026-02-26] (<a href="https://github.com/chamilo/chamilo-lms/commit/df9002f99cd8ab7feeb62c64660cf4339b54bd0c">df9002f9</a>) Security: Remove unsafe custom_dates SQL concatenation in model.ajax.php The custom_dates parameter in the get_sessions action was directly concatenated into a raw SQL WHERE clause without sanitization, allowing unauthenticated SQL injection via the filters parameter. The parameter had no legitimate callers in the codebase (not used by jqGrid filters nor any frontend flow), so it has been removed completely with no functional impact. Se advisory GHSA-84gw-qjw9-v8jv</li>
<li>[2026-02-27] (<a href="https://github.com/chamilo/chamilo-lms/commit/3a9df760bb918b744f3fd1da3965ca7710084f59">3a9df760</a>) Security: Fix upload issue reported by Meng Hokseng</li>
<li>[2026-02-27] (<a href="https://github.com/chamilo/chamilo-lms/commit/7b5bcc771179d47feebe042afeac0e2702a084ff">7b5bcc77</a>) Security: Add authorization check on personal course categories edition - reported by Bulwarkers Websecurity PVT. LTD</li>
<li>[2026-02-27] (<a href="https://github.com/chamilo/chamilo-lms/commit/9c9a73170faad500ff4619c812d32cd7b1148bd8">9c9a7317</a>) Security: Add CSRF to personal course categories edition - reported by Bulwarkers Websecurity PVT. LTD</li>
</ul>
<h3>Notable new Features</h3>
<h4>For end-users, teachers and Chamilo admins</h4>
These features are immediately available to users through the web interface.<br />
<ul aria-live="off">
<li>[2025-08-01] (<a href="https://github.com/chamilo/chamilo-lms/commit/3bcb5a21784f5d9c4d23cbdb391b88cd9f0d674f">3bcb5a21</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/6501">GH#6501</a>) Ticket: Add Allow category manager to view all tickets in his category</li>
<li>[2025-08-04] (<a href="https://github.com/chamilo/chamilo-lms/commit/12da449e0d46a307fa4921277a11cd5fec5595e3">12da449e</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/6526">GH#6526</a>) Exercise: Add date filter to pending exercises</li>
<li>[2025-09-02] (<a href="https://github.com/chamilo/chamilo-lms/commit/7b28a2bd1e6619438be55e2709086a7d22c01cbd">7b28a2bd</a>) Exercise: Add sorting by date and search by user in pending.php</li>
<li>[2025-10-20] (<a href="https://github.com/chamilo/chamilo-lms/commit/5b1657af106554c2b6a7e87c0798650bd9fcf5ed">5b1657af</a> - <a href="https://task.beeznest.com/issues/22963">BT#22963</a>) Learnpath: Enable drag-and-drop reordering for categories</li>
<li>[2026-02-24] (<a href="https://github.com/chamilo/chamilo-lms/commit/4364b698077f1a931adf11336ea8b7148c365a98">4364b698</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/7132">GH#7132</a>) Admin: Add phone number search functionality to user search</li>
</ul>
<h4>For developers and sysadmins</h4>
Although most features here will be used by teachers or Chamilo admins, they require sysadmin privileges to enable them on the server.
<ul aria-live="off">
<li>[2025-07-04] (<a href="https://github.com/chamilo/chamilo-lms/commit/872c79e8aad749e515e4d58983f96627aedcc77f">872c79e8</a> - <a href="https://task.beeznest.com/issues/22723">BT#22723</a>) Tracking: Add global parameter to enable theoretical time for course on myStudent tracking and resume session</li>
<li>[2025-08-22] (<a href="https://github.com/chamilo/chamilo-lms/commit/d5e64a5a53f8e1667efcf85d18ec3c5e4f5e6359">d5e64a5a</a> - <a href="https://task.beeznest.com/issues/22708">BT#22708</a>) Add wysiwyg_image_auto_resize_max conf setting</li>
<li>[2025-09-23] (<a href="https://github.com/chamilo/chamilo-lms/commit/d5ef11fd17efc28d49f10d5ce9dce6a62664a827">d5ef11fd</a> - <a href="https://task.beeznest.com/issues/22960">BT#22960</a>) User: Account unification: safe merge of duplicates by extra field</li>
<li>[2025-10-23] (<a href="https://github.com/chamilo/chamilo-lms/commit/9856d886767e82c2c83a97543635289bf08441a6">9856d886</a>) Admin: Add configuration setting 'teacher_access_all_tracking' to allow teachers to access tracking of all courses</li>
<li>[2025-11-10] (<a href="https://github.com/chamilo/chamilo-lms/commit/8aec3e8a6b48981eae6e5b7f8262394403dcf203">8aec3e8a</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/6958">GH#6958</a>) Course: Moodle export: Add activities meta exports (Announcements, Attendance, Calendar, Gradebook, etc.)</li>
<li>[2025-11-20] (<a href="https://github.com/chamilo/chamilo-lms/commit/87a7422fd5538a51460b05a2d4d5c352ec9c86a1">87a7422f</a> - <a href="https://task.beeznest.com/issues/23065">BT#23065</a>) Exercise: Add configuration setting 'quiz_result_pdf_export_include_official_code_in_file_name' to add official code in the quiz result pdf export file name</li>
<li>[2025-11-22] (<a href="https://github.com/chamilo/chamilo-lms/commit/5021d6ca60f7e1e6ef3f34717acb9eeeb3d32b3f">5021d6ca</a> - <a href="https://task.beeznest.com/issues/23092">BT#23092</a>) Tracking: Add configuration setting 'session_admin_access_global_statistics' to give session admin access to statistics module</li>
<li>[2025-11-25] (<a href="https://github.com/chamilo/chamilo-lms/commit/e537f667d0acf5606bb572a18a122df418b7966c">e537f667</a> - <a href="https://task.beeznest.com/issues/23089">BT#23089</a>) Admin: Add configuration setting 'disallow_hrm_login_as' to disallow HR managers to use the 'login as' feature</li>
<li>[2025-12-09] (<a href="https://github.com/chamilo/chamilo-lms/commit/94e5bc284a159221fc078251a3e3b6c4edeb4433">94e5bc28</a> - <a href="https://task.beeznest.com/issues/22320">BT#22320</a>) Attendance: Add configuration setting 'attendance_add_official_code' to show official code in attendance table, pdf and xls export</li>
<li>[2025-12-10] (<a href="https://github.com/chamilo/chamilo-lms/commit/b26553a8979ccb131e6e2e26e7440e2eea1b1b78">b26553a8</a> - <a href="https://task.beeznest.com/issues/23114">BT#23114</a>) Admin: Add configuration setting 'disallow_session_admin_edit_users'</li>
<li>[2025-12-10] (<a href="https://github.com/chamilo/chamilo-lms/commit/4491592cae610f6550f082ce24a5dd6a6ba52a2d">4491592c</a> - <a href="https://task.beeznest.com/issues/23114">BT#23114</a>) Admin: Add configuration setting 'disallow_session_admin_login_as'</li>
</ul>
<h3>Improvements (minor features) and debug</h3>
In reverse chronological order...
<ul aria-live="off">
<li>[2026-02-28] (<a href="https://github.com/chamilo/chamilo-lms/commit/7a5689e84b0dae00774784dc391ea154312eaf5a">7a5689e8</a>) Internal: Bump version to 1.11.34</li>
<li>[2026-02-28] (<a href="https://github.com/chamilo/chamilo-lms/commit/7c5ef56c227775626a529c16d69d4570d3ebecc3">7c5ef56c</a>) Internal: Update code to avoid PHP8-specific deprecation alerts</li>
<li>[2026-02-28] (<a href="https://github.com/chamilo/chamilo-lms/commit/74bc7c1c7b51ab14c2351bd23bc40f40a8535e1c">74bc7c1c</a>) Documentation: Update changelog for 1.11.34 release</li>
<li>[2026-02-27] (<a href="https://github.com/chamilo/chamilo-lms/commit/37a8853912e0350f6274dd60920bdfd594cf2852">37a88539</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/7294">GH#7294</a>) Plugin: IMS/LTI: Add requirement for PHP OpenSSL to IMS/LTI plugin README.md</li>
<li>[2026-02-27] (<a href="https://github.com/chamilo/chamilo-lms/commit/cc88ae0d719db0e2fff738234c57936f9fe855a1">cc88ae0d</a> - <a href="https://task.beeznest.com/issues/23032">BT#23032</a>) User: Internal: fix user update when imported user already exist and username is empty</li>
<li>[2026-02-26] (<a href="https://github.com/chamilo/chamilo-lms/commit/f93a10ae7cfadbd06b7d014429c20ead7f07df55">f93a10ae</a>) Internal: Update calls to create_function() to increase compatibility with #PHP8</li>
<li>[2026-02-26] (<a href="https://github.com/chamilo/chamilo-lms/commit/11e503c0925a48443cd05b77610c8bc5c82ef616">11e503c0</a>) Internal: Remove calls to mb_convert_encoding() with HTML-ENTITIES for compatibilty with PHP 8.3</li>
<li>[2026-02-25] (<a href="https://github.com/chamilo/chamilo-lms/commit/352afd9e0a996e7c59924dbb840ca37d9519d813">352afd9e</a> - <a href="https://task.beeznest.com/issues/23219">BT#23219</a>) Tracking: Order session courses by position in tracking query</li>
<li>[2026-02-25] (<a href="https://github.com/chamilo/chamilo-lms/commit/66fca6eab78b55ddf6fbee0874afa49286126710">66fca6ea</a> - <a href="https://task.beeznest.com/issues/23219">BT#23219</a>) Tracking: Add report for session student progress</li>
<li>[2026-02-25] (<a href="https://github.com/chamilo/chamilo-lms/commit/2fa7fc25b0d407e7bb92d115ae3387bae245dff0">2fa7fc25</a> - <a href="https://task.beeznest.com/issues/23219">BT#23219</a>) Internal: Fix getFinalEvaluationItem return type and replace array_pop with end for clarity</li>
<li>[2026-02-25] (<a href="https://github.com/chamilo/chamilo-lms/commit/f184d07bc1e2cbcfb659d6d0e038c3839e1a8dd4">f184d07b</a>) Internal: Update phpmailer to ^6.8 to increase compatibility with #PHP8</li>
<li>[2026-02-25] (<a href="https://github.com/chamilo/chamilo-lms/commit/72ba5f3bd04f4eba3dc8849f43de99483800101c">72ba5f3b</a>) Internal: Fix export to document from wiki (use .docx rather than .odt for wider compatibility) + rename file export to include course name + fix temp path for generated file</li>
<li>[2026-02-25] (<a href="https://github.com/chamilo/chamilo-lms/commit/d90c7578087550adebdff4060990c4c6cca5c0e6">d90c7578</a>) Internal: Fix image proportions in resize of social group images</li>
<li>[2026-02-25] (<a href="https://github.com/chamilo/chamilo-lms/commit/a831f5e3d9d1efc7498725f5fac69c9385626e04">a831f5e3</a>) Internal: Fix group image display issue with convertion from imagine to GD</li>
<li>[2026-02-25] (<a href="https://github.com/chamilo/chamilo-lms/commit/7168bdb677a7e83f4bb5185a7d133f7a2720f4f1">7168bdb6</a>) Internal: Remove dependency on imagine/imagine (only used in one place, replaceable) and replace media-alchemyst (abandoned) by symfony/process for .doc generation from wiki to increase #PHP8 support</li>
<li>[2026-02-25] (<a href="https://github.com/chamilo/chamilo-lms/commit/768f2d3d7be15dcdeb826157f7ee70cb41fc1aa8">768f2d3d</a>) Internal: Upgrade imagine/imagine to 1.3 to increase PHP8 support</li>
<li>[2026-02-25] (<a href="https://github.com/chamilo/chamilo-lms/commit/daa5d21568734f5301e955a1f1dcdcd5c73b063f">daa5d215</a> - <a href="https://task.beeznest.com/issues/21977">BT#21977</a>) Course: Fix Moodle export file references and LP activity mappings</li>
<li>[2026-02-24] (<a href="https://github.com/chamilo/chamilo-lms/commit/ac03c9453b45d23a4884f98ffe2645129b9cbb73">ac03c945</a> - <a href="https://task.beeznest.com/issues/23032">BT#23032</a>) User: Internal: Add user update if user already exist when extra_field_to_validate_on_user_registration is set</li>
<li>[2026-02-24] (<a href="https://github.com/chamilo/chamilo-lms/commit/f08781885913e2e394553c4d8cf3a41a6ec01994">f0878188</a> - <a href="https://task.beeznest.com/issues/23241">BT#23241</a>) User: Admin: Add configuration setting 'user_hide_expiration_date_for_session_admin' to hide expiration date for session admins + Adapt option to hide 'Never expire'</li>
<li>[2026-02-24] (<a href="https://github.com/chamilo/chamilo-lms/commit/96356ff8286dd03816c409eef15be10e55779ebb">96356ff8</a>) Plugin: CustomCertiicate: Allow further access to Sessions Admins</li>
<li>[2026-02-24] (<a href="https://github.com/chamilo/chamilo-lms/commit/1b42b0b4c76ee05af5bc1f5fd27633500433b547">1b42b0b4</a>) Plugin: CustomCertificate: Allow access to Sessions Admins</li>
<li>[2026-02-24] (<a href="https://github.com/chamilo/chamilo-lms/commit/1ad65dadb4f4ae347a06c604d2f2d40b3b34079f">1ad65dad</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/7196">GH#7196</a>) Learnpath: Remove status prerequisite from check. Fixes issue when a student takes a test multiple times and the last time is not a success</li>
<li>[2026-02-24] (<a href="https://github.com/chamilo/chamilo-lms/commit/8d29f011b7c4991d0ff5a2b2f694474715cea564">8d29f011</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/7176">GH#7176</a>) Exercise: Remove invisible/problematic Unicode characters introduced by word</li>
<li>[2026-02-24] (<a href="https://github.com/chamilo/chamilo-lms/commit/1ce0dd8a5da33f6bead65451b423c4185060cec4">1ce0dd8a</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/6745">GH#6745</a>) Learnpath: Remove max score checking and fix other code syntax issues</li>
<li>[2026-02-23] (<a href="https://github.com/chamilo/chamilo-lms/commit/7c952909cae17b9ce440d823f044334c3758068e">7c952909</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/6841">GH#6841</a>) Survey: Normalize survey_type to int to avoid strict type mismatch on PHP 8.3</li>
<li>[2026-02-23] (<a href="https://github.com/chamilo/chamilo-lms/commit/50a003a35e1024b5e7b1378a2d08b0e53e076ac2">50a003a3</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/7113">GH#7113</a>) Tracking: Fix visibility of courses for HR managers (ignore api_drh_can_access_all_session_content())</li>
<li>[2026-02-23] (<a href="https://github.com/chamilo/chamilo-lms/commit/3b8245e5a05f925fa078347334394afe06826fe4">3b8245e5</a> - <a href="https://task.beeznest.com/issues/22929">BT#22929</a>) Skill: Fix condition for display of skills block</li>
<li>[2026-02-23] (<a href="https://github.com/chamilo/chamilo-lms/commit/13c4b4ac80abe770c3f7840c8c0826fca2bdc1b6">13c4b4ac</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/6312">GH#6312</a>) Learnpath: Fix commit 638aa1cbb66a: Add missing lp_initialize_item.inc.php</li>
<li>[2026-02-23] (<a href="https://github.com/chamilo/chamilo-lms/commit/c6d6154265bd92ff2fe022260127e1cba8d8d666">c6d61542</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/6312">GH#6312</a>) Learnpath: Implement prefetching of init data for LMSInitialize(). Use JS promise to avoid synchronous XHR</li>
<li>[2026-02-23] (<a href="https://github.com/chamilo/chamilo-lms/commit/638aa1cbb66ab0cfe33899ef02dd08e92a6853a0">638aa1cb</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/6312">GH#6312</a>) Learnpath: Encapsulate LMSInitialize() inside a backend method</li>
<li>[2026-02-20] (<a href="https://github.com/chamilo/chamilo-lms/commit/751175aee528ef4e0bcb284697eb359998ad1341">751175ae</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/5557">GH#5557</a>) Exercise: Restrict Actions for Hidden Exercises in Base Course</li>
<li>[2026-02-20] (<a href="https://github.com/chamilo/chamilo-lms/commit/453c668a1de4530d5fa4de519f299fa6b169a1e8">453c668a</a>) Internal: Replace phpoffice/phpexcel: ~1.8 by phpoffice/phpspreadsheet: ^1.28 to increase PHP8 support</li>
<li>[2026-02-20] (<a href="https://github.com/chamilo/chamilo-lms/commit/017d34403d971330af3a7776fc0307ba375e05f0">017d3440</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/5657">GH#5657</a>) WYSIWYG: Filter access to session-specific resources through ElFinder</li>
<li>[2026-02-20] (<a href="https://github.com/chamilo/chamilo-lms/commit/447f7d784ebf5bf2856f75f79000c10e5d94043d">447f7d78</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/5657">GH#5657</a>) WYSIWYG: Filter access to session-specific resources through ElFinder</li>
<li>[2026-02-09] (<a href="https://github.com/chamilo/chamilo-lms/commit/4157937cc67d8156a749025c25d0160c233cd0ee">4157937c</a>) Internal: Fix E_NOTICE Undefined variable: lpShowMaxProgress</li>
<li>[2026-02-08] (<a href="https://github.com/chamilo/chamilo-lms/commit/0cd2120d5edf4c86b5ff2a539f583eb89f17fa23">0cd2120d</a> - <a href="https://task.beeznest.com/issues/23172">BT#23172</a>) Script: User: Add tolerance for other type of single quote in names + change phone column header title</li>
<li>[2026-02-04] (<a href="https://github.com/chamilo/chamilo-lms/commit/8bee623b69f03837acd2a7b2b3b6aa8e02c3f703">8bee623b</a>) Documentation: Add more specific instructions for commit messages in changelog.html</li>
<li>[2026-01-30] (<a href="https://github.com/chamilo/chamilo-lms/commit/19e0674d43bc774507761658cd82b080292dac90">19e0674d</a> - <a href="https://task.beeznest.com/issues/23189">BT#23189</a>) Learnpath: Fix course backup import and LP restore flow</li>
<li>[2026-01-23] (<a href="https://github.com/chamilo/chamilo-lms/commit/f3074706a5c083f92701725ddd1b5adc74129468">f3074706</a> - <a href="https://task.beeznest.com/issues/23162">BT#23162</a>) Group: Add official code to usergroup_users list</li>
<li>[2026-01-23] (<a href="https://github.com/chamilo/chamilo-lms/commit/0d535da60505d01e214fa97a03ac4b775b6cda70">0d535da6</a> - <a href="https://task.beeznest.com/issues/22393">BT#22393</a>) Exercise: Fix user answer for fill in blanks question with menu options already hashed</li>
<li>[2026-01-20] (<a href="https://github.com/chamilo/chamilo-lms/commit/502d35da58017d6e1ce1eefbb0e3e9a6d69518e2">502d35da</a> - <a href="https://task.beeznest.com/issues/22396">BT#22396</a>) Tracking: Fix Show progress based on visibles LPs only error 500</li>
<li>[2026-01-20] (<a href="https://github.com/chamilo/chamilo-lms/commit/9c9e26aace5d7a870d691d0c3d41ada7434f3707">9c9e26aa</a> - <a href="https://support.chamilo.org/issues/23180">CT#23180</a>) Attendance: Fix session copy with session content to avoid duplicate of attendance in the original sesion</li>
<li>[2026-01-12] (<a href="https://github.com/chamilo/chamilo-lms/commit/4a34838acd11cf0ff80b6d38a1c8d755f53a8307">4a34838a</a> - <a href="https://task.beeznest.com/issues/23138">BT#23138</a>) Internal: Refactor session coach edit to use `SessionManager::getCoachesByCourseSession`</li>
<li>[2026-01-08] (<a href="https://github.com/chamilo/chamilo-lms/commit/70e7e20ff7d7cd86eb077bec4693096159aacd47">70e7e20f</a> - <a href="https://task.beeznest.com/issues/23138">BT#23138</a>) Internal: Update extra field array key from `title` to `display_text` for consistency</li>
<li>[2026-01-06] (<a href="https://github.com/chamilo/chamilo-lms/commit/ad24b8ffa309158effc63b42a3119a84116772fd">ad24b8ff</a> - <a href="https://task.beeznest.com/issues/23154">BT#23154</a>) Script: fix warning and move debug variable to avoid override in included files</li>
<li>[2026-01-06] (<a href="https://github.com/chamilo/chamilo-lms/commit/33b6ed85b2d8ea1715e708afd3aaf30a00f3c7d8">33b6ed85</a> - <a href="https://task.beeznest.com/issues/23138">BT#23138</a>) Internal: Move code to function</li>
<li>[2026-01-06] (<a href="https://github.com/chamilo/chamilo-lms/commit/8e8ad82579188247b18ee0146110b185764f1528">8e8ad825</a> - <a href="https://task.beeznest.com/issues/23138">BT#23138</a>) Internal: Clean handling of query parameters + improve type safety in `decodeParams` method</li>
<li>[2026-01-05] (<a href="https://github.com/chamilo/chamilo-lms/commit/e34cc0bd3929028e7e33d0626f21634aa7d93db8">e34cc0bd</a> - <a href="https://task.beeznest.com/issues/21977">BT#21977</a>) Course: Fix lesson document exports and LP activity mapping</li>
<li>[2025-12-19] (<a href="https://github.com/chamilo/chamilo-lms/commit/4b3495039fc40118199c851eaced95c84364126d">4b349503</a> - <a href="https://task.beeznest.com/issues/23132">BT#23132</a>) Exercise: Fix user choice presentation in result page for Dragging question</li>
<li>[2025-12-16] (<a href="https://github.com/chamilo/chamilo-lms/commit/256da57cacef46ed580177bb1e0558afab91fe3d">256da57c</a>) Remove invisible Unicode characters that can cause // Remove invisible Unicode characters that can cause comparison issues // U+200B = ZERO WIDTH SPACE // U+200C = ZERO WIDTH NON-JOINER // U+200D = ZERO WIDTH JOINER // U+FEFF = ZERO WIDTH NO-BREAK SPACE (BOM)</li>
<li>[2025-12-12] (<a href="https://github.com/chamilo/chamilo-lms/commit/acd49cc940801bbe09cc712fe3eec7daf1a34a6c">acd49cc9</a> - <a href="https://task.beeznest.com/issues/23132">BT#23132</a>) Exercise: revert commit d005c99dda697a and apply a better fix for user choice and expected choice presentation on result page</li>
<li>[2025-12-12] (<a href="https://github.com/chamilo/chamilo-lms/commit/d005c99dda697ac60089733878f3189b41c5c5fb">d005c99d</a> - <a href="https://task.beeznest.com/issues/23132">BT#23132</a>) Exercise: Fix user choice presentation in result page for matching question</li>
<li>[2025-12-10] (<a href="https://github.com/chamilo/chamilo-lms/commit/7a6ed04102a59e328e32ef3390ce7778e6f93411">7a6ed041</a> - <a href="https://task.beeznest.com/issues/23122">BT#23122</a>) Script: User: Add filter for url white list and url black list</li>
<li>[2025-12-08] (<a href="https://github.com/chamilo/chamilo-lms/commit/324be333569b037f103e620807e66d25251173d7">324be333</a> - <a href="https://task.beeznest.com/issues/23002">BT#23002</a>) Tracking: Change for method from GET to POST to avoid error with URL too long</li>
<li>[2025-12-04] (<a href="https://github.com/chamilo/chamilo-lms/commit/aafc5380c480e021afff5e4bcbd06c077b20e830">aafc5380</a>) Exercise: Fix missing MATCHING_COMBINATION, MATCHING_DRAGGABLE_COMBINATION in questions' duplicate() method</li>
<li>[2025-12-02] (<a href="https://github.com/chamilo/chamilo-lms/commit/ce11a8ba8791116f434ded7b8171a8234c3c8b7c">ce11a8ba</a> - <a href="https://task.beeznest.com/issues/22702">BT#22702</a>) Exercise: Enable attempt deletion by session_admin if session_admins_edit_courses_content is true</li>
<li>[2025-11-30] (<a href="https://github.com/chamilo/chamilo-lms/commit/f8f363800205ba59648958bb1a31de2e55924a32">f8f36380</a> - <a href="https://task.beeznest.com/issues/22702">BT#22702</a>) User: Account unification: Add possibility to filter on user status and the unification on a teacher account if one exist instead of the most recent account</li>
<li>[2025-11-28] (<a href="https://github.com/chamilo/chamilo-lms/commit/aafbb7b7ab88f2997d40be70f9a5326573857da7">aafbb7b7</a> - <a href="https://task.beeznest.com/issues/23109">BT#23109</a>) Tracking: Language: Minor adapt translation variable to correspond to the sessions lists</li>
<li>[2025-11-27] (<a href="https://github.com/chamilo/chamilo-lms/commit/46fe78a84fd47dee3b838b483dcf50011c7d2074">46fe78a8</a> - <a href="https://task.beeznest.com/issues/23090">BT#23090</a>) Course: Fix course backup import for accented document paths</li>
<li>[2025-11-27] (<a href="https://github.com/chamilo/chamilo-lms/commit/288a8306b5efa9ebe232a04374c980fbc3fa241a">288a8306</a> - <a href="https://task.beeznest.com/issues/23109">BT#23109</a>) Tracking: Use advanced session multiselect in certificates session report</li>
<li>[2025-11-27] (<a href="https://github.com/chamilo/chamilo-lms/commit/a3e0a5e4010d69adce0599346a9325022d15e905">a3e0a5e4</a> - <a href="https://task.beeznest.com/issues/22702">BT#22702</a>) User: Account unification: session's id_coach and session_admin_id to the unification process</li>
<li>[2025-11-27] (<a href="https://github.com/chamilo/chamilo-lms/commit/9f7c7c904f2baf158da01af203e65fb2cb0cbba0">9f7c7c90</a> - <a href="https://task.beeznest.com/issues/22702">BT#22702</a>) Script: User: Use searched field as filter, fix syntax errors, add filter on URLs</li>
<li>[2025-11-26] (<a href="https://github.com/chamilo/chamilo-lms/commit/e5da9444be3bd213cfcdb465e1ff449710ff5a28">e5da9444</a> - <a href="https://task.beeznest.com/issues/23109">BT#23109</a>) Tracking: Add multi-session selector and bulk certificate export in session filter</li>
<li>[2025-11-26] (<a href="https://github.com/chamilo/chamilo-lms/commit/18ab7d9d47dff39e8755009c77f48c4022e16a87">18ab7d9d</a> - <a href="https://task.beeznest.com/issues/21977">BT#21977</a>) Course: Fix Moodle export: include root Documents in folder and keep learnpath items ordered by display_order</li>
<li>[2025-11-25] (<a href="https://github.com/chamilo/chamilo-lms/commit/a446e4be306d9e4405539e58c191f72d7ff0aabf">a446e4be</a> - <a href="https://task.beeznest.com/issues/22702">BT#22702</a>) Script: User: Add timestamp to know how long it takes to unify a user</li>
<li>[2025-11-25] (<a href="https://github.com/chamilo/chamilo-lms/commit/64fbd53cfed0a21ae8a6a48d818bd21b1eb16ffb">64fbd53c</a> - <a href="https://task.beeznest.com/issues/22702">BT#22702</a>) Script: User: Add filtered user list to unify only those users</li>
<li>[2025-11-24] (<a href="https://github.com/chamilo/chamilo-lms/commit/162d1358833ce7604ab140d374784f90ddadc68e">162d1358</a> - <a href="https://task.beeznest.com/issues/23089">BT#23089</a>) Gradebook: Allow show flatview to HR users</li>
<li>[2025-11-24] (<a href="https://github.com/chamilo/chamilo-lms/commit/196c97a0abcc3c9661d6c48996f8d468239e43f5">196c97a0</a>) Internal: Add return and parameter types across gradebook classes for improved type safety and readability</li>
<li>[2025-11-19] (<a href="https://github.com/chamilo/chamilo-lms/commit/7f2006f37c1700421c9e9fcc64e46c37be3978aa">7f2006f3</a> - <a href="https://task.beeznest.com/issues/23091">BT#23091</a>) Session: Add possibility to return session with no course</li>
<li>[2025-11-11] (<a href="https://github.com/chamilo/chamilo-lms/commit/7da8728bba6046ee3c6b070c72ab89b8df46cd57">7da8728b</a> - <a href="https://task.beeznest.com/issues/22702">BT#22702</a>) User: Account unification: add track_e_access_complete to the unification process if the table exist</li>
<li>[2025-11-10] (<a href="https://github.com/chamilo/chamilo-lms/commit/36fa8a6fbe0827eb2f2298fdafca24ce836778e0">36fa8a6f</a> - <a href="https://task.beeznest.com/issues/23071">BT#23071</a>) Learnpath: Internal: Fix commit 8ba13c4056 because there is not always a LearnpathVisible registry</li>
<li>[2025-11-10] (<a href="https://github.com/chamilo/chamilo-lms/commit/8ba13c4056f9358b2b1d608c76f8670657fe2d51">8ba13c40</a> - <a href="https://task.beeznest.com/issues/23071">BT#23071</a>) Learnpath: Internal: Fix lp visibility when there have been lp subscription in between</li>
<li>[2025-11-05] (<a href="https://github.com/chamilo/chamilo-lms/commit/16fbd415e58f81eb56745f6c83530b8f0ba048a5">16fbd415</a> - <a href="https://task.beeznest.com/issues/23068">BT#23068</a>) Internal: Simplify conditional checks and use null coalescing operator across exercise-related files for cleaner and more consistent code readability</li>
<li>[2025-11-05] (<a href="https://github.com/chamilo/chamilo-lms/commit/c2ec80ad85ecc37148bdf005ef783440412cfb13">c2ec80ad</a> - <a href="https://task.beeznest.com/issues/23068">BT#23068</a>) Exercise: Fix escape user answer for fill in blanks question</li>
<li>[2025-11-05] (<a href="https://github.com/chamilo/chamilo-lms/commit/60fda7f3a9bd71d464daeaff13205d1553360ec8">60fda7f3</a> - <a href="https://task.beeznest.com/issues/22702">BT#22702</a>) Script: User: Unify on most recent user duplicated users based on extra field value</li>
<li>[2025-10-28] (<a href="https://github.com/chamilo/chamilo-lms/commit/899aaa1dab77a426028b48c50a1a45b40e316332">899aaa1d</a>) Session: Improve option Show subscription column in session course list on main/mySpace/myStudents.php</li>
<li>[2025-11-04] (<a href="https://github.com/chamilo/chamilo-lms/commit/dfe42b68b151b504bf7b6f22b47f27142ef12f7f">dfe42b68</a> - <a href="https://task.beeznest.com/issues/22320">BT#22320</a>) Cron: Update import_users_from_xlsx.php with improved tolerance for repeated accounts</li>
<li>[2025-10-23] (<a href="https://github.com/chamilo/chamilo-lms/commit/857d5940f0256c23d62989f426ea27f9aff971fb">857d5940</a> - <a href="https://task.beeznest.com/issues/22702">BT#22702</a>) User: Account unification: Language fix content and translation variable to have translation and add missing variables and translation in EN, FR and ES</li>
<li>[2025-10-23] (<a href="https://github.com/chamilo/chamilo-lms/commit/f1b84396815349bceaa2409ef7422a8c9f551be1">f1b84396</a> - <a href="https://task.beeznest.com/issues/22702">BT#22702</a>) User: Account unification: Add registration date column</li>
<li>[2025-10-20] (<a href="https://github.com/chamilo/chamilo-lms/commit/41d6a4f5d9c99435033ca3d40af7a1697b6aba87">41d6a4f5</a> - <a href="https://task.beeznest.com/issues/21977">BT#21977</a>) Course: Moodle export: mirror Documents tree; use LP item titles; restore document items</li>
<li>[2025-10-20] (<a href="https://github.com/chamilo/chamilo-lms/commit/246873da0605840ad634850257b6dfbe5e62a112">246873da</a> - <a href="https://task.beeznest.com/issues/23018">BT#23018</a>) Plugin: AzureActiveDirectory: Fix user picture deletion on update user on login</li>
<li>[2025-10-20] (<a href="https://github.com/chamilo/chamilo-lms/commit/6c8c24b6204aad31863084ddb640d2a2003e7fa6">6c8c24b6</a> - <a href="https://task.beeznest.com/issues/23014">BT#23014</a>) Gradebook: Fix no action when clicking on import result</li>
<li>[2025-10-17] (<a href="https://github.com/chamilo/chamilo-lms/commit/ab212d35cb0312b7260f38fe1a3c69b4dce501c3">ab212d35</a> - <a href="https://task.beeznest.com/issues/23012">BT#23012</a>) Group: Fix random user assignation to group and only students no teacher</li>
<li>[2025-10-15] (<a href="https://github.com/chamilo/chamilo-lms/commit/9b56878e244db4a5a919cb626459741315356975">9b56878e</a> - <a href="https://task.beeznest.com/issues/22963">BT#22963</a>) Learnpath: Enable drag-and-drop reordering for categories and lessons</li>
<li>[2025-10-09] (<a href="https://github.com/chamilo/chamilo-lms/commit/08debf33b7731adfef4ba7d004605d622b771de1">08debf33</a> - <a href="https://task.beeznest.com/issues/22974">BT#22974</a>) Internal: Fix opening file in fullscreen when sso forceredirect is activated because we need the config information</li>
<li>[2025-10-07] (<a href="https://github.com/chamilo/chamilo-lms/commit/419daee2e290e749742695944d463b6317d9fa88">419daee2</a> - <a href="https://task.beeznest.com/issues/22768">BT#22768</a>) Language: Partial translation update for specific function user import with unique extrafield check</li>
<li>[2025-10-02] (<a href="https://github.com/chamilo/chamilo-lms/commit/8405a83a20c459279ab392012dab9f4877f8ca24">8405a83a</a>) Internal: Adjust subscribeUsersToSession to preserve course-level unsubscriptions when re-adding a user already subscribed to the session.</li>
<li>[2025-09-29] (<a href="https://github.com/chamilo/chamilo-lms/commit/d1dcf0b7ecb8d73e6859c2757bb3209b0f472d93">d1dcf0b7</a> - <a href="https://task.beeznest.com/issues/22990">BT#22990</a>) Gradebook: Fix ranking calculation to show same position for users with the same score</li>
<li>[2025-09-23] (<a href="https://github.com/chamilo/chamilo-lms/commit/e3784d244fa769b0a8c7fec3d5548166bff0bfa1">e3784d24</a> - <a href="https://task.beeznest.com/issues/22959">BT#22959</a>) Learnpath: Fix learning path audio modification not accessible when in a session on the lp user subscription page</li>
<li>[2025-09-22] (<a href="https://github.com/chamilo/chamilo-lms/commit/e63b2c5991ac8cb256dbd4a7bf501580451d1a82">e63b2c59</a> - <a href="https://task.beeznest.com/issues/22959">BT#22959</a>) Learnpath: Fix learning path edition and configuration not accessible when in a session on the lp user subscripcion page</li>
<li>[2025-09-21] (<a href="https://github.com/chamilo/chamilo-lms/commit/4a10e1c77d751219580d307e5be3d364af61c596">4a10e1c7</a> - <a href="https://task.beeznest.com/issues/22899">BT#22899</a>) Exercise: Prevent duplicate question creation on Hotspot zone validation</li>
<li>[2025-09-19] (<a href="https://github.com/chamilo/chamilo-lms/commit/82feaa2bcb0d936c71e4a53878f13b9e102b7e5e">82feaa2b</a>) Mystudent.php : Add optional subscription column in student session details</li>
<li>[2025-09-12] (<a href="https://github.com/chamilo/chamilo-lms/commit/ee2489905409d1f1b2bbef06ededcb0f157f09e8">ee248990</a>) Internal: Use null coalescing operator for cleaner input handling</li>
<li>[2025-09-12] (<a href="https://github.com/chamilo/chamilo-lms/commit/90745c0b567e31833ca18b3d9a0b685756d2d5e7">90745c0b</a>) Internal: Fix undefined array key</li>
<li>[2025-09-12] (<a href="https://github.com/chamilo/chamilo-lms/commit/ec1e185fd455b65e458d572dd0aa95f6f1a61ce0">ec1e185f</a>) Learnpath: Fix conditional check for quiz result validation</li>
<li>[2025-09-12] (<a href="https://github.com/chamilo/chamilo-lms/commit/69c6e08dc3594c88ab2834cc09c09e60e1c50e4c">69c6e08d</a> - <a href="https://task.beeznest.com/issues/22929">BT#22929</a>) Skill: Minor: Fix skill block to be visible only if user is logged and is not an anonymous user</li>
<li>[2025-09-08] (<a href="https://github.com/chamilo/chamilo-lms/commit/ba58b26ddad82749bf2b54dfdaed805f3e07c7d4">ba58b26d</a>) Internal: Sanitize option text and use Display::tag for generating option elements See advisory GHSA-pxrh-3rcp-h7m6</li>
<li>[2025-09-05] (<a href="https://github.com/chamilo/chamilo-lms/commit/405b142780fe1ea5d452409703dd028b77db7aff">405b1427</a> - <a href="https://task.beeznest.com/issues/22709">BT#22709</a>) Portfolio: Fix exporting to ZIP when files are not found</li>
<li>[2025-09-05] (<a href="https://github.com/chamilo/chamilo-lms/commit/d0e1aff93fc84c343cd6007510c87ddac05cb51c">d0e1aff9</a> - <a href="https://task.beeznest.com/issues/22775">BT#22775</a>) Session: [Minor] Fix tipo for correct date format</li>
<li>[2025-09-04] (<a href="https://github.com/chamilo/chamilo-lms/commit/557bb4c0122a3cb55b42bbbd5ad69c7176eedd1a">557bb4c0</a>) Ticket: Improve search and export ticket</li>
<li>[2025-09-04] (<a href="https://github.com/chamilo/chamilo-lms/commit/04c907e9c3400a632b61a342ecee3d41d4b1b5d3">04c907e9</a> - <a href="https://task.beeznest.com/issues/22714">BT#22714</a>) Internal: Announcement: Fix select user and unselect all for formvalidator reverting part of commit 1b7e0030d1c34</li>
<li>[2025-09-01] (<a href="https://github.com/chamilo/chamilo-lms/commit/32e03374893969bbd393aa0aa00b3f2d48ea1ba5">32e03374</a> - <a href="https://task.beeznest.com/issues/22884">BT#22884</a>) Attendance: Fix student attendance calendar list with time to be in local time</li>
<li>[2025-09-01] (<a href="https://github.com/chamilo/chamilo-lms/commit/3534fec909991a49267461e9e13738e433eca51f">3534fec9</a>) Course description: Refactor course description handling to use Doctrine repository for improved data retrieval See advisory GHSA-p32q-6gh3-3gcv</li>
<li>[2025-08-25] (<a href="https://github.com/chamilo/chamilo-lms/commit/adbe2138876fca039542077267eb8e9c7541145c">adbe2138</a> - <a href="https://task.beeznest.com/issues/22708">BT#22708</a>) WYSIWYG: Add automatic image resize before upload with ElFinder</li>
<li>[2025-08-25] (<a href="https://github.com/chamilo/chamilo-lms/commit/63aafbcc8ec4fa6d936f13ae0e79536a0d20b072">63aafbcc</a> - <a href="https://task.beeznest.com/issues/22709">BT#22709</a>) Portfolio: Fix paths to handle video and audio tags when exporting to ZIP</li>
<li>[2025-08-22] (<a href="https://github.com/chamilo/chamilo-lms/commit/6d99fd97a0dcad4398fe78b2165d2b9aa7f1d0cc">6d99fd97</a> - <a href="https://task.beeznest.com/issues/22709">BT#22709</a>) Portfolio: Include files linked in post and comments when exporting ZIP file</li>
<li>[2025-08-22] (<a href="https://github.com/chamilo/chamilo-lms/commit/b0c75f40542070e7ee556324fbade7bb34d29e50">b0c75f40</a> - <a href="https://task.beeznest.com/issues/22709">BT#22709</a>) Portfolio: Fix undefined variable E_NOTICE</li>
<li>[2025-08-18] (<a href="https://github.com/chamilo/chamilo-lms/commit/5f24fcaad7417e8e2a23d0302ea4ff92e4e20898">5f24fcaa</a>) Plugin: LTI: Fix translation of messages when replying tool in session courses</li>
<li>[2025-08-18] (<a href="https://github.com/chamilo/chamilo-lms/commit/581252c31075d6082beb20a60daaa69c1b68da53">581252c3</a>) Tracking: #improve-option-summary-section-in-mystudent.php</li>
<li>[2025-08-18] (<a href="https://github.com/chamilo/chamilo-lms/commit/216fd7d1d40aea49d73da285f2c63b966f567ae2">216fd7d1</a>) Plugin: LTI: Fix message when replying tool in session courses</li>
<li>[2025-08-04] (<a href="https://github.com/chamilo/chamilo-lms/commit/dd8a919069ed6b35d7e1fe336912eeae0807b6c1">dd8a9190</a>) Tracking: [Minor] Add partial translation for new funcionality progress summary section in the sessions in mystudents</li>
<li>[2025-08-04] (<a href="https://github.com/chamilo/chamilo-lms/commit/2f4d91317a913f1ff38b6d6fb1b26ba351f147cd">2f4d9131</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/6531">GH#6531</a>) Tracking: Add option to enable progress summary section in the sessions in mystudents.php</li>
<li>[2025-07-29] (<a href="https://github.com/chamilo/chamilo-lms/commit/0907f72a584f0453b67210c7140f12e6346c62a0">0907f72a</a>) Tracking: Fix total courses progress calculation in session in myStudent.php to consider only subscribed courses</li>
<li>[2025-07-29] (<a href="https://github.com/chamilo/chamilo-lms/commit/660730fa6f07fc109dc87d7b8412ab556fb577ca">660730fa</a>) Tracking: Add PDF export to time report</li>
<li>[2025-07-29] (<a href="https://github.com/chamilo/chamilo-lms/commit/95083f4adce4bd2ac062d827f99e590a2e0dd6a9">95083f4a</a>) Exercise: Fix pending attempts Excel export to take into account question type and session users filters</li>
<li>[2025-07-25] (<a href="https://github.com/chamilo/chamilo-lms/commit/3eda9b4caca071e12d3922d4f8a54253de87a472">3eda9b4c</a> - <a href="https://task.beeznest.com/issues/22802">BT#22802</a>) User: Fix error when extra_field_to_validate_on_user_registration is not set</li>
<li>[2025-07-24] (<a href="https://github.com/chamilo/chamilo-lms/commit/4c3fe93c0e2b02eb43cd9c42461b445bc5d01937">4c3fe93c</a> - <a href="https://task.beeznest.com/issues/22802">BT#22802</a>) User: Improve user import and extrafield validation</li>
<li>[2025-07-24] (<a href="https://github.com/chamilo/chamilo-lms/commit/75ef74554322f26ed883433e0460e212f66210af">75ef7455</a> - <a href="https://task.beeznest.com/issues/22802">BT#22802</a>) User: Enforce unique extrafield per URL on user creation</li>
<li>[2025-07-22] (<a href="https://github.com/chamilo/chamilo-lms/commit/52e70db47bdafc681112a7f10c77ca74440d1144">52e70db4</a>) Plugin: GoogleMeet: Fix missing icon for Google Meet plugin on course homepage Author: @christianbeeznest</li>
<li>[2025-07-21] (<a href="https://github.com/chamilo/chamilo-lms/commit/f8899d001a147c97de7c1c5566cefc64d0f3f6f4">f8899d00</a>) Ticket: Add subject to ticket xls export</li>
<li>[2025-07-21] (<a href="https://github.com/chamilo/chamilo-lms/commit/1957a39d6cb56559f9ae87cafd21310961f04690">1957a39d</a> - <a href="https://task.beeznest.com/issues/22775">BT#22775</a>) Session: Add option to limit number of letter to show for extra field value, define specific value for course field #change date format and show learner variable for each student line in Excel export</li>
<li>[2025-07-18] (<a href="https://github.com/chamilo/chamilo-lms/commit/a037586b112cfbffdbc4d08a827f463667321485">a037586b</a> - <a href="https://task.beeznest.com/issues/22302">BT#22302</a>) Documentation: Fixed logging message about replacing in tests when it replaces it everywhere (creates false asumption that it is slowlier than reality)</li>
<li>[2025-07-18] (<a href="https://github.com/chamilo/chamilo-lms/commit/9093557d808eaf5a65d2b581932e9e33a20f1971">9093557d</a> - <a href="https://task.beeznest.com/issues/22302">BT#22302</a>) Documentation: Add more detailed logging to replace_course_code.php</li>
<li>[2025-07-18] (<a href="https://github.com/chamilo/chamilo-lms/commit/5dc48f19e640dfc3c46a28d793c613753ad1b805">5dc48f19</a> - <a href="https://task.beeznest.com/issues/22302">BT#22302</a>) Documentation: Add more detailed logging to replace_course_code.php</li>
<li>[2025-07-18] (<a href="https://github.com/chamilo/chamilo-lms/commit/69dcd65d89ed26b3561f6a2c8facd8cf9007bbe9">69dcd65d</a> - <a href="https://task.beeznest.com/issues/22302">BT#22302</a>) Documentation: Add PHPDoc to api_replace_terms_in_content</li>
<li>[2025-07-18] (<a href="https://github.com/chamilo/chamilo-lms/commit/10794fc1e1fc43e2978e1123f0b655a50d759b41">10794fc1</a> - <a href="https://task.beeznest.com/issues/22302">BT#22302</a>) Script: Escape & sign in find to end cidReq search & replace syntax and avoid gobbing up larger course codes</li>
<li>[2025-07-18] (<a href="https://github.com/chamilo/chamilo-lms/commit/0f3d00c4a06a499f865c03532e1bd0733cb84638">0f3d00c4</a> - <a href="https://task.beeznest.com/issues/22302">BT#22302</a>) Script: Add & sign to end cidReq search & replace syntax and avoid gobbing up larger course codes</li>
<li>[2025-07-15] (<a href="https://github.com/chamilo/chamilo-lms/commit/890acb26d7f63eea19abf5dd20c65c8eef1fe079">890acb26</a>) Internal: Move ExerciseLib::replaceTermsInContent() function to api.lib.php</li>
<li>[2025-07-15] (<a href="https://github.com/chamilo/chamilo-lms/commit/cc5805494e0041cbf28d8138b17fe952e43e81bf">cc580549</a> - <a href="https://task.beeznest.com/issues/22302">BT#22302</a>) Script: Add conversion of cidReq markers in HTML files to replace_course_code script + options to preview without change</li>
<li>[2025-07-08] (<a href="https://github.com/chamilo/chamilo-lms/commit/aff71f39059fad28b1c1852e1e62244701d8b2f3">aff71f39</a> - <a href="https://task.beeznest.com/issues/22763">BT#22763</a>) Internal: Fix encoding error with ' in language variables</li>
<li>[2025-07-04] (<a href="https://github.com/chamilo/chamilo-lms/commit/4c4edfed3d471e58ab36a2b1eea3321bc4504c97">4c4edfed</a> - <a href="https://task.beeznest.com/issues/22723">BT#22723</a>) Language: Add partial translation in ES, FR, EN for theoretical time functionnality</li>
<li>[2025-07-04] (<a href="https://github.com/chamilo/chamilo-lms/commit/a3d222d1d368b49164d21616645fd66c38068718">a3d222d1</a> - <a href="https://task.beeznest.com/issues/22722">BT#22722</a>) Ticket: Fix date parameters for advanced search</li>
<li>[2025-07-04] (<a href="https://github.com/chamilo/chamilo-lms/commit/4f70bccd55d987f110b97ac2eaaa049806bcbccc">4f70bccd</a> - <a href="https://task.beeznest.com/issues/22722">BT#22722</a>) Ticket: Fix date parameters for excel export</li>
<li>[2025-07-03] (<a href="https://github.com/chamilo/chamilo-lms/commit/3322b97c7dd202ba4dd571d586c5c59fe02ba138">3322b97c</a> - <a href="https://task.beeznest.com/issues/22724">BT#22724</a>) Session: Replace session course ordering by arrows for drag and drop on resume_session and always available</li>
<li>[2025-06-27] (<a href="https://github.com/chamilo/chamilo-lms/commit/cc625edf76dacc01b9f33cc8a18fe2698849380a">cc625edf</a>) CI: Use "not allowed" instead of "not authorized" in course feature</li>
<li>[2025-06-27] (<a href="https://github.com/chamilo/chamilo-lms/commit/f93c3960b676830d17a6fa7211f99ab038daef9e">f93c3960</a>) CI: Use "not allowed" instead of "not authorized" in access company reports behat test</li>
<li>[2025-06-27] (<a href="https://github.com/chamilo/chamilo-lms/commit/431a26a8708b0f54891784614af82d123b74f189">431a26a8</a>) CI: Fix classes feature</li>
</ul>
<h3>Stylesheets and theming</h3>
<ul aria-live="off">
<li>No notable style change</li>
</ul>
<h3>Web services</h3>
<ul aria-live="off">
<li>[2025-09-22] (<a href="https://github.com/chamilo/chamilo-lms/commit/92e7d411e9ed1206a527030d9f9256588afe231b">92e7d411</a> - <a href="https://task.beeznest.com/issues/22302">BT#22302</a>) Webservice: Add update_session_from_extra_field WS - fix typo</li>
<li>[2025-09-22] (<a href="https://github.com/chamilo/chamilo-lms/commit/09fb91d4141b3983c468e84825d44ef77c9c9236">09fb91d4</a> - <a href="https://task.beeznest.com/issues/22302">BT#22302</a>) Webservice: Add update_session_from_extra_field WS</li>
<li>[2025-09-22] (<a href="https://github.com/chamilo/chamilo-lms/commit/806f347034ee14f4d0ae5deea6f00aa2337e36e8">806f3470</a> - <a href="https://task.beeznest.com/issues/22302">BT#22302</a>) Webservice: Add subscribe_user_to_session_from_extra_field WS</li>
<li>[2025-09-21] (<a href="https://github.com/chamilo/chamilo-lms/commit/972456724d2b7dd01f393de7045da3a05ea47fff">97245672</a> - <a href="https://task.beeznest.com/issues/22302">BT#22302</a>) Webservice: Add subscribe_course_to_session_from_extra_field WS</li>
<li>[2026-01-12] (<a href="https://github.com/chamilo/chamilo-lms/commit/b0ef139dadae47a55cfd7d4f8fbb81efdb68d8d5">b0ef139d</a> - <a href="https://task.beeznest.com/issues/23138">BT#23138</a>) Webservice: Add `add_session_course_coaches` action</li>
<li>[2026-01-12] (<a href="https://github.com/chamilo/chamilo-lms/commit/430c33b44e642dff7e982d4baa8ea4ab7bf79aaa">430c33b4</a> - <a href="https://task.beeznest.com/issues/23138">BT#23138</a>) Webservice: Improve course search response with detailed course information</li>
<li>[2026-01-08] (<a href="https://github.com/chamilo/chamilo-lms/commit/94d14ba702b8ea10ba07a4efe6ce1feea9321087">94d14ba7</a> - <a href="https://task.beeznest.com/issues/23138">BT#23138</a>) Webservice: Refactor session retrieval APIs to avoid `$_POST` usage and improve type safety</li>
<li>[2026-01-07] (<a href="https://github.com/chamilo/chamilo-lms/commit/9e1dc9a204064049ff30c968e17d0997f0a4be9f">9e1dc9a2</a> - <a href="https://task.beeznest.com/issues/23138">BT#23138</a>) Webservice: Log course code and session ID in GET_COURSE_BY_CODE requests</li>
<li>[2026-01-07] (<a href="https://github.com/chamilo/chamilo-lms/commit/fe4ce2182776732de769c5a66bcbb8bc718c0ed8">fe4ce218</a> - <a href="https://task.beeznest.com/issues/23138">BT#23138</a>) Webservice: allows getting user extra fields in request made by teachers</li>
<li>[2026-01-07] (<a href="https://github.com/chamilo/chamilo-lms/commit/22457947b6749da5cded10f1da9e639c45b7867c">22457947</a> - <a href="https://task.beeznest.com/issues/23138">BT#23138</a>) Webservice: Avoid to use $_POST and validate the `get_user_info_from_username` request</li>
<li>[2026-01-07] (<a href="https://github.com/chamilo/chamilo-lms/commit/ba7f514f61f9ebc83705814354650c15ca55c62f">ba7f514f</a> - <a href="https://task.beeznest.com/issues/23138">BT#23138</a>) Webservice: Allow passing course template during course creation</li>
<li>[2026-01-07] (<a href="https://github.com/chamilo/chamilo-lms/commit/43e19f89ede2347dec348da268b3c3c0064c8c11">43e19f89</a> - <a href="https://task.beeznest.com/issues/23138">BT#23138</a>) Webservice: Refactor `addCourse` method to use `ParameterBag` for improved request handling</li>
<li>[2026-01-06] (<a href="https://github.com/chamilo/chamilo-lms/commit/3b7f46da828e6683762e78ebe3d476cc401a8cb2">3b7f46da</a>) Webservice: RESTore composer.json</li>
<li>[2026-01-06] (<a href="https://github.com/chamilo/chamilo-lms/commit/d461887e9f9ed3a9dac507bdc0d4bead6e5195cc">d461887e</a> - <a href="https://task.beeznest.com/issues/23138">BT#23138</a>) Webservice: Add GET_COURSE_BY_CODE API endpoint for retrieving course details by code</li>
<li>[2026-02-27] (<a href="https://github.com/chamilo/chamilo-lms/commit/db0b0d99b34f11b86a0eb434d4bb299ec5399075">db0b0d99</a> - <a href="https://task.beeznest.com/issues/23138">BT#23138</a>) Webservice: Restrict access to `GET_COURSE_GRADEBOOK`</li>
<li>[2026-02-27] (<a href="https://github.com/chamilo/chamilo-lms/commit/fbb022e66b9323f7372f32da25c5d6569af1e402">fbb022e6</a> - <a href="https://task.beeznest.com/issues/23138">BT#23138</a>) Webservice: Add `GET_COURSE_GRADEBOOK` action to retrieve gradebook data</li>
</ul>
<h3>Removals</h3>
<ul aria-live="off">
<li>Some very difficult to enable, unofficial, undocumented CMS/FAQ management features have been removed.</li>
</ul>
<h3>Known issues</h3>
<ul aria-live="off">
<li>Compatibility with PHP 8.3 is not fully tested. Where it might work, we do depend on legacy Symfony and Doctrine versions that are not compatible with PHP 8, so this package is built using PHP 7.4. It has reasonable chances to work in a PHP 8.3 environment, but if this is critical for you, we suggest starting with Chamilo 2.0.</li>
</ul>
</div>
<div class="version" aria-label="1.11.32">
<a id="1.11.32"></a>
<h1>Chamilo 1.11.32 - Tikal, 27/06/2025</h1>
<h3>Release notes - summary</h3>
<p>Chamilo 1.11.32 is a micro corrective release on top of 1.11.30. For any significant change, please check the 1.11.30 release notes.</p>
<h3>Release name</h3>
<p><a href="https://en.wikipedia.org/wiki/Huaral">Huaral</a> a city in Peru, capital of the Huaral Province in the Department of Lima, on the path to the ancient city of Caral. 1.11.32 being a minor corrective release on top of 1.11.30 (called "Caral"), relating to a small city geographically close to Caral is a subtle wink to the Chamilo community there.</p>
<h3>Security fixes</h3>
<ul aria-live="off">
<li>No new vulnerability detected in this version</li>
</ul>
<h3>Important note</h3>
<p>Chamilo 1.11.30 comes with subtle changes in the root .htaccess file which could affect your system (for example by triggering "Not Found" errors on course homepages) if you use Apache &lt; 2.4.38-3. Please check line 37 of /.htaccess for more info.</p>
<h3>Notable new Features</h3>
<h4>For end-users, teachers and Chamilo admins</h4>
These features are immediately available to users through the web interface.<br />
<ul aria-live="off">
<li>No notable change</li>
</ul>
<h4>For developers and sysadmins</h4>
Although most features here will be used by teachers or Chamilo admins, they require sysadmin privileges to enable them on the server.
<ul aria-live="off">
<li>No notable change</li>
</ul>
<h3>Improvements (minor features) and debug</h3>
In reverse chronological order...
<ul aria-live="off">
<li>[2025-06-27] (<a href="https://github.com/chamilo/chamilo-lms/commit/a039923b7065fb00d2b5b1cfd56e7d4fb2703dbd">a039923b</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/6400">GH#6400</a>) Internal: Remove potential double conversion of quotes in api_htmlentites() (change introduced in 1.11.30 to increase support for PHP 8.3) - loosely refs</li>
<li>[2025-06-27] (<a href="https://github.com/chamilo/chamilo-lms/commit/ef7c8b5eb65eb6ffeaa4422fbfbb690e920b28e9">ef7c8b5e</a>) Plugin: OnlyOffice: Bump version to 1.5.0 and merge with exercise-specific code + fix issues saving document details</li>
</ul>
<h3>Stylesheets and theming</h3>
<ul aria-live="off">
<li>No notable style change</li>
</ul>
<h3>Web services</h3>
<ul aria-live="off">
<li>[2025-06-26] (<a href="https://github.com/chamilo/chamilo-lms/commit/ccaf0f6629e77b6ab5dc3e2ca068ec6e5dd0e7ab">ccaf0f66</a> - <a href="https://task.beeznest.com/issues/22611">BT#22611</a>) Webservice: Allow receiving body content in JSON format -</li>
</ul>
<h3>Removals</h3>
<ul aria-live="off">
<li>No notable removal</li>
</ul>
<h3>Known issues</h3>
<ul aria-live="off">
<li>No notable known issue</li>
</ul>
</div>
<div class="version" aria-label="1.11.30">
<a id="1.11.30"></a>
<h1>Chamilo 1.11.30 - Caral, 25/06/2025</h1>
<h3>Release notes - summary</h3>
<p>Chamilo 1.11.30 is a patch release on top of 1.11.28.</p>
<h3>Release name</h3>
<p><a href="https://en.wikipedia.org/wiki/Caral">Caral</a> is an archaeological site in Peru where the remains of the main city of the Caral civilization are found. It is attributed an antiquity of 5,000 years and it is considered the oldest city in the Americas and one of the oldest in the world. We found this reference ironically related to Chamilo 1.11.30 as we are closing towards a 2.0 release.</p>
<h3>Security fixes</h3>
<ul aria-live="off">
<li>[2024-09-27] (<a href="https://github.com/chamilo/chamilo-lms/commit/387808b5e23963cea126414cf068b05e4c31ede8">387808b5</a>) Security: Social: Add sec_token when commenting posts Fix GHSA-33gm-vrgh-m239</li>
<li>[2024-09-27] (<a href="https://github.com/chamilo/chamilo-lms/commit/ad03014235c6f55a4b5d1d9c56e203af525a2280">ad030142</a>) Security: Social: Add sec_token when accepting a friend request Fix GHSA-33gm-vrgh-m239</li>
<li>[2024-09-27] (<a href="https://github.com/chamilo/chamilo-lms/commit/0c4dae40c0b126d77930543fa03ac081d6f38693">0c4dae40</a>) Security: Social: Add sec_token when denying a friend request Fix GHSA-33gm-vrgh-m239</li>
<li>[2024-09-27] (<a href="https://github.com/chamilo/chamilo-lms/commit/5fadf075429329aff59133e833fae715b6de6b4e">5fadf075</a>) Security: Social: Add sec_token when deleting friend Fix GHSA-33gm-vrgh-m239</li>
<li>[2024-10-23] (<a href="https://github.com/chamilo/chamilo-lms/commit/8ff67c3ad239b1668279e0be3f334b333927a445">8ff67c3a</a>) Security: Glossary: Remove XSS Fix GHSA-4wcp-3rh3-7wm4 advisory</li>
<li>[2024-12-02] (<a href="https://github.com/chamilo/chamilo-lms/commit/f9150075246df4ed9755a4a150e25edb468767be">f9150075</a>) Security: Confirm delete action with modal instead of alert Fix advisory GHSA-gw58-89f7-4xgj</li>
<li>[2024-12-02] (<a href="https://github.com/chamilo/chamilo-lms/commit/82cc07edd8ef316e6b36da7c501120d5c0aeb151">82cc07ed</a>) Security: Remove on* attributes through new filter of HTML Purifier Fix advisory GHSA-gw58-89f7-4xgj</li>
<li>[2024-12-02] (<a href="https://github.com/chamilo/chamilo-lms/commit/241c569dde0ad0e34d558ae51271f70438189b0e">241c569d</a>) Security: Remove on* attributes for input text fields Fix advisory GHSA-gw58-89f7-4xgj</li>
<li>[2024-12-06] (<a href="https://github.com/chamilo/chamilo-lms/commit/7212fb2a1e00d68a35c89527569beb91c8433daa">7212fb2a</a> - <a href="https://task.beeznest.com/issues/22273">BT#22273</a>) Work: Security: Sanitize file name that could import document with special characters</li>
<li>[2024-12-06] (<a href="https://github.com/chamilo/chamilo-lms/commit/3075eeba7a3482dc19b7025a317221e4aaa27f6e">3075eeba</a> - <a href="https://task.beeznest.com/issues/22273">BT#22273</a>) Dropbox: Security: Sanitize file name that could import document with special characters</li>
<li>[2024-12-15] (<a href="https://github.com/chamilo/chamilo-lms/commit/822ae55513dce5c7dfa42850c0466ff5ed37355d">822ae555</a>) Security: Plugin: OnlyOffice: Add filtering to new filenames created through the plugin</li>
<li>[2025-01-14] (<a href="https://github.com/chamilo/chamilo-lms/commit/15023ce6301a8b623d789e8e788b2db9bf470547">15023ce6</a>) Display: Sanitize attributes for anchor tag in Display::url function Refs advisory GHSA-gw58-89f7-4xgj</li>
<li>[2025-01-14] (<a href="https://github.com/chamilo/chamilo-lms/commit/89d2026720a9cf65275ac03ea467e09a22370316">89d20267</a>) Glossary: Use entity to save glossary Refs advisory GHSA-gw58-89f7-4xgj</li>
<li>[2025-01-16] (<a href="https://github.com/chamilo/chamilo-lms/commit/beb07770d674fcc9db6df0e59aab107678c28682">beb07770</a>) Security: Remove on-attributes when showing an HTML editor in forms Refs advisory GHSA-24cc-9jp9-rxx6</li>
<li>[2025-01-24] (<a href="https://github.com/chamilo/chamilo-lms/commit/d5c29cf39ac30d7364a52bba4036c3e870412066">d5c29cf3</a>) Security: Prevent XSS when setting image alt text by using the ckeditor image plugin Refs advisory GHSA-24cc-9jp9-rxx6</li>
<li>[2025-02-17] (<a href="https://github.com/chamilo/chamilo-lms/commit/8c4e6436379c31d88acc30cfd1095feed6d43e96">8c4e6436</a> - <a href="https://task.beeznest.com/issues/22421">BT#22421</a>) Security: Fix pattern to remove on* attributes from HTML tags</li>
<li>[2025-04-09] (<a href="https://github.com/chamilo/chamilo-lms/commit/fd0f2a95b21fa28da47d785c88b879a77951b0f7">fd0f2a95</a>) Security: Fix SQL injection vulnerability by escaping dates in SOAP registration script</li>
<li>[2025-04-09] (<a href="https://github.com/chamilo/chamilo-lms/commit/06155f128395acc6745af826150a0bb2b4a68496">06155f12</a>) Security: Fix SQL injection vulnerability by escaping filename when uploading hot potatoes image</li>
<li>[2025-04-09] (<a href="https://github.com/chamilo/chamilo-lms/commit/613eb19a0fac6dd49c233f32ad5428cd29d5a468">613eb19a</a>) Security: Fix SQL injection vulnerability by escaping assoc_handle in openid login</li> </ul>
<li>[2025-04-10] (<a href="https://github.com/chamilo/chamilo-lms/commit/22bb81df8f7062da20a2f6248789f47b221ca705">22bb81df</a>) Security: Improve database query handling in CourseSelectForm by using parameterized queries and simplifying SQL logic</li>
<li>[2025-04-14] (<a href="https://github.com/chamilo/chamilo-lms/commit/4d07349340543b9c913e76f8cb436a4570500ce2">4d073493</a>) Security: Sanitize English name input in sub_language_add.php to prevent dangerous characters</li>
<li>[2025-04-14] (<a href="https://github.com/chamilo/chamilo-lms/commit/f35acf013b72656ac33ac273a9ed579a5d3af3d4">f35acf01</a>) Security: Use FormValidator::addText and FormValidator::addCheckBox instead addElement method</li>
<li>[2025-04-14] (<a href="https://github.com/chamilo/chamilo-lms/commit/afdbd4bb9a9ea17b7740559dd4e05aa13b16480d">afdbd4bb</a>) Security: Plugin: VChamilo: Add clearDatabaseName method to sanitize database names and update usages</li>
<li>[2025-04-15] (<a href="https://github.com/chamilo/chamilo-lms/commit/e1c7879d63172cd7e5e47c9a03ade0e32023e134">e1c7879d</a>) Security: Sanitize language variable inputs and improve variable handling in multiple files</li>
<li>[2025-04-15] (<a href="https://github.com/chamilo/chamilo-lms/commit/e343c764493b457c82d1e85b91a3655b093767ed">e343c764</a>) Security: Sanitize main database name in Virtual.php to prevent unsafe inputs</li>
<li>[2025-04-21] (<a href="https://github.com/chamilo/chamilo-lms/commit/89c67e6630852cf94d288499e2cd6f6a06f9c6f1">89c67e66</a>) Security: Add MIME type validation for image uploads in ck_uploadimage action</li>
<li>[2025-04-21] (<a href="https://github.com/chamilo/chamilo-lms/commit/07f7954f2dd18c4f5a307b2a6fa802d9ce36b827">07f7954f</a>) Security: Plugin: VChamilo: Update import logic to handle 'phar://' paths to import config</li>
<li>[2025-04-24] (<a href="https://github.com/chamilo/chamilo-lms/commit/349062d31533d464feea78d41996bc0857f90f61">349062d3</a>) Security: Refactor hidden input generation using Display::input Replaced manual HTML string construction with the Display::input method for hidden inputs. This improves code readability, maintainability, and aligns with existing utility usage. Security measures with Security::remove_XSS remain intact. See advisory GHSA-7p5f-34rx-49h8</li>
<li>[2025-04-24] (<a href="https://github.com/chamilo/chamilo-lms/commit/aaf975467213bbd340fc7493513fd9570eb5cf9f">aaf97546</a>) Security: Remove unused 'page' parameter from session user forms. See advisory GHSA-h3m8-53j3-xjx8</li>
<li>[2025-04-25] (<a href="https://github.com/chamilo/chamilo-lms/commit/083b1d2b0c29b0cc0313a28165ad47bebae9dcb2">083b1d2b</a>) Security: Add a whitelist of allowed help topics</li>
<li>[2025-05-12] (<a href="https://github.com/chamilo/chamilo-lms/commit/39e0fa88a2ba5dd197e0d8ce7335730b666992a6">39e0fa88</a>) Security: Messages: Ensure accepted friends have invitations sent See advisory GHSA-m5xj-5xf3-rqch</li>
<li>[2025-05-12] (<a href="https://github.com/chamilo/chamilo-lms/commit/790ef513aceacae6fe5b6641145901f04c7992dd">790ef513</a>) Security: Apply XSS removal when importing users See advisory GHSA-hc3c-8p55-xh4r</li>
<li>[2025-05-13] (<a href="https://github.com/chamilo/chamilo-lms/commit/ba7e15d8cfefcd451de939e98d461b17e72eb627">ba7e15d8</a>) Security: Fix case sensitivity in phar file validation Adjusted the `str_starts_with` checks to be case-insensitive by converting paths to lowercase. Also refactored the logic to separate `isPharFile` handling from writable checks, improving readability and error handling with better feedback messages.</li>
<li>[2025-05-14] (<a href="https://github.com/chamilo/chamilo-lms/commit/9fef8f30b41d586cb4f4fc823906c16a12ae0ff4">9fef8f30</a>) Security: Sanitize uploaded file names in a user import process See advisory GHSA-wrx6-5v5r-mmgx</li>
<li>[2025-05-26] (<a href="https://github.com/chamilo/chamilo-lms/commit/4eb2ffed674eedc2e5e524b7a5a66801b20f28ca">4eb2ffed</a>) Security: Initialize the variable to avoid a wrong result by old value See GHSA-qv5j-rq2p-q5w5</li>
<li>[2025-05-26] (<a href="https://github.com/chamilo/chamilo-lms/commit/c3afce072806198c39e36de7b0b72be01ca7c6c2">c3afce07</a>) Security: Initialize variable to avoid wrong result by old value See GHSA-qv5j-rq2p-q5w5</li>
<li>[2025-06-09] (<a href="https://github.com/chamilo/chamilo-lms/commit/a02c1cd04018fc883054a9cbc32024c4c07d7dc6">a02c1cd0</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/5972">GH#5972</a>) Documentation: Add security documentation about CSP (Content Security Policy) headers needing to allow unsafe-inline and unsafe-eval for the inline editor to work</li>
<li>[2025-06-17] (<a href="https://github.com/chamilo/chamilo-lms/commit/ead79db4eb034b8c11a3d6036759d083de37530c">ead79db4</a>) Security: Fix XSS in session category See advisory GHSA-p4m6-gwhg-x89f</li>
<li>[2025-06-24] (<a href="https://github.com/chamilo/chamilo-lms/commit/d672d11871628a68f9948b40d0a5ed1a87013bfa">d672d118</a>) Security: Set token validation to set a student as tutor</li>
<li>[2025-06-24] (<a href="https://github.com/chamilo/chamilo-lms/commit/1aaa53da9bc358c5493c5e6de71be825fd8de4f8">1aaa53da</a>) Security: Exercise: Remove XSS when displaying fill in blanks results</li>
</ul>
<h3>Important note</h3>
<p>Chamilo 1.11.30 comes with subtle changes in the root .htaccess file which could affect your system (for example by triggering "Not Found" errors on course homepages) if you use Apache &lt; 2.4.38-3. Please check line 37 of /.htaccess for more info.</p>
<h3>Notable new Features</h3>
<h4>For end-users, teachers and Chamilo admins</h4>
These features are immediately available to users through the web interface.<br />
<ul aria-live="off">
<li>[2024-10-28] (<a href="https://github.com/chamilo/chamilo-lms/commit/775a5452f10c8084e3f4c0e4302b3895a5a06050">775a5452</a> - <a href="https://task.beeznest.com/issues/20900">BT#20900</a>) Plugin: Exercise monitoring and mouse focus tracking allowing for better control of remote exams, akin to some proctoring services</li>
<li>[2024-11-14] (<a href="https://github.com/chamilo/chamilo-lms/commit/8bcf1c1678b37efc01c7776e679856c7dbd0871a">8bcf1c16</a> - <a href="https://task.beeznest.com/issues/22195">BT#22195</a>) Learnpath: Plugin: OnlyOffice: Allow showing PDF files directly in the learning path when ONLYOFFICE plugin is enabled</li>
<li>[2024-12-13] (<a href="https://github.com/chamilo/chamilo-lms/commit/ab1f124dfdf3efe812c5ebe2f58a02d0c591f2ec">ab1f124d</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/5395">GH#5395</a>) Plugin: OnlyOffice: Add OnlyOffice viewer by default for corresponding extensions in documents tool when the plugin is enabled</li>
<li>[2024-12-23] (<a href="https://github.com/chamilo/chamilo-lms/commit/918416a8443f1a7861f94be09fc93f76e27129a1">918416a8</a> - <a href="https://task.beeznest.com/issues/22232">BT#22232</a>) Portfolio: List base-course content in sessions when setting 'portfolio_show_base_course_post_in_sessions' is enabled</li>
<li>[2025-01-22] (<a href="https://github.com/chamilo/chamilo-lms/commit/6954d866c7513bcad937215f9061232ca11e8739">6954d866</a>) Plugin: AI Helper: Add DeepSeek support</li>
<li>[2025-02-11] (<a href="https://github.com/chamilo/chamilo-lms/commit/7d9da9c491e255f02c6637829d25ebe9b2c3f9d3">7d9da9c4</a> - <a href="https://task.beeznest.com/issues/22305">BT#22305</a>) User: Add extra fields filter to advanced user search in administration</li>
<li>[2025-02-18] (<a href="https://github.com/chamilo/chamilo-lms/commit/df715cb4b043c88e69fe8e05156cef8b0678cbef">df715cb4</a> - <a href="https://task.beeznest.com/issues/22403">BT#22403</a>) Exercise: Add configuration setting 'exercise_subscribe_session_when_finished_failure' to allow subscribing a user to a specific session when the user has failed a given test</li>
<li>[2025-02-18] (<a href="https://github.com/chamilo/chamilo-lms/commit/4f5406bed5638268f06b55ce78159406a1fb7b3e">4f5406be</a> - <a href="https://task.beeznest.com/issues/22403">BT#22403</a>) Exercise: Add configuration setting 'exercise_text_when_finished_failure' to allow displaying a text when the user has failed</li>
<li>[2025-02-19] (<a href="https://github.com/chamilo/chamilo-lms/commit/905ea485af6821a7cca6561a903114eac0d1990b">905ea485</a> - <a href="https://task.beeznest.com/issues/22396">BT#22396</a>) Tracking: Show progress based on visibles learning paths only (was previously showing global progress based on all learning paths including hidden ones, which made no visual sense)</li>
<li>[2025-03-06] (<a href="https://github.com/chamilo/chamilo-lms/commit/07072db9fc44277287ed816009ce9fe50a452e75">07072db9</a>) Plugin: H5pImport: Add hook to create course tool in new courses</li>
<li>[2025-03-08] (<a href="https://github.com/chamilo/chamilo-lms/commit/6bc5b60f6fed810a811859e50edcb9a463911100">6bc5b60f</a> - <a href="https://task.beeznest.com/issues/22370">BT#22370</a>) Exercise: Add OnlyOffice question type with document editing support</li>
<li>[2025-04-28] (<a href="https://github.com/chamilo/chamilo-lms/commit/7805cd609c425f20fa37153207a51aa170ed1ad1">7805cd60</a> - <a href="https://task.beeznest.com/issues/22464">BT#22464</a>) Calendar: Add option for course agenda to define default participants</li>
<li>[2025-05-21] (<a href="https://github.com/chamilo/chamilo-lms/commit/5012f87432650e06dcf6f59b73871bb806219ebc">5012f874</a> - <a href="https://task.beeznest.com/issues/22617">BT#22617</a>) Document: Add option to define an access start date for any given document</li>
<li>[2025-06-20] (<a href="https://github.com/chamilo/chamilo-lms/commit/92d370cd12eb43f548878188b8171fa80a0b5ecd">92d370cd</a>) Language: Mongolian language added. Requires following query to manually enable in 1.11.30: "INSERT INTO language (original_name, english_name, isocode, dokeos_folder, available) VALUES ('&#1052;&#1086;&#1085;&#1075;&#1086;&#1083; &#1093;&#1101;&#1083;','mongolian','mn','mongolian',1);" - Contributed by Tsogtsaikhan Byambaa</li>
</ul>
<h4>For developers and sysadmins</h4>
Although most features here will be used by teachers or Chamilo admins, they require sysadmin privileges to enable them on the server.
<ul aria-live="off">
<li>[2024-11-18] (<a href="https://github.com/chamilo/chamilo-lms/commit/286b61d167d1ca0b67b09c71511871e64768d36a">286b61d1</a> - <a href="https://task.beeznest.com/issues/21930">BT#21930</a>) Plugin: Azure: Add option to use delta queries when syncing</li>
<li>[2024-12-23] (<a href="https://github.com/chamilo/chamilo-lms/commit/e661cbb2932ba0f8b5324eac4c9330b523994140">e661cbb2</a> - <a href="https://task.beeznest.com/issues/22232">BT#22232</a>) Portfolio: Add configuration setting 'portfolio_show_base_course_post_in_sessions'</li>
<li>[2025-01-03] (<a href="https://github.com/chamilo/chamilo-lms/commit/c1aa647717d20874b7b61dd4f6baab33ee7e0f71">c1aa6477</a>) Session: Enable user session duration manual extension</li>
<li>[2025-01-29] (<a href="https://github.com/chamilo/chamilo-lms/commit/055273bd2444079660436ec10267b50ac58513c6">055273bd</a>) Internal: Set Apache redirect rules in .htaccess to defaults that work with most Apache >2.4.38-3.</li>
<li>[2025-02-04] (<a href="https://github.com/chamilo/chamilo-lms/commit/44f063a249a6cea2b351a15181dc6ed8e634ba10">44f063a2</a> - <a href="https://task.beeznest.com/issues/22302">BT#22302</a>) Extra Fields: Add support for attendance extra field</li>
<li>[2025-05-30] (<a href="https://github.com/chamilo/chamilo-lms/commit/358089b19a644ca1c900b359e5f7a479432b68c8">358089b1</a> - <a href="https://task.beeznest.com/issues/22302">BT#22302</a>) Scripts: Add script to change course code. Replaces strings in all HTML files and exercises content</li>
<li>[2025-06-17] (<a href="https://github.com/chamilo/chamilo-lms/commit/85febd572d37916037e86cdea3a304db89fb03c6">85febd57</a> - <a href="https://task.beeznest.com/issues/22320">BT#22320</a>) Cron: Add users import from XLSX cron script with configurable input fields and comparison mechanisms for a first sync</li>
<li>[2025-06-17] (<a href="https://github.com/chamilo/chamilo-lms/commit/493303827207a1c5fc50d8e405c735afcfb2b993">49330382</a> - <a href="https://task.beeznest.com/issues/22676">BT#22676</a>) Display: Add configuration setting 'display_menu_use_course_categories' to use course categories as top horizontal drop-down menu</li>
<li>[2025-06-17] (<a href="https://github.com/chamilo/chamilo-lms/commit/205484522c2555251852f7dfddf48310c56724f9">20548452</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/6013">GH#6013</a>) Session: Add configuration setting 'scheduled_announcements_use_base_progress' for scheduled session announcement - by progress</li>
<li>[2025-06-18] (<a href="https://github.com/chamilo/chamilo-lms/commit/e69282cded7b999398a6ebfd769e21d9d219a4eb">e69282cd</a>) Admin: Add configuration setting 'session_import_drh_hide_old_relationships_check_box' to hide the old relationships checkbox on session import view for drh users</li>
<li>[2025-06-25] (<a href="https://github.com/chamilo/chamilo-lms/commit/4fd8e7d607043030f65cf20df583780304734371">4fd8e7d6</a> - <a href="https://task.beeznest.com/issues/22725">BT#22725</a>) Ticket: Add ticket deletion feature</li>
</ul>
<h3>Improvements (minor features) and debug</h3>
In reverse chronological order...
<ul aria-live="off">
<li>[2025-06-25] (<a href="https://github.com/chamilo/chamilo-lms/commit/1d47856bcae8cb8e992b577a4f7ed8dc17b9b755">1d47856b</a> - <a href="https://task.beeznest.com/issues/22725">BT#22725</a>) Language: Ticket: Add partial translation in ES, FR, EN for ticket deletion functionnality</li>
<li>[2025-06-25] (<a href="https://github.com/chamilo/chamilo-lms/commit/735267ddace929bc99e58c0af424e172096a8717">735267dd</a>) Internal: Fix deprecated calls to mb_convert_encoding() to encode to HTML entities. Helps support PHP 8.3</li
<li>[2025-06-25] (<a href="https://github.com/chamilo/chamilo-lms/commit/735267ddace929bc99e58c0af424e172096a8717">735267dd</a>) Internal: Fix deprecated calls to mb_convert_encoding() to encode to HTML entities. Helps support PHP 8.3</li>
<li>[2025-06-25] (<a href="https://github.com/chamilo/chamilo-lms/commit/be94e40ff58eed62e05f369298c950fa7d1297e3">be94e40f</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/5021">GH#5021</a>) Internal: Kses: Fix issue preventing loading $kses_allowedentitynames from global scope in PHP 8+</li>
<li>[2025-06-24] (<a href="https://github.com/chamilo/chamilo-lms/commit/0712adb23e29a873cd87f903501dd94dc45089e2">0712adb2</a>) CI: Fix issues setting up test environment for behat</li>
<li>[2025-06-24] (<a href="https://github.com/chamilo/chamilo-lms/commit/50a181d14350a6c91d12c0da074d6983776f28d7">50a181d1</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/6384">GH#6384</a>) Ticket: Add option to give session admin the same right as admin</li>
<li>[2025-06-24] (<a href="https://github.com/chamilo/chamilo-lms/commit/c3ef857a22c615b637fd0c6a594c5d2ea05ee3a2">c3ef857a</a>) Use null coalescing operator for cleaner code in fill_blanks and question classes</li>
<li>[2025-06-24] (<a href="https://github.com/chamilo/chamilo-lms/commit/62d37abfa192e5c92fd7b629ddf58e491ee1bedd">62d37abf</a>) CI: Test updated version of Chrome install</li>
<li>[2025-06-24] (<a href="https://github.com/chamilo/chamilo-lms/commit/77c1b6cb75751b197c5241f6d337acaf671a1632">77c1b6cb</a>) CI: Force use of PHP 7.4 in automated tests building sequence</li>
<li>[2025-06-24] (<a href="https://github.com/chamilo/chamilo-lms/commit/655cf868fadbe541065a0e6d6cae13ab3baf85ee">655cf868</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/5138">GH#5138</a>) Plugin: before_login: Fix error due to (apparently) rogue copy-paste in plugin "Before login". Add information about config and theming to README.md</li>
<li>[2025-06-24] (<a href="https://github.com/chamilo/chamilo-lms/commit/ea2e427e88252c0d66eacbf48bc9dd1c0fede4af">ea2e427e</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/5138">GH#5138</a>) Plugin: before_login: Fix error due to (apparently) rogue copy-paste in plugin "Before login". Add information about config and theming to README.md</li>
<li>[2025-06-24] (<a href="https://github.com/chamilo/chamilo-lms/commit/7d21eb8a20da129a0aae4ff01d2b984a2ae8a526">7d21eb8a</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/5538">GH#5538</a>) Admin: Modify default expiration date behaviour to avoid adding default expiration period when selecting "Never expires". Affects CSV import and user creation/edition forms</li>
<li>[2025-06-24] (<a href="https://github.com/chamilo/chamilo-lms/commit/056f3fa2204a4f0707b7b2a43d8498cd616df737">056f3fa2</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/5581">GH#5581</a>) Plugin: BuyCourses: Fix error in coupon processing</li>
<li>[2025-06-24] (<a href="https://github.com/chamilo/chamilo-lms/commit/3caf822dd5b287c353528cc7f7423a8b19e90e11">3caf822d</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/5637">GH#5637</a>) Documentation: Fix RewriteRule for optimization of files access</li>
<li>[2025-06-24] (<a href="https://github.com/chamilo/chamilo-lms/commit/b3e7531115467a2d02a3c66f0536a29b07ce7566">b3e75311</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/5637">GH#5637</a>) Internal: Fix .htaccess rules for scorm content and course home icons</li>
<li>[2025-06-23] (<a href="https://github.com/chamilo/chamilo-lms/commit/221e6d80ab21a77839af690dbe3a81728eb48133">221e6d80</a> - <a href="https://task.beeznest.com/issues/21977">BT#21977</a>) Course: Improve export of quiz questions, assignments in LP, and images in introduction page with mzb format</li>
<li>[2025-06-24] (<a href="https://github.com/chamilo/chamilo-lms/commit/992c6158d16ad090bad08be08fe77c894c3cc8ec">992c6158</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/5673">GH#5673</a>) Internal: Fix support for X-Sendfile headers</li>
<li>[2025-06-24] (<a href="https://github.com/chamilo/chamilo-lms/commit/e797ca057c53c631560ffde5e7d79506e3bc004d">e797ca05</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/5717">GH#5717</a>) Plugin: Add courseToolDefaultVisibility property to Plugin class to enable course plugins that are not visible by default (set it from the plugin with $this->setCourseToolDefaultVisibility(false); before install_course_fields_in_all_courses()). Enabled on positioning plugin</li>
<li>[2025-06-24] (<a href="https://github.com/chamilo/chamilo-lms/commit/9533472f70cfb58d26c6fd578858ca9678f12458">9533472f</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/6185">GH#6185</a>) Internal: Trim spaces around passwords in mail to user on user creation</li>
<li>[2025-06-23] (<a href="https://github.com/chamilo/chamilo-lms/commit/3de62682f90d33efe772cb1f4745e9051dec8ea0">3de62682</a> - <a href="https://task.beeznest.com/issues/21977">BT#21977</a>) Course: Export HTML documents as Moodle page activities</li>
<li>[2025-06-21] (<a href="https://github.com/chamilo/chamilo-lms/commit/f9726b414556b8268f94c190630c7d2e00b50a5c">f9726b41</a>) Documentation: Add info about Mongolian language to changelog & estimated release date for 1.11.30</li>
<li>[2025-06-21] (<a href="https://github.com/chamilo/chamilo-lms/commit/c55d63dd1c8aea3e3d2a77181fc7b3cc450362c8">c55d63dd</a>) Language: Added comments in data.sql for the addition of Estionian and Mongolian languages in a future major version. The languages are there, but internal policy prevent us from adding them in a minor version (because if would make the database records different from the default of 1.11.0)</li>
<li>[2025-06-20] (<a href="https://github.com/chamilo/chamilo-lms/commit/a2cc79241319626e03c17dc6f2ba9193677972f5">a2cc7924</a>) Language: Update translations</li>
<li>[2025-06-20] (<a href="https://github.com/chamilo/chamilo-lms/commit/19327a6213b4adc1a6b10dd1da01d95c1879e74c">19327a62</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/6218">GH#6218</a>) Session: Add send_subscription_notification field when copying sessions (#6218) Author: @nosolored</li>
<li>[2025-06-20] (<a href="https://github.com/chamilo/chamilo-lms/commit/2373eed527b6bc0409ef994f2ac60fcb58028cc7">2373eed5</a>) Attendance: Add group ID to parameters when using tablet vue with signature Author: @TheTomcat14</li>
<li>[2025-06-20] (<a href="https://github.com/chamilo/chamilo-lms/commit/98c42aaf628c2a18a94a81e3de8375f4e0ef9927">98c42aaf</a>) Admin: ensure "login_as" flag is off anytime we logout or login with username/password directly</li>
<li>[2025-06-20] (<a href="https://github.com/chamilo/chamilo-lms/commit/d808c28a9430333f0fed73d33d4f5c3aab044ac8">d808c28a</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/6012">GH#6012</a>) Session: Enable user session duration extension days (#6012)</li>
<li>[2025-06-20] (<a href="https://github.com/chamilo/chamilo-lms/commit/6a26e4ab1329093ff88cb21e208e99a1461803a4">6a26e4ab</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/6012">GH#6012</a>) Session: Add avatar display in session_user_edit.php for sessions with duration</li>
<li>[2025-06-18] (<a href="https://github.com/chamilo/chamilo-lms/commit/e16b30320c0c0c48803a97b41c2adcf850ad2b03">e16b3032</a> - <a href="https://task.beeznest.com/issues/22714">BT#22714</a>) Learnpath: fix Language variable to reuse an existing one</li>
<li>[2025-06-17] (<a href="https://github.com/chamilo/chamilo-lms/commit/d08274508892d2bb63aca04cd4e8a14187c268ef">d0827450</a> - <a href="https://task.beeznest.com/issues/22175">BT#22175</a>) WYSIWYG: Allow Genially iframes in HTMLPurifier filter</li>
<li>[2025-06-18] (<a href="https://github.com/chamilo/chamilo-lms/commit/18dc815b7022ab203de0b5b35aeb8a0a0d6b618e">18dc815b</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/6013">GH#6013</a>) Session: Fix label in schedule announcements for 'progress' type</li>
<li>[2025-06-17] (<a href="https://github.com/chamilo/chamilo-lms/commit/e630edee37d9899566899916fc1bb26727483dc3">e630edee</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/5504">GH#5504</a>) Webservice: Update example WS call for add_courses_session</li>
<li>[2025-06-17] (<a href="https://github.com/chamilo/chamilo-lms/commit/1b7e0030d1c347abd72a12715da10cf89a1475f0">1b7e0030</a> - <a href="https://task.beeznest.com/issues/22714">BT#22714</a>) Internal: Add labels to addmultiselect element for formvalidator</li>
<li>[2025-06-17] (<a href="https://github.com/chamilo/chamilo-lms/commit/874f59ceb7f7670aea1b938025449cc00c10ee21">874f59ce</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/6030">GH#6030</a>) Setitngs: Language: Fix PlatformLanguage visualization when on multi-URL configuration</li>
<li>[2025-06-17] (<a href="https://github.com/chamilo/chamilo-lms/commit/24367776fd66ff28b136ac4bc6386b16b5af968c">24367776</a> - <a href="https://task.beeznest.com/issues/22710">BT#22710</a>) Portfolio: Keep user seleccion context when going back to post list from post view</li>
<li>[2025-06-17] (<a href="https://github.com/chamilo/chamilo-lms/commit/995c4fb025569a2475b3fe0bad480a30a25b0a76">995c4fb0</a> - <a href="https://task.beeznest.com/issues/22711">BT#22711</a>) Portfolio: Fix encoding error to show all post by alphabetical order</li>
<li>[2025-06-17] (<a href="https://github.com/chamilo/chamilo-lms/commit/d27e1216fc741120360d2bcc9a2dd878423bc31f">d27e1216</a> - <a href="https://task.beeznest.com/issues/22711">BT#22711</a>) Portfolio: Fix encoding error to show all post by alphabetical order</li>
<li>[2025-06-17] (<a href="https://github.com/chamilo/chamilo-lms/commit/23c53c9ad6fe2ea95a3636b9ad4dba40eb177d9b">23c53c9a</a> - <a href="https://task.beeznest.com/issues/22711">BT#22711</a>) Portfolio: Fix option to show all post by alphabetical order and set only one link</li>
<li>[2025-06-17] (<a href="https://github.com/chamilo/chamilo-lms/commit/2d0b264931942061baedf0e11249556deb7e7a0c">2d0b2649</a> - <a href="https://task.beeznest.com/issues/22711">BT#22711</a>) Portfolio: Add option to show all post by alphabetical order</li>
<li>[2025-06-16] (<a href="https://github.com/chamilo/chamilo-lms/commit/b66bb19a92cefe68dcca7c2cf66f0331ba0c436c">b66bb19a</a> - <a href="https://task.beeznest.com/issues/22688">BT#22688</a>) Ticket: Fix advanced ticket search to select all type of user since tickets can be assign to all type of user</li>
<li>[2025-06-13] (<a href="https://github.com/chamilo/chamilo-lms/commit/340deb6298899a9d061be219ebdc33e060ce942b">340deb62</a>) Plugin: Azure: Update resource for auth code with new Microsoft Graph API</li>
<li>[2025-06-10] (<a href="https://github.com/chamilo/chamilo-lms/commit/70d71006bd8f73a87ca0ca95159b4929b6f49900">70d71006</a> - <a href="https://task.beeznest.com/issues/22691">BT#22691</a>) Session: Fix session per duration visibility management for user_portal page</li>
<li>[2025-06-04] (<a href="https://github.com/chamilo/chamilo-lms/commit/e1b3af971b9f67c0b2f49a7dc06aed8f5559bb51">e1b3af97</a> - <a href="https://task.beeznest.com/issues/22302">BT#22302</a>) Scripts: Replace course code in cidReq param</li>
<li>[2025-06-04] (<a href="https://github.com/chamilo/chamilo-lms/commit/ee656f0f3ac27310028136e8cc18899c198ad056">ee656f0f</a> - <a href="https://task.beeznest.com/issues/22302">BT#22302</a>) Scripts: Fix tables fields to replace course code</li>
<li>[2025-06-04] (<a href="https://github.com/chamilo/chamilo-lms/commit/d1bf59068f0a0fb612a655874e9fcbf6ef446399">d1bf5906</a> - <a href="https://task.beeznest.com/issues/22302">BT#22302</a>) Scripts: Use the appropriate directory name when replacing the course in replace_course_code.php</li>
<li>[2025-06-04] (<a href="https://github.com/chamilo/chamilo-lms/commit/4e8be04358ed0e115293077aba849b7bb389b1fa">4e8be043</a> - <a href="https://task.beeznest.com/issues/22302">BT#22302</a>) Simplify variable initialization using null coalescing operator</li>
<li>[2025-06-04] (<a href="https://github.com/chamilo/chamilo-lms/commit/3917784c1daed663c2e4df0cc5cee238b2f04173">3917784c</a> - <a href="https://task.beeznest.com/issues/22302">BT#22302</a>) Scripts: Improve action messages in replace_course_code</li>
<li>[2025-06-03] (<a href="https://github.com/chamilo/chamilo-lms/commit/94127eecb4175173c16361c853687c2e09109db9">94127eec</a> - <a href="https://task.beeznest.com/issues/22302">BT#22302</a>) Scripts: Add missing tables to replace course code</li>
<li>[2025-06-03] (<a href="https://github.com/chamilo/chamilo-lms/commit/84d4f67eb52765bf8e8aa3d17b3851f5c0a103e4">84d4f67e</a> - <a href="https://task.beeznest.com/issues/22302">BT#22302</a>) Refactoring ExerciseLib::replaceTermsInContent function to avoid repeat code</li>
<li>[2025-06-03] (<a href="https://github.com/chamilo/chamilo-lms/commit/cf21ea40654b5753583948bff57d9584c90920d2">cf21ea40</a>) Internal: Fix fclose() call to avoid undetected error</li>
<li>[2025-05-27] (<a href="https://github.com/chamilo/chamilo-lms/commit/982bbfaa7db30c2f80a571ba6fb2b1f2cd3a5844">982bbfaa</a> - <a href="https://task.beeznest.com/issues/22634">BT#22634</a>) Ticket: Fix advanced search to give result</li>
<li>[2025-05-26] (<a href="https://github.com/chamilo/chamilo-lms/commit/c4d98f1dcedcdfb4b566ac7eeb4568896d3e7b84">c4d98f1d</a> - <a href="https://task.beeznest.com/issues/22647">BT#22647</a>) Plugin: H5P: Add missing translations for config option</li>
<li>[2025-05-26] (<a href="https://github.com/chamilo/chamilo-lms/commit/8887610a86a0b6be2c6dd1097da66e15a9b48047">8887610a</a> - <a href="https://task.beeznest.com/issues/22647">BT#22647</a>) Plugin: H5P: Fix typo to include missing jquery-ui</li>
<li>[2025-05-26] (<a href="https://github.com/chamilo/chamilo-lms/commit/d103759bcfe6760823cdd46ea810c0e4d340f1a6">d103759b</a> - <a href="https://task.beeznest.com/issues/22618">BT#22618</a>) Language: Quiz: Add partial update in FR, ES, EN for translation of parameter HideComment</li>
<li>[2025-05-26] (<a href="https://github.com/chamilo/chamilo-lms/commit/543ccb4e5d47d298ff9bd455cdb3cea15ae14bd7">543ccb4e</a> - <a href="https://task.beeznest.com/issues/22618">BT#22618</a>) Exercise: Add new option 'Hide comment' to the result page configuration extra parameter</li>
<li>[2025-05-22] (<a href="https://github.com/chamilo/chamilo-lms/commit/378899f9718683cb7bed6c1a7302244c0dc15509">378899f9</a> - <a href="https://task.beeznest.com/issues/22393">BT#22393</a>) Plugin: H5P Import: Fix class declaration to be compatible with H5P library update</li>
<li>[2025-05-22] (<a href="https://github.com/chamilo/chamilo-lms/commit/7f4bd80cafa8663e42a080490ac5789f70d0ee52">7f4bd80c</a> - <a href="https://task.beeznest.com/issues/22393">BT#22393</a>) Internal: Fix stricter functions declarations to improve PHPDoc from commit #ea334a3f6f because the return can be null when there is no token in session</li>
<li>[2025-05-21] (<a href="https://github.com/chamilo/chamilo-lms/commit/e2d89c5f1205974cf9e17a55b16c7cccc3ac0787">e2d89c5f</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/5393">GH#5393</a>) Tracking: Fix to adapt app/cache/.htaccess regeneration on archive cleanup to include the fix for the issue displaying the pChart-generated charts due to Apache 2.4 syntax change</li>
<li>[2025-05-21] (<a href="https://github.com/chamilo/chamilo-lms/commit/0c4d67c8f5107f69dd41fdbdcd4b5fb3231f54ec">0c4d67c8</a>) Document: [Minor] fix typo in a comment from previous commit</li>
<li>[2025-05-20] (<a href="https://github.com/chamilo/chamilo-lms/commit/d204845d3345e23b4e1c0e8213f26966fe596b0c">d204845d</a> - <a href="https://task.beeznest.com/issues/22464">BT#22464</a>) Session: Add use of session's position on myStudent page when session_list_order is activated</li>
<li>[2025-05-19] (<a href="https://github.com/chamilo/chamilo-lms/commit/42d0e44e68da2d6e94f579ec9fb4af8c7d06819b">42d0e44e</a> - <a href="https://task.beeznest.com/issues/22464">BT#22464</a>) Session: Fix ambigous position error when activation session_list_order</li>
<li>[2025-05-19] (<a href="https://github.com/chamilo/chamilo-lms/commit/ea248be77c23f83761ec338b2692c410a09e46ca">ea248be7</a> - <a href="https://task.beeznest.com/issues/22515">BT#22515</a>) Session: Fix on course inclusion in session with gradebook copy set generate certificate and is requirement</li>
<li>[2025-05-19] (<a href="https://github.com/chamilo/chamilo-lms/commit/6f3d730bfa4e55bf8abc1690528369b11b449179">6f3d730b</a> - <a href="https://task.beeznest.com/issues/22464">BT#22464</a>) Ticket: Fix form validate redirection and return icon to keep projet_id context</li>
<li>[2025-05-15] (<a href="https://github.com/chamilo/chamilo-lms/commit/ed06cdbd29f46e1174678fdfb23a7cba4e56d552">ed06cdbd</a> - <a href="https://task.beeznest.com/issues/22502">BT#22502</a>) Session: Use config-defined headers for base user and session fields for Excel export</li>
<li>[2025-05-13] (<a href="https://github.com/chamilo/chamilo-lms/commit/83dc6e3d7a10f660337d71fee1723bc2fdc52cab">83dc6e3d</a>) CI: Update GitHub workflows to use ubuntu-24.04</li>
<li>[2025-05-13] (<a href="https://github.com/chamilo/chamilo-lms/commit/2b202b09a66fd449eea62bb7b04fc1c469f6f32c">2b202b09</a> - <a href="https://task.beeznest.com/issues/22562">BT#22562</a>) Document: # fix total document tool size usage calculation to avoid documents that have many registries in c_item_property</li>
<li>[2025-05-13] (<a href="https://github.com/chamilo/chamilo-lms/commit/d005bcdd7ada112596b95a81be35f741ac4b7f38">d005bcdd</a> - <a href="https://task.beeznest.com/issues/22562">BT#22562</a>) Document: # fix total document tool size usage calculation to avoid DELETED documents that have many registries in c_item_property</li>
<li>[2025-05-13] (<a href="https://github.com/chamilo/chamilo-lms/commit/5b3755be0af03eb7e418aa882128a4df79b7b3d4">5b3755be</a> - <a href="https://task.beeznest.com/issues/22562">BT#22562</a>) Document: # fix folder size calculation to avoid DELETED documents that have many registries in c_item_property</li>
<li>[2025-05-09] (<a href="https://github.com/chamilo/chamilo-lms/commit/2b7f62660839239445e6ea28cee0ffcbd654d005">2b7f6266</a> - <a href="https://task.beeznest.com/issues/22502">BT#22502</a>) Session: Fix configuration variable format to correspond to adaptation of report generation</li>
<li>[2025-05-08] (<a href="https://github.com/chamilo/chamilo-lms/commit/13ae07525a8212220d432aba38374795c24f2714">13ae0752</a> - <a href="https://task.beeznest.com/issues/22533">BT#22533</a>) Session: Use config-defined headers and order for Excel export</li>
<li>[2025-05-08] (<a href="https://github.com/chamilo/chamilo-lms/commit/e4f9239e2ea4e5a7b7c5db7d307e3ad337e5a806">e4f9239e</a>) Course settings: Remove e=1 from direct course access link to be consistent with the explanation text</li>
<li>[2025-05-08] (<a href="https://github.com/chamilo/chamilo-lms/commit/db1a302f90cd49193fd5e572574fb5559e79758c">db1a302f</a> - <a href="https://task.beeznest.com/issues/22502">BT#22502</a>) Extra Fieldss: Fix default value to be used for text type extra fields only when there is no value and it is not null</li>
<li>[2025-05-06] (<a href="https://github.com/chamilo/chamilo-lms/commit/1f38d4e8ea987805747496978348354ce078a094">1f38d4e8</a> - <a href="https://task.beeznest.com/issues/22393">BT#22393</a>) Learnpath: Fix session's lp copy</li>
<li>[2025-04-30] (<a href="https://github.com/chamilo/chamilo-lms/commit/58f0faa4d89fe48109f697a5aac26f37edb4d6a1">58f0faa4</a> - <a href="https://task.beeznest.com/issues/22502">BT#22502</a>) Extra Fieldss: Fix default value to be used for text type extra fields only when there is no value</li>
<li>[2025-04-30] (<a href="https://github.com/chamilo/chamilo-lms/commit/c599103d129f13b5494efacfb89c7ded508f6bcc">c599103d</a> - <a href="https://task.beeznest.com/issues/22502">BT#22502</a>) Extra Fieldss: Add default value to be used for text type extra fields</li>
<li>[2025-04-29] (<a href="https://github.com/chamilo/chamilo-lms/commit/f5acfda2a46b316c876db421cb5ddbb5678cbd2c">f5acfda2</a> - <a href="https://task.beeznest.com/issues/22502">BT#22502</a>) Session: Fix Excel export show display text for user's headers and fix spanish translation</li>
<li>[2025-04-29] (<a href="https://github.com/chamilo/chamilo-lms/commit/31747dbf3f132296723a07c30ae227ca96881e1c">31747dbf</a> - <a href="https://task.beeznest.com/issues/22485">BT#22485</a>) Language: Portfolio: Add partial update in FR, ES, EN for translation related to new alphabetical ordering option</li>
<li>[2025-04-29] (<a href="https://github.com/chamilo/chamilo-lms/commit/78bf8d9ebee534e10bea2c74ada7531673a3d4c3">78bf8d9e</a> - <a href="https://task.beeznest.com/issues/22485">BT#22485</a>) Portfolio: Add option to order post by title alphabetical order</li>
<li>[2025-04-29] (<a href="https://github.com/chamilo/chamilo-lms/commit/b63e5856084193259f312dfe22db74301289e0b8">b63e5856</a> - <a href="https://task.beeznest.com/issues/22502">BT#22502</a>) Session: Fix Excel export do not put headers in upper case and remove official code column</li>
<li>[2025-04-29] (<a href="https://github.com/chamilo/chamilo-lms/commit/04742fa697189ecc9e423c55c41b9123768b7549">04742fa6</a> - <a href="https://task.beeznest.com/issues/22502">BT#22502</a>) Language: partial update in FR, ES, EN for translation related to specific feature</li>
<li>[2025-04-25] (<a href="https://github.com/chamilo/chamilo-lms/commit/1ee2d8bb61b67e08946cd80b1a9b92c1a9959c7b">1ee2d8bb</a>) Standardize header titles for tools help</li>
<li>[2025-04-25] (<a href="https://github.com/chamilo/chamilo-lms/commit/284a7502b54ae2d431f3d0170c4e942b49a16c3b">284a7502</a>) Internal: Fix E_NOTICE by undefined variable</li>
<li>[2025-04-23] (<a href="https://github.com/chamilo/chamilo-lms/commit/e3a37889fe4e8e47518941452d76340cf2dac661">e3a37889</a> - <a href="https://task.beeznest.com/issues/22577">BT#22577</a>) Plugin: BBB: fix video download link to correspond to new path in BBB</li>
<li>[2025-04-21] (<a href="https://github.com/chamilo/chamilo-lms/commit/8bd86913a89fec084053e2c2916df19f15d03d95">8bd86913</a>) Internal: Refactor file upload error handling for early exits</li>
<li>[2025-04-17] (<a href="https://github.com/chamilo/chamilo-lms/commit/3608709731f714b92d2b306030fb64fe7928c05e">36087097</a> - <a href="https://task.beeznest.com/issues/22344">BT#22344</a>) Attendance: Fix escaping of language var in JS context preventing comments to be added in attendance in courses in French</li>
<li>[2025-04-17] (<a href="https://github.com/chamilo/chamilo-lms/commit/dc4d7c8611ea3526a85d885c14beefae19a5f971">dc4d7c86</a> - <a href="https://task.beeznest.com/issues/22563">BT#22563</a>) Learnpath: Remove uploaded SCORM/AICC file immediately after treatment</li>
<li>[2025-04-15] (<a href="https://github.com/chamilo/chamilo-lms/commit/ac908246c59121ddc976cd00e960aca5a6eba971">ac908246</a> - <a href="https://task.beeznest.com/issues/22551">BT#22551</a>) Exercise: Fix notice appearing in exercise list and exercise rendering</li>
<li>[2025-04-15] (<a href="https://github.com/chamilo/chamilo-lms/commit/80f631dbffa13906a482385fa7f0884a1ca84250">80f631db</a> - <a href="https://task.beeznest.com/issues/22023">BT#22023</a>) Work Fix compilatio error on pending work when compilatio is not activated</li>
<li>[2025-04-14] (<a href="https://github.com/chamilo/chamilo-lms/commit/7903cef2eb41817c11a52ba6ac34a1d454bc5ef7">7903cef2</a>) Internal: Refactor CourseSelectForm to simplify conditional logic and improve readability</li>
<li>[2025-04-10] (<a href="https://github.com/chamilo/chamilo-lms/commit/75ab03c938adc48a3cd8234d98fc340e1998aa81">75ab03c9</a>) Refactor CourseSelectForm to simplify conditional logic and improve readability</li>
<li>[2025-04-03] (<a href="https://github.com/chamilo/chamilo-lms/commit/07ce29dcfbf5a34a317eb2b26a642c69ad4598c7">07ce29dc</a>) Plugin: Azure: User the id property instead of objectId from resource</li>
<li>[2025-04-03] (<a href="https://github.com/chamilo/chamilo-lms/commit/5455c42d23f5af59c4adb1ce2158f836d1ae988b">5455c42d</a>) Plugin: Azure: Refactor provider to enhance user data retrieval with new Microsoft Graph API</li>
<li>[2025-04-02] (<a href="https://github.com/chamilo/chamilo-lms/commit/d193237235c8eb7f73aac443573579b21cf4852f">d1932372</a> - <a href="https://task.beeznest.com/issues/22533">BT#22533</a>) Session: Add Excel export of certified users in course session with extra fields</li>
<li>[2025-04-02] (<a href="https://github.com/chamilo/chamilo-lms/commit/6079094bb2d7c3ac35619c36fa625a251b7b52bb">6079094b</a> - <a href="https://task.beeznest.com/issues/22531">BT#22531</a>) Internals: return the users last registered first when password lost in case of many occurences</li>
<li>[2025-03-30] (<a href="https://github.com/chamilo/chamilo-lms/commit/680d765bcd8ffcbe2f98ea79fc9ca4325b268833">680d765b</a> - <a href="https://task.beeznest.com/issues/22402">BT#22402</a>) Tracking: Fix session admin permissions in statistics module</li>
<li>[2025-03-29] (<a href="https://github.com/chamilo/chamilo-lms/commit/4b377c5d37e94c9174f08978bf1b9d862aedea8f">4b377c5d</a> - <a href="https://task.beeznest.com/issues/22370">BT#22370</a>) Exercise: Improve document reload in result view</li>
<li>[2025-03-28] (<a href="https://github.com/chamilo/chamilo-lms/commit/b789f216ad8b95d2f6ca1011c93e90d7ee3c1b3c">b789f216</a> - <a href="https://task.beeznest.com/issues/22370">BT#22370</a>) Exercise: Prevent cached document in results page by adding timestamp to iframe</li>
<li>[2025-03-24] (<a href="https://github.com/chamilo/chamilo-lms/commit/524a1655c52dfe2b2de500d41854a7c59dfb5170">524a1655</a>) Documentation: Update link to public internal documentation in README.md Author: @Kaneda-1</li>
<li>[2025-03-24] (<a href="https://github.com/chamilo/chamilo-lms/commit/d31bce53c42a6c9650300f3b108678bcfb9b05a3">d31bce53</a> - <a href="https://task.beeznest.com/issues/22370">BT#22370</a>) Exercise: Auto-refresh OnlyOffice iframe</li>
<li>[2025-03-24] (<a href="https://github.com/chamilo/chamilo-lms/commit/c486375d5233b52301c8800f750f8ac7a499dd6d">c486375d</a> - <a href="https://task.beeznest.com/issues/22370">BT#22370</a>) Exercise: Auto-refresh OnlyOffice iframe to show results</li>
<li>[2025-03-24] (<a href="https://github.com/chamilo/chamilo-lms/commit/5bc1912d70107207fc1508d9a605aae06d84fdcf">5bc1912d</a> - <a href="https://task.beeznest.com/issues/22370">BT#22370</a>) Exercise: Auto-refresh OnlyOffice iframe to show latest edits</li>
<li>[2025-03-23] (<a href="https://github.com/chamilo/chamilo-lms/commit/be554b60a2178e62eb6f38f5a60ff63abd017dc9">be554b60</a>) Language: Update language terms</li>
<li>[2025-03-23] (<a href="https://github.com/chamilo/chamilo-lms/commit/d2c57d6f42df0acbf48fcc36edbf7e46a38cf049">d2c57d6f</a> - <a href="https://task.beeznest.com/issues/22404">BT#22404</a>) Exercise: Add validation to enforce correct answer and positive score</li>
<li>[2025-03-23] (<a href="https://github.com/chamilo/chamilo-lms/commit/232aa648d21f98fe6c1ffe3ae14d1c358b488806">232aa648</a>) Language: Update language terms</li>
<li>[2025-03-21] (<a href="https://github.com/chamilo/chamilo-lms/commit/58d7dc8267b14fb446f3a966ab83ad9f428c1e7f">58d7dc82</a> - <a href="https://task.beeznest.com/issues/22503">BT#22503</a>) Admin: remove restriction due to experimental feature on access to entry to main/admin/user_move_stats.php in the administration menu</li>
<li>[2025-03-20] (<a href="https://github.com/chamilo/chamilo-lms/commit/98cf6fe60a6cd61792b8823579a9a119bcabf045">98cf6fe6</a>) Exercise: Fix student id to display onlyoffice answer</li>
<li>[2025-03-20] (<a href="https://github.com/chamilo/chamilo-lms/commit/1a5bc078217e2529c29e974b37fb5ff34b47c9f8">1a5bc078</a>) Exercise: Fix student edit permissions & finalization view in OnlyOffice</li>
<li>[2025-03-18] (<a href="https://github.com/chamilo/chamilo-lms/commit/00b080cc239760df72f3784b944339d08adc1429">00b080cc</a> - <a href="https://task.beeznest.com/issues/21977">BT#21977</a>) Internal: Improvements and structure adjustments for moodle import</li>
<li>[2025-03-14] (<a href="https://github.com/chamilo/chamilo-lms/commit/e34e1a5cd2ac71a175193c90df83e7be65c8f380">e34e1a5c</a> - <a href="https://task.beeznest.com/issues/22335">BT#22335</a>) Plugin: Azure: Fix session redirect after login from custom page</li>
<li>[2025-03-12] (<a href="https://github.com/chamilo/chamilo-lms/commit/2f7f85c102f101a1c8ee88dbf64c54bee73adff7">2f7f85c1</a> - <a href="https://task.beeznest.com/issues/22455">BT#22455</a>) Exercise: Add automatic feedback comments to email notification</li>
<li>[2025-03-11] (<a href="https://github.com/chamilo/chamilo-lms/commit/07d3aedbdaece9706709c6d11aee3730573787e6">07d3aedb</a> - <a href="https://task.beeznest.com/issues/22370">BT#22370</a>) Exercise: Add readonly to editor onlyoffice in results</li>
<li>[2025-03-11] (<a href="https://github.com/chamilo/chamilo-lms/commit/5fb30f2c9d0848d7166ac6a2013bcf6f89eeefa0">5fb30f2c</a> - <a href="https://task.beeznest.com/issues/22370">BT#22370</a>) Exercise: Improve display onlyoffice doc editor</li>
<li>[2025-03-11] (<a href="https://github.com/chamilo/chamilo-lms/commit/bb4ed6d55cbe21bffce1e3dcd1236ee9d740637c">bb4ed6d5</a> - <a href="https://task.beeznest.com/issues/22370">BT#22370</a>) Exercise: Fix evaluation of Answer in Office doc question</li>
<li>[2025-03-10] (<a href="https://github.com/chamilo/chamilo-lms/commit/b088041f134e658fd3fefc4a7111e4bc19b46c19">b088041f</a> - <a href="https://task.beeznest.com/issues/22370">BT#22370</a>) Exercise: Improve file handling with onlyoffice and exercise tracking</li>
<li>[2025-03-09] (<a href="https://github.com/chamilo/chamilo-lms/commit/e10f338782d5622db57323d840083809eedfffe1">e10f3387</a> - <a href="https://task.beeznest.com/issues/22370">BT#22370</a>) Exercise: Add missing parameters for docInfo onlyoffice</li>
<li>[2025-03-09] (<a href="https://github.com/chamilo/chamilo-lms/commit/45a6736ec1e6e9d4d4724990ff2e4967dd4b86bc">45a6736e</a> - <a href="https://task.beeznest.com/issues/22451">BT#22451</a>) Learnpath: Add direct lessons list access button & hide header in reduced mode</li>
<li>[2025-03-08] (<a href="https://github.com/chamilo/chamilo-lms/commit/a60bfcb3148c369d74806d499609d06876aafc3e">a60bfcb3</a> - <a href="https://task.beeznest.com/issues/22370">BT#22370</a>) Exercise: Improve OnlyOffice integration with dynamic return URLs</li>
<li>[2025-03-08] (<a href="https://github.com/chamilo/chamilo-lms/commit/4d82256e54916b067caee42d8fc9b7382592fcbe">4d82256e</a> - <a href="https://task.beeznest.com/issues/22370">BT#22370</a>) Exercise: Improved OnlyOffice integration and URL handling</li>
<li>[2025-02-25] (<a href="https://github.com/chamilo/chamilo-lms/commit/43a28402f95c93c44c0774eaa8fa60c7a9ecfa95">43a28402</a> - <a href="https://task.beeznest.com/issues/21500">BT#21500</a>) User: Language: Fix syntax from previous commit about plateformLanguage by default when updating a user instead of english</li>
<li>[2025-02-25] (<a href="https://github.com/chamilo/chamilo-lms/commit/2faa79eb9ab18ed40314dfc39b28fbc6669edd6a">2faa79eb</a> - <a href="https://task.beeznest.com/issues/21500">BT#21500</a>) User: Language: #change set platformLanguage by default when updating a user instead of english</li>
<li>[2025-02-24] (<a href="https://github.com/chamilo/chamilo-lms/commit/9853e6ede71bf7d6e17877d1cb0b94b832c7a01c">9853e6ed</a> - <a href="https://task.beeznest.com/issues/22048">BT#22048</a>) Internal: Fix commit de5623b2740be to show last 10 registered users in user group and session pages to show only user from the current URL</li>
<li>[2025-02-21] (<a href="https://github.com/chamilo/chamilo-lms/commit/44adc09a3c2744d55620430874651d4072981039">44adc09a</a> - <a href="https://task.beeznest.com/issues/22396">BT#22396</a>) Tracking: improve display on comment to indicate progress based on visibles LPs only</li>
<li>[2025-02-20] (<a href="https://github.com/chamilo/chamilo-lms/commit/cbc77b6996b9871a14bfaadc12d9f6c17c83c178">cbc77b69</a> - <a href="https://task.beeznest.com/issues/22396">BT#22396</a>) Learnpath: Fix LP visibility to review registry in base course if nothing set in session</li>
<li>[2025-02-20] (<a href="https://github.com/chamilo/chamilo-lms/commit/e23191212cca37076442f90d821f09961ec55bbe">e2319121</a> - <a href="https://task.beeznest.com/issues/22396">BT#22396</a>) Tracking: improve display on comment to indicate progress based on visibles LPs only</li>
<li>[2025-02-20] (<a href="https://github.com/chamilo/chamilo-lms/commit/e95dceb9218a698ae89374ee8625a52da353054e">e95dceb9</a> - <a href="https://task.beeznest.com/issues/22426">BT#22426</a>) Document: LP: fix access to document in LP for sessionAdmin when session_admins_access_all_content is true</li>
<li>[2025-02-19] (<a href="https://github.com/chamilo/chamilo-lms/commit/45e7d2922aafb8a0562ef25409157b745d9c2e0b">45e7d292</a> - <a href="https://task.beeznest.com/issues/22423">BT#22423</a>) Survey: Fix survey publication form blank block and htmlspecialchars() TypeError</li>
<li>[2025-02-19] (<a href="https://github.com/chamilo/chamilo-lms/commit/16beb27b2a6379d569961f4da78ba8ce9ee5e5a2">16beb27b</a> - <a href="https://task.beeznest.com/issues/22396">BT#22396</a>) Language: Update language terms for progress calculation and others</li>
<li>[2025-02-18] (<a href="https://github.com/chamilo/chamilo-lms/commit/9d2a2171d125cfe3e7775cd87965934d1e4dfb2e">9d2a2171</a> - <a href="https://task.beeznest.com/issues/22407">BT#22407</a>) Skill: Minor: adapt skill block to be visible to all on the index page as it is on the user_portal page</li>
<li>[2025-02-17] (<a href="https://github.com/chamilo/chamilo-lms/commit/070094f2298793603705950f3c399be4328859c9">070094f2</a> - <a href="https://task.beeznest.com/issues/22305">BT#22305</a>) User: Fix filters & editable columns in extra fields for advanced edit</li>
<li>[2025-02-16] (<a href="https://github.com/chamilo/chamilo-lms/commit/4f9d10530367a6c590f2a53d26eb9b3f835d41f8">4f9d1053</a> - <a href="https://task.beeznest.com/issues/22305">BT#22305</a>) Admin: Filter extra fields from GET in user_advanced_edit.php and allow filtering on fields as admin (do not take into account modifyability of field)</li>
<li>[2025-02-11] (<a href="https://github.com/chamilo/chamilo-lms/commit/cb34d089a783079c0cf536b6190bab404c1bac6e">cb34d089</a> - <a href="https://task.beeznest.com/issues/22390">BT#22390</a>) Gradebook: Fix total average weight calculation</li>
<li>[2025-02-10] (<a href="https://github.com/chamilo/chamilo-lms/commit/68ad7d96a1794c65f9ae9ad74552340996077a08">68ad7d96</a> - <a href="https://task.beeznest.com/issues/22304">BT#22304</a>) Tracking: Fix column position for email in report</li>
<li>[2025-02-10] (<a href="https://github.com/chamilo/chamilo-lms/commit/6e9152b71bc0ba03f23b0b39fc46dc9355ff7527">6e9152b7</a> - <a href="https://task.beeznest.com/issues/22318">BT#22318</a>) Plagiarism: Compilatio: fix analyses API query to work with all services and contracts for compilatio</li>
<li>[2025-02-09] (<a href="https://github.com/chamilo/chamilo-lms/commit/aacc534fe6f0b078243865a944e6a4eecfe659c8">aacc534f</a> - <a href="https://task.beeznest.com/issues/22305">BT#22305</a>) Language: Update language terms</li>
<li>[2025-02-09] (<a href="https://github.com/chamilo/chamilo-lms/commit/6f9c01a120b9c5b88a360ea0927aabe0dd03da74">6f9c01a1</a> - <a href="https://task.beeznest.com/issues/22305">BT#22305</a>) User: add advanced user edition with bulk and ajax updates</li>
<li>[2025-02-09] (<a href="https://github.com/chamilo/chamilo-lms/commit/c19b19d626801e2a525302ee37c65858579e5a66">c19b19d6</a>) Plugin: BuyCourses: Fix bug in learning path end page/final item when plugin is enabled. End page could not load because of buggy query in services feature of plugin.</li>
<li>[2025-02-06] (<a href="https://github.com/chamilo/chamilo-lms/commit/6e975be050d7dd64b62e0b799ffbcdabd9e10200">6e975be0</a> - <a href="https://task.beeznest.com/issues/22390">BT#22390</a>) Gradebook: Fix pre-loading of stats with the allow_gradebook_stats setting</li>
<li>[2025-02-04] (<a href="https://github.com/chamilo/chamilo-lms/commit/72aa66a1e9216dfb6f929896563aeba3f95f061c">72aa66a1</a>) Documentation: Add PHPDoc block to getAllValuesByItem() to explain it only returns fields with filter=1</li>
<li>[2025-02-03] (<a href="https://github.com/chamilo/chamilo-lms/commit/b7ceb1456e349c41f273ff45bcde673a1938b48a">b7ceb145</a>) Tracking: fix average total time spent on platform calculation and use See dfca26416355703049309d2b69b38b15669a0e42</li>
<li>[2025-02-03] (<a href="https://github.com/chamilo/chamilo-lms/commit/981d7ceac74bf19e55dbaec57d565c5374ce69f7">981d7cea</a> - <a href="https://task.beeznest.com/issues/22341">BT#22341</a>) Documentation: Add index on c_quiz_question.type in optimization guide</li>
<li>[2025-01-30] (<a href="https://github.com/chamilo/chamilo-lms/commit/ea499b8920138a8b3c0db5b16e3aea04c368980c">ea499b89</a> - <a href="https://task.beeznest.com/issues/22303">BT#22303</a>) Statistics: Add user extra fields to export users in course session</li>
<li>[2025-01-30] (<a href="https://github.com/chamilo/chamilo-lms/commit/1cf3d21d57a1a0c34e33bb5f55db175f84227619">1cf3d21d</a>) Plugin: Azure: Fix create/drop of azure_ad_sync_state table on install/uninstall</li>
<li>[2025-01-29] (<a href="https://github.com/chamilo/chamilo-lms/commit/6b2da59aef019b923a0c60053d21337ece180da2">6b2da59a</a> - <a href="https://task.beeznest.com/issues/22306">BT#22306</a>) Exercise: set tolerance to pointer for draggable question</li>
<li>[2025-01-29] (<a href="https://github.com/chamilo/chamilo-lms/commit/12e827aefe49c286bc85d0ab8302c4d10aa497f1">12e827ae</a>) Documentation: Add changelog entry for 1.11.30 with note on the .htaccess rule change.</li>
<li>[2025-01-28] (<a href="https://github.com/chamilo/chamilo-lms/commit/46f11d3e28bedd22bc7972f61397fe135ac83137">46f11d3e</a> - <a href="https://task.beeznest.com/issues/22308">BT#22308</a>) Exercise: Refactor answer handling in exercise_show_functions.lib.php for oral expression questions</li>
<li>[2025-01-28] (<a href="https://github.com/chamilo/chamilo-lms/commit/7638f49ec1a79defc6ed8170049f3ee60471012c">7638f49e</a> - <a href="https://task.beeznest.com/issues/22308">BT#22308</a>) Exercise: Simplify ternary operator</li>
<li>[2025-01-26] (<a href="https://github.com/chamilo/chamilo-lms/commit/aa72a3beb83a7867d4183de1b6e4b564b7ed5b4d">aa72a3be</a>) Plugin: AI Helper: Rename Url class to DeepSeekUrl to resolve conflict</li>
<li>[2025-01-23] (<a href="https://github.com/chamilo/chamilo-lms/commit/57b0fe66a2ac5abf1e9baf2597ec9125d4294abe">57b0fe66</a>) Plugin: AI Helper: Fix params of generateDeepSeekQuestions()</li>
<li>[2025-01-23] (<a href="https://github.com/chamilo/chamilo-lms/commit/fe4564802c7a35c1ec5c6ef8381610c3d12e0a6b">fe456480</a>) Plugin: AI Helper: Fix the DeepSeek prompt (use same as for OpenAI by default)</li>
<li>[2025-01-17] (<a href="https://github.com/chamilo/chamilo-lms/commit/a9d0097466e5112d7fbf592a15247ce5bb44a9ae">a9d00974</a>) Link: Avoid double htmlspecialchars for links using Display::url See 15023ce6301a8b623d789e8e788b2db9bf470547</li>
<li>[2025-01-17] (<a href="https://github.com/chamilo/chamilo-lms/commit/32d3d5de8e10f1956e95e81af012267dc0d5e8c4">32d3d5de</a>) Internal: Use $.getJson instead of fetch</li>
<li>[2025-01-15] (<a href="https://github.com/chamilo/chamilo-lms/commit/7f75d7066dfcfc345f84727f297068ddb2f83b3d">7f75d706</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/5977">GH#5977</a>) Calendar: Allow to delete registry in agenda_event_invitee when deleting user</li>
<li>[2025-01-15] (<a href="https://github.com/chamilo/chamilo-lms/commit/7ea28f8df60db59d46778bace2f7cc7a1f592776">7ea28f8d</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/5977">GH#5977</a>) Calendar: Add property types</li>
<li>[2025-01-15] (<a href="https://github.com/chamilo/chamilo-lms/commit/e0507216f0871dd22cad4ce461f657f969056f04">e0507216</a> - <a href="https://task.beeznest.com/issues/22323">BT#22323</a>) Internal: Avoid unnecessary loop condition in duplicate links deletion script</li>
<li>[2025-01-14] (<a href="https://github.com/chamilo/chamilo-lms/commit/b7513f284f95ea1dac752bd1acb191f60c45c1e3">b7513f28</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/6030">GH#6030</a>) Settings: Fix PlatformLanguage setting to support multi-URL configurations</li>
<li>[2025-01-14] (<a href="https://github.com/chamilo/chamilo-lms/commit/2eb0030eedffdd5c9c206635bc981e261fb7703b">2eb0030e</a> - <a href="https://task.beeznest.com/issues/22323">BT#22323</a>) Internal: Fix duplicate links handling with improved LP checks and deletion logic</li>
<li>[2025-01-14] (<a href="https://github.com/chamilo/chamilo-lms/commit/8c9caf6ad3ac2a97cf1dd0dd93293989b4621edc">8c9caf6a</a>) Language: Don't load i18n files for datepicker and timepicker when language ISO code is EN</li>
<li>[2025-01-14] (<a href="https://github.com/chamilo/chamilo-lms/commit/0a91306a2c8a3db973d32da3d895bfd6fbcda983">0a91306a</a> - <a href="https://task.beeznest.com/issues/22335">BT#22335</a>) User: Fix session redirect after login from custom page</li>
<li>[2025-01-13] (<a href="https://github.com/chamilo/chamilo-lms/commit/cfa1f18c4f24652fddb6a5869f182bbc982c318e">cfa1f18c</a> - <a href="https://task.beeznest.com/issues/22335">BT#22335</a>) User: Improve layout of custom login page</li>
<li>[2025-01-13] (<a href="https://github.com/chamilo/chamilo-lms/commit/27b344eeee5f45eab373ceaa419ccf7a35e66c8e">27b344ee</a>) Internal: Fix params and return types</li>
<li>[2025-01-13] (<a href="https://github.com/chamilo/chamilo-lms/commit/4d3c2a9533b54eb309a28e061f51d556b1cf3ee3">4d3c2a95</a>) Auth: Fix locale field when logging with facebook</li>
<li>[2025-01-09] (<a href="https://github.com/chamilo/chamilo-lms/commit/b7accd6d69c85027844bfec2c366fbed2c10ec69">b7accd6d</a> - <a href="https://task.beeznest.com/issues/22335">BT#22335</a>) User: Add custom login template support for session expiration page</li>
<li>[2025-01-07] (<a href="https://github.com/chamilo/chamilo-lms/commit/8f51bd8f118801a25cb32051425a9fbbe7712243">8f51bd8f</a> - <a href="https://task.beeznest.com/issues/22330">BT#22330</a>) Session: Add dynamic sorting for users table by name and date</li>
<li>[2025-01-02] (<a href="https://github.com/chamilo/chamilo-lms/commit/135e61ebf5505028bf46ab37df97d0f821bdf274">135e61eb</a>) Skill: Display: Use badge.design instead of openbadges.me/designer</li>
<li>[2024-12-31] (<a href="https://github.com/chamilo/chamilo-lms/commit/060e433498387b38fbed8d48a9ef98e48655db9a">060e4334</a>) Internal: Add return and params types + format code</li>
<li>[2024-12-31] (<a href="https://github.com/chamilo/chamilo-lms/commit/c343e89a8823a707af73d324e26bcaf33cfa0831">c343e89a</a>) Vendor: Use facebook/graph-sdk instead of facebook/php-sdk-v4</li>
<li>[2024-12-31] (<a href="https://github.com/chamilo/chamilo-lms/commit/f6a79759095ee55c1b123c3686599c936a4bc040">f6a79759</a>) Internal: Fix script to upload video to Vimeo</li>
<li>[2024-12-31] (<a href="https://github.com/chamilo/chamilo-lms/commit/18b2131d7518baa635f08af6ffb2a2ee2245495d">18b2131d</a> - <a href="https://task.beeznest.com/issues/22318">BT#22318</a>) Plagiarism: Compilatio: fix analyses API query to work with all services and contracts for compilatio</li>
<li>[2024-12-30] (<a href="https://github.com/chamilo/chamilo-lms/commit/8f92e154b7f3c7c731880bb895d85606aee11bc6">8f92e154</a> - <a href="https://task.beeznest.com/issues/22235">BT#22235</a>) Gossary: complement to Fix load glossary from base course if not found in session</li>
<li>[2024-12-28] (<a href="https://github.com/chamilo/chamilo-lms/commit/3d19c0014744ea219b8a7790b80e190a5737edc7">3d19c001</a> - <a href="https://task.beeznest.com/issues/22319">BT#22319</a>) Internal: Fix pagination issue in SortableTable session handling</li>
<li>[2024-12-28] (<a href="https://github.com/chamilo/chamilo-lms/commit/38d8bce275164684e824e4f9b7b2d50c6f07c728">38d8bce2</a> - <a href="https://task.beeznest.com/issues/21930">BT#21930</a>) Plugin: Azure: Add option to filter groups by display name</li>
<li>[2024-12-26] (<a href="https://github.com/chamilo/chamilo-lms/commit/10b6d6125dfdb23d765d694eaede0ae4e22ef6c0">10b6d612</a>) Internal: Fix undefined index in request</li>
<li>[2024-12-26] (<a href="https://github.com/chamilo/chamilo-lms/commit/49816b6855d03cbea4898533169675ff3dd91e44">49816b68</a> - <a href="https://task.beeznest.com/issues/21354">BT#21354</a>) Display: Load missing i18n files for date and time pickers</li>
<li>[2024-12-24] (<a href="https://github.com/chamilo/chamilo-lms/commit/8c16c19299b01ea1321b4d721efdbf67c174b5c0">8c16c192</a> - <a href="https://task.beeznest.com/issues/22321">BT#22321</a>) Internal: Revert PHP 8 compatibility improvement in api_htmlentities() as it causes issues filtering HTML tags in HTML titles</li>
<li>[2024-12-17] (<a href="https://github.com/chamilo/chamilo-lms/commit/c6880d3554918bba237e2c2e6aad4708acf61218">c6880d35</a>) Vendor: Remove brumann/polyfill-unserialize > In case you are using PHP 7.0+ the original unserialize() will be used instead.</li>
<li>[2024-12-23] (<a href="https://github.com/chamilo/chamilo-lms/commit/a025de461aa7447446a9eab77f30a2a48ef8c642">a025de46</a> - <a href="https://task.beeznest.com/issues/22232">BT#22232</a>) Portfolio: Duplicate post in session when commenting if portfolio_show_base_course_post_in_sessions is enabled</li>
<li>[2024-12-21] (<a href="https://github.com/chamilo/chamilo-lms/commit/542765cb6335eefe34f9dafee81beb8b35bd9396">542765cb</a> - <a href="https://task.beeznest.com/issues/21977">BT#21977</a>) Course: Fix export mbz validation, root-only resources, skip empty folders</li>
<li>[2024-12-20] (<a href="https://github.com/chamilo/chamilo-lms/commit/34975cd966974a821471c92f39c1ecf286ea1c30">34975cd9</a> - <a href="https://task.beeznest.com/issues/22232">BT#22232</a>) Internal: Portfolio: Add types to entity properties</li>
<li>[2024-12-19] (<a href="https://github.com/chamilo/chamilo-lms/commit/c60a4e23f5b64bf12385fb123a066259a96a9fa1">c60a4e23</a>) Plugin: OnlyOffice: Fix E_NOTICEs when api_get_setting is not returning an array value</li>
<li>[2024-12-18] (<a href="https://github.com/chamilo/chamilo-lms/commit/2d12af3491a2742fb3b3ef2f4f773fe57f69a587">2d12af34</a>) Internal: Remove return type mixed The mixed return type is available from php8.0</li>
<li>[2024-12-18] (<a href="https://github.com/chamilo/chamilo-lms/commit/d617e04be734ec096e0681bd92a5bdc57e8836d1">d617e04b</a>) Internal: Fix ErrorCorrectionLevel::MEDIUM constant usage</li>
<li>[2024-12-17] (<a href="https://github.com/chamilo/chamilo-lms/commit/e117d3f69f9f831c12240f3a932685108518a239">e117d3f6</a>) Add missing use of ReflectionClass</li>
<li>[2024-12-16] (<a href="https://github.com/chamilo/chamilo-lms/commit/600d8a1a9269b21c4d5ba78f04c2a34e60fd2688">600d8a1a</a>) Internal: Fix various PHP8 compatibility issues to reduce error messages</li>
<li>[2024-12-13] (<a href="https://github.com/chamilo/chamilo-lms/commit/3439ff1d714f18594a6d9db3d8d344e8b02cb4b7">3439ff1d</a>) Documentation: Add precision bout the syntax for new RewriteRule with Apache > 2.4.38-3</li>
<li>[2024-12-11] (<a href="https://github.com/chamilo/chamilo-lms/commit/b21a8a27fbb14dfa5fda603523727e7d02d34883">b21a8a27</a> - <a href="https://task.beeznest.com/issues/22272">BT#22272</a>) Exercise: fix delete attempt from pending exercice page</li>
<li>[2024-12-11] (<a href="https://github.com/chamilo/chamilo-lms/commit/b590a170d69195e1baca37f0e02abcf1771d8537">b590a170</a>) Internal: Fix warning and error with php8</li>
<li>[2024-12-10] (<a href="https://github.com/chamilo/chamilo-lms/commit/ca83c918ea43df18933a68dd519f4f7c312fb3bc">ca83c918</a> - <a href="https://task.beeznest.com/issues/22235">BT#22235</a>) Gossary: Fix load glossary from base course if not found in session</li>
<li>[2024-12-10] (<a href="https://github.com/chamilo/chamilo-lms/commit/f846c254038550c3b86d5ab485107df3a7088a15">f846c254</a> - <a href="https://task.beeznest.com/issues/22234">BT#22234</a>) Session: Fix session course position handling in CSV export/import</li>
<li>[2024-12-10] (<a href="https://github.com/chamilo/chamilo-lms/commit/e11bb258e5bf8d3bbaa3bd0fb05c7a530a340395">e11bb258</a>) Plugin: OnlyOffice: Add checked vendor/ to git</li>
<li>[2024-12-03] (<a href="https://github.com/chamilo/chamilo-lms/commit/406e0dd5f47366c609a9bc0878edd3763aa4f7d2">406e0dd5</a> - <a href="https://task.beeznest.com/issues/22231">BT#22231</a>) Gradebook: Fix theme image paths in certificates</li>
<li>[2024-11-18] (<a href="https://github.com/chamilo/chamilo-lms/commit/5e1031678798f1229acb4315e07df261cbbbcab8">5e103167</a> - <a href="https://task.beeznest.com/issues/21930">BT#21930</a>) Plugin: Azure: Bump version to v2.5</li>
<li>[2024-11-18] (<a href="https://github.com/chamilo/chamilo-lms/commit/2c779cbc56c20d0b88a1a748a75f773a00411fc4">2c779cbc</a> - <a href="https://task.beeznest.com/issues/21969">BT#21969</a>) Exercise: Fix hide_expected_answer exercise option for fill in the blank question type</li>
<li>[2024-11-13] (<a href="https://github.com/chamilo/chamilo-lms/commit/54d55b59b46e727aeafbe2243bcf85db09d09289">54d55b59</a> - <a href="https://task.beeznest.com/issues/22195">BT#22195</a>) Learnpath: Avoid showing PDF files in embed view</li>
<li>[2024-11-13] (<a href="https://github.com/chamilo/chamilo-lms/commit/a0322486a563a2ae3029314916d714fbad4c374e">a0322486</a> - <a href="https://task.beeznest.com/issues/22195">BT#22195</a>) Group: Fix advanced search when adding user to class</li>
<li>[2024-11-13] (<a href="https://github.com/chamilo/chamilo-lms/commit/d12798db17f6e0297c9f6be307069686996af640">d12798db</a> - <a href="https://task.beeznest.com/issues/22195">BT#22195</a>) Remove E_NOTICE about undefined variable</li>
<li>[2024-11-07] (<a href="https://github.com/chamilo/chamilo-lms/commit/da839c3f8cb0bd465a2e87c5a34088d3e1e78732">da839c3f</a> - <a href="https://task.beeznest.com/issues/22175">BT#22175</a>) WYSIWYG: Allow all domain in iframes insertion if setting to allow iframe is true in HTMLPurifier filter</li>
<li>[2024-11-06] (<a href="https://github.com/chamilo/chamilo-lms/commit/a591c25b577131dbeb76794454c998076872672d">a591c25b</a> - <a href="https://task.beeznest.com/issues/21436">BT#21436</a>) Userportal: Fix coach url when it is null</li>
<li>[2024-10-30] (<a href="https://github.com/chamilo/chamilo-lms/commit/e63569abb7f26d52412a9709b18b485b4e21dff6">e63569ab</a> - <a href="https://task.beeznest.com/issues/20849">BT#20849</a>) LDAP: Fix encrypted password generation to use the api function which is updated and not the local function that ended being different</li>
<li>[2024-10-30] (<a href="https://github.com/chamilo/chamilo-lms/commit/23d210040f61ee3a096c1d8ccda1fcde38165813">23d21004</a> - <a href="https://task.beeznest.com/issues/22159">BT#22159</a>) Admin: Fix download of existing CSS by platform admin</li>
<li>[2024-10-24] (<a href="https://github.com/chamilo/chamilo-lms/commit/3e2582f64f3017b0af0f515ce5200e209a9e4478">3e2582f6</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/5887">GH#5887</a>) Internal: Fix filter for "on" attributes was too wide and replaced normal text containing " on"</li>
<li>[2024-10-24] (<a href="https://github.com/chamilo/chamilo-lms/commit/00cdb857e034ed688ef8a141dc5d8317d1eb4ccd">00cdb857</a>) Documentation: Update upgrade guide from other minor versions regarding cache and CSS directories</li>
<li>[2024-10-24] (<a href="https://github.com/chamilo/chamilo-lms/commit/368dfaf932bd9084f60088b24495ed3aa8c6becc">368dfaf9</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/5887">GH#5887</a>) Internal: Fix df47eac9b93700bdf3a73e2596e956e14ab1e4f2 where the filter for "on" attributes was too wide and replaced normal text containing " on"</li>
</ul>
<h3>Stylesheets and theming</h3>
<ul aria-live="off">
<li>No notable style change</li>
</ul>
<h3>Web services</h3>
<ul aria-live="off">
<li>[2025-02-04] (<a href="https://github.com/chamilo/chamilo-lms/commit/bc2e5fde6e46a224c92a9d244dd64c15aff138d6">bc2e5fde</a> - <a href="https://task.beeznest.com/issues/22302">BT#22302</a>) Webservice: Add get_session_info_from_extra_field WS</li>
<li>[2025-02-04] (<a href="https://github.com/chamilo/chamilo-lms/commit/be0cceabfa43509b90b86c83607aa36799638c49">be0cceab</a> - <a href="https://task.beeznest.com/issues/22302">BT#22302</a>) Webservice: Add get_extra_fields option to get_sessions WS</li>
<li>[2025-02-07] (<a href="https://github.com/chamilo/chamilo-lms/commit/f4f6e1ee9383538308fcd82c5eb51562f2e6abab">f4f6e1ee</a> - <a href="https://task.beeznest.com/issues/22302">BT#22302</a>) Webservice: Only return relevant extra field properties in getSessionInfoFromExtraField()</li>
<li>[2025-02-09] (<a href="https://github.com/chamilo/chamilo-lms/commit/c8ff566ff0e6c385e5fe70a789b26ea6e5e94a14">c8ff566f</a> - <a href="https://github.com/chamilo/chamilo-lms/issues/6078">GH#6078</a>) Webservice: Add get_user_info_from_username WS (rename from get_user_from_username)</li>
<li>[2025-02-26] (<a href="https://github.com/chamilo/chamilo-lms/commit/0786df76a53455b0712a4605df11b1de27e6379e">0786df76</a> - <a href="https://task.beeznest.com/issues/22409">BT#22409</a>) Webservice: Message: Add a new only_local option to save_user_message</li>
<li>[2025-03-05] (<a href="https://github.com/chamilo/chamilo-lms/commit/1ec72a419f48a8e5c27d2095c83ea0ffee2e043c">1ec72a41</a> - <a href="https://task.beeznest.com/issues/22409">BT#22409</a>) Webservice: Add a new get_user_progress_and_time_in_session ws</li>
<li>[2025-03-06] (<a href="https://github.com/chamilo/chamilo-lms/commit/0dd652811e00108115811e5f4e540470db3ffe62">0dd65281</a> - <a href="https://task.beeznest.com/issues/22409">BT#22409</a>) Webservice: Add official_code parameter to the save_user ws</li>
</ul>
<h3>Removals</h3>
<ul aria-live="off">
<li>No notable removal</li>
</ul>
<h3>Known issues</h3>
<ul aria-live="off">
<li>No notable known issue</li>
</ul>
</div>
<div class="version" aria-label="1.11.28">
<a id="1.11.28"></a>
<h1>Chamilo 1.11.28 - Alcatraz, 21/10/2024</h1>
@@ -16930,6 +17647,27 @@ a simple videoconferencing interface.</p>
<li>(#scorm): indicates that this feature relates to the SCORM standard</li>
<li>(#fresh-users): indicates that this feature helps prevent drama with fresh users lacking experience and prone to error</li>
</ul>
<h3>Commit messages syntax</h3>
We try to use a structured commit messages syntax that matches the above, so the changelog can be partially generated by a script of ours.<br />
A typical commit message should look like this:
<pre>
Exercise: Fix error changing test visibility - refs GH#1234
</pre>
The structure goes like this:
<pre>
[Minor: ][Tool: ][Action] [topic] [- refs [repo]#[issue ID]]
</pre>
Where:
<ul>
<li><em>Minor: </em> is a prefix *only used* if the change does not affect any logic and does not have any notable visual effect (documentation change, small spacing change in the interface, etc) so we know we can ignore it when searching for breaking changes. Otherwise just do not use that prefix and go straight to the Tool. Note: the space after the colon is important.</li>
<li><em>Tool: </em> is the name of any of the tools defined in the section above (starting with an uppercase letter). Note: the space after the colon is important.</li>
<li><em>Action</em> is a non-conjugated verb like "Fix", "Add", "Remove", "Move" etc. It defines the type of action undertaken by this change.</li>
<li><em>topic</em> is a continuation of the <em>Action</em> to explain the object that was changed and/or the effect that the change will have.</li>
<li><em> - refs </em> is a special keyword to link this change to some issue. Note: spaces around " - refs " are important.</li>
<li><em>repo</em> is a marker that identifies the location of the related issue: <em>GH</em>(or nothing) is for an issue number on Github, <em>BT</em> is for internal issues at BeezNest, and you should add your code if the change relates to your internal system.</li>
<li><em>issue ID</em> is the numerical ID of the corresponding issue in the given repo.</li>
</ul>
</div>
</div>
</div>
+24 -8
View File
@@ -395,7 +395,7 @@ some manual work, but the upgrade procedure will not touch sublanguages directly
<br />
<span class="text-muted">* Styles and images are located in the main/css or main/img
<span class="text-muted">* Styles and images are located in the main/img/ or app/Resources/public/css/themes/
directories. You can still recover them from your backup if you have made it.
Any modified style or image that uses the default style/image name will be
overwritten by the next step. To avoid loosing your customisations, always
@@ -674,7 +674,23 @@ Chamilo LMS 1.9.8 (and following versions) supports the X-Sendfile headers, but
<pre>
$_configuration['enable_x_sendfile_headers'] = true;
</pre>
If you have issues with files taking a long time to download, make sure you reconfigure your webserver and add this line. You should see a notable difference in download time.
If you have issues with files taking a long time to download, make sure you reconfigure your webserver and add this line. You should see a notable difference in download time.<br>
Your webserver must be configured to support X-Sendfile, both by enabling the module and setting options in your web server's configuration.<br>
If you are running Apache 2.4 on Ubuntu, for example, this would mean installing and enabling the module:
<pre>
sudo apt install libapache2-mod-xsendfile
sudo a2enmod xsendfile
</pre>
Then in your active vhost for Chamilo, you would have to add the following lines, where the path is the same as your DocumentRoot (we use /var/www/chamilo in this example):
<pre>
XSendFile On
XSendFilePath /var/www/chamilo
</pre>
Then restart Apache:
<pre>
sudo systemctl restart apache2
</pre>
<hr style="width: 100%; height: 2px;" />
<h2><a id="15._Videoconference"></a>13. Videoconference</h2>
<p>
@@ -735,11 +751,11 @@ If you have issues with files taking a long time to download, make sure you reco
RewriteRule ^certificates/$ certificates/index.php?id=%1 [L]
RewriteRule ^courses/([^/]+)/?$ main/course_home/course_home.php?cDir=$1 [QSA,L]
RewriteRule ^courses/([^/]+)/index.php$ main/course_home/course_home.php?cDir=$1 [QSA,L]
RewriteRule ^courses/([^/]+)/scorm/(.*([\.js|\.css|\.png|\.jpg|\.jpeg|\.gif]))$ app/courses/$1/scorm/$2 [QSA,L]
RewriteRule ^courses/([^/]+)/scorm/(.*\.(js|css|png|jpg|jpeg|gif))$ app/courses/$1/scorm/$2 [QSA,L]
RewriteRule ^courses/([^/]+)/scorm/(.*)$ main/document/download_scorm.php?doc_url=/$2&cDir=$1 [QSA,L]
RewriteRule ^courses/([^/]+)/document/certificates/(.*)$ app/courses/$1/document/certificates/$2 [QSA,L]
RewriteRule ^courses/([^/]+)/document/(.*)$ main/document/download.php?doc_url=/$2&cDir=$1 [QSA,L]
RewriteRule ^courses/([^/]+)/upload/course_home_icons/(.*([\.js|\.css|\.png|\.jpg|\.jpeg|\.gif]))$ app/courses/$1/upload/course_home_icons/$2 [QSA,L]
RewriteRule ^courses/([^/]+)/upload/course_home_icons/(.*\.(js|css|png|jpg|jpeg|gif))$ app/courses/$1/upload/course_home_icons/$2 [QSA,L]
RewriteRule ^courses/([^/]+)/upload/([^/]+)/(.*)$ main/document/download_uploaded_files.php?code=$1&type=$2&file=$3 [QSA,L]
RewriteRule ^courses/([^/]+)/work/(.*)$ main/work/download.php?file=work/$2&cDir=$1 [QSA,L]
RewriteRule ^courses/([^/]+)/course-pic85x85.png$ main/inc/ajax/course.ajax.php?a=get_course_image&code=$1&image=course_image_source [QSA,L]
@@ -806,7 +822,7 @@ If you have issues with files taking a long time to download, make sure you reco
rewrite ^certificates/$ certificates/index.php last;
rewrite ^/courses/([^/]+)/$ /main/course_home/course_home.php?cDir=$1 last;
rewrite ^/courses/([^/]+)/index.php$ /main/course_home/course_home.php?cDir=$1 last;
rewrite ^/courses/([^/]+)/scorm/(.*([\.js|\.css|\.png|\.jpg|\.jpeg|\.gif]))$ app/courses/$1/scorm/$2 last;
rewrite ^/courses/([^/]+)/scorm/(.*\.(js|css|png|jpg|jpeg|gif))$ app/courses/$1/scorm/$2 last;
rewrite ^/courses/([^/]+)/scorm/(.*)$ /main/document/download_scorm.php?doc_url=/$2&cDir=$1 last;
# Alternatively, you can choose to give direct access to all SCORM files, which is much faster but less secure
# rewrite "^/courses/([^/]+)/scorm/(.*)$" /app/courses/$1/scorm/$2 break;
@@ -815,7 +831,7 @@ If you have issues with files taking a long time to download, make sure you reco
rewrite ^/courses/([^/]+)/document/(.*)$ /main/document/download.php?doc_url=/$2&cDir=$1 last;
rewrite ^/courses/([^/]+)/upload/([^/]+)/(.*)$ /main/document/download_uploaded_files.php?code=$1&type=$2&file=$3 last;
rewrite ^/courses/([^/]+)/work/(.*)$ /main/work/download.php?file=work/$2&cDir=$1 last;
rewrite ^/courses/([^/]+)/upload/course_home_icons/(.*([\.png|\.jpg|\.jpeg|\.gif]))$ app/courses/$1/upload/course_home_icons/$2 last;
rewrite ^/courses/([^/]+)/upload/course_home_icons/(.*\.(png|jpg|jpeg|gif))$ app/courses/$1/upload/course_home_icons/$2 last;
rewrite ^/courses/([^/]+)/(.*)$ /app/courses/$1/$2 last;
rewrite ^/session/([^/]+)/about/?$ /main/session/about.php?session_id=$1 last;
rewrite ^/course/([^/]+)/about/?$ /main/course_info/about.php?course_id=$1 last;
@@ -915,7 +931,7 @@ If you have issues with files taking a long time to download, make sure you reco
&lt;action type="Rewrite" url="main/course_home/course_home.php?cDir={R:1}" /&gt;
&lt;/rule&gt;
&lt;rule name="rule 4v" stopProcessing="true"&gt;
&lt;match url="^courses/([^/]+)/scorm/(.*([\.js|\.css|\.png|\.jpg|\.jpeg|\.gif]))$" /&gt;
&lt;match url="^courses/([^/]+)/scorm/(.*\.(js|css|png|jpg|jpeg|gif))$" /&gt;
&lt;action type="Rewrite" url="app/courses/{R:1}/scorm/{R:2}" /&gt;
&lt;/rule&gt;
&lt;rule name="rule 5v" stopProcessing="true"&gt;
@@ -931,7 +947,7 @@ If you have issues with files taking a long time to download, make sure you reco
&lt;action type="Rewrite" url="main/document/download.php?doc_url=/{R:2}&amp;cDir={R:1}" /&gt;
&lt;/rule&gt;
&lt;rule name="rule v8" stopProcessing="true"&gt;
&lt;match url="^courses/([^/]+)/upload/course_home_icons/(.*([\.js|\.css|\.png|\.jpg|\.jpeg|\.gif]))$" /&gt;
&lt;match url="^courses/([^/]+)/upload/course_home_icons/(.*\.(js|css|png|jpg|jpeg|gif))$" /&gt;
&lt;action type="Rewrite" url="app/courses/{R:1}/upload/course_home_icons/{R:2}" /&gt;
&lt;/rule&gt;
&lt;rule name="rule v9" stopProcessing="true"&gt;
+24 -7
View File
@@ -525,7 +525,7 @@ Este script se proporciona sin garantía. Por favor * siempre * realice una copi
</li>
</ul>
<span class="text-muted">
* Los estilos e imágenes están ubicados en el directorio main/css o main/img. Usted puede recuperarlos desde la
* Los estilos e imágenes están ubicados en el directorio main/img o app/Resources/public/css/themes. Usted puede recuperarlos desde la
copia de seguridad en el caso de que usted hya tenido la precaución de realizarla. Cualquier estilo o imagen
modificada que use el nombre predeterminado style/image será sobrescrita en el siguiente paso. Para evitar
perder cualquier personalización, siempre asegúrese de copiar styles/images bajo un nuevo nombre y use y
@@ -796,6 +796,23 @@ específica de configuración para ser agregado a configuration.php:
<pre>$_configuration['enable_x_sendfile_headers'] = true;</pre>
Si tiene problemas con los archivos que tardan mucho tiempo en descargarse, asegúrese de reconfigurar su
servidor web y agregar esta línea. Debería ver una diferencia notable en el tiempo de descarga.
El servidor web tiene que ser configurado para soportar X-Sendfile, por activar el módulo y por configurar opciones en su configuración.<br>
Si tiene Apache 2.4 en Ubuntu, por ejemplo, tendría que ser algo así:
<pre>
sudo apt install libapache2-mod-xsendfile
sudo a2enmod xsendfile
</pre>
De ahí, en su vhost para Chamilo, tendría que añadir las líneas siguientes, donde el path es el mismo que su DocumentRoot (usamos /var/www/chamilo en este ejemplo):
<pre>
XSendFile On
XSendFilePath /var/www/chamilo
</pre>
Luego reiniciar Apache:
<pre>
sudo systemctl restart apache2
</pre>
<hr style="width: 100%; height: 2px;" />
<h2><a id="15._Videoconference"></a>13. Videoconferencia</h2>
@@ -867,11 +884,11 @@ servidor web y agregar esta línea. Debería ver una diferencia notable en el ti
RewriteRule ^certificates/$ certificates/index.php?id=%1 [L]
RewriteRule ^courses/([^/]+)/?$ main/course_home/course_home.php?cDir=$1 [QSA,L]
RewriteRule ^courses/([^/]+)/index.php$ main/course_home/course_home.php?cDir=$1 [QSA,L]
RewriteRule ^courses/([^/]+)/scorm/(.*([\.js|\.css|\.png|\.jpg|\.jpeg|\.gif]))$ app/courses/$1/scorm/$2 [QSA,L]
RewriteRule ^courses/([^/]+)/scorm/(.*\.(js|css|png|jpg|jpeg|gif))$ app/courses/$1/scorm/$2 [QSA,L]
RewriteRule ^courses/([^/]+)/scorm/(.*)$ main/document/download_scorm.php?doc_url=/$2&cDir=$1 [QSA,L]
RewriteRule ^courses/([^/]+)/document/certificates/(.*)$ app/courses/$1/document/certificates/$2 [QSA,L]
RewriteRule ^courses/([^/]+)/document/(.*)$ main/document/download.php?doc_url=/$2&cDir=$1 [QSA,L]
RewriteRule ^courses/([^/]+)/upload/course_home_icons/(.*([\.js|\.css|\.png|\.jpg|\.jpeg|\.gif]))$ app/courses/$1/upload/course_home_icons/$2 [QSA,L]
RewriteRule ^courses/([^/]+)/upload/course_home_icons/(.*\.(js|css|png|jpg|jpeg|gif))$ app/courses/$1/upload/course_home_icons/$2 [QSA,L]
RewriteRule ^courses/([^/]+)/upload/([^/]+)/(.*)$ main/document/download_uploaded_files.php?code=$1&type=$2&file=$3 [QSA,L]
RewriteRule ^courses/([^/]+)/work/(.*)$ main/work/download.php?file=work/$2&cDir=$1 [QSA,L]
RewriteRule ^courses/([^/]+)/course-pic85x85.png$ main/inc/ajax/course.ajax.php?a=get_course_image&code=$1&image=course_image_source [QSA,L]
@@ -926,7 +943,7 @@ que se colocarán dentro de un bloque de servidor {}, ya que otras configuracion
rewrite ^certificates/$ certificates/index.php last;
rewrite ^/courses/([^/]+)/$ /main/course_home/course_home.php?cDir=$1 last;
rewrite ^/courses/([^/]+)/index.php$ /main/course_home/course_home.php?cDir=$1 last;
rewrite ^/courses/([^/]+)/scorm/(.*([\.js|\.css|\.png|\.jpg|\.jpeg|\.gif]))$ app/courses/$1/scorm/$2 last;
rewrite ^/courses/([^/]+)/scorm/(.*\.(js|css|png|jpg|jpeg|gif))$ app/courses/$1/scorm/$2 last;
rewrite ^/courses/([^/]+)/scorm/(.*)$ /main/document/download_scorm.php?doc_url=/$2&cDir=$1 last;
# Alternatively, you can choose to give direct access to all SCORM files, which is much faster but less secure
# rewrite "^/courses/([^/]+)/scorm/(.*)$" /app/courses/$1/scorm/$2 break;
@@ -935,7 +952,7 @@ que se colocarán dentro de un bloque de servidor {}, ya que otras configuracion
rewrite ^/courses/([^/]+)/document/(.*)$ /main/document/download.php?doc_url=/$2&cDir=$1 last;
rewrite ^/courses/([^/]+)/upload/([^/]+)/(.*)$ /main/document/download_uploaded_files.php?code=$1&type=$2&file=$3 last;
rewrite ^/courses/([^/]+)/work/(.*)$ /main/work/download.php?file=work/$2&cDir=$1 last;
rewrite ^/courses/([^/]+)/upload/course_home_icons/(.*([\.png|\.jpg|\.jpeg|\.gif]))$ app/courses/$1/upload/course_home_icons/$2 last;
rewrite ^/courses/([^/]+)/upload/course_home_icons/(.*\.(png|jpg|jpeg|gif))$ app/courses/$1/upload/course_home_icons/$2 last;
rewrite ^/courses/([^/]+)/(.*)$ /app/courses/$1/$2 last;
rewrite ^/session/([^/]+)/about/?$ /main/session/about.php?session_id=$1 last;
rewrite ^/course/([^/]+)/about/?$ /main/course_info/about.php?course_id=$1 last;
@@ -1033,7 +1050,7 @@ que se colocarán dentro de un bloque de servidor {}, ya que otras configuracion
&lt;action type="Rewrite" url="main/course_home/course_home.php?cDir={R:1}" /&gt;
&lt;/rule&gt;
&lt;rule name="rule 4v" stopProcessing="true"&gt;
&lt;match url="^courses/([^/]+)/scorm/(.*([\.js|\.css|\.png|\.jpg|\.jpeg|\.gif]))$" /&gt;
&lt;match url="^courses/([^/]+)/scorm/(.*\.(js|css|png|jpg|jpeg|gif))$" /&gt;
&lt;action type="Rewrite" url="app/courses/{R:1}/scorm/{R:2}" /&gt;
&lt;/rule&gt;
&lt;rule name="rule 5v" stopProcessing="true"&gt;
@@ -1049,7 +1066,7 @@ que se colocarán dentro de un bloque de servidor {}, ya que otras configuracion
&lt;action type="Rewrite" url="main/document/download.php?doc_url=/{R:2}&amp;cDir={R:1}" /&gt;
&lt;/rule&gt;
&lt;rule name="rule v8" stopProcessing="true"&gt;
&lt;match url="^courses/([^/]+)/upload/course_home_icons/(.*([\.js|\.css|\.png|\.jpg|\.jpeg|\.gif]))$" /&gt;
&lt;match url="^courses/([^/]+)/upload/course_home_icons/(.*\.(js|css|png|jpg|jpeg|gif))$" /&gt;
&lt;action type="Rewrite" url="app/courses/{R:1}/upload/course_home_icons/{R:2}" /&gt;
&lt;/rule&gt;
&lt;rule name="rule v9" stopProcessing="true"&gt;
+40 -24
View File
@@ -342,7 +342,7 @@
<li>php-pcre: L'extension pcre partagée pour php</li>
<li>php-xml</li>
<li>php-json</li>
<li>php-iconv ou php5-mbstring (au choix)</li>
<li>php-iconv ou php-mbstring (au choix)</li>
<li>php-gd L'extension de manipulation d'images pour PHP</li>
<li>php-intl L'extension pour la gestion de l'internationalisation pour PHP</li>
</ul>
@@ -411,20 +411,20 @@
quelconque version 1.11.*, les seuls pas à suivre sont:
<ul>
<li>
vérifier que vous n'avez pas créé une version modifiée d'une feuille de
Vérifiez que vous n'avez pas créé une version modifiée d'une feuille de
style en utilisant un répertoire css existant <span class="text-muted">(si c'est le cas, elle sera
écrasée par la mise à jour. Gardez-en une copie que vous renommerez au
moment de la replacer dans le répertoire main/css/)</span>
moment de la replacer dans le répertoire app/Resources/public/css/themes/)</span>
</li>
<li> téléchargez le paquet d'installation de Chamilo 1.11 depuis la
<li> Téléchargez le paquet d'installation de Chamilo 1.11 depuis la
<a href="https://chamilo.org/download">page de téléchargement de Chamilo</a></li>
<li> dézippez les nouveaux fichiers de Chamilo 1.11 par dessus les fichiers de
<li> Dézippez les nouveaux fichiers de Chamilo 1.11 par dessus les fichiers de
l'ancienne version (ou dézippez les fichiers dans un répertoire et copiez-les ensuite
là où se trouve votre version actuelle de Chamilo)</li>
<li> éditez le fichier app/config/configuration.php : localisez le numéro de version antérieur dans le paramètre <i>$_configuration['system_version']</i> (p.ex. '1.11.18') et remplacez-le
par le numéro de la nouvelle version (p.ex. '1.11.26')</li>
<li> videz votre dossier archive/ : faites une copie temporaire de index.html, supprimez touss les contenus *dans* ce dossier (ne supprimer pas le dossier, juste ses contenus). Vous pouvez également vider ce dossier depuis le lien "Vidange du répertoire archive" du bloc "Système" de l'interface d'administration.</li>
<li> c'est fini! Il n'y a pas d'autre étape requise.</li>
là où se trouve votre version actuelle de Chamilo, le zip contenant un sous-répertoire qui ne porte peut-être pas le même nom que votre installation)</li>
<li> Éditez le fichier app/config/configuration.php : localisez le numéro de version antérieur dans le paramètre <i>$_configuration['system_version']</i> (p.ex. '1.11.18') et remplacez-le
par le numéro de la nouvelle version (p.ex. '1.11.30')</li>
<li> Videz votre dossier app/cache/twig/ : supprimez tous les contenus *dans* ce dossier (ne supprimez pas le dossier, juste ses contenus). Vous pouvez également vider ce dossier depuis le lien "Vidange du cache et des fichiers temporaires" du bloc "Système" de l'interface d'administration.</li>
<li> C'est fini! Il n'y a pas d'autre étape requise.</li>
</ul>
<br />
@@ -436,15 +436,15 @@
<li> télécharger le paquet Chamilo 1.11 depuis la <a href="https://chamilo.org/download">page de téléchargement de Chamilo</a></li>
<li> décompressez les nouveaux fichiers de Chamilo 1.11 sur les fichiers de votre ancienne installation (ou décompressez les dans un nouveau dossier et copier les fichiers extraits sur les anciens fichiers).</li>
<li> vérifiez que le fichier .htaccess de la version 1.11 a bien été copié</li>
<li> vérifiez que "AllowOverride All" est présent dans votre configuration Apache, car l'interprétation du fichier .htaccess est capitale pour le fonctionnement de Chamilo</li>
<li> vérifiez que "AllowOverride All" ou "Require all granted" (selon la version d'Apache) est présent dans votre configuration Apache, car l'interprétation du fichier .htaccess est capitale pour le fonctionnement de Chamilo</li>
<li> accéder à l'adresse de votre portail URL + main/install </li>
<li> Choisissez votre langue et cliquez sur <span style="font-style: italic;">Mettre à jour depuis la version 1.8.x / 1.9.x</span></li>
<li> Videz votre dossier archive/ : faites une copie temporaire de index.html, supprimez touss les contenus *dans* ce dossier (ne supprimer pas le dossier, juste ses contenus). Vous pouvez également vider ce dossier depuis le lien "Vidange du répertoire archive" du bloc "Système" de l'interface d'administration.</li>
<li> Videz votre dossier app/cache/ : faites une copie temporaire de index.html, supprimez touss les contenus *dans* ce dossier (ne supprimer pas le dossier, juste ses contenus). Vous pouvez également vider ce dossier depuis le lien "Vidange du répertoire archive" du bloc "Système" de l'interface d'administration.</li>
</ul>
<br />
<span class="text-muted">* Les styles et images se trouvent dans les répertoires main/css ou main/img.
<span class="text-muted">* Les styles et images se trouvent dans les répertoires main/img et app/Resources/public/css/themes/.
Vous pouvez les récupérer depuis la copie de sauvegarde (que vous n'avez pas manqué de prendre).
Toute image ou style modifié qui utilise le nom de l'image/style par défaut sera
écrasé lors de l'étape suivante. Pour éviter la perte de customisations, assurez-vous toujours
@@ -720,14 +720,30 @@ Please note that, although Chamilo allows you to define its position, the "title
<hr style="width: 100%; height: 2px;" />
<h2><a id="14._Improving_files_download"></a>12. Améliorer la performance des téléchargements de fichiers</h2>
<div>
File download can be very slow when passing through a PHP script to control permissions. One solution to this
is to use the X-Sendfile header, which depends on a module on the webserver. <a href="https://stackoverflow.com/a/3731639/1406662">Check https://stackoverflow.com/a/3731639/1406662 for more details on implementing Sendfile</a>.
Chamilo LMS 1.9.8 (and following versions) supports the X-Sendfile headers, but requires a specific line of configuration to be
added to configuration.php:
Le téléchargement de fichiers peut &ecirc;tre très lent quand il passe au travers d'un script PHP qui contr&ocirc;le les permissions. One solution to this
Une solution pour cela est d'utiliser les en-t&ecirc;tes X-Sendfile, qui dépendent d'un module du serveur web. <a href="https://stackoverflow.com/a/3731639/1406662">Lisez https://stackoverflow.com/a/3731639/1406662 pour plus de d&eacute;tails concernant son impl&eacut;mentation</a>.
Chamilo LMS 1.9.8 (et les versions suivantes) supporte les en-t&ecirc;tes X-Sendfile, mais exige une ligne de configuration spéciale à ajouter à configuration.php :
<pre>
$_configuration['enable_x_sendfile_headers'] = true;
</pre>
If you have issues with files taking a long time to download, make sure you reconfigure your webserver and add this line. You should see a notable difference in download time.
Si vous avez des soucis avec le téléchargement trop lent de fichiers, essayez cette option. Vous devriez observer une différence sensible dans les temps de téléchargement.
Votre serveur web doit &ecirc;tre configuré pour supporter X-Sendfile, en activant le moédule correspondant ainsi qu'une configuration de votre vhost.<br>
Si vous utilisez Apache 2.4 sur Ubuntu, par exemple, ça voudrait dire installer et activer le module X-Sendfile :
<pre>
sudo apt install libapache2-mod-xsendfile
sudo a2enmod xsendfile
</pre>
Ensuite il faut configurer le vhost actif de Chamilo en ajoutant les lignes suivantes, où le path est le m&ecirc;me que votre DocumentRoot (nous utilisons /var/www/chamilo dans cet exemple) :
<pre>
XSendFile On
XSendFilePath /var/www/chamilo
</pre>
Puis red&eacute;marrer Apache :
<pre>
sudo systemctl restart apache2
</pre>
</div>
<hr style="width: 100%; height: 2px;" />
<h2><a id="15._Videoconference"></a>13. Vidéo-conférence</h2>
@@ -787,11 +803,11 @@ ou, si vous travaillez avec Apache 2.4, la syntaxe est légèrement différente
RewriteRule ^certificates/$ certificates/index.php?id=%1 [L]
RewriteRule ^courses/([^/]+)/?$ main/course_home/course_home.php?cDir=$1 [QSA,L]
RewriteRule ^courses/([^/]+)/index.php$ main/course_home/course_home.php?cDir=$1 [QSA,L]
RewriteRule ^courses/([^/]+)/scorm/(.*([\.js|\.css|\.png|\.jpg|\.jpeg|\.gif]))$ app/courses/$1/scorm/$2 [QSA,L]
RewriteRule ^courses/([^/]+)/scorm/(.*\.(js|css|png|jpg|jpeg|gif))$ app/courses/$1/scorm/$2 [QSA,L]
RewriteRule ^courses/([^/]+)/scorm/(.*)$ main/document/download_scorm.php?doc_url=/$2&cDir=$1 [QSA,L]
RewriteRule ^courses/([^/]+)/document/certificates/(.*)$ app/courses/$1/document/certificates/$2 [QSA,L]
RewriteRule ^courses/([^/]+)/document/(.*)$ main/document/download.php?doc_url=/$2&cDir=$1 [QSA,L]
RewriteRule ^courses/([^/]+)/upload/course_home_icons/(.*([\.js|\.css|\.png|\.jpg|\.jpeg|\.gif]))$ app/courses/$1/upload/course_home_icons/$2 [QSA,L]
RewriteRule ^courses/([^/]+)/upload/course_home_icons/(.*\.(js|css|png|jpg|jpeg|gif))$ app/courses/$1/upload/course_home_icons/$2 [QSA,L]
RewriteRule ^courses/([^/]+)/upload/([^/]+)/(.*)$ main/document/download_uploaded_files.php?code=$1&type=$2&file=$3 [QSA,L]
RewriteRule ^courses/([^/]+)/work/(.*)$ main/work/download.php?file=work/$2&cDir=$1 [QSA,L]
RewriteRule ^courses/([^/]+)/course-pic85x85.png$ main/inc/ajax/course.ajax.php?a=get_course_image&code=$1&image=course_image_source [QSA,L]
@@ -842,7 +858,7 @@ Ce sont uniquement les redirections à placer dans un bloc server{}, comme les a
rewrite ^certificates/$ certificates/index.php last;
rewrite ^/courses/([^/]+)/$ /main/course_home/course_home.php?cDir=$1 last;
rewrite ^/courses/([^/]+)/index.php$ /main/course_home/course_home.php?cDir=$1 last;
rewrite ^/courses/([^/]+)/scorm/(.*([\.js|\.css|\.png|\.jpg|\.jpeg|\.gif]))$ app/courses/$1/scorm/$2 last;
rewrite ^/courses/([^/]+)/scorm/(.*\.(js|css|png|jpg|jpeg|gif))$ app/courses/$1/scorm/$2 last;
rewrite ^/courses/([^/]+)/scorm/(.*)$ /main/document/download_scorm.php?doc_url=/$2&cDir=$1 last;
# Alternatively, you can choose to give direct access to all SCORM files, which is much faster but less secure
# rewrite "^/courses/([^/]+)/scorm/(.*)$" /app/courses/$1/scorm/$2 break;
@@ -851,7 +867,7 @@ Ce sont uniquement les redirections à placer dans un bloc server{}, comme les a
rewrite ^/courses/([^/]+)/document/(.*)$ /main/document/download.php?doc_url=/$2&cDir=$1 last;
rewrite ^/courses/([^/]+)/upload/([^/]+)/(.*)$ /main/document/download_uploaded_files.php?code=$1&type=$2&file=$3 last;
rewrite ^/courses/([^/]+)/work/(.*)$ /main/work/download.php?file=work/$2&cDir=$1 last;
rewrite ^/courses/([^/]+)/upload/course_home_icons/(.*([\.png|\.jpg|\.jpeg|\.gif]))$ app/courses/$1/upload/course_home_icons/$2 last;
rewrite ^/courses/([^/]+)/upload/course_home_icons/(.*\.(png|jpg|jpeg|gif))$ app/courses/$1/upload/course_home_icons/$2 last;
rewrite ^/courses/([^/]+)/(.*)$ /app/courses/$1/$2 last;
rewrite ^/session/([^/]+)/about/?$ /main/session/about.php?session_id=$1 last;
rewrite ^/course/([^/]+)/about/?$ /main/course_info/about.php?course_id=$1 last;
@@ -935,7 +951,7 @@ Ce sont uniquement les redirections à placer dans un bloc server{}, comme les a
&lt;action type="Rewrite" url="main/course_home/course_home.php?cDir={R:1}" /&gt;
&lt;/rule&gt;
&lt;rule name="rule 4v" stopProcessing="true"&gt;
&lt;match url="^courses/([^/]+)/scorm/(.*([\.js|\.css|\.png|\.jpg|\.jpeg|\.gif]))$" /&gt;
&lt;match url="^courses/([^/]+)/scorm/(.*\.(js|css|png|jpg|jpeg|gif))$" /&gt;
&lt;action type="Rewrite" url="app/courses/{R:1}/scorm/{R:2}" /&gt;
&lt;/rule&gt;
&lt;rule name="rule 5v" stopProcessing="true"&gt;
@@ -951,7 +967,7 @@ Ce sont uniquement les redirections à placer dans un bloc server{}, comme les a
&lt;action type="Rewrite" url="main/document/download.php?doc_url=/{R:2}&amp;cDir={R:1}" /&gt;
&lt;/rule&gt;
&lt;rule name="rule v8" stopProcessing="true"&gt;
&lt;match url="^courses/([^/]+)/upload/course_home_icons/(.*([\.js|\.css|\.png|\.jpg|\.jpeg|\.gif]))$" /&gt;
&lt;match url="^courses/([^/]+)/upload/course_home_icons/(.*\.(js|css|png|jpg|jpeg|gif))$" /&gt;
&lt;action type="Rewrite" url="app/courses/{R:1}/upload/course_home_icons/{R:2}" /&gt;
&lt;/rule&gt;
&lt;rule name="rule v9" stopProcessing="true"&gt;
+2 -2
View File
@@ -122,7 +122,7 @@
<p class="p2"><span class="s1">Opzionalmente puoi fare lo stesso alle seguenti cartelle se vuoi consentire il caricamento dei pacchetti di stile CSS (CSS styles package) e la definizione di sotto-linguaggi (sub-language definition):</span></p>
<p class="p3"><span class="s1"></span><br></p>
<ul>
<li class="li2"><span class="s1">[chamilo]/main/css/</span></li>
<li class="li2"><span class="s1">[chamilo]/app/Resources/public/css/themes/</span></li>
<li class="li2"><span class="s1">[chamilo]/main/lang/</span></li>
</ul>
<p class="p3"><span class="s1"></span><br></p>
@@ -252,7 +252,7 @@
<p class="p3"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">Alcuni amministratori di Chamilo hanno riportato alcuni problemi minori nella migrazione tra versioni molto diverse tra loro (per esempio nel passaggio da DokΩos a Chamilo). Questi includono la perdita di alcuni esercizi assegnati o post dei forum. Per evitare qualsiasi spiacevole effetto nei confronti dei tuoi utenti ti raccomandiamo di definire una checklist di tutti i contenuti che sono critici per te e di mantenere attiva una copia funzionante del tuo sito durante la migrazione. In questo modo sarà più semplice gestire la transizione permettendo ai tuoi utenti di accedere ai propri contenuti dalla precedente versione del sito e permettendo a te di effettuare una facile comparazione delle versioni dei contenuti. Se incontri difficoltà considera la possibilità di chiedere aiuto ad uno sviluppatore PHP o chiedere a qualcuno dei provider ufficiali di Chamilo. Essi ti daranno il supporto migliore per assicurarti una corretta migrazione di Chamilo.</span></p>
<p class="p3"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">*Gli Style e le immagini sono localizzate nella cartella main/css e main/img. Puoi comunque recuperarle dal tuo backup se lo hai fatto. Qualsiasi style o image che utilizza il nome di default di style o image verrà sovrascritto nel passaggio successivo a questa fase della migrazione. Per evitare di perdere la tua personalizzazione, ricordati sempre di fare una copia degli styles e images con un nuovo nome, utilizzando e modificando questo file copia, mai loriginale. Loriginale viene sempre sovrascritto nella nuova versione. In Do€os 1.8.5 abbiamo cambiato il nome di molti temi CSS. La retro compatibilità è assicurata dal fatto che un aggiornamento aggiunge solamente nuovi temi, ma dovresti provare ed utilizzare i nuovi temi piuttosto che continuare ad utilizzare i vecchi che verranno deprecati rapidamente (ovvero non più mantenuti).</span></p>
<p class="p2"><span class="s1">*Gli Style e le immagini sono localizzate nella cartella main/img/ e app/Resources/public/css/themes/. Puoi comunque recuperarle dal tuo backup se lo hai fatto. Qualsiasi style o image che utilizza il nome di default di style o image verrà sovrascritto nel passaggio successivo a questa fase della migrazione. Per evitare di perdere la tua personalizzazione, ricordati sempre di fare una copia degli styles e images con un nuovo nome, utilizzando e modificando questo file copia, mai loriginale. Loriginale viene sempre sovrascritto nella nuova versione. In Do€os 1.8.5 abbiamo cambiato il nome di molti temi CSS. La retro compatibilità è assicurata dal fatto che un aggiornamento aggiunge solamente nuovi temi, ma dovresti provare ed utilizzare i nuovi temi piuttosto che continuare ad utilizzare i vecchi che verranno deprecati rapidamente (ovvero non più mantenuti).</span></p>
<p class="p3"><span class="s1"></span><br></p>
<p class="p3"><span class="s1"></span><br></p>
<p class="p2"><span class="s1"><b>3.4 Aggiornare da Dok€os 1.6.x</b></span></p>
+3 -2
View File
@@ -257,6 +257,7 @@ ALTER TABLE c_lp_item_view ADD INDEX idx_clpiv_c_i_v (c_id, id, view_count);
ALTER TABLE c_chat_connected ADD INDEX idx_course_group(c_id, to_group_id);
-- For big online exams, this index reduces the load considerably
ALTER TABLE c_quiz_question ADD INDEX idx_cqq_cidid (c_id, id);
ALTER TABLE c_quiz_question ADD INDEX idx_cqq_type (type);
-- If you have many messages with attachments
ALTER TABLE message_attachment ADD index idx_msgat_msgid (message_id);
-- If you query audit logs from track_e_default on huge tables
@@ -505,10 +506,10 @@ RewriteRule (\.(html|gif|jpg|jpeg|png|js|pdf|ico|icon|css|swf|avi|mp3|ogg|wav|tt
<p>
Since version 1.11.10, the .htaccess has been modified to do this by default with media files, and a change post-1.11.10 also does it for documents that are not in a SCORM folder. These changes will improve speed considerably but will lower the security on media files, as a direct link could be used to open the file with no validation. As such, you can comment those lines with media files to ensure increased security, at the cost of performance. These are the two lines (followed by their more wide-ranging rule) that have to be present in .htaccess for maximum efficiency.
<pre>
RewriteRule ^courses/([^/]+)/scorm/(.*([\.js|\.css|\.png|\.jpg|\.jpeg|\.gif]))$ app/courses/$1/scorm/$2 [QSA,L]
RewriteRule ^courses/([^/]+)/scorm/(.*\.(js|css|png|jpg|jpeg|gif))$ app/courses/$1/scorm/$2 [QSA,L]
RewriteRule ^courses/([^/]+)/scorm/(.*)$ main/document/download_scorm.php?doc_url=/$2&cDir=$1 [QSA,L]
[...]
RewriteRule ^courses/([^/]+)/document/(.*([\.js|\.css|\.png|\.jpg|\.jpeg|\.gif|\.mp4|\.webm|\.avi|\.mpeg|\.mp3|\.wav|\.ogg]))$ app/courses/$1/document/$2 [QSA,L]
RewriteRule ^courses/([^/]+)/document/(.*\.(js|css|png|jpg|jpeg|gif|mp4|webm|avi|mpeg|mp3|wav|ogg]))$ app/courses/$1/document/$2 [QSA,L]
RewriteRule ^courses/([^/]+)/document/(.*)$ main/document/download.php?doc_url=/$2&cDir=$1 [QSA,L]
</pre>
</p>
+38
View File
@@ -29,6 +29,7 @@
<li><a href="#9.Change-password-first-login">Change password on first login</a></li>
<li><a href="#10.Hide-breadcrumb">Hide breadcrumb on unauthorized page load</a></li>
<li><a href="#11.SVG-and-XSS">SVG and XSS</a></li>
<li><a href="#12.Template-files-access">Restricting access to template files</a></li>
</ol>
<h2><a id="1.Disclosing-server-info"></a>1. Disclosing server info</h2>
@@ -184,6 +185,11 @@ This will prevent direct access to your settings and make it seem totally the sa
but dangerous if you cannot trust people using these features. These features cannot be used by anonymous
users, but portals allowing for open registration could be particularly vulnerable.
</p>
<p>The inline editor in Chamilo 1.11 requires
the Content Security Policy (CSP) headers to include 'unsafe-inline' and 'unsafe-eval'. This is because the
editor includes a number of complex features that require those accesses. This means that your Chamilo install
will probably not validate as an "A+" score in standard CSP tests. We have no solution for this except to
prevent users from using the inline editor, which would remove a considerable level of usability from Chamilo.</p>
<br />
<hr />
<h2><a id="7.Direct-web-access">Direct web access to files</a></h2>
@@ -275,6 +281,38 @@ This will prevent direct access to your settings and make it seem totally the sa
</ul>
</p>
<h2><a id="12.Template-files-access"></a>12. Restricting access to template files</h2>
<p>
Twig template files (<code>.tpl</code>) under <code>main/template/</code> are
not meant to be served directly over HTTP. They are loaded by PHP from the
filesystem. If left accessible, they expose internal application logic,
AJAX endpoint URLs, admin panel structure, and variable names to
unauthenticated users.
</p>
<p>
Chamilo ships a <code>.htaccess</code> file in <code>main/template/</code>
that blocks direct access. If your Apache configuration does not support
<code>.htaccess</code> overrides, add the following to your VirtualHost
definition (replace <code>/var/www/URL</code> with your Chamilo root):
</p>
<pre>
&lt;Directory /var/www/URL/main/template&gt;
&lt;FilesMatch "\.tpl$"&gt;
Require all denied
&lt;/FilesMatch&gt;
&lt;/Directory&gt;
</pre>
<p>
For Nginx, add this rule near the top of your location blocks (before
any generic location rules) so it takes priority:
</p>
<pre>
location ~* \.tpl$ {
deny all;
return 403;
}
</pre>
<h2>Authors</h2>
<ul>
<li>Yannick Warnier, Chamilo Project Leader, Zend Certified PHP Engineer, BeezNest Belgium SPRL,
+2 -1
View File
@@ -195,9 +195,10 @@ $controller->tpl->assign('navigation_links', $controller->return_navigation_link
$controller->tpl->assign('notice_block', $controller->return_notice());
$controller->tpl->assign('help_block', $controller->return_help());
$controller->tpl->assign('student_publication_block', $controller->studentPublicationBlock());
if (api_is_platform_admin() || api_is_drh()) {
if (!api_is_anonymous() && api_user_is_login()) {
$controller->tpl->assign('skills_block', $controller->returnSkillLinks());
}
if (api_is_anonymous()) {
$controller->tpl->setLoginBodyClass();
}
+1 -1
View File
@@ -95,7 +95,7 @@ if ($usergroup->allowTeachers()) {
$onlyThisSessionList = array_column($sessionList, 'id');
}
}
$session_list = SessionManager::get_sessions_list([], ['name'], null, null, 0, $onlyThisSessionList);
$session_list = SessionManager::get_sessions_list([], ['name'], null, null, 0, $onlyThisSessionList, true);
$elements_not_in = $elements_in = [];
if (!empty($session_list)) {
+8 -2
View File
@@ -175,11 +175,16 @@ if (ChamiloApi::isAjaxRequest() && $_SERVER['REQUEST_METHOD'] === 'POST' && isse
$excludedUsers = isset($_POST['excludedUsers']) ? $_POST['excludedUsers'] : [];
$id = isset($_POST['id']) ? intval($_POST['id']) : 0;
$accessUrlId = api_get_current_access_url_id();
$excludedIds = !empty($excludedUsers) ? implode(",", array_map('intval', $excludedUsers)) : '0';
$sql = 'SELECT id, username, firstname, lastname
FROM user
WHERE status != '.ANONYMOUS.'
AND id NOT IN ('.$excludedIds.')
AND u.id IN (
SELECT user_id
FROM access_url_rel_user
WHERE access_url_id ='.$accessUrlId.')
ORDER BY id DESC
LIMIT 10';
@@ -203,8 +208,8 @@ if (ChamiloApi::isAjaxRequest() && $_SERVER['REQUEST_METHOD'] === 'POST' && isse
$first_letter_user = '';
if ((isset($_POST['form_sent']) && $_POST['form_sent']) || isset($_REQUEST['firstLetterUser'])) {
$form_sent = $_POST['form_sent'];
$elements_posted = $_POST['elements_in_name'] ?? null;
$form_sent = $_POST['form_sent'] ?? 0;
$elements_posted = $_POST['elements_in_name'] ?? [];
$first_letter_user = Security::remove_XSS($_REQUEST['firstLetterUser']);
if (!is_array($elements_posted)) {
@@ -326,6 +331,7 @@ if (1 === $activeUser) {
$filterData = [];
if ($searchForm->validate()) {
$showAllStudentByDefault = true;
$filterData = $searchForm->getSubmitValues();
foreach ($filters as $filter) {
+9 -5
View File
@@ -49,16 +49,20 @@ if ($form->validate()) {
$htaccess = <<<TEXT
<IfModule mod_authz_core.c>
Require all denied
# pChart generated files should be allowed
<FilesMatch "^[0-9a-f]+$">
require all granted
</FilesMatch>
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
# pChart generated files should be allowed
<FilesMatch "^[0-9a-f]+$">
order allow,deny
allow from all
</FilesMatch>
</IfModule>
# pChart generated files should be allowed
<FilesMatch "^[0-9a-f]+$">
order allow,deny
allow from all
</FilesMatch>
php_flag engine off
TEXT;
+30 -15
View File
@@ -147,6 +147,11 @@ if (api_is_platform_admin()) {
'url' => 'usergroups.php',
'label' => get_lang('Classes'),
];
$items[] = [
'class' => 'item-user-advanced_edit',
'url' => 'user_advanced_edit.php',
'label' => get_lang('AdvancedUserEdition'),
];
if (api_get_configuration_value('show_link_request_hrm_user')) {
$items[] = [
'class' => 'item-user-linking-requests',
@@ -177,6 +182,11 @@ if (api_is_platform_admin()) {
'label' => get_lang('Classes'),
],
];
$items[] = [
'class' => 'item-user-advanced_edit',
'url' => 'user_advanced_edit.php',
'label' => get_lang('AdvancedUserEdition'),
];
if (api_is_session_admin()) {
if ('true' === api_get_setting('limit_session_admin_role')) {
@@ -531,14 +541,22 @@ if (api_is_platform_admin()) {
}
$blockPlatform['items'] = $items;
} elseif (api_is_session_admin() && api_get_configuration_value('session_admin_access_system_announcement')) {
} elseif (api_is_session_admin()) {
$items = [];
$items[] = [
'class' => 'item-global-announcement',
'url' => 'system_announcements.php',
'label' => get_lang('SystemAnnouncements'),
];
if (api_get_configuration_value('session_admin_access_global_statistics')) {
$items[] = [
'class' => 'item-stats',
'url' => 'statistics/index.php',
'label' => get_lang('Statistics'),
];
}
if (api_get_configuration_value('session_admin_access_system_announcement')) {
$items[] = [
'class' => 'item-global-announcement',
'url' => 'system_announcements.php',
'label' => get_lang('SystemAnnouncements'),
];
}
$blockPlatform['items'] = $items;
}
@@ -629,14 +647,11 @@ $items[] = [
$allowCareer = api_get_configuration_value('allow_session_admin_read_careers');
if (api_is_platform_admin() || ($allowCareer && api_is_session_admin())) {
// option only visible in development mode. Enable through code if required
if (is_dir(api_get_path(SYS_TEST_PATH).'datafiller/')) {
$items[] = [
'class' => 'item-session-user-move-stats',
'url' => 'user_move_stats.php',
'label' => get_lang('MoveUserStats'),
];
}
$items[] = [
'class' => 'item-session-user-move-stats',
'url' => 'user_move_stats.php',
'label' => get_lang('MoveUserStats'),
];
$items[] = [
'class' => 'item-session-user-move',
+6 -7
View File
@@ -204,7 +204,7 @@ if (isset($_POST['Submit']) && $_POST['Submit']) {
);
// changing the Platform language
if (isset($_POST['platformlanguage']) && $_POST['platformlanguage'] != '') {
api_set_setting('platformLanguage', $_POST['platformlanguage'], null, null, $_configuration['access_url']);
api_set_setting('platformLanguage', $_POST['platformlanguage'], null, null, api_get_current_access_url_id());
}
} elseif (isset($_POST['action'])) {
switch ($_POST['action']) {
@@ -263,9 +263,8 @@ echo '<a
$sql_select = "SELECT * FROM $tbl_admin_languages";
$result_select = Database::query($sql_select);
$sql_select_lang = "SELECT * FROM $tbl_settings_current WHERE category='Languages'";
$result_select_lang = Database::query($sql_select_lang);
$row_lang = Database::fetch_array($result_select_lang);
$current_access_url = api_get_current_access_url_id();
$platformLanguage = api_get_setting('platformLanguage');
// the table data
$language_data = [];
@@ -275,7 +274,7 @@ while ($row = Database::fetch_array($result_select)) {
// the first column is the original name of the language OR a form containing the original name
if ($action == 'edit' and $row['id'] == $_GET['id']) {
$checked = '';
if ($row['english_name'] == api_get_setting('platformLanguage')) {
if ($row['english_name'] == $platformLanguage) {
$checked = ' checked="checked" ';
}
@@ -291,7 +290,7 @@ while ($row = Database::fetch_array($result_select)) {
// the third column
$row_td[] = $row['dokeos_folder'];
if ($row['english_name'] == $row_lang['selected_value']) {
if ($row['english_name'] == $platformLanguage) {
$setplatformlanguage = Display::return_icon('languages.png', get_lang('CurrentLanguagesPortal'), '', ICON_SIZE_SMALL);
} else {
$setplatformlanguage = "<a href=\"javascript:if (confirm('".addslashes(get_lang('AreYouSureYouWantToSetThisLanguageAsThePortalDefault'))."')) { location.href='".api_get_self()."?action=setplatformlanguage&id=".$row['id']."'; }\">".Display::return_icon('languages_na.png', get_lang('SetLanguageAsDefault'), '', ICON_SIZE_SMALL)."</a>";
@@ -322,7 +321,7 @@ while ($row = Database::fetch_array($result_select)) {
$allow_add_term_sub_language = '';
}
if ($row['english_name'] == $row_lang['selected_value']) {
if ($row['english_name'] == $platformLanguage) {
$row_td[] = Display::return_icon('visible.png', get_lang('Visible'))."<a href='".api_get_self()."?action=edit&id=".$row['id']."#value'>".Display::return_icon('edit.png', get_lang('Edit'), '', ICON_SIZE_SMALL)."</a>
&nbsp;".$setplatformlanguage.$allow_use_sub_language.$allow_add_term_sub_language.$allow_delete_sub_language;
} else {
+77 -48
View File
@@ -8,7 +8,7 @@
$cidReset = true;
require_once __DIR__.'/../../inc/global.inc.php';
api_protect_admin_script();
api_protect_admin_script(true);
$interbreadcrumb[] = ['url' => '../index.php', 'name' => get_lang('PlatformAdmin')];
@@ -18,6 +18,72 @@ $validated = false;
$sessionStatusAllowed = api_get_configuration_value('allow_session_status');
$invoicingMonth = isset($_GET['invoicing_month']) ? (int) $_GET['invoicing_month'] : '';
$invoicingYear = isset($_GET['invoicing_year']) ? (int) $_GET['invoicing_year'] : '';
$tool_name = get_lang('Statistics');
if (api_is_platform_admin()) {
$tools = [
get_lang('Courses') => [
'report=courses' => get_lang('CountCours'),
'report=tools' => get_lang('PlatformToolAccess'),
'report=courselastvisit' => get_lang('LastAccess'),
'report=coursebylanguage' => get_lang('CountCourseByLanguage'),
],
get_lang('Users') => [
'report=users' => get_lang('CountUsers'),
'report=recentlogins' => get_lang('Logins'),
'report=logins&amp;type=month' => get_lang('Logins').' ('.get_lang('PeriodMonth').')',
'report=logins&amp;type=day' => get_lang('Logins').' ('.get_lang('PeriodDay').')',
'report=logins&amp;type=hour' => get_lang('Logins').' ('.get_lang('PeriodHour').')',
'report=pictures' => get_lang('CountUsers').' ('.get_lang('UserPicture').')',
'report=logins_by_date' => get_lang('LoginsByDate'),
'report=no_login_users' => get_lang('StatsUsersDidNotLoginInLastPeriods'),
'report=zombies' => get_lang('Zombies'),
'report=users_active' => get_lang('UserStats'),
'report=users_online' => get_lang('UsersOnline'),
'report=invoicing' => get_lang('InvoicingByAccessUrl'),
'report=duplicated_users' => get_lang('DuplicatedUsers'),
'report=duplicated_users_by_mail' => get_lang('DuplicatedUsersByMail'),
],
get_lang('System') => [
'report=activities' => get_lang('ImportantActivities'),
'report=user_session' => get_lang('PortalUserSessionStats'),
'report=courses_usage' => get_lang('CoursesUsage'),
'report=quarterly_report' => get_lang('QuarterlyReport'),
],
get_lang('Social') => [
'report=messagereceived' => get_lang('MessagesReceived'),
'report=messagesent' => get_lang('MessagesSent'),
'report=friends' => get_lang('CountFriends'),
],
get_lang('Session') => [
'report=session_by_date' => get_lang('SessionsByDate'),
],
];
if ('true' === api_get_plugin_setting('lti_provider', 'enabled')) {
$tools[get_lang('Users')]['report=lti_tool_lp'] = get_lang('LearningPathLTI');
}
} elseif (api_is_session_admin()) {
$tools = [
get_lang('Session') => [
'report=session_by_date' => get_lang('SessionsByDate'),
],
];
}
// Get list of allowed reports based on role
$allowedReports = [];
foreach ($tools as $section => $items) {
foreach ($items as $key => $label) {
if (preg_match('/report=([a-zA-Z0-9_]+)/', $key, $matches)) {
$allowedReports[] = $matches[1];
}
}
}
// Ensure current report is valid for this user, or default to first available
if (!in_array($report, $allowedReports)) {
$report = reset($allowedReports);
}
if (
in_array(
@@ -334,50 +400,6 @@ if (isset($_GET['export'])) {
ob_start();
}
$tool_name = get_lang('Statistics');
$tools = [
get_lang('Courses') => [
'report=courses' => get_lang('CountCours'),
'report=tools' => get_lang('PlatformToolAccess'),
'report=courselastvisit' => get_lang('LastAccess'),
'report=coursebylanguage' => get_lang('CountCourseByLanguage'),
],
get_lang('Users') => [
'report=users' => get_lang('CountUsers'),
'report=recentlogins' => get_lang('Logins'),
'report=logins&amp;type=month' => get_lang('Logins').' ('.get_lang('PeriodMonth').')',
'report=logins&amp;type=day' => get_lang('Logins').' ('.get_lang('PeriodDay').')',
'report=logins&amp;type=hour' => get_lang('Logins').' ('.get_lang('PeriodHour').')',
'report=pictures' => get_lang('CountUsers').' ('.get_lang('UserPicture').')',
'report=logins_by_date' => get_lang('LoginsByDate'),
'report=no_login_users' => get_lang('StatsUsersDidNotLoginInLastPeriods'),
'report=zombies' => get_lang('Zombies'),
'report=users_active' => get_lang('UserStats'),
'report=users_online' => get_lang('UsersOnline'),
'report=invoicing' => get_lang('InvoicingByAccessUrl'),
'report=duplicated_users' => get_lang('DuplicatedUsers'),
'report=duplicated_users_by_mail' => get_lang('DuplicatedUsersByMail'),
],
get_lang('System') => [
'report=activities' => get_lang('ImportantActivities'),
'report=user_session' => get_lang('PortalUserSessionStats'),
'report=courses_usage' => get_lang('CoursesUsage'),
'report=quarterly_report' => get_lang('QuarterlyReport'),
],
get_lang('Social') => [
'report=messagereceived' => get_lang('MessagesReceived'),
'report=messagesent' => get_lang('MessagesSent'),
'report=friends' => get_lang('CountFriends'),
],
get_lang('Session') => [
'report=session_by_date' => get_lang('SessionsByDate'),
],
];
if ('true' === api_get_plugin_setting('lti_provider', 'enabled')) {
$tools[get_lang('Users')]['report=lti_tool_lp'] = get_lang('LearningPathLTI');
}
$course_categories = Statistics::getCourseCategories();
$content = '';
@@ -938,8 +960,16 @@ switch ($report) {
case 'users_active':
$content = '';
if ($validated) {
$startDate = $values['daterange_start'];
$endDate = $values['daterange_end'];
// Validate date inputs strictly. Security::remove_XSS() (used for
// the display value above) does not protect against SQL injection.
$rawStartDate = isset($values['daterange_start']) ? $values['daterange_start'] : '';
$rawEndDate = isset($values['daterange_end']) ? $values['daterange_end'] : '';
$parsedStart = !empty($rawStartDate) ? DateTime::createFromFormat('Y-m-d', $rawStartDate) : false;
$parsedEnd = !empty($rawEndDate) ? DateTime::createFromFormat('Y-m-d', $rawEndDate) : false;
$startDate = (false !== $parsedStart && $parsedStart->format('Y-m-d') === $rawStartDate)
? Database::escape_string($rawStartDate) : '';
$endDate = (false !== $parsedEnd && $parsedEnd->format('Y-m-d') === $rawEndDate)
? Database::escape_string($rawEndDate) : '';
$graph = '<div class="row">';
$graph .= '<div class="col-md-4"><canvas id="canvas1" style="margin-bottom: 20px"></canvas></div>';
@@ -964,7 +994,6 @@ switch ($report) {
$conditions = [];
$extraConditions = '';
if (!empty($startDate) && !empty($endDate)) {
// $extraConditions is already cleaned inside the function getUserListExtraConditions
$extraConditions .= " AND registration_date BETWEEN '$startDate' AND '$endDate' ";
}
+5 -9
View File
@@ -209,9 +209,8 @@ $msg = '';
if (isset($_POST['SubmitAddNewLanguage'])) {
$original_name = $_POST['original_name'];
$english_name = $_POST['english_name'];
$isocode = $_POST['isocode'];
$english_name = str_replace(' ', '_', $english_name);
$english_name = api_replace_dangerous_char($_POST['english_name']);
$isocode = str_replace(' ', '_', $isocode);
$sublanguage_available = $_POST['sub_language_is_visible'];
@@ -298,14 +297,11 @@ if (isset($_GET['action']) && $_GET['action'] == 'definenewsublanguage') {
);
$class = 'add';
$form->addElement('header', '', $text);
$form->addElement('text', 'original_name', get_lang('OriginalName'), 'class="input_titles"');
$form->addRule('original_name', get_lang('ThisFieldIsRequired'), 'required');
$form->addElement('text', 'english_name', get_lang('EnglishName'), 'class="input_titles"');
$form->addRule('english_name', get_lang('ThisFieldIsRequired'), 'required');
$form->addElement('text', 'isocode', get_lang('ISOCode'), 'class="input_titles"');
$form->addRule('isocode', get_lang('ThisFieldIsRequired'), 'required');
$form->addText('original_name', get_lang('OriginalName'));
$form->addText('english_name', get_lang('EnglishName'));
$form->addText('isocode', get_lang('ISOCode'));
$form->addElement('static', null, '&nbsp;', '<i>en, es, fr</i>');
$form->addElement('checkbox', 'sub_language_is_visible', '', get_lang('Visibility'));
$form->addCheckBox('sub_language_is_visible', '', get_lang('Visibility'));
$form->addButtonCreate(get_lang('CreateSubLanguage'), 'SubmitAddNewLanguage');
//$values['original_name'] = $language_details['original_name'].'...'; -> cannot be used because of quickform filtering (freeze)
$values['english_name'] = $language_details['english_name'].'2';
+8 -8
View File
@@ -14,10 +14,15 @@ require_once __DIR__.'/../inc/global.inc.php';
api_protect_admin_script();
$new_language = Security::remove_XSS($_REQUEST['new_language']);
$language_variable = Security::remove_XSS($_REQUEST['variable_language']);
$language_variable = ltrim(
Security::remove_XSS($_REQUEST['variable_language']),
'$'
);
$file_id = intval($_REQUEST['file_id']);
if (isset($new_language) && isset($language_variable) && isset($file_id)) {
$variableIsValid = isset($language_variable) && preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $language_variable);
if (isset($new_language) && $variableIsValid && isset($file_id)) {
$file_language = $language_files_to_load[$file_id].'.inc.php';
$id_language = intval($_REQUEST['id']);
$sub_language_id = intval($_REQUEST['sub']);
@@ -27,12 +32,7 @@ if (isset($new_language) && isset($language_variable) && isset($file_id)) {
$all_file_of_directory = SubLanguageManager::get_all_language_variable_in_file($path_folder);
$return_value = SubLanguageManager::add_file_in_language_directory($path_folder);
//update variable language
// Replace double quotes to avoid parse errors
$new_language = str_replace('"', '\"', $new_language);
// Replace new line signs to avoid parse errors - see #6773
$new_language = str_replace("\n", "\\n", $new_language);
$all_file_of_directory[$language_variable] = "\"".$new_language."\";";
$all_file_of_directory[$language_variable] = $new_language;
$result_array = [];
foreach ($all_file_of_directory as $key_value => $value_info) {
+33 -24
View File
@@ -278,30 +278,33 @@ $form->addGroup($group, 'mail', get_lang('SendMailToNewUser'));
$hideNeverExpiresOpt = api_get_configuration_value('user_hide_never_expire_option');
$lblExpiration = '';
$defaultExpiration = 0;
if ($hideNeverExpiresOpt) {
$lblExpiration = get_lang('ExpirationDate');
$defaultExpiration = 1;
$group = [];
$group[] = $form->createElement('radio', 'radio_expiration_date', get_lang('ExpirationDate'), get_lang('Enabled'), 1);
$group[] = $form->createElement(
'DateTimePicker',
'expiration_date',
null
);
} else {
$form->addElement('radio', 'radio_expiration_date', get_lang('ExpirationDate'), get_lang('NeverExpires'), 0);
$group = [];
$group[] = $form->createElement('radio', 'radio_expiration_date', null, get_lang('Enabled'), 1);
$group[] = $form->createElement(
'DateTimePicker',
'expiration_date',
null,
[
'onchange' => 'javascript: enable_expiration_date();',
]
);
$hideExpirationDate = api_get_configuration_value('user_hide_expiration_date_for_session_admin');
if (!$hideExpirationDate || api_is_platform_admin()) {
if ($hideNeverExpiresOpt && !api_is_platform_admin()) {
$lblExpiration = get_lang('ExpirationDate');
$defaultExpiration = 1;
$group = [];
$group[] = $form->createElement('radio', 'radio_expiration_date', get_lang('ExpirationDate'), get_lang('Enabled'), 1);
$group[] = $form->createElement(
'DateTimePicker',
'expiration_date',
null
);
} else {
$form->addElement('radio', 'radio_expiration_date', get_lang('ExpirationDate'), get_lang('NeverExpires'), 0);
$group = [];
$group[] = $form->createElement('radio', 'radio_expiration_date', null, get_lang('Enabled'), 1);
$group[] = $form->createElement(
'DateTimePicker',
'expiration_date',
null,
[
'onchange' => 'javascript: enable_expiration_date();',
]
);
}
$form->addGroup($group, 'max_member_group', $lblExpiration, null, false);
}
$form->addGroup($group, 'max_member_group', $lblExpiration, null, false);
// Active account or inactive account
$form->addElement('radio', 'active', get_lang('ActiveAccount'), get_lang('Active'), 1);
@@ -397,7 +400,13 @@ if ($form->validate()) {
}
if ($user['radio_expiration_date'] == '1') {
$expiration_date = $user['expiration_date'];
if (!empty($user['expiration_date'])) {
$expiration_date = $user['expiration_date'];
} else {
if (!empty($days)) {
$expiration_date = api_get_local_time('+'.$days.' day');
}
}
} else {
$expiration_date = null;
}
+388
View File
@@ -0,0 +1,388 @@
<?php
/* For licensing terms, see /license.txt */
$cidReset = true;
require_once __DIR__.'/../inc/global.inc.php';
api_protect_admin_script(true);
$this_section = SECTION_PLATFORM_ADMIN;
$tool_name = get_lang('AdvancedUserEdition');
$message = '';
// Secure GET parameters
$parameters = [];
if (!empty($_GET)) {
foreach ($_GET as $key => $value) {
$parameters[$key] = Security::remove_XSS($value);
}
}
$interbreadcrumb[] = ['url' => 'index.php', 'name' => get_lang('PlatformAdmin')];
// Toolbar actions
$toolbarActions = '';
// Filter GET params
$keywordUsername = !empty($_GET['keywordUsername']) ? Security::remove_XSS($_GET['keywordUsername']) : '';
$keywordEmail = !empty($_GET['keywordEmail']) ? Security::remove_XSS($_GET['keywordEmail']) : '';
$keywordFirstname = !empty($_GET['keywordFirstname']) ? Security::remove_XSS($_GET['keywordFirstname']) : '';
$keywordLastname = !empty($_GET['keywordLastname']) ? Security::remove_XSS($_GET['keywordLastname']) : '';
$keywordOfficialCode = !empty($_GET['keywordOfficialCode']) ? Security::remove_XSS($_GET['keywordOfficialCode']) : '';
$keywordStatus = !empty($_GET['keywordStatus']) ? Security::remove_XSS($_GET['keywordStatus']) : '';
// Advanced search form
$form = new FormValidator('advancedSearch', 'get', '', '', [], FormValidator::LAYOUT_HORIZONTAL);
$form->addElement('header', '', get_lang('AdvancedSearch'));
$form->addText('keywordUsername', get_lang('LoginName'), false);
$form->addText('keywordEmail', get_lang('Email'), false);
$form->addText('keywordFirstname', get_lang('FirstName'), false);
$form->addText('keywordLastname', get_lang('LastName'), false);
$form->addText('keywordOfficialCode', get_lang('OfficialCode'), false);
$statusOptions = [
'%' => get_lang('All'),
STUDENT => get_lang('Student'),
COURSEMANAGER => get_lang('Teacher'),
DRH => get_lang('Drh'),
SESSIONADMIN => get_lang('SessionsAdmin'),
PLATFORM_ADMIN => get_lang('Administrator'),
];
$form->addElement('select', 'keywordStatus', get_lang('Profile'), $statusOptions);
$form->setDefaults(
[
'keywordUsername' => $keywordUsername,
'keywordEmail' => $keywordEmail,
'keywordFirstname' => $keywordFirstname,
'keywordLastname' => $keywordLastname,
'keywordOfficialCode' => $keywordOfficialCode,
'keywordStatus' => $keywordStatus,
]
);
$activeGroup = [];
$activeGroup[] = $form->createElement('checkbox', 'keywordActive', '', get_lang('Active'), ['checked' => isset($_GET['keywordActive'])]);
$activeGroup[] = $form->createElement('checkbox', 'keywordInactive', '', get_lang('Inactive'), ['checked' => isset($_GET['keywordInactive'])]);
$form->addGroup($activeGroup, '', get_lang('ActiveAccount'), null, false);
$parameters = array_map(function ($value) {
return Security::remove_XSS($value);
}, $_GET);
$extraUserField = new ExtraField('user');
$returnParams = $extraUserField->addElements(
$form,
0,
[],
true,
false,
[],
[],
$_REQUEST,
false,
true
);
$htmlHeadXtra[] = '<script>
$(function () {
'.$returnParams['jquery_ready_content'].'
})
</script>';
$form->addButtonSearch(get_lang('SearchUsers'), 'filter');
$users = [];
if (isset($_GET['filter'])) {
$users = UserManager::searchUsers($parameters);
}
$fieldSelector = '';
$jqueryReadyContent = '';
if (!empty($users)) {
$extraFields = $extraUserField->get_all(['filter = ?' => 1], 'option_order');
$editableFields = [
'firstname' => get_lang('FirstName'),
'lastname' => get_lang('LastName'),
'email' => get_lang('Email'),
'phone' => get_lang('PhoneNumber'),
'official_code' => get_lang('OfficialCode'),
'status' => get_lang('Profile'),
'active' => get_lang('ActiveAccount'),
'password' => get_lang('Password'),
];
foreach ($extraFields as $field) {
$editableFields[$field['variable']] = ucfirst($field['variable']);
}
$form->addElement('select', 'editableFields', get_lang('FieldsToEdit'), $editableFields, [
'multiple' => 'multiple',
'size' => 7,
]);
$form->addElement('submit', 'filter', get_lang('View'));
}
$tableResult = '';
if (!empty($users)) {
foreach ($users as &$user) {
$userData = api_get_user_info($user['id']);
if ($userData) {
$user = array_merge($user, $userData);
}
$extraFieldValues = new ExtraFieldValue('user');
$userExtraFields = $extraFieldValues->getAllValuesByItem($user['id']);
$formattedExtraFields = [];
foreach ($userExtraFields as $extraField) {
$formattedExtraFields[$extraField['variable']] = $extraField['value'];
}
$user['extra_fields'] = $formattedExtraFields;
}
unset($user);
$selectedFields = $_GET['editableFields'] ?? [];
$filtersUsed = [
'keywordUsername' => 'username',
'keywordEmail' => 'email',
'keywordFirstname' => 'firstname',
'keywordLastname' => 'lastname',
'keywordOfficialCode' => 'official_code',
'keywordStatus' => 'status',
];
foreach ($filtersUsed as $filterKey => $fieldName) {
$getFilterKey = Security::remove_XSS($_GET[$filterKey]);
if (!empty($getFilterKey) && !in_array($fieldName, $selectedFields)) {
$selectedFields[] = $fieldName;
}
}
foreach ($extraFields as $field) {
$extraVariable = Security::remove_XSS($_GET['extra_'.$field['variable']]);
if (is_array($extraVariable)) {
$extraVariable = array_filter($extraVariable, function ($v) {
return $v !== null && $v !== '';
});
}
if (!empty($extraVariable) && !in_array($field['variable'], $selectedFields)) {
$selectedFields[] = $field['variable'];
}
}
$parameters = array_diff_key($parameters, array_flip(['users_direction', 'users_column']));
$userTable = new SortableTable('users', null, null, 0, count($users));
$userTable->set_additional_parameters($parameters);
$userTable->setTotalNumberOfItems(count($users));
$userTable->set_header(0, get_lang('ID'));
$userTable->set_header(1, get_lang('Username'));
$columnIndex = 2;
foreach ($selectedFields as $field) {
$userTable->set_header($columnIndex, ucfirst($field));
$columnIndex++;
}
$userTable->set_header($columnIndex, get_lang('Actions'));
$userTable->addRow([]);
foreach ($users as $user) {
$row = [$user['id'], $user['username']];
foreach ($selectedFields as $field) {
$value = isset($user[$field]) ? htmlspecialchars($user[$field]) : '';
$extraFieldTypes = [];
foreach ($extraFields as $extraField) {
$extraFieldTypes[$extraField['variable']] = $extraField['field_type'];
}
if (isset($user['extra_fields'][$field])) {
$fieldType = $extraFieldTypes[$field] ?? ExtraField::FIELD_TYPE_TEXT;
$value = htmlspecialchars($user['extra_fields'][$field]);
switch ($fieldType) {
case ExtraField::FIELD_TYPE_TEXTAREA:
$row[] = '<textarea name="extra_'.$field.'['.$user['id'].']" class="form-control">'.$value.'</textarea>';
break;
case ExtraField::FIELD_TYPE_SELECT:
$fieldHtml = '<select name="extra_'.$field.'['.$user['id'].']" class="form-control">';
foreach ($extraField['options'] as $option) {
$selected = ($option['option_value'] == $value) ? 'selected' : '';
$fieldHtml .= '<option value="'.$option['option_value'].'" '.$selected.'>'.$option['display_text'].'</option>';
}
$fieldHtml .= '</select>';
$row[] = $fieldHtml;
break;
case ExtraField::FIELD_TYPE_CHECKBOX:
$checked = ($value == '1') ? 'checked' : '';
$row[] = '<input type="checkbox" name="extra_'.$field.'['.$user['id'].']" value="1" '.$checked.'>';
break;
case ExtraField::FIELD_TYPE_RADIO:
$fieldHtml = '';
foreach ($extraField['options'] as $option) {
$checked = ($option['option_value'] == $value) ? 'checked' : '';
$fieldHtml .= '<label><input type="radio" name="extra_'.$field.'['.$user['id'].']" value="'.$option['option_value'].'" '.$checked.'> '.$option['display_text'].'</label>';
}
$row[] = $fieldHtml;
break;
case ExtraField::FIELD_TYPE_TAG:
$extraTagField = $extraUserField->get_handler_field_info_by_field_variable($field);
$formattedValue = UserManager::get_user_tags_to_string(
$user['id'],
$extraTagField['id'],
false
);
$row[] = '<input type="text" name="extra_'.$field.'['.$user['id'].']" value="'.$formattedValue.'" class="form-control">'.
'<small>'.get_lang('KeywordTip').'</small>';
break;
case ExtraField::FIELD_TYPE_DOUBLE_SELECT:
if (is_array($value) && isset($value["extra_{$field}"]) && isset($value["extra_{$field}_second"])) {
$formattedValue = $value["extra_{$field}"].','.$value["extra_{$field}_second"];
} else {
$formattedValue = '';
}
$row[] = '<input type="text" name="extra_'.$field.'['.$user['id'].']" value="'.$formattedValue.'" class="form-control">'.
'<small>'.get_lang('KeywordTip').'</small>';
break;
default:
$row[] = '<input type="text" name="extra_'.$field.'['.$user['id'].']" value="'.$value.'" class="form-control">';
break;
}
} else {
if ($field === 'password') {
$row[] = '<input type="password" name="'.$field.'['.$user['id'].']" value="" class="form-control" placeholder="'.get_lang('Password').'">';
} elseif ($field === 'status') {
$statusOptions = [
STUDENT => get_lang('Student'),
COURSEMANAGER => get_lang('Teacher'),
DRH => get_lang('Drh'),
SESSIONADMIN => get_lang('SessionsAdmin'),
PLATFORM_ADMIN => get_lang('Administrator'),
];
$select = '<select name="status['.$user['id'].']" class="form-control">';
foreach ($statusOptions as $key => $label) {
$selected = ($key == $user['status']) ? 'selected' : '';
$select .= '<option value="'.$key.'" '.$selected.'>'.$label.'</option>';
}
$select .= '</select>';
$row[] = $select;
} elseif ($field === 'active') {
$checkedActive = ($user['active'] == 1) ? 'checked' : '';
$checkedInactive = ($user['active'] == 0) ? 'checked' : '';
$row[] = '<label><input type="radio" name="active['.$user['id'].']" value="1" '.$checkedActive.'> '.get_lang('Active').'</label>
<label><input type="radio" name="active['.$user['id'].']" value="0" '.$checkedInactive.'> '.get_lang('Inactive').'</label>';
} else {
$row[] = '<input type="text" name="'.$field.'['.$user['id'].']" value="'.$value.'" class="form-control">';
}
}
}
$row[] = '<button class="btn btn-primary saveUser" data-user-id="'.$user['id'].'">'.get_lang('SaveOne').'</button>';
$userTable->addRow($row);
}
$tableResult = $userTable->return_table();
}
$htmlHeadXtra[] = '<script>
$(document).ready(function() {
function getUserData(userId) {
let userData = { user_id: userId };
$("input[name$=\'[" + userId + "]\'], select[name$=\'[" + userId + "]\'], textarea[name$=\'[" + userId + "]\']").each(function() {
let fieldName = $(this).attr("name").replace("[" + userId + "]", "");
userData[fieldName] = $(this).val();
});
$("input[type=\'radio\'][name$=\'[" + userId + "]\']:checked").each(function() {
let fieldName = $(this).attr("name").replace("[" + userId + "]", "");
userData[fieldName] = $(this).val();
});
$("input[type=\'checkbox\'][name$=\'[" + userId + "]\']:checked").each(function() {
let fieldName = $(this).attr("name").replace("[" + userId + "]", "");
userData[fieldName] = "1";
});
$("input[name^=\'extra_[" + userId + "]\'], select[name^=\'extra_[" + userId + "]\'], textarea[name^=\'extra_[" + userId + "]\']").each(function() {
let fieldName = $(this).attr("name").replace("extra_[" + userId + "]", "extra_");
if ($(this).hasClass("tags-input")) {
userData[fieldName] = $(this).val().split(",");
}
else if ($(this).hasClass("doubleselect-input")) {
let values = $(this).val().split(",");
if (values.length === 2) {
userData[fieldName] = values[0];
userData[fieldName + "_second"] = values[1];
}
}
else {
userData[fieldName] = $(this).val();
}
});
return userData;
}
$(".saveUser").click(function() {
let userId = $(this).data("user-id");
if (!userId) {
return;
}
let userData = getUserData(userId);
$.post("'.api_get_path(WEB_AJAX_PATH).'user_manager.ajax.php", {
a: "update_users",
users: JSON.stringify([userData])
}, function(response) {
alert(response.message);
}, "json");
});
$("#saveAll").click(function() {
let usersData = [];
$(".saveUser").each(function() {
let userId = $(this).data("user-id");
let userData = getUserData(userId);
if (userData) usersData.push(userData);
});
if (usersData.length === 0) {
return;
}
$.post("'.api_get_path(WEB_AJAX_PATH).'user_manager.ajax.php", {
a: "update_users",
users: JSON.stringify(usersData)
}, function(response) {
alert(response.message);
}, "json");
});
});
</script>';
$formContent = $form->returnForm();
// Render page
$tpl = new Template($tool_name);
$tpl->assign('actions', $toolbarActions);
$tpl->assign('message', $message);
$tpl->assign('content', $formContent.$fieldSelector.$tableResult.(!empty($users) ? '<button class="btn btn-success" id="saveAll">'.get_lang('SaveAll').'</button>' : ''));
$tpl->display_one_col_template();
+29 -25
View File
@@ -12,7 +12,7 @@ $this_section = SECTION_PLATFORM_ADMIN;
api_protect_admin_script(true);
$user_id = isset($_GET['user_id']) ? (int) $_GET['user_id'] : (int) $_POST['user_id'];
api_protect_super_admin($user_id, null, true);
api_protect_super_admin($user_id, null, true !== api_get_configuration_value('disallow_session_admin_edit_users'));
$is_platform_admin = api_is_platform_admin() ? 1 : 0;
$userInfo = api_get_user_info($user_id);
$userEntity = api_get_user_entity($user_id);
@@ -320,31 +320,34 @@ $form->addElement('label', get_lang('RegistrationDate'), $date);
$defaultExpiration = 0;
if (!$user_data['platform_admin']) {
$hideNeverExpiresOpt = api_get_configuration_value('user_hide_never_expire_option');
$hideExpirationDate = api_get_configuration_value('user_hide_expiration_date_for_session_admin');
$lblExpiration = '';
if ($hideNeverExpiresOpt) {
$lblExpiration = get_lang('ExpirationDate');
$defaultExpiration = 1;
$group = [];
$group[] = $form->createElement('radio', 'radio_expiration_date', get_lang('ExpirationDate'), get_lang('Enabled'), 1);
$group[] = $form->createElement(
'DateTimePicker',
'expiration_date',
null
);
} else {
$form->addElement('radio', 'radio_expiration_date', get_lang('ExpirationDate'), get_lang('NeverExpires'), 0);
$group = [];
$group[] = $form->createElement('radio', 'radio_expiration_date', null, get_lang('Enabled'), 1);
$group[] = $form->createElement(
'DateTimePicker',
'expiration_date',
null,
[
'onchange' => 'javascript: enable_expiration_date();',
]
);
if (!$hideExpirationDate || api_is_platform_admin()) {
if ($hideNeverExpiresOpt && !api_is_platform_admin()) {
$lblExpiration = get_lang('ExpirationDate');
$defaultExpiration = 1;
$group = [];
$group[] = $form->createElement('radio', 'radio_expiration_date', get_lang('ExpirationDate'), get_lang('Enabled'), 1);
$group[] = $form->createElement(
'DateTimePicker',
'expiration_date',
null
);
} else {
$form->addElement('radio', 'radio_expiration_date', get_lang('ExpirationDate'), get_lang('NeverExpires'), 0);
$group = [];
$group[] = $form->createElement('radio', 'radio_expiration_date', null, get_lang('Enabled'), 1);
$group[] = $form->createElement(
'DateTimePicker',
'expiration_date',
null,
[
'onchange' => 'javascript: enable_expiration_date();',
]
);
}
$form->addGroup($group, 'max_member_group', $lblExpiration, null, false);
}
$form->addGroup($group, 'max_member_group', $lblExpiration, null, false);
// Active account or inactive account
$form->addElement('radio', 'active', get_lang('ActiveAccount'), get_lang('Active'), 1);
@@ -421,7 +424,8 @@ $expiration_date = $user_data['expiration_date'];
if (empty($expiration_date)) {
$user_data['radio_expiration_date'] = 0;
$user_data['expiration_date'] = api_get_local_time();
$days = api_get_setting('account_valid_duration');
$user_data['expiration_date'] = api_get_local_time('+'.$days.' day');
} else {
$user_data['radio_expiration_date'] = 1;
$user_data['expiration_date'] = api_get_local_time($expiration_date);
+253 -152
View File
@@ -21,12 +21,8 @@ set_time_limit(0);
/**
* Create directories and subdirectories for backup import csv data.
*
* @param null $path
*
* @return string|null
*/
function createDirectory($path = null)
function createDirectory(?string $path = null): ?string
{
if ($path == null) {
return $path;
@@ -54,8 +50,10 @@ Options -Indexes';
$fp = fopen($data.'/.htaccess', 'w');
if ($fp) {
fwrite($fp, $block);
fclose($fp);
} else {
error_log("Failed to open .htaccess file in $data for writing.");
}
fclose($fp);
}
}
}
@@ -63,13 +61,7 @@ Options -Indexes';
return $data;
}
/**
* @param array $users
* @param bool $checkUniqueEmail
*
* @return array
*/
function validate_data($users, $checkUniqueEmail = false)
function validate_data(array $users, bool $checkUniqueEmail = false): array
{
global $defined_auth_sources;
$usernames = [];
@@ -94,7 +86,7 @@ function validate_data($users, $checkUniqueEmail = false)
}
}
$username = isset($user['UserName']) ? $user['UserName'] : '';
$username = $user['UserName'] ?? '';
// 2. Check username, first, check whether it is empty.
if (!UserManager::is_username_empty($username)) {
// 2.1. Check whether username is too long.
@@ -204,14 +196,12 @@ function validate_data($users, $checkUniqueEmail = false)
/**
* Add missing user-information (which isn't required, like password, username etc).
*
* @param array $user
*/
function complete_missing_data($user)
function complete_missing_data(array $user): array
{
global $purification_option_for_usernames;
$username = isset($user['UserName']) ? $user['UserName'] : '';
$username = $user['UserName'] ?? '';
// 1. Create a username if necessary.
if (UserManager::is_username_empty($username)) {
@@ -265,17 +255,12 @@ function complete_missing_data($user)
/**
* Save the imported data.
*
* @param array $users List of users
* @param bool $sendMail
* @uses global $inserted_in_course, which returns the list of courses the user was inserted in
*
* @uses \global variable $inserted_in_course, which returns the list of
* courses the user was inserted in
* @return array The $users array, with 'message' and (for reused users) 'id' set
*/
function save_data(
$users,
$sendMail = false,
$targetFolder = null
) {
function save_data(array $users, bool $sendMail = false, ?string $targetFolder = null): array
{
global $inserted_in_course, $extra_fields;
// Not all scripts declare the $inserted_in_course array (although they should).
@@ -290,20 +275,112 @@ function save_data(
}
$usergroup = new UserGroup();
if (is_array($users)) {
$efo = new ExtraFieldOption('user');
$efo = new ExtraFieldOption('user');
$optionsByField = [];
$optionsByField = [];
foreach ($users as &$user) {
if ($user['has_error']) {
$userError[] = $user;
continue;
// which extrafield variable are we validating on?
$uniqueField = api_get_configuration_value('extra_field_to_validate_on_user_registration');
foreach ($users as &$user) {
if ($user['has_error']) {
$userError[] = $user;
continue;
}
$returnMessage = '';
$user_id = null;
// 1) If the CSV row has that unique extrafield, try to look up an existing user
if (!empty($uniqueField) && !empty($user[$uniqueField])) {
// pass true to getUserId mode
$existing = UserManager::isExtraFieldValueUniquePerUrl($user[$uniqueField], true);
if ($existing !== null) {
// existing user found → reuse
$user_id = $existing;
$returnMessage = Display::return_message(
sprintf(
get_lang('ExistingUserWithSameExtraFieldValue'),
$uniqueField,
$existing
),
'info'
);
$userInfo = api_get_user_info($user_id);
$firstName = $user['FirstName'] ?? $userInfo['firstname'];
$lastName = $user['LastName'] ?? $userInfo['lastname'];
$userName = $userInfo['username'];
if (!empty($user['UserName'])) {
$userName = $user['UserName'];
}
$changePassMethod = 0;
$password = null;
$authSource = $userInfo['auth_source'];
if (isset($user['Password'])) {
$changePassMethod = 2;
$password = $user['Password'];
}
if (isset($user['AuthSource']) && $user['AuthSource'] != $authSource) {
$authSource = $user['AuthSource'];
$changePassMethod = 3;
}
$email = $user['Email'] ?? $userInfo['email'];
$status = $user['Status'] ?? $userInfo['status'];
$officialCode = $user['OfficialCode'] ?? $userInfo['official_code'];
$phone = $user['PhoneNumber'] ?? $userInfo['phone'];
$pictureUrl = $user['PictureUri'] ?? $userInfo['picture_uri'];
$expirationDate = $user['ExpiryDate'] ?? $userInfo['expiration_date'];
// Fix wrong date in DB for old users (sometimes would be expiration_date = '9999-12-31 ********') where it should be null
if (substr($expirationDate, 0, 4) === '9999') {
$expirationDate = null;
}
$active = $userInfo['active'];
if (isset($user['Active'])) {
$user['Active'] = (int) $user['Active'];
if (-1 === $user['Active']) {
$user['Active'] = 0;
}
$active = $user['Active'];
}
$creatorId = $userInfo['creator_id'];
$hrDeptId = $userInfo['hr_dept_id'];
$language = $user['Language'] ?? $userInfo['language'];
UserManager::update_user(
$user_id,
$firstName,
$lastName,
$userName,
$password,
$authSource,
$email,
$status,
$officialCode,
$phone,
$pictureUrl,
$expirationDate,
$active,
$creatorId,
$hrDeptId,
$extra,
$language,
'',
false,
$changePassMethod
);
}
}
// 2) If not found, go through normal creation
if ($user_id === null) {
// fill in missing fields, generate password, etc.
$user = complete_missing_data($user);
$user['Status'] = api_status_key($user['Status']);
$redirection = isset($user['Redirection']) ? $user['Redirection'] : '';
$redirection = $user['Redirection'] ?? '';
$user_id = UserManager::create_user(
$user['FirstName'],
@@ -334,69 +411,84 @@ function save_data(
if ($user_id) {
$returnMessage = Display::return_message(get_lang('UserAdded'), 'success');
if (isset($user['Courses']) && is_array($user['Courses'])) {
foreach ($user['Courses'] as $course) {
if (CourseManager::course_exists($course)) {
$result = CourseManager::subscribeUser($user_id, $course, $user['Status']);
if ($result) {
$course_info = api_get_course_info($course);
$inserted_in_course[$course] = $course_info['title'];
}
}
}
}
if (isset($user['Sessions']) && is_array($user['Sessions'])) {
foreach ($user['Sessions'] as $sessionId) {
$sessionInfo = api_get_session_info($sessionId);
if (!empty($sessionInfo)) {
SessionManager::subscribeUsersToSession(
$sessionId,
[$user_id],
SESSION_VISIBLE_READ_ONLY,
false
);
}
}
}
if (!empty($user['ClassId'])) {
$classId = explode('|', trim($user['ClassId']));
foreach ($classId as $id) {
$usergroup->subscribe_users_to_usergroup($id, [$user_id], false);
}
}
// We are sure that the extra field exists.
foreach ($extra_fields as $extras) {
if (!isset($user[$extras[1]])) {
continue;
}
$key = $extras[1];
$value = $user[$key];
if (!array_key_exists($key, $optionsByField)) {
$optionsByField[$key] = $efo->getOptionsByFieldVariable($key);
}
/** @var ExtraFieldOptions $option */
foreach ($optionsByField[$key] as $option) {
if ($option->getDisplayText() === $value) {
$value = $option->getValue();
}
}
UserManager::update_extra_field_value($user_id, $key, $value);
}
$userSaved[] = $user;
} else {
$returnMessage = Display::return_message(get_lang('Error'), 'warning');
$returnMessage = Display::return_message(get_lang('Error'), 'error');
$userWarning[] = $user;
$user['message'] = $returnMessage;
continue;
}
$user['message'] = $returnMessage;
}
// 3) At this point $user_id is either reused or newly created.
// Enroll in courses:
if (isset($user['Courses']) && is_array($user['Courses'])) {
foreach ($user['Courses'] as $course) {
if (CourseManager::course_exists($course)) {
$result = CourseManager::subscribeUser($user_id, $course, $user['Status']);
if ($result) {
$info = api_get_course_info($course);
$inserted_in_course[$course] = $info['title'];
}
}
}
}
// 4) Enroll in sessions:
if (isset($user['Sessions']) && is_array($user['Sessions'])) {
foreach ($user['Sessions'] as $sessionId) {
$sessionInfo = api_get_session_info($sessionId);
if (!empty($sessionInfo)) {
SessionManager::subscribeUsersToSession(
$sessionId,
[$user_id],
SESSION_VISIBLE_READ_ONLY,
false
);
}
}
}
// 5) Subscribe to usergroups:
if (!empty($user['ClassId'])) {
$classIds = explode('|', trim($user['ClassId']));
foreach ($classIds as $id) {
$usergroup->subscribe_users_to_usergroup($id, [$user_id], false);
}
}
// 6) Update extrafield values (for newly created or even reused users):
foreach ($extra_fields as $extras) {
$fieldVar = $extras[1];
$matchedKey = null;
foreach ($user as $colName => $colVal) {
if (strtolower($colName) === strtolower($fieldVar)) {
$matchedKey = $colName;
break;
}
}
if ($matchedKey === null) {
continue;
}
$value = $user[$matchedKey];
if (!array_key_exists($matchedKey, $optionsByField)) {
$optionsByField[$matchedKey] = $efo->getOptionsByFieldVariable($matchedKey);
}
/** @var ExtraFieldOptions $option */
foreach ($optionsByField[$matchedKey] as $option) {
if ($option->getDisplayText() === $value) {
$value = $option->getValue();
break;
}
}
UserManager::update_extra_field_value($user_id, $matchedKey, $value);
}
// 7) Record success
$user['id'] = $user_id;
$user['message'] = $returnMessage;
$userSaved[] = $user;
}
// Save with success, error and warning users
@@ -417,12 +509,12 @@ function save_data(
$csv_content[] = $csv_row;
foreach ($userSaved as $userItem) {
$csv_row = [];
$csv_row[] = isset($userItem['id']) ? $userItem['id'] : '';
$csv_row[] = isset($userItem['FirstName']) ? $userItem['FirstName'] : '';
$csv_row[] = isset($userItem['LastName']) ? $userItem['LastName'] : '';
$csv_row[] = isset($userItem['Status']) ? $userItem['Status'] : '';
$csv_row[] = isset($userItem['Email']) ? $userItem['Email'] : '';
$csv_row[] = isset($userItem['UserName']) ? $userItem['UserName'] : '';
$csv_row[] = $userItem['id'] ?? '';
$csv_row[] = $userItem['FirstName'] ?? '';
$csv_row[] = $userItem['LastName'] ?? '';
$csv_row[] = $userItem['Status'] ?? '';
$csv_row[] = $userItem['Email'] ?? '';
$csv_row[] = $userItem['UserName'] ?? '';
$csv_row[] = isset($userItem['message']) ? strip_tags($userItem['message']) : '';
$csv_content[] = $csv_row;
}
@@ -436,12 +528,12 @@ function save_data(
$csv_content[] = $csv_row;
foreach ($userError as $userItem) {
$csv_row = [];
$csv_row[] = isset($userItem['id']) ? $userItem['id'] : '';
$csv_row[] = isset($userItem['FirstName']) ? $userItem['FirstName'] : '';
$csv_row[] = isset($userItem['LastName']) ? $userItem['LastName'] : '';
$csv_row[] = isset($userItem['Status']) ? $userItem['Status'] : '-';
$csv_row[] = isset($userItem['Email']) ? $userItem['Email'] : '';
$csv_row[] = isset($userItem['UserName']) ? $userItem['UserName'] : '';
$csv_row[] = $userItem['id'] ?? '';
$csv_row[] = $userItem['FirstName'] ?? '';
$csv_row[] = $userItem['LastName'] ?? '';
$csv_row[] = $userItem['Status'] ?? '-';
$csv_row[] = $userItem['Email'] ?? '';
$csv_row[] = $userItem['UserName'] ?? '';
$csv_row[] = isset($userItem['message']) ? strip_tags($userItem['message']) : '';
$csv_content[] = $csv_row;
}
@@ -455,12 +547,12 @@ function save_data(
$csv_content[] = $csv_row;
foreach ($userWarning as $userItem) {
$csv_row = [];
$csv_row[] = isset($userItem['id']) ? $userItem['id'] : '';
$csv_row[] = isset($userItem['FirstName']) ? $userItem['FirstName'] : '';
$csv_row[] = isset($userItem['LastName']) ? $userItem['LastName'] : '';
$csv_row[] = isset($userItem['Status']) ? $userItem['Status'] : '';
$csv_row[] = isset($userItem['Email']) ? $userItem['Email'] : '';
$csv_row[] = isset($userItem['UserName']) ? $userItem['UserName'] : '';
$csv_row[] = $userItem['id'] ?? '';
$csv_row[] = $userItem['FirstName'] ?? '';
$csv_row[] = $userItem['LastName'] ?? '';
$csv_row[] = $userItem['Status'] ?? '';
$csv_row[] = $userItem['Email'] ?? '';
$csv_row[] = $userItem['UserName'] ?? '';
$csv_row[] = isset($userItem['message']) ? strip_tags($userItem['message']) : '';
$csv_content[] = $csv_row;
}
@@ -474,12 +566,8 @@ function save_data(
/**
* Save array to a specific file.
*
* @param array $data
* @param $file
* @param string $enclosure
*/
function saveCsvFile($data = [], $file = 'example', $enclosure = '"')
function saveCsvFile(array $data = [], string $file = 'example', string $enclosure = '"')
{
$filePath = $file.'.csv';
$stream = fopen($filePath, 'w');
@@ -495,21 +583,15 @@ function saveCsvFile($data = [], $file = 'example', $enclosure = '"')
$writer->writeItem($item);
}
$writer->finish();
return null;
}
/**
* @param array $users
* @param string $fileName
* @param int $sendEmail
* @param bool $checkUniqueEmail
* @param bool $resumeImport
*
* @return array
*/
function parse_csv_data($users, $fileName, $sendEmail = 0, $checkUniqueEmail = true, $resumeImport = false)
{
function parse_csv_data(
array $users,
string $fileName,
int $sendEmail = 0,
bool $checkUniqueEmail = true,
bool $resumeImport = false
): array {
$usersFromOrigin = $users;
$allowRandom = api_get_configuration_value('generate_random_login');
if ($allowRandom) {
@@ -559,13 +641,13 @@ function parse_csv_data($users, $fileName, $sendEmail = 0, $checkUniqueEmail = t
}
// Lastname is needed.
if (!isset($user['LastName']) || (isset($user['LastName']) && empty($user['LastName']))) {
if ((empty($user['LastName']))) {
unset($users[$index]);
continue;
}
// FirstName is needed.
if (!isset($user['FirstName']) || (isset($user['FirstName']) && empty($user['FirstName']))) {
if ((empty($user['FirstName']))) {
unset($users[$index]);
continue;
}
@@ -573,6 +655,11 @@ function parse_csv_data($users, $fileName, $sendEmail = 0, $checkUniqueEmail = t
$users[$index] = $user;
}
$users = array_map(
fn ($user) => Security::remove_XSS($user),
$users
);
$globalCounter = $counter;
if (!empty($importData)) {
$globalCounter = $importData['counter'] + $counter;
@@ -601,7 +688,7 @@ function parse_csv_data($users, $fileName, $sendEmail = 0, $checkUniqueEmail = t
*
* @return array All user information read from the file
*/
function parse_xml_data($file, $sendEmail = 0, $checkUniqueEmail = true)
function parse_xml_data(string $file, $sendEmail = 0, $checkUniqueEmail = true): array
{
$crawler = Import::xml($file);
$crawler = $crawler->filter('Contacts > Contact ');
@@ -631,12 +718,7 @@ function parse_xml_data($file, $sendEmail = 0, $checkUniqueEmail = true)
return $array;
}
/**
* @param array $users
* @param bool $sendMail
* @param string $targetFolder
*/
function processUsers(&$users, $sendMail, $targetFolder = null)
function processUsers(array &$users, bool $sendMail, ?string $targetFolder = null)
{
$users = save_data($users, $sendMail, $targetFolder);
@@ -703,10 +785,10 @@ if (isset($_POST['formSent']) && $_POST['formSent'] && $_FILES['import_file']['s
$allowed_file_mimetype = ['csv', 'xml'];
$error_kind_file = true;
$checkUniqueEmail = isset($_POST['check_unique_email']) ? $_POST['check_unique_email'] : null;
$sendMail = $_POST['sendMail'] ? true : false;
$resume = isset($_POST['resume_import']) ? true : false;
$askNewPassword = isset($_POST['ask_new_password']) ? true : false;
$checkUniqueEmail = $_POST['check_unique_email'] ?? false;
$sendMail = (bool) $_POST['sendMail'];
$resume = isset($_POST['resume_import']);
$askNewPassword = isset($_POST['ask_new_password']);
$uploadInfo = pathinfo($_FILES['import_file']['name']);
$ext_import_file = $uploadInfo['extension'];
$targetFolder = null;
@@ -722,7 +804,9 @@ if (isset($_POST['formSent']) && $_POST['formSent'] && $_FILES['import_file']['s
$targetFolder = api_get_configuration_value('root_sys').'app/cache/backup/import_users';
$targetFolder .= DIRECTORY_SEPARATOR.$userId.DIRECTORY_SEPARATOR.$today;
$targetFolder = createDirectory($targetFolder).DIRECTORY_SEPARATOR;
$originalFile = $targetFolder.$_FILES['import_file']['name'];
$cleanFileName = api_replace_dangerous_char($_FILES['import_file']['name']);
$cleanFileName = disable_dangerous_file($cleanFileName);
$originalFile = $targetFolder.$cleanFileName;
// save original file
if (!file_exists($originalFile)) {
touch($originalFile);
@@ -731,9 +815,28 @@ if (isset($_POST['formSent']) && $_POST['formSent'] && $_FILES['import_file']['s
Session::erase('user_import_data_'.$userId);
$users = Import::csvToArray($_FILES['import_file']['tmp_name']);
$uniqueField = api_get_configuration_value('extra_field_to_validate_on_user_registration');
if (!empty($uniqueField) && !empty($users)) {
$firstRow = reset($users);
$csvHeader = array_keys($firstRow);
$csvHeaderLower = array_map('trim', array_map('strtolower', $csvHeader));
if (!in_array($uniqueField, $csvHeaderLower, true)) {
Display::addFlash(
Display::return_message(
sprintf('The column "%s" is required in the CSV for this platform', $uniqueField),
'error'
)
);
header('Location: '.api_get_self());
exit;
}
}
$users = parse_csv_data(
$users,
$_FILES['import_file']['name'],
$cleanFileName,
$sendMail,
$checkUniqueEmail,
$resume
@@ -803,13 +906,13 @@ if (!empty($importData)) {
}
$formContinue->addHeader($label);
if (isset($importData['filename'])) {
$formContinue->addLabel(get_lang('File'), $importData['filename'] ?? '');
$formContinue->addLabel(get_lang('File'), $importData['filename'] ?: '');
}
$resumeStop = true;
if ($isResume) {
$totalUsers = isset($importData['complete_list']) ? count($importData['complete_list']) : 0;
$counter = isset($importData['counter']) ? $importData['counter'] : 0;
$counter = $importData['counter'] ?? 0;
$bar = '';
if (!empty($totalUsers)) {
$bar = Display::bar_progress($counter / $totalUsers * 100);
@@ -837,7 +940,7 @@ if (!empty($importData)) {
if ($isResume) {
$resumeStop = $importData['counter'] >= count($importData['complete_list']);
if ($resumeStop == false) {
if (!$resumeStop) {
$formContinue->addButtonImport(get_lang('ContinueImport'), 'import_continue');
}
}
@@ -911,7 +1014,7 @@ $form->addElement(
get_lang('ResumeImport')
);
if (api_get_configuration_value('force_renew_password_at_first_login') == true) {
if (api_get_configuration_value('force_renew_password_at_first_login')) {
$form->addElement(
'checkbox',
'ask_new_password',
@@ -927,9 +1030,7 @@ $defaults['sendMail'] = 0;
$defaults['file_type'] = 'csv';
$extraSettings = api_get_configuration_value('user_import_settings');
if (!empty($extraSettings) && isset($extraSettings['options']) &&
isset($extraSettings['options']['send_mail_default_option'])
) {
if (isset($extraSettings['options']['send_mail_default_option']) && !empty($extraSettings)) {
$defaults['sendMail'] = $extraSettings['options']['send_mail_default_option'];
}
+17 -3
View File
@@ -155,6 +155,7 @@ function trimVariables()
'keyword_username',
'keyword_email',
'keyword_officialcode',
'keyword_phone',
];
foreach ($filterVariables as $variable) {
@@ -235,6 +236,7 @@ function prepare_user_sql_query($getCount)
'keyword_username',
'keyword_email',
'keyword_officialcode',
'keyword_phone',
'keyword_status',
'keyword_active',
'keyword_inactive',
@@ -264,7 +266,8 @@ function prepare_user_sql_query($getCount)
concat(u.lastname,' ',u.firstname) LIKE '$keywordFiltered' OR
u.username LIKE '$keywordFiltered' OR
u.official_code LIKE '$keywordFiltered' OR
u.email LIKE '$keywordFiltered'
u.email LIKE '$keywordFiltered' OR
u.phone LIKE '$keywordFiltered'
)
";
} elseif (isset($keywordListValues) && !empty($keywordListValues)) {
@@ -308,6 +311,9 @@ function prepare_user_sql_query($getCount)
if (!empty($keywordListValues['keyword_officialcode'])) {
$sql .= " AND u.official_code LIKE '".Database::escape_string("%".$keywordListValues['keyword_officialcode']."%")."' ";
}
if (!empty($keywordListValues['keyword_phone'])) {
$sql .= " AND u.phone LIKE '".Database::escape_string("%".$keywordListValues['keyword_phone']."%")."' ";
}
$sql .= " $keyword_admin $keyword_extra_value ";
@@ -643,8 +649,12 @@ function modify_filter($user_id, $url_params, $row)
if (api_is_platform_admin(true)) {
$editProfileUrl = Display::getProfileEditionLink($user_id, true);
if (!$user_is_anonymous &&
api_global_admin_can_edit_admin($user_id, null, true)
if (!$user_is_anonymous
&& api_global_admin_can_edit_admin(
$user_id,
null,
true !== api_get_configuration_value('disallow_session_admin_edit_users')
)
) {
$result .= '<a href="'.$editProfileUrl.'">'.
Display::return_icon(
@@ -985,6 +995,9 @@ if (isset($_GET['keyword'])) {
$parameters['keyword_email'] = Security::remove_XSS($_GET['keyword_email']);
$parameters['keyword_officialcode'] = Security::remove_XSS($_GET['keyword_officialcode']);
$parameters['keyword_status'] = Security::remove_XSS($_GET['keyword_status']);
if (isset($_GET['keyword_phone'])) {
$parameters['keyword_phone'] = Security::remove_XSS($_GET['keyword_phone']);
}
if (isset($_GET['keyword_active'])) {
$parameters['keyword_active'] = Security::remove_XSS($_GET['keyword_active']);
}
@@ -1014,6 +1027,7 @@ $form->addText('keyword_lastname', get_lang('LastName'), false);
$form->addText('keyword_username', get_lang('LoginName'), false);
$form->addText('keyword_email', get_lang('Email'), false);
$form->addText('keyword_officialcode', get_lang('OfficialCode'), false);
$form->addText('keyword_phone', get_lang('Phone'), false);
$classId = isset($_REQUEST['class_id']) && !empty($_REQUEST['class_id']) ? (int) $_REQUEST['class_id'] : 0;
$options = [];
+9 -1
View File
@@ -37,6 +37,8 @@ if (isset($_REQUEST['load_ajax'])) {
}
$user_id = (int) $_REQUEST['user_id'];
$new_course_list = SessionManager::get_course_list_by_session_id($new_session_id);
$coursesInOldSession = SessionManager::get_course_list_by_session_id($origin_session_id);
$countCoursesInOldSession = count($coursesInOldSession);
$course_founded = false;
foreach ($new_course_list as $course_item) {
@@ -55,13 +57,14 @@ if (isset($_REQUEST['load_ajax'])) {
// Check if the same course exist in the session destination
if ($course_founded) {
$result = SessionManager::get_users_by_session($new_session_id);
$subscribedToNew = false;
if (empty($result) || !in_array($user_id, array_keys($result))) {
if ($debug) {
echo 'User added to the session';
}
// Registering user to the new session
if ($update_database) {
SessionManager::subscribeUsersToSession(
$subscribedToNew = SessionManager::subscribeUsersToSession(
$new_session_id,
[$user_id],
false,
@@ -80,6 +83,11 @@ if (isset($_REQUEST['load_ajax'])) {
$update_database,
$debug
);
// If there is only one course in the old session, remove the user from the old session (otherwise leads to confusion)
if ($update_database && $subscribedToNew && $countCoursesInOldSession === 1) {
SessionManager::unsubscribe_user_from_session($origin_session_id, $user_id);
echo get_lang('UserUnsubscribedFromOldSessionAsThereWasOnlyOneCourse');
}
} else {
echo get_lang('CourseDoesNotExistInThisSession');
}
+1 -1
View File
@@ -951,7 +951,7 @@ if (!empty($_GET['remind_inactive'])) {
if (empty($_GET['origin']) or $_GET['origin'] !== 'learnpath') {
// We are not in the learning path
Display::display_header($nameTools, get_lang('Announcements'));
Display::display_header($nameTools, 'Announcements');
}
// Tool introduction
+1 -1
View File
@@ -24,7 +24,7 @@
$("#comment-popup-save").on("click", function() {
var comment = $("#txt-comment").val();
if (comment == '') {
alert('<?php echo get_lang('ProvideACommentFirst'); ?>');
alert('<?php echo addslashes(get_lang('ProvideACommentFirst')); ?>');
return false;
}
var selected = $("#comment-selected").val();
+9 -1
View File
@@ -649,7 +649,12 @@ class AttendanceController
// Get data table
$data_table = [];
$head_table = ['#', get_lang('Name')];
$addOfficialCode = api_get_configuration_value('attendance_add_official_code');
if ($addOfficialCode) {
$head_table = ['#', get_lang('OfficialCode'), get_lang('Name')];
} else {
$head_table = ['#', get_lang('Name')];
}
foreach ($data_array['attendant_calendar'] as $class_day) {
$labelDuration = !empty($class_day['duration']) ? get_lang('Duration').' : '.$class_day['duration'] : '';
$head_table[] =
@@ -667,6 +672,9 @@ class AttendanceController
$cols = 1;
$result = [];
$result['count'] = $count;
if ($addOfficialCode) {
$result['official_code'] = $user['official_code'];
}
$result['full_name'] = api_get_person_name($user['firstname'], $user['lastname']);
foreach ($data_array['attendant_calendar'] as $class_day) {
if ($class_day['done_attendance'] == 1) {
+11 -1
View File
@@ -210,6 +210,11 @@ if (api_is_allowed_to_edit(null, true) ||
});
</script>
<?php $addOfficialCode = api_get_configuration_value('attendance_add_official_code');
$headerOfficialCode = '';
if ($addOfficialCode) {
$headerOfficialCode = '<th width="100px">'.get_lang('OfficialCode').'</th>';
} ?>
<form method="post" action="index.php?action=attendance_sheet_add&<?php echo api_get_cidreq().$param_filter; ?>&attendance_id=<?php echo $attendance_id; ?>" >
<div class="attendance-sheet-content" style="width:100%;background-color:#E1E1E1;margin-top:20px;">
<div class="divTableWithFloatingHeader attendance-users-table" style="width:45%;float:left;margin:0px;padding:0px;">
@@ -218,6 +223,7 @@ if (api_is_allowed_to_edit(null, true) ||
<tr class="tableFloatingHeader" style="position: absolute; top: 0px; left: 0px; visibility: hidden; margin:0px;padding:0px" >
<th width="10px"><?php echo '#'; ?></th>
<th width="10px"><?php echo get_lang('Photo'); ?></th>
<?php echo $headerOfficialCode; ?>
<th width="100px"><?php echo get_lang('LastName'); ?></th>
<th width="100px"><?php echo get_lang('FirstName'); ?></th>
<th width="100px"><?php echo get_lang('AttendancesFaults'); ?></th>
@@ -225,6 +231,7 @@ if (api_is_allowed_to_edit(null, true) ||
<tr class="tableFloatingHeaderOriginal" >
<th width="10px"><?php echo '#'; ?></th>
<th width="10px"><?php echo get_lang('Photo'); ?></th>
<?php echo $headerOfficialCode; ?>
<th width="150px"><?php echo get_lang('LastName'); ?></th>
<th width="140px"><?php echo get_lang('FirstName'); ?></th>
<th width="100px"><?php echo get_lang('AttendancesFaults'); ?></th>
@@ -248,6 +255,9 @@ if (api_is_allowed_to_edit(null, true) ||
<tr class="<?php echo $class; ?>">
<td><center><?php echo $i; ?></center></td>
<td><?php echo $data['photo']; ?></td>
<?php if ($addOfficialCode) {
echo '<td>'.$data['official_code'].'</td>';
} ?>
<td><span title="<?php echo $username; ?>"><?php echo $data['lastname']; ?></span></td>
<td><?php echo $data['firstname']; ?></td>
<td>
@@ -286,7 +296,7 @@ if (api_is_allowed_to_edit(null, true) ||
if ($allowSignature) {
$iconFullScreen = Display::url(
Display::return_icon('view_fullscreen.png', get_lang('SeeForTablet'), [], ICON_SIZE_SMALL),
api_get_self().'?'.api_get_cidreq().'&action=attendance_sheet_list&func=fullscreen&attendance_id='.$attendance_id.'&calendar_id='.$calendar['id']
api_get_self().'?'.api_get_cidreq().'&action=attendance_sheet_list&func=fullscreen&attendance_id='.$attendance_id.'&calendar_id='.$calendar['id'].(!empty($groupId) ? '&group_id='.$groupId : '')
);
$isBlocked = 0;
$iconBlockName = 'eyes.png';
+1 -1
View File
@@ -63,7 +63,7 @@
$("#sign_popup_save").on("click", function() {
if (signaturePad.isEmpty()) {
alert('<?php echo get_lang('ProvideASignatureFirst'); ?>');
alert('<?php echo addslashes(get_lang('ProvideASignatureFirst')); ?>');
return false;
}
var selected = $("#sign-selected").val();
+27 -25
View File
@@ -10,6 +10,11 @@
* This files provides the facebookConnect() and facebook_get_url functions
* Please edit the facebook.conf.php file to adapt it to your fb application parameter
*/
use Facebook\Exceptions\FacebookResponseException;
use Facebook\Exceptions\FacebookSDKException;
use Facebook\Facebook;
require_once __DIR__.'/../../inc/global.inc.php';
require_once __DIR__.'/facebook.init.php';
require_once __DIR__.'/functions.inc.php';
@@ -18,27 +23,29 @@ require_once __DIR__.'/functions.inc.php';
* This function connect to facebook and retrieves the user info
* If user does not exist in chamilo, it creates it and logs in
* If user already exists, it updates his info.
*
* @throws FacebookSDKException
*/
function facebookConnect()
{
$fb = new \Facebook\Facebook([
$fb = new Facebook([
'app_id' => $GLOBALS['facebook_config']['appId'],
'app_secret' => $GLOBALS['facebook_config']['secret'],
'default_graph_version' => 'v2.2',
'default_graph_version' => 'v21.0',
]);
$helper = $fb->getRedirectLoginHelper();
try {
$accessToken = $helper->getAccessToken();
} catch (Facebook\Exceptions\FacebookResponseException $e) {
} catch (FacebookResponseException $e) {
Display::addFlash(
Display::return_message('Facebook Graph returned an error: '.$e->getMessage(), 'error')
);
header('Location: '.api_get_path(WEB_PATH));
exit;
} catch (Facebook\Exceptions\FacebookSDKException $e) {
} catch (FacebookSDKException $e) {
Display::addFlash(
Display::return_message('Facebook SDK returned an error: '.$e->getMessage(), 'error')
);
@@ -79,7 +86,7 @@ function facebookConnect()
if (!$accessToken->isLongLived()) {
try {
$accessToken = $oAuth2Client->getLongLivedAccessToken($accessToken);
} catch (Facebook\Exceptions\FacebookSDKException $e) {
} catch (FacebookSDKException $e) {
Display::addFlash(
Display::return_message('Error getting long-lived access token: '.$e->getMessage(), 'error')
);
@@ -91,14 +98,14 @@ function facebookConnect()
try {
$response = $fb->get('/me?fields=id,first_name,last_name,locale,email', $accessToken->getValue());
} catch (Facebook\Exceptions\FacebookResponseException $e) {
} catch (FacebookResponseException $e) {
Display::addFlash(
Display::return_message('Graph returned an error: '.$e->getMessage(), 'error')
);
header('Location: '.api_get_path(WEB_PATH));
exit;
} catch (Facebook\Exceptions\FacebookSDKException $e) {
} catch (FacebookSDKException $e) {
Display::addFlash(
Display::return_message('Facebook SDK returned an error: '.$e->getMessage(), 'error')
);
@@ -108,10 +115,10 @@ function facebookConnect()
}
$user = $response->getGraphUser();
$language = facebookPluginGetLanguage($user['locale']);
$language = facebookPluginGetLanguage($user->getField('locale', ''));
if (!$language) {
$language = 'en_US';
$language = 'english';
}
$u = [
@@ -164,45 +171,40 @@ function facebookConnect()
/**
* Get facebook login url for the platform.
*
* @return string
* @throws FacebookSDKException
*/
function facebookGetLoginUrl()
function facebookGetLoginUrl(): string
{
$fb = new \Facebook\Facebook([
$fb = new Facebook([
'app_id' => $GLOBALS['facebook_config']['appId'],
'app_secret' => $GLOBALS['facebook_config']['secret'],
'default_graph_version' => 'v2.2',
]);
$helper = $fb->getRedirectLoginHelper();
$loginUrl = $helper->getLoginUrl(api_get_path(WEB_PATH).'?action=fbconnect', [
return $helper->getLoginUrl(api_get_path(WEB_PATH).'?action=fbconnect', [
'email',
]);
return $loginUrl;
}
/**
* Return a valid Chamilo login
* Chamilo login only use characters lettres, des chiffres et les signes _ . -.
*
* @param $in_txt
*
* @return mixed
*/
function changeToValidChamiloLogin($in_txt)
function changeToValidChamiloLogin(?string $in_txt): string
{
if (empty($in_txt)) {
return '';
}
return preg_replace("/[^a-zA-Z1-9_\-.]/", "_", $in_txt);
}
/**
* Get user language.
*
* @param string $language
*
* @return bool
*/
function facebookPluginGetLanguage($language = 'en_US')
function facebookPluginGetLanguage(string $language = 'en_US'): bool
{
$language = substr($language, 0, 2);
$sqlResult = Database::query(
+22 -24
View File
@@ -165,32 +165,30 @@ function external_add_user($u)
* Update the user in chamilo database. It upgrade only info that is present in the
* new_user array.
*
* @param $new_user associative array with the value to upgrade
* WARNING user_id key is MANDATORY
* Possible keys are :
* - firstname
* - lastname
* - username
* - auth_source
* - email
* - status
* - official_code
* - phone
* - picture_uri
* - expiration_date
* - active
* - creator_id
* - hr_dept_id
* - extra : array of custom fields
* - language
* - courses : string of all courses code separated by '|'
* - admin : boolean
*
* @return bool|null
* @param array $new_user associative array with the value to upgrade
* WARNING user_id key is MANDATORY
* Possible keys are :
* - firstname
* - lastname
* - username
* - auth_source
* - email
* - status
* - official_code
* - phone
* - picture_uri
* - expiration_date
* - active
* - creator_id
* - hr_dept_id
* - extra : array of custom fields
* - language
* - courses : string of all courses code separated by '|'
* - admin : boolean
*
* @author ndiechburg <noel@cblue.be>
* */
function external_update_user($new_user)
function external_update_user(array $new_user): void
{
$old_user = api_get_user_info($new_user['user_id']);
$u = array_merge($old_user, $new_user);
@@ -214,7 +212,7 @@ function external_update_user($new_user)
$u['language'],
''
);
if (isset($u['courses']) && !empty($u['courses'])) {
if (!empty($u['courses'])) {
$autoSubscribe = explode('|', $u['courses']);
foreach ($autoSubscribe as $code) {
if (CourseManager::course_exists($code)) {
+10 -5
View File
@@ -89,20 +89,25 @@ if ($form->validate()) {
$user = Login::get_user_accounts_by_username($values['user']);
if (!$user) {
$messageText = get_lang('NoUserAccountWithThisEmailAddress');
// Always return the same neutral response regardless of whether the
// username/email exists. Revealing "no account with this email" allows
// user enumeration by observing the differing redirect destination or
// message. Using the same message and redirect as the success path
// prevents that information leak.
$messageText = get_lang('AnEmailToResetYourPasswordHasBeenSent');
if (CustomPages::enabled() && CustomPages::exists(CustomPages::LOST_PASSWORD)) {
if (CustomPages::enabled() && CustomPages::exists(CustomPages::INDEX_UNLOGGED)) {
CustomPages::display(
CustomPages::LOST_PASSWORD,
CustomPages::INDEX_UNLOGGED,
['info' => $messageText]
);
exit;
}
Display::addFlash(
Display::return_message($messageText, 'error', false)
Display::return_message($messageText, 'info', false)
);
header('Location: '.api_get_self());
header('Location: '.api_get_path(WEB_PATH));
exit;
}
+11 -4
View File
@@ -298,10 +298,17 @@ function openid_verify_assertion($op_endpoint, $response) {
//TODO
$openid_association = Database::get_main_table(TABLE_MAIN_OPENID_ASSOCIATION);
$sql = sprintf("SELECT * FROM $openid_association WHERE assoc_handle = '%s'", $response['openid.assoc_handle']);
$res = Database::query($sql);
$association = Database::fetch_object($res);
if ($association && isset($association->session_type)) {
$association = Database::select(
'*',
$openid_association,
[
'where' => [
'assoc_handle = ?' => [$response['openid.assoc_handle']],
]
],
'first'
);
if ($association && isset($association['session_type'])) {
$keys_to_sign = explode(',', $response['openid.signed']);
$self_sig = _openid_signature($association, $response, $keys_to_sign);
if ($self_sig == $response['openid.sig']) {
+5 -5
View File
@@ -165,7 +165,7 @@ function _openid_parse_message($message) {
*/
function _openid_nonce() {
// YYYY-MM-DDThh:mm:ssTZD UTC, plus some optional extra unique chars
return gmstrftime('%Y-%m-%dT%H:%M:%S%Z') .
return gmdate('Y-m-d\TH:i:sT') .
chr(mt_rand(0, 25) + 65) .
chr(mt_rand(0, 25) + 65) .
chr(mt_rand(0, 25) + 65) .
@@ -201,14 +201,14 @@ function _openid_meta_httpequiv($equiv, $html) {
/**
* Sign certain keys in a message
* @param $association - object loaded from openid_association or openid_server_association table
* @param $association - array loaded from openid_association or openid_server_association table
* - important fields are ->assoc_type and ->mac_key
* @param $message_array - array of entire message about to be sent
* @param $keys_to_sign - keys in the message to include in signature (without
* 'openid.' appended)
*/
function _openid_signature($association, $message_array, $keys_to_sign) {
$signature = '';
function _openid_signature(array $association, $message_array, $keys_to_sign): string
{
$sign_data = array();
foreach ($keys_to_sign as $key) {
@@ -218,7 +218,7 @@ function _openid_signature($association, $message_array, $keys_to_sign) {
}
$message = _openid_create_message($sign_data);
$secret = base64_decode($association->mac_key);
$secret = base64_decode($association['mac_key']);
$signature = _openid_hmac($secret, $message);
return base64_encode($signature);
+43 -36
View File
@@ -2,12 +2,16 @@
/* For licensing terms, see /license.txt */
use Symfony\Component\HttpFoundation\Request as HttpRequest;
$cidReset = true; // Flag forcing the 'current course' reset
require_once __DIR__.'/../inc/global.inc.php';
api_block_anonymous_users();
$httpRequest = HttpRequest::createFromGlobals();
$auth = new Auth();
$user_course_categories = CourseManager::get_user_course_categories(api_get_user_id());
$courses_in_category = $auth->getCoursesInCategory(false);
@@ -21,8 +25,11 @@ $authorizedActions = [
'set_collapsable',
'unsubscribe',
];
if (in_array(trim($_REQUEST['action']), $authorizedActions)) {
$action = trim($_REQUEST['action']);
$action = $httpRequest->query->get('action', $httpRequest->request->get('action', ''));
if (!in_array($action, $authorizedActions)) {
$action = '';
}
$currentUrl = api_get_self();
@@ -33,9 +40,9 @@ $interbreadcrumb[] = [
];
// We are moving the course of the user to a different user defined course category (=Sort My Courses).
if (isset($_POST['submit_change_course_category'])) {
$course2EditCategory = Security::remove_XSS($_POST['course_2_edit_category']);
$courseCategories = Security::remove_XSS($_POST['course_categories']);
if ($httpRequest->request->has('submit_change_course_category')) {
$course2EditCategory = Security::remove_XSS($httpRequest->request->get('course_2_edit_category'));
$courseCategories = Security::remove_XSS($httpRequest->request->get('course_categories'));
$result = $auth->updateCourseCategory($course2EditCategory, $courseCategories);
if ($result) {
Display::addFlash(
@@ -47,16 +54,20 @@ if (isset($_POST['submit_change_course_category'])) {
}
// We edit course category
if (isset($_POST['submit_edit_course_category']) &&
isset($_POST['title_course_category'])
if ($httpRequest->request->has('submit_edit_course_category')
&& $httpRequest->request->has('title_course_category')
&& Security::check_token('post')
) {
$titleCourseCategory = Security::remove_XSS($_POST['title_course_category']);
$categoryId = Security::remove_XSS($_POST['category_id']);
$result = $auth->store_edit_course_category($titleCourseCategory, $categoryId);
if ($result) {
Display::addFlash(
Display::return_message(get_lang('CourseCategoryEditStored'))
);
$titleCourseCategory = Security::remove_XSS($httpRequest->request->get('title_course_category'));
$categoryId = Security::remove_XSS($httpRequest->request->get('category_id'));
$categoryInfo = $auth->getUserCourseCategory($categoryId);
if ($categoryInfo) {
$result = $auth->store_edit_course_category($titleCourseCategory, $categoryId);
if ($result) {
Display::addFlash(
Display::return_message(get_lang('CourseCategoryEditStored'))
);
}
}
header('Location: '.api_get_self());
@@ -64,11 +75,10 @@ if (isset($_POST['submit_edit_course_category']) &&
}
// We are creating a new user defined course category (= Create Course Category).
if (isset($_POST['create_course_category']) &&
isset($_POST['title_course_category']) &&
strlen(trim($_POST['title_course_category'])) > 0
if ($httpRequest->request->has('create_course_category')
&& $titleCourseCategory = $httpRequest->request->get('title_course_category')
) {
$titleCourseCategory = Security::remove_XSS($_POST['title_course_category']);
$titleCourseCategory = Security::remove_XSS($titleCourseCategory);
$result = $auth->store_course_category($titleCourseCategory);
if ($result) {
Display::addFlash(
@@ -87,10 +97,10 @@ if (isset($_POST['create_course_category']) &&
}
// We are moving a course or category of the user up/down the list (=Sort My Courses).
if (isset($_GET['move'])) {
$getCourse = isset($_GET['course']) ? Security::remove_XSS($_GET['course']) : '';
$getMove = Security::remove_XSS($_GET['move']);
$getCategory = isset($_GET['category']) ? Security::remove_XSS($_GET['category']) : '';
if ($getMove = $httpRequest->query->get('move')) {
$getCourse = Security::remove_XSS($httpRequest->query->get('course'));
$getMove = Security::remove_XSS($getMove);
$getCategory = Security::remove_XSS($httpRequest->query->get('category'));
if (!empty($getCourse)) {
$result = $auth->move_course($getMove, $getCourse, $getCategory);
if ($result) {
@@ -113,7 +123,7 @@ if (isset($_GET['move'])) {
switch ($action) {
case 'edit_category':
$categoryId = isset($_GET['category_id']) ? (int) $_GET['category_id'] : 0;
$categoryId = $httpRequest->query->getInt('category_id');
$categoryInfo = $auth->getUserCourseCategory($categoryId);
if ($categoryInfo) {
$categoryName = $categoryInfo['title'];
@@ -124,15 +134,15 @@ switch ($action) {
);
$form->addText('title_course_category', get_lang('Name'));
$form->addHidden('category_id', $categoryId);
$form->addHidden('sec_token', Security::get_token());
$form->addButtonSave(get_lang('Edit'), 'submit_edit_course_category');
$form->setDefaults(['title_course_category' => $categoryName]);
$form->display();
}
exit;
break;
case 'edit_course_category':
$edit_course = (int) $_GET['course_id'];
$defaultCategoryId = isset($_GET['category_id']) ? (int) $_GET['category_id'] : 0;
$edit_course = $httpRequest->query->getInt('course_id');
$defaultCategoryId = $httpRequest->query->getInt('category_id');
$courseInfo = api_get_course_info_by_id($edit_course);
if (empty($courseInfo)) {
@@ -167,12 +177,10 @@ switch ($action) {
$form->addButtonSave(get_lang('Save'), 'submit_change_course_category');
$form->display();
exit;
break;
case 'deletecoursecategory':
// we are deleting a course category
if (isset($_GET['id'])) {
if ($getId = $httpRequest->query->getInt('id')) {
if (Security::check_token('get')) {
$getId = Security::remove_XSS($_GET['id']);
$result = $auth->delete_course_category($getId);
if ($result) {
Display::addFlash(
@@ -183,7 +191,6 @@ switch ($action) {
}
header('Location: '.api_get_self());
exit;
break;
case 'createcoursecategory':
$form = new FormValidator(
'create_course_category',
@@ -194,16 +201,15 @@ switch ($action) {
$form->addButtonSave(get_lang('AddCategory'), 'create_course_category');
$form->display();
exit;
break;
case 'set_collapsable':
if (!api_get_configuration_value('allow_user_course_category_collapsable')) {
api_not_allowed(true);
}
$userId = api_get_user_id();
$categoryId = isset($_REQUEST['categoryid']) ? (int) $_REQUEST['categoryid'] : 0;
$option = isset($_REQUEST['option']) ? (int) $_REQUEST['option'] : 0;
$redirect = isset($_REQUEST['redirect']) ? Security::remove_XSS($_REQUEST['redirect']) : 0;
$categoryId = $httpRequest->query->getInt('categoryid', $httpRequest->request->getInt('categoryid'));
$option = $httpRequest->query->get('option', $httpRequest->request->getInt('option'));
$redirect = $httpRequest->query->get('redirect', $httpRequest->request->get('redirect', ''));
if (empty($userId) || empty($categoryId)) {
api_not_allowed(true);
@@ -225,7 +231,6 @@ switch ($action) {
$url = api_get_self();
header('Location: '.$url);
exit;
break;
}
function generateUnsubscribeForm(string $courseCode, string $secToken): string
@@ -433,7 +438,9 @@ if (!empty($courses_without_category)) {
);
}
echo '';
if (isset($_GET['edit']) && $course['code'] == $_GET['edit']) {
if ($httpRequest->query->has('edit')
&& $httpRequest->query->get('edit') === $course['code']
) {
echo Display::return_icon('edit_na.png', get_lang('Edit'), '', 22);
} else {
echo Display::url(
+31 -8
View File
@@ -3,6 +3,7 @@
use Chamilo\CoreBundle\Entity\Skill;
use Skill as SkillManager;
use Symfony\Component\HttpFoundation\Request as HttpRequest;
/**
* Page for assign skills to a user.
@@ -11,7 +12,12 @@ use Skill as SkillManager;
*/
require_once __DIR__.'/../inc/global.inc.php';
$userId = isset($_REQUEST['user']) ? (int) $_REQUEST['user'] : 0;
$httpRequest = HttpRequest::createFromGlobals();
$userId = $httpRequest->query->getInt(
'user',
$httpRequest->request->getInt('user')
);
if (empty($userId)) {
api_not_allowed(true);
@@ -53,11 +59,25 @@ if (empty($skillLevels)) {
$skillsOptions[$skill['data']['id']] = $skill['data']['name'];
}
}
$skillIdFromGet = isset($_REQUEST['id']) ? (int) $_REQUEST['id'] : 0;
$currentValue = isset($_REQUEST['current_value']) ? (int) $_REQUEST['current_value'] : 0;
$currentLevel = isset($_REQUEST['current']) ? (int) str_replace('sub_skill_id_', '', $_REQUEST['current']) : 0;
$skillIdFromGet = $httpRequest->query->getInt(
'id',
$httpRequest->request->getInt('id')
);
$currentValue = $httpRequest->query->getInt(
'current_value',
$httpRequest->request->getInt('current_value')
);
$currentLevel = $httpRequest->query->get(
'current',
$httpRequest->request->get('current', '')
);
$currentLevel = (int) str_replace('sub_skill_id_', '', $currentLevel);
$subSkillList = isset($_REQUEST['sub_skill_list']) ? explode(',', $_REQUEST['sub_skill_list']) : [];
$subSkillList = $httpRequest->query->get(
'sub_skill_list',
$httpRequest->request->get('sub_skill_list', '')
);
$subSkillList = explode(',', $subSkillList);
$subSkillList = array_unique($subSkillList);
if (!empty($subSkillList)) {
@@ -94,7 +114,10 @@ if (!empty($currentLevel)) {
}
}
$skillId = isset($_REQUEST['id']) ? (int) $_REQUEST['id'] : key($skillsOptions);
$skillId = $httpRequest->query->getInt(
'id',
$httpRequest->request->getInt('id', key($skillsOptions))
);
$skill = $skillRepo->find($skillId);
$profile = false;
if ($skill) {
@@ -234,8 +257,7 @@ if ($showLevels) {
//$form->addRule('acquired_level', get_lang('ThisFieldIsRequired'), 'required');
}
$form->addTextarea('argumentation', get_lang('Argumentation'), ['rows' => 6]);
$form->addRule('argumentation', get_lang('ThisFieldIsRequired'), 'required');
$form->addTextarea('argumentation', get_lang('Argumentation'), ['rows' => 6], true);
$form->addRule(
'argumentation',
sprintf(get_lang('ThisTextShouldBeAtLeastXCharsLong'), 10),
@@ -243,6 +265,7 @@ $form->addRule(
10
);
$form->applyFilter('argumentation', 'trim');
$form->applyFilter('argumentation', 'html_filter');
$form->addButtonSave(get_lang('Save'));
$form->setDefaults($formDefaultValues);
+8 -2
View File
@@ -4,6 +4,7 @@
use Chamilo\CoreBundle\Entity\SkillRelUser;
use Chamilo\CoreBundle\Entity\SkillRelUserComment;
use SkillRelUser as SkillRelUserManager;
use Symfony\Component\HttpFoundation\Request as HttpRequest;
/**
* Show information about the issued badge.
@@ -13,7 +14,12 @@ use SkillRelUser as SkillRelUserManager;
*/
require_once __DIR__.'/../inc/global.inc.php';
$issue = isset($_REQUEST['issue']) ? (int) $_REQUEST['issue'] : 0;
$httpRequest = HttpRequest::createFromGlobals();
$issue = $httpRequest->query->getInt(
'issue',
$httpRequest->request->getInt('issue')
);
if (empty($issue)) {
api_not_allowed(true);
@@ -103,7 +109,7 @@ $skillIssueInfo = [
'acquired_level' => $currentSkillLevel,
'argumentation_author_id' => $skillIssue->getArgumentationAuthorId(),
'argumentation_author_name' => $author['complete_name'],
'argumentation' => $skillIssue->getArgumentation(),
'argumentation' => Security::remove_XSS($skillIssue->getArgumentation()),
'source_name' => $skillIssue->getSourceName(),
'user_id' => $skillIssue->getUser()->getId(),
'user_complete_name' => UserManager::formatUserFullName($skillIssue->getUser()),
+6 -3
View File
@@ -4,6 +4,7 @@
use Chamilo\CoreBundle\Entity\SkillRelUser;
use Chamilo\CoreBundle\Entity\SkillRelUserComment;
use SkillRelUser as SkillRelUserManager;
use Symfony\Component\HttpFoundation\Request as HttpRequest;
/**
* Show information about all issued badges with same skill by user.
@@ -12,8 +13,10 @@ use SkillRelUser as SkillRelUserManager;
*/
require_once __DIR__.'/../inc/global.inc.php';
$userId = isset($_GET['user']) ? (int) $_GET['user'] : 0;
$skillId = isset($_GET['skill']) ? (int) $_GET['skill'] : 0;
$httpRequest = HttpRequest::createFromGlobals();
$userId = $httpRequest->query->getInt('user');
$skillId = $httpRequest->query->getInt('skill');
if (!$userId || !$skillId) {
api_not_allowed(true);
@@ -90,7 +93,7 @@ foreach ($userSkills as $index => $skillIssue) {
$argumentationAuthor['firstname'],
$argumentationAuthor['lastname']
),
'argumentation' => $skillIssue->getArgumentation(),
'argumentation' => Security::remove_XSS($skillIssue->getArgumentation()),
'source_name' => $skillIssue->getSourceName(),
'user_id' => $skillIssue->getUser()->getId(),
'user_complete_name' => UserManager::formatUserFullName($skillIssue->getUser()),
+6 -3
View File
@@ -18,7 +18,7 @@ if ((!api_is_allowed_in_course() || !api_is_allowed_in_course()) && !api_is_allo
}
$origin = api_get_origin();
$action = isset($_GET['action']) ? $_GET['action'] : '';
$action = $_GET['action'] ?? '';
if (api_is_allowed_to_edit()) {
$nameTools = get_lang('blog_management');
@@ -58,11 +58,14 @@ if (api_is_allowed_to_edit()) {
echo Display::return_message(get_lang('BlogEdited'), 'confirmation');
}
}
if (isset($_GET['action']) && $_GET['action'] == 'visibility') {
$isValidToken = Security::check_token('get', null, 'blog');
if ($action == 'visibility' && $isValidToken) {
Blog::changeBlogVisibility(intval($_GET['blog_id']));
echo Display::return_message(get_lang('VisibilityChanged'), 'confirmation');
}
if (isset($_GET['action']) && $_GET['action'] == 'delete') {
if ($action == 'delete' && $isValidToken) {
Blog::deleteBlog(intval($_GET['blog_id']));
echo Display::return_message(get_lang('BlogDeleted'), 'confirmation');
}
+6 -2
View File
@@ -31,8 +31,12 @@ if (in_array($extension, ['xml', 'csv', 'imscc', 'mbz']) &&
(api_is_platform_admin(true) || api_is_drh() || CourseManager::is_course_teacher(api_get_user_id(), api_get_course_id()))
) {
$content_type = 'application/force-download';
} elseif ('zip' === $extension && $_cid && (api_is_platform_admin(true) || api_is_course_admin())) {
$content_type = 'application/force-download';
} elseif ('zip' === $extension) {
if ($_cid && (api_is_platform_admin(true) || api_is_course_admin())) {
$content_type = 'application/force-download';
} elseif (empty($_cid) && api_is_platform_admin()) {
$content_type = 'application/force-download';
}
}
if (empty($content_type)) {
+1 -1
View File
@@ -287,7 +287,7 @@ if ($visibilityChangeable) {
);
}
$url = api_get_path(WEB_CODE_PATH)."auth/inscription.php?c=$course_code&e=1";
$url = api_get_path(WEB_CODE_PATH)."auth/inscription.php?c=$course_code&";
$url = Display::url($url, $url);
$label = $form->addLabel(get_lang('DirectLink'), sprintf(get_lang('CourseSettingsRegisterDirectLink'), $url), true);
@@ -33,7 +33,7 @@ if (!api_is_coach()) {
api_not_allowed(true);
}
$action = isset($_POST['action']) ? $_POST['action'] : '';
$action = $_POST['action'] ?? '';
$courseId = api_get_course_int_id();
$courseInfo = api_get_course_info_by_id($courseId);
@@ -52,10 +52,7 @@ $interbreadcrumb[] = [
'name' => get_lang('Maintenance'),
];
/**
* @param string $name
*/
function make_select_session_list($name, $sessions, $attr = [])
function make_select_session_list($name, $sessions, $attr = []): string
{
$attrs = '';
if (count($attr) > 0) {
@@ -184,7 +181,7 @@ function displayForm()
echo $html;
}
function searchCourses($idSession, $type)
function searchCourses($idSession, $type): xajaxResponse
{
$xajaxResponse = new xajaxResponse();
$return = null;
@@ -193,6 +190,7 @@ function searchCourses($idSession, $type)
if (!empty($type)) {
$idSession = (int) $idSession;
$courseList = SessionManager::get_course_list_by_session_id($idSession);
$course_list_destination = [];
$return .= '<select id="destination" name="SessionCoursesListDestination[]" style="width:380px;" >';
@@ -292,8 +290,6 @@ if (($action === 'course_select_form') ||
$cr = new CourseRestorer($course);
$cr->restore($destinationCourse, $destinationSession);
echo Display::return_message(get_lang('CopyFinished'), 'confirmation');
displayForm();
} else {
$arrCourseOrigin = [];
$arrCourseDestination = [];
@@ -333,16 +329,15 @@ if (($action === 'course_select_form') ||
$cr->restore($courseDestination, $destinationSession);
echo Display::return_message(get_lang('CopyFinished'), 'confirmation');
}
displayForm();
} else {
echo Display::return_message(
get_lang('YouMustSelectACourseFromOriginalSession'),
'error'
);
displayForm();
}
}
displayForm();
} elseif (isset($_POST['copy_option']) && $_POST['copy_option'] == 'select_items') {
// Else, if a CourseSelectForm is requested, show it
if (api_get_setting('show_glossary_in_documents') != 'none') {
+3 -2
View File
@@ -51,10 +51,11 @@ if ($action === 'course_select_form' && Security::check_token('post')) {
// Rebuild the course object based on selected resources
$cb = new CourseBuilder('partial');
$course = $cb->build(0, null, false, array_keys($selectedResources), $selectedResources);
$course = CourseSelectForm::get_posted_course(null, 0, '', $course);
// Get admin details
$adminId = (int) $_POST['admin_id'];
$adminUsername = filter_var($_POST['admin_username'], FILTER_SANITIZE_STRING);
$adminUsername = strip_tags((string) $_POST['admin_username']);
if (!preg_match('/^[a-zA-Z0-9_]+$/', $adminUsername)) {
echo Display::return_message(get_lang('PleaseEnterValidLogin'), 'error');
exit();
@@ -137,7 +138,7 @@ if ($action === 'course_select_form' && Security::check_token('post')) {
$exportDir = 'moodle_export_'.$courseId;
try {
$moodleVersion = isset($values['moodle_version']) ? $values['moodle_version'] : '3';
$moodleVersion = $values['moodle_version'] ?? '3';
$mbzFile = $exporter->export($courseId, $exportDir, $moodleVersion);
echo Display::return_message(get_lang('MoodleExportCreated'), 'confirm');
echo '<br />';
+4 -18
View File
@@ -715,8 +715,6 @@ class ImportCsv
if (!empty($data)) {
$this->logger->addInfo(count($data)." records found.");
$expirationDateOnCreation = api_get_utc_datetime(strtotime("+".intval($this->expirationDateInUserCreation)."years"));
$expirationDateOnUpdate = api_get_utc_datetime(strtotime("+".intval($this->expirationDateInUserUpdate)."years"));
$batchSize = $this->batchSize;
$em = Database::getManager();
@@ -752,7 +750,7 @@ class ImportCsv
$row['phone'],
null, //$row['picture'], //picture
$row['auth_source'], // ?
$expirationDateOnCreation, //'0000-00-00 00:00:00', //$row['expiration_date'], //$expiration_date = '0000-00-00 00:00:00',
null,
1, //active
0,
null, // extra
@@ -800,7 +798,7 @@ class ImportCsv
$userInfo['official_code'],
$userInfo['phone'],
$userInfo['picture_uri'],
$expirationDateOnUpdate,
null,
$userInfo['active'],
null, //$creator_id = null,
0, //$hr_dept_id = 0,
@@ -892,13 +890,6 @@ class ImportCsv
$language = $this->defaultLanguage;
$this->logger->addInfo(count($data)." records found.");
$expirationDateOnCreate = api_get_utc_datetime(
strtotime("+".intval($this->expirationDateInUserCreation)."years")
);
$expirationDateOnUpdate = api_get_utc_datetime(
strtotime("+".intval($this->expirationDateInUserUpdate)."years")
);
$counter = 1;
$secondsInYear = 365 * 24 * 60 * 60;
@@ -965,7 +956,7 @@ class ImportCsv
$row['phone'],
null, //$row['picture'], //picture
$row['auth_source'], // ?
$expirationDateOnCreate,
null,
1, //active
0,
null, // extra
@@ -1000,11 +991,6 @@ class ImportCsv
continue;
}
if (isset($row['action']) && $row['action'] === 'delete') {
// Inactive one year later
$userInfo['expiration_date'] = api_get_utc_datetime(api_strtotime(time() + $secondsInYear));
}
$password = $row['password']; // change password
$email = $row['email']; // change email
$resetPassword = 2; // allow password change
@@ -1075,7 +1061,7 @@ class ImportCsv
$userInfo['official_code'],
$userInfo['phone'],
$userInfo['picture_uri'],
$expirationDateOnUpdate,
null,
$userInfo['active'],
null, //$creator_id = null,
0, //$hr_dept_id = 0,
+717
View File
@@ -0,0 +1,717 @@
<?php
/* For licensing terms, see /license.txt */
/**
* This script imports users from an XLSX file, compares them to existing users in the Chamilo database,
* updates or inserts users based on specific rules, and generates XLSX files for accounts with missing
* email, lastname, or username, and exports accounts with duplicate Mail or Nom Prénom fields.
* Uses 'Nom' and 'Prénom' columns for name duplicates and includes 'Actif' in exports.
* - Skips import of users with empty 'Actif' unless they exist in DB (then updates, including inactive status)
* - Updates existing users by username (always generated via generateProposedLogin) instead of importing as new
* - Stores 'Matricule' as extra field 'external_user_id' without trimming leading zeros
* - Logs decisions (skipped, updated, inserted) with details in simulation or proceed mode
* - Stops processing on two consecutive empty rows in XLSX
* - Allows custom output directory for XLSX files via command line
* - Always generates username using generateProposedLogin()
* - Username format: lastname + first letter of each firstname word; for active duplicates, append next letter from last firstname part
* - For 3+ occurrences of lastname + firstname, append increasing letters from last firstname part (e.g., jpii, jpiii)
* - Generates unmatched_db_users.xlsx listing database users not found in the input XLSX based on username
* - Exports terminal output to import-yyyymmddhhiiss.log in the output directory.
*/
// Ensure the script is run from the command line
if (php_sapi_name() !== 'cli') {
exit('This script must be run from the command line.');
}
// Configuration
$domain = 'example.com'; // Manually configured domain for generated emails
// Include Chamilo bootstrap and necessary classes
require_once __DIR__.'/../../main/inc/global.inc.php';
require_once __DIR__.'/../../vendor/autoload.php';
// Command-line arguments parsing (without getopt)
$proceed = false;
$outputDir = '/tmp'; // Default output directory
$xlsxFile = $argv[1] ?? ''; // Expect XLSX file path as first argument
// Parse arguments manually
for ($i = 2; $i < count($argv); $i++) {
$arg = $argv[$i];
if ($arg === '--proceed' || $arg === '-p') {
$proceed = true;
} elseif (preg_match('/^--output-dir=(.+)$/', $arg, $matches)) {
$outputDir = $matches[1];
} elseif ($arg === '-o' && $i + 1 < count($argv)) {
$outputDir = $argv[++$i];
} elseif (preg_match('/^--mail-domain=(.+)$/', $arg, $matches)) {
$domain = $matches[1];
}
}
// Validate and prepare output directory
if (!is_dir($outputDir)) {
if (!mkdir($outputDir, 0755, true)) {
exit("Error: Could not create output directory '$outputDir'\n");
}
}
if (!is_writable($outputDir)) {
exit("Error: Output directory '$outputDir' is not writable\n");
}
// Ensure trailing slash for consistency
$outputDir = rtrim($outputDir, '/').'/';
// Start output buffering to capture terminal output
ob_start();
// Add start timestamp
$startTime = new DateTime();
echo '['.$startTime->format('Y-m-d H:i:s')."] Script started\n";
// Debug: Log parsed arguments
echo "Parsed arguments:\n";
echo " XLSX file: $xlsxFile\n";
echo " Proceed: ".($proceed ? 'true' : 'false')."\n";
echo " Output directory: $outputDir\n";
if (empty($xlsxFile) || !file_exists($xlsxFile)) {
exit("Usage: php import_users_from_xlsx.php <path_to_xlsx_file> [-p|--proceed] [-o <directory>|--output-dir=<directory>]\n");
}
// Initialize database connection
global $database;
// Load XLSX file
try {
$inputFileType = \PhpOffice\PhpSpreadsheet\IOFactory::identify($xlsxFile);
$reader = \PhpOffice\PhpSpreadsheet\IOFactory::createReader($inputFileType);
$phpExcel = $reader->load($xlsxFile);
$worksheet = $phpExcel->getActiveSheet();
$xlsxRows = $worksheet->toArray();
} catch (Exception $e) {
exit("Error loading XLSX file: {$e->getMessage()}\n");
}
// Map XLSX columns to Chamilo database user table fields
$xlsxColumnMap = [
'Nom' => 'lastname',
'Prénom' => 'firstname',
'Nom Prénom' => 'fullname',
'Mail' => 'email',
'Matricule' => 'official_code',
'N° de badge' => 'password',
'tel mobile' => 'phone',
'Actif' => 'active',
];
// Extract headers and validate
$xlsxHeaders = array_shift($xlsxRows);
$xlsxColumnIndices = [];
foreach ($xlsxColumnMap as $xlsxHeader => $dbField) {
$index = array_search($xlsxHeader, $xlsxHeaders);
if ($index === false) {
exit("Missing required column: {$xlsxHeader}\n");
}
$xlsxColumnIndices[$dbField] = $index;
}
// Initialize arrays to store rows with missing fields, duplicates, and XLSX usernames
$emailMissing = [];
$lastnameMissing = [];
$usernameMissing = [];
$xlsxEmailCounts = [];
$xlsxNameCounts = [];
$duplicateEmails = [];
$duplicateNames = [];
$generatedEmails = []; // username -> generatedEmail
$xlsxUsernames = []; // Store usernames from XLSX
// Output columns for missing field and duplicate files
$outputColumns = ['Matricule', 'Nom', 'Prénom', 'Nom Prénom', 'Mail', 'N° de badge', 'Actif', 'Generated login'];
// Normalize string for duplicate detection
function normalizeName($name)
{
$name = strtolower(trim($name));
$name = preg_replace('/[\s-]+/', ' ', $name);
return $name;
}
// Remove accents from strings
function removeAccents($str)
{
$str = str_replace(
['à', 'á', 'â', 'ã', 'ä', 'ç', 'è', 'é', 'ê', 'ë', 'ì', 'í', 'î', 'ï', 'ñ', 'ò', 'ó', 'ô', 'õ', 'ö', 'ù', 'ú', 'û', 'ü', 'ý', 'ÿ', 'À', 'Á', 'Â', 'Ã', 'Ä', 'Å', 'Ç', 'È', 'É', 'Ê', 'Ë', 'Ì', 'Í', 'Î', 'Ï', 'Ñ', 'Ò', 'Ó', 'Ô', 'Õ', 'Ö', 'Ù', 'Ú', 'Û', 'Ü', 'Ý', "'", ""],
['a', 'a', 'a', 'a', 'a', 'c', 'e', 'e', 'e', 'e', 'i', 'i', 'i', 'i', 'n', 'o', 'o', 'o', 'o', 'o', 'u', 'u', 'u', 'u', 'y', 'y', 'A', 'A', 'A', 'A', 'A', 'A', 'C', 'E', 'E', 'E', 'E', 'I', 'I', 'I', 'I', 'N', 'O', 'O', 'O', 'O', 'O', 'U', 'U', 'U', 'U', 'Y', '', ''],
$str
);
return $str;
}
// Generate login based on lastname and firstname
function generateProposedLogin($xlsxLastname, $xlsxFirstname, $isActive, &$usedLogins)
{
$lastname = strtolower(trim(removeAccents($xlsxLastname)));
$lastname = preg_replace('/[\s-]+/', '', $lastname);
$firstname = trim(removeAccents($xlsxFirstname));
$firstnameParts = preg_split('/[\s-]+/', $firstname, -1, PREG_SPLIT_NO_EMPTY);
$firstLetters = '';
foreach ($firstnameParts as $part) {
if (!empty($part)) {
$firstLetters .= strtolower(substr($part, 0, 1));
}
}
// Base username: lastname + first letter of each firstname word
$baseLogin = $lastname.$firstLetters;
$login = $baseLogin;
// Get last part of firstname for duplicate resolution
$lastFirstnamePart = end($firstnameParts);
$lastPartLetters = strtolower(preg_replace('/[\s-]+/', '', $lastFirstnamePart));
// Handle duplicates by incrementally adding letters from the last firstname part if active
if ($isActive) {
$letterCount = 0;
while (isset($usedLogins['logins'][$login]) && $usedLogins['logins'][$login]['active']) {
$letterCount++;
if ($letterCount > strlen($lastPartLetters) - 1) {
break; // No more letters available. Will append a number below
}
$login = $baseLogin.substr($lastPartLetters, 1, $letterCount);
}
}
// Ensure uniqueness by appending a number if still conflicting
$suffix = 1;
$originalLogin = $login;
while (isset($usedLogins['logins'][$login]) && $usedLogins['logins'][$login]['active']) {
$login = $originalLogin.$suffix;
$suffix++;
}
// Store login with active status
$usedLogins['logins'][$login] = ['active' => $isActive];
return $login;
}
// Generate XLSX files for missing fields and duplicates
function createMissingFieldFile($filename, $rows, $columns)
{
if (empty($rows)) {
echo "No rows to write for $filename\n";
return;
}
$phpExcel = new \PhpOffice\PhpSpreadsheet\Spreadsheet();
$worksheet = $phpExcel->getActiveSheet();
foreach ($columns as $colIndex => $column) {
$worksheet->setCellValueByColumnAndRow($colIndex + 1, 1, $column);
}
foreach ($rows as $rowIndex => $rowData) {
foreach ($columns as $colIndex => $column) {
$worksheet->setCellValueByColumnAndRow($colIndex + 1, $rowIndex + 2, $rowData[$column]);
}
}
try {
$writer = \PhpOffice\PhpSpreadsheet\IOFactory::createWriter($phpExcel, 'Xlsx');
$writer->save($filename);
echo "Generated $filename with ".count($rows)." rows\n";
} catch (Exception $e) {
echo "Error saving $filename: {$e->getMessage()}\n";
}
}
/**
* Generate a tentative e-mail address from firstname and lastname.
*/
function generateMailFromFirstAndLastNames(string $firstname, string $lastname, string $domain): string
{
$emailLastnameParts = preg_split('/[\s-]+/', trim(removeAccents($lastname)), -1, PREG_SPLIT_NO_EMPTY);
$emailLastname = !empty($emailLastnameParts[0]) ? strtolower($emailLastnameParts[0]) : '';
$emailFirstnameParts = preg_split('/[\s-]+/', trim(removeAccents($firstname)), -1, PREG_SPLIT_NO_EMPTY);
$emailFirstname = !empty($emailFirstnameParts[0]) ? strtolower($emailFirstnameParts[0]) : '';
return "$emailLastname.$emailFirstname@$domain";
}
// Detect potential issues in XLSX file
$usedLogins = ['logins' => [], 'counts' => []];
$generatedEmailCounts = [];
$emptyRowCount = 0;
foreach ($xlsxRows as $rowIndex => $xlsxRow) {
// Check for empty row
$isEmpty = true;
foreach ($xlsxRow as $cell) {
if (!empty(trim($cell))) {
$isEmpty = false;
break;
}
}
if ($isEmpty) {
$emptyRowCount++;
if ($emptyRowCount >= 2) {
echo "Stopping processing: Found two consecutive empty rows at row ".($rowIndex + 2)."\n";
break;
}
continue;
} else {
$emptyRowCount = 0; // Reset counter if row is not empty
}
$xlsxUserData = [];
foreach ($xlsxColumnMap as $dbField) {
$xlsxUserData[$dbField] = $xlsxRow[$xlsxColumnIndices[$dbField]] ?? '';
}
// Generate username and store it
$isActive = !empty($xlsxUserData['active']);
$xlsxUserData['username'] = generateProposedLogin($xlsxUserData['lastname'], $xlsxUserData['firstname'], $isActive, $usedLogins);
$xlsxUsernames[] = $xlsxUserData['username'];
$rowData = [
'Matricule' => $xlsxUserData['official_code'],
'Nom' => $xlsxUserData['lastname'],
'Prénom' => $xlsxUserData['firstname'],
'Nom Prénom' => $xlsxUserData['fullname'],
'Mail' => $xlsxUserData['email'],
'N° de badge' => $xlsxUserData['password'],
'Actif' => $xlsxUserData['active'],
'Generated login' => $xlsxUserData['username'],
];
if ($isActive) {
if (empty($xlsxUserData['email'])) {
$generatedEmail = $baseEmail = generateMailFromFirstAndLastNames($xlsxUserData['firstname'], $xlsxUserData['lastname'], $domain);
$suffix = isset($generatedEmailCounts[$baseEmail]) ? count($generatedEmailCounts[$baseEmail]) + 1 : 1;
if ($suffix > 1) {
$generatedEmail = preg_replace('/^([^@]+)@(.+)/', '${1}'.$suffix.'@${2}', $baseEmail);
}
$generatedEmail = strtoupper($generatedEmail);
$generatedEmailCounts[$baseEmail][] = $rowData;
$rowData['Mail'] = $generatedEmail;
$xlsxUserData['email'] = $generatedEmail;
$xlsxUserData['emailSource'] = 'Generated during import';
$emailMissing[] = $rowData;
$xlsxEmailCounts[$generatedEmail][] = $rowData;
$generatedEmails[$xlsxUserData['official_code']] = [$generatedEmail];
}
if (empty($xlsxUserData['lastname'])) {
$lastnameMissing[] = $rowData;
}
// All usernames are generated
$usernameMissing[] = $rowData;
$email = strtolower(trim($xlsxUserData['email']));
$name = normalizeName($xlsxUserData['fullname']);
if (!empty($email)) {
$xlsxEmailCounts[$email][] = $rowData;
}
if (!empty($xlsxUserData['fullname'])) {
$xlsxNameCounts[$name][] = $rowData;
}
}
}
foreach ($xlsxEmailCounts as $email => $rows) {
if (count($rows) > 1) {
$duplicateEmails = array_merge($duplicateEmails, $rows);
}
}
foreach ($xlsxNameCounts as $name => $rowData) {
if (count($rowData) > 1) {
$duplicateNames = array_merge($duplicateNames, $rowData);
}
}
usort($duplicateEmails, function ($a, $b) {
return strcmp(strtolower($a['Mail'] ?? ''), strtolower($b['Mail'] ?? ''));
});
usort($duplicateNames, function ($a, $b) {
return strcmp(normalizeName($a['Nom Prénom'] ?? ''), normalizeName($b['Nom Prénom'] ?? ''));
});
createMissingFieldFile($outputDir.'email_missing.xlsx', $emailMissing, $outputColumns);
createMissingFieldFile($outputDir.'lastname_missing.xlsx', $lastnameMissing, $outputColumns);
createMissingFieldFile($outputDir.'username_missing.xlsx', $usernameMissing, $outputColumns);
createMissingFieldFile($outputDir.'duplicate_email.xlsx', $duplicateEmails, $outputColumns);
createMissingFieldFile($outputDir.'duplicate_name.xlsx', $duplicateNames, $outputColumns);
// Generate unmatched_db_users.xlsx
$unmatchedUsers = [];
$sql = "SELECT id, username, official_code, email, active FROM user";
$stmt = $database->query($sql);
while ($dbUser = $stmt->fetch()) {
if (!in_array($dbUser['username'], $xlsxUsernames) && !empty($dbUser['username'])) {
$unmatchedUsers[] = [
'Matricule' => $dbUser['official_code'],
'Username' => $dbUser['username'],
'User ID' => $dbUser['id'],
'E-mail' => $dbUser['email'],
'Active' => $dbUser['active'] ? 'Yes' : 'No',
];
}
}
$unmatchedColumns = ['Matricule', 'Username', 'User ID', 'E-mail', 'Active'];
createMissingFieldFile($outputDir.'unmatched_db_users.xlsx', $unmatchedUsers, $unmatchedColumns);
// Process users: compare with database, log decisions, and update/insert if --proceed
echo "\n=== Processing Users ===\n";
$userManager = new UserManager();
$usedLogins = ['logins' => [], 'counts' => []]; // Reset usedLogins to avoid false duplicates
$emptyRowCount = 0;
$userActions = []; // Initialize array to store user actions
$userSkippedWhileActive = []; // Initialize array to store special cases
foreach ($xlsxRows as $rowIndex => $rowData) {
// Check for empty row
$emailSource = 'SAP';
$isEmpty = true;
foreach ($rowData as $cell) {
if (!empty(trim($cell))) {
$isEmpty = false;
break;
}
}
if ($isEmpty) {
$emptyRowCount++;
if ($emptyRowCount >= 2) {
echo "Stopping processing: Found two consecutive empty rows at row ".($rowIndex + 2)."\n";
break;
}
continue;
} else {
$emptyRowCount = 0; // Reset counter if row is not empty
}
$xlsxUserData = [];
foreach ($xlsxColumnMap as $dbField) {
$xlsxUserData[$dbField] = $rowData[$xlsxColumnIndices[$dbField]] ?? '';
}
// Generate username
$isActive = !empty($xlsxUserData['active']);
$xlsxUserData['username'] = generateProposedLogin($xlsxUserData['lastname'], $xlsxUserData['firstname'], $isActive, $usedLogins);
$dbUsername = Database::escape_string($xlsxUserData['username']);
if (!empty($xlsxUserData['official_code']) && !empty($generatedEmails[$xlsxUserData['official_code']])) {
$emailSource = 'E-mail generated during import';
$xlsxUserData['email'] = $generatedEmails[$xlsxUserData['official_code']];
} elseif (!empty($rowData['emailSource'])) {
$emailSource = $rowData['emailSource'];
}
// Get current time for row logging
$rowTime = new DateTime();
// Skip users with Matricule starting with 0009
if (strpos($xlsxUserData['official_code'], '0009') === 0) {
echo '['.$rowTime->format('H:i:s').'] Row '.($rowIndex + 2).": Skipped - Matricule starts with 0009 (username: $dbUsername)\n";
$logRow = [
'Action Type' => 'skipped',
'User ID' => '',
'Username' => $dbUsername,
'Official Code' => $xlsxUserData['official_code'],
'E-mail' => $xlsxUserData['email'],
'E-mail source' => $emailSource,
'External User ID' => $xlsxUserData['official_code'],
'Updated Fields' => 'Matricule starts with 0009',
];
$userActions[] = $logRow;
$userSkippedWhileActive[] = $logRow;
continue;
}
// Check for existing user by username
$sql = "SELECT id, firstname, lastname, email, official_code, phone, active, status, picture_uri, expiration_date, language, creator_id
FROM user
WHERE username = '$dbUsername'";
$stmt = $database->query($sql);
$dbUser = $stmt->fetch();
// Prepare data for logging and potential import/update
$xlsxMatricule = $xlsxUserData['official_code'] ?? ''; // Keep leading zeros
$xlsxActive = !empty($xlsxUserData['active']) ? 1 : 0;
// Decision logic
if (empty($dbUser) && empty($xlsxUserData['active'])) {
echo '['.$rowTime->format('H:i:s').'] Row '.($rowIndex + 2).": Skipped - 'Actif' is empty and no matching user in database (username: $dbUsername)\n";
$emailSource = 'Not relevant (user ignored)';
$userActions[] = [
'Action Type' => 'skipped',
'User ID' => '',
'Username' => $dbUsername,
'Official Code' => $xlsxUserData['official_code'],
'E-mail' => $xlsxUserData['email'],
'E-mail source' => $emailSource,
'External User ID' => $xlsxMatricule,
'Updated Fields' => '"Active" field is empty and no matching user in database',
];
continue;
}
// Validate required fields
$requiredFields = ['lastname', 'firstname', 'email'];
$missingFields = [];
foreach ($requiredFields as $field) {
if (empty($xlsxUserData[$field])) {
$missingFields[] .= $field;
if ($field == 'email') {
$emailSource = 'EMPTY IN SAP';
}
}
}
if (!empty($missingFields)) {
echo '['.$rowTime->format('H:i:s').'] Row '.($rowIndex + 2).': Skipped - missing fields: '.implode(', ', $missingFields)." (username: $dbUsername)\n";
$logRow = [
'Action Type' => 'skipped',
'User ID' => '',
'Username' => $dbUsername,
'Official Code' => $xlsxUserData['official_code'],
'E-mail' => $xlsxUserData['email'],
'E-mail source' => $emailSource,
'External User ID' => $xlsxMatricule,
'Updated Fields' => 'Missing fields: '.implode(', ', $missingFields),
];
$userActions[] = $logRow;
$userSkippedWhileActive[] = $logRow;
continue;
}
// If the user was found/existed in the local database
if ($dbUser) {
// Check for updates
$updates = [];
if ($dbUser['firstname'] !== $xlsxUserData['firstname']) {
$updates[] .= "firstname: '".$dbUser['firstname']."' -> '".$xlsxUserData['firstname']."' ";
}
if ($dbUser['lastname'] !== $xlsxUserData['lastname']) {
$updates[] .= "lastname: '".$dbUser['lastname']."' -> '".$xlsxUserData['lastname']."' ";
}
if ($dbUser['email'] !== $xlsxUserData['email']) {
$updates[] .= "email: '".$dbUser['email']."' -> '".$xlsxUserData['email']."' ";
}
if ($dbUser['official_code'] !== $xlsxUserData['official_code']) {
$updates[] .= "official_code: '".$dbUser['official_code']."' -> '".$xlsxUserData['official_code']."' ";
}
if ($dbUser['phone'] !== ($xlsxUserData['phone'] ?? '')) {
$updates[] .= "phone: '".$dbUser['phone']."' -> '".($xlsxUserData['phone'] ?? '')."' ";
}
if ($dbUser['active'] != $xlsxActive) {
$updates[] .= "active: ".$dbUser['active']." -> '".$xlsxActive."' ";
}
if (!empty($updates)) {
echo '['.$rowTime->format('H:i:s').'] Row '.($rowIndex + 2).": Update - Existing user found, updates needed (username: $dbUsername)\n";
echo " Updates: ".implode(', ', $updates)."\n";
if ($proceed) {
try {
$user = UserManager::update_user(
$dbUser['id'],
$xlsxUserData['firstname'],
$xlsxUserData['lastname'],
$xlsxUserData['username'], // username generated from lastname + firstname's first letter (although it should not change, it is required by the update_user method)
null, // password not updated
null, // auth_source
$xlsxUserData['email'],
$dbUser['status'], // status
$xlsxUserData['official_code'],
$xlsxUserData['phone'],
$dbUser['picture_uri'], // picture_uri
$dbUser['expiration_date'], // expiration_date
$xlsxActive,
$dbUser['creator_id'],
0,
null,
$dbUser['language']
);
if ($user) {
// Update extra field 'external_user_id'
UserManager::update_extra_field_value($dbUser['id'], 'external_user_id', $xlsxMatricule);
echo " Success: Updated user and external_user_id (username: $dbUsername)\n";
$userActions[] = [
'Action Type' => 'updated',
'User ID' => $dbUser['id'],
'Username' => $dbUsername,
'Official Code' => $xlsxUserData['official_code'],
'E-mail' => $xlsxUserData['email'],
'E-mail source' => $emailSource,
'External User ID' => $xlsxMatricule,
'Updated Fields' => implode(', ', array_map(function ($update) { return trim(explode(':', $update)[0]); }, $updates)),
];
} else {
echo " Error: Could not update user (username: $dbUsername)\n";
$logRow = [
'Action Type' => 'skipped',
'User ID' => $dbUser['id'],
'Username' => $dbUsername,
'Official Code' => $xlsxUserData['official_code'],
'E-mail' => $xlsxUserData['email'],
'E-mail source' => $emailSource,
'External User ID' => $xlsxMatricule,
'Updated Fields' => 'Could not update user',
];
$userActions[] = $logRow;
$userSkippedWhileActive[] = $logRow;
}
} catch (Exception $e) {
echo " Error: Failed to update user (username: $dbUsername): {$e->getMessage()}\n";
$logRow = [
'Action Type' => 'skipped',
'User ID' => $dbUser['id'],
'Username' => $dbUsername,
'Official Code' => $xlsxUserData['official_code'],
'E-mail' => $xlsxUserData['email'],
'E-mail source' => $emailSource,
'External User ID' => $xlsxMatricule,
'Updated Fields' => 'Failed to update user: '.$e->getMessage(),
];
$userActions[] = $logRow;
$userSkippedWhileActive[] = $logRow;
}
} else {
echo " Sim mode: Updated user and external_user_id (username: $dbUsername)\n";
$userActions[] = [
'Action Type' => 'updated',
'User ID' => $dbUser['id'],
'Username' => $dbUsername,
'Official Code' => $xlsxUserData['official_code'],
'E-mail' => $xlsxUserData['email'],
'E-mail source' => $emailSource,
'External User ID' => $xlsxMatricule,
'Updated Fields' => implode(', ', array_map(function ($update) { return trim(explode(':', $update)[0]); }, $updates)),
];
}
} else {
echo '['.$rowTime->format('H:i:s').'] Row '.($rowIndex + 2).": No action - no changes needed (username: $dbUsername)\n";
$logRow = [
'Action Type' => 'skipped',
'User ID' => $dbUser['id'],
'Username' => $dbUsername,
'Official Code' => $xlsxUserData['official_code'],
'E-mail' => $xlsxUserData['email'],
'E-mail source' => $emailSource,
'External User ID' => $xlsxMatricule,
'Updated Fields' => 'No changes needed',
];
$userActions[] = $logRow;
$userSkippedWhileActive[] = $logRow;
}
} else {
echo '['.$rowTime->format('H:i:s').'] Row '.($rowIndex + 2).": Insert new user - No existing user found (username: $dbUsername)\n";
if ($proceed) {
try {
$password = !empty($xlsxUserData['password']) ? $xlsxUserData['password'] : 'temporary_password';
$userId = $userManager->create_user(
$xlsxUserData['firstname'],
$xlsxUserData['lastname'],
5, // status (5 = student, adjust as needed)
$xlsxUserData['email'],
$dbUsername,
$password,
$xlsxUserData['official_code'],
'', // language
$xlsxUserData['phone'],
'',
null,
null,
$xlsxActive,
0,
null // creator_id
);
if ($userId) {
// Add extra field 'external_user_id'
$userManager->update_extra_field_value($userId, 'external_user_id', $xlsxMatricule);
echo " Success: Created user and set external_user_id (username: $dbUsername)\n";
$userActions[] = [
'Action Type' => 'created',
'User ID' => $userId,
'Username' => $dbUsername,
'Official Code' => $xlsxUserData['official_code'],
'E-mail' => $xlsxUserData['email'],
'E-mail source' => $emailSource,
'External User ID' => $xlsxMatricule,
'Updated Fields' => '',
];
} else {
echo " Error: Could not create user (username: $dbUsername)\n";
$logRow = [
'Action Type' => 'skipped',
'User ID' => '',
'Username' => $dbUsername,
'Official Code' => $xlsxUserData['official_code'],
'E-mail' => $xlsxUserData['email'],
'E-mail source' => $emailSource,
'External User ID' => $xlsxMatricule,
'Updated Fields' => 'Could not create user',
];
$userActions[] = $logRow;
$userSkippedWhileActive[] = $logRow;
}
} catch (Exception $e) {
echo " Error: Failed to insert user (username: $dbUsername): {$e->getMessage()}\n";
$logRow = [
'Action Type' => 'skipped',
'User ID' => '',
'Username' => $dbUsername,
'Official Code' => $xlsxUserData['official_code'],
'E-mail' => $xlsxUserData['email'],
'E-mail source' => $emailSource,
'External User ID' => $xlsxMatricule,
'Updated Fields' => 'Failed to insert user: '.$e->getMessage(),
];
$userActions[] = $logRow;
$userSkippedWhileActive[] = $logRow;
}
} else {
echo " Sim mode: Inserted user and external_user_id (username: $dbUsername)\n";
$userActions[] = [
'Action Type' => 'created',
'User ID' => '',
'Username' => $dbUsername,
'Official Code' => $xlsxUserData['official_code'],
'E-mail' => $xlsxUserData['email'],
'E-mail source' => $emailSource,
'External User ID' => $xlsxMatricule,
'Updated Fields' => '',
];
}
}
}
// Generate user actions XLSX file
$actionColumns = ['Action Type', 'User ID', 'Username', 'Official Code', 'E-mail', 'E-mail source', 'External User ID', 'Updated Fields'];
createMissingFieldFile($outputDir.'user_actions.xlsx', $userActions, $actionColumns);
createMissingFieldFile($outputDir.'skipped_user_actions.xlsx', $userSkippedWhileActive, $actionColumns);
if (!$proceed) {
echo "\nUse --proceed to apply changes to the database.\n";
} else {
echo "\nImport completed successfully.\n";
}
// Save terminal output to log file
$output = ob_get_clean();
echo $output; // Output to terminal
$endTime = new DateTime();
$output .= '['.$endTime->format('Y-m-d H:i:s')."] Script completed\n";
$logTimestamp = $startTime->format('YmdHis');
$logFile = $outputDir.'import-'.$logTimestamp.'.log';
if (!file_put_contents($logFile, $output)) {
echo "Error: Could not write to log file $logFile\n";
} else {
echo "Generated log file: $logFile\n";
}
+2 -8
View File
@@ -190,10 +190,7 @@ class langstats
$vars = [];
$priority = ['trad4all'];
foreach ($priority as $file) {
$list = SubLanguageManager::get_all_language_variable_in_file(
$path.$file.'.inc.php',
true
);
$list = SubLanguageManager::get_all_language_variable_in_file($path.$file.'.inc.php');
foreach ($list as $var => $trad) {
$vars[$var] = $file.'.inc.php';
}
@@ -203,10 +200,7 @@ class langstats
if (substr($file, 0, 1) == '.' or in_array($file, $priority)) {
continue;
}
$list = SubLanguageManager::get_all_language_variable_in_file(
$path.$file,
true
);
$list = SubLanguageManager::get_all_language_variable_in_file($path.$file);
foreach ($list as $var => $trad) {
$vars[$var] = $file;
}
+1 -1
View File
@@ -20,7 +20,7 @@ $list = SubLanguageManager::get_lang_folder_files_list($path);
foreach ($list as $entry) {
$file = $path.'/'.$entry;
if (is_file($file)) {
$terms = array_merge($terms, SubLanguageManager::get_all_language_variable_in_file($file, true));
$terms = array_merge($terms, SubLanguageManager::get_all_language_variable_in_file($file));
}
}
// get only the array keys (the language variables defined in language files)
+1 -1
View File
@@ -20,7 +20,7 @@ $list = SubLanguageManager::get_lang_folder_files_list($path);
foreach ($list as $entry) {
$file = $path.'/'.$entry;
if (is_file($file)) {
$terms = array_merge($terms, SubLanguageManager::get_all_language_variable_in_file($file, true));
$terms = array_merge($terms, SubLanguageManager::get_all_language_variable_in_file($file));
}
}
// get only the array keys (the language variables defined in language files)
+1 -1
View File
@@ -20,7 +20,7 @@ $list = SubLanguageManager::get_lang_folder_files_list($path);
foreach ($list as $entry) {
$file = $path.'/'.$entry;
if (is_file($file)) {
$terms = array_merge($terms, SubLanguageManager::get_all_language_variable_in_file($file, true));
$terms = array_merge($terms, SubLanguageManager::get_all_language_variable_in_file($file));
}
}
foreach ($terms as $index => $translation) {
+216
View File
@@ -0,0 +1,216 @@
<?php
/* For licensing terms, see /license.txt */
/**
* Class AnswerInOfficeDoc
* Allows a question type where the answer is written in an Office document.
*
* @author Cristian
*/
class AnswerInOfficeDoc extends Question
{
public $typePicture = 'options_evaluation.png';
public $explanationLangVar = 'AnswerInOfficeDoc';
public $sessionId;
public $userId;
public $exerciseId;
public $exeId;
private $storePath;
private $fileName;
private $filePath;
/**
* Constructor.
*/
public function __construct()
{
if ('true' !== OnlyofficePlugin::create()->get('enable_onlyoffice_plugin')) {
throw new Exception(get_lang('OnlyOfficePluginRequired'));
}
parent::__construct();
$this->type = ANSWER_IN_OFFICE_DOC;
$this->isContent = $this->getIsContent();
}
/**
* Initialize the file path structure.
*/
public function initFile(int $sessionId, int $userId, int $exerciseId, int $exeId): void
{
$this->sessionId = $sessionId ?: 0;
$this->userId = $userId;
$this->exerciseId = $exerciseId ?: 0;
$this->exeId = $exeId;
$this->storePath = $this->generateDirectory();
$this->fileName = $this->generateFileName();
$this->filePath = $this->storePath.$this->fileName;
}
/**
* Create form for uploading an Office document.
*/
public function createAnswersForm($form): void
{
if (!empty($this->exerciseList)) {
$this->exerciseId = reset($this->exerciseList);
}
$allowedFormats = [
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // .docx
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx
'application/msword', // .doc
'application/vnd.ms-excel', // .xls
];
$form->addElement('file', 'office_file', get_lang('UploadOfficeDoc'));
$form->addRule('office_file', get_lang('ThisFieldIsRequired'), 'required');
$form->addRule('office_file', get_lang('InvalidFileFormat'), 'mimetype', $allowedFormats);
$allowedExtensions = implode(', ', ['.docx', '.xlsx', '.doc', '.xls']);
$form->addElement('static', 'file_hint', get_lang('AllowedFormats'), "<p>{$allowedExtensions}</p>");
if (!empty($this->extra)) {
$fileUrl = api_get_path(WEB_COURSE_PATH).$this->getStoredFilePath();
$form->addElement('static', 'current_office_file', get_lang('CurrentOfficeDoc'), "<a href='{$fileUrl}' target='_blank'>{$this->extra}</a>");
}
$form->addText('weighting', get_lang('Weighting'), ['class' => 'span1']);
global $text;
$form->addButtonSave($text, 'submitQuestion');
if (!empty($this->iid)) {
$form->setDefaults(['weighting' => float_format($this->weighting, 1)]);
} else {
if ($this->isContent == 1) {
$form->setDefaults(['weighting' => '10']);
}
}
}
/**
* Process the uploaded document and save it.
*/
public function processAnswersCreation($form, $exercise): void
{
if (!empty($_FILES['office_file']['name'])) {
$extension = pathinfo($_FILES['office_file']['name'], PATHINFO_EXTENSION);
$tempFilename = "office_".uniqid().".".$extension;
$tempPath = sys_get_temp_dir().'/'.$tempFilename;
if (!move_uploaded_file($_FILES['office_file']['tmp_name'], $tempPath)) {
return;
}
$this->weighting = $form->getSubmitValue('weighting');
$this->extra = "";
$this->save($exercise);
$this->exerciseId = $exercise->iid;
$uploadDir = $this->generateDirectory();
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0775, true);
}
$filename = "office_".$this->iid.".".$extension;
$filePath = $uploadDir.$filename;
if (!rename($tempPath, $filePath)) {
return;
}
$this->extra = $filename;
$this->save($exercise);
}
}
/**
* Get the stored file path dynamically.
*/
public function getStoredFilePath(): ?string
{
if (empty($this->extra)) {
return null;
}
return "{$this->course['path']}/exercises/onlyoffice/{$this->exerciseId}/{$this->iid}/{$this->extra}";
}
/**
* Get the absolute file path. Returns null if the file doesn't exist.
*/
public function getFileUrl(bool $loadFromDatabase = false): ?string
{
if ($loadFromDatabase) {
$em = Database::getManager();
$result = $em->getRepository('ChamiloCoreBundle:TrackEAttempt')->findOneBy([
'exeId' => $this->exeId,
'userId' => $this->userId,
'questionId' => $this->iid,
'sessionId' => $this->sessionId,
'cId' => $this->course['real_id'],
]);
if (!$result || empty($result->getFilename())) {
return null;
}
$this->fileName = $result->getFilename();
} else {
if (empty($this->extra)) {
return null;
}
$this->fileName = $this->extra;
}
$filePath = $this->getStoredFilePath();
if (is_file(api_get_path(SYS_COURSE_PATH).$filePath)) {
return $filePath;
}
return null;
}
/**
* Show the question in an exercise.
*/
public function return_header(Exercise $exercise, $counter = null, $score = [])
{
$score['revised'] = $this->isQuestionWaitingReview($score);
$header = parent::return_header($exercise, $counter, $score);
$header .= '<table class="'.$this->question_table_class.'">
<tr>
<th>'.get_lang("Answer").'</th>
</tr>';
return $header;
}
/**
* Generate the necessary directory for OnlyOffice documents.
*/
private function generateDirectory(): string
{
$exercisePath = api_get_path(SYS_COURSE_PATH).$this->course['path']."/exercises/onlyoffice/{$this->exerciseId}/{$this->iid}/";
if (!is_dir($exercisePath)) {
mkdir($exercisePath, 0775, true);
}
return rtrim($exercisePath, '/').'/';
}
/**
* Generate the file name for the OnlyOffice document.
*/
private function generateFileName(): string
{
return 'office_'.uniqid();
}
}
+3 -9
View File
@@ -215,15 +215,9 @@ class Draggable extends Question
{
$header = parent::return_header($exercise, $counter, $score);
$header .= '<table class="'.$this->question_table_class.'"><tr>';
if ($exercise->showExpectedChoice()) {
$header .= '<th>'.get_lang('YourChoice').'</th>';
if ($exercise->showExpectedChoiceColumn()) {
$header .= '<th>'.get_lang('ExpectedChoice').'</th>';
}
} else {
$header .= '<th>'.get_lang('ElementList').'</th>';
$header .= '<th>'.get_lang('YourChoice').'</th>';
$header .= '<th>'.get_lang('ElementList').'</th>';
$header .= '<th>'.get_lang('YourChoice').'</th>';
if ($exercise->showExpectedChoice() || $exercise->showExpectedChoiceColumn()) {
$header .= '<th>'.get_lang('ExpectedChoice').'</th>';
}
$header .= '<th>'.get_lang('Status').'</th>';
+1 -1
View File
@@ -49,7 +49,7 @@ if ((api_is_allowed_to_edit(null, true))) {
}
}
Display::display_header(get_lang('ImportAikenQuiz'), 'Exercises');
Display::display_header(get_lang('ImportAikenQuiz'), 'Exercise');
aiken_display_form();
generateAikenForm();
Display::display_footer();
+1 -1
View File
@@ -1050,7 +1050,7 @@ class Answer
}
// Fix correct answers
if (in_array($newQuestion->type, [DRAGGABLE, MATCHING, MATCHING_DRAGGABLE])) {
if (in_array($newQuestion->type, [DRAGGABLE, MATCHING, MATCHING_DRAGGABLE, MATCHING_COMBINATION, MATCHING_DRAGGABLE_COMBINATION])) {
$onlyAnswersFlip = array_flip($onlyAnswers);
foreach ($correctAnswers as $answer_id => $correct_answer) {
$params = [];
+1 -1
View File
@@ -83,7 +83,7 @@ if ($exportXls) {
Export::arrayToXls($tableXls, $fileName);
exit;
}
Display::display_header($nameTools, get_lang('Exercise'));
Display::display_header($nameTools, 'Exercise');
$actions = '<a href="exercise_report.php?exerciseId='.$exerciseId.'&'.api_get_cidreq().'">'.
Display::return_icon(
'back.png',
+216 -111
View File
@@ -51,6 +51,7 @@ class Exercise
public $review_answers;
public $randomByCat;
public $text_when_finished;
public $text_when_finished_failure;
public $display_category_name;
public $pass_percentage;
public $edit_exercise_in_lp = false;
@@ -127,6 +128,7 @@ class Exercise
$this->review_answers = false;
$this->randomByCat = 0;
$this->text_when_finished = '';
$this->text_when_finished_failure = '';
$this->display_category_name = 0;
$this->pass_percentage = 0;
$this->modelType = 1;
@@ -204,6 +206,7 @@ class Exercise
$this->saveCorrectAnswers = $object->save_correct_answers;
$this->randomByCat = $object->random_by_category;
$this->text_when_finished = $object->text_when_finished;
$this->text_when_finished_failure = isset($object->text_when_finished_failure) ? $object->text_when_finished_failure : null;
$this->display_category_name = $object->display_category_name;
$this->pass_percentage = $object->pass_percentage;
$this->is_gradebook_locked = api_resource_is_locked_by_gradebook($id, LINK_EXERCISE);
@@ -456,6 +459,28 @@ class Exercise
$this->text_when_finished = $text;
}
/**
* Get the text to display when the user has failed the test.
*
* @return string html text : the text to display ay the end of the test
*/
public function getTextWhenFinishedFailure(): string
{
if (empty($this->text_when_finished_failure)) {
return '';
}
return $this->text_when_finished_failure;
}
/**
* Set the text to display when the user has succeeded in the test.
*/
public function setTextWhenFinishedFailure(string $text): void
{
$this->text_when_finished_failure = $text;
}
/**
* return 1 or 2 if randomByCat.
*
@@ -1440,56 +1465,6 @@ class Exercise
}
}
/**
* changes the exercise sound file.
*
* @author Olivier Brouckaert
*
* @param string $sound - exercise sound file
* @param string $delete - ask to delete the file
*/
public function updateSound($sound, $delete)
{
global $audioPath, $documentPath;
$TBL_DOCUMENT = Database::get_course_table(TABLE_DOCUMENT);
if ($sound['size'] && (strstr($sound['type'], 'audio') || strstr($sound['type'], 'video'))) {
$this->sound = $sound['name'];
if (@move_uploaded_file($sound['tmp_name'], $audioPath.'/'.$this->sound)) {
$sql = "SELECT 1 FROM $TBL_DOCUMENT
WHERE
c_id = ".$this->course_id." AND
path = '".str_replace($documentPath, '', $audioPath).'/'.$this->sound."'";
$result = Database::query($sql);
if (!Database::num_rows($result)) {
$id = add_document(
$this->course,
str_replace($documentPath, '', $audioPath).'/'.$this->sound,
'file',
$sound['size'],
$sound['name']
);
api_item_property_update(
$this->course,
TOOL_DOCUMENT,
$id,
'DocumentAdded',
api_get_user_id()
);
item_property_update_on_folder(
$this->course,
str_replace($documentPath, '', $audioPath),
api_get_user_id()
);
}
}
} elseif ($delete && is_file($audioPath.'/'.$this->sound)) {
$this->sound = '';
}
}
/**
* changes the exercise type.
*
@@ -1599,6 +1574,7 @@ class Exercise
$review_answers = isset($this->review_answers) && $this->review_answers ? 1 : 0;
$randomByCat = (int) $this->randomByCat;
$text_when_finished = $this->text_when_finished;
$text_when_finished_failure = $this->text_when_finished_failure;
$display_category_name = (int) $this->display_category_name;
$pass_percentage = (int) $this->pass_percentage;
$session_id = $this->sessionId;
@@ -1654,6 +1630,10 @@ class Exercise
'hide_question_title' => $this->getHideQuestionTitle(),
];
if (true === api_get_configuration_value('exercise_text_when_finished_failure')) {
$paramsExtra['text_when_finished_failure'] = $text_when_finished_failure;
}
$allow = api_get_configuration_value('allow_quiz_show_previous_button_setting');
if ($allow === true) {
$paramsExtra['show_previous_button'] = $this->showPreviousButton();
@@ -1758,6 +1738,10 @@ class Exercise
'hide_question_title' => $this->getHideQuestionTitle(),
];
if (true === api_get_configuration_value('exercise_text_when_finished_failure')) {
$params['text_when_finished_failure'] = $text_when_finished_failure;
}
$allow = api_get_configuration_value('allow_exercise_categories');
if (true === $allow) {
if (!empty($this->getExerciseCategoryId())) {
@@ -2278,6 +2262,12 @@ class Exercise
null,
get_lang('HideCorrectAnsweredQuestions')
),
$form->createElement(
'checkbox',
'hide_comment',
null,
get_lang('HideComment')
),
];
$form->addGroup($group, null, get_lang('ResultsConfigurationPage'));
}
@@ -2505,6 +2495,16 @@ class Exercise
$editor_config
);
if (true === api_get_configuration_value('exercise_text_when_finished_failure')) {
$form->addHtmlEditor(
'text_when_finished_failure',
get_lang('TextAppearingAtTheEndOfTheTestWhenTheUserHasFailed'),
false,
false,
$editor_config
);
}
$allow = api_get_configuration_value('allow_notification_setting_per_exercise');
if ($allow === true) {
$settings = ExerciseLib::getNotificationSettings();
@@ -2572,6 +2572,7 @@ class Exercise
'notifications',
'remedialcourselist',
'advancedcourselist',
'subscribe_session_when_finished_failure',
], //exclude
false, // filter
false, // tag as select
@@ -2632,6 +2633,25 @@ class Exercise
}
}
if (true === api_get_configuration_value('exercise_subscribe_session_when_finished_failure')) {
$optionSessionWhenFailure = [];
if ($failureSession = ExerciseLib::getSessionWhenFinishedFailure($this->iid)) {
$defaults['subscribe_session_when_finished_failure'] = $failureSession->getId();
$optionSessionWhenFailure[$failureSession->getId()] = $failureSession->getName();
}
$form->addSelectAjax(
'extra_subscribe_session_when_finished_failure',
get_lang('SubscribeSessionWhenFinishedFailure'),
$optionSessionWhenFailure,
[
'url' => api_get_path(WEB_AJAX_PATH).'session.ajax.php?'
.http_build_query(['a' => 'search_session']),
]
);
}
$settings = api_get_configuration_value('exercise_finished_notification_settings');
if (!empty($settings)) {
$options = [];
@@ -2687,6 +2707,10 @@ class Exercise
$defaults['exercise_category_id'] = $this->getExerciseCategoryId();
$defaults['prevent_backwards'] = $this->getPreventBackwards();
if (true === api_get_configuration_value('exercise_text_when_finished_failure')) {
$defaults['text_when_finished_failure'] = $this->getTextWhenFinishedFailure();
}
if (!empty($this->start_time)) {
$defaults['activate_start_date_check'] = 1;
}
@@ -2715,6 +2739,11 @@ class Exercise
$defaults['results_disabled'] = 0;
$defaults['randomByCat'] = 0;
$defaults['text_when_finished'] = '';
if (true === api_get_configuration_value('exercise_text_when_finished_failure')) {
$defaults['text_when_finished_failure'] = '';
}
$defaults['start_time'] = date('Y-m-d 12:00:00');
$defaults['display_category_name'] = 1;
$defaults['end_time'] = date('Y-m-d 12:00:00', time() + 84600);
@@ -2886,6 +2915,11 @@ class Exercise
$this->updateSaveCorrectAnswers($form->getSubmitValue('save_correct_answers'));
$this->updateRandomByCat($form->getSubmitValue('randomByCat'));
$this->updateTextWhenFinished($form->getSubmitValue('text_when_finished'));
if (true === api_get_configuration_value('exercise_text_when_finished_failure')) {
$this->setTextWhenFinishedFailure($form->getSubmitValue('text_when_finished_failure'));
}
$this->updateDisplayCategoryName($form->getSubmitValue('display_category_name'));
$this->updateReviewAnswers($form->getSubmitValue('review_answers'));
$this->updatePassPercentage($form->getSubmitValue('pass_percentage'));
@@ -3872,15 +3906,16 @@ class Exercise
if ($answerType == MULTIPLE_ANSWER_TRUE_FALSE_DEGREE_CERTAINTY) {
$choiceTmp = $choice;
$choice = isset($choiceTmp['choice']) ? $choiceTmp['choice'] : '';
$choiceDegreeCertainty = isset($choiceTmp['choiceDegreeCertainty']) ? $choiceTmp['choiceDegreeCertainty'] : '';
$choice = $choiceTmp['choice'] ?? '';
$choiceDegreeCertainty = $choiceTmp['choiceDegreeCertainty'] ?? '';
}
if ($answerType == FREE_ANSWER ||
$answerType == ORAL_EXPRESSION ||
$answerType == CALCULATED_ANSWER ||
$answerType == ANNOTATION ||
$answerType == UPLOAD_ANSWER
$answerType == UPLOAD_ANSWER ||
$answerType == ANSWER_IN_OFFICE_DOC
) {
$nbrAnswers = 1;
}
@@ -3888,12 +3923,12 @@ class Exercise
$generatedFile = '';
if ($answerType == ORAL_EXPRESSION) {
$exe_info = Event::get_exercise_results_by_attempt($exeId);
$exe_info = isset($exe_info[$exeId]) ? $exe_info[$exeId] : null;
$exe_info = $exe_info[$exeId] ?? null;
$objQuestionTmp->initFile(
api_get_session_id(),
isset($exe_info['exe_user_id']) ? $exe_info['exe_user_id'] : api_get_user_id(),
isset($exe_info['exe_exo_id']) ? $exe_info['exe_exo_id'] : $this->iid,
isset($exe_info['exe_id']) ? $exe_info['exe_id'] : $exeId
$exe_info['exe_user_id'] ?? api_get_user_id(),
$exe_info['exe_exo_id'] ?? $this->iid,
$exe_info['exe_id'] ?? $exeId
);
// Probably this attempt came in an exercise all question by page
@@ -4003,7 +4038,12 @@ class Exercise
$matchingCorrectAnswers = [];
for ($answerId = 1; $answerId <= $nbrAnswers; $answerId++) {
$answer = $objAnswerTmp->selectAnswer($answerId);
$answerComment = $objAnswerTmp->selectComment($answerId);
$hideComment = (int) $this->getPageConfigurationAttribute('hide_comment');
if (1 === $hideComment) {
$answerComment = null;
} else {
$answerComment = $objAnswerTmp->selectComment($answerId);
}
$answerCorrect = $objAnswerTmp->isCorrect($answerId);
$answerWeighting = (float) $objAnswerTmp->selectWeighting($answerId);
$answerAutoId = $objAnswerTmp->selectId($answerId);
@@ -4068,7 +4108,7 @@ class Exercise
$userAnsweredQuestion = !empty($choice);
}
$studentChoice = isset($choice[$answerAutoId]) ? $choice[$answerAutoId] : null;
$studentChoice = $choice[$answerAutoId] ?? null;
if (isset($studentChoice)) {
$correctAnswerId[] = $answerAutoId;
if ($studentChoice == $answerCorrect) {
@@ -4110,8 +4150,8 @@ class Exercise
}
}
$studentChoice = isset($choice[$answerAutoId]) ? $choice[$answerAutoId] : null;
$studentChoiceDegree = isset($choiceDegreeCertainty[$answerAutoId]) ? $choiceDegreeCertainty[$answerAutoId] : null;
$studentChoice = $choice[$answerAutoId] ?? null;
$studentChoiceDegree = $choiceDegreeCertainty[$answerAutoId] ?? null;
// student score update
if (!empty($studentChoice)) {
@@ -4150,14 +4190,14 @@ class Exercise
$choice[$row['answer']] = 1;
}
$studentChoice = isset($choice[$answerAutoId]) ? $choice[$answerAutoId] : null;
$studentChoice = $choice[$answerAutoId] ?? null;
$real_answers[$answerId] = (bool) $studentChoice;
if ($studentChoice) {
$questionScore += $answerWeighting;
}
} else {
$studentChoice = isset($choice[$answerAutoId]) ? $choice[$answerAutoId] : null;
$studentChoice = $choice[$answerAutoId] ?? null;
$real_answers[$answerId] = (bool) $studentChoice;
if (isset($studentChoice)
@@ -4178,13 +4218,13 @@ class Exercise
while ($row = Database::fetch_array($resultans)) {
$choice[$row['answer']] = 1;
}
$studentChoice = isset($choice[$answerAutoId]) ? $choice[$answerAutoId] : null;
$studentChoice = $choice[$answerAutoId] ?? null;
$real_answers[$answerId] = (bool) $studentChoice;
if ($studentChoice) {
$questionScore += $answerWeighting;
}
} else {
$studentChoice = isset($choice[$answerAutoId]) ? $choice[$answerAutoId] : null;
$studentChoice = $choice[$answerAutoId] ?? null;
if (isset($studentChoice)) {
$questionScore += $answerWeighting;
}
@@ -4209,13 +4249,13 @@ class Exercise
$choice[$my_answer_id] = $option;
}
}
$studentChoice = isset($choice[$answerAutoId]) ? $choice[$answerAutoId] : '';
$studentChoice = $choice[$answerAutoId] ?? '';
$real_answers[$answerId] = false;
if ($answerCorrect == $studentChoice) {
$real_answers[$answerId] = true;
}
} else {
$studentChoice = isset($choice[$answerAutoId]) ? $choice[$answerAutoId] : '';
$studentChoice = $choice[$answerAutoId] ?? '';
$real_answers[$answerId] = false;
if ($answerCorrect == $studentChoice) {
$real_answers[$answerId] = true;
@@ -4232,7 +4272,7 @@ class Exercise
$choice[$row['answer']] = 1;
}
$studentChoice = isset($choice[$answerAutoId]) ? $choice[$answerAutoId] : null;
$studentChoice = $choice[$answerAutoId] ?? null;
if (1 == $answerCorrect) {
$real_answers[$answerId] = false;
if ($studentChoice) {
@@ -4245,7 +4285,7 @@ class Exercise
}
}
} else {
$studentChoice = isset($choice[$answerAutoId]) ? $choice[$answerAutoId] : null;
$studentChoice = $choice[$answerAutoId] ?? null;
if (1 == $answerCorrect) {
$real_answers[$answerId] = false;
if ($studentChoice) {
@@ -4426,7 +4466,7 @@ class Exercise
if (!$switchableAnswerSet) {
// not switchable answer, must be in the same place than teacher order
for ($i = 0; $i < count($listCorrectAnswers['words']); $i++) {
$studentAnswer = isset($choice[$i]) ? $choice[$i] : '';
$studentAnswer = $choice[$i] ?? '';
$correctAnswer = $listCorrectAnswers['words'][$i];
if ($debug) {
@@ -4437,16 +4477,16 @@ class Exercise
// This value is the user input, not escaped while correct answer is escaped by ckeditor
// Works with cyrillic alphabet and when using ">" chars see #7718 #7610 #7618
// ENT_QUOTES is used in order to transform ' to &#039;
if (!$from_database) {
$studentAnswer = FillBlanks::clearStudentAnswer($studentAnswer);
if ($debug) {
error_log('Student answer cleaned:');
error_log($studentAnswer);
}
//if (!$from_database) {
$studentAnswer = FillBlanks::clearStudentAnswer($studentAnswer);
if ($debug) {
error_log('Student answer cleaned:');
error_log($studentAnswer);
}
//}
$isAnswerCorrect = 0;
if (FillBlanks::isStudentAnswerGood($studentAnswer, $correctAnswer, $from_database)) {
if (FillBlanks::isStudentAnswerGood($studentAnswer, $correctAnswer, $from_database, true)) {
// gives the related weighting to the student
$questionScore += $answerWeighting[$i];
// increments total score
@@ -4504,7 +4544,7 @@ class Exercise
$found = false;
for ($j = 0; $j < count($listTeacherAnswerTemp); $j++) {
$correctAnswer = isset($listTeacherAnswerTemp[$j]) ? $listTeacherAnswerTemp[$j] : '';
$correctAnswer = $listTeacherAnswerTemp[$j] ?? '';
if (is_array($listTeacherAnswerTemp)) {
$correctAnswer = implode('||', $listTeacherAnswerTemp);
}
@@ -4684,6 +4724,7 @@ class Exercise
break;
case UPLOAD_ANSWER:
case FREE_ANSWER:
case ANSWER_IN_OFFICE_DOC:
if ($from_database) {
$sql = "SELECT answer, marks FROM $TBL_TRACK_ATTEMPT
WHERE
@@ -4904,7 +4945,7 @@ class Exercise
if (false === $this->showExpectedChoice() &&
false === $showTotalScoreAndUserChoicesInLastAttempt
) {
$user_answer = '';
$this->hideExpectedAnswer = true;
}
switch ($answerType) {
case MATCHING:
@@ -4966,9 +5007,6 @@ class Exercise
echo '</tr>';
break;
case DRAGGABLE:
if (false == $showTotalScoreAndUserChoicesInLastAttempt) {
$s_answer_label = '';
}
if (RESULT_DISABLE_SHOW_SCORE_ATTEMPT_SHOW_ANSWERS_LAST_ATTEMPT_NO_FEEDBACK == $this->results_disabled) {
if (false === $showTotalScoreAndUserChoicesInLastAttempt && empty($s_user_answer)) {
break;
@@ -4976,35 +5014,15 @@ class Exercise
}
echo '<tr>';
if ($this->showExpectedChoice()) {
if (!in_array($this->results_disabled, [
RESULT_DISABLE_SHOW_ONLY_IN_CORRECT_ANSWER,
//RESULT_DISABLE_SHOW_SCORE_AND_EXPECTED_ANSWERS_AND_RANKING,
])
) {
echo '<td>'.$user_answer.'</td>';
} else {
$status = Display::label(get_lang('Correct'), 'success');
}
if ($this->showExpectedChoice() || $this->showExpectedChoiceColumn()) {
echo '<td>'.$s_answer_label.'</td>';
echo '<td>'.$user_answer.'</td>';
echo '<td>'.$real_list[$i_answer_correct_answer].'</td>';
echo '<td>'.$status.'</td>';
} else {
echo '<td>'.$s_answer_label.'</td>';
echo '<td>'.$user_answer.'</td>';
echo '<td>'.$counterAnswer.'</td>';
echo '<td>'.$status.'</td>';
echo '<td>';
if (in_array($answerType, [MATCHING, MATCHING_COMBINATION, MATCHING_DRAGGABLE, MATCHING_DRAGGABLE_COMBINATION])) {
if (isset($real_list[$i_answer_correct_answer]) &&
$showTotalScoreAndUserChoicesInLastAttempt === true
) {
echo Display::span(
$real_list[$i_answer_correct_answer],
['style' => 'color: #008000; font-weight: bold;']
);
}
}
echo '</td>';
}
echo '</tr>';
break;
@@ -5345,6 +5363,18 @@ class Exercise
$questionScore,
$results_disabled
);
} elseif ($answerType == ANSWER_IN_OFFICE_DOC) {
$exe_info = Event::get_exercise_results_by_attempt($exeId);
$exe_info = $exe_info[$exeId] ?? null;
ExerciseShowFunctions::displayOnlyOfficeAnswer(
$feedback_type,
$exeId,
$exe_info['exe_user_id'] ?? api_get_user_id(),
$this->iid,
$questionId,
$questionScore,
true
);
} elseif ($answerType == ORAL_EXPRESSION) {
// to store the details of open questions in an array to be used in mail
/** @var OralExpression $objQuestionTmp */
@@ -5743,9 +5773,21 @@ class Exercise
$results_disabled
);
break;
case ANSWER_IN_OFFICE_DOC:
$exe_info = Event::get_exercise_results_by_attempt($exeId);
$exe_info = $exe_info[$exeId] ?? null;
ExerciseShowFunctions::displayOnlyOfficeAnswer(
$feedback_type,
$exeId,
$exe_info['exe_user_id'] ?? api_get_user_id(),
$this->iid,
$questionId,
$questionScore
);
break;
case ORAL_EXPRESSION:
echo '<tr>
<td valign="top">'.
<td>'.
ExerciseShowFunctions::display_oral_expression_answer(
$feedback_type,
$choice,
@@ -6385,6 +6427,24 @@ class Exercise
false,
$questionDuration
);
} elseif ($answerType == ANSWER_IN_OFFICE_DOC) {
$answer = $choice;
$exerciseId = $this->iid;
$questionId = $quesId;
$originalFilePath = $objQuestionTmp->getFileUrl();
$originalExtension = !empty($originalFilePath) ? pathinfo($originalFilePath, PATHINFO_EXTENSION) : 'docx';
$fileName = "response_{$exeId}.{$originalExtension}";
Event::saveQuestionAttempt(
$questionScore,
$answer,
$questionId,
$exeId,
0,
$exerciseId,
false,
$questionDuration,
$fileName
);
} elseif ($answerType == ORAL_EXPRESSION) {
$answer = $choice;
$absFilePath = $objQuestionTmp->getAbsoluteFilePath();
@@ -8816,6 +8876,7 @@ class Exercise
'hide_total_score' => $values['hide_total_score'] ?? '',
'hide_category_table' => $values['hide_category_table'] ?? '',
'hide_correct_answered_questions' => $values['hide_correct_answered_questions'] ?? '',
'hide_comment' => $values['hide_comment'] ?? '',
];
$type = Type::getType('array');
$platform = Database::getManager()->getConnection()->getDatabasePlatform();
@@ -9909,15 +9970,31 @@ class Exercise
);
} else {
if ($row['active'] == 0 || $visibility == 0) {
$visibility = Display::url(
Display::return_icon(
$visibleOnBaseCourse = api_get_item_visibility(
$courseInfo,
TOOL_QUIZ,
$row['iid'],
0
);
if ($visibleOnBaseCourse) {
$visibility = Display::url(
Display::return_icon(
'invisible.png',
get_lang('Activate'),
'',
ICON_SIZE_SMALL
),
'exercise.php?'.api_get_cidreq().'&choice=enable&sec_token='.$token.'&exerciseId='.$row['iid']
);
} else {
$visibility = Display::return_icon(
'invisible.png',
get_lang('Activate'),
'',
ICON_SIZE_SMALL
),
'exercise.php?'.api_get_cidreq().'&choice=enable&sec_token='.$token.'&exerciseId='.$row['iid']
);
);
}
} else {
// else if not active
$visibility = Display::url(
@@ -11759,6 +11836,34 @@ class Exercise
return $result;
}
/**
* Return the text to display, based on the score and the max score.
*
* @param int|float $score
* @param int|float $maxScore
*/
public function getFinishText($score, $maxScore): string
{
if (true !== api_get_configuration_value('exercise_text_when_finished_failure')) {
return $this->getTextWhenFinished();
}
$passPercentage = $this->selectPassPercentage();
if (!empty($passPercentage)) {
$percentage = float_format(
($score / (0 != $maxScore ? $maxScore : 1)) * 100,
1
);
if ($percentage < $passPercentage) {
return $this->getTextWhenFinishedFailure();
}
}
return $this->getTextWhenFinished();
}
/**
* Get number of questions in exercise by user attempt.
*
+39 -1
View File
@@ -266,6 +266,22 @@ if (!empty($action) && $is_allowedToEdit) {
break;
}
if (!empty($sessionId)) {
$visibleOnBaseCourse = api_get_item_visibility(
$courseInfo,
TOOL_QUIZ,
$objExerciseTmp->iid,
0
);
if (!$visibleOnBaseCourse) {
Display::addFlash(Display::return_message(
sprintf(get_lang('CannotChangeVisibilityOfBaseCourseResourceX'), $objExerciseTmp->name),
'error'
));
break;
}
}
// enables an exercise
if (empty($sessionId)) {
$objExerciseTmp->enable();
@@ -368,6 +384,22 @@ if ($is_allowedToEdit) {
break;
}
if (!empty($sessionId)) {
$visibleOnBaseCourse = api_get_item_visibility(
$courseInfo,
TOOL_QUIZ,
$objExerciseTmp->iid,
0
);
if (!$visibleOnBaseCourse) {
Display::addFlash(Display::return_message(
sprintf(get_lang('CannotChangeVisibilityOfBaseCourseResourceX'), $objExerciseTmp->name),
'error'
));
break;
}
}
// Enables an exercise
if (empty($sessionId)) {
$objExerciseTmp->enable();
@@ -520,6 +552,12 @@ if ($is_allowedToEdit) {
// Teacher change exercise
break;
}
// Security: reject path traversal attempts (CWE-22)
if (!Security::check_abs_path($documentPath.$file, $documentPath.'/')) {
api_not_allowed(true);
}
// deletes an exercise
$imgparams = [];
$imgcount = 0;
@@ -600,7 +638,7 @@ if ($is_allowedToEdit) {
if (!in_array($origin, ['learnpath', 'mobileapp'])) {
//so we are not in learnpath tool
Display::display_header($nameTools, get_lang('Exercise'));
Display::display_header($nameTools, 'Exercise');
if (isset($_GET['message']) && in_array($_GET['message'], ['ExerciseEdited'])) {
echo Display::return_message(get_lang('ExerciseEdited'), 'confirmation');
}
+1 -1
View File
@@ -181,7 +181,7 @@ if ($form->validate()) {
'name' => $objExercise->selectTitle(true),
];
Display::display_header($nameTools, get_lang('Exercise'));
Display::display_header($nameTools, 'Exercise');
echo '<div class="actions">';
if ($objExercise->iid != 0) {
+2 -2
View File
@@ -39,7 +39,7 @@ $TBL_USER = Database::get_main_table(TABLE_MAIN_USER);
$TBL_EXERCISES = Database::get_course_table(TABLE_QUIZ_TEST);
$TBL_EXERCISES_QUESTION = Database::get_course_table(TABLE_QUIZ_QUESTION);
$TBL_TRACK_ATTEMPT_RECORDING = Database::get_main_table(TABLE_STATISTIC_TRACK_E_ATTEMPT_RECORDING);
Display::display_header($nameTools, get_lang('Exercise'));
Display::display_header(get_lang('ViewHistoryChange'), 'Exercise');
if (isset($_GET['message'])) {
if (in_array($_GET['message'], ['ExerciseEdited'])) {
@@ -79,7 +79,7 @@ while ($row = Database::fetch_array($query)) {
echo '<td>'.$row['question'].'</td>';
echo '<td>'.$row['marks'].'</td>';
if (!empty($row['teacher_comment'])) {
echo '<td>'.$row['teacher_comment'].'</td>';
echo '<td>'.Security::remove_XSS($row['teacher_comment']).'</td>';
} else {
echo '<td>'.get_lang('WithoutComment').'</td>';
}
+1 -1
View File
@@ -96,7 +96,7 @@ $interbreadcrumb[] = ['url' => 'exercise.php?'.api_get_cidreq(), 'name' => get_l
$hideHeaderAndFooter = in_array($origin, ['learnpath', 'embeddable', 'iframe']);
if (!$hideHeaderAndFooter) {
Display::display_header($nameTools, get_lang('Exercise'));
Display::display_header($nameTools, 'Exercise');
} else {
Display::display_reduced_header();
}
+1 -1
View File
@@ -82,7 +82,7 @@ $interbreadcrumb[] = ['url' => 'exercise.php?'.api_get_cidreq(), 'name' => get_l
$hideHeaderAndFooter = in_array($origin, ['learnpath', 'embeddable', 'iframe']);
if (!$hideHeaderAndFooter) {
Display::display_header($nameTools, get_lang('Exercise'));
Display::display_header($nameTools, 'Exercise');
} else {
Display::display_reduced_header();
}
+15 -3
View File
@@ -60,7 +60,7 @@ $locked = api_resource_is_locked_by_gradebook($exercise_id, LINK_EXERCISE);
$sessionId = api_get_session_id();
$action = isset($_REQUEST['action']) ? $_REQUEST['action'] : null;
if ('export_all_exercises_results' !== $action) {
if ('export_all_exercises_results' !== $action && !(isset($_GET['delete']) && $_GET['delete'] === 'delete')) {
if (empty($exercise_id)) {
api_not_allowed(true);
}
@@ -211,7 +211,7 @@ if (isset($_REQUEST['comments']) &&
// From the database.
$marksFromDatabase = $questionListData[$questionId]['marks'];
if (in_array($question->type, [FREE_ANSWER, ORAL_EXPRESSION, ANNOTATION, UPLOAD_ANSWER])) {
if (in_array($question->type, [FREE_ANSWER, ORAL_EXPRESSION, ANNOTATION, UPLOAD_ANSWER, ANSWER_IN_OFFICE_DOC])) {
// From the form.
$params['marks'] = $marks;
if ($marksFromDatabase != $marks) {
@@ -312,7 +312,13 @@ if (isset($_REQUEST['comments']) &&
if (isset($_POST['send_notification'])) {
//@todo move this somewhere else
$subject = get_lang('ExamSheetVCC');
$message = isset($_POST['notification_content']) ? $_POST['notification_content'] : '';
$message = $_POST['notification_content'] ?? '';
$feedbackComments = ExerciseLib::getFeedbackComments($id);
$message .= "<div style='padding:10px; border:1px solid #ddd; border-radius:5px;'>";
$message .= $feedbackComments;
$message .= "</div>";
MessageManager::send_message_simple(
$student_id,
$subject,
@@ -500,6 +506,8 @@ if ($is_allowedToEdit && $origin !== 'learnpath') {
'comparative_group_report.php?'.api_get_cidreq().'&id='.$exercise_id,
['class' => 'btn btn-default']
);
$actions .= ExerciseFocusedPlugin::create()->getLinkReporting($exercise_id);
}
} else {
$actions .= '<a href="exercise.php">'.
@@ -521,6 +529,10 @@ if (($is_allowedToEdit || $is_tutor || api_is_coach()) &&
if (!empty($exe_id)) {
ExerciseLib::deleteExerciseAttempt($exe_id);
if (empty($exercise_id)) {
header('Location: pending.php');
exit;
}
header('Location: exercise_report.php?'.api_get_cidreq().'&exerciseId='.$exercise_id);
exit;
}
+5 -5
View File
@@ -491,12 +491,12 @@ class ExerciseResult
$filename = 'exercise_results_user_'.$user_id.'_'.$now.'.xlsx';
}
$spreadsheet = new PHPExcel();
$spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet();
$spreadsheet->setActiveSheetIndex(0);
$worksheet = $spreadsheet->getActiveSheet();
$line = 1; // Skip first line
$column = 0; //skip the first column (row titles)
$line = 1;
$column = 1;
// check if exists column 'user'
$with_column_user = false;
@@ -584,7 +584,7 @@ class ExerciseResult
$line++;
foreach ($this->results as $row) {
$column = 0;
$column = 1;
if ($with_column_user) {
if (api_is_western_name_order()) {
$worksheet->setCellValueByColumnAndRow(
@@ -738,7 +738,7 @@ class ExerciseResult
}
$file = api_get_path(SYS_ARCHIVE_PATH).api_replace_dangerous_char($filename);
$writer = new PHPExcel_Writer_Excel2007($spreadsheet);
$writer = new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet);
$writer->save($file);
DocumentManager::file_send_for_download($file, true, $filename);
+5 -6
View File
@@ -111,11 +111,10 @@ if (api_is_course_admin() && !in_array($origin, ['learnpath', 'embeddable', 'ifr
);
}
$exercise_stat_info = $objExercise->get_stat_track_exercise_info_by_exe_id($exeId);
$learnpath_id = isset($exercise_stat_info['orig_lp_id']) ? $exercise_stat_info['orig_lp_id'] : 0;
$learnpath_item_id = isset($exercise_stat_info['orig_lp_item_id']) ? $exercise_stat_info['orig_lp_item_id'] : 0;
$learnpath_item_view_id = isset($exercise_stat_info['orig_lp_item_view_id'])
? $exercise_stat_info['orig_lp_item_view_id'] : 0;
$exerciseId = isset($exercise_stat_info['exe_exo_id']) ? $exercise_stat_info['exe_exo_id'] : 0;
$learnpath_id = $exercise_stat_info['orig_lp_id'] ?? 0;
$learnpath_item_id = $exercise_stat_info['orig_lp_item_id'] ?? 0;
$learnpath_item_view_id = $exercise_stat_info['orig_lp_item_view_id'] ?? 0;
$exerciseId = $exercise_stat_info['exe_exo_id'] ?? 0;
$logInfo = [
'tool' => TOOL_QUIZ,
@@ -357,7 +356,7 @@ $template->assign('actions', $pageActions);
$template->assign('content', $template->fetch($template->get_template('exercise/result.tpl')));
$template->display_one_col_template();
function showEmbeddableFinishButton()
function showEmbeddableFinishButton(): string
{
$js = '<script>
$(function () {
+19 -12
View File
@@ -379,13 +379,6 @@ if (!empty($track_exercise_info['data_tracking'])) {
$questionList = $question_list_from_database;
}
// Display the text when finished message if we are on a LP #4227
$end_of_message = $objExercise->getTextWhenFinished();
if (!empty($end_of_message) && ($origin === 'learnpath')) {
echo Display::return_message($end_of_message, 'normal', false);
echo "<div class='clear'>&nbsp;</div>";
}
// for each question
$total_weighting = 0;
foreach ($questionList as $questionId) {
@@ -416,7 +409,7 @@ if ($allowRecordAudio && $allowTeacherCommentAudio) {
}
foreach ($questionList as $questionId) {
$choice = isset($exerciseResult[$questionId]) ? $exerciseResult[$questionId] : '';
$choice = $exerciseResult[$questionId] ?? '';
// destruction of the Question object
unset($objQuestionTmp);
$questionWeighting = 0;
@@ -451,6 +444,7 @@ foreach ($questionList as $questionId) {
case GLOBAL_MULTIPLE_ANSWER:
case FREE_ANSWER:
case UPLOAD_ANSWER:
case ANSWER_IN_OFFICE_DOC:
case ORAL_EXPRESSION:
case MATCHING:
case MATCHING_COMBINATION:
@@ -619,7 +613,7 @@ foreach ($questionList as $questionId) {
if ($isFeedbackAllowed && $action !== 'export') {
$name = 'fckdiv'.$questionId;
$marksname = 'marksName'.$questionId;
if (in_array($answerType, [FREE_ANSWER, ORAL_EXPRESSION, ANNOTATION, UPLOAD_ANSWER])) {
if (in_array($answerType, [FREE_ANSWER, ORAL_EXPRESSION, ANNOTATION, UPLOAD_ANSWER, ANSWER_IN_OFFICE_DOC])) {
$url_name = get_lang('EditCommentsAndMarks');
} else {
$url_name = get_lang('AddComments');
@@ -696,7 +690,7 @@ foreach ($questionList as $questionId) {
}
if ($is_allowedToEdit && $isFeedbackAllowed && $action !== 'export') {
if (in_array($answerType, [FREE_ANSWER, ORAL_EXPRESSION, ANNOTATION, UPLOAD_ANSWER])) {
if (in_array($answerType, [FREE_ANSWER, ORAL_EXPRESSION, ANNOTATION, UPLOAD_ANSWER, ANSWER_IN_OFFICE_DOC])) {
$marksname = 'marksName'.$questionId;
$arrmarks[] = $questionId;
@@ -853,7 +847,7 @@ foreach ($questionList as $questionId) {
}
}
if (in_array($objQuestionTmp->type, [FREE_ANSWER, ORAL_EXPRESSION, ANNOTATION, UPLOAD_ANSWER])) {
if (in_array($objQuestionTmp->type, [FREE_ANSWER, ORAL_EXPRESSION, ANNOTATION, UPLOAD_ANSWER, ANSWER_IN_OFFICE_DOC])) {
$scoreToReview = [
'score' => $my_total_score,
'comments' => isset($comnt) ? $comnt : null,
@@ -884,6 +878,13 @@ foreach ($questionList as $questionId) {
$exercise_content .= Display::panel($question_content);
} // end of large foreach on questions
// Display the text when finished message if we are on a LP #4227
$end_of_message = $objExercise->getFinishText($totalScore, $totalWeighting);
if (!empty($end_of_message) && ($origin === 'learnpath')) {
echo Display::return_message($end_of_message, 'normal', false);
echo "<div class='clear'>&nbsp;</div>";
}
$totalScoreText = '';
if ($answerType != MULTIPLE_ANSWER_TRUE_FALSE_DEGREE_CERTAINTY) {
$pluginEvaluation = QuestionOptionsEvaluationPlugin::create();
@@ -973,9 +974,15 @@ if ('export' === $action) {
$content = Security::remove_XSS($content);
$includeOfficialCode = "";
if (true === api_get_configuration_value('quiz_result_pdf_export_include_official_code_in_file_name')) {
$includeOfficialCode = $user_info['official_code'].' ';
}
$params = [
'filename' => api_replace_dangerous_char(
$objExercise->name.' '.
$includeOfficialCode.
$user_info['complete_name'].' '.
api_get_local_time()
),
@@ -998,7 +1005,7 @@ if ('export' === $action) {
if (!is_dir($exportFolderPath)) {
@mkdir($exportFolderPath);
}
$pdfFileName = $user_info['firstname'].' '.$user_info['lastname'].'-attemptId'.$id.'.pdf';
$pdfFileName = $includeOfficialCode.$user_info['firstname'].' '.$user_info['lastname'].'-attemptId'.$id.'.pdf';
$pdfFileName = api_replace_dangerous_char($pdfFileName);
$fileNameToSave = $exportFolderPath.'/'.$pdfFileName;
$pdf->html_to_pdf_with_template($content, true, false, true, [], 'F', $fileNameToSave);
+7 -7
View File
@@ -123,11 +123,11 @@ $learnpath_item_view_id = isset($_REQUEST['learnpath_item_view_id']) ? (int) $_R
$reminder = isset($_REQUEST['reminder']) ? (int) $_REQUEST['reminder'] : 0;
$remind_question_id = isset($_REQUEST['remind_question_id']) ? (int) $_REQUEST['remind_question_id'] : 0;
$exerciseId = isset($_REQUEST['exerciseId']) ? (int) $_REQUEST['exerciseId'] : 0;
$formSent = isset($_REQUEST['formSent']) ? $_REQUEST['formSent'] : null;
$exerciseResult = isset($_REQUEST['exerciseResult']) ? $_REQUEST['exerciseResult'] : null;
$exerciseResultCoordinates = isset($_REQUEST['exerciseResultCoordinates']) ? $_REQUEST['exerciseResultCoordinates'] : null;
$choice = isset($_REQUEST['choice']) ? $_REQUEST['choice'] : null;
$choice = empty($choice) ? isset($_REQUEST['choice2']) ? $_REQUEST['choice2'] : null : null;
$formSent = $_REQUEST['formSent'] ?? null;
$exerciseResult = $_REQUEST['exerciseResult'] ?? null;
$exerciseResultCoordinates = $_REQUEST['exerciseResultCoordinates'] ?? null;
$choice = $_REQUEST['choice'] ?? null;
$choice = empty($choice) ? $_REQUEST['choice2'] ?? null : null;
$current_question = $currentQuestionFromUrl = isset($_REQUEST['num']) ? (int) $_REQUEST['num'] : null;
$currentAnswer = isset($_REQUEST['num_answer']) ? (int) $_REQUEST['num_answer'] : null;
$logInfo = [
@@ -1030,7 +1030,7 @@ if (false !== $quizKeepAlivePingInterval) {
if (!in_array($origin, ['learnpath', 'embeddable', 'mobileapp', 'iframe'])) {
//so we are not in learnpath tool
SessionManager::addFlashSessionReadOnly();
Display::display_header(null, 'Exercises');
Display::display_header(null, 'Exercise');
} else {
Display::display_reduced_header();
echo '<div style="height:10px">&nbsp;</div>';
@@ -1081,7 +1081,7 @@ if (!api_is_allowed_to_session_edit()) {
}
$exercise_timeover = false;
$limit_time_exists = !empty($objExercise->start_time) || !empty($objExercise->end_time) ? true : false;
$limit_time_exists = !empty($objExercise->start_time) || !empty($objExercise->end_time);
if ($limit_time_exists) {
$exercise_start_time = api_strtotime($objExercise->start_time, 'UTC');
$exercise_end_time = api_strtotime($objExercise->end_time, 'UTC');
+32 -15
View File
@@ -44,8 +44,8 @@ function aiken_display_form()
}
/**
* Generates aiken format using AI api.
* Requires plugin ai_helper to connect to the api.
* Generates Aiken format using AI APIs (supports multiple providers).
* Requires plugin ai_helper to connect to the API.
*/
function generateAikenForm()
{
@@ -53,6 +53,12 @@ function generateAikenForm()
return false;
}
$plugin = AiHelperPlugin::create();
$availableApis = $plugin->getApiList();
$configuredApi = $plugin->get('api_name');
$hasSingleApi = count($availableApis) === 1 || isset($availableApis[$configuredApi]);
$form = new FormValidator(
'aiken_generate',
'post',
@@ -60,20 +66,31 @@ function generateAikenForm()
null
);
$form->addElement('header', get_lang('AIQuestionsGenerator'));
$form->addElement('text', 'quiz_name', [get_lang('QuestionsTopic'), get_lang('QuestionsTopicHelp')]);
if ($hasSingleApi) {
$apiName = $availableApis[$configuredApi] ?? $configuredApi;
$form->addHtml('<div style="margin-bottom: 10px; font-size: 14px; color: #555;">'
.sprintf(get_lang('UsingAIProviderX'), '<strong>'.htmlspecialchars($apiName).'</strong>').'</div>');
}
$form->addElement('text', 'quiz_name', get_lang('QuestionsTopic'));
$form->addRule('quiz_name', get_lang('ThisFieldIsRequired'), 'required');
$form->addElement('number', 'nro_questions', [get_lang('NumberOfQuestions'), get_lang('AIQuestionsGeneratorNumberHelper')]);
$form->addElement('number', 'nro_questions', get_lang('NumberOfQuestions'));
$form->addRule('nro_questions', get_lang('ThisFieldIsRequired'), 'required');
$options = [
'multiple_choice' => get_lang('MultipleAnswer'),
];
$form->addElement(
'select',
'question_type',
get_lang('QuestionType'),
$options
);
$form->addElement('select', 'question_type', get_lang('QuestionType'), $options);
if (!$hasSingleApi) {
$form->addElement(
'select',
'ai_provider',
get_lang('AIProvider'),
array_combine(array_keys($availableApis), array_keys($availableApis))
);
}
$generateUrl = api_get_path(WEB_PLUGIN_PATH).'ai_helper/tool/answers.php';
$language = api_get_interface_language();
@@ -87,10 +104,9 @@ function generateAikenForm()
var btnGenerate = $(this);
var quizName = $("[name=\'quiz_name\']").val();
var nroQ = parseInt($("[name=\'nro_questions\']").val());
var qType = $("[name=\'question_type\']").val();
var valid = (quizName != \'\' && nroQ > 0);
var qWeight = 1;
var qType = $("[name=\'question_type\']").val();'
.(!$hasSingleApi ? 'var provider = $("[name=\'ai_provider\']").val();' : 'var provider = "'.$configuredApi.'";').
'var valid = (quizName != \'\' && nroQ > 0);
if (valid) {
btnGenerate.attr("disabled", true);
btnGenerate.text("'.get_lang('PleaseWaitThisCouldTakeAWhile').'");
@@ -100,7 +116,8 @@ function generateAikenForm()
"quiz_name": quizName,
"nro_questions": nroQ,
"question_type": qType,
"language": "'.$language.'"
"language": "'.$language.'",
"ai_provider": provider
}).done(function (data) {
btnGenerate.attr("disabled", false);
btnGenerate.text("'.get_lang('Generate').'");
+2 -1
View File
@@ -688,7 +688,8 @@ function isQtiManifest($filePath)
*/
function qtiProcessManifest($filePath)
{
$xml = simplexml_load_file($filePath);
libxml_use_internal_errors(true);
$xml = simplexml_load_file($filePath, SimpleXMLElement::class, LIBXML_NONET);
$course = api_get_course_info();
$sessionId = api_get_session_id();
$courseDir = $course['path'];
+50 -27
View File
@@ -404,6 +404,8 @@ class FillBlanks extends Question
// remove starting and ending space and &nbsp;
$answer = api_preg_replace("/\xc2\xa0/", " ", $answer);
// remove invisible Unicode characters introduced by word processors
$answer = self::stripInvisibleChars($answer);
// start and end separator
$blankStartSeparator = self::getStartSeparator($form->getSubmitValue('select_separator'));
@@ -748,13 +750,13 @@ class FillBlanks extends Question
$listSeveral
);
//$studentAnswer = htmlspecialchars($studentAnswer);
$result = in_array($studentAnswer, $listSeveral);
$result = in_array(self::trimOption($studentAnswer), $listSeveral);
break;
case self::FILL_THE_BLANK_STANDARD:
default:
$correctAnswer = api_html_entity_decode($correctAnswer);
//$studentAnswer = htmlspecialchars($studentAnswer);
$result = $studentAnswer == self::trimOption($correctAnswer);
$result = self::trimOption($studentAnswer) == self::trimOption($correctAnswer);
break;
}
@@ -824,15 +826,14 @@ class FillBlanks extends Question
$listDetails = explode(':', $listArobaseSplit[0]);
// < number of item after the ::[score]:[size]:[separator_id]@ , here there are 3
$listWeightings = explode(',', $listDetails[0]);
if (count($listDetails) < 3) {
$listWeightings = explode(',', $listDetails[0]);
$listSizeOfInput = [];
for ($i = 0; $i < count($listWeightings); $i++) {
$listSizeOfInput[] = 200;
}
$blankSeparatorNumber = 0; // 0 is [...]
} else {
$listWeightings = explode(',', $listDetails[0]);
$listSizeOfInput = explode(',', $listDetails[1]);
$blankSeparatorNumber = $listDetails[2];
}
@@ -893,7 +894,7 @@ class FillBlanks extends Question
// should always be
$i++;
}
$listAnswerResults['student_answer'][] = $listAnswerResults['words'][$i];
$listAnswerResults['student_answer'][] = Security::remove_XSS($listAnswerResults['words'][$i]);
if ($i + 1 < count($listAnswerResults['words'])) {
// should always be
$i++;
@@ -1201,7 +1202,8 @@ class FillBlanks extends Question
$answer,
$feedbackType,
$resultsDisabled = false,
$showTotalScoreAndUserChoices = false
$showTotalScoreAndUserChoices = false,
$exercise
) {
$result = '';
$listStudentAnswerInfo = self::getAnswerInfo($answer, true);
@@ -1215,7 +1217,8 @@ class FillBlanks extends Question
$listStudentAnswerInfo['words'][$i],
$feedbackType,
$resultsDisabled,
$showTotalScoreAndUserChoices
$showTotalScoreAndUserChoices,
$exercise
);
} else {
$listStudentAnswerInfo['student_answer'][$i] = self::getHtmlWrongAnswer(
@@ -1223,7 +1226,8 @@ class FillBlanks extends Question
$listStudentAnswerInfo['words'][$i],
$feedbackType,
$resultsDisabled,
$showTotalScoreAndUserChoices
$showTotalScoreAndUserChoices,
$exercise
);
}
}
@@ -1235,13 +1239,13 @@ class FillBlanks extends Question
continue;
}
}
$result .= isset($listStudentAnswerInfo['common_words'][$i]) ? $listStudentAnswerInfo['common_words'][$i] : '';
$studentLabel = isset($listStudentAnswerInfo['student_answer'][$i]) ? $listStudentAnswerInfo['student_answer'][$i] : '';
$result .= $listStudentAnswerInfo['common_words'][$i] ?? '';
$studentLabel = $listStudentAnswerInfo['student_answer'][$i] ?? '';
$result .= $studentLabel;
}
// the last common word (should be </p>)
$result .= isset($listStudentAnswerInfo['common_words'][$i]) ? $listStudentAnswerInfo['common_words'][$i] : '';
$result .= $listStudentAnswerInfo['common_words'][$i] ?? '';
return $result;
}
@@ -1255,8 +1259,6 @@ class FillBlanks extends Question
* @param int $feedbackType
* @param bool $resultsDisabled
* @param bool $showTotalScoreAndUserChoices
*
* @return string
*/
public static function getHtmlAnswer(
$answer,
@@ -1264,10 +1266,14 @@ class FillBlanks extends Question
$right,
$feedbackType,
$resultsDisabled = false,
$showTotalScoreAndUserChoices = false
) {
$showTotalScoreAndUserChoices = false,
$exercise
): string {
$hideExpectedAnswer = false;
$hideUserSelection = false;
if (!$exercise->showExpectedChoiceColumn()) {
$hideExpectedAnswer = true;
}
switch ($resultsDisabled) {
case RESULT_DISABLE_SHOW_SCORE_AND_EXPECTED_ANSWERS_AND_RANKING:
case RESULT_DISABLE_SHOW_ONLY_IN_CORRECT_ANSWER:
@@ -1356,7 +1362,8 @@ class FillBlanks extends Question
$correct,
$feedbackType,
$resultsDisabled = false,
$showTotalScoreAndUserChoices = false
$showTotalScoreAndUserChoices = false,
$exercise
) {
return self::getHtmlAnswer(
$answer,
@@ -1364,7 +1371,8 @@ class FillBlanks extends Question
true,
$feedbackType,
$resultsDisabled,
$showTotalScoreAndUserChoices
$showTotalScoreAndUserChoices,
$exercise
);
}
@@ -1384,7 +1392,8 @@ class FillBlanks extends Question
$correct,
$feedbackType,
$resultsDisabled = false,
$showTotalScoreAndUserChoices = false
$showTotalScoreAndUserChoices = false,
$exercise
) {
return self::getHtmlAnswer(
$answer,
@@ -1392,7 +1401,8 @@ class FillBlanks extends Question
false,
$feedbackType,
$resultsDisabled,
$showTotalScoreAndUserChoices
$showTotalScoreAndUserChoices,
$exercise
);
}
@@ -1400,10 +1410,8 @@ class FillBlanks extends Question
* Check if a answer is correct by its text.
*
* @param string $answerText
*
* @return bool
*/
public static function isCorrect($answerText)
public static function isCorrect($answerText): bool
{
$answerInfo = self::getAnswerInfo($answerText, true);
$correctAnswerList = $answerInfo['words'];
@@ -1422,10 +1430,8 @@ class FillBlanks extends Question
* Clear the answer entered by student.
*
* @param string $answer
*
* @return string
*/
public static function clearStudentAnswer($answer)
public static function clearStudentAnswer($answer): string
{
$answer = htmlentities(api_utf8_encode($answer), ENT_QUOTES);
$answer = str_replace('&#039;', '&#39;', $answer); // fix apostrophe
@@ -1436,7 +1442,23 @@ class FillBlanks extends Question
}
/**
* Removes double spaces between words.
* Strips invisible/problematic Unicode characters introduced by word
* processors such as Microsoft Word.
* U+00A0 is normalised to a regular space; all others are removed.
*/
private static function stripInvisibleChars(string $text): string
{
$text = str_replace("\u{00A0}", ' ', $text); // Non-Breaking Space → space
return str_replace(
["\u{00AD}", "\u{200B}", "\u{200C}", "\u{200D}", "\u{2060}", "\u{FEFF}"],
'',
$text
);
}
/**
* Strips invisible characters and normalises whitespace.
*
* @param string $text
*
@@ -1445,7 +1467,8 @@ class FillBlanks extends Question
private static function trimOption($text)
{
$text = trim($text);
$text = self::stripInvisibleChars($text);
return preg_replace("/\s+/", ' ', $text);
return preg_replace("/\s+/", ' ', trim($text));
}
}
+10 -12
View File
@@ -129,9 +129,10 @@ if ((api_is_allowed_to_edit(null, true)) && (($finish == 0) || ($finish == 2)))
$unzip = 1;
}
$filename = api_replace_dangerous_char(trim($_FILES['userFile']['name']));
if ($finish == 0) {
// Generate new test folder if on first step of file upload.
$filename = api_replace_dangerous_char(trim($_FILES['userFile']['name']));
$fld = GenerateHpFolder($document_sys_path.$uploadPath.'/');
@mkdir($document_sys_path.$uploadPath.'/'.$fld, api_get_permissions_for_new_directories());
$doc_id = add_document($_course, '/HotPotatoes_files/'.$fld, 'folder', 0, $fld);
@@ -142,9 +143,6 @@ if ((api_is_allowed_to_edit(null, true)) && (($finish == 0) || ($finish == 2)))
'FolderCreated',
api_get_user_id()
);
} else {
// It is not the first step... get the filename directly from the system params.
$filename = $_FILES['userFile']['name'];
}
$allow_output_on_success = false;
@@ -207,21 +205,21 @@ if ((api_is_allowed_to_edit(null, true)) && (($finish == 0) || ($finish == 2)))
$select = "SELECT iid FROM $dbTable
WHERE c_id = $course_id
$sessionAnd
AND path = '$path'";
AND path = '".Database::escape_string($path)."'";
$query = Database::query($select);
if (Database::num_rows($query)) {
$row = Database::fetch_array($query);
$hotPotatoesDocumentId = $row['iid'];
// Update the record with the 'comment' (HP title)
$query = "UPDATE $dbTable
SET comment = '".Database::escape_string($title)."'
WHERE iid = $hotPotatoesDocumentId";
Database::query($query);
Database::update(
$dbTable,
['comment' => $title],
['iid = ?' => $row['iid']]
);
// Mark the addition of the HP quiz in the item_property table
api_item_property_update(
$_course,
TOOL_QUIZ,
$hotPotatoesDocumentId,
$row['iid'],
'QuizAdded',
api_get_user_id()
);
@@ -238,7 +236,7 @@ if ((api_is_allowed_to_edit(null, true)) && (($finish == 0) || ($finish == 2)))
exit;
}
Display::display_header($nameTools, get_lang('Exercise'));
Display::display_header($nameTools, 'Exercise');
echo '<div class="actions">';
echo '<a href="exercise.php?show=test">'.
@@ -213,12 +213,12 @@ class HotpotatoesExerciseResult
$filename = 'exercise_results_user_'.$user_id.'_'.api_get_local_time().'.xls';
}
$spreadsheet = new PHPExcel();
$spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet();
$spreadsheet->setActiveSheetIndex(0);
$worksheet = $spreadsheet->getActiveSheet();
$line = 0;
$column = 0; //skip the first column (row titles)
$line = 1;
$column = 1;
// check if exists column 'user'
$with_column_user = false;
@@ -335,7 +335,7 @@ class HotpotatoesExerciseResult
$line++;
foreach ($this->results as $row) {
$column = 0;
$column = 1;
if ($with_column_user) {
$worksheet->setCellValueByColumnAndRow(
@@ -426,7 +426,7 @@ class HotpotatoesExerciseResult
}
$file = api_get_path(SYS_ARCHIVE_PATH).api_replace_dangerous_char($filename);
$writer = new PHPExcel_Writer_Excel2007($spreadsheet);
$writer = new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet);
$writer->save($file);
DocumentManager::file_send_for_download($file, true, $filename);
+12 -6
View File
@@ -13,8 +13,12 @@ use Symfony\Component\HttpFoundation\Request;
* @author Toon Keppens
*/
$modifyAnswers = (int) $_GET['hotspotadmin'];
if (!is_object($objQuestion)) {
if (!is_object($objQuestion) || empty($objQuestion->iid) || (int) $objQuestion->iid !== $modifyAnswers) {
$objQuestion = Question::read($modifyAnswers);
if (!$objQuestion) {
api_not_allowed();
}
Session::write('objQuestion', $objQuestion);
}
$questionName = $objQuestion->selectTitle();
@@ -330,14 +334,16 @@ if ($submitAnswers || $buttonBack) {
);
$objAnswer->save();
// sets the total weighting of the question
$objQuestion->updateWeighting($questionWeighting);
$objQuestion->iid = (int) $modifyAnswers;
$objQuestion->course = api_get_course_info();
$objQuestion->updateWeighting((float) $questionWeighting);
$objQuestion->save($objExercise);
$editQuestion = $questionId;
$editQuestion = $objQuestion->iid;
unset($modifyAnswers);
echo '<script type="text/javascript">window.location.href="'.$hotspot_admin_url
.'&message=ItemUpdated"</script>';
echo '<script type="text/javascript">window.location.href="'.
$hotspot_admin_url.'&message=ItemUpdated"</script>';
}
}
}
+48
View File
@@ -184,11 +184,59 @@ class MultipleAnswer extends Question
$form->setConstants(['nb_answers' => $nb_answers]);
}
/**
* Validate question answers before saving.
*
* @param object $form The form object.
*
* @return array|bool True if valid, or an array with errors if invalid.
*/
public function validateAnswers($form)
{
$nb_answers = $form->getSubmitValue('nb_answers');
$hasCorrectAnswer = false;
$hasValidWeighting = false;
$errors = [];
$error_fields = [];
for ($i = 1; $i <= $nb_answers; $i++) {
$isCorrect = $form->getSubmitValue("correct[$i]");
$weighting = trim($form->getSubmitValue("weighting[$i]") ?? '');
if ($isCorrect) {
$hasCorrectAnswer = true;
if (is_numeric($weighting) && floatval($weighting) > 0) {
$hasValidWeighting = true;
}
}
}
if (!$hasCorrectAnswer) {
$errors[] = get_lang('AtLeastOneCorrectAnswerRequired');
$error_fields[] = "correct";
}
if ($hasCorrectAnswer && !$hasValidWeighting) {
// Only show if at least one correct answer exists but its weighting is not valid
$errors[] = get_lang('AtLeastOneCorrectAnswerMustHaveAPositiveScore');
}
return empty($errors) ? true : ['errors' => $errors, 'error_fields' => $error_fields];
}
/**
* {@inheritdoc}
*/
public function processAnswersCreation($form, $exercise)
{
$validationResult = $this->validateAnswers($form);
if ($validationResult !== true) {
Display::addFlash(Display::return_message(implode("<br>", $validationResult['errors']), 'error'));
return;
}
$questionWeighting = 0;
$objAnswer = new Answer($this->iid);
$nb_answers = $form->getSubmitValue('nb_answers');
+3
View File
@@ -337,6 +337,7 @@ if (!empty($attempts)) {
RESULT_DISABLE_DONT_SHOW_SCORE_ONLY_IF_USER_FINISHES_ATTEMPTS_SHOW_ALWAYS_FEEDBACK,
RESULT_DISABLE_RANKING,
RESULT_DISABLE_SHOW_ONLY_IN_CORRECT_ANSWER,
RESULT_DISABLE_RADAR,
]
)) {
$row['result'] = $score;
@@ -353,6 +354,7 @@ if (!empty($attempts)) {
RESULT_DISABLE_DONT_SHOW_SCORE_ONLY_IF_USER_FINISHES_ATTEMPTS_SHOW_ALWAYS_FEEDBACK,
RESULT_DISABLE_RANKING,
RESULT_DISABLE_SHOW_ONLY_IN_CORRECT_ANSWER,
RESULT_DISABLE_RADAR,
]
) || (
$objExercise->results_disabled == RESULT_DISABLE_SHOW_SCORE_ONLY &&
@@ -419,6 +421,7 @@ if (!empty($attempts)) {
case RESULT_DISABLE_SHOW_FINAL_SCORE_ONLY_WITH_CATEGORIES:
case RESULT_DISABLE_RANKING:
case RESULT_DISABLE_SHOW_ONLY_IN_CORRECT_ANSWER:
case RESULT_DISABLE_RADAR:
$header_names = [
get_lang('Attempt'),
get_lang('StartDate'),
+48 -15
View File
@@ -16,6 +16,8 @@ $statusId = isset($_REQUEST['status']) ? (int) $_REQUEST['status'] : 0;
$questionTypeId = isset($_REQUEST['questionTypeId']) ? (int) $_REQUEST['questionTypeId'] : 0;
$exportXls = isset($_REQUEST['export_xls']) && !empty($_REQUEST['export_xls']) ? (int) $_REQUEST['export_xls'] : 0;
$action = $_REQUEST['a'] ?? null;
$startDate = isset($_REQUEST['start_date']) ? $_REQUEST['start_date'] : '';
$endDate = isset($_REQUEST['end_date']) ? $_REQUEST['end_date'] : '';
api_block_anonymous_users();
@@ -170,6 +172,14 @@ $htmlHeadXtra[] = '<script>
}
</script>';
$htmlHeadXtra[] = '<script>
$(function() {
$(".datepicker").datepicker({
dateFormat: "yy-mm-dd"
});
});
</script>';
if ($exportXls) {
ExerciseLib::exportPendingAttemptsToExcel($_REQUEST);
}
@@ -286,6 +296,22 @@ $form->addSelect(
]
);
$userOptions = [];
if (!empty($filter_user)) {
$userInfo = api_get_user_info($filter_user);
if (!empty($userInfo)) {
$userOptions[$filter_user] = $userInfo['complete_name_with_username'];
}
}
$form->addSelectAjax(
'filter_by_user',
get_lang('User'),
$userOptions,
[
'url' => api_get_path(WEB_AJAX_PATH).'user_manager.ajax.php?a=get_user_like',
]
);
$status = [
1 => get_lang('All'),
2 => get_lang('Validated'),
@@ -293,16 +319,27 @@ $status = [
4 => get_lang('Unclosed'),
5 => get_lang('Ongoing'),
];
$form->addSelect('status', get_lang('Status'), $status);
$questionType = [
0 => get_lang('All'),
1 => get_lang('QuestionsWithNoAutomaticCorrection'),
];
$form->addSelect('questionTypeId', get_lang('QuestionType'), $questionType);
$form->addElement(
'text',
'start_date',
get_lang('StartDate'),
['id' => 'start_date', 'class' => 'datepicker', 'autocomplete' => 'off', 'style' => 'width:120px']
);
$form->addElement(
'text',
'end_date',
get_lang('EndDate'),
['id' => 'end_date', 'class' => 'datepicker', 'autocomplete' => 'off', 'style' => 'width:120px']
);
$form->addButtonSearch(get_lang('Search'), 'pendingSubmit');
$content = $form->returnForm();
@@ -315,7 +352,9 @@ if (empty($statusId)) {
$url = api_get_path(WEB_AJAX_PATH).
'model.ajax.php?a=get_exercise_pending_results&filter_by_user='.$filter_user.
'&course_id='.$courseId.'&exercise_id='.$exerciseId.'&status='.$statusId.'&questionType='.$questionTypeId.'&showAttemptsInSessions='.$showAttemptsInSessions;
'&course_id='.$courseId.'&exercise_id='.$exerciseId.'&status='.$statusId.'&questionType='.$questionTypeId.
'&showAttemptsInSessions='.$showAttemptsInSessions.
'&start_date='.$startDate.'&end_date='.$endDate;
$action_links = '';
$officialCodeInList = api_get_setting('show_official_code_exercise_result_list');
@@ -375,16 +414,6 @@ $column_model = [
'align' => 'left',
'search' => 'false',
'sortable' => 'false',
//'stype' => 'select',
//for the bottom bar
/*'searchoptions' => [
'defaultValue' => '',
'value' => ':'.get_lang('All').';1:'.get_lang('Validated').';0:'.get_lang('NotValidated'),
],*/
//for the top bar
/*'editoptions' => [
'value' => ':'.get_lang('All').';1:'.get_lang('Validated').';0:'.get_lang('NotValidated'),
],*/
],
[
'name' => 'qualificator_fullname',
@@ -432,8 +461,12 @@ function action_formatter(cellvalue, options, rowObject) {
return "<span title=\""+tabLoginx[0]+rowObject[2]+tabLoginx[1]+"\">"+cellvalue+"</span>";
}';
$extra_params['autowidth'] = 'true';
$extra_params['height'] = 'auto';
$extra_params = [
'autowidth' => 'true',
'height' => 'auto',
'sortname' => 'exe_date',
'sortorder' => 'asc',
];
$gridJs = Display::grid_js(
'results',
$url,
+1 -1
View File
@@ -95,7 +95,7 @@ if (api_is_allowed_to_edit(null, true)) {
}
}
Display::display_header(get_lang('ImportQtiQuiz'), 'Exercises');
Display::display_header(get_lang('ImportQtiQuiz'), 'Exercise');
echo $message;
+9 -3
View File
@@ -75,6 +75,7 @@ abstract class Question
UPLOAD_ANSWER => ['UploadAnswer.php', 'UploadAnswer'],
MULTIPLE_ANSWER_DROPDOWN => ['MultipleAnswerDropdown.php', 'MultipleAnswerDropdown'],
MULTIPLE_ANSWER_DROPDOWN_COMBINATION => ['MultipleAnswerDropdownCombination.php', 'MultipleAnswerDropdownCombination'],
ANSWER_IN_OFFICE_DOC => ['AnswerInOfficeDoc.php', 'AnswerInOfficeDoc'],
];
/**
@@ -110,6 +111,7 @@ abstract class Question
FILL_IN_BLANKS,
FILL_IN_BLANKS_COMBINATION,
FREE_ANSWER,
ANSWER_IN_OFFICE_DOC,
ORAL_EXPRESSION,
CALCULATED_ANSWER,
ANNOTATION,
@@ -1663,6 +1665,9 @@ abstract class Question
self::$questionTypes[HOT_SPOT_DELINEATION] = null;
unset(self::$questionTypes[HOT_SPOT_DELINEATION]);
}
if ('true' !== OnlyofficePlugin::create()->get('enable_onlyoffice_plugin')) {
unset(self::$questionTypes[ANSWER_IN_OFFICE_DOC]);
}
return self::$questionTypes;
}
@@ -2248,8 +2253,9 @@ abstract class Question
case FREE_ANSWER:
case UPLOAD_ANSWER:
case ORAL_EXPRESSION:
case ANSWER_IN_OFFICE_DOC:
case ANNOTATION:
$score['revised'] = isset($score['revised']) ? $score['revised'] : false;
$score['revised'] = $score['revised'] ?? false;
if ($score['revised'] == true) {
$scoreLabel = get_lang('Revised');
$class = '';
@@ -2298,8 +2304,8 @@ abstract class Question
}
$scoreCurrent = [
'used' => isset($score['score']) ? $score['score'] : '',
'missing' => isset($score['weight']) ? $score['weight'] : '',
'used' => $score['score'] ?? '',
'missing' => $score['weight'] ?? '',
];
// Check whether we need to hide the question ID
+44 -41
View File
@@ -51,50 +51,53 @@ if (is_object($objQuestion)) {
}
// FORM VALIDATION
if (isset($_POST['submitQuestion']) && $form->validate()) {
// Question
$objQuestion->processCreation($form, $objExercise);
$objQuestion->processAnswersCreation($form, $objExercise);
// TODO: maybe here is the better place to index this tool, including answers text
// redirect
if (in_array($objQuestion->type, [HOT_SPOT, HOT_SPOT_COMBINATION, HOT_SPOT_DELINEATION])) {
echo '<script type="text/javascript">window.location.href="admin.php?exerciseId='.$exerciseId.'&page='.$page.'&hotspotadmin='.$objQuestion->iid.'&'.api_get_cidreq(
).'"</script>';
} elseif (in_array($objQuestion->type, [MULTIPLE_ANSWER_DROPDOWN, MULTIPLE_ANSWER_DROPDOWN_COMBINATION])) {
$url = 'admin.php?'
.api_get_cidreq().'&'
.http_build_query(['exerciseId' => $exerciseId, 'page' => $page, 'mad_admin' => $objQuestion->iid]);
echo '<script type="text/javascript">window.location.href="'.$url.'"</script>';
} else {
if (isset($_GET['editQuestion'])) {
if (empty($exerciseId)) {
Display::addFlash(Display::return_message(get_lang('ItemUpdated')));
$url = 'admin.php?exerciseId='.$exerciseId.'&'.api_get_cidreq().'&editQuestion='.$objQuestion->iid;
echo '<script type="text/javascript">window.location.href="'.$url.'"</script>';
exit;
}
echo '<script type="text/javascript">window.location.href="admin.php?exerciseId='.$exerciseId.'&'.api_get_cidreq().'&page='.$page.'&message=ItemUpdated"</script>';
if (isset($_POST['submitQuestion'])) {
$validationResult = true;
if (method_exists($objQuestion, 'validateAnswers')) {
$validationResult = $objQuestion->validateAnswers($form);
}
if (is_array($validationResult) && !empty($validationResult['errors'])) {
echo Display::return_message(implode("<br>", $validationResult['errors']), 'error', false);
} elseif ($form->validate()) {
$objQuestion->processCreation($form, $objExercise);
$objQuestion->processAnswersCreation($form, $objExercise);
if (in_array($objQuestion->type, [HOT_SPOT, HOT_SPOT_COMBINATION, HOT_SPOT_DELINEATION])) {
echo '<script type="text/javascript">window.location.href="admin.php?exerciseId='.$exerciseId.'&page='.$page.'&hotspotadmin='.$objQuestion->iid.'&'.api_get_cidreq().'";</script>';
} elseif (in_array($objQuestion->type, [MULTIPLE_ANSWER_DROPDOWN, MULTIPLE_ANSWER_DROPDOWN_COMBINATION])) {
$url = 'admin.php?'.api_get_cidreq().'&'.http_build_query(['exerciseId' => $exerciseId, 'page' => $page, 'mad_admin' => $objQuestion->iid]);
echo '<script type="text/javascript">window.location.href="'.$url.'";</script>';
} else {
// New question
$page = 1;
$length = api_get_configuration_value('question_pagination_length');
if (!empty($length)) {
$page = round($objExercise->getQuestionCount() / $length);
if (isset($_GET['editQuestion'])) {
if (empty($exerciseId)) {
Display::addFlash(Display::return_message(get_lang('ItemUpdated')));
$url = 'admin.php?exerciseId='.$exerciseId.'&'.api_get_cidreq().'&editQuestion='.$objQuestion->iid;
echo '<script type="text/javascript">window.location.href="'.$url.'";</script>';
exit;
}
echo '<script type="text/javascript">window.location.href="admin.php?exerciseId='.$exerciseId.'&'.api_get_cidreq().'&page='.$page.'&message=ItemUpdated";</script>';
} else {
// New question
$page = 1;
$length = api_get_configuration_value('question_pagination_length');
if (!empty($length)) {
$page = round($objExercise->getQuestionCount() / $length);
}
echo '<script type="text/javascript">window.location.href="admin.php?exerciseId='.$exerciseId.'&'.api_get_cidreq().'&page='.$page.'&message=ItemAdded";</script>';
}
echo '<script type="text/javascript">window.location.href="admin.php?exerciseId='.$exerciseId.'&'.api_get_cidreq().'&page='.$page.'&message=ItemAdded"</script>';
}
exit();
}
} else {
if (isset($questionName)) {
echo '<h3>'.$questionName.'</h3>';
}
if (!empty($pictureName)) {
echo '<img src="../document/download.php?doc_url=%2Fimages%2F'.$pictureName.'" border="0">';
}
if (!empty($msgErr)) {
echo Display::return_message($msgErr);
}
// display the form
$form->display();
}
if (isset($questionName)) {
echo '<h3>'.$questionName.'</h3>';
}
if (!empty($pictureName)) {
echo '<img src="../document/download.php?doc_url=%2Fimages%2F'.$pictureName.'" border="0">';
}
if (!empty($msgErr)) {
echo Display::return_message($msgErr);
}
$form->display();
}
+3
View File
@@ -66,6 +66,9 @@ if ($student_id === $current_user_id && ExerciseSignaturePlugin::exerciseHasSign
}
}
if (RESULT_DISABLE_RADAR === (int) $objExercise->results_disabled) {
$htmlHeadXtra[] = api_get_js('chartjs/Chart.min.js');
}
$htmlHeadXtra[] = '<link rel="stylesheet" href="'.api_get_path(WEB_LIBRARY_JS_PATH).'hotspot/css/hotspot.css">';
$htmlHeadXtra[] = '<script src="'.api_get_path(WEB_LIBRARY_JS_PATH).'hotspot/js/hotspot.js"></script>';
$htmlHeadXtra[] = '<script src="'.api_get_path(WEB_LIBRARY_JS_PATH).'annotation/js/annotation.js"></script>';
+10 -3
View File
@@ -16,15 +16,20 @@ $_user = api_get_user_info();
$this_section = SECTION_COURSES;
$documentPath = api_get_path(SYS_COURSE_PATH).$courseInfo['path']."/document";
$test = $_REQUEST['test'];
$test = $_REQUEST['test'] ?? '';
$full_file_path = $documentPath.$test;
$fileToDelete = $full_file_path.$_user['user_id'].".t.html";
my_delete($full_file_path.$_user['user_id'].".t.html");
if (!Security::check_abs_path($fileToDelete, $documentPath.'/')) {
api_not_allowed(true);
}
my_delete($fileToDelete);
$TABLETRACK_HOTPOTATOES = Database::get_main_table(TABLE_STATISTIC_TRACK_E_HOTPOTATOES);
$TABLE_LP_ITEM_VIEW = Database::get_course_table(TABLE_LP_ITEM_VIEW);
$score = $_REQUEST['score'];
$score = isset($_REQUEST['score']) ? Security::remove_XSS($_REQUEST['score']) : '';
$origin = api_get_origin();
$learnpath_item_id = intval($_REQUEST['learnpath_item_id']);
$lpViewId = isset($_REQUEST['lp_view_id']) ? intval($_REQUEST['lp_view_id']) : null;
@@ -45,6 +50,8 @@ function save_scores($file, $score)
global $origin;
$TABLETRACK_HOTPOTATOES = Database::get_main_table(TABLE_STATISTIC_TRACK_E_HOTPOTATOES);
$_user = api_get_user_info();
$file = Security::remove_XSS($file);
$score = intval($score);
// if tracking is disabled record nothing
$weighting = 100; // 100%
$date = api_get_utc_datetime();
+9 -2
View File
@@ -28,7 +28,14 @@ $lpViewId = isset($_REQUEST['lp_view_id']) ? $_REQUEST['lp_view_id'] : null;
$user_id = api_get_user_id();
$full_file_path = $document_path.$doc_url;
my_delete($full_file_path.$user_id.'.t.html');
// Security: reject path traversal attempts (CWE-22)
if (!Security::check_abs_path($full_file_path, $document_path.'/')) {
api_not_allowed(true);
}
$fileToDelete = $full_file_path.$user_id.'.t.html';
my_delete($fileToDelete);
$content = ReadFileCont($full_file_path.$user_id.'.t.html');
if ($content == '') {
@@ -95,7 +102,7 @@ $htmlHeadXtra[] = <<<HTML
});
iframe.height = maxheight;
}
$(function() {
var iframe = document.getElementById('hotpotatoe');
iframe.onload = function () {
+55
View File
@@ -335,11 +335,66 @@ class UniqueAnswer extends Question
);
}
/**
* Validate question answers before saving.
*
* @return bool|string True if valid, error message if invalid.
*/
public function validateAnswers($form)
{
$correct = $form->getSubmitValue('correct');
$nb_answers = $form->getSubmitValue('nb_answers');
$hasCorrectAnswer = false;
$hasPositiveScore = false;
$errors = [];
$error_fields = [];
for ($i = 1; $i <= $nb_answers; $i++) {
$answer = trim($form->getSubmitValue("answer[$i]") ?? '');
$weighting = trim($form->getSubmitValue("weighting[$i]") ?? '');
$isCorrect = ($correct == $i);
if (empty($answer)) {
$errors[] = sprintf(get_lang('NoAnswerCanBeEmpty'), $i);
$error_fields[] = "answer[$i]";
}
if ($weighting === '' || !is_numeric($weighting)) {
$errors[] = sprintf(get_lang('ScoreMustBeNumeric'), $i);
$error_fields[] = "weighting[$i]";
}
if ($isCorrect) {
$hasCorrectAnswer = true;
if (floatval($weighting) > 0) {
$hasPositiveScore = true;
}
}
}
if (!$hasCorrectAnswer) {
$errors[] = get_lang('ACorrectAnswerIsRequired');
$error_fields[] = "correct";
}
if (!$hasPositiveScore) {
$errors[] = get_lang('TheCorrectAnswerMustHaveAPositiveScore');
}
return empty($errors) ? true : ['errors' => $errors, 'error_fields' => $error_fields];
}
/**
* {@inheritdoc}
*/
public function processAnswersCreation($form, $exercise)
{
$validationResult = $this->validateAnswers($form);
if ($validationResult !== true) {
Display::addFlash(Display::return_message(implode("<br>", $validationResult['errors']), 'error'));
return;
}
$questionWeighting = $nbrGoodAnswers = 0;
$correct = $form->getSubmitValue('correct');
$objAnswer = new Answer($this->iid);
+10 -10
View File
@@ -41,7 +41,7 @@ $interbreadcrumb[] = [
'name' => get_lang('Exercises'),
];
Display::display_header(get_lang('ImportExcelQuiz'), 'Exercises');
Display::display_header(get_lang('ImportExcelQuiz'), 'Exercise');
echo '<div class="actions">';
echo lp_upload_quiz_actions();
@@ -160,7 +160,7 @@ function lp_upload_quiz_action_handling()
$questionTypeList = [];
$answerList = [];
$quizTitle = '';
$objPHPExcel = PHPExcel_IOFactory::load($_FILES['user_upload_quiz']['tmp_name']);
$objPHPExcel = \PhpOffice\PhpSpreadsheet\IOFactory::load($_FILES['user_upload_quiz']['tmp_name']);
$objPHPExcel->setActiveSheetIndex(0);
$worksheet = $objPHPExcel->getActiveSheet();
$highestRow = $worksheet->getHighestRow(); // e.g. 10
@@ -171,9 +171,9 @@ function lp_upload_quiz_action_handling()
$useCustomScore = isset($_POST['user_custom_score']) ? true : false;
for ($row = 1; $row <= $highestRow; $row++) {
$cellTitleInfo = $worksheet->getCellByColumnAndRow(0, $row);
$cellDataInfo = $worksheet->getCellByColumnAndRow(1, $row);
$cellScoreInfo = $worksheet->getCellByColumnAndRow(2, $row);
$cellTitleInfo = $worksheet->getCellByColumnAndRow(1, $row);
$cellDataInfo = $worksheet->getCellByColumnAndRow(2, $row);
$cellScoreInfo = $worksheet->getCellByColumnAndRow(3, $row);
$title = $cellTitleInfo->getValue();
switch ($title) {
@@ -188,9 +188,9 @@ function lp_upload_quiz_action_handling()
$answerIndex = 0;
while ($continue) {
$answerRow++;
$answerInfoTitle = $worksheet->getCellByColumnAndRow(0, $answerRow);
$answerInfoData = $worksheet->getCellByColumnAndRow(1, $answerRow);
$answerInfoExtra = $worksheet->getCellByColumnAndRow(2, $answerRow);
$answerInfoTitle = $worksheet->getCellByColumnAndRow(1, $answerRow);
$answerInfoData = $worksheet->getCellByColumnAndRow(2, $answerRow);
$answerInfoExtra = $worksheet->getCellByColumnAndRow(3, $answerRow);
$answerInfoTitle = $answerInfoTitle->getValue();
if (strpos($answerInfoTitle, 'Answer') !== false) {
$answerList[$numberQuestions][$answerIndex]['data'] = $answerInfoData->getValue();
@@ -212,8 +212,8 @@ function lp_upload_quiz_action_handling()
$questionTypeIndex = 0;
while ($continue) {
$answerRow++;
$questionTypeTitle = $worksheet->getCellByColumnAndRow(0, $answerRow);
$questionTypeExtra = $worksheet->getCellByColumnAndRow(2, $answerRow);
$questionTypeTitle = $worksheet->getCellByColumnAndRow(1, $answerRow);
$questionTypeExtra = $worksheet->getCellByColumnAndRow(3, $answerRow);
$title = $questionTypeTitle->getValue();
if ($title === 'QuestionType') {
$questionTypeList[$numberQuestions] = $questionTypeExtra->getValue();
+4 -8
View File
@@ -90,12 +90,12 @@ switch ($action) {
$form->addHtmlEditor(
'name',
get_lang('TermName'),
false,
true,
false,
['ToolbarSet' => 'TitleAsHtml']
);
} else {
$form->addElement('text', 'name', get_lang('TermName'), ['id' => 'glossary_title']);
$form->addText('name', get_lang('TermName'), true, ['id' => 'glossary_title']);
}
$form->addHtmlEditor(
@@ -107,7 +107,6 @@ switch ($action) {
);
$form->addButtonCreate(get_lang('TermAddButton'), 'SubmitGlossary');
// setting the rules
$form->addRule('name', get_lang('ThisFieldIsRequired'), 'required');
// The validation or display
if ($form->validate()) {
$check = Security::check_token('post');
@@ -154,12 +153,12 @@ switch ($action) {
$form->addHtmlEditor(
'name',
get_lang('TermName'),
false,
true,
false,
['ToolbarSet' => 'TitleAsHtml']
);
} else {
$form->addElement('text', 'name', get_lang('TermName'), ['id' => 'glossary_title']);
$form->addText('name', get_lang('TermName'), true, ['id' => 'glossary_title']);
}
$form->addHtmlEditor(
@@ -192,9 +191,6 @@ switch ($action) {
$form->addButtonUpdate(get_lang('TermUpdateButton'), 'SubmitGlossary');
$form->setDefaults($glossary_data);
// setting the rules
$form->addRule('name', get_lang('ThisFieldIsRequired'), 'required');
// The validation or display
if ($form->validate()) {
$check = Security::check_token('post');
+11
View File
@@ -9,6 +9,17 @@ api_protect_course_script(true);
api_block_anonymous_users();
GradebookUtils::block_students();
if (isset($_GET['import'])) {
$queryString = $_SERVER['QUERY_STRING'];
$webPath = api_get_path(WEB_CODE_PATH);
$newUrl = $webPath.'gradebook/gradebook_view_result.php';
if (!empty($queryString)) {
$newUrl .= '?'.$queryString;
}
header("Location: $newUrl");
exit;
}
$selectEval = isset($_GET['selecteval']) ? (int) $_GET['selecteval'] : 0;
$resultadd = new Result();
+19 -2
View File
@@ -10,8 +10,19 @@ api_block_anonymous_users();
GradebookUtils::block_students();
$evaledit = Evaluation::load($_GET['editeval']);
if ($evaledit[0]->is_locked() && !api_is_platform_admin()) {
api_not_allowed();
if (empty($evaledit[0])) {
api_not_allowed(true);
}
if (!api_is_platform_admin()) {
$currentCourseCode = api_get_course_id();
if ($evaledit[0]->get_course_code() && $evaledit[0]->get_course_code() != $currentCourseCode) {
api_not_allowed(true);
}
if ($evaledit[0]->is_locked()) {
api_not_allowed(true);
}
}
$form = new EvalForm(
EvalForm::TYPE_EDIT,
@@ -23,6 +34,12 @@ $form = new EvalForm(
);
if ($form->validate()) {
$values = $form->exportValues();
$evaluationId = (int) $values['hid_id'];
if ($evaluationId !== (int) $evaledit[0]->get_id()) {
api_not_allowed(true);
}
$eval = new Evaluation();
$eval->set_id($values['hid_id']);
$eval->set_name($values['name']);
+11 -5
View File
@@ -10,12 +10,18 @@ $current_course_tool = TOOL_GRADEBOOK;
api_protect_course_script(true);
api_block_anonymous_users();
$currentUserId = api_get_user_id();
$sessionId = api_get_session_id();
$isDrhOfCourse = CourseManager::isUserSubscribedInCourseAsDrh(
api_get_user_id(),
$currentUserId,
api_get_course_info()
);
if (!$isDrhOfCourse) {
$isDrhOfSession = $sessionId && !empty(SessionManager::getSessionFollowedByDrh($currentUserId, $sessionId));
if (!$isDrhOfCourse && !$isDrhOfSession) {
GradebookUtils::block_students();
}
@@ -75,7 +81,7 @@ $simple_search_form = new UserForm(
$values = $simple_search_form->exportValues();
$keyword = '';
if (isset($_GET['search']) && !empty($_GET['search'])) {
if (!empty($_GET['search'])) {
$keyword = Security::remove_XSS($_GET['search']);
}
if ($simple_search_form->validate() && empty($keyword)) {
@@ -90,7 +96,7 @@ if (!empty($keyword)) {
$users = GradebookUtils::get_all_users($alleval, $alllinks);
}
}
$offset = isset($_GET['offset']) ? $_GET['offset'] : '0';
$offset = $_GET['offset'] ?? '0';
$addparams = ['selectcat' => $cat[0]->get_id()];
if (isset($_GET['search'])) {
@@ -128,7 +134,7 @@ if (isset($_GET['export_pdf']) && 'category' === $_GET['export_pdf']) {
$params['join_firstname_lastname'] = true;
$params['show_official_code'] = true;
$params['export_pdf'] = true;
if ($cat[0]->is_locked() == true || api_is_platform_admin()) {
if ($cat[0]->is_locked() || api_is_platform_admin()) {
Display::set_header(null, false, false);
GradebookUtils::export_pdf_flatview(
$flatViewTable,
+15 -7
View File
@@ -40,7 +40,7 @@ if ($eval[0]->get_category_id() < 0) {
//load the result with the evaluation id
if (isset($_GET['delete_mark'])) {
$result = Result::load($_GET['delete_mark']);
if (!empty($result[0])) {
if (!empty($result[0]) && $result[0]->get_evaluation_id() == $select_eval) {
$result[0]->delete();
}
}
@@ -56,7 +56,9 @@ if (isset($_GET['action'])) {
switch ($_GET['action']) {
case 'delete_attempt':
$result = Result::load($_GET['editres']);
if ($allowMultipleAttempts && !empty($result) && isset($result[0]) && api_is_allowed_to_edit()) {
if ($allowMultipleAttempts && !empty($result) && isset($result[0]) && api_is_allowed_to_edit()
&& $result[0]->get_evaluation_id() == $select_eval
) {
/** @var Result $result */
$result = $result[0];
$url = api_get_self().'?selecteval='.$select_eval.'&'.api_get_cidreq().'&editres='.$result->get_id();
@@ -74,7 +76,9 @@ if (isset($_GET['action'])) {
break;
case 'add_attempt':
$result = Result::load($_GET['editres']);
if ($allowMultipleAttempts && !empty($result) && isset($result[0]) && api_is_allowed_to_edit()) {
if ($allowMultipleAttempts && !empty($result) && isset($result[0]) && api_is_allowed_to_edit()
&& $result[0]->get_evaluation_id() == $select_eval
) {
/** @var Result $result */
$result = $result[0];
$backUrl = api_get_self().'?selecteval='.$select_eval.'&'.api_get_cidreq();
@@ -512,8 +516,10 @@ if (isset($_GET['export'])) {
if (isset($_GET['resultdelete'])) {
$result = Result::load($_GET['resultdelete']);
$result[0]->delete();
Display::addFlash(Display::return_message(get_lang('ResultDeleted')));
if (!empty($result[0]) && $result[0]->get_evaluation_id() == $select_eval) {
$result[0]->delete();
Display::addFlash(Display::return_message(get_lang('ResultDeleted')));
}
header('Location: gradebook_view_result.php?selecteval='.$select_eval.'&'.api_get_cidreq());
exit;
}
@@ -534,8 +540,10 @@ if (isset($_POST['action'])) {
$number_of_deleted_results = 0;
foreach ($_POST['id'] as $indexstr) {
$result = Result::load($indexstr);
$result[0]->delete();
$number_of_deleted_results++;
if (!empty($result[0]) && $result[0]->get_evaluation_id() == $select_eval) {
$result[0]->delete();
$number_of_deleted_results++;
}
}
Display::addFlash(Display::return_message(get_lang('ResultsDeleted'), 'confirmation', false));
header('Location: gradebook_view_result.php?massdelete=&selecteval='.$select_eval.'&'.api_get_cidreq());
+1 -1
View File
@@ -44,7 +44,7 @@ switch ($action) {
continue;
}
$exerciseId = $data['id'];
$exerciseId = $data['iid'];
$result = $exercise->read($exerciseId);
if ($result) {
$exercise->generateStats($exerciseId, api_get_course_info(), api_get_session_id());
+2 -2
View File
@@ -973,8 +973,8 @@ class GradebookUtils
}
/**
* @param FlatViewTable $flatviewtable
* @param Category $cat
* @param FlatViewTable $flatviewtable
* @param array<int, Category> $cat
* @param $users
* @param $alleval
* @param $alllinks
+10 -5
View File
@@ -633,18 +633,23 @@ abstract class AbstractLink implements GradebookItem
*/
public static function getCurrentUserRanking($userId, $studentList)
{
$previousScore = null;
$ranking = null;
$position = null;
$currentUserId = $userId;
if (!empty($studentList) && !empty($currentUserId)) {
$studentList = array_map('floatval', $studentList);
asort($studentList);
$ranking = $count = count($studentList);
foreach ($studentList as $userId => $position) {
arsort($studentList);
$count = count($studentList);
foreach ($studentList as $userId => $score) {
$position++;
if ($previousScore === null || $score < $previousScore) {
$ranking = $position;
}
$previousScore = $score;
if ($currentUserId == $userId) {
break;
}
$ranking--;
}
// If no ranking was detected.
+7 -13
View File
@@ -128,12 +128,9 @@ class Category implements GradebookItem
return $this->weight;
}
/**
* @return bool
*/
public function is_locked()
public function is_locked(): bool
{
return isset($this->locked) && $this->locked == 1 ? true : false;
return isset($this->locked) && $this->locked == 1;
}
/**
@@ -429,7 +426,7 @@ class Category implements GradebookItem
* @param bool $order_by Whether to show all "session"
* categories (true) or hide them (false) in case there is no session id
*
* @return array
* @return array<int, Category>
*/
public static function load(
$id = null,
@@ -439,7 +436,7 @@ class Category implements GradebookItem
$visible = null,
$session_id = null,
$order_by = null
) {
): array {
//if the category given is explicitly 0 (not null), then create
// a root category object (in memory)
if (isset($id) && (int) $id === 0) {
@@ -2681,10 +2678,7 @@ class Category implements GradebookItem
return $this->weight - $subWeight;
}
/**
* @return Category
*/
private static function create_root_category()
private static function create_root_category(): Category
{
$cat = new Category();
$cat->set_id(0);
@@ -2704,9 +2698,9 @@ class Category implements GradebookItem
/**
* @param Doctrine\DBAL\Driver\Statement|null $result
*
* @return array
* @return array<int, Category>
*/
private static function create_category_objects_from_sql_result($result)
private static function create_category_objects_from_sql_result($result): array
{
$categories = [];
$allow = api_get_configuration_value('allow_gradebook_stats');
+4 -4
View File
@@ -221,7 +221,7 @@ class Evaluation implements GradebookItem
* @param int $category_id parent category
* @param int $visible visible
*
* @return array
* @return array<int, Evaluation>
*/
public static function load(
$id = null,
@@ -230,7 +230,7 @@ class Evaluation implements GradebookItem
$category_id = null,
$visible = null,
$locked = null
) {
): array {
$table = Database::get_main_table(TABLE_MAIN_GRADEBOOK_EVALUATION);
$sql = 'SELECT * FROM '.$table;
$paramcount = 0;
@@ -935,9 +935,9 @@ class Evaluation implements GradebookItem
/**
* @param array $result
*
* @return array
* @return array<int, Evaluation>
*/
private static function create_evaluation_objects_from_sql_result($result)
private static function create_evaluation_objects_from_sql_result($result): array
{
$alleval = [];
$allow = api_get_configuration_value('allow_gradebook_stats');
+14 -14
View File
@@ -93,7 +93,7 @@ class ForumThreadLink extends AbstractLink
$sql = "SELECT count(*) AS number FROM $table
WHERE
c_id = ".$this->course_id." AND
thread_id = '".$this->get_ref_id()."'
thread_id = '".$this->get_ref_id()."'
";
$result = Database::query($sql);
$number = Database::fetch_row($result);
@@ -122,8 +122,8 @@ class ForumThreadLink extends AbstractLink
$sql = 'SELECT thread_qualify_max
FROM '.Database::get_course_table(TABLE_FORUM_THREAD)."
WHERE
c_id = ".$this->course_id." AND
WHERE
c_id = ".$this->course_id." AND
thread_id = '".$this->get_ref_id()."'
$sessionCondition
";
@@ -131,8 +131,8 @@ class ForumThreadLink extends AbstractLink
$assignment = Database::fetch_array($query);
$sql = "SELECT * FROM $thread_qualify
WHERE
c_id = ".$this->course_id." AND
WHERE
c_id = ".$this->course_id." AND
thread_id = ".$this->get_ref_id()."
$sessionCondition
";
@@ -265,9 +265,9 @@ class ForumThreadLink extends AbstractLink
{
$sessionId = $this->get_session_id();
$sql = 'SELECT count(id) from '.$this->get_forum_thread_table().'
WHERE
c_id = '.$this->course_id.' AND
thread_id = '.$this->get_ref_id().' AND
WHERE
c_id = '.$this->course_id.' AND
thread_id = '.$this->get_ref_id().' AND
session_id='.$sessionId;
$result = Database::query($sql);
$number = Database::fetch_row($result);
@@ -281,8 +281,8 @@ class ForumThreadLink extends AbstractLink
//it was extracts the forum id
$sql = 'SELECT * FROM '.$this->get_forum_thread_table()."
WHERE
c_id = '.$this->course_id.' AND
thread_id = '".$this->get_ref_id()."' AND
c_id = ".$this->course_id." AND
thread_id = ".$this->get_ref_id()." AND
session_id = $sessionId ";
$result = Database::query($sql);
$row = Database::fetch_array($result, 'ASSOC');
@@ -304,7 +304,7 @@ class ForumThreadLink extends AbstractLink
$ref_id = $this->get_ref_id();
if (!empty($ref_id)) {
$sql = 'UPDATE '.$this->get_forum_thread_table().' SET
$sql = 'UPDATE '.$this->get_forum_thread_table().' SET
thread_weight='.api_float_val($weight).'
WHERE c_id = '.$this->course_id.' AND thread_id= '.$ref_id;
Database::query($sql);
@@ -344,9 +344,9 @@ class ForumThreadLink extends AbstractLink
if (!isset($this->exercise_data)) {
$sql = 'SELECT * FROM '.$this->get_forum_thread_table().'
WHERE
c_id = '.$this->course_id.' AND
thread_id = '.$this->get_ref_id().' AND
WHERE
c_id = '.$this->course_id.' AND
thread_id = '.$this->get_ref_id().' AND
'.$session_condition;
$query = Database::query($sql);
$this->exercise_data = Database::fetch_array($query);
+2 -2
View File
@@ -85,9 +85,9 @@ class Result
* @param $user_id user id (student)
* @param $evaluation_id evaluation where this is a result for
*
* @return array
* @return array<int, Result>
*/
public static function load($id = null, $user_id = null, $evaluation_id = null)
public static function load($id = null, $user_id = null, $evaluation_id = null): array
{
$tbl_user = Database::get_main_table(TABLE_MAIN_USER);
$tbl_grade_results = Database::get_main_table(TABLE_MAIN_GRADEBOOK_RESULT);
+100 -90
View File
@@ -20,35 +20,39 @@ class DisplayGradebook
if (api_is_allowed_to_edit(null, true)) {
$header = '<div class="actions">';
if ('statistics' !== $page) {
$header .= '<a href="'.Category::getUrl().'selectcat='.$selectcat.'">'.
Display::return_icon('back.png', get_lang('FolderView'), '', ICON_SIZE_MEDIUM)
$header .= '<a href="'.Category::getUrl().'selectcat='.$selectcat.'">'
.Display::return_icon('back.png', get_lang('FolderView'), [], ICON_SIZE_MEDIUM)
.'</a>';
if (($evalobj->get_course_code() != null) && !$evalobj->has_results()) {
$header .= '<a href="gradebook_add_result.php?'.api_get_cidreq().'&selectcat='.$selectcat.'&selecteval='.$evalobj->get_id().'">
'.Display::return_icon('evaluation_rate.png', get_lang('AddResult'), '', ICON_SIZE_MEDIUM).'</a>';
$header .= '<a href="gradebook_add_result.php?'.api_get_cidreq().'&selectcat='.$selectcat.'&selecteval='.$evalobj->get_id().'">'
.Display::return_icon('evaluation_rate.png', get_lang('AddResult'), '', ICON_SIZE_MEDIUM)
.'</a>';
}
if (api_is_platform_admin() || $evalobj->is_locked() == false) {
$header .= '<a href="'.api_get_self().'?'.api_get_cidreq().'&selecteval='.$evalobj->get_id().'&import=">'.
Display::return_icon('import_evaluation.png', get_lang('ImportResult'), '', ICON_SIZE_MEDIUM).'</a>';
$header .= '<a href="'.api_get_self().'?'.api_get_cidreq().'&selecteval='.$evalobj->get_id().'&import=">'
.Display::return_icon('import_evaluation.png', get_lang('ImportResult'), [], ICON_SIZE_MEDIUM)
.'</a>';
}
if ($evalobj->has_results()) {
$header .= '<a href="'.api_get_self().'?'.api_get_cidreq().'&selecteval='.$evalobj->get_id().'&export=">'.
Display::return_icon('export_evaluation.png', get_lang('ExportResult'), '', ICON_SIZE_MEDIUM).'</a>';
$header .= '<a href="'.api_get_self().'?'.api_get_cidreq().'&selecteval='.$evalobj->get_id().'&export=">'
.Display::return_icon('export_evaluation.png', get_lang('ExportResult'), [], ICON_SIZE_MEDIUM)
.'</a>';
if (api_is_platform_admin() || $evalobj->is_locked() == false) {
$header .= '<a href="gradebook_edit_result.php?'.api_get_cidreq().'&selecteval='.$evalobj->get_id().'">'.
Display::return_icon('edit.png', get_lang('EditResult'), '', ICON_SIZE_MEDIUM).'</a>';
$header .= '<a href="'.api_get_self().'?'.api_get_cidreq().'&selecteval='.$evalobj->get_id().'&deleteall=" onclick="return confirmationall();">'.
Display::return_icon('delete.png', get_lang('DeleteResult'), '', ICON_SIZE_MEDIUM).'</a>';
if (api_is_platform_admin() || !$evalobj->is_locked()) {
$header .= '<a href="gradebook_edit_result.php?'.api_get_cidreq().'&selecteval='.$evalobj->get_id().'">'
.Display::return_icon('edit.png', get_lang('EditResult'), [], ICON_SIZE_MEDIUM)
.'</a>';
$header .= '<a href="'.api_get_self().'?'.api_get_cidreq().'&selecteval='.$evalobj->get_id().'&deleteall=" onclick="return confirmationall();">'
.Display::return_icon('delete.png', get_lang('DeleteResult'), [], ICON_SIZE_MEDIUM).'</a>';
}
}
$header .= '<a href="'.api_get_self().'?'.api_get_cidreq().'&print=&selecteval='.$evalobj->get_id().'" target="_blank">'.
Display::return_icon('printer.png', get_lang('Print'), '', ICON_SIZE_MEDIUM).'</a>';
$header .= '<a href="'.api_get_self().'?'.api_get_cidreq().'&print=&selecteval='.$evalobj->get_id().'" target="_blank">'
.Display::return_icon('printer.png', get_lang('Print'), [], ICON_SIZE_MEDIUM).'</a>';
} else {
$header .= '<a href="gradebook_view_result.php?'.api_get_cidreq().'&selecteval='.Security::remove_XSS($_GET['selecteval']).'"> '.
Display::return_icon('back.png', get_lang('FolderView'), '', ICON_SIZE_MEDIUM).'</a>';
$header .= '<a href="gradebook_view_result.php?'.api_get_cidreq().'&selecteval='.Security::remove_XSS($_GET['selecteval']).'"> '
.Display::return_icon('back.png', get_lang('FolderView'), [], ICON_SIZE_MEDIUM).'</a>';
}
$header .= '</div>';
}
@@ -112,17 +116,17 @@ class DisplayGradebook
if ($page != 'statistics') {
if (api_is_allowed_to_edit(null, true)) {
$evalinfo .= '<br /><a href="gradebook_statistics.php?'.api_get_cidreq().'&selecteval='.Security::remove_XSS($_GET['selecteval']).'"> '.
Display::return_icon(
$evalinfo .= '<br /><a href="gradebook_statistics.php?'.api_get_cidreq().'&selecteval='.Security::remove_XSS($_GET['selecteval']).'"> '
.Display::return_icon(
'statistics.png',
get_lang('ViewStatistics'),
'',
[],
ICON_SIZE_MEDIUM
).'</a>';
}
}
$evalinfo .= '</td><td>'.
Display::return_icon(
$evalinfo .= '</td><td>'
.Display::return_icon(
'tutorial.gif',
'',
['style' => 'float:right; position:relative;']
@@ -149,12 +153,12 @@ class DisplayGradebook
$select_cat = $catobj->get_parent_id();
$url = 'gradebook_flatview.php';
}
$header .= '<a href="'.$url.'?'.api_get_cidreq().'&selectcat='.$select_cat.'">'.
Display::return_icon('back.png', get_lang('FolderView'), '', ICON_SIZE_MEDIUM).'</a>';
$header .= '<a href="'.$url.'?'.api_get_cidreq().'&selectcat='.$select_cat.'">'
.Display::return_icon('back.png', get_lang('FolderView'), [], ICON_SIZE_MEDIUM).'</a>';
$pageNum = isset($_GET['flatviewlist_page_nr']) ? (int) $_GET['flatviewlist_page_nr'] : null;
$perPage = isset($_GET['flatviewlist_per_page']) ? (int) $_GET['flatviewlist_per_page'] : null;
$offset = isset($_GET['offset']) ? $_GET['offset'] : '0';
$offset = $_GET['offset'] ?? '0';
$exportCsvUrl = api_get_self().'?'.api_get_cidreq().'&'.http_build_query([
'export_format' => 'csv',
@@ -367,8 +371,8 @@ class DisplayGradebook
$additionalButtons = null;
if (!empty($certificateLinkInfo)) {
$additionalButtons .= '<div class="btn-group pull-right">';
$additionalButtons .= isset($certificateLinkInfo['certificate_link']) ? $certificateLinkInfo['certificate_link'] : '';
$additionalButtons .= isset($certificateLinkInfo['badge_link']) ? $certificateLinkInfo['badge_link'] : '';
$additionalButtons .= $certificateLinkInfo['certificate_link'] ?? '';
$additionalButtons .= $certificateLinkInfo['badge_link'] ?? '';
$additionalButtons .= '</div>';
}
$scoreinfo .= '<strong>'.sprintf(get_lang('TotalX'), $scorecourse_display.$additionalButtons).'</strong>';
@@ -381,33 +385,28 @@ class DisplayGradebook
$header = '<div class="actions"><table>';
$header .= '<tr>';
if (!$selectcat == '0') {
$header .= '<td><a href="'.api_get_self().'?selectcat='.$catobj->get_parent_id().'">'.
Display::return_icon(
$header .= '<td><a href="'.api_get_self().'?selectcat='.$catobj->get_parent_id().'">'
.Display::return_icon(
'back.png',
get_lang('BackTo').' '.get_lang('RootCat'),
'',
[],
ICON_SIZE_MEDIUM
).
'</a></td>';
)
.'</a></td>';
}
$header .= '<td>'.get_lang('CurrentCategory').'</td>'.
'<td><form name="selector"><select name="selectcat" onchange="document.selector.submit()">';
$header .= '<td>'.get_lang('CurrentCategory').'</td>'
.'<td><form name="selector"><select name="selectcat" onchange="document.selector.submit()">';
$cats = Category::load();
$tree = $cats[0]->get_tree();
unset($cats);
$line = null;
foreach ($tree as $cat) {
for ($i = 0; $i < $cat[2]; $i++) {
$line .= '&mdash;';
}
$line = isset($line) ? $line : '';
$line = str_repeat('&mdash;', $cat[2]);
if (isset($_GET['selectcat']) && $_GET['selectcat'] == $cat[0]) {
$header .= '<option selected value='.$cat[0].'>'.$line.' '.$cat[1].'</option>';
} else {
$header .= '<option value='.$cat[0].'>'.$line.' '.$cat[1].'</option>';
}
$line = '';
}
$header .= '</select></form></td>';
if (!empty($simple_search_form) && $message_resource === false) {
@@ -430,6 +429,7 @@ class DisplayGradebook
}
// for course admin & platform admin add item buttons are added to the header
$toolbarActions = [];
$actionsLeft = '';
$actionsRight = '';
$my_api_cidreq = api_get_cidreq();
@@ -445,25 +445,25 @@ class DisplayGradebook
$my_api_cidreq = 'cidReq='.$my_category['course_code'];
}
if ($show_add_link && !$message_resource) {
$actionsLeft .= '<a href="gradebook_add_eval.php?'.$my_api_cidreq.'&selectcat='.$catobj->get_id().'" >'.
Display::return_icon('new_evaluation.png', get_lang('NewEvaluation'), '',
ICON_SIZE_MEDIUM).'</a>';
$actionsLeft .= '<a href="gradebook_add_eval.php?'.$my_api_cidreq.'&selectcat='.$catobj->get_id().'" >'
.Display::return_icon('new_evaluation.png', get_lang('NewEvaluation'), [], ICON_SIZE_MEDIUM)
.'</a>';
$cats = Category::load($selectcat);
if ($cats[0]->get_course_code() != null && !$message_resource) {
$actionsLeft .= '<a href="gradebook_add_link.php?'.$my_api_cidreq.'&selectcat='.$catobj->get_id().'">'.
Display::return_icon('new_online_evaluation.png', get_lang('MakeLink'), '',
ICON_SIZE_MEDIUM).'</a>';
$actionsLeft .= '<a href="gradebook_add_link.php?'.$my_api_cidreq.'&selectcat='.$catobj->get_id().'">'
.Display::return_icon('new_online_evaluation.png', get_lang('MakeLink'), [], ICON_SIZE_MEDIUM)
.'</a>';
} else {
$actionsLeft .= '<a href="gradebook_add_link_select_course.php?'.$my_api_cidreq.'&selectcat='.$catobj->get_id().'">'.
Display::return_icon('new_online_evaluation.png', get_lang('MakeLink'), '',
ICON_SIZE_MEDIUM).'</a>';
$actionsLeft .= '<a href="gradebook_add_link_select_course.php?'.$my_api_cidreq.'&selectcat='.$catobj->get_id().'">'
.Display::return_icon('new_online_evaluation.png', get_lang('MakeLink'), [], ICON_SIZE_MEDIUM)
.'</a>';
}
}
}
if ((empty($grade_model_id) || $grade_model_id == -1) && $accessToEdit) {
$actionsLeft .= '<a href="gradebook_add_cat.php?'.api_get_cidreq().'&selectcat='.$catobj->get_id().'">'.
Display::return_icon(
$actionsLeft .= '<a href="gradebook_add_cat.php?'.api_get_cidreq().'&selectcat='.$catobj->get_id().'">'
.Display::return_icon(
'new_folder.png',
get_lang('AddGradebook'),
[],
@@ -471,10 +471,11 @@ class DisplayGradebook
).'</a></td>';
}
if ($selectcat != '0' && $accessToRead) {
if ($selectcat != '0') {
if (!$message_resource) {
$actionsLeft .= '<a href="gradebook_flatview.php?'.$my_api_cidreq.'&selectcat='.$catobj->get_id().'">'.
Display::return_icon('statistics.png', get_lang('FlatView'), '', ICON_SIZE_MEDIUM).'</a>';
$actionsLeft .= '<a href="gradebook_flatview.php?'.$my_api_cidreq.'&selectcat='.$catobj->get_id().'">'
.Display::return_icon('statistics.png', get_lang('FlatView'), [], ICON_SIZE_MEDIUM)
.'</a>';
if ($my_category['generate_certificates'] == 1) {
$actionsLeft .= Display::url(
@@ -510,41 +511,40 @@ class DisplayGradebook
// Right icons
if ($accessToEdit) {
$actionsRight = '<a href="gradebook_edit_cat.php?editcat='.$catobj->get_id(
).'&cidReq='.$catobj->get_course_code().'&id_session='.$catobj->get_session_id().'">'.
Display::return_icon('edit.png', get_lang('Edit'), '', ICON_SIZE_MEDIUM).'</a>';
).'&cidReq='.$catobj->get_course_code().'&id_session='.$catobj->get_session_id().'">'
.Display::return_icon('edit.png', get_lang('Edit'), [], ICON_SIZE_MEDIUM)
.'</a>';
if (api_get_plugin_setting('customcertificate', 'enable_plugin_customcertificate') === 'true' &&
api_get_course_setting('customcertificate_course_enable') == 1
) {
$actionsRight .= '<a href="'.api_get_path(
WEB_PLUGIN_PATH
).'customcertificate/src/index.php?'.
$my_api_cidreq.'&origin=gradebook&selectcat='.$catobj->get_id().'">'.
$actionsRight .= '<a href="'.api_get_path(WEB_PLUGIN_PATH).'customcertificate/src/index.php?'
.$my_api_cidreq.'&origin=gradebook&selectcat='.$catobj->get_id().'">'.
Display::return_icon(
'certificate.png',
get_lang('AttachCertificate'),
'',
[],
ICON_SIZE_MEDIUM
).'</a>';
} else {
$actionsRight .= '<a href="'.api_get_path(WEB_CODE_PATH).
'document/document.php?curdirpath=/certificates&'.
$my_api_cidreq.'&origin=gradebook&selectcat='.$catobj->get_id().'">'.
$actionsRight .= '<a href="'.api_get_path(WEB_CODE_PATH)
.'document/document.php?curdirpath=/certificates&'.$my_api_cidreq
.'&origin=gradebook&selectcat='.$catobj->get_id().'">'.
Display::return_icon(
'certificate.png',
get_lang('AttachCertificate'),
'',
[],
ICON_SIZE_MEDIUM
).'</a>';
}
if (empty($categories)) {
$actionsRight .= '<a href="gradebook_edit_all.php?id_session='.api_get_session_id(
).'&'.$my_api_cidreq.'&selectcat='.$catobj->get_id().'">'.
Display::return_icon(
$actionsRight .= '<a href="gradebook_edit_all.php?id_session='.api_get_session_id()
.'&'.$my_api_cidreq.'&selectcat='.$catobj->get_id().'">'
.Display::return_icon(
'percentage.png',
get_lang('EditAllWeights'),
'',
[],
ICON_SIZE_MEDIUM
).'</a>';
}
@@ -552,8 +552,8 @@ class DisplayGradebook
if (api_get_setting('teachers_can_change_score_settings') == 'true' &&
$score_display_custom['my_display_custom'] == 'true'
) {
$actionsRight .= '<a href="gradebook_scoring_system.php?'.$my_api_cidreq.'&selectcat='.$catobj->get_id().'">'.
Display::return_icon('ranking.png', get_lang('ScoreEdit'), '', ICON_SIZE_MEDIUM).'</a>';
$actionsRight .= '<a href="gradebook_scoring_system.php?'.$my_api_cidreq.'&selectcat='.$catobj->get_id().'">'
.Display::return_icon('ranking.png', get_lang('ScoreEdit'), [], ICON_SIZE_MEDIUM).'</a>';
}
}
}
@@ -563,25 +563,35 @@ class DisplayGradebook
}
$isDrhOfCourse = CourseManager::isUserSubscribedInCourseAsDrh(
api_get_user_id(),
$userId,
api_get_course_info()
);
if ($isDrhOfCourse) {
$actionsLeft .= '<a href="gradebook_flatview.php?'.$my_api_cidreq.'&selectcat='.$catobj->get_id().'">'.
Display::return_icon(
$isDrhOfSession = $sessionId && !empty(SessionManager::getSessionFollowedByDrh($userId, $sessionId));
if ($isDrhOfCourse || $isDrhOfSession) {
$actionsLeft .= '<a href="gradebook_flatview.php?'.$my_api_cidreq.'&selectcat='.$catobj->get_id().'">'
.Display::return_icon(
'statistics.png',
get_lang('FlatView'),
'',
[],
ICON_SIZE_MEDIUM
).
'</a>';
)
.'</a>';
}
if ($isCoach || api_is_allowed_to_edit(null, true)) {
echo $toolbar = Display::toolbarAction(
$toolbarActions = [$actionsLeft, $actionsRight];
}
if (empty($toolbarActions) && ($isDrhOfCourse || $isDrhOfSession)) {
$toolbarActions = [$actionsLeft];
}
if ($toolbarActions) {
echo Display::toolbarAction(
'gradebook-actions',
[$actionsLeft, $actionsRight]
$toolbarActions
);
}
@@ -607,8 +617,8 @@ class DisplayGradebook
}
$min_certification = get_lang('CertificateMinScore').' : '.$min_certification;
$edit_icon = '<a href="gradebook_edit_cat.php?editcat='.$catobj->get_id().'&cidReq='.$catobj->get_course_code().'&id_session='.$catobj->get_session_id().'">'.
Display::return_icon('edit.png', get_lang('Edit'), [], ICON_SIZE_SMALL).'</a>';
$edit_icon = '<a href="gradebook_edit_cat.php?editcat='.$catobj->get_id().'&cidReq='.$catobj->get_course_code().'&id_session='.$catobj->get_session_id().'">'
.Display::return_icon('edit.png', get_lang('Edit')).'</a>';
$msg = $weight.' - '.$min_certification.$edit_icon;
//@todo show description
@@ -659,16 +669,16 @@ class DisplayGradebook
$header = '<div class="actions">';
if ($is_course_admin) {
$header .= '<a href="gradebook_flatview.php?'.api_get_cidreq().'&selectcat='.$catobj->get_id().'">'.
Display::return_icon('statistics.png', get_lang('FlatView'), '', ICON_SIZE_MEDIUM).'</a>';
$header .= '<a href="gradebook_scoring_system.php?'.api_get_cidreq().'&selectcat='.$catobj->get_id().'">'.
Display::return_icon('settings.png', get_lang('ScoreEdit'), '', ICON_SIZE_MEDIUM).'</a>';
$header .= '<a href="gradebook_flatview.php?'.api_get_cidreq().'&selectcat='.$catobj->get_id().'">'
.Display::return_icon('statistics.png', get_lang('FlatView'), [], ICON_SIZE_MEDIUM).'</a>';
$header .= '<a href="gradebook_scoring_system.php?'.api_get_cidreq().'&selectcat='.$catobj->get_id().'">'
.Display::return_icon('settings.png', get_lang('ScoreEdit'), [], ICON_SIZE_MEDIUM).'</a>';
} elseif (!(isset($_GET['studentoverview']))) {
$header .= '<a href="'.api_get_self().'?'.api_get_cidreq().'&studentoverview=&selectcat='.$catobj->get_id().'">'.
Display::return_icon('view_list.gif', get_lang('FlatView')).' '.get_lang('FlatView').'</a>';
$header .= '<a href="'.api_get_self().'?'.api_get_cidreq().'&studentoverview=&selectcat='.$catobj->get_id().'">'
.Display::return_icon('view_list.gif', get_lang('FlatView')).' '.get_lang('FlatView').'</a>';
} else {
$header .= '<a href="'.api_get_self().'?'.api_get_cidreq().'&studentoverview=&exportpdf=&selectcat='.$catobj->get_id().'" target="_blank">'.
Display::return_icon('pdf.png', get_lang('ExportPDF'), '', ICON_SIZE_MEDIUM).'</a>';
$header .= '<a href="'.api_get_self().'?'.api_get_cidreq().'&studentoverview=&exportpdf=&selectcat='.$catobj->get_id().'" target="_blank">'
.Display::return_icon('pdf.png', get_lang('ExportPDF'), [], ICON_SIZE_MEDIUM).'</a>';
}
$header .= '</div>';
echo $header;
@@ -915,7 +915,7 @@ class GradebookTable extends SortableTable
$averageWeight += $averageScore[1];
}
$categoryAverage = $categoryAverage / count($totalAverageList);
//$averageWeight = $averageWeight ($totalAverageList);
$averageWeight = $averageWeight / count($totalAverageList);
// Overwrite main weight
//$totalAverage[0] = $average / count($this->studentList);

Some files were not shown because too many files have changed in this diff Show More