Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.80% covered (warning)
87.80%
72 / 82
33.33% covered (danger)
33.33%
3 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
provider
87.80% covered (warning)
87.80%
72 / 82
33.33% covered (danger)
33.33%
3 / 9
38.35
0.00% covered (danger)
0.00%
0 / 1
 get_metadata
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 user_where
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 get_contexts_for_userid
80.00% covered (warning)
80.00%
12 / 15
0.00% covered (danger)
0.00%
0 / 1
4.13
 get_users_in_context
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
5.02
 export_user_data
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
9.01
 delete_data_for_all_users_in_context
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
4.59
 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
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 delete_users
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
6.09
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
17/**
18 * Privacy provider.
19 *
20 * @package    local_atrisk
21 * @copyright  2026 DevLMS <contact@devlms.com>
22 * @license    https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23 */
24
25namespace local_atrisk\privacy;
26
27use core_privacy\local\metadata\collection;
28use core_privacy\local\request\approved_contextlist;
29use core_privacy\local\request\approved_userlist;
30use core_privacy\local\request\contextlist;
31use core_privacy\local\request\transform;
32use core_privacy\local\request\userlist;
33use core_privacy\local\request\writer;
34
35/**
36 * Privacy provider, driven by the TABLES map below.
37 *
38 * @package    local_atrisk
39 * @copyright  2026 DevLMS <contact@devlms.com>
40 * @license    https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
41 */
42class provider implements
43    \core_privacy\local\metadata\provider,
44    \core_privacy\local\request\core_userlist_provider,
45    \core_privacy\local\request\plugin\provider {
46    /**
47     * Each table with personal data. The owner's rows are deleted with the owner; actor fields (who
48     * changed or decided something) are set to 0 instead, so the owner's record stays whole.
49     * 'instance' is the field holding the context instance id, or null for the system context.
50     */
51    private const TABLES = [
52        'local_atrisk_flag' => [
53            'key' => 'flag',
54            'level' => CONTEXT_COURSE,
55            'instance' => 'courseid',
56            'owner' => 'userid',
57            'actors' => [],
58            'fields' => ['userid', 'courseid', 'inactive', 'missed', 'grade', 'timecreated'],
59        ],
60        'local_atrisk_contact' => [
61            'key' => 'contact',
62            'level' => CONTEXT_COURSE,
63            'instance' => 'courseid',
64            'owner' => 'userid',
65            'actors' => ['contactedby'],
66            'fields' => ['userid', 'courseid', 'contactedby', 'note', 'timecreated'],
67        ],
68    ];
69
70    /**
71     * Describe the stored data.
72     *
73     * @param collection $collection
74     * @return collection
75     */
76    public static function get_metadata(collection $collection): collection {
77        foreach (self::TABLES as $table => $t) {
78            $fields = [];
79            foreach ($t['fields'] as $f) {
80                $fields[$f] = "privacy:metadata:{$t['key']}:$f";
81            }
82            $collection->add_database_table($table, $fields, "privacy:metadata:{$t['key']}");
83        }
84        return $collection;
85    }
86
87    /**
88     * SQL matching rows where any user field is one of the given users.
89     *
90     * @param array $t table definition
91     * @param array $userids
92     * @return array [sql, params]
93     */
94    private static function user_where(array $t, array $userids): array {
95        global $DB;
96        $where = [];
97        $params = [];
98        foreach (array_merge([$t['owner']], $t['actors']) as $f) {
99            [$in, $p] = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
100            $where[] = "t.$f $in";
101            $params += $p;
102        }
103        return ['(' . implode(' OR ', $where) . ')', $params];
104    }
105
106    /**
107     * Contexts holding data about the user.
108     *
109     * @param int $userid
110     * @return contextlist
111     */
112    public static function get_contexts_for_userid(int $userid): contextlist {
113        global $DB;
114        $list = new contextlist();
115        foreach (self::TABLES as $table => $t) {
116            [$where, $params] = self::user_where($t, [$userid]);
117            if ($t['instance'] === null) {
118                if ($DB->record_exists_sql("SELECT 1 FROM {{$table}} t WHERE $where", $params)) {
119                    $list->add_system_context();
120                }
121                continue;
122            }
123            $params['lvl'] = $t['level'];
124            $list->add_from_sql(
125                "SELECT ctx.id FROM {{$table}} t
126                   JOIN {context} ctx ON ctx.instanceid = t.{$t['instance']} AND ctx.contextlevel = :lvl
127                  WHERE $where",
128                $params
129            );
130        }
131        return $list;
132    }
133
134    /**
135     * Users with data in a context.
136     *
137     * @param userlist $userlist
138     */
139    public static function get_users_in_context(userlist $userlist) {
140        $ctx = $userlist->get_context();
141        foreach (self::TABLES as $table => $t) {
142            if ($ctx->contextlevel != $t['level']) {
143                continue;
144            }
145            foreach (array_merge([$t['owner']], $t['actors']) as $f) {
146                $sql = "SELECT $f FROM {{$table}} WHERE $f > 0";
147                $params = [];
148                if ($t['instance'] !== null) {
149                    $sql .= " AND {$t['instance']} = :i";
150                    $params['i'] = $ctx->instanceid;
151                }
152                $userlist->add_from_sql($f, $sql, $params);
153            }
154        }
155    }
156
157    /**
158     * Export the user's rows, one file per row.
159     *
160     * @param approved_contextlist $contextlist
161     */
162    public static function export_user_data(approved_contextlist $contextlist) {
163        global $DB;
164        $userid = (int) $contextlist->get_user()->id;
165        foreach ($contextlist->get_contexts() as $ctx) {
166            foreach (self::TABLES as $table => $t) {
167                if ($ctx->contextlevel != $t['level']) {
168                    continue;
169                }
170                [$where, $params] = self::user_where($t, [$userid]);
171                if ($t['instance'] !== null) {
172                    $where .= " AND t.{$t['instance']} = :i";
173                    $params['i'] = $ctx->instanceid;
174                }
175                $rows = $DB->get_records_sql("SELECT t.* FROM {{$table}} t WHERE $where ORDER BY t.id", $params);
176                foreach ($rows as $r) {
177                    $data = [];
178                    foreach ($t['fields'] as $f) {
179                        $data[$f] = (strpos($f, 'time') === 0 && $r->$f) ? transform::datetime($r->$f) : $r->$f;
180                    }
181                    writer::with_context($ctx)->export_data(
182                        [get_string('pluginname', 'local_atrisk'), $t['key'], $r->id],
183                        (object) $data
184                    );
185                }
186            }
187        }
188    }
189
190    /**
191     * Delete everything in a context.
192     *
193     * @param \context $context
194     */
195    public static function delete_data_for_all_users_in_context(\context $context) {
196        global $DB;
197        foreach (self::TABLES as $table => $t) {
198            if ($context->contextlevel != $t['level']) {
199                continue;
200            }
201            if ($t['instance'] === null) {
202                $DB->delete_records($table);
203            } else {
204                $DB->delete_records($table, [$t['instance'] => $context->instanceid]);
205            }
206        }
207    }
208
209    /**
210     * Delete one user's data in the approved contexts.
211     *
212     * @param approved_contextlist $contextlist
213     */
214    public static function delete_data_for_user(approved_contextlist $contextlist) {
215        $userid = (int) $contextlist->get_user()->id;
216        foreach ($contextlist->get_contexts() as $ctx) {
217            self::delete_users($ctx, [$userid]);
218        }
219    }
220
221    /**
222     * Delete several users' data in one context.
223     *
224     * @param approved_userlist $userlist
225     */
226    public static function delete_data_for_users(approved_userlist $userlist) {
227        self::delete_users($userlist->get_context(), $userlist->get_userids());
228    }
229
230    /**
231     * Owners lose their rows; actors lose only their id on other people's rows.
232     *
233     * @param \context $ctx
234     * @param int[] $userids
235     */
236    private static function delete_users(\context $ctx, array $userids): void {
237        global $DB;
238        if (!$userids) {
239            return;
240        }
241        foreach (self::TABLES as $table => $t) {
242            if ($ctx->contextlevel != $t['level']) {
243                continue;
244            }
245            $scope = '';
246            $scopeparams = [];
247            if ($t['instance'] !== null) {
248                $scope = " AND {$t['instance']} = :i";
249                $scopeparams['i'] = $ctx->instanceid;
250            }
251            [$in, $params] = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
252            $DB->delete_records_select($table, "{$t['owner']} $in" . $scope, $params + $scopeparams);
253            foreach ($t['actors'] as $f) {
254                [$in, $params] = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
255                $DB->set_field_select($table, $f, 0, "$f $in" . $scope, $params + $scopeparams);
256            }
257        }
258    }
259}