Upgrade 1-11.38

This commit is contained in:
xesmyd
2026-03-30 14:10:30 +02:00
parent f2a7e6d1fc
commit ac648ef29d
24665 changed files with 69682 additions and 2205004 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
+4 -4
View File
@@ -68,7 +68,7 @@
"graphp/graphviz": "~0.2.0", "graphp/graphviz": "~0.2.0",
"guzzlehttp/guzzle": "~6.0", "guzzlehttp/guzzle": "~6.0",
"h5p/h5p-core": "*", "h5p/h5p-core": "*",
"imagine/imagine": "0.6.3",
"ircmaxell/password-compat": "~1.0.4", "ircmaxell/password-compat": "~1.0.4",
"jbroadway/urlify": "1.1.0-stable", "jbroadway/urlify": "1.1.0-stable",
"jeroendesloovere/vcard": "~1.7", "jeroendesloovere/vcard": "~1.7",
@@ -78,7 +78,7 @@
"knplabs/gaufrette": "~0.3", "knplabs/gaufrette": "~0.3",
"knplabs/knp-components": "~1.3", "knplabs/knp-components": "~1.3",
"league/csv": "~8.0", "league/csv": "~8.0",
"media-alchemyst/media-alchemyst": "~0.5", "symfony/process": "~3.0|~4.0",
"michelf/php-markdown": "~1.7", "michelf/php-markdown": "~1.7",
"monolog/monolog": "~1.0", "monolog/monolog": "~1.0",
"mpdf/mpdf": "^8.0", "mpdf/mpdf": "^8.0",
@@ -92,8 +92,8 @@
"php-xapi/repository-api": "dev-master as 0.3.1", "php-xapi/repository-api": "dev-master as 0.3.1",
"php-xapi/repository-doctrine": "dev-master", "php-xapi/repository-doctrine": "dev-master",
"php-xapi/symfony-serializer": "2.1.0 as 2.0", "php-xapi/symfony-serializer": "2.1.0 as 2.0",
"phpmailer/phpmailer": "~6.1", "phpmailer/phpmailer": "^6.8",
"phpoffice/phpexcel": "~1.8", "phpoffice/phpspreadsheet": "^1.28",
"phpoffice/phpword": "~0.14", "phpoffice/phpword": "~0.14",
"phpseclib/phpseclib": "^2.0", "phpseclib/phpseclib": "^2.0",
"robrichards/xmlseclibs": "3.0.*", "robrichards/xmlseclibs": "3.0.*",
Generated
+1488 -1679
View File
File diff suppressed because it is too large Load Diff
+383 -11
View File
@@ -110,6 +110,357 @@
</table> </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"> <div class="version" aria-label="1.11.32">
<a id="1.11.32"></a> <a id="1.11.32"></a>
<h1>Chamilo 1.11.32 - Tikal, 27/06/2025</h1> <h1>Chamilo 1.11.32 - Tikal, 27/06/2025</h1>
@@ -248,12 +599,12 @@
<h3>Improvements (minor features) and debug</h3> <h3>Improvements (minor features) and debug</h3>
In reverse chronological order... In reverse chronological order...
<ul aria-live="off"> <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/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/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-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/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, an … (#6384) Author: @yverhenne</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/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/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/77c1b6cb75751b197c5241f6d337acaf671a1632">77c1b6cb</a>) CI: Force use of PHP 7.4 in automated tests building sequence</li>
@@ -289,7 +640,7 @@
<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-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-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-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-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/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/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/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>
@@ -298,7 +649,7 @@
<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/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/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-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-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/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/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/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>
@@ -334,8 +685,8 @@
<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-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/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-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/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-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-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-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/07ce29dcfbf5a34a317eb2b26a642c69ad4598c7">07ce29dc</a>) Plugin: Azure: User the id property instead of objectId from resource</li>
@@ -366,9 +717,9 @@
<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-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/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-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/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-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-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-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/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/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>
@@ -461,9 +812,9 @@
<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-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-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-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-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-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> <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> </ul>
<h3>Removals</h3> <h3>Removals</h3>
<ul aria-live="off"> <ul aria-live="off">
@@ -17296,6 +17647,27 @@ a simple videoconferencing interface.</p>
<li>(#scorm): indicates that this feature relates to the SCORM standard</li> <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> <li>(#fresh-users): indicates that this feature helps prevent drama with fresh users lacking experience and prone to error</li>
</ul> </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> </div>
</div> </div>
+33
View File
@@ -29,6 +29,7 @@
<li><a href="#9.Change-password-first-login">Change password on first login</a></li> <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="#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="#11.SVG-and-XSS">SVG and XSS</a></li>
<li><a href="#12.Template-files-access">Restricting access to template files</a></li>
</ol> </ol>
<h2><a id="1.Disclosing-server-info"></a>1. Disclosing server info</h2> <h2><a id="1.Disclosing-server-info"></a>1. Disclosing server info</h2>
@@ -280,6 +281,38 @@ This will prevent direct access to your settings and make it seem totally the sa
</ul> </ul>
</p> </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> <h2>Authors</h2>
<ul> <ul>
<li>Yannick Warnier, Chamilo Project Leader, Zend Certified PHP Engineer, BeezNest Belgium SPRL, <li>Yannick Warnier, Chamilo Project Leader, Zend Certified PHP Engineer, BeezNest Belgium SPRL,
+2
View File
@@ -195,7 +195,9 @@ $controller->tpl->assign('navigation_links', $controller->return_navigation_link
$controller->tpl->assign('notice_block', $controller->return_notice()); $controller->tpl->assign('notice_block', $controller->return_notice());
$controller->tpl->assign('help_block', $controller->return_help()); $controller->tpl->assign('help_block', $controller->return_help());
$controller->tpl->assign('student_publication_block', $controller->studentPublicationBlock()); $controller->tpl->assign('student_publication_block', $controller->studentPublicationBlock());
if (!api_is_anonymous() && api_user_is_login()) {
$controller->tpl->assign('skills_block', $controller->returnSkillLinks()); $controller->tpl->assign('skills_block', $controller->returnSkillLinks());
}
if (api_is_anonymous()) { if (api_is_anonymous()) {
$controller->tpl->setLoginBodyClass(); $controller->tpl->setLoginBodyClass();
+1 -1
View File
@@ -95,7 +95,7 @@ if ($usergroup->allowTeachers()) {
$onlyThisSessionList = array_column($sessionList, 'id'); $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 = []; $elements_not_in = $elements_in = [];
if (!empty($session_list)) { if (!empty($session_list)) {
+2
View File
@@ -543,11 +543,13 @@ if (api_is_platform_admin()) {
$blockPlatform['items'] = $items; $blockPlatform['items'] = $items;
} elseif (api_is_session_admin()) { } elseif (api_is_session_admin()) {
$items = []; $items = [];
if (api_get_configuration_value('session_admin_access_global_statistics')) {
$items[] = [ $items[] = [
'class' => 'item-stats', 'class' => 'item-stats',
'url' => 'statistics/index.php', 'url' => 'statistics/index.php',
'label' => get_lang('Statistics'), 'label' => get_lang('Statistics'),
]; ];
}
if (api_get_configuration_value('session_admin_access_system_announcement')) { if (api_get_configuration_value('session_admin_access_system_announcement')) {
$items[] = [ $items[] = [
'class' => 'item-global-announcement', 'class' => 'item-global-announcement',
+10 -3
View File
@@ -960,8 +960,16 @@ switch ($report) {
case 'users_active': case 'users_active':
$content = ''; $content = '';
if ($validated) { if ($validated) {
$startDate = $values['daterange_start']; // Validate date inputs strictly. Security::remove_XSS() (used for
$endDate = $values['daterange_end']; // 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="row">';
$graph .= '<div class="col-md-4"><canvas id="canvas1" style="margin-bottom: 20px"></canvas></div>'; $graph .= '<div class="col-md-4"><canvas id="canvas1" style="margin-bottom: 20px"></canvas></div>';
@@ -986,7 +994,6 @@ switch ($report) {
$conditions = []; $conditions = [];
$extraConditions = ''; $extraConditions = '';
if (!empty($startDate) && !empty($endDate)) { if (!empty($startDate) && !empty($endDate)) {
// $extraConditions is already cleaned inside the function getUserListExtraConditions
$extraConditions .= " AND registration_date BETWEEN '$startDate' AND '$endDate' "; $extraConditions .= " AND registration_date BETWEEN '$startDate' AND '$endDate' ";
} }
+4 -1
View File
@@ -278,7 +278,9 @@ $form->addGroup($group, 'mail', get_lang('SendMailToNewUser'));
$hideNeverExpiresOpt = api_get_configuration_value('user_hide_never_expire_option'); $hideNeverExpiresOpt = api_get_configuration_value('user_hide_never_expire_option');
$lblExpiration = ''; $lblExpiration = '';
$defaultExpiration = 0; $defaultExpiration = 0;
if ($hideNeverExpiresOpt) { $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'); $lblExpiration = get_lang('ExpirationDate');
$defaultExpiration = 1; $defaultExpiration = 1;
$group = []; $group = [];
@@ -302,6 +304,7 @@ if ($hideNeverExpiresOpt) {
); );
} }
$form->addGroup($group, 'max_member_group', $lblExpiration, null, false); $form->addGroup($group, 'max_member_group', $lblExpiration, null, false);
}
// Active account or inactive account // Active account or inactive account
$form->addElement('radio', 'active', get_lang('ActiveAccount'), get_lang('Active'), 1); $form->addElement('radio', 'active', get_lang('ActiveAccount'), get_lang('Active'), 1);
+5 -2
View File
@@ -12,7 +12,7 @@ $this_section = SECTION_PLATFORM_ADMIN;
api_protect_admin_script(true); api_protect_admin_script(true);
$user_id = isset($_GET['user_id']) ? (int) $_GET['user_id'] : (int) $_POST['user_id']; $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; $is_platform_admin = api_is_platform_admin() ? 1 : 0;
$userInfo = api_get_user_info($user_id); $userInfo = api_get_user_info($user_id);
$userEntity = api_get_user_entity($user_id); $userEntity = api_get_user_entity($user_id);
@@ -320,8 +320,10 @@ $form->addElement('label', get_lang('RegistrationDate'), $date);
$defaultExpiration = 0; $defaultExpiration = 0;
if (!$user_data['platform_admin']) { if (!$user_data['platform_admin']) {
$hideNeverExpiresOpt = api_get_configuration_value('user_hide_never_expire_option'); $hideNeverExpiresOpt = api_get_configuration_value('user_hide_never_expire_option');
$hideExpirationDate = api_get_configuration_value('user_hide_expiration_date_for_session_admin');
$lblExpiration = ''; $lblExpiration = '';
if ($hideNeverExpiresOpt) { if (!$hideExpirationDate || api_is_platform_admin()) {
if ($hideNeverExpiresOpt && !api_is_platform_admin()) {
$lblExpiration = get_lang('ExpirationDate'); $lblExpiration = get_lang('ExpirationDate');
$defaultExpiration = 1; $defaultExpiration = 1;
$group = []; $group = [];
@@ -345,6 +347,7 @@ if (!$user_data['platform_admin']) {
); );
} }
$form->addGroup($group, 'max_member_group', $lblExpiration, null, false); $form->addGroup($group, 'max_member_group', $lblExpiration, null, false);
}
// Active account or inactive account // Active account or inactive account
$form->addElement('radio', 'active', get_lang('ActiveAccount'), get_lang('Active'), 1); $form->addElement('radio', 'active', get_lang('ActiveAccount'), get_lang('Active'), 1);
+152 -26
View File
@@ -255,14 +255,12 @@ function complete_missing_data(array $user): array
/** /**
* Save the imported data. * Save the imported data.
* *
* @uses \global variable $inserted_in_course, which returns the list of * @uses global $inserted_in_course, which returns the list of courses the user was inserted in
* courses the user was inserted in *
* @return array The $users array, with 'message' and (for reused users) 'id' set
*/ */
function save_data( function save_data(array $users, bool $sendMail = false, ?string $targetFolder = null): array
array $users, {
bool $sendMail = false,
?string $targetFolder = null
): array {
global $inserted_in_course, $extra_fields; global $inserted_in_course, $extra_fields;
// Not all scripts declare the $inserted_in_course array (although they should). // Not all scripts declare the $inserted_in_course array (although they should).
@@ -281,12 +279,105 @@ function save_data(
$optionsByField = []; $optionsByField = [];
// which extrafield variable are we validating on?
$uniqueField = api_get_configuration_value('extra_field_to_validate_on_user_registration');
foreach ($users as &$user) { foreach ($users as &$user) {
if ($user['has_error']) { if ($user['has_error']) {
$userError[] = $user; $userError[] = $user;
continue; 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 = complete_missing_data($user);
$user['Status'] = api_status_key($user['Status']); $user['Status'] = api_status_key($user['Status']);
$redirection = $user['Redirection'] ?? ''; $redirection = $user['Redirection'] ?? '';
@@ -320,19 +411,29 @@ function save_data(
if ($user_id) { if ($user_id) {
$returnMessage = Display::return_message(get_lang('UserAdded'), 'success'); $returnMessage = Display::return_message(get_lang('UserAdded'), 'success');
} else {
$returnMessage = Display::return_message(get_lang('Error'), 'error');
$userWarning[] = $user;
$user['message'] = $returnMessage;
continue;
}
}
// 3) At this point $user_id is either reused or newly created.
// Enroll in courses:
if (isset($user['Courses']) && is_array($user['Courses'])) { if (isset($user['Courses']) && is_array($user['Courses'])) {
foreach ($user['Courses'] as $course) { foreach ($user['Courses'] as $course) {
if (CourseManager::course_exists($course)) { if (CourseManager::course_exists($course)) {
$result = CourseManager::subscribeUser($user_id, $course, $user['Status']); $result = CourseManager::subscribeUser($user_id, $course, $user['Status']);
if ($result) { if ($result) {
$course_info = api_get_course_info($course); $info = api_get_course_info($course);
$inserted_in_course[$course] = $course_info['title']; $inserted_in_course[$course] = $info['title'];
} }
} }
} }
} }
// 4) Enroll in sessions:
if (isset($user['Sessions']) && is_array($user['Sessions'])) { if (isset($user['Sessions']) && is_array($user['Sessions'])) {
foreach ($user['Sessions'] as $sessionId) { foreach ($user['Sessions'] as $sessionId) {
$sessionInfo = api_get_session_info($sessionId); $sessionInfo = api_get_session_info($sessionId);
@@ -347,41 +448,47 @@ function save_data(
} }
} }
// 5) Subscribe to usergroups:
if (!empty($user['ClassId'])) { if (!empty($user['ClassId'])) {
$classId = explode('|', trim($user['ClassId'])); $classIds = explode('|', trim($user['ClassId']));
foreach ($classId as $id) { foreach ($classIds as $id) {
$usergroup->subscribe_users_to_usergroup($id, [$user_id], false); $usergroup->subscribe_users_to_usergroup($id, [$user_id], false);
} }
} }
// We are sure that the extra field exists. // 6) Update extrafield values (for newly created or even reused users):
foreach ($extra_fields as $extras) { foreach ($extra_fields as $extras) {
if (!isset($user[$extras[1]])) { $fieldVar = $extras[1];
$matchedKey = null;
foreach ($user as $colName => $colVal) {
if (strtolower($colName) === strtolower($fieldVar)) {
$matchedKey = $colName;
break;
}
}
if ($matchedKey === null) {
continue; continue;
} }
$key = $extras[1]; $value = $user[$matchedKey];
$value = $user[$key]; if (!array_key_exists($matchedKey, $optionsByField)) {
$optionsByField[$matchedKey] = $efo->getOptionsByFieldVariable($matchedKey);
if (!array_key_exists($key, $optionsByField)) {
$optionsByField[$key] = $efo->getOptionsByFieldVariable($key);
} }
/** @var ExtraFieldOptions $option */ /** @var ExtraFieldOptions $option */
foreach ($optionsByField[$key] as $option) { foreach ($optionsByField[$matchedKey] as $option) {
if ($option->getDisplayText() === $value) { if ($option->getDisplayText() === $value) {
$value = $option->getValue(); $value = $option->getValue();
break;
} }
} }
UserManager::update_extra_field_value($user_id, $key, $value); UserManager::update_extra_field_value($user_id, $matchedKey, $value);
}
$userSaved[] = $user;
} else {
$returnMessage = Display::return_message(get_lang('Error'), 'warning');
$userWarning[] = $user;
} }
// 7) Record success
$user['id'] = $user_id;
$user['message'] = $returnMessage; $user['message'] = $returnMessage;
$userSaved[] = $user;
} }
// Save with success, error and warning users // Save with success, error and warning users
@@ -708,6 +815,25 @@ if (isset($_POST['formSent']) && $_POST['formSent'] && $_FILES['import_file']['s
Session::erase('user_import_data_'.$userId); Session::erase('user_import_data_'.$userId);
$users = Import::csvToArray($_FILES['import_file']['tmp_name']); $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 = parse_csv_data(
$users, $users,
$cleanFileName, $cleanFileName,
+17 -3
View File
@@ -155,6 +155,7 @@ function trimVariables()
'keyword_username', 'keyword_username',
'keyword_email', 'keyword_email',
'keyword_officialcode', 'keyword_officialcode',
'keyword_phone',
]; ];
foreach ($filterVariables as $variable) { foreach ($filterVariables as $variable) {
@@ -235,6 +236,7 @@ function prepare_user_sql_query($getCount)
'keyword_username', 'keyword_username',
'keyword_email', 'keyword_email',
'keyword_officialcode', 'keyword_officialcode',
'keyword_phone',
'keyword_status', 'keyword_status',
'keyword_active', 'keyword_active',
'keyword_inactive', 'keyword_inactive',
@@ -264,7 +266,8 @@ function prepare_user_sql_query($getCount)
concat(u.lastname,' ',u.firstname) LIKE '$keywordFiltered' OR concat(u.lastname,' ',u.firstname) LIKE '$keywordFiltered' OR
u.username LIKE '$keywordFiltered' OR u.username LIKE '$keywordFiltered' OR
u.official_code 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)) { } elseif (isset($keywordListValues) && !empty($keywordListValues)) {
@@ -308,6 +311,9 @@ function prepare_user_sql_query($getCount)
if (!empty($keywordListValues['keyword_officialcode'])) { if (!empty($keywordListValues['keyword_officialcode'])) {
$sql .= " AND u.official_code LIKE '".Database::escape_string("%".$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 "; $sql .= " $keyword_admin $keyword_extra_value ";
@@ -643,8 +649,12 @@ function modify_filter($user_id, $url_params, $row)
if (api_is_platform_admin(true)) { if (api_is_platform_admin(true)) {
$editProfileUrl = Display::getProfileEditionLink($user_id, true); $editProfileUrl = Display::getProfileEditionLink($user_id, true);
if (!$user_is_anonymous && if (!$user_is_anonymous
api_global_admin_can_edit_admin($user_id, null, true) && api_global_admin_can_edit_admin(
$user_id,
null,
true !== api_get_configuration_value('disallow_session_admin_edit_users')
)
) { ) {
$result .= '<a href="'.$editProfileUrl.'">'. $result .= '<a href="'.$editProfileUrl.'">'.
Display::return_icon( Display::return_icon(
@@ -985,6 +995,9 @@ if (isset($_GET['keyword'])) {
$parameters['keyword_email'] = Security::remove_XSS($_GET['keyword_email']); $parameters['keyword_email'] = Security::remove_XSS($_GET['keyword_email']);
$parameters['keyword_officialcode'] = Security::remove_XSS($_GET['keyword_officialcode']); $parameters['keyword_officialcode'] = Security::remove_XSS($_GET['keyword_officialcode']);
$parameters['keyword_status'] = Security::remove_XSS($_GET['keyword_status']); $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'])) { if (isset($_GET['keyword_active'])) {
$parameters['keyword_active'] = Security::remove_XSS($_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_username', get_lang('LoginName'), false);
$form->addText('keyword_email', get_lang('Email'), false); $form->addText('keyword_email', get_lang('Email'), false);
$form->addText('keyword_officialcode', get_lang('OfficialCode'), 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; $classId = isset($_REQUEST['class_id']) && !empty($_REQUEST['class_id']) ? (int) $_REQUEST['class_id'] : 0;
$options = []; $options = [];
+9 -1
View File
@@ -37,6 +37,8 @@ if (isset($_REQUEST['load_ajax'])) {
} }
$user_id = (int) $_REQUEST['user_id']; $user_id = (int) $_REQUEST['user_id'];
$new_course_list = SessionManager::get_course_list_by_session_id($new_session_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; $course_founded = false;
foreach ($new_course_list as $course_item) { 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 // Check if the same course exist in the session destination
if ($course_founded) { if ($course_founded) {
$result = SessionManager::get_users_by_session($new_session_id); $result = SessionManager::get_users_by_session($new_session_id);
$subscribedToNew = false;
if (empty($result) || !in_array($user_id, array_keys($result))) { if (empty($result) || !in_array($user_id, array_keys($result))) {
if ($debug) { if ($debug) {
echo 'User added to the session'; echo 'User added to the session';
} }
// Registering user to the new session // Registering user to the new session
if ($update_database) { if ($update_database) {
SessionManager::subscribeUsersToSession( $subscribedToNew = SessionManager::subscribeUsersToSession(
$new_session_id, $new_session_id,
[$user_id], [$user_id],
false, false,
@@ -80,6 +83,11 @@ if (isset($_REQUEST['load_ajax'])) {
$update_database, $update_database,
$debug $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 { } else {
echo get_lang('CourseDoesNotExistInThisSession'); echo get_lang('CourseDoesNotExistInThisSession');
} }
@@ -649,7 +649,12 @@ class AttendanceController
// Get data table // Get data table
$data_table = []; $data_table = [];
$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')]; $head_table = ['#', get_lang('Name')];
}
foreach ($data_array['attendant_calendar'] as $class_day) { foreach ($data_array['attendant_calendar'] as $class_day) {
$labelDuration = !empty($class_day['duration']) ? get_lang('Duration').' : '.$class_day['duration'] : ''; $labelDuration = !empty($class_day['duration']) ? get_lang('Duration').' : '.$class_day['duration'] : '';
$head_table[] = $head_table[] =
@@ -667,6 +672,9 @@ class AttendanceController
$cols = 1; $cols = 1;
$result = []; $result = [];
$result['count'] = $count; $result['count'] = $count;
if ($addOfficialCode) {
$result['official_code'] = $user['official_code'];
}
$result['full_name'] = api_get_person_name($user['firstname'], $user['lastname']); $result['full_name'] = api_get_person_name($user['firstname'], $user['lastname']);
foreach ($data_array['attendant_calendar'] as $class_day) { foreach ($data_array['attendant_calendar'] as $class_day) {
if ($class_day['done_attendance'] == 1) { if ($class_day['done_attendance'] == 1) {
+10
View File
@@ -210,6 +210,11 @@ if (api_is_allowed_to_edit(null, true) ||
}); });
</script> </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; ?>" > <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="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;"> <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" > <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 '#'; ?></th>
<th width="10px"><?php echo get_lang('Photo'); ?></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('LastName'); ?></th>
<th width="100px"><?php echo get_lang('FirstName'); ?></th> <th width="100px"><?php echo get_lang('FirstName'); ?></th>
<th width="100px"><?php echo get_lang('AttendancesFaults'); ?></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" > <tr class="tableFloatingHeaderOriginal" >
<th width="10px"><?php echo '#'; ?></th> <th width="10px"><?php echo '#'; ?></th>
<th width="10px"><?php echo get_lang('Photo'); ?></th> <th width="10px"><?php echo get_lang('Photo'); ?></th>
<?php echo $headerOfficialCode; ?>
<th width="150px"><?php echo get_lang('LastName'); ?></th> <th width="150px"><?php echo get_lang('LastName'); ?></th>
<th width="140px"><?php echo get_lang('FirstName'); ?></th> <th width="140px"><?php echo get_lang('FirstName'); ?></th>
<th width="100px"><?php echo get_lang('AttendancesFaults'); ?></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; ?>"> <tr class="<?php echo $class; ?>">
<td><center><?php echo $i; ?></center></td> <td><center><?php echo $i; ?></center></td>
<td><?php echo $data['photo']; ?></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><span title="<?php echo $username; ?>"><?php echo $data['lastname']; ?></span></td>
<td><?php echo $data['firstname']; ?></td> <td><?php echo $data['firstname']; ?></td>
<td> <td>
+10 -5
View File
@@ -89,20 +89,25 @@ if ($form->validate()) {
$user = Login::get_user_accounts_by_username($values['user']); $user = Login::get_user_accounts_by_username($values['user']);
if (!$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::display(
CustomPages::LOST_PASSWORD, CustomPages::INDEX_UNLOGGED,
['info' => $messageText] ['info' => $messageText]
); );
exit; exit;
} }
Display::addFlash( 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; exit;
} }
+1 -1
View File
@@ -165,7 +165,7 @@ function _openid_parse_message($message) {
*/ */
function _openid_nonce() { function _openid_nonce() {
// YYYY-MM-DDThh:mm:ssTZD UTC, plus some optional extra unique chars // 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) . chr(mt_rand(0, 25) + 65) .
chr(mt_rand(0, 25) + 65) . chr(mt_rand(0, 25) + 65) .
+38 -31
View File
@@ -2,12 +2,16 @@
/* For licensing terms, see /license.txt */ /* For licensing terms, see /license.txt */
use Symfony\Component\HttpFoundation\Request as HttpRequest;
$cidReset = true; // Flag forcing the 'current course' reset $cidReset = true; // Flag forcing the 'current course' reset
require_once __DIR__.'/../inc/global.inc.php'; require_once __DIR__.'/../inc/global.inc.php';
api_block_anonymous_users(); api_block_anonymous_users();
$httpRequest = HttpRequest::createFromGlobals();
$auth = new Auth(); $auth = new Auth();
$user_course_categories = CourseManager::get_user_course_categories(api_get_user_id()); $user_course_categories = CourseManager::get_user_course_categories(api_get_user_id());
$courses_in_category = $auth->getCoursesInCategory(false); $courses_in_category = $auth->getCoursesInCategory(false);
@@ -21,8 +25,11 @@ $authorizedActions = [
'set_collapsable', 'set_collapsable',
'unsubscribe', '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(); $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). // 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'])) { if ($httpRequest->request->has('submit_change_course_category')) {
$course2EditCategory = Security::remove_XSS($_POST['course_2_edit_category']); $course2EditCategory = Security::remove_XSS($httpRequest->request->get('course_2_edit_category'));
$courseCategories = Security::remove_XSS($_POST['course_categories']); $courseCategories = Security::remove_XSS($httpRequest->request->get('course_categories'));
$result = $auth->updateCourseCategory($course2EditCategory, $courseCategories); $result = $auth->updateCourseCategory($course2EditCategory, $courseCategories);
if ($result) { if ($result) {
Display::addFlash( Display::addFlash(
@@ -47,28 +54,31 @@ if (isset($_POST['submit_change_course_category'])) {
} }
// We edit course category // We edit course category
if (isset($_POST['submit_edit_course_category']) && if ($httpRequest->request->has('submit_edit_course_category')
isset($_POST['title_course_category']) && $httpRequest->request->has('title_course_category')
&& Security::check_token('post')
) { ) {
$titleCourseCategory = Security::remove_XSS($_POST['title_course_category']); $titleCourseCategory = Security::remove_XSS($httpRequest->request->get('title_course_category'));
$categoryId = Security::remove_XSS($_POST['category_id']); $categoryId = Security::remove_XSS($httpRequest->request->get('category_id'));
$categoryInfo = $auth->getUserCourseCategory($categoryId);
if ($categoryInfo) {
$result = $auth->store_edit_course_category($titleCourseCategory, $categoryId); $result = $auth->store_edit_course_category($titleCourseCategory, $categoryId);
if ($result) { if ($result) {
Display::addFlash( Display::addFlash(
Display::return_message(get_lang('CourseCategoryEditStored')) Display::return_message(get_lang('CourseCategoryEditStored'))
); );
} }
}
header('Location: '.api_get_self()); header('Location: '.api_get_self());
exit; exit;
} }
// We are creating a new user defined course category (= Create Course Category). // We are creating a new user defined course category (= Create Course Category).
if (isset($_POST['create_course_category']) && if ($httpRequest->request->has('create_course_category')
isset($_POST['title_course_category']) && && $titleCourseCategory = $httpRequest->request->get('title_course_category')
strlen(trim($_POST['title_course_category'])) > 0
) { ) {
$titleCourseCategory = Security::remove_XSS($_POST['title_course_category']); $titleCourseCategory = Security::remove_XSS($titleCourseCategory);
$result = $auth->store_course_category($titleCourseCategory); $result = $auth->store_course_category($titleCourseCategory);
if ($result) { if ($result) {
Display::addFlash( 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). // We are moving a course or category of the user up/down the list (=Sort My Courses).
if (isset($_GET['move'])) { if ($getMove = $httpRequest->query->get('move')) {
$getCourse = isset($_GET['course']) ? Security::remove_XSS($_GET['course']) : ''; $getCourse = Security::remove_XSS($httpRequest->query->get('course'));
$getMove = Security::remove_XSS($_GET['move']); $getMove = Security::remove_XSS($getMove);
$getCategory = isset($_GET['category']) ? Security::remove_XSS($_GET['category']) : ''; $getCategory = Security::remove_XSS($httpRequest->query->get('category'));
if (!empty($getCourse)) { if (!empty($getCourse)) {
$result = $auth->move_course($getMove, $getCourse, $getCategory); $result = $auth->move_course($getMove, $getCourse, $getCategory);
if ($result) { if ($result) {
@@ -113,7 +123,7 @@ if (isset($_GET['move'])) {
switch ($action) { switch ($action) {
case 'edit_category': case 'edit_category':
$categoryId = isset($_GET['category_id']) ? (int) $_GET['category_id'] : 0; $categoryId = $httpRequest->query->getInt('category_id');
$categoryInfo = $auth->getUserCourseCategory($categoryId); $categoryInfo = $auth->getUserCourseCategory($categoryId);
if ($categoryInfo) { if ($categoryInfo) {
$categoryName = $categoryInfo['title']; $categoryName = $categoryInfo['title'];
@@ -124,15 +134,15 @@ switch ($action) {
); );
$form->addText('title_course_category', get_lang('Name')); $form->addText('title_course_category', get_lang('Name'));
$form->addHidden('category_id', $categoryId); $form->addHidden('category_id', $categoryId);
$form->addHidden('sec_token', Security::get_token());
$form->addButtonSave(get_lang('Edit'), 'submit_edit_course_category'); $form->addButtonSave(get_lang('Edit'), 'submit_edit_course_category');
$form->setDefaults(['title_course_category' => $categoryName]); $form->setDefaults(['title_course_category' => $categoryName]);
$form->display(); $form->display();
} }
exit; exit;
break;
case 'edit_course_category': case 'edit_course_category':
$edit_course = (int) $_GET['course_id']; $edit_course = $httpRequest->query->getInt('course_id');
$defaultCategoryId = isset($_GET['category_id']) ? (int) $_GET['category_id'] : 0; $defaultCategoryId = $httpRequest->query->getInt('category_id');
$courseInfo = api_get_course_info_by_id($edit_course); $courseInfo = api_get_course_info_by_id($edit_course);
if (empty($courseInfo)) { if (empty($courseInfo)) {
@@ -167,12 +177,10 @@ switch ($action) {
$form->addButtonSave(get_lang('Save'), 'submit_change_course_category'); $form->addButtonSave(get_lang('Save'), 'submit_change_course_category');
$form->display(); $form->display();
exit; exit;
break;
case 'deletecoursecategory': case 'deletecoursecategory':
// we are deleting a course category // we are deleting a course category
if (isset($_GET['id'])) { if ($getId = $httpRequest->query->getInt('id')) {
if (Security::check_token('get')) { if (Security::check_token('get')) {
$getId = Security::remove_XSS($_GET['id']);
$result = $auth->delete_course_category($getId); $result = $auth->delete_course_category($getId);
if ($result) { if ($result) {
Display::addFlash( Display::addFlash(
@@ -183,7 +191,6 @@ switch ($action) {
} }
header('Location: '.api_get_self()); header('Location: '.api_get_self());
exit; exit;
break;
case 'createcoursecategory': case 'createcoursecategory':
$form = new FormValidator( $form = new FormValidator(
'create_course_category', 'create_course_category',
@@ -194,16 +201,15 @@ switch ($action) {
$form->addButtonSave(get_lang('AddCategory'), 'create_course_category'); $form->addButtonSave(get_lang('AddCategory'), 'create_course_category');
$form->display(); $form->display();
exit; exit;
break;
case 'set_collapsable': case 'set_collapsable':
if (!api_get_configuration_value('allow_user_course_category_collapsable')) { if (!api_get_configuration_value('allow_user_course_category_collapsable')) {
api_not_allowed(true); api_not_allowed(true);
} }
$userId = api_get_user_id(); $userId = api_get_user_id();
$categoryId = isset($_REQUEST['categoryid']) ? (int) $_REQUEST['categoryid'] : 0; $categoryId = $httpRequest->query->getInt('categoryid', $httpRequest->request->getInt('categoryid'));
$option = isset($_REQUEST['option']) ? (int) $_REQUEST['option'] : 0; $option = $httpRequest->query->get('option', $httpRequest->request->getInt('option'));
$redirect = isset($_REQUEST['redirect']) ? Security::remove_XSS($_REQUEST['redirect']) : 0; $redirect = $httpRequest->query->get('redirect', $httpRequest->request->get('redirect', ''));
if (empty($userId) || empty($categoryId)) { if (empty($userId) || empty($categoryId)) {
api_not_allowed(true); api_not_allowed(true);
@@ -225,7 +231,6 @@ switch ($action) {
$url = api_get_self(); $url = api_get_self();
header('Location: '.$url); header('Location: '.$url);
exit; exit;
break;
} }
function generateUnsubscribeForm(string $courseCode, string $secToken): string function generateUnsubscribeForm(string $courseCode, string $secToken): string
@@ -433,7 +438,9 @@ if (!empty($courses_without_category)) {
); );
} }
echo ''; 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); echo Display::return_icon('edit_na.png', get_lang('Edit'), '', 22);
} else { } else {
echo Display::url( echo Display::url(
+31 -8
View File
@@ -3,6 +3,7 @@
use Chamilo\CoreBundle\Entity\Skill; use Chamilo\CoreBundle\Entity\Skill;
use Skill as SkillManager; use Skill as SkillManager;
use Symfony\Component\HttpFoundation\Request as HttpRequest;
/** /**
* Page for assign skills to a user. * Page for assign skills to a user.
@@ -11,7 +12,12 @@ use Skill as SkillManager;
*/ */
require_once __DIR__.'/../inc/global.inc.php'; 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)) { if (empty($userId)) {
api_not_allowed(true); api_not_allowed(true);
@@ -53,11 +59,25 @@ if (empty($skillLevels)) {
$skillsOptions[$skill['data']['id']] = $skill['data']['name']; $skillsOptions[$skill['data']['id']] = $skill['data']['name'];
} }
} }
$skillIdFromGet = isset($_REQUEST['id']) ? (int) $_REQUEST['id'] : 0; $skillIdFromGet = $httpRequest->query->getInt(
$currentValue = isset($_REQUEST['current_value']) ? (int) $_REQUEST['current_value'] : 0; 'id',
$currentLevel = isset($_REQUEST['current']) ? (int) str_replace('sub_skill_id_', '', $_REQUEST['current']) : 0; $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); $subSkillList = array_unique($subSkillList);
if (!empty($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); $skill = $skillRepo->find($skillId);
$profile = false; $profile = false;
if ($skill) { if ($skill) {
@@ -234,8 +257,7 @@ if ($showLevels) {
//$form->addRule('acquired_level', get_lang('ThisFieldIsRequired'), 'required'); //$form->addRule('acquired_level', get_lang('ThisFieldIsRequired'), 'required');
} }
$form->addTextarea('argumentation', get_lang('Argumentation'), ['rows' => 6]); $form->addTextarea('argumentation', get_lang('Argumentation'), ['rows' => 6], true);
$form->addRule('argumentation', get_lang('ThisFieldIsRequired'), 'required');
$form->addRule( $form->addRule(
'argumentation', 'argumentation',
sprintf(get_lang('ThisTextShouldBeAtLeastXCharsLong'), 10), sprintf(get_lang('ThisTextShouldBeAtLeastXCharsLong'), 10),
@@ -243,6 +265,7 @@ $form->addRule(
10 10
); );
$form->applyFilter('argumentation', 'trim'); $form->applyFilter('argumentation', 'trim');
$form->applyFilter('argumentation', 'html_filter');
$form->addButtonSave(get_lang('Save')); $form->addButtonSave(get_lang('Save'));
$form->setDefaults($formDefaultValues); $form->setDefaults($formDefaultValues);
+8 -2
View File
@@ -4,6 +4,7 @@
use Chamilo\CoreBundle\Entity\SkillRelUser; use Chamilo\CoreBundle\Entity\SkillRelUser;
use Chamilo\CoreBundle\Entity\SkillRelUserComment; use Chamilo\CoreBundle\Entity\SkillRelUserComment;
use SkillRelUser as SkillRelUserManager; use SkillRelUser as SkillRelUserManager;
use Symfony\Component\HttpFoundation\Request as HttpRequest;
/** /**
* Show information about the issued badge. * Show information about the issued badge.
@@ -13,7 +14,12 @@ use SkillRelUser as SkillRelUserManager;
*/ */
require_once __DIR__.'/../inc/global.inc.php'; 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)) { if (empty($issue)) {
api_not_allowed(true); api_not_allowed(true);
@@ -103,7 +109,7 @@ $skillIssueInfo = [
'acquired_level' => $currentSkillLevel, 'acquired_level' => $currentSkillLevel,
'argumentation_author_id' => $skillIssue->getArgumentationAuthorId(), 'argumentation_author_id' => $skillIssue->getArgumentationAuthorId(),
'argumentation_author_name' => $author['complete_name'], 'argumentation_author_name' => $author['complete_name'],
'argumentation' => $skillIssue->getArgumentation(), 'argumentation' => Security::remove_XSS($skillIssue->getArgumentation()),
'source_name' => $skillIssue->getSourceName(), 'source_name' => $skillIssue->getSourceName(),
'user_id' => $skillIssue->getUser()->getId(), 'user_id' => $skillIssue->getUser()->getId(),
'user_complete_name' => UserManager::formatUserFullName($skillIssue->getUser()), 'user_complete_name' => UserManager::formatUserFullName($skillIssue->getUser()),
+6 -3
View File
@@ -4,6 +4,7 @@
use Chamilo\CoreBundle\Entity\SkillRelUser; use Chamilo\CoreBundle\Entity\SkillRelUser;
use Chamilo\CoreBundle\Entity\SkillRelUserComment; use Chamilo\CoreBundle\Entity\SkillRelUserComment;
use SkillRelUser as SkillRelUserManager; use SkillRelUser as SkillRelUserManager;
use Symfony\Component\HttpFoundation\Request as HttpRequest;
/** /**
* Show information about all issued badges with same skill by user. * 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'; require_once __DIR__.'/../inc/global.inc.php';
$userId = isset($_GET['user']) ? (int) $_GET['user'] : 0; $httpRequest = HttpRequest::createFromGlobals();
$skillId = isset($_GET['skill']) ? (int) $_GET['skill'] : 0;
$userId = $httpRequest->query->getInt('user');
$skillId = $httpRequest->query->getInt('skill');
if (!$userId || !$skillId) { if (!$userId || !$skillId) {
api_not_allowed(true); api_not_allowed(true);
@@ -90,7 +93,7 @@ foreach ($userSkills as $index => $skillIssue) {
$argumentationAuthor['firstname'], $argumentationAuthor['firstname'],
$argumentationAuthor['lastname'] $argumentationAuthor['lastname']
), ),
'argumentation' => $skillIssue->getArgumentation(), 'argumentation' => Security::remove_XSS($skillIssue->getArgumentation()),
'source_name' => $skillIssue->getSourceName(), 'source_name' => $skillIssue->getSourceName(),
'user_id' => $skillIssue->getUser()->getId(), 'user_id' => $skillIssue->getUser()->getId(),
'user_complete_name' => UserManager::formatUserFullName($skillIssue->getUser()), '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(); $origin = api_get_origin();
$action = isset($_GET['action']) ? $_GET['action'] : ''; $action = $_GET['action'] ?? '';
if (api_is_allowed_to_edit()) { if (api_is_allowed_to_edit()) {
$nameTools = get_lang('blog_management'); $nameTools = get_lang('blog_management');
@@ -58,11 +58,14 @@ if (api_is_allowed_to_edit()) {
echo Display::return_message(get_lang('BlogEdited'), 'confirmation'); 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'])); Blog::changeBlogVisibility(intval($_GET['blog_id']));
echo Display::return_message(get_lang('VisibilityChanged'), 'confirmation'); 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'])); Blog::deleteBlog(intval($_GET['blog_id']));
echo Display::return_message(get_lang('BlogDeleted'), 'confirmation'); echo Display::return_message(get_lang('BlogDeleted'), 'confirmation');
} }
+1 -1
View File
@@ -55,7 +55,7 @@ if ($action === 'course_select_form' && Security::check_token('post')) {
// Get admin details // Get admin details
$adminId = (int) $_POST['admin_id']; $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)) { if (!preg_match('/^[a-zA-Z0-9_]+$/', $adminUsername)) {
echo Display::return_message(get_lang('PleaseEnterValidLogin'), 'error'); echo Display::return_message(get_lang('PleaseEnterValidLogin'), 'error');
exit(); exit();
+100 -64
View File
@@ -30,10 +30,7 @@ $domain = 'example.com'; // Manually configured domain for generated emails
// Include Chamilo bootstrap and necessary classes // Include Chamilo bootstrap and necessary classes
require_once __DIR__.'/../../main/inc/global.inc.php'; require_once __DIR__.'/../../main/inc/global.inc.php';
// Include PHPExcel classes (assuming installed via Composer)
require_once __DIR__.'/../../vendor/autoload.php'; require_once __DIR__.'/../../vendor/autoload.php';
require_once __DIR__.'/../../vendor/phpoffice/phpexcel/Classes/PHPExcel.php';
require_once __DIR__.'/../../vendor/phpoffice/phpexcel/Classes/PHPExcel/IOFactory.php';
// Command-line arguments parsing (without getopt) // Command-line arguments parsing (without getopt)
$proceed = false; $proceed = false;
@@ -88,8 +85,8 @@ global $database;
// Load XLSX file // Load XLSX file
try { try {
$inputFileType = PHPExcel_IOFactory::identify($xlsxFile); $inputFileType = \PhpOffice\PhpSpreadsheet\IOFactory::identify($xlsxFile);
$reader = PHPExcel_IOFactory::createReader($inputFileType); $reader = \PhpOffice\PhpSpreadsheet\IOFactory::createReader($inputFileType);
$phpExcel = $reader->load($xlsxFile); $phpExcel = $reader->load($xlsxFile);
$worksheet = $phpExcel->getActiveSheet(); $worksheet = $phpExcel->getActiveSheet();
$xlsxRows = $worksheet->toArray(); $xlsxRows = $worksheet->toArray();
@@ -105,7 +102,7 @@ $xlsxColumnMap = [
'Mail' => 'email', 'Mail' => 'email',
'Matricule' => 'official_code', 'Matricule' => 'official_code',
'N° de badge' => 'password', 'N° de badge' => 'password',
'Tel mobile' => 'phone', 'tel mobile' => 'phone',
'Actif' => 'active', 'Actif' => 'active',
]; ];
@@ -128,6 +125,7 @@ $xlsxEmailCounts = [];
$xlsxNameCounts = []; $xlsxNameCounts = [];
$duplicateEmails = []; $duplicateEmails = [];
$duplicateNames = []; $duplicateNames = [];
$generatedEmails = []; // username -> generatedEmail
$xlsxUsernames = []; // Store usernames from XLSX $xlsxUsernames = []; // Store usernames from XLSX
// Output columns for missing field and duplicate files // Output columns for missing field and duplicate files
@@ -146,8 +144,8 @@ function normalizeName($name)
function removeAccents($str) function removeAccents($str)
{ {
$str = str_replace( $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'], ['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 $str
); );
@@ -177,37 +175,22 @@ function generateProposedLogin($xlsxLastname, $xlsxFirstname, $isActive, &$usedL
$lastFirstnamePart = end($firstnameParts); $lastFirstnamePart = end($firstnameParts);
$lastPartLetters = strtolower(preg_replace('/[\s-]+/', '', $lastFirstnamePart)); $lastPartLetters = strtolower(preg_replace('/[\s-]+/', '', $lastFirstnamePart));
// Increment occurrence count for this login // Handle duplicates by incrementally adding letters from the last firstname part if active
$usedLogins['counts'][$login] = isset($usedLogins['counts'][$login]) ? $usedLogins['counts'][$login] + 1 : 1; if ($isActive) {
$occurrence = $usedLogins['counts'][$login]; $letterCount = 0;
while (isset($usedLogins['logins'][$login]) && $usedLogins['logins'][$login]['active']) {
// Handle duplicates $letterCount++;
if (isset($usedLogins['logins'][$login])) { if ($letterCount > strlen($lastPartLetters) - 1) {
// Only modify if both current and previous users are active break; // No more letters available. Will append a number below
if ($isActive && $usedLogins['logins'][$login]['active']) {
if ($occurrence == 2) {
// Second occurrence: append next letter from last firstname part
if (strlen($lastPartLetters) > 1) {
$login = $baseLogin.substr($lastPartLetters, 1, 1); // e.g., 'i' from 'Pierre'
} else {
$login = $baseLogin.'1'; // Fallback if no more letters
}
} elseif ($occurrence >= 3) {
// Third+ occurrence: append increasing letters from last firstname part
$extraLetters = min($occurrence - 1, strlen($lastPartLetters) - 1); // e.g., 2 letters for 3rd, 3 for 4th
if ($extraLetters > 0) {
$login = $baseLogin.substr($lastPartLetters, 1, $extraLetters); // e.g., 'ii', 'iii'
} else {
$login = $baseLogin.($occurrence - 1); // Fallback to number
}
} }
$login = $baseLogin.substr($lastPartLetters, 1, $letterCount);
} }
} }
// Ensure uniqueness by appending a number if still conflicting // Ensure uniqueness by appending a number if still conflicting
$suffix = 1; $suffix = 1;
$originalLogin = $login; $originalLogin = $login;
while (isset($usedLogins['logins'][$login])) { while (isset($usedLogins['logins'][$login]) && $usedLogins['logins'][$login]['active']) {
$login = $originalLogin.$suffix; $login = $originalLogin.$suffix;
$suffix++; $suffix++;
} }
@@ -227,21 +210,21 @@ function createMissingFieldFile($filename, $rows, $columns)
return; return;
} }
$phpExcel = new PHPExcel(); $phpExcel = new \PhpOffice\PhpSpreadsheet\Spreadsheet();
$worksheet = $phpExcel->getActiveSheet(); $worksheet = $phpExcel->getActiveSheet();
foreach ($columns as $colIndex => $column) { foreach ($columns as $colIndex => $column) {
$worksheet->setCellValueByColumnAndRow($colIndex, 1, $column); $worksheet->setCellValueByColumnAndRow($colIndex + 1, 1, $column);
} }
foreach ($rows as $rowIndex => $rowData) { foreach ($rows as $rowIndex => $rowData) {
foreach ($columns as $colIndex => $column) { foreach ($columns as $colIndex => $column) {
$worksheet->setCellValueByColumnAndRow($colIndex, $rowIndex + 2, $rowData[$column]); $worksheet->setCellValueByColumnAndRow($colIndex + 1, $rowIndex + 2, $rowData[$column]);
} }
} }
try { try {
$writer = PHPExcel_IOFactory::createWriter($phpExcel, 'Excel2007'); $writer = \PhpOffice\PhpSpreadsheet\IOFactory::createWriter($phpExcel, 'Xlsx');
$writer->save($filename); $writer->save($filename);
echo "Generated $filename with ".count($rows)." rows\n"; echo "Generated $filename with ".count($rows)." rows\n";
} catch (Exception $e) { } catch (Exception $e) {
@@ -249,6 +232,19 @@ function createMissingFieldFile($filename, $rows, $columns)
} }
} }
/**
* 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 // Detect potential issues in XLSX file
$usedLogins = ['logins' => [], 'counts' => []]; $usedLogins = ['logins' => [], 'counts' => []];
$generatedEmailCounts = []; $generatedEmailCounts = [];
@@ -295,27 +291,21 @@ foreach ($xlsxRows as $rowIndex => $xlsxRow) {
]; ];
if ($isActive) { if ($isActive) {
if (empty($xlsxUserData['email']) && strpos($xlsxUserData['official_code'], '0009') !== false) { if (empty($xlsxUserData['email'])) {
$emailLastnameParts = preg_split('/[\s-]+/', trim(removeAccents($xlsxUserData['lastname'])), -1, PREG_SPLIT_NO_EMPTY); $generatedEmail = $baseEmail = generateMailFromFirstAndLastNames($xlsxUserData['firstname'], $xlsxUserData['lastname'], $domain);
$emailLastname = !empty($emailLastnameParts[0]) ? strtolower($emailLastnameParts[0]) : '';
$emailFirstnameParts = preg_split('/[\s-]+/', trim(removeAccents($xlsxUserData['firstname'])), -1, PREG_SPLIT_NO_EMPTY);
$emailFirstname = !empty($emailFirstnameParts[0]) ? strtolower($emailFirstnameParts[0]) : '';
$baseEmail = "{$emailLastname}.{$emailFirstname}@{$domain}";
$generatedEmail = $baseEmail;
$suffix = isset($generatedEmailCounts[$baseEmail]) ? count($generatedEmailCounts[$baseEmail]) + 1 : 1; $suffix = isset($generatedEmailCounts[$baseEmail]) ? count($generatedEmailCounts[$baseEmail]) + 1 : 1;
if ($suffix > 1) { if ($suffix > 1) {
$generatedEmail = "{$emailLastname}.{$emailFirstname}{$suffix}@{$domain}"; $generatedEmail = preg_replace('/^([^@]+)@(.+)/', '${1}'.$suffix.'@${2}', $baseEmail);
} }
$generatedEmail = strtoupper($generatedEmail); $generatedEmail = strtoupper($generatedEmail);
$generatedEmailCounts[$baseEmail][] = $rowData; $generatedEmailCounts[$baseEmail][] = $rowData;
$rowData['Mail'] = $generatedEmail; $rowData['Mail'] = $generatedEmail;
$xlsxUserData['email'] = $generatedEmail; $xlsxUserData['email'] = $generatedEmail;
$xlsxUserData['emailSource'] = 'Generated during import';
$emailMissing[] = $rowData; $emailMissing[] = $rowData;
$xlsxEmailCounts[$generatedEmail][] = $rowData; $xlsxEmailCounts[$generatedEmail][] = $rowData;
} elseif (empty($xlsxUserData['email'])) { $generatedEmails[$xlsxUserData['official_code']] = [$generatedEmail];
$emailMissing[] = $rowData;
} }
if (empty($xlsxUserData['lastname'])) { if (empty($xlsxUserData['lastname'])) {
@@ -362,7 +352,7 @@ createMissingFieldFile($outputDir.'duplicate_name.xlsx', $duplicateNames, $outpu
// Generate unmatched_db_users.xlsx // Generate unmatched_db_users.xlsx
$unmatchedUsers = []; $unmatchedUsers = [];
$sql = "SELECT id, username, official_code, email FROM user"; $sql = "SELECT id, username, official_code, email, active FROM user";
$stmt = $database->query($sql); $stmt = $database->query($sql);
while ($dbUser = $stmt->fetch()) { while ($dbUser = $stmt->fetch()) {
if (!in_array($dbUser['username'], $xlsxUsernames) && !empty($dbUser['username'])) { if (!in_array($dbUser['username'], $xlsxUsernames) && !empty($dbUser['username'])) {
@@ -371,10 +361,11 @@ while ($dbUser = $stmt->fetch()) {
'Username' => $dbUser['username'], 'Username' => $dbUser['username'],
'User ID' => $dbUser['id'], 'User ID' => $dbUser['id'],
'E-mail' => $dbUser['email'], 'E-mail' => $dbUser['email'],
'Active' => $dbUser['active'] ? 'Yes' : 'No',
]; ];
} }
} }
$unmatchedColumns = ['Matricule', 'Username', 'User ID', 'E-mail']; $unmatchedColumns = ['Matricule', 'Username', 'User ID', 'E-mail', 'Active'];
createMissingFieldFile($outputDir.'unmatched_db_users.xlsx', $unmatchedUsers, $unmatchedColumns); createMissingFieldFile($outputDir.'unmatched_db_users.xlsx', $unmatchedUsers, $unmatchedColumns);
// Process users: compare with database, log decisions, and update/insert if --proceed // Process users: compare with database, log decisions, and update/insert if --proceed
@@ -383,8 +374,10 @@ $userManager = new UserManager();
$usedLogins = ['logins' => [], 'counts' => []]; // Reset usedLogins to avoid false duplicates $usedLogins = ['logins' => [], 'counts' => []]; // Reset usedLogins to avoid false duplicates
$emptyRowCount = 0; $emptyRowCount = 0;
$userActions = []; // Initialize array to store user actions $userActions = []; // Initialize array to store user actions
$userSkippedWhileActive = []; // Initialize array to store special cases
foreach ($xlsxRows as $rowIndex => $rowData) { foreach ($xlsxRows as $rowIndex => $rowData) {
// Check for empty row // Check for empty row
$emailSource = 'SAP';
$isEmpty = true; $isEmpty = true;
foreach ($rowData as $cell) { foreach ($rowData as $cell) {
if (!empty(trim($cell))) { if (!empty(trim($cell))) {
@@ -413,26 +406,36 @@ foreach ($xlsxRows as $rowIndex => $rowData) {
$xlsxUserData['username'] = generateProposedLogin($xlsxUserData['lastname'], $xlsxUserData['firstname'], $isActive, $usedLogins); $xlsxUserData['username'] = generateProposedLogin($xlsxUserData['lastname'], $xlsxUserData['firstname'], $isActive, $usedLogins);
$dbUsername = Database::escape_string($xlsxUserData['username']); $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 // Get current time for row logging
$rowTime = new DateTime(); $rowTime = new DateTime();
// Skip users with Matricule starting with 0009 // Skip users with Matricule starting with 0009
if (strpos($xlsxUserData['official_code'], '0009') === 0) { if (strpos($xlsxUserData['official_code'], '0009') === 0) {
echo '['.$rowTime->format('H:i:s').'] Row '.($rowIndex + 2).": Skipped - Matricule starts with 0009 (username: $dbUsername)\n"; echo '['.$rowTime->format('H:i:s').'] Row '.($rowIndex + 2).": Skipped - Matricule starts with 0009 (username: $dbUsername)\n";
$userActions[] = [ $logRow = [
'Action Type' => 'skipped', 'Action Type' => 'skipped',
'User ID' => '', 'User ID' => '',
'Username' => $dbUsername, 'Username' => $dbUsername,
'Official Code' => $xlsxUserData['official_code'], 'Official Code' => $xlsxUserData['official_code'],
'E-mail' => $xlsxUserData['email'], 'E-mail' => $xlsxUserData['email'],
'E-mail source' => $emailSource,
'External User ID' => $xlsxUserData['official_code'], 'External User ID' => $xlsxUserData['official_code'],
'Updated Fields' => 'Matricule starts with 0009', 'Updated Fields' => 'Matricule starts with 0009',
]; ];
$userActions[] = $logRow;
$userSkippedWhileActive[] = $logRow;
continue; continue;
} }
// Check for existing user by username // Check for existing user by username
$sql = "SELECT id, firstname, lastname, email, official_code, phone, active $sql = "SELECT id, firstname, lastname, email, official_code, phone, active, status, picture_uri, expiration_date, language, creator_id
FROM user FROM user
WHERE username = '$dbUsername'"; WHERE username = '$dbUsername'";
$stmt = $database->query($sql); $stmt = $database->query($sql);
@@ -445,14 +448,16 @@ foreach ($xlsxRows as $rowIndex => $rowData) {
// Decision logic // Decision logic
if (empty($dbUser) && empty($xlsxUserData['active'])) { 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"; 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[] = [ $userActions[] = [
'Action Type' => 'skipped', 'Action Type' => 'skipped',
'User ID' => '', 'User ID' => '',
'Username' => $dbUsername, 'Username' => $dbUsername,
'Official Code' => $xlsxUserData['official_code'], 'Official Code' => $xlsxUserData['official_code'],
'E-mail' => $xlsxUserData['email'], 'E-mail' => $xlsxUserData['email'],
'E-mail source' => $emailSource,
'External User ID' => $xlsxMatricule, 'External User ID' => $xlsxMatricule,
'Updated Fields' => 'Actif is empty and no matching user in database', 'Updated Fields' => '"Active" field is empty and no matching user in database',
]; ];
continue; continue;
} }
@@ -463,23 +468,30 @@ foreach ($xlsxRows as $rowIndex => $rowData) {
foreach ($requiredFields as $field) { foreach ($requiredFields as $field) {
if (empty($xlsxUserData[$field])) { if (empty($xlsxUserData[$field])) {
$missingFields[] .= $field; $missingFields[] .= $field;
if ($field == 'email') {
$emailSource = 'EMPTY IN SAP';
}
} }
} }
if (!empty($missingFields)) { if (!empty($missingFields)) {
echo '['.$rowTime->format('H:i:s').'] Row '.($rowIndex + 2).': Skipped - missing fields: '.implode(', ', $missingFields)." (username: $dbUsername)\n"; echo '['.$rowTime->format('H:i:s').'] Row '.($rowIndex + 2).': Skipped - missing fields: '.implode(', ', $missingFields)." (username: $dbUsername)\n";
$userActions[] = [ $logRow = [
'Action Type' => 'skipped', 'Action Type' => 'skipped',
'User ID' => '', 'User ID' => '',
'Username' => $dbUsername, 'Username' => $dbUsername,
'Official Code' => $xlsxUserData['official_code'], 'Official Code' => $xlsxUserData['official_code'],
'E-mail' => $xlsxUserData['email'], 'E-mail' => $xlsxUserData['email'],
'E-mail source' => $emailSource,
'External User ID' => $xlsxMatricule, 'External User ID' => $xlsxMatricule,
'Updated Fields' => 'Missing fields: '.implode(', ', $missingFields), 'Updated Fields' => 'Missing fields: '.implode(', ', $missingFields),
]; ];
$userActions[] = $logRow;
$userSkippedWhileActive[] = $logRow;
continue; continue;
} }
// If the user was found/existed in the local database
if ($dbUser) { if ($dbUser) {
// Check for updates // Check for updates
$updates = []; $updates = [];
@@ -515,12 +527,16 @@ foreach ($xlsxRows as $rowIndex => $rowData) {
null, // password not updated null, // password not updated
null, // auth_source null, // auth_source
$xlsxUserData['email'], $xlsxUserData['email'],
null, // status $dbUser['status'], // status
$xlsxUserData['official_code'], $xlsxUserData['official_code'],
$xlsxUserData['phone'], $xlsxUserData['phone'],
null, // picture_uri $dbUser['picture_uri'], // picture_uri
null, // expiration_date $dbUser['expiration_date'], // expiration_date
$xlsxActive $xlsxActive,
$dbUser['creator_id'],
0,
null,
$dbUser['language']
); );
if ($user) { if ($user) {
// Update extra field 'external_user_id' // Update extra field 'external_user_id'
@@ -532,32 +548,39 @@ foreach ($xlsxRows as $rowIndex => $rowData) {
'Username' => $dbUsername, 'Username' => $dbUsername,
'Official Code' => $xlsxUserData['official_code'], 'Official Code' => $xlsxUserData['official_code'],
'E-mail' => $xlsxUserData['email'], 'E-mail' => $xlsxUserData['email'],
'E-mail source' => $emailSource,
'External User ID' => $xlsxMatricule, 'External User ID' => $xlsxMatricule,
'Updated Fields' => implode(', ', array_map(function ($update) { return trim(explode(':', $update)[0]); }, $updates)), 'Updated Fields' => implode(', ', array_map(function ($update) { return trim(explode(':', $update)[0]); }, $updates)),
]; ];
} else { } else {
echo " Error: Could not update user (username: $dbUsername)\n"; echo " Error: Could not update user (username: $dbUsername)\n";
$userActions[] = [ $logRow = [
'Action Type' => 'skipped', 'Action Type' => 'skipped',
'User ID' => $dbUser['id'], 'User ID' => $dbUser['id'],
'Username' => $dbUsername, 'Username' => $dbUsername,
'Official Code' => $xlsxUserData['official_code'], 'Official Code' => $xlsxUserData['official_code'],
'E-mail' => $xlsxUserData['email'], 'E-mail' => $xlsxUserData['email'],
'E-mail source' => $emailSource,
'External User ID' => $xlsxMatricule, 'External User ID' => $xlsxMatricule,
'Updated Fields' => 'Could not update user', 'Updated Fields' => 'Could not update user',
]; ];
$userActions[] = $logRow;
$userSkippedWhileActive[] = $logRow;
} }
} catch (Exception $e) { } catch (Exception $e) {
echo " Error: Failed to update user (username: $dbUsername): {$e->getMessage()}\n"; echo " Error: Failed to update user (username: $dbUsername): {$e->getMessage()}\n";
$userActions[] = [ $logRow = [
'Action Type' => 'skipped', 'Action Type' => 'skipped',
'User ID' => $dbUser['id'], 'User ID' => $dbUser['id'],
'Username' => $dbUsername, 'Username' => $dbUsername,
'Official Code' => $xlsxUserData['official_code'], 'Official Code' => $xlsxUserData['official_code'],
'E-mail' => $xlsxUserData['email'], 'E-mail' => $xlsxUserData['email'],
'E-mail source' => $emailSource,
'External User ID' => $xlsxMatricule, 'External User ID' => $xlsxMatricule,
'Updated Fields' => 'Failed to update user: '.$e->getMessage(), 'Updated Fields' => 'Failed to update user: '.$e->getMessage(),
]; ];
$userActions[] = $logRow;
$userSkippedWhileActive[] = $logRow;
} }
} else { } else {
echo " Sim mode: Updated user and external_user_id (username: $dbUsername)\n"; echo " Sim mode: Updated user and external_user_id (username: $dbUsername)\n";
@@ -567,21 +590,25 @@ foreach ($xlsxRows as $rowIndex => $rowData) {
'Username' => $dbUsername, 'Username' => $dbUsername,
'Official Code' => $xlsxUserData['official_code'], 'Official Code' => $xlsxUserData['official_code'],
'E-mail' => $xlsxUserData['email'], 'E-mail' => $xlsxUserData['email'],
'E-mail source' => $emailSource,
'External User ID' => $xlsxMatricule, 'External User ID' => $xlsxMatricule,
'Updated Fields' => implode(', ', array_map(function ($update) { return trim(explode(':', $update)[0]); }, $updates)), 'Updated Fields' => implode(', ', array_map(function ($update) { return trim(explode(':', $update)[0]); }, $updates)),
]; ];
} }
} else { } else {
echo '['.$rowTime->format('H:i:s').'] Row '.($rowIndex + 2).": No action - no changes needed (username: $dbUsername)\n"; echo '['.$rowTime->format('H:i:s').'] Row '.($rowIndex + 2).": No action - no changes needed (username: $dbUsername)\n";
$userActions[] = [ $logRow = [
'Action Type' => 'skipped', 'Action Type' => 'skipped',
'User ID' => $dbUser['id'], 'User ID' => $dbUser['id'],
'Username' => $dbUsername, 'Username' => $dbUsername,
'Official Code' => $xlsxUserData['official_code'], 'Official Code' => $xlsxUserData['official_code'],
'E-mail' => $xlsxUserData['email'], 'E-mail' => $xlsxUserData['email'],
'E-mail source' => $emailSource,
'External User ID' => $xlsxMatricule, 'External User ID' => $xlsxMatricule,
'Updated Fields' => 'No changes needed', 'Updated Fields' => 'No changes needed',
]; ];
$userActions[] = $logRow;
$userSkippedWhileActive[] = $logRow;
} }
} else { } else {
echo '['.$rowTime->format('H:i:s').'] Row '.($rowIndex + 2).": Insert new user - No existing user found (username: $dbUsername)\n"; echo '['.$rowTime->format('H:i:s').'] Row '.($rowIndex + 2).": Insert new user - No existing user found (username: $dbUsername)\n";
@@ -602,7 +629,7 @@ foreach ($xlsxRows as $rowIndex => $rowData) {
null, null,
null, null,
$xlsxActive, $xlsxActive,
null, 0,
null // creator_id null // creator_id
); );
if ($userId) { if ($userId) {
@@ -615,32 +642,39 @@ foreach ($xlsxRows as $rowIndex => $rowData) {
'Username' => $dbUsername, 'Username' => $dbUsername,
'Official Code' => $xlsxUserData['official_code'], 'Official Code' => $xlsxUserData['official_code'],
'E-mail' => $xlsxUserData['email'], 'E-mail' => $xlsxUserData['email'],
'E-mail source' => $emailSource,
'External User ID' => $xlsxMatricule, 'External User ID' => $xlsxMatricule,
'Updated Fields' => '', 'Updated Fields' => '',
]; ];
} else { } else {
echo " Error: Could not create user (username: $dbUsername)\n"; echo " Error: Could not create user (username: $dbUsername)\n";
$userActions[] = [ $logRow = [
'Action Type' => 'skipped', 'Action Type' => 'skipped',
'User ID' => '', 'User ID' => '',
'Username' => $dbUsername, 'Username' => $dbUsername,
'Official Code' => $xlsxUserData['official_code'], 'Official Code' => $xlsxUserData['official_code'],
'E-mail' => $xlsxUserData['email'], 'E-mail' => $xlsxUserData['email'],
'E-mail source' => $emailSource,
'External User ID' => $xlsxMatricule, 'External User ID' => $xlsxMatricule,
'Updated Fields' => 'Could not create user', 'Updated Fields' => 'Could not create user',
]; ];
$userActions[] = $logRow;
$userSkippedWhileActive[] = $logRow;
} }
} catch (Exception $e) { } catch (Exception $e) {
echo " Error: Failed to insert user (username: $dbUsername): {$e->getMessage()}\n"; echo " Error: Failed to insert user (username: $dbUsername): {$e->getMessage()}\n";
$userActions[] = [ $logRow = [
'Action Type' => 'skipped', 'Action Type' => 'skipped',
'User ID' => '', 'User ID' => '',
'Username' => $dbUsername, 'Username' => $dbUsername,
'Official Code' => $xlsxUserData['official_code'], 'Official Code' => $xlsxUserData['official_code'],
'E-mail' => $xlsxUserData['email'], 'E-mail' => $xlsxUserData['email'],
'E-mail source' => $emailSource,
'External User ID' => $xlsxMatricule, 'External User ID' => $xlsxMatricule,
'Updated Fields' => 'Failed to insert user: '.$e->getMessage(), 'Updated Fields' => 'Failed to insert user: '.$e->getMessage(),
]; ];
$userActions[] = $logRow;
$userSkippedWhileActive[] = $logRow;
} }
} else { } else {
echo " Sim mode: Inserted user and external_user_id (username: $dbUsername)\n"; echo " Sim mode: Inserted user and external_user_id (username: $dbUsername)\n";
@@ -650,6 +684,7 @@ foreach ($xlsxRows as $rowIndex => $rowData) {
'Username' => $dbUsername, 'Username' => $dbUsername,
'Official Code' => $xlsxUserData['official_code'], 'Official Code' => $xlsxUserData['official_code'],
'E-mail' => $xlsxUserData['email'], 'E-mail' => $xlsxUserData['email'],
'E-mail source' => $emailSource,
'External User ID' => $xlsxMatricule, 'External User ID' => $xlsxMatricule,
'Updated Fields' => '', 'Updated Fields' => '',
]; ];
@@ -658,8 +693,9 @@ foreach ($xlsxRows as $rowIndex => $rowData) {
} }
// Generate user actions XLSX file // Generate user actions XLSX file
$actionColumns = ['Action Type', 'User ID', 'Username', 'Official Code', 'E-mail', 'External User ID', 'Updated Fields']; $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.'user_actions.xlsx', $userActions, $actionColumns);
createMissingFieldFile($outputDir.'skipped_user_actions.xlsx', $userSkippedWhileActive, $actionColumns);
if (!$proceed) { if (!$proceed) {
echo "\nUse --proceed to apply changes to the database.\n"; echo "\nUse --proceed to apply changes to the database.\n";
+1 -7
View File
@@ -215,15 +215,9 @@ class Draggable extends Question
{ {
$header = parent::return_header($exercise, $counter, $score); $header = parent::return_header($exercise, $counter, $score);
$header .= '<table class="'.$this->question_table_class.'"><tr>'; $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('ElementList').'</th>';
$header .= '<th>'.get_lang('YourChoice').'</th>'; $header .= '<th>'.get_lang('YourChoice').'</th>';
if ($exercise->showExpectedChoice() || $exercise->showExpectedChoiceColumn()) {
$header .= '<th>'.get_lang('ExpectedChoice').'</th>'; $header .= '<th>'.get_lang('ExpectedChoice').'</th>';
} }
$header .= '<th>'.get_lang('Status').'</th>'; $header .= '<th>'.get_lang('Status').'</th>';
+1 -1
View File
@@ -1050,7 +1050,7 @@ class Answer
} }
// Fix correct answers // 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); $onlyAnswersFlip = array_flip($onlyAnswers);
foreach ($correctAnswers as $answer_id => $correct_answer) { foreach ($correctAnswers as $answer_id => $correct_answer) {
$params = []; $params = [];
+25 -82
View File
@@ -1465,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. * changes the exercise type.
* *
@@ -4516,7 +4466,7 @@ class Exercise
if (!$switchableAnswerSet) { if (!$switchableAnswerSet) {
// not switchable answer, must be in the same place than teacher order // not switchable answer, must be in the same place than teacher order
for ($i = 0; $i < count($listCorrectAnswers['words']); $i++) { for ($i = 0; $i < count($listCorrectAnswers['words']); $i++) {
$studentAnswer = isset($choice[$i]) ? $choice[$i] : ''; $studentAnswer = $choice[$i] ?? '';
$correctAnswer = $listCorrectAnswers['words'][$i]; $correctAnswer = $listCorrectAnswers['words'][$i];
if ($debug) { if ($debug) {
@@ -4527,16 +4477,16 @@ class Exercise
// This value is the user input, not escaped while correct answer is escaped by ckeditor // 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 // Works with cyrillic alphabet and when using ">" chars see #7718 #7610 #7618
// ENT_QUOTES is used in order to transform ' to &#039; // ENT_QUOTES is used in order to transform ' to &#039;
if (!$from_database) { //if (!$from_database) {
$studentAnswer = FillBlanks::clearStudentAnswer($studentAnswer); $studentAnswer = FillBlanks::clearStudentAnswer($studentAnswer);
if ($debug) { if ($debug) {
error_log('Student answer cleaned:'); error_log('Student answer cleaned:');
error_log($studentAnswer); error_log($studentAnswer);
} }
} //}
$isAnswerCorrect = 0; $isAnswerCorrect = 0;
if (FillBlanks::isStudentAnswerGood($studentAnswer, $correctAnswer, $from_database)) { if (FillBlanks::isStudentAnswerGood($studentAnswer, $correctAnswer, $from_database, true)) {
// gives the related weighting to the student // gives the related weighting to the student
$questionScore += $answerWeighting[$i]; $questionScore += $answerWeighting[$i];
// increments total score // increments total score
@@ -4594,7 +4544,7 @@ class Exercise
$found = false; $found = false;
for ($j = 0; $j < count($listTeacherAnswerTemp); $j++) { for ($j = 0; $j < count($listTeacherAnswerTemp); $j++) {
$correctAnswer = isset($listTeacherAnswerTemp[$j]) ? $listTeacherAnswerTemp[$j] : ''; $correctAnswer = $listTeacherAnswerTemp[$j] ?? '';
if (is_array($listTeacherAnswerTemp)) { if (is_array($listTeacherAnswerTemp)) {
$correctAnswer = implode('||', $listTeacherAnswerTemp); $correctAnswer = implode('||', $listTeacherAnswerTemp);
} }
@@ -4995,7 +4945,7 @@ class Exercise
if (false === $this->showExpectedChoice() && if (false === $this->showExpectedChoice() &&
false === $showTotalScoreAndUserChoicesInLastAttempt false === $showTotalScoreAndUserChoicesInLastAttempt
) { ) {
$user_answer = ''; $this->hideExpectedAnswer = true;
} }
switch ($answerType) { switch ($answerType) {
case MATCHING: case MATCHING:
@@ -5057,9 +5007,6 @@ class Exercise
echo '</tr>'; echo '</tr>';
break; break;
case DRAGGABLE: case DRAGGABLE:
if (false == $showTotalScoreAndUserChoicesInLastAttempt) {
$s_answer_label = '';
}
if (RESULT_DISABLE_SHOW_SCORE_ATTEMPT_SHOW_ANSWERS_LAST_ATTEMPT_NO_FEEDBACK == $this->results_disabled) { if (RESULT_DISABLE_SHOW_SCORE_ATTEMPT_SHOW_ANSWERS_LAST_ATTEMPT_NO_FEEDBACK == $this->results_disabled) {
if (false === $showTotalScoreAndUserChoicesInLastAttempt && empty($s_user_answer)) { if (false === $showTotalScoreAndUserChoicesInLastAttempt && empty($s_user_answer)) {
break; break;
@@ -5067,35 +5014,15 @@ class Exercise
} }
echo '<tr>'; echo '<tr>';
if ($this->showExpectedChoice()) { if ($this->showExpectedChoice() || $this->showExpectedChoiceColumn()) {
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');
}
echo '<td>'.$s_answer_label.'</td>'; echo '<td>'.$s_answer_label.'</td>';
echo '<td>'.$user_answer.'</td>';
echo '<td>'.$real_list[$i_answer_correct_answer].'</td>';
echo '<td>'.$status.'</td>'; echo '<td>'.$status.'</td>';
} else { } else {
echo '<td>'.$s_answer_label.'</td>'; echo '<td>'.$s_answer_label.'</td>';
echo '<td>'.$user_answer.'</td>'; echo '<td>'.$user_answer.'</td>';
echo '<td>'.$counterAnswer.'</td>';
echo '<td>'.$status.'</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>'; echo '</tr>';
break; break;
@@ -10043,6 +9970,14 @@ class Exercise
); );
} else { } else {
if ($row['active'] == 0 || $visibility == 0) { if ($row['active'] == 0 || $visibility == 0) {
$visibleOnBaseCourse = api_get_item_visibility(
$courseInfo,
TOOL_QUIZ,
$row['iid'],
0
);
if ($visibleOnBaseCourse) {
$visibility = Display::url( $visibility = Display::url(
Display::return_icon( Display::return_icon(
'invisible.png', 'invisible.png',
@@ -10052,6 +9987,14 @@ class Exercise
), ),
'exercise.php?'.api_get_cidreq().'&choice=enable&sec_token='.$token.'&exerciseId='.$row['iid'] '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
);
}
} else { } else {
// else if not active // else if not active
$visibility = Display::url( $visibility = Display::url(
+38
View File
@@ -266,6 +266,22 @@ if (!empty($action) && $is_allowedToEdit) {
break; 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 // enables an exercise
if (empty($sessionId)) { if (empty($sessionId)) {
$objExerciseTmp->enable(); $objExerciseTmp->enable();
@@ -368,6 +384,22 @@ if ($is_allowedToEdit) {
break; 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 // Enables an exercise
if (empty($sessionId)) { if (empty($sessionId)) {
$objExerciseTmp->enable(); $objExerciseTmp->enable();
@@ -520,6 +552,12 @@ if ($is_allowedToEdit) {
// Teacher change exercise // Teacher change exercise
break; break;
} }
// Security: reject path traversal attempts (CWE-22)
if (!Security::check_abs_path($documentPath.$file, $documentPath.'/')) {
api_not_allowed(true);
}
// deletes an exercise // deletes an exercise
$imgparams = []; $imgparams = [];
$imgcount = 0; $imgcount = 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 = Database::get_course_table(TABLE_QUIZ_TEST);
$TBL_EXERCISES_QUESTION = Database::get_course_table(TABLE_QUIZ_QUESTION); $TBL_EXERCISES_QUESTION = Database::get_course_table(TABLE_QUIZ_QUESTION);
$TBL_TRACK_ATTEMPT_RECORDING = Database::get_main_table(TABLE_STATISTIC_TRACK_E_ATTEMPT_RECORDING); $TBL_TRACK_ATTEMPT_RECORDING = Database::get_main_table(TABLE_STATISTIC_TRACK_E_ATTEMPT_RECORDING);
Display::display_header($nameTools, 'Exercise'); Display::display_header(get_lang('ViewHistoryChange'), 'Exercise');
if (isset($_GET['message'])) { if (isset($_GET['message'])) {
if (in_array($_GET['message'], ['ExerciseEdited'])) { if (in_array($_GET['message'], ['ExerciseEdited'])) {
@@ -79,7 +79,7 @@ while ($row = Database::fetch_array($query)) {
echo '<td>'.$row['question'].'</td>'; echo '<td>'.$row['question'].'</td>';
echo '<td>'.$row['marks'].'</td>'; echo '<td>'.$row['marks'].'</td>';
if (!empty($row['teacher_comment'])) { if (!empty($row['teacher_comment'])) {
echo '<td>'.$row['teacher_comment'].'</td>'; echo '<td>'.Security::remove_XSS($row['teacher_comment']).'</td>';
} else { } else {
echo '<td>'.get_lang('WithoutComment').'</td>'; echo '<td>'.get_lang('WithoutComment').'</td>';
} }
+5 -5
View File
@@ -491,12 +491,12 @@ class ExerciseResult
$filename = 'exercise_results_user_'.$user_id.'_'.$now.'.xlsx'; $filename = 'exercise_results_user_'.$user_id.'_'.$now.'.xlsx';
} }
$spreadsheet = new PHPExcel(); $spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet();
$spreadsheet->setActiveSheetIndex(0); $spreadsheet->setActiveSheetIndex(0);
$worksheet = $spreadsheet->getActiveSheet(); $worksheet = $spreadsheet->getActiveSheet();
$line = 1; // Skip first line $line = 1;
$column = 0; //skip the first column (row titles) $column = 1;
// check if exists column 'user' // check if exists column 'user'
$with_column_user = false; $with_column_user = false;
@@ -584,7 +584,7 @@ class ExerciseResult
$line++; $line++;
foreach ($this->results as $row) { foreach ($this->results as $row) {
$column = 0; $column = 1;
if ($with_column_user) { if ($with_column_user) {
if (api_is_western_name_order()) { if (api_is_western_name_order()) {
$worksheet->setCellValueByColumnAndRow( $worksheet->setCellValueByColumnAndRow(
@@ -738,7 +738,7 @@ class ExerciseResult
} }
$file = api_get_path(SYS_ARCHIVE_PATH).api_replace_dangerous_char($filename); $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); $writer->save($file);
DocumentManager::file_send_for_download($file, true, $filename); 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); $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_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_id = $exercise_stat_info['orig_lp_item_id'] ?? 0;
$learnpath_item_view_id = isset($exercise_stat_info['orig_lp_item_view_id']) $learnpath_item_view_id = $exercise_stat_info['orig_lp_item_view_id'] ?? 0;
? $exercise_stat_info['orig_lp_item_view_id'] : 0; $exerciseId = $exercise_stat_info['exe_exo_id'] ?? 0;
$exerciseId = isset($exercise_stat_info['exe_exo_id']) ? $exercise_stat_info['exe_exo_id'] : 0;
$logInfo = [ $logInfo = [
'tool' => TOOL_QUIZ, 'tool' => TOOL_QUIZ,
@@ -357,7 +356,7 @@ $template->assign('actions', $pageActions);
$template->assign('content', $template->fetch($template->get_template('exercise/result.tpl'))); $template->assign('content', $template->fetch($template->get_template('exercise/result.tpl')));
$template->display_one_col_template(); $template->display_one_col_template();
function showEmbeddableFinishButton() function showEmbeddableFinishButton(): string
{ {
$js = '<script> $js = '<script>
$(function () { $(function () {
+7 -1
View File
@@ -974,9 +974,15 @@ if ('export' === $action) {
$content = Security::remove_XSS($content); $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 = [ $params = [
'filename' => api_replace_dangerous_char( 'filename' => api_replace_dangerous_char(
$objExercise->name.' '. $objExercise->name.' '.
$includeOfficialCode.
$user_info['complete_name'].' '. $user_info['complete_name'].' '.
api_get_local_time() api_get_local_time()
), ),
@@ -999,7 +1005,7 @@ if ('export' === $action) {
if (!is_dir($exportFolderPath)) { if (!is_dir($exportFolderPath)) {
@mkdir($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); $pdfFileName = api_replace_dangerous_char($pdfFileName);
$fileNameToSave = $exportFolderPath.'/'.$pdfFileName; $fileNameToSave = $exportFolderPath.'/'.$pdfFileName;
$pdf->html_to_pdf_with_template($content, true, false, true, [], 'F', $fileNameToSave); $pdf->html_to_pdf_with_template($content, true, false, true, [], 'F', $fileNameToSave);
+6 -6
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; $reminder = isset($_REQUEST['reminder']) ? (int) $_REQUEST['reminder'] : 0;
$remind_question_id = isset($_REQUEST['remind_question_id']) ? (int) $_REQUEST['remind_question_id'] : 0; $remind_question_id = isset($_REQUEST['remind_question_id']) ? (int) $_REQUEST['remind_question_id'] : 0;
$exerciseId = isset($_REQUEST['exerciseId']) ? (int) $_REQUEST['exerciseId'] : 0; $exerciseId = isset($_REQUEST['exerciseId']) ? (int) $_REQUEST['exerciseId'] : 0;
$formSent = isset($_REQUEST['formSent']) ? $_REQUEST['formSent'] : null; $formSent = $_REQUEST['formSent'] ?? null;
$exerciseResult = isset($_REQUEST['exerciseResult']) ? $_REQUEST['exerciseResult'] : null; $exerciseResult = $_REQUEST['exerciseResult'] ?? null;
$exerciseResultCoordinates = isset($_REQUEST['exerciseResultCoordinates']) ? $_REQUEST['exerciseResultCoordinates'] : null; $exerciseResultCoordinates = $_REQUEST['exerciseResultCoordinates'] ?? null;
$choice = isset($_REQUEST['choice']) ? $_REQUEST['choice'] : null; $choice = $_REQUEST['choice'] ?? null;
$choice = empty($choice) ? isset($_REQUEST['choice2']) ? $_REQUEST['choice2'] : null : null; $choice = empty($choice) ? $_REQUEST['choice2'] ?? null : null;
$current_question = $currentQuestionFromUrl = isset($_REQUEST['num']) ? (int) $_REQUEST['num'] : null; $current_question = $currentQuestionFromUrl = isset($_REQUEST['num']) ? (int) $_REQUEST['num'] : null;
$currentAnswer = isset($_REQUEST['num_answer']) ? (int) $_REQUEST['num_answer'] : null; $currentAnswer = isset($_REQUEST['num_answer']) ? (int) $_REQUEST['num_answer'] : null;
$logInfo = [ $logInfo = [
@@ -1081,7 +1081,7 @@ if (!api_is_allowed_to_session_edit()) {
} }
$exercise_timeover = false; $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) { if ($limit_time_exists) {
$exercise_start_time = api_strtotime($objExercise->start_time, 'UTC'); $exercise_start_time = api_strtotime($objExercise->start_time, 'UTC');
$exercise_end_time = api_strtotime($objExercise->end_time, 'UTC'); $exercise_end_time = api_strtotime($objExercise->end_time, 'UTC');
+2 -1
View File
@@ -688,7 +688,8 @@ function isQtiManifest($filePath)
*/ */
function qtiProcessManifest($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(); $course = api_get_course_info();
$sessionId = api_get_session_id(); $sessionId = api_get_session_id();
$courseDir = $course['path']; $courseDir = $course['path'];
+27 -15
View File
@@ -404,6 +404,8 @@ class FillBlanks extends Question
// remove starting and ending space and &nbsp; // remove starting and ending space and &nbsp;
$answer = api_preg_replace("/\xc2\xa0/", " ", $answer); $answer = api_preg_replace("/\xc2\xa0/", " ", $answer);
// remove invisible Unicode characters introduced by word processors
$answer = self::stripInvisibleChars($answer);
// start and end separator // start and end separator
$blankStartSeparator = self::getStartSeparator($form->getSubmitValue('select_separator')); $blankStartSeparator = self::getStartSeparator($form->getSubmitValue('select_separator'));
@@ -748,13 +750,13 @@ class FillBlanks extends Question
$listSeveral $listSeveral
); );
//$studentAnswer = htmlspecialchars($studentAnswer); //$studentAnswer = htmlspecialchars($studentAnswer);
$result = in_array($studentAnswer, $listSeveral); $result = in_array(self::trimOption($studentAnswer), $listSeveral);
break; break;
case self::FILL_THE_BLANK_STANDARD: case self::FILL_THE_BLANK_STANDARD:
default: default:
$correctAnswer = api_html_entity_decode($correctAnswer); $correctAnswer = api_html_entity_decode($correctAnswer);
//$studentAnswer = htmlspecialchars($studentAnswer); //$studentAnswer = htmlspecialchars($studentAnswer);
$result = $studentAnswer == self::trimOption($correctAnswer); $result = self::trimOption($studentAnswer) == self::trimOption($correctAnswer);
break; break;
} }
@@ -824,15 +826,14 @@ class FillBlanks extends Question
$listDetails = explode(':', $listArobaseSplit[0]); $listDetails = explode(':', $listArobaseSplit[0]);
// < number of item after the ::[score]:[size]:[separator_id]@ , here there are 3 // < number of item after the ::[score]:[size]:[separator_id]@ , here there are 3
if (count($listDetails) < 3) {
$listWeightings = explode(',', $listDetails[0]); $listWeightings = explode(',', $listDetails[0]);
if (count($listDetails) < 3) {
$listSizeOfInput = []; $listSizeOfInput = [];
for ($i = 0; $i < count($listWeightings); $i++) { for ($i = 0; $i < count($listWeightings); $i++) {
$listSizeOfInput[] = 200; $listSizeOfInput[] = 200;
} }
$blankSeparatorNumber = 0; // 0 is [...] $blankSeparatorNumber = 0; // 0 is [...]
} else { } else {
$listWeightings = explode(',', $listDetails[0]);
$listSizeOfInput = explode(',', $listDetails[1]); $listSizeOfInput = explode(',', $listDetails[1]);
$blankSeparatorNumber = $listDetails[2]; $blankSeparatorNumber = $listDetails[2];
} }
@@ -1258,8 +1259,6 @@ class FillBlanks extends Question
* @param int $feedbackType * @param int $feedbackType
* @param bool $resultsDisabled * @param bool $resultsDisabled
* @param bool $showTotalScoreAndUserChoices * @param bool $showTotalScoreAndUserChoices
*
* @return string
*/ */
public static function getHtmlAnswer( public static function getHtmlAnswer(
$answer, $answer,
@@ -1269,7 +1268,7 @@ class FillBlanks extends Question
$resultsDisabled = false, $resultsDisabled = false,
$showTotalScoreAndUserChoices = false, $showTotalScoreAndUserChoices = false,
$exercise $exercise
) { ): string {
$hideExpectedAnswer = false; $hideExpectedAnswer = false;
$hideUserSelection = false; $hideUserSelection = false;
if (!$exercise->showExpectedChoiceColumn()) { if (!$exercise->showExpectedChoiceColumn()) {
@@ -1411,10 +1410,8 @@ class FillBlanks extends Question
* Check if a answer is correct by its text. * Check if a answer is correct by its text.
* *
* @param string $answerText * @param string $answerText
*
* @return bool
*/ */
public static function isCorrect($answerText) public static function isCorrect($answerText): bool
{ {
$answerInfo = self::getAnswerInfo($answerText, true); $answerInfo = self::getAnswerInfo($answerText, true);
$correctAnswerList = $answerInfo['words']; $correctAnswerList = $answerInfo['words'];
@@ -1433,10 +1430,8 @@ class FillBlanks extends Question
* Clear the answer entered by student. * Clear the answer entered by student.
* *
* @param string $answer * @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 = htmlentities(api_utf8_encode($answer), ENT_QUOTES);
$answer = str_replace('&#039;', '&#39;', $answer); // fix apostrophe $answer = str_replace('&#039;', '&#39;', $answer); // fix apostrophe
@@ -1447,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 * @param string $text
* *
@@ -1456,7 +1467,8 @@ class FillBlanks extends Question
private static function trimOption($text) private static function trimOption($text)
{ {
$text = trim($text); $text = trim($text);
$text = self::stripInvisibleChars($text);
return preg_replace("/\s+/", ' ', $text); return preg_replace("/\s+/", ' ', trim($text));
} }
} }
@@ -213,12 +213,12 @@ class HotpotatoesExerciseResult
$filename = 'exercise_results_user_'.$user_id.'_'.api_get_local_time().'.xls'; $filename = 'exercise_results_user_'.$user_id.'_'.api_get_local_time().'.xls';
} }
$spreadsheet = new PHPExcel(); $spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet();
$spreadsheet->setActiveSheetIndex(0); $spreadsheet->setActiveSheetIndex(0);
$worksheet = $spreadsheet->getActiveSheet(); $worksheet = $spreadsheet->getActiveSheet();
$line = 0; $line = 1;
$column = 0; //skip the first column (row titles) $column = 1;
// check if exists column 'user' // check if exists column 'user'
$with_column_user = false; $with_column_user = false;
@@ -335,7 +335,7 @@ class HotpotatoesExerciseResult
$line++; $line++;
foreach ($this->results as $row) { foreach ($this->results as $row) {
$column = 0; $column = 1;
if ($with_column_user) { if ($with_column_user) {
$worksheet->setCellValueByColumnAndRow( $worksheet->setCellValueByColumnAndRow(
@@ -426,7 +426,7 @@ class HotpotatoesExerciseResult
} }
$file = api_get_path(SYS_ARCHIVE_PATH).api_replace_dangerous_char($filename); $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); $writer->save($file);
DocumentManager::file_send_for_download($file, true, $filename); DocumentManager::file_send_for_download($file, true, $filename);
+12 -6
View File
@@ -13,8 +13,12 @@ use Symfony\Component\HttpFoundation\Request;
* @author Toon Keppens * @author Toon Keppens
*/ */
$modifyAnswers = (int) $_GET['hotspotadmin']; $modifyAnswers = (int) $_GET['hotspotadmin'];
if (!is_object($objQuestion)) { if (!is_object($objQuestion) || empty($objQuestion->iid) || (int) $objQuestion->iid !== $modifyAnswers) {
$objQuestion = Question::read($modifyAnswers); $objQuestion = Question::read($modifyAnswers);
if (!$objQuestion) {
api_not_allowed();
}
Session::write('objQuestion', $objQuestion);
} }
$questionName = $objQuestion->selectTitle(); $questionName = $objQuestion->selectTitle();
@@ -330,14 +334,16 @@ if ($submitAnswers || $buttonBack) {
); );
$objAnswer->save(); $objAnswer->save();
// sets the total weighting of the question $objQuestion->iid = (int) $modifyAnswers;
$objQuestion->updateWeighting($questionWeighting); $objQuestion->course = api_get_course_info();
$objQuestion->updateWeighting((float) $questionWeighting);
$objQuestion->save($objExercise); $objQuestion->save($objExercise);
$editQuestion = $questionId; $editQuestion = $objQuestion->iid;
unset($modifyAnswers); unset($modifyAnswers);
echo '<script type="text/javascript">window.location.href="'.$hotspot_admin_url echo '<script type="text/javascript">window.location.href="'.
.'&message=ItemUpdated"</script>'; $hotspot_admin_url.'&message=ItemUpdated"</script>';
} }
} }
} }
+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_DONT_SHOW_SCORE_ONLY_IF_USER_FINISHES_ATTEMPTS_SHOW_ALWAYS_FEEDBACK,
RESULT_DISABLE_RANKING, RESULT_DISABLE_RANKING,
RESULT_DISABLE_SHOW_ONLY_IN_CORRECT_ANSWER, RESULT_DISABLE_SHOW_ONLY_IN_CORRECT_ANSWER,
RESULT_DISABLE_RADAR,
] ]
)) { )) {
$row['result'] = $score; $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_DONT_SHOW_SCORE_ONLY_IF_USER_FINISHES_ATTEMPTS_SHOW_ALWAYS_FEEDBACK,
RESULT_DISABLE_RANKING, RESULT_DISABLE_RANKING,
RESULT_DISABLE_SHOW_ONLY_IN_CORRECT_ANSWER, RESULT_DISABLE_SHOW_ONLY_IN_CORRECT_ANSWER,
RESULT_DISABLE_RADAR,
] ]
) || ( ) || (
$objExercise->results_disabled == RESULT_DISABLE_SHOW_SCORE_ONLY && $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_SHOW_FINAL_SCORE_ONLY_WITH_CATEGORIES:
case RESULT_DISABLE_RANKING: case RESULT_DISABLE_RANKING:
case RESULT_DISABLE_SHOW_ONLY_IN_CORRECT_ANSWER: case RESULT_DISABLE_SHOW_ONLY_IN_CORRECT_ANSWER:
case RESULT_DISABLE_RADAR:
$header_names = [ $header_names = [
get_lang('Attempt'), get_lang('Attempt'),
get_lang('StartDate'), 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; $questionTypeId = isset($_REQUEST['questionTypeId']) ? (int) $_REQUEST['questionTypeId'] : 0;
$exportXls = isset($_REQUEST['export_xls']) && !empty($_REQUEST['export_xls']) ? (int) $_REQUEST['export_xls'] : 0; $exportXls = isset($_REQUEST['export_xls']) && !empty($_REQUEST['export_xls']) ? (int) $_REQUEST['export_xls'] : 0;
$action = $_REQUEST['a'] ?? null; $action = $_REQUEST['a'] ?? null;
$startDate = isset($_REQUEST['start_date']) ? $_REQUEST['start_date'] : '';
$endDate = isset($_REQUEST['end_date']) ? $_REQUEST['end_date'] : '';
api_block_anonymous_users(); api_block_anonymous_users();
@@ -170,6 +172,14 @@ $htmlHeadXtra[] = '<script>
} }
</script>'; </script>';
$htmlHeadXtra[] = '<script>
$(function() {
$(".datepicker").datepicker({
dateFormat: "yy-mm-dd"
});
});
</script>';
if ($exportXls) { if ($exportXls) {
ExerciseLib::exportPendingAttemptsToExcel($_REQUEST); 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 = [ $status = [
1 => get_lang('All'), 1 => get_lang('All'),
2 => get_lang('Validated'), 2 => get_lang('Validated'),
@@ -293,16 +319,27 @@ $status = [
4 => get_lang('Unclosed'), 4 => get_lang('Unclosed'),
5 => get_lang('Ongoing'), 5 => get_lang('Ongoing'),
]; ];
$form->addSelect('status', get_lang('Status'), $status); $form->addSelect('status', get_lang('Status'), $status);
$questionType = [ $questionType = [
0 => get_lang('All'), 0 => get_lang('All'),
1 => get_lang('QuestionsWithNoAutomaticCorrection'), 1 => get_lang('QuestionsWithNoAutomaticCorrection'),
]; ];
$form->addSelect('questionTypeId', get_lang('QuestionType'), $questionType); $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'); $form->addButtonSearch(get_lang('Search'), 'pendingSubmit');
$content = $form->returnForm(); $content = $form->returnForm();
@@ -315,7 +352,9 @@ if (empty($statusId)) {
$url = api_get_path(WEB_AJAX_PATH). $url = api_get_path(WEB_AJAX_PATH).
'model.ajax.php?a=get_exercise_pending_results&filter_by_user='.$filter_user. '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 = ''; $action_links = '';
$officialCodeInList = api_get_setting('show_official_code_exercise_result_list'); $officialCodeInList = api_get_setting('show_official_code_exercise_result_list');
@@ -375,16 +414,6 @@ $column_model = [
'align' => 'left', 'align' => 'left',
'search' => 'false', 'search' => 'false',
'sortable' => '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', 'name' => 'qualificator_fullname',
@@ -432,8 +461,12 @@ function action_formatter(cellvalue, options, rowObject) {
return "<span title=\""+tabLoginx[0]+rowObject[2]+tabLoginx[1]+"\">"+cellvalue+"</span>"; return "<span title=\""+tabLoginx[0]+rowObject[2]+tabLoginx[1]+"\">"+cellvalue+"</span>";
}'; }';
$extra_params['autowidth'] = 'true'; $extra_params = [
$extra_params['height'] = 'auto'; 'autowidth' => 'true',
'height' => 'auto',
'sortname' => 'exe_date',
'sortorder' => 'asc',
];
$gridJs = Display::grid_js( $gridJs = Display::grid_js(
'results', 'results',
$url, $url,
+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[] = '<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).'hotspot/js/hotspot.js"></script>';
$htmlHeadXtra[] = '<script src="'.api_get_path(WEB_LIBRARY_JS_PATH).'annotation/js/annotation.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; $this_section = SECTION_COURSES;
$documentPath = api_get_path(SYS_COURSE_PATH).$courseInfo['path']."/document"; $documentPath = api_get_path(SYS_COURSE_PATH).$courseInfo['path']."/document";
$test = $_REQUEST['test']; $test = $_REQUEST['test'] ?? '';
$full_file_path = $documentPath.$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); $TABLETRACK_HOTPOTATOES = Database::get_main_table(TABLE_STATISTIC_TRACK_E_HOTPOTATOES);
$TABLE_LP_ITEM_VIEW = Database::get_course_table(TABLE_LP_ITEM_VIEW); $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(); $origin = api_get_origin();
$learnpath_item_id = intval($_REQUEST['learnpath_item_id']); $learnpath_item_id = intval($_REQUEST['learnpath_item_id']);
$lpViewId = isset($_REQUEST['lp_view_id']) ? intval($_REQUEST['lp_view_id']) : null; $lpViewId = isset($_REQUEST['lp_view_id']) ? intval($_REQUEST['lp_view_id']) : null;
@@ -45,6 +50,8 @@ function save_scores($file, $score)
global $origin; global $origin;
$TABLETRACK_HOTPOTATOES = Database::get_main_table(TABLE_STATISTIC_TRACK_E_HOTPOTATOES); $TABLETRACK_HOTPOTATOES = Database::get_main_table(TABLE_STATISTIC_TRACK_E_HOTPOTATOES);
$_user = api_get_user_info(); $_user = api_get_user_info();
$file = Security::remove_XSS($file);
$score = intval($score);
// if tracking is disabled record nothing // if tracking is disabled record nothing
$weighting = 100; // 100% $weighting = 100; // 100%
$date = api_get_utc_datetime(); $date = api_get_utc_datetime();
+8 -1
View File
@@ -28,7 +28,14 @@ $lpViewId = isset($_REQUEST['lp_view_id']) ? $_REQUEST['lp_view_id'] : null;
$user_id = api_get_user_id(); $user_id = api_get_user_id();
$full_file_path = $document_path.$doc_url; $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'); $content = ReadFileCont($full_file_path.$user_id.'.t.html');
if ($content == '') { if ($content == '') {
+9 -9
View File
@@ -160,7 +160,7 @@ function lp_upload_quiz_action_handling()
$questionTypeList = []; $questionTypeList = [];
$answerList = []; $answerList = [];
$quizTitle = ''; $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); $objPHPExcel->setActiveSheetIndex(0);
$worksheet = $objPHPExcel->getActiveSheet(); $worksheet = $objPHPExcel->getActiveSheet();
$highestRow = $worksheet->getHighestRow(); // e.g. 10 $highestRow = $worksheet->getHighestRow(); // e.g. 10
@@ -171,9 +171,9 @@ function lp_upload_quiz_action_handling()
$useCustomScore = isset($_POST['user_custom_score']) ? true : false; $useCustomScore = isset($_POST['user_custom_score']) ? true : false;
for ($row = 1; $row <= $highestRow; $row++) { for ($row = 1; $row <= $highestRow; $row++) {
$cellTitleInfo = $worksheet->getCellByColumnAndRow(0, $row); $cellTitleInfo = $worksheet->getCellByColumnAndRow(1, $row);
$cellDataInfo = $worksheet->getCellByColumnAndRow(1, $row); $cellDataInfo = $worksheet->getCellByColumnAndRow(2, $row);
$cellScoreInfo = $worksheet->getCellByColumnAndRow(2, $row); $cellScoreInfo = $worksheet->getCellByColumnAndRow(3, $row);
$title = $cellTitleInfo->getValue(); $title = $cellTitleInfo->getValue();
switch ($title) { switch ($title) {
@@ -188,9 +188,9 @@ function lp_upload_quiz_action_handling()
$answerIndex = 0; $answerIndex = 0;
while ($continue) { while ($continue) {
$answerRow++; $answerRow++;
$answerInfoTitle = $worksheet->getCellByColumnAndRow(0, $answerRow); $answerInfoTitle = $worksheet->getCellByColumnAndRow(1, $answerRow);
$answerInfoData = $worksheet->getCellByColumnAndRow(1, $answerRow); $answerInfoData = $worksheet->getCellByColumnAndRow(2, $answerRow);
$answerInfoExtra = $worksheet->getCellByColumnAndRow(2, $answerRow); $answerInfoExtra = $worksheet->getCellByColumnAndRow(3, $answerRow);
$answerInfoTitle = $answerInfoTitle->getValue(); $answerInfoTitle = $answerInfoTitle->getValue();
if (strpos($answerInfoTitle, 'Answer') !== false) { if (strpos($answerInfoTitle, 'Answer') !== false) {
$answerList[$numberQuestions][$answerIndex]['data'] = $answerInfoData->getValue(); $answerList[$numberQuestions][$answerIndex]['data'] = $answerInfoData->getValue();
@@ -212,8 +212,8 @@ function lp_upload_quiz_action_handling()
$questionTypeIndex = 0; $questionTypeIndex = 0;
while ($continue) { while ($continue) {
$answerRow++; $answerRow++;
$questionTypeTitle = $worksheet->getCellByColumnAndRow(0, $answerRow); $questionTypeTitle = $worksheet->getCellByColumnAndRow(1, $answerRow);
$questionTypeExtra = $worksheet->getCellByColumnAndRow(2, $answerRow); $questionTypeExtra = $worksheet->getCellByColumnAndRow(3, $answerRow);
$title = $questionTypeTitle->getValue(); $title = $questionTypeTitle->getValue();
if ($title === 'QuestionType') { if ($title === 'QuestionType') {
$questionTypeList[$numberQuestions] = $questionTypeExtra->getValue(); $questionTypeList[$numberQuestions] = $questionTypeExtra->getValue();
+11
View File
@@ -9,6 +9,17 @@ api_protect_course_script(true);
api_block_anonymous_users(); api_block_anonymous_users();
GradebookUtils::block_students(); 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; $selectEval = isset($_GET['selecteval']) ? (int) $_GET['selecteval'] : 0;
$resultadd = new Result(); $resultadd = new Result();
+19 -2
View File
@@ -10,8 +10,19 @@ api_block_anonymous_users();
GradebookUtils::block_students(); GradebookUtils::block_students();
$evaledit = Evaluation::load($_GET['editeval']); $evaledit = Evaluation::load($_GET['editeval']);
if ($evaledit[0]->is_locked() && !api_is_platform_admin()) { if (empty($evaledit[0])) {
api_not_allowed(); 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( $form = new EvalForm(
EvalForm::TYPE_EDIT, EvalForm::TYPE_EDIT,
@@ -23,6 +34,12 @@ $form = new EvalForm(
); );
if ($form->validate()) { if ($form->validate()) {
$values = $form->exportValues(); $values = $form->exportValues();
$evaluationId = (int) $values['hid_id'];
if ($evaluationId !== (int) $evaledit[0]->get_id()) {
api_not_allowed(true);
}
$eval = new Evaluation(); $eval = new Evaluation();
$eval->set_id($values['hid_id']); $eval->set_id($values['hid_id']);
$eval->set_name($values['name']); $eval->set_name($values['name']);
+11 -5
View File
@@ -10,12 +10,18 @@ $current_course_tool = TOOL_GRADEBOOK;
api_protect_course_script(true); api_protect_course_script(true);
api_block_anonymous_users(); api_block_anonymous_users();
$currentUserId = api_get_user_id();
$sessionId = api_get_session_id();
$isDrhOfCourse = CourseManager::isUserSubscribedInCourseAsDrh( $isDrhOfCourse = CourseManager::isUserSubscribedInCourseAsDrh(
api_get_user_id(), $currentUserId,
api_get_course_info() api_get_course_info()
); );
if (!$isDrhOfCourse) { $isDrhOfSession = $sessionId && !empty(SessionManager::getSessionFollowedByDrh($currentUserId, $sessionId));
if (!$isDrhOfCourse && !$isDrhOfSession) {
GradebookUtils::block_students(); GradebookUtils::block_students();
} }
@@ -75,7 +81,7 @@ $simple_search_form = new UserForm(
$values = $simple_search_form->exportValues(); $values = $simple_search_form->exportValues();
$keyword = ''; $keyword = '';
if (isset($_GET['search']) && !empty($_GET['search'])) { if (!empty($_GET['search'])) {
$keyword = Security::remove_XSS($_GET['search']); $keyword = Security::remove_XSS($_GET['search']);
} }
if ($simple_search_form->validate() && empty($keyword)) { if ($simple_search_form->validate() && empty($keyword)) {
@@ -90,7 +96,7 @@ if (!empty($keyword)) {
$users = GradebookUtils::get_all_users($alleval, $alllinks); $users = GradebookUtils::get_all_users($alleval, $alllinks);
} }
} }
$offset = isset($_GET['offset']) ? $_GET['offset'] : '0'; $offset = $_GET['offset'] ?? '0';
$addparams = ['selectcat' => $cat[0]->get_id()]; $addparams = ['selectcat' => $cat[0]->get_id()];
if (isset($_GET['search'])) { if (isset($_GET['search'])) {
@@ -128,7 +134,7 @@ if (isset($_GET['export_pdf']) && 'category' === $_GET['export_pdf']) {
$params['join_firstname_lastname'] = true; $params['join_firstname_lastname'] = true;
$params['show_official_code'] = true; $params['show_official_code'] = true;
$params['export_pdf'] = 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); Display::set_header(null, false, false);
GradebookUtils::export_pdf_flatview( GradebookUtils::export_pdf_flatview(
$flatViewTable, $flatViewTable,
+11 -3
View File
@@ -40,7 +40,7 @@ if ($eval[0]->get_category_id() < 0) {
//load the result with the evaluation id //load the result with the evaluation id
if (isset($_GET['delete_mark'])) { if (isset($_GET['delete_mark'])) {
$result = Result::load($_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(); $result[0]->delete();
} }
} }
@@ -56,7 +56,9 @@ if (isset($_GET['action'])) {
switch ($_GET['action']) { switch ($_GET['action']) {
case 'delete_attempt': case 'delete_attempt':
$result = Result::load($_GET['editres']); $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 */ /** @var Result $result */
$result = $result[0]; $result = $result[0];
$url = api_get_self().'?selecteval='.$select_eval.'&'.api_get_cidreq().'&editres='.$result->get_id(); $url = api_get_self().'?selecteval='.$select_eval.'&'.api_get_cidreq().'&editres='.$result->get_id();
@@ -74,7 +76,9 @@ if (isset($_GET['action'])) {
break; break;
case 'add_attempt': case 'add_attempt':
$result = Result::load($_GET['editres']); $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 */ /** @var Result $result */
$result = $result[0]; $result = $result[0];
$backUrl = api_get_self().'?selecteval='.$select_eval.'&'.api_get_cidreq(); $backUrl = api_get_self().'?selecteval='.$select_eval.'&'.api_get_cidreq();
@@ -512,8 +516,10 @@ if (isset($_GET['export'])) {
if (isset($_GET['resultdelete'])) { if (isset($_GET['resultdelete'])) {
$result = Result::load($_GET['resultdelete']); $result = Result::load($_GET['resultdelete']);
if (!empty($result[0]) && $result[0]->get_evaluation_id() == $select_eval) {
$result[0]->delete(); $result[0]->delete();
Display::addFlash(Display::return_message(get_lang('ResultDeleted'))); Display::addFlash(Display::return_message(get_lang('ResultDeleted')));
}
header('Location: gradebook_view_result.php?selecteval='.$select_eval.'&'.api_get_cidreq()); header('Location: gradebook_view_result.php?selecteval='.$select_eval.'&'.api_get_cidreq());
exit; exit;
} }
@@ -534,9 +540,11 @@ if (isset($_POST['action'])) {
$number_of_deleted_results = 0; $number_of_deleted_results = 0;
foreach ($_POST['id'] as $indexstr) { foreach ($_POST['id'] as $indexstr) {
$result = Result::load($indexstr); $result = Result::load($indexstr);
if (!empty($result[0]) && $result[0]->get_evaluation_id() == $select_eval) {
$result[0]->delete(); $result[0]->delete();
$number_of_deleted_results++; $number_of_deleted_results++;
} }
}
Display::addFlash(Display::return_message(get_lang('ResultsDeleted'), 'confirmation', false)); Display::addFlash(Display::return_message(get_lang('ResultsDeleted'), 'confirmation', false));
header('Location: gradebook_view_result.php?massdelete=&selecteval='.$select_eval.'&'.api_get_cidreq()); header('Location: gradebook_view_result.php?massdelete=&selecteval='.$select_eval.'&'.api_get_cidreq());
exit; exit;
+1 -1
View File
@@ -974,7 +974,7 @@ class GradebookUtils
/** /**
* @param FlatViewTable $flatviewtable * @param FlatViewTable $flatviewtable
* @param Category $cat * @param array<int, Category> $cat
* @param $users * @param $users
* @param $alleval * @param $alleval
* @param $alllinks * @param $alllinks
+10 -5
View File
@@ -633,18 +633,23 @@ abstract class AbstractLink implements GradebookItem
*/ */
public static function getCurrentUserRanking($userId, $studentList) public static function getCurrentUserRanking($userId, $studentList)
{ {
$previousScore = null;
$ranking = null; $ranking = null;
$position = null;
$currentUserId = $userId; $currentUserId = $userId;
if (!empty($studentList) && !empty($currentUserId)) { if (!empty($studentList) && !empty($currentUserId)) {
$studentList = array_map('floatval', $studentList); $studentList = array_map('floatval', $studentList);
asort($studentList); arsort($studentList);
$ranking = $count = count($studentList); $count = count($studentList);
foreach ($studentList as $userId => $score) {
foreach ($studentList as $userId => $position) { $position++;
if ($previousScore === null || $score < $previousScore) {
$ranking = $position;
}
$previousScore = $score;
if ($currentUserId == $userId) { if ($currentUserId == $userId) {
break; break;
} }
$ranking--;
} }
// If no ranking was detected. // If no ranking was detected.
+7 -13
View File
@@ -128,12 +128,9 @@ class Category implements GradebookItem
return $this->weight; return $this->weight;
} }
/** public function is_locked(): bool
* @return bool
*/
public function is_locked()
{ {
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" * @param bool $order_by Whether to show all "session"
* categories (true) or hide them (false) in case there is no session id * categories (true) or hide them (false) in case there is no session id
* *
* @return array * @return array<int, Category>
*/ */
public static function load( public static function load(
$id = null, $id = null,
@@ -439,7 +436,7 @@ class Category implements GradebookItem
$visible = null, $visible = null,
$session_id = null, $session_id = null,
$order_by = null $order_by = null
) { ): array {
//if the category given is explicitly 0 (not null), then create //if the category given is explicitly 0 (not null), then create
// a root category object (in memory) // a root category object (in memory)
if (isset($id) && (int) $id === 0) { if (isset($id) && (int) $id === 0) {
@@ -2681,10 +2678,7 @@ class Category implements GradebookItem
return $this->weight - $subWeight; return $this->weight - $subWeight;
} }
/** private static function create_root_category(): Category
* @return Category
*/
private static function create_root_category()
{ {
$cat = new Category(); $cat = new Category();
$cat->set_id(0); $cat->set_id(0);
@@ -2704,9 +2698,9 @@ class Category implements GradebookItem
/** /**
* @param Doctrine\DBAL\Driver\Statement|null $result * @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 = []; $categories = [];
$allow = api_get_configuration_value('allow_gradebook_stats'); $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 $category_id parent category
* @param int $visible visible * @param int $visible visible
* *
* @return array * @return array<int, Evaluation>
*/ */
public static function load( public static function load(
$id = null, $id = null,
@@ -230,7 +230,7 @@ class Evaluation implements GradebookItem
$category_id = null, $category_id = null,
$visible = null, $visible = null,
$locked = null $locked = null
) { ): array {
$table = Database::get_main_table(TABLE_MAIN_GRADEBOOK_EVALUATION); $table = Database::get_main_table(TABLE_MAIN_GRADEBOOK_EVALUATION);
$sql = 'SELECT * FROM '.$table; $sql = 'SELECT * FROM '.$table;
$paramcount = 0; $paramcount = 0;
@@ -935,9 +935,9 @@ class Evaluation implements GradebookItem
/** /**
* @param array $result * @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 = []; $alleval = [];
$allow = api_get_configuration_value('allow_gradebook_stats'); $allow = api_get_configuration_value('allow_gradebook_stats');
@@ -281,8 +281,8 @@ class ForumThreadLink extends AbstractLink
//it was extracts the forum id //it was extracts the forum id
$sql = 'SELECT * FROM '.$this->get_forum_thread_table()." $sql = 'SELECT * FROM '.$this->get_forum_thread_table()."
WHERE WHERE
c_id = '.$this->course_id.' AND c_id = ".$this->course_id." AND
thread_id = '".$this->get_ref_id()."' AND thread_id = ".$this->get_ref_id()." AND
session_id = $sessionId "; session_id = $sessionId ";
$result = Database::query($sql); $result = Database::query($sql);
$row = Database::fetch_array($result, 'ASSOC'); $row = Database::fetch_array($result, 'ASSOC');
+2 -2
View File
@@ -85,9 +85,9 @@ class Result
* @param $user_id user id (student) * @param $user_id user id (student)
* @param $evaluation_id evaluation where this is a result for * @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_user = Database::get_main_table(TABLE_MAIN_USER);
$tbl_grade_results = Database::get_main_table(TABLE_MAIN_GRADEBOOK_RESULT); $tbl_grade_results = Database::get_main_table(TABLE_MAIN_GRADEBOOK_RESULT);
+15 -4
View File
@@ -429,6 +429,7 @@ class DisplayGradebook
} }
// for course admin & platform admin add item buttons are added to the header // for course admin & platform admin add item buttons are added to the header
$toolbarActions = [];
$actionsLeft = ''; $actionsLeft = '';
$actionsRight = ''; $actionsRight = '';
$my_api_cidreq = api_get_cidreq(); $my_api_cidreq = api_get_cidreq();
@@ -562,11 +563,13 @@ class DisplayGradebook
} }
$isDrhOfCourse = CourseManager::isUserSubscribedInCourseAsDrh( $isDrhOfCourse = CourseManager::isUserSubscribedInCourseAsDrh(
api_get_user_id(), $userId,
api_get_course_info() api_get_course_info()
); );
if ($isDrhOfCourse) { $isDrhOfSession = $sessionId && !empty(SessionManager::getSessionFollowedByDrh($userId, $sessionId));
if ($isDrhOfCourse || $isDrhOfSession) {
$actionsLeft .= '<a href="gradebook_flatview.php?'.$my_api_cidreq.'&selectcat='.$catobj->get_id().'">' $actionsLeft .= '<a href="gradebook_flatview.php?'.$my_api_cidreq.'&selectcat='.$catobj->get_id().'">'
.Display::return_icon( .Display::return_icon(
'statistics.png', 'statistics.png',
@@ -578,9 +581,17 @@ class DisplayGradebook
} }
if ($isCoach || api_is_allowed_to_edit(null, true)) { 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', 'gradebook-actions',
[$actionsLeft, $actionsRight] $toolbarActions
); );
} }
+8 -11
View File
@@ -21,11 +21,11 @@ class GradeBookResult
/** /**
* Exports the complete report as a CSV file. * Exports the complete report as a CSV file.
* *
* @param string $dato Document path inside the document tool * @param array $dato Document path inside the document tool
* *
* @return bool False on error * @return bool False on error
*/ */
public function exportCompleteReportCSV($dato) public function exportCompleteReportCSV(array $dato)
{ {
$filename = 'gradebook_results_'.gmdate('YmdGis').'.csv'; $filename = 'gradebook_results_'.gmdate('YmdGis').'.csv';
$data = ''; $data = '';
@@ -80,19 +80,16 @@ class GradeBookResult
* Exports the complete report as an XLS file. * Exports the complete report as an XLS file.
* *
* @param array $data * @param array $data
*
* @throws PHPExcel_Exception
* @throws PHPExcel_Writer_Exception
*/ */
public function exportCompleteReportXLS($data) public function exportCompleteReportXLS($data)
{ {
$filename = 'gradebook-results-'.api_get_local_time().'.xlsx'; $filename = 'gradebook-results-'.api_get_local_time().'.xlsx';
$spreadsheet = new PHPExcel(); $spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet();
$spreadsheet->setActiveSheetIndex(0); $spreadsheet->setActiveSheetIndex(0);
$worksheet = $spreadsheet->getActiveSheet(); $worksheet = $spreadsheet->getActiveSheet();
$line = 1; $line = 1;
$column = 0; $column = 1;
// headers. // headers.
foreach ($data[0] as $headerData) { foreach ($data[0] as $headerData) {
$title = $headerData; $title = $headerData;
@@ -100,7 +97,7 @@ class GradeBookResult
$title = $headerData['header']; $title = $headerData['header'];
} }
$title = html_entity_decode(strip_tags($title)); $title = html_entity_decode(strip_tags($title));
$worksheet->SetCellValueByColumnAndRow( $worksheet->setCellValueByColumnAndRow(
$column, $column,
$line, $line,
$title $title
@@ -110,9 +107,9 @@ class GradeBookResult
$line++; $line++;
$cant_students = count($data[1]); $cant_students = count($data[1]);
for ($i = 0; $i < $cant_students; $i++) { for ($i = 0; $i < $cant_students; $i++) {
$column = 0; $column = 1;
foreach ($data[1][$i] as $col_name) { foreach ($data[1][$i] as $col_name) {
$worksheet->SetCellValueByColumnAndRow( $worksheet->setCellValueByColumnAndRow(
$column, $column,
$line, $line,
html_entity_decode(strip_tags($col_name)) html_entity_decode(strip_tags($col_name))
@@ -123,7 +120,7 @@ class GradeBookResult
} }
$file = api_get_path(SYS_ARCHIVE_PATH).api_replace_dangerous_char($filename); $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); $writer->save($file);
DocumentManager::file_send_for_download($file, true, $filename); DocumentManager::file_send_for_download($file, true, $filename);
exit; exit;
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

+3 -36
View File
@@ -145,43 +145,10 @@ switch ($action) {
break; break;
case 'search_course': case 'search_course':
if (api_is_teacher() || api_is_platform_admin()) { if (api_is_teacher() || api_is_platform_admin()) {
if (isset($_GET['session_id']) && !empty($_GET['session_id'])) { $courseList = CourseManager::searchCourse(
//if session is defined, lets find only courses of this session $_REQUEST['q'],
$courseList = SessionManager::get_course_list_by_session_id( isset($_GET['session_id']) ? (int) $_GET['session_id'] : 0
$_GET['session_id'],
$_GET['q']
); );
} else {
//if session is not defined lets search all courses STARTING with $_GET['q']
//TODO change this function to search not only courses STARTING with $_GET['q']
if (api_is_platform_admin()) {
$courseList = CourseManager::get_courses_list(
0,
0,
'title',
'ASC',
-1,
$_GET['q'],
null,
true
);
} elseif (api_is_teacher()) {
$courseList = CourseManager::get_course_list_of_user_as_course_admin(api_get_user_id(), $_GET['q']);
$category = api_get_configuration_value('course_category_code_to_use_as_model');
if (!empty($category)) {
$alreadyAdded = [];
if (!empty($courseList)) {
$alreadyAdded = array_column($courseList, 'id');
}
$coursesInCategory = CourseCategory::getCoursesInCategory($category, $_GET['q']);
foreach ($coursesInCategory as $course) {
if (!in_array($course['id'], $alreadyAdded)) {
$courseList[] = $course;
}
}
}
}
}
$results = []; $results = [];
if (empty($courseList)) { if (empty($courseList)) {
+12 -9
View File
@@ -1,6 +1,7 @@
<?php <?php
/* For licensing terms, see /license.txt */ /* For licensing terms, see /license.txt */
use Chamilo\CourseBundle\Entity\CCourseDescription;
use Chamilo\CourseBundle\Entity\CTool; use Chamilo\CourseBundle\Entity\CTool;
use ChamiloSession as Session; use ChamiloSession as Session;
@@ -290,15 +291,17 @@ switch ($action) {
echo get_lang('PrivateAccess'); echo get_lang('PrivateAccess');
break; break;
} }
$table = Database::get_course_table(TABLE_COURSE_DESCRIPTION);
$sql = "SELECT * FROM $table /** @var array<int, CCourseDescription> $courseDescriptions */
WHERE c_id = ".$course_info['real_id']." AND session_id = 0 $courseDescriptions = Database::getManager()
ORDER BY id"; ->getRepository(CCourseDescription::class)
$result = Database::query($sql); ->findBy(['cId' => $course_info['real_id'], 'sessionId' => 0])
if (Database::num_rows($result) > 0) { ;
while ($description = Database::fetch_object($result)) {
$descriptions[$description->id] = $description; $descriptions = [];
}
foreach ($courseDescriptions as $courseDescription) {
$descriptions[$courseDescription->getIid()] = $courseDescription;
// Function that displays the details of the course description in html. // Function that displays the details of the course description in html.
$content = CourseManager::get_details_course_description_html( $content = CourseManager::get_details_course_description_html(
$descriptions, $descriptions,
+32 -1
View File
@@ -6,6 +6,7 @@
*/ */
use Chamilo\CoreBundle\Component\Editor\Driver\Driver; use Chamilo\CoreBundle\Component\Editor\Driver\Driver;
use Chamilo\CoreBundle\Component\Editor\Driver\PersonalDriver;
require_once __DIR__.'/../global.inc.php'; require_once __DIR__.'/../global.inc.php';
@@ -217,6 +218,20 @@ switch ($action) {
$data = []; $data = [];
$fileUpload = $_FILES['upload']; $fileUpload = $_FILES['upload'];
try {
new Image($fileUpload['tmp_name']);
} catch (Exception $e) {
echo json_encode([
'uploaded' => 0,
'error' => [
'message' => get_lang('MissingImagesDetected'),
],
]);
exit;
}
$mimeType = mime_content_type($fileUpload['tmp_name']); $mimeType = mime_content_type($fileUpload['tmp_name']);
$isMimeAccepted = (new Driver())->mimeAccepted($mimeType, ['image']); $isMimeAccepted = (new Driver())->mimeAccepted($mimeType, ['image']);
@@ -225,6 +240,22 @@ switch ($action) {
exit; exit;
} }
try {
$fileUpload['size'] = DocumentManager::autoResizeImageIfNeeded(
$fileUpload['size'],
$fileUpload['tmp_name']
);
} catch (Exception $e) {
echo json_encode([
'uploaded' => 0,
'error' => [
'message' => $e->getMessage(),
],
]);
exit;
}
$isAllowedToEdit = api_is_allowed_to_edit(null, true); $isAllowedToEdit = api_is_allowed_to_edit(null, true);
if ($isAllowedToEdit) { if ($isAllowedToEdit) {
$globalFile = ['files' => $fileUpload]; $globalFile = ['files' => $fileUpload];
@@ -257,7 +288,7 @@ switch ($action) {
mkdir($syspath, api_get_permissions_for_new_directories(), true); mkdir($syspath, api_get_permissions_for_new_directories(), true);
} }
$webpath = UserManager::getUserPathById($userId, 'web').'my_files'; $webpath = UserManager::getUserPathById($userId, 'web').'my_files';
$fileUploadName = $fileUpload['name']; $fileUploadName = disable_dangerous_file(api_replace_dangerous_char($fileUpload['name']));
if (file_exists($syspath.$fileUploadName)) { if (file_exists($syspath.$fileUploadName)) {
$extension = pathinfo($fileUploadName, PATHINFO_EXTENSION); $extension = pathinfo($fileUploadName, PATHINFO_EXTENSION);
$fileName = pathinfo($fileUploadName, PATHINFO_FILENAME); $fileName = pathinfo($fileUploadName, PATHINFO_FILENAME);
+12 -1
View File
@@ -56,6 +56,10 @@ switch ($action) {
} }
break;*/ break;*/
case 'export_all_certificates': case 'export_all_certificates':
if (!api_is_allowed_to_edit() && !api_is_student_boss()) {
exit;
}
$categoryId = (int) $_GET['cat_id']; $categoryId = (int) $_GET['cat_id'];
$filterOfficialCodeGet = isset($_GET['filter']) ? Security::remove_XSS($_GET['filter']) : null; $filterOfficialCodeGet = isset($_GET['filter']) ? Security::remove_XSS($_GET['filter']) : null;
@@ -76,7 +80,14 @@ switch ($action) {
$userList = implode(',', $userList); $userList = implode(',', $userList);
shell_exec("php $commandScript $courseCode $sessionId $categoryId $userList > /dev/null &"); shell_exec(sprintf(
"php %s %s %s %s %s > /dev/null &",
escapeshellarg($commandScript),
escapeshellarg($courseCode),
escapeshellarg((string) $sessionId),
escapeshellarg((string) $categoryId),
escapeshellarg($userList)
));
break; break;
case 'verify_export_all_certificates': case 'verify_export_all_certificates':
$categoryId = (int) $_GET['cat_id']; $categoryId = (int) $_GET['cat_id'];
+14 -8
View File
@@ -251,10 +251,6 @@ if (($search || $forceSearch) && ($search !== 'false')) {
} }
$whereCondition .= $extraQuestionCondition; $whereCondition .= $extraQuestionCondition;
if (isset($filters->custom_dates)) {
$whereCondition .= $filters->custom_dates;
}
} }
} elseif (!empty($filters->rules)) { } elseif (!empty($filters->rules)) {
$whereCondition .= ' AND ( '; $whereCondition .= ' AND ( ';
@@ -643,17 +639,19 @@ switch ($action) {
true true
); );
break; break;
case 'get_exercise_pending_results': case 'get_exercise_pending_results':
if ((false === api_is_teacher()) && (false === api_is_session_admin())) { if ((false === api_is_teacher()) && (false === api_is_session_admin())) {
exit; exit;
} }
$search_start_date = isset($_REQUEST['start_date']) && !empty($_REQUEST['start_date']) ? $_REQUEST['start_date'] : null;
$search_end_date = isset($_REQUEST['end_date']) && !empty($_REQUEST['end_date']) ? $_REQUEST['end_date'] : null;
$courseId = $_REQUEST['course_id'] ?? 0; $courseId = $_REQUEST['course_id'] ?? 0;
$exerciseId = $_REQUEST['exercise_id'] ?? 0; $exerciseId = $_REQUEST['exercise_id'] ?? 0;
$status = $_REQUEST['status'] ?? 0; $status = $_REQUEST['status'] ?? 0;
$questionType = $_REQUEST['questionType'] ?? 0; $questionType = $_REQUEST['questionType'] ?? 0;
$showAttemptsInSessions = (bool) $_REQUEST['showAttemptsInSessions']; $showAttemptsInSessions = $_REQUEST['showAttemptsInSessions'] ? true : false;
if (!empty($_GET['filter_by_user'])) { if (isset($_GET['filter_by_user']) && !empty($_GET['filter_by_user'])) {
$filter_user = (int) $_GET['filter_by_user']; $filter_user = (int) $_GET['filter_by_user'];
if (empty($whereCondition)) { if (empty($whereCondition)) {
$whereCondition .= " te.exe_user_id = '$filter_user'"; $whereCondition .= " te.exe_user_id = '$filter_user'";
@@ -662,7 +660,7 @@ switch ($action) {
} }
} }
if (!empty($_GET['group_id_in_toolbar'])) { if (isset($_GET['group_id_in_toolbar']) && !empty($_GET['group_id_in_toolbar'])) {
$groupIdFromToolbar = (int) $_GET['group_id_in_toolbar']; $groupIdFromToolbar = (int) $_GET['group_id_in_toolbar'];
if (!empty($groupIdFromToolbar)) { if (!empty($groupIdFromToolbar)) {
if (empty($whereCondition)) { if (empty($whereCondition)) {
@@ -681,6 +679,14 @@ switch ($action) {
$whereCondition .= " AND te.c_id = $courseId"; $whereCondition .= " AND te.c_id = $courseId";
} }
// Filtrage sur la date de fin d'exercice (exe_date)
if (!empty($search_start_date)) {
$whereCondition .= " AND te.exe_date >= '".Database::escape_string($search_start_date)." 00:00:00'";
}
if (!empty($search_end_date)) {
$whereCondition .= " AND te.exe_date <= '".Database::escape_string($search_end_date)." 23:59:59'";
}
$count = ExerciseLib::get_count_exam_results( $count = ExerciseLib::get_count_exam_results(
$exerciseId, $exerciseId,
$whereCondition, $whereCondition,
+16 -3
View File
@@ -251,13 +251,26 @@ switch ($action) {
$statsName = 'NumberOfUsers'; $statsName = 'NumberOfUsers';
$filter = $_REQUEST['filter']; $filter = $_REQUEST['filter'];
$startDate = $_REQUEST['date_start']; $rawStartDate = isset($_REQUEST['date_start']) ? $_REQUEST['date_start'] : '';
$endDate = $_REQUEST['date_end']; $rawEndDate = isset($_REQUEST['date_end']) ? $_REQUEST['date_end'] : '';
$extraConditions = ''; $extraConditions = '';
if (!empty($startDate) && !empty($endDate)) { if (!empty($rawStartDate) && !empty($rawEndDate)) {
// Validate both values against YYYY-MM-DD before embedding in SQL.
// Any other format (including SQL metacharacters) is silently ignored.
$parsedStart = DateTime::createFromFormat('Y-m-d', $rawStartDate);
$parsedEnd = DateTime::createFromFormat('Y-m-d', $rawEndDate);
if (false !== $parsedStart
&& false !== $parsedEnd
&& $parsedStart->format('Y-m-d') === $rawStartDate
&& $parsedEnd->format('Y-m-d') === $rawEndDate
) {
$startDate = Database::escape_string($rawStartDate);
$endDate = Database::escape_string($rawEndDate);
$extraConditions .= " AND registration_date BETWEEN '$startDate' AND '$endDate' "; $extraConditions .= " AND registration_date BETWEEN '$startDate' AND '$endDate' ";
} }
}
switch ($filter) { switch ($filter) {
case 'active': case 'active':
+17
View File
@@ -226,6 +226,23 @@ foreach ($result as &$row) {
ini_set('log_errors', '1'); ini_set('log_errors', '1');
// Including configuration files
$configurationFiles = [
'mail.conf.php',
'profile.conf.php',
'course_info.conf.php',
'add_course.conf.php',
'events.conf.php',
'auth.conf.php',
];
foreach ($configurationFiles as $file) {
$file = api_get_path(CONFIGURATION_PATH).$file;
if (file_exists($file)) {
require_once $file;
}
}
/** /**
* Include the trad4all language file. * Include the trad4all language file.
*/ */
+81 -24
View File
@@ -16,6 +16,7 @@ use Chamilo\CourseBundle\Entity\CItemProperty;
use Chamilo\UserBundle\Entity\User; use Chamilo\UserBundle\Entity\User;
use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\Query\Expr\Join;
use Mpdf\MpdfException; use Mpdf\MpdfException;
use Symfony\Component\Filesystem\Exception\FileNotFoundException;
use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\HttpFoundation\Request as HttpRequest; use Symfony\Component\HttpFoundation\Request as HttpRequest;
@@ -1417,7 +1418,7 @@ class PortfolioController
); );
$urlUserString = ""; $urlUserString = "";
if (isset($urlUser)) { if (!empty($urlUser)) {
$urlUserString = "user=".$urlUser; $urlUserString = "user=".$urlUser;
} }
@@ -2350,12 +2351,24 @@ class PortfolioController
$itemDirectory = $item->getCreationDate()->format('Y-m-d-H-i-s'); $itemDirectory = $item->getCreationDate()->format('Y-m-d-H-i-s');
$itemFilename = sprintf('%s/items/%s/item.html', $tempPortfolioDirectory, $itemDirectory); $itemFilename = sprintf('%s/items/%s/item.html', $tempPortfolioDirectory, $itemDirectory);
$itemFileContent = $this->fixImagesSourcesToHtml($itemsHtml[$i]); $imagePaths = [];
$itemFileContent = $this->fixMediaSourcesToHtml($itemsHtml[$i], $imagePaths);
$fs->dumpFile($itemFilename, $itemFileContent); $fs->dumpFile($itemFilename, $itemFileContent);
$filenames[] = $itemFilename; $filenames[] = $itemFilename;
foreach ($imagePaths as $imagePath) {
$inlineFile = dirname($itemFilename).'/'.basename($imagePath);
try {
$filenames[] = $inlineFile;
$fs->copy($imagePath, $inlineFile);
} catch (FileNotFoundException $notFoundException) {
continue;
}
}
$attachments = $attachmentsRepo->findFromItem($item); $attachments = $attachmentsRepo->findFromItem($item);
/** @var PortfolioAttachment $attachment */ /** @var PortfolioAttachment $attachment */
@@ -2367,12 +2380,15 @@ class PortfolioController
$attachment->getFilename() $attachment->getFilename()
); );
try {
$fs->copy( $fs->copy(
$attachmentsDirectory.$attachment->getPath(), $attachmentsDirectory.$attachment->getPath(),
$attachmentFilename $attachmentFilename
); );
$filenames[] = $attachmentFilename; $filenames[] = $attachmentFilename;
} catch (FileNotFoundException $notFoundException) {
continue;
}
} }
$tblItemsData[] = [ $tblItemsData[] = [
@@ -2397,13 +2413,25 @@ class PortfolioController
foreach ($comments as $i => $comment) { foreach ($comments as $i => $comment) {
$commentDirectory = $comment->getDate()->format('Y-m-d-H-i-s'); $commentDirectory = $comment->getDate()->format('Y-m-d-H-i-s');
$commentFileContent = $this->fixImagesSourcesToHtml($commentsHtml[$i]); $imagePaths = [];
$commentFileContent = $this->fixMediaSourcesToHtml($commentsHtml[$i], $imagePaths);
$commentFilename = sprintf('%s/comments/%s/comment.html', $tempPortfolioDirectory, $commentDirectory); $commentFilename = sprintf('%s/comments/%s/comment.html', $tempPortfolioDirectory, $commentDirectory);
$fs->dumpFile($commentFilename, $commentFileContent); $fs->dumpFile($commentFilename, $commentFileContent);
$filenames[] = $commentFilename; $filenames[] = $commentFilename;
foreach ($imagePaths as $imagePath) {
$inlineFile = dirname($commentFilename).'/'.basename($imagePath);
try {
$filenames[] = $inlineFile;
$fs->copy($imagePath, $inlineFile);
} catch (FileNotFoundException $notFoundException) {
continue;
}
}
$attachments = $attachmentsRepo->findFromComment($comment); $attachments = $attachmentsRepo->findFromComment($comment);
/** @var PortfolioAttachment $attachment */ /** @var PortfolioAttachment $attachment */
@@ -2415,12 +2443,15 @@ class PortfolioController
$attachment->getFilename() $attachment->getFilename()
); );
try {
$fs->copy( $fs->copy(
$attachmentsDirectory.$attachment->getPath(), $attachmentsDirectory.$attachment->getPath(),
$attachmentFilename $attachmentFilename
); );
$filenames[] = $attachmentFilename; $filenames[] = $attachmentFilename;
} catch (FileNotFoundException $notFoundException) {
continue;
}
} }
$tblCommentsData[] = [ $tblCommentsData[] = [
@@ -4277,44 +4308,70 @@ class PortfolioController
return $commentsHtml; return $commentsHtml;
} }
private function fixImagesSourcesToHtml(string $htmlContent): string /**
* @param array $imagePaths Relative paths found in $htmlContent
*/
private function fixMediaSourcesToHtml(string $htmlContent, array &$imagePaths): string
{ {
$doc = new DOMDocument(); $doc = new DOMDocument();
@$doc->loadHTML($htmlContent); @$doc->loadHTML($htmlContent);
$elements = $doc->getElementsByTagName('img'); $tagsWithSrc = ['img', 'video', 'audio', 'source'];
/** @var array<int, \DOMElement> $elements */
$elements = [];
if (empty($elements->length)) { foreach ($tagsWithSrc as $tag) {
foreach ($doc->getElementsByTagName($tag) as $element) {
if ($element->hasAttribute('src')) {
$elements[] = $element;
}
}
}
if (empty($elements)) {
return $htmlContent; return $htmlContent;
} }
$webCoursePath = api_get_path(WEB_COURSE_PATH); /** @var array<int, \DOMElement> $anchorElements */
$webUploadPath = api_get_path(WEB_UPLOAD_PATH); $anchorElements = $doc->getElementsByTagName('a');
$webPath = api_get_path(WEB_PATH);
$sysPath = rtrim(api_get_path(SYS_PATH), '/');
$paths = [
'/app/upload/' => $sysPath,
'/courses/' => $sysPath.'/app',
];
/** @var \DOMElement $element */
foreach ($elements as $element) { foreach ($elements as $element) {
$src = trim($element->getAttribute('src')); $src = trim($element->getAttribute('src'));
if (strpos($src, 'http') === 0) { if (!str_starts_with($src, '/')
&& !str_starts_with($src, $webPath)
) {
continue; continue;
} }
if (strpos($src, '/app/upload/') === 0) { // to search anchors linking to files
$element->setAttribute( if ($anchorElements->length > 0) {
'src', foreach ($anchorElements as $anchorElement) {
preg_replace('/\/app/upload\//', $webUploadPath, $src, 1) if (!$anchorElement->hasAttribute('href')) {
);
continue; continue;
} }
if (strpos($src, '/courses/') === 0) { if ($src === $anchorElement->getAttribute('href')) {
$element->setAttribute( $anchorElement->setAttribute('href', basename($src));
'src', }
preg_replace('/\/courses\//', $webCoursePath, $src, 1) }
); }
continue; $src = str_replace($webPath, '/', $src);
foreach ($paths as $prefix => $basePath) {
if (str_starts_with($src, $prefix)) {
$imagePaths[] = $basePath.urldecode($src);
$element->setAttribute('src', basename($src));
}
} }
} }
+127 -40
View File
@@ -242,6 +242,39 @@ class TicketManager
return Database::store_result($result); return Database::store_result($result);
} }
/**
* Returns the list of category IDs assigned to a user.
*
* @param int $userId
* @param int $projectId
*
* @return int[]
*/
public static function getCategoryIdsByUser($userId, $projectId = 0)
{
$tableRel = Database::get_main_table(TABLE_TICKET_CATEGORY_REL_USER);
$tableCat = Database::get_main_table(TABLE_TICKET_CATEGORY);
$userId = (int) $userId;
$projectId = (int) $projectId;
$sql = "SELECT rel.category_id
FROM $tableRel rel
INNER JOIN $tableCat cat ON (rel.category_id = cat.id)
WHERE rel.user_id = $userId";
if (!empty($projectId)) {
$sql .= " AND cat.project_id = $projectId";
}
$result = Database::query($sql);
$categories = [];
while ($row = Database::fetch_array($result)) {
$categories[] = (int) $row['category_id'];
}
return $categories;
}
/** /**
* @param int $categoryId * @param int $categoryId
*/ */
@@ -322,13 +355,36 @@ class TicketManager
$course_id = (int) $course_id; $course_id = (int) $course_id;
$category_id = (int) $category_id; $category_id = (int) $category_id;
$project_id = (int) $project_id; $project_id = (int) $project_id;
$priority = empty($priority) ? self::PRIORITY_NORMAL : (int) $priority;
if ($status === '') { // Resolve priority to numeric ID (accepts ID or code like 'NRM')
$status = self::STATUS_NEW; $priorityId = null;
if ($other_area > 0) { $priorityCodeOrId = $priority;
$status = self::STATUS_FORWARDED; if (empty($priorityCodeOrId)) {
$priorityCodeOrId = self::PRIORITY_NORMAL;
} }
if (is_numeric($priorityCodeOrId)) {
$priorityId = (int) $priorityCodeOrId;
} else {
$priorityId = self::getPriorityIdFromCode((string) $priorityCodeOrId);
}
if (empty($priorityId)) {
// Fallback to default priority if mapping failed
$priorityId = self::getPriorityIdFromCode(self::PRIORITY_NORMAL);
}
// Resolve status to numeric ID (accepts ID or code like 'NAT')
$statusId = null;
$statusCodeOrId = $status;
if ($statusCodeOrId === '' || $statusCodeOrId === null) {
$statusCodeOrId = self::STATUS_NEW;
if ($other_area > 0) {
$statusCodeOrId = self::STATUS_FORWARDED;
}
}
if (is_numeric($statusCodeOrId)) {
$statusId = (int) $statusCodeOrId;
} else {
$statusId = self::getStatusIdFromCode((string) $statusCodeOrId);
} }
if (!empty($category_id)) { if (!empty($category_id)) {
@@ -355,9 +411,9 @@ class TicketManager
$params = [ $params = [
'project_id' => $project_id, 'project_id' => $project_id,
'category_id' => $category_id, 'category_id' => $category_id,
'priority_id' => $priority, 'priority_id' => $priorityId,
'personal_email' => $personalEmail, 'personal_email' => $personalEmail,
'status_id' => $status, 'status_id' => $statusId,
'start_date' => $now, 'start_date' => $now,
'sys_insert_user_id' => $currentUserId, 'sys_insert_user_id' => $currentUserId,
'sys_insert_datetime' => $now, 'sys_insert_datetime' => $now,
@@ -902,7 +958,14 @@ class TicketManager
// Check if a role was set to the project // Check if a role was set to the project
if ($userIsAllowInProject == false) { if ($userIsAllowInProject == false) {
$sql .= " AND (ticket.assigned_last_user = $userId OR ticket.sys_insert_user_id = $userId )"; $categoryList = self::getCategoryIdsByUser($userId, $projectId);
$categoryCondition = '';
if (!empty($categoryList)) {
$categoryIds = implode(',', array_map('intval', $categoryList));
$categoryCondition = " OR ticket.category_id IN ($categoryIds)";
}
$sql .= " AND (ticket.assigned_last_user = $userId OR ticket.sys_insert_user_id = $userId".$categoryCondition.")";
} }
// Search simple // Search simple
@@ -948,11 +1011,11 @@ class TicketManager
$keyword_range = !empty($keyword_start_date_start) && !empty($keyword_start_date_end); $keyword_range = !empty($keyword_start_date_start) && !empty($keyword_start_date_end);
if ($keyword_range == false && $keyword_start_date_start != '') { if ($keyword_range == false && $keyword_start_date_start != '') {
$sql .= " AND DATE_FORMAT(ticket.start_date,'%d/%m/%Y') >= '$keyword_start_date_start' "; $sql .= " AND ticket.start_date >= '$keyword_start_date_start' ";
} }
if ($keyword_range && $keyword_start_date_start != '' && $keyword_start_date_end != '') { if ($keyword_range && $keyword_start_date_start != '' && $keyword_start_date_end != '') {
$sql .= " AND DATE_FORMAT(ticket.start_date,'%d/%m/%Y') >= '$keyword_start_date_start' $sql .= " AND ticket.start_date >= '$keyword_start_date_start'
AND DATE_FORMAT(ticket.start_date,'%d/%m/%Y') <= '$keyword_start_date_end'"; AND ticket.start_date <= '$keyword_start_date_end'";
} }
if ($keyword_course != '') { if ($keyword_course != '') {
@@ -1072,13 +1135,13 @@ class TicketManager
if (empty($userInfo)) { if (empty($userInfo)) {
return 0; return 0;
} }
$userId = $userInfo['id']; $userId = (int) $userInfo['id'];
if (!isset($_GET['project_id'])) { if (!isset($_GET['project_id'])) {
return 0; return 0;
} }
$sql = "SELECT COUNT(ticket.id) AS total $sql = "SELECT COUNT(DISTINCT ticket.id) AS total
FROM $table_support_tickets ticket FROM $table_support_tickets ticket
INNER JOIN $table_support_category cat INNER JOIN $table_support_category cat
ON (cat.id = ticket.category_id) ON (cat.id = ticket.category_id)
@@ -1089,34 +1152,37 @@ class TicketManager
WHERE 1 = 1"; WHERE 1 = 1";
$projectId = (int) $_GET['project_id']; $projectId = (int) $_GET['project_id'];
$allowRoleList = self::getAllowedRolesFromProject($projectId); $userIsAllowInProject = self::userIsAllowInProject($userInfo, $projectId);
// Check if a role was set to the project // Apply same permission constraints as getTicketsByCurrentUser
if (!empty($allowRoleList) && is_array($allowRoleList)) { if ($userIsAllowInProject == false) {
if (!in_array($userInfo['status'], $allowRoleList)) { $categoryList = self::getCategoryIdsByUser($userId, $projectId);
$sql .= " AND (ticket.assigned_last_user = $userId OR ticket.sys_insert_user_id = $userId )"; $categoryCondition = '';
} if (!empty($categoryList)) {
} else { $categoryIds = implode(',', array_map('intval', $categoryList));
if (!api_is_platform_admin()) { $categoryCondition = " OR ticket.category_id IN ($categoryIds)";
$sql .= " AND (ticket.assigned_last_user = $userId OR ticket.sys_insert_user_id = $userId )";
} }
$sql .= " AND (ticket.assigned_last_user = $userId OR ticket.sys_insert_user_id = $userId".$categoryCondition.")";
} }
// Search simple // Simple search (align with getTicketsByCurrentUser)
if (isset($_GET['submit_simple'])) { if (isset($_GET['submit_simple']) && $_GET['keyword'] !== '') {
if ($_GET['keyword'] != '') {
$keyword = Database::escape_string(trim($_GET['keyword'])); $keyword = Database::escape_string(trim($_GET['keyword']));
$sql .= " AND ( $sql .= " AND (
ticket.id LIKE '%$keyword%' OR
ticket.code LIKE '%$keyword%' OR ticket.code LIKE '%$keyword%' OR
ticket.subject LIKE '%$keyword%' OR ticket.subject LIKE '%$keyword%' OR
ticket.message LIKE '%$keyword%' OR ticket.message LIKE '%$keyword%' OR
ticket.keyword LIKE '%$keyword%' OR ticket.keyword LIKE '%$keyword%' OR
ticket.personal_email LIKE '%$keyword%' OR ticket.source LIKE '%$keyword%' OR
ticket.source LIKE '%$keyword%' cat.name LIKE '%$keyword%' OR
status.name LIKE '%$keyword%' OR
priority.name LIKE '%$keyword%' OR
ticket.personal_email LIKE '%$keyword%'
)"; )";
} }
}
// Exact-match filters
$keywords = [ $keywords = [
'project_id' => 'ticket.project_id', 'project_id' => 'ticket.project_id',
'keyword_category' => 'ticket.category_id', 'keyword_category' => 'ticket.category_id',
@@ -1128,30 +1194,30 @@ class TicketManager
]; ];
foreach ($keywords as $keyword => $sqlLabel) { foreach ($keywords as $keyword => $sqlLabel) {
if (!empty($_GET[$keyword])) { if (isset($_GET[$keyword])) {
$data = Database::escape_string(trim($_GET[$keyword])); $data = Database::escape_string(trim($_GET[$keyword]));
if ($data !== '') {
$sql .= " AND $sqlLabel = '$data' "; $sql .= " AND $sqlLabel = '$data' ";
} }
} }
}
// Search advanced // Advanced search: date range and course
$keyword_start_date_start = isset($_GET['keyword_start_date_start']) ? Database::escape_string(trim($_GET['keyword_start_date_start'])) : ''; $keyword_start_date_start = isset($_GET['keyword_start_date_start']) ? Database::escape_string(trim($_GET['keyword_start_date_start'])) : '';
$keyword_start_date_end = isset($_GET['keyword_start_date_end']) ? Database::escape_string(trim($_GET['keyword_start_date_end'])) : ''; $keyword_start_date_end = isset($_GET['keyword_start_date_end']) ? Database::escape_string(trim($_GET['keyword_start_date_end'])) : '';
$keyword_range = isset($_GET['keyword_dates']) ? Database::escape_string(trim($_GET['keyword_dates'])) : '';
$keyword_course = isset($_GET['keyword_course']) ? Database::escape_string(trim($_GET['keyword_course'])) : ''; $keyword_course = isset($_GET['keyword_course']) ? Database::escape_string(trim($_GET['keyword_course'])) : '';
$keyword_range = !empty($keyword_start_date_start) && !empty($keyword_start_date_end);
if ($keyword_range == false && $keyword_start_date_start != '') { if (!$keyword_range && $keyword_start_date_start !== '') {
$sql .= " AND DATE_FORMAT( ticket.start_date,'%d/%m/%Y') = '$keyword_start_date_start' "; $sql .= " AND ticket.start_date >= '$keyword_start_date_start' ";
} }
if ($keyword_range && $keyword_start_date_start != '' && $keyword_start_date_end != '') { if ($keyword_range) {
$sql .= " AND DATE_FORMAT( ticket.start_date,'%d/%m/%Y') >= '$keyword_start_date_start' $sql .= " AND ticket.start_date >= '$keyword_start_date_start' AND ticket.start_date <= '$keyword_start_date_end'";
AND DATE_FORMAT( ticket.start_date,'%d/%m/%Y') <= '$keyword_start_date_end'";
} }
if ($keyword_course != '') { if ($keyword_course !== '') {
$course_table = Database::get_main_table(TABLE_MAIN_COURSE); $course_table = Database::get_main_table(TABLE_MAIN_COURSE);
$sql .= " AND ticket.course_id IN ( $sql .= " AND ticket.course_id IN (
SELECT id SELECT id FROM $course_table
FROM $course_table
WHERE ( WHERE (
title LIKE '%$keyword_course%' OR title LIKE '%$keyword_course%' OR
code LIKE '%$keyword_course%' OR code LIKE '%$keyword_course%' OR
@@ -1163,7 +1229,7 @@ class TicketManager
$res = Database::query($sql); $res = Database::query($sql);
$obj = Database::fetch_object($res); $obj = Database::fetch_object($res);
return (int) $obj->total; return $obj ? (int) $obj->total : 0;
} }
/** /**
@@ -1964,6 +2030,27 @@ class TicketManager
return 0; return 0;
} }
/**
* Returns the numeric priority ID from its code (e.g. 'NRM', 'HGH').
*
* @param string $code
*
* @return int
*/
public static function getPriorityIdFromCode($code)
{
$item = Database::getManager()
->getRepository('ChamiloTicketBundle:Priority')
->findOneBy(['code' => $code])
;
if ($item) {
return $item->getId();
}
return 0;
}
/** /**
* @return array * @return array
*/ */
+5 -5
View File
@@ -86,11 +86,11 @@ class UnserializeApi
[ [
learnpath::class, learnpath::class,
learnpathItem::class, learnpathItem::class,
aicc::class, //aicc::class,
aiccBlock::class, //aiccBlock::class,
aiccItem::class, //aiccItem::class,
aiccObjective::class, //aiccObjective::class,
aiccResource::class, //aiccResource::class,
scorm::class, scorm::class,
scormItem::class, scormItem::class,
scormMetadata::class, scormMetadata::class,
+11 -11
View File
@@ -1224,15 +1224,15 @@ class AddCourse
$code = $params['code']; $code = $params['code'];
$visual_code = $params['visual_code']; $visual_code = $params['visual_code'];
$directory = $params['directory']; $directory = $params['directory'];
$tutor_name = isset($params['tutor_name']) ? $params['tutor_name'] : null; $tutor_name = $params['tutor_name'] ?? null;
$category_code = isset($params['course_category']) ? $params['course_category'] : ''; $category_code = $params['course_category'] ?? '';
$course_language = isset($params['course_language']) && !empty($params['course_language']) ? $params['course_language'] : api_get_setting( $course_language = !empty($params['course_language'])
'platformLanguage' ? $params['course_language']
); : api_get_setting('platformLanguage');
$user_id = empty($params['user_id']) ? api_get_user_id() : (int) $params['user_id']; $user_id = empty($params['user_id']) ? api_get_user_id() : (int) $params['user_id'];
$department_name = isset($params['department_name']) ? $params['department_name'] : null; $department_name = $params['department_name'] ?? null;
$department_url = isset($params['department_url']) ? $params['department_url'] : null; $department_url = $params['department_url'] ?? null;
$disk_quota = isset($params['disk_quota']) ? $params['disk_quota'] : null; $disk_quota = $params['disk_quota'] ?? null;
if (!isset($params['visibility'])) { if (!isset($params['visibility'])) {
$default_course_visibility = api_get_setting( $default_course_visibility = api_get_setting(
@@ -1253,9 +1253,9 @@ class AddCourse
$subscribe = $visibility == COURSE_VISIBILITY_OPEN_PLATFORM ? 1 : 0; $subscribe = $visibility == COURSE_VISIBILITY_OPEN_PLATFORM ? 1 : 0;
} }
$unsubscribe = isset($params['unsubscribe']) ? (int) $params['unsubscribe'] : 0; $unsubscribe = isset($params['unsubscribe']) ? (int) $params['unsubscribe'] : 0;
$expiration_date = isset($params['expiration_date']) ? $params['expiration_date'] : null; $expiration_date = $params['expiration_date'] ?? null;
$teachers = isset($params['teachers']) ? $params['teachers'] : null; $teachers = $params['teachers'] ?? null;
$status = isset($params['status']) ? $params['status'] : null; $status = $params['status'] ?? null;
$TABLECOURSE = Database::get_main_table(TABLE_MAIN_COURSE); $TABLECOURSE = Database::get_main_table(TABLE_MAIN_COURSE);
$TABLECOURSUSER = Database::get_main_table(TABLE_MAIN_COURSE_USER); $TABLECOURSUSER = Database::get_main_table(TABLE_MAIN_COURSE_USER);
+429 -8
View File
@@ -4338,6 +4338,11 @@ function api_get_item_visibility(
$groupCondition = " AND to_group_id = '$group_id' "; $groupCondition = " AND to_group_id = '$group_id' ";
} }
$lpVisibilityCondition = '';
if ($tool === 'learnpath') {
$lpVisibilityCondition = " AND lastedit_type != 'LearnpathSubscription' ";
}
$sql = "SELECT visibility $sql = "SELECT visibility
FROM $TABLE_ITEMPROPERTY FROM $TABLE_ITEMPROPERTY
WHERE WHERE
@@ -4345,7 +4350,7 @@ function api_get_item_visibility(
tool = '$tool' AND tool = '$tool' AND
ref = $id AND ref = $id AND
(session_id = $session OR session_id = 0 OR session_id IS NULL) (session_id = $session OR session_id = 0 OR session_id IS NULL)
$userCondition $typeCondition $groupCondition $userCondition $typeCondition $groupCondition $lpVisibilityCondition
ORDER BY session_id DESC, lastedit_date DESC ORDER BY session_id DESC, lastedit_date DESC
LIMIT 1"; LIMIT 1";
@@ -4991,7 +4996,7 @@ function api_get_item_property_info($course_id, $tool, $ref, $session_id = 0, $g
* *
* @return array with all fields from c_item_property, empty array if not found or false if course could not be found * @return array with all fields from c_item_property, empty array if not found or false if course could not be found
*/ */
function api_get_last_item_property_info(int $courseId, string $tool, int $ref, int $sessionId = null, int $groupId = null): array function api_get_last_item_property_info(int $courseId, string $tool, int $ref, ?int $sessionId = null, ?int $groupId = null): array
{ {
$tool = Database::escape_string($tool); $tool = Database::escape_string($tool);
// Definition of tables. // Definition of tables.
@@ -7163,6 +7168,22 @@ function api_is_xml_http_request()
*/ */
function api_getimagesize($path) function api_getimagesize($path)
{ {
$path = str_replace(
[
api_get_path(WEB_COURSE_PATH),
api_get_path(WEB_UPLOAD_PATH),
api_get_path(WEB_CODE_PATH),
api_get_path(WEB_PATH),
],
[
api_get_path(SYS_COURSE_PATH),
api_get_path(SYS_UPLOAD_PATH),
api_get_path(SYS_CODE_PATH),
api_get_path(SYS_PATH),
],
$path
);
$image = new Image($path); $image = new Image($path);
return $image->get_image_size(); return $image->get_image_size();
@@ -8955,6 +8976,10 @@ function api_can_login_as($loginAsUserId, $userId = null)
$userInfo = api_get_user_info($loginAsUserId); $userInfo = api_get_user_info($loginAsUserId);
$isDrh = function () use ($loginAsUserId) { $isDrh = function () use ($loginAsUserId) {
if (api_is_drh()) { if (api_is_drh()) {
if (true === api_get_configuration_value('disallow_hrm_login_as')) {
return false;
}
if (api_drh_can_access_all_session_content()) { if (api_drh_can_access_all_session_content()) {
$users = SessionManager::getAllUsersFromCoursesFromAllSessionFromStatus( $users = SessionManager::getAllUsersFromCoursesFromAllSessionFromStatus(
'drh_all', 'drh_all',
@@ -8981,14 +9006,26 @@ function api_can_login_as($loginAsUserId, $userId = null)
return false; return false;
}; };
$allowSessionAdmin = function () use ($userInfo) {
if (!api_is_session_admin()) {
return false;
}
if (true === api_get_configuration_value('disallow_session_admin_login_as')) {
return false;
}
$loginAsStatusForSessionAdmins = [STUDENT]; $loginAsStatusForSessionAdmins = [STUDENT];
if (api_get_configuration_value('allow_session_admin_login_as_teacher')) { if (api_get_configuration_value('allow_session_admin_login_as_teacher')) {
$loginAsStatusForSessionAdmins[] = COURSEMANAGER; $loginAsStatusForSessionAdmins[] = COURSEMANAGER;
} }
return in_array($userInfo['status'], $loginAsStatusForSessionAdmins);
};
return api_is_platform_admin() || return api_is_platform_admin() ||
(api_is_session_admin() && in_array($userInfo['status'], $loginAsStatusForSessionAdmins)) || $allowSessionAdmin() ||
$isDrh(); $isDrh();
} }
@@ -10234,11 +10271,11 @@ function api_unserialize_content($type, $serialized, $ignoreErrors = false)
$allowedClasses = [ $allowedClasses = [
learnpath::class, learnpath::class,
learnpathItem::class, learnpathItem::class,
aicc::class, //aicc::class,
aiccBlock::class, //aiccBlock::class,
aiccItem::class, //aiccItem::class,
aiccObjective::class, //aiccObjective::class,
aiccResource::class, //aiccResource::class,
scorm::class, scorm::class,
scormItem::class, scormItem::class,
scormMetadata::class, scormMetadata::class,
@@ -10699,3 +10736,387 @@ function api_encrypt_hash($data, $secret)
return base64_encode($iv).base64_encode($encrypted.$tag); return base64_encode($iv).base64_encode($encrypted.$tag);
} }
/**
* Replace a specific term by another in all course-related text elements in the database.
* Does not rename directories or replace content of files on disk. Check tests/scripts/replace_course_code.php if
* you are looking for this.
* The replacement can replace bits in larger strings, requiring the search string to be very specific to avoid
* excess replacements.
*
* @return array The number of changes executed in each table
*/
function api_replace_terms_in_content(string $search, string $replace): array
{
$replacements = [
Database::get_course_table(TABLE_QUIZ_TEST) => [
'iid' => ['title', 'description', 'sound'],
],
Database::get_course_table(TABLE_QUIZ_QUESTION) => [
'iid' => ['question', 'description'],
],
Database::get_course_table(TABLE_QUIZ_ANSWER) => [
'iid' => ['answer', 'comment'],
],
Database::get_course_table(TABLE_ANNOUNCEMENT) => [
'iid' => ['title', 'content'],
],
Database::get_course_table(TABLE_ANNOUNCEMENT_ATTACHMENT) => [
'iid' => ['path', 'comment'],
],
Database::get_course_table(TABLE_ATTENDANCE) => [
'iid' => ['name', 'description', 'attendance_qualify_title'],
],
Database::get_course_table(TABLE_BLOGS) => [
'iid' => ['blog_name', 'blog_subtitle'],
],
Database::get_course_table(TABLE_BLOGS_ATTACHMENT) => [
'iid' => ['path', 'comment'],
],
Database::get_course_table(TABLE_BLOGS_COMMENTS) => [
'iid' => ['title', 'comment'],
],
Database::get_course_table(TABLE_BLOGS_POSTS) => [
'iid' => ['title', 'full_text'],
],
Database::get_course_table(TABLE_BLOGS_TASKS) => [
'iid' => ['title', 'description'],
],
Database::get_course_table(TABLE_AGENDA) => [
'iid' => ['title', 'content', 'comment'],
],
Database::get_course_table(TABLE_AGENDA_ATTACHMENT) => [
'iid' => ['path', 'comment', 'filename'],
],
Database::get_course_table(TABLE_COURSE_DESCRIPTION) => [
'iid' => ['title', 'content'],
],
Database::get_course_table(TABLE_DOCUMENT) => [
'iid' => ['path', 'comment'],
],
Database::get_course_table(TABLE_DROPBOX_FEEDBACK) => [
'iid' => ['feedback'],
],
Database::get_course_table(TABLE_DROPBOX_FILE) => [
'iid' => ['title', 'description'],
],
Database::get_course_table(TABLE_DROPBOX_POST) => [
'iid' => ['feedback'],
],
Database::get_course_table(TABLE_FORUM_ATTACHMENT) => [
'iid' => ['path', 'comment', 'filename'],
],
Database::get_course_table(TABLE_FORUM_CATEGORY) => [
'iid' => ['cat_title', 'cat_comment'],
],
Database::get_course_table(TABLE_FORUM) => [
'iid' => ['forum_title', 'forum_comment', 'forum_image'],
],
Database::get_course_table(TABLE_FORUM_POST) => [
'iid' => ['post_title', 'post_text', 'poster_name'],
],
Database::get_course_table(TABLE_FORUM_THREAD) => [
'iid' => ['thread_title', 'thread_poster_name', 'thread_title_qualify'],
],
Database::get_course_table(TABLE_GLOSSARY) => [
'iid' => ['name', 'description'],
],
Database::get_course_table(TABLE_GROUP_CATEGORY) => [
'iid' => ['title', 'description'],
],
Database::get_course_table(TABLE_GROUP) => [
'iid' => ['name', 'description', 'secret_directory'],
],
Database::get_course_table(TABLE_LINK) => [
'iid' => ['description'],
],
Database::get_course_table(TABLE_LINK_CATEGORY) => [
'iid' => ['category_title', 'description'],
],
Database::get_course_table(TABLE_LP_MAIN) => [
'iid' => ['name', 'ref', 'description', 'path', 'content_license', 'preview_image', 'theme'],
],
Database::get_course_table(TABLE_LP_CATEGORY) => [
'iid' => ['name'],
],
Database::get_course_table(TABLE_LP_ITEM) => [
'iid' => ['prerequisite', 'description', 'title', 'parameters', 'launch_data', 'terms'],
],
Database::get_course_table(TABLE_LP_ITEM_VIEW) => [
'iid' => ['suspend_data', 'lesson_location'],
],
Database::get_course_table(TABLE_NOTEBOOK) => [
'iid' => ['title', 'description'],
],
Database::get_course_table(TABLE_ONLINE_LINK) => [
'iid' => ['name'],
],
Database::get_course_table(TABLE_QUIZ_QUESTION_CATEGORY) => [
'iid' => ['title', 'description'],
],
Database::get_course_table(TABLE_ROLE) => [
'iid' => ['role_name', 'role_comment'],
],
Database::get_course_table(TABLE_STUDENT_PUBLICATION) => [
'iid' => ['title', 'title_correction', 'description'],
],
Database::get_course_table(TABLE_STUDENT_PUBLICATION_ASSIGNMENT_COMMENT) => [
'iid' => ['comment', 'file'],
],
Database::get_course_table(TABLE_SURVEY) => [
'iid' => ['title', 'subtitle', 'surveythanks', 'invite_mail', 'reminder_mail', 'mail_subject', 'access_condition', 'form_fields'],
],
Database::get_course_table(TABLE_SURVEY_QUESTION_GROUP) => [
'iid' => ['name', 'description'],
],
Database::get_course_table(TABLE_SURVEY_QUESTION) => [
'iid' => ['survey_question', 'survey_question_comment'],
],
Database::get_course_table(TABLE_SURVEY_QUESTION_OPTION) => [
'iid' => ['option_text'],
],
Database::get_course_table(TABLE_THEMATIC) => [
'iid' => ['content', 'title'],
],
Database::get_course_table(TABLE_THEMATIC_ADVANCE) => [
'iid' => ['content'],
],
Database::get_course_table(TABLE_THEMATIC_PLAN) => [
'iid' => ['description'],
],
Database::get_course_table(TABLE_TOOL_LIST) => [
'iid' => ['description'],
],
Database::get_course_table(TABLE_TOOL_INTRO) => [
'iid' => ['intro_text'],
],
Database::get_course_table(TABLE_USER_INFO_DEF) => [
'iid' => ['comment'],
],
Database::get_course_table(TABLE_WIKI) => [
'iid' => ['title', 'content', 'comment', 'progress', 'linksto'],
],
Database::get_course_table(TABLE_WIKI_CONF) => [
'iid' => ['feedback1', 'feedback2', 'feedback3'],
],
Database::get_course_table(TABLE_WIKI_DISCUSS) => [
'iid' => ['comment'],
],
Database::get_main_table(TABLE_CAREER) => [
'id' => ['name', 'description'],
],
Database::get_main_table(TABLE_MAIN_CHAT) => [
'id' => ['message'],
],
Database::get_main_table(TABLE_MAIN_CLASS) => [
'id' => ['name'],
],
Database::get_main_table(TABLE_MAIN_COURSE_REQUEST) => [
'id' => ['description', 'title', 'objetives', 'target_audience'],
],
'course_type' => [
'id' => ['description'],
],
Database::get_main_table(TABLE_EVENT_EMAIL_TEMPLATE) => [
'id' => ['message', 'subject', 'event_type_name'],
],
Database::get_main_table(TABLE_GRADE_MODEL) => [
'id' => ['name', 'description'],
],
Database::get_main_table(TABLE_MAIN_GRADEBOOK_CATEGORY) => [
'id' => ['name', 'description'],
],
Database::get_main_table(TABLE_MAIN_GRADEBOOK_CERTIFICATE) => [
'id' => ['path_certificate'],
],
Database::get_main_table(TABLE_MAIN_GRADEBOOK_EVALUATION) => [
'id' => ['description', 'name'],
],
Database::get_main_table(TABLE_MAIN_GRADEBOOK_LINKEVAL_LOG) => [
'id' => ['name', 'description'],
],
Database::get_main_table(TABLE_MAIN_LEGAL) => [
'id' => ['content', 'changes'],
],
Database::get_main_table(TABLE_MESSAGE) => [
'id' => ['content'],
],
Database::get_main_table(TABLE_MESSAGE_ATTACHMENT) => [
'id' => ['path', 'comment', 'filename'],
],
Database::get_main_table(TABLE_NOTIFICATION) => [
'id' => ['content'],
],
Database::get_main_table(TABLE_PERSONAL_AGENDA) => [
'id' => ['title', 'text'],
],
Database::get_main_table(TABLE_PROMOTION) => [
'id' => ['description'],
],
'room' => [
'id' => ['description'],
],
'sequence_condition' => [
'id' => ['description'],
],
'sequence_method' => [
'id' => ['description', 'formula'],
],
'sequence_rule' => [
'id' => ['description'],
],
'sequence_type_entity' => [
'id' => ['description'],
],
'sequence_variable' => [
'id' => ['description'],
],
Database::get_main_table(TABLE_MAIN_SESSION) => [
'id' => ['description'],
],
Database::get_main_table(TABLE_MAIN_SHARED_SURVEY) => [
'survey_id' => ['subtitle', 'surveythanks', 'intro'],
],
Database::get_main_table(TABLE_MAIN_SHARED_SURVEY_QUESTION) => [
'question_id' => ['survey_question', 'survey_question_comment'],
],
Database::get_main_table(TABLE_MAIN_SHARED_SURVEY_QUESTION_OPTION) => [
'question_option_id' => ['option_text'],
],
Database::get_main_table(TABLE_MAIN_SKILL) => [
'id' => ['name', 'description', 'criteria'],
],
Database::get_main_table(TABLE_MAIN_SKILL_PROFILE) => [
'id' => ['description'],
],
Database::get_main_table(TABLE_MAIN_SKILL_REL_USER) => [
'id' => ['argumentation'],
],
'skill_rel_user_comment' => [
'id' => ['feedback_text'],
],
Database::get_main_table(TABLE_MAIN_SYSTEM_ANNOUNCEMENTS) => [
'id' => ['content'],
],
Database::get_main_table(TABLE_MAIN_SYSTEM_CALENDAR) => [
'id' => ['content'],
],
Database::get_main_table(TABLE_MAIN_SYSTEM_TEMPLATE) => [
'id' => ['comment', 'content'],
],
Database::get_main_table(TABLE_MAIN_TEMPLATES) => [
'id' => ['description', 'image'],
],
Database::get_main_table(TABLE_TICKET_CATEGORY) => [
'id' => ['description'],
],
Database::get_main_table(TABLE_TICKET_MESSAGE) => [
'id' => ['message'],
],
Database::get_main_table(TABLE_TICKET_MESSAGE_ATTACHMENTS) => [
'id' => ['filename', 'path'],
],
Database::get_main_table(TABLE_TICKET_PRIORITY) => [
'id' => ['description'],
],
Database::get_main_table(TABLE_TICKET_PROJECT) => [
'id' => ['description'],
],
Database::get_main_table(TABLE_TICKET_STATUS) => [
'id' => ['description'],
],
Database::get_main_table(TABLE_TICKET_TICKET) => [
'id' => ['message'],
],
Database::get_main_table(TABLE_STATISTIC_TRACK_E_ATTEMPT) => [
'id' => ['answer', 'teacher_comment', 'filename'],
],
Database::get_main_table(TABLE_STATISTIC_TRACK_E_ATTEMPT_RECORDING) => [
'id' => ['teacher_comment'],
],
Database::get_main_table(TABLE_STATISTIC_TRACK_E_DEFAULT) => [
'default_id' => ['default_value'],
],
Database::get_main_table(TABLE_STATISTIC_TRACK_E_EXERCISES) => [
'exe_id' => ['data_tracking', 'questions_to_check'],
],
Database::get_main_table(TABLE_STATISTIC_TRACK_E_ITEM_PROPERTY) => [
'id' => ['content'],
],
'track_e_open' => [
'open_id' => ['open_remote_host', 'open_agent', 'open_referer'],
],
Database::get_main_table(TABLE_TRACK_STORED_VALUES) => [
'id' => ['sv_value'],
],
Database::get_main_table(TABLE_TRACK_STORED_VALUES_STACK) => [
'id' => ['sv_value'],
],
Database::get_main_table(TABLE_MAIN_USER_API_KEY) => [
'id' => ['description'],
],
Database::get_main_table(TABLE_USERGROUP) => [
'id' => ['name', 'description', 'picture', 'url'],
],
Database::get_main_table(TABLE_MAIN_BLOCK) => [
'id' => ['name', 'description', 'path'],
],
];
if (api_get_configuration_value('attendance_allow_comments')) {
$replacements['c_attendance_result_comment'] = [
'iid' => ['comment'],
];
}
if (api_get_configuration_value('exercise_text_when_finished_failure')) {
$replacements[Database::get_course_table(TABLE_QUIZ_TEST)]['iid'][] = 'text_when_finished_failure';
}
$changes = array_map(
fn ($table) => 0,
$replacements
);
foreach ($replacements as $table => $replacement) {
foreach ($replacement as $idColumn => $columns) {
$keys = array_map(fn ($column) => "$column LIKE %?%", $columns);
$values = array_fill(0, count($columns), $search);
$result = Database::select(
[$idColumn, ...$columns],
$table,
[
'where' => [
implode(' OR ', $keys) => $values,
],
'order' => "$idColumn ASC",
]
);
foreach ($result as $row) {
$attributes = array_combine(
$columns,
array_map(
fn ($column) => preg_replace('#'.$search.'#', $replace, $row[$column]),
$columns
)
);
try {
Database::update(
$table,
$attributes,
["$idColumn = ?" => $row[$idColumn]]
);
} catch (Exception $e) {
Database::handleError($e);
}
$changes[$table]++;
}
}
}
return $changes;
}
+13
View File
@@ -776,6 +776,10 @@ class Attendance
); );
$value['photo'] = $photo; $value['photo'] = $photo;
$addOfficialCode = api_get_configuration_value('attendance_add_official_code');
if ($addOfficialCode) {
$value['official_code'] = $user_data['official_code'];
}
$value['firstname'] = $user_data['firstname']; $value['firstname'] = $user_data['firstname'];
$value['lastname'] = $user_data['lastname']; $value['lastname'] = $user_data['lastname'];
$value['username'] = $user_data['username']; $value['username'] = $user_data['username'];
@@ -1321,6 +1325,7 @@ class Attendance
if (Database::num_rows($res) > 0) { if (Database::num_rows($res) > 0) {
while ($row = Database::fetch_array($res)) { while ($row = Database::fetch_array($res)) {
$row['duration'] = Attendance::getAttendanceCalendarExtraFieldValue('duration', $row['calendar_id']); $row['duration'] = Attendance::getAttendanceCalendarExtraFieldValue('duration', $row['calendar_id']);
$row['date_time'] = api_get_local_time($row['date_time']);
$row['date_time'] = api_convert_and_format_date($row['date_time'], null, date_default_timezone_get()); $row['date_time'] = api_convert_and_format_date($row['date_time'], null, date_default_timezone_get());
$data[$user_id][] = $row; $data[$user_id][] = $row;
} }
@@ -2722,7 +2727,12 @@ class Attendance
// Get data table // Get data table
$dataTable = []; $dataTable = [];
$addOfficialCode = api_get_configuration_value('attendance_add_official_code');
if ($addOfficialCode) {
$headTable = ['#', get_lang('OfficialCode'), get_lang('Name')];
} else {
$headTable = ['#', get_lang('Name')]; $headTable = ['#', get_lang('Name')];
}
foreach ($calendar as $classDay) { foreach ($calendar as $classDay) {
$labelDuration = !empty($classDay['duration']) ? get_lang('Duration').' : '.$classDay['duration'] : ''; $labelDuration = !empty($classDay['duration']) ? get_lang('Duration').' : '.$classDay['duration'] : '';
$headTable[] = $headTable[] =
@@ -2739,6 +2749,9 @@ class Attendance
$cols = 1; $cols = 1;
$result = []; $result = [];
$result['count'] = $count; $result['count'] = $count;
if ($addOfficialCode) {
$result['official_code'] = $user['official_code'];
}
$result['full_name'] = api_get_person_name($user['firstname'], $user['lastname']); $result['full_name'] = api_get_person_name($user['firstname'], $user['lastname']);
foreach ($calendar as $classDay) { foreach ($calendar as $classDay) {
$commentInfo = $this->getComment($user['user_id'], $classDay['id']); $commentInfo = $this->getComment($user['user_id'], $classDay['id']);
+1 -1
View File
@@ -235,7 +235,7 @@ class Auth
$table = Database::get_main_table(TABLE_USER_COURSE_CATEGORY); $table = Database::get_main_table(TABLE_USER_COURSE_CATEGORY);
$sql = "UPDATE $table $sql = "UPDATE $table
SET title = '".api_htmlentities($title, ENT_QUOTES, api_get_system_encoding())."' SET title = '".api_htmlentities($title, ENT_QUOTES, api_get_system_encoding())."'
WHERE id='".$category_id."'"; WHERE id = ".$category_id." AND user_id = ".api_get_user_id();
$resultQuery = Database::query($sql); $resultQuery = Database::query($sql);
if (Database::affected_rows($resultQuery)) { if (Database::affected_rows($resultQuery)) {
$result = true; $result = true;
+19 -4
View File
@@ -3030,16 +3030,31 @@ class Blog
$visibility_icon = ($info_log[2] == 0) ? 'invisible' : 'visible'; $visibility_icon = ($info_log[2] == 0) ? 'invisible' : 'visible';
$visibility_info = ($info_log[2] == 0) ? 'Visible' : 'Invisible'; $visibility_info = ($info_log[2] == 0) ? 'Visible' : 'Invisible';
$my_image = '<a href="'.api_get_self().'?action=visibility&blog_id='.$info_log[3].'">'; $secToken = Security::get_existing_token('blog');
$my_image = '<a href="'.api_get_self().'?'
.http_build_query([
'action' => 'visibility',
'blog_id' => $info_log[3],
'blog_sec_token' => $secToken,
]).'">';
$my_image .= Display::return_icon($visibility_icon.'.png', get_lang($visibility_info)); $my_image .= Display::return_icon($visibility_icon.'.png', get_lang($visibility_info));
$my_image .= "</a>"; $my_image .= "</a>";
$my_image .= '<a href="'.api_get_self().'?action=edit&blog_id='.$info_log[3].'">'; $my_image .= '<a href="'.api_get_self().'?'
.http_build_query([
'action' => 'edit',
'blog_id' => $info_log[3],
]).'">';
$my_image .= Display::return_icon('edit.png', get_lang('EditBlog')); $my_image .= Display::return_icon('edit.png', get_lang('EditBlog'));
$my_image .= "</a>"; $my_image .= "</a>";
$my_image .= '<a href="'.api_get_self().'?action=delete&blog_id='.$info_log[3].'" '; $my_image .= '<a href="'.api_get_self().'?'
$my_image .= 'onclick="javascript:if(!confirm(\''.addslashes( .http_build_query([
'action' => 'delete',
'blog_id' => $info_log[3],
'blog_sec_token' => $secToken,
]).'" onclick="javascript:if(!confirm(\''.addslashes(
api_htmlentities(get_lang("ConfirmYourChoice"), ENT_QUOTES, $charset) api_htmlentities(get_lang("ConfirmYourChoice"), ENT_QUOTES, $charset)
).'\')) return false;" >'; ).'\')) return false;" >';
$my_image .= Display::return_icon('delete.png', get_lang('DeleteBlog')); $my_image .= Display::return_icon('delete.png', get_lang('DeleteBlog'));
+63 -14
View File
@@ -8,6 +8,7 @@ use Chamilo\CoreBundle\Entity\Repository\SequenceResourceRepository;
use Chamilo\CoreBundle\Entity\SequenceResource; use Chamilo\CoreBundle\Entity\SequenceResource;
use Chamilo\CourseBundle\Component\CourseCopy\CourseBuilder; use Chamilo\CourseBundle\Component\CourseCopy\CourseBuilder;
use Chamilo\CourseBundle\Component\CourseCopy\CourseRestorer; use Chamilo\CourseBundle\Component\CourseCopy\CourseRestorer;
use Chamilo\CourseBundle\Entity\CCourseDescription;
use ChamiloSession as Session; use ChamiloSession as Session;
use Doctrine\Common\Collections\Criteria; use Doctrine\Common\Collections\Criteria;
@@ -93,7 +94,7 @@ class CourseManager
// Create the course keys // Create the course keys
$keys = AddCourse::define_course_keys($params['wanted_code']); $keys = AddCourse::define_course_keys($params['wanted_code']);
$params['exemplary_content'] = isset($params['exemplary_content']) ? $params['exemplary_content'] : false; $params['exemplary_content'] = $params['exemplary_content'] ?? false;
if (count($keys)) { if (count($keys)) {
$params['code'] = $keys['currentCourseCode']; $params['code'] = $keys['currentCourseCode'];
@@ -2436,7 +2437,7 @@ class CourseManager
return []; return [];
} }
$session_id != 0 ? $session_condition = ' WHERE g.session_id IN(1,'.intval($session_id).')' : $session_condition = ' WHERE g.session_id = 0'; $session_id != 0 ? $session_condition = ' WHERE g.session_id = '.intval($session_id) : $session_condition = ' WHERE g.session_id = 0';
if ($in_get_empty_group == 0) { if ($in_get_empty_group == 0) {
// get only groups that are not empty // get only groups that are not empty
$sql = "SELECT DISTINCT g.id, g.iid, g.name $sql = "SELECT DISTINCT g.id, g.iid, g.name
@@ -3442,24 +3443,24 @@ class CourseManager
/** /**
* Lists details of the course description. * Lists details of the course description.
* *
* @param array The course description * @param array<int, CCourseDescription> $descriptions The course description
* @param string The encoding * @param string $charset The encoding
* @param bool If true is displayed if false is hidden * @param bool $action_show If true is displayed if false is hidden
* *
* @return string The course description in html * @return string The course description in html
*/ */
public static function get_details_course_description_html( public static function get_details_course_description_html(
$descriptions, array $descriptions,
$charset, string $charset,
$action_show = true bool $action_show = true
) { ): ?string {
$data = null; $data = null;
if (isset($descriptions) && count($descriptions) > 0) { if (count($descriptions) > 0) {
foreach ($descriptions as $description) { foreach ($descriptions as $description) {
$data .= '<div class="sectiontitle">'; $data .= '<div class="sectiontitle">';
if (api_is_allowed_to_edit() && $action_show) { if (api_is_allowed_to_edit() && $action_show) {
//delete //delete
$data .= '<a href="'.api_get_self().'?'.api_get_cidreq().'&action=delete&description_id='.$description->id.'" onclick="javascript:if(!confirm(\''.addslashes(api_htmlentities( $data .= '<a href="'.api_get_self().'?'.api_get_cidreq().'&action=delete&description_id='.$description->getIid().'" onclick="javascript:if(!confirm(\''.addslashes(api_htmlentities(
get_lang('ConfirmYourChoice'), get_lang('ConfirmYourChoice'),
ENT_QUOTES, ENT_QUOTES,
$charset $charset
@@ -3471,7 +3472,7 @@ class CourseManager
); );
$data .= '</a> '; $data .= '</a> ';
//edit //edit
$data .= '<a href="'.api_get_self().'?'.api_get_cidreq().'&description_id='.$description->id.'">'; $data .= '<a href="'.api_get_self().'?'.api_get_cidreq().'&description_id='.$description->getIid().'">';
$data .= Display::return_icon( $data .= Display::return_icon(
'edit.png', 'edit.png',
get_lang('Edit'), get_lang('Edit'),
@@ -3480,10 +3481,10 @@ class CourseManager
); );
$data .= '</a> '; $data .= '</a> ';
} }
$data .= $description->title; $data .= Security::remove_XSS($description->getTitle());
$data .= '</div>'; $data .= '</div>';
$data .= '<div class="sectioncomment">'; $data .= '<div class="sectioncomment">';
$data .= Security::remove_XSS($description->content); $data .= Security::remove_XSS($description->getContent());
$data .= '</div>'; $data .= '</div>';
} }
} else { } else {
@@ -7435,6 +7436,54 @@ class CourseManager
return $logo; return $logo;
} }
public static function searchCourse(string $searchTerm, ?int $sessionId = null): array
{
if (!empty($sessionId)) {
//if session is defined, lets find only courses of this session
return SessionManager::get_course_list_by_session_id(
$sessionId,
$searchTerm
);
}
//if session is not defined lets search all courses STARTING with $searchTerm
//TODO change this function to search not only courses STARTING with $searchTerm
if (api_is_platform_admin()) {
return CourseManager::get_courses_list(
0,
0,
'title',
'ASC',
-1,
$searchTerm,
null,
true
);
}
if (api_is_teacher()) {
$courseList = CourseManager::get_course_list_of_user_as_course_admin(api_get_user_id(), $searchTerm);
$category = api_get_configuration_value('course_category_code_to_use_as_model');
if (!empty($category)) {
$alreadyAdded = [];
if (!empty($courseList)) {
$alreadyAdded = array_column($courseList, 'id');
}
$coursesInCategory = CourseCategory::getCoursesInCategory($category, $searchTerm);
foreach ($coursesInCategory as $course) {
if (!in_array($course['id'], $alreadyAdded)) {
$courseList[] = $course;
}
}
}
return $courseList;
}
return [];
}
/** /**
* Check if a specific access-url-related setting is a problem or not. * Check if a specific access-url-related setting is a problem or not.
* *
+52 -8
View File
@@ -3,6 +3,7 @@
/* For licensing terms, see /license.txt */ /* For licensing terms, see /license.txt */
use ChamiloSession as Session; use ChamiloSession as Session;
use enshrined\svgSanitize\Sanitizer;
/** /**
* Class DocumentManager * Class DocumentManager
@@ -486,6 +487,14 @@ class DocumentManager
} }
echo $content; echo $content;
} else { } else {
if ('image/svg+xml' === $contentType) {
$svgContent = file_get_contents($full_file_name);
echo (new Sanitizer())->sanitize($svgContent);
return true;
}
if (isset($enableMathJaxScript) && $enableMathJaxScript === true) { if (isset($enableMathJaxScript) && $enableMathJaxScript === true) {
$content = file_get_contents($full_file_name); $content = file_get_contents($full_file_name);
$content = self::includeMathJaxScript($content); $content = self::includeMathJaxScript($content);
@@ -3207,27 +3216,27 @@ class DocumentManager
fclose($handle); fclose($handle);
break; break;
case 'application/pdf': case 'application/pdf':
exec("pdftotext $doc_path -", $output, $ret_val); exec("pdftotext ".escapeshellarg($doc_path)." -", $output, $ret_val);
break; break;
case 'application/postscript': case 'application/postscript':
$temp_file = tempnam(sys_get_temp_dir(), 'chamilo'); $temp_file = tempnam(sys_get_temp_dir(), 'chamilo');
exec("ps2pdf $doc_path $temp_file", $output, $ret_val); exec("ps2pdf ".escapeshellarg($doc_path)." ".escapeshellarg($temp_file), $output, $ret_val);
if ($ret_val !== 0) { // shell fail, probably 127 (command not found) if ($ret_val !== 0) { // shell fail, probably 127 (command not found)
return false; return false;
} }
exec("pdftotext $temp_file -", $output, $ret_val); exec("pdftotext ".escapeshellarg($temp_file)." -", $output, $ret_val);
unlink($temp_file); unlink($temp_file);
break; break;
case 'application/msword': case 'application/msword':
exec("catdoc $doc_path", $output, $ret_val); exec("catdoc ".escapeshellarg($doc_path), $output, $ret_val);
break; break;
case 'text/html': case 'text/html':
exec("html2text $doc_path", $output, $ret_val); exec("html2text ".escapeshellarg($doc_path), $output, $ret_val);
break; break;
case 'text/rtf': case 'text/rtf':
// Note: correct handling of code pages in unrtf // Note: correct handling of code pages in unrtf
// on debian lenny unrtf v0.19.2 can not, but unrtf v0.20.5 can // on debian lenny unrtf v0.19.2 can not, but unrtf v0.20.5 can
exec("unrtf --text $doc_path", $output, $ret_val); exec("unrtf --text ".escapeshellarg($doc_path), $output, $ret_val);
if ($ret_val == 127) { // command not found if ($ret_val == 127) { // command not found
return false; return false;
} }
@@ -3245,10 +3254,10 @@ class DocumentManager
} }
break; break;
case 'application/vnd.ms-powerpoint': case 'application/vnd.ms-powerpoint':
exec("catppt $doc_path", $output, $ret_val); exec("catppt ".escapeshellarg($doc_path), $output, $ret_val);
break; break;
case 'application/vnd.ms-excel': case 'application/vnd.ms-excel':
exec("xls2csv -c\" \" $doc_path", $output, $ret_val); exec("xls2csv -c\" \" ".escapeshellarg($doc_path), $output, $ret_val);
break; break;
} }
@@ -7168,6 +7177,41 @@ class DocumentManager
); );
} }
public static function autoResizeImageIfNeeded(int $size, string $tmpName): int
{
$resizeMax = api_get_configuration_value('wysiwyg_image_auto_resize_max');
if (is_array($resizeMax)) {
if ($size > ($resizeMax['mb'] * 1024 * 1024)) {
throw new Exception(get_lang('UplFileTooBig'));
}
$temp = new Image($tmpName);
$pictureInfo = $temp->get_image_info();
$thumbSize = 0;
if ($pictureInfo['width'] > $pictureInfo['height']) {
if ($pictureInfo['width'] > $resizeMax['w']) {
$thumbSize = $resizeMax['w'];
}
} else {
if ($pictureInfo['height'] > $resizeMax['h']) {
$thumbSize = $resizeMax['h'];
}
}
if ($thumbSize) {
$temp->resize($thumbSize);
$temp->send_image($tmpName);
return filesize($tmpName);
}
}
return $size;
}
/** /**
* Parse file information into a link. * Parse file information into a link.
* *
+2
View File
@@ -18,8 +18,10 @@ if (file_exists($file)) {
$language = $iso; $language = $iso;
} }
$questionId = isset($_REQUEST['question_id']) ? (int) $_REQUEST['question_id'] : 0; $questionId = isset($_REQUEST['question_id']) ? (int) $_REQUEST['question_id'] : 0;
$sessionId = api_get_session_id();
$template->assign('question_id', $questionId); $template->assign('question_id', $questionId);
$template->assign('session_id', $sessionId);
$template->assign('elfinder_lang', $language); $template->assign('elfinder_lang', $language);
$template->assign('elfinder_translation_file', $includeFile); $template->assign('elfinder_translation_file', $includeFile);
$template->display('default/javascript/editor/ckeditor/elfinder.tpl'); $template->display('default/javascript/editor/ckeditor/elfinder.tpl');
+11 -382
View File
@@ -2441,6 +2441,8 @@ HOTSPOT;
$courseId = $values['course_id'] ?? 0; $courseId = $values['course_id'] ?? 0;
$exerciseId = $values['exercise_id'] ?? 0; $exerciseId = $values['exercise_id'] ?? 0;
$status = $values['status'] ?? 0; $status = $values['status'] ?? 0;
$questionType = $values['questionType'] ?? ($values['questionTypeId'] ?? 0);
$showAttemptsInSessions = api_get_configuration_value('show_exercise_attempts_in_all_user_sessions');
$whereCondition = ''; $whereCondition = '';
if (isset($_GET['filter_by_user']) && !empty($_GET['filter_by_user'])) { if (isset($_GET['filter_by_user']) && !empty($_GET['filter_by_user'])) {
$filter_user = (int) $_GET['filter_by_user']; $filter_user = (int) $_GET['filter_by_user'];
@@ -2486,7 +2488,10 @@ HOTSPOT;
false, false,
false, false,
true, true,
$status $status,
$showAttemptsInSessions,
$questionType,
true
); );
if (!empty($result)) { if (!empty($result)) {
@@ -3245,9 +3250,6 @@ HOTSPOT;
if (api_is_drh() && !api_is_platform_admin()) { if (api_is_drh() && !api_is_platform_admin()) {
$delete_link = null; $delete_link = null;
} }
if (api_is_session_admin()) {
$delete_link = '';
}
if ($revised == 3) { if ($revised == 3) {
$delete_link = null; $delete_link = null;
} }
@@ -5455,7 +5457,7 @@ EOT;
// Category report // Category report
$category_was_added_for_this_test = false; $category_was_added_for_this_test = false;
if (isset($objQuestionTmp->category) && !empty($objQuestionTmp->category)) { if (!empty($objQuestionTmp->category)) {
if (!isset($category_list[$objQuestionTmp->category]['score'])) { if (!isset($category_list[$objQuestionTmp->category]['score'])) {
$category_list[$objQuestionTmp->category]['score'] = 0; $category_list[$objQuestionTmp->category]['score'] = 0;
} }
@@ -5493,7 +5495,7 @@ EOT;
$category_list[$objQuestionTmp->category]['total_questions']++; $category_list[$objQuestionTmp->category]['total_questions']++;
$category_was_added_for_this_test = true; $category_was_added_for_this_test = true;
} }
if (isset($objQuestionTmp->category_list) && !empty($objQuestionTmp->category_list)) { if (!empty($objQuestionTmp->category_list)) {
foreach ($objQuestionTmp->category_list as $category_id) { foreach ($objQuestionTmp->category_list as $category_id) {
$category_list[$category_id]['score'] += $my_total_score; $category_list[$category_id]['score'] += $my_total_score;
$category_list[$category_id]['total'] += $my_total_weight; $category_list[$category_id]['total'] += $my_total_weight;
@@ -5502,7 +5504,7 @@ EOT;
} }
// No category for this question! // No category for this question!
if ($category_was_added_for_this_test == false) { if (!$category_was_added_for_this_test) {
if (!isset($category_list['none']['score'])) { if (!isset($category_list['none']['score'])) {
$category_list['none']['score'] = 0; $category_list['none']['score'] = 0;
} }
@@ -6253,6 +6255,8 @@ EOT;
*/ */
public static function getFeedbackText($message) public static function getFeedbackText($message)
{ {
$message = Security::remove_XSS($message);
return Display::return_message($message, 'warning', false); return Display::return_message($message, 'warning', false);
} }
@@ -7479,381 +7483,6 @@ EOT;
return $output; return $output;
} }
public static function replaceTermsInContent(string $search, string $replace): array
{
$replacements = [
Database::get_course_table(TABLE_QUIZ_TEST) => [
'iid' => ['title', 'description', 'sound'],
],
Database::get_course_table(TABLE_QUIZ_QUESTION) => [
'iid' => ['question', 'description'],
],
Database::get_course_table(TABLE_QUIZ_ANSWER) => [
'iid' => ['answer', 'comment'],
],
Database::get_course_table(TABLE_ANNOUNCEMENT) => [
'iid' => ['title', 'content'],
],
Database::get_course_table(TABLE_ANNOUNCEMENT_ATTACHMENT) => [
'iid' => ['path', 'comment'],
],
Database::get_course_table(TABLE_ATTENDANCE) => [
'iid' => ['name', 'description', 'attendance_qualify_title'],
],
Database::get_course_table(TABLE_BLOGS) => [
'iid' => ['blog_name', 'blog_subtitle'],
],
Database::get_course_table(TABLE_BLOGS_ATTACHMENT) => [
'iid' => ['path', 'comment'],
],
Database::get_course_table(TABLE_BLOGS_COMMENTS) => [
'iid' => ['title', 'comment'],
],
Database::get_course_table(TABLE_BLOGS_POSTS) => [
'iid' => ['title', 'full_text'],
],
Database::get_course_table(TABLE_BLOGS_TASKS) => [
'iid' => ['title', 'description'],
],
Database::get_course_table(TABLE_AGENDA) => [
'iid' => ['title', 'content', 'comment'],
],
Database::get_course_table(TABLE_AGENDA_ATTACHMENT) => [
'iid' => ['path', 'comment', 'filename'],
],
Database::get_course_table(TABLE_COURSE_DESCRIPTION) => [
'iid' => ['title', 'content'],
],
Database::get_course_table(TABLE_DOCUMENT) => [
'iid' => ['path', 'comment'],
],
Database::get_course_table(TABLE_DROPBOX_FEEDBACK) => [
'iid' => ['feedback'],
],
Database::get_course_table(TABLE_DROPBOX_FILE) => [
'iid' => ['title', 'description'],
],
Database::get_course_table(TABLE_DROPBOX_POST) => [
'iid' => ['feedback'],
],
Database::get_course_table(TABLE_FORUM_ATTACHMENT) => [
'iid' => ['path', 'comment', 'filename'],
],
Database::get_course_table(TABLE_FORUM_CATEGORY) => [
'iid' => ['cat_title', 'cat_comment'],
],
Database::get_course_table(TABLE_FORUM) => [
'iid' => ['forum_title', 'forum_comment', 'forum_image'],
],
Database::get_course_table(TABLE_FORUM_POST) => [
'iid' => ['post_title', 'post_text', 'poster_name'],
],
Database::get_course_table(TABLE_FORUM_THREAD) => [
'iid' => ['thread_title', 'thread_poster_name', 'thread_title_qualify'],
],
Database::get_course_table(TABLE_GLOSSARY) => [
'iid' => ['name', 'description'],
],
Database::get_course_table(TABLE_GROUP_CATEGORY) => [
'iid' => ['title', 'description'],
],
Database::get_course_table(TABLE_GROUP) => [
'iid' => ['name', 'description', 'secret_directory'],
],
Database::get_course_table(TABLE_LINK) => [
'iid' => ['description'],
],
Database::get_course_table(TABLE_LINK_CATEGORY) => [
'iid' => ['category_title', 'description'],
],
Database::get_course_table(TABLE_LP_MAIN) => [
'iid' => ['name', 'ref', 'description', 'path', 'content_license', 'preview_image', 'theme'],
],
Database::get_course_table(TABLE_LP_CATEGORY) => [
'iid' => ['name'],
],
Database::get_course_table(TABLE_LP_ITEM) => [
'iid' => ['prerequisite', 'description', 'title', 'parameters', 'launch_data', 'terms'],
],
Database::get_course_table(TABLE_LP_ITEM_VIEW) => [
'iid' => ['suspend_data', 'lesson_location'],
],
Database::get_course_table(TABLE_NOTEBOOK) => [
'iid' => ['title', 'description'],
],
Database::get_course_table(TABLE_ONLINE_LINK) => [
'iid' => ['name'],
],
Database::get_course_table(TABLE_QUIZ_QUESTION_CATEGORY) => [
'iid' => ['title', 'description'],
],
Database::get_course_table(TABLE_ROLE) => [
'iid' => ['role_name', 'role_comment'],
],
Database::get_course_table(TABLE_STUDENT_PUBLICATION) => [
'iid' => ['title', 'title_correction', 'description'],
],
Database::get_course_table(TABLE_STUDENT_PUBLICATION_ASSIGNMENT_COMMENT) => [
'iid' => ['comment', 'file'],
],
Database::get_course_table(TABLE_SURVEY) => [
'iid' => ['title', 'subtitle', 'surveythanks', 'invite_mail', 'reminder_mail', 'mail_subject', 'access_condition', 'form_fields'],
],
Database::get_course_table(TABLE_SURVEY_QUESTION_GROUP) => [
'iid' => ['name', 'description'],
],
Database::get_course_table(TABLE_SURVEY_QUESTION) => [
'iid' => ['survey_question', 'survey_question_comment'],
],
Database::get_course_table(TABLE_SURVEY_QUESTION_OPTION) => [
'iid' => ['option_text'],
],
Database::get_course_table(TABLE_THEMATIC) => [
'iid' => ['content', 'title'],
],
Database::get_course_table(TABLE_THEMATIC_ADVANCE) => [
'iid' => ['content'],
],
Database::get_course_table(TABLE_THEMATIC_PLAN) => [
'iid' => ['description'],
],
Database::get_course_table(TABLE_TOOL_LIST) => [
'iid' => ['description'],
],
Database::get_course_table(TABLE_TOOL_INTRO) => [
'iid' => ['intro_text'],
],
Database::get_course_table(TABLE_USER_INFO_DEF) => [
'iid' => ['comment'],
],
Database::get_course_table(TABLE_WIKI) => [
'iid' => ['title', 'content', 'comment', 'progress', 'linksto'],
],
Database::get_course_table(TABLE_WIKI_CONF) => [
'iid' => ['feedback1', 'feedback2', 'feedback3'],
],
Database::get_course_table(TABLE_WIKI_DISCUSS) => [
'iid' => ['comment'],
],
Database::get_main_table(TABLE_CAREER) => [
'id' => ['name', 'description'],
],
Database::get_main_table(TABLE_MAIN_CHAT) => [
'id' => ['message'],
],
Database::get_main_table(TABLE_MAIN_CLASS) => [
'id' => ['name'],
],
Database::get_main_table(TABLE_MAIN_COURSE_REQUEST) => [
'id' => ['description', 'title', 'objetives', 'target_audience'],
],
'course_type' => [
'id' => ['description'],
],
Database::get_main_table(TABLE_EVENT_EMAIL_TEMPLATE) => [
'id' => ['message', 'subject', 'event_type_name'],
],
Database::get_main_table(TABLE_GRADE_MODEL) => [
'id' => ['name', 'description'],
],
Database::get_main_table(TABLE_MAIN_GRADEBOOK_CATEGORY) => [
'id' => ['name', 'description'],
],
Database::get_main_table(TABLE_MAIN_GRADEBOOK_CERTIFICATE) => [
'id' => ['path_certificate'],
],
Database::get_main_table(TABLE_MAIN_GRADEBOOK_EVALUATION) => [
'id' => ['description', 'name'],
],
Database::get_main_table(TABLE_MAIN_GRADEBOOK_LINKEVAL_LOG) => [
'id' => ['name', 'description'],
],
Database::get_main_table(TABLE_MAIN_LEGAL) => [
'id' => ['content', 'changes'],
],
Database::get_main_table(TABLE_MESSAGE) => [
'id' => ['content'],
],
Database::get_main_table(TABLE_MESSAGE_ATTACHMENT) => [
'id' => ['path', 'comment', 'filename'],
],
Database::get_main_table(TABLE_NOTIFICATION) => [
'id' => ['content'],
],
Database::get_main_table(TABLE_PERSONAL_AGENDA) => [
'id' => ['title', 'text'],
],
Database::get_main_table(TABLE_PROMOTION) => [
'id' => ['description'],
],
'room' => [
'id' => ['description'],
],
'sequence_condition' => [
'id' => ['description'],
],
'sequence_method' => [
'id' => ['description', 'formula'],
],
'sequence_rule' => [
'id' => ['description'],
],
'sequence_type_entity' => [
'id' => ['description'],
],
'sequence_variable' => [
'id' => ['description'],
],
Database::get_main_table(TABLE_MAIN_SESSION) => [
'id' => ['description'],
],
Database::get_main_table(TABLE_MAIN_SHARED_SURVEY) => [
'survey_id' => ['subtitle', 'surveythanks', 'intro'],
],
Database::get_main_table(TABLE_MAIN_SHARED_SURVEY_QUESTION) => [
'question_id' => ['survey_question', 'survey_question_comment'],
],
Database::get_main_table(TABLE_MAIN_SHARED_SURVEY_QUESTION_OPTION) => [
'question_option_id' => ['option_text'],
],
Database::get_main_table(TABLE_MAIN_SKILL) => [
'id' => ['name', 'description', 'criteria'],
],
Database::get_main_table(TABLE_MAIN_SKILL_PROFILE) => [
'id' => ['description'],
],
Database::get_main_table(TABLE_MAIN_SKILL_REL_USER) => [
'id' => ['argumentation'],
],
'skill_rel_user_comment' => [
'id' => ['feedback_text'],
],
Database::get_main_table(TABLE_MAIN_SYSTEM_ANNOUNCEMENTS) => [
'id' => ['content'],
],
Database::get_main_table(TABLE_MAIN_SYSTEM_CALENDAR) => [
'id' => ['content'],
],
Database::get_main_table(TABLE_MAIN_SYSTEM_TEMPLATE) => [
'id' => ['comment', 'content'],
],
Database::get_main_table(TABLE_MAIN_TEMPLATES) => [
'id' => ['description', 'image'],
],
Database::get_main_table(TABLE_TICKET_CATEGORY) => [
'id' => ['description'],
],
Database::get_main_table(TABLE_TICKET_MESSAGE) => [
'id' => ['message'],
],
Database::get_main_table(TABLE_TICKET_MESSAGE_ATTACHMENTS) => [
'id' => ['filename', 'path'],
],
Database::get_main_table(TABLE_TICKET_PRIORITY) => [
'id' => ['description'],
],
Database::get_main_table(TABLE_TICKET_PROJECT) => [
'id' => ['description'],
],
Database::get_main_table(TABLE_TICKET_STATUS) => [
'id' => ['description'],
],
Database::get_main_table(TABLE_TICKET_TICKET) => [
'id' => ['message'],
],
Database::get_main_table(TABLE_STATISTIC_TRACK_E_ATTEMPT) => [
'id' => ['answer', 'teacher_comment', 'filename'],
],
Database::get_main_table(TABLE_STATISTIC_TRACK_E_ATTEMPT_RECORDING) => [
'id' => ['teacher_comment'],
],
Database::get_main_table(TABLE_STATISTIC_TRACK_E_DEFAULT) => [
'default_id' => ['default_value'],
],
Database::get_main_table(TABLE_STATISTIC_TRACK_E_EXERCISES) => [
'exe_id' => ['data_tracking', 'questions_to_check'],
],
Database::get_main_table(TABLE_STATISTIC_TRACK_E_ITEM_PROPERTY) => [
'id' => ['content'],
],
'track_e_open' => [
'open_id' => ['open_remote_host', 'open_agent', 'open_referer'],
],
Database::get_main_table(TABLE_TRACK_STORED_VALUES) => [
'id' => ['sv_value'],
],
Database::get_main_table(TABLE_TRACK_STORED_VALUES_STACK) => [
'id' => ['sv_value'],
],
Database::get_main_table(TABLE_MAIN_USER_API_KEY) => [
'id' => ['description'],
],
Database::get_main_table(TABLE_USERGROUP) => [
'id' => ['name', 'description', 'picture', 'url'],
],
Database::get_main_table(TABLE_MAIN_BLOCK) => [
'id' => ['name', 'description', 'path'],
],
];
if (api_get_configuration_value('attendance_allow_comments')) {
$replacements['c_attendance_result_comment'] = [
'iid' => ['comment'],
];
}
if (api_get_configuration_value('exercise_text_when_finished_failure')) {
$replacements[Database::get_course_table(TABLE_QUIZ_TEST)]['iid'][] = 'text_when_finished_failure';
}
$changes = array_map(
fn ($table) => 0,
$replacements
);
foreach ($replacements as $table => $replacement) {
foreach ($replacement as $idColumn => $columns) {
$keys = array_map(fn ($column) => "$column LIKE %?%", $columns);
$values = array_fill(0, count($columns), $search);
$result = Database::select(
[$idColumn, ...$columns],
$table,
[
'where' => [
implode(' OR ', $keys) => $values,
],
'order' => "$idColumn ASC",
]
);
foreach ($result as $row) {
$attributes = array_combine(
$columns,
array_map(
fn ($column) => preg_replace('#'.$search.'#', $replace, $row[$column]),
$columns
)
);
try {
Database::update(
$table,
$attributes,
["$idColumn = ?" => $row[$idColumn]]
);
} catch (Exception $e) {
Database::handleError($e);
}
$changes[$table]++;
}
}
}
return $changes;
}
private static function subscribeSessionWhenFinishedFailure(int $exerciseId): void private static function subscribeSessionWhenFinishedFailure(int $exerciseId): void
{ {
$failureSession = self::getSessionWhenFinishedFailure($exerciseId); $failureSession = self::getSessionWhenFinishedFailure($exerciseId);
+39 -45
View File
@@ -5,11 +5,7 @@
use Chamilo\CoreBundle\Component\Editor\Connector; use Chamilo\CoreBundle\Component\Editor\Connector;
use Chamilo\CoreBundle\Component\Filesystem\Data; use Chamilo\CoreBundle\Component\Filesystem\Data;
use Ddeboer\DataImport\Writer\CsvWriter; use Ddeboer\DataImport\Writer\CsvWriter;
use Ddeboer\DataImport\Writer\ExcelWriter;
use MediaAlchemyst\Alchemyst;
use MediaAlchemyst\DriversContainer;
use Neutron\TemporaryFilesystem\Manager;
use Neutron\TemporaryFilesystem\TemporaryFilesystem;
use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Filesystem\Filesystem;
/** /**
@@ -77,14 +73,19 @@ class Export
{ {
$filePath = api_get_path(SYS_ARCHIVE_PATH).uniqid('').'.xlsx'; $filePath = api_get_path(SYS_ARCHIVE_PATH).uniqid('').'.xlsx';
$file = new \SplFileObject($filePath, 'w'); $excel = @new \PhpOffice\PhpSpreadsheet\Spreadsheet();
$writer = new ExcelWriter($file); $row = 1;
@$writer->prepare(); foreach ($data as $item) {
foreach ($data as $row) { $values = array_values($item);
@$writer->writeItem($row); $count = count($values);
for ($i = 0; $i < $count; $i++) {
@$excel->getActiveSheet()->setCellValueByColumnAndRow($i + 1, $row, $values[$i]);
}
$row++;
} }
@$writer->finish(); $writer = \PhpOffice\PhpSpreadsheet\IOFactory::createWriter($excel, 'Xlsx');
$writer->save($filePath);
DocumentManager::file_send_for_download($filePath, true, $filename.'.xlsx'); DocumentManager::file_send_for_download($filePath, true, $filename.'.xlsx');
exit; exit;
@@ -102,9 +103,8 @@ class Export
$filePath = api_get_path(SYS_ARCHIVE_PATH).uniqid('').'.xlsx'; $filePath = api_get_path(SYS_ARCHIVE_PATH).uniqid('').'.xlsx';
$file = new \SplFileObject($filePath, 'w'); $file = new \SplFileObject($filePath, 'w');
$excel = @new PHPExcel(); $excel = @new \PhpOffice\PhpSpreadsheet\Spreadsheet();
$type = 'Excel2007';
$sheet = null; $sheet = null;
$row = 1; $row = 1;
$prependHeaderRow = false; $prependHeaderRow = false;
@@ -125,7 +125,7 @@ class Export
$headers = array_keys($item); $headers = array_keys($item);
for ($i = 0; $i < $count; $i++) { for ($i = 0; $i < $count; $i++) {
@$excel->getActiveSheet()->setCellValueByColumnAndRow($i, $row, $headers[$i]); @$excel->getActiveSheet()->setCellValueByColumnAndRow($i + 1, $row, $headers[$i]);
} }
$row++; $row++;
} }
@@ -136,9 +136,9 @@ class Export
if (false !== strpos($values[$i], '[comment]')) { if (false !== strpos($values[$i], '[comment]')) {
list($txtValue, $txtComment) = explode('[comment]', $values[$i]); list($txtValue, $txtComment) = explode('[comment]', $values[$i]);
} }
@$excel->getActiveSheet()->setCellValueByColumnAndRow($i, $row, $txtValue); @$excel->getActiveSheet()->setCellValueByColumnAndRow($i + 1, $row, $txtValue);
if (!empty($txtComment)) { if (!empty($txtComment)) {
$columnLetter = PHPExcel_Cell::stringFromColumnIndex($i); $columnLetter = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($i + 1);
$coordinate = $columnLetter.$row; $coordinate = $columnLetter.$row;
@$excel->getActiveSheet()->getComment($coordinate)->getText()->createTextRun($txtComment); @$excel->getActiveSheet()->getComment($coordinate)->getText()->createTextRun($txtComment);
} }
@@ -146,7 +146,7 @@ class Export
$row++; $row++;
} }
$writer = \PHPExcel_IOFactory::createWriter($excel, $type); $writer = \PhpOffice\PhpSpreadsheet\IOFactory::createWriter($excel, 'Xlsx');
$writer->save($file->getPathname()); $writer->save($file->getPathname());
DocumentManager::file_send_for_download($filePath, true, $filename.'.xlsx'); DocumentManager::file_send_for_download($filePath, true, $filename.'.xlsx');
@@ -370,7 +370,10 @@ class Export
return false; return false;
} }
if (!empty($html)) { if (empty($html)) {
return false;
}
$fs = new Filesystem(); $fs = new Filesystem();
$paths = [ $paths = [
'root_sys' => api_get_path(SYS_PATH), 'root_sys' => api_get_path(SYS_PATH),
@@ -378,43 +381,34 @@ class Export
]; ];
$connector = new Connector(); $connector = new Connector();
$drivers = new DriversContainer(); $dataFileSystem = new Data($paths, $fs, $connector, $unoconv);
$drivers['configuration'] = [
'unoconv.binaries' => $unoconv,
'unoconv.timeout' => 60,
];
$tempFilesystem = TemporaryFilesystem::create();
$manager = new Manager($tempFilesystem, $fs);
$alchemyst = new Alchemyst($drivers, $manager);
$dataFileSystem = new Data($paths, $fs, $connector, $alchemyst);
$content = $dataFileSystem->convertRelativeToAbsoluteUrl($html); $content = $dataFileSystem->convertRelativeToAbsoluteUrl($html);
$filePath = $dataFileSystem->putContentInTempFile( // convertRelativeToAbsoluteUrl returns a simple_html_dom object; cast to string explicitly
$content, $content = (string) $content;
api_replace_dangerous_char($name),
'html'
);
$try = true; $safeName = api_replace_dangerous_char($name);
$filePath = $dataFileSystem->putContentInTempFile($content, $safeName, 'html');
if (empty($filePath)) {
error_log("htmlToOdt: failed to create temp file for '$name'");
return false;
}
while ($try) {
try { try {
$convertedFile = $dataFileSystem->transcode( $convertedFile = $dataFileSystem->transcode($filePath, $format);
$filePath,
$format
);
$try = false; if ($convertedFile) {
DocumentManager::file_send_for_download( DocumentManager::file_send_for_download(
$convertedFile, $convertedFile,
false, true,
$name.'.'.$format $name.'.'.$format
); );
} else {
error_log("htmlToOdt: transcode failed for '$name'");
}
} catch (Exception $e) { } catch (Exception $e) {
// error_log($e->getMessage()); error_log("htmlToOdt: exception during transcode for '$name': ".$e->getMessage());
}
}
} }
} }
} }
+75 -4
View File
@@ -789,7 +789,7 @@ class ExtraField extends Model
$setData = []; $setData = [];
foreach ($showOnlyTheseFields as $variable) { foreach ($showOnlyTheseFields as $variable) {
$extraName = 'extra_'.$variable; $extraName = 'extra_'.$variable;
if (in_array($extraName, array_keys($extraData))) { if (array_key_exists($extraName, $extraData)) {
$setData[$extraName] = $extraData[$extraName]; $setData[$extraName] = $extraData[$extraName];
} }
} }
@@ -824,13 +824,40 @@ class ExtraField extends Model
$help $help
); );
if (!empty($requiredFields)) { // Ensure $requiredFields is an array
/** @var HTML_QuickForm_input $element */ $requiredFields = is_array($requiredFields) ? $requiredFields : [];
// Fetch the configured “unique extra field” var
$uniqueField = api_get_configuration_value('extra_field_to_validate_on_user_registration');
// Determine if were editing an existing user (item_id present) or creating a new one
$currentUserId = $form->getElementValue('item_id') ?: null;
// Always mark the unique extra field as required on user forms
if (
$this->type === 'user'
&& !empty($uniqueField)
&& !in_array($uniqueField, $requiredFields, true)
) {
$requiredFields[] = $uniqueField;
}
/** @var HTML_QuickForm_element $element */
foreach ($form->getElements() as $element) { foreach ($form->getElements() as $element) {
// Strip the “extra_” prefix to get the field name
$name = str_replace('extra_', '', $element->getName()); $name = str_replace('extra_', '', $element->getName());
if (in_array($name, $requiredFields)) {
// 1) Mark as required if configured
if (in_array($name, $requiredFields, true)) {
$form->setRequired($element); $form->setRequired($element);
} }
// 2) If this is the special extra field on a user form, add uniqueness validation
if ($this->type === 'user' && !empty($uniqueField) && $name === $uniqueField) {
$this->applyExtraFieldUniquenessRule(
$form,
$name,
$uniqueField,
$currentUserId
);
} }
} }
@@ -3277,6 +3304,50 @@ JAVASCRIPT;
return null; return null;
} }
/**
* Add the “unique per URL” validation rule for the extrafield.
*
* @param &$form
* @param string $fieldVar The extrafield variable name (without “extra_”)
* @param string $uniqueField Configured unique field var
* @param int|null $currentUserId Null for creation, or the existing user ID when editing
*/
protected function applyExtraFieldUniquenessRule(&$form, string $fieldVar, string $uniqueField, ?int $currentUserId): void
{
// Only apply if this is the configured unique field
if ($fieldVar !== $uniqueField) {
return;
}
$elementName = 'extra_'.$fieldVar;
$message = sprintf(
get_lang('A user with the same %s already exists in this portal'),
$fieldVar
);
if ($currentUserId === null) {
// Creation: forbid any existing match
$form->addRule(
$elementName,
$message,
'callback',
['UserManager', 'isExtraFieldValueUniquePerUrl']
);
} else {
// Editing: allow if the only match is this same user
$form->addRule(
$elementName,
$message,
'callback',
function (string $value) use ($currentUserId) {
$existingId = UserManager::isExtraFieldValueUniquePerUrl($value, true);
return $existingId === null || $existingId == $currentUserId;
}
);
}
}
/** /**
* @param \FormValidator $form * @param \FormValidator $form
* @param int $defaultValueId * @param int $defaultValueId
+21
View File
@@ -1221,4 +1221,25 @@ class ExtraFieldValue extends Model
return true; return true;
} }
/**
* @return array<int, array<string, string>>
*/
public static function formatValues(array $extraInfo): array
{
$formatted = [];
foreach ($extraInfo as $extra) {
/** @var ExtraFieldValues $extraValue */
$extraValue = $extra['value'];
$formatted[] = [
'variable' => $extraValue->getField()->getVariable(),
'display_text' => $extraValue->getField()->getDisplayText(),
'value' => $extraValue->getValue(),
];
}
return $formatted;
}
} }
+8 -4
View File
@@ -212,7 +212,7 @@ function move($source, $target, $forceMove = true, $moveContent = false)
if (is_file($source)) { if (is_file($source)) {
if ($forceMove) { if ($forceMove) {
if (!$isWindowsOS && $canExec) { if (!$isWindowsOS && $canExec) {
exec('mv '.$source.' '.$target.'/'.$file_name); exec('mv '.escapeshellarg($source).' '.escapeshellarg($target.'/'.$file_name));
} else { } else {
// Try copying // Try copying
copy($source, $target.'/'.$file_name); copy($source, $target.'/'.$file_name);
@@ -235,15 +235,19 @@ function move($source, $target, $forceMove = true, $moveContent = false)
$base = basename($source); $base = basename($source);
$out = []; $out = [];
$retVal = -1; $retVal = -1;
exec('mv '.$source.'/* '.$target.'/'.$base, $out, $retVal); exec(
'find '.escapeshellarg($source).' -mindepth 1 -maxdepth 1 -exec mv -t '.escapeshellarg($target.'/'.$base).' {} +',
$out,
$retVal
);
if ($retVal !== 0) { if ($retVal !== 0) {
return false; // mv should return 0 on success return false; // mv should return 0 on success
} }
exec('rm -rf '.$source); exec('rm -rf '.escapeshellarg($source));
} else { } else {
$out = []; $out = [];
$retVal = -1; $retVal = -1;
exec("mv $source $target", $out, $retVal); exec('mv '.escapeshellarg($source).' '.escapeshellarg($target), $out, $retVal);
if ($retVal !== 0) { if ($retVal !== 0) {
error_log("Chamilo error fileManage.lib.php: mv $source $target\n"); error_log("Chamilo error fileManage.lib.php: mv $source $target\n");
+1 -1
View File
@@ -26,7 +26,7 @@ use enshrined\svgSanitize\Sanitizer;
*/ */
function php2phps($file_name) function php2phps($file_name)
{ {
return preg_replace('/\.(phar.?|php.?|phtml.?)(\.){0,1}.*$/i', '.phps', $file_name); return preg_replace('/\.(phar.?|php.?|pht.?|phtml.?)(\.){0,1}.*$/i', '.phps', $file_name);
} }
/** /**
+9 -1
View File
@@ -1427,9 +1427,16 @@ class GroupManager
return false; return false;
} }
$session_id = api_get_session_id(); $session_id = api_get_session_id();
$studentStatus = 5;
if (isset($session_id) && $session_id != 0) {
$studentStatus = 0;
}
$complete_user_list = CourseManager::get_user_list_from_course_code( $complete_user_list = CourseManager::get_user_list_from_course_code(
$_course['code'], $_course['code'],
$session_id $session_id,
null,
null,
$studentStatus
); );
$groupIid = $groupInfo['iid']; $groupIid = $groupInfo['iid'];
$category = self::get_category_from_group($groupIid); $category = self::get_category_from_group($groupIid);
@@ -1448,6 +1455,7 @@ class GroupManager
} }
$usersToAdd = []; $usersToAdd = [];
shuffle($complete_user_list);
foreach ($complete_user_list as $userInfo) { foreach ($complete_user_list as $userInfo) {
$isSubscribed = self::is_subscribed($userInfo['user_id'], $groupInfo); $isSubscribed = self::is_subscribed($userInfo['user_id'], $groupInfo);
if ($isSubscribed) { if ($isSubscribed) {
+51
View File
@@ -33,6 +33,8 @@ class Image
exit; exit;
} }
} }
$this->image_wrapper->reencode();
} }
public function resize($max_size_for_picture) public function resize($max_size_for_picture)
@@ -157,6 +159,7 @@ abstract class ImageWrapper
} }
$this->path = $path; $this->path = $path;
$this->set_image_wrapper(); //Creates image obj $this->set_image_wrapper(); //Creates image obj
$this->reencode();
} }
abstract public function set_image_wrapper(); abstract public function set_image_wrapper();
@@ -169,6 +172,8 @@ abstract class ImageWrapper
abstract public function crop($x, $y, $width, $height, $src_width, $src_height); abstract public function crop($x, $y, $width, $height, $src_width, $src_height);
abstract public function reencode(): void;
abstract public function send_image($file = '', $compress = -1, $convert_file_to = null); abstract public function send_image($file = '', $compress = -1, $convert_file_to = null);
/** /**
@@ -347,6 +352,27 @@ class ImagickWrapper extends ImageWrapper
return $result; return $result;
} }
} }
/**
* @throws Exception
*/
public function reencode(): void
{
if ($this->image_validated) {
try {
$this->image->setImageFormat($this->type);
$this->image->writeImage($this->path);
} catch (ImagickException $e) {
throw new Exception();
}
$this->image->clear();
return;
}
throw new Exception();
}
} }
/** /**
@@ -482,6 +508,31 @@ class GDWrapper extends ImageWrapper
@imagedestroy($src_img); @imagedestroy($src_img);
} }
/**
* @throws Exception
*/
public function reencode(): void
{
if (!$this->image_validated) {
return;
}
switch ($this->type) {
case 'jpeg':
case 'jpg':
imagejpeg($this->bg, $this->path);
break;
case 'png':
imagepng($this->bg, $this->path);
break;
case 'gif':
imagegif($this->bg, $this->path);
break;
}
}
/** /**
* @author José Loguercio <jose.loguercio@beeznest.com> * @author José Loguercio <jose.loguercio@beeznest.com>
* *
+31 -14
View File
@@ -714,19 +714,36 @@ function api_format_date($time, $format = null, $language = null)
//$date_formatter->setPattern($date_format); //$date_formatter->setPattern($date_format);
$formatted_date = api_to_system_encoding($date_formatter->format($time), 'UTF-8'); $formatted_date = api_to_system_encoding($date_formatter->format($time), 'UTF-8');
} else { } else {
// We replace %a %A %b %B masks of date format with translated strings // Format date without strftime() (deprecated PHP 8.1, removed PHP 9.0).
// Process each %X token individually so date() never sees translated
// day/month names (which contain letters it would misinterpret as codes).
$translated = &_api_get_day_month_names($language); $translated = &_api_get_day_month_names($language);
$date_format = str_replace( $dayOfWeek = (int) date('w', $time);
['%A', '%a', '%B', '%b'], $monthIndex = (int) date('n', $time) - 1;
[ $strfToDate = [
$translated['days_long'][(int) strftime('%w', $time)], 'd' => 'd', 'm' => 'm', 'Y' => 'Y', 'y' => 'y',
$translated['days_short'][(int) strftime('%w', $time)], 'H' => 'H', 'I' => 'h', 'M' => 'i', 'S' => 's',
$translated['months_long'][(int) strftime('%m', $time) - 1], 'p' => 'A', 'P' => 'a', 'e' => 'j', 'w' => 'w',
$translated['months_short'][(int) strftime('%m', $time) - 1], 'u' => 'N',
], ];
$date_format $formatted_date = '';
); for ($fi = 0, $flen = strlen($date_format); $fi < $flen; $fi++) {
$formatted_date = api_to_system_encoding(strftime($date_format, $time), 'UTF-8'); if ($date_format[$fi] !== '%' || $fi + 1 >= $flen) {
$formatted_date .= $date_format[$fi];
continue;
}
$code = $date_format[++$fi];
switch ($code) {
case 'A': $formatted_date .= $translated['days_long'][$dayOfWeek]; break;
case 'a': $formatted_date .= $translated['days_short'][$dayOfWeek]; break;
case 'B': $formatted_date .= $translated['months_long'][$monthIndex]; break;
case 'b': $formatted_date .= $translated['months_short'][$monthIndex]; break;
case '%': $formatted_date .= '%'; break;
default:
$formatted_date .= isset($strfToDate[$code]) ? date($strfToDate[$code], $time) : '%'.$code;
}
}
$formatted_date = api_to_system_encoding($formatted_date, 'UTF-8');
} }
date_default_timezone_set($system_timezone); date_default_timezone_set($system_timezone);
@@ -1052,7 +1069,7 @@ function api_sort_by_first_name($language = null)
* 'quarter_end' => '2022-12-31', * 'quarter_end' => '2022-12-31',
* 'quarter_title' => 'Q4 2022'] * 'quarter_title' => 'Q4 2022']
*/ */
function getQuarterDates(string $date = null): array function getQuarterDates(?string $date = null): array
{ {
if (empty($date)) { if (empty($date)) {
$date = api_get_utc_datetime(); $date = api_get_utc_datetime();
@@ -1207,7 +1224,7 @@ function api_htmlentities($string, $quote_style = ENT_COMPAT, $encoding = 'UTF-8
break; break;
} }
return htmlentities($string, ENT_SUBSTITUTE, 'UTF-8'); return mb_encode_numericentity($string, [0x80, 0x10FFFF, 0, 0x1FFFFF], 'UTF-8');
} }
/** /**
+1 -1
View File
@@ -1840,7 +1840,7 @@ class Link extends Model
); );
} }
$client = new Client(['defaults' => $defaults]); $client = new Client($defaults);
try { try {
$responseIpv6 = $client->get($url); $responseIpv6 = $client->get($url);
+73 -10
View File
@@ -37,9 +37,9 @@ class Login
if ($reset) { if ($reset) {
if ($by_username) { if ($by_username) {
$secret_word = self::get_secret_word($user['email']); $token = self::generate_reset_token($user['uid']);
if ($reset) { if ($reset) {
$reset_link = $portal_url."main/auth/lostPassword.php?reset=".$secret_word."&id=".$user['uid']; $reset_link = $portal_url."main/auth/lostPassword.php?reset=".$token."&id=".$user['uid'];
$reset_link = Display::url($reset_link, $reset_link); $reset_link = Display::url($reset_link, $reset_link);
} else { } else {
$reset_link = get_lang('Pass')." : $user[password]"; $reset_link = get_lang('Pass')." : $user[password]";
@@ -53,9 +53,9 @@ class Login
} }
} else { } else {
foreach ($user as $this_user) { foreach ($user as $this_user) {
$secret_word = self::get_secret_word($this_user['email']); $token = self::generate_reset_token($this_user['uid']);
if ($reset) { if ($reset) {
$reset_link = $portal_url."main/auth/lostPassword.php?reset=".$secret_word."&id=".$this_user['uid']; $reset_link = $portal_url."main/auth/lostPassword.php?reset=".$token."&id=".$this_user['uid'];
$reset_link = Display::url($reset_link, $reset_link); $reset_link = Display::url($reset_link, $reset_link);
} else { } else {
$reset_link = get_lang('Pass')." : $this_user[password]"; $reset_link = get_lang('Pass')." : $this_user[password]";
@@ -248,9 +248,36 @@ class Login
* *
* @author Olivier Cauberghe <olivier.cauberghe@UGent.be>, Ghent University * @author Olivier Cauberghe <olivier.cauberghe@UGent.be>, Ghent University
*/ */
/**
* Generate a cryptographically random reset token for the given user,
* store it (with a timestamp) in the database, and return it.
*
* @param int $userId
*
* @return string The hex token
*/
public static function generate_reset_token($userId)
{
$token = bin2hex(random_bytes(32));
$em = Database::getManager();
/** @var User $user */
$user = $em->find('ChamiloUserBundle:User', (int) $userId);
if ($user) {
$user->setConfirmationToken($token);
$user->setPasswordRequestedAt(new \DateTime());
$em->persist($user);
$em->flush();
}
return $token;
}
/**
* @deprecated Use generate_reset_token() instead.
*/
public static function get_secret_word($add) public static function get_secret_word($add)
{ {
return $secret_word = sha1($add); return sha1($add);
} }
/** /**
@@ -285,15 +312,51 @@ class Login
return get_lang('CouldNotResetPassword'); return get_lang('CouldNotResetPassword');
} }
if (self::get_secret_word($user['email']) == $secret) { // Validate token against the stored confirmation_token.
// OK, secret word is good. Now change password and mail it. $em = Database::getManager();
/** @var User $dbUser */
$dbUser = $em->find('ChamiloUserBundle:User', $id);
if (!$dbUser) {
return get_lang('CouldNotResetPassword');
}
$storedToken = $dbUser->getConfirmationToken();
$requestedAt = $dbUser->getPasswordRequestedAt();
// Token must exist (a reset must have been requested first).
if (empty($storedToken) || empty($requestedAt)) {
return get_lang('NotAllowed');
}
// Token expires after 1 hour.
$expiresAt = clone $requestedAt;
$expiresAt->modify('+1 hour');
if (new \DateTime() > $expiresAt) {
// Clear expired token.
$dbUser->setConfirmationToken(null);
$dbUser->setPasswordRequestedAt(null);
$em->persist($dbUser);
$em->flush();
return get_lang('NotAllowed');
}
// Timing-safe comparison.
if (!hash_equals($storedToken, $secret)) {
return get_lang('NotAllowed');
}
// Token is valid — change the password and clear the token.
$user['password'] = api_generate_password(); $user['password'] = api_generate_password();
UserManager::updatePassword($id, $user['password']); UserManager::updatePassword($id, $user['password']);
return self::send_password_to_user($user, $by_username); $dbUser->setConfirmationToken(null);
} $dbUser->setPasswordRequestedAt(null);
$em->persist($dbUser);
$em->flush();
return get_lang('NotAllowed'); return self::send_password_to_user($user, $by_username);
} }
/** /**
+4
View File
@@ -1097,6 +1097,10 @@ class MessageManager
$fileCopied = true; $fileCopied = true;
} }
} }
if ('image/svg+xml' === $type) {
sanitizeSvgFile($new_path);
}
} }
if ($fileCopied) { if ($fileCopied) {
+348 -9
View File
@@ -13,6 +13,7 @@ use Exception;
*/ */
abstract class ActivityExport abstract class ActivityExport
{ {
public const DOCS_MODULE_ID = 0;
protected $course; protected $course;
public function __construct($course) public function __construct($course)
@@ -27,15 +28,44 @@ abstract class ActivityExport
abstract public function export($activityId, $exportDir, $moduleId, $sectionId); abstract public function export($activityId, $exportDir, $moduleId, $sectionId);
/** /**
* Get the section ID for a given activity ID. * Get the section ID (learnpath source_id) for a given activity.
*/ */
public function getSectionIdForActivity(int $activityId, string $itemType): int public function getSectionIdForActivity(int $activityId, string $itemType): int
{ {
if (empty($this->course->resources[RESOURCE_LEARNPATH])) {
return 0;
}
foreach ($this->course->resources[RESOURCE_LEARNPATH] as $learnpath) { foreach ($this->course->resources[RESOURCE_LEARNPATH] as $learnpath) {
if (empty($learnpath->items)) {
continue;
}
foreach ($learnpath->items as $item) { foreach ($learnpath->items as $item) {
$item['item_type'] = $item['item_type'] === 'student_publication' ? 'work' : $item['item_type']; $normalizedType = $item['item_type'] === 'student_publication'
if ($item['item_type'] == $itemType && $item['path'] == $activityId) { ? 'work'
return $learnpath->source_id; : $item['item_type'];
if ($normalizedType !== $itemType) {
continue;
}
// Classic case: LP stores the numeric id in "path"
if (ctype_digit((string) $item['path']) && (int) $item['path'] === $activityId) {
return (int) $learnpath->source_id;
}
// Fallback for documents when LP stores the path instead of the id
if ($itemType === RESOURCE_DOCUMENT) {
$doc = \DocumentManager::get_document_data_by_id($activityId, $this->course->code);
if (!empty($doc['path'])) {
$p = (string) $doc['path'];
foreach ([$p, 'document/'.$p, '/'.$p] as $candidate) {
if ((string) $item['path'] === $candidate) {
return (int) $learnpath->source_id;
}
}
}
} }
} }
} }
@@ -116,27 +146,29 @@ abstract class ActivityExport
*/ */
protected function createInforefXml(array $references, string $directory): void protected function createInforefXml(array $references, string $directory): void
{ {
// Start the XML content
$xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL; $xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
$xmlContent .= '<inforef>'.PHP_EOL; $xmlContent .= '<inforef>'.PHP_EOL;
// Add user references if provided
if (isset($references['users']) && is_array($references['users'])) { if (isset($references['users']) && is_array($references['users'])) {
$xmlContent .= ' <userref>'.PHP_EOL; $xmlContent .= ' <userref>'.PHP_EOL;
foreach ($references['users'] as $userId) { foreach ($references['users'] as $userId) {
$xmlContent .= ' <user>'.PHP_EOL; $xmlContent .= ' <user>'.PHP_EOL;
$xmlContent .= ' <id>'.htmlspecialchars($userId).'</id>'.PHP_EOL; $xmlContent .= ' <id>'.htmlspecialchars((string) $userId).'</id>'.PHP_EOL;
$xmlContent .= ' </user>'.PHP_EOL; $xmlContent .= ' </user>'.PHP_EOL;
} }
$xmlContent .= ' </userref>'.PHP_EOL; $xmlContent .= ' </userref>'.PHP_EOL;
} }
// Add file references if provided
if (isset($references['files']) && is_array($references['files'])) { if (isset($references['files']) && is_array($references['files'])) {
$xmlContent .= ' <fileref>'.PHP_EOL; $xmlContent .= ' <fileref>'.PHP_EOL;
foreach ($references['files'] as $file) { foreach ($references['files'] as $file) {
$fileId = is_array($file) ? (int) ($file['id'] ?? 0) : (int) $file;
if ($fileId <= 0) {
continue;
}
$xmlContent .= ' <file>'.PHP_EOL; $xmlContent .= ' <file>'.PHP_EOL;
$xmlContent .= ' <id>'.htmlspecialchars($file['id']).'</id>'.PHP_EOL; $xmlContent .= ' <id>'.$fileId.'</id>'.PHP_EOL;
$xmlContent .= ' </file>'.PHP_EOL; $xmlContent .= ' </file>'.PHP_EOL;
} }
$xmlContent .= ' </fileref>'.PHP_EOL; $xmlContent .= ' </fileref>'.PHP_EOL;
@@ -252,4 +284,311 @@ abstract class ActivityExport
$this->createXmlFile('calendar', $xmlContent, $destinationDir); $this->createXmlFile('calendar', $xmlContent, $destinationDir);
} }
/**
* Creates a Moodle-safe activity name.
* - Strip HTML
* - Decode entities
* - Normalize whitespace
* - Truncate to a maximum length.
*/
protected function sanitizeMoodleActivityName(string $raw, int $maxLen = 255): string
{
$s = trim($raw);
if ($s === '') {
return '';
}
$s = html_entity_decode($s, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$s = strip_tags($s);
$s = preg_replace('/\s+/u', ' ', $s);
$s = trim($s);
if ($s === '') {
return '';
}
if (function_exists('mb_strlen') && function_exists('mb_substr')) {
if (mb_strlen($s, 'UTF-8') > $maxLen) {
$s = mb_substr($s, 0, $maxLen, 'UTF-8');
}
} else {
if (strlen($s) > $maxLen) {
$s = substr($s, 0, $maxLen);
}
}
return $s;
}
/**
* Returns the title of the item in the LP if it exists, otherwise the fallback.
*/
protected function lpItemTitle(int $sectionId, string $itemType, int $resourceId, ?string $fallback): string
{
if (!isset($this->course->resources[RESOURCE_LEARNPATH])) {
return $fallback ?? '';
}
foreach ($this->course->resources[RESOURCE_LEARNPATH] as $lp) {
if ((int) $lp->source_id !== $sectionId || empty($lp->items)) {
continue;
}
foreach ($lp->items as $it) {
$type = $it['item_type'] === 'student_publication' ? 'work' : $it['item_type'];
if ($type === $itemType && (int) $it['path'] === $resourceId) {
return $it['title'] ?? ($fallback ?? '');
}
}
}
return $fallback ?? '';
}
/**
* Extract embedded files from HTML content and normalize URLs to @@PLUGINFILE@@.
*
* Supported sources:
* - Course documents: /document/...
* - Platform assets: /main/default_course_document/... and /main/img/...
*
* @param callable $fileIdBuilder Receives the 1-based sequence and must return a unique file id.
*
* @return array{content:string, files:array<int,array<string,mixed>>}
*/
protected function extractEmbeddedFilesAndNormalizeContent(
string $html,
int $contextId,
string $component,
string $fileArea,
int $itemId,
callable $fileIdBuilder
): array {
if ($html === '') {
return [
'content' => '',
'files' => [],
];
}
$courseInfo = api_get_course_info($this->course->code);
$adminId = (int) (MoodleExport::getAdminUserData()['id'] ?? 1);
$fileExport = new FileExport($this->course);
$files = [];
$seenSources = [];
$sequence = 0;
$normalizedHtml = preg_replace_callback(
'#<img[^>]+src=["\'](?<url>[^"\']+)["\']#i',
function ($match) use (
$courseInfo,
$contextId,
$component,
$fileArea,
$itemId,
$fileIdBuilder,
$adminId,
$fileExport,
&$files,
&$seenSources,
&$sequence
) {
$src = (string) ($match['url'] ?? '');
$resolved = $this->resolveEmbeddedFileSource($src, $courseInfo);
if ($resolved === null) {
return $match[0];
}
$sourceKey = (string) $resolved['sourcekey'];
if (!isset($seenSources[$sourceKey])) {
$sequence++;
$fileId = (int) call_user_func($fileIdBuilder, $sequence);
$absolutePath = (string) $resolved['absolutepath'];
$filename = (string) $resolved['filename'];
$files[] = [
'id' => $fileId,
'contenthash' => is_file($absolutePath)
? sha1_file($absolutePath)
: hash('sha1', $filename),
'contextid' => $contextId,
'component' => $component,
'filearea' => $fileArea,
'itemid' => $itemId,
'filepath' => (string) $resolved['filepath'],
'documentpath' => (string) $resolved['documentpath'],
'absolutepath' => $absolutePath,
'filename' => $filename,
'userid' => $adminId,
'filesize' => is_file($absolutePath) ? (int) filesize($absolutePath) : 0,
'mimetype' => $fileExport->getMimeType($filename),
'status' => 0,
'timecreated' => time() - 3600,
'timemodified' => time(),
'source' => $filename,
'author' => 'Unknown',
'license' => 'allrightsreserved',
];
$seenSources[$sourceKey] = true;
}
return str_replace($src, '@@PLUGINFILE@@'.(string) $resolved['pluginfilepath'], $match[0]);
},
$html
);
return [
'content' => (string) $normalizedHtml,
'files' => $files,
];
}
/**
* Resolve an embeddable source URL into export metadata.
*
* @return array<string,string>|null
*/
protected function resolveEmbeddedFileSource(string $src, array $courseInfo): ?array
{
$urlPath = $this->extractUrlPath($src);
if (preg_match('#/document(?P<path>/[^"\']+)#', $urlPath, $m)) {
$documentRelativePath = (string) $m['path'];
$docId = \DocumentManager::get_document_id($courseInfo, $documentRelativePath);
if (empty($docId)) {
return null;
}
$document = \DocumentManager::get_document_data_by_id((int) $docId, $this->course->code);
if (empty($document)) {
return null;
}
$documentPath = (string) ($document['path'] ?? '');
if ($documentPath === '') {
return null;
}
return [
'sourcekey' => 'document:'.$docId,
'filepath' => $this->buildPluginFileDirectoryFromChamiloDocumentPath($documentPath),
'pluginfilepath' => $this->buildPluginFilePathFromChamiloDocumentPath($documentRelativePath),
'documentpath' => 'document'.$documentPath,
'absolutepath' => $this->buildChamiloDocumentAbsolutePath($documentPath),
'filename' => basename($documentPath),
];
}
if (preg_match('#(?P<path>/main/(?:default_course_document|img)/[^"\']+)#', $urlPath, $m)) {
$assetPath = (string) $m['path'];
return [
'sourcekey' => 'static:'.$assetPath,
'filepath' => $this->buildPluginFileDirectoryFromStaticAssetPath($assetPath),
'pluginfilepath' => $this->buildPluginFilePathFromStaticAssetPath($assetPath),
'documentpath' => '',
'absolutepath' => $this->buildStaticAssetAbsolutePath($assetPath),
'filename' => basename($assetPath),
];
}
return null;
}
/**
* Extract the path part from a raw URL.
*/
protected function extractUrlPath(string $src): string
{
$decoded = html_entity_decode($src, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$path = (string) parse_url($decoded, PHP_URL_PATH);
return $path !== '' ? $path : $decoded;
}
/**
* Build the absolute path for one Chamilo course document.
*/
protected function buildChamiloDocumentAbsolutePath(string $documentPath): string
{
$normalizedPath = (string) preg_replace('#^/?document/#', '', str_replace('\\', '/', trim($documentPath)));
$normalizedPath = '/'.ltrim($normalizedPath, '/');
return rtrim((string) $this->course->path, '/').'/document'.$normalizedPath;
}
/**
* Build the absolute path for one static platform asset.
*/
protected function buildStaticAssetAbsolutePath(string $assetPath): string
{
return rtrim(api_get_path(SYS_PATH), '/').'/'.ltrim($assetPath, '/');
}
/**
* Build the pluginfile directory path from a Chamilo document path.
*/
protected function buildPluginFileDirectoryFromChamiloDocumentPath(string $documentPath): string
{
$relative = $this->stripChamiloDocumentPrefix($documentPath);
$relative = ltrim(str_replace('\\', '/', $relative), '/');
$dir = dirname($relative);
if ($dir === '.' || $dir === '/') {
return '/';
}
return '/'.trim($dir, '/').'/';
}
/**
* Build the pluginfile full path used in HTML content.
*/
protected function buildPluginFilePathFromChamiloDocumentPath(string $documentPath): string
{
$relative = $this->stripChamiloDocumentPrefix($documentPath);
$relative = ltrim(str_replace('\\', '/', $relative), '/');
return '/'.$relative;
}
/**
* Build the pluginfile directory path for one static platform asset.
*/
protected function buildPluginFileDirectoryFromStaticAssetPath(string $assetPath): string
{
$relative = ltrim(str_replace('\\', '/', trim($assetPath)), '/');
$dir = dirname($relative);
if ($dir === '.' || $dir === '/') {
return '/Static/';
}
return '/Static/'.trim($dir, '/').'/';
}
/**
* Build the pluginfile full path used in HTML content for one static platform asset.
*/
protected function buildPluginFilePathFromStaticAssetPath(string $assetPath): string
{
$relative = ltrim(str_replace('\\', '/', trim($assetPath)), '/');
return '/Static/'.$relative;
}
/**
* Remove the internal Chamilo "document/" prefix if present.
*/
protected function stripChamiloDocumentPrefix(string $path): string
{
$path = str_replace('\\', '/', trim($path));
$path = preg_replace('#^/?document/#', '', $path);
return (string) $path;
}
} }
+22 -10
View File
@@ -55,8 +55,12 @@ class CourseExport
*/ */
private function createCourseXml(string $destinationDir): void private function createCourseXml(string $destinationDir): void
{ {
$courseId = $this->courseInfo['real_id'] ?? 0; $courseId = (int) ($this->courseInfo['real_id'] ?? 0);
$contextId = $this->courseInfo['real_id'] ?? 1; $contextId = (int) MoodleExport::getBackupCourseContextId();
if ($contextId <= 0) {
$contextId = 700000000 + max(1, $courseId);
}
$shortname = $this->courseInfo['code'] ?? 'Unknown Course'; $shortname = $this->courseInfo['code'] ?? 'Unknown Course';
$fullname = $this->courseInfo['title'] ?? 'Unknown Fullname'; $fullname = $this->courseInfo['title'] ?? 'Unknown Fullname';
$showgrades = $this->courseInfo['showgrades'] ?? 0; $showgrades = $this->courseInfo['showgrades'] ?? 0;
@@ -153,17 +157,26 @@ class CourseExport
$questionCategories = []; $questionCategories = [];
foreach ($this->activities as $activity) { foreach ($this->activities as $activity) {
if ($activity['modulename'] === 'quiz') { if (($activity['modulename'] ?? '') !== 'quiz') {
continue;
}
$quizExport = new QuizExport($this->course); $quizExport = new QuizExport($this->course);
$quizData = $quizExport->getData($activity['id'], $activity['sectionid']); $quizData = $quizExport->getData(
foreach ($quizData['questions'] as $question) { (int) ($activity['id'] ?? 0),
$categoryId = $question['questioncategoryid']; (int) ($activity['sectionid'] ?? 0),
if (!in_array($categoryId, $questionCategories, true)) { (int) ($activity['moduleid'] ?? 0)
);
if (empty($quizData)) {
continue;
}
$categoryId = (int) ($quizData['question_category_id'] ?? 0);
if ($categoryId > 0 && !in_array($categoryId, $questionCategories, true)) {
$questionCategories[] = $categoryId; $questionCategories[] = $categoryId;
} }
} }
}
}
if (!empty($questionCategories)) { if (!empty($questionCategories)) {
$xmlContent .= ' <question_categoryref>'.PHP_EOL; $xmlContent .= ' <question_categoryref>'.PHP_EOL;
@@ -175,7 +188,6 @@ class CourseExport
$xmlContent .= ' </question_categoryref>'.PHP_EOL; $xmlContent .= ' </question_categoryref>'.PHP_EOL;
} }
// Add role references
$xmlContent .= ' <roleref>'.PHP_EOL; $xmlContent .= ' <roleref>'.PHP_EOL;
$xmlContent .= ' <role>'.PHP_EOL; $xmlContent .= ' <role>'.PHP_EOL;
$xmlContent .= ' <id>5</id>'.PHP_EOL; $xmlContent .= ' <id>5</id>'.PHP_EOL;
+111 -93
View File
@@ -38,20 +38,17 @@ class FileExport
mkdir($filesDir, api_get_permissions_for_new_directories(), true); mkdir($filesDir, api_get_permissions_for_new_directories(), true);
} }
// Create placeholder index.html
$this->createPlaceholderFile($filesDir); $this->createPlaceholderFile($filesDir);
// Export each file
foreach ($filesData['files'] as $file) { foreach ($filesData['files'] as $file) {
$this->copyFileToExportDir($file, $filesDir); $this->copyFileToExportDir($file, $filesDir);
} }
// Create files.xml in the export directory
$this->createFilesXml($filesData, $exportDir); $this->createFilesXml($filesData, $exportDir);
} }
/** /**
* Get file data from course resources. This is for testing purposes. * Get file data from course resources.
*/ */
public function getFilesData(): array public function getFilesData(): array
{ {
@@ -99,6 +96,17 @@ class FileExport
return $filesData; return $filesData;
} }
/**
* Get MIME type based on the file extension.
*/
public function getMimeType($filePath): string
{
$extension = strtolower((string) pathinfo((string) $filePath, PATHINFO_EXTENSION));
$mimeTypes = $this->getMimeTypes();
return $mimeTypes[$extension] ?? 'application/octet-stream';
}
/** /**
* Create a placeholder index.html file to prevent an empty directory. * Create a placeholder index.html file to prevent an empty directory.
*/ */
@@ -113,27 +121,38 @@ class FileExport
*/ */
private function copyFileToExportDir(array $file, string $filesDir): void private function copyFileToExportDir(array $file, string $filesDir): void
{ {
if ($file['filepath'] === '.') { if (($file['filepath'] ?? '') === '.') {
return;
}
$contenthash = (string) ($file['contenthash'] ?? '');
if ($contenthash === '') {
return; return;
} }
$contenthash = $file['contenthash'];
$subDir = substr($contenthash, 0, 2); $subDir = substr($contenthash, 0, 2);
$filePath = $this->course->path.$file['documentpath'];
$exportSubDir = $filesDir.'/'.$subDir; $exportSubDir = $filesDir.'/'.$subDir;
// Ensure the subdirectory exists
if (!is_dir($exportSubDir)) { if (!is_dir($exportSubDir)) {
mkdir($exportSubDir, api_get_permissions_for_new_directories(), true); mkdir($exportSubDir, api_get_permissions_for_new_directories(), true);
} }
// Copy the file to the export directory
$destinationFile = $exportSubDir.'/'.$contenthash; $destinationFile = $exportSubDir.'/'.$contenthash;
if (file_exists($filePath)) {
copy($filePath, $destinationFile); $filePath = '';
if (!empty($file['absolutepath'])) {
$filePath = (string) $file['absolutepath'];
} else { } else {
throw new Exception("File {$filePath} not found."); $filePath = $this->course->path.($file['documentpath'] ?? '');
} }
if (is_file($filePath)) {
copy($filePath, $destinationFile);
return;
}
throw new Exception("File {$filePath} not found.");
} }
/** /**
@@ -152,28 +171,56 @@ class FileExport
file_put_contents($destinationDir.'/files.xml', $xmlContent); file_put_contents($destinationDir.'/files.xml', $xmlContent);
} }
/**
* Ensure mandatory file fields exist for Moodle restore.
*/
private function normalizeFileEntry(array $file): array
{
$adminData = MoodleExport::getAdminUserData();
$adminId = (int) ($adminData['id'] ?? 1);
if (!isset($file['itemid']) || $file['itemid'] === null || (is_string($file['itemid']) && trim($file['itemid']) === '')) {
$file['itemid'] = 0;
} else {
$file['itemid'] = (int) $file['itemid'];
}
if (!isset($file['userid']) || $file['userid'] === null || (is_string($file['userid']) && trim($file['userid']) === '')) {
$file['userid'] = $adminId;
} else {
$file['userid'] = (int) $file['userid'];
}
return $file;
}
/** /**
* Create an XML entry for a file. * Create an XML entry for a file.
*/ */
private function createFileXmlEntry(array $file): string private function createFileXmlEntry(array $file): string
{ {
$file = $this->normalizeFileEntry($file);
$itemId = (int) $file['itemid'];
$userId = (int) $file['userid'];
return ' <file id="'.$file['id'].'">'.PHP_EOL. return ' <file id="'.$file['id'].'">'.PHP_EOL.
' <contenthash>'.htmlspecialchars($file['contenthash']).'</contenthash>'.PHP_EOL. ' <contenthash>'.htmlspecialchars((string) $file['contenthash']).'</contenthash>'.PHP_EOL.
' <contextid>'.$file['contextid'].'</contextid>'.PHP_EOL. ' <contextid>'.$file['contextid'].'</contextid>'.PHP_EOL.
' <component>'.htmlspecialchars($file['component']).'</component>'.PHP_EOL. ' <component>'.htmlspecialchars((string) $file['component']).'</component>'.PHP_EOL.
' <filearea>'.htmlspecialchars($file['filearea']).'</filearea>'.PHP_EOL. ' <filearea>'.htmlspecialchars((string) $file['filearea']).'</filearea>'.PHP_EOL.
' <itemid>0</itemid>'.PHP_EOL. ' <itemid>'.$itemId.'</itemid>'.PHP_EOL.
' <filepath>'.htmlspecialchars($file['filepath']).'</filepath>'.PHP_EOL. ' <filepath>'.htmlspecialchars((string) $file['filepath']).'</filepath>'.PHP_EOL.
' <filename>'.htmlspecialchars($file['filename']).'</filename>'.PHP_EOL. ' <filename>'.htmlspecialchars((string) $file['filename']).'</filename>'.PHP_EOL.
' <userid>'.$file['userid'].'</userid>'.PHP_EOL. ' <userid>'.$userId.'</userid>'.PHP_EOL.
' <filesize>'.$file['filesize'].'</filesize>'.PHP_EOL. ' <filesize>'.$file['filesize'].'</filesize>'.PHP_EOL.
' <mimetype>'.htmlspecialchars($file['mimetype']).'</mimetype>'.PHP_EOL. ' <mimetype>'.htmlspecialchars((string) $file['mimetype']).'</mimetype>'.PHP_EOL.
' <status>'.$file['status'].'</status>'.PHP_EOL. ' <status>'.$file['status'].'</status>'.PHP_EOL.
' <timecreated>'.$file['timecreated'].'</timecreated>'.PHP_EOL. ' <timecreated>'.$file['timecreated'].'</timecreated>'.PHP_EOL.
' <timemodified>'.$file['timemodified'].'</timemodified>'.PHP_EOL. ' <timemodified>'.$file['timemodified'].'</timemodified>'.PHP_EOL.
' <source>'.htmlspecialchars($file['source']).'</source>'.PHP_EOL. ' <source>'.htmlspecialchars((string) $file['source']).'</source>'.PHP_EOL.
' <author>'.htmlspecialchars($file['author']).'</author>'.PHP_EOL. ' <author>'.htmlspecialchars((string) $file['author']).'</author>'.PHP_EOL.
' <license>'.htmlspecialchars($file['license']).'</license>'.PHP_EOL. ' <license>'.htmlspecialchars((string) $file['license']).'</license>'.PHP_EOL.
' <sortorder>0</sortorder>'.PHP_EOL. ' <sortorder>0</sortorder>'.PHP_EOL.
' <repositorytype>$@NULL@$</repositorytype>'.PHP_EOL. ' <repositorytype>$@NULL@$</repositorytype>'.PHP_EOL.
' <repositoryid>$@NULL@$</repositoryid>'.PHP_EOL. ' <repositoryid>$@NULL@$</repositoryid>'.PHP_EOL.
@@ -186,41 +233,48 @@ class FileExport
*/ */
private function processDocument(array $filesData, object $document): array private function processDocument(array $filesData, object $document): array
{ {
if ( if ($document->file_type !== 'file') {
$document->file_type === 'file' &&
isset($this->course->used_page_doc_ids) &&
in_array($document->source_id, $this->course->used_page_doc_ids)
) {
return $filesData; return $filesData;
} }
if (
$document->file_type === 'file' &&
pathinfo($document->path, PATHINFO_EXTENSION) === 'html' &&
substr_count($document->path, '/') === 1
) {
return $filesData;
}
if ($document->file_type === 'file') {
$extension = pathinfo($document->path, PATHINFO_EXTENSION);
if (!in_array(strtolower($extension), ['html', 'htm'])) {
$fileData = $this->getFileData($document); $fileData = $this->getFileData($document);
$fileData['filepath'] = '/Documents/'; $filepath = $this->buildMoodleFilepathFromChamiloPath((string) $document->path);
$fileData['contextid'] = 0;
$fileData['filepath'] = $filepath;
$fileData['contextid'] = ActivityExport::DOCS_MODULE_ID;
$fileData['component'] = 'mod_folder'; $fileData['component'] = 'mod_folder';
$fileData['filearea'] = 'content';
$fileData['itemid'] = ActivityExport::DOCS_MODULE_ID;
$filesData['files'][] = $fileData; $filesData['files'][] = $fileData;
}
} elseif ($document->file_type === 'folder') {
$folderFiles = \DocumentManager::getAllDocumentsByParentId($this->course->info, $document->source_id);
foreach ($folderFiles as $file) {
$filesData['files'][] = $this->getFolderFileData($file, (int) $document->source_id, '/Documents/'.dirname($file['path']).'/');
}
}
return $filesData; return $filesData;
} }
/**
* Build a Moodle filepath from a Chamilo document path.
*/
private function buildMoodleFilepathFromChamiloPath(string $documentPath): string
{
$normalizedPath = $this->stripChamiloDocumentPrefix($documentPath);
$normalizedPath = ltrim(str_replace('\\', '/', $normalizedPath), '/');
$relDir = dirname($normalizedPath);
return $this->ensureTrailingSlash($relDir === '.' ? '/' : '/'.$relDir.'/');
}
/**
* Remove the internal Chamilo document prefix from a path.
*/
private function stripChamiloDocumentPrefix(string $path): string
{
$path = str_replace('\\', '/', trim($path));
$path = preg_replace('#^/?document/#', '', $path);
return $path;
}
/** /**
* Get file data for a single document. * Get file data for a single document.
*/ */
@@ -253,40 +307,6 @@ class FileExport
]; ];
} }
/**
* Get file data for files inside a folder.
*/
private function getFolderFileData(array $file, int $sourceId, string $parentPath = '/Documents/'): array
{
$adminData = MoodleExport::getAdminUserData();
$adminId = $adminData['id'];
$contenthash = hash('sha1', basename($file['path']));
$mimetype = $this->getMimeType($file['path']);
$filename = basename($file['path']);
$filepath = $this->ensureTrailingSlash($parentPath);
return [
'id' => $file['id'],
'contenthash' => $contenthash,
'contextid' => $sourceId,
'component' => 'mod_folder',
'filearea' => 'content',
'itemid' => (int) $file['id'],
'filepath' => $filepath,
'documentpath' => 'document/'.$file['path'],
'filename' => $filename,
'userid' => $adminId,
'filesize' => $file['size'],
'mimetype' => $mimetype,
'status' => 0,
'timecreated' => time() - 3600,
'timemodified' => time(),
'source' => $file['title'],
'author' => 'Unknown',
'license' => 'allrightsreserved',
];
}
/** /**
* Ensure the directory path has a trailing slash. * Ensure the directory path has a trailing slash.
*/ */
@@ -301,17 +321,6 @@ class FileExport
return rtrim($path, '/').'/'; return rtrim($path, '/').'/';
} }
/**
* Get MIME type based on the file extension.
*/
public function getMimeType($filePath): string
{
$extension = pathinfo($filePath, PATHINFO_EXTENSION);
$mimeTypes = $this->getMimeTypes();
return $mimeTypes[$extension] ?? 'application/octet-stream';
}
/** /**
* Get an array of file extensions and their corresponding MIME types. * Get an array of file extensions and their corresponding MIME types.
*/ */
@@ -323,8 +332,17 @@ class FileExport
'jpeg' => 'image/jpeg', 'jpeg' => 'image/jpeg',
'png' => 'image/png', 'png' => 'image/png',
'gif' => 'image/gif', 'gif' => 'image/gif',
'svg' => 'image/svg+xml',
'webp' => 'image/webp',
'html' => 'text/html', 'html' => 'text/html',
'htm' => 'text/html',
'txt' => 'text/plain', 'txt' => 'text/plain',
'css' => 'text/css',
'js' => 'application/javascript',
'mp4' => 'video/mp4',
'webm' => 'video/webm',
'mp3' => 'audio/mpeg',
'ogg' => 'audio/ogg',
'doc' => 'application/msword', 'doc' => 'application/msword',
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'xls' => 'application/vnd.ms-excel', 'xls' => 'application/vnd.ms-excel',
+6 -13
View File
@@ -44,12 +44,12 @@ class FolderExport extends ActivityExport
*/ */
public function getData(int $folderId, int $sectionId): ?array public function getData(int $folderId, int $sectionId): ?array
{ {
if ($folderId === 0) { if ($folderId === 0 || $folderId === ActivityExport::DOCS_MODULE_ID) {
return [ return [
'id' => 0, 'id' => ActivityExport::DOCS_MODULE_ID,
'moduleid' => 0, 'moduleid' => ActivityExport::DOCS_MODULE_ID,
'modulename' => 'folder', 'modulename' => 'folder',
'contextid' => 0, 'contextid' => ActivityExport::DOCS_MODULE_ID,
'name' => 'Documents', 'name' => 'Documents',
'sectionid' => $sectionId, 'sectionid' => $sectionId,
'timemodified' => time(), 'timemodified' => time(),
@@ -98,17 +98,10 @@ class FolderExport extends ActivityExport
private function getFilesForFolder(int $folderId): array private function getFilesForFolder(int $folderId): array
{ {
$files = []; $files = [];
if ($folderId === 0) { if ($folderId === ActivityExport::DOCS_MODULE_ID) {
foreach ($this->course->resources[RESOURCE_DOCUMENT] as $doc) { foreach ($this->course->resources[RESOURCE_DOCUMENT] as $doc) {
if ($doc->file_type === 'file') { if ($doc->file_type === 'file') {
$files[] = [ $files[] = ['id' => (int) $doc->source_id];
'id' => (int) $doc->source_id,
'contenthash' => hash('sha1', basename($doc->path)),
'filename' => basename($doc->path),
'filepath' => '/Documents/',
'filesize' => (int) $doc->size,
'mimetype' => $this->getMimeType($doc->path),
];
} }
} }
} }
+133 -43
View File
@@ -11,6 +11,8 @@ namespace moodleexport;
*/ */
class ForumExport extends ActivityExport class ForumExport extends ActivityExport
{ {
private static int $embeddedFileGlobalSeq = 0;
/** /**
* Export all forum data into a single Moodle forum activity. * Export all forum data into a single Moodle forum activity.
* *
@@ -21,13 +23,18 @@ class ForumExport extends ActivityExport
*/ */
public function export($activityId, $exportDir, $moduleId, $sectionId): void public function export($activityId, $exportDir, $moduleId, $sectionId): void
{ {
// Prepare the directory where the forum export will be saved $effectiveModuleId = (int) $moduleId;
$forumDir = $this->prepareActivityDirectory($exportDir, 'forum', $moduleId); if ($effectiveModuleId <= 0) {
$effectiveModuleId = (int) $activityId;
}
// Retrieve forum data $forumDir = $this->prepareActivityDirectory($exportDir, 'forum', $effectiveModuleId);
$forumData = $this->getData($activityId, $sectionId); $forumData = $this->getData((int) $activityId, (int) $sectionId, $effectiveModuleId);
if (empty($forumData)) {
return;
}
// Generate XML files for the forum
$this->createForumXml($forumData, $forumDir); $this->createForumXml($forumData, $forumDir);
$this->createModuleXml($forumData, $forumDir); $this->createModuleXml($forumData, $forumDir);
$this->createGradesXml($forumData, $forumDir); $this->createGradesXml($forumData, $forumDir);
@@ -43,50 +50,123 @@ class ForumExport extends ActivityExport
/** /**
* Get all forum data from the course. * Get all forum data from the course.
*/ */
public function getData(int $forumId, int $sectionId): ?array public function getData(int $forumId, int $sectionId, ?int $moduleId = null): ?array
{ {
$forum = $this->course->resources['forum'][$forumId]->obj; if (empty($this->course->resources[RESOURCE_FORUM][$forumId])) {
return null;
}
$forum = $this->course->resources[RESOURCE_FORUM][$forumId]->obj;
$effectiveModuleId = (int) ($moduleId ?? $forumId);
if ($effectiveModuleId <= 0) {
$effectiveModuleId = $forumId;
}
$adminData = MoodleExport::getAdminUserData(); $adminData = MoodleExport::getAdminUserData();
$adminId = $adminData['id']; $adminId = (int) ($adminData['id'] ?? 1);
$name = (string) ($forum->forum_title ?? '');
if ($sectionId > 0) {
$name = $this->lpItemTitle($sectionId, RESOURCE_FORUM, $forumId, $name);
}
$name = $this->sanitizeMoodleActivityName($name, 255);
$descriptionResult = $this->extractEmbeddedFilesAndNormalizeContent(
(string) ($forum->forum_comment ?? ''),
$effectiveModuleId,
'mod_forum',
'intro',
0,
fn (int $sequence): int => $this->buildForumEmbeddedFileId()
);
$forumFiles = $descriptionResult['files'];
$threads = []; $threads = [];
foreach ($this->course->resources['thread'] as $threadId => $thread) {
if ($thread->obj->forum_id == $forumId) { $threadResources = $this->course->resources['thread'] ?? [];
// Get the posts for each thread $postResources = $this->course->resources['post'] ?? [];
$posts = [];
foreach ($this->course->resources['post'] as $postId => $post) { foreach ($threadResources as $threadId => $thread) {
if ($post->obj->thread_id == $threadId) { if ((int) ($thread->obj->forum_id ?? 0) !== $forumId) {
$posts[] = [ continue;
'id' => $post->obj->post_id, }
$threadPosts = [];
foreach ($postResources as $post) {
if ((int) ($post->obj->thread_id ?? 0) !== (int) $threadId) {
continue;
}
$postId = (int) ($post->obj->post_id ?? 0);
$postDate = strtotime((string) ($post->obj->post_date ?? 'now'));
if ($postDate === false) {
$postDate = time();
}
$messageResult = $this->extractEmbeddedFilesAndNormalizeContent(
(string) ($post->obj->post_text ?? ''),
$effectiveModuleId,
'mod_forum',
'post',
$postId,
fn (int $sequence): int => $this->buildForumEmbeddedFileId()
);
if (!empty($messageResult['files'])) {
$forumFiles = array_merge($forumFiles, $messageResult['files']);
}
$threadPosts[] = [
'id' => $postId,
'userid' => $adminId, 'userid' => $adminId,
'message' => $post->obj->post_text, 'message' => $messageResult['content'],
'created' => strtotime($post->obj->post_date), 'created' => $postDate,
'modified' => strtotime($post->obj->post_date), 'modified' => $postDate,
]; ];
} }
usort($threadPosts, static function (array $a, array $b): int {
return ((int) $a['created']) <=> ((int) $b['created']);
});
$firstPostId = 0;
foreach ($threadPosts as $index => &$postData) {
if ($index === 0) {
$firstPostId = (int) $postData['id'];
$postData['parent'] = 0;
} else {
$postData['parent'] = $firstPostId;
}
$postData['mailed'] = 0;
$postData['subject'] = (string) ($thread->obj->thread_title ?? get_lang('Forum'));
}
unset($postData);
$threadDate = strtotime((string) ($thread->obj->thread_date ?? 'now'));
if ($threadDate === false) {
$threadDate = time();
} }
$threads[] = [ $threads[] = [
'id' => $thread->obj->thread_id, 'id' => (int) ($thread->obj->thread_id ?? 0),
'title' => $thread->obj->thread_title, 'title' => (string) ($thread->obj->thread_title ?? ''),
'firstpost' => $firstPostId,
'userid' => $adminId, 'userid' => $adminId,
'timemodified' => strtotime($thread->obj->thread_date), 'timemodified' => $threadDate,
'usermodified' => $adminId, 'usermodified' => $adminId,
'posts' => $posts, 'posts' => $threadPosts,
]; ];
} }
}
$fileIds = [];
return [ return [
'id' => $forumId, 'id' => $forumId,
'moduleid' => $forumId, 'moduleid' => $effectiveModuleId,
'modulename' => 'forum', 'modulename' => 'forum',
'contextid' => $this->course->info['real_id'], 'contextid' => $effectiveModuleId,
'name' => $forum->forum_title, 'name' => $name,
'description' => $forum->forum_comment, 'description' => $descriptionResult['content'],
'timecreated' => time(), 'timecreated' => time(),
'timemodified' => time(), 'timemodified' => time(),
'sectionid' => $sectionId, 'sectionid' => $sectionId,
@@ -94,7 +174,7 @@ class ForumExport extends ActivityExport
'userid' => $adminId, 'userid' => $adminId,
'threads' => $threads, 'threads' => $threads,
'users' => [$adminId], 'users' => [$adminId],
'files' => $fileIds, 'files' => $forumFiles,
]; ];
} }
@@ -107,8 +187,8 @@ class ForumExport extends ActivityExport
$xmlContent .= '<activity id="'.$forumData['id'].'" moduleid="'.$forumData['moduleid'].'" modulename="'.$forumData['modulename'].'" contextid="'.$forumData['contextid'].'">'.PHP_EOL; $xmlContent .= '<activity id="'.$forumData['id'].'" moduleid="'.$forumData['moduleid'].'" modulename="'.$forumData['modulename'].'" contextid="'.$forumData['contextid'].'">'.PHP_EOL;
$xmlContent .= ' <forum id="'.$forumData['id'].'">'.PHP_EOL; $xmlContent .= ' <forum id="'.$forumData['id'].'">'.PHP_EOL;
$xmlContent .= ' <type>general</type>'.PHP_EOL; $xmlContent .= ' <type>general</type>'.PHP_EOL;
$xmlContent .= ' <name>'.htmlspecialchars($forumData['name']).'</name>'.PHP_EOL; $xmlContent .= ' <name>'.htmlspecialchars((string) $forumData['name']).'</name>'.PHP_EOL;
$xmlContent .= ' <intro>'.htmlspecialchars($forumData['description']).'</intro>'.PHP_EOL; $xmlContent .= ' <intro><![CDATA['.(string) $forumData['description'].']]></intro>'.PHP_EOL;
$xmlContent .= ' <introformat>1</introformat>'.PHP_EOL; $xmlContent .= ' <introformat>1</introformat>'.PHP_EOL;
$xmlContent .= ' <duedate>0</duedate>'.PHP_EOL; $xmlContent .= ' <duedate>0</duedate>'.PHP_EOL;
$xmlContent .= ' <cutoffdate>0</cutoffdate>'.PHP_EOL; $xmlContent .= ' <cutoffdate>0</cutoffdate>'.PHP_EOL;
@@ -132,13 +212,12 @@ class ForumExport extends ActivityExport
$xmlContent .= ' <displaywordcount>0</displaywordcount>'.PHP_EOL; $xmlContent .= ' <displaywordcount>0</displaywordcount>'.PHP_EOL;
$xmlContent .= ' <lockdiscussionafter>0</lockdiscussionafter>'.PHP_EOL; $xmlContent .= ' <lockdiscussionafter>0</lockdiscussionafter>'.PHP_EOL;
$xmlContent .= ' <grade_forum>0</grade_forum>'.PHP_EOL; $xmlContent .= ' <grade_forum>0</grade_forum>'.PHP_EOL;
// Add forum threads
$xmlContent .= ' <discussions>'.PHP_EOL; $xmlContent .= ' <discussions>'.PHP_EOL;
foreach ($forumData['threads'] as $thread) { foreach ($forumData['threads'] as $thread) {
$xmlContent .= ' <discussion id="'.$thread['id'].'">'.PHP_EOL; $xmlContent .= ' <discussion id="'.$thread['id'].'">'.PHP_EOL;
$xmlContent .= ' <name>'.htmlspecialchars($thread['title']).'</name>'.PHP_EOL; $xmlContent .= ' <name>'.htmlspecialchars((string) $thread['title']).'</name>'.PHP_EOL;
$xmlContent .= ' <firstpost>'.$thread['firstpost'].'</firstpost>'.PHP_EOL; $xmlContent .= ' <firstpost>'.(int) ($thread['firstpost'] ?? 0).'</firstpost>'.PHP_EOL;
$xmlContent .= ' <userid>'.$thread['userid'].'</userid>'.PHP_EOL; $xmlContent .= ' <userid>'.$thread['userid'].'</userid>'.PHP_EOL;
$xmlContent .= ' <groupid>-1</groupid>'.PHP_EOL; $xmlContent .= ' <groupid>-1</groupid>'.PHP_EOL;
$xmlContent .= ' <assessed>0</assessed>'.PHP_EOL; $xmlContent .= ' <assessed>0</assessed>'.PHP_EOL;
@@ -148,18 +227,17 @@ class ForumExport extends ActivityExport
$xmlContent .= ' <timeend>0</timeend>'.PHP_EOL; $xmlContent .= ' <timeend>0</timeend>'.PHP_EOL;
$xmlContent .= ' <pinned>0</pinned>'.PHP_EOL; $xmlContent .= ' <pinned>0</pinned>'.PHP_EOL;
$xmlContent .= ' <timelocked>0</timelocked>'.PHP_EOL; $xmlContent .= ' <timelocked>0</timelocked>'.PHP_EOL;
// Add forum posts to the thread
$xmlContent .= ' <posts>'.PHP_EOL; $xmlContent .= ' <posts>'.PHP_EOL;
foreach ($thread['posts'] as $post) { foreach ($thread['posts'] as $post) {
$xmlContent .= ' <post id="'.$post['id'].'">'.PHP_EOL; $xmlContent .= ' <post id="'.$post['id'].'">'.PHP_EOL;
$xmlContent .= ' <parent>'.$post['parent'].'</parent>'.PHP_EOL; $xmlContent .= ' <parent>'.(int) ($post['parent'] ?? 0).'</parent>'.PHP_EOL;
$xmlContent .= ' <userid>'.$post['userid'].'</userid>'.PHP_EOL; $xmlContent .= ' <userid>'.$post['userid'].'</userid>'.PHP_EOL;
$xmlContent .= ' <created>'.$post['created'].'</created>'.PHP_EOL; $xmlContent .= ' <created>'.$post['created'].'</created>'.PHP_EOL;
$xmlContent .= ' <modified>'.$post['modified'].'</modified>'.PHP_EOL; $xmlContent .= ' <modified>'.$post['modified'].'</modified>'.PHP_EOL;
$xmlContent .= ' <mailed>'.$post['mailed'].'</mailed>'.PHP_EOL; $xmlContent .= ' <mailed>'.(int) ($post['mailed'] ?? 0).'</mailed>'.PHP_EOL;
$xmlContent .= ' <subject>'.htmlspecialchars($post['subject']).'</subject>'.PHP_EOL; $xmlContent .= ' <subject>'.htmlspecialchars((string) ($post['subject'] ?? '')).'</subject>'.PHP_EOL;
$xmlContent .= ' <message>'.htmlspecialchars($post['message']).'</message>'.PHP_EOL; $xmlContent .= ' <message><![CDATA['.(string) ($post['message'] ?? '').']]></message>'.PHP_EOL;
$xmlContent .= ' <messageformat>1</messageformat>'.PHP_EOL; $xmlContent .= ' <messageformat>1</messageformat>'.PHP_EOL;
$xmlContent .= ' <messagetrust>0</messagetrust>'.PHP_EOL; $xmlContent .= ' <messagetrust>0</messagetrust>'.PHP_EOL;
$xmlContent .= ' <attachment></attachment>'.PHP_EOL; $xmlContent .= ' <attachment></attachment>'.PHP_EOL;
@@ -170,6 +248,7 @@ class ForumExport extends ActivityExport
$xmlContent .= ' </ratings>'.PHP_EOL; $xmlContent .= ' </ratings>'.PHP_EOL;
$xmlContent .= ' </post>'.PHP_EOL; $xmlContent .= ' </post>'.PHP_EOL;
} }
$xmlContent .= ' </posts>'.PHP_EOL; $xmlContent .= ' </posts>'.PHP_EOL;
$xmlContent .= ' <discussion_subs>'.PHP_EOL; $xmlContent .= ' <discussion_subs>'.PHP_EOL;
$xmlContent .= ' <discussion_sub id="'.$thread['id'].'">'.PHP_EOL; $xmlContent .= ' <discussion_sub id="'.$thread['id'].'">'.PHP_EOL;
@@ -179,10 +258,21 @@ class ForumExport extends ActivityExport
$xmlContent .= ' </discussion_subs>'.PHP_EOL; $xmlContent .= ' </discussion_subs>'.PHP_EOL;
$xmlContent .= ' </discussion>'.PHP_EOL; $xmlContent .= ' </discussion>'.PHP_EOL;
} }
$xmlContent .= ' </discussions>'.PHP_EOL; $xmlContent .= ' </discussions>'.PHP_EOL;
$xmlContent .= ' </forum>'.PHP_EOL; $xmlContent .= ' </forum>'.PHP_EOL;
$xmlContent .= '</activity>'; $xmlContent .= '</activity>';
$this->createXmlFile('forum', $xmlContent, $forumDir); $this->createXmlFile('forum', $xmlContent, $forumDir);
} }
/**
* Build a stable embedded file id for forum files.
*/
private function buildForumEmbeddedFileId(): int
{
self::$embeddedFileGlobalSeq++;
return 1400000000 + self::$embeddedFileGlobalSeq;
}
} }
+63 -19
View File
@@ -11,6 +11,8 @@ namespace moodleexport;
*/ */
class GlossaryExport extends ActivityExport class GlossaryExport extends ActivityExport
{ {
private static int $embeddedFileGlobalSeq = 0;
/** /**
* Export all glossary terms into a single Moodle glossary. * Export all glossary terms into a single Moodle glossary.
* *
@@ -21,13 +23,18 @@ class GlossaryExport extends ActivityExport
*/ */
public function export($activityId, $exportDir, $moduleId, $sectionId): void public function export($activityId, $exportDir, $moduleId, $sectionId): void
{ {
// Prepare the directory where the glossary export will be saved $effectiveModuleId = (int) $moduleId;
$glossaryDir = $this->prepareActivityDirectory($exportDir, 'glossary', $moduleId); if ($effectiveModuleId <= 0) {
$effectiveModuleId = (int) $activityId;
}
// Retrieve glossary data $glossaryDir = $this->prepareActivityDirectory($exportDir, 'glossary', $effectiveModuleId);
$glossaryData = $this->getData($activityId, $sectionId); $glossaryData = $this->getData((int) $activityId, (int) $sectionId, $effectiveModuleId);
if (empty($glossaryData)) {
return;
}
// Generate XML files for the glossary
$this->createGlossaryXml($glossaryData, $glossaryDir); $this->createGlossaryXml($glossaryData, $glossaryDir);
$this->createModuleXml($glossaryData, $glossaryDir); $this->createModuleXml($glossaryData, $glossaryDir);
$this->createGradesXml($glossaryData, $glossaryDir); $this->createGradesXml($glossaryData, $glossaryDir);
@@ -43,30 +50,57 @@ class GlossaryExport extends ActivityExport
/** /**
* Get all terms from the course and group them into a single glossary. * Get all terms from the course and group them into a single glossary.
*/ */
public function getData(int $glossaryId, int $sectionId): ?array public function getData(int $glossaryId, int $sectionId, ?int $moduleId = null): ?array
{ {
if (empty($this->course->resources['glossary'])) {
return null;
}
$adminData = MoodleExport::getAdminUserData(); $adminData = MoodleExport::getAdminUserData();
$adminId = $adminData['id']; $adminId = (int) ($adminData['id'] ?? 1);
$effectiveModuleId = (int) ($moduleId ?? $glossaryId);
if ($effectiveModuleId <= 0) {
$effectiveModuleId = $glossaryId;
}
$glossaryEntries = []; $glossaryEntries = [];
$glossaryFiles = [];
foreach ($this->course->resources['glossary'] as $glossary) { foreach ($this->course->resources['glossary'] as $glossary) {
$entryId = (int) ($glossary->glossary_id ?? 0);
$definitionResult = $this->extractEmbeddedFilesAndNormalizeContent(
(string) ($glossary->description ?? ''),
$effectiveModuleId,
'mod_glossary',
'entry',
$entryId,
fn (int $sequence): int => $this->buildGlossaryEmbeddedFileId()
);
if (!empty($definitionResult['files'])) {
$glossaryFiles = array_merge($glossaryFiles, $definitionResult['files']);
}
$glossaryEntries[] = [ $glossaryEntries[] = [
'id' => $glossary->glossary_id, 'id' => $entryId,
'userid' => $adminId, 'userid' => $adminId,
'concept' => $glossary->name, 'concept' => $this->sanitizeMoodleActivityName((string) ($glossary->name ?? ''), 255),
'definition' => $glossary->description, 'definition' => $definitionResult['content'],
'timecreated' => time(), 'timecreated' => time(),
'timemodified' => time(), 'timemodified' => time(),
]; ];
} }
// Return the glossary data with all terms included $glossaryName = $this->sanitizeMoodleActivityName((string) get_lang('Glossary'), 255);
return [ return [
'id' => $glossaryId, 'id' => $glossaryId,
'moduleid' => $glossaryId, 'moduleid' => $effectiveModuleId,
'modulename' => 'glossary', 'modulename' => 'glossary',
'contextid' => $this->course->info['real_id'], 'contextid' => $effectiveModuleId,
'name' => get_lang('Glossary'), 'name' => $glossaryName,
'description' => '', 'description' => '',
'timecreated' => time(), 'timecreated' => time(),
'timemodified' => time(), 'timemodified' => time(),
@@ -75,7 +109,7 @@ class GlossaryExport extends ActivityExport
'userid' => $adminId, 'userid' => $adminId,
'entries' => $glossaryEntries, 'entries' => $glossaryEntries,
'users' => [$adminId], 'users' => [$adminId],
'files' => [], 'files' => $glossaryFiles,
]; ];
} }
@@ -87,7 +121,7 @@ class GlossaryExport extends ActivityExport
$xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL; $xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
$xmlContent .= '<activity id="'.$glossaryData['id'].'" moduleid="'.$glossaryData['moduleid'].'" modulename="'.$glossaryData['modulename'].'" contextid="'.$glossaryData['contextid'].'">'.PHP_EOL; $xmlContent .= '<activity id="'.$glossaryData['id'].'" moduleid="'.$glossaryData['moduleid'].'" modulename="'.$glossaryData['modulename'].'" contextid="'.$glossaryData['contextid'].'">'.PHP_EOL;
$xmlContent .= ' <glossary id="'.$glossaryData['id'].'">'.PHP_EOL; $xmlContent .= ' <glossary id="'.$glossaryData['id'].'">'.PHP_EOL;
$xmlContent .= ' <name>'.htmlspecialchars($glossaryData['name']).'</name>'.PHP_EOL; $xmlContent .= ' <name>'.htmlspecialchars((string) $glossaryData['name']).'</name>'.PHP_EOL;
$xmlContent .= ' <intro></intro>'.PHP_EOL; $xmlContent .= ' <intro></intro>'.PHP_EOL;
$xmlContent .= ' <introformat>1</introformat>'.PHP_EOL; $xmlContent .= ' <introformat>1</introformat>'.PHP_EOL;
$xmlContent .= ' <allowduplicatedentries>0</allowduplicatedentries>'.PHP_EOL; $xmlContent .= ' <allowduplicatedentries>0</allowduplicatedentries>'.PHP_EOL;
@@ -114,12 +148,11 @@ class GlossaryExport extends ActivityExport
$xmlContent .= ' <completionentries>0</completionentries>'.PHP_EOL; $xmlContent .= ' <completionentries>0</completionentries>'.PHP_EOL;
$xmlContent .= ' <entries>'.PHP_EOL; $xmlContent .= ' <entries>'.PHP_EOL;
// Add glossary terms (entries)
foreach ($glossaryData['entries'] as $entry) { foreach ($glossaryData['entries'] as $entry) {
$xmlContent .= ' <entry id="'.$entry['id'].'">'.PHP_EOL; $xmlContent .= ' <entry id="'.$entry['id'].'">'.PHP_EOL;
$xmlContent .= ' <userid>'.$entry['userid'].'</userid>'.PHP_EOL; $xmlContent .= ' <userid>'.$entry['userid'].'</userid>'.PHP_EOL;
$xmlContent .= ' <concept>'.htmlspecialchars($entry['concept']).'</concept>'.PHP_EOL; $xmlContent .= ' <concept>'.htmlspecialchars((string) $entry['concept']).'</concept>'.PHP_EOL;
$xmlContent .= ' <definition><![CDATA['.$entry['definition'].']]></definition>'.PHP_EOL; $xmlContent .= ' <definition><![CDATA['.(string) $entry['definition'].']]></definition>'.PHP_EOL;
$xmlContent .= ' <definitionformat>1</definitionformat>'.PHP_EOL; $xmlContent .= ' <definitionformat>1</definitionformat>'.PHP_EOL;
$xmlContent .= ' <definitiontrust>0</definitiontrust>'.PHP_EOL; $xmlContent .= ' <definitiontrust>0</definitiontrust>'.PHP_EOL;
$xmlContent .= ' <attachment></attachment>'.PHP_EOL; $xmlContent .= ' <attachment></attachment>'.PHP_EOL;
@@ -135,6 +168,7 @@ class GlossaryExport extends ActivityExport
$xmlContent .= ' </ratings>'.PHP_EOL; $xmlContent .= ' </ratings>'.PHP_EOL;
$xmlContent .= ' </entry>'.PHP_EOL; $xmlContent .= ' </entry>'.PHP_EOL;
} }
$xmlContent .= ' </entries>'.PHP_EOL; $xmlContent .= ' </entries>'.PHP_EOL;
$xmlContent .= ' <entriestags></entriestags>'.PHP_EOL; $xmlContent .= ' <entriestags></entriestags>'.PHP_EOL;
$xmlContent .= ' <categories></categories>'.PHP_EOL; $xmlContent .= ' <categories></categories>'.PHP_EOL;
@@ -143,4 +177,14 @@ class GlossaryExport extends ActivityExport
$this->createXmlFile('glossary', $xmlContent, $glossaryDir); $this->createXmlFile('glossary', $xmlContent, $glossaryDir);
} }
/**
* Build a stable embedded file id for glossary files.
*/
private function buildGlossaryEmbeddedFileId(): int
{
self::$embeddedFileGlobalSeq++;
return 1500000000 + self::$embeddedFileGlobalSeq;
}
} }
File diff suppressed because it is too large Load Diff
+94 -88
View File
@@ -11,6 +11,9 @@ namespace moodleexport;
*/ */
class PageExport extends ActivityExport class PageExport extends ActivityExport
{ {
public const INTRO_PAGE_MODULE_ID = 910000000;
private const CONTENT_REVISION = 1;
/** /**
* Export a page to the specified directory. * Export a page to the specified directory.
* *
@@ -21,13 +24,21 @@ class PageExport extends ActivityExport
*/ */
public function export($activityId, $exportDir, $moduleId, $sectionId): void public function export($activityId, $exportDir, $moduleId, $sectionId): void
{ {
// Prepare the directory where the page export will be saved $effectiveModuleId = (int) $moduleId;
$pageDir = $this->prepareActivityDirectory($exportDir, 'page', $moduleId); if ($effectiveModuleId <= 0 && (int) $activityId === 0) {
$effectiveModuleId = self::INTRO_PAGE_MODULE_ID;
}
if ($effectiveModuleId <= 0) {
$effectiveModuleId = (int) $activityId;
}
// Retrieve page data $pageDir = $this->prepareActivityDirectory($exportDir, 'page', $effectiveModuleId);
$pageData = $this->getData($activityId, $sectionId);
$pageData = $this->getData((int) $activityId, (int) $sectionId, $effectiveModuleId);
if (empty($pageData)) {
return;
}
// Generate XML files
$this->createPageXml($pageData, $pageDir); $this->createPageXml($pageData, $pageDir);
$this->createModuleXml($pageData, $pageDir); $this->createModuleXml($pageData, $pageDir);
$this->createGradesXml($pageData, $pageDir); $this->createGradesXml($pageData, $pageDir);
@@ -42,92 +53,88 @@ class PageExport extends ActivityExport
/** /**
* Get page data dynamically from the course. * Get page data dynamically from the course.
*/ */
public function getData(int $pageId, int $sectionId): ?array public function getData(int $pageId, int $sectionId, ?int $moduleId = null): ?array
{ {
$contextid = $this->course->info['real_id'];
if ($pageId === 0) { if ($pageId === 0) {
$introText = trim($this->course->resources[RESOURCE_TOOL_INTRO]['course_homepage']->intro_text ?? ''); $introText = trim((string) ($this->course->resources[RESOURCE_TOOL_INTRO]['course_homepage']->intro_text ?? ''));
if ($introText === '') {
return null;
}
if (!empty($introText)) { $effectiveModuleId = (int) ($moduleId ?? self::INTRO_PAGE_MODULE_ID);
$files = []; if ($effectiveModuleId <= 0) {
$resources = \DocumentManager::get_resources_from_source_html($introText); $effectiveModuleId = self::INTRO_PAGE_MODULE_ID;
$courseInfo = api_get_course_info($this->course->code); }
$adminId = MoodleExport::getAdminUserData()['id'];
foreach ($resources as [$src]) { $introResult = $this->extractEmbeddedFilesAndNormalizeContent(
if (preg_match('#/document(/[^"\']+)#', $src, $matches)) { $introText,
$path = $matches[1]; $effectiveModuleId,
$docId = \DocumentManager::get_document_id($courseInfo, $path); 'mod_page',
if ($docId) { 'content',
$this->course->used_page_doc_ids[] = $docId; 0,
$document = \DocumentManager::get_document_data_by_id($docId, $this->course->code); fn (int $sequence): int => $this->buildPageFileId($effectiveModuleId, $effectiveModuleId, $sequence, true)
if ($document) { );
$contenthash = hash('sha1', basename($document['path']));
$mimetype = (new FileExport($this->course))->getMimeType($document['path']);
$files[] = [
'id' => $document['id'],
'contenthash' => $contenthash,
'contextid' => $contextid,
'component' => 'mod_page',
'filearea' => 'content',
'itemid' => 1,
'filepath' => '/Documents/',
'documentpath' => 'document' . $document['path'],
'filename' => basename($document['path']),
'userid' => $adminId,
'filesize' => $document['size'],
'mimetype' => $mimetype,
'status' => 0,
'timecreated' => time() - 3600,
'timemodified' => time(),
'source' => $document['title'],
'author' => 'Unknown',
'license' => 'allrightsreserved',
];
}
}
}
}
return [ return [
'id' => 0, 'id' => $effectiveModuleId,
'moduleid' => 0, 'moduleid' => $effectiveModuleId,
'modulename' => 'page', 'modulename' => 'page',
'contextid' => $contextid, 'contextid' => $effectiveModuleId,
'name' => get_lang('Introduction'), 'name' => $this->sanitizeMoodleActivityName((string) get_lang('Introduction'), 255),
'intro' => '', 'intro' => '',
'content' => $this->normalizeContent($introText), 'content' => $introResult['content'],
'sectionid' => $sectionId, 'sectionid' => $sectionId,
'sectionnumber' => 1, 'sectionnumber' => 1,
'display' => 0, 'display' => 5,
'timemodified' => time(), 'timemodified' => time(),
'users' => [], 'users' => [],
'files' => $files, 'files' => $introResult['files'],
]; ];
} }
}
$pageResources = $this->course->resources[RESOURCE_DOCUMENT] ?? []; $pageResources = $this->course->resources[RESOURCE_DOCUMENT] ?? [];
foreach ($pageResources as $page) { foreach ($pageResources as $page) {
if ($page->source_id == $pageId) { if ((int) $page->source_id !== $pageId) {
continue;
}
$effectiveModuleId = (int) ($moduleId ?? $page->source_id);
if ($effectiveModuleId <= 0) {
$effectiveModuleId = (int) $page->source_id;
}
$pageName = (string) ($page->title ?? '');
if ($sectionId > 0) {
$pageName = $this->lpItemTitle($sectionId, RESOURCE_DOCUMENT, $pageId, $pageName);
}
$pageName = $this->sanitizeMoodleActivityName($pageName, 255);
$rawContent = $this->getPageContent($page);
$pageResult = $this->extractEmbeddedFilesAndNormalizeContent(
$rawContent,
$effectiveModuleId,
'mod_page',
'content',
0,
fn (int $sequence): int => $this->buildPageFileId($effectiveModuleId, $effectiveModuleId, $sequence, false)
);
return [ return [
'id' => $page->source_id, 'id' => (int) $page->source_id,
'moduleid' => $page->source_id, 'moduleid' => $effectiveModuleId,
'modulename' => 'page', 'modulename' => 'page',
'contextid' => $contextid, 'contextid' => $effectiveModuleId,
'name' => $page->title, 'name' => $pageName,
'intro' => $page->comment ?? '', 'intro' => (string) ($page->comment ?? ''),
'content' => $this->normalizeContent($this->getPageContent($page)), 'content' => $pageResult['content'],
'sectionid' => $sectionId, 'sectionid' => $sectionId,
'sectionnumber' => 1, 'sectionnumber' => 1,
'display' => 0, 'display' => 5,
'timemodified' => time(), 'timemodified' => time(),
'users' => [], 'users' => [],
'files' => [], 'files' => $pageResult['files'],
]; ];
} }
}
return null; return null;
} }
@@ -140,15 +147,15 @@ class PageExport extends ActivityExport
$xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL; $xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
$xmlContent .= '<activity id="'.$pageData['id'].'" moduleid="'.$pageData['moduleid'].'" modulename="page" contextid="'.$pageData['contextid'].'">'.PHP_EOL; $xmlContent .= '<activity id="'.$pageData['id'].'" moduleid="'.$pageData['moduleid'].'" modulename="page" contextid="'.$pageData['contextid'].'">'.PHP_EOL;
$xmlContent .= ' <page id="'.$pageData['id'].'">'.PHP_EOL; $xmlContent .= ' <page id="'.$pageData['id'].'">'.PHP_EOL;
$xmlContent .= ' <name>'.htmlspecialchars($pageData['name']).'</name>'.PHP_EOL; $xmlContent .= ' <name>'.htmlspecialchars((string) $pageData['name']).'</name>'.PHP_EOL;
$xmlContent .= ' <intro>'.htmlspecialchars($pageData['intro']).'</intro>'.PHP_EOL; $xmlContent .= ' <intro><![CDATA['.(string) $pageData['intro'].']]></intro>'.PHP_EOL;
$xmlContent .= ' <introformat>1</introformat>'.PHP_EOL; $xmlContent .= ' <introformat>1</introformat>'.PHP_EOL;
$xmlContent .= ' <content>'.htmlspecialchars($pageData['content']).'</content>'.PHP_EOL; $xmlContent .= ' <content><![CDATA['.(string) $pageData['content'].']]></content>'.PHP_EOL;
$xmlContent .= ' <contentformat>1</contentformat>'.PHP_EOL; $xmlContent .= ' <contentformat>1</contentformat>'.PHP_EOL;
$xmlContent .= ' <legacyfiles>0</legacyfiles>'.PHP_EOL; $xmlContent .= ' <legacyfiles>0</legacyfiles>'.PHP_EOL;
$xmlContent .= ' <display>5</display>'.PHP_EOL; $xmlContent .= ' <display>'.(int) ($pageData['display'] ?? 5).'</display>'.PHP_EOL;
$xmlContent .= ' <displayoptions>a:3:{s:12:"printheading";s:1:"1";s:10:"printintro";s:1:"0";s:17:"printlastmodified";s:1:"1";}</displayoptions>'.PHP_EOL; $xmlContent .= ' <displayoptions>a:3:{s:12:"printheading";s:1:"1";s:10:"printintro";s:1:"0";s:17:"printlastmodified";s:1:"1";}</displayoptions>'.PHP_EOL;
$xmlContent .= ' <revision>1</revision>'.PHP_EOL; $xmlContent .= ' <revision>'.self::CONTENT_REVISION.'</revision>'.PHP_EOL;
$xmlContent .= ' <timemodified>'.$pageData['timemodified'].'</timemodified>'.PHP_EOL; $xmlContent .= ' <timemodified>'.$pageData['timemodified'].'</timemodified>'.PHP_EOL;
$xmlContent .= ' </page>'.PHP_EOL; $xmlContent .= ' </page>'.PHP_EOL;
$xmlContent .= '</activity>'; $xmlContent .= '</activity>';
@@ -156,22 +163,16 @@ class PageExport extends ActivityExport
$this->createXmlFile('page', $xmlContent, $pageDir); $this->createXmlFile('page', $xmlContent, $pageDir);
} }
private function normalizeContent(string $html): string /**
* Build a unique files.xml id for page embedded files.
*/
private function buildPageFileId(int $moduleId, int $contextId, int $sequence, bool $isIntro): int
{ {
return preg_replace_callback( if ($isIntro) {
'#<img[^>]+src=["\'](?<url>[^"\']+)["\']#i', return 1150000000 + max(0, $contextId) + max(1, $sequence);
function ($match) {
$src = $match['url'];
if (preg_match('#/courses/[^/]+/document/(.+)$#', $src, $parts)) {
$filename = basename($parts[1]);
return str_replace($src, '@@PLUGINFILE@@/Documents/' . $filename, $match[0]);
} }
return $match[0]; return 1100000000 + max(0, $moduleId) + max(1, $sequence);
},
$html
);
} }
/** /**
@@ -179,10 +180,15 @@ class PageExport extends ActivityExport
*/ */
private function getPageContent(object $page): string private function getPageContent(object $page): string
{ {
if ($page->file_type === 'file') { if (($page->file_type ?? '') !== 'file') {
return file_get_contents($this->course->path.$page->path);
}
return ''; return '';
} }
$absolutePath = $this->course->path.$page->path;
if (!is_file($absolutePath)) {
return '';
}
return (string) file_get_contents($absolutePath);
}
} }
+484 -138
View File
@@ -14,6 +14,8 @@ use FillBlanks;
*/ */
class QuizExport extends ActivityExport class QuizExport extends ActivityExport
{ {
private static $embeddedFileGlobalSeq = 0;
/** /**
* Export a quiz to the specified directory. * Export a quiz to the specified directory.
* *
@@ -24,13 +26,13 @@ class QuizExport extends ActivityExport
*/ */
public function export($activityId, $exportDir, $moduleId, $sectionId): void public function export($activityId, $exportDir, $moduleId, $sectionId): void
{ {
// Prepare the directory where the quiz export will be saved $quizDir = $this->prepareActivityDirectory($exportDir, 'quiz', (int) $moduleId);
$quizDir = $this->prepareActivityDirectory($exportDir, 'quiz', $moduleId); $quizData = $this->getData((int) $activityId, (int) $sectionId, (int) $moduleId);
// Retrieve quiz data if (empty($quizData)) {
$quizData = $this->getData($activityId, $sectionId); return;
}
// Generate XML files
$this->createQuizXml($quizData, $quizDir); $this->createQuizXml($quizData, $quizDir);
$this->createModuleXml($quizData, $quizDir); $this->createModuleXml($quizData, $quizDir);
$this->createGradesXml($quizData, $quizDir); $this->createGradesXml($quizData, $quizDir);
@@ -44,72 +46,189 @@ class QuizExport extends ActivityExport
$this->createCalendarXml($quizData, $quizDir); $this->createCalendarXml($quizData, $quizDir);
} }
/**
* Moodle requires a valid question behaviour.
*/
private function sanitizePreferredBehaviour($raw): string
{
$behaviour = trim((string) $raw);
$allowed = [
'deferredfeedback',
'adaptive',
'adaptivenopenalty',
'immediatefeedback',
'interactive',
'manualgraded',
'deferredcbm',
'immediatecbm',
'interactivecountback',
];
if ($behaviour === '' || !in_array($behaviour, $allowed, true)) {
return 'deferredfeedback';
}
return $behaviour;
}
/**
* Normalize an integer-like value.
*/
private function sanitizeInt($raw, int $default): int
{
if ($raw === null) {
return $default;
}
if (is_string($raw) && trim($raw) === '') {
return $default;
}
return is_numeric($raw) ? (int) $raw : $default;
}
/**
* Parse a float or return null when missing.
*/
private function parseNullableFloat($raw): ?float
{
if ($raw === null) {
return null;
}
if (is_string($raw) && trim($raw) === '') {
return null;
}
return is_numeric($raw) ? (float) $raw : null;
}
/**
* Moodle quiz navmethod accepted values.
*/
private function sanitizeNavMethod($raw): string
{
$v = trim((string) $raw);
return in_array($v, ['free', 'sequential'], true) ? $v : 'free';
}
/**
* Build a stable question category id per exported quiz occurrence.
*/
private function buildQuizQuestionCategoryId(int $moduleId): int
{
return 1000000000 + max(1, $moduleId);
}
/** /**
* Retrieves the quiz data. * Retrieves the quiz data.
*/ */
public function getData(int $quizId, int $sectionId): array public function getData(int $quizId, int $sectionId, ?int $moduleId = null): array
{ {
$quizResources = $this->course->resources[RESOURCE_QUIZ]; $quizResources = $this->course->resources[RESOURCE_QUIZ] ?? [];
foreach ($quizResources as $quiz) { foreach ($quizResources as $quiz) {
if ($quiz->obj->iid == -1) { if ($quiz->obj->iid == -1) {
continue; continue;
} }
if ($quiz->obj->iid == $quizId) { if ((int) $quiz->obj->iid !== $quizId) {
$contextid = $quiz->obj->c_id; continue;
}
$courseCode = $this->course->info['code'] ?? ($this->course->code ?? '');
$courseInfo = api_get_course_info($courseCode);
$courseRealId = (int) ($courseInfo['real_id'] ?? 0);
$contextid = $this->buildQuestionBankContextId($courseRealId);
$effectiveModuleId = (int) ($moduleId ?? ($quiz->obj->iid ?? 0));
if ($effectiveModuleId <= 0) {
$effectiveModuleId = (int) ($quiz->obj->iid ?? 0);
}
$questionCategoryId = $this->buildQuizQuestionCategoryId($effectiveModuleId);
$name = (string) ($quiz->obj->title ?? '');
if ($sectionId > 0) {
$name = $this->lpItemTitle($sectionId, RESOURCE_QUIZ, $quizId, $name);
}
$name = $this->sanitizeMoodleActivityName($name, 255);
$adminData = MoodleExport::getAdminUserData();
$adminId = (int) ($adminData['id'] ?? 1);
$quizFiles = [];
$questions = $this->getQuestionsForQuiz(
$quizId,
$questionCategoryId,
$contextid,
$courseInfo,
$adminId,
$quizFiles
);
$computedSumgrades = 0.0;
foreach ($questions as $q) {
$computedSumgrades += (float) ($q['maxmark'] ?? 0.0);
}
$rawSumgrades = $this->parseNullableFloat($quiz->obj->sumgrades ?? null);
$rawGrade = $this->parseNullableFloat($quiz->obj->grade ?? null);
$sumgrades = $rawSumgrades !== null ? $rawSumgrades : $computedSumgrades;
$grade = $rawGrade !== null ? $rawGrade : ($sumgrades > 0 ? $sumgrades : 0.0);
return [ return [
'id' => $quiz->obj->iid, 'id' => (int) ($quiz->obj->iid ?? 0),
'name' => $quiz->obj->title, 'name' => $name,
'intro' => $quiz->obj->description, 'intro' => (string) ($quiz->obj->description ?? ''),
'timeopen' => $quiz->obj->start_time ?? 0, 'timeopen' => $this->sanitizeInt($quiz->obj->start_time ?? null, 0),
'timeclose' => $quiz->obj->end_time ?? 0, 'timeclose' => $this->sanitizeInt($quiz->obj->end_time ?? null, 0),
'timelimit' => $quiz->obj->timelimit ?? 0, 'timelimit' => $this->sanitizeInt($quiz->obj->timelimit ?? null, 0),
'grademethod' => $quiz->obj->grademethod ?? 1, 'grademethod' => $this->sanitizeInt($quiz->obj->grademethod ?? null, 1),
'decimalpoints' => $quiz->obj->decimalpoints ?? 2, 'decimalpoints' => $this->sanitizeInt($quiz->obj->decimalpoints ?? null, 2),
'sumgrades' => $quiz->obj->sumgrades ?? 0, 'sumgrades' => $sumgrades,
'grade' => $quiz->obj->grade ?? 0, 'grade' => $grade,
'questionsperpage' => $quiz->obj->questionsperpage ?? 1, 'questionsperpage' => $this->sanitizeInt($quiz->obj->questionsperpage ?? null, 1),
'preferredbehaviour' => $quiz->obj->preferredbehaviour ?? 'deferredfeedback', 'preferredbehaviour' => $this->sanitizePreferredBehaviour($quiz->obj->preferredbehaviour ?? null),
'shuffleanswers' => $quiz->obj->shuffleanswers ?? 1, 'shuffleanswers' => $this->sanitizeInt($quiz->obj->shuffleanswers ?? null, 1),
'questions' => $this->getQuestionsForQuiz($quizId), 'navmethod' => $this->sanitizeNavMethod($quiz->obj->navmethod ?? null),
'question_category_id' => $questionCategoryId,
'questions' => $questions,
'files' => $quizFiles,
'feedbacks' => $this->getFeedbacksForQuiz($quizId), 'feedbacks' => $this->getFeedbacksForQuiz($quizId),
'sectionid' => $sectionId, 'sectionid' => $sectionId,
'moduleid' => $quiz->obj->iid ?? 0, 'moduleid' => $effectiveModuleId,
'modulename' => 'quiz', 'modulename' => 'quiz',
'contextid' => $contextid, 'contextid' => $contextid,
'overduehandling' => $quiz->obj->overduehandling ?? 'autosubmit', 'courseid' => $courseRealId,
'graceperiod' => $quiz->obj->graceperiod ?? 0, 'overduehandling' => (string) ($quiz->obj->overduehandling ?? 'autosubmit'),
'canredoquestions' => $quiz->obj->canredoquestions ?? 0, 'graceperiod' => $this->sanitizeInt($quiz->obj->graceperiod ?? null, 0),
'attempts_number' => $quiz->obj->attempts_number ?? 0, 'canredoquestions' => $this->sanitizeInt($quiz->obj->canredoquestions ?? null, 0),
'attemptonlast' => $quiz->obj->attemptonlast ?? 0, 'attempts_number' => $this->sanitizeInt($quiz->obj->attempts_number ?? null, 0),
'questiondecimalpoints' => $quiz->obj->questiondecimalpoints ?? 2, 'attemptonlast' => $this->sanitizeInt($quiz->obj->attemptonlast ?? null, 0),
'reviewattempt' => $quiz->obj->reviewattempt ?? 0, 'questiondecimalpoints' => $this->sanitizeInt($quiz->obj->questiondecimalpoints ?? null, 2),
'reviewcorrectness' => $quiz->obj->reviewcorrectness ?? 0, 'reviewattempt' => $this->sanitizeInt($quiz->obj->reviewattempt ?? null, 0),
'reviewmarks' => $quiz->obj->reviewmarks ?? 0, 'reviewcorrectness' => $this->sanitizeInt($quiz->obj->reviewcorrectness ?? null, 0),
'reviewspecificfeedback' => $quiz->obj->reviewspecificfeedback ?? 0, 'reviewmarks' => $this->sanitizeInt($quiz->obj->reviewmarks ?? null, 0),
'reviewgeneralfeedback' => $quiz->obj->reviewgeneralfeedback ?? 0, 'reviewspecificfeedback' => $this->sanitizeInt($quiz->obj->reviewspecificfeedback ?? null, 0),
'reviewrightanswer' => $quiz->obj->reviewrightanswer ?? 0, 'reviewgeneralfeedback' => $this->sanitizeInt($quiz->obj->reviewgeneralfeedback ?? null, 0),
'reviewoverallfeedback' => $quiz->obj->reviewoverallfeedback ?? 0, 'reviewrightanswer' => $this->sanitizeInt($quiz->obj->reviewrightanswer ?? null, 0),
'timecreated' => $quiz->obj->insert_date ?? time(), 'reviewoverallfeedback' => $this->sanitizeInt($quiz->obj->reviewoverallfeedback ?? null, 0),
'timemodified' => $quiz->obj->lastedit_date ?? time(), 'timecreated' => $this->sanitizeInt($quiz->obj->insert_date ?? null, time()),
'password' => $quiz->obj->password ?? '', 'timemodified' => $this->sanitizeInt($quiz->obj->lastedit_date ?? null, time()),
'subnet' => $quiz->obj->subnet ?? '', 'password' => (string) ($quiz->obj->password ?? ''),
'browsersecurity' => $quiz->obj->browsersecurity ?? '-', 'subnet' => (string) ($quiz->obj->subnet ?? ''),
'delay1' => $quiz->obj->delay1 ?? 0, 'browsersecurity' => (string) ($quiz->obj->browsersecurity ?? '-'),
'delay2' => $quiz->obj->delay2 ?? 0, 'delay1' => $this->sanitizeInt($quiz->obj->delay1 ?? null, 0),
'showuserpicture' => $quiz->obj->showuserpicture ?? 0, 'delay2' => $this->sanitizeInt($quiz->obj->delay2 ?? null, 0),
'showblocks' => $quiz->obj->showblocks ?? 0, 'showuserpicture' => $this->sanitizeInt($quiz->obj->showuserpicture ?? null, 0),
'completionattemptsexhausted' => $quiz->obj->completionattemptsexhausted ?? 0, 'showblocks' => $this->sanitizeInt($quiz->obj->showblocks ?? null, 0),
'completionpass' => $quiz->obj->completionpass ?? 0, 'completionattemptsexhausted' => $this->sanitizeInt($quiz->obj->completionattemptsexhausted ?? null, 0),
'completionminattempts' => $quiz->obj->completionminattempts ?? 0, 'completionpass' => $this->sanitizeInt($quiz->obj->completionpass ?? null, 0),
'allowofflineattempts' => $quiz->obj->allowofflineattempts ?? 0, 'completionminattempts' => $this->sanitizeInt($quiz->obj->completionminattempts ?? null, 0),
'allowofflineattempts' => $this->sanitizeInt($quiz->obj->allowofflineattempts ?? null, 0),
'users' => [], 'users' => [],
'files' => [],
]; ];
} }
}
return []; return [];
} }
@@ -119,16 +238,19 @@ class QuizExport extends ActivityExport
*/ */
public function exportQuestion(array $question): string public function exportQuestion(array $question): string
{ {
$questionText = (string) ($question['questiontext'] ?? 'No question text');
$questionName = $this->sanitizeQuestionName($questionText, 255);
$xmlContent = ' <question id="'.($question['id'] ?? '0').'">'.PHP_EOL; $xmlContent = ' <question id="'.($question['id'] ?? '0').'">'.PHP_EOL;
$xmlContent .= ' <parent>0</parent>'.PHP_EOL; $xmlContent .= ' <parent>0</parent>'.PHP_EOL;
$xmlContent .= ' <name>'.htmlspecialchars($question['questiontext'] ?? 'No question text').'</name>'.PHP_EOL; $xmlContent .= ' <name>'.htmlspecialchars($questionName).'</name>'.PHP_EOL;
$xmlContent .= ' <questiontext>'.htmlspecialchars($question['questiontext'] ?? 'No question text').'</questiontext>'.PHP_EOL; $xmlContent .= ' <questiontext>'.htmlspecialchars($questionText).'</questiontext>'.PHP_EOL;
$xmlContent .= ' <questiontextformat>1</questiontextformat>'.PHP_EOL; $xmlContent .= ' <questiontextformat>1</questiontextformat>'.PHP_EOL;
$xmlContent .= ' <generalfeedback></generalfeedback>'.PHP_EOL; $xmlContent .= ' <generalfeedback></generalfeedback>'.PHP_EOL;
$xmlContent .= ' <generalfeedbackformat>1</generalfeedbackformat>'.PHP_EOL; $xmlContent .= ' <generalfeedbackformat>1</generalfeedbackformat>'.PHP_EOL;
$xmlContent .= ' <defaultmark>'.($question['maxmark'] ?? '0').'</defaultmark>'.PHP_EOL; $xmlContent .= ' <defaultmark>'.($question['maxmark'] ?? '0').'</defaultmark>'.PHP_EOL;
$xmlContent .= ' <penalty>0.3333333</penalty>'.PHP_EOL; $xmlContent .= ' <penalty>0.3333333</penalty>'.PHP_EOL;
$xmlContent .= ' <qtype>'.htmlspecialchars(str_replace('_nosingle', '', $question['qtype']) ?? 'unknown').'</qtype>'.PHP_EOL; $xmlContent .= ' <qtype>'.htmlspecialchars((string) str_replace('_nosingle', '', $question['qtype'] ?? 'unknown')).'</qtype>'.PHP_EOL;
$xmlContent .= ' <length>1</length>'.PHP_EOL; $xmlContent .= ' <length>1</length>'.PHP_EOL;
$xmlContent .= ' <stamp>moodle+'.time().'+QUESTIONSTAMP</stamp>'.PHP_EOL; $xmlContent .= ' <stamp>moodle+'.time().'+QUESTIONSTAMP</stamp>'.PHP_EOL;
$xmlContent .= ' <version>moodle+'.time().'+VERSIONSTAMP</version>'.PHP_EOL; $xmlContent .= ' <version>moodle+'.time().'+VERSIONSTAMP</version>'.PHP_EOL;
@@ -138,7 +260,6 @@ class QuizExport extends ActivityExport
$xmlContent .= ' <createdby>2</createdby>'.PHP_EOL; $xmlContent .= ' <createdby>2</createdby>'.PHP_EOL;
$xmlContent .= ' <modifiedby>2</modifiedby>'.PHP_EOL; $xmlContent .= ' <modifiedby>2</modifiedby>'.PHP_EOL;
// Add question type-specific content
switch ($question['qtype']) { switch ($question['qtype']) {
case 'multichoice': case 'multichoice':
$xmlContent .= $this->exportMultichoiceQuestion($question); $xmlContent .= $this->exportMultichoiceQuestion($question);
@@ -165,25 +286,59 @@ class QuizExport extends ActivityExport
/** /**
* Retrieves the questions for a specific quiz. * Retrieves the questions for a specific quiz.
*/ */
private function getQuestionsForQuiz(int $quizId): array private function getQuestionsForQuiz(
{ int $quizId,
int $questionCategoryId,
int $contextId,
array $courseInfo,
int $adminId,
array &$quizFiles
): array {
$questions = []; $questions = [];
$quizResources = $this->course->resources[RESOURCE_QUIZQUESTION] ?? []; $quizResources = $this->course->resources[RESOURCE_QUIZQUESTION] ?? [];
foreach ($quizResources as $questionId => $questionData) { foreach ($quizResources as $questionId => $questionData) {
if (in_array($questionId, $this->course->resources[RESOURCE_QUIZ][$quizId]->obj->question_ids)) { if (!in_array($questionId, $this->course->resources[RESOURCE_QUIZ][$quizId]->obj->question_ids)) {
$categoryId = $questionData->question_category ?? 0; continue;
$categoryId = $categoryId > 0 ? $categoryId : $this->getDefaultCategoryId(); }
$qRes = $this->extractQuestionEmbeddedFilesAndNormalizeContent(
(string) ($questionData->question ?? ''),
$contextId,
'questiontext',
(int) $questionData->source_id,
'',
$courseInfo,
$adminId
);
if (!empty($qRes['files'])) {
$quizFiles = array_merge($quizFiles, $qRes['files']);
}
$answers = $this->getAnswersForQuestion(
(int) $questionData->source_id,
$contextId,
$courseInfo,
$adminId,
$quizFiles
);
$mappedType = $this->mapQuestionType((string) $questionData->quiz_type);
if ($mappedType === 'unknown') {
continue;
}
$questions[] = [ $questions[] = [
'id' => $questionData->source_id, 'id' => (int) $questionData->source_id,
'questiontext' => $questionData->question, 'questiontext' => $qRes['content'],
'qtype' => $this->mapQuestionType($questionData->quiz_type), 'qtype' => $mappedType,
'questioncategoryid' => $categoryId, 'questioncategoryid' => $questionCategoryId,
'answers' => $this->getAnswersForQuestion($questionData->source_id), 'answers' => $answers,
'maxmark' => $questionData->ponderation ?? 1, 'maxmark' => $questionData->ponderation ?? 1,
]; ];
} }
}
return $questions; return $questions;
} }
@@ -194,36 +349,69 @@ class QuizExport extends ActivityExport
private function mapQuestionType(string $quizType): string private function mapQuestionType(string $quizType): string
{ {
switch ($quizType) { switch ($quizType) {
case UNIQUE_ANSWER: return 'multichoice'; case UNIQUE_ANSWER:
case MULTIPLE_ANSWER: return 'multichoice_nosingle'; return 'multichoice';
case FILL_IN_BLANKS: return 'match'; case MULTIPLE_ANSWER:
case FREE_ANSWER: return 'shortanswer'; return 'multichoice_nosingle';
case CALCULATED_ANSWER: return 'calculated'; case FILL_IN_BLANKS:
case UPLOAD_ANSWER: return 'fileupload'; return 'match';
default: return 'unknown'; case FREE_ANSWER:
return 'shortanswer';
case CALCULATED_ANSWER:
return 'calculated';
case UPLOAD_ANSWER:
return 'fileupload';
default:
return 'unknown';
} }
} }
/** /**
* Retrieves the answers for a specific question ID. * Retrieves the answers for a specific question ID.
*/ */
private function getAnswersForQuestion(int $questionId): array private function getAnswersForQuestion(
{ int $questionId,
static $globalCounter = 0; int $contextId,
array $courseInfo,
int $adminId,
array &$collectedFiles
): array {
$answers = []; $answers = [];
$quizResources = $this->course->resources[RESOURCE_QUIZQUESTION] ?? []; $quizResources = $this->course->resources[RESOURCE_QUIZQUESTION] ?? [];
foreach ($quizResources as $questionData) { foreach ($quizResources as $questionData) {
if ($questionData->source_id == $questionId) { if ((int) $questionData->source_id !== $questionId) {
foreach ($questionData->answers as $answer) { continue;
$globalCounter++;
$answers[] = [
'id' => $questionId * 1000 + $globalCounter,
'text' => $answer['answer'],
'fraction' => $answer['correct'] == '1' ? 100 : 0,
'feedback' => $answer['comment'],
];
} }
$answerIndex = 0;
foreach ($questionData->answers as $answer) {
$answerIndex++;
$answerId = $this->buildStableQuestionAnswerId($questionId, $answerIndex);
$rawText = (string) ($answer['answer'] ?? '');
$res = $this->extractQuestionEmbeddedFilesAndNormalizeContent(
$rawText,
$contextId,
'answer',
$answerId,
'',
$courseInfo,
$adminId
);
if (!empty($res['files'])) {
$collectedFiles = array_merge($collectedFiles, $res['files']);
}
$answers[] = [
'id' => $answerId,
'text' => $res['content'],
'fraction' => (($answer['correct'] ?? '0') == '1') ? 100 : 0,
'feedback' => (string) ($answer['comment'] ?? ''),
];
} }
} }
@@ -239,9 +427,9 @@ class QuizExport extends ActivityExport
$quizResources = $this->course->resources[RESOURCE_QUIZ] ?? []; $quizResources = $this->course->resources[RESOURCE_QUIZ] ?? [];
foreach ($quizResources as $quiz) { foreach ($quizResources as $quiz) {
if ($quiz->obj->iid == $quizId) { if ((int) $quiz->obj->iid === $quizId) {
$feedbacks[] = [ $feedbacks[] = [
'feedbacktext' => $quiz->obj->description ?? '', 'feedbacktext' => (string) ($quiz->obj->description ?? ''),
'mingrade' => 0.00000, 'mingrade' => 0.00000,
'maxgrade' => $quiz->obj->grade ?? 10.00000, 'maxgrade' => $quiz->obj->grade ?? 10.00000,
]; ];
@@ -251,36 +439,42 @@ class QuizExport extends ActivityExport
return $feedbacks; return $feedbacks;
} }
private function getDefaultCategoryId(): int
{
return 1;
}
/** /**
* Creates the quiz.xml file. * Creates the quiz.xml file.
*/ */
private function createQuizXml(array $quizData, string $destinationDir): void private function createQuizXml(array $quizData, string $destinationDir): void
{ {
$preferredBehaviour = trim((string) ($quizData['preferredbehaviour'] ?? ''));
if ($preferredBehaviour === '') {
$preferredBehaviour = 'deferredfeedback';
}
$gradeMethod = (int) ($quizData['grademethod'] ?? 1);
$decimalPoints = (int) ($quizData['decimalpoints'] ?? 2);
$questionsPerPage = (int) ($quizData['questionsperpage'] ?? 1);
$navMethod = (string) ($quizData['navmethod'] ?? 'free');
$shuffleAnswers = (int) ($quizData['shuffleanswers'] ?? 1);
$sumGrades = (float) ($quizData['sumgrades'] ?? 0.0);
$grade = (float) ($quizData['grade'] ?? 0.0);
$xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL; $xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
$xmlContent .= '<activity id="'.$quizData['id'].'" moduleid="'.$quizData['moduleid'].'" modulename="quiz" contextid="'.$quizData['contextid'].'">'.PHP_EOL; $xmlContent .= '<activity id="'.$quizData['id'].'" moduleid="'.$quizData['moduleid'].'" modulename="quiz" contextid="'.$quizData['contextid'].'">'.PHP_EOL;
$xmlContent .= ' <quiz id="'.$quizData['id'].'">'.PHP_EOL; $xmlContent .= ' <quiz id="'.$quizData['id'].'">'.PHP_EOL;
$xmlContent .= ' <name>'.htmlspecialchars($quizData['name']).'</name>'.PHP_EOL; $xmlContent .= ' <name>'.htmlspecialchars((string) $quizData['name']).'</name>'.PHP_EOL;
$xmlContent .= ' <intro>'.htmlspecialchars($quizData['intro']).'</intro>'.PHP_EOL; $xmlContent .= ' <intro>'.htmlspecialchars((string) $quizData['intro']).'</intro>'.PHP_EOL;
$xmlContent .= ' <introformat>1</introformat>'.PHP_EOL; $xmlContent .= ' <introformat>1</introformat>'.PHP_EOL;
$xmlContent .= ' <timeopen>'.($quizData['timeopen'] ?? 0).'</timeopen>'.PHP_EOL; $xmlContent .= ' <timeopen>'.($quizData['timeopen'] ?? 0).'</timeopen>'.PHP_EOL;
$xmlContent .= ' <timeclose>'.($quizData['timeclose'] ?? 0).'</timeclose>'.PHP_EOL; $xmlContent .= ' <timeclose>'.($quizData['timeclose'] ?? 0).'</timeclose>'.PHP_EOL;
$xmlContent .= ' <timelimit>'.($quizData['timelimit'] ?? 0).'</timelimit>'.PHP_EOL; $xmlContent .= ' <timelimit>'.($quizData['timelimit'] ?? 0).'</timelimit>'.PHP_EOL;
$xmlContent .= ' <overduehandling>'.($quizData['overduehandling'] ?? 'autosubmit').'</overduehandling>'.PHP_EOL; $xmlContent .= ' <overduehandling>'.($quizData['overduehandling'] ?? 'autosubmit').'</overduehandling>'.PHP_EOL;
$xmlContent .= ' <graceperiod>'.($quizData['graceperiod'] ?? 0).'</graceperiod>'.PHP_EOL; $xmlContent .= ' <graceperiod>'.($quizData['graceperiod'] ?? 0).'</graceperiod>'.PHP_EOL;
$xmlContent .= ' <preferredbehaviour>'.htmlspecialchars($quizData['preferredbehaviour']).'</preferredbehaviour>'.PHP_EOL; $xmlContent .= ' <preferredbehaviour>'.htmlspecialchars($preferredBehaviour).'</preferredbehaviour>'.PHP_EOL;
$xmlContent .= ' <canredoquestions>'.($quizData['canredoquestions'] ?? 0).'</canredoquestions>'.PHP_EOL; $xmlContent .= ' <canredoquestions>'.($quizData['canredoquestions'] ?? 0).'</canredoquestions>'.PHP_EOL;
$xmlContent .= ' <attempts_number>'.($quizData['attempts_number'] ?? 0).'</attempts_number>'.PHP_EOL; $xmlContent .= ' <attempts_number>'.($quizData['attempts_number'] ?? 0).'</attempts_number>'.PHP_EOL;
$xmlContent .= ' <attemptonlast>'.($quizData['attemptonlast'] ?? 0).'</attemptonlast>'.PHP_EOL; $xmlContent .= ' <attemptonlast>'.($quizData['attemptonlast'] ?? 0).'</attemptonlast>'.PHP_EOL;
$xmlContent .= ' <grademethod>'.$quizData['grademethod'].'</grademethod>'.PHP_EOL; $xmlContent .= ' <grademethod>'.$gradeMethod.'</grademethod>'.PHP_EOL;
$xmlContent .= ' <decimalpoints>'.$quizData['decimalpoints'].'</decimalpoints>'.PHP_EOL; $xmlContent .= ' <decimalpoints>'.$decimalPoints.'</decimalpoints>'.PHP_EOL;
$xmlContent .= ' <questiondecimalpoints>'.($quizData['questiondecimalpoints'] ?? -1).'</questiondecimalpoints>'.PHP_EOL; $xmlContent .= ' <questiondecimalpoints>'.($quizData['questiondecimalpoints'] ?? -1).'</questiondecimalpoints>'.PHP_EOL;
// Review options
$xmlContent .= ' <reviewattempt>'.($quizData['reviewattempt'] ?? 69888).'</reviewattempt>'.PHP_EOL; $xmlContent .= ' <reviewattempt>'.($quizData['reviewattempt'] ?? 69888).'</reviewattempt>'.PHP_EOL;
$xmlContent .= ' <reviewcorrectness>'.($quizData['reviewcorrectness'] ?? 4352).'</reviewcorrectness>'.PHP_EOL; $xmlContent .= ' <reviewcorrectness>'.($quizData['reviewcorrectness'] ?? 4352).'</reviewcorrectness>'.PHP_EOL;
$xmlContent .= ' <reviewmarks>'.($quizData['reviewmarks'] ?? 4352).'</reviewmarks>'.PHP_EOL; $xmlContent .= ' <reviewmarks>'.($quizData['reviewmarks'] ?? 4352).'</reviewmarks>'.PHP_EOL;
@@ -288,36 +482,26 @@ class QuizExport extends ActivityExport
$xmlContent .= ' <reviewgeneralfeedback>'.($quizData['reviewgeneralfeedback'] ?? 4352).'</reviewgeneralfeedback>'.PHP_EOL; $xmlContent .= ' <reviewgeneralfeedback>'.($quizData['reviewgeneralfeedback'] ?? 4352).'</reviewgeneralfeedback>'.PHP_EOL;
$xmlContent .= ' <reviewrightanswer>'.($quizData['reviewrightanswer'] ?? 4352).'</reviewrightanswer>'.PHP_EOL; $xmlContent .= ' <reviewrightanswer>'.($quizData['reviewrightanswer'] ?? 4352).'</reviewrightanswer>'.PHP_EOL;
$xmlContent .= ' <reviewoverallfeedback>'.($quizData['reviewoverallfeedback'] ?? 4352).'</reviewoverallfeedback>'.PHP_EOL; $xmlContent .= ' <reviewoverallfeedback>'.($quizData['reviewoverallfeedback'] ?? 4352).'</reviewoverallfeedback>'.PHP_EOL;
$xmlContent .= ' <questionsperpage>'.$questionsPerPage.'</questionsperpage>'.PHP_EOL;
// Navigation and presentation settings $xmlContent .= ' <navmethod>'.htmlspecialchars($navMethod).'</navmethod>'.PHP_EOL;
$xmlContent .= ' <questionsperpage>'.$quizData['questionsperpage'].'</questionsperpage>'.PHP_EOL; $xmlContent .= ' <shuffleanswers>'.$shuffleAnswers.'</shuffleanswers>'.PHP_EOL;
$xmlContent .= ' <navmethod>'.htmlspecialchars($quizData['navmethod']).'</navmethod>'.PHP_EOL; $xmlContent .= ' <sumgrades>'.$sumGrades.'</sumgrades>'.PHP_EOL;
$xmlContent .= ' <shuffleanswers>'.$quizData['shuffleanswers'].'</shuffleanswers>'.PHP_EOL; $xmlContent .= ' <grade>'.$grade.'</grade>'.PHP_EOL;
$xmlContent .= ' <sumgrades>'.$quizData['sumgrades'].'</sumgrades>'.PHP_EOL;
$xmlContent .= ' <grade>'.$quizData['grade'].'</grade>'.PHP_EOL;
// Timing and security
$xmlContent .= ' <timecreated>'.($quizData['timecreated'] ?? time()).'</timecreated>'.PHP_EOL; $xmlContent .= ' <timecreated>'.($quizData['timecreated'] ?? time()).'</timecreated>'.PHP_EOL;
$xmlContent .= ' <timemodified>'.($quizData['timemodified'] ?? time()).'</timemodified>'.PHP_EOL; $xmlContent .= ' <timemodified>'.($quizData['timemodified'] ?? time()).'</timemodified>'.PHP_EOL;
$xmlContent .= ' <password>'.(isset($quizData['password']) ? htmlspecialchars($quizData['password']) : '').'</password>'.PHP_EOL; $xmlContent .= ' <password>'.(isset($quizData['password']) ? htmlspecialchars((string) $quizData['password']) : '').'</password>'.PHP_EOL;
$xmlContent .= ' <subnet>'.(isset($quizData['subnet']) ? htmlspecialchars($quizData['subnet']) : '').'</subnet>'.PHP_EOL; $xmlContent .= ' <subnet>'.(isset($quizData['subnet']) ? htmlspecialchars((string) $quizData['subnet']) : '').'</subnet>'.PHP_EOL;
$xmlContent .= ' <browsersecurity>'.(isset($quizData['browsersecurity']) ? htmlspecialchars($quizData['browsersecurity']) : '-').'</browsersecurity>'.PHP_EOL; $xmlContent .= ' <browsersecurity>'.(isset($quizData['browsersecurity']) ? htmlspecialchars((string) $quizData['browsersecurity']) : '-').'</browsersecurity>'.PHP_EOL;
$xmlContent .= ' <delay1>'.($quizData['delay1'] ?? 0).'</delay1>'.PHP_EOL; $xmlContent .= ' <delay1>'.($quizData['delay1'] ?? 0).'</delay1>'.PHP_EOL;
$xmlContent .= ' <delay2>'.($quizData['delay2'] ?? 0).'</delay2>'.PHP_EOL; $xmlContent .= ' <delay2>'.($quizData['delay2'] ?? 0).'</delay2>'.PHP_EOL;
// Additional options
$xmlContent .= ' <showuserpicture>'.($quizData['showuserpicture'] ?? 0).'</showuserpicture>'.PHP_EOL; $xmlContent .= ' <showuserpicture>'.($quizData['showuserpicture'] ?? 0).'</showuserpicture>'.PHP_EOL;
$xmlContent .= ' <showblocks>'.($quizData['showblocks'] ?? 0).'</showblocks>'.PHP_EOL; $xmlContent .= ' <showblocks>'.($quizData['showblocks'] ?? 0).'</showblocks>'.PHP_EOL;
$xmlContent .= ' <completionattemptsexhausted>'.($quizData['completionattemptsexhausted'] ?? 0).'</completionattemptsexhausted>'.PHP_EOL; $xmlContent .= ' <completionattemptsexhausted>'.($quizData['completionattemptsexhausted'] ?? 0).'</completionattemptsexhausted>'.PHP_EOL;
$xmlContent .= ' <completionpass>'.($quizData['completionpass'] ?? 0).'</completionpass>'.PHP_EOL; $xmlContent .= ' <completionpass>'.($quizData['completionpass'] ?? 0).'</completionpass>'.PHP_EOL;
$xmlContent .= ' <completionminattempts>'.($quizData['completionminattempts'] ?? 0).'</completionminattempts>'.PHP_EOL; $xmlContent .= ' <completionminattempts>'.($quizData['completionminattempts'] ?? 0).'</completionminattempts>'.PHP_EOL;
$xmlContent .= ' <allowofflineattempts>'.($quizData['allowofflineattempts'] ?? 0).'</allowofflineattempts>'.PHP_EOL; $xmlContent .= ' <allowofflineattempts>'.($quizData['allowofflineattempts'] ?? 0).'</allowofflineattempts>'.PHP_EOL;
// Subplugin, if applicable
$xmlContent .= ' <subplugin_quizaccess_seb_quiz>'.PHP_EOL; $xmlContent .= ' <subplugin_quizaccess_seb_quiz>'.PHP_EOL;
$xmlContent .= ' </subplugin_quizaccess_seb_quiz>'.PHP_EOL; $xmlContent .= ' </subplugin_quizaccess_seb_quiz>'.PHP_EOL;
// Add question instances
$xmlContent .= ' <question_instances>'.PHP_EOL; $xmlContent .= ' <question_instances>'.PHP_EOL;
$slotIndex = 1; $slotIndex = 1;
foreach ($quizData['questions'] as $question) { foreach ($quizData['questions'] as $question) {
@@ -333,37 +517,28 @@ class QuizExport extends ActivityExport
$slotIndex++; $slotIndex++;
} }
$xmlContent .= ' </question_instances>'.PHP_EOL; $xmlContent .= ' </question_instances>'.PHP_EOL;
// Quiz sections
$xmlContent .= ' <sections>'.PHP_EOL; $xmlContent .= ' <sections>'.PHP_EOL;
$xmlContent .= ' <section id="'.$quizData['id'].'">'.PHP_EOL; $xmlContent .= ' <section id="'.$quizData['id'].'">'.PHP_EOL;
$xmlContent .= ' <firstslot>1</firstslot>'.PHP_EOL; $xmlContent .= ' <firstslot>1</firstslot>'.PHP_EOL;
$xmlContent .= ' <shufflequestions>0</shufflequestions>'.PHP_EOL; $xmlContent .= ' <shufflequestions>0</shufflequestions>'.PHP_EOL;
$xmlContent .= ' </section>'.PHP_EOL; $xmlContent .= ' </section>'.PHP_EOL;
$xmlContent .= ' </sections>'.PHP_EOL; $xmlContent .= ' </sections>'.PHP_EOL;
// Add feedbacks
$xmlContent .= ' <feedbacks>'.PHP_EOL; $xmlContent .= ' <feedbacks>'.PHP_EOL;
foreach ($quizData['feedbacks'] as $feedback) { foreach ($quizData['feedbacks'] as $feedback) {
$xmlContent .= ' <feedback id="'.$quizData['id'].'">'.PHP_EOL; $xmlContent .= ' <feedback id="'.$quizData['id'].'">'.PHP_EOL;
$xmlContent .= ' <feedbacktext>'.htmlspecialchars($feedback['feedbacktext']).'</feedbacktext>'.PHP_EOL; $xmlContent .= ' <feedbacktext>'.htmlspecialchars((string) $feedback['feedbacktext']).'</feedbacktext>'.PHP_EOL;
$xmlContent .= ' <feedbacktextformat>1</feedbacktextformat>'.PHP_EOL; $xmlContent .= ' <feedbacktextformat>1</feedbacktextformat>'.PHP_EOL;
$xmlContent .= ' <mingrade>'.$feedback['mingrade'].'</mingrade>'.PHP_EOL; $xmlContent .= ' <mingrade>'.$feedback['mingrade'].'</mingrade>'.PHP_EOL;
$xmlContent .= ' <maxgrade>'.$feedback['maxgrade'].'</maxgrade>'.PHP_EOL; $xmlContent .= ' <maxgrade>'.$feedback['maxgrade'].'</maxgrade>'.PHP_EOL;
$xmlContent .= ' </feedback>'.PHP_EOL; $xmlContent .= ' </feedback>'.PHP_EOL;
} }
$xmlContent .= ' </feedbacks>'.PHP_EOL; $xmlContent .= ' </feedbacks>'.PHP_EOL;
// Complete with placeholders for attempts and grades
$xmlContent .= ' <overrides>'.PHP_EOL.' </overrides>'.PHP_EOL; $xmlContent .= ' <overrides>'.PHP_EOL.' </overrides>'.PHP_EOL;
$xmlContent .= ' <grades>'.PHP_EOL.' </grades>'.PHP_EOL; $xmlContent .= ' <grades>'.PHP_EOL.' </grades>'.PHP_EOL;
$xmlContent .= ' <attempts>'.PHP_EOL.' </attempts>'.PHP_EOL; $xmlContent .= ' <attempts>'.PHP_EOL.' </attempts>'.PHP_EOL;
// Close the activity tag
$xmlContent .= ' </quiz>'.PHP_EOL; $xmlContent .= ' </quiz>'.PHP_EOL;
$xmlContent .= '</activity>'.PHP_EOL; $xmlContent .= '</activity>'.PHP_EOL;
// Save the XML file
$xmlFile = $destinationDir.'/quiz.xml'; $xmlFile = $destinationDir.'/quiz.xml';
if (file_put_contents($xmlFile, $xmlContent) === false) { if (file_put_contents($xmlFile, $xmlContent) === false) {
throw new Exception(get_lang('ErrorCreatingQuizXml')); throw new Exception(get_lang('ErrorCreatingQuizXml'));
@@ -404,10 +579,7 @@ class QuizExport extends ActivityExport
*/ */
private function exportMultichoiceNosingleQuestion(array $question): string private function exportMultichoiceNosingleQuestion(array $question): string
{ {
// Similar structure to exportMultichoiceQuestion, but with single=0 return str_replace('<single>1</single>', '<single>0</single>', $this->exportMultichoiceQuestion($question));
$xmlContent = str_replace('<single>1</single>', '<single>0</single>', $this->exportMultichoiceQuestion($question));
return $xmlContent;
} }
/** /**
@@ -457,13 +629,13 @@ class QuizExport extends ActivityExport
private function exportMatchQuestion(array $question): string private function exportMatchQuestion(array $question): string
{ {
$xmlContent = ' <plugin_qtype_match_question>'.PHP_EOL; $xmlContent = ' <plugin_qtype_match_question>'.PHP_EOL;
$xmlContent .= ' <matchoptions id="'.htmlspecialchars($question['id'] ?? '0').'">'.PHP_EOL; $xmlContent .= ' <matchoptions id="'.htmlspecialchars((string) ($question['id'] ?? '0')).'">'.PHP_EOL;
$xmlContent .= ' <shuffleanswers>1</shuffleanswers>'.PHP_EOL; $xmlContent .= ' <shuffleanswers>1</shuffleanswers>'.PHP_EOL;
$xmlContent .= ' <correctfeedback>'.htmlspecialchars($question['correctfeedback'] ?? '').'</correctfeedback>'.PHP_EOL; $xmlContent .= ' <correctfeedback>'.htmlspecialchars((string) ($question['correctfeedback'] ?? '')).'</correctfeedback>'.PHP_EOL;
$xmlContent .= ' <correctfeedbackformat>0</correctfeedbackformat>'.PHP_EOL; $xmlContent .= ' <correctfeedbackformat>0</correctfeedbackformat>'.PHP_EOL;
$xmlContent .= ' <partiallycorrectfeedback>'.htmlspecialchars($question['partiallycorrectfeedback'] ?? '').'</partiallycorrectfeedback>'.PHP_EOL; $xmlContent .= ' <partiallycorrectfeedback>'.htmlspecialchars((string) ($question['partiallycorrectfeedback'] ?? '')).'</partiallycorrectfeedback>'.PHP_EOL;
$xmlContent .= ' <partiallycorrectfeedbackformat>0</partiallycorrectfeedbackformat>'.PHP_EOL; $xmlContent .= ' <partiallycorrectfeedbackformat>0</partiallycorrectfeedbackformat>'.PHP_EOL;
$xmlContent .= ' <incorrectfeedback>'.htmlspecialchars($question['incorrectfeedback'] ?? '').'</incorrectfeedback>'.PHP_EOL; $xmlContent .= ' <incorrectfeedback>'.htmlspecialchars((string) ($question['incorrectfeedback'] ?? '')).'</incorrectfeedback>'.PHP_EOL;
$xmlContent .= ' <incorrectfeedbackformat>0</incorrectfeedbackformat>'.PHP_EOL; $xmlContent .= ' <incorrectfeedbackformat>0</incorrectfeedbackformat>'.PHP_EOL;
$xmlContent .= ' <shownumcorrect>0</shownumcorrect>'.PHP_EOL; $xmlContent .= ' <shownumcorrect>0</shownumcorrect>'.PHP_EOL;
$xmlContent .= ' </matchoptions>'.PHP_EOL; $xmlContent .= ' </matchoptions>'.PHP_EOL;
@@ -496,11 +668,185 @@ class QuizExport extends ActivityExport
private function exportAnswer(array $answer): string private function exportAnswer(array $answer): string
{ {
return ' <answer id="'.($answer['id'] ?? '0').'">'.PHP_EOL. return ' <answer id="'.($answer['id'] ?? '0').'">'.PHP_EOL.
' <answertext>'.htmlspecialchars($answer['text'] ?? 'No answer text').'</answertext>'.PHP_EOL. ' <answertext>'.htmlspecialchars((string) ($answer['text'] ?? 'No answer text')).'</answertext>'.PHP_EOL.
' <answerformat>1</answerformat>'.PHP_EOL. ' <answerformat>1</answerformat>'.PHP_EOL.
' <fraction>'.($answer['fraction'] ?? '0').'</fraction>'.PHP_EOL. ' <fraction>'.($answer['fraction'] ?? '0').'</fraction>'.PHP_EOL.
' <feedback>'.htmlspecialchars($answer['feedback'] ?? '').'</feedback>'.PHP_EOL. ' <feedback>'.htmlspecialchars((string) ($answer['feedback'] ?? '')).'</feedback>'.PHP_EOL.
' <feedbackformat>1</feedbackformat>'.PHP_EOL. ' <feedbackformat>1</feedbackformat>'.PHP_EOL.
' </answer>'.PHP_EOL; ' </answer>'.PHP_EOL;
} }
private function ensureTrailingSlash(string $path): string
{
if ($path === '' || $path === '.' || $path === '/') {
return '/';
}
$path = preg_replace('#/+#', '/', $path);
return rtrim($path, '/').'/';
}
private function buildQuestionEmbeddedFileId(int $baseId, int $sequence): int
{
self::$embeddedFileGlobalSeq++;
return 1300000000 + self::$embeddedFileGlobalSeq;
}
/**
* Extract embedded /document/* images from HTML and rewrite src to @@PLUGINFILE@@.
*
* @return array{content:string, files:array<int,array<string,mixed>>}
*/
private function extractQuestionEmbeddedFilesAndNormalizeContent(
string $html,
int $contextId,
string $fileArea,
int $itemId,
string $prefix,
array $courseInfo,
int $adminId
): array {
if ($html === '') {
return ['content' => '', 'files' => []];
}
$fileExport = new FileExport($this->course);
$files = [];
$seenDocIds = [];
$sequence = 0;
$normalizedHtml = preg_replace_callback(
'#<img[^>]+src=["\'](?<url>[^"\']+)["\']#i',
function ($match) use (
$contextId,
$fileArea,
$itemId,
$courseInfo,
$adminId,
$fileExport,
&$files,
&$seenDocIds,
&$sequence
) {
$src = (string) ($match['url'] ?? '');
if (!preg_match('#/document(?P<path>/[^"\']+)#', $src, $m)) {
return $match[0];
}
$docRelPath = (string) $m['path'];
$docId = \DocumentManager::get_document_id($courseInfo, $docRelPath);
if (empty($docId)) {
return $match[0];
}
$docId = (int) $docId;
if (!isset($seenDocIds[$docId])) {
$doc = \DocumentManager::get_document_data_by_id($docId, $courseInfo['code']);
if (!empty($doc) && !empty($doc['path'])) {
$docPath = (string) $doc['path'];
$rel = ltrim(str_replace('\\', '/', $docPath), '/');
$dir = dirname($rel);
$dir = ($dir === '.' ? '' : $dir.'/');
$filepath = '/'.$dir;
$filepath = $this->ensureTrailingSlash($filepath);
$filename = basename($rel);
$absolutePath = $this->course->path.'document'.$docPath;
$sequence++;
$fileId = $this->buildQuestionEmbeddedFileId($itemId, $sequence);
$files[] = [
'id' => $fileId,
'contenthash' => is_file($absolutePath) ? sha1_file($absolutePath) : hash('sha1', $filename),
'contextid' => $contextId,
'component' => 'question',
'filearea' => $fileArea,
'itemid' => $itemId,
'filepath' => $filepath,
'documentpath' => 'document'.$docPath,
'filename' => $filename,
'userid' => $adminId,
'filesize' => (int) ($doc['size'] ?? 0),
'mimetype' => $fileExport->getMimeType($docPath),
'status' => 0,
'timecreated' => time() - 3600,
'timemodified' => time(),
'source' => (string) ($doc['title'] ?? $filename),
'author' => 'Unknown',
'license' => 'allrightsreserved',
];
}
$seenDocIds[$docId] = true;
}
$relHtml = ltrim($docRelPath, '/');
$pluginPath = '@@PLUGINFILE@@/'.$relHtml;
return str_replace($src, $pluginPath, $match[0]);
},
$html
);
return [
'content' => (string) $normalizedHtml,
'files' => $files,
];
}
/**
* Build a deterministic answer id for one question answer.
*/
private function buildStableQuestionAnswerId(int $questionId, int $answerIndex): int
{
return ($questionId * 1000) + $answerIndex;
}
/**
* Build a stable question-bank course context id for backup purposes.
*/
private function buildQuestionBankContextId(int $courseId): int
{
$contextId = MoodleExport::getBackupCourseContextId();
if ($contextId > 0) {
return $contextId;
}
return 700000000 + max(1, $courseId);
}
/**
* Build a clean question name from the question text.
*/
private function sanitizeQuestionName(string $raw, int $maxLen = 255): string
{
$text = trim($raw);
if ($text === '') {
return 'Question';
}
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$text = strip_tags($text);
$text = preg_replace('/\s+/u', ' ', $text);
$text = trim($text);
if ($text === '') {
return 'Question';
}
if (function_exists('mb_strlen') && function_exists('mb_substr')) {
if (mb_strlen($text, 'UTF-8') > $maxLen) {
$text = mb_substr($text, 0, $maxLen, 'UTF-8');
}
} elseif (strlen($text) > $maxLen) {
$text = substr($text, 0, $maxLen);
}
return $text;
}
} }
+144 -15
View File
@@ -21,13 +21,13 @@ class ResourceExport extends ActivityExport
*/ */
public function export($activityId, $exportDir, $moduleId, $sectionId): void public function export($activityId, $exportDir, $moduleId, $sectionId): void
{ {
// Prepare the directory where the resource export will be saved $resourceDir = $this->prepareActivityDirectory($exportDir, 'resource', (int) $moduleId);
$resourceDir = $this->prepareActivityDirectory($exportDir, 'resource', $moduleId); $resourceData = $this->getData((int) $activityId, (int) $sectionId, (int) $moduleId);
// Retrieve resource data if (empty($resourceData)) {
$resourceData = $this->getData($activityId, $sectionId); return;
}
// Generate XML files
$this->createResourceXml($resourceData, $resourceDir); $this->createResourceXml($resourceData, $resourceDir);
$this->createModuleXml($resourceData, $resourceDir); $this->createModuleXml($resourceData, $resourceDir);
$this->createGradesXml($resourceData, $resourceDir); $this->createGradesXml($resourceData, $resourceDir);
@@ -42,22 +42,48 @@ class ResourceExport extends ActivityExport
/** /**
* Get resource data dynamically from the course. * Get resource data dynamically from the course.
*/ */
public function getData(int $resourceId, int $sectionId): array public function getData(int $resourceId, int $sectionId, ?int $moduleId = null): array
{ {
if (empty($this->course->resources[RESOURCE_DOCUMENT][$resourceId])) {
return [];
}
$resource = $this->course->resources[RESOURCE_DOCUMENT][$resourceId]; $resource = $this->course->resources[RESOURCE_DOCUMENT][$resourceId];
$name = (string) ($resource->title ?? '');
if ($sectionId > 0) {
$name = $this->lpItemTitle($sectionId, RESOURCE_DOCUMENT, $resourceId, $name);
}
$name = $this->sanitizeMoodleActivityName($name, 255);
$effectiveModuleId = (int) ($moduleId ?? $resource->source_id);
if ($effectiveModuleId <= 0) {
$effectiveModuleId = (int) $resource->source_id;
}
$resourceFile = $this->buildResourceFileEntry($resource, $effectiveModuleId);
$introResult = $this->extractEmbeddedFilesAndNormalizeContent(
(string) ($resource->comment ?? ''),
$effectiveModuleId,
'mod_resource',
'intro',
0,
fn (int $sequence): int => $this->buildResourceIntroFileId($effectiveModuleId, $sequence)
);
return [ return [
'id' => $resourceId, 'id' => $resourceId,
'moduleid' => $resource->source_id, 'moduleid' => $effectiveModuleId,
'modulename' => 'resource', 'modulename' => 'resource',
'contextid' => $resource->source_id, 'contextid' => $effectiveModuleId,
'name' => $resource->title, 'name' => $name,
'intro' => $resource->comment ?? '', 'intro' => $introResult['content'],
'sectionid' => $sectionId, 'sectionid' => $sectionId,
'sectionnumber' => 1, 'sectionnumber' => 1,
'timemodified' => time(), 'timemodified' => time(),
'users' => [], 'users' => [],
'files' => [], 'files' => array_merge([$resourceFile], $introResult['files']),
]; ];
} }
@@ -72,18 +98,121 @@ class ResourceExport extends ActivityExport
$xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL; $xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
$xmlContent .= '<inforef>'.PHP_EOL; $xmlContent .= '<inforef>'.PHP_EOL;
if (!empty($references['files']) && is_array($references['files'])) {
$xmlContent .= ' <fileref>'.PHP_EOL; $xmlContent .= ' <fileref>'.PHP_EOL;
foreach ($references['files'] as $file) {
$fileId = is_array($file) ? (int) ($file['id'] ?? 0) : (int) $file;
if ($fileId <= 0) {
continue;
}
$xmlContent .= ' <file>'.PHP_EOL; $xmlContent .= ' <file>'.PHP_EOL;
$xmlContent .= ' <id>'.htmlspecialchars($references['id']).'</id>'.PHP_EOL; $xmlContent .= ' <id>'.$fileId.'</id>'.PHP_EOL;
$xmlContent .= ' </file>'.PHP_EOL; $xmlContent .= ' </file>'.PHP_EOL;
}
$xmlContent .= ' </fileref>'.PHP_EOL; $xmlContent .= ' </fileref>'.PHP_EOL;
}
$xmlContent .= '</inforef>'.PHP_EOL; $xmlContent .= '</inforef>'.PHP_EOL;
// Save the XML content to the directory
$this->createXmlFile('inforef', $xmlContent, $directory); $this->createXmlFile('inforef', $xmlContent, $directory);
} }
/**
* Build the files.xml entry for a resource activity file.
*/
private function buildResourceFileEntry(object $resource, int $moduleId): array
{
$adminData = MoodleExport::getAdminUserData();
$adminId = (int) ($adminData['id'] ?? 1);
$documentPath = (string) $resource->path;
$absolutePath = $this->course->path.$documentPath;
$filename = basename($documentPath);
$contenthash = is_file($absolutePath)
? sha1_file($absolutePath)
: hash('sha1', $filename);
return [
'id' => $this->buildResourceFileId($moduleId, (int) $resource->source_id),
'contenthash' => $contenthash,
'contextid' => $moduleId,
'component' => 'mod_resource',
'filearea' => 'content',
'itemid' => 0,
'filepath' => '/',
'documentpath' => $documentPath,
'filename' => $filename,
'userid' => $adminId,
'filesize' => (int) ($resource->size ?? 0),
'mimetype' => $this->guessMimeType($documentPath),
'status' => 0,
'timecreated' => time() - 3600,
'timemodified' => time(),
'source' => (string) ($resource->title ?? $filename),
'author' => 'Unknown',
'license' => 'allrightsreserved',
];
}
/**
* Build a stable file id for mod_resource main file entries.
*/
private function buildResourceFileId(int $moduleId, int $resourceId): int
{
$base = $moduleId > 0 ? $moduleId : $resourceId;
return 1000000000 + $base;
}
/**
* Build a stable file id for embedded intro files in mod_resource.
*/
private function buildResourceIntroFileId(int $moduleId, int $sequence): int
{
return 1250000000 + max(0, $moduleId) + max(1, $sequence);
}
/**
* Guess MIME type from file extension.
*/
private function guessMimeType(string $filePath): string
{
$ext = strtolower((string) pathinfo($filePath, PATHINFO_EXTENSION));
$map = [
'pdf' => 'application/pdf',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'svg' => 'image/svg+xml',
'webp' => 'image/webp',
'html' => 'text/html',
'htm' => 'text/html',
'txt' => 'text/plain',
'css' => 'text/css',
'js' => 'application/javascript',
'mp4' => 'video/mp4',
'webm' => 'video/webm',
'mp3' => 'audio/mpeg',
'ogg' => 'audio/ogg',
'doc' => 'application/msword',
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'xls' => 'application/vnd.ms-excel',
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'ppt' => 'application/vnd.ms-powerpoint',
'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'zip' => 'application/zip',
'rar' => 'application/x-rar-compressed',
];
return $map[$ext] ?? 'application/octet-stream';
}
/** /**
* Create the XML file for the resource. * Create the XML file for the resource.
*/ */
@@ -92,8 +221,8 @@ class ResourceExport extends ActivityExport
$xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL; $xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
$xmlContent .= '<activity id="'.$resourceData['id'].'" moduleid="'.$resourceData['moduleid'].'" modulename="resource" contextid="'.$resourceData['contextid'].'">'.PHP_EOL; $xmlContent .= '<activity id="'.$resourceData['id'].'" moduleid="'.$resourceData['moduleid'].'" modulename="resource" contextid="'.$resourceData['contextid'].'">'.PHP_EOL;
$xmlContent .= ' <resource id="'.$resourceData['id'].'">'.PHP_EOL; $xmlContent .= ' <resource id="'.$resourceData['id'].'">'.PHP_EOL;
$xmlContent .= ' <name>'.htmlspecialchars($resourceData['name']).'</name>'.PHP_EOL; $xmlContent .= ' <name>'.htmlspecialchars((string) $resourceData['name']).'</name>'.PHP_EOL;
$xmlContent .= ' <intro>'.htmlspecialchars($resourceData['intro']).'</intro>'.PHP_EOL; $xmlContent .= ' <intro><![CDATA['.(string) $resourceData['intro'].']]></intro>'.PHP_EOL;
$xmlContent .= ' <introformat>1</introformat>'.PHP_EOL; $xmlContent .= ' <introformat>1</introformat>'.PHP_EOL;
$xmlContent .= ' <tobemigrated>0</tobemigrated>'.PHP_EOL; $xmlContent .= ' <tobemigrated>0</tobemigrated>'.PHP_EOL;
$xmlContent .= ' <legacyfiles>0</legacyfiles>'.PHP_EOL; $xmlContent .= ' <legacyfiles>0</legacyfiles>'.PHP_EOL;
+158 -52
View File
@@ -15,15 +15,17 @@ use Exception;
class SectionExport class SectionExport
{ {
private $course; private $course;
private $activitiesBySection = [];
/** /**
* Constructor to initialize the course object. * Constructor to initialize the course object.
* *
* @param object $course The course object to be exported. * @param object $course The course object to be exported.
*/ */
public function __construct($course) public function __construct($course, array $activitiesBySection = [])
{ {
$this->course = $course; $this->course = $course;
$this->activitiesBySection = $activitiesBySection;
} }
/** /**
@@ -47,8 +49,8 @@ class SectionExport
$sectionData = [ $sectionData = [
'id' => 0, 'id' => 0,
'number' => 0, 'number' => 0,
'name' => get_lang('General'), 'name' => $this->sanitizeText((string) get_lang('General')),
'summary' => get_lang('GeneralResourcesCourse'), 'summary' => (string) get_lang('GeneralResourcesCourse'),
'sequence' => 0, 'sequence' => 0,
'visible' => 1, 'visible' => 1,
'timemodified' => time(), 'timemodified' => time(),
@@ -68,7 +70,6 @@ class SectionExport
{ {
$generalItems = []; $generalItems = [];
// List of resource types and their corresponding ID keys
$resourceTypes = [ $resourceTypes = [
RESOURCE_DOCUMENT => 'source_id', RESOURCE_DOCUMENT => 'source_id',
RESOURCE_QUIZ => 'source_id', RESOURCE_QUIZ => 'source_id',
@@ -85,8 +86,9 @@ class SectionExport
foreach ($this->course->resources[$resourceType] as $id => $resource) { foreach ($this->course->resources[$resourceType] as $id => $resource) {
if (!$this->isItemInLearnpath($resource, $resourceType)) { if (!$this->isItemInLearnpath($resource, $resourceType)) {
$title = $resourceType === RESOURCE_WORK $title = $resourceType === RESOURCE_WORK
? ($resource->params['title'] ?? '') ? (string) ($resource->params['title'] ?? '')
: ($resource->title ?? $resource->name); : (string) ($resource->title ?? $resource->name ?? '');
$generalItems[] = [ $generalItems[] = [
'id' => $resource->$idKey, 'id' => $resource->$idKey,
'item_type' => $resourceType, 'item_type' => $resourceType,
@@ -106,6 +108,10 @@ class SectionExport
*/ */
public function getActivitiesForGeneral(): array public function getActivitiesForGeneral(): array
{ {
if (isset($this->activitiesBySection[0]) && is_array($this->activitiesBySection[0])) {
return $this->activitiesBySection[0];
}
$generalLearnpath = (object) [ $generalLearnpath = (object) [
'items' => $this->getGeneralItems(), 'items' => $this->getGeneralItems(),
'source_id' => 0, 'source_id' => 0,
@@ -113,7 +119,7 @@ class SectionExport
$activities = $this->getActivitiesForSection($generalLearnpath, true); $activities = $this->getActivitiesForSection($generalLearnpath, true);
if (!in_array('folder', array_column($activities, 'modulename'))) { if (!in_array('folder', array_column($activities, 'modulename'), true)) {
$activities[] = [ $activities[] = [
'id' => 0, 'id' => 0,
'moduleid' => 0, 'moduleid' => 0,
@@ -132,7 +138,7 @@ class SectionExport
public function getLearnpathById(int $sectionId): ?object public function getLearnpathById(int $sectionId): ?object
{ {
foreach ($this->course->resources[RESOURCE_LEARNPATH] as $learnpath) { foreach ($this->course->resources[RESOURCE_LEARNPATH] as $learnpath) {
if ($learnpath->source_id == $sectionId) { if ((int) $learnpath->source_id === $sectionId) {
return $learnpath; return $learnpath;
} }
} }
@@ -145,14 +151,16 @@ class SectionExport
*/ */
public function getSectionData(object $learnpath): array public function getSectionData(object $learnpath): array
{ {
$sectionId = (int) $learnpath->source_id;
return [ return [
'id' => $learnpath->source_id, 'id' => $sectionId,
'number' => $learnpath->display_order, 'number' => (int) ($learnpath->display_order ?? 0),
'name' => $learnpath->name, 'name' => $this->sanitizeText((string) ($learnpath->name ?? '')),
'summary' => $learnpath->description, 'summary' => (string) ($learnpath->description ?? ''),
'sequence' => $learnpath->source_id, 'sequence' => $sectionId,
'visible' => $learnpath->visibility, 'visible' => 1,
'timemodified' => strtotime($learnpath->modified_on), 'timemodified' => !empty($learnpath->modified_on) ? (int) strtotime((string) $learnpath->modified_on) : time(),
'activities' => $this->getActivitiesForSection($learnpath), 'activities' => $this->getActivitiesForSection($learnpath),
]; ];
} }
@@ -162,9 +170,13 @@ class SectionExport
*/ */
public function getActivitiesForSection(object $learnpath, bool $isGeneral = false): array public function getActivitiesForSection(object $learnpath, bool $isGeneral = false): array
{ {
$activities = []; $sectionId = $isGeneral ? 0 : (int) $learnpath->source_id;
$sectionId = $isGeneral ? 0 : $learnpath->source_id;
if (isset($this->activitiesBySection[$sectionId]) && is_array($this->activitiesBySection[$sectionId])) {
return $this->activitiesBySection[$sectionId];
}
$activities = [];
foreach ($learnpath->items as $item) { foreach ($learnpath->items as $item) {
$this->addActivityToList($item, $sectionId, $activities); $this->addActivityToList($item, $sectionId, $activities);
} }
@@ -195,23 +207,77 @@ class SectionExport
$exportClass = new $exportClasses[$moduleName]($this->course); $exportClass = new $exportClasses[$moduleName]($this->course);
$exportClass->export($activity['id'], $exportDir, $activity['moduleid'], $sectionId); $exportClass->export($activity['id'], $exportDir, $activity['moduleid'], $sectionId);
} else { } else {
throw new \Exception("Export for module '$moduleName' is not supported."); throw new Exception("Export for module '$moduleName' is not supported.");
} }
} }
} }
/**
* Normalize resource / LP item types for reliable comparison.
*/
private function normalizeItemTypeForLpComparison(string $type): string
{
switch ($type) {
case 'student_publication':
case 'work':
return 'work';
case 'link':
case 'url':
return 'link';
case 'survey':
case 'feedback':
return 'survey';
default:
return $type;
}
}
/** /**
* Check if an item is associated with any learnpath. * Check if an item is associated with any learnpath.
*/ */
private function isItemInLearnpath(object $item, string $type): bool private function isItemInLearnpath(object $item, string $type): bool
{ {
if (!empty($this->course->resources[RESOURCE_LEARNPATH])) { if (empty($this->course->resources[RESOURCE_LEARNPATH])) {
return false;
}
$normalizedType = $this->normalizeItemTypeForLpComparison($type);
$itemSourceId = isset($item->source_id) ? (string) $item->source_id : '';
foreach ($this->course->resources[RESOURCE_LEARNPATH] as $learnpath) { foreach ($this->course->resources[RESOURCE_LEARNPATH] as $learnpath) {
if (!empty($learnpath->items)) { if (empty($learnpath->items)) {
continue;
}
foreach ($learnpath->items as $learnpathItem) { foreach ($learnpath->items as $learnpathItem) {
if ($learnpathItem['item_type'] === $type && $learnpathItem['path'] == $item->source_id) { $lpType = isset($learnpathItem['item_type'])
? $this->normalizeItemTypeForLpComparison((string) $learnpathItem['item_type'])
: '';
if ($lpType !== $normalizedType) {
continue;
}
$lpPath = isset($learnpathItem['path']) ? (string) $learnpathItem['path'] : '';
if ($itemSourceId !== '' && $lpPath === $itemSourceId) {
return true; return true;
} }
if ($normalizedType === 'document' && $itemSourceId !== '' && ctype_digit($itemSourceId)) {
$doc = \DocumentManager::get_document_data_by_id((int) $itemSourceId, $this->course->code);
if (!empty($doc['path'])) {
$docPath = (string) $doc['path'];
$candidates = [$docPath, 'document/'.$docPath, '/'.$docPath];
foreach ($candidates as $candidate) {
if ($lpPath === $candidate) {
return true;
}
}
} }
} }
} }
@@ -249,14 +315,14 @@ class SectionExport
'feedback' => FeedbackExport::class, 'feedback' => FeedbackExport::class,
]; ];
if ($item['id'] == 'course_homepage') { if (($item['id'] ?? null) === 'course_homepage') {
$item['item_type'] = 'page'; $item['item_type'] = 'page';
$item['path'] = 0; $item['path'] = 0;
} }
$itemType = $item['item_type'] === 'link' ? 'url' : $itemType = ($item['item_type'] ?? '') === 'link' ? 'url'
($item['item_type'] === 'work' || $item['item_type'] === 'student_publication' ? 'assign' : : ((($item['item_type'] ?? '') === 'work' || ($item['item_type'] ?? '') === 'student_publication') ? 'assign'
($item['item_type'] === 'survey' ? 'feedback' : $item['item_type'])); : ((($item['item_type'] ?? '') === 'survey') ? 'feedback' : ($item['item_type'] ?? '')));
switch ($itemType) { switch ($itemType) {
case 'quiz': case 'quiz':
@@ -266,41 +332,48 @@ class SectionExport
case 'forum': case 'forum':
case 'feedback': case 'feedback':
case 'page': case 'page':
$activityId = $itemType === 'glossary' ? 1 : (int) $item['path']; $activityId = $itemType === 'glossary' ? 1 : (int) ($item['path'] ?? 0);
$exportClass = $activityClassMap[$itemType]; $exportClass = $activityClassMap[$itemType];
$exportInstance = new $exportClass($this->course); $exportInstance = new $exportClass($this->course);
$activityData = $exportInstance->getData($activityId, $sectionId); $activityData = $exportInstance->getData($activityId, $sectionId);
break; break;
case 'document': case 'document':
$documentId = (int) $item['path']; $documentId = (int) ($item['path'] ?? 0);
$document = \DocumentManager::get_document_data_by_id($documentId, $this->course->code); $document = \DocumentManager::get_document_data_by_id($documentId, $this->course->code);
if ($document) { if ($document) {
$isRoot = substr_count($document['path'], '/') === 1; $documentType = $this->getDocumentType((string) $document['filetype'], (string) $document['path']);
$documentType = $this->getDocumentType($document['filetype'], $document['path']);
if ($documentType === 'page' && $isRoot) { if ($sectionId > 0 && $documentType && isset($activityClassMap[$documentType])) {
$activityClass = $activityClassMap['page'];
$exportInstance = new $activityClass($this->course);
$activityData = $exportInstance->getData($item['path'], $sectionId);
}
elseif ($sectionId > 0 && $documentType && isset($activityClassMap[$documentType])) {
$activityClass = $activityClassMap[$documentType]; $activityClass = $activityClassMap[$documentType];
$exportInstance = new $activityClass($this->course); $exportInstance = new $activityClass($this->course);
$activityData = $exportInstance->getData($item['path'], $sectionId); $activityData = $exportInstance->getData($documentId, $sectionId);
} elseif ($sectionId === 0 && $documentType === 'page') {
$activityClass = $activityClassMap['page'];
$exportInstance = new $activityClass($this->course);
$activityData = $exportInstance->getData($documentId, $sectionId);
} }
} }
break; break;
} }
// Add the activity to the list if the data exists if (!empty($activityData) && $sectionId > 0) {
$lpItemId = isset($item['id']) ? (int) $item['id'] : 0;
$modName = (string) ($activityData['modulename'] ?? '');
if ($lpItemId > 0 && !in_array($modName, ['folder', 'glossary'], true)) {
$activityData['moduleid'] = 900000000 + $lpItemId;
}
}
if ($activityData) { if ($activityData) {
$activities[] = [ $activities[] = [
'id' => $activityData['id'], 'id' => $activityData['id'],
'moduleid' => $activityData['moduleid'], 'moduleid' => $activityData['moduleid'],
'type' => $item['item_type'], 'type' => $item['item_type'],
'modulename' => $activityData['modulename'], 'modulename' => $activityData['modulename'],
'name' => $activityData['name'], 'name' => $this->sanitizeText((string) ($activityData['name'] ?? '')),
]; ];
} }
} }
@@ -310,13 +383,15 @@ class SectionExport
*/ */
private function getDocumentType(string $filetype, string $path): ?string private function getDocumentType(string $filetype, string $path): ?string
{ {
if ('html' === pathinfo($path, PATHINFO_EXTENSION)) { $ext = strtolower((string) pathinfo($path, PATHINFO_EXTENSION));
if ($ext === 'html' || $ext === 'htm') {
return 'page'; return 'page';
} elseif ('file' === $filetype) { }
if ($filetype === 'file') {
return 'resource'; return 'resource';
} /*elseif ('folder' === $filetype) { }
return 'folder';
}*/
return null; return null;
} }
@@ -326,19 +401,20 @@ class SectionExport
*/ */
private function createSectionXml(array $sectionData, string $destinationDir): void private function createSectionXml(array $sectionData, string $destinationDir): void
{ {
$sequence = implode(',', array_column($sectionData['activities'], 'moduleid'));
$xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL; $xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
$xmlContent .= '<section id="'.$sectionData['id'].'">'.PHP_EOL; $xmlContent .= '<section id="'.$sectionData['id'].'">'.PHP_EOL;
$xmlContent .= ' <number>'.$sectionData['number'].'</number>'.PHP_EOL; $xmlContent .= ' <number>'.$sectionData['number'].'</number>'.PHP_EOL;
$xmlContent .= ' <name>'.htmlspecialchars($sectionData['name']).'</name>'.PHP_EOL; $xmlContent .= ' <name>'.htmlspecialchars((string) $sectionData['name']).'</name>'.PHP_EOL;
$xmlContent .= ' <summary>'.htmlspecialchars($sectionData['summary']).'</summary>'.PHP_EOL; $xmlContent .= ' <summary>'.htmlspecialchars((string) $sectionData['summary']).'</summary>'.PHP_EOL;
$xmlContent .= ' <summaryformat>1</summaryformat>'.PHP_EOL; $xmlContent .= ' <summaryformat>1</summaryformat>'.PHP_EOL;
$xmlContent .= ' <sequence>'.implode(',', array_column($sectionData['activities'], 'moduleid')).'</sequence>'.PHP_EOL; $xmlContent .= ' <sequence>'.$sequence.'</sequence>'.PHP_EOL;
$xmlContent .= ' <visible>'.$sectionData['visible'].'</visible>'.PHP_EOL; $xmlContent .= ' <visible>1</visible>'.PHP_EOL;
$xmlContent .= ' <timemodified>'.$sectionData['timemodified'].'</timemodified>'.PHP_EOL; $xmlContent .= ' <timemodified>'.$sectionData['timemodified'].'</timemodified>'.PHP_EOL;
$xmlContent .= '</section>'.PHP_EOL; $xmlContent .= '</section>'.PHP_EOL;
$xmlFile = $destinationDir.'/section.xml'; file_put_contents($destinationDir.'/section.xml', $xmlContent);
file_put_contents($xmlFile, $xmlContent);
} }
/** /**
@@ -350,12 +426,42 @@ class SectionExport
$xmlContent .= '<inforef>'.PHP_EOL; $xmlContent .= '<inforef>'.PHP_EOL;
foreach ($sectionData['activities'] as $activity) { foreach ($sectionData['activities'] as $activity) {
$xmlContent .= ' <activity id="'.$activity['id'].'">'.htmlspecialchars($activity['name']).'</activity>'.PHP_EOL; $refId = $activity['moduleid'] ?? $activity['id'];
$xmlContent .= ' <activity id="'.$refId.'">'.htmlspecialchars((string) ($activity['name'] ?? '')).'</activity>'.PHP_EOL;
} }
$xmlContent .= '</inforef>'.PHP_EOL; $xmlContent .= '</inforef>'.PHP_EOL;
$xmlFile = $destinationDir.'/inforef.xml'; file_put_contents($destinationDir.'/inforef.xml', $xmlContent);
file_put_contents($xmlFile, $xmlContent); }
/**
* Sanitize section or activity titles for Moodle.
*/
private function sanitizeText(string $raw, int $maxLen = 255): string
{
$text = trim($raw);
if ($text === '') {
return '';
}
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$text = strip_tags($text);
$text = preg_replace('/\s+/u', ' ', $text);
$text = trim($text);
if ($text === '') {
return '';
}
if (function_exists('mb_strlen') && function_exists('mb_substr')) {
if (mb_strlen($text, 'UTF-8') > $maxLen) {
$text = mb_substr($text, 0, $maxLen, 'UTF-8');
}
} elseif (strlen($text) > $maxLen) {
$text = substr($text, 0, $maxLen);
}
return $text;
} }
} }
+55 -20
View File
@@ -12,22 +12,27 @@ namespace moodleexport;
class UrlExport extends ActivityExport class UrlExport extends ActivityExport
{ {
/** /**
* Export all URL resources into a single Moodle activity. * Export one URL activity.
* *
* @param int $activityId The ID of the URL. * @param int $activityId The ID of the URL.
* @param string $exportDir The directory where the URL will be exported. * @param string $exportDir The directory where the URL will be exported.
* @param int $moduleId The ID of the module. * @param int $moduleId The exported module ID.
* @param int $sectionId The ID of the section. * @param int $sectionId The ID of the section.
*/ */
public function export($activityId, $exportDir, $moduleId, $sectionId): void public function export($activityId, $exportDir, $moduleId, $sectionId): void
{ {
// Prepare the directory where the URL export will be saved $effectiveModuleId = (int) $moduleId;
$urlDir = $this->prepareActivityDirectory($exportDir, 'url', $moduleId); if ($effectiveModuleId <= 0) {
$effectiveModuleId = (int) $activityId;
}
// Retrieve URL data $urlDir = $this->prepareActivityDirectory($exportDir, 'url', $effectiveModuleId);
$urlData = $this->getData($activityId, $sectionId); $urlData = $this->getData((int) $activityId, (int) $sectionId, $effectiveModuleId);
if (empty($urlData)) {
return;
}
// Generate XML file for the URL
$this->createUrlXml($urlData, $urlDir); $this->createUrlXml($urlData, $urlDir);
$this->createModuleXml($urlData, $urlDir); $this->createModuleXml($urlData, $urlDir);
$this->createGradesXml($urlData, $urlDir); $this->createGradesXml($urlData, $urlDir);
@@ -40,28 +45,50 @@ class UrlExport extends ActivityExport
} }
/** /**
* Get all URL data for the course. * Get URL data for the course.
*/ */
public function getData(int $activityId, int $sectionId): ?array public function getData(int $activityId, int $sectionId, ?int $moduleId = null): ?array
{ {
// Extract the URL information from the course data if (empty($this->course->resources['link'][$activityId])) {
return null;
}
$url = $this->course->resources['link'][$activityId]; $url = $this->course->resources['link'][$activityId];
// Return the URL data formatted for export $effectiveModuleId = (int) ($moduleId ?? $activityId);
if ($effectiveModuleId <= 0) {
$effectiveModuleId = $activityId;
}
$name = (string) ($url->title ?? '');
if ($sectionId > 0) {
$name = $this->lpItemTitle($sectionId, RESOURCE_LINK, $activityId, $name);
}
$name = $this->sanitizeMoodleActivityName($name, 255);
$descriptionResult = $this->extractEmbeddedFilesAndNormalizeContent(
(string) ($url->description ?? ''),
$effectiveModuleId,
'mod_url',
'intro',
0,
fn (int $sequence): int => $this->buildUrlEmbeddedFileId($effectiveModuleId, $sequence)
);
return [ return [
'id' => $activityId, 'id' => $activityId,
'moduleid' => $activityId, 'moduleid' => $effectiveModuleId,
'modulename' => 'url', 'modulename' => 'url',
'contextid' => $this->course->info['real_id'], 'contextid' => $effectiveModuleId,
'name' => $url->title, 'name' => $name,
'description' => $url->description, 'description' => $descriptionResult['content'],
'externalurl' => $url->url, 'externalurl' => (string) ($url->url ?? ''),
'timecreated' => time(), 'timecreated' => time(),
'timemodified' => time(), 'timemodified' => time(),
'sectionid' => $sectionId, 'sectionid' => $sectionId,
'sectionnumber' => 0, 'sectionnumber' => 0,
'users' => [], 'users' => [],
'files' => [], 'files' => $descriptionResult['files'],
]; ];
} }
@@ -73,10 +100,10 @@ class UrlExport extends ActivityExport
$xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL; $xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
$xmlContent .= '<activity id="'.$urlData['id'].'" moduleid="'.$urlData['moduleid'].'" modulename="'.$urlData['modulename'].'" contextid="'.$urlData['contextid'].'">'.PHP_EOL; $xmlContent .= '<activity id="'.$urlData['id'].'" moduleid="'.$urlData['moduleid'].'" modulename="'.$urlData['modulename'].'" contextid="'.$urlData['contextid'].'">'.PHP_EOL;
$xmlContent .= ' <url id="'.$urlData['id'].'">'.PHP_EOL; $xmlContent .= ' <url id="'.$urlData['id'].'">'.PHP_EOL;
$xmlContent .= ' <name>'.htmlspecialchars($urlData['name']).'</name>'.PHP_EOL; $xmlContent .= ' <name>'.htmlspecialchars((string) $urlData['name']).'</name>'.PHP_EOL;
$xmlContent .= ' <intro><![CDATA['.htmlspecialchars($urlData['description']).']]></intro>'.PHP_EOL; $xmlContent .= ' <intro><![CDATA['.(string) $urlData['description'].']]></intro>'.PHP_EOL;
$xmlContent .= ' <introformat>1</introformat>'.PHP_EOL; $xmlContent .= ' <introformat>1</introformat>'.PHP_EOL;
$xmlContent .= ' <externalurl>'.htmlspecialchars($urlData['externalurl']).'</externalurl>'.PHP_EOL; $xmlContent .= ' <externalurl>'.htmlspecialchars((string) $urlData['externalurl']).'</externalurl>'.PHP_EOL;
$xmlContent .= ' <display>0</display>'.PHP_EOL; $xmlContent .= ' <display>0</display>'.PHP_EOL;
$xmlContent .= ' <displayoptions>a:1:{s:10:"printintro";i:1;}</displayoptions>'.PHP_EOL; $xmlContent .= ' <displayoptions>a:1:{s:10:"printintro";i:1;}</displayoptions>'.PHP_EOL;
$xmlContent .= ' <parameters>a:0:{}</parameters>'.PHP_EOL; $xmlContent .= ' <parameters>a:0:{}</parameters>'.PHP_EOL;
@@ -86,4 +113,12 @@ class UrlExport extends ActivityExport
$this->createXmlFile('url', $xmlContent, $urlDir); $this->createXmlFile('url', $xmlContent, $urlDir);
} }
/**
* Build a stable embedded file id for URL intro files.
*/
private function buildUrlEmbeddedFileId(int $moduleId, int $sequence): int
{
return 1200000000 + max(0, $moduleId) + max(1, $sequence);
}
} }

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