mirror of
https://github.com/moodle/moodle.git
synced 2025-02-19 23:55:54 +01:00
Merge branch 'MDL-81767-main' of https://github.com/roland04/moodle
This commit is contained in:
commit
e812811b11
12
.upgradenotes/MDL-81767-2024082909213766.yml
Normal file
12
.upgradenotes/MDL-81767-2024082909213766.yml
Normal file
@ -0,0 +1,12 @@
|
||||
issueNumber: MDL-81767
|
||||
notes:
|
||||
core_course:
|
||||
- message: >-
|
||||
Added new 'activitychooserbutton' output class to display the
|
||||
activitychooser button. New action_links can be added to the button via
|
||||
hooks converting it into a dropdown.
|
||||
type: improved
|
||||
- message: >-
|
||||
New `core_course\hook\before_activitychooserbutton_exported` hook added
|
||||
to allow third-party plugins to extend activity chooser button options
|
||||
type: improved
|
12
.upgradenotes/MDL-81767-2024082909393804.yml
Normal file
12
.upgradenotes/MDL-81767-2024082909393804.yml
Normal file
@ -0,0 +1,12 @@
|
||||
issueNumber: MDL-81767
|
||||
notes:
|
||||
mod:
|
||||
- message: >-
|
||||
Added new FEATURE_QUICKCREATE for modules that can be quickly created in
|
||||
the course wihout filling a previous form.
|
||||
type: improved
|
||||
core_courseformat:
|
||||
- message: >-
|
||||
Added new 'create_module' webservice to create new module (with
|
||||
quickcreate feature) instances in the course.
|
||||
type: improved
|
File diff suppressed because one or more lines are too long
2
course/amd/build/actions.min.js
vendored
2
course/amd/build/actions.min.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -65,7 +65,7 @@ define(
|
||||
const componentActions = [
|
||||
'moveSection', 'moveCm', 'addSection', 'deleteSection', 'cmDelete', 'cmDuplicate', 'sectionHide', 'sectionShow',
|
||||
'cmHide', 'cmShow', 'cmStealth', 'sectionHighlight', 'sectionUnhighlight', 'cmMoveRight', 'cmMoveLeft',
|
||||
'cmNoGroups', 'cmVisibleGroups', 'cmSeparateGroups',
|
||||
'cmNoGroups', 'cmVisibleGroups', 'cmSeparateGroups', 'addModule',
|
||||
];
|
||||
|
||||
// The course reactive instance.
|
||||
|
@ -0,0 +1,93 @@
|
||||
<?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/>.
|
||||
|
||||
namespace core_course\hook;
|
||||
|
||||
use cm_info;
|
||||
use section_info;
|
||||
use core\hook\described_hook;
|
||||
use core_course\output\activitychooserbutton;
|
||||
|
||||
/**
|
||||
* Hook before activity chooser button export.
|
||||
*
|
||||
* @package core_course
|
||||
* @copyright 2024 Mikel Martín <mikel@moodle.com>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class before_activitychooserbutton_exported implements described_hook {
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param activitychooserbutton $activitychooserbutton the activity chooser button output
|
||||
* @param section_info $section the course section
|
||||
* @param cm_info|null $cm the course module
|
||||
*/
|
||||
public function __construct(
|
||||
/** @var activitychooserbutton the activity chooser button output */
|
||||
protected activitychooserbutton $activitychooserbutton,
|
||||
/** @var section_info the course section */
|
||||
protected section_info $section,
|
||||
/** @var cm_info|null the course module */
|
||||
protected ?cm_info $cm = null,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes the hook purpose.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function get_hook_description(): string {
|
||||
return 'This hook is triggered when a activity chooser button is exported.';
|
||||
}
|
||||
|
||||
/**
|
||||
* List of tags that describe this hook.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public static function get_hook_tags(): array {
|
||||
return ['course'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get activitychooserbutton output instance.
|
||||
*
|
||||
* @return activitychooserbutton
|
||||
*/
|
||||
public function get_activitychooserbutton(): activitychooserbutton {
|
||||
return $this->activitychooserbutton;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get course section instance.
|
||||
*
|
||||
* @return section_info
|
||||
*/
|
||||
public function get_section(): section_info {
|
||||
return $this->section;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get course module instance.
|
||||
*
|
||||
* @return cm_info|null
|
||||
*/
|
||||
public function get_cm(): ?cm_info {
|
||||
return $this->cm;
|
||||
}
|
||||
}
|
92
course/classes/output/activitychooserbutton.php
Normal file
92
course/classes/output/activitychooserbutton.php
Normal file
@ -0,0 +1,92 @@
|
||||
<?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/>.
|
||||
|
||||
namespace core_course\output;
|
||||
|
||||
use action_link;
|
||||
use cm_info;
|
||||
use renderable;
|
||||
use renderer_base;
|
||||
use section_info;
|
||||
use stdClass;
|
||||
use templatable;
|
||||
use core\di;
|
||||
use core\hook;
|
||||
|
||||
/**
|
||||
* Class to render a activity chooser button.
|
||||
*
|
||||
* @package core_course
|
||||
* @copyright 2024 Mikel Martín <mikel@moodle.com>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class activitychooserbutton implements templatable, renderable {
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param section_info $section the section info
|
||||
* @param cm_info|null $mod the course module ionfo
|
||||
* @param int|null $sectionreturn the section to return to
|
||||
* @param array|null $actionlinks the action links
|
||||
*/
|
||||
public function __construct(
|
||||
/** @var section_info the section object */
|
||||
protected section_info $section,
|
||||
/** @var cm_info|null the course module instance */
|
||||
protected ?cm_info $mod = null,
|
||||
/** @var sectionreturn|null the section to return to */
|
||||
protected ?int $sectionreturn = null,
|
||||
/** @var array|null action_link[] the action links */
|
||||
protected ?array $actionlinks = [],
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Export this data so it can be used as the context for a mustache template.
|
||||
*
|
||||
* @param renderer_base $output typically, the renderer that's calling this function
|
||||
* @return stdClass data context for a mustache template
|
||||
*/
|
||||
public function export_for_template(renderer_base $output): stdClass {
|
||||
// Look for plugins that want to add extra action links to the activity chooser button.
|
||||
di::get(hook\manager::class)->dispatch(
|
||||
new \core_course\hook\before_activitychooserbutton_exported(
|
||||
$this,
|
||||
$this->section,
|
||||
$this->mod,
|
||||
),
|
||||
);
|
||||
|
||||
return (object)[
|
||||
'sectionnum' => $this->section->section,
|
||||
'sectionreturn' => $this->sectionreturn ?? false,
|
||||
'modid' => $this->mod ? $this->mod->id : false,
|
||||
'activityname' => $this->mod ? $this->mod->get_formatted_name() : false,
|
||||
'hasactionlinks' => !empty($this->actionlinks),
|
||||
'actionlinks' => array_map(fn(action_link $action) => $action->export_for_template($output), $this->actionlinks),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an action link.
|
||||
*
|
||||
* @param action_link $action the action link to add
|
||||
*/
|
||||
public function add_action_link(action_link $action): void {
|
||||
$this->actionlinks[] = $action;
|
||||
}
|
||||
}
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -458,6 +458,17 @@ export default class extends BaseComponent {
|
||||
this.reactive.dispatch('addSection', target.dataset.id ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a create subsection request.
|
||||
*
|
||||
* @param {Element} target the dispatch action element
|
||||
* @param {Event} event the triggered event
|
||||
*/
|
||||
async _requestAddModule(target, event) {
|
||||
event.preventDefault();
|
||||
this.reactive.dispatch('addModule', target.dataset.modname, target.dataset.sectionnum, target.dataset.beforemod);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a delete section request.
|
||||
*
|
||||
|
@ -66,6 +66,31 @@ export default class {
|
||||
return JSON.parse(ajaxresult);
|
||||
}
|
||||
|
||||
/**
|
||||
* Private method to call core_courseformat_create_module webservice.
|
||||
*
|
||||
* @method _callEditWebservice
|
||||
* @param {number} courseId
|
||||
* @param {string} modName module name
|
||||
* @param {number} targetSectionNum target section number
|
||||
* @param {number} targetCmId optional target cm id
|
||||
*/
|
||||
async _callAddModuleWebservice(courseId, modName, targetSectionNum, targetCmId) {
|
||||
const args = {
|
||||
courseid: courseId,
|
||||
modname: modName,
|
||||
targetsectionnum: targetSectionNum,
|
||||
};
|
||||
if (targetCmId) {
|
||||
args.targetcmid = targetCmId;
|
||||
}
|
||||
let ajaxresult = await ajax.call([{
|
||||
methodname: 'core_courseformat_create_module',
|
||||
args,
|
||||
}])[0];
|
||||
return JSON.parse(ajaxresult);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a basic section state action.
|
||||
* @param {StateManager} stateManager the current state manager
|
||||
@ -391,6 +416,29 @@ export default class {
|
||||
stateManager.processUpdates(updates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new module to a specific course section.
|
||||
*
|
||||
* @param {StateManager} stateManager the current state manager
|
||||
* @param {string} modName the modulename to add
|
||||
* @param {number} targetSectionNum the target section number
|
||||
* @param {number} targetCmId optional the target cm id
|
||||
*/
|
||||
async addModule(stateManager, modName, targetSectionNum, targetCmId) {
|
||||
if (!modName) {
|
||||
throw new Error(`Mutation addModule requires moduleName`);
|
||||
}
|
||||
if (!targetSectionNum) {
|
||||
throw new Error(`Mutation addModule requires targetSectionNum`);
|
||||
}
|
||||
if (!targetCmId) {
|
||||
targetCmId = 0;
|
||||
}
|
||||
const course = stateManager.get('course');
|
||||
const updates = await this._callAddModuleWebservice(course.id, modName, targetSectionNum, targetCmId);
|
||||
stateManager.processUpdates(updates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark or unmark course modules as dragging.
|
||||
*
|
||||
|
144
course/format/classes/external/create_module.php
vendored
Normal file
144
course/format/classes/external/create_module.php
vendored
Normal file
@ -0,0 +1,144 @@
|
||||
<?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/>.
|
||||
|
||||
namespace core_courseformat\external;
|
||||
|
||||
use core_external\external_api;
|
||||
use core_external\external_function_parameters;
|
||||
use core_external\external_value;
|
||||
use moodle_exception;
|
||||
use coding_exception;
|
||||
use context_course;
|
||||
use core_courseformat\base as course_format;
|
||||
|
||||
/**
|
||||
* External service to create a new module instance in the course.
|
||||
*
|
||||
* @package core_courseformat
|
||||
* @copyright 2024 Mikel Martín <mikel@moodle.com>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class create_module extends external_api {
|
||||
|
||||
/**
|
||||
* Webservice parameters.
|
||||
*
|
||||
* @return external_function_parameters
|
||||
*/
|
||||
public static function execute_parameters(): external_function_parameters {
|
||||
return new external_function_parameters(
|
||||
[
|
||||
'courseid' => new external_value(PARAM_INT, 'course id', VALUE_REQUIRED),
|
||||
'modname' => new external_value(PARAM_ALPHANUMEXT, 'module name', VALUE_REQUIRED),
|
||||
'targetsectionnum' => new external_value(PARAM_INT, 'target section number', VALUE_REQUIRED, null),
|
||||
'targetcmid' => new external_value(PARAM_INT, 'Optional target cm id', VALUE_DEFAULT, null),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* This webservice will execute the create_module action from the course editor.
|
||||
*
|
||||
* The action will register in a {@see \core_courseformat\stateupdates} all the affected
|
||||
* sections, cms and course attribute. This object (in JSON) will be sent back to the
|
||||
* frontend editor to refresh the updated state elements.
|
||||
*
|
||||
* By default, {@see \core_courseformat\stateupdates} will register only create, delete and update events
|
||||
* on cms, sections and the general course data. However, if some plugin needs adhoc messages for
|
||||
* its own mutation module, extend this class in format_XXX\course.
|
||||
*
|
||||
* @param int $courseid the course id
|
||||
* @param string $modname the module name
|
||||
* @param int $targetsectionnum the target section number
|
||||
* @param int|null $targetcmid optional target cm id
|
||||
* @return string Course state in JSON
|
||||
*/
|
||||
public static function execute(
|
||||
int $courseid,
|
||||
string $modname,
|
||||
int $targetsectionnum,
|
||||
?int $targetcmid = null
|
||||
): string {
|
||||
global $CFG;
|
||||
|
||||
require_once($CFG->dirroot . '/course/lib.php');
|
||||
|
||||
[
|
||||
'courseid' => $courseid,
|
||||
'modname' => $modname,
|
||||
'targetsectionnum' => $targetsectionnum,
|
||||
'targetcmid' => $targetcmid,
|
||||
] = self::validate_parameters(self::execute_parameters(), [
|
||||
'courseid' => $courseid,
|
||||
'modname' => $modname,
|
||||
'targetsectionnum' => $targetsectionnum,
|
||||
'targetcmid' => $targetcmid,
|
||||
]);
|
||||
|
||||
self::validate_context(context_course::instance($courseid));
|
||||
|
||||
// Plugin needs to support quick creation and the course format needs to support components.
|
||||
// Formats using YUI modules should not be able to quick-create because the front end cannot react to the change.
|
||||
if (!plugin_supports('mod', $modname, FEATURE_QUICKCREATE) || !course_get_format($courseid)->supports_components()) {
|
||||
throw new moodle_exception("Module $modname does not support quick creation");
|
||||
}
|
||||
|
||||
$courseformat = course_get_format($courseid);
|
||||
|
||||
// Create a course changes tracker object.
|
||||
$defaultupdatesclass = 'core_courseformat\\stateupdates';
|
||||
$updatesclass = 'format_' . $courseformat->get_format() . '\\courseformat\\stateupdates';
|
||||
if (!class_exists($updatesclass)) {
|
||||
$updatesclass = $defaultupdatesclass;
|
||||
}
|
||||
$updates = new $updatesclass($courseformat);
|
||||
|
||||
if (!is_a($updates, $defaultupdatesclass)) {
|
||||
throw new coding_exception("The \"$updatesclass\" class must extend \"$defaultupdatesclass\"");
|
||||
}
|
||||
|
||||
// Get the actions class from the course format.
|
||||
$actionsclass = 'format_'. $courseformat->get_format().'\\courseformat\\stateactions';
|
||||
if (!class_exists($actionsclass)) {
|
||||
$actionsclass = 'core_courseformat\\stateactions';
|
||||
}
|
||||
$actions = new $actionsclass();
|
||||
|
||||
$action = 'create_module';
|
||||
if (!is_callable([$actions, $action])) {
|
||||
throw new moodle_exception("Invalid course state action $action in ".get_class($actions));
|
||||
}
|
||||
|
||||
$course = $courseformat->get_course();
|
||||
|
||||
// Execute the action.
|
||||
$actions->$action($updates, $course, $modname, $targetsectionnum, $targetcmid);
|
||||
|
||||
// Any state action mark the state cache as dirty.
|
||||
course_format::session_cache_reset($course);
|
||||
|
||||
return json_encode($updates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Webservice returns.
|
||||
*
|
||||
* @return external_value
|
||||
*/
|
||||
public static function execute_returns(): external_value {
|
||||
return new external_value(PARAM_RAW, 'Encoded course update JSON');
|
||||
}
|
||||
}
|
@ -136,6 +136,7 @@ class cm implements named_templatable, renderable {
|
||||
$haspartials['editor'] = $this->add_editor_data($data, $output);
|
||||
$haspartials['groupmode'] = $this->add_groupmode_data($data, $output);
|
||||
$haspartials['visibility'] = $this->add_visibility_data($data, $output);
|
||||
$this->add_actvitychooserbutton_data($data, $output);
|
||||
$this->add_format_data($data, $haspartials, $output);
|
||||
|
||||
// Calculated fields.
|
||||
@ -359,6 +360,17 @@ class cm implements named_templatable, renderable {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the activity chooser button data to the data structure.
|
||||
*
|
||||
* @param stdClass $data the current cm data reference
|
||||
* @param renderer_base $output typically, the renderer that's calling this function
|
||||
*/
|
||||
protected function add_actvitychooserbutton_data(stdClass &$data, renderer_base $output): void {
|
||||
$activitychooserbutton = new \core_course\output\activitychooserbutton($this->section, $this->mod);
|
||||
$data->activitychooserbutton = $activitychooserbutton->export_for_template($output);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the CSS classes for the activity name/content
|
||||
*
|
||||
|
@ -1180,4 +1180,35 @@ class stateactions {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a course module.
|
||||
*
|
||||
* @param stateupdates $updates the affected course elements track
|
||||
* @param stdClass $course the course object
|
||||
* @param string $modname the module name
|
||||
* @param int $targetsectionnum target section number
|
||||
* @param int|null $targetcmid optional target cm id
|
||||
*/
|
||||
public function create_module(
|
||||
stateupdates $updates,
|
||||
stdClass $course,
|
||||
string $modname,
|
||||
int $targetsectionnum,
|
||||
?int $targetcmid = null
|
||||
): void {
|
||||
global $CFG;
|
||||
require_once($CFG->dirroot . '/course/modlib.php');
|
||||
|
||||
$coursecontext = context_course::instance($course->id);
|
||||
require_capability('moodle/course:update', $coursecontext);
|
||||
|
||||
// Method "can_add_moduleinfo" called in "prepare_new_moduleinfo_data" will handle the capability checks.
|
||||
[, , , , $moduleinfo] = prepare_new_moduleinfo_data($course, $modname, $targetsectionnum);
|
||||
$moduleinfo->beforemod = $targetcmid;
|
||||
create_module((object) $moduleinfo);
|
||||
|
||||
// Adding module affects section structure, and if the module has a delegated section even the course structure.
|
||||
$this->course_state($updates, $course);
|
||||
}
|
||||
}
|
||||
|
@ -59,7 +59,9 @@
|
||||
}}
|
||||
{{#editing}}
|
||||
{{< core_courseformat/local/content/divider}}
|
||||
{{$content}}{{> core_course/activitychooserbuttonactivity}}{{/content}}
|
||||
{{$content}}
|
||||
{{#activitychooserbutton}}{{> core_course/activitychooserbutton}}{{/activitychooserbutton}}
|
||||
{{/content}}
|
||||
{{/ core_courseformat/local/content/divider}}
|
||||
{{/editing}}
|
||||
<div class="activity-item focus-control {{#modstealth}}hiddenactivity{{/modstealth}}{{!
|
||||
|
@ -29,6 +29,6 @@
|
||||
<div class="divider bulk-hidden d-flex justify-content-center align-items-center {{$extraclasses}}{{extraclasses}}{{/extraclasses}}">
|
||||
<hr>
|
||||
<div class="divider-content px-3">
|
||||
{{$content}}{{content}}{{/content}}
|
||||
{{$content}}{{{content}}}{{/content}}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -39,7 +39,7 @@
|
||||
{{$extraclasses}}always-hidden mt-2{{/extraclasses}}
|
||||
{{$content}}
|
||||
<a href="{{{url}}}"
|
||||
class="btn add-content section-modchooser section-modchooser-link activitychooser-button d-flex justify-content-center align-items-center p-1 icon-no-margin
|
||||
class="btn add-content section-modchooser section-modchooser-link d-flex justify-content-center align-items-center p-1 icon-no-margin
|
||||
{{^canaddsection}}disabled{{/canaddsection}}"
|
||||
data-add-sections="{{title}}"
|
||||
data-new-sections="{{newsection}}"
|
||||
|
159
course/format/tests/external/create_module_test.php
vendored
Normal file
159
course/format/tests/external/create_module_test.php
vendored
Normal file
@ -0,0 +1,159 @@
|
||||
<?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_courseformat\external;
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
use moodle_exception;
|
||||
use stdClass;
|
||||
|
||||
global $CFG;
|
||||
require_once($CFG->dirroot . '/webservice/tests/helpers.php');
|
||||
|
||||
/**
|
||||
* Tests for the create_module class.
|
||||
*
|
||||
* @package core_courseformat
|
||||
* @category test
|
||||
* @copyright 2024 Mikel Martín <mikel@moodle.com>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
* @coversDefaultClass \core_courseformat\external\create_module
|
||||
*/
|
||||
final class create_module_test extends \externallib_advanced_testcase {
|
||||
|
||||
/**
|
||||
* Setup to ensure that fixtures are loaded.
|
||||
*/
|
||||
public static function setupBeforeClass(): void { // phpcs:ignore
|
||||
global $CFG;
|
||||
|
||||
require_once($CFG->dirroot . '/course/format/tests/fixtures/format_theunittest.php');
|
||||
require_once($CFG->dirroot . '/course/format/tests/fixtures/format_theunittest_output_course_format_state.php');
|
||||
require_once($CFG->dirroot . '/course/format/tests/fixtures/format_theunittest_stateactions.php');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the webservice can execute the create_module action.
|
||||
*
|
||||
* @covers ::execute
|
||||
*/
|
||||
public function test_execute(): void {
|
||||
$this->resetAfterTest();
|
||||
|
||||
$modname = 'subsection';
|
||||
$manager = \core_plugin_manager::resolve_plugininfo_class('mod');
|
||||
$manager::enable_plugin($modname, 1);
|
||||
|
||||
// Create a course with an activity.
|
||||
$course = $this->getDataGenerator()->create_course(['numsections' => 1]);
|
||||
$activity = $this->getDataGenerator()->create_module('book', ['course' => $course->id]);
|
||||
$targetsection = get_fast_modinfo($course->id)->get_section_info(1);
|
||||
|
||||
$this->setAdminUser();
|
||||
|
||||
// Execute course action.
|
||||
$results = json_decode(create_module::execute((int)$course->id, $modname, (int)$targetsection->id, (int)$activity->id));
|
||||
|
||||
// Check result.
|
||||
$cmupdate = $this->find_update_by_fieldname($results, 'put', 'cm', get_string('quickcreatename', 'mod_' . $modname));
|
||||
$this->assertNotEmpty($cmupdate);
|
||||
$this->assertEquals($modname, $cmupdate->fields->module);
|
||||
$this->assertEquals($targetsection->id, $cmupdate->fields->sectionnumber);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the webservice can execute the create_module action with a format override.
|
||||
*
|
||||
* @covers ::execute
|
||||
*/
|
||||
public function test_execute_with_format_override(): void {
|
||||
$this->resetAfterTest();
|
||||
|
||||
$manager = \core_plugin_manager::resolve_plugininfo_class('mod');
|
||||
$manager::enable_plugin('subsection', 1);
|
||||
|
||||
// Create a course.
|
||||
$course = $this->getDataGenerator()->create_course(['format' => 'theunittest', 'numsections' => 1, 'initsections' => 1]);
|
||||
$targetsection = get_fast_modinfo($course->id)->get_section_info(1);
|
||||
|
||||
$this->setAdminUser();
|
||||
|
||||
// Execute course action.
|
||||
$modname = 'subsection';
|
||||
$results = json_decode(create_module::execute((int)$course->id, $modname, (int)$targetsection->id));
|
||||
|
||||
// Some course formats doesn't have the renderer file, so a debugging message will be displayed.
|
||||
$this->assertDebuggingCalled();
|
||||
|
||||
// Check result.
|
||||
$this->assertEmpty($results);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the webservice can execute the create_module action with an invalid module.
|
||||
*
|
||||
* @covers ::execute
|
||||
*/
|
||||
public function test_execute_with_invalid_module(): void {
|
||||
$this->resetAfterTest();
|
||||
|
||||
// Create a course.
|
||||
$course = $this->getDataGenerator()->create_course(['numsections' => 1]);
|
||||
$targetsection = get_fast_modinfo($course->id)->get_section_info(1);
|
||||
|
||||
$this->setAdminUser();
|
||||
|
||||
// Expect exception. Book module doesn't support quickcreate feature.
|
||||
$this->expectException(moodle_exception::class);
|
||||
|
||||
// Execute course action.
|
||||
$modname = 'book';
|
||||
create_module::execute((int)$course->id, $modname, (int)$targetsection->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper methods to find a specific update in the updadelist.
|
||||
*
|
||||
* @param array $updatelist the update list
|
||||
* @param string $action the action to find
|
||||
* @param string $name the element name to find
|
||||
* @param string $fieldname the element identifiername
|
||||
* @return stdClass|null the object found, if any.
|
||||
*/
|
||||
private function find_update_by_fieldname(
|
||||
array $updatelist,
|
||||
string $action,
|
||||
string $name,
|
||||
string $fieldname,
|
||||
|
||||
): ?stdClass {
|
||||
foreach ($updatelist as $update) {
|
||||
if ($update->action != $action || $update->name != $name) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($update->fields->name)) {
|
||||
continue;
|
||||
}
|
||||
if ($update->fields->name == $fieldname) {
|
||||
return $update;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
@ -82,4 +82,25 @@ class format_theunittest extends core_courseformat\base {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this course format uses sections
|
||||
*/
|
||||
public function uses_sections() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this course format is compatible with content components.
|
||||
*
|
||||
* Using components means the content elements can watch the frontend course state and
|
||||
* react to the changes. Formats with component compatibility can have more interactions
|
||||
* without refreshing the page, like having drag and drop from the course index to reorder
|
||||
* sections and activities.
|
||||
*
|
||||
* @return bool if the format is compatible with components.
|
||||
*/
|
||||
public function supports_components() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
@ -48,6 +48,24 @@ class stateactions extends core_actions {
|
||||
$updates->add_cm_create(array_pop($ids));
|
||||
}
|
||||
|
||||
/**
|
||||
* Alternative create_module state action for testing.
|
||||
*
|
||||
* @param stateupdates $updates the affected course elements track
|
||||
* @param stdClass $course the course object
|
||||
* @param string $modname the module name
|
||||
* @param int $targetsectionid target section id
|
||||
* @param int|null $targetcmid optional target cm id
|
||||
*/
|
||||
public function create_module(
|
||||
stateupdates $updates,
|
||||
stdClass $course,
|
||||
string $modname,
|
||||
int $targetsectionid,
|
||||
?int $targetcmid = null
|
||||
): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Course format custom state action.
|
||||
*
|
||||
|
@ -1702,4 +1702,99 @@ class stateactions_test extends \advanced_testcase {
|
||||
$this->assertArrayNotHasKey($subsection->cmid, $result);
|
||||
$this->assertEquals($otheractvityinfo, $result[$otheractvityinfo->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test for create_module public method.
|
||||
*
|
||||
* @covers ::create_module
|
||||
*/
|
||||
public function test_create_module(): void {
|
||||
$this->resetAfterTest();
|
||||
|
||||
$modname = 'subsection';
|
||||
$manager = \core_plugin_manager::resolve_plugininfo_class('mod');
|
||||
$manager::enable_plugin($modname, 1);
|
||||
|
||||
// Create a course with 1 section and 1 student.
|
||||
$course = $this->getDataGenerator()->create_course(['numsections' => 1]);
|
||||
$student = $this->getDataGenerator()->create_user();
|
||||
$this->getDataGenerator()->enrol_user($student->id, $course->id, 'student');
|
||||
$courseformat = course_get_format($course->id);
|
||||
$targetsection = $courseformat->get_modinfo()->get_section_info(1);
|
||||
|
||||
$this->setAdminUser();
|
||||
|
||||
// Sanity check.
|
||||
$this->assertEmpty($courseformat->get_modinfo()->get_cms());
|
||||
|
||||
// Execute given method.
|
||||
$actions = new stateactions();
|
||||
$updates = new stateupdates($courseformat);
|
||||
$actions->create_module($updates, $course, $modname, $targetsection->sectionnum);
|
||||
|
||||
// Validate cm was created and updates were generated.
|
||||
$results = $this->summarize_updates($updates);
|
||||
$cmupdate = reset($results['put']['cm']);
|
||||
$this->assertCount(1, $courseformat->get_modinfo()->get_cms());
|
||||
$this->assertEquals($modname, $cmupdate->module);
|
||||
$this->assertEquals($targetsection->id, $cmupdate->sectionid);
|
||||
$this->assertEquals(get_string('quickcreatename', 'mod_' . $modname), $cmupdate->name);
|
||||
|
||||
// Change to a user without permission.
|
||||
$this->setUser($student);
|
||||
|
||||
// Validate that the method throws an exception.
|
||||
$this->expectException(moodle_exception::class);
|
||||
$actions->create_module($updates, $course, $modname, $targetsection->sectionnum);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test for create_module public method with targetcmid parameter.
|
||||
*
|
||||
* @covers ::create_module
|
||||
*/
|
||||
public function test_create_module_with_targetcmid(): void {
|
||||
$this->resetAfterTest();
|
||||
|
||||
$modname = 'subsection';
|
||||
$manager = \core_plugin_manager::resolve_plugininfo_class('mod');
|
||||
$manager::enable_plugin($modname, 1);
|
||||
|
||||
// Create a course with 1 section, 2 modules (forum and page) and 1 student.
|
||||
$course = $this->getDataGenerator()->create_course(['numsections' => 1]);
|
||||
$forum = $this->getDataGenerator()->create_module('forum', ['course' => $course], ['section' => 1]);
|
||||
$page = $this->getDataGenerator()->create_module('page', ['course' => $course], ['section' => 1]);
|
||||
$student = $this->getDataGenerator()->create_user();
|
||||
$this->getDataGenerator()->enrol_user($student->id, $course->id, 'student');
|
||||
$courseformat = course_get_format($course->id);
|
||||
$targetsection = $courseformat->get_modinfo()->get_section_info(1);
|
||||
|
||||
$this->setAdminUser();
|
||||
|
||||
// Sanity check.
|
||||
$this->assertCount(2, $courseformat->get_modinfo()->get_cms());
|
||||
|
||||
// Execute given method.
|
||||
$actions = new stateactions();
|
||||
$updates = new stateupdates($courseformat);
|
||||
$actions->create_module($updates, $course, $modname, $targetsection->sectionnum, $page->cmid);
|
||||
|
||||
$modinfo = $courseformat->get_modinfo();
|
||||
$cms = $modinfo->get_cms();
|
||||
$results = $this->summarize_updates($updates);
|
||||
$cmupdate = reset($results['put']['cm']);
|
||||
|
||||
// Validate updates were generated.
|
||||
$this->assertEquals($modname, $cmupdate->module);
|
||||
$this->assertEquals($targetsection->id, $cmupdate->sectionid);
|
||||
$this->assertEquals(get_string('quickcreatename', 'mod_' . $modname), $cmupdate->name);
|
||||
|
||||
// Validate that the new module was created between both modules.
|
||||
$this->assertCount(3, $cms);
|
||||
$this->assertArrayHasKey($cmupdate->id, $cms);
|
||||
$this->assertEquals(
|
||||
implode(',', [$forum->cmid, $cmupdate->id, $page->cmid]),
|
||||
$modinfo->get_section_info(1)->sequence
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -926,5 +926,11 @@ function prepare_new_moduleinfo_data($course, $modulename, $section, string $suf
|
||||
}
|
||||
}
|
||||
|
||||
if (plugin_supports('mod', $data->modulename, FEATURE_QUICKCREATE)) {
|
||||
if (get_string_manager()->string_exists('quickcreatename', "mod_{$data->modulename}")) {
|
||||
$data->name = get_string("quickcreatename", "mod_{$data->modulename}");
|
||||
}
|
||||
}
|
||||
|
||||
return array($module, $context, $cw, $cm, $data);
|
||||
}
|
||||
|
@ -224,16 +224,20 @@ class core_course_renderer extends plugin_renderer_base {
|
||||
return '';
|
||||
}
|
||||
|
||||
$data = [
|
||||
'sectionnum' => $section,
|
||||
'sectionreturn' => $sectionreturn
|
||||
];
|
||||
$ajaxcontrol = $this->render_from_template('course/activitychooserbutton', $data);
|
||||
$sectioninfo = get_fast_modinfo($course)->get_section_info($section);
|
||||
|
||||
$activitychooserbutton = new \core_course\output\activitychooserbutton($sectioninfo, null, $sectionreturn);
|
||||
|
||||
// Load the JS for the modal.
|
||||
$this->course_activitychooser($course->id);
|
||||
|
||||
return $ajaxcontrol;
|
||||
return $this->render_from_template(
|
||||
'core_courseformat/local/content/divider',
|
||||
[
|
||||
'content' => $this->render($activitychooserbutton),
|
||||
'extraclasses' => 'always-visible my-3',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -19,26 +19,41 @@
|
||||
|
||||
Displays a add activity or resource button.
|
||||
|
||||
Context variables required for this template:
|
||||
* sectionid - Relative section number (field course_sections.section).
|
||||
* sectionreturn - The section to link back to.
|
||||
|
||||
Example context (json):
|
||||
{
|
||||
"sectionid": 1,
|
||||
"hasactionlinks": false,
|
||||
"sectionnum": 0,
|
||||
"modid": 1,
|
||||
"activityname": "Activity example",
|
||||
"sectionreturn": 0
|
||||
}
|
||||
}}
|
||||
{{< core_courseformat/local/content/divider}}
|
||||
{{$extraclasses}}always-visible my-3{{/extraclasses}}
|
||||
{{$content}}
|
||||
<button class="btn add-content section-modchooser section-modchooser-link d-flex justify-content-center align-items-center py-1 px-2"
|
||||
data-action="open-chooser"
|
||||
data-sectionnum="{{sectionnum}}"
|
||||
{{#sectionreturn}}data-sectionreturnnum="{{.}}"{{/sectionreturn}}
|
||||
>
|
||||
{{#pix}} t/add, core {{/pix}}
|
||||
<span class="activity-add-text pe-1">{{#str}}addresourceoractivity, core{{/str}}</span>
|
||||
</button>
|
||||
{{/content}}
|
||||
{{/ core_courseformat/local/content/divider}}
|
||||
|
||||
{{! Single button to add a new activity or resource. }}
|
||||
{{^hasactionlinks}}
|
||||
{{> core_course/addresourceoractivitybutton }}
|
||||
{{/hasactionlinks}}
|
||||
|
||||
{{! Dropdown with "add a new activity or resource" item and any other action links. }}
|
||||
{{#hasactionlinks}}
|
||||
<div class="dropdown">
|
||||
<button class="btn add-content d-flex justify-content-center align-items-center p-1 icon-no-margin"
|
||||
type="button"
|
||||
id="dropdownMenuButton"
|
||||
data-toggle="dropdown"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
aria-label="{{#str}}insertcontentbefore, core, { "activityname": {{#quote}} {{activityname}} {{/quote}} } {{/str}}"
|
||||
tabindex="0"
|
||||
title="{{#str}}addcontent, core{{/str}}"
|
||||
>
|
||||
{{#pix}} t/add, core {{/pix}}
|
||||
</button>
|
||||
<div class="dropdown-menu" aria-labelledby="dropdownMenuButton">
|
||||
{{> core_course/addresourceoractivitybutton }}
|
||||
{{#actionlinks}}
|
||||
{{> core/action_link}}
|
||||
{{/actionlinks}}
|
||||
</div>
|
||||
</div>
|
||||
{{/hasactionlinks}}
|
||||
|
@ -1,44 +0,0 @@
|
||||
{{!
|
||||
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/>.
|
||||
}}
|
||||
{{!
|
||||
@template core_course/activitychooserbuttonactivity
|
||||
|
||||
Displays a add activity or resource button.
|
||||
|
||||
Context variables required for this template:
|
||||
* id - Which activity we want to add the new activity before.
|
||||
* num - Relative section number (field course_sections.section).
|
||||
* sectionreturn - The section to link back to.
|
||||
|
||||
Example context (json):
|
||||
{
|
||||
"id": 0,
|
||||
"sectionnum": 1,
|
||||
"sectionreturn": 5
|
||||
}
|
||||
}}
|
||||
<button class="btn add-content section-modchooser section-modchooser-link activitychooser-button d-flex justify-content-center align-items-center p-1 icon-no-margin"
|
||||
data-action="open-chooser"
|
||||
data-sectionnum="{{sectionnum}}"
|
||||
{{#sectionreturn}}data-sectionreturnnum="{{.}}"{{/sectionreturn}}
|
||||
data-beforemod="{{id}}"
|
||||
aria-label="{{#str}}insertresourceoractivitybefore, core, { "activityname": {{#quote}} {{activityname}} {{/quote}} } {{/str}}"
|
||||
tabindex="0"
|
||||
title="{{#str}}addresourceoractivity, core{{/str}}"
|
||||
>
|
||||
{{#pix}} t/add, core {{/pix}}
|
||||
</button>
|
57
course/templates/addresourceoractivitybutton.mustache
Normal file
57
course/templates/addresourceoractivitybutton.mustache
Normal file
@ -0,0 +1,57 @@
|
||||
{{!
|
||||
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/>.
|
||||
}}
|
||||
{{!
|
||||
@template core_course/addresourceoractivitybutton
|
||||
|
||||
Displays a add resource or activity button.
|
||||
|
||||
Example context (json):
|
||||
{
|
||||
"sectionnum": 0,
|
||||
"modid": 1,
|
||||
"activityname": "Activity example",
|
||||
"sectionreturn": 0
|
||||
}
|
||||
}}
|
||||
<button class="section-modchooser section-modchooser-link {{!
|
||||
}}{{^hasactionlinks}}btn add-content d-flex justify-content-center align-items-center p-1 icon-no-margin{{/hasactionlinks}}{{!
|
||||
}}{{#hasactionlinks}}dropdown-item{{/hasactionlinks}}"
|
||||
data-action="open-chooser"
|
||||
data-sectionnum="{{sectionnum}}"
|
||||
{{#sectionreturn}}data-sectionreturnnum="{{.}}"{{/sectionreturn}}
|
||||
{{#modid}}
|
||||
data-beforemod="{{modid}}"
|
||||
aria-label="{{#str}}insertresourceoractivitybefore, core, { "activityname": {{#quote}} {{activityname}} {{/quote}} } {{/str}}"
|
||||
tabindex="0"
|
||||
title="{{#str}}addresourceoractivity, core{{/str}}"
|
||||
{{/modid}}
|
||||
>
|
||||
{{^hasactionlinks}}
|
||||
{{#modid}}
|
||||
{{#pix}} t/add, core {{/pix}}
|
||||
{{/modid}}
|
||||
{{^modid}}
|
||||
<div class="px-1">
|
||||
{{#pix}} t/add, core {{/pix}}
|
||||
<span class="activity-add-text pr-1">{{#str}}addresourceoractivity, core{{/str}}</span>
|
||||
</div>
|
||||
{{/modid}}
|
||||
{{/hasactionlinks}}
|
||||
{{#hasactionlinks}}
|
||||
{{#pix}} i/activities, core {{/pix}}{{#str}}activityorresource, core{{/str}}
|
||||
{{/hasactionlinks}}
|
||||
</button>
|
@ -40,6 +40,7 @@ $string['activityheader'] = 'Activity menu';
|
||||
$string['activitymodule'] = 'Activity module';
|
||||
$string['activitymodules'] = 'Activity modules';
|
||||
$string['activitynotready'] = 'Activity not ready yet';
|
||||
$string['activityorresource'] = 'Activity or resource';
|
||||
$string['activityreport'] = 'Activity report';
|
||||
$string['activityreports'] = 'Activity reports';
|
||||
$string['activityselect'] = 'Select this activity to be moved elsewhere';
|
||||
@ -54,6 +55,7 @@ $string['addadmin'] = 'Add admin';
|
||||
$string['addblock'] = 'Add a block';
|
||||
$string['addcomment'] = 'Add a comment...';
|
||||
$string['addcondition'] = 'Add condition';
|
||||
$string['addcontent'] = 'Add content';
|
||||
$string['addcountertousername'] = 'Create user by adding number to username';
|
||||
$string['addcreator'] = 'Add course creator';
|
||||
$string['adddots'] = 'Add...';
|
||||
@ -1168,6 +1170,7 @@ $string['indicator:userforumstracking'] = 'User is tracking forums';
|
||||
$string['indicator:userforumstracking_help'] = 'This indicator represents whether or not the student has tracking turned on in the forums.';
|
||||
$string['info'] = 'Information';
|
||||
$string['inprogress'] = 'In progress';
|
||||
$string['insertcontentbefore'] = 'Insert content before \'{$a->activityname}\'';
|
||||
$string['insertresourceoractivitybefore'] = 'Insert an activity or resource before \'{$a->activityname}\'';
|
||||
$string['institution'] = 'Institution';
|
||||
$string['instudentview'] = 'in student view';
|
||||
|
@ -599,6 +599,14 @@ $functions = array(
|
||||
'ajax' => true,
|
||||
'capabilities' => 'moodle/course:sectionvisibility, moodle/course:activityvisibility',
|
||||
],
|
||||
'core_courseformat_create_module' => [
|
||||
'classname' => 'core_courseformat\external\create_module',
|
||||
'methodname' => 'execute',
|
||||
'description' => 'Add module to course.',
|
||||
'type' => 'write',
|
||||
'ajax' => true,
|
||||
'capabilities' => 'moodle/course:manageactivities',
|
||||
],
|
||||
'core_course_edit_module' => array(
|
||||
'classname' => 'core_course_external',
|
||||
'methodname' => 'edit_module',
|
||||
|
@ -500,6 +500,8 @@ define('MOD_PURPOSE_OTHER', 'other');
|
||||
*/
|
||||
define('MOD_PURPOSE_INTERFACE', 'interface');
|
||||
|
||||
/** True if module can be quickly created without filling a previous form. */
|
||||
define('FEATURE_QUICKCREATE', 'quickcreate');
|
||||
/**
|
||||
* Security token used for allowing access
|
||||
* from external application such as web services.
|
||||
|
@ -0,0 +1,65 @@
|
||||
<?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/>.
|
||||
|
||||
namespace mod_subsection\local\callbacks;
|
||||
|
||||
use core_course\hook\before_activitychooserbutton_exported;
|
||||
use action_link;
|
||||
use moodle_url;
|
||||
use mod_subsection\permission;
|
||||
use pix_icon;
|
||||
use section_info;
|
||||
|
||||
/**
|
||||
* Class before activity choooser button export handler.
|
||||
*
|
||||
* @package mod_subsection
|
||||
* @copyright 2024 Mikel Martín <mikel@moodle.com>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class before_activitychooserbutton_exported_handler {
|
||||
/**
|
||||
* Handle the activity chooser button extra items addition.
|
||||
*
|
||||
* @param before_activitychooserbutton_exported $hook
|
||||
*/
|
||||
public static function callback(before_activitychooserbutton_exported $hook): void {
|
||||
/** @var section_info $section */
|
||||
$section = $hook->get_section();
|
||||
|
||||
if (!permission::can_add_subsection($section)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$attributes = [
|
||||
'class' => 'dropdown-item',
|
||||
'data-action' => 'addModule',
|
||||
'data-modname' => 'subsection',
|
||||
'data-sectionnum' => $section->sectionnum,
|
||||
];
|
||||
if ($hook->get_cm()) {
|
||||
$attributes['data-beforemod'] = $hook->get_cm()->id;
|
||||
}
|
||||
|
||||
$hook->get_activitychooserbutton()->add_action_link(new action_link(
|
||||
new moodle_url('#'),
|
||||
get_string('modulename', 'mod_subsection'),
|
||||
null,
|
||||
$attributes,
|
||||
new pix_icon('subsection', '', 'mod_subsection')
|
||||
));
|
||||
}
|
||||
}
|
60
mod/subsection/classes/permission.php
Normal file
60
mod/subsection/classes/permission.php
Normal file
@ -0,0 +1,60 @@
|
||||
<?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/>.
|
||||
|
||||
namespace mod_subsection;
|
||||
|
||||
use context_course;
|
||||
use section_info;
|
||||
|
||||
/**
|
||||
* Class to check permissions for subsection module.
|
||||
*
|
||||
* @package mod_subsection
|
||||
* @copyright 2024 Mikel Martín <mikel@moodle.com>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class permission {
|
||||
/**
|
||||
* Whether given user can add a subsection in a section
|
||||
*
|
||||
* @param section_info $section the course section
|
||||
* @param int|null $userid User ID to check, or the current user if omitted
|
||||
* @return bool
|
||||
*/
|
||||
public static function can_add_subsection(section_info $section, ?int $userid = null): bool {
|
||||
// Until MDL-82349 is resolved, we need to skip the site course.
|
||||
if ($section->modinfo->get_course()->format == 'site') {
|
||||
return false;
|
||||
}
|
||||
if (!array_key_exists('subsection', \core_plugin_manager::instance()->get_enabled_plugins('mod'))) {
|
||||
return false;
|
||||
}
|
||||
if (!has_capability('mod/subsection:addinstance', context_course::instance($section->course), $userid)) {
|
||||
return false;
|
||||
}
|
||||
if ($section->is_delegated()) {
|
||||
return false;
|
||||
}
|
||||
$format = course_get_format($section->course);
|
||||
if ($format->get_last_section_number() >= $format->get_max_sections()) {
|
||||
return false;
|
||||
}
|
||||
if (!$format->supports_components()) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
@ -30,4 +30,8 @@ $callbacks = [
|
||||
'callback' => 'mod_subsection\local\callbacks\after_cm_name_edited_handler::callback',
|
||||
'priority' => 0,
|
||||
],
|
||||
[
|
||||
'hook' => core_course\hook\before_activitychooserbutton_exported::class,
|
||||
'callback' => [mod_subsection\local\callbacks\before_activitychooserbutton_exported_handler::class, 'callback'],
|
||||
],
|
||||
];
|
||||
|
@ -29,6 +29,7 @@ $string['modulenameplural'] = 'Subsections';
|
||||
$string['pluginadministration'] = 'Subsection administration';
|
||||
$string['pluginname'] = 'Subsection';
|
||||
$string['privacy:metadata'] = 'Subsection does not store any personal data';
|
||||
$string['quickcreatename'] = 'New subsection';
|
||||
$string['subsection:addinstance'] = 'Add subsection';
|
||||
$string['subsection:view'] = 'View subsection';
|
||||
$string['subsectionname'] = 'Name';
|
||||
|
@ -22,8 +22,8 @@
|
||||
* @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
use core_courseformat\formatactions;
|
||||
use mod_subsection\manager;
|
||||
use core_courseformat\formatactions;
|
||||
use mod_subsection\manager;
|
||||
|
||||
/**
|
||||
* Return if the plugin supports $feature.
|
||||
@ -43,6 +43,7 @@ function subsection_supports($feature) {
|
||||
FEATURE_BACKUP_MOODLE2 => true,
|
||||
FEATURE_SHOW_DESCRIPTION => false,
|
||||
FEATURE_MOD_PURPOSE => MOD_PURPOSE_CONTENT,
|
||||
FEATURE_QUICKCREATE => true,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
@ -291,3 +292,12 @@ function subsection_get_coursemodule_info(stdClass $coursemodule): cached_cm_inf
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get icon mapping for font-awesome.
|
||||
*/
|
||||
function mod_subsection_get_fontawesome_icon_map() {
|
||||
return [
|
||||
'mod_subsection:subsection' => 'fa-rectangle-list',
|
||||
];
|
||||
}
|
||||
|
1
mod/subsection/pix/subsection.svg
Normal file
1
mod/subsection/pix/subsection.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M0 96C0 60.7 28.7 32 64 32l448 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zM128 288a32 32 0 1 0 0-64 32 32 0 1 0 0 64zm32-128a32 32 0 1 0 -64 0 32 32 0 1 0 64 0zM128 384a32 32 0 1 0 0-64 32 32 0 1 0 0 64zm96-248c-13.3 0-24 10.7-24 24s10.7 24 24 24l224 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-224 0zm0 96c-13.3 0-24 10.7-24 24s10.7 24 24 24l224 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-224 0zm0 96c-13.3 0-24 10.7-24 24s10.7 24 24 24l224 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-224 0z"/></svg>
|
After Width: | Height: | Size: 750 B |
@ -11,8 +11,8 @@ Feature: Users view subsections on course page
|
||||
| teacher1 | Teacher | 1 | teacher1@example.com |
|
||||
| student1 | Student | 1 | student1@example.com |
|
||||
And the following "courses" exist:
|
||||
| fullname | shortname | category | numsections |
|
||||
| Course 1 | C1 | 0 | 2 |
|
||||
| fullname | shortname | category | numsections | initsections |
|
||||
| Course 1 | C1 | 0 | 3 | 1 |
|
||||
And the following "course enrolments" exist:
|
||||
| user | course | role |
|
||||
| teacher1 | C1 | editingteacher |
|
||||
@ -20,9 +20,10 @@ Feature: Users view subsections on course page
|
||||
And the following "activities" exist:
|
||||
| activity | name | course | idnumber | section |
|
||||
| subsection | Subsection1 | C1 | sub1 | 1 |
|
||||
| page | Page1 in Subsection1 | C1 | page11 | 3 |
|
||||
| page | Page1 in Subsection1 | C1 | page11 | 4 |
|
||||
| subsection | Subsection2 | C1 | sub2 | 1 |
|
||||
|
||||
| data | New database | C1 | data1 | 3 |
|
||||
| page | New page | C1 | page1 | 3 |
|
||||
@javascript
|
||||
Scenario: Student can view, expand and collapse subsections on course page
|
||||
When I log in as "student1"
|
||||
@ -55,8 +56,9 @@ Feature: Users view subsections on course page
|
||||
Scenario: Teacher can create activities between subsections on course page
|
||||
When I log in as "teacher1"
|
||||
And I am on "Course 1" course homepage with editing mode on
|
||||
And I hover "Insert an activity or resource before 'Subsection2'" "button"
|
||||
And I press "Insert an activity or resource before 'Subsection2'"
|
||||
And I hover "Insert content before 'Subsection2'" "button"
|
||||
And I press "Insert content before 'Subsection2'"
|
||||
And I click on "Activity or resource" "button" in the ".dropdown-menu.show" "css_element"
|
||||
And I click on "Add a new Assignment" "link" in the "Add an activity or resource" "dialogue"
|
||||
And I set the following fields to these values:
|
||||
| Assignment name | Assignment between subsections |
|
||||
@ -64,3 +66,47 @@ Feature: Users view subsections on course page
|
||||
And I wait "5" seconds
|
||||
And "Assignment between subsections" "link" should appear after "Page1 in Subsection1" "text"
|
||||
And "Assignment between subsections" "link" should appear before "Subsection2" "text"
|
||||
|
||||
@javascript
|
||||
Scenario: Teacher can create a subsection at section bottom
|
||||
When I log in as "teacher1"
|
||||
And I am on "Course 1" course homepage with editing mode on
|
||||
And I click on "Add content" "button" in the "General" "section"
|
||||
And I click on "Subsection" "link" in the ".dropdown-menu.show" "css_element"
|
||||
Then I should see "New subsection" in the "General" "section"
|
||||
|
||||
@javascript
|
||||
Scenario: Teacher can create a subsection between activities
|
||||
When I log in as "teacher1"
|
||||
And I am on "Course 1" course homepage with editing mode on
|
||||
And I hover "Insert content before 'New page'" "button"
|
||||
And I press "Insert content before 'New page'"
|
||||
And I click on "Subsection" "link" in the ".dropdown-menu.show" "css_element"
|
||||
Then I should see "New subsection" in the "Section 3" "section"
|
||||
And "New database" "text" should appear before "New subsection" "text"
|
||||
And "New subsection" "text" should appear before "New page" "text"
|
||||
|
||||
@javascript
|
||||
Scenario: Teacher can create an activity at section bottom
|
||||
When I log in as "teacher1"
|
||||
And I am on "Course 1" course homepage with editing mode on
|
||||
And I click on "Add content" "button" in the "General" "section"
|
||||
And I click on "Activity or resource" "button" in the ".dropdown-menu.show" "css_element"
|
||||
And I click on "Add a new Forum" "link" in the "Add an activity or resource" "dialogue"
|
||||
And I set the field "Forum name" to "New forum"
|
||||
And I press "Save and return to course"
|
||||
Then I should see "New forum" in the "General" "section"
|
||||
|
||||
@javascript
|
||||
Scenario: Teacher can create an activity between activities
|
||||
When I log in as "teacher1"
|
||||
And I am on "Course 1" course homepage with editing mode on
|
||||
And I hover "Insert content before 'New page'" "button"
|
||||
And I press "Insert content before 'New page'"
|
||||
And I click on "Activity or resource" "button" in the ".dropdown-menu.show" "css_element"
|
||||
And I click on "Add a new Forum" "link" in the "Add an activity or resource" "dialogue"
|
||||
And I set the field "Forum name" to "New forum"
|
||||
And I press "Save and return to course"
|
||||
Then I should see "New forum" in the "Section 3" "section"
|
||||
And "New database" "text" should appear before "New forum" "text"
|
||||
And "New forum" "text" should appear before "New page" "text"
|
||||
|
@ -22,7 +22,8 @@ Feature: Teacher can only add subsection when certain conditions are met
|
||||
| maxsections | 10 | moodlecourse |
|
||||
And I log in as "teacher1"
|
||||
And I am on "Course 1" course homepage with editing mode on
|
||||
And I click on "Add an activity or resource" "button" in the "Section 1" "section"
|
||||
And I click on "Add content" "button" in the "Section 1" "section"
|
||||
And I click on "Activity or resource" "button" in the ".dropdown-menu.show" "css_element"
|
||||
And I should see "Subsection" in the "Add an activity or resource" "dialogue"
|
||||
When the following config values are set as admin:
|
||||
| maxsections | 4 | moodlecourse |
|
||||
|
141
mod/subsection/tests/permission_test.php
Normal file
141
mod/subsection/tests/permission_test.php
Normal file
@ -0,0 +1,141 @@
|
||||
<?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 mod_subsection;
|
||||
|
||||
use advanced_testcase;
|
||||
use context_course;
|
||||
|
||||
/**
|
||||
* Unit tests for the subsection permission class
|
||||
*
|
||||
* @package mod_subsection
|
||||
* @covers \mod_subsection\permission
|
||||
* @copyright 2024 Mikel Martín <mikel@moodle.com>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
final class permission_test extends advanced_testcase {
|
||||
|
||||
/**
|
||||
* Test that viewing reports list observes capability to do so
|
||||
*
|
||||
* @param bool $ismoddisabled
|
||||
* @param bool $missingcapability
|
||||
* @param bool $isdelegated
|
||||
* @param bool $maxsectionsreached
|
||||
* @param string $format
|
||||
* @param bool $expected
|
||||
*
|
||||
* @dataProvider can_add_subsection_provider
|
||||
*/
|
||||
public function test_can_add_subsection(
|
||||
bool $ismoddisabled,
|
||||
bool $missingcapability,
|
||||
bool $isdelegated,
|
||||
bool $maxsectionsreached,
|
||||
string $format,
|
||||
bool $expected
|
||||
): void {
|
||||
global $DB;
|
||||
|
||||
$this->resetAfterTest();
|
||||
|
||||
$course = $this->getDataGenerator()->create_course(['format' => $format, 'numsections' => 5]);
|
||||
$user = $this->getDataGenerator()->create_and_enrol($course, 'editingteacher');
|
||||
$courseformat = course_get_format($course->id);
|
||||
$targetsection = $courseformat->get_modinfo()->get_section_info(5);
|
||||
|
||||
$manager = \core_plugin_manager::resolve_plugininfo_class('mod');
|
||||
$manager::enable_plugin('subsection', (int)!$ismoddisabled);
|
||||
|
||||
if ($missingcapability) {
|
||||
$userrole = $DB->get_field('role', 'id', ['shortname' => 'editingteacher']);
|
||||
assign_capability('mod/subsection:addinstance', CAP_PROHIBIT, $userrole, context_course::instance($course->id));
|
||||
}
|
||||
|
||||
if ($maxsectionsreached) {
|
||||
set_config('maxsections', 5, 'moodlecourse');
|
||||
}
|
||||
|
||||
if ($isdelegated) {
|
||||
$this->getDataGenerator()->create_module('subsection', ['course' => $course->id, 'section' => 1]);
|
||||
$targetsection = $courseformat->get_modinfo()->get_section_info(6);
|
||||
}
|
||||
|
||||
$this->setUser($user);
|
||||
$this->assertEquals($expected, permission::can_add_subsection($targetsection, (int)$user->id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Data provider for {@see self::test_can_add_subsection}
|
||||
*
|
||||
* @return array[]
|
||||
*/
|
||||
public static function can_add_subsection_provider(): array {
|
||||
return [
|
||||
'Plugin disabled' => [
|
||||
'ismoddisabled' => true,
|
||||
'missingcapability' => false,
|
||||
'isdelegated' => false,
|
||||
'maxsectionsreached' => false,
|
||||
'format' => 'topics',
|
||||
'expected' => false,
|
||||
],
|
||||
'User without capability' => [
|
||||
'ismoddisabled' => false,
|
||||
'missingcapability' => true,
|
||||
'isdelegated' => false,
|
||||
'maxsectionsreached' => false,
|
||||
'format' => 'topics',
|
||||
'expected' => false,
|
||||
],
|
||||
'Max sections reached' => [
|
||||
'ismoddisabled' => false,
|
||||
'missingcapability' => false,
|
||||
'isdelegated' => false,
|
||||
'maxsectionsreached' => true,
|
||||
'format' => 'topics',
|
||||
'expected' => false,
|
||||
],
|
||||
'Target section is a delegated section' => [
|
||||
'ismoddisabled' => false,
|
||||
'missingcapability' => false,
|
||||
'isdelegated' => true,
|
||||
'maxsectionsreached' => false,
|
||||
'format' => 'topics',
|
||||
'expected' => false,
|
||||
],
|
||||
'Format does not support components' => [
|
||||
'ismoddisabled' => false,
|
||||
'missingcapability' => false,
|
||||
'isdelegated' => false,
|
||||
'maxsectionsreached' => false,
|
||||
'format' => 'singleactivity',
|
||||
'expected' => false,
|
||||
],
|
||||
'Plugin enabled, with capability, max sections not reached, not inside a delegated section' => [
|
||||
'ismoddisabled' => false,
|
||||
'missingcapability' => false,
|
||||
'isdelegated' => false,
|
||||
'maxsectionsreached' => false,
|
||||
'format' => 'topics',
|
||||
'expected' => true,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
@ -26,6 +26,6 @@ defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
$plugin->component = 'mod_subsection';
|
||||
$plugin->release = '0.1.0';
|
||||
$plugin->version = 2024070100;
|
||||
$plugin->version = 2024080800;
|
||||
$plugin->requires = 2024070500;
|
||||
$plugin->maturity = MATURITY_ALPHA;
|
||||
|
@ -1629,7 +1629,7 @@ $divider-hover-color: $primary !default;
|
||||
.btn.add-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
@include border-radius($rounded-pill);
|
||||
@include border-radius();
|
||||
font-size: $font-size-sm;
|
||||
font-weight: bold;
|
||||
color: theme-color-level("primary", $alert-color-level);
|
||||
@ -1799,6 +1799,7 @@ $divider-hover-color: $primary !default;
|
||||
> .activity-item {
|
||||
border: $border-width solid $border-color;
|
||||
padding: 0;
|
||||
margin: map-get($spacers, 2) 0;
|
||||
.activity-altcontent {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
@ -29839,7 +29839,7 @@ span.editinstructions .alert-link {
|
||||
.btn.add-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
border-radius: 50rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.8203125rem;
|
||||
font-weight: bold;
|
||||
color: #083863;
|
||||
@ -29980,6 +29980,7 @@ span.editinstructions .alert-link {
|
||||
.activity.subsection > .activity-item {
|
||||
border: 1px solid #dee2e6;
|
||||
padding: 0;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.activity.subsection > .activity-item .activity-altcontent {
|
||||
margin-top: 0;
|
||||
|
@ -29839,7 +29839,7 @@ span.editinstructions .alert-link {
|
||||
.btn.add-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
border-radius: 50rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.8203125rem;
|
||||
font-weight: bold;
|
||||
color: #083863;
|
||||
@ -29980,6 +29980,7 @@ span.editinstructions .alert-link {
|
||||
.activity.subsection > .activity-item {
|
||||
border: 1px solid #dee2e6;
|
||||
padding: 0;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.activity.subsection > .activity-item .activity-altcontent {
|
||||
margin-top: 0;
|
||||
|
@ -29,7 +29,7 @@
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
$version = 2024090300.00; // YYYYMMDD = weekly release date of this DEV branch.
|
||||
$version = 2024090300.01; // YYYYMMDD = weekly release date of this DEV branch.
|
||||
// RR = release increments - 00 in DEV branches.
|
||||
// .XX = incremental changes.
|
||||
$release = '4.5dev+ (Build: 20240903)'; // Human-friendly version name
|
||||
|
Loading…
x
Reference in New Issue
Block a user