Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.10% covered (warning)
79.10%
53 / 67
50.00% covered (danger)
50.00%
4 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
provider
79.10% covered (warning)
79.10%
53 / 67
50.00% covered (danger)
50.00%
4 / 8
23.65
0.00% covered (danger)
0.00%
0 / 1
 get_metadata
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 get_contexts_for_userid
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 get_users_in_context
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 export_user_data
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
6
 delete_data_for_all_users_in_context
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 delete_data_for_user
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 delete_data_for_users
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 delete_users
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
4.01
1<?php
2// This file is part of Moodle - https://moodle.org/
3//
4// Moodle is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8//
9// Moodle is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12// GNU General Public License for more details.
13//
14// You should have received a copy of the GNU General Public License
15// along with Moodle.  If not, see <https://www.gnu.org/licenses/>.
16
17namespace local_transcript\privacy;
18
19use core_privacy\local\metadata\collection;
20use core_privacy\local\request\approved_contextlist;
21use core_privacy\local\request\approved_userlist;
22use core_privacy\local\request\contextlist;
23use core_privacy\local\request\transform;
24use core_privacy\local\request\userlist;
25use core_privacy\local\request\writer;
26
27/**
28 * Privacy provider. Transcripts live in the student's user context.
29 *
30 * Deleting a student deletes their transcripts, so their codes stop verifying. Deleting the staff
31 * member who issued or revoked one only removes their id: the signature does not cover it, so the
32 * transcript still verifies.
33 *
34 * @package    local_transcript
35 * @copyright  2026 DevLMS <contact@devlms.com>
36 * @license    https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
37 */
38class provider implements
39    \core_privacy\local\metadata\provider,
40    \core_privacy\local\request\core_userlist_provider,
41    \core_privacy\local\request\plugin\provider {
42    /**
43     * Describe the stored data.
44     *
45     * @param collection $collection
46     * @return collection
47     */
48    public static function get_metadata(collection $collection): collection {
49        $collection->add_database_table('local_transcript', [
50            'userid' => 'privacy:metadata:transcript:userid',
51            'code' => 'privacy:metadata:transcript:code',
52            'payload' => 'privacy:metadata:transcript:payload',
53            'issuedby' => 'privacy:metadata:transcript:issuedby',
54            'timecreated' => 'privacy:metadata:transcript:timecreated',
55            'revokedby' => 'privacy:metadata:transcript:revokedby',
56            'revokedreason' => 'privacy:metadata:transcript:revokedreason',
57            'timerevoked' => 'privacy:metadata:transcript:timerevoked',
58        ], 'privacy:metadata:transcript');
59        return $collection;
60    }
61
62    /**
63     * User contexts of students the user has a transcript for, issued or revoked.
64     *
65     * @param int $userid
66     * @return contextlist
67     */
68    public static function get_contexts_for_userid(int $userid): contextlist {
69        $list = new contextlist();
70        $list->add_from_sql(
71            "SELECT ctx.id
72                               FROM {local_transcript} t
73                               JOIN {context} ctx ON ctx.instanceid = t.userid AND ctx.contextlevel = :lvl
74                              WHERE t.userid = :u1 OR t.issuedby = :u2 OR t.revokedby = :u3",
75            ['lvl' => CONTEXT_USER, 'u1' => $userid, 'u2' => $userid, 'u3' => $userid]
76        );
77        return $list;
78    }
79
80    /**
81     * The student and the staff who issued or revoked their transcripts.
82     *
83     * @param userlist $userlist
84     */
85    public static function get_users_in_context(userlist $userlist) {
86        $ctx = $userlist->get_context();
87        if (!$ctx instanceof \context_user) {
88            return;
89        }
90        foreach (['userid', 'issuedby', 'revokedby'] as $field) {
91            $userlist->add_from_sql(
92                $field,
93                "SELECT $field FROM {local_transcript} WHERE userid = :s AND $field > 0",
94                ['s' => $ctx->instanceid]
95            );
96        }
97    }
98
99    /**
100     * Export transcripts: in full for the student, the code and dates for staff.
101     *
102     * @param approved_contextlist $contextlist
103     */
104    public static function export_user_data(approved_contextlist $contextlist) {
105        global $DB;
106        $userid = (int) $contextlist->get_user()->id;
107        foreach ($contextlist->get_contexts() as $ctx) {
108            if (!$ctx instanceof \context_user) {
109                continue;
110            }
111            $rows = $DB->get_records_select(
112                'local_transcript',
113                'userid = :s AND (userid = :u1 OR issuedby = :u2 OR revokedby = :u3)',
114                ['s' => $ctx->instanceid, 'u1' => $userid, 'u2' => $userid, 'u3' => $userid],
115                'id'
116            );
117            foreach ($rows as $r) {
118                $data = (object) [
119                    'code' => $r->code,
120                    'issued' => transform::datetime($r->timecreated),
121                    'issuedbyyou' => transform::yesno($r->issuedby == $userid),
122                    'revoked' => $r->timerevoked ? transform::datetime($r->timerevoked) : '',
123                    'revokedreason' => (string) $r->revokedreason,
124                ];
125                if ($r->userid == $userid) {
126                    $payload = json_decode($r->payload);
127                    $data->student = $payload->student ?? null;
128                    $data->courses = $payload->courses ?? [];
129                }
130                writer::with_context($ctx)->export_data([get_string('pluginname', 'local_transcript'), $r->code], $data);
131            }
132        }
133    }
134
135    /**
136     * Delete everything in a student's user context.
137     *
138     * @param \context $context
139     */
140    public static function delete_data_for_all_users_in_context(\context $context) {
141        global $DB;
142        if ($context instanceof \context_user) {
143            $DB->delete_records('local_transcript', ['userid' => $context->instanceid]);
144        }
145    }
146
147    /**
148     * Delete one user's data in the given contexts.
149     *
150     * @param approved_contextlist $contextlist
151     */
152    public static function delete_data_for_user(approved_contextlist $contextlist) {
153        $userid = (int) $contextlist->get_user()->id;
154        foreach ($contextlist->get_contexts() as $ctx) {
155            self::delete_users($ctx, [$userid]);
156        }
157    }
158
159    /**
160     * Delete several users' data in one context.
161     *
162     * @param approved_userlist $userlist
163     */
164    public static function delete_data_for_users(approved_userlist $userlist) {
165        self::delete_users($userlist->get_context(), $userlist->get_userids());
166    }
167
168    /**
169     * Students lose their transcripts; staff lose only their id on them.
170     *
171     * @param \context $ctx
172     * @param int[] $userids
173     */
174    private static function delete_users(\context $ctx, array $userids): void {
175        global $DB;
176        if (!$ctx instanceof \context_user || !$userids) {
177            return;
178        }
179        if (in_array((int) $ctx->instanceid, array_map('intval', $userids), true)) {
180            $DB->delete_records('local_transcript', ['userid' => $ctx->instanceid]);
181            return;
182        }
183        [$in, $params] = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
184        $params['s'] = $ctx->instanceid;
185        $DB->execute("UPDATE {local_transcript} SET issuedby = 0 WHERE userid = :s AND issuedby $in", $params);
186        [$in, $params] = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
187        $params['s'] = $ctx->instanceid;
188        $DB->execute("UPDATE {local_transcript} SET revokedby = 0 WHERE userid = :s AND revokedby $in", $params);
189    }
190}