Small CLI scripts for the two yearly Moodle chores at ISC — HES-SO Valais, run from the terminal instead of clicking through the GUI for every course and every student:
- Yearly archive — rename last year's courses with the academic year, move them to an Archive category, and create clean copies for the new year.
- Student registration — turn the secretariat's module-registration workbook into Moodle accounts and per-course enrolments, without touching anyone who is already there.
The scripts only rely on Moodle's own APIs (copy_helper, update_course, enrol_manual, the uploaduser CLI), so they work on any Moodle 4.x/5.x installation. The site-specific parts are the ID of the Archive category and the shape of the registration workbook.
Each course is renamed with the academic year that just ended (e.g. 25-26 / CS101), moved to an Archive category with its students still enrolled, and a clean copy is created in the original category with the original name, dates shifted by one year, no user data and no students. Every other role (managers, teachers, ...) is kept in the copy.
- Batch archiving — pass any number of course IDs and every course is archived and copied in one run
- Academic-year prefix — archived courses are renamed
YY-YY / <name>from the current date, so the archive stays sorted and searchable - Students out, staff in — the copy drops student enrolments and all user data but keeps every other role, resolved from the
roletable rather than hardcoded IDs - Safe to re-run — courses already in the Archive category (or one of its sub-categories) are skipped, unknown IDs are reported, and one failing course never stops the others
- Course picker — a companion script lists every non-archived course grouped by category and saves the IDs for the next step
# 1. Copy the scripts into your Moodle root (next to config.php)
cp script_get_courses.php script_update_courses.php /var/www/moodle/
cd /var/www/moodle
# 2. Set the ID of your Archive category in both scripts ($archiveCategoryId)
# 3. List the courses that are not archived yet, optionally saving their IDs
sudo -u www-data php script_get_courses.php
# 4. Archive and copy the courses you want, by ID ...
sudo -u www-data php script_update_courses.php 81 82 84 51
# ... or every course saved by step 3
sudo -u www-data php script_update_courses.php $(tr ',' ' ' < course_ids.txt)
# 5. The copies run as Moodle ad-hoc tasks: wait for cron, or run them now
sudo -u www-data php admin/cli/adhoc_task.php --executeCaution
Both scripts must run as the web server user (www-data) so the backup files land in moodledata with the right ownership. Try it on a single test course first.
For each course ID given to script_update_courses.php:
- Loads — fetches the course and skips it if it already lives in the Archive category tree
- Archives — prefixes the full name and short name with the ending academic year and moves the course to Archive via
update_course() - Queues the copy — builds the same form data the GUI would and calls
copy_helper::create_copy(), keeping every role exceptstudent - Reports — prints a summary of skipped and failed IDs and exits non-zero if anything went wrong
flowchart LR
A["📚 CS101 (2025-26)"] -->|update_course| B["🗄️ Archive / 25-26 / CS101<br/>students kept"]
A -->|copy_helper| C["📄 CS101 (2026-27)<br/>no students, no user data"]
C -->|adhoc_task.php| D["✅ copy completed by cron"]
The prefix is computed from the current date (previous year – current year), so the script is meant to be run once a year during the summer break.
The secretariat keeps one workbook per academic year (Inscription aux modules YYYY-YY.xlsx): a header row Nom, Prénom, Email, … followed by one column per module code (100.1, 200.3, …) whose cells say what each student does with it — X registered, R repeating, AL auditing, A acquired, EQ/RA equivalence, X?/? undecided. That workbook is the source of truth; the scripts read it and never write to it.
- Per-student enrolments, not cohorts — repeaters, equivalences and part-time students take a subset of their year's modules, so each student is enrolled exactly in the courses marked in the workbook
- Accounts checked before creation — existing accounts are matched on username and e-mail; homonyms with another address, suspended and deleted accounts are reported and left for a human, never created or duplicated
- Shibboleth-ready — accounts are created with
auth=shibbolethand no forced password change (a forced change locks a Shibboleth user out); the upload tool's "auth plugin not supported" warning is expected - Courses matched on the module code —
200.3 - Machine learningis the course for module200.3; codes with no course and courses with no code are listed,course_map.csvcan force a pairing - Add-only and idempotent — nobody is unenrolled or modified; a second run adds nothing and reports what is already there;
--dry-runshows the full plan first
# 1. On your machine: workbook → students.csv + enrolments.csv (needs uv; the workbook is opened read-only)
./students_from_xlsx.py "Inscription aux modules 2026-27.xlsx" --out-dir out/
scp out/*.csv script_check_users.php script_enrol_students.php moodle-host:/tmp/isc-enrol/
# 2. On the Moodle host: who is new, who exists, who needs a decision → students_new.csv
cd /var/www/moodle # or export MOODLE_ROOT=/var/www/moodle and run the scripts from anywhere
sudo -u www-data php /tmp/isc-enrol/script_check_users.php /tmp/isc-enrol/students.csv
# 3. Create the new accounts with Moodle's own upload tool (add new only, no forced password change;
# uupasswordnew=1 is required by the form but only applies to internal auth — nothing is generated or mailed for shibboleth)
sudo -u www-data php public/admin/tool/uploaduser/cli/uploaduser.php \
--file=/tmp/isc-enrol/students_new.csv --delimiter_name=comma \
--uutype=0 --uuforcepasswordchange=0 --uupasswordnew=1
# 4. Enrol everyone in the courses of their modules — dry-run first, then for real
sudo -u www-data php /tmp/isc-enrol/script_enrol_students.php /tmp/isc-enrol/enrolments.csv --dry-run --semester=S1,S3,S5
sudo -u www-data php /tmp/isc-enrol/script_enrol_students.php /tmp/isc-enrol/enrolments.csv --semester=S1,S3,S5On Moodle 4.x the upload tool lives in admin/tool/uploaduser/cli/ (no public/). Step 2 exits with status 2 when something needs a decision; --semester=S1,S3,S5 restricts step 4 to the autumn modules (the S1…S6/SS1/SS2 labels above the module codes in the workbook); step 4 lists the marks it did not act on (X?, ? — add --include-uncertain to take them), the module codes without a course, and the accounts it could not find (run step 3 first).
flowchart LR
X["📗 Inscription aux modules.xlsx<br/>(read-only)"] -->|students_from_xlsx.py| S["students.csv"]
X -->|students_from_xlsx.py| E["enrolments.csv<br/>email, module, mark"]
S -->|script_check_users.php| N["students_new.csv<br/>+ report: existing / to decide"]
N -->|uploaduser.php| U["👤 accounts<br/>auth=shibboleth"]
E -->|script_enrol_students.php| C["🎓 student in each<br/>module's course"]
U --> C
Twice a year, once the secretariat's workbook is final. The ISC-specific run (where the workbook is, the exact commands on our Moodle host, what was left open last time) is in the ISC³ ops docs: ISC Learn — registering students each semester. Nothing here modifies the workbook, and every step can be re-run: a second pass creates nothing and reports "already there".
Before starting
- The courses of the new academic year must exist (run the Yearly archive first in summer): a module whose course is still in Archive is reported as "module sans cours" and skipped.
- Moodle 5.x: the web root is
public/, so the upload tool ispublic/admin/tool/uploaduser/cli/uploaduser.php; on 4.x drop thepublic/. - The PHP scripts need
config.php: copy them into the Moodle root, or leave them anywhere andexport MOODLE_ROOT=/path/to/moodle. Run them aswww-data; files in/tmpmust be readable by that user.
Run
| # | Where | Command | Read the output for |
|---|---|---|---|
| 1 | your machine | ./students_from_xlsx.py "<workbook>.xlsx" --out-dir out/ |
the per-module counts, the semesters: line (each module code must carry S1…S6/SS1/SS2), and the Rows skipped list (bad or duplicate e-mails → fix the workbook, re-run) |
| 2 | Moodle host | php script_check_users.php students.csv |
À arbitrer: homonym with another address, suspended or deleted account. Decide by hand (fix the workbook or Moodle), re-run until the list is empty or only holds cases you accept. Exit 2 while something is listed. |
| 3 | Moodle host | php public/admin/tool/uploaduser/cli/uploaduser.php --file=students_new.csv --delimiter_name=comma --uutype=0 --uuforcepasswordchange=0 --uupasswordnew=1 |
Utilisateurs créés: N, Erreurs: 0. One "plugin d'authentification non pris en charge" warning per line is expected for shibboleth. |
| 4 | Moodle host | php script_enrol_students.php enrolments.csv --dry-run --semester=S1,S3,S5 |
the plan: per-course counts, Comptes introuvables (must be empty after step 3), Modules sans cours, Marques incertaines |
| 5 | Moodle host | same without --dry-run |
Inscriptions effectuées: N; then re-run step 4: it must announce À inscrire : 0 |
Autumn is --semester=S1,S3,S5, spring --semester=S2,S4,S6; add SS1,SS2 when the summer schools open. Without --semester every marked module is enrolled.
Things that bit us
--uupasswordnew=0makes the upload tool demand apasswordcolumn even for external auth;1is required by the form and is a no-op forshibboleth(no password generated, no mail sent — the stored password is thenot cachedmarker).- Any forced password change (
--uuforcepasswordchangeother than0) locks Shibboleth users out. - Uncertain marks (
X?,?) are never enrolled unless--include-uncertain; the report lists them so the secretariat can settle them. - Yearly modules with a spring label in the workbook (the bachelor thesis is under S6) are skipped by the autumn filter — run them separately if they must open in autumn.
| Tool | Required for | Linux (Debian/Ubuntu) | macOS (Homebrew) |
|---|---|---|---|
| Moodle 4.x / 5.x | copy_helper, update_course, enrol_manual, tool_uploaduser APIs |
moodle.org | — |
| PHP CLI | running the PHP scripts on the Moodle host (same version as your Moodle) | apt install php-cli |
brew install php |
| uv | students_from_xlsx.py (fetches openpyxl on the fly, nothing to install) |
astral.sh/uv | brew install uv |
| Moodle cron | executing the queued copy tasks | admin/cli/adhoc_task.php --execute or the site cron |
— |
Copyright © 2026 P.-A. Mudry / ISC — HES-SO Valais. Released under the MIT License: use it, adapt it to your own Moodle, and share it freely, as long as the copyright notice stays.
Made with ♥ by mui, 2026