Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.
<?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; } }