Commit 489318e3 authored by Sara Arjona's avatar Sara Arjona
Browse files

Merge branch 'MDL-70815-master-6' of git://github.com/junpataleta/moodle

parents 471819e5 8d29653f
<?php
// This file is part of Moodle - http://moodle.org/
//
// 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 <http://www.gnu.org/licenses/>.
declare(strict_types = 1);
namespace core_completion;
use cm_info;
use coding_exception;
use moodle_exception;
/**
* Base class for defining an activity module's custom completion rules.
*
* Class for defining an activity module's custom completion rules and fetching the completion statuses
* of the custom completion rules for a given module instance and a user.
*
* @package core_completion
* @copyright 2021 Jun Pataleta <jun@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class activity_custom_completion {
/** @var cm_info The course module information object. */
protected $cm;
/** @var int The user's ID. */
protected $userid;
/**
* activity_custom_completion constructor.
*
* @param cm_info $cm
* @param int $userid
*/
public function __construct(cm_info $cm, int $userid) {
$this->cm = $cm;
$this->userid = $userid;
}
/**
* Validates that the custom rule is defined by this plugin and is enabled for this activity instance.
*
* @param string $rule The custom completion rule.
*/
public function validate_rule(string $rule): void {
// Check that this custom completion rule is defined.
if (!$this->is_defined($rule)) {
throw new coding_exception("Undefined custom completion rule '$rule'");
}
// Check that this custom rule is included in the course module's custom completion rules.
if (!$this->is_available($rule)) {
throw new moodle_exception("Custom completion rule '$rule' is not used by this activity.");
}
}
/**
* Whether this module defines this custom rule.
*
* @param string $rule The custom completion rule.
* @return bool
*/
public function is_defined(string $rule): bool {
return in_array($rule, static::get_defined_custom_rules());
}
/**
* Checks whether the custom completion rule is being used by the activity module instance.
*
* @param string $rule The custom completion rule.
* @return bool
*/
public function is_available(string $rule): bool {
return in_array($rule, $this->get_available_custom_rules());
}
/**
* Fetches the list of custom completion rules that are being used by this activity module instance.
*
* @return array
*/
public function get_available_custom_rules(): array {
$rules = static::get_defined_custom_rules();
$availablerules = [];
foreach ($rules as $rule) {
$customrule = $this->cm->customdata['customcompletionrules'][$rule] ?? false;
if (!empty($customrule)) {
$availablerules[] = $rule;
}
}
return $availablerules;
}
/**
* Fetches the overall completion status of this activity instance for a user based on its available custom completion rules.
*
* @return int The completion state (e.g. COMPLETION_COMPLETE, COMPLETION_INCOMPLETE).
*/
public function get_overall_completion_state(): int {
foreach ($this->get_available_custom_rules() as $rule) {
$state = $this->get_state($rule);
// Return early if one of the custom completion rules is not yet complete.
if ($state == COMPLETION_INCOMPLETE) {
return $state;
}
}
// If this was reached, then all custom rules have been marked complete.
return COMPLETION_COMPLETE;
}
/**
* Fetches the description for a given custom completion rule.
*
* @param string $rule The custom completion rule.
* @return string
*/
public function get_custom_rule_description(string $rule): string {
$descriptions = $this->get_custom_rule_descriptions();
if (!isset($descriptions[$rule])) {
// Lang string not found for this custom completion rule. Just return it.
return $rule;
}
return $descriptions[$rule];
}
/**
* Fetches the module's custom completion class implementation if it's available.
*
* @param string $modname The activity module name. Usually from cm_info::modname.
* @return string|null
*/
public static function get_cm_completion_class(string $modname): ?string {
$cmcompletionclass = "mod_{$modname}\\completion\\custom_completion";
if (class_exists($cmcompletionclass) && is_subclass_of($cmcompletionclass, self::class)) {
return $cmcompletionclass;
}
return null;
}
/**
* Fetches the completion state for a given completion rule.
*
* @param string $rule The completion rule.
* @return int The completion state.
*/
public abstract function get_state(string $rule): int;
/**
* Fetch the list of custom completion rules that this module defines.
*
* @return array
*/
public abstract static function get_defined_custom_rules(): array;
/**
* Returns an associative array of the descriptions of custom completion rules.
*
* @return array
*/
public abstract function get_custom_rule_descriptions(): array;
}
<?php
// This file is part of Moodle - http://moodle.org/
//
// 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 <http://www.gnu.org/licenses/>.
declare(strict_types = 1);
namespace core_completion;
use advanced_testcase;
use coding_exception;
use moodle_exception;
use PHPUnit\Framework\MockObject\MockObject;
/**
* Class for unit testing core_completion/activity_custom_completion.
*
* @package core_completion
* @copyright 2021 Jun Pataleta <jun@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class activity_custom_completion_test extends advanced_testcase {
/**
* Fetches a mocked activity_custom_completion instance.
*
* @param string[] $methods List of methods to mock.
* @return activity_custom_completion|MockObject
*/
protected function setup_mock(array $methods) {
return $this->getMockBuilder(activity_custom_completion::class)
->disableOriginalConstructor()
->onlyMethods($methods)
->getMockForAbstractClass();
}
/**
* Data provider for test_get_overall_completion_state().
*/
public function overall_completion_state_provider(): array {
global $CFG;
require_once($CFG->libdir . '/completionlib.php');
return [
'First incomplete, second complete' => [
['completionsubmit', 'completioncreate'],
[COMPLETION_INCOMPLETE, COMPLETION_COMPLETE],
1,
COMPLETION_INCOMPLETE
],
'First complete, second incomplete' => [
['completionsubmit', 'completioncreate'],
[COMPLETION_COMPLETE, COMPLETION_INCOMPLETE],
2,
COMPLETION_INCOMPLETE
],
'All complete' => [
['completionsubmit', 'completioncreate'],
[COMPLETION_COMPLETE, COMPLETION_COMPLETE],
2,
COMPLETION_COMPLETE
],
'No rules' => [
[],
[],
0,
COMPLETION_COMPLETE
],
];
}
/**
* Test for \core_completion\activity_custom_completion::get_overall_completion_state().
*
* @dataProvider overall_completion_state_provider
* @param string[] $rules The custom completion rules.
* @param int[] $rulestates The completion states of these custom completion rules.
* @param int $invokecount Expected invoke count of get_state().
* @param int $state The expected overall completion state
*/
public function test_get_overall_completion_state(array $rules, array $rulestates, int $invokecount, int $state) {
$stub = $this->setup_mock([
'get_available_custom_rules',
'get_state',
]);
// Mock activity_custom_completion's get_available_custom_rules() method.
$stub->expects($this->once())
->method('get_available_custom_rules')
->willReturn($rules);
// Mock activity_custom_completion's get_state() method.
if ($invokecount > 0) {
$stub->expects($this->exactly($invokecount))
->method('get_state')
->withConsecutive(
[$rules[0]],
[$rules[1]]
)
->willReturn($rulestates[0], $rulestates[1]);
} else {
$stub->expects($this->never())
->method('get_state');
}
$this->assertEquals($state, $stub->get_overall_completion_state());
}
/**
* Data provider for test_validate_rule().
*
* @return array[]
*/
public function validate_rule_provider() {
return [
'Not defined' => [
false, true, coding_exception::class
],
'Not available' => [
true, false, moodle_exception::class
],
'Defined and available' => [
true, true, null
],
];
}
/**
* Test for validate_rule()
*
* @dataProvider validate_rule_provider
* @param bool $defined is_defined()'s mocked return value.
* @param bool $available is_available()'s mocked return value.
* @param string|null $expectedexception Expected expectation class name.
*/
public function test_validate_rule(bool $defined, bool $available, ?string $expectedexception) {
$stub = $this->setup_mock([
'is_defined',
'is_available'
]);
// Mock activity_custom_completion's is_defined() method.
$stub->expects($this->any())
->method('is_defined')
->willReturn($defined);
// Mock activity_custom_completion's is_available() method.
$stub->expects($this->any())
->method('is_available')
->willReturn($available);
if ($expectedexception) {
$this->expectException($expectedexception);
}
$stub->validate_rule('customcompletionrule');
}
/**
* Test for is_available().
*/
public function test_is_available() {
$stub = $this->setup_mock([
'get_available_custom_rules',
]);
// Mock activity_custom_completion's get_available_custom_rules() method.
$stub->expects($this->any())
->method('get_available_custom_rules')
->willReturn(['rule1', 'rule2']);
// Rule is available.
$this->assertTrue($stub->is_available('rule1'));
// Rule is not available.
$this->assertFalse($stub->is_available('rule'));
}
}
......@@ -26,6 +26,8 @@
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
use core_completion\activity_custom_completion;
defined('MOODLE_INTERNAL') || die();
/**
......@@ -643,7 +645,7 @@ class completion_info {
* @return mixed
*/
public function internal_get_state($cm, $userid, $current) {
global $USER, $DB, $CFG;
global $USER, $DB;
// Get user ID
if (!$userid) {
......@@ -657,55 +659,91 @@ class completion_info {
return COMPLETION_INCOMPLETE;
}
// Modname hopefully is provided in $cm but just in case it isn't, let's grab it
if (!isset($cm->modname)) {
$cm->modname = $DB->get_field('modules', 'name', array('id'=>$cm->module));
if ($cm instanceof stdClass) {
// Modname hopefully is provided in $cm but just in case it isn't, let's grab it.
if (!isset($cm->modname)) {
$cm->modname = $DB->get_field('modules', 'name', array('id' => $cm->module));
}
// Some functions call this method and pass $cm as an object with ID only. Make sure course is set as well.
if (!isset($cm->course)) {
$cm->course = $this->course_id;
}
}
// Make sure we're using a cm_info object.
$cminfo = cm_info::create($cm, $userid);
$newstate = COMPLETION_COMPLETE;
// Check grade
if (!is_null($cm->completiongradeitemnumber)) {
require_once($CFG->libdir.'/gradelib.php');
$item = grade_item::fetch(array('courseid'=>$cm->course, 'itemtype'=>'mod',
'itemmodule'=>$cm->modname, 'iteminstance'=>$cm->instance,
'itemnumber'=>$cm->completiongradeitemnumber));
if ($item) {
// Fetch 'grades' (will be one or none)
$grades = grade_grade::fetch_users_grades($item, array($userid), false);
if (empty($grades)) {
// No grade for user
if (!is_null($cminfo->completiongradeitemnumber)) {
$newstate = $this->get_grade_completion($cminfo, $userid);
if ($newstate == COMPLETION_INCOMPLETE) {
return COMPLETION_INCOMPLETE;
}
}
if (plugin_supports('mod', $cminfo->modname, FEATURE_COMPLETION_HAS_RULES)) {
$cmcompletionclass = activity_custom_completion::get_cm_completion_class($cminfo->modname);
if ($cmcompletionclass) {
/** @var activity_custom_completion $cmcompletion */
$cmcompletion = new $cmcompletionclass($cminfo, $userid);
if ($cmcompletion->get_overall_completion_state() == COMPLETION_INCOMPLETE) {
return COMPLETION_INCOMPLETE;
}
if (count($grades) > 1) {
$this->internal_systemerror("Unexpected result: multiple grades for
item '{$item->id}', user '{$userid}'");
} else {
// Fallback to the get_completion_state callback.
$function = $cminfo->modname . '_get_completion_state';
if (!function_exists($function)) {
$this->internal_systemerror("Module {$cminfo->modname} claims to support
FEATURE_COMPLETION_HAS_RULES but does not have required
{$cminfo->modname}_get_completion_state function");
}
$newstate = self::internal_get_grade_state($item, reset($grades));
if ($newstate == COMPLETION_INCOMPLETE) {
if (!$function($this->course, $cminfo, $userid, COMPLETION_AND)) {
return COMPLETION_INCOMPLETE;
}
} else {
$this->internal_systemerror("Cannot find grade item for '{$cm->modname}'
cm '{$cm->id}' matching number '{$cm->completiongradeitemnumber}'");
}
}
if (plugin_supports('mod', $cm->modname, FEATURE_COMPLETION_HAS_RULES)) {
$function = $cm->modname.'_get_completion_state';
if (!function_exists($function)) {
$this->internal_systemerror("Module {$cm->modname} claims to support
FEATURE_COMPLETION_HAS_RULES but does not have required
{$cm->modname}_get_completion_state function");
}
if (!$function($this->course, $cm, $userid, COMPLETION_AND)) {
return $newstate;
}
/**
* Fetches the completion state for an activity completion's require grade completion requirement.
*
* @param cm_info $cm The course module information.
* @param int $userid The user ID.
* @return int The completion state.
*/
public function get_grade_completion(cm_info $cm, int $userid): int {
global $CFG;
require_once($CFG->libdir . '/gradelib.php');
$item = grade_item::fetch([
'courseid' => $cm->course,
'itemtype' => 'mod',
'itemmodule' => $cm->modname,
'iteminstance' => $cm->instance,
'itemnumber' => $cm->completiongradeitemnumber
]);
if ($item) {
// Fetch 'grades' (will be one or none).
$grades = grade_grade::fetch_users_grades($item, [$userid], false);
if (empty($grades)) {
// No grade for user.
return COMPLETION_INCOMPLETE;
}
if (count($grades) > 1) {
$this->internal_systemerror("Unexpected result: multiple grades for
item '{$item->id}', user '{$userid}'");
}
return self::internal_get_grade_state($item, reset($grades));
} else {
$this->internal_systemerror("Cannot find grade item for '{$cm->modname}'
cm '{$cm->id}' matching number '{$cm->completiongradeitemnumber}'");
}
return $newstate;
return COMPLETION_INCOMPLETE;
}
/**
......@@ -927,7 +965,7 @@ class completion_info {
* Obtains completion data for a particular activity and user (from the
* completion cache if available, or by SQL query)
*
* @param stcClass|cm_info $cm Activity; only required field is ->id
* @param stdClass|cm_info $cm Activity; only required field is ->id
* @param bool $wholecourse If true (default false) then, when necessary to
* fill the cache, retrieves information from the entire course not just for
* this one activity
......@@ -936,10 +974,12 @@ class completion_info {
* testing and so that it can be called recursively from within
* get_fast_modinfo. (Needs only list of all CMs with IDs.)
* Otherwise the method calls get_fast_modinfo itself.
* @return object Completion data (record from course_modules_completion)
* @return object Completion data. Record from course_modules_completion plus other completion statuses such as
* - Completion status for 'must-receive-grade' completion rule.
* - Custom completion statuses defined by the activity module plugin.
*/
public function get_data($cm, $wholecourse = false, $userid = 0, $modinfo = null) {
global $USER, $CFG, $DB;
global $USER, $DB;
$completioncache = cache::make('core', 'completion');
// Get user ID
......@@ -965,7 +1005,27 @@ class completion_info {
}
}
// Not there, get via SQL
// Some call completion_info::get_data and pass $cm as an object with ID only. Make sure course is set as well.
if ($cm instanceof stdClass && !isset($cm->course)) {
$cm->course = $this->course_id;
}
// Make sure we're working on a cm_info object.
$cminfo = cm_info::create($cm, $userid);
// Default data to return when no completion data is found.
$defaultdata = [
'id' => 0,
'coursemoduleid' => $cminfo->id,
'userid' => $userid,
'completionstate' => 0,
'viewed' => 0,
'overrideby' => null,
'timemodified' => 0,
];
// If cached completion data is not found, fetch via SQL.
// Fetch completion data for all of the activities in the course ONLY if we're caching the fetched completion data.
// If we're not caching the completion data, then just fetch the completion data for the user in this course module.
if ($usecache && $wholecourse) {
// Get whole course data for cache
$alldatabycmc = $DB->get_records_sql("
......@@ -991,49 +1051,85 @@ class completion_info {
if (isset($alldata[$othercm->id])) {
$data = $alldata[$othercm->id];
} else {
// Row not present counts as 'not complete'
$data = array();
$data['id'] = 0;
// Row not present counts as 'not complete'.
$data = $defaultdata;
$data['coursemoduleid'] = $othercm->id;
$data['userid'] = $userid;
$data['completionstate'] = 0;
$data['viewed'] = 0;
$data['overrideby'] = null;
$data['timemodified'] = 0;
}
$cacheddata[$othercm->id] = $data;
// Make sure we're working on a cm_info object.
$othercminfo = cm_info::create($othercm, $userid);
// Add the other completion data for this user in this module instance.
$data += $this->get_other_cm_completion_data($othercminfo, $userid);
$cacheddata[$othercminfo->id] = $data;
}
if (!isset($cacheddata[$cm->id])) {
$this->internal_systemerror("Unexpected error: course-module {$cm->id} could not be found on course {$this->course->id}");
if (!isset($cacheddata[$cminfo->id])) {
$errormessage = "Unexpected error: course-module {$cminfo->id} could not be found on course {$this->course->id}";
$this->internal_systemerror($errormessage);
}
} else {
// Get single record
$data = $DB->get_record('course_modules_completion', array('coursemoduleid'=>$cm->id, 'userid'=>$userid));
$data = $DB->get_record('course_modules_completion', array('coursemoduleid' => $cminfo->id, 'userid' => $userid));
if ($data) {
$data = (array)$data;
} else {
// Row not present counts as 'not complete'
$data = array();
$data['id'] = 0;
$data['coursemoduleid'] = $cm->id;
$data['userid'] = $userid;
$data['completionstate'] = 0;
$data['viewed'] = 0;
$data['overrideby'] = null;
$data['timemodified'] = 0;
// Row not present counts as 'not complete'.
$data = $defaultdata;
}
// Fill the other completion data for this user in this module instance.
$data += $this->get_other_cm_completion_data($cminfo, $userid);
// Put in cache
$cacheddata[$cm->id] = $data;
$cacheddata[$cminfo->id] = $data;
}
if ($usecache) {
$cacheddata['cacherev'] =