Merge branch 'MDL-77958-master' of https://github.com/andrewnicols/moodle

This commit is contained in:
Jake Dallimore
2023-04-19 17:08:14 +08:00
11 changed files with 159 additions and 104 deletions
+12 -22
View File
@@ -21,6 +21,7 @@ use backup_controller;
use backup_root_task;
use cm_info;
use core\context\user;
use stored_file;
defined('MOODLE_INTERNAL') || die();
@@ -46,14 +47,14 @@ class activity_packager {
*/
public function __construct(
protected cm_info $cminfo,
protected int $userid
protected int $userid,
) {
// Check backup/restore support.
if (!plugin_supports('mod', $cminfo->modname , FEATURE_BACKUP_MOODLE2)) {
throw new \coding_exception("Cannot backup module $cminfo->modname. This module doesn't support the backup feature.");
}
$this->controller = new backup_controller (
$this->controller = new backup_controller(
backup::TYPE_1ACTIVITY,
$cminfo->id,
backup::FORMAT_MOODLE,
@@ -66,10 +67,9 @@ class activity_packager {
/**
* Prepare the backup file using appropriate setting overrides and return relevant information.
*
* @return array Array containing packaged file and stored_file object describing it.
* Array in the format [storedfile => stored_file object, filecontents => raw file].
* @return stored_file
*/
public function get_package(): array {
public function get_package(): stored_file {
$alltasksettings = $this->get_all_task_settings();
// Override relevant settings to remove user data when packaging to share to MoodleNet.
@@ -106,7 +106,6 @@ class activity_packager {
* @param array $alltasksettings All task settings.
* @param string $settingname The name of the setting to be overridden (task class name format).
* @param int $settingvalue Value to be given to the setting.
* @return void
*/
protected function override_task_setting(array $alltasksettings, string $settingname, int $settingvalue): void {
if (empty($rootsettings = $alltasksettings[backup_root_task::class])) {
@@ -123,13 +122,12 @@ class activity_packager {
}
/**
* Package the activity identified by CMID.
* Package the activity identified by CMID into a new stored_file.
*
* @return array Array containing packaged file and stored_file object describing it.
* Array in the format [storedfile => stored_file object, filecontents => raw file].
* @throws \moodle_exception.
* @return stored_file
* @throws \moodle_exception
*/
protected function package(): array {
protected function package(): stored_file {
// Execute the backup and fetch the result.
$this->controller->execute_plan();
$result = $this->controller->get_results();
@@ -147,7 +145,7 @@ class activity_packager {
}
// Create the location we want to copy this file to.
$fr = [
$filerecord = [
'contextid' => user::instance($this->userid)->id,
'userid' => $this->userid,
'component' => 'user',
@@ -159,19 +157,11 @@ class activity_packager {
// Create the local file based on the backup.
$fs = get_file_storage();
$packagedfiledata = [
'storedfile' => $fs->create_file_from_storedfile($fr, $backupfile),
];
$file = $fs->create_file_from_storedfile($filerecord, $backupfile);
// Delete the backup now it has been created in the file area.
$backupfile->delete();
// Ensure we can handle files at the upper end of the limit supported by MoodleNet.
raise_memory_limit(activity_sender::MAX_FILESIZE);
// Get the actual file content.
$packagedfiledata['filecontents'] = $packagedfiledata['storedfile']->get_content();
return $packagedfiledata;
return $file;
}
}
+23 -25
View File
@@ -21,6 +21,7 @@ use core\event\moodlenet_resource_exported;
use core\oauth2\client;
use moodle_exception;
use stdClass;
use stored_file;
/**
* API for sharing Moodle LMS activities to MoodleNet instances.
@@ -65,9 +66,8 @@ class activity_sender {
protected int $userid,
protected moodlenet_client $moodlenetclient,
protected client $oauthclient,
protected int $shareformat = self::SHARE_FORMAT_BACKUP
protected int $shareformat = self::SHARE_FORMAT_BACKUP,
) {
[$this->course, $this->cminfo] = get_course_and_cm_from_cmid($cmid);
if (!in_array($shareformat, $this->get_allowed_share_formats())) {
@@ -90,7 +90,7 @@ class activity_sender {
$issuer = $this->oauthclient->get_issuer();
// Check user can share to the requested MoodleNet instance.
$coursecontext = \context_course::instance($this->course->id);
$coursecontext = \core\context\course::instance($this->course->id);
$usercanshare = utilities::can_user_share($coursecontext, $this->userid);
if ($usercanshare && utilities::is_valid_instance($issuer) && $this->oauthclient->is_logged_in()) {
@@ -108,17 +108,15 @@ class activity_sender {
$filedata = $this->prepare_share_contents();
// If we have successfully prepared a file to share of permitted size, share it to MoodleNet.
if (!empty($filedata['storedfile'])) {
if (!empty($filedata)) {
// Avoid sending a file larger than the defined limit.
$filesize = $filedata['storedfile']->get_filesize();
$filesize = $filedata->get_filesize();
if ($filesize > self::MAX_FILESIZE) {
$filedata['storedfile']->delete();
throw new moodle_exception('moodlenet:sharefilesizelimitexceeded', 'core', '',
[
'filesize' => $filesize,
'filesizelimit' => self::MAX_FILESIZE,
]);
$filedata->delete();
throw new moodle_exception('moodlenet:sharefilesizelimitexceeded', 'core', '', [
'filesize' => $filesize,
'filesizelimit' => self::MAX_FILESIZE,
]);
}
// MoodleNet only accept plaintext descriptions.
@@ -130,7 +128,11 @@ class activity_sender {
['context' => $coursecontext]
);
$response = $this->moodlenetclient->create_resource_from_file($filedata, $this->cminfo->name, $resourcedescription);
$response = $this->moodlenetclient->create_resource_from_stored_file(
$filedata,
$this->cminfo->name,
$resourcedescription,
);
$responsecode = $response->getStatusCode();
$responsebody = json_decode($response->getBody());
@@ -140,7 +142,7 @@ class activity_sender {
// Delete the generated file now it is no longer required.
// (It has either been sent, or failed - retries not currently supported).
$filedata['storedfile']->delete();
$filedata->delete();
}
// Log every attempt to share (and whether or not it was successful).
@@ -155,20 +157,17 @@ class activity_sender {
/**
* Prepare the data for sharing, in the format specified.
*
* @return array Array of metadata about the file, as well as a stored_file object for the file.
* @return stored_file
*/
protected function prepare_share_contents(): array {
protected function prepare_share_contents(): stored_file {
switch ($this->shareformat) {
case self::SHARE_FORMAT_BACKUP:
default:
// If sharing the activity as a backup, prepare the packaged backup.
$packager = new activity_packager($this->cminfo, $this->userid);
$filedata = $packager->get_package();
break;
return $packager->get_package();
default:
throw new \coding_exception("Unknown share format: {$this->shareformat}'");
};
return $filedata;
}
/**
@@ -181,10 +180,10 @@ class activity_sender {
* @return void
*/
protected function log_event(
\context $coursecontext,
\core\context $coursecontext,
int $cmid,
string $resourceurl,
int $responsecode
int $responsecode,
): void {
$event = moodlenet_resource_exported::create([
'context' => $coursecontext,
@@ -203,7 +202,6 @@ class activity_sender {
* @return array Array of supported share format values.
*/
protected function get_allowed_share_formats(): array {
return [
self::SHARE_FORMAT_BACKUP,
];
+27 -10
View File
@@ -18,6 +18,9 @@ namespace core\moodlenet;
use core\http_client;
use core\oauth2\client;
use stored_file;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamInterface;
/**
* MoodleNet client which handles direct outbound communication with MoodleNet instances.
@@ -54,22 +57,31 @@ class moodlenet_client {
/**
* Create a resource on MoodleNet which includes a file.
*
* @param array $filedata The file data in the format [storedfile => stored_file object, filecontents => raw file].
* @param stored_file $file The file data to send to MoodleNet.
* @param string $resourcename The name of the resource being shared.
* @param string $resourcedescription A description of the resource being shared.
* @return Psr\Http\Message\ResponseInterface The HTTP client response from MoodleNet.
* @return \Psr\Http\Message\ResponseInterface The HTTP client response from MoodleNet.
*/
public function create_resource_from_file(array $filedata, string $resourcename, $resourcedescription) {
public function create_resource_from_stored_file(
stored_file $file,
string $resourcename,
string $resourcedescription,
): ResponseInterface {
// This may take a long time if a lot of data is being shared.
\core_php_time_limit::raise();
$moodleneturl = $this->oauthclient->get_issuer()->get('baseurl');
$apiurl = rtrim($moodleneturl, '/') . self::API_CREATE_RESOURCE_URI;
$requestdata = $this->prepare_file_share_request_data($filedata, $resourcename, $resourcedescription);
$requestdata = $this->prepare_file_share_request_data(
$file->get_filename(),
$file->get_mimetype(),
$file->get_psr_stream(),
$resourcename,
$resourcedescription,
);
return $this->httpclient->request('POST', $apiurl, $requestdata);
}
/**
@@ -81,7 +93,13 @@ class moodlenet_client {
* @param string $resourcedescription A description of the resource being shared.
* @return array Data in the format required to send a file to MoodleNet using \core\httpclient.
*/
protected function prepare_file_share_request_data(array $filedata, string $resourcename, $resourcedescription): array {
protected function prepare_file_share_request_data(
string $filename,
string $mimetype,
StreamInterface $stream,
string $resourcename,
$resourcedescription,
): array {
return [
'headers' => [
'Authorization' => 'Bearer ' . $this->oauthclient->get_accesstoken()->token,
@@ -99,11 +117,10 @@ class moodlenet_client {
],
[
'name' => 'filecontents',
'contents' => $filedata['filecontents'],
'contents' => $stream,
'headers' => [
'Content-Disposition' => 'form-data; name=".resource"; filename="' .
$filedata['storedfile']->get_filename() . '"',
'Content-Type' => $filedata['storedfile']->get_mimetype(),
'Content-Disposition' => 'form-data; name=".resource"; filename="' . $filename . '"',
'Content-Type' => $mimetype,
'Content-Transfer-Encoding' => 'binary',
],
],
+2 -3
View File
@@ -16,7 +16,6 @@
namespace core\moodlenet;
use context_course;
use core\oauth2\issuer;
/**
@@ -46,11 +45,11 @@ class utilities {
/**
* Check whether a user has the capabilities required to share activities from a given course to MoodleNet.
*
* @param context_course $coursecontext Course context where the activity would be shared from.
* @param \core\context\course $coursecontext Course context where the activity would be shared from.
* @param int $userid The user ID being checked.
* @return boolean
*/
public static function can_user_share(context_course $coursecontext, int $userid): bool {
public static function can_user_share(\core\context\course $coursecontext, int $userid): bool {
return (has_capability('moodle/moodlenet:shareactivity', $coursecontext, $userid) &&
has_capability('moodle/backup:backupactivity', $coursecontext, $userid));
}
+11 -9
View File
@@ -14,15 +14,7 @@
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Core file system class definition.
*
* @package core_files
* @copyright 2017 Andrew Nicols <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
use Psr\Http\Message\StreamInterface;
/**
* File system class used for low level access to real files in filedir.
@@ -631,6 +623,16 @@ abstract class file_system {
}
}
/**
* Get a PSR7 Stream for the specified file which implements the PSR Message StreamInterface.
*
* @param stored_file $file
* @return StreamInterface
*/
public function get_psr_stream(stored_file $file): StreamInterface {
return \GuzzleHttp\Psr7\Utils::streamFor($this->get_content_file_handle($file));
}
/**
* Retrieve the mime information for the specified stored file.
*
+14
View File
@@ -23,6 +23,8 @@
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
use Psr\Http\Message\StreamInterface;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->dirroot . '/lib/filestorage/file_progress.php');
@@ -437,6 +439,18 @@ class stored_file {
return $this->filesystem->get_content_file_handle($this, $type);
}
/**
* Get a read-only PSR-7 stream for this file.
*
* Note: This stream is read-only. If you want to modify the file, create a new file and delete the old one.
* The File API creates immutable files.
*
* @return StreamInterface
*/
public function get_psr_stream(): StreamInterface {
return $this->filesystem->get_psr_stream($this);
}
/**
* Dumps file content to page.
*/
+18 -1
View File
@@ -1077,6 +1077,24 @@ class file_system_test extends \advanced_testcase {
$fs->get_content_file_handle($file, -1);
}
/**
* Ensure that get_content_file_handle returns a valid file handle.
*
* @covers ::get_psr_stream
*/
public function test_get_psr_stream(): void {
$file = $this->get_stored_file('');
$fs = $this->get_testable_mock(['get_remote_path_from_storedfile']);
$fs->method('get_remote_path_from_storedfile')
->willReturn(__FILE__);
$stream = $fs->get_psr_stream($file);
$this->assertInstanceOf(\Psr\Http\Message\StreamInterface::class, $stream);
$this->assertEquals(file_get_contents(__FILE__), $stream->getContents());
$this->assertFalse($stream->isWritable());
}
/**
* Test that mimetype_from_hash returns the correct mimetype with
* a file whose filename suggests mimetype.
@@ -1248,4 +1266,3 @@ class file_system_test extends \advanced_testcase {
];
}
}
@@ -91,4 +91,33 @@ class stored_file_test extends advanced_testcase {
$this->assertEquals(1200, $size['width']);
$this->assertEquals(297, $size['height']);
}
/**
* Ensure that get_content_file_handle returns a valid file handle.
*
* @covers ::get_psr_stream
*/
public function test_get_psr_stream(): void {
global $CFG;
$this->resetAfterTest();
$filename = 'testimage.jpg';
$filepath = $CFG->dirroot . '/lib/filestorage/tests/fixtures/' . $filename;
$filerecord = [
'contextid' => context_system::instance()->id,
'component' => 'core',
'filearea' => 'unittest',
'itemid' => 0,
'filepath' => '/',
'filename' => $filename,
];
$fs = get_file_storage();
$file = $fs->create_file_from_pathname($filerecord, $filepath);
$stream = $file->get_psr_stream();
$this->assertInstanceOf(\Psr\Http\Message\StreamInterface::class, $stream);
$this->assertEquals(file_get_contents($filepath), $stream->getContents());
$this->assertFalse($stream->isWritable());
}
}
+11 -20
View File
@@ -33,9 +33,8 @@ class activity_packager_test extends \advanced_testcase {
*
* @covers ::override_task_setting
* @covers ::get_all_task_settings
* @return void
*/
public function test_override_task_setting() {
public function test_override_task_setting(): void {
global $USER;
$this->resetAfterTest();
$this->setAdminUser();
@@ -99,9 +98,8 @@ class activity_packager_test extends \advanced_testcase {
*
* @covers ::get_package
* @covers ::package
* @return void
*/
public function test_get_package() {
public function test_get_package(): void {
global $USER;
$this->resetAfterTest();
$this->setAdminUser();
@@ -120,25 +118,18 @@ class activity_packager_test extends \advanced_testcase {
$packager = new activity_packager($cminfo, $USER->id);
$package = $packager->get_package();
$this->assertEquals(2, count($package));
// Confirm there are backup file contents returned.
$this->assertTrue(array_key_exists('filecontents', $package));
$this->assertNotEmpty($package['filecontents']);
// Confirm the expected stored_file object is returned.
$this->assertTrue(array_key_exists('storedfile', $package));
$this->assertInstanceOf(\stored_file::class, $package['storedfile']);
$this->assertInstanceOf(\stored_file::class, $package);
// Check some known values in the returned stored_file object to confirm they match the file we have packaged.
$this->assertNotEmpty($package['storedfile']->get_contenthash());
$this->assertEquals(user::instance($USER->id)->id, $package['storedfile']->get_contextid());
$this->assertEquals('user', $package['storedfile']->get_component());
$this->assertEquals('draft', $package['storedfile']->get_filearea());
$this->assertEquals('assign_backup.mbz', $package['storedfile']->get_filename());
$this->assertGreaterThan(0, $package['storedfile']->get_filesize());
$timecreated = $package['storedfile']->get_timecreated();
$this->assertNotEmpty($package->get_contenthash());
$this->assertEquals(user::instance($USER->id)->id, $package->get_contextid());
$this->assertEquals('user', $package->get_component());
$this->assertEquals('draft', $package->get_filearea());
$this->assertEquals('assign_backup.mbz', $package->get_filename());
$this->assertGreaterThan(0, $package->get_filesize());
$timecreated = $package->get_timecreated();
$this->assertGreaterThanOrEqual($currenttime, $timecreated);
$this->assertEquals($timecreated, $package['storedfile']->get_timemodified());
$this->assertEquals($timecreated, $package->get_timemodified());
}
}
+10 -14
View File
@@ -29,10 +29,6 @@ use ReflectionMethod;
use stdClass;
use testing_data_generator;
defined('MOODLE_INTERNAL') || die();
require_once('helpers.php');
/**
* Unit tests for {@see activity_sender}.
*
@@ -56,6 +52,12 @@ class activity_sender_test extends \advanced_testcase {
/** @var MockObject $mockoauthclient Mock OAuth client. */
private MockObject $mockoauthclient;
public static function setUpBeforeClass(): void {
parent::setUpBeforeClass();
require_once(__DIR__ . '/helpers.php');
}
/**
* Set up function for tests.
*/
@@ -83,9 +85,8 @@ class activity_sender_test extends \advanced_testcase {
* Test prepare_share_contents method.
*
* @covers ::prepare_share_contents
* @return void
*/
public function test_prepare_share_contents() {
public function test_prepare_share_contents(): void {
global $USER;
$this->setAdminUser();
@@ -126,13 +127,9 @@ class activity_sender_test extends \advanced_testcase {
activity_sender::SHARE_FORMAT_BACKUP
));
$this->assertNotEmpty($package);
// Confirm there are backup file contents returned.
$this->assertTrue(array_key_exists('filecontents', $package));
$this->assertNotEmpty($package['filecontents']);
// Confirm the expected stored_file object is returned.
$this->assertTrue(array_key_exists('storedfile', $package));
$this->assertInstanceOf(\stored_file::class, $package['storedfile']);
$this->assertInstanceOf(\stored_file::class, $package);
}
/**
@@ -141,13 +138,12 @@ class activity_sender_test extends \advanced_testcase {
* @dataProvider share_activity_provider
* @covers ::share_activity
* @covers ::log_event
* @covers \core\moodlenet\moodlenet_client::create_resource_from_file
* @covers \core\moodlenet\moodlenet_client::create_resource_from_stored_file
* @covers \core\moodlenet\moodlenet_client::prepare_file_share_request_data
* @param ResponseInterface $httpresponse
* @param array $expected
* @return void
*/
public function test_share_activity(ResponseInterface $httpresponse, array $expected) {
public function test_share_activity(ResponseInterface $httpresponse, array $expected): void {
global $CFG, $USER;
$this->setAdminUser();
+2
View File
@@ -138,6 +138,8 @@ information provided here is intended especially for developers.
- taskslimit - to limit the number of tasks in one run
- failed - to limit the run to only the tasks that failed in their previous run
Can be mixed, apart from 'id' of course.
* The file_system class now declares a new, optional, `get_psr_stream` function which allows a Stream implementing `\Psr\Http\Message\StreamInterface` to be returned.
A default implementation which uses the existing file handle resource is used by default, but this should be extended by file_system implementations where relevant.
=== 4.1 ===