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/>.

/**
 * Completion Progress block.
 *
 * @package    block_completion_progress
 * @copyright  2016 Michael de Raadt
 * @copyright  2021 Jonathon Fowler <fowlerj@usq.edu.au>
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */

namespace block_completion_progress;

use stdClass;
use completion_info;
use context_course;
use coding_exception;

/**
 * Completion Progress.
 *
 * @package    block_completion_progress
 * @copyright  2016 Michael de Raadt
 * @copyright  2021 Jonathon Fowler <fowlerj@usq.edu.au>
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
class completion_progress implements \renderable {
    /**
     * Sort activities by course order.
     */
    const ORDERBY_COURSE = 'orderbycourse';

    /**
     * Sort activities by expected time order.
     */
    const ORDERBY_TIME = 'orderbytime';

    /**
     * The course.
     * @var object
     */
    protected $course;

    /**
     * The course context.
     * @var context_course
     */
    protected $context;

    /**
     * Completion info for the course.
     * @var completion_info
     */
    protected $completioninfo;

    /**
     * The user.
     * @var object
     */
    protected $user;

    /**
     * Block instance record.
     * @var stdClass
     */
    protected $blockinstance;

    /**
     * Block instance config.
     * @var stdClass
     */
    protected $blockconfig;

    /**
     * List of activities.
     * @var array cmid => obj
     */
    protected $activities = null;

    /**
     * List of visible activities.
     * @var array cmid => obj
     */
    protected $visibleactivities = null;

    /**
     * List of grade exclusions.
     * @var array of: [module-instance-userid, ...]
     */
    protected $exclusions = null;

    /**
     * List of submissions.
     * @var array of arrays: userid => [cmid => obj]
     */
    protected $submissions = null;

    /**
     * List of computed completions.
     * @var array of arrays: userid => [cmid => state]
     */
    protected $completions = null;

    /**
     * Whether exclusions have been loaded for all course users already.
     * @var boolean
     */
    protected $exclusionsforall = false;

    /**
     * Whether submissions have been loaded for all course users already.
     * @var boolean
     */
    protected $submissionsforall = false;

    /**
     * Whether completions have been loaded for all course users already.
     * @var boolean
     */
    protected $completionsforall = false;

    /**
     * Simple bar mode (for overview).
     * @var boolean
     */
    protected $simplebar = false;

    /**
     * Constructor.
     * @param object|int $courseorid
     */
    public function __construct($courseorid) {
        global $CFG;

        require_once($CFG->libdir.'/completionlib.php');

        if (is_object($courseorid)) {
            $this->course = $courseorid;
        } else {
            $this->course = get_course($courseorid);
        }
        $this->context = context_course::instance($this->course->id);
        $this->completioninfo = new completion_info($this->course);
    }

    /**
     * Specialise for a specific user.
     * @param stdClass $user containing minimum of core_user\fields::for_name()
     * @return self
     */
    public function for_user(stdClass $user): self {
        $this->user = $user;

        $this->load_exclusions();
        $this->load_submissions();
        $this->load_completions();
        $this->filter_visible_activities();

        return $this;
    }

    /**
     * Specialise for overview page use.
     * @return self
     */
    public function for_overview() {
        if ($this->user) {
            throw new coding_exception('cannot re-specialise for overview');
        }
        $this->user = null;
        $this->simplebar = true;

        $this->load_exclusions();
        $this->load_submissions();
        $this->load_completions();

        return $this;
    }

    /**
     * Specialise for a particular block instance.
     * @param stdClass $instance Instance record.
     * @param boolean $selectedonly Whether to filter by configured selected items.
     * @return self
     */
    public function for_block_instance(stdClass $instance, $selectedonly = true): self {
        if ($this->blockinstance) {
            throw new coding_exception('cannot re-specialise for a different block instance');
        }
        $this->blockinstance = $instance;
        $this->blockconfig = (object)(array)unserialize(base64_decode($instance->configdata ?? ''));

        $this->load_activities($selectedonly);
        $this->filter_visible_activities();

        return $this;
    }

    /**
     * Return the course object.
     * @return object
     */
    public function get_course(): stdClass {
        return $this->course;
    }

    /**
     * Return the course object.
     * @return context_course
     */
    public function get_context(): context_course {
        return $this->context;
    }

    /**
     * Return the completion info object.
     * @return completion_info
     */
    public function get_completion_info(): completion_info {
        return $this->completioninfo;
    }

    /**
     * Return the user.
     * @return stdClass
     */
    public function get_user(): ?stdClass {
        return $this->user;
    }

    /**
     * Return the simple bar mode.
     * @return boolean
     */
    public function is_simple_bar(): bool {
        return $this->simplebar;
    }

    /**
     * Check whether any activities are available.
     * @return boolean
     */
    public function has_activities(): bool {
        if ($this->activities === null) {
            throw new coding_exception('activities not loaded until for_block_instance() is called');
        }
        return !empty($this->activities);
    }

    /**
     * Return the activities in presentation order.
     * @param string|null $orderoverride
     * @return array
     */
    public function get_activities($orderoverride = null): array {
        if ($this->activities === null) {
            throw new coding_exception('activities not loaded until for_block_instance() is called');
        }
        $order = $orderoverride ?? $this->blockconfig->orderby ?? self::ORDERBY_COURSE;
        usort($this->activities, [$this, 'sorter_' . $order]);
        return $this->activities;
    }

    /**
     * Check whether any visible activities are available.
     * @return boolean
     */
    public function has_visible_activities(): bool {
        if ($this->visibleactivities === null) {
            throw new coding_exception('visible activities not computed until for_block_instance() is called');
        }
        return !empty($this->visibleactivities);
    }

    /**
     * Return the activities visible to the user in presentation order.
     * @param string|null $orderoverride
     * @return array
     */
    public function get_visible_activities($orderoverride = null): array {
        if ($this->visibleactivities === null) {
            throw new coding_exception('visible activities not computed until for_block_instance() is called');
        }
        $order = $orderoverride ?? $this->blockconfig->orderby ?? self::ORDERBY_COURSE;
        usort($this->visibleactivities, [$this, 'sorter_' . $order]);
        return $this->visibleactivities;
    }

    /**
     * Return the exclusions.
     * @return array of modname-modinstance-userid formatted items
     */
    public function get_exclusions(): array {
        return $this->exclusions;
    }

    /**
     * Get block instance.
     * @return stdClass|null
     */
    public function get_block_instance(): ?stdClass {
        return $this->blockinstance;
    }

    /**
     * Get block configuration.
     * @return stdClass
     */
    public function get_block_config(): stdClass {
        return $this->blockconfig;
    }

    /**
     * Get user activity submissions.
     * @return array cmid => info
     */
    public function get_submissions(): array {
        if ($this->submissions === null) {
            throw new coding_exception('submissions not computed until for_user() or for_overview() is called');
        }
        if ($this->user) {
            return $this->submissions[$this->user->id] ?? [];
        } else {
            throw new coding_exception('unimplemented');
        }
    }

    /**
     * Get user activity completion states.
     * @return array cmid => status
     */
    public function get_completions() {
        if ($this->completions === null) {
            throw new coding_exception('completions not computed until for_user() or for_overview() is called');
        }
        if ($this->user) {
            // Filter to visible activities and fill in gaps.
            $completions = $this->completions[$this->user->id] ?? [];
            $ret = [];
            foreach ($this->visibleactivities as $activity) {
                $ret[$activity->id] = $completions[$activity->id] ?? COMPLETION_INCOMPLETE;
            }
            return $ret;
        } else {
            throw new coding_exception('unimplemented');
        }
    }

    /**
     * Calculates an overall percentage of progress.
     * @return integer  Progress value as a percentage
     */
    public function get_percentage(): ?int {
        $completions = $this->get_completions();
        if (count($completions) == 0) {
            return null;
        }

        $completecount = 0;
        foreach ($completions as $complete) {
            if ($complete == COMPLETION_COMPLETE || $complete == COMPLETION_COMPLETE_PASS) {
                $completecount++;
            }
        }

        return (int)round(100 * $completecount / count($this->visibleactivities));
    }

    /**
     * Used to compare two activity entries based on order on course page.
     *
     * @param array $a
     * @param array $b
     * @return integer
     */
    private function sorter_orderbycourse($a, $b): int {
        if ($a->section != $b->section) {
            return $a->section <=> $b->section;
        } else {
            return $a->position <=> $b->position;
        }
    }

    /**
     * Used to compare two activity entries based their expected completion times
     *
     * @param array $a
     * @param array $b
     * @return integer
     */
    private function sorter_orderbytime($a, $b): int {
        if ($a->expected != 0 && $b->expected != 0 && $a->expected != $b->expected) {
            return $a->expected <=> $b->expected;
        } else if ($a->expected != 0 && $b->expected == 0) {
            return -1;
        } else if ($a->expected == 0 && $b->expected != 0) {
            return 1;
        } else {
            return $this->sorter_orderbycourse($a, $b);
        }
    }


    /**
     * Loads activities with completion set in current course.
     *
     * @param boolean $selectedonly Whether to filter by configured selected items.
     * @return array
     */
    protected function load_activities($selectedonly) {
        $modinfo = get_fast_modinfo($this->course, -1);
        $sections = $modinfo->get_sections();
        $selectedonly = $selectedonly && ($this->blockconfig->activitiesincluded ?? '') === 'selectedactivities';
        $selectedcms = $this->blockconfig->selectactivities ?? [];

        $this->activities = [];

        foreach ($modinfo->instances as $module => $cms) {
            $modulename = get_string('pluginname', $module);
            foreach ($cms as $cm) {
                if ($cm->completion == COMPLETION_TRACKING_NONE) {
                    continue;
                }
                if ($selectedonly && !in_array($module.'-'.$cm->instance, $selectedcms)) {
                    continue;
                }

                $this->activities[$cm->id] = (object)[
                    'type'       => $module,
                    'modulename' => $modulename,
                    'id'         => $cm->id,
                    'instance'   => $cm->instance,
                    'name'       => $cm->get_formatted_name(),
                    'expected'   => $cm->completionexpected,
                    'section'    => $cm->sectionnum,
                    'position'   => array_search($cm->id, $sections[$cm->sectionnum]),
                    'url'        => $cm->url instanceof \moodle_url ? $cm->url->out() : '',
                    'onclick'    => $cm->onclick,
                    'context'    => $cm->context,
                    'icon'       => $cm->get_icon_url(),
                    'available'  => $cm->available,
                ];
            }
        }
    }

    /**
     * Filter down the activities to those a user can see.
     */
    protected function filter_visible_activities() {
        global $CFG, $USER;

        if (!$this->user || $this->activities === null) {
            return;
        }

        $this->visibleactivities = [];
        $modinfo = get_fast_modinfo($this->course, $this->user->id);
        $canviewhidden = has_capability('moodle/course:viewhiddenactivities', $this->context, $this->user);

        // Keep only activities that are visible.
        foreach ($this->activities as $key => $activity) {
            $cm = $modinfo->cms[$activity->id];

            // Check visibility in course.
            if (!$cm->visible && !$canviewhidden) {
                continue;
            }

            // Check availability, allowing for visible, but not accessible items.
            if (!empty($CFG->enableavailability)) {
                if ($canviewhidden) {
                    $activity->available = true;
                } else {
                    if (isset($cm->available) && !$cm->available && empty($cm->availableinfo)) {
                        continue;
                    }
                    $activity->available = $cm->available;
                }
            }

            // Check for exclusions.
            if (in_array($activity->type.'-'.$activity->instance.'-'.$this->user->id, $this->exclusions)) {
                continue;
            }

            // Save the visible event.
            $this->visibleactivities[$key] = $activity;
        }
    }

    /**
     * Finds gradebook exclusions for students in the course.
     */
    protected function load_exclusions() {
        global $DB;

        if ($this->exclusionsforall) {
            // Already loaded.
            return;
        }

        $query = "SELECT g.id, i.itemmodule, i.iteminstance, g.userid
                   FROM {grade_grades} g, {grade_items} i
                  WHERE i.courseid = :courseid
                    AND i.id = g.itemid
                    AND g.excluded <> 0";
        $params = ['courseid' => $this->course->id];
        if ($this->user) {
            $query .= " AND g.userid = :userid";
            $params['userid'] = $this->user->id;
        } else {
            // Avoid refetching this info if specialising for user later.
            $this->exclusionsforall = true;
        }

        $this->exclusions = [];
        foreach ($DB->get_records_sql($query, $params) as $rec) {
            $this->exclusions[] = $rec->itemmodule . '-' . $rec->iteminstance . '-' . $rec->userid;
        }
    }

    /**
     * Loads completion information for enrolled users in the course.
     */
    protected function load_completions() {
        global $DB;

        if ($this->completionsforall) {
            // Already loaded.
            return;
        }

        // Somewhat faster than lots of calls to completion_info::get_data($cm, true, $userid)
        // where its cache can't be used because the userid is different.
        $enrolsql = get_enrolled_join($this->context, 'u.id', false);
        $query = "SELECT DISTINCT " . $DB->sql_concat('cm.id', "'-'", 'u.id') . " AS id,
                        u.id AS userid, cm.id AS cmid,
                        COALESCE(cmc.completionstate, :incomplete) AS completionstate
                    FROM {user} u {$enrolsql->joins}
              CROSS JOIN {course_modules} cm
               LEFT JOIN {course_modules_completion} cmc ON cmc.coursemoduleid = cm.id AND cmc.userid = u.id
                   WHERE {$enrolsql->wheres}
                     AND cm.course = :courseid
                     AND cm.completion <> :none";
        $params = $enrolsql->params + [
            'courseid' => $this->course->id,
            'incomplete' => COMPLETION_INCOMPLETE,
            'none' => COMPLETION_TRACKING_NONE,
        ];
        if ($this->user) {
            $query .= " AND u.id = :userid";
            $params['userid'] = $this->user->id;
        } else {
            // Avoid refetching this info if specialising for user later.
            $this->completionsforall = true;
        }

        $rset = $DB->get_recordset_sql($query, $params);
        $this->completions = [];
        foreach ($rset as $compl) {
            $submission = $this->submissions[$compl->userid][$compl->cmid] ?? null;

            if ($compl->completionstate == COMPLETION_INCOMPLETE && $submission) {
                $this->completions[$compl->userid][$compl->cmid] = 'submitted';
            } else if ($compl->completionstate == COMPLETION_COMPLETE_FAIL && $submission
                    && !$submission->graded) {
                $this->completions[$compl->userid][$compl->cmid] = 'submitted';
            } else {
                $this->completions[$compl->userid][$compl->cmid] = $compl->completionstate;
            }
        }
        $rset->close();
    }

    /**
     * Find submissions for students in the course.
     */
    protected function load_submissions() {
        global $DB, $CFG;

        if ($this->submissionsforall) {
            // Already loaded.
            return;
        }

        require_once($CFG->dirroot . '/mod/quiz/lib.php');

        $params = [
            'courseid' => $this->course->id,
        ];

        if ($this->user) {
            $assignwhere = 'AND s.userid = :userid';
            $workshopwhere = 'AND s.authorid = :userid';
            $quizwhere = 'AND qa.userid = :userid';

            $params += [
              'userid' => $this->user->id,
            ];
        } else {
            $assignwhere = '';
            $workshopwhere = '';
            $quizwhere = '';

            // Avoid refetching this info if specialising for user later.
            $this->submissionsforall = true;
        }

        // Queries to deliver instance IDs of activities with submissions by user.
        $queries = array (
            [
                // Assignments with individual submission, or groups requiring a submission per user,
                // or ungrouped users in a group submission situation.
                'module' => 'assign',
                'query' => "SELECT ". $DB->sql_concat('s.userid', "'-'", 'c.id') ." AS id,
                             s.userid, c.id AS cmid,
                             MAX(CASE WHEN ag.grade IS NULL OR ag.grade = -1 THEN 0 ELSE 1 END) AS graded
                          FROM {assign_submission} s
                            INNER JOIN {assign} a ON s.assignment = a.id
                            INNER JOIN {course_modules} c ON c.instance = a.id
                            INNER JOIN {modules} m ON m.name = 'assign' AND m.id = c.module
                            LEFT JOIN {assign_grades} ag ON ag.assignment = s.assignment
                                  AND ag.attemptnumber = s.attemptnumber
                                  AND ag.userid = s.userid
                          WHERE s.latest = 1
                            AND s.status = 'submitted'
                            AND a.course = :courseid
                            AND (
                                a.teamsubmission = 0 OR
                                (a.teamsubmission <> 0 AND a.requireallteammemberssubmit <> 0 AND s.groupid = 0) OR
                                (a.teamsubmission <> 0 AND a.preventsubmissionnotingroup = 0 AND s.groupid = 0)
                            )
                            $assignwhere
                        GROUP BY s.userid, c.id",
                'params' => [ ],
            ],

            [
                // Assignments with groups requiring only one submission per group.
                'module' => 'assign',
                'query' => "SELECT ". $DB->sql_concat('s.userid', "'-'", 'c.id') ." AS id,
                             s.userid, c.id AS cmid,
                             MAX(CASE WHEN ag.grade IS NULL OR ag.grade = -1 THEN 0 ELSE 1 END) AS graded
                          FROM {assign_submission} gs
                            INNER JOIN {assign} a ON gs.assignment = a.id
                            INNER JOIN {course_modules} c ON c.instance = a.id
                            INNER JOIN {modules} m ON m.name = 'assign' AND m.id = c.module
                            INNER JOIN {groups_members} s ON s.groupid = gs.groupid
                            LEFT JOIN {assign_grades} ag ON ag.assignment = gs.assignment
                                  AND ag.attemptnumber = gs.attemptnumber
                                  AND ag.userid = s.userid
                          WHERE gs.latest = 1
                            AND gs.status = 'submitted'
                            AND gs.userid = 0
                            AND a.course = :courseid
                            AND (a.teamsubmission <> 0 AND a.requireallteammemberssubmit = 0)
                            $assignwhere
                        GROUP BY s.userid, c.id",
                'params' => [ ],
            ],

            [
                'module' => 'workshop',
                'query' => "SELECT ". $DB->sql_concat('s.authorid', "'-'", 'c.id') ." AS id,
                               s.authorid AS userid, c.id AS cmid,
                               1 AS graded
                             FROM {workshop_submissions} s, {workshop} w, {modules} m, {course_modules} c
                            WHERE s.workshopid = w.id
                              AND w.course = :courseid
                              AND m.name = 'workshop'
                              AND m.id = c.module
                              AND c.instance = w.id
                              $workshopwhere
                          GROUP BY s.authorid, c.id",
                'params' => [ ],
            ],

            [
                // Quizzes with 'first' and 'last attempt' grading methods.
                'module' => 'quiz',
                'query' => "SELECT ". $DB->sql_concat('qa.userid', "'-'", 'c.id') ." AS id,
                           qa.userid, c.id AS cmid,
                           (CASE WHEN qa.sumgrades IS NULL THEN 0 ELSE 1 END) AS graded
                         FROM {quiz_attempts} qa
                           INNER JOIN {quiz} q ON q.id = qa.quiz
                           INNER JOIN {course_modules} c ON c.instance = q.id
                           INNER JOIN {modules} m ON m.name = 'quiz' AND m.id = c.module
                        WHERE qa.state = 'finished'
                          AND q.course = :courseid
                          AND qa.attempt = (
                            SELECT CASE WHEN q.grademethod = :gmfirst THEN MIN(qa1.attempt)
                                        WHEN q.grademethod = :gmlast THEN MAX(qa1.attempt) END
                            FROM {quiz_attempts} qa1
                            WHERE qa1.quiz = qa.quiz
                              AND qa1.userid = qa.userid
                              AND qa1.state = 'finished'
                          )
                          $quizwhere",
                'params' => [
                    'gmfirst' => QUIZ_ATTEMPTFIRST,
                    'gmlast' => QUIZ_ATTEMPTLAST,
                ],
            ],
            [
                // Quizzes with 'maximum' and 'average' grading methods.
                'module' => 'quiz',
                'query' => "SELECT ". $DB->sql_concat('qa.userid', "'-'", 'c.id') ." AS id,
                           qa.userid, c.id AS cmid,
                           MIN(CASE WHEN qa.sumgrades IS NULL THEN 0 ELSE 1 END) AS graded
                         FROM {quiz_attempts} qa
                           INNER JOIN {quiz} q ON q.id = qa.quiz
                           INNER JOIN {course_modules} c ON c.instance = q.id
                           INNER JOIN {modules} m ON m.name = 'quiz' AND m.id = c.module
                        WHERE (q.grademethod = :gmmax OR q.grademethod = :gmavg)
                          AND qa.state = 'finished'
                          AND q.course = :courseid
                          $quizwhere
                       GROUP BY qa.userid, c.id",
                'params' => [
                    'gmmax' => QUIZ_GRADEHIGHEST,
                    'gmavg' => QUIZ_GRADEAVERAGE,
                ],
            ],
        );

        $this->submissions = [];
        foreach ($queries as $spec) {
            $results = $DB->get_records_sql($spec['query'], $params + $spec['params']);
            foreach ($results as $obj) {
                unset($obj->id);
                $this->submissions[$obj->userid][$obj->cmid] = $obj;
            }
        }
    }

}