. /** * Webservice related to courses * @package local_treestudyplan * @copyright 2023 P.M. Kuipers * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace local_treestudyplan; defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir.'/externallib.php'); use local_treestudyplan\courseinfo; use local_treestudyplan\associationservice; use local_treestudyplan\local\helpers\webservicehelper; use local_treestudyplan\completionscanner; use local_treestudyplan\gradingscanner; use local_treestudyplan\debug; use core_course_category; use moodle_exception; /** * Webservice related to courses */ class courseservice extends \external_api { /** * Capability required to edit study plans * @var string */ const CAP_EDIT = "local/treestudyplan:editstudyplan"; /** * Capability required to view studyplans (for other users) * @var string */ const CAP_VIEW = "local/treestudyplan:viewuserreports"; /** * Get the topmost categories for the specicied user. * Most of the work is offloaded to an SQL query in the interest of speed, * but moodle functions are used to double check access permissions. * @param int $userid Id of the user * @return array of core_course_category */ public static function user_tops($userid=null, $capability='moodle/category:viewcourselist') { global $DB, $USER; if ($userid == null) { $userid = $USER->id; } $tops = []; if (has_capability($capability, \context_system::instance(), $userid)) { if ($capability == 'moodle/category:viewcourselist') { // We are now just looking for the visible main level categories. // Add all categories of depth = 1. $rs = $DB->get_recordset("course_categories", ["depth" => 1], 'sortorder'); foreach ($rs as $rcat) { // Get the category, and double check if the category is visible to the current user. // Just in case it is a hidden category and the user does not have the viewhidden permission. $cat = \core_course_category::get($rcat->id, \IGNORE_MISSING, false, $userid); if ($cat !== null) { // Register the category. array_push($tops, $cat); } } $rs->close(); } else { // Context is system, but system may not be visible. // Return the top visible categories for this user. // Recurses only once. return self::user_tops($userid); } } else { // We need to search for the permissions on an individial context level. // This part finds all top categories with a certain permission that are also visible for the user. $sql = "SELECT UNIQUE ctx.* FROM {context} ctx INNER JOIN {role_assignments} ra ON ra.contextid = ctx.id INNER JOIN {role_capabilities} rc ON ra.roleid = rc.roleid LEFT JOIN {course_categories} cat ON ctx.instanceid = cat.id WHERE ( ctx.contextlevel = :ctxl_coursecat ) AND ra.userid = :userid AND rc.capability = :capability ORDER BY ctx.depth ASC, cat.sortorder ASC"; // Use recordset to handle the eventuality of a really big and complex moodle setup. $recordset = $DB->get_records_sql($sql, ["userid" => $userid, "capability" => $capability, "ctxl_coursecat" => \CONTEXT_COURSECAT ]); $params = ["userid" => $userid, "capability" => $capability, "ctxl_coursecat" => \CONTEXT_COURSECAT ]; $contextids = []; foreach ($recordset as $r) { // Get the paths as an array. $parents = explode("/", $r->path); // Strip the first item, since it is an empty one. array_shift($parents); // Strip the last item, since it refers to self. array_pop($parents); // Figure out if any of the remaining parent contexts are already contexts with permission. $intersect = array_intersect($contextids, $parents); if (count($intersect) == 0) { // Double check permissions according to the moodle capability system. $ctx = \context::instance_by_id($r->id); if (has_capability($capability, $ctx, $userid)) { // Get the category, and double check if the category is visible to the current user. // Just in case it is a hidden category and the user does not have the viewhidden permission. $cat = \core_course_category::get($r->instanceid, \IGNORE_MISSING, false, $userid); if ($cat !== null) { // Register the context id in the list now, since we know the category is really visible. array_push($contextids, $r->id); // Register the category. array_push($tops, $cat); } else { // The category is not visible. Add the first known visible subcategories. $children = self::get_first_visible_children($r->id, $userid); foreach ($children as $cat) { array_push($tops, $cat); } } } } } } return $tops; } /** * Find the top-most child categories for a given category that are visible. * * @param int $parentid The category to search for * @return array of \core_course_category */ private static function get_first_visible_children($parentid, $userid) { global $DB; $capability = 'moodle/category:viewcourselist'; $tops = []; $pathlike = $DB->sql_like('ctx.path', ':pathsearch'); $sql = "SELECT UNIQUE ctx.* FROM {context} ctx INNER JOIN {role_assignments} ra ON ra.contextid = ctx.id INNER JOIN {role_capabilities} rc ON ra.roleid = rc.roleid LEFT JOIN {course_categories} cat ON ctx.instanceid = cat.id WHERE ( ctx.contextlevel = :ctxl_coursecat ) AND ra.userid = :userid AND rc.capability = :capability AND {$pathlike} ORDER BY ctx.depth ASC, cat.sortorder ASC"; // Use recordset to handle the eventuality of a really big and complex moodle setup. $recordset = $DB->get_recordset_sql($sql, ["userid" => $userid, "capability" => $capability, "ctxl_coursecat" => \CONTEXT_COURSECAT, "pathsearch" => "%/{$parentid}/%", ]); $contextids = []; foreach ($recordset as $r) { // Get the paths as an array. $parents = explode("/", $r->path); // Strip the first item, since it is an empty one. array_shift($parents); // Strip the last item, since it refers to self. array_pop($parents); // Figure out if any of the remaining parent contexts are already contexts with permission. $intersect = array_intersect($contextids, $parents); if (count($intersect) == 0) { // Double check permissions according to the moodle capability system. $ctx = \context::instance_by_id($r->id); if (has_capability($capability, $ctx, $userid)) { // Get the category, and double check if the category is visible to the current user. // Just in case it is a hidden category and the user does not have the viewhidden permission. $cat = \core_course_category::get($r->instanceid, \IGNORE_MISSING, false, $userid); if ($cat !== null) { // Register the context id in the list now, since we know the category is really visible. array_push($contextids, $r->id); // Register the category. array_push($tops, $cat); } } } } $recordset->close(); return $tops; } /** * Return value description for map_categories function */ public static function map_categories_parameters(): \external_function_parameters { return new \external_function_parameters( [ "studyplan_id" => new \external_value(PARAM_INT, 'ID of studyplan to map the categories for', VALUE_DEFAULT), ] ); } /** * Parameter description for map_categories function */ public static function map_categories_returns(): \external_description { return new \external_multiple_structure(static::map_category_structure(false)); } /** * Structure description for category map, used in a number of return descriptions * @param bool $lazy * @param int $value */ protected static function map_category_structure($lazy = false, $value = VALUE_REQUIRED) { $s = [ "id" => new \external_value(PARAM_INT, 'course category id'), "context_id" => new \external_value(PARAM_INT, 'course category context id'), "category" => contextinfo::structure(VALUE_OPTIONAL), "haschildren" => new \external_value(PARAM_BOOL, 'true if the category has child categories'), "hascourses" => new \external_value(PARAM_BOOL, 'true if the category contains courses'), "studyplancount" => new \external_value(PARAM_INT, 'number of linked studyplans', VALUE_OPTIONAL), ]; if (!$lazy > 0) { $s["courses"] = new \external_multiple_structure( courseinfo::editor_structure() ); $s["children"] = new \external_multiple_structure( static::map_category_structure(true)); } return new \external_single_structure($s, "CourseCat info", $value); } /** * Get a category map, and optionally specify a root category to search for * User's top category will be used if none specified * @param int $rootid Optional starting category for the map * @return array */ public static function map_categories($studyplanid = 0) { global $USER; // Determine top categories from provided context. if ($studyplanid == 0) { // On the system level, determine the user's topmost allowed catecories. // This uses a custom function, since moodle's "core_course_category::user_top()" is somewhat deficient. $children = self::user_tops(); if (count($children) == 0) { throw new moodle_exception("error:nocategoriesvisible", "local_treestudyplan"); } } else { if (\get_config("local_treestudyplan", "limitcourselist")) { $studyplan = studyplan::find_by_id($studyplanid); $context = $studyplan->context(); if ($context->contextlevel == \CONTEXT_SYSTEM) { $children = self::user_tops(); } else if ($context->contextlevel == \CONTEXT_COURSECAT) { $cat = \core_course_category::get($context->instanceid, \MUST_EXIST, true); if ($cat->is_uservisible()) { $children = [$cat]; } else { $ci = new contextinfo($context); $contextname = $ci->pathstr(); throw new moodle_exception("error:cannotviewcategory", "local_treestudyplan", '', $contextname); } } else { $children = []; } } else { $children = self::user_tops(); if (count($children) == 0) { throw new moodle_exception("error:nocategoriesvisible", "local_treestudyplan"); } } } $list = []; foreach ($children as $cat) { $list[] = static::map_category($cat, false); } return $list; } /** * Return value description for get_category function */ public static function get_category_parameters(): \external_function_parameters { return new \external_function_parameters( [ "id" => new \external_value(PARAM_INT, 'id of category'), ] ); } /** * Parameter description for get_category function */ public static function get_category_returns(): \external_description { return static::map_category_structure(false); } /** * Get category info by id * @param mixed $id * @return array */ public static function get_category($id) { $cat = \core_course_category::get($id); return static::map_category($cat); } /** * Create a category map, given a specific category * @param core_course_category $cat The category to scan * @param bool $lazy If lazy loading, do not scan child categories * @return array */ protected static function map_category(core_course_category $cat, $lazy = false) { global $DB; $catcontext = $cat->get_context(); $ctxinfo = new contextinfo($catcontext); $children = $cat->get_children(); // Only shows children visible to the current user. $courses = $cat->get_courses(); $model = [ "id" => $cat->id, "context_id" => $catcontext->id, "category" => $ctxinfo->model(), "haschildren" => !empty($children), "hascourses" => !empty($courses), ]; if (!$lazy) { $model["courses"] = []; foreach ($courses as $course) { $courseinfo = new courseinfo($course->id); $model["courses"][] = $courseinfo->editor_model(); } $model["children"] = []; foreach ($children as $child) { $model["children"][] = static::map_category($child, true); } } return $model; } /** * List all user visible categories the current user has a given capability for. * @param mixed $capability * @param core_course_category|null $parent * @return array */ public static function categories_by_capability($capability) { global $USER; // List the categories in which the user has a specific capability. $list = []; $parents = self::user_tops($USER->id, $capability); array_merge($list, $parents); foreach ($parents as $parent) { // For optimization purposes, we include all its children now, since they will have inherited the permission. // #PREMATURE_OPTIMIZATION ???. $list = array_merge($list, self::recursive_child_categories($parent)); } return $list; } /** * Recursively create a list of all categories unter a specified parent * @param core_course_category $parent * @return core_course_category[] */ protected static function recursive_child_categories(core_course_category $parent) { $list = []; $children = $parent->get_children(); foreach ($children as $child) { $list[] = $child; if ($child->get_children_count() > 0) { $list = array_merge($list, self::recursive_child_categories($child)); } } return $list; } /** * Return value description for list_available_categories function */ public static function list_available_categories_parameters(): \external_function_parameters { return new \external_function_parameters( [ "operation" => new \external_value(PARAM_TEXT, 'type of operation ["view"|"edit"]', VALUE_DEFAULT), "refcontext_id" => new \external_value(PARAM_INT, 'id of reference context', VALUE_DEFAULT), ]); } /** * Parameter description for list_available_categories function */ public static function list_available_categories_returns(): \external_description { return new \external_multiple_structure(static::map_category_structure(true)); } /** * List all categories available to the current user for editing or viewing studyplans * @param string $operation The operation to scan usage for [edit, view] * @param int $refctxid Reference context id * @return array */ public static function list_available_categories($operation = 'edit', $refctxid = 0) { global $DB; if ($operation == "edit") { $capability = self::CAP_EDIT; } else { // Operation == "view" || default. $capability = self::CAP_VIEW; } // Get the context ids of all categories the user has access to view and wich have the given permission. $contextids = []; $tops = self::user_tops(null, $capability); foreach ($tops as $cat) { $ctx = \context_coursecat::instance($cat->id); $contextids[] = $ctx->id; } // Now get an overview of the number of study plans in a given context. $contextcounts = []; $insertctxs = []; $rs = $DB->get_recordset_sql("SELECT DISTINCT context_id, COUNT(*) as num FROM {local_treestudyplan} GROUP BY context_id"); foreach ($rs as $r) { // Build the counts. $contextcounts[$r->context_id] = $r->num; // Add any of the categories containing studyplans to the list. $ctx = \context::instance_by_id($r->context_id); if (has_capability($capability, $ctx) && !in_array($r->context_id, $contextids)) { $insertctxs[] = $ctx; } } $rs->close(); $cats = []; // If the reference context id is not in the list, push it there if the user has proper permissions in that context. if ($refctxid > 1 && !in_array($refctxid, $contextids)) { try { // Get the context. $refctx = \context::instance_by_id($refctxid); // Double check permissions. if (has_capability($capability, $refctx)) { $insertctxs[] = $refctx; } } catch (\dml_missing_record_exception $x) { $refctx = null; } } foreach ($insertctxs as $ictx) { // Place this context and all relevant direct parents in the correct spots. $ipath = $ictx->get_parent_context_ids(true); $found = false; foreach ($ipath as $i => $pid) { $idx = array_search($pid, $contextids); if ($idx !== false) { $contextids = array_merge( array_slice($contextids, 0, $idx + 1), array_reverse(array_slice($ipath, 0, $i)), array_slice($contextids, $idx + 1, count($contextids) - 1) ); $found = true; break; } } if (!$found) { array_unshift($contextids, $ictx->id); } } // Now translate this to the list of categories. foreach ($contextids as $ctxid) { try { $ctx = \context::instance_by_id($ctxid); if ($ctx->contextlevel == CONTEXT_SYSTEM) { $cat = \core_course_category::top(); } else if ($ctx->contextlevel == CONTEXT_COURSECAT) { $cat = \core_course_category::get($ctx->instanceid, \MUST_EXIST, false); } $cats[] = $cat; // In edit mode, also include direct children of the currently selected context. if ($operation == "edit" && $ctxid == $refctxid) { // Include direct children for navigation purposes. foreach ($cat->get_children() as $ccat) { $ccatctx = \context_coursecat::instance($ccat->id); if (!in_array($ccatctx->id, $contextids)) { $cats[] = $ccat; } } } } catch (\dml_missing_record_exception $x) { $ctx = null; } } // And finally build the proper models, including studyplan count in the category context. $list = []; foreach ($cats as $cat) { $count = 0; $ctxid = $cat->get_context()->id; if (array_key_exists($ctxid, $contextcounts)) { $count = $contextcounts[$ctxid]; } $o = static::map_category($cat, true); $o["studyplancount"] = $count; $list[] = $o; } return $list; } /************************************** * * Progress scanners for teacherview * **************************************/ /** * Return value description for scan_grade_progress function * @return external_function_parameters */ public static function scan_grade_progress_parameters(): \external_function_parameters { return new \external_function_parameters( [ "gradeitemid" => new \external_value(PARAM_INT, 'Grade item ID to scan progress for', VALUE_DEFAULT), "studyplanid" => new \external_value(PARAM_INT, 'Study plan id to check progress in', VALUE_DEFAULT), ]); } /** * Parameter description for scan_grade_progress function */ public static function scan_grade_progress_returns(): \external_description { return gradingscanner::structure(VALUE_REQUIRED); } /** * Scan grade item for progress statistics * @param mixed $gradeitemid Grade item id * @param mixed $studyplanid Id of studyitem the grade is selected in * @return array */ public static function scan_grade_progress($gradeitemid, $studyplanid) { global $DB; // Verify access to the study plan. $o = studyplan::find_by_id($studyplanid); webservicehelper::require_capabilities(self::CAP_VIEW, $o->context()); // Retrieve grade item. $gi = \grade_item::fetch(["id" => $gradeitemid]); // Validate course is linked to studyplan. $courseid = $gi->courseid; if (!$o->course_linked($courseid)) { throw new \webservice_access_exception( "Course {$courseid} linked to grade item {$gradeitemid} is not linked to studyplan {$o->id()}" ); } $scanner = new gradingscanner($gi); return $scanner->model(); } /** * Return value description for scan_completion_progress function */ public static function scan_completion_progress_parameters(): \external_function_parameters { return new \external_function_parameters( [ "criteriaid" => new \external_value(PARAM_INT, 'CriteriaID to scan progress for', VALUE_DEFAULT), "studyplanid" => new \external_value(PARAM_INT, 'Study plan id to check progress in', VALUE_DEFAULT), "courseid" => new \external_value(PARAM_INT, 'Course id of criteria', VALUE_DEFAULT), ]); } /** * Parameter description for scan_completion_progress function */ public static function scan_completion_progress_returns(): \external_description { return completionscanner::structure(VALUE_REQUIRED); } /** * Scan criterium for progress statistics * @param mixed $criteriaid Id of criterium * @param mixed $studyitemid Id of studyplan relevant to this criteria * @return array */ public static function scan_completion_progress($criteriaid, $studyitemid) { global $DB; // Verify access to the study plan. $item = studyitem::find_by_id($studyitemid); $o = $item->studyline()->studyplan(); webservicehelper::require_capabilities(self::CAP_VIEW, $o->context()); $crit = \completion_criteria::fetch(["id" => $criteriaid]); $scanner = new completionscanner($crit, $studyitemid); return $scanner->model(); } /** * Return value description for scan_badge_progress function */ public static function scan_badge_progress_parameters(): \external_function_parameters { return new \external_function_parameters( [ "badgeid" => new \external_value(PARAM_INT, 'Badge to scan progress for', VALUE_DEFAULT), "studyplanid" => new \external_value(PARAM_INT, 'Study plan id to limit progress search to (to determine which students to scan)', VALUE_DEFAULT), ]); } /** * Parameter description for scan_badge_progress function */ public static function scan_badge_progress_returns(): \external_description { return new \external_single_structure([ "total" => new \external_value(PARAM_INT, 'Total number of students scanned'), "issued" => new \external_value(PARAM_INT, 'Number of issued badges'), ]); } /** * Scan badge for completion progress statistica * @param mixed $badgeid ID of the badge * @param mixed $studyplanid ID of the relevant study plan * @return array */ public static function scan_badge_progress($badgeid, $studyplanid) { global $DB; // Check access to the study plan. $o = studyplan::find_by_id($studyplanid); webservicehelper::require_capabilities(self::CAP_VIEW, $o->context()); // Validate that badge is linked to studyplan. if (!$o->badge_linked($badgeid)) { throw new \webservice_access_exception("Badge {$badgeid} is not linked to studyplan {$o->id()}"); } // Get badge info. $badge = new \core_badges\badge($badgeid); $badgeinfo = new badgeinfo($badge); // Get the connected users. $students = associationservice::all_associated($studyplanid); // Just get the user ids. $studentids = array_map(function ($a) { return $a["id"]; }, $students); return [ "total" => count($studentids), "issued" => $badgeinfo->count_issued($studentids), ]; } }