Add enhanced SDS sync functions#3071
Conversation
This commit adds a new configuration option to suspend user enrolments instead of completely unenrolling them when users are removed from SDS classes. This preserves user data such as grades, submissions, and activity completion. Key changes: - Added 'sdssuspendenrolment' configuration setting (defaults to enabled) - Modified SDS sync to suspend enrolments when users are removed from SDS classes (if setting is enabled) - Added logic to reactivate suspended users when they are added back to SDS classes - Teachers are never suspended/unenrolled by SDS sync - Falls back to complete unenrolment if setting is disabled (existing behavior)
- Add support for course start/end dates from SDS term data - Add subject-based course categorization option - Add expired course filtering (prefix-based and date-based) - Add configurable expired course prefix setting - Auto-update course dates when changed in SDS - Create subject categories as children of school categories - Extract subject from externalName or classCode fields - Add comprehensive settings and language strings"
This commit adds the ability to create and synchronise Moodle cohorts from SDS classes, enabling site-wide grouping and enrolment features for synchronised classes. Key changes: - Added get_or_create_class_cohort() function to create cohorts from SDS classes with proper category context - Added sync_cohort_members() function to synchronise cohort membership from SDS class members (teachers and students) - Integrated cohort sync in main SDS sync loop - Cohorts tracked in local_o365_objects table for management - Added 'sdscreatecohorts' configuration setting (defaults to disabled) - Added 'sdscohortincludeteachers' setting to control teacher inclusion - Automatic cohort membership updates when users join/leave SDS classes - Cohort name truncation to 254 characters (database limit) - Proper @odata.type detection for teacher vs student classification Benefits: - Enables site-wide enrolment from SDS classes via cohorts - Provides alternative grouping mechanism independent of course enrollment - Automatic membership sync keeps cohorts up-to-date with SDS - Teacher inclusion configurable based on institution needs - Cohorts created in proper course category context - Supports bulk operations and cross-course features - Backwards compatible - feature disabled by default
This commit adds a new configuration option to enable or disable two-way synchronization between SDS courses and Microsoft 365 groups. This gives administrators control over whether course content should sync with Teams and other Office 365 services.
Key changes:
- Added 'sdsenablecoursesync' configuration setting (defaults to disabled)
- Modified SDS sync to conditionally associate courses with M365 groups
based on the setting
- Two-way sync only enabled when setting is active
- Existing two-way sync infrastructure remains unchanged when enabled
There was a problem hiding this comment.
Pull request overview
This pull request adds enhanced synchronization capabilities for School Data Sync (SDS) integration with Microsoft 365. The changes introduce several new features to improve course organization, manage course lifecycle, enable cohort synchronization, and provide more flexible user enrolment management.
Changes:
- Added settings for categorizing courses by subject, filtering expired courses, creating cohorts from SDS classes, and suspending rather than unenrolling users
- Implemented course date extraction from SDS term information with automatic updates for existing courses
- Added cohort creation and membership synchronization functionality with configurable teacher inclusion
- Implemented enrolment suspension/reactivation logic to preserve user data when users are temporarily removed from SDS classes
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| local/o365/settings.php | Adds 8 new configuration settings for SDS sync features including categorization, expiration handling, cohort sync, and enrolment suspension |
| local/o365/lang/en/local_o365.php | Adds comprehensive language strings and descriptions for all new settings |
| local/o365/classes/feature/sds/task/sync.php | Implements all new SDS sync features including subject categorization, expired course filtering, course date handling, two-way sync configuration, cohort creation/sync, and enrolment suspension/reactivation logic |
Comments suppressed due to low confidence (9)
local/o365/classes/feature/sds/task/sync.php:583
- Missing null check for enrol plugin before calling unenrol_user(). The code should verify that enrol_get_plugin() returned a valid plugin before calling methods on it, similar to the check on line 562-563 for the suspension case. If the enrol plugin type has been disabled or removed, enrol_get_plugin() may return null, which would cause a fatal error when attempting to call unenrol_user().
$enrolplugin = enrol_get_plugin($enrols[$userenrolment->enrolid]->enrol);
$enrolplugin->unenrol_user($enrols[$userenrolment->enrolid], $userid);
local/o365/lang/en/local_o365.php:664
- Inconsistent spelling: "enrollment" is used in the description text, but the codebase consistently uses British English spelling "enrolment" elsewhere. The word "enrollment" should be changed to "enrolment" to maintain consistency with the rest of the codebase.
$string['settings_sds_cohortsync_desc'] = 'These options control creating and synchronizing cohorts from SDS classes. Cohorts provide an alternative way to group users that can be used for site-wide enrollment or other Moodle features.';
local/o365/classes/feature/sds/task/sync.php:334
- Configuration value 'local_o365/sdsenablecoursesync' is fetched inside the schoolclasses loop. This value should be fetched once before the loop (similar to how sdscategorizebysubject, sdsignorepastcourses, and sdsexpiredprefix are fetched at lines 264-266) to avoid unnecessary repeated database queries for the same configuration value.
$sdsenablecoursesync = get_config('local_o365', 'sdsenablecoursesync');
local/o365/classes/feature/sds/task/sync.php:376
- Configuration values 'local_o365/sdscreatecohorts' and 'local_o365/sdscohortincludeteachers' are fetched inside the schoolclasses loop. These values should be fetched once before the loop (similar to how sdscategorizebysubject, sdsignorepastcourses, and sdsexpiredprefix are fetched at lines 264-266) to avoid unnecessary repeated database queries for the same configuration values.
$sdscreatecohorts = get_config('local_o365', 'sdscreatecohorts');
if ($sdscreatecohorts) {
static::mtrace('Running cohort sync', 4);
// Create or get the cohort for this class.
$cohort = static::get_or_create_class_cohort(
$schoolclass['id'],
$schoolclass['mailNickname'],
$schoolclass['displayName'],
$coursecat->id
);
// Sync cohort membership.
$includeteachers = get_config('local_o365', 'sdscohortincludeteachers');
local/o365/classes/feature/sds/task/sync.php:534
- Configuration value 'local_o365/sdssuspendenrolment' is fetched inside the schoolclasses loop (and potentially inside a user removal loop). This value should be fetched once before the loop to avoid unnecessary repeated database queries for the same configuration value.
$suspendusers = get_config('local_o365', 'sdssuspendenrolment');
local/o365/classes/feature/sds/task/sync.php:876
- The new SDS sync features (cohort sync, enrolment suspension, expired course filtering, subject categorization) introduce significant new functionality with complex logic and multiple configuration options, but there are no corresponding PHPUnit tests. The codebase has comprehensive test coverage for other features like usersync, coursesync, and observers. Consider adding unit tests to cover key scenarios such as: cohort creation and member sync, enrolment suspension and reactivation, expired course filtering by prefix and date, subject category creation, and course date updates. This would help ensure the reliability of these features and prevent regressions.
/**
* Get or create a course category for a subject within a school.
*
* @param string $subjectname The name of the subject.
* @param int $parentcategoryid The ID of the parent category (usually school category).
* @return core_course_category A course category object for the retrieved or created course category.
*/
public static function get_or_create_subject_coursecategory(string $subjectname, int $parentcategoryid): core_course_category {
global $DB;
// Look for existing category by name and parent.
$params = ['name' => $subjectname, 'parent' => $parentcategoryid];
$existingcat = $DB->get_record('course_categories', $params);
if (!empty($existingcat)) {
return core_course_category::get($existingcat->id);
}
// Create new course category.
$data = ['visible' => 1, 'name' => $subjectname, 'parent' => $parentcategoryid];
if (strlen($data['name']) > 255) {
static::mtrace('Subject name was over 255 chars when creating course category, truncating to 255.', 4);
$data['name'] = substr($data['name'], 0, 255);
}
$coursecat = core_course_category::create($data);
static::mtrace('Created subject category: ' . $subjectname, 4);
return $coursecat;
}
/**
* Get or create a cohort for a SDS class.
*
* @param string $classobjectid The object ID of the class.
* @param string $shortname The shortname for the cohort.
* @param string $fullname The full name for the cohort.
* @param int $categoryid The ID of the category (for context).
* @return object The cohort object.
*/
public static function get_or_create_class_cohort(
string $classobjectid,
string $shortname,
string $fullname,
int $categoryid
): object {
global $DB, $CFG;
require_once($CFG->dirroot . '/cohort/lib.php');
// Look for existing cohort with the class object ID as idnumber.
$cohort = $DB->get_record('cohort', ['idnumber' => $classobjectid]);
if (!empty($cohort)) {
static::mtrace('Found existing cohort for ' . $fullname, 4);
return $cohort;
}
// Create new cohort.
$catcontext = \context_coursecat::instance($categoryid);
$cohortdata = [
'idnumber' => $classobjectid,
'name' => substr($fullname, 0, 254), // Cohort name max length is 254.
'contextid' => $catcontext->id,
'description' => 'Cohort synced from SDS class',
'descriptionformat' => FORMAT_HTML,
];
$cohortid = cohort_add_cohort((object) $cohortdata);
$cohort = $DB->get_record('cohort', ['id' => $cohortid]);
static::mtrace('Created cohort for ' . $fullname, 4);
// Track this cohort in local_o365_objects.
$now = time();
$objectrec = [
'type' => 'sdssection',
'subtype' => 'cohort',
'objectid' => $classobjectid,
'moodleid' => $cohort->id,
'o365name' => $shortname,
'tenant' => '',
'timecreated' => $now,
'timemodified' => $now,
];
$DB->insert_record('local_o365_objects', $objectrec);
return $cohort;
}
/**
* Sync cohort membership from SDS class members.
*
* @param object $cohort The cohort object.
* @param array $classmembers Array of class members from Microsoft Graph API.
* @param bool $includeteachers Whether to include teachers in the cohort.
* @return void
*/
public static function sync_cohort_members(object $cohort, array $classmembers, bool $includeteachers): void {
global $DB, $CFG;
require_once($CFG->dirroot . '/cohort/lib.php');
// Get current cohort members.
$currentmembers = $DB->get_records('cohort_members', ['cohortid' => $cohort->id], '', 'userid, id');
$currentmemberids = array_keys($currentmembers);
$newmemberids = [];
// Process each class member.
foreach ($classmembers as $classmember) {
// Get Moodle user ID from Office 365 object ID.
$objectrec = $DB->get_record('local_o365_objects', ['type' => 'user', 'objectid' => $classmember['id']]);
if (empty($objectrec)) {
continue;
}
// Check if this is a teacher.
$isteacher = false;
if (isset($classmember['@odata.type'])) {
$isteacher = (strpos($classmember['@odata.type'], 'teacher') !== false);
}
// Skip teachers if configured.
if ($isteacher && !$includeteachers) {
continue;
}
$newmemberids[] = $objectrec->moodleid;
// Add to cohort if not already a member.
if (!in_array($objectrec->moodleid, $currentmemberids)) {
static::mtrace('Adding user ' . $objectrec->moodleid . ' to cohort ' . $cohort->id, 5);
cohort_add_member($cohort->id, $objectrec->moodleid);
}
}
// Remove members who are no longer in the SDS class.
$memberstoremove = array_diff($currentmemberids, $newmemberids);
foreach ($memberstoremove as $userid) {
static::mtrace('Removing user ' . $userid . ' from cohort ' . $cohort->id, 5);
cohort_remove_member($cohort->id, $userid);
}
}
local/o365/classes/feature/sds/task/sync.php:283
- strtotime() may return false if the date string is invalid or malformed. This could cause issues when comparing with time() at line 294 or when passing to update_course() later. Consider adding validation to ensure strtotime() returned a valid timestamp before using it, or use a more robust date parsing method.
if (isset($schoolclass['term']['startDate'])) {
$classstartdate = strtotime($schoolclass['term']['startDate']);
}
if (isset($schoolclass['term']['endDate'])) {
$classenddate = strtotime($schoolclass['term']['endDate']);
}
local/o365/lang/en/local_o365.php:667
- Inconsistent spelling: "enrollment" is used in the description text, but the codebase consistently uses British English spelling "enrolment" elsewhere (e.g., in setting keys like 'sdssuspendenrolment', in function names, and in other language strings). The word "enrollment" should be changed to "enrolment" to maintain consistency.
<b>Note:</b> Cohorts are created in addition to courses, not instead of them. This allows you to use cohorts for site-wide enrollment or grouping while still maintaining SDS-synced courses.';
local/o365/classes/feature/sds/task/sync.php:501
- Fetching all enrol instances for every student user in the reactivation check is inefficient. The code iterates through all enrol instances to find the user's enrolment record, but this repeats for each student. Consider fetching user_enrolments directly with a query that filters by userid and courseid, or cache the enrol instances outside the student loop to avoid repeated database queries.
$enrolinstances = $DB->get_records('enrol', ['courseid' => $course->id]);
foreach ($enrolinstances as $enrolinstance) {
$userenrolment = $DB->get_record(
'user_enrolments',
['enrolid' => $enrolinstance->id, 'userid' => $objectrec->moodleid]
);
if ($userenrolment && $userenrolment->status == ENROL_USER_SUSPENDED) {
static::mtrace('Reactivating suspended user ' . $objectrec->moodleid .
' in course ' . $course->id, 5);
$enrolplugin = enrol_get_plugin($enrolinstance->enrol);
if ($enrolplugin && method_exists($enrolplugin, 'update_user_enrol')) {
$enrolplugin->update_user_enrol(
$enrolinstance,
$objectrec->moodleid,
ENROL_USER_ACTIVE
);
}
}
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
No description provided.