From afafd4780d8e81f82837835811cb94035fe751f6 Mon Sep 17 00:00:00 2001
From: ben <ben.walpole@jmaconsulting.biz>
Date: Fri, 14 Mar 2025 12:50:54 +0000
Subject: [PATCH 1/7] update civix version

---
 info.xml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/info.xml b/info.xml
index 10dd804..d9f7376 100644
--- a/info.xml
+++ b/info.xml
@@ -25,7 +25,7 @@
     Information related to the completion of Moodle units is pulled back to CiviCRM.</comments>
   <civix>
     <namespace>CRM/Civimoodle</namespace>
-    <format>24.09.1</format>
+    <format>25.01.1</format>
     <angularModule>crmCivimoodle</angularModule>
   </civix>
   <mixins>
-- 
GitLab


From 73ab907eb1e18afbbfd92997a0d6bc4efc632896 Mon Sep 17 00:00:00 2001
From: ben <ben.walpole@jmaconsulting.biz>
Date: Thu, 20 Mar 2025 13:49:52 +0000
Subject: [PATCH 2/7] fix duplicate menu item

---
 civimoodle.php | 17 -----------------
 1 file changed, 17 deletions(-)

diff --git a/civimoodle.php b/civimoodle.php
index 07b7e79..415c104 100644
--- a/civimoodle.php
+++ b/civimoodle.php
@@ -409,20 +409,3 @@ function civimoodle_civicrm_tabset($tabsetName, &$tabs, $context) {
     'weight' => 200,
   ];
 }
-
-/**
- * Implements hook_civicrm_navigationMenu().
- *
- * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_navigationMenu/
- */
-function civimoodle_civicrm_navigationMenu(&$menu) {
-  _civimoodle_civix_insert_navigation_menu($menu, 'Administer/System Settings', [
-    'label' => E::ts('Moodle integration'),
-    'name' => 'moodle_admin',
-    'url' => 'civicrm/admin/setting/moodle',
-    'permission' => 'administer CiviCRM',
-    'operator' => 'OR',
-    'separator' => 0,
-  ]);
-  _civimoodle_civix_navigationMenu($menu);
-}
-- 
GitLab


From 61a77c7259606d768dec0884fc78cda055fbbd95 Mon Sep 17 00:00:00 2001
From: ben <ben.walpole@jmaconsulting.biz>
Date: Thu, 20 Mar 2025 13:50:28 +0000
Subject: [PATCH 3/7] add check course status to api helper

---
 CRM/Civimoodle/API.php | 49 +++++++++++++++++++++++++++++++-----------
 1 file changed, 37 insertions(+), 12 deletions(-)

diff --git a/CRM/Civimoodle/API.php b/CRM/Civimoodle/API.php
index 36a70e5..6aa6a6b 100644
--- a/CRM/Civimoodle/API.php
+++ b/CRM/Civimoodle/API.php
@@ -100,6 +100,24 @@ class CRM_Civimoodle_API {
     return $this->sendRequest('enrol_manual_enrol_users');
   }
 
+  /**
+   * Function to core_completion_get_course_completion_status from the moodle server
+   *
+   * NOTE: passing search params here is different from other functions, but the params
+   * are requied
+   *
+   * @param $courseId the course id to get status for
+   * @param $userId the user id to get status for
+   *
+   * @return array the api result
+   */
+  public function getCourseCompletionStatus($courseId, $userId) {
+    return $this->sendRequest('core_completion_get_course_completion_status', [
+      'courseid' => $courseId,
+      'userid' => $userId,
+    ]);
+  }
+
   /**
    * Function used to make Moodle API request
    *
@@ -108,7 +126,7 @@ class CRM_Civimoodle_API {
    *
    * @return array
    */
-  public function sendRequest($apiFunc) {
+  public function sendRequest($apiFunc, array $apiParams = []) {
     $searchArgs = array(
       'wstoken=' . $this->_wsToken,
       'wsfunction=' . $apiFunc,
@@ -118,30 +136,37 @@ class CRM_Civimoodle_API {
     switch ($apiFunc) {
       case 'core_user_get_users':
         // expects search params to be in array('key' => 'firstname', 'value' => 'Adam') format
-        foreach (array('key', 'value') as $arg) {
+        foreach (['key', 'value'] as $arg) {
           $searchArgs[] = "criteria[0][$arg]=" . $this->_searchParams[$arg];
         }
         break;
 
       case 'core_user_create_users':
-      foreach (array('username', 'password', 'firstname', 'lastname', 'email') as $arg) {
-        $searchArgs[] = "users[0][$arg]=" . $this->_searchParams[$arg];
-      }
-      break;
+        foreach (['username', 'password', 'firstname', 'lastname', 'email'] as $arg) {
+          $searchArgs[] = "users[0][$arg]=" . $this->_searchParams[$arg];
+        }
+        break;
 
       case 'core_user_update_users':
-      foreach (array('id', 'firstname', 'lastname', 'email') as $arg) {
-        if (!empty($this->_searchParams[$arg])) {
-          $searchArgs[] = "users[0][$arg]=" . $this->_searchParams[$arg];
+        foreach (['id', 'firstname', 'lastname', 'email'] as $arg) {
+          if (!empty($this->_searchParams[$arg])) {
+            $searchArgs[] = "users[0][$arg]=" . $this->_searchParams[$arg];
+          }
         }
-      }
-      break;
+        break;
 
       case 'enrol_manual_enrol_users':
-        foreach (array('roleid', 'userid', 'courseid') as $arg) {
+        foreach (['roleid', 'userid', 'courseid'] as $arg) {
           $searchArgs[] = "enrolments[0][$arg]=" . $this->_searchParams[$arg];
         }
         break;
+
+      case 'core_completion_get_course_completion_status':
+        foreach (['userid', 'courseid'] as $arg) {
+          $searchArgs[] = "$arg=" . $apiParams[$arg];
+        }
+        break;
+
       default:
         //do nothing
         break;
-- 
GitLab


From 902808cf5abba1f6375f5a27f51febd8ffc9484d Mon Sep 17 00:00:00 2001
From: ben <ben.walpole@jmaconsulting.biz>
Date: Thu, 20 Mar 2025 13:50:55 +0000
Subject: [PATCH 4/7] add custom fieldset for moodle enrollments

---
 ...stomGroup_moodle_course_enrollment.mgd.php | 137 ++++++++++++++++++
 1 file changed, 137 insertions(+)
 create mode 100644 managed/CustomGroup_moodle_course_enrollment.mgd.php

diff --git a/managed/CustomGroup_moodle_course_enrollment.mgd.php b/managed/CustomGroup_moodle_course_enrollment.mgd.php
new file mode 100644
index 0000000..40d9d33
--- /dev/null
+++ b/managed/CustomGroup_moodle_course_enrollment.mgd.php
@@ -0,0 +1,137 @@
+<?php
+use CRM_Civimoodle_ExtensionUtil as E;
+
+return [
+  [
+    'name' => 'CustomGroup_moodle_course_enrollment',
+    'entity' => 'CustomGroup',
+    'cleanup' => 'unused',
+    'update' => 'unmodified',
+    'params' => [
+      'version' => 4,
+      'values' => [
+        'name' => 'moodle_course_enrollment',
+        'title' => E::ts('Moodle Courses'),
+        'style' => 'Tab with table',
+        'weight' => 6,
+        'is_multiple' => TRUE,
+        'collapse_adv_display' => TRUE,
+        'created_date' => '2025-03-20 06:11:05',
+      ],
+      'match' => ['name'],
+    ],
+  ],
+  [
+    'name' => 'CustomGroup_moodle_course_enrollments_CustomField_course_id',
+    'entity' => 'CustomField',
+    'cleanup' => 'unused',
+    'update' => 'unmodified',
+    'params' => [
+      'version' => 4,
+      'values' => [
+        'custom_group_id.name' => 'moodle_course_enrollment',
+        'name' => 'course_id',
+        'label' => E::ts('Course'),
+        'data_type' => 'Int',
+        'html_type' => 'Select',
+        'is_required' => TRUE,
+        'in_selector' => TRUE,
+        'option_group_id.name' => 'available_courses',
+      ],
+      'match' => [
+        'name',
+        'custom_group_id',
+      ],
+    ],
+  ],
+  [
+    'name' => 'CustomGroup_moodle_course_enrollments_CustomField_enroll_date',
+    'entity' => 'CustomField',
+    'cleanup' => 'unused',
+    'update' => 'unmodified',
+    'params' => [
+      'version' => 4,
+      'values' => [
+        'custom_group_id.name' => 'moodle_course_enrollment',
+        'name' => 'enroll_date',
+        'label' => E::ts('Enrollment Date'),
+        'data_type' => 'Date',
+        'html_type' => 'Select Date',
+        'is_required' => TRUE,
+        'date_format' => 'dd/mm/yy',
+        'in_selector' => TRUE,
+      ],
+      'match' => [
+        'name',
+        'custom_group_id',
+      ],
+    ],
+  ],
+  [
+    'name' => 'CustomGroup_moodle_course_enrollments_CustomField_completion_date',
+    'entity' => 'CustomField',
+    'cleanup' => 'unused',
+    'update' => 'unmodified',
+    'params' => [
+      'version' => 4,
+      'values' => [
+        'custom_group_id.name' => 'moodle_course_enrollment',
+        'name' => 'completion_date',
+        'label' => E::ts('Completion Date'),
+        'data_type' => 'Date',
+        'html_type' => 'Select Date',
+        'date_format' => 'dd/mm/yy',
+        'in_selector' => TRUE,
+      ],
+      'match' => [
+        'name',
+        'custom_group_id',
+      ],
+    ],
+  ],
+  [
+    'name' => 'CustomGroup_moodle_course_enrollments_CustomField_registration_participant_id',
+    'entity' => 'CustomField',
+    'cleanup' => 'unused',
+    'update' => 'unmodified',
+    'params' => [
+      'version' => 4,
+      'values' => [
+        'custom_group_id.name' => 'moodle_course_enrollment',
+        'name' => 'courseregistration_participant_id',
+        'label' => E::ts('Participant ID'),
+        'data_type' => 'EntityReference',
+        'html_type' => 'Autocomplete-Select',
+        'fk_entity' => 'Participant',
+        'is_required' => TRUE,
+      ],
+      'match' => [
+        'name',
+        'custom_group_id',
+      ],
+    ],
+  ],
+  [
+    'name' => 'CustomGroup_moodle_course_enrollments_CustomField_completed_by',
+    'entity' => 'CustomField',
+    'cleanup' => 'unused',
+    'update' => 'unmodified',
+    'params' => [
+      'version' => 4,
+      'values' => [
+        'custom_group_id.name' => 'moodle_course_enrollment',
+        'name' => 'completed_by',
+        'label' => E::ts('Completed By'),
+        'data_type' => 'EntityReference',
+        'html_type' => 'Autocomplete-Select',
+        'fk_entity' => 'Contact',
+        'is_required' => TRUE,
+        'in_selector' => TRUE,
+      ],
+      'match' => [
+        'name',
+        'custom_group_id',
+      ],
+    ],
+  ],
+];
-- 
GitLab


From cff1a5417c84285ad5b60f7962222ead6a5ac517 Mon Sep 17 00:00:00 2001
From: ben <ben.walpole@jmaconsulting.biz>
Date: Thu, 20 Mar 2025 14:32:45 +0000
Subject: [PATCH 5/7] add hook to create enrollments when participants are
 registered

---
 CRM/Civimoodle/Util.php | 16 ++++++++++++++++
 civimoodle.php          | 24 +++++++++++++++---------
 2 files changed, 31 insertions(+), 9 deletions(-)

diff --git a/CRM/Civimoodle/Util.php b/CRM/Civimoodle/Util.php
index fe29177..869af75 100644
--- a/CRM/Civimoodle/Util.php
+++ b/CRM/Civimoodle/Util.php
@@ -203,4 +203,20 @@ class CRM_Civimoodle_Util {
     }
     return $options;
   }
+
+  public static function createLocalEnrollmentRecords(int $contactID, array $courses, int $participantID, $date = 'now') {
+    $enrollmentCreate = \Civi\Api4\CustomValue::save('moodle_course_enrollment', FALSE);
+
+    foreach ($courses as $course) {
+      $enrollmentCreate->addRecord([
+        'course_id' => $course,
+        'entity_id' => $contactID,
+        'registration_participant_id' => $participantID,
+        'enroll_date' => $date,
+      ]);
+    }
+
+    $enrollmentCreate->execute();
+  }
+
 }
diff --git a/civimoodle.php b/civimoodle.php
index 415c104..6ca14d8 100644
--- a/civimoodle.php
+++ b/civimoodle.php
@@ -53,14 +53,15 @@ function civimoodle_civicrm_container(\Symfony\Component\DependencyInjection\Con
  * @link http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_fieldOptions
  */
 function civimoodle_civicrm_fieldOptions($entity, $field, &$options, $params) {
-  if ($entity == 'Event') {
-    // TODO: This backward-compat for legacy custom field names can be removed when dropping support for CiviCRM < 5.79
-    if ($field == 'moodle_courses.courses' || $field == CRM_Civimoodle_Util::getCustomFieldKey('courses')) {
-      // fetch available Moodle courses in array('id' => 'fullname') format
-      $courses = CRM_Civimoodle_Util::getAvailableCourseNames();
-      if (isset($courses) && count($courses)) {
-        $options = $courses;
-      }
+  // TODO: This backward-compat for legacy custom field names can be removed when dropping support for CiviCRM < 5.79
+  if (
+    (($entity === 'Event') && ($field == 'moodle_courses.courses' || $field == CRM_Civimoodle_Util::getCustomFieldKey('courses')))
+    || ($field === 'moodle_course_enrollment.course_id')
+  ) {
+    // fetch available Moodle courses in array('id' => 'fullname') format
+    $courses = CRM_Civimoodle_Util::getAvailableCourseNames();
+    if (isset($courses) && count($courses)) {
+      $options = $courses;
     }
   }
 }
@@ -116,6 +117,11 @@ function civimoodle_civicrm_post($op, $objectName, $objectId, &$objectRef) {
     // fetch courses from given event ID
     $courses = CRM_Civimoodle_Util::getCoursesFromEvent($objectRef->event_id);
     if (isset($courses) && count($courses) > 0) {
+
+      \CRM_Civimoodle_Util::createLocalEnrollmentRecords($objectRef->contact_id, $courses, $objectId, $objectRef->register_date);
+
+      // now register the partipant at Moodle
+
       //Changed 'user_load' to directory '\Drupal\user\Entity\User' for Drupal 10 compatibility
       if (Civi::settings()->get('moodle_cms_credential')) {
         $user = \Drupal\user\Entity\User::load($ufID);
@@ -322,7 +328,7 @@ function updateCMSUserDetails($ufID, $contactParams, $create = FALSE){
 
       return get_userdata($ufID);
       break;
-    
+
     /* For Joomla implementation when needed
     case 'Joomla':
     break;
-- 
GitLab


From 3054ec94e5762946926432c8c6018e7437d4f1d3 Mon Sep 17 00:00:00 2001
From: ben <ben.walpole@jmaconsulting.biz>
Date: Thu, 20 Mar 2025 14:34:58 +0000
Subject: [PATCH 6/7] add upgrader for backfilling moodle registration records

---
 CRM/Civimoodle/Upgrader.php | 26 ++++++++++++++++++++++++++
 1 file changed, 26 insertions(+)

diff --git a/CRM/Civimoodle/Upgrader.php b/CRM/Civimoodle/Upgrader.php
index 9836fe6..50ad961 100644
--- a/CRM/Civimoodle/Upgrader.php
+++ b/CRM/Civimoodle/Upgrader.php
@@ -32,4 +32,30 @@ class CRM_Civimoodle_Upgrader extends CRM_Extension_Upgrader_Base {
     return TRUE;
   }
 
+  /**
+   * Add enrollment records for pre-existing registrations
+   */
+  public function upgrade_1010() {
+    $participants = \Civi\Api4\Participant::get(FALSE)
+      ->addSelect('id', 'contact_id', 'moodle_courses.courses', 'register_date')
+      ->addWhere('moodle_courses.courses', 'IS NOT EMPTY')
+      ->execute();
+
+    foreach ($participants as $participant) {
+      try {
+        \CRM_Civimoodle_Util::createLocalEnrollmentRecords(
+          $participant['contact_id'],
+          $participant['moodle_courses.courses'],
+          $participant['id'],
+          $participant['register_date']
+        );
+      }
+      catch (\Exception $e) {
+        \Civi::log()->debug('Error creating Moodle registration records for participant ID ' . $participant['id']);
+      }
+    }
+
+    return TRUE;
+  }
+
 }
-- 
GitLab


From 44d738c311b8ca18615a3c417355cdfe7f0bb8c9 Mon Sep 17 00:00:00 2001
From: ben <ben.walpole@jmaconsulting.biz>
Date: Thu, 20 Mar 2025 13:52:46 +0000
Subject: [PATCH 7/7] add job to fetch course completions

---
 CRM/Civimoodle/FetchCourseCompletions.php | 101 ++++++++++++++++++++++
 api/v3/Job/Fetchcoursecompletions.mgd.php |  21 +++++
 api/v3/Job/Fetchcoursecompletions.php     |  35 ++++++++
 3 files changed, 157 insertions(+)
 create mode 100644 CRM/Civimoodle/FetchCourseCompletions.php
 create mode 100644 api/v3/Job/Fetchcoursecompletions.mgd.php
 create mode 100644 api/v3/Job/Fetchcoursecompletions.php

diff --git a/CRM/Civimoodle/FetchCourseCompletions.php b/CRM/Civimoodle/FetchCourseCompletions.php
new file mode 100644
index 0000000..ebaf08b
--- /dev/null
+++ b/CRM/Civimoodle/FetchCourseCompletions.php
@@ -0,0 +1,101 @@
+<?php
+
+class CRM_Civimoodle_FetchCourseCompletions {
+
+  public static function run() {
+    $results = [];
+    $counts = [
+      'completed' => 0,
+      'errors' => 0,
+    ];
+
+    $enrollmentsToCheck = \Civi\Api4\CustomValue::get('moodle_course_enrollment', FALSE)
+      ->addWhere('completion_date', 'IS EMPTY')
+      ->addSelect('id', 'course_id', 'entity_id', 'entity_id.moodle_credential.user_id')
+      ->execute();
+
+    foreach ($enrollmentsToCheck as $enrollment) {
+      $result = self::checkAndUpdateEnrollment($enrollment);
+      $results[] = $result;
+
+      if ($result['is_complete']) {
+        $counts['completed'] += 1;
+      }
+      if ($result['is_error']) {
+        $counts['errors'] += 1;
+      }
+    }
+
+    // add the total count
+    $counts['checked'] = count($results);
+
+    return [
+      'records' => $results,
+      'counts' => $counts,
+    ];
+  }
+
+  protected static function checkAndUpdateEnrollment(array $enrollment) {
+    $enrollmentId = $enrollment['id'];
+    $courseId = $enrollment['course_id'];
+    $moodleUserId = $enrollment['entity_id.moodle_credential.user_id'];
+
+    try {
+      $remoteDetails = \CRM_Civimoodle_API::singleton()->getCourseCompletionStatus($courseId, $moodleUserId);
+
+      $parsedDetails = json_decode($remoteDetails[1], TRUE)['completionstatus'];
+
+      if ($parsedDetails['completed'] ?? FALSE) {
+
+        $completionDate = self::extractCompletionDate($parsedDetails['completions'] ?? []) ?? 'now';
+
+        // unfortunately moodle api doesn't seem to provide a user
+        // $completedBy = self::getCompletedBy($parsedDetails['completions']);
+
+        $updateEnrollment = \Civi\Api4\CustomValue::update('moodle_course_enrollment', FALSE)
+          ->addWhere('id', '=', $enrollmentId)
+          ->addValue('completion_date', $completionDate);
+
+        if ($completedBy) {
+          $updateEnrollment->addValue('completed_by', $completedBy);
+        }
+
+        $updateEnrollment->execute();
+
+        return [
+          'id' => $enrollmentId,
+          'is_complete' => TRUE,
+          'is_error' => FALSE,
+        ];
+      }
+
+      return [
+        'id' => $enrollmentId,
+        'is_complete' => FALSE,
+        'is_error' => FALSE,
+      ];
+    }
+    catch (\Exception $e) {
+      return [
+        'id' => $enrollment,
+        'is_error' => TRUE,
+        'error_message' => $e->getMessage(),
+        'is_complete' => FALSE,
+      ];
+    }
+  }
+
+  /**
+   * Try to find a completion date in the Moodle API response
+   */
+  protected static function extractCompletionDate(array $completions) {
+    foreach ($completions as $completion) {
+      if ($completion['timecompleted']) {
+        return $completion['timecompleted'];
+      }
+    }
+
+    return NULL;
+  }
+
+}
diff --git a/api/v3/Job/Fetchcoursecompletions.mgd.php b/api/v3/Job/Fetchcoursecompletions.mgd.php
new file mode 100644
index 0000000..4a9c354
--- /dev/null
+++ b/api/v3/Job/Fetchcoursecompletions.mgd.php
@@ -0,0 +1,21 @@
+<?php
+
+// This file declares a managed database record of type "Job".
+// The record will be automatically inserted, updated, or deleted from the
+// database as appropriate. For more details, see "hook_civicrm_managed" at:
+// https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_managed
+return [
+  [
+    'name' => 'Cron:Job.Fetchcoursecompletions',
+    'entity' => 'Job',
+    'params' => [
+      'version' => 3,
+      'name' => 'Call Job.Fetchcoursecompletions API',
+      'description' => 'Call Job.Fetchcoursecompletions API',
+      'run_frequency' => 'Daily',
+      'api_entity' => 'Job',
+      'api_action' => 'Fetchcoursecompletions',
+      'parameters' => '',
+    ],
+  ],
+];
diff --git a/api/v3/Job/Fetchcoursecompletions.php b/api/v3/Job/Fetchcoursecompletions.php
new file mode 100644
index 0000000..8cbfb91
--- /dev/null
+++ b/api/v3/Job/Fetchcoursecompletions.php
@@ -0,0 +1,35 @@
+<?php
+use CRM_Civimoodle_ExtensionUtil as E;
+
+/**
+ * Job.Updatecoursestatus API specification (optional)
+ * This is used for documentation and validation.
+ *
+ * @param array $spec description of fields supported by this API call
+ *
+ * @see https://docs.civicrm.org/dev/en/latest/framework/api-architecture/
+ */
+function _civicrm_api3_job_Fetchcoursecompletions_spec(&$spec) {
+  // $spec['magicword']['api.required'] = 1;
+}
+
+/**
+ * Job.Updatecoursestatus API
+ *
+ * @param array $params
+ *
+ * @return array
+ *   API result descriptor
+ *
+ * @see civicrm_api3_create_success
+ *
+ * @throws CRM_Core_Exception
+ */
+function civicrm_api3_job_Fetchcoursecompletions($params) {
+  $result = \CRM_Civimoodle_FetchCourseCompletions::run();
+
+  if ($result['counts']['errors']) {
+    return civicrm_api3_create_error($result, $params, 'Job', 'Updatecoursestatus');
+  }
+  return civicrm_api3_create_success($result, $params, 'Job', 'Updatecoursestatus');
+}
-- 
GitLab