moodle_local_treestudyplan/classes/courseservice.php

713 lines
28 KiB
PHP

<?php
// This file is part of the Studyplan plugin for Moodle
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <https://www.gnu.org/licenses/>.
/**
* 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 \core_course_category;
/**
* 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) {
global $DB;
$tops = [];
/*
SELECT UNIQUE ctx.* FROM mdl_context AS ctx
INNER JOIN mdl_role_assignments AS ra ON ra.contextid = ctx.id
INNER JOIN mdl_role_capabilities AS rc ON ra.roleid = rc.roleid
LEFT JOIN mdl_course_categories AS cat ON ctx.instanceid = cat.id
WHERE ( ctx.contextlevel = 40 OR ctx.contextlevel = 10 )
AND ra.userid = 58 AND rc.capability = 'moodle/category:viewcourselist'
ORDER BY ctx.depth ASC, cat.sortorder ASC;
*/
$capability = 'moodle/category:viewcourselist';
$sql = "SELECT UNIQUE ctx.* FROM {context} AS ctx
INNER JOIN {role_assignments} AS ra ON ra.contextid = ctx.id
INNER JOIN {role_capabilities} AS rc ON ra.roleid = rc.roleid
LEFT JOIN {course_categories} AS cat ON ctx.instanceid = cat.id
WHERE ( ctx.contextlevel = :ctxl_coursecat OR ctx.contextlevel = :ctxl_system )
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_recordset_sql($sql, ["userid" => $userid, "capability" => $capability,
"ctxl_coursecat" => \CONTEXT_COURSECAT, "ctxl_system" => \CONTEXT_SYSTEM,]);
$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 actual category object.
if ($r->contextlevel == \CONTEXT_SYSTEM) {
// The user can view all (non-hidden) categories, so add all categories of depth = 1;
$tops = []; // Reset the array, just in case.
$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();
break; // Stop the loop immediately so the list of visible depth 2 categories is returned.
} else { // Can only be \CONTEXT_COURSECAT according to the SQL query.
// 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( [
"root_id" => new \external_value(PARAM_INT, 'root category to use as base', 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($rootid = 0) {
global $USER;
$root = \core_course_category::get($rootid,\MUST_EXIST,true);
// Determine top categories from provided context.
if ($root->id == 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($USER->id);
} else if ($root->is_uservisible()) {
$children = [$root];
} else {
return []; // Category not user visible.
}
$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;
}
/**
* Return value description for list_accessible_categories function
*/
public static function list_accessible_categories_parameters() : \external_function_parameters {
return new \external_function_parameters( [
"operation" => new \external_value(PARAM_TEXT, 'type of operation ["view"|"edit"]', VALUE_DEFAULT), ]
);
}
/**
* Parameter description for list_accessible_categories function
*/
public static function list_accessible_categories_returns() : \external_description {
return new \external_multiple_structure(static::map_category_structure(true));
}
/**
* List all categories the user has access to
* Used in the studyplan edit form
* @param string $operation
* @return array
*/
public static function list_accessible_categories($operation = "edit") {
if ($operation == "edit") {
$capability = self::CAP_EDIT;
} else { // Operation == "view" || default.
$capability = self::CAP_VIEW;
}
$cats = static::categories_by_capability($capability);
$list = [];
foreach ($cats as $cat) {
$list[] = static::map_category($cat, true);
}
return $list;
}
/**
* [Description for categories_by_capability]
* @param mixed $capability
* @param core_course_category|null $parent
* @return array
*/
public static function categories_by_capability($capability, core_course_category $parent = null) {
// List the categories in which the user has a specific capability.
$list = [];
// Initialize parent if needed.
if ($parent == null) {
$parent = \core_course_category::user_top();
if (has_capability($capability, $parent->get_context())) {
$list[] = $parent;
}
}
$children = $parent->get_children();
foreach ($children as $child) {
// Check if we should add this category.
if (has_capability($capability, $child->get_context())) {
$list[] = $child;
// 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($child));
} else {
if ($child->get_children_count() > 0) {
$list = array_merge($list, self::categories_by_capability($capability, $child));
}
}
}
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_used_categories function
*/
public static function list_used_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_used_categories function
*/
public static function list_used_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_used_categories($operation = 'edit', $refctxid = 0) {
global $DB;
if ($operation == "edit") {
$capability = self::CAP_EDIT;
} else { // Operation == "view" || default.
$capability = self::CAP_VIEW;
}
$contextcounts = [];
$contextids = [];
$rs = $DB->get_recordset_sql("SELECT DISTINCT context_id, COUNT(*) as num FROM {local_treestudyplan}
GROUP BY context_id");
foreach ($rs as $r) {
$contextcounts[$r->context_id] = $r->num;
$contextids[] = $r->context_id;
}
// Add system context to list if needed.
if (!in_array(1,$contextids)) {
array_unshift($contextids,1);
}
$rs->close();
$cats = [];
// If the reference context id is not in the list, push it there
if ($refctxid > 1 && !in_array($refctxid, $contextids)) {
try {
$refctx = \context::instance_by_id($refctxid);
$refpath = $refctx->get_parent_context_ids(true);
$found = false;
foreach ($refpath as $i => $pid) {
$idx = array_search($pid,$contextids);
if($idx !== false) {
$contextids = array_merge(
array_slice($contextids, 0, $idx+1),
array_reverse(array_slice($refpath,0,$i)),
array_slice($contextids, $idx+1, count($contextids) - 1)
) ;
$found = true;
break;
}
}
if(!$found) {
array_unshift($contextids,$refctxid);
}
} catch(\dml_missing_record_exception $x) {
// ignore context
}
}
// we only have to check the contexts having a study plan for access permissions
foreach ($contextids as $ctxid ) {
try {
$ctx = \context::instance_by_id($ctxid);
if (has_capability($capability, $ctx)) {
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,true);
}
$cats[] = $cat;
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) {
// ignore 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;
}
/**
* List all categories available to the current user for editing or viewing studyplans and add information about their usage
* (Not a webservice function)
* @param string $operation
* @param int $refctxid Reference context id
* @return stdClass[]
*/
public static function list_accessible_categories_with_usage($operation = 'edit', $refctxid = 0) {
global $DB;
if ($operation == "edit") {
$capability = self::CAP_EDIT;
} else { // Operation == "view" || default.
$capability = self::CAP_VIEW;
}
// Retrieve context ids used.
$contextcounts = [];
$contextids = [];
$rs = $DB->get_recordset_sql("SELECT DISTINCT context_id, COUNT(*) as num FROM {local_treestudyplan}
GROUP BY context_id");
foreach ($rs as $r) {
$contextcounts[$r->context_id] = $r->num;
$contextids[] = $r->context_id;
}
$rs->close();
// Add system context to list if needed.
if (!in_array(1,$contextids)) {
array_unshift($contextids,1);
}
$cats = [];
// If the reference context id is not in the list, push it there
if ($refctxid > 1 && !in_array($refctxid, $contextids)) {
try {
$refctx = \context::instance_by_id($refctxid);
$refpath = $refctx->get_parent_context_ids(true);
$found = false;
foreach ($refpath as $i => $pid) {
$idx = array_search($pid,$contextids);
if($idx !== false) {
$contextids = array_merge(
array_slice($contextids, 0, $idx+1),
array_reverse(array_slice($refpath,0,$i)),
array_slice($contextids, $idx+1, count($contextids) - 1)
) ;
$found = true;
break;
}
}
if(!$found) {
array_unshift($contextids,$refctxid);
}
} catch(\dml_missing_record_exception $x) {
// ignore context
}
}
// we only have to check these contexts for access permissions
foreach ($contextids as $ctxid ) {
try {
$ctx = \context::instance_by_id($ctxid);
if (has_capability($capability, $ctx)) {
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,true);
}
$cats[] = $cat;
if ($operation == "view" && $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) {
// ignore context
}
}
$list = [];
foreach ($cats as $cat) {
$count = 0;
$ctxid = $cat->get_context()->id;
if (array_key_exists($ctxid, $contextcounts)) {
$count = $contextcounts[$ctxid];
}
$o = new \stdClass();
$o->cat = $cat;
$o->ctxid = $ctxid;
$o->count = $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),
];
}
}