<?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 tool_brickfield;
use context_system;
use moodle_exception;
use moodle_url;
use stdClass;
use tool_brickfield\local\tool\filter;
/**
* Provides the Brickfield Accessibility toolkit API.
*
* @package tool_brickfield
* @copyright 2020 onward Brickfield Education Labs Ltd, https://www.brickfield.ie
* @author Mike Churchward (mike@brickfieldlabs.ie)
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class accessibility {
/** @var string The component sub path */
private static $pluginpath = 'tool/brickfield';
/** @var string Supported format of topics */
const TOOL_BRICKFIELD_FORMAT_TOPIC = 'topics';
/** @var string Supported format of weeks */
const TOOL_BRICKFIELD_FORMAT_WEEKLY = 'weeks';
/**
* Return the state of the site enable condition.
* @return bool
*/
public static function is_accessibility_enabled(): bool {
global $CFG;
return !empty($CFG->enableaccessibilitytools);
}
/**
* Throw an error if the toolkit is not enabled.
* @return bool
* @throws moodle_exception
*/
public static function require_accessibility_enabled(): bool {
if (!static::is_accessibility_enabled()) {
throw new moodle_exception('accessibilitydisabled', manager::PLUGINNAME);
}
return true;
}
/**
* Get a URL for a page within the plugin.
*
* This takes into account the value of the admin config value.
*
* @param string $url The URL within the plugin
* @return moodle_url
*/
public static function get_plugin_url(string $url = ''): moodle_url {
$url = ($url == '') ? 'index.php' : $url;
$pluginpath = self::$pluginpath;
return new moodle_url("/admin/{$pluginpath}/{$url}");
}
/**
* Get a file path for a file within the plugin.
*
* This takes into account the value of the admin config value.
*
* @param string $path The path within the plugin
* @return string
*/
public static function get_file_path(string $path): string {
global $CFG;
return implode(DIRECTORY_SEPARATOR, [$CFG->dirroot, $CFG->admin, self::$pluginpath, $path, ]);
}
/**
* Get the canonicalised name of a capability.
*
* @param string $capability
* @return string
*/
public static function get_capability_name(string $capability): string {
return self::$pluginpath . ':' . $capability;
}
/**
* Get the relevant title.
* @param filter $filter
* @param int $countdata
* @return string
* @throws \coding_exception
* @throws \dml_exception
* @throws \moodle_exception
*/
public static function get_title(filter $filter, int $countdata): string {
global $DB;
$tmp = new \stdClass();
$tmp->count = $countdata;
$langstr = 'title' . $filter->tab . 'partial';
if ($filter->courseid != 0) {
$thiscourse = get_fast_modinfo($filter->courseid)->get_course();
$tmp->name = $thiscourse->fullname;
} else {
$langstr = 'title' . $filter->tab . 'all';
}
return get_string($langstr, manager::PLUGINNAME, $tmp);
}
/**
* Function to be run periodically according to the scheduled task.
* Return true if a process was completed. False if no process executed.
* Finds all unprocessed courses for bulk batch processing and completes them.
* @param int $batch
* @return bool
* @throws \ReflectionException
* @throws \coding_exception
* @throws \ddl_exception
* @throws \ddl_table_missing_exception
* @throws \dml_exception
*/
public static function bulk_process_courses_cron(int $batch = 0): bool {
global $PAGE;
// Run a registration check.
if (!(new registration())->validate()) {
return false;
}
if (analysis::is_enabled()) {
$PAGE->set_context(context_system::instance());
mtrace("Starting cron for bulk_process_courses");
// Do regular processing. True if full deployment type isn't selected as well.
static::bulk_processing($batch);
mtrace("Ending cron for bulk_process_courses");
return true;
} else {
mtrace('Content analysis is currently disabled in settings.');
return false;
}
}
/**
* Bulk processing.
* @param int $batch
* @return bool
*/
protected static function bulk_processing(int $batch = 0): bool {
manager::check_course_updates();
mtrace("check_course_updates completed at " . time());
$recordsprocessed = manager::check_scheduled_areas($batch);
mtrace("check_scheduled_areas completed at " . time());
manager::check_scheduled_deletions();
mtrace("check_scheduled_deletions completed at " . time());
manager::delete_historical_data();
mtrace("delete_historical_data completed at " . time());
return $recordsprocessed;
}
/**
* Function to be run periodically according to the scheduled task.
* Finds all unprocessed courses for cache processing and completes them.
*/
public static function bulk_process_caches_cron() {
global $DB;
// Run a registration check.
if (!(new registration())->validate()) {
return;
}
if (analysis::is_enabled()) {
mtrace("Starting cron for bulk_process_caches");
// Monitor ongoing caching requests.
$fields = 'DISTINCT courseid';
$reruns = $DB->get_records(manager::DB_PROCESS, ['item' => 'cache'], '', $fields);
foreach ($reruns as $rerun) {
mtrace("Running rerun caching for Courseid " . $rerun->courseid);
manager::store_result_summary($rerun->courseid);
mtrace("rerun cache completed at " . time());
$DB->delete_records(manager::DB_PROCESS, ['courseid' => $rerun->courseid, 'item' => 'cache']);
}
mtrace("Ending cron for bulk_process_caches at " . time());
} else {
mtrace('Content analysis is currently disabled in settings.');
}
}
/**
* This function runs the checks on the html item
*
* @param string $html The html string to be analysed; might be NULL.
* @param int $contentid The content area ID
* @param int $processingtime
* @param int $resultstime
*/
public static function run_check(string $html, int $contentid, int &$processingtime, int &$resultstime) {
global $DB;
// Change the limit if 10,000 is not appropriate.
$bulkrecordlimit = manager::BULKRECORDLIMIT;
$bulkrecordcount = 0;
$checkids = static::checkids();
$checknameids = array_flip($checkids);
$testname = 'brickfield';
$stime = time();
// Swapping in new library.
$htmlchecker = new local\htmlchecker\brickfield_accessibility($html, $testname, 'string');
$htmlchecker->run_check();
$tests = $htmlchecker->guideline->get_tests();
$report = $htmlchecker->get_report();
$processingtime += (time() - $stime);
$records = [];
foreach ($tests as $test) {
$records[$test]['count'] = 0;
$records[$test]['errors'] = [];
}
foreach ($report['report'] as $a) {
if (!isset($a['type'])) {
continue;
}
$type = $a['type'];
$records[$type]['errors'][] = $a;
if (!isset($records[$type]['count'])) {
$records[$type]['count'] = 0;
}
$records[$type]['count']++;
}
$stime = time();
$returnchecks = [];
$errors = [];
// Build up records for inserting.
foreach ($records as $key => $rec) {
$recordres = new stdClass();
// Handling if checkid is unknown.
$checkid = (isset($checknameids[$key])) ? $checknameids[$key] : 0;
$recordres->contentid = $contentid;
$recordres->checkid = $checkid;
$recordres->errorcount = $rec['count'];
// Build error inserts if needed.
if ($rec['count'] > 0) {
foreach ($rec['errors'] as $tmp) {
$error = new stdClass();
$error->resultid = 0;
$error->linenumber = $tmp['lineNo'];
$error->htmlcode = $tmp['html'];
$error->errordescription = $tmp['title'];
// Add contentid and checkid so that we can query for the results record id later.
$error->contentid = $contentid;
$error->checkid = $checkid;
$errors[] = $error;
}
}
$returnchecks[] = $recordres;
$bulkrecordcount++;
// If we've hit the bulk limit, write the results records and reset.
if ($bulkrecordcount > $bulkrecordlimit) {
$DB->insert_records(manager::DB_RESULTS, $returnchecks);
$bulkrecordcount = 0;
$returnchecks = [];
// Get the results id value for each error record and write the errors.
foreach ($errors as $key2 => $error) {
$errors[$key2]->resultid = $DB->get_field(manager::DB_RESULTS, 'id',
['contentid' => $error->contentid, 'checkid' => $error->checkid]);
unset($errors[$key2]->contentid);
unset($errors[$key2]->checkid);
}
$DB->insert_records(manager::DB_ERRORS, $errors);
$errors = [];
}
}
// Write any leftover records.
if ($bulkrecordcount > 0) {
$DB->insert_records(manager::DB_RESULTS, $returnchecks);
// Get the results id value for each error record and write the errors.
foreach ($errors as $key => $error) {
$errors[$key]->resultid = $DB->get_field(manager::DB_RESULTS, 'id',
['contentid' => $error->contentid, 'checkid' => $error->checkid]);
unset($errors[$key]->contentid);
unset($errors[$key]->checkid);
}
$DB->insert_records(manager::DB_ERRORS, $errors);
}
$resultstime += (time() - $stime);
}
/**
* This function runs one specified check on the html item
*
* @param string|null $html The html string to be analysed; might be NULL.
* @param int $contentid The content area ID
* @param int $errid The error ID
* @param string $check The check name to run
* @param int $processingtime
* @param int $resultstime
* @throws \coding_exception
* @throws \dml_exception
*/
public static function run_one_check(
?string $html,
int $contentid,
int $errid,
string $check,
int &$processingtime,
int &$resultstime
) {
global $DB;
$stime = time();
$checkdata = $DB->get_record(manager::DB_CHECKS, ['shortname' => $check], 'id,shortname,severity');
$testname = 'brickfield';
// Swapping in new library.
$htmlchecker = new local\htmlchecker\brickfield_accessibility($html, $testname, 'string');
$htmlchecker->run_check();
$report = $htmlchecker->get_test($check);
$processingtime += (time() - $stime);
$record = [];
$record['count'] = 0;
$record['errors'] = [];
foreach ($report as $a) {
$a->html = $a->get_html();
$record['errors'][] = $a;
$record['count']++;
}
// Build up record for inserting.
$recordres = new stdClass();
// Handling if checkid is unknown.
$checkid = (isset($checkdata->id)) ? $checkdata->id : 0;
$recordres->contentid = $contentid;
$recordres->checkid = $checkid;
$recordres->errorcount = $record['count'];
if ($exists = $DB->get_record(manager::DB_RESULTS, ['contentid' => $contentid, 'checkid' => $checkid])) {
$resultid = $exists->id;
$DB->set_field(manager::DB_RESULTS, 'errorcount', $record['count'], ['id' => $resultid]);
// Remove old error records for specific resultid, if existing.
$DB->delete_records(manager::DB_ERRORS, ['id' => $errid]);
} else {
$resultid = $DB->insert_record(manager::DB_RESULTS, $recordres);
}
$errors = [];
// Build error inserts if needed.
if ($record['count'] > 0) {
// Reporting all found errors for this check, so need to ignore existing other error records.
foreach ($record['errors'] as $tmp) {
// Confirm if error is reported separately.
if ($DB->record_exists_select(manager::DB_ERRORS,
'resultid = ? AND ' . $DB->sql_compare_text('htmlcode', 255) . ' = ' . $DB->sql_compare_text('?', 255),
< [$resultid, html_entity_decode($tmp->html)])) {
> [$resultid, html_entity_decode($tmp->html, ENT_COMPAT)])) {
continue;
}
$error = new stdClass();
$error->resultid = $resultid;
$error->linenumber = $tmp->line;
< $error->htmlcode = html_entity_decode($tmp->html);
> $error->htmlcode = html_entity_decode($tmp->html, ENT_COMPAT);
$errors[] = $error;
}
$DB->insert_records(manager::DB_ERRORS, $errors);
}
$resultstime += (time() - $stime);
}
/**
* Returns all of the id's and shortnames of all of the checks.
* @param int $status
* @return array
* @throws \dml_exception
*/
public static function checkids(int $status = 1): array {
global $DB;
$checks = $DB->get_records_menu(manager::DB_CHECKS, ['status' => $status], 'id ASC', 'id,shortname');
return $checks;
}
/**
* Returns an array of translations from htmlchecker of all of the checks, and their descriptions.
* @return array
* @throws \dml_exception
*/
public static function get_translations(): array {
global $DB;
$htmlchecker = new local\htmlchecker\brickfield_accessibility('test', 'brickfield', 'string');
$htmlchecker->run_check();
ksort($htmlchecker->guideline->translations);
// Need to limit to active checks.
$activechecks = $DB->get_fieldset_select(manager::DB_CHECKS, 'shortname', 'status = :status', ['status' => 1]);
$translations = [];
foreach ($htmlchecker->guideline->translations as $key => $trans) {
if (in_array($key, $activechecks)) {
$translations[$key] = $trans;
}
}
return $translations;
}
/**
* Returns an array of all of the course id's for a given category.
* @param int $categoryid
* @return array|null
* @throws \dml_exception
*/
public static function get_category_courseids(int $categoryid): ?array {
global $DB;
if (!$DB->record_exists('course_categories', ['id' => $categoryid])) {
return null;
}
$sql = "SELECT {course}.id
FROM {course}, {course_categories}
WHERE {course}.category = {course_categories}.id
AND (
" . $DB->sql_like('path', ':categoryid1') . "
OR " . $DB->sql_like('path', ':categoryid2') . "
)";
$params = ['categoryid1' => "%/$categoryid/%", 'categoryid2' => "%/$categoryid"];
$courseids = $DB->get_fieldset_sql($sql, $params);
return $courseids;
}
/**
* Get summary data for this site.
* @param int $id
* @return \stdClass
* @throws \dml_exception
*/
public static function get_summary_data(int $id): \stdClass {
global $CFG, $DB;
$summarydata = new \stdClass();
$summarydata->siteurl = (substr($CFG->wwwroot, -1) !== '/') ? $CFG->wwwroot . '/' : $CFG->wwwroot;
$summarydata->moodlerelease = (preg_match('/^(\d+\.\d.*?)[. ]/', $CFG->release, $matches)) ? $matches[1] : $CFG->release;
$summarydata->numcourses = $DB->count_records('course') - 1;
$summarydata->numusers = $DB->count_records('user', array('deleted' => 0));
$summarydata->numfiles = $DB->count_records('files');
$summarydata->numfactivities = $DB->count_records('course_modules');
$summarydata->mobileservice = (int)$CFG->enablemobilewebservice === 1 ? true : false;
$summarydata->usersmobileregistered = $DB->count_records('user_devices');
$summarydata->contenttyperesults = static::get_contenttyperesults($id);
$summarydata->contenttypeerrors = static::get_contenttypeerrors();
$summarydata->percheckerrors = static::get_percheckerrors();
return $summarydata;
}
/**
* Get content type results.
* @param int $id
* @return \stdClass
*/
private static function get_contenttyperesults(int $id): \stdClass {
global $DB;
$sql = 'SELECT component, COUNT(id) AS count
FROM {' . manager::DB_AREAS . '}
GROUP BY component';
$components = $DB->get_recordset_sql($sql);
$contenttyperesults = new \stdClass();
$contenttyperesults->id = $id;
$contenttyperesults->contenttype = new \stdClass();
foreach ($components as $component) {
$componentname = $component->component;
$contenttyperesults->contenttype->$componentname = $component->count;
}
$components->close();
$contenttyperesults->summarydatastorage = static::get_summary_data_storage();
$contenttyperesults->datachecked = time();
return $contenttyperesults;
}
/**
* Get per check errors.
* @return stdClass
* @throws dml_exception
*/
private static function get_percheckerrors(): stdClass {
global $DB;
$sql = 'SELECT ' . $DB->sql_concat_join("'_'", ['courseid', 'checkid']) . ' as tmpid,
ca.courseid, ca.status, ca.checkid, ch.shortname, ca.checkcount, ca.errorcount
FROM {' . manager::DB_CACHECHECK . '} ca
INNER JOIN {' . manager::DB_CHECKS . '} ch on ch.id = ca.checkid
ORDER BY courseid, checkid ASC';
$combo = $DB->get_records_sql($sql);
return (object) [
'percheckerrors' => $combo,
];
}
/**
* Get content type errors.
* @return stdClass
* @throws dml_exception
*/
private static function get_contenttypeerrors(): stdClass {
global $DB;
$fields = 'courseid, status, activities, activitiespassed, activitiesfailed,
errorschecktype1, errorschecktype2, errorschecktype3, errorschecktype4,
errorschecktype5, errorschecktype6, errorschecktype7,
failedchecktype1, failedchecktype2, failedchecktype3, failedchecktype4,
failedchecktype5, failedchecktype6, failedchecktype7,
percentchecktype1, percentchecktype2, percentchecktype3, percentchecktype4,
percentchecktype5, percentchecktype6, percentchecktype7';
$combo = $DB->get_records(manager::DB_SUMMARY, null, 'courseid ASC', $fields);
return (object) [
'typeerrors' => $combo,
];
}
/**
* Get summary data storage.
* @return array
* @throws dml_exception
*/
private static function get_summary_data_storage(): array {
global $DB;
$fields = $DB->sql_concat_join("''", ['component', 'courseid']) . ' as tmpid,
courseid, component, errorcount, totalactivities, failedactivities, passedactivities';
$combo = $DB->get_records(manager::DB_CACHEACTS, null, 'courseid, component ASC', $fields);
return $combo;
}
}