Proyectos de Subversion Moodle

Rev

Autoría | Ultima modificación | Ver Log |

<?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 qtype_multianswer\task;

use context_system;
use core\task\stored_progress_task_trait;
use core_question\local\bank\question_version_status;
use question_bank;
use question_engine_data_mapper;

/**
 * Cleanup duplicate subquestions
 *
 * Due to MDL-85721, there may be duplicated subquestions in the database. These have a question bank entry, question version,
 * and question record with a parent, but they are not referred to in that parent's sequence.
 *
 * @package   qtype_multianswer
 * @copyright 2025 onwards Catalyst IT EU {@link https://catalyst-eu.net}
 * @author    Mark Johnson <mark.johnson@catalyst-eu.net>
 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
class cleanup_duplicate_subquestions extends \core\task\adhoc_task {
    use stored_progress_task_trait;

    /**
     * Find questions where there are other questions with identical text, stamp and multianswer parent
     *
     * We may have multiple subquestions with the same stamp but different text or parents due to historical bugs,
     * so this includes the ID field from one of the duplicates to ensure we have a unique first field.
     *
     * @return array
     */
    public function find_duplicated_subquestions(): array {
        global $DB;
        $questiontext = $DB->sql_cast_to_char('subq.questiontext');
        $sequence = $DB->sql_cast_to_char('qm.sequence');
        return $DB->get_records_sql("
            SELECT MIN(subq.id) AS firstid,
                   subq.stamp AS stamp,
                   {$questiontext} AS questiontext,
                   subq.parent,
                   {$sequence} AS sequence,
                   COUNT(1) AS count
              FROM {question} subq
              JOIN {question} q ON q.id = subq.parent
              JOIN {question_multianswer} qm ON q.id = qm.question
             WHERE q.qtype = 'multianswer'
          GROUP BY subq.stamp, {$questiontext}, subq.parent, {$sequence}
            HAVING COUNT(1) > 1
        ");
    }

    #[\Override]
    public function execute() {
        global $CFG, $DB;
        require_once($CFG->libdir . '/questionlib.php');

        $duplicatedsubquestions = $this->find_duplicated_subquestions();

        $duplicatedcount = count($duplicatedsubquestions);

        if ($duplicatedcount === 0) {
            mtrace("No duplicated questions found.");
            return;
        }

        mtrace("Found {$duplicatedcount} subquestions with duplicates.");

        $this->start_stored_progress();
        $progress = $this->get_progress();
        foreach ($duplicatedsubquestions as $subquestion) {
            // Find instances of the subquestion that do not appear in the sequence of the parent.
            [$insql, $inparams] = $DB->get_in_or_equal(explode(',', $subquestion->sequence), equal: false);
            $params = array_merge([$subquestion->parent, $subquestion->stamp], $inparams);
            $duplicates = $DB->get_records_select('question', "parent = ? AND stamp = ? AND id {$insql}", $params);
            $duplicatecount = count($duplicates);
            // Delete each duplicate, with a progress bar.
            mtrace("");
            mtrace("Deleting {$duplicatecount} duplicates:");
            $progress->start_progress($subquestion->stamp, $duplicatecount);
            foreach ($duplicates as $duplicate) {
                // Based on question_delete_question(), without checking for parent usage or deleting children.
                // If the question is being used, just mark it as hidden. Otherwise, delete the question, version and question bank
                // entry.
                $sql = "SELECT qv.id as versionid,
                               qv.version,
                               qbe.id as entryid,
                               qc.id as categoryid,
                               ctx.id as contextid
                          FROM {question} q
                     LEFT JOIN {question_versions} qv ON qv.questionid = q.id
                     LEFT JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
                     LEFT JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
                     LEFT JOIN {context} ctx ON ctx.id = qc.contextid
                         WHERE q.id = ?";
                $questiondata = $DB->get_record_sql($sql, [$duplicate->id]);

                // Do not delete a question if it is used by an activity module. Just mark the version hidden.
                if (questions_in_use([$duplicate->id])) {
                    $DB->set_field(
                        'question_versions',
                        'status',
                        question_version_status::QUESTION_STATUS_HIDDEN,
                        ['questionid' => $duplicate->id]
                    );
                    $progress->increment_progress();
                    continue;
                }

                // This sometimes happens in old sites with bad data.
                if (!$questiondata->contextid) {
                    debugging('Deleting question ' . $duplicate->id . ' which is no longer linked to a context. ' .
                        'Assuming system context to avoid errors, but this may mean that some data like files, ' .
                        'tags, are not cleaned up.');
                    $questiondata->contextid = context_system::instance()->id;
                    $questiondata->categoryid = 0;
                }

                // Delete previews of the question.
                $dm = new question_engine_data_mapper();
                $dm->delete_previews($duplicate->id);

                // Delete questiontype-specific data.
                question_bank::get_qtype($duplicate->qtype, false)->delete_question($duplicate->id, $questiondata->contextid);

                // Finally delete the question record itself.
                $DB->delete_records('question', ['id' => $duplicate->id]);
                $DB->delete_records('question_versions', ['id' => $questiondata->versionid]);
                $DB->delete_records('question_references',
                    [
                        'version' => $questiondata->version,
                        'questionbankentryid' => $questiondata->entryid,
                    ]);
                delete_question_bank_entry($questiondata->entryid);
                question_bank::notify_question_edited($duplicate->id);

                // Log the deletion of this question.
                $duplicate->category = $questiondata->categoryid;
                $duplicate->contextid = $questiondata->contextid;
                $event = \core\event\question_deleted::create_from_question_instance($duplicate);
                $event->add_record_snapshot('question', $duplicate);
                $event->trigger();

                $progress->increment_progress();
            }
            $progress->end_progress();
        }
    }
}