diff --git a/admin/settings/appearance.php b/admin/settings/appearance.php index 8a725a39f88..0844b6f4ed6 100644 --- a/admin/settings/appearance.php +++ b/admin/settings/appearance.php @@ -293,14 +293,27 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) { // sp new lang_string('configthemedesignermode', 'admin'), 0); $setting->set_updatedcallback('theme_reset_all_caches'); $temp->add($setting); - $temp->add(new admin_setting_configcheckbox('allowuserthemes', new lang_string('allowuserthemes', 'admin'), - new lang_string('configallowuserthemes', 'admin'), 0)); - $temp->add(new admin_setting_configcheckbox('allowcoursethemes', new lang_string('allowcoursethemes', 'admin'), - new lang_string('configallowcoursethemes', 'admin'), 0)); - $temp->add(new admin_setting_configcheckbox('allowcategorythemes', new lang_string('allowcategorythemes', 'admin'), - new lang_string('configallowcategorythemes', 'admin'), 0)); - $temp->add(new admin_setting_configcheckbox('allowcohortthemes', new lang_string('allowcohortthemes', 'admin'), - new lang_string('configallowcohortthemes', 'admin'), 0)); + + $setting = new admin_setting_configcheckbox('allowuserthemes', new lang_string('allowuserthemes', 'admin'), + new lang_string('configallowuserthemes', 'admin'), 0); + $setting->set_updatedcallback('theme_purge_used_in_context_caches'); + $temp->add($setting); + + $setting = new admin_setting_configcheckbox('allowcoursethemes', new lang_string('allowcoursethemes', 'admin'), + new lang_string('configallowcoursethemes', 'admin'), 0); + $setting->set_updatedcallback('theme_purge_used_in_context_caches'); + $temp->add($setting); + + $setting = new admin_setting_configcheckbox('allowcategorythemes', new lang_string('allowcategorythemes', 'admin'), + new lang_string('configallowcategorythemes', 'admin'), 0); + $setting->set_updatedcallback('theme_purge_used_in_context_caches'); + $temp->add($setting); + + $setting = new admin_setting_configcheckbox('allowcohortthemes', new lang_string('allowcohortthemes', 'admin'), + new lang_string('configallowcohortthemes', 'admin'), 0); + $setting->set_updatedcallback('theme_purge_used_in_context_caches'); + $temp->add($setting); + $temp->add(new admin_setting_configcheckbox('allowthemechangeonurl', new lang_string('allowthemechangeonurl', 'admin'), new lang_string('configallowthemechangeonurl', 'admin'), 0)); $temp->add(new admin_setting_configcheckbox('allowuserblockhiding', new lang_string('allowuserblockhiding', 'admin'), diff --git a/admin/templates/themeselector/theme_card.mustache b/admin/templates/themeselector/theme_card.mustache index c1a892d23d3..6d7fc0b409c 100644 --- a/admin/templates/themeselector/theme_card.mustache +++ b/admin/templates/themeselector/theme_card.mustache @@ -27,7 +27,8 @@ "current": true, "actionurl": "http://moodlesite/admin/themeselector.php", "sesskey": "123XYZ", - "settingsurl": "http://moodlesite/admin/settings.php?section=themesettingboost" + "settingsurl": "http://moodlesite/admin/settings.php?section=themesettingboost", + "reporturl": "http://moodlesite/report/themeusage/index.php?themechoice=boost" } }}
@@ -53,6 +54,16 @@ {{#str}}previewthemename, moodle, {{name}}{{/str}} + {{#reporturl}} + + + {{#str}}themeusagereportname, admin, {{name}}{{/str}} + + {{/reporturl}} {{#settingsurl}} $themedir) { $themedata['settingsurl'] = $settingsurl; } + // Link to the theme usage report if override enabled and it is being used in at least one context. + if (\core\output\theme_usage::is_theme_used_in_any_context($themename) === \core\output\theme_usage::THEME_IS_USED) { + $reporturl = new moodle_url($CFG->wwwroot . '/report/themeusage/index.php'); + $reporturl->params(['themechoice' => $themename]); + $themedata['reporturl'] = $reporturl->out(false); + } + $data[$index] = $themedata; $index++; } diff --git a/cohort/lib.php b/cohort/lib.php index 48c7c87f645..3d7ef7195ac 100644 --- a/cohort/lib.php +++ b/cohort/lib.php @@ -101,6 +101,15 @@ function cohort_update_cohort($cohort) { if (empty($CFG->allowcohortthemes) && isset($cohort->theme)) { unset($cohort->theme); } + + // Delete theme usage cache if the theme has been changed. + if (isset($cohort->theme)) { + $oldcohort = $DB->get_record('cohort', ['id' => $cohort->id]); + if ($cohort->theme != $oldcohort->theme) { + theme_delete_used_in_context_cache($cohort->theme, $oldcohort->theme); + } + } + $cohort->timemodified = time(); // Update custom fields if there are any of them in the form. diff --git a/course/classes/category.php b/course/classes/category.php index 38a530a7e46..1a12b9b8c52 100644 --- a/course/classes/category.php +++ b/course/classes/category.php @@ -628,6 +628,14 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr fix_course_sortorder(); } + // Delete theme usage cache if the theme has been changed. + if (isset($data->theme)) { + $oldcategory = $DB->get_record('course_categories', ['id' => $data->id]); + if ($data->theme != $oldcategory->theme) { + theme_delete_used_in_context_cache($data->theme, (string)$oldcategory->theme); + } + } + $newcategory->timemodified = time(); $categorycontext = $this->get_context(); diff --git a/course/edit_form.php b/course/edit_form.php index 3b67acb6f50..5769e4ff24d 100644 --- a/course/edit_form.php +++ b/course/edit_form.php @@ -488,7 +488,6 @@ class course_edit_form extends moodleform { // Tweak the form with values provided by custom fields in use. $handler = core_course\customfield\course_handler::create(); $handler->instance_form_definition_after_data($mform, empty($courseid) ? 0 : $courseid); - } /** diff --git a/course/lib.php b/course/lib.php index 1813d580050..0328590043f 100644 --- a/course/lib.php +++ b/course/lib.php @@ -2485,6 +2485,11 @@ function update_course($data, $editoroptions = NULL) { $DB->delete_records('course_format_options', array('courseid' => $course->id, 'format' => $oldcourse->format)); } + + // Delete theme usage cache if the theme has been changed. + if (isset($data->theme) && ($data->theme != $oldcourse->theme)) { + theme_delete_used_in_context_cache($data->theme, $oldcourse->theme); + } } /** diff --git a/lang/en/admin.php b/lang/en/admin.php index 25591017db7..5b979f4abc4 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -1463,6 +1463,8 @@ $string['themeselect'] = 'Change theme'; $string['themeselector'] = 'Themes'; $string['themesettingsadvanced'] = 'Advanced theme settings'; $string['themeeditsettingsname'] = 'Edit theme settings \'{$a}\''; +$string['themesettingsname'] = 'Theme settings \'{$a}\''; +$string['themeusagereportname'] = 'Theme usage report \'{$a}\''; $string['therewereerrors'] = 'There were errors in your data'; $string['thirdpartylibrary'] = 'Library'; $string['thirdpartylibrarylocation'] = 'Location'; diff --git a/lang/en/cache.php b/lang/en/cache.php index c7632027880..6c7dd1abbdb 100644 --- a/lang/en/cache.php +++ b/lang/en/cache.php @@ -96,6 +96,7 @@ $string['cachedef_grade_letters'] = 'Grade letter queries'; $string['cachedef_string'] = 'Language string cache'; $string['cachedef_tags'] = 'Tags collections and areas'; $string['cachedef_temp_tables'] = 'Temporary tables cache'; +$string['cachedef_theme_usedincontext'] = 'A theme has been used in context to override the default theme'; $string['cachedef_userselections'] = 'Data used to persist user selections throughout Moodle'; $string['cachedef_user_favourite_course_content_items'] = 'User\'s starred items'; $string['cachedef_user_group_groupings'] = 'User\'s groupings and groups per course'; diff --git a/lib/classes/output/theme_usage.php b/lib/classes/output/theme_usage.php new file mode 100644 index 00000000000..21d33dda046 --- /dev/null +++ b/lib/classes/output/theme_usage.php @@ -0,0 +1,127 @@ +. + +namespace core\output; + +/** + * This class houses methods for checking theme usage in a given context. + * + * @package core + * @category output + * @copyright 2024 David Woloszyn + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class theme_usage { + + /** @var string The theme usage type for users. */ + public const THEME_USAGE_TYPE_USER = 'user'; + + /** @var string The theme usage type for courses. */ + public const THEME_USAGE_TYPE_COURSE = 'course'; + + /** @var string The theme usage type for cohorts. */ + public const THEME_USAGE_TYPE_COHORT = 'cohort'; + + /** @var string The theme usage type for categories. */ + public const THEME_USAGE_TYPE_CATEGORY = 'category'; + + /** @var string The theme usage type for all. */ + public const THEME_USAGE_TYPE_ALL = 'all'; + + /** @var int The theme is used in context. */ + public const THEME_IS_USED = 1; + + /** @var int The theme is not used in context. */ + public const THEME_IS_NOT_USED = 0; + + /** + * Check if the theme is used in any context (e.g. user, course, cohort, category). + * + * This query is cached. + * + * @param string $themename The theme to check. + * @return int Return 1 if at least one record was found, 0 if none. + */ + public static function is_theme_used_in_any_context(string $themename): int { + global $DB; + $cache = \cache::make('core', 'theme_usedincontext'); + $isused = $cache->get($themename); + + if ($isused === false) { + + $sqlunions = []; + + // For each context, check if the config is enabled and there is at least one use. + if (get_config('core', 'allowuserthemes')) { + $sqlunions[self::THEME_USAGE_TYPE_USER] = " + SELECT u.id + FROM {user} u + WHERE u.theme = :usertheme + "; + } + + if (get_config('core', 'allowcoursethemes')) { + $sqlunions[self::THEME_USAGE_TYPE_COURSE] = " + SELECT c.id + FROM {course} c + WHERE c.theme = :coursetheme + "; + } + + if (get_config('core', 'allowcohortthemes')) { + $sqlunions[self::THEME_USAGE_TYPE_COHORT] = " + SELECT co.id + FROM {cohort} co + WHERE co.theme = :cohorttheme + "; + } + + if (get_config('core', 'allowcategorythemes')) { + $sqlunions[self::THEME_USAGE_TYPE_CATEGORY] = " + SELECT cat.id + FROM {course_categories} cat + WHERE cat.theme = :categorytheme + "; + } + + // Union the sql statements from the different tables. + if (!empty($sqlunions)) { + $sql = implode(' UNION ', $sqlunions); + + // Prepare params. + $params = []; + foreach ($sqlunions as $type => $val) { + $params[$type . 'theme'] = $themename; + } + + $result = $DB->record_exists_sql($sql, $params); + } + + if (!empty($result)) { + $isused = self::THEME_IS_USED; + } else { + $isused = self::THEME_IS_NOT_USED; + } + + // Cache the result so we don't have to keep checking for this theme. + $cache->set($themename, $isused); + return $isused; + + } else { + return $isused; + } + } +} diff --git a/lib/db/caches.php b/lib/db/caches.php index 5a066730485..09b36ffc4f3 100644 --- a/lib/db/caches.php +++ b/lib/db/caches.php @@ -609,4 +609,13 @@ $definitions = array( 'changesincourse', ], ], + + // A theme has been used in context to override the default theme. + // Applies to user, cohort, category and course. + 'theme_usedincontext' => [ + 'mode' => cache_store::MODE_APPLICATION, + 'simplekeys' => true, + 'simpledata' => true, + 'staticacceleration' => true, + ], ); diff --git a/lib/outputlib.php b/lib/outputlib.php index b2d44e6af2a..63e2376b57a 100644 --- a/lib/outputlib.php +++ b/lib/outputlib.php @@ -300,6 +300,33 @@ function theme_set_designer_mod($state) { theme_reset_all_caches(); } +/** + * Purge theme used in context caches. + */ +function theme_purge_used_in_context_caches() { + \cache::make('core', 'theme_usedincontext')->purge(); +} + +/** + * Delete theme used in context cache for a particular theme. + * + * When switching themes, both old and new theme caches are deleted. + * This gives the query the opportunity to recache accurate results for both themes. + * + * @param string $newtheme The incoming new theme. + * @param string $oldtheme The theme that was already set. + */ +function theme_delete_used_in_context_cache(string $newtheme, string $oldtheme): void { + if ((strlen($newtheme) > 0) && (strlen($oldtheme) > 0)) { + // Theme -> theme. + \cache::make('core', 'theme_usedincontext')->delete($oldtheme); + \cache::make('core', 'theme_usedincontext')->delete($newtheme); + } else { + // No theme -> theme, or theme -> no theme. + \cache::make('core', 'theme_usedincontext')->delete($newtheme . $oldtheme); + } +} + /** * This class represents the configuration variables of a Moodle theme. * diff --git a/report/themeusage/classes/form/theme_usage_form.php b/report/themeusage/classes/form/theme_usage_form.php new file mode 100644 index 00000000000..3ec508769d8 --- /dev/null +++ b/report/themeusage/classes/form/theme_usage_form.php @@ -0,0 +1,98 @@ +. + +namespace report_themeusage\form; + +use moodleform; +use core\output\theme_usage; + +/** + * Defines the form for generating theme usage report data. + * + * @package report_themeusage + * @copyright 2023 David Woloszyn + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL Juv3 or later + */ +class theme_usage_form extends moodleform { + + /** + * Build the form definition. + */ + protected function definition() { + $mform = $this->_form; + + // Theme choices (e.g. boost, classic). + $themechoice = $this->_customdata['themechoice']; + $themechoices = array_merge(['' => get_string('select') . '...'], self::get_theme_choices()); + $mform->addElement('select', 'themechoice', get_string('themename', 'report_themeusage'), $themechoices); + $mform->setType('themechoice', PARAM_TEXT); + $mform->addRule('themechoice', get_string('required'), 'required', null, 'client'); + if (!empty($themechoice)) { + $mform->setDefault('themechoice', $themechoice); + } + + // Theme usage types (e.g. user, course, cohort, category). + $typechoices = self::get_type_choices(); + $mform->addElement('select', 'typechoice', get_string('usagetype', 'report_themeusage'), $typechoices); + $mform->setType('typechoice', PARAM_TEXT); + $mform->addRule('typechoice', get_string('required'), 'required', null, 'client'); + $mform->setDefault(theme_usage::THEME_USAGE_TYPE_ALL, $themechoice); + + // Submit button. + $mform->addElement('submit', 'submit', get_string('getreport', 'report_themeusage')); + } + + /** + * Get a list of available theme usage types. + * + * @return array + */ + public static function get_type_choices(): array { + return [ + theme_usage::THEME_USAGE_TYPE_ALL => get_string('all'), + theme_usage::THEME_USAGE_TYPE_USER => get_string('user'), + theme_usage::THEME_USAGE_TYPE_COURSE => get_string('course'), + theme_usage::THEME_USAGE_TYPE_COHORT => get_string('cohort', 'cohort'), + theme_usage::THEME_USAGE_TYPE_CATEGORY => get_string('category'), + ]; + } + + /** + * Get a list of available themes. + * + * @return array + */ + public static function get_theme_choices(): array { + $themes = \core_component::get_plugin_list('theme'); + foreach ($themes as $themename => $themedir) { + $themechoices[$themename] = get_string('pluginname', 'theme_'.$themename); + } + return $themechoices; + } + + /** + * Check the requested theme is in a list of available themes. + * + * @param string $themechoice The theme name. + * @return bool + */ + public static function validate_theme_choice_param(string $themechoice): bool { + if (!empty($themechoice) && !array_key_exists($themechoice, self::get_theme_choices())) { + return false; + } + return true; + } +} diff --git a/report/themeusage/classes/privacy/provider.php b/report/themeusage/classes/privacy/provider.php new file mode 100644 index 00000000000..8bcef194d1f --- /dev/null +++ b/report/themeusage/classes/privacy/provider.php @@ -0,0 +1,45 @@ +. + +/** + * Privacy Subsystem implementation for report_themeusage. + * + * @package report_themeusage + * @copyright 2023 David Woloszyn + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace report_themeusage\privacy; + +/** + * Privacy Subsystem for report_themeusage implementing null_provider. + * + * @package report_themeusage + * @copyright 2023 David Woloszyn + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class provider implements \core_privacy\local\metadata\null_provider { + + /** + * Get the language string identifier with the component's language + * file to explain why this plugin stores no data. + * + * @return string + */ + public static function get_reason(): string { + return 'privacy:metadata'; + } +} diff --git a/report/themeusage/classes/reportbuilder/local/entities/theme.php b/report/themeusage/classes/reportbuilder/local/entities/theme.php new file mode 100644 index 00000000000..0c7313dddc4 --- /dev/null +++ b/report/themeusage/classes/reportbuilder/local/entities/theme.php @@ -0,0 +1,134 @@ +. + +namespace report_themeusage\reportbuilder\local\entities; + +use core_reportbuilder\local\entities\base; +use core_reportbuilder\local\report\column; +use lang_string; + +/** + * Theme entity. + * + * Defines all the columns and filters that can be added to reports that use this entity. + * + * @package report_themeusage + * @copyright 2023 David Woloszyn + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class theme extends base { + + /** + * Database tables that this entity uses. + * + * @return array + */ + protected function get_default_tables(): array { + return [ + 'config_plugins', + ]; + } + + /** + * The default title for this entity. + * + * @return lang_string + */ + protected function get_default_entity_title(): lang_string { + return new lang_string('theme'); + } + + /** + * Initialize the entity. + * + * @return base + */ + public function initialise(): base { + $columns = $this->get_all_columns(); + foreach ($columns as $column) { + $this->add_column($column); + } + + return $this; + } + + /** + * Returns list of all available columns. + * + * @return column[] + */ + protected function get_all_columns(): array { + global $DB; + $themealias = $this->get_table_alias('config_plugins'); + $sqlsubstring = $DB->sql_substr("{$themealias}.plugin", 7); + + $courselabel = get_string('course'); + $cohortlabel = get_string('cohort', 'cohort'); + $userlabel = get_string('user'); + $categorylabel = get_string('category'); + + // Force theme column. + $columns[] = (new column( + 'forcetheme', + new lang_string('forcetheme'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type(column::TYPE_TEXT) + ->add_fields("{$themealias}.plugin") + ->add_callback(static function(?string $theme): string { + $theme = get_string('pluginname', $theme); + return format_text($theme, FORMAT_PLAIN); + }); + + // Usage type column. + $columns[] = (new column( + 'usagetype', + new lang_string('usagetype', 'report_themeusage'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->add_join("LEFT JOIN ( + SELECT '{$courselabel}' AS usagetype, theme, COUNT(theme) AS themecount + FROM {course} + WHERE " . $DB->sql_isnotempty('course', 'theme', false, false) . " + GROUP BY theme + UNION + SELECT '{$userlabel}' AS usagetype, theme, COUNT(theme) AS themecount + FROM {user} + WHERE " . $DB->sql_isnotempty('user', 'theme', false, false) . " + GROUP BY theme + UNION + SELECT '{$cohortlabel}' AS usagetype, theme, COUNT(theme) AS themecount + FROM {cohort} + WHERE " . $DB->sql_isnotempty('cohort', 'theme', false, false) . " + GROUP BY theme + UNION + SELECT '{$categorylabel}' AS usagetype, theme, COUNT(theme) AS themecount + FROM {course_categories} + WHERE " . $DB->sql_isnotempty('course_categories', 'theme', false, false) . " + GROUP BY theme + ) tuse ON tuse.theme={$sqlsubstring}") + ->set_type(column::TYPE_TEXT) + ->add_fields("tuse.usagetype, tuse.themecount") + ->add_callback(static function(?string $usagetype, \stdClass $row): string { + $count = $row->themecount ?? 0; + return format_text($usagetype . ' ('. $count . ')', FORMAT_PLAIN); + }); + + return $columns; + } +} diff --git a/report/themeusage/classes/reportbuilder/local/systemreports/theme_usage_report.php b/report/themeusage/classes/reportbuilder/local/systemreports/theme_usage_report.php new file mode 100644 index 00000000000..84f7599ef52 --- /dev/null +++ b/report/themeusage/classes/reportbuilder/local/systemreports/theme_usage_report.php @@ -0,0 +1,256 @@ +. + +namespace report_themeusage\reportbuilder\local\systemreports; + +use context_system; +use core_reportbuilder\local\entities\{course, user}; +use core_cohort\reportbuilder\local\entities\cohort; +use core_course\reportbuilder\local\entities\course_category; +use core_reportbuilder\local\helpers\database; +use core_reportbuilder\system_report; +use core\output\theme_usage; +use report_themeusage\reportbuilder\local\entities\theme; + +/** + * Config changes system report class implementation + * + * @package report_themeusage + * @copyright 2023 David Woloszyn + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class theme_usage_report extends system_report { + + /** + * Initialise report, we need to set the main table, load our entities and set columns/filters. + */ + protected function initialise(): void { + // Show results depending on the theme and type chosen. + $themechoice = $this->get_parameter('themechoice', '', PARAM_TEXT); + $typechoice = $this->get_parameter('typechoice', '', PARAM_TEXT); + + $themename = get_string('pluginname', 'theme_'.$themechoice); + + $themeentity = new theme(); + $themealias = $themeentity->get_table_alias('config_plugins'); + $this->set_main_table('config_plugins', $themealias); + $this->add_entity($themeentity); + + $param1 = database::generate_param_name(); + $param2 = database::generate_param_name(); + $params = [$param1 => 'theme_' . $themechoice, $param2 => 'version']; + + $this->add_base_condition_sql("{$themealias}.plugin = :{$param1} AND {$themealias}.name = :{$param2}", $params); + + switch ($typechoice) { + + case theme_usage::THEME_USAGE_TYPE_ALL: + + $this->add_columns_theme(); + $this->set_downloadable(true, get_string('themeusagereportall', 'report_themeusage', $themename)); + + break; + + case theme_usage::THEME_USAGE_TYPE_USER: + + $userentity = new user(); + $useralias = $userentity->get_table_alias('user'); + + $this->add_entity($userentity->add_join( + "JOIN {user} {$useralias} + ON $useralias.theme = '{$themechoice}' + AND {$themealias}.plugin = 'theme_{$themechoice}'")); + + $this->add_columns_user(); + $this->add_filters_user(); + $this->set_downloadable(true, get_string('themeusagereportuser', 'report_themeusage', $themename)); + break; + + case theme_usage::THEME_USAGE_TYPE_COURSE: + + $courseentity = new course(); + $coursealias = $courseentity->get_table_alias('course'); + + $this->add_entity($courseentity->add_join( + "JOIN {course} {$coursealias} + ON $coursealias.theme = '{$themechoice}' + AND {$themealias}.plugin = 'theme_{$themechoice}'")); + + $this->add_columns_course(); + $this->add_filters_course(); + $this->set_downloadable(true, get_string('themeusagereportcourse', 'report_themeusage', $themename)); + break; + + case theme_usage::THEME_USAGE_TYPE_COHORT: + + $cohortentity = new cohort(); + $cohortalias = $cohortentity->get_table_alias('cohort'); + + $this->add_entity($cohortentity->add_join( + "JOIN {cohort} {$cohortalias} + ON $cohortalias.theme = '{$themechoice}' + AND {$themealias}.plugin = 'theme_{$themechoice}'")); + + $this->add_columns_cohort(); + $this->add_filters_cohort(); + $this->set_downloadable(true, get_string('themeusagereportcohort', 'report_themeusage', $themename)); + break; + + case theme_usage::THEME_USAGE_TYPE_CATEGORY: + + $categoryentity = new course_category(); + $categoryalias = $categoryentity->get_table_alias('course_categories'); + + $this->add_entity($categoryentity->add_join( + "JOIN {course_categories} {$categoryalias} + ON $categoryalias.theme = '{$themechoice}' + AND {$themealias}.plugin = 'theme_{$themechoice}'")); + + $this->add_columns_category(); + $this->add_filters_category(); + $this->set_downloadable(true, get_string('themeusagereportcategory', 'report_themeusage', $themename)); + break; + + default: + break; + } + } + + /** + * Validates access to view this report. + * + * @return bool + */ + protected function can_view(): bool { + return has_capability('moodle/site:config', context_system::instance()); + } + + /** + * Adds the columns we want to display in the report for 'theme'. + */ + protected function add_columns_theme(): void { + $columns = [ + 'theme:usagetype', + 'theme:forcetheme', + ]; + + $this->add_columns_from_entities($columns); + $this->set_initial_sort_column('theme:forcetheme', SORT_ASC); + } + + /** + * Adds the columns we want to display in the report for 'user'. + */ + protected function add_columns_user(): void { + $columns = [ + 'user:firstname', + 'user:lastname', + 'theme:forcetheme', + ]; + + $this->add_columns_from_entities($columns); + $this->set_initial_sort_column('user:firstname', SORT_ASC); + } + + /** + * Adds the filters we want to display in the report for 'user'. + */ + protected function add_filters_user(): void { + $filters = [ + 'user:firstname', + 'user:lastname', + ]; + + $this->add_filters_from_entities($filters); + } + + /** + * Adds the columns we want to display in the report for 'course'. + */ + protected function add_columns_course(): void { + $columns = [ + 'course:fullname', + 'course:shortname', + 'theme:forcetheme', + ]; + + $this->add_columns_from_entities($columns); + $this->set_initial_sort_column('course:fullname', SORT_ASC); + } + + /** + * Adds the filters we want to display in the report for 'course'. + */ + protected function add_filters_course(): void { + $filters = [ + 'course:fullname', + 'course:shortname', + ]; + + $this->add_filters_from_entities($filters); + } + + /** + * Adds the columns we want to display in the report for 'cohort'. + */ + protected function add_columns_cohort(): void { + $columns = [ + 'cohort:name', + 'cohort:context', + 'theme:forcetheme', + ]; + + $this->add_columns_from_entities($columns); + $this->set_initial_sort_column('cohort:name', SORT_ASC); + } + + /** + * Adds the filters we want to display in the report for 'cohort'. + */ + protected function add_filters_cohort(): void { + $filters = [ + 'cohort:name', + 'cohort:context', + ]; + + $this->add_filters_from_entities($filters); + } + + /** + * Adds the columns we want to display in the report for 'category'. + */ + protected function add_columns_category(): void { + $columns = [ + 'course_category:name', + 'course_category:coursecount', + 'theme:forcetheme', + ]; + + $this->add_columns_from_entities($columns); + $this->set_initial_sort_column('course_category:name', SORT_ASC); + } + + /** + * Adds the filters we want to display in the report for 'category'. + */ + protected function add_filters_category(): void { + $filters = [ + 'course_category:name', + ]; + + $this->add_filters_from_entities($filters); + } +} diff --git a/report/themeusage/index.php b/report/themeusage/index.php new file mode 100644 index 00000000000..54204037558 --- /dev/null +++ b/report/themeusage/index.php @@ -0,0 +1,83 @@ +. + +/** + * Display usage information about themes. + * + * @package report_themeusage + * @copyright 2023 David Woloszyn + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +use core_reportbuilder\system_report_factory; +use report_themeusage\form\theme_usage_form; +use report_themeusage\reportbuilder\local\systemreports\theme_usage_report; + +require(__DIR__.'/../../config.php'); +require_once($CFG->libdir.'/adminlib.php'); + +require_login(); +admin_externalpage_setup('reportthemeusage'); + +// Get URL parameters. +$themechoice = optional_param('themechoice', '', PARAM_TEXT); + +// Check the requested theme is a valid one. +if (!theme_usage_form::validate_theme_choice_param($themechoice)) { + throw new \moodle_exception(get_string('invalidparametertheme', 'report_themeusage')); +} + +// Set up the page. +$pageurl = new moodle_url($CFG->wwwroot . '/report/themeusage/index.php'); +$PAGE->set_url($pageurl); +$PAGE->set_context(context_system::instance()); +$PAGE->set_pagelayout('report'); +$PAGE->set_primary_active_tab('siteadminnode'); +echo $OUTPUT->header(); + +// Show heading. +$heading = get_string('themeusage', 'report_themeusage'); +echo $OUTPUT->heading($heading); + +// Build form with prepared data. +$cutomdata['themechoice'] = $themechoice; +$form = new theme_usage_form($pageurl, $cutomdata); +$form->display(); + +if ($data = $form->get_data()) { + // Build report using submitted form data. + $themechoice = $data->themechoice; + $typechoice = $data->typechoice; + +} else if (!empty($themechoice)) { + // Build report with incoming theme choice and set the type to 'all'. + $typechoice = 'all'; +} + +if (!empty($themechoice) && !empty($typechoice)) { + // Show a heading that explains what the report is showing. + $themename = get_string('pluginname', 'theme_' . $themechoice); + $reportheading = get_string('themeusagereport' . $typechoice, 'report_themeusage', $themename); + echo $OUTPUT->heading($reportheading, 3, 'mt-4'); + + // Build the report. + $reportparams = ['themechoice' => $themechoice, 'typechoice' => $typechoice]; + $report = system_report_factory::create(theme_usage_report::class, context_system::instance(), '', '', 0, $reportparams); + echo $report->output(); +} + +// Show footer. +echo $OUTPUT->footer(); diff --git a/report/themeusage/lang/en/report_themeusage.php b/report/themeusage/lang/en/report_themeusage.php new file mode 100644 index 00000000000..7ae6233d99f --- /dev/null +++ b/report/themeusage/lang/en/report_themeusage.php @@ -0,0 +1,37 @@ +. + +/** + * Lang strings for theme usage report. + * + * @package report_themeusage + * @copyright 2023 David Woloszyn + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +$string['getreport'] = 'Get report'; +$string['invalidparametertheme'] = 'Invalid paramater set for theme'; +$string['pluginname'] = 'Theme usage'; +$string['privacy:metadata'] = 'The theme report plugin does not store any personal data.'; +$string['themename'] = 'Theme name'; +$string['themeusage'] = 'Theme usage'; +$string['themeusagereport'] = 'Theme usage report'; +$string['themeusagereportall'] = 'All uses of {$a}'; +$string['themeusagereportcategory'] = 'Categories using {$a}'; +$string['themeusagereportcohort'] = 'Cohorts using {$a}'; +$string['themeusagereportcourse'] = 'Courses using {$a}'; +$string['themeusagereportuser'] = 'Users using {$a}'; +$string['usagetype'] = 'Usage type'; diff --git a/report/themeusage/settings.php b/report/themeusage/settings.php new file mode 100644 index 00000000000..2bb1edbe66c --- /dev/null +++ b/report/themeusage/settings.php @@ -0,0 +1,37 @@ +. + +/** + * Settings and links. + * + * @package report_themeusage + * @copyright 2023 David Woloszyn + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die; + +$ADMIN->add('reports', + new admin_externalpage( + 'reportthemeusage', + get_string('pluginname', 'report_themeusage'), + "$CFG->wwwroot/report/themeusage/index.php", + 'moodle/site:config', + ), +); + +// No report settings. +$settings = null; diff --git a/report/themeusage/tests/behat/theme_usage.feature b/report/themeusage/tests/behat/theme_usage.feature new file mode 100644 index 00000000000..799d4c8d349 --- /dev/null +++ b/report/themeusage/tests/behat/theme_usage.feature @@ -0,0 +1,92 @@ +@report @report_themeusage +Feature: Navigate to a theme usage report + In order to see a theme usage report + As an admin + I need to set a theme for user/course/category/cohort and view the report + + Background: + Given the following config values are set as admin: + | allowuserthemes | 1 | + | allowcoursethemes | 1 | + | allowcategorythemes | 1 | + | allowcohortthemes | 1 | + And I log in as "admin" + + Scenario: I am able to see theme usage report for all contexts overriding the default theme + Given the following "courses" exist: + | fullname | shortname | theme | + | Course 1 | course1 | boost | + | Course 2 | course2 | boost | + And the following "user" exists: + | username | student1 | + | firstname | Student | + | lastname | One | + | theme | boost | + And I navigate to "Reports > Theme usage" in site administration + And I set the field "Theme name" to "boost" + And I set the field "Usage type" to "all" + When I press "Get report" + Then the following should exist in the "reportbuilder-table" table: + | Usage type | Force theme | + | Course (2) | Boost | + | User (1) | Boost | + + Scenario: I am able to see theme usage report for courses overriding the default theme + Given the following "course" exists: + | fullname | Course 1 | + | shortname | course1 | + | theme | boost | + And I navigate to "Reports > Theme usage" in site administration + And I set the field "Theme name" to "boost" + And I set the field "Usage type" to "course" + When I press "Get report" + Then the following should exist in the "reportbuilder-table" table: + | Course full name | Course short name | Force theme | + | Course 1 | course1 | Boost | + + Scenario: I am able to see theme usage report for users overriding the default theme + Given the following "user" exists: + | username | student1 | + | firstname | Student | + | lastname | One | + | theme | boost | + And I navigate to "Reports > Theme usage" in site administration + And I set the field "Theme name" to "boost" + And I set the field "Usage type" to "user" + When I press "Get report" + Then the following should exist in the "reportbuilder-table" table: + | First name | Last name | Force theme | + | Student | One | Boost | + + Scenario: I am able to see theme usage report for cohorts overriding the default theme + Given the following "cohort" exists: + | name | Cohort 1 | + | idnumber | cohort1 | + | context | system | + And I navigate to "Users > Accounts > Cohorts" in site administration + And I press "Edit" action in the "cohort1" report row + And I set the field "theme" to "boost" + And I press "Save changes" + And I navigate to "Reports > Theme usage" in site administration + And I set the field "Theme name" to "boost" + And I set the field "Usage type" to "cohort" + When I press "Get report" + Then the following should exist in the "reportbuilder-table" table: + | Name | Category | Force theme | + | Cohort 1 | System | Boost | + + Scenario: I am able to see theme usage report for categories overriding the default theme + Given the following "categories" exist: + | name | category | idnumber | + | Category 1 | 0 | category1 | + And I navigate to "Courses > Manage courses and categories" in site administration + And I click on "edit" action for "Category 1" in management category listing + And I set the field "theme" to "boost" + And I press "Save changes" + And I navigate to "Reports > Theme usage" in site administration + And I set the field "Theme name" to "boost" + And I set the field "Usage type" to "category" + When I press "Get report" + Then the following should exist in the "reportbuilder-table" table: + | Category name | Course count | Force theme | + | Category 1 | 0 | Boost | diff --git a/report/themeusage/tests/theme_usage_test.php b/report/themeusage/tests/theme_usage_test.php new file mode 100644 index 00000000000..8682c1c2287 --- /dev/null +++ b/report/themeusage/tests/theme_usage_test.php @@ -0,0 +1,101 @@ +. + +namespace report_themeusage; + +use testing_data_generator; +use core\output\theme_usage; + +/** + * Unit tests for theme usage. + * + * @package report_themeusage + * @copyright 2023 David Woloszyn + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class theme_usage_test extends \advanced_testcase { + + /** @var testing_data_generator Data generator. */ + private testing_data_generator $generator; + + /** + * Set up function for tests. + */ + protected function setUp(): void { + parent::setUp(); + + $this->resetAfterTest(); + $this->generator = $this->getDataGenerator(); + } + + /** + * Test is_theme_used_in_any_context method. + * + * @covers ::is_theme_used_in_any_context + * @covers ::theme_purge_used_in_context_caches + */ + public function test_is_theme_used_in_any_context(): void { + // Enable theme overrides. + set_config('allowuserthemes', 1); + set_config('allowcoursethemes', 1); + set_config('allowcohortthemes', 1); + set_config('allowcategorythemes', 1); + + $theme = 'boost'; + + // Check there are no contexts using 'boost' as their preferred theme yet. + $usedinanycontext = theme_usage::is_theme_used_in_any_context($theme); + $this->assertEquals(theme_usage::THEME_IS_NOT_USED, $usedinanycontext); + + // Create a user and set its theme preference to 'boost'. + // The outcome of this test should be the same if we use a cohort/course/category. + $this->generator->create_user(['theme' => $theme]); + + // Because we have already checked and cached a response, purge this cache. + theme_purge_used_in_context_caches(); + + $usedinanycontext = theme_usage::is_theme_used_in_any_context($theme); + $this->assertEquals(theme_usage::THEME_IS_USED, $usedinanycontext); + + // Double-check the the cache is set for the theme. + $cache = \cache::make('core', 'theme_usedincontext')->get($theme); + $this->assertEquals(theme_usage::THEME_IS_USED, $cache); + } + + /** + * Test the deleting of cache using theme_delete_used_in_context_cache. + * + * @covers ::theme_delete_used_in_context_cache + */ + public function test_theme_delete_used_in_context_cache(): void { + // Enable theme override. + set_config('allowuserthemes', 1); + + // Create a user and set its theme preference to 'boost'. + $theme = 'boost'; + $user = $this->generator->create_user(['theme' => $theme]); + + // Check for theme usage. This will create a cached result. + theme_usage::is_theme_used_in_any_context($theme); + $cache = \cache::make('core', 'theme_usedincontext')->get($theme); + $this->assertEquals(theme_usage::THEME_IS_USED, $cache); + + // Delete the cache by switching themes. + theme_delete_used_in_context_cache('classic', $user->theme); + $cache = \cache::make('core', 'theme_usedincontext')->get($theme); + $this->assertFalse($cache); + } +} diff --git a/report/themeusage/version.php b/report/themeusage/version.php new file mode 100644 index 00000000000..46233dda9af --- /dev/null +++ b/report/themeusage/version.php @@ -0,0 +1,29 @@ +. + +/** + * Version info. + * + * @package report_themeusage + * @copyright 2023 David Woloszyn + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die; + +$plugin->version = 2023110900; // The current plugin version (Date: YYYYMMDDXX). +$plugin->requires = 2023100900; // Requires this Moodle version. +$plugin->component = 'report_themeusage'; // Full name of the plugin (used for diagnostics). diff --git a/user/editadvanced_form.php b/user/editadvanced_form.php index 82af2a8451a..a796111167d 100644 --- a/user/editadvanced_form.php +++ b/user/editadvanced_form.php @@ -241,6 +241,14 @@ class user_editadvanced_form extends moodleform { } } + // User changing their preferred theme will delete the cache for this theme. + if ($mform->elementExists('theme') && $mform->isSubmitted()) { + $theme = $mform->getSubmitValue('theme'); + if (!empty($user) && ($theme != $user->theme)) { + theme_delete_used_in_context_cache($theme, $user->theme); + } + } + // Next the customisable profile fields. profile_definition_after_data($mform, $userid); } diff --git a/user/lib.php b/user/lib.php index 75d316a3900..7b9933052af 100644 --- a/user/lib.php +++ b/user/lib.php @@ -209,6 +209,15 @@ function user_update_user($user, $updatepassword = true, $triggerevent = true) { unset($user->calendartype); } + // Delete theme usage cache if the theme has been changed. + if (isset($user->theme)) { + if ($user->theme != $currentrecord->theme) { + theme_delete_used_in_context_cache($user->theme, $currentrecord->theme); + } + } + + $user->timemodified = time(); + // Validate user data object. $uservalidation = core_user::validate($user); if ($uservalidation !== true) {