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

/**
 * Data provider.
 *
 * @package    core_badges
 * @copyright  2018 Frédéric Massart
 * @author     Frédéric Massart <fred@branchup.tech>
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */

namespace core_badges\privacy;

defined('MOODLE_INTERNAL') || die();

use badge;
use context;
use context_course;
use context_helper;
use context_system;
use context_user;
use core_text;
use core_privacy\local\metadata\collection;
use core_privacy\local\request\approved_contextlist;
use core_privacy\local\request\transform;
use core_privacy\local\request\writer;
use core_privacy\local\request\userlist;
use core_privacy\local\request\approved_userlist;

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

/**
 * Data provider class.
 *
 * @package    core_badges
 * @copyright  2018 Frédéric Massart
 * @author     Frédéric Massart <fred@branchup.tech>
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
class provider implements
    \core_privacy\local\metadata\provider,
    \core_privacy\local\request\core_userlist_provider,
    \core_privacy\local\request\subsystem\provider {

    /**
     * Returns metadata.
     *
     * @param collection $collection The initialised collection to add items to.
     * @return collection A listing of user data stored through this system.
     */
    public static function get_metadata(collection $collection): collection {

        $collection->add_database_table('badge', [
            'usercreated' => 'privacy:metadata:badge:usercreated',
            'usermodified' => 'privacy:metadata:badge:usermodified',
            'timecreated' => 'privacy:metadata:badge:timecreated',
            'timemodified' => 'privacy:metadata:badge:timemodified',
        ], 'privacy:metadata:badge');

        $collection->add_database_table('badge_issued', [
            'userid' => 'privacy:metadata:issued:userid',
            'dateissued' => 'privacy:metadata:issued:dateissued',
            'dateexpire' => 'privacy:metadata:issued:dateexpire',
        ], 'privacy:metadata:issued');

        $collection->add_database_table('badge_criteria_met', [
            'userid' => 'privacy:metadata:criteriamet:userid',
            'datemet' => 'privacy:metadata:criteriamet:datemet',
        ], 'privacy:metadata:criteriamet');

        $collection->add_database_table('badge_manual_award', [
            'recipientid' => 'privacy:metadata:manualaward:recipientid',
            'issuerid' => 'privacy:metadata:manualaward:issuerid',
            'issuerrole' => 'privacy:metadata:manualaward:issuerrole',
            'datemet' => 'privacy:metadata:manualaward:datemet',
        ], 'privacy:metadata:manualaward');

        $collection->add_database_table('badge_backpack', [
            'userid' => 'privacy:metadata:backpack:userid',
            'email' => 'privacy:metadata:backpack:email',
            'externalbackpackid' => 'privacy:metadata:backpack:externalbackpackid',
            'backpackuid' => 'privacy:metadata:backpack:backpackuid',
            // The columns autosync and password are not used.
        ], 'privacy:metadata:backpack');

        $collection->add_external_location_link('backpacks', [
            'name' => 'privacy:metadata:external:backpacks:badge',
            'description' => 'privacy:metadata:external:backpacks:description',
            'image' => 'privacy:metadata:external:backpacks:image',
            'url' => 'privacy:metadata:external:backpacks:url',
            'issuer' => 'privacy:metadata:external:backpacks:issuer',
        ], 'privacy:metadata:external:backpacks');

        $collection->add_database_table('badge_backpack_oauth2', [
            'userid' => 'privacy:metadata:backpackoauth2:userid',
            'usermodified' => 'privacy:metadata:backpackoauth2:usermodified',
            'token' => 'privacy:metadata:backpackoauth2:token',
            'issuerid' => 'privacy:metadata:backpackoauth2:issuerid',
            'scope' => 'privacy:metadata:backpackoauth2:scope',
        ], 'privacy:metadata:backpackoauth2');

        return $collection;
    }

    /**
     * Get the list of contexts that contain user information for the specified user.
     *
     * @param int $userid The user to search.
     * @return contextlist $contextlist The contextlist containing the list of contexts used in this plugin.
     */
    public static function get_contexts_for_userid(int $userid): \core_privacy\local\request\contextlist {
        $contextlist = new \core_privacy\local\request\contextlist();

        // Find the modifications we made on badges (course & system).
        $sql = "
            SELECT ctx.id
              FROM {badge} b
              JOIN {context} ctx
                ON (b.type = :typecourse AND b.courseid = ctx.instanceid AND ctx.contextlevel = :courselevel)
                OR (b.type = :typesite AND ctx.id = :syscontextid)
             WHERE b.usermodified = :userid1
                OR b.usercreated = :userid2";
        $params = [
            'courselevel' => CONTEXT_COURSE,
            'syscontextid' => SYSCONTEXTID,
            'typecourse' => BADGE_TYPE_COURSE,
            'typesite' => BADGE_TYPE_SITE,
            'userid1' => $userid,
            'userid2' => $userid,
        ];
        $contextlist->add_from_sql($sql, $params);

        // Find where we've manually awarded a badge (recipient user context).
        $sql = "
            SELECT ctx.id
              FROM {badge_manual_award} bma
              JOIN {context} ctx
                ON ctx.instanceid = bma.recipientid
               AND ctx.contextlevel = :userlevel
             WHERE bma.issuerid = :userid";
        $params = [
            'userlevel' => CONTEXT_USER,
            'userid' => $userid,
        ];
        $contextlist->add_from_sql($sql, $params);

        // Now find where there is real user data (user context).
        $sql = "
            SELECT ctx.id
              FROM {context} ctx
         LEFT JOIN {badge_manual_award} bma
                ON bma.recipientid = ctx.instanceid
         LEFT JOIN {badge_issued} bi
                ON bi.userid = ctx.instanceid
         LEFT JOIN {badge_criteria_met} bcm
                ON bcm.userid = ctx.instanceid
         LEFT JOIN {badge_backpack} bb
                ON bb.userid = ctx.instanceid
             WHERE ctx.contextlevel = :userlevel
               AND ctx.instanceid = :userid
               AND (bma.id IS NOT NULL
                OR bi.id IS NOT NULL
                OR bcm.id IS NOT NULL
                OR bb.id IS NOT NULL)";
        $params = [
            'userlevel' => CONTEXT_USER,
            'userid' => $userid,
        ];
        $contextlist->add_from_sql($sql, $params);

        return $contextlist;
    }

    /**
     * Get the list of users within a specific context.
     *
     * @param userlist $userlist The userlist containing the list of users who have data in this context/plugin combination.
     */
    public static function get_users_in_context(userlist $userlist) {
        $context = $userlist->get_context();

        $allowedcontexts = [
            CONTEXT_COURSE,
            CONTEXT_SYSTEM,
            CONTEXT_USER
        ];

        if (!in_array($context->contextlevel, $allowedcontexts)) {
            return;
        }

        if ($context->contextlevel == CONTEXT_COURSE || $context->contextlevel == CONTEXT_SYSTEM) {
            // Find the modifications we made on badges (course & system).
            if ($context->contextlevel == CONTEXT_COURSE) {
                $extrawhere = 'AND b.courseid = :courseid';
                $params = [
                    'badgetype' => BADGE_TYPE_COURSE,
                    'courseid'  => $context->instanceid
                ];
            } else {
                $extrawhere = '';
                $params = ['badgetype' => BADGE_TYPE_SITE];
            }

            $sql = "SELECT b.usermodified, b.usercreated
                      FROM {badge} b
                     WHERE b.type = :badgetype
                           $extrawhere";

            $userlist->add_from_sql('usermodified', $sql, $params);
            $userlist->add_from_sql('usercreated', $sql, $params);
        }

        if ($context->contextlevel == CONTEXT_USER) {
            // Find where we've manually awarded a badge (recipient user context).
            $params = [
                'instanceid' => $context->instanceid
            ];

            $sql = "SELECT issuerid, recipientid
                      FROM {badge_manual_award}
                     WHERE recipientid = :instanceid";

            $userlist->add_from_sql('issuerid', $sql, $params);
            $userlist->add_from_sql('recipientid', $sql, $params);

            $sql = "SELECT userid
                      FROM {badge_issued}
                     WHERE userid = :instanceid";

            $userlist->add_from_sql('userid', $sql, $params);

            $sql = "SELECT userid
                      FROM {badge_criteria_met}
                     WHERE userid = :instanceid";

            $userlist->add_from_sql('userid', $sql, $params);

            $sql = "SELECT userid
                      FROM {badge_backpack}
                     WHERE userid = :instanceid";

            $userlist->add_from_sql('userid', $sql, $params);
        }
    }

    /**
     * Export all user data for the specified user, in the specified contexts.
     *
     * @param approved_contextlist $contextlist The approved contexts to export information for.
     */
    public static function export_user_data(approved_contextlist $contextlist) {
        global $DB;

        $userid = $contextlist->get_user()->id;
        $contexts = array_reduce($contextlist->get_contexts(), function($carry, $context) {
            $level = $context->contextlevel;
            if ($level == CONTEXT_USER || $level == CONTEXT_COURSE) {
                $carry[$level][] = $context->instanceid;
            } else if ($level == CONTEXT_SYSTEM) {
                $carry[$level] = SYSCONTEXTID;
            }
            return $carry;
        }, [
            CONTEXT_COURSE => [],
            CONTEXT_USER => [],
            CONTEXT_SYSTEM => null,
        ]);

        $path = [get_string('badges', 'core_badges')];
        $ctxfields = context_helper::get_preload_record_columns_sql('ctx');

        // Export the badges we've created or modified.
        if (!empty($contexts[CONTEXT_SYSTEM]) || !empty($contexts[CONTEXT_COURSE])) {
            $sqls = [];
            $params = [];

            if (!empty($contexts[CONTEXT_SYSTEM])) {
                $sqls[] = "b.type = :typesite";
                $params['typesite'] = BADGE_TYPE_SITE;
            }

            if (!empty($contexts[CONTEXT_COURSE])) {
                list($insql, $inparams) = $DB->get_in_or_equal($contexts[CONTEXT_COURSE], SQL_PARAMS_NAMED);
                $sqls[] = "(b.type = :typecourse AND b.courseid $insql)";
                $params = array_merge($params, ['typecourse' => BADGE_TYPE_COURSE], $inparams);
            }

            $sqlwhere = '(' . implode(' OR ', $sqls) . ')';
            $sql = "
                SELECT b.*, COALESCE(b.courseid, 0) AS normalisedcourseid
                  FROM {badge} b
                 WHERE (b.usermodified = :userid1 OR b.usercreated = :userid2)
                   AND $sqlwhere
              ORDER BY b.courseid, b.id";
            $params = array_merge($params, ['userid1' => $userid, 'userid2' => $userid]);
            $recordset = $DB->get_recordset_sql($sql, $params);
            static::recordset_loop_and_export($recordset, 'normalisedcourseid', [], function($carry, $record) use ($userid) {
                $carry[] = [
                    'name' => $record->name,
                    'created_on' => transform::datetime($record->timecreated),
                    'created_by_you' => transform::yesno($record->usercreated == $userid),
                    'modified_on' => transform::datetime($record->timemodified),
                    'modified_by_you' => transform::yesno($record->usermodified == $userid),
                ];
                return $carry;
            }, function($courseid, $data) use ($path) {
                $context = $courseid ? context_course::instance($courseid) : context_system::instance();
                writer::with_context($context)->export_data($path, (object) ['badges' => $data]);
            });
        }

        // Export the badges we've manually awarded.
        if (!empty($contexts[CONTEXT_USER])) {
            list($insql, $inparams) = $DB->get_in_or_equal($contexts[CONTEXT_USER], SQL_PARAMS_NAMED);
            $sql = "
                SELECT bma.id, bma.recipientid, bma.datemet, b.name, b.courseid,
                       r.id AS roleid,
                       r.name AS rolename,
                       r.shortname AS roleshortname,
                       r.archetype AS rolearchetype,
                       $ctxfields
                  FROM {badge_manual_award} bma
                  JOIN {badge} b
                    ON b.id = bma.badgeid
                  JOIN {role} r
                    ON r.id = bma.issuerrole
                  JOIN {context} ctx
                    ON (COALESCE(b.courseid, 0) > 0 AND ctx.instanceid = b.courseid AND ctx.contextlevel = :courselevel)
                    OR (COALESCE(b.courseid, 0) = 0 AND ctx.id = :syscontextid)
                 WHERE bma.recipientid $insql
                   AND bma.issuerid = :userid
              ORDER BY bma.recipientid, bma.id";
            $params = array_merge($inparams, [
                'courselevel' => CONTEXT_COURSE,
                'syscontextid' => SYSCONTEXTID,
                'userid' => $userid
            ]);
            $recordset = $DB->get_recordset_sql($sql, $params);
            static::recordset_loop_and_export($recordset, 'recipientid', [], function($carry, $record) use ($userid) {

                // The only reason we fetch the context and role is to format the name of the role, which could be
                // different to the standard name if the badge was created in a course.
                context_helper::preload_from_record($record);
                $context = $record->courseid ? context_course::instance($record->courseid) : context_system::instance();
                $role = (object) [
                    'id' => $record->roleid,
                    'name' => $record->rolename,
                    'shortname' => $record->roleshortname,
                    'archetype' => $record->rolearchetype,
                    // Mock those two fields as they do not matter.
                    'sortorder' => 0,
                    'description' => ''
                ];

                $carry[] = [
                    'name' => $record->name,
                    'issued_by_you' => transform::yesno(true),
                    'issued_on' => transform::datetime($record->datemet),
                    'issuer_role' => role_get_name($role, $context),
                ];
                return $carry;
            }, function($userid, $data) use ($path) {
                $context = context_user::instance($userid);
                writer::with_context($context)->export_related_data($path, 'manual_awards', (object) ['badges' => $data]);
            });
        }

        // Export our data.
        if (in_array($userid, $contexts[CONTEXT_USER])) {

            // Export the badges.
            $uniqueid = $DB->sql_concat_join("'-'", ['b.id', 'COALESCE(bc.id, 0)', 'COALESCE(bi.id, 0)',
                'COALESCE(bma.id, 0)', 'COALESCE(bcm.id, 0)', 'COALESCE(brb.id, 0)', 'COALESCE(ba.id, 0)']);
            $sql = "
                SELECT $uniqueid AS uniqueid, b.id,
                       bi.id AS biid, bi.dateissued, bi.dateexpire, bi.uniquehash,
                       bma.id AS bmaid, bma.datemet, bma.issuerid,
                       bcm.id AS bcmid,
                       c.fullname AS coursename,
                       be.id AS beid,
                       be.issuername AS beissuername,
                       be.issuerurl AS beissuerurl,
                       be.issueremail AS beissueremail,
                       be.claimid AS beclaimid,
                       be.claimcomment AS beclaimcomment,
                       be.dateissued AS bedateissued,
                       brb.id as rbid,
                       brb.badgeid as rbbadgeid,
                       brb.relatedbadgeid as rbrelatedbadgeid,
                       ba.id as baid,
                       ba.targetname as batargetname,
                       ba.targeturl as batargeturl,
                       ba.targetdescription as batargetdescription,
                       ba.targetframework as batargetframework,
                       ba.targetcode as batargetcode,
                       $ctxfields
                  FROM {badge} b
             LEFT JOIN {badge_issued} bi
                    ON bi.badgeid = b.id
                   AND bi.userid = :userid1
            LEFT JOIN {badge_related} brb
                    ON ( b.id = brb.badgeid OR b.id = brb.relatedbadgeid )
             LEFT JOIN {badge_alignment} ba
                    ON ( b.id = ba.badgeid )
             LEFT JOIN {badge_endorsement} be
                    ON be.badgeid = b.id
             LEFT JOIN {badge_manual_award} bma
                    ON bma.badgeid = b.id
                   AND bma.recipientid = :userid2
             LEFT JOIN {badge_criteria} bc
                    ON bc.badgeid = b.id
             LEFT JOIN {badge_criteria_met} bcm
                    ON bcm.critid = bc.id
                   AND bcm.userid = :userid3
             LEFT JOIN {course} c
                    ON c.id = b.courseid
                   AND b.type = :typecourse
             LEFT JOIN {context} ctx
                    ON ctx.instanceid = c.id
                   AND ctx.contextlevel = :courselevel
                 WHERE bi.id IS NOT NULL
                    OR bma.id IS NOT NULL
                    OR bcm.id IS NOT NULL
              ORDER BY b.id";
            $params = [
                'userid1' => $userid,
                'userid2' => $userid,
                'userid3' => $userid,
                'courselevel' => CONTEXT_COURSE,
                'typecourse' => BADGE_TYPE_COURSE,
            ];
            $recordset = $DB->get_recordset_sql($sql, $params);
            static::recordset_loop_and_export($recordset, 'id', null, function($carry, $record) use ($userid) {
                $badge = new badge($record->id);

                // Export details of the badge.
                if ($carry === null) {
                    $carry = [
                        'name' => $badge->name,
                        'version' => $badge->version,
                        'language' => $badge->language,
                        'imageauthorname' => $badge->imageauthorname,
                        'imageauthoremail' => $badge->imageauthoremail,
                        'imageauthorurl' => $badge->imageauthorurl,
                        'imagecaption' => $badge->imagecaption,
                        'issued' => null,
                        'manual_award' => null,
                        'criteria_met' => [],
                        'endorsement' => null,
                    ];

                    if ($badge->type == BADGE_TYPE_COURSE) {
                        context_helper::preload_from_record($record);
                        $carry['course'] = format_string($record->coursename, true, ['context' => $badge->get_context()]);
                    }

                    if (!empty($record->beid)) {
                        $carry['endorsement'] = [
                            'issuername' => $record->beissuername,
                            'issuerurl' => $record->beissuerurl,
                            'issueremail' => $record->beissueremail,
                            'claimid' => $record->beclaimid,
                            'claimcomment' => $record->beclaimcomment,
                            'dateissued' => $record->bedateissued ? transform::datetime($record->bedateissued) : null
                        ];
                    }

                    if (!empty($record->biid)) {
                        $carry['issued'] = [
                            'issued_on' => transform::datetime($record->dateissued),
                            'expires_on' => $record->dateexpire ? transform::datetime($record->dateexpire) : null,
                            'unique_hash' => $record->uniquehash,
                        ];
                    }

                    if (!empty($record->bmaid)) {
                        $carry['manual_award'] = [
                            'awarded_on' => transform::datetime($record->datemet),
                            'issuer' => transform::user($record->issuerid)
                        ];
                    }
                }
                if (!empty($record->rbid)) {
                    if (empty($carry['related_badge'])) {
                        $carry['related_badge'] = [];
                    }
                    $rbid = $record->rbbadgeid;
                    if ($rbid == $record->id) {
                        $rbid = $record->rbrelatedbadgeid;
                    }
                    $exists = false;
                    foreach ($carry['related_badge'] as $related) {
                        if ($related['badgeid'] == $rbid) {
                            $exists = true;
                            break;
                        }
                    }
                    if (!$exists) {
                        $relatedbadge = new badge($rbid);
                        $carry['related_badge'][] = [
                            'badgeid' => $rbid,
                            'badgename' => $relatedbadge->name
                        ];
                    }
                }

                if (!empty($record->baid)) {
                    if (empty($carry['alignment'])) {
                        $carry['alignment'] = [];
                    }
                    $exists = false;
                    $newalignment = [
                        'targetname' => $record->batargetname,
                        'targeturl' => $record->batargeturl,
                        'targetdescription' => $record->batargetdescription,
                        'targetframework' => $record->batargetframework,
                        'targetcode' => $record->batargetcode,
                    ];
                    foreach ($carry['alignment'] as $alignment) {
                        if ($alignment == $newalignment) {
                            $exists = true;
                            break;
                        }
                    }
                    if (!$exists) {
                        $carry['alignment'][] = $newalignment;
                    }
                }

                // Export the details of the criteria met.
                // We only do that once, when we find that a least one criteria was met.
                // This is heavily based on the logic present in core_badges_renderer::render_issued_badge.
                if (!empty($record->bcmid) && empty($carry['criteria_met'])) {

                    $agg = $badge->get_aggregation_methods();
                    $evidenceids = array_map(function($record) {
                        return $record->critid;
                    }, $badge->get_criteria_completions($userid));

                    $criteria = $badge->criteria;
                    unset($criteria[BADGE_CRITERIA_TYPE_OVERALL]);

                    $items = [];
                    foreach ($criteria as $type => $c) {
                        if (in_array($c->id, $evidenceids)) {
                            $details = $c->get_details(true);
                            if (count($c->params) == 1) {
                                $items[] = get_string('criteria_descr_single_' . $type , 'core_badges') . ' ' . $details;
                            } else {
                                $items[] = get_string('criteria_descr_' . $type , 'core_badges',
                                    core_text::strtoupper($agg[$badge->get_aggregation_method($type)])) . ' ' . $details;
                            }
                        }
                    }
                    $carry['criteria_met'] = $items;
                }
                return $carry;
            }, function($badgeid, $data) use ($path, $userid) {
                $path = array_merge($path, ["{$data['name']} ({$badgeid})"]);
                $writer = writer::with_context(context_user::instance($userid));
                $writer->export_data($path, (object) $data);
                $writer->export_area_files($path, 'badges', 'userbadge', $badgeid);
            });

            // Export the backpacks.
            $data = [];
            $recordset = $DB->get_recordset_select('badge_backpack', 'userid = :userid', ['userid' => $userid]);
            foreach ($recordset as $record) {
                $data[] = [
                    'email' => $record->email,
                    'externalbackpackid' => $record->externalbackpackid,
                    'uid' => $record->backpackuid
                ];
            }
            $recordset->close();
            if (!empty($data)) {
                writer::with_context(context_user::instance($userid))->export_related_data($path, 'backpacks',
                    (object) ['backpacks' => $data]);
            }
        }
    }

    /**
     * Delete all data for all users in the specified context.
     *
     * @param context $context The specific context to delete data for.
     */
    public static function delete_data_for_all_users_in_context(context $context) {
        // We cannot delete the course or system data as it is needed by the system.
        if ($context->contextlevel != CONTEXT_USER) {
            return;
        }

        // Delete all the user data.
        static::delete_user_data($context->instanceid);
    }

    /**
     * Delete multiple users within a single context.
     *
     * @param approved_userlist $userlist The approved context and user information to delete information for.
     */
    public static function delete_data_for_users(approved_userlist $userlist) {
        $context = $userlist->get_context();

        if (!in_array($context->instanceid, $userlist->get_userids())) {
            return;
        }

        if ($context->contextlevel == CONTEXT_USER) {
            // We can only delete our own data in the user context, nothing in course or system.
            static::delete_user_data($context->instanceid);
        }
    }

    /**
     * Delete all user data for the specified user, in the specified contexts.
     *
     * @param approved_contextlist $contextlist The approved contexts and user information to delete information for.
     */
    public static function delete_data_for_user(approved_contextlist $contextlist) {
        $userid = $contextlist->get_user()->id;
        foreach ($contextlist->get_contexts() as $context) {
            if ($context->contextlevel == CONTEXT_USER && $context->instanceid == $userid) {
                // We can only delete our own data in the user context, nothing in course or system.
                static::delete_user_data($userid);
                break;
            }
        }
    }

    /**
     * Delete all the data for a user.
     *
     * @param int $userid The user ID.
     * @return void
     */
    protected static function delete_user_data($userid) {
        global $DB;

        // Delete the stuff.
        $DB->delete_records('badge_manual_award', ['recipientid' => $userid]);
        $DB->delete_records('badge_criteria_met', ['userid' => $userid]);
        $DB->delete_records('badge_issued', ['userid' => $userid]);

        // Delete the backpacks and related stuff.
        $backpackids = $DB->get_fieldset_select('badge_backpack', 'id', 'userid = :userid', ['userid' => $userid]);
        if (!empty($backpackids)) {
            list($insql, $inparams) = $DB->get_in_or_equal($backpackids, SQL_PARAMS_NAMED);
            $DB->delete_records_select('badge_external', "backpackid $insql", $inparams);
            $DB->delete_records_select('badge_backpack', "id $insql", $inparams);
        }
    }

    /**
     * Loop and export from a recordset.
     *
     * @param \moodle_recordset $recordset The recordset.
     * @param string $splitkey The record key to determine when to export.
     * @param mixed $initial The initial data to reduce from.
     * @param callable $reducer The function to return the dataset, receives current dataset, and the current record.
     * @param callable $export The function to export the dataset, receives the last value from $splitkey and the dataset.
     * @return void
     */
    protected static function recordset_loop_and_export(\moodle_recordset $recordset, $splitkey, $initial,
            callable $reducer, callable $export) {

        $data = $initial;
        $lastid = null;

        foreach ($recordset as $record) {
            if ($lastid !== null && $record->{$splitkey} != $lastid) {
                $export($lastid, $data);
                $data = $initial;
            }
            $data = $reducer($data, $record);
            $lastid = $record->{$splitkey};
        }
        $recordset->close();

        if ($lastid !== null) {
            $export($lastid, $data);
        }
    }
}