Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
63.00% covered (warning)
63.00%
63 / 100
50.00% covered (danger)
50.00%
4 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
provider
63.00% covered (warning)
63.00%
63 / 100
50.00% covered (danger)
50.00%
4 / 8
56.66
0.00% covered (danger)
0.00%
0 / 1
 get_metadata
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
2
 get_contexts_for_userid
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 get_users_in_context
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
4.13
 export_user_data
77.14% covered (warning)
77.14%
27 / 35
0.00% covered (danger)
0.00%
0 / 1
9.97
 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
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
5.12
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_finalgrades\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: submitted final grades about students, who submitted and reviewed them, and who made exports.
29 *
30 * Deleting a teacher's or approver's data keeps the grades without their id, because the grade is the
31 * student's record. Export files are copies of what was already sent to the student records system.
32 *
33 * @package    local_finalgrades
34 * @copyright  2026 DevLMS <contact@devlms.com>
35 * @license    https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
36 */
37class provider implements
38    \core_privacy\local\metadata\provider,
39    \core_privacy\local\request\core_userlist_provider,
40    \core_privacy\local\request\plugin\provider {
41    /**
42     * Describe the stored and sent data.
43     *
44     * @param collection $collection
45     * @return collection
46     */
47    public static function get_metadata(collection $collection): collection {
48        $collection->add_database_table('local_finalgrades_sub', [
49            'userid' => 'privacy:metadata:sub:userid',
50            'grade' => 'privacy:metadata:sub:grade',
51            'percentage' => 'privacy:metadata:sub:percentage',
52            'letter' => 'privacy:metadata:sub:letter',
53            'useridnumber' => 'privacy:metadata:sub:useridnumber',
54            'status' => 'privacy:metadata:sub:status',
55            'reason' => 'privacy:metadata:sub:reason',
56            'submittedby' => 'privacy:metadata:sub:submittedby',
57            'timesubmitted' => 'privacy:metadata:sub:timesubmitted',
58            'reviewedby' => 'privacy:metadata:sub:reviewedby',
59            'timereviewed' => 'privacy:metadata:sub:timereviewed',
60        ], 'privacy:metadata:sub');
61        $collection->add_database_table('local_finalgrades_export', [
62            'createdby' => 'privacy:metadata:export:createdby',
63            'timecreated' => 'privacy:metadata:export:timecreated',
64        ], 'privacy:metadata:export');
65        $collection->add_external_location_link('studentrecords', [
66            'useridnumber' => 'privacy:metadata:studentrecords:useridnumber',
67            'fullname' => 'privacy:metadata:studentrecords:fullname',
68            'grade' => 'privacy:metadata:studentrecords:grade',
69            'submittedby' => 'privacy:metadata:studentrecords:submittedby',
70        ], 'privacy:metadata:studentrecords');
71        $collection->add_subsystem_link('core_files', [], 'privacy:metadata:files');
72        return $collection;
73    }
74
75    /**
76     * Course contexts where the user is a student, submitter or reviewer; the system context for exports.
77     *
78     * @param int $userid
79     * @return contextlist
80     */
81    public static function get_contexts_for_userid(int $userid): contextlist {
82        $list = new contextlist();
83        $list->add_from_sql(
84            "SELECT ctx.id FROM {local_finalgrades_sub} s
85            JOIN {context} ctx ON ctx.instanceid = s.courseid AND ctx.contextlevel = :level
86            WHERE s.userid = :u1 OR s.submittedby = :u2 OR s.reviewedby = :u3",
87            ['level' => CONTEXT_COURSE, 'u1' => $userid, 'u2' => $userid, 'u3' => $userid]
88        );
89        $list->add_from_sql(
90            "SELECT ctx.id FROM {context} ctx
91            WHERE ctx.contextlevel = :level
92              AND EXISTS (SELECT 1 FROM {local_finalgrades_export} e WHERE e.createdby = :userid)",
93            ['level' => CONTEXT_SYSTEM, 'userid' => $userid]
94        );
95        return $list;
96    }
97
98    /**
99     * Users with data in a context.
100     *
101     * @param userlist $userlist
102     */
103    public static function get_users_in_context(userlist $userlist) {
104        $context = $userlist->get_context();
105        if ($context instanceof \context_course) {
106            foreach (['userid', 'submittedby', 'reviewedby'] as $column) {
107                $userlist->add_from_sql(
108                    $column,
109                    "SELECT $column FROM {local_finalgrades_sub} WHERE courseid = :courseid AND $column > 0",
110                    ['courseid' => $context->instanceid]
111                );
112            }
113        } else if ($context instanceof \context_system) {
114            $userlist->add_from_sql('createdby', 'SELECT createdby FROM {local_finalgrades_export} WHERE createdby > 0', []);
115        }
116    }
117
118    /**
119     * Export the user's grades as a student, and what they did as staff.
120     *
121     * @param approved_contextlist $contextlist
122     */
123    public static function export_user_data(approved_contextlist $contextlist) {
124        global $DB;
125        $userid = (int) $contextlist->get_user()->id;
126        $folder = [get_string('pluginname', 'local_finalgrades')];
127        foreach ($contextlist->get_contexts() as $context) {
128            if ($context instanceof \context_system) {
129                $exports = $DB->get_records('local_finalgrades_export', ['createdby' => $userid], 'id');
130                writer::with_context($context)->export_data($folder, (object) ['exports' => array_values(array_map(fn($e) => [
131                    'filename' => $e->filename,
132                    'rowcount' => (int) $e->rowcount,
133                    'timecreated' => transform::datetime($e->timecreated),
134                ], $exports))]);
135                continue;
136            }
137            if (!$context instanceof \context_course) {
138                continue;
139            }
140            $rows = $DB->get_records('local_finalgrades_sub', ['courseid' => $context->instanceid], 'id');
141            $grades = $actions = [];
142            foreach ($rows as $row) {
143                if ((int) $row->userid === $userid) {
144                    $grades[] = [
145                        'grade' => $row->grade,
146                        'percentage' => $row->percentage,
147                        'letter' => $row->letter,
148                        'idnumber' => $row->useridnumber,
149                        'status' => $row->status,
150                        'reason' => $row->reason,
151                        'timesubmitted' => transform::datetime($row->timesubmitted),
152                        'timereviewed' => $row->timereviewed ? transform::datetime($row->timereviewed) : null,
153                        'sent' => transform::yesno($row->exportid > 0),
154                    ];
155                }
156                if ((int) $row->submittedby === $userid) {
157                    $actions[] = ['action' => 'submitted', 'student' => (int) $row->userid,
158                        'time' => transform::datetime($row->timesubmitted)];
159                }
160                if ((int) $row->reviewedby === $userid) {
161                    $actions[] = ['action' => $row->status, 'student' => (int) $row->userid,
162                        'reason' => $row->reason, 'time' => transform::datetime($row->timereviewed)];
163                }
164            }
165            writer::with_context($context)->export_data($folder, (object) ['grades' => $grades, 'actions' => $actions]);
166        }
167    }
168
169    /**
170     * Delete everything in a course context.
171     *
172     * @param \context $context
173     */
174    public static function delete_data_for_all_users_in_context(\context $context) {
175        global $DB;
176        if ($context instanceof \context_course) {
177            $DB->delete_records('local_finalgrades_sub', ['courseid' => $context->instanceid]);
178        }
179    }
180
181    /**
182     * Delete one user's data in the approved contexts.
183     *
184     * @param approved_contextlist $contextlist
185     */
186    public static function delete_data_for_user(approved_contextlist $contextlist) {
187        $userid = (int) $contextlist->get_user()->id;
188        foreach ($contextlist->get_contexts() as $context) {
189            self::delete_users($context, [$userid]);
190        }
191    }
192
193    /**
194     * Delete several users' data in one context.
195     *
196     * @param approved_userlist $userlist
197     */
198    public static function delete_data_for_users(approved_userlist $userlist) {
199        self::delete_users($userlist->get_context(), array_map('intval', $userlist->get_userids()));
200    }
201
202    /**
203     * Students lose their rows; staff ids are cleared from rows and exports.
204     *
205     * @param \context $context
206     * @param int[] $userids
207     */
208    protected static function delete_users(\context $context, array $userids): void {
209        global $DB;
210        if (!$userids) {
211            return;
212        }
213        [$in, $params] = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
214        if ($context instanceof \context_system) {
215            $DB->set_field_select('local_finalgrades_export', 'createdby', 0, "createdby $in", $params);
216            return;
217        }
218        if (!$context instanceof \context_course) {
219            return;
220        }
221        $params['courseid'] = $context->instanceid;
222        $DB->delete_records_select('local_finalgrades_sub', "courseid = :courseid AND userid $in", $params);
223        foreach (['submittedby', 'reviewedby'] as $column) {
224            $DB->set_field_select('local_finalgrades_sub', $column, 0, "courseid = :courseid AND $column $in", $params);
225        }
226    }
227}