Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.70% covered (success)
97.70%
85 / 87
80.00% covered (warning)
80.00%
8 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
provider
97.70% covered (success)
97.70%
85 / 87
80.00% covered (warning)
80.00%
8 / 10
29
0.00% covered (danger)
0.00%
0 / 1
 get_metadata
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
1
 get_contexts_for_userid
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 get_users_in_context
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 export_user_data
96.67% covered (success)
96.67%
29 / 30
0.00% covered (danger)
0.00%
0 / 1
12
 delete_data_for_all_users_in_context
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 delete_data_for_user
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 delete_data_for_users
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 delete_user
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 delete_case
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 course_contexts
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
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_integritycase\privacy;
18
19use context;
20use context_course;
21use core_privacy\local\metadata\collection;
22use core_privacy\local\request\approved_contextlist;
23use core_privacy\local\request\approved_userlist;
24use core_privacy\local\request\contextlist;
25use core_privacy\local\request\transform;
26use core_privacy\local\request\userlist;
27use core_privacy\local\request\writer;
28use local_integritycase\local\case_manager;
29
30/**
31 * Privacy provider.
32 *
33 * A student's request covers whole cases about them, staff notes included. A staff member's request
34 * covers their own actions; deleting it keeps the case for the record and removes their name.
35 *
36 * @package    local_integritycase
37 * @copyright  2026 DevLMS <contact@devlms.com>
38 * @license    https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
39 */
40final class provider implements
41    \core_privacy\local\metadata\provider,
42    \core_privacy\local\request\core_userlist_provider,
43    \core_privacy\local\request\plugin\provider {
44    /**
45     * Describe the personal data stored.
46     *
47     * @param collection $collection
48     * @return collection
49     */
50    public static function get_metadata(collection $collection): collection {
51        $collection->add_database_table('local_integritycase', [
52            'studentid' => 'privacy:metadata:case:studentid',
53            'handlerid' => 'privacy:metadata:case:handlerid',
54            'createdby' => 'privacy:metadata:case:createdby',
55            'summary' => 'privacy:metadata:case:summary',
56            'stage' => 'privacy:metadata:case:stage',
57            'outcome' => 'privacy:metadata:case:outcome',
58            'timecreated' => 'privacy:metadata:case:timecreated',
59        ], 'privacy:metadata:case');
60        $collection->add_database_table('local_integritycase_event', [
61            'userid' => 'privacy:metadata:event:userid',
62            'note' => 'privacy:metadata:event:note',
63            'timecreated' => 'privacy:metadata:event:timecreated',
64        ], 'privacy:metadata:event');
65        $collection->add_subsystem_link('core_files', [], 'privacy:metadata:core_files');
66        $collection->add_subsystem_link('core_message', [], 'privacy:metadata:core_message');
67        return $collection;
68    }
69
70    /**
71     * Course contexts where the user is in a case: as student, handler, opener or actor.
72     *
73     * @param int $userid
74     * @return contextlist
75     */
76    public static function get_contexts_for_userid(int $userid): contextlist {
77        $sql = "SELECT ctx.id
78                  FROM {context} ctx
79                  JOIN {local_integritycase} c ON c.courseid = ctx.instanceid AND ctx.contextlevel = :level
80             LEFT JOIN {local_integritycase_event} e ON e.caseid = c.id
81                 WHERE c.studentid = :u1 OR c.handlerid = :u2 OR c.createdby = :u3 OR e.userid = :u4";
82        $list = new contextlist();
83        $list->add_from_sql($sql, ['level' => CONTEXT_COURSE, 'u1' => $userid, 'u2' => $userid, 'u3' => $userid,
84            'u4' => $userid]);
85        return $list;
86    }
87
88    /**
89     * Users with data in a course context.
90     *
91     * @param userlist $userlist
92     */
93    public static function get_users_in_context(userlist $userlist) {
94        $context = $userlist->get_context();
95        if (!$context instanceof context_course) {
96            return;
97        }
98        $params = ['courseid' => $context->instanceid];
99        foreach (['studentid', 'handlerid', 'createdby'] as $field) {
100            $userlist->add_from_sql($field, "SELECT $field FROM {local_integritycase} WHERE courseid = :courseid", $params);
101        }
102        $userlist->add_from_sql('userid', "SELECT e.userid
103                                              FROM {local_integritycase_event} e
104                                              JOIN {local_integritycase} c ON c.id = e.caseid
105                                             WHERE c.courseid = :courseid", $params);
106    }
107
108    /**
109     * Export the user's data, one folder per case.
110     *
111     * @param approved_contextlist $contextlist
112     */
113    public static function export_user_data(approved_contextlist $contextlist) {
114        global $DB;
115        $userid = (int) $contextlist->get_user()->id;
116        foreach (self::course_contexts($contextlist->get_contexts()) as $context) {
117            foreach ($DB->get_records('local_integritycase', ['courseid' => $context->instanceid]) as $case) {
118                $isstudent = (int) $case->studentid === $userid;
119                $history = [];
120                foreach (case_manager::events($case, false) as $event) {
121                    if ($isstudent || (int) $event->userid === $userid) {
122                        $history[] = (object) [
123                            'action' => get_string('action:' . $event->action, 'local_integritycase'),
124                            'stage' => $event->tostage ? case_manager::stage_name($event->tostage) : null,
125                            'note' => $event->note,
126                            'byyou' => transform::yesno((int) $event->userid === $userid),
127                            'time' => transform::datetime($event->timecreated),
128                        ];
129                    }
130                }
131                $involved = $isstudent || in_array($userid, [(int) $case->handlerid, (int) $case->createdby], true);
132                if (!$involved && !$history) {
133                    continue;
134                }
135                $data = (object) [
136                    'stage' => case_manager::stage_name($case->stage),
137                    'outcome' => $case->outcome ? get_string('outcome:' . $case->outcome, 'local_integritycase') : null,
138                    'youarethehandler' => transform::yesno((int) $case->handlerid === $userid),
139                    'youopenedit' => transform::yesno((int) $case->createdby === $userid),
140                    'timecreated' => transform::datetime($case->timecreated),
141                    'history' => $history,
142                ];
143                $path = [get_string('pluginname', 'local_integritycase'), $case->id];
144                if ($isstudent) {
145                    $data->summary = $case->summary;
146                    writer::with_context($context)->export_area_files($path, 'local_integritycase', 'evidence', $case->id);
147                }
148                writer::with_context($context)->export_data($path, $data);
149            }
150        }
151    }
152
153    /**
154     * Delete every case in a course context.
155     *
156     * @param context $context
157     */
158    public static function delete_data_for_all_users_in_context(context $context) {
159        global $DB;
160        if (!$context instanceof context_course) {
161            return;
162        }
163        foreach ($DB->get_fieldset_select('local_integritycase', 'id', 'courseid = ?', [$context->instanceid]) as $caseid) {
164            self::delete_case($context, (int) $caseid);
165        }
166    }
167
168    /**
169     * Delete one user's data in the approved contexts.
170     *
171     * @param approved_contextlist $contextlist
172     */
173    public static function delete_data_for_user(approved_contextlist $contextlist) {
174        foreach (self::course_contexts($contextlist->get_contexts()) as $context) {
175            self::delete_user($context, (int) $contextlist->get_user()->id);
176        }
177    }
178
179    /**
180     * Delete several users' data in one context.
181     *
182     * @param approved_userlist $userlist
183     */
184    public static function delete_data_for_users(approved_userlist $userlist) {
185        $context = $userlist->get_context();
186        if (!$context instanceof context_course) {
187            return;
188        }
189        foreach ($userlist->get_userids() as $userid) {
190            self::delete_user($context, (int) $userid);
191        }
192    }
193
194    /**
195     * Remove a student's cases, and a staff member's name from the rest.
196     *
197     * @param context_course $context
198     * @param int $userid
199     */
200    private static function delete_user(context_course $context, int $userid): void {
201        global $DB;
202        $courseid = $context->instanceid;
203        $own = $DB->get_fieldset_select('local_integritycase', 'id', 'courseid = ? AND studentid = ?', [$courseid, $userid]);
204        foreach ($own as $caseid) {
205            self::delete_case($context, (int) $caseid);
206        }
207        $DB->set_field('local_integritycase', 'handlerid', 0, ['courseid' => $courseid, 'handlerid' => $userid]);
208        $DB->set_field('local_integritycase', 'createdby', 0, ['courseid' => $courseid, 'createdby' => $userid]);
209        $DB->execute(
210            "UPDATE {local_integritycase_event}
211                         SET userid = 0
212                       WHERE userid = :userid
213                         AND caseid IN (SELECT id FROM {local_integritycase} WHERE courseid = :courseid)",
214            ['userid' => $userid, 'courseid' => $courseid]
215        );
216    }
217
218    /**
219     * Delete a case, its trail and its evidence.
220     *
221     * @param context_course $context
222     * @param int $caseid
223     */
224    private static function delete_case(context_course $context, int $caseid): void {
225        global $DB;
226        get_file_storage()->delete_area_files($context->id, 'local_integritycase', 'evidence', $caseid);
227        $DB->delete_records('local_integritycase_event', ['caseid' => $caseid]);
228        $DB->delete_records('local_integritycase', ['id' => $caseid]);
229    }
230
231    /**
232     * Keep only course contexts.
233     *
234     * @param context[] $contexts
235     * @return context_course[]
236     */
237    private static function course_contexts(array $contexts): array {
238        return array_filter($contexts, fn($c) => $c instanceof context_course);
239    }
240}