Commit 0616f045 authored by Andrew Nicols's avatar Andrew Nicols
Browse files

MDL-53566 core: Add support for context locking

This chagne adds support for a new feature known as Context Locking.
This allows a context to be locked, thereby removing all write
capabilities for all users (including admin) for that context, and all
child contexts.
parent 208950cf
......@@ -634,4 +634,4 @@ function cohort_get_list_of_themes() {
}
}
return $themes;
}
\ No newline at end of file
}
......@@ -238,6 +238,7 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
$record->visible = 1;
$record->depth = 0;
$record->path = '';
$record->locked = 0;
self::$coursecat0 = new self($record);
}
return self::$coursecat0;
......@@ -2458,6 +2459,7 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
$context = $this->get_context();
$a['xi'] = $context->id;
$a['xp'] = $context->path;
$a['xl'] = $context->locked;
return $a;
}
......@@ -2486,6 +2488,7 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
$record->ctxdepth = $record->depth + 1;
$record->ctxlevel = CONTEXT_COURSECAT;
$record->ctxinstance = $record->id;
$record->ctxlocked = $a['xl'];
return new self($record, true);
}
......
......@@ -411,6 +411,7 @@ $string['site:maintenanceaccess'] = 'Access site while in maintenance mode';
$string['site:manageallmessaging'] = 'Add, remove, block and unblock contacts for any user';
$string['site:manageblocks'] = 'Manage blocks on a page';
$string['site:messageanyuser'] = 'Bypass user privacy preferences for messaging any user';
$string['site:managecontextlocks'] = 'Manage locking of site contexts';
$string['site:mnetloginfromremote'] = 'Login from a remote application via MNet';
$string['site:mnetlogintoremote'] = 'Roam to a remote application via MNet';
$string['site:readallmessages'] = 'Read all messages on site';
......
......@@ -478,6 +478,13 @@ function has_capability($capability, context $context, $user = null, $doanything
}
}
// Check whether context locking is enabled.
if (!empty($CFG->contextlocking)) {
if ($capinfo->captype === 'write' && $context->locked && $capinfo->name !== 'moodle/site:managecontextlocks') {
return false;
}
}
// somehow make sure the user is not deleted and actually exists
if ($userid != 0) {
if ($userid == $USER->id and isset($USER->deleted)) {
......@@ -4727,6 +4734,24 @@ abstract class context extends stdClass implements IteratorAggregate {
*/
protected $_depth;
/**
* Whether this context is locked or not.
*
* Can be accessed publicly through $context->locked.
*
* @var int
*/
protected $_locked;
/**
* Whether any parent of the current context is locked.
*
* Can be accessed publicly through $context->ancestorlocked.
*
* @var int
*/
protected $_ancestorlocked;
/**
* @var array Context caching info
*/
......@@ -4862,22 +4887,40 @@ abstract class context extends stdClass implements IteratorAggregate {
* @param stdClass $rec
* @return void (modifies $rec)
*/
protected static function preload_from_record(stdClass $rec) {
if (empty($rec->ctxid) or empty($rec->ctxlevel) or !isset($rec->ctxinstance) or empty($rec->ctxpath) or empty($rec->ctxdepth)) {
// $rec does not have enough data, passed here repeatedly or context does not exist yet
return;
}
// note: in PHP5 the objects are passed by reference, no need to return $rec
$record = new stdClass();
$record->id = $rec->ctxid; unset($rec->ctxid);
$record->contextlevel = $rec->ctxlevel; unset($rec->ctxlevel);
$record->instanceid = $rec->ctxinstance; unset($rec->ctxinstance);
$record->path = $rec->ctxpath; unset($rec->ctxpath);
$record->depth = $rec->ctxdepth; unset($rec->ctxdepth);
return context::create_instance_from_record($record);
}
protected static function preload_from_record(stdClass $rec) {
$notenoughdata = false;
$notenoughdata = $notenoughdata || empty($rec->ctxid);
$notenoughdata = $notenoughdata || empty($rec->ctxlevel);
$notenoughdata = $notenoughdata || !isset($rec->ctxinstance);
$notenoughdata = $notenoughdata || empty($rec->ctxpath);
$notenoughdata = $notenoughdata || empty($rec->ctxdepth);
$notenoughdata = $notenoughdata || !isset($rec->ctxlocked);
if ($notenoughdata) {
// The record does not have enough data, passed here repeatedly or context does not exist yet.
if (isset($rec->ctxid) && !isset($rec->ctxlocked)) {
debugging('Locked value missing. Code is possibly not usings the getter properly.', DEBUG_DEVELOPER);
}
return;
}
$record = (object) [
'id' => $rec->ctxid,
'contextlevel' => $rec->ctxlevel,
'instanceid' => $rec->ctxinstance,
'path' => $rec->ctxpath,
'depth' => $rec->ctxdepth,
'locked' => $rec->ctxlocked,
];
unset($rec->ctxid);
unset($rec->ctxlevel);
unset($rec->ctxinstance);
unset($rec->ctxpath);
unset($rec->ctxdepth);
unset($rec->ctxlocked);
return context::create_instance_from_record($record);
}
// ====== magic methods =======
......@@ -4898,11 +4941,18 @@ abstract class context extends stdClass implements IteratorAggregate {
*/
public function __get($name) {
switch ($name) {
case 'id': return $this->_id;
case 'contextlevel': return $this->_contextlevel;
case 'instanceid': return $this->_instanceid;
case 'path': return $this->_path;
case 'depth': return $this->_depth;
case 'id':
return $this->_id;
case 'contextlevel':
return $this->_contextlevel;
case 'instanceid':
return $this->_instanceid;
case 'path':
return $this->_path;
case 'depth':
return $this->_depth;
case 'locked':
return $this->is_locked();
default:
debugging('Invalid context property accessed! '.$name);
......@@ -4917,19 +4967,26 @@ abstract class context extends stdClass implements IteratorAggregate {
*/
public function __isset($name) {
switch ($name) {
case 'id': return isset($this->_id);
case 'contextlevel': return isset($this->_contextlevel);
case 'instanceid': return isset($this->_instanceid);
case 'path': return isset($this->_path);
case 'depth': return isset($this->_depth);
default: return false;
case 'id':
return isset($this->_id);
case 'contextlevel':
return isset($this->_contextlevel);
case 'instanceid':
return isset($this->_instanceid);
case 'path':
return isset($this->_path);
case 'depth':
return isset($this->_depth);
case 'locked':
// Locked is always set.
return true;
default:
return false;
}
}
/**
* ALl properties are read only, sorry.
* All properties are read only, sorry.
* @param string $name
*/
public function __unset($name) {
......@@ -4950,7 +5007,8 @@ abstract class context extends stdClass implements IteratorAggregate {
'contextlevel' => $this->contextlevel,
'instanceid' => $this->instanceid,
'path' => $this->path,
'depth' => $this->depth
'depth' => $this->depth,
'locked' => $this->locked,
);
return new ArrayIterator($ret);
}
......@@ -4969,6 +5027,12 @@ abstract class context extends stdClass implements IteratorAggregate {
$this->_instanceid = $record->instanceid;
$this->_path = $record->path;
$this->_depth = $record->depth;
if (isset($record->locked)) {
$this->_locked = $record->locked;
} else if (!during_initial_install() && !moodle_needs_upgrading()) {
debugging('Locked value missing. Code is possibly not usings the getter properly.', DEBUG_DEVELOPER);
}
}
/**
......@@ -5011,12 +5075,13 @@ abstract class context extends stdClass implements IteratorAggregate {
if ($dbfamily == 'mysql') {
$updatesql = "UPDATE {context} ct, {context_temp} temp
SET ct.path = temp.path,
ct.depth = temp.depth
ct.depth = temp.depth,
ct.locked = temp.locked
WHERE ct.id = temp.id";
} else if ($dbfamily == 'oracle') {
$updatesql = "UPDATE {context} ct
SET (ct.path, ct.depth) =
(SELECT temp.path, temp.depth
SET (ct.path, ct.depth, ct.locked) =
(SELECT temp.path, temp.depth, temp.locked
FROM {context_temp} temp
WHERE temp.id=ct.id)
WHERE EXISTS (SELECT 'x'
......@@ -5025,14 +5090,16 @@ abstract class context extends stdClass implements IteratorAggregate {
} else if ($dbfamily == 'postgres' or $dbfamily == 'mssql') {
$updatesql = "UPDATE {context}
SET path = temp.path,
depth = temp.depth
depth = temp.depth,
locked = temp.locked
FROM {context_temp} temp
WHERE temp.id={context}.id";
} else {
// sqlite and others
$updatesql = "UPDATE {context}
SET path = (SELECT path FROM {context_temp} WHERE id = {context}.id),
depth = (SELECT depth FROM {context_temp} WHERE id = {context}.id)
depth = (SELECT depth FROM {context_temp} WHERE id = {context}.id),
locked = (SELECT locked FROM {context_temp} WHERE id = {context}.id)
WHERE id IN (SELECT id FROM {context_temp})";
}
......@@ -5118,6 +5185,27 @@ abstract class context extends stdClass implements IteratorAggregate {
$trans->allow_commit();
}
/**
* Set whether this context has been locked or not.
*
* @param bool $locked
* @return $this
*/
public function set_locked(bool $locked) {
global $DB;
if ($this->_locked == $locked) {
return $this;
}
$this->_locked = $locked;
$DB->set_field('context', 'locked', (int) $locked, ['id' => $this->id]);
$this->mark_dirty();
self::reset_caches();
return $this;
}
/**
* Remove all context path info and optionally rebuild it.
*
......@@ -5239,6 +5327,7 @@ abstract class context extends stdClass implements IteratorAggregate {
$record->instanceid = $instanceid;
$record->depth = 0;
$record->path = null; //not known before insert
$record->locked = 0;
$record->id = $DB->insert_record('context', $record);
......@@ -5266,6 +5355,24 @@ abstract class context extends stdClass implements IteratorAggregate {
throw new coding_exception('can not get name of abstract context');
}
/**
* Whether the current context is locked.
*
* @return bool
*/
public function is_locked() {
if ($this->_locked) {
return true;
}
if ($parent = $this->get_parent_context()) {
$this->_ancestorlocked = $parent->is_locked();
return $this->_ancestorlocked;
}
return false;
}
/**
* Returns the most relevant URL for this context.
*
......@@ -5724,7 +5831,14 @@ class context_helper extends context {
* @return array (table.column=>alias, ...)
*/
public static function get_preload_record_columns($tablealias) {
return array("$tablealias.id"=>"ctxid", "$tablealias.path"=>"ctxpath", "$tablealias.depth"=>"ctxdepth", "$tablealias.contextlevel"=>"ctxlevel", "$tablealias.instanceid"=>"ctxinstance");
return [
"$tablealias.id" => "ctxid",
"$tablealias.path" => "ctxpath",
"$tablealias.depth" => "ctxdepth",
"$tablealias.contextlevel" => "ctxlevel",
"$tablealias.instanceid" => "ctxinstance",
"$tablealias.locked" => "ctxlocked",
];
}
/**
......@@ -5737,7 +5851,12 @@ class context_helper extends context {
* @return string
*/
public static function get_preload_record_columns_sql($tablealias) {
return "$tablealias.id AS ctxid, $tablealias.path AS ctxpath, $tablealias.depth AS ctxdepth, $tablealias.contextlevel AS ctxlevel, $tablealias.instanceid AS ctxinstance";
return "$tablealias.id AS ctxid, " .
"$tablealias.path AS ctxpath, " .
"$tablealias.depth AS ctxdepth, " .
"$tablealias.contextlevel AS ctxlevel, " .
"$tablealias.instanceid AS ctxinstance, " .
"$tablealias.locked AS ctxlocked";
}
/**
......@@ -5920,12 +6039,12 @@ class context_system extends context {
$record->instanceid = 0;
$record->path = '/'.SYSCONTEXTID;
$record->depth = 1;
$record->locked = 0;
context::$systemcontext = new context_system($record);
}
return context::$systemcontext;
}
try {
// We ignore the strictness completely because system context must exist except during install.
$record = $DB->get_record('context', array('contextlevel'=>CONTEXT_SYSTEM), '*', MUST_EXIST);
......@@ -5943,7 +6062,8 @@ class context_system extends context {
$record->contextlevel = CONTEXT_SYSTEM;
$record->instanceid = 0;
$record->depth = 1;
$record->path = null; //not known before insert
$record->path = null; // Not known before insert.
$record->locked = 0;
try {
if ($DB->count_records('context')) {
......@@ -5976,6 +6096,10 @@ class context_system extends context {
$DB->update_record('context', $record);
}
if (empty($record->locked)) {
$record->locked = 0;
}
if (!defined('SYSCONTEXTID')) {
define('SYSCONTEXTID', $record->id);
}
......@@ -6056,6 +6180,18 @@ class context_system extends context {
$DB->update_record('context', $record);
}
}
/**
* Set whether this context has been locked or not.
*
* @param bool $locked
* @return $this
*/
public function set_locked(bool $locked) {
throw new \coding_exception('It is not possible to lock the system context');
return $this;
}
}
......@@ -6458,8 +6594,8 @@ class context_coursecat extends context {
// Deeper categories - one query per depthlevel
$maxdepth = $DB->get_field_sql("SELECT MAX(depth) FROM {course_categories}");
for ($n=2; $n<=$maxdepth; $n++) {
$sql = "INSERT INTO {context_temp} (id, path, depth)
SELECT ctx.id, ".$DB->sql_concat('pctx.path', "'/'", 'ctx.id').", pctx.depth+1
$sql = "INSERT INTO {context_temp} (id, path, depth, locked)
SELECT ctx.id, ".$DB->sql_concat('pctx.path', "'/'", 'ctx.id').", pctx.depth+1, ctx.locked
FROM {context} ctx
JOIN {course_categories} cc ON (cc.id = ctx.instanceid AND ctx.contextlevel = ".CONTEXT_COURSECAT." AND cc.depth = $n)
JOIN {context} pctx ON (pctx.instanceid = cc.parent AND pctx.contextlevel = ".CONTEXT_COURSECAT.")
......@@ -6682,8 +6818,8 @@ class context_course extends context {
$DB->execute($sql);
// standard courses
$sql = "INSERT INTO {context_temp} (id, path, depth)
SELECT ctx.id, ".$DB->sql_concat('pctx.path', "'/'", 'ctx.id').", pctx.depth+1
$sql = "INSERT INTO {context_temp} (id, path, depth, locked)
SELECT ctx.id, ".$DB->sql_concat('pctx.path', "'/'", 'ctx.id').", pctx.depth+1, ctx.locked
FROM {context} ctx
JOIN {course} c ON (c.id = ctx.instanceid AND ctx.contextlevel = ".CONTEXT_COURSE." AND c.category <> 0)
JOIN {context} pctx ON (pctx.instanceid = c.category AND pctx.contextlevel = ".CONTEXT_COURSECAT.")
......@@ -6951,8 +7087,8 @@ class context_module extends context {
$ctxemptyclause = "AND (ctx.path IS NULL OR ctx.depth = 0)";
}
$sql = "INSERT INTO {context_temp} (id, path, depth)
SELECT ctx.id, ".$DB->sql_concat('pctx.path', "'/'", 'ctx.id').", pctx.depth+1
$sql = "INSERT INTO {context_temp} (id, path, depth, locked)
SELECT ctx.id, ".$DB->sql_concat('pctx.path', "'/'", 'ctx.id').", pctx.depth+1, ctx.locked
FROM {context} ctx
JOIN {course_modules} cm ON (cm.id = ctx.instanceid AND ctx.contextlevel = ".CONTEXT_MODULE.")
JOIN {context} pctx ON (pctx.instanceid = cm.course AND pctx.contextlevel = ".CONTEXT_COURSE.")
......@@ -7172,8 +7308,8 @@ class context_block extends context {
}
// pctx.path IS NOT NULL prevents fatal problems with broken block instances that point to invalid context parent
$sql = "INSERT INTO {context_temp} (id, path, depth)
SELECT ctx.id, ".$DB->sql_concat('pctx.path', "'/'", 'ctx.id').", pctx.depth+1
$sql = "INSERT INTO {context_temp} (id, path, depth, locked)
SELECT ctx.id, ".$DB->sql_concat('pctx.path', "'/'", 'ctx.id').", pctx.depth+1, ctx.locked
FROM {context} ctx
JOIN {block_instances} bi ON (bi.id = ctx.instanceid AND ctx.contextlevel = ".CONTEXT_BLOCK.")
JOIN {context} pctx ON (pctx.id = bi.parentcontextid)
......
......@@ -382,7 +382,7 @@ class core_user {
protected static function get_enrolled_sql_on_courses_with_capability($capability) {
// Get all courses where user have the capability.
$courses = get_user_capability_course($capability, null, true,
'ctxid, ctxpath, ctxdepth, ctxlevel, ctxinstance');
implode(',', array_values(context_helper::get_preload_record_columns('ctx'))));
if (!$courses) {
return [null, null];
}
......
......@@ -2432,4 +2432,11 @@ $capabilities = array(
)
),
// Context locking/unlocking.
'moodle/site:managecontextlocks' => [
'captype' => 'write',
'contextlevel' => CONTEXT_SYSTEM,
'archetypes' => [
],
],
);
......@@ -1130,6 +1130,7 @@
<FIELD NAME="instanceid" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="path" TYPE="char" LENGTH="255" NOTNULL="false" SEQUENCE="false"/>
<FIELD NAME="depth" TYPE="int" LENGTH="2" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="locked" TYPE="int" LENGTH="2" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Whether this context and its children are locked"/>
</FIELDS>
<KEYS>
<KEY NAME="primary" TYPE="primary" FIELDS="id"/>
......@@ -1145,6 +1146,7 @@
<FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="This id isn't autonumeric/sequence. It's the context-&gt;id"/>
<FIELD NAME="path" TYPE="char" LENGTH="255" NOTNULL="true" SEQUENCE="false"/>
<FIELD NAME="depth" TYPE="int" LENGTH="2" NOTNULL="true" SEQUENCE="false"/>
<FIELD NAME="locked" TYPE="int" LENGTH="2" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Whether this context and its children are locked"/>
</FIELDS>
<KEYS>
<KEY NAME="primary" TYPE="primary" FIELDS="id"/>
......
......@@ -2658,10 +2658,6 @@ function xmldb_main_upgrade($oldversion) {
$field = new xmldb_field('predictionsprocessor', XMLDB_TYPE_CHAR, '255', null, null, null, null, 'timesplitting');
// Conditionally launch add field predictionsprocessor.
if (!$dbman->field_exists($table, $field)) {
$dbman->add_field($table, $field);
}
// Main savepoint reached.
upgrade_main_savepoint(true, 2018102900.00);
}
......@@ -2771,5 +2767,28 @@ function xmldb_main_upgrade($oldversion) {
upgrade_main_savepoint(true, 2018110700.01);
}
if ($oldversion < 2018111300.00) {
// Define field locked to be added to context.
$table = new xmldb_table('context');
$field = new xmldb_field('locked', XMLDB_TYPE_INTEGER, '2', null, XMLDB_NOTNULL, null, '0', 'depth');
// Conditionally launch add field locked.
if (!$dbman->field_exists($table, $field)) {
$dbman->add_field($table, $field);
}
// Define field locked to be added to context_temp.
$table = new xmldb_table('context_temp');
$field = new xmldb_field('locked', XMLDB_TYPE_INTEGER, '2', null, XMLDB_NOTNULL, null, '0', 'depth');
// Conditionally launch add field locked.
if (!$dbman->field_exists($table, $field)) {
$dbman->add_field($table, $field);
}
// Note: This change also requires a bump in is_major_upgrade_required.
upgrade_main_savepoint(true, 2018111300.00);
}
return true;
}
......@@ -530,17 +530,24 @@ class file_info_context_course extends file_info {
'contextlevel' => CONTEXT_MODULE,
'depth' => $this->context->depth + 1,
'pathmask' => $this->context->path . '/%'];
$sql1 = "SELECT ctx.id AS contextid, f.component, f.filearea, f.itemid, ctx.instanceid AS cmid, " .
context_helper::get_preload_record_columns_sql('ctx') . "
$ctxfieldsas = context_helper::get_preload_record_columns_sql('ctx');
$ctxfields = implode(', ', array_keys(context_helper::get_preload_record_columns('ctx')));
$sql1 = "SELECT
ctx.id AS contextid,
f.component,
f.filearea,
f.itemid,
ctx.instanceid AS cmid,
{$ctxfieldsas}
FROM {files} f
INNER JOIN {context} ctx ON ctx.id = f.contextid
WHERE f.filename <> :emptyfilename
AND ctx.contextlevel = :contextlevel
AND ctx.depth = :depth
AND " . $DB->sql_like('ctx.path', ':pathmask') . " ";
$sql3 = ' GROUP BY ctx.id, f.component, f.filearea, f.itemid, ctx.instanceid,
ctx.path, ctx.depth, ctx.contextlevel
ORDER BY ctx.id, f.component, f.filearea, f.itemid';
$sql3 = "
GROUP BY ctx.id, f.component, f.filearea, f.itemid, {$ctxfields}
ORDER BY ctx.id, f.component, f.filearea, f.itemid";
list($sql2, $params2) = $this->build_search_files_sql($extensions);
$areas = [];
if ($rs = $DB->get_recordset_sql($sql1. $sql2 . $sql3, array_merge($params1, $params2))) {
......
......@@ -132,6 +132,9 @@ abstract class advanced_testcase extends base_testcase {
self::resetAllData(true);
}
// Reset context cache.
context_helper::reset_caches();
// make sure test did not forget to close transaction
if ($DB->is_transaction_started()) {
self::resetAllData();
......
......@@ -1395,7 +1395,7 @@ function disable_output_buffering() {
*/
function is_major_upgrade_required() {
global $CFG;
$lastmajordbchanges = 2017092900.00;
$lastmajordbchanges = 2018111300.00;
$required = empty($CFG->version);
$required = $required || (float)$CFG->version < $lastmajordbchanges;
......
<?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/>.
/**
* A collection of tests for accesslib::has_capability().
*
* @package core
* @copyright 2018 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
/**
* Unit tests tests for has_capability.
*
* @package core
* @copyright 2018 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class accesslib_has_capability_testcase extends \advanced_testcase {
/**
* Unit tests to check the operation of locked contexts.
*
* Note: We only check the admin user here.
* If the admin cannot do it, then no-one can.
*
* @dataProvider locked_context_provider
* @param string[] $lockedcontexts The list of contexts, by name, to mark as locked
* @param string[] $blocked The list of contexts which will be 'blocked' by has_capability
*/
public function test_locked_contexts($lockedcontexts, $blocked) {
global $DB;
$this->resetAfterTest();
set_config('contextlocking', 1);
$generator = $this->getDataGenerator();
$otheruser = $generator->create_user();
// / (system)
// /Cat1
// /Cat1/Block
// /Cat1/Course1
// /Cat1/Course1/Block
// /Cat1/Course2
// /Cat1/Course2/Block
// /Cat1/Cat1a
// /Cat1/Cat1a/Block
// /Cat1/Cat1a/Course1
// /Cat1/Cat1a/Course1/Block
// /Cat1/Cat1a/Course2
// /Cat1/Cat1a/Course2/Block
// /Cat1/Cat1b
// /Cat1/Cat1b/Block
// /Cat1/Cat1b/Course1
// /Cat1/Cat1b/Course1/Block
// /Cat1/Cat1b/Course2
// /Cat1/Cat1b/Course2/Block
// /Cat2
// /Cat2/Block
// /Cat2/Course1