Merge branch 'MDL-62169-master' of git://github.com/zig-moodle/moodle
authorAndrew Nicols <andrew@nicols.co.uk>
Mon, 7 May 2018 09:24:26 +0000 (17:24 +0800)
committerAndrew Nicols <andrew@nicols.co.uk>
Mon, 7 May 2018 09:24:26 +0000 (17:24 +0800)
353 files changed:
admin/classes/privacy/provider.php [new file with mode: 0644]
admin/registration/index.php
admin/registration/renderer.php [deleted file]
admin/renderer.php
admin/settings/development.php
admin/settings/privacy.php [deleted file]
admin/settings/security.php
admin/settings/top.php
admin/settings/users.php
admin/tool/dataprivacy/settings.php
admin/tool/log/classes/local/privacy/helper.php [new file with mode: 0644]
admin/tool/log/classes/local/privacy/logstore_provider.php [new file with mode: 0644]
admin/tool/log/classes/local/privacy/moodle_database_export_and_delete.php [new file with mode: 0644]
admin/tool/log/classes/privacy/provider.php [new file with mode: 0644]
admin/tool/log/lang/en/tool_log.php
admin/tool/log/store/database/classes/privacy/provider.php [new file with mode: 0644]
admin/tool/log/store/database/lang/en/logstore_database.php
admin/tool/log/store/database/tests/privacy_test.php [new file with mode: 0644]
admin/tool/log/store/legacy/classes/privacy/provider.php [new file with mode: 0644]
admin/tool/log/store/legacy/lang/en/logstore_legacy.php
admin/tool/log/store/legacy/tests/privacy_test.php [new file with mode: 0644]
admin/tool/log/store/standard/classes/privacy/provider.php [new file with mode: 0644]
admin/tool/log/store/standard/lang/en/logstore_standard.php
admin/tool/log/store/standard/tests/privacy_test.php [new file with mode: 0644]
admin/tool/log/tests/privacy_test.php [new file with mode: 0644]
admin/tool/lp/amd/build/planactions.min.js
admin/tool/lp/amd/src/planactions.js
admin/tool/messageinbound/classes/manager.php
admin/tool/messageinbound/classes/privacy/provider.php [new file with mode: 0644]
admin/tool/messageinbound/classes/task/cleanup_task.php
admin/tool/messageinbound/lang/en/tool_messageinbound.php
admin/tool/messageinbound/tests/manager_test.php [new file with mode: 0644]
admin/tool/messageinbound/tests/privacy_test.php [new file with mode: 0644]
admin/tool/policy/accept.php
admin/tool/policy/amd/build/acceptmodal.min.js
admin/tool/policy/amd/src/acceptmodal.js
admin/tool/policy/classes/api.php
admin/tool/policy/classes/form/accept_policy.php
admin/tool/policy/classes/output/acceptances.php
admin/tool/policy/classes/output/page_agreedocs.php
admin/tool/policy/classes/output/page_viewalldoc.php
admin/tool/policy/classes/output/user_agreement.php
admin/tool/policy/lang/en/tool_policy.php
admin/tool/policy/lib.php
admin/tool/policy/templates/acceptances.mustache
admin/tool/policy/templates/page_viewalldoc.mustache
admin/tool/policy/templates/user_agreement.mustache
admin/tool/policy/tests/api_test.php
admin/tool/policy/tests/behat/acceptances.feature
admin/tool/policy/tests/behat/managepolicies.feature
admin/tool/profiling/settings.php
analytics/classes/local/analyser/base.php
analytics/classes/privacy/provider.php [new file with mode: 0644]
analytics/tests/fixtures/test_site_users_analyser.php [new file with mode: 0644]
analytics/tests/fixtures/test_target_course_users.php [new file with mode: 0644]
analytics/tests/fixtures/test_target_site_users.php [new file with mode: 0644]
analytics/tests/privacy_test.php [new file with mode: 0644]
analytics/upgrade.txt [new file with mode: 0644]
auth/classes/privacy/provider.php [new file with mode: 0644]
auth/tests/privacy_test.php [new file with mode: 0644]
availability/classes/privacy/provider.php [new file with mode: 0644]
cache/classes/privacy/provider.php [new file with mode: 0644]
cache/locks/file/classes/privacy/provider.php [new file with mode: 0644]
cache/locks/file/lang/en/cachelock_file.php
cache/stores/apcu/classes/privacy/provider.php [new file with mode: 0644]
cache/stores/apcu/lang/en/cachestore_apcu.php
cache/stores/file/classes/privacy/provider.php [new file with mode: 0644]
cache/stores/file/lang/en/cachestore_file.php
cache/stores/memcache/classes/privacy/provider.php [new file with mode: 0644]
cache/stores/memcache/lang/en/cachestore_memcache.php
cache/stores/memcached/classes/privacy/provider.php [new file with mode: 0644]
cache/stores/memcached/lang/en/cachestore_memcached.php
cache/stores/mongodb/classes/privacy/provider.php [new file with mode: 0644]
cache/stores/mongodb/lang/en/cachestore_mongodb.php
cache/stores/redis/classes/privacy/provider.php [new file with mode: 0644]
cache/stores/redis/lang/en/cachestore_redis.php
cache/stores/session/classes/privacy/provider.php [new file with mode: 0644]
cache/stores/session/lang/en/cachestore_session.php
cache/stores/static/classes/privacy/provider.php [new file with mode: 0644]
cache/stores/static/lang/en/cachestore_static.php
calendar/classes/privacy/provider.php [new file with mode: 0644]
calendar/lib.php
calendar/tests/privacy_test.php [new file with mode: 0644]
comment/classes/privacy/provider.php
comment/tests/privacy_test.php
competency/classes/external/performance_helper.php
competency/classes/privacy/provider.php [new file with mode: 0644]
competency/tests/privacy_test.php [new file with mode: 0644]
completion/classes/privacy/provider.php [new file with mode: 0644]
completion/tests/fixtures/completion_creation.php [new file with mode: 0644]
completion/tests/privacy_test.php [new file with mode: 0644]
course/publish/backup.php
course/publish/index.php
course/publish/metadata.php
course/publish/renderer.php [deleted file]
course/renderer.php
course/tests/externallib_test.php
grade/classes/privacy/provider.php [new file with mode: 0644]
grade/tests/privacy_test.php [new file with mode: 0644]
lang/en/admin.php
lang/en/analytics.php
lang/en/auth.php
lang/en/availability.php
lang/en/cache.php
lang/en/calendar.php
lang/en/competency.php
lang/en/completion.php
lang/en/form.php
lang/en/grades.php
lang/en/message.php
lang/en/mnet.php
lang/en/question.php
lang/en/webservice.php
lib/amd/build/modal.min.js
lib/amd/src/modal.js
lib/classes/analytics/analyser/student_enrolments.php
lib/cronlib.php
lib/db/upgrade.php
lib/form/classes/privacy/provider.php [new file with mode: 0644]
lib/form/modgrade.php
lib/form/tests/privacy_provider_test.php [new file with mode: 0644]
lib/mlbackend/php/classes/privacy/provider.php [new file with mode: 0644]
lib/mlbackend/php/lang/en/mlbackend_php.php
lib/mlbackend/python/classes/privacy/provider.php [new file with mode: 0644]
lib/mlbackend/python/lang/en/mlbackend_python.php
lib/moodlelib.php
lib/questionlib.php
lib/templates/modal.mustache
lib/tests/moodlelib_test.php
lib/tests/weblib_format_text_test.php
lib/upgrade.txt
lib/xhprof/xhprof_moodle.php
media/player/swf/tests/player_test.php
message/classes/privacy/provider.php [new file with mode: 0644]
message/templates/message_area_message.mustache
message/tests/privacy_provider_test.php [new file with mode: 0644]
mnet/classes/privacy/provider.php [new file with mode: 0644]
mod/assign/feedback/editpdf/classes/document_services.php
mod/book/classes/privacy/provider.php [new file with mode: 0644]
mod/book/lang/en/book.php
mod/chat/styles.css
mod/data/classes/privacy/datafield_provider.php [new file with mode: 0644]
mod/data/classes/privacy/provider.php [new file with mode: 0644]
mod/data/field/checkbox/classes/privacy/provider.php [new file with mode: 0644]
mod/data/field/checkbox/lang/en/datafield_checkbox.php
mod/data/field/date/classes/privacy/provider.php [new file with mode: 0644]
mod/data/field/date/lang/en/datafield_date.php
mod/data/field/file/classes/privacy/provider.php [new file with mode: 0644]
mod/data/field/file/field.class.php
mod/data/field/file/lang/en/datafield_file.php
mod/data/field/latlong/classes/privacy/provider.php [new file with mode: 0644]
mod/data/field/latlong/lang/en/datafield_latlong.php
mod/data/field/menu/classes/privacy/provider.php [new file with mode: 0644]
mod/data/field/menu/lang/en/datafield_menu.php
mod/data/field/multimenu/classes/privacy/provider.php [new file with mode: 0644]
mod/data/field/multimenu/lang/en/datafield_multimenu.php
mod/data/field/number/classes/privacy/provider.php [new file with mode: 0644]
mod/data/field/number/lang/en/datafield_number.php
mod/data/field/picture/classes/privacy/provider.php [new file with mode: 0644]
mod/data/field/picture/field.class.php
mod/data/field/picture/lang/en/datafield_picture.php
mod/data/field/radiobutton/classes/privacy/provider.php [new file with mode: 0644]
mod/data/field/radiobutton/lang/en/datafield_radiobutton.php
mod/data/field/text/classes/privacy/provider.php [new file with mode: 0644]
mod/data/field/text/lang/en/datafield_text.php
mod/data/field/textarea/classes/privacy/provider.php [new file with mode: 0644]
mod/data/field/textarea/lang/en/datafield_textarea.php
mod/data/field/url/classes/privacy/provider.php [new file with mode: 0644]
mod/data/field/url/lang/en/datafield_url.php
mod/data/lang/en/data.php
mod/data/tests/generator/lib.php
mod/data/tests/generator_test.php
mod/data/tests/privacy_provider_test.php [new file with mode: 0644]
mod/lti/classes/privacy/provider.php [new file with mode: 0644]
mod/lti/lang/en/lti.php
mod/lti/service/gradebookservices/classes/privacy/provider.php [new file with mode: 0644]
mod/lti/service/gradebookservices/lang/en/ltiservice_gradebookservices.php
mod/lti/service/gradebookservices/tests/privacy_provider_test.php [new file with mode: 0644]
mod/lti/service/memberships/classes/privacy/provider.php [new file with mode: 0644]
mod/lti/service/memberships/lang/en/ltiservice_memberships.php
mod/lti/service/memberships/tests/privacy_provider_test.php [new file with mode: 0644]
mod/lti/service/profile/classes/privacy/provider.php [new file with mode: 0644]
mod/lti/service/profile/lang/en/ltiservice_profile.php
mod/lti/service/toolproxy/classes/privacy/provider.php [new file with mode: 0644]
mod/lti/service/toolproxy/lang/en/ltiservice_toolproxy.php
mod/lti/service/toolsettings/classes/privacy/provider.php [new file with mode: 0644]
mod/lti/service/toolsettings/lang/en/ltiservice_toolsettings.php
mod/lti/tests/privacy_provider_test.php [new file with mode: 0644]
mod/quiz/accessrule/delaybetweenattempts/classes/privacy/provider.php [new file with mode: 0644]
mod/quiz/accessrule/delaybetweenattempts/lang/en/quizaccess_delaybetweenattempts.php
mod/quiz/accessrule/ipaddress/classes/privacy/provider.php [new file with mode: 0644]
mod/quiz/accessrule/ipaddress/lang/en/quizaccess_ipaddress.php
mod/quiz/accessrule/numattempts/classes/privacy/provider.php [new file with mode: 0644]
mod/quiz/accessrule/numattempts/lang/en/quizaccess_numattempts.php
mod/quiz/accessrule/offlineattempts/classes/privacy/provider.php [new file with mode: 0644]
mod/quiz/accessrule/offlineattempts/lang/en/quizaccess_offlineattempts.php
mod/quiz/accessrule/openclosedate/classes/privacy/provider.php [new file with mode: 0644]
mod/quiz/accessrule/openclosedate/lang/en/quizaccess_openclosedate.php
mod/quiz/accessrule/password/classes/privacy/provider.php [new file with mode: 0644]
mod/quiz/accessrule/password/lang/en/quizaccess_password.php
mod/quiz/accessrule/safebrowser/classes/privacy/provider.php [new file with mode: 0644]
mod/quiz/accessrule/safebrowser/lang/en/quizaccess_safebrowser.php
mod/quiz/accessrule/securewindow/classes/privacy/provider.php [new file with mode: 0644]
mod/quiz/accessrule/securewindow/lang/en/quizaccess_securewindow.php
mod/quiz/accessrule/timelimit/classes/privacy/provider.php [new file with mode: 0644]
mod/quiz/accessrule/timelimit/lang/en/quizaccess_timelimit.php
mod/quiz/classes/output/edit_renderer.php
mod/quiz/classes/privacy/helper.php [new file with mode: 0644]
mod/quiz/classes/privacy/legacy_quizaccess_polyfill.php [new file with mode: 0644]
mod/quiz/classes/privacy/provider.php [new file with mode: 0644]
mod/quiz/classes/privacy/quizaccess_provider.php [new file with mode: 0644]
mod/quiz/classes/structure.php
mod/quiz/lang/en/quiz.php
mod/quiz/lib.php
mod/quiz/locallib.php
mod/quiz/report/grading/classes/privacy/provider.php [new file with mode: 0644]
mod/quiz/report/grading/lang/en/quiz_grading.php
mod/quiz/report/overview/classes/privacy/provider.php [new file with mode: 0644]
mod/quiz/report/overview/lang/en/quiz_overview.php
mod/quiz/report/overview/tests/privacy_provider_test.php [new file with mode: 0644]
mod/quiz/report/responses/classes/privacy/provider.php [new file with mode: 0644]
mod/quiz/report/responses/lang/en/quiz_responses.php
mod/quiz/report/responses/tests/privacy_provider_test.php [new file with mode: 0644]
mod/quiz/report/statistics/classes/privacy/provider.php [new file with mode: 0644]
mod/quiz/report/statistics/lang/en/quiz_statistics.php
mod/quiz/tests/locallib_test.php
mod/quiz/tests/privacy_legacy_quizaccess_polyfill_test.php [new file with mode: 0644]
mod/quiz/tests/privacy_provider_test.php [new file with mode: 0644]
mod/quiz/tests/structure_test.php
privacy/classes/local/request/helper.php
privacy/classes/local/request/moodle_content_writer.php
privacy/classes/tests/request/content_writer.php
privacy/tests/moodle_content_writer_test.php
privacy/tests/writer_test.php
question/behaviour/adaptive/classes/privacy/provider.php [new file with mode: 0644]
question/behaviour/adaptive/lang/en/qbehaviour_adaptive.php
question/behaviour/adaptivenopenalty/classes/privacy/provider.php [new file with mode: 0644]
question/behaviour/adaptivenopenalty/lang/en/qbehaviour_adaptivenopenalty.php
question/behaviour/deferredcbm/classes/privacy/provider.php [new file with mode: 0644]
question/behaviour/deferredcbm/lang/en/qbehaviour_deferredcbm.php
question/behaviour/deferredfeedback/classes/privacy/provider.php [new file with mode: 0644]
question/behaviour/deferredfeedback/lang/en/qbehaviour_deferredfeedback.php
question/behaviour/immediatecbm/classes/privacy/provider.php [new file with mode: 0644]
question/behaviour/immediatecbm/lang/en/qbehaviour_immediatecbm.php
question/behaviour/immediatefeedback/classes/privacy/provider.php [new file with mode: 0644]
question/behaviour/immediatefeedback/lang/en/qbehaviour_immediatefeedback.php
question/behaviour/informationitem/classes/privacy/provider.php [new file with mode: 0644]
question/behaviour/informationitem/lang/en/qbehaviour_informationitem.php
question/behaviour/interactive/classes/privacy/provider.php [new file with mode: 0644]
question/behaviour/interactive/lang/en/qbehaviour_interactive.php
question/behaviour/interactivecountback/classes/privacy/provider.php [new file with mode: 0644]
question/behaviour/interactivecountback/lang/en/qbehaviour_interactivecountback.php
question/behaviour/manualgraded/classes/privacy/provider.php [new file with mode: 0644]
question/behaviour/manualgraded/lang/en/qbehaviour_manualgraded.php
question/behaviour/missing/classes/privacy/provider.php [new file with mode: 0644]
question/behaviour/missing/lang/en/qbehaviour_missing.php
question/classes/privacy/provider.php [new file with mode: 0644]
question/format.php
question/format/aiken/classes/privacy/provider.php [new file with mode: 0644]
question/format/aiken/lang/en/qformat_aiken.php
question/format/blackboard_six/classes/privacy/provider.php [new file with mode: 0644]
question/format/blackboard_six/lang/en/qformat_blackboard_six.php
question/format/examview/classes/privacy/provider.php [new file with mode: 0644]
question/format/examview/lang/en/qformat_examview.php
question/format/gift/classes/privacy/provider.php [new file with mode: 0644]
question/format/gift/lang/en/qformat_gift.php
question/format/missingword/classes/privacy/provider.php [new file with mode: 0644]
question/format/missingword/lang/en/qformat_missingword.php
question/format/multianswer/classes/privacy/provider.php [new file with mode: 0644]
question/format/multianswer/lang/en/qformat_multianswer.php
question/format/webct/classes/privacy/provider.php [new file with mode: 0644]
question/format/webct/lang/en/qformat_webct.php
question/format/xhtml/classes/privacy/provider.php [new file with mode: 0644]
question/format/xhtml/lang/en/qformat_xhtml.php
question/format/xml/classes/privacy/provider.php [new file with mode: 0644]
question/format/xml/lang/en/qformat_xml.php
question/tests/generator/lib.php
question/tests/privacy_helper.php [new file with mode: 0644]
question/tests/privacy_provider_test.php [new file with mode: 0644]
question/type/calculated/classes/privacy/provider.php [new file with mode: 0644]
question/type/calculated/lang/en/qtype_calculated.php
question/type/calculatedmulti/classes/privacy/provider.php [new file with mode: 0644]
question/type/calculatedmulti/lang/en/qtype_calculatedmulti.php
question/type/calculatedsimple/classes/privacy/provider.php [new file with mode: 0644]
question/type/calculatedsimple/lang/en/qtype_calculatedsimple.php
question/type/ddimageortext/classes/privacy/provider.php [new file with mode: 0644]
question/type/ddimageortext/lang/en/qtype_ddimageortext.php
question/type/ddmarker/classes/privacy/provider.php [new file with mode: 0644]
question/type/ddmarker/lang/en/qtype_ddmarker.php
question/type/ddwtos/classes/privacy/provider.php [new file with mode: 0644]
question/type/ddwtos/lang/en/qtype_ddwtos.php
question/type/description/classes/privacy/provider.php [new file with mode: 0644]
question/type/description/lang/en/qtype_description.php
question/type/essay/classes/privacy/provider.php [new file with mode: 0644]
question/type/essay/lang/en/qtype_essay.php
question/type/gapselect/classes/privacy/provider.php [new file with mode: 0644]
question/type/gapselect/lang/en/qtype_gapselect.php
question/type/gapselect/renderer.php
question/type/gapselect/rendererbase.php
question/type/match/classes/privacy/provider.php [new file with mode: 0644]
question/type/match/lang/en/qtype_match.php
question/type/missingtype/classes/privacy/provider.php [new file with mode: 0644]
question/type/missingtype/lang/en/qtype_missingtype.php
question/type/multianswer/classes/privacy/provider.php [new file with mode: 0644]
question/type/multianswer/lang/en/qtype_multianswer.php
question/type/multichoice/classes/privacy/provider.php [new file with mode: 0644]
question/type/multichoice/lang/en/qtype_multichoice.php
question/type/numerical/classes/privacy/provider.php [new file with mode: 0644]
question/type/numerical/lang/en/qtype_numerical.php
question/type/random/classes/privacy/provider.php [new file with mode: 0644]
question/type/random/lang/en/qtype_random.php
question/type/randomsamatch/classes/privacy/provider.php [new file with mode: 0644]
question/type/randomsamatch/lang/en/qtype_randomsamatch.php
question/type/shortanswer/classes/privacy/provider.php [new file with mode: 0644]
question/type/shortanswer/lang/en/qtype_shortanswer.php
question/type/truefalse/classes/privacy/provider.php [new file with mode: 0644]
question/type/truefalse/lang/en/qtype_truefalse.php
question/type/upgrade.txt
question/upgrade.txt
rating/classes/privacy/provider.php
rating/tests/privacy_provider_test.php
tag/classes/privacy/provider.php
tag/tests/privacy_test.php
theme/boost/scss/moodle/blocks.scss
theme/boost/scss/moodle/bs4alphacompat.scss
theme/boost/scss/moodle/chat.scss
theme/boost/scss/moodle/core.scss
theme/boost/scss/moodle/forms.scss
theme/boost/scss/moodle/message.scss
theme/boost/scss/moodle/modules.scss
theme/boost/scss/moodle/undo.scss
theme/boost/templates/core/custom_menu_item.mustache
theme/boost/templates/core/filemanager_modal_generallayout.mustache
theme/boost/templates/core/modal.mustache
theme/boost/tests/behat/behat_theme_boost_behat_repository_upload.php
theme/bootstrapbase/less/moodle/undo.less
theme/bootstrapbase/style/moodle.css
user/profile/field/checkbox/classes/privacy/provider.php
user/profile/field/datetime/classes/privacy/provider.php [new file with mode: 0644]
user/profile/field/datetime/lang/en/profilefield_datetime.php
user/profile/field/datetime/tests/privacy_test.php [new file with mode: 0644]
user/profile/field/menu/classes/privacy/provider.php [new file with mode: 0644]
user/profile/field/menu/lang/en/profilefield_menu.php
user/profile/field/menu/tests/privacy_test.php [new file with mode: 0644]
user/profile/field/text/classes/privacy/provider.php [new file with mode: 0644]
user/profile/field/text/lang/en/profilefield_text.php
user/profile/field/text/tests/privacy_test.php [new file with mode: 0644]
user/profile/field/textarea/classes/privacy/provider.php [new file with mode: 0644]
user/profile/field/textarea/lang/en/profilefield_textarea.php
user/profile/field/textarea/tests/privacy_test.php [new file with mode: 0644]
version.php
webservice/classes/privacy/provider.php [new file with mode: 0644]
webservice/tests/privacy_test.php [new file with mode: 0644]

diff --git a/admin/classes/privacy/provider.php b/admin/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..118308d
--- /dev/null
@@ -0,0 +1,41 @@
+<?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/>.
+/**
+ * Privacy Subsystem implementation for core_admin.
+ *
+ * @package    core_admin
+ * @copyright  2018 Carlos Escobedo <carlos@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_admin\privacy;
+defined('MOODLE_INTERNAL') || die();
+/**
+ * Privacy Subsystem for core_admin implementing null_provider.
+ *
+ * @copyright  2018 Carlos Escobedo <carlos@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class provider implements \core_privacy\local\metadata\null_provider {
+    /**
+     * Get the language string identifier with the component's language
+     * file to explain why this plugin stores no data.
+     *
+     * @return  string
+     */
+    public static function get_reason() : string {
+        return 'privacy:metadata';
+    }
+}
index dee1ba0..57d65a3 100644 (file)
@@ -110,7 +110,7 @@ if (\core\hub\registration::is_registered()) {
     echo $OUTPUT->heading(get_string('registerwithmoodleorg', 'admin'));
 }
 
-$renderer = $PAGE->get_renderer('core', 'register');
+$renderer = $PAGE->get_renderer('core', 'admin');
 echo $renderer->moodleorg_registration_message();
 
 $siteregistrationform->display();
diff --git a/admin/registration/renderer.php b/admin/registration/renderer.php
deleted file mode 100644 (file)
index b7414b4..0000000
+++ /dev/null
@@ -1,41 +0,0 @@
-<?php
-
-///////////////////////////////////////////////////////////////////////////
-//                                                                       //
-// This file is part of Moodle - http://moodle.org/                      //
-// Moodle - Modular Object-Oriented Dynamic Learning Environment         //
-//                                                                       //
-// 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/>.       //
-//                                                                       //
-///////////////////////////////////////////////////////////////////////////
-
-/**
- * Registration renderer.
- * @package   moodle
- * @subpackage registration
- * @copyright 2010 Moodle Pty Ltd (http://moodle.com)
- * @author    Jerome Mouneyrac
- * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-class core_register_renderer extends plugin_renderer_base {
-
-    /**
-     * Display message about the benefits of registering on Moodle.org
-     *
-     * @return string
-     */
-    public function moodleorg_registration_message() {
-        return format_text(get_string('registermoodlenet', 'admin'), FORMAT_HTML, ['noclean' => true]);
-    }
-}
index dbd77f6..f22c6a2 100644 (file)
@@ -2084,4 +2084,13 @@ class core_admin_renderer extends plugin_renderer_base {
             return $this->warning(get_string('legacylogginginuse'));
         }
     }
+
+    /**
+     * Display message about the benefits of registering on Moodle.org
+     *
+     * @return string
+     */
+    public function moodleorg_registration_message() {
+        return format_text(get_string('registermoodlenet', 'admin'), FORMAT_HTML, ['noclean' => true]);
+    }
 }
index 41d779f..0d42707 100644 (file)
@@ -30,7 +30,9 @@ if ($hassiteconfig) { // speedup for non-admins, add all caps used on this page
     $ADMIN->add('development', $temp);
 
     // "Profiling" settingpage (conditionally if the 'xhprof' extension is available only).
-    $xhprofenabled = extension_loaded('xhprof') || extension_loaded('tideways');
+    $xhprofenabled = extension_loaded('tideways_xhprof');
+    $xhprofenabled = $xhprofenabled || extension_loaded('tideways');
+    $xhprofenabled = $xhprofenabled || extension_loaded('xhprof');
     $temp = new admin_settingpage('profiling', new lang_string('profiling', 'admin'), 'moodle/site:config', !$xhprofenabled);
     // Main profiling switch.
     $temp->add(new admin_setting_configcheckbox('profilingenabled', new lang_string('profilingenabled', 'admin'), new lang_string('profilingenabled_help', 'admin'), false));
diff --git a/admin/settings/privacy.php b/admin/settings/privacy.php
deleted file mode 100644 (file)
index 87f8c52..0000000
+++ /dev/null
@@ -1,80 +0,0 @@
-<?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/>.
-
-/**
- * Adds privacy and policies links to admin tree.
- *
- * @package   core_privacy
- * @copyright 2018 Marina Glancy
- * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-
-defined('MOODLE_INTERNAL') || die();
-
-if ($hassiteconfig) {
-    // Privacy settings.
-    $temp = new admin_settingpage('privacysettings', new lang_string('privacysettings', 'admin'));
-
-    $options = array(
-        0 => get_string('no'),
-        1 => get_string('yes')
-    );
-    $url = new moodle_url('/admin/settings.php?section=supportcontact');
-    $url = $url->out();
-    $setting = new admin_setting_configselect('agedigitalconsentverification',
-        new lang_string('agedigitalconsentverification', 'admin'),
-        new lang_string('agedigitalconsentverification_desc', 'admin', $url), 0, $options);
-    $setting->set_force_ltr(true);
-    $temp->add($setting);
-
-    $setting = new admin_setting_agedigitalconsentmap('agedigitalconsentmap',
-        new lang_string('ageofdigitalconsentmap', 'admin'),
-        new lang_string('ageofdigitalconsentmap_desc', 'admin'),
-        // See {@link https://gdpr-info.eu/art-8-gdpr/}.
-        implode(PHP_EOL, [
-            '*, 16',
-            'AT, 14',
-            'CZ, 13',
-            'DE, 14',
-            'DK, 13',
-            'ES, 13',
-            'FI, 15',
-            'GB, 13',
-            'HU, 14',
-            'IE, 13',
-            'LT, 16',
-            'LU, 16',
-            'NL, 16',
-            'PL, 13',
-            'SE, 13',
-        ]),
-        PARAM_RAW
-    );
-    $temp->add($setting);
-
-    $ADMIN->add('privacy', $temp);
-
-    // Policy settings.
-    $temp = new admin_settingpage('policysettings', new lang_string('policysettings', 'admin'));
-    $temp->add(new admin_settings_sitepolicy_handler_select('sitepolicyhandler', new lang_string('sitepolicyhandler', 'core_admin'),
-        new lang_string('sitepolicyhandler_desc', 'core_admin')));
-    $temp->add(new admin_setting_configtext('sitepolicy', new lang_string('sitepolicy', 'core_admin'),
-        new lang_string('sitepolicy_help', 'core_admin'), '', PARAM_RAW));
-    $temp->add(new admin_setting_configtext('sitepolicyguest', new lang_string('sitepolicyguest', 'core_admin'),
-        new lang_string('sitepolicyguest_help', 'core_admin'), (isset($CFG->sitepolicy) ? $CFG->sitepolicy : ''), PARAM_RAW));
-
-    $ADMIN->add('privacy', $temp);
-}
index b6a9f89..3580934 100644 (file)
@@ -44,7 +44,7 @@ if ($hassiteconfig) { // speedup for non-admins, add all caps used on this page
                 new lang_string('configuserquota', 'admin', $params), $defaultuserquota, PARAM_INT, 30));
 
     $temp->add(new admin_setting_configcheckbox('forceclean', new lang_string('forceclean', 'core_admin'),
-        new lang_string('forceclean_desc', 'core_admin'), 0));
+        new lang_string('forceclean_desc', 'core_admin'), 1));
 
     $temp->add(new admin_setting_configcheckbox('allowobjectembed', new lang_string('allowobjectembed', 'admin'), new lang_string('configallowobjectembed', 'admin'), 0));
     $temp->add(new admin_setting_configcheckbox('enabletrusttext', new lang_string('enabletrusttext', 'admin'), new lang_string('configenabletrusttext', 'admin'), 0));
index a049ce9..17621cb 100644 (file)
@@ -29,7 +29,6 @@ $ADMIN->add('root', new admin_category('badges', new lang_string('badges'), empt
 $ADMIN->add('root', new admin_category('location', new lang_string('location','admin')));
 $ADMIN->add('root', new admin_category('language', new lang_string('language')));
 $ADMIN->add('root', new admin_category('modules', new lang_string('plugins', 'admin')));
-$ADMIN->add('root', new admin_category('privacy', new lang_string('privacyandpolicies', 'admin')));
 $ADMIN->add('root', new admin_category('security', new lang_string('security','admin')));
 $ADMIN->add('root', new admin_category('appearance', new lang_string('appearance','admin')));
 $ADMIN->add('root', new admin_category('frontpage', new lang_string('frontpage','admin')));
index d444961..465a9d7 100644 (file)
@@ -4,6 +4,7 @@
 
 $ADMIN->add('users', new admin_category('accounts', new lang_string('accounts', 'admin')));
 $ADMIN->add('users', new admin_category('roles', new lang_string('permissions', 'role')));
+$ADMIN->add('users', new admin_category('privacy', new lang_string('privacyandpolicies', 'admin')));
 
 if ($hassiteconfig
  or has_capability('moodle/user:create', $systemcontext)
@@ -210,3 +211,58 @@ if ($hassiteconfig
     $ADMIN->add('roles', new admin_externalpage('checkpermissions', new lang_string('checkglobalpermissions', 'role'), "$CFG->wwwroot/$CFG->admin/roles/check.php?contextid=".$systemcontext->id, array('moodle/role:assign', 'moodle/role:safeoverride', 'moodle/role:override', 'moodle/role:manage')));
 
 } // end of speedup
+
+// Privacy settings.
+if ($hassiteconfig) {
+    $temp = new admin_settingpage('privacysettings', new lang_string('privacysettings', 'admin'));
+
+    $options = array(
+        0 => get_string('no'),
+        1 => get_string('yes')
+    );
+    $url = new moodle_url('/admin/settings.php?section=supportcontact');
+    $url = $url->out();
+    $setting = new admin_setting_configselect('agedigitalconsentverification',
+        new lang_string('agedigitalconsentverification', 'admin'),
+        new lang_string('agedigitalconsentverification_desc', 'admin', $url), 0, $options);
+    $setting->set_force_ltr(true);
+    $temp->add($setting);
+
+    $setting = new admin_setting_agedigitalconsentmap('agedigitalconsentmap',
+        new lang_string('ageofdigitalconsentmap', 'admin'),
+        new lang_string('ageofdigitalconsentmap_desc', 'admin'),
+        // See {@link https://gdpr-info.eu/art-8-gdpr/}.
+        implode(PHP_EOL, [
+            '*, 16',
+            'AT, 14',
+            'CZ, 13',
+            'DE, 14',
+            'DK, 13',
+            'ES, 13',
+            'FI, 15',
+            'GB, 13',
+            'HU, 14',
+            'IE, 13',
+            'LT, 16',
+            'LU, 16',
+            'NL, 16',
+            'PL, 13',
+            'SE, 13',
+        ]),
+        PARAM_RAW
+    );
+    $temp->add($setting);
+
+    $ADMIN->add('privacy', $temp);
+
+    // Policy settings.
+    $temp = new admin_settingpage('policysettings', new lang_string('policysettings', 'admin'));
+    $temp->add(new admin_settings_sitepolicy_handler_select('sitepolicyhandler', new lang_string('sitepolicyhandler', 'core_admin'),
+        new lang_string('sitepolicyhandler_desc', 'core_admin')));
+    $temp->add(new admin_setting_configtext('sitepolicy', new lang_string('sitepolicy', 'core_admin'),
+        new lang_string('sitepolicy_help', 'core_admin'), '', PARAM_RAW));
+    $temp->add(new admin_setting_configtext('sitepolicyguest', new lang_string('sitepolicyguest', 'core_admin'),
+        new lang_string('sitepolicyguest_help', 'core_admin'), (isset($CFG->sitepolicy) ? $CFG->sitepolicy : ''), PARAM_RAW));
+
+    $ADMIN->add('privacy', $temp);
+}
\ No newline at end of file
index 3f28b00..f04fc10 100644 (file)
@@ -49,7 +49,7 @@ if ($hassiteconfig) {
             }
         }
         if (!empty($roles)) {
-            $privacysettings->add(new admin_setting_configmultiselect('tool_dataprivacy/dporoles',
+            $privacysettings->add(new admin_setting_configmulticheckbox('tool_dataprivacy/dporoles',
                     new lang_string('dporolemapping', 'tool_dataprivacy'),
                     new lang_string('dporolemapping_desc', 'tool_dataprivacy'), null, $roles)
             );
diff --git a/admin/tool/log/classes/local/privacy/helper.php b/admin/tool/log/classes/local/privacy/helper.php
new file mode 100644 (file)
index 0000000..4aa17d1
--- /dev/null
@@ -0,0 +1,148 @@
+<?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/>.
+
+/**
+ * Privacy helper.
+ *
+ * @package    tool_log
+ * @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 tool_log\local\privacy;
+defined('MOODLE_INTERNAL') || die();
+
+use core_privacy\local\request\transform;
+
+/**
+ * Privacy helper class.
+ *
+ * @package    tool_log
+ * @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 helper {
+
+    /**
+     * Returns an event from a standard record.
+     *
+     * @see \logstore_standard\log\store::get_log_event()
+     * @param object $data Log data.
+     * @return \core\event\base
+     */
+    protected static function restore_event_from_standard_record($data) {
+        $extra = ['origin' => $data->origin, 'ip' => $data->ip, 'realuserid' => $data->realuserid];
+        $data = (array) $data;
+        $id = $data['id'];
+        $data['other'] = unserialize($data['other']);
+        if ($data['other'] === false) {
+            $data['other'] = [];
+        }
+        unset($data['origin']);
+        unset($data['ip']);
+        unset($data['realuserid']);
+        unset($data['id']);
+
+        if (!$event = \core\event\base::restore($data, $extra)) {
+            return null;
+        }
+
+        return $event;
+    }
+
+    /**
+     * Transform a standard log record for a user.
+     *
+     * @param object $record The record.
+     * @param int $userid The user ID.
+     * @return array
+     */
+    public static function transform_standard_log_record_for_userid($record, $userid) {
+
+        // Restore the event to try to get the name, description and other field.
+        $restoredevent = static::restore_event_from_standard_record($record);
+        if ($restoredevent) {
+            $name = $restoredevent->get_name();
+            $description = $restoredevent->get_description();
+            $other = $restoredevent->other;
+
+        } else {
+            $name = $record->eventname;
+            $description = "Unknown event ({$name})";
+            $other = unserialize($record->other);
+        }
+
+        $realuserid = $record->realuserid;
+        $isauthor = $record->userid == $userid;
+        $isrelated = $record->relateduserid == $userid;
+        $isrealuser = $realuserid == $userid;
+        $ismasqueraded = $realuserid !== null && $record->userid != $realuserid;
+        $ismasquerading = $isrealuser && !$isauthor;
+        $isanonymous = $record->anonymous;
+
+        $data = [
+            'name' => $name,
+            'description' => $description,
+            'timecreated' => transform::datetime($record->timecreated),
+            'ip' => $record->ip,
+            'origin' => static::transform_origin($record->origin),
+            'other' => $other ? $other : []
+        ];
+
+        if ($isanonymous) {
+            $data['action_was_done_anonymously'] = transform::yesno($isanonymous);
+        }
+        if ($isauthor || !$isanonymous) {
+            $data['authorid'] = transform::user($record->userid);
+            $data['author_of_the_action_was_you'] = transform::yesno($isauthor);
+        }
+
+        if ($record->relateduserid) {
+            $data['relateduserid'] = transform::user($record->relateduserid);
+            $data['related_user_was_you'] = transform::yesno($isrelated);
+        }
+
+        if ($ismasqueraded) {
+            $data['author_of_the_action_was_masqueraded'] = transform::yesno(true);
+            if ($ismasquerading || !$isanonymous) {
+                $data['masqueradinguserid'] = transform::user($realuserid);
+                $data['masquerading_user_was_you'] = transform::yesno($ismasquerading);
+            }
+        }
+
+        return $data;
+    }
+
+    /**
+     * Transform origin.
+     *
+     * @param string $origin The page request origin.
+     * @return string
+     */
+    public static function transform_origin($origin) {
+        switch ($origin) {
+            case 'cli':
+            case 'restore':
+            case 'web':
+            case 'ws':
+                return get_string('privacy:request:origin:' . $origin, 'tool_log');
+                break;
+        }
+        return $origin;
+    }
+}
diff --git a/admin/tool/log/classes/local/privacy/logstore_provider.php b/admin/tool/log/classes/local/privacy/logstore_provider.php
new file mode 100644 (file)
index 0000000..cecc130
--- /dev/null
@@ -0,0 +1,78 @@
+<?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/>.
+
+/**
+ * Logstore provider interface.
+ *
+ * @package    tool_log
+ * @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 tool_log\local\privacy;
+defined('MOODLE_INTERNAL') || die();
+
+use context;
+use core_privacy\local\request\contextlist;
+use core_privacy\local\request\approved_contextlist;
+
+/**
+ * Logstore provider interface.
+ *
+ * Logstore subplugins providers must implement this interface.
+ *
+ * @package    tool_log
+ * @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
+ */
+interface logstore_provider extends \core_privacy\local\request\plugin\subplugin_provider {
+
+    /**
+     * Add contexts that contain user information for the specified user.
+     *
+     * @param contextlist $contextlist The contextlist to add the contexts to.
+     * @param int $userid The user to find the contexts for.
+     * @return void
+     */
+    public static function add_contexts_for_userid(contextlist $contextlist, $userid);
+
+    /**
+     * Export all user data for the specified user, in the specified contexts.
+     *
+     * @param approved_contextlist $contextlist The approved contexts to export information for.
+     * @return void
+     */
+    public static function export_user_data(approved_contextlist $contextlist);
+
+    /**
+     * Delete all data for all users in the specified context.
+     *
+     * @param context $context The specific context to delete data for.
+     * @return void
+     */
+    public static function delete_data_for_all_users_in_context(context $context);
+
+    /**
+     * 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.
+     * @return void
+     */
+    public static function delete_data_for_user(approved_contextlist $contextlist);
+
+}
diff --git a/admin/tool/log/classes/local/privacy/moodle_database_export_and_delete.php b/admin/tool/log/classes/local/privacy/moodle_database_export_and_delete.php
new file mode 100644 (file)
index 0000000..da973ea
--- /dev/null
@@ -0,0 +1,123 @@
+<?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/>.
+
+/**
+ * Moodle database: export and delete.
+ *
+ * @package    tool_log
+ * @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 tool_log\local\privacy;
+defined('MOODLE_INTERNAL') || die();
+
+use context;
+use core_privacy\local\request\approved_contextlist;
+use core_privacy\local\request\writer;
+
+/**
+ * Moodle database: export and delete trait.
+ *
+ * This is to be used with logstores which use a database and table with the same columns
+ * as the core plugin 'logstore_standard'.
+ *
+ * This trait expects the following methods to be present in the object:
+ *
+ * - public static function get_database_and_table(): [moodle_database|null, string|null]
+ * - public static function get_export_subcontext(): []
+ *
+ * @package    tool_log
+ * @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
+ */
+trait moodle_database_export_and_delete {
+
+    /**
+     * 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) {
+        list($db, $table) = static::get_database_and_table();
+        if (!$db || !$table) {
+            return;
+        }
+
+        $userid = $contextlist->get_user()->id;
+        list($insql, $inparams) = $db->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED);
+
+        $sql = "(userid = :userid1 OR relateduserid = :userid2 OR realuserid = :userid3) AND contextid $insql";
+        $params = array_merge($inparams, [
+            'userid1' => $userid,
+            'userid2' => $userid,
+            'userid3' => $userid,
+        ]);
+
+        $path = static::get_export_subcontext();
+        $flush = function($lastcontextid, $data) use ($path) {
+            $context = context::instance_by_id($lastcontextid);
+            writer::with_context($context)->export_data($path, (object) ['logs' => $data]);
+        };
+
+        $lastcontextid = null;
+        $data = [];
+        $recordset = $db->get_recordset_select($table, $sql, $params, 'contextid, timecreated, id');
+        foreach ($recordset as $record) {
+            if ($lastcontextid && $lastcontextid != $record->contextid) {
+                $flush($lastcontextid, $data);
+                $data = [];
+            }
+            $data[] = helper::transform_standard_log_record_for_userid($record, $userid);
+            $lastcontextid = $record->contextid;
+        }
+        if ($lastcontextid) {
+            $flush($lastcontextid, $data);
+        }
+        $recordset->close();
+    }
+
+    /**
+     * 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) {
+        list($db, $table) = static::get_database_and_table();
+        if (!$db || !$table) {
+            return;
+        }
+        $db->delete_records($table, ['contextid' => $context->id]);
+    }
+
+    /**
+     * 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) {
+        list($db, $table) = static::get_database_and_table();
+        if (!$db || !$table) {
+            return;
+        }
+        list($insql, $inparams) = $db->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED);
+        $params = array_merge($inparams, ['userid' => $contextlist->get_user()->id]);
+        $db->delete_records_select($table, "userid = :userid AND contextid $insql", $params);
+    }
+
+}
diff --git a/admin/tool/log/classes/privacy/provider.php b/admin/tool/log/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..af91b8b
--- /dev/null
@@ -0,0 +1,110 @@
+<?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    tool_log
+ * @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 tool_log\privacy;
+defined('MOODLE_INTERNAL') || die();
+
+use context;
+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 tool_log\log\manager;
+
+/**
+ * Data provider class.
+ *
+ * @package    tool_log
+ * @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\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_plugintype_link('logstore', [], 'privacy:metadata:logstore');
+        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();
+        static::call_subplugins_method_with_args('add_contexts_for_userid', [$contextlist, $userid]);
+        return $contextlist;
+    }
+
+    /**
+     * 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) {
+        static::call_subplugins_method_with_args('export_user_data', [$contextlist]);
+    }
+
+    /**
+     * 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) {
+        static::call_subplugins_method_with_args('delete_data_for_all_users_in_context', [$context]);
+    }
+
+    /**
+     * 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) {
+        static::call_subplugins_method_with_args('delete_data_for_user', [$contextlist]);
+    }
+
+    /**
+     * Invoke the subplugins method with arguments.
+     *
+     * @param string $method The method name.
+     * @param array $args The arguments.
+     * @return void
+     */
+    protected static function call_subplugins_method_with_args($method, array $args = []) {
+        $interface = \tool_log\local\privacy\logstore_provider::class;
+        \core_privacy\manager::plugintype_class_callback('logstore', $interface, $method, $args);
+    }
+
+}
index c57c424..bfda300 100644 (file)
@@ -26,7 +26,13 @@ $string['actlogshdr'] = 'Available log stores';
 $string['configlogplugins'] = 'Please enable all required plugins and arrange them in appropriate order.';
 $string['logging'] = 'Logging';
 $string['managelogging'] = 'Manage log stores';
-$string['reportssupported'] = 'Reports supported';
 $string['pluginname'] = 'Log store manager';
+$string['privacy:metadata:logstore'] = 'The log stores';
+$string['privacy:path:logs'] = 'Logs';
+$string['privacy:request:origin:cli'] = 'Command line tool';
+$string['privacy:request:origin:restore'] = 'Backup being restored';
+$string['privacy:request:origin:web'] = 'Standard web request';
+$string['privacy:request:origin:ws'] = 'Mobile app or web service';
+$string['reportssupported'] = 'Reports supported';
 $string['subplugintype_logstore'] = 'Log store';
 $string['subplugintype_logstore_plural'] = 'Log stores';
diff --git a/admin/tool/log/store/database/classes/privacy/provider.php b/admin/tool/log/store/database/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..b5e8eac
--- /dev/null
@@ -0,0 +1,114 @@
+<?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    logstore_database
+ * @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 logstore_database\privacy;
+defined('MOODLE_INTERNAL') || die();
+
+use context;
+use core_privacy\local\metadata\collection;
+use core_privacy\local\request\contextlist;
+
+/**
+ * Data provider class.
+ *
+ * @package    logstore_database
+ * @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,
+    \tool_log\local\privacy\logstore_provider {
+
+    use \tool_log\local\privacy\moodle_database_export_and_delete;
+
+    /**
+     * 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_external_location_link('log', [
+            'eventname' => 'privacy:metadata:log:eventname',
+            'userid' => 'privacy:metadata:log:userid',
+            'relateduserid' => 'privacy:metadata:log:relateduserid',
+            'anonymous' => 'privacy:metadata:log:anonymous',
+            'other' => 'privacy:metadata:log:other',
+            'timecreated' => 'privacy:metadata:log:timecreated',
+            'origin' => 'privacy:metadata:log:origin',
+            'ip' => 'privacy:metadata:log:ip',
+            'realuserid' => 'privacy:metadata:log:realuserid',
+        ], 'privacy:metadata:log');
+        return $collection;
+    }
+
+    /**
+     * Add contexts that contain user information for the specified user.
+     *
+     * @param contextlist $contextlist The contextlist to add the contexts to.
+     * @param int $userid The user to find the contexts for.
+     * @return void
+     */
+    public static function add_contexts_for_userid(contextlist $contextlist, $userid) {
+        list($db, $table) = static::get_database_and_table();
+        if (!$db || !$table) {
+            return;
+        }
+
+        $sql = 'userid = :userid1 OR relateduserid = :userid2 OR realuserid = :userid3';
+        $params = ['userid1' => $userid, 'userid2' => $userid, 'userid3' => $userid];
+        $contextids = $db->get_fieldset_select($table, 'DISTINCT contextid', $sql, $params);
+        if (empty($contextids)) {
+            return;
+        }
+
+        $sql = implode(' UNION ', array_map(function($id) use ($db) {
+            return 'SELECT ' . $id . $db->sql_null_from_clause();
+        }, $contextids));
+        $contextlist->add_from_sql($sql, []);
+    }
+
+    /**
+     * Get the database object.
+     *
+     * @return array Containing moodle_database, string, or null values.
+     */
+    protected static function get_database_and_table() {
+        $manager = get_log_manager();
+        $store = new \logstore_database\log\store($manager);
+        $db = $store->get_extdb();
+        return $db ? [$db, $store->get_config_value('dbtable')] : [null, null];
+    }
+
+    /**
+     * Get the path to export the logs to.
+     *
+     * @return array
+     */
+    protected static function get_export_subcontext() {
+        return [get_string('privacy:path:logs', 'tool_log'), get_string('pluginname', 'logstore_database')];
+    }
+}
index 0873229..8f49696 100644 (file)
@@ -44,6 +44,16 @@ $string['other'] = 'Other';
 $string['participating'] = 'Participating';
 $string['pluginname'] = 'External database log';
 $string['pluginname_desc'] = 'A log plugin that stores log entries in an external database table.';
+$string['privacy:metadata:log'] = 'A collection of past events';
+$string['privacy:metadata:log:anonymous'] = 'Whether the event was flagged as anonymous';
+$string['privacy:metadata:log:eventname'] = 'The event name';
+$string['privacy:metadata:log:ip'] = 'The IP address used at the time of the event';
+$string['privacy:metadata:log:origin'] = 'The origin of the event';
+$string['privacy:metadata:log:other'] = 'Additional information about the event';
+$string['privacy:metadata:log:realuserid'] = 'The ID of the real user behind the event, when masquerading a user.';
+$string['privacy:metadata:log:relateduserid'] = 'The ID of a user related to this event';
+$string['privacy:metadata:log:timecreated'] = 'The time at which the event occurred';
+$string['privacy:metadata:log:userid'] = 'The ID of the user who triggered this event';
 $string['read'] = 'Read';
 $string['tablenotfound'] = 'Specified table was not found';
 $string['teaching'] = 'Teaching';
diff --git a/admin/tool/log/store/database/tests/privacy_test.php b/admin/tool/log/store/database/tests/privacy_test.php
new file mode 100644 (file)
index 0000000..4a1e37e
--- /dev/null
@@ -0,0 +1,416 @@
+<?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 tests.
+ *
+ * @package    logstore_database
+ * @category   test
+ * @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
+ */
+
+defined('MOODLE_INTERNAL') || die();
+global $CFG;
+
+use core_privacy\tests\provider_testcase;
+use core_privacy\local\request\contextlist;
+use core_privacy\local\request\approved_contextlist;
+use core_privacy\local\request\transform;
+use core_privacy\local\request\writer;
+use logstore_database\privacy\provider;
+
+require_once(__DIR__ . '/fixtures/event.php');
+
+/**
+ * Data provider testcase class.
+ *
+ * This testcase is almost identical to the logstore_standard testcase, aside from the
+ * initialisation of the relevant logstore obviously.
+ *
+ * @package    logstore_database
+ * @category   test
+ * @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 logstore_database_privacy_testcase extends provider_testcase {
+
+    public function setUp() {
+        global $CFG;
+        $this->resetAfterTest();
+        $this->preventResetByRollback(); // Logging waits till the transaction gets committed.
+
+        // Fake the settings, we will abuse the standard plugin table here...
+        set_config('dbdriver', $CFG->dblibrary . '/' . $CFG->dbtype, 'logstore_database');
+        set_config('dbhost', $CFG->dbhost, 'logstore_database');
+        set_config('dbuser', $CFG->dbuser, 'logstore_database');
+        set_config('dbpass', $CFG->dbpass, 'logstore_database');
+        set_config('dbname', $CFG->dbname, 'logstore_database');
+        set_config('dbtable', $CFG->prefix . 'logstore_standard_log', 'logstore_database');
+        if (!empty($CFG->dboptions['dbpersist'])) {
+            set_config('dbpersist', 1, 'logstore_database');
+        } else {
+            set_config('dbpersist', 0, 'logstore_database');
+        }
+        if (!empty($CFG->dboptions['dbsocket'])) {
+            set_config('dbsocket', $CFG->dboptions['dbsocket'], 'logstore_database');
+        } else {
+            set_config('dbsocket', '', 'logstore_database');
+        }
+        if (!empty($CFG->dboptions['dbport'])) {
+            set_config('dbport', $CFG->dboptions['dbport'], 'logstore_database');
+        } else {
+            set_config('dbport', '', 'logstore_database');
+        }
+        if (!empty($CFG->dboptions['dbschema'])) {
+            set_config('dbschema', $CFG->dboptions['dbschema'], 'logstore_database');
+        } else {
+            set_config('dbschema', '', 'logstore_database');
+        }
+        if (!empty($CFG->dboptions['dbcollation'])) {
+            set_config('dbcollation', $CFG->dboptions['dbcollation'], 'logstore_database');
+        } else {
+            set_config('dbcollation', '', 'logstore_database');
+        }
+        if (!empty($CFG->dboptions['dbhandlesoptions'])) {
+            set_config('dbhandlesoptions', $CFG->dboptions['dbhandlesoptions'], 'logstore_database');
+        } else {
+            set_config('dbhandlesoptions', false, 'logstore_database');
+        }
+    }
+
+    public function test_get_contexts_for_userid() {
+        $admin = \core_user::get_user(2);
+        $u1 = $this->getDataGenerator()->create_user();
+        $u2 = $this->getDataGenerator()->create_user();
+        $u3 = $this->getDataGenerator()->create_user();
+
+        $c1 = $this->getDataGenerator()->create_course();
+        $cm1 = $this->getDataGenerator()->create_module('url', ['course' => $c1]);
+        $c2 = $this->getDataGenerator()->create_course();
+        $cm2 = $this->getDataGenerator()->create_module('url', ['course' => $c2]);
+
+        $sysctx = context_system::instance();
+        $c1ctx = context_course::instance($c1->id);
+        $c2ctx = context_course::instance($c2->id);
+        $cm1ctx = context_module::instance($cm1->cmid);
+        $cm2ctx = context_module::instance($cm2->cmid);
+
+        $this->enable_logging();
+        $manager = get_log_manager(true);
+
+        // User 1 is the author.
+        $this->setUser($u1);
+        $this->assert_contextlist_equals($this->get_contextlist_for_user($u1), []);
+        $e = \logstore_database\event\unittest_executed::create(['context' => $cm1ctx]);
+        $e->trigger();
+        $this->assert_contextlist_equals($this->get_contextlist_for_user($u1), [$cm1ctx]);
+
+        // User 2 is the related user.
+        $this->setUser(0);
+        $this->assert_contextlist_equals($this->get_contextlist_for_user($u2), []);
+        $e = \logstore_database\event\unittest_executed::create(['context' => $cm2ctx, 'relateduserid' => $u2->id]);
+        $e->trigger();
+        $this->assert_contextlist_equals($this->get_contextlist_for_user($u2), [$cm2ctx]);
+
+        // Admin user is the real user.
+        $this->assert_contextlist_equals($this->get_contextlist_for_user($admin), []);
+        $this->assert_contextlist_equals($this->get_contextlist_for_user($u3), []);
+        $this->setAdminUser();
+        \core\session\manager::loginas($u3->id, $sysctx);
+        $e = \logstore_database\event\unittest_executed::create(['context' => $c1ctx]);
+        $e->trigger();
+        $this->assert_contextlist_equals($this->get_contextlist_for_user($admin), [$sysctx, $c1ctx]);
+        $this->assert_contextlist_equals($this->get_contextlist_for_user($u3), [$sysctx, $c1ctx]);
+
+        // By admin user masquerading u1 related to u3.
+        $this->assert_contextlist_equals($this->get_contextlist_for_user($u1), [$cm1ctx]);
+        $this->assert_contextlist_equals($this->get_contextlist_for_user($u3), [$sysctx, $c1ctx]);
+        $this->assert_contextlist_equals($this->get_contextlist_for_user($admin), [$sysctx, $c1ctx]);
+        $this->setAdminUser();
+        \core\session\manager::loginas($u1->id, context_system::instance());
+        $e = \logstore_database\event\unittest_executed::create(['context' => $c2ctx, 'relateduserid' => $u3->id]);
+        $e->trigger();
+        $this->assert_contextlist_equals($this->get_contextlist_for_user($u1), [$sysctx, $cm1ctx, $c2ctx]);
+        $this->assert_contextlist_equals($this->get_contextlist_for_user($u3), [$sysctx, $c1ctx, $c2ctx]);
+        $this->assert_contextlist_equals($this->get_contextlist_for_user($admin), [$sysctx, $c1ctx, $c2ctx]);
+    }
+
+    public function test_delete_data_for_user() {
+        global $DB;
+        $u1 = $this->getDataGenerator()->create_user();
+        $u2 = $this->getDataGenerator()->create_user();
+        $c1 = $this->getDataGenerator()->create_course();
+        $c2 = $this->getDataGenerator()->create_course();
+        $sysctx = context_system::instance();
+        $c1ctx = context_course::instance($c1->id);
+        $c2ctx = context_course::instance($c2->id);
+
+        $this->enable_logging();
+        $manager = get_log_manager(true);
+
+        // User 1 is the author.
+        $this->setUser($u1);
+        $e = \logstore_database\event\unittest_executed::create(['context' => $c1ctx]);
+        $e->trigger();
+        $e = \logstore_database\event\unittest_executed::create(['context' => $c1ctx]);
+        $e->trigger();
+        $e = \logstore_database\event\unittest_executed::create(['context' => $c2ctx]);
+        $e->trigger();
+
+        // User 2 is the author.
+        $this->setUser($u2);
+        $e = \logstore_database\event\unittest_executed::create(['context' => $c1ctx]);
+        $e->trigger();
+        $e = \logstore_database\event\unittest_executed::create(['context' => $c2ctx]);
+        $e->trigger();
+
+        // Confirm data present.
+        $this->assertTrue($DB->record_exists('logstore_standard_log', ['userid' => $u1->id, 'contextid' => $c1ctx->id]));
+        $this->assertEquals(3, $DB->count_records('logstore_standard_log', ['userid' => $u1->id]));
+        $this->assertEquals(2, $DB->count_records('logstore_standard_log', ['userid' => $u2->id]));
+
+        // Delete all the things!
+        provider::delete_data_for_user(new approved_contextlist($u1, 'logstore_database', [$c1ctx->id]));
+        $this->assertFalse($DB->record_exists('logstore_standard_log', ['userid' => $u1->id, 'contextid' => $c1ctx->id]));
+        $this->assertEquals(1, $DB->count_records('logstore_standard_log', ['userid' => $u1->id]));
+        $this->assertEquals(2, $DB->count_records('logstore_standard_log', ['userid' => $u2->id]));
+    }
+
+    public function test_delete_data_for_all_users_in_context() {
+        global $DB;
+        $u1 = $this->getDataGenerator()->create_user();
+        $u2 = $this->getDataGenerator()->create_user();
+        $c1 = $this->getDataGenerator()->create_course();
+        $c2 = $this->getDataGenerator()->create_course();
+        $sysctx = context_system::instance();
+        $c1ctx = context_course::instance($c1->id);
+        $c2ctx = context_course::instance($c2->id);
+
+        $this->enable_logging();
+        $manager = get_log_manager(true);
+
+        // User 1 is the author.
+        $this->setUser($u1);
+        $e = \logstore_database\event\unittest_executed::create(['context' => $c1ctx]);
+        $e->trigger();
+        $e = \logstore_database\event\unittest_executed::create(['context' => $c1ctx]);
+        $e->trigger();
+        $e = \logstore_database\event\unittest_executed::create(['context' => $c2ctx]);
+        $e->trigger();
+
+        // User 2 is the author.
+        $this->setUser($u2);
+        $e = \logstore_database\event\unittest_executed::create(['context' => $c1ctx]);
+        $e->trigger();
+        $e = \logstore_database\event\unittest_executed::create(['context' => $c2ctx]);
+        $e->trigger();
+
+        // Confirm data present.
+        $this->assertTrue($DB->record_exists('logstore_standard_log', ['contextid' => $c1ctx->id]));
+        $this->assertEquals(3, $DB->count_records('logstore_standard_log', ['userid' => $u1->id]));
+        $this->assertEquals(2, $DB->count_records('logstore_standard_log', ['userid' => $u2->id]));
+
+        // Delete all the things!
+        provider::delete_data_for_all_users_in_context($c1ctx);
+        $this->assertFalse($DB->record_exists('logstore_standard_log', ['contextid' => $c1ctx->id]));
+        $this->assertEquals(1, $DB->count_records('logstore_standard_log', ['userid' => $u1->id]));
+        $this->assertEquals(1, $DB->count_records('logstore_standard_log', ['userid' => $u2->id]));
+    }
+
+    public function test_export_data_for_user() {
+        $admin = \core_user::get_user(2);
+        $u1 = $this->getDataGenerator()->create_user();
+        $u2 = $this->getDataGenerator()->create_user();
+        $u3 = $this->getDataGenerator()->create_user();
+        $u4 = $this->getDataGenerator()->create_user();
+        $c1 = $this->getDataGenerator()->create_course();
+        $cm1 = $this->getDataGenerator()->create_module('url', ['course' => $c1]);
+        $c2 = $this->getDataGenerator()->create_course();
+        $cm2 = $this->getDataGenerator()->create_module('url', ['course' => $c2]);
+        $sysctx = context_system::instance();
+        $c1ctx = context_course::instance($c1->id);
+        $c2ctx = context_course::instance($c2->id);
+        $cm1ctx = context_module::instance($cm1->cmid);
+        $cm2ctx = context_module::instance($cm2->cmid);
+
+        $path = [get_string('privacy:path:logs', 'tool_log'), get_string('pluginname', 'logstore_database')];
+        $this->enable_logging();
+        $manager = get_log_manager(true);
+
+        // User 1 is the author.
+        $this->setUser($u1);
+        $e = \logstore_database\event\unittest_executed::create(['context' => $c1ctx, 'other' => ['i' => 0]]);
+        $e->trigger();
+
+        // User 2 is related.
+        $this->setUser(0);
+        $e = \logstore_database\event\unittest_executed::create(['context' => $c1ctx, 'relateduserid' => $u2->id,
+            'other' => ['i' => 1]]);
+        $e->trigger();
+
+        // Admin user masquerades u3, which is related to u4.
+        $this->setAdminUser();
+        \core\session\manager::loginas($u3->id, $sysctx);
+        $e = \logstore_database\event\unittest_executed::create(['context' => $c1ctx, 'relateduserid' => $u4->id,
+            'other' => ['i' => 2]]);
+        $e->trigger();
+
+        // Confirm data present for u1.
+        provider::export_user_data(new approved_contextlist($u1, 'logstore_database', [$c2ctx->id, $c1ctx->id]));
+        $data = writer::with_context($c2ctx)->get_data($path);
+        $this->assertEmpty($data);
+        $data = writer::with_context($c1ctx)->get_data($path);
+        $this->assertCount(1, $data->logs);
+        $this->assertEquals(transform::yesno(true), $data->logs[0]['author_of_the_action_was_you']);
+        $this->assertSame(0, $data->logs[0]['other']['i']);
+
+        // Confirm data present for u2.
+        writer::reset();
+        provider::export_user_data(new approved_contextlist($u2, 'logstore_database', [$c2ctx->id, $c1ctx->id]));
+        $data = writer::with_context($c2ctx)->get_data($path);
+        $this->assertEmpty($data);
+        $data = writer::with_context($c1ctx)->get_data($path);
+        $this->assertCount(1, $data->logs);
+        $this->assertEquals(transform::yesno(false), $data->logs[0]['author_of_the_action_was_you']);
+        $this->assertEquals(transform::yesno(true), $data->logs[0]['related_user_was_you']);
+        $this->assertSame(1, $data->logs[0]['other']['i']);
+
+        // Confirm data present for u3.
+        writer::reset();
+        provider::export_user_data(new approved_contextlist($u3, 'logstore_database', [$c2ctx->id, $c1ctx->id]));
+        $data = writer::with_context($c2ctx)->get_data($path);
+        $this->assertEmpty($data);
+        $data = writer::with_context($c1ctx)->get_data($path);
+        $this->assertCount(1, $data->logs);
+        $this->assertEquals(transform::yesno(true), $data->logs[0]['author_of_the_action_was_you']);
+        $this->assertEquals(transform::yesno(false), $data->logs[0]['related_user_was_you']);
+        $this->assertEquals(transform::yesno(true), $data->logs[0]['author_of_the_action_was_masqueraded']);
+        $this->assertEquals(transform::yesno(false), $data->logs[0]['masquerading_user_was_you']);
+        $this->assertSame(2, $data->logs[0]['other']['i']);
+
+        // Confirm data present for u4.
+        writer::reset();
+        provider::export_user_data(new approved_contextlist($u4, 'logstore_database', [$c2ctx->id, $c1ctx->id]));
+        $data = writer::with_context($c2ctx)->get_data($path);
+        $this->assertEmpty($data);
+        $data = writer::with_context($c1ctx)->get_data($path);
+        $this->assertCount(1, $data->logs);
+        $this->assertEquals(transform::yesno(false), $data->logs[0]['author_of_the_action_was_you']);
+        $this->assertEquals(transform::yesno(true), $data->logs[0]['related_user_was_you']);
+        $this->assertEquals(transform::yesno(true), $data->logs[0]['author_of_the_action_was_masqueraded']);
+        $this->assertEquals(transform::yesno(false), $data->logs[0]['masquerading_user_was_you']);
+        $this->assertSame(2, $data->logs[0]['other']['i']);
+
+        // Add anonymous events.
+        $this->setUser($u1);
+        $e = \logstore_database\event\unittest_executed::create(['context' => $c2ctx, 'relateduserid' => $u2->id,
+            'anonymous' => true]);
+        $e->trigger();
+        $this->setAdminUser();
+        \core\session\manager::loginas($u3->id, $sysctx);
+        $e = \logstore_database\event\unittest_executed::create(['context' => $c2ctx, 'relateduserid' => $u4->id,
+            'anonymous' => true]);
+        $e->trigger();
+
+        // Confirm data present for u1.
+        provider::export_user_data(new approved_contextlist($u1, 'logstore_database', [$c2ctx->id]));
+        $data = writer::with_context($c2ctx)->get_data($path);
+        $this->assertCount(1, $data->logs);
+        $this->assertEquals(transform::yesno(true), $data->logs[0]['action_was_done_anonymously']);
+        $this->assertEquals(transform::yesno(true), $data->logs[0]['author_of_the_action_was_you']);
+
+        // Confirm data present for u2.
+        writer::reset();
+        provider::export_user_data(new approved_contextlist($u2, 'logstore_database', [$c2ctx->id]));
+        $data = writer::with_context($c2ctx)->get_data($path);
+        $this->assertCount(1, $data->logs);
+        $this->assertEquals(transform::yesno(true), $data->logs[0]['action_was_done_anonymously']);
+        $this->assertArrayNotHasKey('author_of_the_action_was_you', $data->logs[0]);
+        $this->assertArrayNotHasKey('authorid', $data->logs[0]);
+        $this->assertEquals(transform::yesno(true), $data->logs[0]['related_user_was_you']);
+
+        // Confirm data present for u3.
+        writer::reset();
+        provider::export_user_data(new approved_contextlist($u3, 'logstore_database', [$c2ctx->id]));
+        $data = writer::with_context($c2ctx)->get_data($path);
+        $this->assertCount(1, $data->logs);
+        $this->assertEquals(transform::yesno(true), $data->logs[0]['action_was_done_anonymously']);
+        $this->assertEquals(transform::yesno(true), $data->logs[0]['author_of_the_action_was_you']);
+        $this->assertEquals(transform::yesno(true), $data->logs[0]['author_of_the_action_was_masqueraded']);
+        $this->assertArrayNotHasKey('masquerading_user_was_you', $data->logs[0]);
+        $this->assertArrayNotHasKey('masqueradinguserid', $data->logs[0]);
+
+        // Confirm data present for u4.
+        writer::reset();
+        provider::export_user_data(new approved_contextlist($u4, 'logstore_database', [$c2ctx->id]));
+        $data = writer::with_context($c2ctx)->get_data($path);
+        $this->assertCount(1, $data->logs);
+        $this->assertEquals(transform::yesno(true), $data->logs[0]['action_was_done_anonymously']);
+        $this->assertArrayNotHasKey('author_of_the_action_was_you', $data->logs[0]);
+        $this->assertArrayNotHasKey('authorid', $data->logs[0]);
+        $this->assertEquals(transform::yesno(true), $data->logs[0]['related_user_was_you']);
+        $this->assertEquals(transform::yesno(true), $data->logs[0]['author_of_the_action_was_masqueraded']);
+        $this->assertArrayNotHasKey('masquerading_user_was_you', $data->logs[0]);
+        $this->assertArrayNotHasKey('masqueradinguserid', $data->logs[0]);
+    }
+
+    /**
+     * Assert the content of a context list.
+     *
+     * @param contextlist $contextlist The collection.
+     * @param array $expected List of expected contexts or IDs.
+     * @return void
+     */
+    protected function assert_contextlist_equals($contextlist, array $expected) {
+        $expectedids = array_map(function($context) {
+            if (is_object($context)) {
+                return $context->id;
+            }
+            return $context;
+        }, $expected);
+        $contextids = array_map('intval', $contextlist->get_contextids());
+        sort($contextids);
+        sort($expectedids);
+        $this->assertEquals($expectedids, $contextids);
+    }
+
+    /**
+     * Enable logging.
+     *
+     * @return void
+     */
+    protected function enable_logging() {
+        set_config('enabled_stores', 'logstore_database', 'tool_log');
+        set_config('buffersize', 0, 'logstore_database');
+        set_config('logguests', 1, 'logstore_database');
+        get_log_manager(true);
+    }
+
+    /**
+     * Get the contextlist for a user.
+     *
+     * @param object $user The user.
+     * @return contextlist
+     */
+    protected function get_contextlist_for_user($user) {
+        $contextlist = new contextlist();
+        provider::add_contexts_for_userid($contextlist, $user->id);
+        return $contextlist;
+    }
+}
diff --git a/admin/tool/log/store/legacy/classes/privacy/provider.php b/admin/tool/log/store/legacy/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..298d821
--- /dev/null
@@ -0,0 +1,220 @@
+<?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    logstore_legacy
+ * @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 logstore_legacy\privacy;
+defined('MOODLE_INTERNAL') || die();
+
+use context;
+use core_privacy\local\metadata\collection;
+use core_privacy\local\request\approved_contextlist;
+use core_privacy\local\request\contextlist;
+use core_privacy\local\request\transform;
+use core_privacy\local\request\writer;
+use tool_log\local\privacy\helper;
+
+/**
+ * Data provider class.
+ *
+ * @package    logstore_legacy
+ * @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,
+    \tool_log\local\privacy\logstore_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_external_location_link('log', [
+            'time' => 'privacy:metadata:log:time',
+            'userid' => 'privacy:metadata:log:userid',
+            'ip' => 'privacy:metadata:log:ip',
+            'action' => 'privacy:metadata:log:action',
+            'url' => 'privacy:metadata:log:url',
+            'info' => 'privacy:metadata:log:info',
+        ], 'privacy:metadata:log');
+        return $collection;
+    }
+
+    /**
+     * Add contexts that contain user information for the specified user.
+     *
+     * @param contextlist $contextlist The contextlist to add the contexts to.
+     * @param int $userid The user to find the contexts for.
+     * @return void
+     */
+    public static function add_contexts_for_userid(contextlist $contextlist, $userid) {
+        $sql = "
+            SELECT ctx.id
+              FROM {context} ctx
+              JOIN {log} l
+                ON (l.cmid = 0 AND l.course = ctx.instanceid AND ctx.contextlevel = :courselevel)
+                OR (l.cmid > 0 AND l.cmid = ctx.instanceid AND ctx.contextlevel = :modulelevel)
+                OR (l.course <= 0 AND ctx.id = :syscontextid)
+             WHERE l.userid = :userid";
+        $params = [
+            'courselevel' => CONTEXT_COURSE,
+            'modulelevel' => CONTEXT_MODULE,
+            'syscontextid' => SYSCONTEXTID,
+            'userid' => $userid,
+        ];
+        $contextlist->add_from_sql($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;
+        list($insql, $inparams) = static::get_sql_where_from_contexts($contextlist->get_contexts());
+        if (empty($insql)) {
+            return;
+        }
+        $sql = "userid = :userid AND $insql";
+        $params = array_merge($inparams, ['userid' => $userid]);
+
+        $path = [get_string('privacy:path:logs', 'tool_log'), get_string('pluginname', 'logstore_legacy')];
+        $flush = function($lastcontextid, $data) use ($path) {
+            $context = context::instance_by_id($lastcontextid);
+            writer::with_context($context)->export_data($path, (object) ['logs' => $data]);
+        };
+
+        $lastcontextid = null;
+        $data = [];
+        $recordset = $DB->get_recordset_select('log', $sql, $params, 'course, cmid, time, id');
+        foreach ($recordset as $record) {
+            $event = \logstore_legacy\event\legacy_logged::restore_legacy($record);
+            $context = $event->get_context();
+            if ($lastcontextid && $lastcontextid != $context->id) {
+                $flush($lastcontextid, $data);
+                $data = [];
+            }
+
+            $extra = $event->get_logextra();
+            $data[] = [
+                'name' => $event->get_name(),
+                'description' => $event->get_description(),
+                'timecreated' => transform::datetime($event->timecreated),
+                'ip' => $extra['ip'],
+                'origin' => helper::transform_origin($extra['origin']),
+            ];
+
+            $lastcontextid = $context->id;
+        }
+        if ($lastcontextid) {
+            $flush($lastcontextid, $data);
+        }
+        $recordset->close();
+    }
+
+    /**
+     * 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) {
+        global $DB;
+        list($sql, $params) = static::get_sql_where_from_contexts([$context]);
+        if (empty($sql)) {
+            return;
+        }
+        $DB->delete_records_select('log', $sql, $params);
+    }
+
+    /**
+     * 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) {
+        global $DB;
+        list($sql, $params) = static::get_sql_where_from_contexts($contextlist->get_contexts());
+        if (empty($sql)) {
+            return;
+        }
+        $userid = $contextlist->get_user()->id;
+        $DB->delete_records_select('log', "$sql AND userid = :userid", array_merge($params, ['userid' => $userid]));
+    }
+
+    /**
+     * Get an SQL where statement from a list of contexts.
+     *
+     * @param array $contexts The contexts.
+     * @return array [$sql, $params]
+     */
+    protected static function get_sql_where_from_contexts(array $contexts) {
+        global $DB;
+
+        $sorted = array_reduce($contexts, function ($carry, $context) {
+            $level = $context->contextlevel;
+            if ($level == CONTEXT_MODULE || $level == CONTEXT_COURSE) {
+                $carry[$level][] = $context->instanceid;
+            } else if ($level == CONTEXT_SYSTEM) {
+                $carry[$level] = $context->id;
+            }
+            return $carry;
+        }, [
+            CONTEXT_COURSE => [],
+            CONTEXT_MODULE => [],
+            CONTEXT_SYSTEM => null,
+        ]);
+
+        $sqls = [];
+        $params = [];
+
+        if (!empty($sorted[CONTEXT_MODULE])) {
+            list($insql, $inparams) = $DB->get_in_or_equal($sorted[CONTEXT_MODULE], SQL_PARAMS_NAMED);
+            $sqls[] = "cmid $insql";
+            $params = array_merge($params, $inparams);
+        }
+
+        if (!empty($sorted[CONTEXT_COURSE])) {
+            list($insql, $inparams) = $DB->get_in_or_equal($sorted[CONTEXT_COURSE], SQL_PARAMS_NAMED);
+
+            $sqls[] = "cmid = 0 AND course $insql";
+            $params = array_merge($params, $inparams);
+        }
+
+        if (!empty($sorted[CONTEXT_SYSTEM])) {
+            $sqls[] = "course <= 0";
+        }
+
+        if (empty($sqls)) {
+            return [null, null];
+        }
+
+        return ['((' . implode(') OR (', $sqls) . '))', $params];
+    }
+}
index 1086cc6..5f35555 100644 (file)
@@ -27,4 +27,11 @@ $string['loglegacy'] = 'Log legacy data';
 $string['loglegacy_help'] = 'This plugin records log data to the legacy log table (mdl_log). This functionality has been replaced by newer, richer and more efficient logging plugins, so you should only run this plugin if you have old custom reports that directly query the old log table. Writing to the legacy logs will increase load, so it is recommended that you disable this plugin for performance reasons when it is not needed.';
 $string['pluginname'] = 'Legacy log';
 $string['pluginname_desc'] = 'A log plugin that stores log entries in the legacy log table.';
+$string['privacy:metadata:log'] = 'A collection of past events';
+$string['privacy:metadata:log:action'] = 'A description of the action';
+$string['privacy:metadata:log:info'] = 'Additional information';
+$string['privacy:metadata:log:ip'] = 'The IP address used at the time of the event';
+$string['privacy:metadata:log:time'] = 'The date at wich the action took place';
+$string['privacy:metadata:log:url'] = 'The URL related to the event';
+$string['privacy:metadata:log:userid'] = 'The ID of the user who performed the action';
 $string['taskcleanup'] = 'Legacy log table cleanup';
diff --git a/admin/tool/log/store/legacy/tests/privacy_test.php b/admin/tool/log/store/legacy/tests/privacy_test.php
new file mode 100644 (file)
index 0000000..a60960a
--- /dev/null
@@ -0,0 +1,344 @@
+<?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 tests.
+ *
+ * @package    logstore_legacy
+ * @category   test
+ * @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
+ */
+
+defined('MOODLE_INTERNAL') || die();
+global $CFG;
+
+use core_privacy\tests\provider_testcase;
+use core_privacy\local\request\contextlist;
+use core_privacy\local\request\approved_contextlist;
+use core_privacy\local\request\transform;
+use core_privacy\local\request\writer;
+use logstore_legacy\privacy\provider;
+use logstore_legacy\event\unittest_executed;
+
+require_once(__DIR__ . '/fixtures/event.php');
+
+/**
+ * Data provider testcase class.
+ *
+ * @package    logstore_legacy
+ * @category   test
+ * @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 logstore_legacy_privacy_testcase extends provider_testcase {
+
+    public function setUp() {
+        $this->resetAfterTest();
+    }
+
+    public function test_get_contexts_for_userid() {
+        $u1 = $this->getDataGenerator()->create_user();
+        $u2 = $this->getDataGenerator()->create_user();
+        $u3 = $this->getDataGenerator()->create_user();
+        $c1 = $this->getDataGenerator()->create_course();
+        $cm1 = $this->getDataGenerator()->create_module('url', ['course' => $c1]);
+        $sysctx = context_system::instance();
+        $c1ctx = context_course::instance($c1->id);
+        $cm1ctx = context_module::instance($cm1->cmid);
+
+        $this->enable_logging();
+        $manager = get_log_manager(true);
+
+        // User 1 is the author.
+        $this->setUser($u1);
+        $this->assert_contextlist_equals($this->get_contextlist_for_user($u1), []);
+        $e = unittest_executed::create(['context' => $cm1ctx, 'other' => ['sample' => 1]]);
+        $e->trigger();
+        $this->assert_contextlist_equals($this->get_contextlist_for_user($u1), [$cm1ctx]);
+
+        // User 2 is the author.
+        $this->setUser($u2);
+        $this->assert_contextlist_equals($this->get_contextlist_for_user($u2), []);
+        $e = unittest_executed::create(['context' => $cm1ctx, 'other' => ['sample' => 2]]);
+        $e->trigger();
+        $this->assert_contextlist_equals($this->get_contextlist_for_user($u2), [$cm1ctx]);
+
+        // User 3 is the author.
+        $this->setUser($u3);
+        $this->assert_contextlist_equals($this->get_contextlist_for_user($u3), []);
+        $e = unittest_executed::create(['context' => $sysctx, 'other' => ['sample' => 3]]);
+        $e->trigger();
+        $this->assert_contextlist_equals($this->get_contextlist_for_user($u3), [$sysctx]);
+    }
+
+    public function test_delete_data_for_user() {
+        global $DB;
+
+        $u1 = $this->getDataGenerator()->create_user();
+        $u2 = $this->getDataGenerator()->create_user();
+        $u3 = $this->getDataGenerator()->create_user();
+        $c1 = $this->getDataGenerator()->create_course();
+        $c2 = $this->getDataGenerator()->create_course();
+        $cm1 = $this->getDataGenerator()->create_module('url', ['course' => $c1]);
+        $sysctx = context_system::instance();
+        $c1ctx = context_course::instance($c1->id);
+        $c2ctx = context_course::instance($c2->id);
+        $cm1ctx = context_module::instance($cm1->cmid);
+
+        $this->enable_logging();
+        $manager = get_log_manager(true);
+
+        // User 1 is the author.
+        $this->setUser($u1);
+        $e = unittest_executed::create(['context' => $cm1ctx, 'other' => ['sample' => 1]]);
+        $e->trigger();
+        $e = unittest_executed::create(['context' => $cm1ctx, 'other' => ['sample' => 2]]);
+        $e->trigger();
+        $e = unittest_executed::create(['context' => $c1ctx, 'other' => ['sample' => 3]]);
+        $e->trigger();
+        $e = unittest_executed::create(['context' => $sysctx, 'other' => ['sample' => 4]]);
+        $e->trigger();
+
+        // User 2 is the author.
+        $this->setUser($u2);
+        $e = unittest_executed::create(['context' => $cm1ctx, 'other' => ['sample' => 5]]);
+        $e->trigger();
+        $e = unittest_executed::create(['context' => $c1ctx, 'other' => ['sample' => 6]]);
+        $e->trigger();
+        $e = unittest_executed::create(['context' => $sysctx, 'other' => ['sample' => 7]]);
+        $e->trigger();
+
+        // Assert what we have.
+        $this->assertTrue($DB->record_exists('log', ['userid' => $u1->id, 'cmid' => $cm1->cmid, 'course' => $c1->id]));
+        $this->assertTrue($DB->record_exists('log', ['userid' => $u1->id, 'cmid' => 0, 'course' => $c1->id]));
+        $this->assertTrue($DB->record_exists('log', ['userid' => $u1->id, 'cmid' => 0, 'course' => 0]));
+        $this->assertEquals(4, $DB->count_records('log', ['userid' => $u1->id]));
+        $this->assertEquals(3, $DB->count_records('log', ['userid' => $u2->id]));
+
+        // Delete other context.
+        provider::delete_data_for_user(new approved_contextlist($u1, 'logstore_legacy', [$c2ctx->id]));
+        $this->assertTrue($DB->record_exists('log', ['userid' => $u1->id, 'cmid' => $cm1->cmid, 'course' => $c1->id]));
+        $this->assertTrue($DB->record_exists('log', ['userid' => $u1->id, 'cmid' => 0, 'course' => $c1->id]));
+        $this->assertTrue($DB->record_exists('log', ['userid' => $u1->id, 'cmid' => 0, 'course' => 0]));
+        $this->assertEquals(4, $DB->count_records('log', ['userid' => $u1->id]));
+        $this->assertEquals(3, $DB->count_records('log', ['userid' => $u2->id]));
+
+        // Delete system.
+        provider::delete_data_for_user(new approved_contextlist($u1, 'logstore_legacy', [$sysctx->id]));
+        $this->assertTrue($DB->record_exists('log', ['userid' => $u1->id, 'cmid' => $cm1->cmid, 'course' => $c1->id]));
+        $this->assertTrue($DB->record_exists('log', ['userid' => $u1->id, 'cmid' => 0, 'course' => $c1->id]));
+        $this->assertFalse($DB->record_exists('log', ['userid' => $u1->id, 'cmid' => 0, 'course' => 0]));
+        $this->assertEquals(3, $DB->count_records('log', ['userid' => $u1->id]));
+        $this->assertEquals(3, $DB->count_records('log', ['userid' => $u2->id]));
+
+        // Delete course.
+        provider::delete_data_for_user(new approved_contextlist($u1, 'logstore_legacy', [$c1ctx->id]));
+        $this->assertTrue($DB->record_exists('log', ['userid' => $u1->id, 'cmid' => $cm1->cmid, 'course' => $c1->id]));
+        $this->assertFalse($DB->record_exists('log', ['userid' => $u1->id, 'cmid' => 0, 'course' => $c1->id]));
+        $this->assertFalse($DB->record_exists('log', ['userid' => $u1->id, 'cmid' => 0, 'course' => 0]));
+        $this->assertEquals(2, $DB->count_records('log', ['userid' => $u1->id]));
+        $this->assertEquals(3, $DB->count_records('log', ['userid' => $u2->id]));
+
+        // Delete course.
+        provider::delete_data_for_user(new approved_contextlist($u1, 'logstore_legacy', [$cm1ctx->id]));
+        $this->assertFalse($DB->record_exists('log', ['userid' => $u1->id, 'cmid' => $cm1->cmid, 'course' => $c1->id]));
+        $this->assertFalse($DB->record_exists('log', ['userid' => $u1->id, 'cmid' => 0, 'course' => $c1->id]));
+        $this->assertFalse($DB->record_exists('log', ['userid' => $u1->id, 'cmid' => 0, 'course' => 0]));
+        $this->assertEquals(0, $DB->count_records('log', ['userid' => $u1->id]));
+        $this->assertEquals(3, $DB->count_records('log', ['userid' => $u2->id]));
+    }
+
+    public function test_delete_data_for_all_users_in_context() {
+        global $DB;
+
+        $u1 = $this->getDataGenerator()->create_user();
+        $u2 = $this->getDataGenerator()->create_user();
+        $u3 = $this->getDataGenerator()->create_user();
+        $c1 = $this->getDataGenerator()->create_course();
+        $c2 = $this->getDataGenerator()->create_course();
+        $cm1 = $this->getDataGenerator()->create_module('url', ['course' => $c1]);
+        $sysctx = context_system::instance();
+        $c1ctx = context_course::instance($c1->id);
+        $c2ctx = context_course::instance($c2->id);
+        $cm1ctx = context_module::instance($cm1->cmid);
+
+        $this->enable_logging();
+        $manager = get_log_manager(true);
+
+        // User 1 is the author.
+        $this->setUser($u1);
+        $e = unittest_executed::create(['context' => $cm1ctx, 'other' => ['sample' => 1]]);
+        $e->trigger();
+        $e = unittest_executed::create(['context' => $cm1ctx, 'other' => ['sample' => 2]]);
+        $e->trigger();
+        $e = unittest_executed::create(['context' => $c1ctx, 'other' => ['sample' => 3]]);
+        $e->trigger();
+        $e = unittest_executed::create(['context' => $sysctx, 'other' => ['sample' => 4]]);
+        $e->trigger();
+
+        // User 2 is the author.
+        $this->setUser($u2);
+        $e = unittest_executed::create(['context' => $cm1ctx, 'other' => ['sample' => 5]]);
+        $e->trigger();
+        $e = unittest_executed::create(['context' => $c1ctx, 'other' => ['sample' => 6]]);
+        $e->trigger();
+        $e = unittest_executed::create(['context' => $sysctx, 'other' => ['sample' => 7]]);
+        $e->trigger();
+
+        // Assert what we have.
+        $this->assertTrue($DB->record_exists('log', ['cmid' => $cm1->cmid, 'course' => $c1->id]));
+        $this->assertTrue($DB->record_exists('log', ['cmid' => 0, 'course' => $c1->id]));
+        $this->assertTrue($DB->record_exists('log', ['cmid' => 0, 'course' => 0]));
+        $this->assertEquals(4, $DB->count_records('log', ['userid' => $u1->id]));
+        $this->assertEquals(3, $DB->count_records('log', ['userid' => $u2->id]));
+
+        // Delete other context.
+        provider::delete_data_for_all_users_in_context($c2ctx);
+        $this->assertTrue($DB->record_exists('log', ['cmid' => $cm1->cmid, 'course' => $c1->id]));
+        $this->assertTrue($DB->record_exists('log', ['cmid' => 0, 'course' => $c1->id]));
+        $this->assertTrue($DB->record_exists('log', ['cmid' => 0, 'course' => 0]));
+        $this->assertEquals(4, $DB->count_records('log', ['userid' => $u1->id]));
+        $this->assertEquals(3, $DB->count_records('log', ['userid' => $u2->id]));
+
+        // Delete system.
+        provider::delete_data_for_all_users_in_context($sysctx);
+        $this->assertTrue($DB->record_exists('log', ['cmid' => $cm1->cmid, 'course' => $c1->id]));
+        $this->assertTrue($DB->record_exists('log', ['cmid' => 0, 'course' => $c1->id]));
+        $this->assertFalse($DB->record_exists('log', ['cmid' => 0, 'course' => 0]));
+        $this->assertEquals(3, $DB->count_records('log', ['userid' => $u1->id]));
+        $this->assertEquals(2, $DB->count_records('log', ['userid' => $u2->id]));
+
+        // Delete course.
+        provider::delete_data_for_all_users_in_context($c1ctx);
+        $this->assertTrue($DB->record_exists('log', ['cmid' => $cm1->cmid, 'course' => $c1->id]));
+        $this->assertFalse($DB->record_exists('log', ['cmid' => 0, 'course' => $c1->id]));
+        $this->assertFalse($DB->record_exists('log', ['cmid' => 0, 'course' => 0]));
+        $this->assertEquals(2, $DB->count_records('log', ['userid' => $u1->id]));
+        $this->assertEquals(1, $DB->count_records('log', ['userid' => $u2->id]));
+
+        // Delete course.
+        provider::delete_data_for_all_users_in_context($cm1ctx);
+        $this->assertFalse($DB->record_exists('log', ['cmid' => $cm1->cmid, 'course' => $c1->id]));
+        $this->assertFalse($DB->record_exists('log', ['cmid' => 0, 'course' => $c1->id]));
+        $this->assertFalse($DB->record_exists('log', ['cmid' => 0, 'course' => 0]));
+        $this->assertEquals(0, $DB->count_records('log', ['userid' => $u1->id]));
+        $this->assertEquals(0, $DB->count_records('log', ['userid' => $u2->id]));
+    }
+
+    public function test_export_data_for_user() {
+        global $DB;
+
+        $u1 = $this->getDataGenerator()->create_user();
+        $u2 = $this->getDataGenerator()->create_user();
+        $u3 = $this->getDataGenerator()->create_user();
+        $c1 = $this->getDataGenerator()->create_course();
+        $c2 = $this->getDataGenerator()->create_course();
+        $cm1 = $this->getDataGenerator()->create_module('url', ['course' => $c1]);
+        $sysctx = context_system::instance();
+        $c1ctx = context_course::instance($c1->id);
+        $c2ctx = context_course::instance($c2->id);
+        $cm1ctx = context_module::instance($cm1->cmid);
+
+        $this->enable_logging();
+        $manager = get_log_manager(true);
+        $path = [get_string('privacy:path:logs', 'tool_log'), get_string('pluginname', 'logstore_legacy')];
+
+        // User 1 is the author.
+        $this->setUser($u1);
+        $e = unittest_executed::create(['context' => $cm1ctx, 'other' => ['sample' => 1]]);
+        $e->trigger();
+        $e = unittest_executed::create(['context' => $cm1ctx, 'other' => ['sample' => 2]]);
+        $e->trigger();
+        $e = unittest_executed::create(['context' => $c1ctx, 'other' => ['sample' => 3]]);
+        $e->trigger();
+        $e = unittest_executed::create(['context' => $sysctx, 'other' => ['sample' => 4]]);
+        $e->trigger();
+
+        // User 2 is the author.
+        $this->setUser($u2);
+        $e = unittest_executed::create(['context' => $cm1ctx, 'other' => ['sample' => 5]]);
+        $e->trigger();
+        $e = unittest_executed::create(['context' => $c1ctx, 'other' => ['sample' => 6]]);
+        $e->trigger();
+        $e = unittest_executed::create(['context' => $sysctx, 'other' => ['sample' => 7]]);
+        $e->trigger();
+
+        // Test export.
+        provider::export_user_data(new approved_contextlist($u1, 'logstore_legacy', [$cm1ctx->id]));
+        $data = writer::with_context($c1ctx)->get_data($path);
+        $this->assertEmpty($data);
+        $data = writer::with_context($cm1ctx)->get_data($path);
+        $this->assertCount(2, $data->logs);
+
+        writer::reset();
+        provider::export_user_data(new approved_contextlist($u1, 'logstore_legacy', [$c1ctx->id]));
+        $data = writer::with_context($cm1ctx)->get_data($path);
+        $this->assertEmpty($data);
+        $data = writer::with_context($c1ctx)->get_data($path);
+        $this->assertCount(1, $data->logs);
+
+        writer::reset();
+        provider::export_user_data(new approved_contextlist($u1, 'logstore_legacy', [$sysctx->id]));
+        $data = writer::with_context($sysctx)->get_data($path);
+        $this->assertCount(1, $data->logs);
+    }
+
+    /**
+     * Assert the content of a context list.
+     *
+     * @param contextlist $contextlist The collection.
+     * @param array $expected List of expected contexts or IDs.
+     * @return void
+     */
+    protected function assert_contextlist_equals($contextlist, array $expected) {
+        $expectedids = array_map(function($context) {
+            if (is_object($context)) {
+                return $context->id;
+            }
+            return $context;
+        }, $expected);
+        $contextids = array_map('intval', $contextlist->get_contextids());
+        sort($contextids);
+        sort($expectedids);
+        $this->assertEquals($expectedids, $contextids);
+    }
+
+    /**
+     * Enable logging.
+     *
+     * @return void
+     */
+    protected function enable_logging() {
+        set_config('enabled_stores', 'logstore_legacy', 'tool_log');
+        set_config('loglegacy', 1, 'logstore_legacy');
+        get_log_manager(true);
+    }
+
+    /**
+     * Get the contextlist for a user.
+     *
+     * @param object $user The user.
+     * @return contextlist
+     */
+    protected function get_contextlist_for_user($user) {
+        $contextlist = new contextlist();
+        provider::add_contexts_for_userid($contextlist, $user->id);
+        return $contextlist;
+    }
+}
diff --git a/admin/tool/log/store/standard/classes/privacy/provider.php b/admin/tool/log/store/standard/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..1994e29
--- /dev/null
@@ -0,0 +1,109 @@
+<?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    logstore_standard
+ * @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 logstore_standard\privacy;
+defined('MOODLE_INTERNAL') || die();
+
+use context;
+use core_privacy\local\metadata\collection;
+use core_privacy\local\request\contextlist;
+
+/**
+ * Data provider class.
+ *
+ * @package    logstore_standard
+ * @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,
+    \tool_log\local\privacy\logstore_provider {
+
+    use \tool_log\local\privacy\moodle_database_export_and_delete;
+
+    /**
+     * 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('log', [
+            'eventname' => 'privacy:metadata:log:eventname',
+            'userid' => 'privacy:metadata:log:userid',
+            'relateduserid' => 'privacy:metadata:log:relateduserid',
+            'anonymous' => 'privacy:metadata:log:anonymous',
+            'other' => 'privacy:metadata:log:other',
+            'timecreated' => 'privacy:metadata:log:timecreated',
+            'origin' => 'privacy:metadata:log:origin',
+            'ip' => 'privacy:metadata:log:ip',
+            'realuserid' => 'privacy:metadata:log:realuserid',
+        ], 'privacy:metadata:log');
+        return $collection;
+    }
+
+    /**
+     * Add contexts that contain user information for the specified user.
+     *
+     * @param contextlist $contextlist The contextlist to add the contexts to.
+     * @param int $userid The user to find the contexts for.
+     * @return void
+     */
+    public static function add_contexts_for_userid(contextlist $contextlist, $userid) {
+        $sql = "
+            SELECT ctx.id
+              FROM {context} ctx
+              JOIN {logstore_standard_log} l
+                ON l.contextid = ctx.id
+             WHERE l.userid = :userid1
+                OR l.relateduserid = :userid2
+                OR l.realuserid = :userid3";
+        $contextlist->add_from_sql($sql, [
+            'userid1' => $userid,
+            'userid2' => $userid,
+            'userid3' => $userid,
+        ]);
+    }
+
+    /**
+     * Get the database object.
+     *
+     * @return array Containing moodle_database, string, or null values.
+     */
+    protected static function get_database_and_table() {
+        global $DB;
+        return [$DB, 'logstore_standard_log'];
+    }
+
+    /**
+     * Get the path to export the logs to.
+     *
+     * @return array
+     */
+    protected static function get_export_subcontext() {
+        return [get_string('privacy:path:logs', 'tool_log'), get_string('pluginname', 'logstore_standard')];
+    }
+}
index 2e2c2a7..33c229f 100644 (file)
 $string['buffersize'] = 'Write buffer size';
 $string['pluginname'] = 'Standard log';
 $string['pluginname_desc'] = 'A log plugin stores log entries in a Moodle database table.';
+$string['privacy:metadata:log'] = 'A collection of past events';
+$string['privacy:metadata:log:anonymous'] = 'Whether the event was flagged as anonymous';
+$string['privacy:metadata:log:eventname'] = 'The event name';
+$string['privacy:metadata:log:ip'] = 'The IP address used at the time of the event';
+$string['privacy:metadata:log:origin'] = 'The origin of the event';
+$string['privacy:metadata:log:other'] = 'Additional information about the event';
+$string['privacy:metadata:log:realuserid'] = 'The ID of the real user behind the event, when masquerading a user.';
+$string['privacy:metadata:log:relateduserid'] = 'The ID of a user related to this event';
+$string['privacy:metadata:log:timecreated'] = 'The time at which the event occurred';
+$string['privacy:metadata:log:userid'] = 'The ID of the user who triggered this event';
 $string['taskcleanup'] = 'Log table cleanup';
diff --git a/admin/tool/log/store/standard/tests/privacy_test.php b/admin/tool/log/store/standard/tests/privacy_test.php
new file mode 100644 (file)
index 0000000..5103b3a
--- /dev/null
@@ -0,0 +1,373 @@
+<?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 tests.
+ *
+ * @package    logstore_standard
+ * @category   test
+ * @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
+ */
+
+defined('MOODLE_INTERNAL') || die();
+global $CFG;
+
+use core_privacy\tests\provider_testcase;
+use core_privacy\local\request\contextlist;
+use core_privacy\local\request\approved_contextlist;
+use core_privacy\local\request\transform;
+use core_privacy\local\request\writer;
+use logstore_standard\privacy\provider;
+
+require_once(__DIR__ . '/fixtures/event.php');
+
+/**
+ * Data provider testcase class.
+ *
+ * @package    logstore_standard
+ * @category   test
+ * @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 logstore_standard_privacy_testcase extends provider_testcase {
+
+    public function setUp() {
+        $this->resetAfterTest();
+        $this->preventResetByRollback(); // Logging waits till the transaction gets committed.
+    }
+
+    public function test_get_contexts_for_userid() {
+        $admin = \core_user::get_user(2);
+        $u1 = $this->getDataGenerator()->create_user();
+        $u2 = $this->getDataGenerator()->create_user();
+        $u3 = $this->getDataGenerator()->create_user();
+
+        $c1 = $this->getDataGenerator()->create_course();
+        $cm1 = $this->getDataGenerator()->create_module('url', ['course' => $c1]);
+        $c2 = $this->getDataGenerator()->create_course();
+        $cm2 = $this->getDataGenerator()->create_module('url', ['course' => $c2]);
+
+        $sysctx = context_system::instance();
+        $c1ctx = context_course::instance($c1->id);
+        $c2ctx = context_course::instance($c2->id);
+        $cm1ctx = context_module::instance($cm1->cmid);
+        $cm2ctx = context_module::instance($cm2->cmid);
+
+        $this->enable_logging();
+        $manager = get_log_manager(true);
+
+        // User 1 is the author.
+        $this->setUser($u1);
+        $this->assert_contextlist_equals($this->get_contextlist_for_user($u1), []);
+        $e = \logstore_standard\event\unittest_executed::create(['context' => $cm1ctx]);
+        $e->trigger();
+        $this->assert_contextlist_equals($this->get_contextlist_for_user($u1), [$cm1ctx]);
+
+        // User 2 is the related user.
+        $this->setUser(0);
+        $this->assert_contextlist_equals($this->get_contextlist_for_user($u2), []);
+        $e = \logstore_standard\event\unittest_executed::create(['context' => $cm2ctx, 'relateduserid' => $u2->id]);
+        $e->trigger();
+        $this->assert_contextlist_equals($this->get_contextlist_for_user($u2), [$cm2ctx]);
+
+        // Admin user is the real user.
+        $this->assert_contextlist_equals($this->get_contextlist_for_user($admin), []);
+        $this->assert_contextlist_equals($this->get_contextlist_for_user($u3), []);
+        $this->setAdminUser();
+        \core\session\manager::loginas($u3->id, $sysctx);
+        $e = \logstore_standard\event\unittest_executed::create(['context' => $c1ctx]);
+        $e->trigger();
+        $this->assert_contextlist_equals($this->get_contextlist_for_user($admin), [$sysctx, $c1ctx]);
+        $this->assert_contextlist_equals($this->get_contextlist_for_user($u3), [$sysctx, $c1ctx]);
+
+        // By admin user masquerading u1 related to u3.
+        $this->assert_contextlist_equals($this->get_contextlist_for_user($u1), [$cm1ctx]);
+        $this->assert_contextlist_equals($this->get_contextlist_for_user($u3), [$sysctx, $c1ctx]);
+        $this->assert_contextlist_equals($this->get_contextlist_for_user($admin), [$sysctx, $c1ctx]);
+        $this->setAdminUser();
+        \core\session\manager::loginas($u1->id, context_system::instance());
+        $e = \logstore_standard\event\unittest_executed::create(['context' => $c2ctx, 'relateduserid' => $u3->id]);
+        $e->trigger();
+        $this->assert_contextlist_equals($this->get_contextlist_for_user($u1), [$sysctx, $cm1ctx, $c2ctx]);
+        $this->assert_contextlist_equals($this->get_contextlist_for_user($u3), [$sysctx, $c1ctx, $c2ctx]);
+        $this->assert_contextlist_equals($this->get_contextlist_for_user($admin), [$sysctx, $c1ctx, $c2ctx]);
+    }
+
+    public function test_delete_data_for_user() {
+        global $DB;
+        $u1 = $this->getDataGenerator()->create_user();
+        $u2 = $this->getDataGenerator()->create_user();
+        $c1 = $this->getDataGenerator()->create_course();
+        $c2 = $this->getDataGenerator()->create_course();
+        $sysctx = context_system::instance();
+        $c1ctx = context_course::instance($c1->id);
+        $c2ctx = context_course::instance($c2->id);
+
+        $this->enable_logging();
+        $manager = get_log_manager(true);
+
+        // User 1 is the author.
+        $this->setUser($u1);
+        $e = \logstore_standard\event\unittest_executed::create(['context' => $c1ctx]);
+        $e->trigger();
+        $e = \logstore_standard\event\unittest_executed::create(['context' => $c1ctx]);
+        $e->trigger();
+        $e = \logstore_standard\event\unittest_executed::create(['context' => $c2ctx]);
+        $e->trigger();
+
+        // User 2 is the author.
+        $this->setUser($u2);
+        $e = \logstore_standard\event\unittest_executed::create(['context' => $c1ctx]);
+        $e->trigger();
+        $e = \logstore_standard\event\unittest_executed::create(['context' => $c2ctx]);
+        $e->trigger();
+
+        // Confirm data present.
+        $this->assertTrue($DB->record_exists('logstore_standard_log', ['userid' => $u1->id, 'contextid' => $c1ctx->id]));
+        $this->assertEquals(3, $DB->count_records('logstore_standard_log', ['userid' => $u1->id]));
+        $this->assertEquals(2, $DB->count_records('logstore_standard_log', ['userid' => $u2->id]));
+
+        // Delete all the things!
+        provider::delete_data_for_user(new approved_contextlist($u1, 'logstore_standard', [$c1ctx->id]));
+        $this->assertFalse($DB->record_exists('logstore_standard_log', ['userid' => $u1->id, 'contextid' => $c1ctx->id]));
+        $this->assertEquals(1, $DB->count_records('logstore_standard_log', ['userid' => $u1->id]));
+        $this->assertEquals(2, $DB->count_records('logstore_standard_log', ['userid' => $u2->id]));
+    }
+
+    public function test_delete_data_for_all_users_in_context() {
+        global $DB;
+        $u1 = $this->getDataGenerator()->create_user();
+        $u2 = $this->getDataGenerator()->create_user();
+        $c1 = $this->getDataGenerator()->create_course();
+        $c2 = $this->getDataGenerator()->create_course();
+        $sysctx = context_system::instance();
+        $c1ctx = context_course::instance($c1->id);
+        $c2ctx = context_course::instance($c2->id);
+
+        $this->enable_logging();
+        $manager = get_log_manager(true);
+
+        // User 1 is the author.
+        $this->setUser($u1);
+        $e = \logstore_standard\event\unittest_executed::create(['context' => $c1ctx]);
+        $e->trigger();
+        $e = \logstore_standard\event\unittest_executed::create(['context' => $c1ctx]);
+        $e->trigger();
+        $e = \logstore_standard\event\unittest_executed::create(['context' => $c2ctx]);
+        $e->trigger();
+
+        // User 2 is the author.
+        $this->setUser($u2);
+        $e = \logstore_standard\event\unittest_executed::create(['context' => $c1ctx]);
+        $e->trigger();
+        $e = \logstore_standard\event\unittest_executed::create(['context' => $c2ctx]);
+        $e->trigger();
+
+        // Confirm data present.
+        $this->assertTrue($DB->record_exists('logstore_standard_log', ['contextid' => $c1ctx->id]));
+        $this->assertEquals(3, $DB->count_records('logstore_standard_log', ['userid' => $u1->id]));
+        $this->assertEquals(2, $DB->count_records('logstore_standard_log', ['userid' => $u2->id]));
+
+        // Delete all the things!
+        provider::delete_data_for_all_users_in_context($c1ctx);
+        $this->assertFalse($DB->record_exists('logstore_standard_log', ['contextid' => $c1ctx->id]));
+        $this->assertEquals(1, $DB->count_records('logstore_standard_log', ['userid' => $u1->id]));
+        $this->assertEquals(1, $DB->count_records('logstore_standard_log', ['userid' => $u2->id]));
+    }
+
+    public function test_export_data_for_user() {
+        $admin = \core_user::get_user(2);
+        $u1 = $this->getDataGenerator()->create_user();
+        $u2 = $this->getDataGenerator()->create_user();
+        $u3 = $this->getDataGenerator()->create_user();
+        $u4 = $this->getDataGenerator()->create_user();
+        $c1 = $this->getDataGenerator()->create_course();
+        $cm1 = $this->getDataGenerator()->create_module('url', ['course' => $c1]);
+        $c2 = $this->getDataGenerator()->create_course();
+        $cm2 = $this->getDataGenerator()->create_module('url', ['course' => $c2]);
+        $sysctx = context_system::instance();
+        $c1ctx = context_course::instance($c1->id);
+        $c2ctx = context_course::instance($c2->id);
+        $cm1ctx = context_module::instance($cm1->cmid);
+        $cm2ctx = context_module::instance($cm2->cmid);
+
+        $path = [get_string('privacy:path:logs', 'tool_log'), get_string('pluginname', 'logstore_standard')];
+        $this->enable_logging();
+        $manager = get_log_manager(true);
+
+        // User 1 is the author.
+        $this->setUser($u1);
+        $e = \logstore_standard\event\unittest_executed::create(['context' => $c1ctx, 'other' => ['i' => 0]]);
+        $e->trigger();
+
+        // User 2 is related.
+        $this->setUser(0);
+        $e = \logstore_standard\event\unittest_executed::create(['context' => $c1ctx, 'relateduserid' => $u2->id,
+            'other' => ['i' => 1]]);
+        $e->trigger();
+
+        // Admin user masquerades u3, which is related to u4.
+        $this->setAdminUser();
+        \core\session\manager::loginas($u3->id, $sysctx);
+        $e = \logstore_standard\event\unittest_executed::create(['context' => $c1ctx, 'relateduserid' => $u4->id,
+            'other' => ['i' => 2]]);
+        $e->trigger();
+
+        // Confirm data present for u1.
+        provider::export_user_data(new approved_contextlist($u1, 'logstore_standard', [$c2ctx->id, $c1ctx->id]));
+        $data = writer::with_context($c2ctx)->get_data($path);
+        $this->assertEmpty($data);
+        $data = writer::with_context($c1ctx)->get_data($path);
+        $this->assertCount(1, $data->logs);
+        $this->assertEquals(transform::yesno(true), $data->logs[0]['author_of_the_action_was_you']);
+        $this->assertSame(0, $data->logs[0]['other']['i']);
+
+        // Confirm data present for u2.
+        writer::reset();
+        provider::export_user_data(new approved_contextlist($u2, 'logstore_standard', [$c2ctx->id, $c1ctx->id]));
+        $data = writer::with_context($c2ctx)->get_data($path);
+        $this->assertEmpty($data);
+        $data = writer::with_context($c1ctx)->get_data($path);
+        $this->assertCount(1, $data->logs);
+        $this->assertEquals(transform::yesno(false), $data->logs[0]['author_of_the_action_was_you']);
+        $this->assertEquals(transform::yesno(true), $data->logs[0]['related_user_was_you']);
+        $this->assertSame(1, $data->logs[0]['other']['i']);
+
+        // Confirm data present for u3.
+        writer::reset();
+        provider::export_user_data(new approved_contextlist($u3, 'logstore_standard', [$c2ctx->id, $c1ctx->id]));
+        $data = writer::with_context($c2ctx)->get_data($path);
+        $this->assertEmpty($data);
+        $data = writer::with_context($c1ctx)->get_data($path);
+        $this->assertCount(1, $data->logs);
+        $this->assertEquals(transform::yesno(true), $data->logs[0]['author_of_the_action_was_you']);
+        $this->assertEquals(transform::yesno(false), $data->logs[0]['related_user_was_you']);
+        $this->assertEquals(transform::yesno(true), $data->logs[0]['author_of_the_action_was_masqueraded']);
+        $this->assertEquals(transform::yesno(false), $data->logs[0]['masquerading_user_was_you']);
+        $this->assertSame(2, $data->logs[0]['other']['i']);
+
+        // Confirm data present for u4.
+        writer::reset();
+        provider::export_user_data(new approved_contextlist($u4, 'logstore_standard', [$c2ctx->id, $c1ctx->id]));
+        $data = writer::with_context($c2ctx)->get_data($path);
+        $this->assertEmpty($data);
+        $data = writer::with_context($c1ctx)->get_data($path);
+        $this->assertCount(1, $data->logs);
+        $this->assertEquals(transform::yesno(false), $data->logs[0]['author_of_the_action_was_you']);
+        $this->assertEquals(transform::yesno(true), $data->logs[0]['related_user_was_you']);
+        $this->assertEquals(transform::yesno(true), $data->logs[0]['author_of_the_action_was_masqueraded']);
+        $this->assertEquals(transform::yesno(false), $data->logs[0]['masquerading_user_was_you']);
+        $this->assertSame(2, $data->logs[0]['other']['i']);
+
+        // Add anonymous events.
+        $this->setUser($u1);
+        $e = \logstore_standard\event\unittest_executed::create(['context' => $c2ctx, 'relateduserid' => $u2->id,
+            'anonymous' => true]);
+        $e->trigger();
+        $this->setAdminUser();
+        \core\session\manager::loginas($u3->id, $sysctx);
+        $e = \logstore_standard\event\unittest_executed::create(['context' => $c2ctx, 'relateduserid' => $u4->id,
+            'anonymous' => true]);
+        $e->trigger();
+
+        // Confirm data present for u1.
+        provider::export_user_data(new approved_contextlist($u1, 'logstore_standard', [$c2ctx->id]));
+        $data = writer::with_context($c2ctx)->get_data($path);
+        $this->assertCount(1, $data->logs);
+        $this->assertEquals(transform::yesno(true), $data->logs[0]['action_was_done_anonymously']);
+        $this->assertEquals(transform::yesno(true), $data->logs[0]['author_of_the_action_was_you']);
+
+        // Confirm data present for u2.
+        writer::reset();
+        provider::export_user_data(new approved_contextlist($u2, 'logstore_standard', [$c2ctx->id]));
+        $data = writer::with_context($c2ctx)->get_data($path);
+        $this->assertCount(1, $data->logs);
+        $this->assertEquals(transform::yesno(true), $data->logs[0]['action_was_done_anonymously']);
+        $this->assertArrayNotHasKey('author_of_the_action_was_you', $data->logs[0]);
+        $this->assertArrayNotHasKey('authorid', $data->logs[0]);
+        $this->assertEquals(transform::yesno(true), $data->logs[0]['related_user_was_you']);
+
+        // Confirm data present for u3.
+        writer::reset();
+        provider::export_user_data(new approved_contextlist($u3, 'logstore_standard', [$c2ctx->id]));
+        $data = writer::with_context($c2ctx)->get_data($path);
+        $this->assertCount(1, $data->logs);
+        $this->assertEquals(transform::yesno(true), $data->logs[0]['action_was_done_anonymously']);
+        $this->assertEquals(transform::yesno(true), $data->logs[0]['author_of_the_action_was_you']);
+        $this->assertEquals(transform::yesno(true), $data->logs[0]['author_of_the_action_was_masqueraded']);
+        $this->assertArrayNotHasKey('masquerading_user_was_you', $data->logs[0]);
+        $this->assertArrayNotHasKey('masqueradinguserid', $data->logs[0]);
+
+        // Confirm data present for u4.
+        writer::reset();
+        provider::export_user_data(new approved_contextlist($u4, 'logstore_standard', [$c2ctx->id]));
+        $data = writer::with_context($c2ctx)->get_data($path);
+        $this->assertCount(1, $data->logs);
+        $this->assertEquals(transform::yesno(true), $data->logs[0]['action_was_done_anonymously']);
+        $this->assertArrayNotHasKey('author_of_the_action_was_you', $data->logs[0]);
+        $this->assertArrayNotHasKey('authorid', $data->logs[0]);
+        $this->assertEquals(transform::yesno(true), $data->logs[0]['related_user_was_you']);
+        $this->assertEquals(transform::yesno(true), $data->logs[0]['author_of_the_action_was_masqueraded']);
+        $this->assertArrayNotHasKey('masquerading_user_was_you', $data->logs[0]);
+        $this->assertArrayNotHasKey('masqueradinguserid', $data->logs[0]);
+    }
+
+    /**
+     * Assert the content of a context list.
+     *
+     * @param contextlist $contextlist The collection.
+     * @param array $expected List of expected contexts or IDs.
+     * @return void
+     */
+    protected function assert_contextlist_equals($contextlist, array $expected) {
+        $expectedids = array_map(function($context) {
+            if (is_object($context)) {
+                return $context->id;
+            }
+            return $context;
+        }, $expected);
+        $contextids = array_map('intval', $contextlist->get_contextids());
+        sort($contextids);
+        sort($expectedids);
+        $this->assertEquals($expectedids, $contextids);
+    }
+
+    /**
+     * Enable logging.
+     *
+     * @return void
+     */
+    protected function enable_logging() {
+        set_config('enabled_stores', 'logstore_standard', 'tool_log');
+        set_config('buffersize', 0, 'logstore_standard');
+        set_config('logguests', 1, 'logstore_standard');
+    }
+
+    /**
+     * Get the contextlist for a user.
+     *
+     * @param object $user The user.
+     * @return contextlist
+     */
+    protected function get_contextlist_for_user($user) {
+        $contextlist = new contextlist();
+        provider::add_contexts_for_userid($contextlist, $user->id);
+        return $contextlist;
+    }
+}
diff --git a/admin/tool/log/tests/privacy_test.php b/admin/tool/log/tests/privacy_test.php
new file mode 100644 (file)
index 0000000..9e5be71
--- /dev/null
@@ -0,0 +1,176 @@
+<?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 tests.
+ *
+ * @package    tool_log
+ * @category   test
+ * @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
+ */
+
+defined('MOODLE_INTERNAL') || die();
+global $CFG;
+
+use core_privacy\tests\provider_testcase;
+use core_privacy\local\request\contextlist;
+use core_privacy\local\request\approved_contextlist;
+use core_privacy\local\request\transform;
+use core_privacy\local\request\writer;
+use tool_log\privacy\provider;
+
+require_once($CFG->dirroot . '/admin/tool/log/store/standard/tests/fixtures/event.php');
+
+/**
+ * Data provider testcase class.
+ *
+ * We're not testing the full functionality, just that the provider passes the requests
+ * down to at least one of its subplugin. Each subplugin should have tests to cover the
+ * different provider methods in depth.
+ *
+ * @package    tool_log
+ * @category   test
+ * @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 tool_log_privacy_testcase extends provider_testcase {
+
+    public function setUp() {
+        $this->resetAfterTest();
+        $this->preventResetByRollback(); // Logging waits till the transaction gets committed.
+    }
+
+    public function test_get_contexts_for_userid() {
+        $admin = \core_user::get_user(2);
+        $u1 = $this->getDataGenerator()->create_user();
+        $c1 = $this->getDataGenerator()->create_course();
+        $c1ctx = context_course::instance($c1->id);
+
+        $this->enable_logging();
+        $manager = get_log_manager(true);
+
+        $this->setUser($u1);
+        $this->assertEmpty(provider::get_contexts_for_userid($u1->id)->get_contextids(), []);
+        $e = \logstore_standard\event\unittest_executed::create(['context' => $c1ctx]);
+        $e->trigger();
+        $this->assertEquals($c1ctx->id, provider::get_contexts_for_userid($u1->id)->get_contextids()[0]);
+    }
+
+    public function test_delete_data_for_user() {
+        global $DB;
+        $u1 = $this->getDataGenerator()->create_user();
+        $u2 = $this->getDataGenerator()->create_user();
+        $c1 = $this->getDataGenerator()->create_course();
+        $c1ctx = context_course::instance($c1->id);
+
+        $this->enable_logging();
+        $manager = get_log_manager(true);
+
+        // User 1 is the author.
+        $this->setUser($u1);
+        $e = \logstore_standard\event\unittest_executed::create(['context' => $c1ctx]);
+        $e->trigger();
+        $e = \logstore_standard\event\unittest_executed::create(['context' => $c1ctx]);
+        $e->trigger();
+
+        // User 2 is the author.
+        $this->setUser($u2);
+        $e = \logstore_standard\event\unittest_executed::create(['context' => $c1ctx]);
+        $e->trigger();
+
+        // Confirm data present.
+        $this->assertTrue($DB->record_exists('logstore_standard_log', ['userid' => $u1->id, 'contextid' => $c1ctx->id]));
+        $this->assertEquals(2, $DB->count_records('logstore_standard_log', ['userid' => $u1->id]));
+        $this->assertEquals(1, $DB->count_records('logstore_standard_log', ['userid' => $u2->id]));
+
+        // Delete all the things!
+        provider::delete_data_for_user(new approved_contextlist($u1, 'logstore_standard', [$c1ctx->id]));
+        $this->assertFalse($DB->record_exists('logstore_standard_log', ['userid' => $u1->id, 'contextid' => $c1ctx->id]));
+        $this->assertEquals(0, $DB->count_records('logstore_standard_log', ['userid' => $u1->id]));
+        $this->assertEquals(1, $DB->count_records('logstore_standard_log', ['userid' => $u2->id]));
+    }
+
+    public function test_delete_data_for_all_users_in_context() {
+        global $DB;
+        $u1 = $this->getDataGenerator()->create_user();
+        $u2 = $this->getDataGenerator()->create_user();
+        $c1 = $this->getDataGenerator()->create_course();
+        $c1ctx = context_course::instance($c1->id);
+
+        $this->enable_logging();
+        $manager = get_log_manager(true);
+
+        // User 1 is the author.
+        $this->setUser($u1);
+        $e = \logstore_standard\event\unittest_executed::create(['context' => $c1ctx]);
+        $e->trigger();
+        $e = \logstore_standard\event\unittest_executed::create(['context' => $c1ctx]);
+        $e->trigger();
+
+        // User 2 is the author.
+        $this->setUser($u2);
+        $e = \logstore_standard\event\unittest_executed::create(['context' => $c1ctx]);
+        $e->trigger();
+
+        // Confirm data present.
+        $this->assertTrue($DB->record_exists('logstore_standard_log', ['contextid' => $c1ctx->id]));
+        $this->assertEquals(2, $DB->count_records('logstore_standard_log', ['userid' => $u1->id]));
+        $this->assertEquals(1, $DB->count_records('logstore_standard_log', ['userid' => $u2->id]));
+
+        // Delete all the things!
+        provider::delete_data_for_all_users_in_context($c1ctx);
+        $this->assertFalse($DB->record_exists('logstore_standard_log', ['contextid' => $c1ctx->id]));
+        $this->assertEquals(0, $DB->count_records('logstore_standard_log', ['userid' => $u1->id]));
+        $this->assertEquals(0, $DB->count_records('logstore_standard_log', ['userid' => $u2->id]));
+    }
+
+    public function test_export_data_for_user() {
+        $admin = \core_user::get_user(2);
+        $u1 = $this->getDataGenerator()->create_user();
+        $c1 = $this->getDataGenerator()->create_course();
+        $c1ctx = context_course::instance($c1->id);
+
+        $path = [get_string('privacy:path:logs', 'tool_log'), get_string('pluginname', 'logstore_standard')];
+        $this->enable_logging();
+        $manager = get_log_manager(true);
+
+        // User 1 is the author.
+        $this->setUser($u1);
+        $e = \logstore_standard\event\unittest_executed::create(['context' => $c1ctx, 'other' => ['i' => 123]]);
+        $e->trigger();
+
+        // Confirm data present for u1.
+        provider::export_user_data(new approved_contextlist($u1, 'tool_log', [$c1ctx->id]));
+        $data = writer::with_context($c1ctx)->get_data($path);
+        $this->assertCount(1, $data->logs);
+        $this->assertEquals(transform::yesno(true), $data->logs[0]['author_of_the_action_was_you']);
+        $this->assertSame(123, $data->logs[0]['other']['i']);
+    }
+
+    /**
+     * Enable logging.
+     *
+     * @return void
+     */
+    protected function enable_logging() {
+        set_config('enabled_stores', 'logstore_standard', 'tool_log');
+        set_config('buffersize', 0, 'logstore_standard');
+        set_config('logguests', 1, 'logstore_standard');
+    }
+}
index f63fbbc..8390ffa 100644 (file)
Binary files a/admin/tool/lp/amd/build/planactions.min.js and b/admin/tool/lp/amd/build/planactions.min.js differ
index 44a335d..d4320c3 100644 (file)
@@ -130,6 +130,11 @@ define(['jquery',
      * @return {Promise}
      */
     PlanActions.prototype._callAndRefresh = function(calls, planData) {
+        // Because this function causes a refresh, we must track the JS completion from start to finish to prevent
+        // stale reference issues in Behat.
+        var callKey = 'tool_lp/planactions:_callAndRefresh-' + Math.floor(Math.random() * Math.floor(1000));
+        M.util.js_pending(callKey);
+
         var self = this;
         calls.push({
             methodname: self._contextMethod,
@@ -141,7 +146,10 @@ define(['jquery',
             .then(function() {
                 return self._renderView(arguments[arguments.length - 1]);
             })
-            .fail(notification.exception);
+            .fail(notification.exception)
+            .always(function() {
+                return M.util.js_complete(callKey);
+            });
     };
 
     /**
index c8ef59f..6596aad 100644 (file)
@@ -310,6 +310,16 @@ class manager {
         return true;
     }
 
+    /**
+     * Remove older verification failures.
+     *
+     * @return void
+     */
+    public function tidy_old_verification_failures() {
+        global $DB;
+        $DB->delete_records_select('messageinbound_messagelist', 'timecreated < :time', ['time' => time() - DAYSECS]);
+    }
+
     /**
      * Process a message and pass it through the Inbound Message handling systems.
      *
diff --git a/admin/tool/messageinbound/classes/privacy/provider.php b/admin/tool/messageinbound/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..53be916
--- /dev/null
@@ -0,0 +1,173 @@
+<?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    tool_messageinbound
+ * @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 tool_messageinbound\privacy;
+defined('MOODLE_INTERNAL') || die();
+
+use context;
+use context_user;
+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;
+
+/**
+ * Data provider class.
+ *
+ * @package    tool_messageinbound
+ * @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\plugin\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('messageinbound_messagelist', [
+            'messageid' => 'privacy:metadata:messagelist:messageid',
+            'userid' => 'privacy:metadata:messagelist:userid',
+            'address' => 'privacy:metadata:messagelist:address',
+            'timecreated' => 'privacy:metadata:messagelist:timecreated',
+        ], 'privacy:metadata:messagelist');
+
+        // Arguably the keys are handled by \core\message\inbound\address_manager and thus could/should be handled by core.
+        $collection->add_subsystem_link('core_userkey', [], 'privacy:metadata:coreuserkey');
+
+        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();
+
+        // Always add the user context so we're sure we're not dodging user keys, besides it's not costly to do so.
+        $contextlist->add_user_context($userid);
+
+        return $contextlist;
+    }
+
+    /**
+     * 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;
+        if (!static::approved_contextlist_contains_my_context($contextlist)) {
+            // We only care about the user's user context.
+            return;
+        }
+
+        $userid = $contextlist->get_user()->id;
+        $context = context_user::instance($userid);
+        $path = [get_string('messageinbound', 'tool_messageinbound')];
+
+        // Export user keys.
+        \core_userkey\privacy\provider::export_userkeys($context, $path, 'messageinbound_handler');
+
+        // Export the message list.
+        $data = [];
+        $recordset = $DB->get_recordset('messageinbound_messagelist', ['userid' => $userid], 'timecreated, id');
+        foreach ($recordset as $record) {
+            $data[] = [
+                'received_at' => $record->address,
+                'timecreated' => transform::datetime($record->timecreated),
+            ];
+        }
+        $recordset->close();
+        writer::with_context($context)->export_data($path, (object) ['messages_pending_validation' => $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) {
+        global $DB;
+        if ($context->contextlevel != CONTEXT_USER) {
+            return;
+        }
+
+        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) {
+        global $DB;
+        if (!static::approved_contextlist_contains_my_context($contextlist)) {
+            // We only care about the user's user context.
+            return;
+        }
+
+        static::delete_user_data($contextlist->get_user()->id);
+    }
+
+    /**
+     * Delete a user's data.
+     *
+     * @param int $userid The user ID.
+     * @return void
+     */
+    protected static function delete_user_data($userid) {
+        global $DB;
+        $DB->delete_records_select('messageinbound_messagelist', 'userid = :userid', ['userid' => $userid]);
+        \core_userkey\privacy\provider::delete_userkeys('messageinbound_handler', $userid);
+    }
+
+    /**
+     * Return whether the contextlist contains our own context.
+     *
+     * @param approved_contextlist $contextlist The contextlist
+     * @return bool
+     */
+    protected static function approved_contextlist_contains_my_context(approved_contextlist $contextlist) {
+        $userid = $contextlist->get_user()->id;
+        foreach ($contextlist->get_contexts() as $context) {
+            if ($context->contextlevel == CONTEXT_USER && $context->instanceid == $userid) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+}
index e8b1c7a..fc2c01d 100644 (file)
@@ -49,6 +49,7 @@ class cleanup_task extends \core\task\scheduled_task {
      */
     public function execute() {
         $manager = new \tool_messageinbound\manager();
-        return $manager->tidy_old_messages();
+        $manager->tidy_old_messages();
+        $manager->tidy_old_verification_failures();
     }
 }
index b074060..c847b03 100644 (file)
@@ -94,6 +94,12 @@ $string['onehour'] = 'One hour';
 $string['oneweek'] = 'One week';
 $string['oneyear'] = 'One year';
 $string['pluginname'] = 'Inbound message configuration';
+$string['privacy:metadata:coreuserkey'] = 'User\'s keys to validate the email received';
+$string['privacy:metadata:messagelist'] = 'A list of message identifiers which failed validation and requires further authorisation';
+$string['privacy:metadata:messagelist:address'] = 'The address at which the email was sent';
+$string['privacy:metadata:messagelist:messageid'] = 'The message ID';
+$string['privacy:metadata:messagelist:timecreated'] = 'The time at which the record was made';
+$string['privacy:metadata:messagelist:userid'] = 'The ID of user who need to approve the message';
 $string['replysubjectprefix'] = 'Re:';
 $string['requirevalidation'] = 'Validate sender address';
 $string['name'] = 'Name';
diff --git a/admin/tool/messageinbound/tests/manager_test.php b/admin/tool/messageinbound/tests/manager_test.php
new file mode 100644 (file)
index 0000000..b88a742
--- /dev/null
@@ -0,0 +1,97 @@
+<?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/>.
+
+/**
+ * Manager tests.
+ *
+ * @package    tool_messageinbound
+ * @category   test
+ * @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
+ */
+
+defined('MOODLE_INTERNAL') || die();
+global $CFG;
+
+use core_privacy\tests\provider_testcase;
+use core_privacy\local\request\approved_contextlist;
+use core_privacy\local\request\transform;
+use core_privacy\local\request\writer;
+use tool_messageinbound\privacy\provider;
+
+/**
+ * Manager testcase class.
+ *
+ * @package    tool_messageinbound
+ * @category   test
+ * @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 tool_messageinbound_manager_testcase extends provider_testcase {
+
+    public function setUp() {
+        global $CFG;
+        $this->resetAfterTest();
+
+        // Pretend the system is enabled.
+        $CFG->messageinbound_enabled = true;
+        $CFG->messageinbound_mailbox = 'mailbox';
+        $CFG->messageinbound_domain = 'example.com';
+    }
+
+    public function test_tidy_old_verification_failures() {
+        global $DB;
+
+        $now = time();
+        $stale = $now - DAYSECS - 1;    // Make a second older because PHP Unit is too damn fast!!
+
+        $this->create_messagelist(['timecreated' => $now]);
+        $this->create_messagelist(['timecreated' => $now - HOURSECS]);
+        $this->create_messagelist(['timecreated' => $stale]);
+        $this->create_messagelist(['timecreated' => $stale - HOURSECS]);
+        $this->create_messagelist(['timecreated' => $stale - YEARSECS]);
+
+        $this->assertEquals(5, $DB->count_records('messageinbound_messagelist', []));
+        $this->assertEquals(3, $DB->count_records_select('messageinbound_messagelist', 'timecreated < :t', ['t' => $stale + 1]));
+
+        $manager = new \tool_messageinbound\manager();
+        $manager->tidy_old_verification_failures();
+
+        $this->assertEquals(2, $DB->count_records('messageinbound_messagelist', []));
+        $this->assertEquals(0, $DB->count_records_select('messageinbound_messagelist', 'timecreated < :t', ['t' => $stale + 1]));
+    }
+
+    /**
+     * Create a message to validate.
+     *
+     * @param array $params The params.
+     * @return stdClass
+     */
+    protected function create_messagelist(array $params) {
+        global $DB, $USER;
+        $record = (object) array_merge([
+            'messageid' => 'abc',
+            'userid' => $USER->id,
+            'address' => 'text@example.com',
+            'timecreated' => time(),
+        ], $params);
+        $record->id = $DB->insert_record('messageinbound_messagelist', $record);
+        return $record;
+    }
+
+}
diff --git a/admin/tool/messageinbound/tests/privacy_test.php b/admin/tool/messageinbound/tests/privacy_test.php
new file mode 100644 (file)
index 0000000..5be172c
--- /dev/null
@@ -0,0 +1,201 @@
+<?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 tests.
+ *
+ * @package    tool_messageinbound
+ * @category   test
+ * @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
+ */
+
+defined('MOODLE_INTERNAL') || die();
+global $CFG;
+
+use core_privacy\tests\provider_testcase;
+use core_privacy\local\request\approved_contextlist;
+use core_privacy\local\request\transform;
+use core_privacy\local\request\writer;
+use tool_messageinbound\privacy\provider;
+
+/**
+ * Data provider testcase class.
+ *
+ * @package    tool_messageinbound
+ * @category   test
+ * @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 tool_messageinbound_privacy_testcase extends provider_testcase {
+
+    public function setUp() {
+        global $CFG;
+        $this->resetAfterTest();
+
+        // Pretend the system is enabled.
+        $CFG->messageinbound_enabled = true;
+        $CFG->messageinbound_mailbox = 'mailbox';
+        $CFG->messageinbound_domain = 'example.com';
+    }
+
+    public function test_get_contexts_for_userid() {
+        $dg = $this->getDataGenerator();
+        $u1 = $dg->create_user();
+        $u2 = $dg->create_user();
+        $u1ctx = context_user::instance($u1->id);
+        $u2ctx = context_user::instance($u2->id);
+
+        $contexts = provider::get_contexts_for_userid($u1->id)->get_contexts();
+        $this->assertCount(1, $contexts);
+        $this->assertEquals($u1ctx->id, $contexts[0]->id);
+
+        $contexts = provider::get_contexts_for_userid($u2->id)->get_contexts();
+        $this->assertCount(1, $contexts);
+        $this->assertEquals($u2ctx->id, $contexts[0]->id);
+    }
+
+    public function test_delete_data_for_user() {
+        global $DB;
+        $dg = $this->getDataGenerator();
+        $u1 = $dg->create_user();
+        $u2 = $dg->create_user();
+        $u1ctx = context_user::instance($u1->id);
+        $u2ctx = context_user::instance($u2->id);
+
+        $addressmanager = new \core\message\inbound\address_manager();
+        $addressmanager->set_handler('\tool_messageinbound\message\inbound\invalid_recipient_handler');
+        $addressmanager->set_data(123);
+
+        // Create a user key for both users.
+        $addressmanager->generate($u1->id);
+        $addressmanager->generate($u2->id);
+
+        // Create a messagelist for both users.
+        $this->create_messagelist(['userid' => $u1->id]);
+        $this->create_messagelist(['userid' => $u2->id]);
+
+        $this->assertTrue($DB->record_exists('user_private_key', ['userid' => $u1->id, 'script' => 'messageinbound_handler']));
+        $this->assertTrue($DB->record_exists('user_private_key', ['userid' => $u2->id, 'script' => 'messageinbound_handler']));
+        $this->assertTrue($DB->record_exists('messageinbound_messagelist', ['userid' => $u1->id]));
+        $this->assertTrue($DB->record_exists('messageinbound_messagelist', ['userid' => $u2->id]));
+
+        // Passing another user's context does not do anything.
+        provider::delete_data_for_user(new approved_contextlist($u1, 'tool_messageinbound', [$u2ctx->id]));
+        $this->assertTrue($DB->record_exists('user_private_key', ['userid' => $u1->id, 'script' => 'messageinbound_handler']));
+        $this->assertTrue($DB->record_exists('user_private_key', ['userid' => $u2->id, 'script' => 'messageinbound_handler']));
+        $this->assertTrue($DB->record_exists('messageinbound_messagelist', ['userid' => $u1->id]));
+        $this->assertTrue($DB->record_exists('messageinbound_messagelist', ['userid' => $u2->id]));
+
+        // Deleting user 1.
+        provider::delete_data_for_user(new approved_contextlist($u1, 'tool_messageinbound', [$u1ctx->id]));
+        $this->assertFalse($DB->record_exists('user_private_key', ['userid' => $u1->id, 'script' => 'messageinbound_handler']));
+        $this->assertTrue($DB->record_exists('user_private_key', ['userid' => $u2->id, 'script' => 'messageinbound_handler']));
+        $this->assertFalse($DB->record_exists('messageinbound_messagelist', ['userid' => $u1->id]));
+        $this->assertTrue($DB->record_exists('messageinbound_messagelist', ['userid' => $u2->id]));
+    }
+
+    public function test_delete_data_for_all_users_in_context() {
+        global $DB;
+        $dg = $this->getDataGenerator();
+        $u1 = $dg->create_user();
+        $u2 = $dg->create_user();
+        $u1ctx = context_user::instance($u1->id);
+        $u2ctx = context_user::instance($u2->id);
+
+        $addressmanager = new \core\message\inbound\address_manager();
+        $addressmanager->set_handler('\tool_messageinbound\message\inbound\invalid_recipient_handler');
+        $addressmanager->set_data(123);
+
+        // Create a user key for both users.
+        $addressmanager->generate($u1->id);
+        $addressmanager->generate($u2->id);
+
+        // Create a messagelist for both users.
+        $this->create_messagelist(['userid' => $u1->id]);
+        $this->create_messagelist(['userid' => $u2->id]);
+
+        $this->assertTrue($DB->record_exists('user_private_key', ['userid' => $u1->id, 'script' => 'messageinbound_handler']));
+        $this->assertTrue($DB->record_exists('user_private_key', ['userid' => $u2->id, 'script' => 'messageinbound_handler']));
+        $this->assertTrue($DB->record_exists('messageinbound_messagelist', ['userid' => $u1->id]));
+        $this->assertTrue($DB->record_exists('messageinbound_messagelist', ['userid' => $u2->id]));
+
+        // Deleting user 1.
+        provider::delete_data_for_all_users_in_context($u1ctx);
+        $this->assertFalse($DB->record_exists('user_private_key', ['userid' => $u1->id, 'script' => 'messageinbound_handler']));
+        $this->assertTrue($DB->record_exists('user_private_key', ['userid' => $u2->id, 'script' => 'messageinbound_handler']));
+        $this->assertFalse($DB->record_exists('messageinbound_messagelist', ['userid' => $u1->id]));
+        $this->assertTrue($DB->record_exists('messageinbound_messagelist', ['userid' => $u2->id]));
+    }
+
+    public function test_export_data_for_user() {
+        $dg = $this->getDataGenerator();
+        $u1 = $dg->create_user();
+        $u2 = $dg->create_user();
+        $u1ctx = context_user::instance($u1->id);
+        $u2ctx = context_user::instance($u2->id);
+
+        $addressmanager = new \core\message\inbound\address_manager();
+        $addressmanager->set_handler('\tool_messageinbound\message\inbound\invalid_recipient_handler');
+        $addressmanager->set_data(123);
+
+        // Create a user key for both users.
+        $addressmanager->generate($u1->id);
+        $addressmanager->generate($u2->id);
+
+        // Create a messagelist for both users.
+        $this->create_messagelist(['userid' => $u1->id, 'address' => 'u1@example1.com']);
+        $this->create_messagelist(['userid' => $u1->id, 'address' => 'u1@example2.com']);
+        $this->create_messagelist(['userid' => $u2->id, 'address' => 'u2@example1.com']);
+
+        // Export for user.
+        $this->setUser($u1);
+        provider::export_user_data(new approved_contextlist($u1, 'tool_messageinbound', [$u1ctx->id, $u2ctx->id]));
+        $data = writer::with_context($u2ctx)->get_data([get_string('messageinbound', 'tool_messageinbound')]);
+        $this->assertEmpty($data);
+        $data = writer::with_context($u1ctx)->get_data([get_string('messageinbound', 'tool_messageinbound')]);
+        $this->assertCount(2, $data->messages_pending_validation);
+        $this->assertEquals('u1@example1.com', $data->messages_pending_validation[0]['received_at']);
+        $this->assertEquals('u1@example2.com', $data->messages_pending_validation[1]['received_at']);
+
+        $data = writer::with_context($u2ctx)->get_related_data([get_string('messageinbound', 'tool_messageinbound')], 'userkeys');
+        $this->assertEmpty($data);
+        $data = writer::with_context($u1ctx)->get_related_data([get_string('messageinbound', 'tool_messageinbound')], 'userkeys');
+        $this->assertCount(1, $data->keys);
+        $this->assertEquals('messageinbound_handler', $data->keys[0]->script);
+    }
+
+    /**
+     * Create a message to validate.
+     *
+     * @param array $params The params.
+     * @return stdClass
+     */
+    protected function create_messagelist(array $params) {
+        global $DB, $USER;
+        $record = (object) array_merge([
+            'messageid' => 'abc',
+            'userid' => $USER->id,
+            'address' => 'text@example.com',
+            'timecreated' => time(),
+        ], $params);
+        $record->id = $DB->insert_record('messageinbound_messagelist', $record);
+        return $record;
+    }
+
+}
index 8a79198..8e6aece 100644 (file)
@@ -15,7 +15,7 @@
 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 
 /**
- * Accept policies on behalf of users (non-JS version)
+ * Accept or revoke policies on behalf of users (non-JS version)
  *
  * @package     tool_policy
  * @copyright   2018 Marina Glancy
@@ -28,6 +28,7 @@ require_once($CFG->dirroot.'/user/editlib.php');
 $userids = optional_param_array('userids', null, PARAM_INT);
 $versionids = optional_param_array('versionids', null, PARAM_INT);
 $returnurl = optional_param('returnurl', null, PARAM_LOCALURL);
+$action = optional_param('action', null, PARAM_ALPHA);
 
 require_login();
 if (isguestuser()) {
@@ -48,7 +49,7 @@ if ($returnurl) {
 }
 // Initialise the form, this will also validate users, versions and check permission to accept policies.
 $form = new \tool_policy\form\accept_policy(null,
-    ['versionids' => $versionids, 'userids' => $userids, 'showbuttons' => true]);
+    ['versionids' => $versionids, 'userids' => $userids, 'showbuttons' => true, 'action' => $action]);
 $form->set_data(['returnurl' => $returnurl]);
 
 if ($form->is_cancelled()) {
@@ -58,8 +59,14 @@ if ($form->is_cancelled()) {
     redirect($returnurl);
 }
 
+if ($action == 'revoke') {
+    $title = get_string('revokedetails', 'tool_policy');
+} else {
+    $title = get_string('consentdetails', 'tool_policy');
+}
+
 $output = $PAGE->get_renderer('tool_policy');
 echo $output->header();
-echo $output->heading(get_string('consentdetails', 'tool_policy'));
+echo $output->heading($title);
 $form->display();
 echo $output->footer();
index 8c9aab2..da1432a 100644 (file)
Binary files a/admin/tool/policy/amd/build/acceptmodal.min.js and b/admin/tool/policy/amd/build/acceptmodal.min.js differ
index 07b9ed7..a642ac1 100644 (file)
@@ -71,6 +71,14 @@ define(['jquery', 'core/str', 'core/modal_factory', 'core/modal_events', 'core/n
             },
             {
                 key: 'ok'
+            },
+            {
+                key: 'revokedetails',
+                component: 'tool_policy'
+            },
+            {
+                key: 'irevokethepolicy',
+                component: 'tool_policy'
             }
         ];
 
@@ -111,16 +119,33 @@ define(['jquery', 'core/str', 'core/modal_factory', 'core/modal_events', 'core/n
          * @param {object} triggerElement The trigger HTML jQuery object
          */
         AcceptOnBehalf.prototype.showFormModal = function(formData, triggerElement) {
+            var action;
+            var params = formData.split('&');
+            for (var i = 0; i < params.length; i++) {
+                var pair = params[i].split('=');
+                if (pair[0] == 'action') {
+                    action = pair[1];
+                }
+            }
             // Fetch the title string.
             Str.get_strings(this.stringKeys).done(function(strings) {
+                var title;
+                var saveText;
+                if (action == 'revoke') {
+                    title = strings[4];
+                    saveText = strings[5];
+                } else {
+                    title = strings[0];
+                    saveText = strings[1];
+                }
                 // Create the modal.
                 ModalFactory.create({
                     type: ModalFactory.types.SAVE_CANCEL,
-                    title: strings[0],
+                    title: title,
                     body: ''
                 }, triggerElement).done(function(modal) {
                     this.modal = modal;
-                    this.setupFormModal(formData, strings[1]);
+                    this.setupFormModal(formData, saveText);
                 }.bind(this));
             }.bind(this))
                 .fail(Notification.exception);
index 0b668d6..39d49a6 100644 (file)
@@ -813,6 +813,39 @@ class api {
         }
     }
 
+    /**
+     * Checks if user can revoke policies for themselves or on behalf of another user
+     *
+     * @param int $userid
+     * @param bool $throwexception
+     * @return bool
+     */
+    public static function can_revoke_policies($userid = null, $throwexception = false) {
+        global $USER;
+
+        if (!isloggedin() || isguestuser()) {
+            if ($throwexception) {
+                throw new \moodle_exception('noguest');
+            } else {
+                return false;
+            }
+        }
+        if (!$userid) {
+            $userid = $USER->id;
+        }
+
+        // At the moment, current users can't revoke their own policies.
+        // Check capability to revoke on behalf as the real user.
+        $realuser = manager::get_realuser();
+        $usercontext = \context_user::instance($userid);
+        if ($throwexception) {
+            require_capability('tool/policy:acceptbehalf', $usercontext, $realuser);
+            return;
+        } else {
+            return has_capability('tool/policy:acceptbehalf', $usercontext, $realuser);
+        }
+    }
+
     /**
      * Accepts the current revisions of all policies that the user has not yet accepted
      *
index 8fcc474..f53af7a 100644 (file)
@@ -32,7 +32,7 @@ defined('MOODLE_INTERNAL') || die();
 require_once($CFG->dirroot.'/lib/formslib.php');
 
 /**
- * Represents the form for accepting a policy.
+ * Represents the form for accepting or revoking a policy.
  *
  * @package     tool_policy
  * @copyright   2018 Marina Glancy
@@ -53,9 +53,10 @@ class accept_policy extends \moodleform {
         if (empty($this->_customdata['versionids']) || !is_array($this->_customdata['versionids'])) {
             throw new \moodle_exception('missingparam', '', '', 'versionids');
         }
+        $revoke = (!empty($this->_customdata['action']) && $this->_customdata['action'] == 'revoke');
         $userids = clean_param_array($this->_customdata['userids'], PARAM_INT);
         $versionids = clean_param_array($this->_customdata['versionids'], PARAM_INT);
-        $usernames = $this->validate_and_get_users($userids);
+        $usernames = $this->validate_and_get_users($userids, $revoke);
         $versionnames = $this->validate_and_get_versions($versionids);
 
         foreach ($usernames as $userid => $name) {
@@ -75,13 +76,23 @@ class accept_policy extends \moodleform {
         $mform->addElement('static', 'policy', get_string('acceptancepolicies', 'tool_policy'),
             join(', ', $versionnames));
 
-        $mform->addElement('static', 'ack', '', get_string('acceptanceacknowledgement', 'tool_policy'));
+        if ($revoke) {
+            $mform->addElement('static', 'ack', '', get_string('revokeacknowledgement', 'tool_policy'));
+            $mform->addElement('hidden', 'action', 'revoke');
+            $mform->setType('action', PARAM_ALPHA);
+        } else {
+            $mform->addElement('static', 'ack', '', get_string('acceptanceacknowledgement', 'tool_policy'));
+        }
 
         $mform->addElement('textarea', 'note', get_string('acceptancenote', 'tool_policy'));
         $mform->setType('note', PARAM_NOTAGS);
 
         if (!empty($this->_customdata['showbuttons'])) {
-            $this->add_action_buttons(true, get_string('iagreetothepolicy', 'tool_policy'));
+            if ($revoke) {
+                $this->add_action_buttons(true, get_string('irevokethepolicy', 'tool_policy'));
+            } else {
+                $this->add_action_buttons(true, get_string('iagreetothepolicy', 'tool_policy'));
+            }
         }
 
         $PAGE->requires->js_call_amd('tool_policy/policyactions', 'init');
@@ -91,9 +102,10 @@ class accept_policy extends \moodleform {
      * Validate userids and return usernames
      *
      * @param array $userids
+     * @param boolean $revoke True if policies will be revoked; false when policies will be accepted.
      * @return array (userid=>username)
      */
-    protected function validate_and_get_users($userids) {
+    protected function validate_and_get_users($userids, $revoke = false) {
         global $DB;
         $usernames = [];
         list($sql, $params) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
@@ -112,7 +124,11 @@ class accept_policy extends \moodleform {
                 throw new \moodle_exception('noguest');
             }
             \context_helper::preload_from_record($user);
-            api::can_accept_policies($userid, true);
+            if ($revoke) {
+                api::can_revoke_policies($userid, true);
+            } else {
+                api::can_accept_policies($userid, true);
+            }
             $usernames[$userid] = fullname($user);
         }
         return $usernames;
@@ -148,8 +164,15 @@ class accept_policy extends \moodleform {
      */
     public function process() {
         if ($data = $this->get_data()) {
+            $revoke = (!empty($data->action) && $data->action == 'revoke');
             foreach ($data->userids as $userid) {
-                \tool_policy\api::accept_policies($data->versionids, $userid, $data->note);
+                if ($revoke) {
+                    foreach ($data->versionids as $versionid) {
+                        \tool_policy\api::revoke_acceptance($versionid, $userid, $data->note);
+                    }
+                } else {
+                    \tool_policy\api::accept_policies($data->versionids, $userid, $data->note);
+                }
             }
         }
     }
index dada079..526f33c 100644 (file)
@@ -50,6 +50,9 @@ class acceptances implements renderable, templatable {
     /** @var moodle_url */
     protected $returnurl;
 
+    /** @var bool */
+    protected $canrevoke;
+
     /**
      * Contructor.
      *
@@ -59,6 +62,7 @@ class acceptances implements renderable, templatable {
     public function __construct($userid, $returnurl = null) {
         $this->userid = $userid;
         $this->returnurl = $returnurl ? (new moodle_url($returnurl))->out(false) : null;
+        $this->canrevoke = \tool_policy\api::can_revoke_policies($this->userid);
     }
 
     /**
@@ -72,6 +76,7 @@ class acceptances implements renderable, templatable {
         $data->hasonbehalfagreements = false;
         $data->pluginbaseurl = (new moodle_url('/admin/tool/policy'))->out(false);
         $data->returnurl = $this->returnurl;
+        $data->canrevoke = $this->canrevoke;
 
         // Get the list of policies and versions that current user is able to see
         // and the respective acceptance records for the selected user.
index 278e97b..6fb5330 100644 (file)
@@ -237,13 +237,13 @@ class page_agreedocs implements renderable, templatable {
      * Redirect to $SESSION->wantsurl if defined or to $CFG->wwwroot if not.
      */
     protected function redirect_to_previous_url() {
-        global $SESSION, $CFG;
+        global $SESSION;
 
         if (!empty($SESSION->wantsurl)) {
             $returnurl = $SESSION->wantsurl;
             unset($SESSION->wantsurl);
         } else {
-            $returnurl = $CFG->wwwroot.'/';
+            $returnurl = (new moodle_url('/admin/tool/policy/user.php'))->out();
         }
 
         redirect($returnurl);
@@ -278,7 +278,9 @@ class page_agreedocs implements renderable, templatable {
         // and $SESSION->wantsurl is defined, redirect to the return page.
         $hasagreedsignupuser = empty($USER->id) && $this->signupuserpolicyagreed;
         $hasagreedloggeduser = $USER->id == $userid && !empty($USER->policyagreed);
-        if (!is_siteadmin() && ($hasagreedsignupuser || ($hasagreedloggeduser && !empty($SESSION->wantsurl)))) {
+        $canrevoke = api::can_revoke_policies($USER->id);
+        if (!is_siteadmin() && ($hasagreedsignupuser ||
+            ($hasagreedloggeduser && !$canrevoke))) {
             $this->redirect_to_previous_url();
         }
 
index 232547a..f8dacf4 100644 (file)
@@ -107,6 +107,10 @@ class page_viewalldoc implements renderable, templatable {
 
         $data->policies = array_values($this->policies);
 
+        array_walk($data->policies, function($item, $key) {
+            $item->policytypestr = get_string('policydoctype'.$item->type, 'tool_policy');
+        });
+
         return $data;
     }
 }
index b1d96c8..6f4a53a 100644 (file)
@@ -59,6 +59,9 @@ class user_agreement implements \templatable, \renderable {
     /** @var bool */
     protected $canaccept;
 
+    /** @var bool */
+    protected $canrevoke;
+
     /**
      * user_agreement constructor
      *
@@ -68,8 +71,10 @@ class user_agreement implements \templatable, \renderable {
      * @param array $versions list of versions (id=>name)
      * @param bool $onbehalf whether at least one version was accepted by somebody else on behalf of the user
      * @param bool $canaccept does the current user have permission to accept the policy on behalf of user $userid
+     * @param bool $canrevoke does the current user have permission to revoke the policy on behalf of user $userid
      */
-    public function __construct($userid, $accepted, moodle_url $pageurl, $versions, $onbehalf = false, $canaccept = null) {
+    public function __construct($userid, $accepted, moodle_url $pageurl, $versions, $onbehalf = false,
+                                $canaccept = null, $canrevoke = null) {
         $this->userid = $userid;
         $this->onbehalf = $onbehalf;
         $this->pageurl = $pageurl;
@@ -79,6 +84,9 @@ class user_agreement implements \templatable, \renderable {
         if (count($this->accepted) < count($this->versions) && $canaccept === null) {
             $this->canaccept = \tool_policy\api::can_accept_policies($this->userid);
         }
+        if (count($this->accepted) == count($this->versions) && $canrevoke === null) {
+            $this->canrevoke = \tool_policy\api::can_revoke_policies($this->userid);
+        }
     }
 
     /**
@@ -92,6 +100,7 @@ class user_agreement implements \templatable, \renderable {
             'status' => count($this->accepted) == count($this->versions),
             'onbehalf' => $this->onbehalf,
             'canaccept' => $this->canaccept,
+            'canrevoke' => $this->canrevoke,
         ];
         if (!$data['status'] && $this->canaccept) {
             $linkparams = ['userids[0]' => $this->userid];
@@ -101,6 +110,15 @@ class user_agreement implements \templatable, \renderable {
             $linkparams['returnurl'] = $this->pageurl->out_as_local_url(false);
             $link = new \moodle_url('/admin/tool/policy/accept.php', $linkparams);
             $data['acceptlink'] = $link->out(false);
+        } else if ($data['status'] && $this->canrevoke) {
+            $linkparams = ['userids[0]' => $this->userid];
+            foreach (array_keys($this->versions) as $versionid) {
+                $linkparams["versionids[{$versionid}]"] = $versionid;
+            }
+            $linkparams['returnurl'] = $this->pageurl->out_as_local_url(false);
+            $linkparams['action'] = 'revoke';
+            $link = new \moodle_url('/admin/tool/policy/accept.php', $linkparams);
+            $data['revokelink'] = $link->out(false);
         }
         $data['singleversion'] = count($this->versions) == 1;
         if ($data['singleversion']) {
index 5febbbe..32be8f9 100644 (file)
@@ -25,7 +25,7 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$string['acceptanceacknowledgement'] = 'I acknowledge that consents to these policies have been acquired';
+$string['acceptanceacknowledgement'] = 'I acknowledge that I have received the user\'s request to consent on the abovementioned policy on behalf of the user.';
 $string['acceptancecount'] = '{$a->agreedcount} of {$a->policiescount}';
 $string['acceptancenote'] = 'Remarks';
 $string['acceptancepolicies'] = 'Policies';
@@ -39,12 +39,22 @@ $string['activateconfirm'] = '<p>You are about to activate policy <em>\'{$a->nam
 $string['activateconfirmyes'] = 'Activate';
 $string['agreed'] = 'Agreed';
 $string['agreedby'] = 'Agreed by';
+$string['agreedno'] = 'Not agreed';
+$string['agreednowithlink'] = 'Not agreed, click to agree to "{$a}"';
+$string['agreednowithlinkall'] = 'Not agreed, click to agree to all policies';
 $string['agreedon'] = 'Agreed on';
+$string['agreedyes'] = 'Agreed';
+$string['agreedyesonbehalf'] = 'Agreed on behalf of';
+$string['agreedyesonbehalfwithlink'] = 'Agreed on behalf of, click to withdraw consent to "{$a}"';
+$string['agreedyesonbehalfwithlinkall'] = 'Agreed on behalf of, click to withdraw consent to all policies"';
+$string['agreedyeswithlink'] = 'Agreed, click to withdraw consent to "{$a}"';
+$string['agreedyeswithlinkall'] = 'Agreed, click to withdraw consent to all policies';
 $string['agreepolicies'] = 'Please agree to the following policies';
 $string['backtotop'] = 'Back to top';
 $string['consentbulk'] = 'Consent';
-$string['consentdetails'] = 'Consent details';
+$string['consentdetails'] = 'Agree on behalf of the user';
 $string['consentpagetitle'] = 'Consent';
+$string['contactdpo'] = 'For questions regarding the policies please contact the Data Protection Officer.';
 $string['dataproc'] = 'Personal data processing';
 $string['deleting'] = 'Deleting a version';
 $string['deleteconfirm'] = '<p>Are you sure you want to delete policy <em>\'{$a->name}\'</em>?</p><p>This operation can not be undone.</p>';
@@ -67,12 +77,13 @@ $string['filterpolicy'] = 'Policy: {$a}';
 $string['guestconsent:continue'] = 'Continue';
 $string['guestconsentmessage'] = 'If you continue browsing this website, you agree to our policies:';
 $string['iagree'] = 'I agree to the {$a}';
-$string['iagreetothepolicy'] = 'I agree to the policy';
+$string['iagreetothepolicy'] = 'Agree';
 $string['inactivate'] = 'Set status to "Inactive"';
 $string['inactivating'] = 'Inactivating a policy';
 $string['inactivatingconfirm'] = '<p>You are about to inactivate policy <em>\'{$a->name}\'</em> version <em>\'{$a->revision}\'</em>.</p><p>The policy will not apply until some version is made the current one.</p>';
 $string['inactivatingconfirmyes'] = 'Inactivate';
 $string['invalidversionid'] = 'There is no policy with this identifier!';
+$string['irevokethepolicy'] = 'Withdraw consent';
 $string['minorchange'] = 'Minor change';
 $string['minorchangeinfo'] = 'Minor changes do not amend the meaning of the policy text, terms or conditions. Users do not need to reconfirm their consent.';
 $string['managepolicies'] = 'Manage policies';
@@ -128,6 +139,8 @@ $string['privacy:metadata:acceptances:note'] = 'Any comments added by the user w
 $string['privacysettings'] = 'Privacy settings';
 $string['readpolicy'] = 'Please read our {$a}';
 $string['refertofullpolicytext'] = 'Please refer to the full {$a} text if you would like to review.';
+$string['revokeacknowledgement'] = 'I acknowledge that I have received the user\'s request to withdraw consent on the abovementioned policy on behalf of the user.';
+$string['revokedetails'] = 'Withdraw user\'s consent';
 $string['save'] = 'Save';
 $string['saveasdraft'] = 'Save as draft';
 $string['selectuser'] = 'Select user {$a}';
@@ -146,8 +159,3 @@ $string['userpolicysettings'] = 'Policies';
 $string['usersaccepted'] = 'Agreements';
 $string['viewarchived'] = 'View previous versions';
 $string['viewconsentpageforuser'] = 'Viewing this page on behalf of {$a}';
-$string['agreedno'] = 'Not agreed';
-$string['agreednowithlink'] = 'Not agreed, click to agree to "{$a}"';
-$string['agreednowithlinkall'] = 'Not agreed, click to agree to all policies';
-$string['agreedyes'] = 'Agreed';
-$string['agreedyesonbehalf'] = 'Agreed on behalf of';
index 410e05c..6c9afa9 100644 (file)
@@ -93,6 +93,25 @@ function tool_policy_before_standard_html_head() {
     return $message;
 }
 
+/**
+ * Callback to add footer elements.
+ *
+ * @return str valid html footer content
+ */
+function tool_policy_standard_footer_html() {
+    global $CFG;
+
+    $output = '';
+    if (!empty($CFG->sitepolicyhandler)
+            && $CFG->sitepolicyhandler == 'tool_policy') {
+        $url = (new moodle_url('/admin/tool/policy/viewall.php'))->out();
+        $output .= html_writer::link($url, get_string('userpolicysettings', 'tool_policy'));
+        $output = html_writer::div($output, 'policiesfooter');
+    }
+
+    return $output;
+}
+
 /**
  * Hooks redirection to policy acceptance pages before sign up.
  */
index 6c37b4c..1315448 100644 (file)
@@ -31,6 +31,7 @@
     Example context (json):
     {
         "hasonbehalfagreements": true,
+        "canrevoke": true,
         "policies": [
             {
               "versions": [
         ]
     }
 }}
+{{^canrevoke}}
+    <div class="alert alert-info">{{#str}} contactdpo, tool_policy {{/str}}</div>
+{{/canrevoke}}
+
 <table class="generaltable fullwidth">
     <thead>
     <tr>
index df70f1a..d25e40c 100644 (file)
     {
         "policies": [
             {
+                "id": "2",
                 "name": "Terms &amp; conditions",
                 "summary": "Policy <u>summary</u>",
-                "content": "Policy <em>content</em>"
+                "content": "Policy <em>content</em>",
+                "policytypestr": "Site policy"
             },
             {
+                "id": "5",
                 "name": "Privacy",
                 "summary": "We keep your information private",
-                "content": "Very private"
+                "content": "Very private",
+                "policytypestr": "Privacy policy"
             }
         ]
     }
 }}
 
+<a id="top"></a>
+<div id="policies_index" class="m-b-3">
+<ul>
+    {{#policies }}
+        <li><a href="#policy-{{id}}">{{{name}}} ({{{policytypestr}}})</a></li>
+    {{/policies }}
+</ul>
+</div>
+
 {{#policies }}
+        <hr>
     <div class="policy_version m-b-3">
         <div class="clearfix m-t-2">
-            <h1>{{{name}}}</h1>
+            <h1><a id="policy-{{id}}">{{{name}}}</a></h1>
         </div>
         <div class="policy_document_summary clearfix m-b-1">
+            <h2>{{# str }} policydocsummary, tool_policy {{/ str }}</h2>
             {{{summary}}}
         </div>
         <div class="policy_document_content m-t-2">
+            <h2>{{# str }} policydoccontent, tool_policy {{/ str }}</h2>
             {{{content}}}
         </div>
-        <hr>
+        <div class="pull-right">
+            <a href="#top">
+                {{# str }} backtotop, tool_policy {{/ str }}
+                <i class="icon text-primary fa fa-caret-up" title="{{# str }} backtotop, tool_policy {{/ str }}" ></i>
+            </a>
+        </div>
     </div>
 
 {{/policies }}
index 684a7cb..0836ed3 100644 (file)
         "status": false,
         "onbehalf": false,
         "canaccept": true,
+        "canrevoke": true,
         "acceptlink": "/",
+        "revokelink": "/",
         "singleversion": false,
         "versionname": ""
     }
 }}
 {{#status}}
+    {{#canrevoke}}
+        {{#singleversion}}
+            {{#onbehalf}}
+                <a href="{{revokelink}}" data-action="acceptmodal">{{#pix}}agreedyesonbehalf, tool_policy,
+                    {{#str}} agreedyesonbehalfwithlink, tool_policy, {{{versionname}}} {{/str}}{{/pix}}</a>
+            {{/onbehalf}}
+            {{^onbehalf}}
+                <a href="{{revokelink}}" data-action="acceptmodal">{{#pix}}agreedyes, tool_policy,
+                    {{#str}} agreedyeswithlink, tool_policy, {{{versionname}}} {{/str}}{{/pix}}</a>
+            {{/onbehalf}}
+        {{/singleversion}}
+        {{^singleversion}}
+            {{#onbehalf}}
+                <a href="{{revokelink}}" data-action="acceptmodal">{{#pix}}agreedyesonbehalf, tool_policy,
+                    {{#str}} agreedyesonbehalfwithlinkall, tool_policy {{/str}}{{/pix}}</a>
+            {{/onbehalf}}
+            {{^onbehalf}}
+                <a href="{{revokelink}}" data-action="acceptmodal">{{#pix}}agreedyes, tool_policy,
+                    {{#str}} agreedyeswithlinkall, tool_policy {{/str}}{{/pix}}</a>
+            {{/onbehalf}}
+        {{/singleversion}}
+    {{/canrevoke}}
 
-    {{#onbehalf}}
-        <span>{{#pix}}agreedyesonbehalf, tool_policy, {{#str}} agreedyesonbehalf, tool_policy {{/str}}{{/pix}}</span>
-    {{/onbehalf}}
-    {{^onbehalf}}
-        <span>{{#pix}}agreedyes, tool_policy, {{#str}} agreedyes, tool_policy {{/str}}{{/pix}}</span>
-    {{/onbehalf}}
+    {{^canrevoke}}
+        {{#onbehalf}}
+            <span>{{#pix}}agreedyesonbehalf, tool_policy, {{#str}} agreedyesonbehalf, tool_policy {{/str}}{{/pix}}</span>
+        {{/onbehalf}}
+        {{^onbehalf}}
+            <span>{{#pix}}agreedyes, tool_policy, {{#str}} agreedyes, tool_policy {{/str}}{{/pix}}</span>
+        {{/onbehalf}}
+    {{/canrevoke}}
 {{/status}}
+
 {{^status}}
     {{#canaccept}}
         {{#singleversion}}
index 547bffc..ed5f3cb 100644 (file)
@@ -368,6 +368,72 @@ class tool_policy_api_testcase extends advanced_testcase {
         $this->assertTrue(api::can_user_view_policy_version($policy3, null, $parent->id));
     }
 
+    /**
+     * Test behaviour of the {@link api::can_revoke_policies()} method.
+     */
+    public function test_can_revoke_policies() {
+        global $CFG;
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $user = $this->getDataGenerator()->create_user();
+        $child = $this->getDataGenerator()->create_user();
+        $parent = $this->getDataGenerator()->create_user();
+        $officer = $this->getDataGenerator()->create_user();
+        $manager = $this->getDataGenerator()->create_user();
+
+        $syscontext = context_system::instance();
+        $childcontext = context_user::instance($child->id);
+
+        $roleminorid = create_role('Digital minor', 'digiminor', 'Not old enough to accept site policies themselves');
+        $roleparentid = create_role('Parent', 'parent', 'Can accept policies on behalf of their child');
+        $roleofficerid = create_role('Policy officer', 'policyofficer', 'Can see all acceptances but can\'t edit policy documents');
+        $rolemanagerid = create_role('Policy manager', 'policymanager', 'Can manage policy documents');
+
+        assign_capability('tool/policy:accept', CAP_PROHIBIT, $roleminorid, $syscontext->id);
+        assign_capability('tool/policy:acceptbehalf', CAP_ALLOW, $roleparentid, $syscontext->id);
+        assign_capability('tool/policy:acceptbehalf', CAP_ALLOW, $roleofficerid, $syscontext->id);
+        assign_capability('tool/policy:viewacceptances', CAP_ALLOW, $roleofficerid, $syscontext->id);
+        assign_capability('tool/policy:acceptbehalf', CAP_ALLOW, $rolemanagerid, $syscontext->id);
+        assign_capability('tool/policy:managedocs', CAP_ALLOW, $rolemanagerid, $syscontext->id);
+
+        role_assign($roleminorid, $child->id, $syscontext->id);
+        // Becoming a parent is easy. Being a good one is difficult.
+        role_assign($roleparentid, $parent->id, $childcontext->id);
+        role_assign($roleofficerid, $officer->id, $syscontext->id);
+        role_assign($rolemanagerid, $manager->id, $syscontext->id);
+
+        accesslib_clear_all_caches_for_unit_testing();
+
+        // Prepare a policy document with some versions.
+        list($policy1, $policy2, $policy3) = $this->create_versions(3);
+
+        // Normally users do not have access to revoke policies.
+        $this->setUser($user);
+        $this->assertFalse(api::can_revoke_policies($user->id));
+        $this->setUser($child);
+        $this->assertFalse(api::can_revoke_policies($child->id));
+
+        // The parent can revoke the policy on behalf of her child (but not her own policies).
+        $this->setUser($parent);
+        $this->assertFalse(api::can_revoke_policies($parent->id));
+        $this->assertTrue(api::can_revoke_policies($child->id));
+
+        // Officers and managers can revoke everything.
+        $this->setUser($officer);
+        $this->assertTrue(api::can_revoke_policies($officer->id));
+        $this->assertTrue(api::can_revoke_policies($child->id));
+        $this->assertTrue(api::can_revoke_policies($parent->id));
+        $this->assertTrue(api::can_revoke_policies($manager->id));
+
+        $this->setUser($manager);
+        $this->assertTrue(api::can_revoke_policies($manager->id));
+        $this->assertTrue(api::can_revoke_policies($child->id));
+        $this->assertTrue(api::can_revoke_policies($parent->id));
+        $this->assertTrue(api::can_revoke_policies($officer->id));
+    }
+
     /**
      * Test {@link api::fix_revision_values()} behaviour.
      */
index 5bdd926..c7ce145 100644 (file)
@@ -40,7 +40,7 @@ Feature: Viewing acceptances reports and accepting on behalf of other users
     And I press "Next"
     And I set the field "I agree to the This site policy" to "1"
     And I press "Next"
-    And I navigate to "Privacy and policies > User agreements" in site administration
+    And I navigate to "Users > Privacy and policies > User agreements" in site administration
     And "Agreed" "icon" should exist in the "User One" "table_row"
     And "Agreed" "icon" should exist in the "Max Manager" "table_row"
     And "Not agreed" "icon" should exist in the "User Two" "table_row"
@@ -55,15 +55,15 @@ Feature: Viewing acceptances reports and accepting on behalf of other users
     And I press "Next"
     And I set the field "I agree to the This site policy" to "1"
     And I press "Next"
-    And I navigate to "Privacy and policies > Manage policies" in site administration
+    And I navigate to "Users > Privacy and policies > Manage policies" in site administration
     And I click on "1 of 4 (25%)" "link" in the "This site policy" "table_row"
     And I click on "Not agreed" "link" in the "User One" "table_row"
-    Then I should see "Consent details"
+    Then I should see "Agree on behalf of the user"
     And I should see "User One"
     And I should see "This site policy"
-    And I should see "I acknowledge that consents to these policies have been acquired"
+    And I should see "I acknowledge that I have received the user's request to consent on the abovementioned policy on behalf of the user."
     And I set the field "Remarks" to "Consent received from a parent"
-    And I press "I agree to the policy"
+    And I press "Agree"
     And "Agreed on behalf of" "icon" should exist in the "User One" "table_row"
     And "Max Manager" "link" should exist in the "User One" "table_row"
     And "Consent received from a parent" "text" should exist in the "User One" "table_row"
@@ -80,15 +80,15 @@ Feature: Viewing acceptances reports and accepting on behalf of other users
     And I press "Next"
     And I set the field "I agree to the This site policy" to "1"
     And I press "Next"
-    And I navigate to "Privacy and policies > Manage policies" in site administration
+    And I navigate to "Users > Privacy and policies > Manage policies" in site administration
     And I click on "1 of 4 (25%)" "link" in the "This site policy" "table_row"
     And I click on "Not agreed" "link" in the "User One" "table_row"
-    Then I should see "Consent details"
+    Then I should see "Agree on behalf of the user"
     And I should see "User One"
     And I should see "This site policy"
-    And I should see "I acknowledge that consents to these policies have been acquired"
+    And I should see "I acknowledge that I have received the user's request to consent on the abovementioned policy on behalf of the user."
     And I set the field "Remarks" to "Consent received from a parent"
-    And I press "I agree to the policy"
+    And I press "Agree"
     And "Agreed on behalf of" "icon" should exist in the "User One" "table_row"
     And "Max Manager" "link" should exist in the "User One" "table_row"
     And "Consent received from a parent" "text" should exist in the "User One" "table_row"
@@ -96,7 +96,7 @@ Feature: Viewing acceptances reports and accepting on behalf of other users
 
   Scenario: View acceptances made by users on their own, multiple policies
     Given I log in as "admin"
-    And I navigate to "Privacy and policies > Manage policies" in site administration
+    And I navigate to "Users > Privacy and policies > Manage policies" in site administration
     And I open the action menu in "This privacy policy" "table_row"
     And I click on "Set status to \"Active\"" "link" in the "This privacy policy" "table_row"
     And I press "Continue"
@@ -117,7 +117,7 @@ Feature: Viewing acceptances reports and accepting on behalf of other users
     And I set the field "I agree to the This site policy" to "1"
     And I set the field "I agree to the This privacy policy" to "1"
     And I press "Next"
-    And I navigate to "Privacy and policies > User agreements" in site administration
+    And I navigate to "Users > Privacy and policies > User agreements" in site administration
     And "Agreed" "icon" should exist in the "User One" "table_row"
     And "Not agreed" "icon" should not exist in the "User One" "table_row"
     And "Agreed" "icon" should exist in the "Max Manager" "table_row"
@@ -127,14 +127,14 @@ Feature: Viewing acceptances reports and accepting on behalf of other users
     And "Agreed" "icon" should exist in the "This site policy" "table_row"
     And "Agreed" "icon" should exist in the "This privacy policy" "table_row"
     And I am on site homepage
-    And I navigate to "Privacy and policies > User agreements" in site administration
+    And I navigate to "Users > Privacy and policies > User agreements" in site administration
     And I click on "0 of 2" "link" in the "User Two" "table_row"
     And "Not agreed" "icon" should exist in the "This site policy" "table_row"
     And "Not agreed" "icon" should exist in the "This privacy policy" "table_row"
 
   Scenario: Agree on behalf of another user as a manager, multiple policies, javascript off
     Given I log in as "admin"
-    And I navigate to "Privacy and policies > Manage policies" in site administration
+    And I navigate to "Users > Privacy and policies > Manage policies" in site administration
     And I open the action menu in "This privacy policy" "table_row"
     And I click on "Set status to \"Active\"" "link" in the "This privacy policy" "table_row"
     And I press "Continue"
@@ -148,14 +148,14 @@ Feature: Viewing acceptances reports and accepting on behalf of other users
     And I set the field "I agree to the This site policy" to "1"
     And I set the field "I agree to the This privacy policy" to "1"
     And I press "Next"
-    And I navigate to "Privacy and policies > User agreements" in site administration
+    And I navigate to "Users > Privacy and policies > User agreements" in site administration
     And I click on "Not agreed, click to agree to \"This site policy\"" "link" in the "User One" "table_row"
-    Then I should see "Consent details"
+    Then I should see "Agree on behalf of the user"
     And I should see "User One"
     And I should see "This site policy"
-    And I should see "I acknowledge that consents to these policies have been acquired"
+    And I should see "I acknowledge that I have received the user's request to consent on the abovementioned policy on behalf of the user."
     And I set the field "Remarks" to "Consent received from a parent"
-    And I press "I agree to the policy"
+    And I press "Agree"
     And "Agreed on behalf of" "icon" should exist in the "User One" "table_row"
     And "Not agreed, click to agree to \"This privacy policy\"" "icon" should exist in the "User One" "table_row"
     And I click on "1 of 2" "link" in the "User One" "table_row"
@@ -167,7 +167,7 @@ Feature: Viewing acceptances reports and accepting on behalf of other users
   @javascript
   Scenario: Agree on behalf of another user as a manager, multiple policies, javascript on
     Given I log in as "admin"
-    And I navigate to "Privacy and policies > Manage policies" in site administration
+    And I navigate to "Users > Privacy and policies > Manage policies" in site administration
     And I open the action menu in "This privacy policy" "table_row"
     And I click on "Set status to \"Active\"" "link" in the "This privacy policy" "table_row"
     And I press "Activate"
@@ -181,14 +181,14 @@ Feature: Viewing acceptances reports and accepting on behalf of other users
     And I set the field "I agree to the This site policy" to "1"
     And I set the field "I agree to the This privacy policy" to "1"
     And I press "Next"
-    And I navigate to "Privacy and policies > User agreements" in site administration
+    And I navigate to "Users > Privacy and policies > User agreements" in site administration
     And I click on "Not agreed, click to agree to \"This site policy\"" "link" in the "User One" "table_row"
-    Then I should see "Consent details"
+    Then I should see "Agree on behalf of the user"
     And I should see "User One"
     And I should see "This site policy"
-    And I should see "I acknowledge that consents to these policies have been acquired"
+    And I should see "I acknowledge that I have received the user's request to consent on the abovementioned policy on behalf of the user."
     And I set the field "Remarks" to "Consent received from a parent"
-    And I press "I agree to the policy"
+    And I press "Agree"
     And "Agreed on behalf of" "icon" should exist in the "User One" "table_row"
     And "Not agreed, click to agree to \"This privacy policy\"" "icon" should exist in the "User One" "table_row"
     And I click on "1 of 2" "link" in the "User One" "table_row"
@@ -245,15 +245,15 @@ Feature: Viewing acceptances reports and accepting on behalf of other users
     And I follow "Manager"
     And I follow "Log in as"
     And I press "Continue"
-    And I navigate to "Privacy and policies > Manage policies" in site administration
+    And I navigate to "Users > Privacy and policies > Manage policies" in site administration
     And I click on "1 of 4 (25%)" "link" in the "This site policy" "table_row"
     And I click on "Not agreed" "link" in the "User One" "table_row"
-    Then I should see "Consent details"
+    Then I should see "Agree on behalf of the user"
     And I should see "User One"
     And I should see "This site policy"
-    And I should see "I acknowledge that consents to these policies have been acquired"
+    And I should see "I acknowledge that I have received the user's request to consent on the abovementioned policy on behalf of the user."
     And I set the field "Remarks" to "Consent received from a parent"
-    And I press "I agree to the policy"
+    And I press "Agree"
     And "Agreed on behalf of" "icon" should exist in the "User One" "table_row"
     And "Max Manager" "link" should not exist in the "User One" "table_row"
     And "Admin User" "link" should exist in the "User One" "table_row"
index 5291c09..7e97540 100644 (file)
@@ -18,7 +18,7 @@ Feature: Manage policies
 
   Scenario: Create new policy and save as draft
     When I log in as "manager"
-    And I navigate to "Privacy and policies > Manage policies" in site administration
+    And I navigate to "Users > Privacy and policies > Manage policies" in site administration
     And I follow "New policy"
     And I set the following fields to these values:
       | Name        | Policy1        |
@@ -41,7 +41,7 @@ Feature: Manage policies
 
   Scenario: Create new policy and save as active
     When I log in as "manager"
-    And I navigate to "Privacy and policies > Manage policies" in site administration
+    And I navigate to "Users > Privacy and policies > Manage policies" in site administration
     And I follow "New policy"
     And I set the following fields to these values:
       | Name        | Policy1        |
@@ -63,7 +63,7 @@ Feature: Manage policies
     And I press "Next"
     And I set the field "I agree to the Policy1" to "1"
     And I press "Next"
-    And I navigate to "Privacy and policies > Manage policies" in site administration
+    And I navigate to "Users > Privacy and policies > Manage policies" in site administration
     And I open the action menu in "Policy1" "table_row"
     And I click on "Edit" "link" in the "Policy1" "table_row"
     And "Draft" "field" should not exist
@@ -86,7 +86,7 @@ Feature: Manage policies
     And I press "Next"
     And I set the field "I agree to the Policy1" to "1"
     And I press "Next"
-    And I navigate to "Privacy and policies > Manage policies" in site administration
+    And I navigate to "Users > Privacy and policies > Manage policies" in site administration
     And I open the action menu in "Policy1" "table_row"
     And I click on "Edit" "link" in the "Policy1" "table_row"
     And I set the field "Version" to "v2"
@@ -105,7 +105,7 @@ Feature: Manage policies
     And I press "Next"
     And I set the field "I agree to the Policy1" to "1"
     And I press "Next"
-    And I navigate to "Privacy and policies > Manage policies" in site administration
+    And I navigate to "Users > Privacy and policies > Manage policies" in site administration
     And I open the action menu in "Policy1" "table_row"
     And I click on "Edit" "link" in the "Policy1" "table_row"
     And I set the field "Name" to "Policy2"
@@ -130,7 +130,7 @@ Feature: Manage policies
       | Name       | Revision | Content    | Summary     | Status   |
       | Policy1    | v1       | full text2 | short text2 | draft    |
     And I log in as "manager"
-    And I navigate to "Privacy and policies > Manage policies" in site administration
+    And I navigate to "Users > Privacy and policies > Manage policies" in site administration
     And I open the action menu in "Policy1" "table_row"
     And I click on "Edit" "link" in the "Policy1" "table_row"
     And I set the field "Version" to "v2"
@@ -153,7 +153,7 @@ Feature: Manage policies
       | Name       | Revision | Content    | Summary     | Status   |
       | Policy1    | v1       | full text2 | short text2 | draft    |
     And I log in as "manager"
-    And I navigate to "Privacy and policies > Manage policies" in site administration
+    And I navigate to "Users > Privacy and policies > Manage policies" in site administration
     And I open the action menu in "Policy1" "table_row"
     And I click on "Edit" "link" in the "Policy1" "table_row"
     And I set the field "Version" to "v2"
@@ -172,7 +172,7 @@ Feature: Manage policies
       | Name       | Revision | Content    | Summary     | Status   |
       | Policy1    | v1       | full text2 | short text2 | draft    |
     And I log in as "manager"
-    And I navigate to "Privacy and policies > Manage policies" in site administration
+    And I navigate to "Users > Privacy and policies > Manage policies" in site administration
     And I open the action menu in "Policy1" "table_row"
     And I click on "Set status to \"Active\"" "link" in the "Policy1" "table_row"
     Then I should see "All users will be required to accept this new policy version to be able to use the site"
@@ -192,7 +192,7 @@ Feature: Manage policies
     And I press "Next"
     And I set the field "I agree to the Policy1" to "1"
     And I press "Next"
-    And I navigate to "Privacy and policies > Manage policies" in site administration
+    And I navigate to "Users > Privacy and policies > Manage policies" in site administration
     And I open the action menu in "Policy1" "table_row"
     And I click on "Set status to \"Inactive\"" "link" in the "Policy1" "table_row"
     Then I should see "The policy will not apply until some version is made the current one"
@@ -233,7 +233,7 @@ Feature: Manage policies
     And I press "Next"
     And I set the field "I agree to the Policy1" to "1"
     And I press "Next"
-    And I navigate to "Privacy and policies > Manage policies" in site administration
+    And I navigate to "Users > Privacy and policies > Manage policies" in site administration
     And I open the action menu in "Policy1" "table_row"
     And I click on "Set status to \"Inactive\"" "link" in the "Policy1" "table_row"
     And I press "Continue"
index c04d32b..2c9840b 100644 (file)
 
 defined('MOODLE_INTERNAL') || die;
 
-// profiling tool, added to development
-if ((extension_loaded('xhprof') || extension_loaded('tideways')) && (!empty($CFG->profilingenabled) || !empty($CFG->earlyprofilingenabled))) {
+// Profiling tool, added to development.
+$hasextension = extension_loaded('tideways_xhprof');
+$hasextension = $hasextension || extension_loaded('tideways');
+$hasextension = $hasextension || extension_loaded('xhprof');
+$isenabled = !empty($CFG->profilingenabled) || !empty($CFG->earlyprofilingenabled);
+if ($hasextension && $isenabled) {
     $ADMIN->add('development', new admin_externalpage('toolprofiling', get_string('pluginname', 'tool_profiling'),
             "$CFG->wwwroot/$CFG->admin/tool/profiling/index.php", 'moodle/site:config'));
 }
index 08601e8..68f7342 100644 (file)
@@ -434,6 +434,53 @@ abstract class base {
         return $this->log;
     }
 
+    /**
+     * Whether the plugin needs user data clearing or not.
+     *
+     * This is related to privacy. Override this method if your analyser samples have any relation
+     * to the 'user' database entity. We need to clean the site from all user-related data if a user
+     * request their data to be deleted from the system. A static::provided_sample_data returning 'user'
+     * is an indicator that you should be returning true.
+     *
+     * @return bool
+     */
+    public function processes_user_data() {
+        return false;
+    }
+
+    /**
+     * SQL JOIN from a sample to users table.
+     *
+     * This function should be defined if static::processes_user_data returns true and it is related to analytics API
+     * privacy API implementation. It allows the analytics API to identify data associated to users that needs to be
+     * deleted or exported.
+     *
+     * This function receives the alias of a table with a 'sampleid' field and it should return a SQL join
+     * with static::get_samples_origin and with 'user' table. Note that:
+     * - The function caller expects the returned 'user' table to be aliased as 'u' (defacto standard in moodle).
+     * - You can join with other tables if your samples origin table does not contain a 'userid' field (if that would be
+     *   a requirement this solution would be automated for you) you can't though use the following
+     *   aliases: 'ap', 'apa', 'aic' and 'am'.
+     *
+     * Some examples:
+     *
+     * static::get_samples_origin() === 'user':
+     *   JOIN {user} u ON {$sampletablealias}.sampleid = u.id
+     *
+     * static::get_samples_origin() === 'role_assignments':
+     *   JOIN {role_assignments} ra ON {$sampletablealias}.sampleid = ra.userid JOIN {user} u ON u.id = ra.userid
+     *
+     * static::get_samples_origin() === 'user_enrolments':
+     *   JOIN {user_enrolments} ue ON {$sampletablealias}.sampleid = ue.userid JOIN {user} u ON u.id = ue.userid
+     *
+     * @throws \coding_exception
+     * @param string $sampletablealias The alias of the table with a sampleid field that will join with this SQL string
+     * @return string
+     */
+    public function join_sample_user($sampletablealias) {
+        throw new \coding_exception('This method should be implemented if static::processes_user_data returns true.');
+    }
+
     /**
      * Processes the analysable samples using the provided time splitting method.
      *
diff --git a/analytics/classes/privacy/provider.php b/analytics/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..ac5ae72
--- /dev/null
@@ -0,0 +1,366 @@
+<?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/>.
+
+/**
+ * Privacy Subsystem implementation for core_analytics.
+ *
+ * @package    core_analytics
+ * @copyright  2018 David Monllaó
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_analytics\privacy;
+
+use core_privacy\local\request\transform;
+use core_privacy\local\request\writer;
+use core_privacy\local\metadata\collection;
+use core_privacy\local\request\approved_contextlist;
+use core_privacy\local\request\context;
+use core_privacy\local\request\contextlist;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Privacy Subsystem for core_analytics implementing metadata and plugin providers.
+ *
+ * @copyright  2018 David Monllaó
+ * @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\plugin\provider {
+
+    /**
+     * Returns meta data about this system.
+     *
+     * @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(
+            'analytics_indicator_calc',
+            [
+                'starttime' => 'privacy:metadata:analytics:indicatorcalc:starttime',
+                'endtime' => 'privacy:metadata:analytics:indicatorcalc:endtime',
+                'contextid' => 'privacy:metadata:analytics:indicatorcalc:contextid',
+                'sampleorigin' => 'privacy:metadata:analytics:indicatorcalc:sampleorigin',
+                'sampleid' => 'privacy:metadata:analytics:indicatorcalc:sampleid',
+                'indicator' => 'privacy:metadata:analytics:indicatorcalc:indicator',
+                'value' => 'privacy:metadata:analytics:indicatorcalc:value',
+                'timecreated' => 'privacy:metadata:analytics:indicatorcalc:timecreated',
+            ],
+            'privacy:metadata:analytics:indicatorcalc'
+        );
+
+        $collection->add_database_table(
+            'analytics_predictions',
+            [
+                'modelid' => 'privacy:metadata:analytics:predictions:modelid',
+                'contextid' => 'privacy:metadata:analytics:predictions:contextid',
+                'sampleid' => 'privacy:metadata:analytics:predictions:sampleid',
+                'rangeindex' => 'privacy:metadata:analytics:predictions:rangeindex',
+                'prediction' => 'privacy:metadata:analytics:predictions:prediction',
+                'predictionscore' => 'privacy:metadata:analytics:predictions:predictionscore',
+                'calculations' => 'privacy:metadata:analytics:predictions:calculations',
+                'timecreated' => 'privacy:metadata:analytics:predictions:timecreated',
+                'timestart' => 'privacy:metadata:analytics:predictions:timestart',
+                'timeend' => 'privacy:metadata:analytics:predictions:timeend',
+            ],
+            'privacy:metadata:analytics:predictions'
+        );
+
+        $collection->add_database_table(
+            'analytics_prediction_actions',
+            [
+                'predictionid' => 'privacy:metadata:analytics:predictionactions:predictionid',
+                'userid' => 'privacy:metadata:analytics:predictionactions:userid',
+                'actionname' => 'privacy:metadata:analytics:predictionactions:actionname',
+                'timecreated' => 'privacy:metadata:analytics:predictionactions:timecreated',
+            ],
+            'privacy:metadata:analytics:predictionactions'
+        );
+
+        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) : contextlist {
+        global $DB;
+
+        $contextlist = new \core_privacy\local\request\contextlist();
+
+        $models = self::get_models_with_user_data();
+
+        foreach ($models as $modelid => $model) {
+
+            $analyser = $model->get_analyser(['notimesplitting' => true]);
+
+            // Analytics predictions.
+            $joinusersql = $analyser->join_sample_user('ap');
+            $sql = "SELECT DISTINCT ap.contextid FROM {analytics_predictions} ap
+                      {$joinusersql}
+                     WHERE u.id = :userid AND ap.modelid = :modelid";
+            $contextlist->add_from_sql($sql, ['userid' => $userid, 'modelid' => $modelid]);
+
+            // Indicator calculations.
+            $joinusersql = $analyser->join_sample_user('aic');
+            $sql = "SELECT DISTINCT aic.contextid FROM {analytics_indicator_calc} aic
+                      {$joinusersql}
+                     WHERE u.id = :userid AND aic.sampleorigin = :analysersamplesorigin";
+            $contextlist->add_from_sql($sql, ['userid' => $userid, 'analysersamplesorigin' => $analyser->get_samples_origin()]);
+        }
+
+        // We can leave this out of the loop as there is no analyser-dependant stuff.
+        list($sql, $params) = self::analytics_prediction_actions_sql($userid, array_keys($models));
+        $sql = "SELECT DISTINCT ap.contextid" . $sql;
+        $contextlist->add_from_sql($sql, $params);
+
+        return $contextlist;
+    }
+
+    /**
+     * 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 = intval($contextlist->get_user()->id);
+
+        $models = self::get_models_with_user_data();
+        $modelids = array_keys($models);
+
+        list ($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED);
+
+        $rootpath = [get_string('analytics', 'analytics')];
+        $ctxfields = \context_helper::get_preload_record_columns_sql('ctx');
+
+        foreach ($models as $modelid => $model) {
+
+            $analyser = $model->get_analyser(['notimesplitting' => true]);
+
+            // Analytics predictions.
+            $joinusersql = $analyser->join_sample_user('ap');
+            $sql = "SELECT ap.*, $ctxfields FROM {analytics_predictions} ap
+                      JOIN {context} ctx ON ctx.id = ap.contextid
+                      {$joinusersql}
+                     WHERE u.id = :userid AND ap.modelid = :modelid AND ap.contextid {$contextsql}";
+            $params = ['userid' => $userid, 'modelid' => $modelid] + $contextparams;
+            $predictions = $DB->get_recordset_sql($sql, $params);
+
+            foreach ($predictions as $prediction) {
+                \context_helper::preload_from_record($prediction);
+                $context = \context::instance_by_id($prediction->contextid);
+                $path = $rootpath;
+                $path[] = get_string('privacy:metadata:analytics:predictions', 'analytics');
+                $path[] = $prediction->id;
+
+                $data = (object)[
+                    'target' => $model->get_target()->get_name()->out(),
+                    'context' => $context->get_context_name(true, true),
+                    'prediction' => $model->get_target()->get_display_value($prediction->prediction),
+                    'timestart' => transform::datetime($prediction->timestart),
+                    'timeend' => transform::datetime($prediction->timeend),
+                    'timecreated' => transform::datetime($prediction->timecreated),
+                ];
+                writer::with_context($context)->export_data($path, $data);
+            }
+            $predictions->close();
+
+            // Indicator calculations.
+            $joinusersql = $analyser->join_sample_user('aic');
+            $sql = "SELECT aic.*, $ctxfields FROM {analytics_indicator_calc} aic
+                      JOIN {context} ctx ON ctx.id = aic.contextid
+                      {$joinusersql}
+                     WHERE u.id = :userid AND aic.sampleorigin = :analysersamplesorigin AND aic.contextid {$contextsql}";
+            $params = ['userid' => $userid, 'analysersamplesorigin' => $analyser->get_samples_origin()] + $contextparams;
+            $indicatorcalculations = $DB->get_recordset_sql($sql, $params);
+            foreach ($indicatorcalculations as $calculation) {
+                \context_helper::preload_from_record($calculation);
+                $context = \context::instance_by_id($calculation->contextid);
+                $path = $rootpath;
+                $path[] = get_string('privacy:metadata:analytics:indicatorcalc', 'analytics');
+                $path[] = $calculation->id;
+
+                $indicator = \core_analytics\manager::get_indicator($calculation->indicator);
+                $data = (object)[
+                    'indicator' => $indicator::get_name()->out(),
+                    'context' => $context->get_context_name(true, true),
+                    'calculation' => $indicator->get_display_value($calculation->value),
+                    'starttime' => transform::datetime($calculation->starttime),
+                    'endtime' => transform::datetime($calculation->endtime),
+                    'timecreated' => transform::datetime($calculation->timecreated),
+                ];
+                writer::with_context($context)->export_data($path, $data);
+            }
+            $indicatorcalculations->close();
+        }
+
+        // Analytics predictions.
+        // Provided contexts are ignored as we export all user-related stuff.
+        list($sql, $params) = self::analytics_prediction_actions_sql($userid, $modelids, $contextsql);
+        $sql = "SELECT apa.*, ap.modelid, ap.contextid, $ctxfields" . $sql;
+        $predictionactions = $DB->get_recordset_sql($sql, $params + $contextparams);
+        foreach ($predictionactions as $predictionaction) {
+
+            \context_helper::preload_from_record($predictionaction);
+            $context = \context::instance_by_id($predictionaction->contextid);
+            $path = $rootpath;
+            $path[] = get_string('privacy:metadata:analytics:predictionactions', 'analytics');
+            $path[] = $predictionaction->id;
+
+            $data = (object)[
+                'target' => $models[$predictionaction->modelid]->get_target()->get_name()->out(),
+                'context' => $context->get_context_name(true, true),
+                'action' => $predictionaction->actionname,
+                'timecreated' => transform::datetime($predictionaction->timecreated),
+            ];
+            writer::with_context($context)->export_data($path, $data);
+        }
+        $predictionactions->close();
+    }
+
+    /**
+     * 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) {
+        global $DB;
+
+        $models = self::get_models_with_user_data();
+        $modelids = array_keys($models);
+
+        foreach ($models as $modelid => $model) {
+
+            $idssql = "SELECT ap.id FROM {analytics_predictions} ap
+                        WHERE ap.contextid = :contextid AND ap.modelid = :modelid";
+            $idsparams = ['contextid' => $context->id, 'modelid' => $modelid];
+            $predictionids = $DB->get_fieldset_sql($idssql, $idsparams);
+            if ($predictionids) {
+                list($predictionidssql, $params) = $DB->get_in_or_equal($predictionids, SQL_PARAMS_NAMED);
+
+                $DB->delete_records_select('analytics_prediction_actions', "predictionid IN ($idssql)", $idsparams);
+                $DB->delete_records_select('analytics_predictions', "id $predictionidssql", $params);
+            }
+        }
+
+        // We delete them all this table is just a cache and we don't know which model filled it.
+        $DB->delete_records('analytics_indicator_calc', ['contextid' => $context->id]);
+    }
+
+    /**
+     * 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) {
+        global $DB;
+
+        $userid = intval($contextlist->get_user()->id);
+
+        $models = self::get_models_with_user_data();
+        $modelids = array_keys($models);
+
+        list ($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED);
+
+        // Analytics prediction actions.
+        list($sql, $apaparams) = self::analytics_prediction_actions_sql($userid, $modelids, $contextsql);
+        $sql = "SELECT apa.id " . $sql;
+
+        $predictionactionids = $DB->get_fieldset_sql($sql, $apaparams + $contextparams);
+        if ($predictionactionids) {
+            list ($predictionactionidssql, $params) = $DB->get_in_or_equal($predictionactionids);
+            $DB->delete_records_select('analytics_prediction_actions', "id {$predictionactionidssql}", $params);
+        }
+
+        foreach ($models as $modelid => $model) {
+
+            $analyser = $model->get_analyser(['notimesplitting' => true]);
+
+            // Analytics predictions.
+            $joinusersql = $analyser->join_sample_user('ap');
+            $sql = "SELECT DISTINCT ap.id FROM {analytics_predictions} ap
+                      {$joinusersql}
+                     WHERE u.id = :userid AND ap.modelid = :modelid AND ap.contextid {$contextsql}";
+
+            $predictionids = $DB->get_fieldset_sql($sql, ['userid' => $userid, 'modelid' => $modelid] + $contextparams);
+            if ($predictionids) {
+                list($predictionidssql, $params) = $DB->get_in_or_equal($predictionids, SQL_PARAMS_NAMED);
+                $DB->delete_records_select('analytics_predictions', "id $predictionidssql", $params);
+            }
+
+            // Indicator calculations.
+            $joinusersql = $analyser->join_sample_user('aic');
+            $sql = "SELECT DISTINCT aic.id FROM {analytics_indicator_calc} aic
+                      {$joinusersql}
+                     WHERE u.id = :userid AND aic.sampleorigin = :analysersamplesorigin AND aic.contextid {$contextsql}";
+
+            $params = ['userid' => $userid, 'analysersamplesorigin' => $analyser->get_samples_origin()] + $contextparams;
+            $indicatorcalcids = $DB->get_fieldset_sql($sql, $params);
+            if ($indicatorcalcids) {
+                list ($indicatorcalcidssql, $params) = $DB->get_in_or_equal($indicatorcalcids, SQL_PARAMS_NAMED);
+                $DB->delete_records_select('analytics_indicator_calc', "id $indicatorcalcidssql", $params);
+            }
+        }
+    }
+
+    /**
+     * Returns a list of models with user data.
+     *
+     * @return \core_analytics\model[]
+     */
+    private static function get_models_with_user_data() {
+        $models = \core_analytics\manager::get_all_models();
+        foreach ($models as $modelid => $model) {
+            $analyser = $model->get_analyser(['notimesplitting' => true]);
+            if (!$analyser->processes_user_data()) {
+                unset($models[$modelid]);
+            }
+        }
+        return $models;
+    }
+
+    /**
+     * Returns the sql query to query analytics_prediction_actions table.
+     *
+     * @param int $userid
+     * @param int[] $modelids
+     * @param string $contextsql
+     * @return array sql string in [0] and params in [1]
+     */
+    private static function analytics_prediction_actions_sql($userid, $modelids, $contextsql = false) {
+        global $DB;
+
+        list($insql, $params) = $DB->get_in_or_equal($modelids, SQL_PARAMS_NAMED);
+        $sql = " FROM {analytics_predictions} ap
+                  JOIN {context} ctx ON ctx.id = ap.contextid
+                  JOIN {analytics_prediction_actions} apa ON apa.predictionid = ap.id
+                  JOIN {analytics_models} am ON ap.modelid = am.id
+                 WHERE apa.userid = :userid AND ap.modelid {$insql}";
+        $params['userid'] = $userid;
+
+        if ($contextsql) {
+            $sql .= " AND ap.contextid $contextsql";
+        }
+
+        return [$sql, $params];
+    }
+}
diff --git a/analytics/tests/fixtures/test_site_users_analyser.php b/analytics/tests/fixtures/test_site_users_analyser.php
new file mode 100644 (file)
index 0000000..39bc1f2
--- /dev/null
@@ -0,0 +1,147 @@
+<?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/>.
+
+/**
+ * Test analyser
+ *
+ * @package   core
+ * @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Test analyser
+ *
+ * @package   core
+ * @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class test_site_users_analyser extends \core_analytics\local\analyser\sitewide {
+
+    /**
+     * Samples origin is course table.
+     *
+     * @return string
+     */
+    public function get_samples_origin() {
+        return 'user';
+    }
+
+    /**
+     * Returns the sample analysable
+     *
+     * @param int $sampleid
+     * @return \core_analytics\analysable
+     */
+    public function get_sample_analysable($sampleid) {
+        return new \core_analytics\site();
+    }
+
+    /**
+     * Data this analyer samples provide.
+     *
+     * @return string[]
+     */
+    protected function provided_sample_data() {
+        return array('user');
+    }
+
+    /**
+     * Returns the sample context.
+     *
+     * @param int $sampleid
+     * @return \context
+     */
+    public function sample_access_context($sampleid) {
+        return \context_system::instance();
+    }
+
+    /**
+     * Returns all site courses.
+     *
+     * @param \core_analytics\analysable $site
+     * @return array
+     */
+    protected function get_all_samples(\core_analytics\analysable $site) {
+        global $DB;
+
+        $users = $DB->get_records('user');
+        $userids = array_keys($users);
+        $sampleids = array_combine($userids, $userids);
+
+        $users = array_map(function($user) {
+            return array('user' => $user);
+        }, $users);
+
+        return array($sampleids, $users);
+    }
+
+    /**
+     * Return all complete samples data from sample ids.
+     *
+     * @param int[] $sampleids
+     * @return array
+     */
+    public function get_samples($sampleids) {
+        global $DB;
+
+        list($userssql, $params) = $DB->get_in_or_equal($sampleids, SQL_PARAMS_NAMED);
+        $users = $DB->get_records_select('user', "id {$userssql}", $params);
+        $userids = array_keys($users);
+        $sampleids = array_combine($userids, $userids);
+
+        $users = array_map(function($user) {
+            return array('user' => $user);
+        }, $users);
+
+        return array($sampleids, $users);
+    }
+
+    /**
+     * Returns the description of a sample.
+     *
+     * @param int $sampleid
+     * @param int $contextid
+     * @param array $sampledata
+     * @return array array(string, \renderable)
+     */
+    public function sample_description($sampleid, $contextid, $sampledata) {
+        $description = fullname($samplesdata['user']);
+        $userimage = new \pix_icon('i/user', get_string('user'));
+        return array($description, $userimage);
+    }
+
+    /**
+     * We need to delete associated data if a user requests his data to be deleted.
+     *
+     * @return bool
+     */
+    public function processes_user_data() {
+        return true;
+    }
+
+    /**
+     * Join the samples origin table with the user id table.
+     *
+     * @param string $sampletablealias
+     * @return string
+     */
+    public function join_sample_user($sampletablealias) {
+        return "JOIN {user} u ON u.id = {$sampletablealias}.sampleid";
+    }
+}
diff --git a/analytics/tests/fixtures/test_target_course_users.php b/analytics/tests/fixtures/test_target_course_users.php
new file mode 100644 (file)
index 0000000..8907a3c
--- /dev/null
@@ -0,0 +1,60 @@
+<?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/>.
+
+/**
+ * Test target.
+ *
+ * @package   core_analytics
+ * @copyright 2017 David Monllaó {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once(__DIR__ . '/test_target_shortname.php');
+
+/**
+ * Test target.
+ *
+ * @package   core_analytics
+ * @copyright 2018 David Monllaó {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class test_target_course_users extends test_target_site_users {
+
+    /**
+     * get_analyser_class
+     *
+     * @return string
+     */
+    public function get_analyser_class() {
+        return '\core\analytics\analyser\student_enrolments';
+    }
+
+    /**
+     * Returns a lang_string object representing the name for the indicator.
+     *
+     * Used as column identificator.
+     *
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
+     */
+    public static function get_name() : \lang_string {
+        // Using a string that exists and contains a corresponding '_help' string.
+        return new \lang_string('adminhelpedituser');
+    }
+}
diff --git a/analytics/tests/fixtures/test_target_site_users.php b/analytics/tests/fixtures/test_target_site_users.php
new file mode 100644 (file)
index 0000000..6f8b30b
--- /dev/null
@@ -0,0 +1,151 @@
+<?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/>.
+
+/**
+ * Test target.
+ *
+ * @package   core_analytics
+ * @copyright 2018 David Monllaó {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once(__DIR__ . '/test_site_users_analyser.php');
+
+/**
+ * Test target.
+ *
+ * @package   core_analytics
+ * @copyright 2018 David Monllaó {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class test_target_site_users extends \core_analytics\local\target\binary {
+
+    /**
+     * Returns a lang_string object representing the name for the indicator.
+     *
+     * Used as column identificator.
+     *
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
+     */
+    public static function get_name() : \lang_string {
+        // Using a string that exists and contains a corresponding '_help' string.
+        return new \lang_string('adminhelplogs');
+    }
+
+    /**
+     * predictions
+     *
+     * @var array
+     */
+    protected $predictions = array();
+
+    /**
+     * get_analyser_class
+     *
+     * @return string
+     */
+    public function get_analyser_class() {
+        return 'test_site_users_analyser';
+    }
+
+    /**
+     * classes_description
+     *
+     * @return string[]
+     */
+    public static function classes_description() {
+        return array(
+            'firstname first char is A',
+            'firstname first char is not A'
+        );
+    }
+
+    /**
+     * We don't want to discard results.
+     * @return float
+     */
+    protected function min_prediction_score() {
+        return null;
+    }
+
+    /**
+     * We don't want to discard results.
+     * @return array
+     */
+    protected function ignored_predicted_classes() {
+        return array();
+    }
+
+    /**
+     * is_valid_analysable
+     *
+     * @param \core_analytics\analysable $analysable
+     * @param bool $fortraining
+     * @return bool
+     */
+    public function is_valid_analysable(\core_analytics\analysable $analysable, $fortraining = true) {
+        // This is testing, let's make things easy.
+        return true;
+    }
+
+    /**
+     * is_valid_sample
+     *
+     * @param int $sampleid
+     * @param \core_analytics\analysable $analysable
+     * @param bool $fortraining
+     * @return bool
+     */
+    public function is_valid_sample($sampleid, \core_analytics\analysable $analysable, $fortraining = true) {
+        // We skip not-visible courses during training as a way to emulate the training data / prediction data difference.
+        // In normal circumstances is_valid_sample will return false when they receive a sample that can not be
+        // processed.
+        if (!$fortraining) {
+            return true;
+        }
+
+        $sample = $this->retrieve('user', $sampleid);
+        if ($sample->lastname == 'b') {
+            return false;
+        }
+        return true;
+    }
+
+    /**
+     * calculate_sample
+     *
+     * @param int $sampleid
+     * @param \core_analytics\analysable $analysable
+     * @param int $starttime
+     * @param int $endtime
+     * @return float
+     */
+    protected function calculate_sample($sampleid, \core_analytics\analysable $analysable, $starttime = false, $endtime = false) {
+
+        $sample = $this->retrieve('user', $sampleid);
+
+        $firstchar = substr($sample->firstname, 0, 1);
+        if ($firstchar === 'a') {
+            return 1;
+        } else {
+            return 0;
+        }
+    }
+}
diff --git a/analytics/tests/privacy_test.php b/analytics/tests/privacy_test.php
new file mode 100644 (file)
index 0000000..026d839
--- /dev/null
@@ -0,0 +1,213 @@
+<?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/>.
+
+/**
+ * Unit tests for privacy.
+ *
+ * @package   core_analytics
+ * @copyright 2018 David Monllaó {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+use \core_analytics\privacy\provider;
+use core_privacy\local\request\transform;
+use core_privacy\local\request\writer;
+use core_privacy\local\request\approved_contextlist;
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once(__DIR__ . '/fixtures/test_indicator_max.php');
+require_once(__DIR__ . '/fixtures/test_indicator_min.php');
+require_once(__DIR__ . '/fixtures/test_target_site_users.php');
+require_once(__DIR__ . '/fixtures/test_target_course_users.php');
+require_once(__DIR__ . '/fixtures/test_analyser.php');
+
+/**
+ * Unit tests for privacy.
+ *
+ * @package   core_analytics
+ * @copyright 2018 David Monllaó {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class core_analytics_privacy_model_testcase extends \core_privacy\tests\provider_testcase {
+
+    public function setUp() {
+
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+
+        $timesplittingid = '\core\analytics\time_splitting\single_range';
+        $target = \core_analytics\manager::get_target('test_target_site_users');
+        $indicators = array('test_indicator_max');
+        foreach ($indicators as $key => $indicator) {
+            $indicators[$key] = \core_analytics\manager::get_indicator($indicator);
+        }
+        $this->model1 = \core_analytics\model::create($target, $indicators, $timesplittingid);
+        $this->modelobj1 = $this->model1->get_model_obj();
+
+        $target = \core_analytics\manager::get_target('test_target_course_users');
+        $indicators = array('test_indicator_min');
+        foreach ($indicators as $key => $indicator) {
+            $indicators[$key] = \core_analytics\manager::get_indicator($indicator);
+        }
+        $this->model2 = \core_analytics\model::create($target, $indicators, $timesplittingid);
+        $this->modelobj2 = $this->model1->get_model_obj();
+
+        $this->u1 = $this->getDataGenerator()->create_user(['firstname' => 'a111111111111', 'lastname' => 'a']);
+        $this->u2 = $this->getDataGenerator()->create_user(['firstname' => 'a222222222222', 'lastname' => 'a']);
+        $this->u3 = $this->getDataGenerator()->create_user(['firstname' => 'b333333333333', 'lastname' => 'b']);
+        $this->u4 = $this->getDataGenerator()->create_user(['firstname' => 'b444444444444', 'lastname' => 'b']);
+
+        $this->c1 = $this->getDataGenerator()->create_course(['visible' => false]);
+        $this->c2 = $this->getDataGenerator()->create_course();
+
+        $this->getDataGenerator()->enrol_user($this->u1->id, $this->c1->id, 'student');
+        $this->getDataGenerator()->enrol_user($this->u2->id, $this->c1->id, 'student');
+        $this->getDataGenerator()->enrol_user($this->u3->id, $this->c1->id, 'student');
+        $this->getDataGenerator()->enrol_user($this->u4->id, $this->c1->id, 'student');
+        $this->getDataGenerator()->enrol_user($this->u1->id, $this->c2->id, 'student');
+        $this->getDataGenerator()->enrol_user($this->u2->id, $this->c2->id, 'student');
+        $this->getDataGenerator()->enrol_user($this->u3->id, $this->c2->id, 'student');
+        $this->getDataGenerator()->enrol_user($this->u4->id, $this->c2->id, 'student');
+
+        $this->setAdminUser();
+
+        $this->model1->train();
+        $this->model1->predict();
+        $this->model2->train();
+        $this->model2->predict();
+
+        list($total, $predictions) = $this->model2->get_predictions(\context_course::instance($this->c1->id));
+
+        $this->setUser($this->u3);
+        $prediction = reset($predictions);
+        $prediction->action_executed('notuseful', $this->model2->get_target());
+
+        $this->setAdminUser();
+    }
+
+    /**
+     * Test delete a context.
+     *
+     * @return null
+     */
+    public function test_delete_context_data() {
+        global $DB;
+
+        // We have 2 predictions for model1 and 4 predictions for model2.
+        $this->assertEquals(6, $DB->count_records('analytics_predictions'));
+        $this->assertEquals(14, $DB->count_records('analytics_indicator_calc'));
+
+        // We have 1 prediction action.
+        $this->assertEquals(1, $DB->count_records('analytics_prediction_actions'));
+
+        $coursecontext = \context_course::instance($this->c1->id);
+
+        // Delete the course that was used for prediction.
+        provider::delete_data_for_all_users_in_context($coursecontext);
+
+        // The course predictions are deleted.
+        $this->assertEquals(4, $DB->count_records('analytics_predictions'));
+
+        // Calculations related to that context are deleted.
+        $this->assertEmpty($DB->count_records('analytics_indicator_calc', ['contextid' => $coursecontext->id]));
+
+        // The deleted context prediction actions are deleted as well.
+        $this->assertEquals(0, $DB->count_records('analytics_prediction_actions'));
+    }
+
+    /**
+     * Test delete a user.
+     *
+     * @return null
+     */
+    public function test_delete_user_data() {
+        global $DB;
+
+        $usercontexts = provider::get_contexts_for_userid($this->u3->id);
+        $contextlist = new \core_privacy\local\request\approved_contextlist($this->u3, 'core_analytics',
+                                                                            $usercontexts->get_contextids());
+        provider::delete_data_for_user($contextlist);
+
+        // The site level prediction for u3 was deleted.
+        $this->assertEquals(3, $DB->count_records('analytics_predictions'));
+        $this->assertEquals(0, $DB->count_records('analytics_prediction_actions'));
+
+        $usercontexts = provider::get_contexts_for_userid($this->u1->id);
+        $contextlist = new \core_privacy\local\request\approved_contextlist($this->u1, 'core_analytics',
+                                                                            $usercontexts->get_contextids());
+        provider::delete_data_for_user($contextlist);
+        // We have nothing for u1.
+        $this->assertEquals(3, $DB->count_records('analytics_predictions'));
+
+        $usercontexts = provider::get_contexts_for_userid($this->u4->id);
+        $contextlist = new \core_privacy\local\request\approved_contextlist($this->u4, 'core_analytics',
+                                                                            $usercontexts->get_contextids());
+        provider::delete_data_for_user($contextlist);
+        $this->assertEquals(0, $DB->count_records('analytics_predictions'));
+    }
+
+    /**
+     * Test export user data.
+     *
+     * @return null
+     */
+    public function test_export_data() {
+        global $DB;
+
+        $system = \context_system::instance();
+        list($total, $predictions) = $this->model1->get_predictions($system);
+        foreach ($predictions as $key => $prediction) {
+            if ($prediction->get_prediction_data()->sampleid !== $this->u3->id) {
+                $otheruserprediction = $prediction;
+                break;
+            }
+        }
+        $this->setUser($this->u3);
+        $otheruserprediction->action_executed('notuseful', $this->model1->get_target());
+        $this->setAdminUser();
+
+        $this->export_context_data_for_user($this->u3->id, $system, 'core_analytics');
+        $writer = \core_privacy\local\request\writer::with_context($system);
+        $this->assertTrue($writer->has_any_data());
+
+        $u3prediction = $DB->get_record('analytics_predictions', ['contextid' => $system->id, 'sampleid' => $this->u3->id]);
+        $data = $writer->get_data([get_string('analytics', 'analytics'),
+            get_string('privacy:metadata:analytics:predictions', 'analytics'), $u3prediction->id]);
+        $this->assertEquals(get_string('adminhelplogs'), $data->target);
+        $this->assertEquals(get_string('coresystem'), $data->context);
+        $this->assertEquals('firstname first char is not A', $data->prediction);
+
+        $u3calculation = $DB->get_record('analytics_indicator_calc', ['contextid' => $system->id, 'sampleid' => $this->u3->id]);
+        $data = $writer->get_data([get_string('analytics', 'analytics'),
+            get_string('privacy:metadata:analytics:indicatorcalc', 'analytics'), $u3calculation->id]);
+        $this->assertEquals('Allow stealth activities', $data->indicator);
+        $this->assertEquals(get_string('coresystem'), $data->context);
+        $this->assertEquals(get_string('yes'), $data->calculation);
+
+        $sql = "SELECT apa.id FROM {analytics_prediction_actions} apa
+                  JOIN {analytics_predictions} ap ON ap.id = apa.predictionid
+                 WHERE ap.contextid = :contextid AND apa.userid = :userid AND ap.modelid = :modelid";
+        $params = ['contextid' => $system->id, 'userid' => $this->u3->id, 'modelid' => $this->model1->get_id()];
+        $u3action = $DB->get_record_sql($sql, $params);
+        $data = $writer->get_data([get_string('analytics', 'analytics'),
+            get_string('privacy:metadata:analytics:predictionactions', 'analytics'), $u3action->id]);
+        $this->assertEquals(get_string('adminhelplogs'), $data->target);
+        $this->assertEquals(get_string('coresystem'), $data->context);
+        $this->assertEquals('notuseful', $data->action);
+
+    }
+}
diff --git a/analytics/upgrade.txt b/analytics/upgrade.txt
new file mode 100644 (file)
index 0000000..550a3da
--- /dev/null
@@ -0,0 +1,9 @@
+This files describes API changes in analytics sub system,
+information provided here is intended especially for developers.
+
+=== 3.5 ===
+
+* There are two new methods for analysers, processes_user_data() and join_sample_user(). You
+  need to overwrite them if your analyser uses user data. As a general statement, you should
+  overwrite these new methods if your samples return 'user' data. These new methods are used
+  for analytics' privacy API implementation.
diff --git a/auth/classes/privacy/provider.php b/auth/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..1c61c57
--- /dev/null
@@ -0,0 +1,103 @@
+<?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_auth
+ * @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_auth\privacy;
+defined('MOODLE_INTERNAL') || die();
+
+use context;
+use core_privacy\local\metadata\collection;
+use core_privacy\local\request\transform;
+use core_privacy\local\request\writer;
+
+/**
+ * Data provider class.
+ *
+ * @package    core_auth
+ * @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\user_preference_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_user_preference('auth_forcepasswordchange', 'privacy:metadata:userpref:forcepasswordchange');
+        $collection->add_user_preference('create_password', 'privacy:metadata:userpref:createpassword');
+        $collection->add_user_preference('login_failed_count', 'privacy:metadata:userpref:loginfailedcount');
+        $collection->add_user_preference('login_failed_count_since_success',
+            'privacy:metadata:userpref:loginfailedcountsincesuccess');
+        $collection->add_user_preference('login_failed_last', 'privacy:metadata:userpref:loginfailedlast');
+        $collection->add_user_preference('login_lockout', 'privacy:metadata:userpref:loginlockout');
+        $collection->add_user_preference('login_lockout_ignored', 'privacy:metadata:userpref:loginlockoutignored');
+        $collection->add_user_preference('login_lockout_secret', 'privacy:metadata:userpref:loginlockoutsecret');
+
+        return $collection;
+    }
+
+    /**
+     * Export all user preferences for the plugin.
+     *
+     * @param int $userid The userid of the user whose data is to be exported.
+     */
+    public static function export_user_preferences(int $userid) {
+
+        $yesno = function($v) {
+            return transform::yesno($v);
+        };
+        $datetime = function($v) {
+            return $v ? transform::datetime($v) : null;
+        };
+
+        $prefs = [
+            ['auth_forcepasswordchange', 'forcepasswordchange', $yesno],
+            ['create_password', 'createpassword', $yesno],
+            ['login_failed_count', 'loginfailedcount', null],
+            ['login_failed_count_since_success', 'loginfailedcountsincesuccess', null],
+            ['login_failed_last', 'loginfailedlast', $datetime],
+            ['login_lockout', 'loginlockout', $datetime],
+            ['login_lockout_ignored', 'loginlockoutignored', $yesno],
+            ['login_lockout_secret', 'loginlockoutsecret', null],
+        ];
+
+        foreach ($prefs as $prefdata) {
+            list($prefname, $langkey, $transformer) = $prefdata;
+            $value = get_user_preferences($prefname, null, $userid);
+            if ($value === null) {
+                continue;
+            }
+            writer::export_user_preference('core_auth', $prefname, $transformer ? $transformer($value) : $value,
+                get_string("privacy:metadata:userpref:{$langkey}", 'core_auth'));
+        }
+    }
+
+}
diff --git a/auth/tests/privacy_test.php b/auth/tests/privacy_test.php
new file mode 100644 (file)
index 0000000..16701f2
--- /dev/null
@@ -0,0 +1,103 @@
+<?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 tests.
+ *
+ * @package    core_auth
+ * @category   test
+ * @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
+ */
+
+defined('MOODLE_INTERNAL') || die();
+global $CFG;
+
+use core_privacy\tests\provider_testcase;
+use core_privacy\local\request\transform;
+use core_privacy\local\request\writer;
+use core_auth\privacy\provider;
+
+/**
+ * Data provider testcase class.
+ *
+ * @package    core_auth
+ * @category   test
+ * @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 core_auth_privacy_testcase extends provider_testcase {
+
+    public function setUp() {
+        $this->resetAfterTest();
+    }
+
+    public function test_export_user_preferences() {
+        $dg = $this->getDataGenerator();
+        $u1 = $dg->create_user();
+        $u2 = $dg->create_user();
+        $sysctx = context_system::instance();
+        $now = time();
+
+        // Check nothing is there.
+        writer::reset();
+        provider::export_user_preferences($u1->id);
+        $prefs = writer::with_context($sysctx)->get_user_preferences('core_auth');
+        $this->assertEmpty((array) $prefs);
+
+        // Set some preferences.
+        set_user_preference('auth_forcepasswordchange', 1, $u1);
+        set_user_preference('create_password', 1, $u1);
+        set_user_preference('login_failed_count', 18, $u1);
+        set_user_preference('login_failed_count_since_success', 7, $u1);
+        set_user_preference('login_failed_last', $now - DAYSECS, $u1);
+        set_user_preference('login_lockout', $now - HOURSECS, $u1);
+        set_user_preference('login_lockout_ignored', 0, $u1);
+        set_user_preference('login_lockout_secret', 'Hello world!', $u1);
+
+        set_user_preference('auth_forcepasswordchange', 0, $u2);
+        set_user_preference('create_password', 0, $u2);
+        set_user_preference('login_lockout_ignored', 1, $u2);
+
+        // Check user 1.
+        writer::reset();
+        provider::export_user_preferences($u1->id);
+        $prefs = writer::with_context($sysctx)->get_user_preferences('core_auth');
+        $this->assertEquals(transform::yesno(true), $prefs->auth_forcepasswordchange->value);
+        $this->assertEquals(transform::yesno(true), $prefs->create_password->value);
+        $this->assertEquals(18, $prefs->login_failed_count->value);
+        $this->assertEquals(7, $prefs->login_failed_count_since_success->value);
+        $this->assertEquals(transform::datetime($now - DAYSECS), $prefs->login_failed_last->value);
+        $this->assertEquals(transform::datetime($now - HOURSECS), $prefs->login_lockout->value);
+        $this->assertEquals(transform::yesno(false), $prefs->login_lockout_ignored->value);
+        $this->assertEquals('Hello world!', $prefs->login_lockout_secret->value);
+
+        // Check user 2.
+        writer::reset();
+        provider::export_user_preferences($u2->id);
+        $prefs = writer::with_context($sysctx)->get_user_preferences('core_auth');
+        $this->assertEquals(transform::yesno(false), $prefs->auth_forcepasswordchange->value);
+        $this->assertEquals(transform::yesno(false), $prefs->create_password->value);
+        $this->assertObjectNotHasAttribute('login_failed_count', $prefs);
+        $this->assertObjectNotHasAttribute('login_failed_count_since_success', $prefs);
+        $this->assertObjectNotHasAttribute('login_failed_last', $prefs);
+        $this->assertObjectNotHasAttribute('login_lockout', $prefs);
+        $this->assertEquals(transform::yesno(true), $prefs->login_lockout_ignored->value);
+        $this->assertObjectNotHasAttribute('login_lockout_secret', $prefs);
+    }
+}
diff --git a/availability/classes/privacy/provider.php b/availability/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..bb8a3a7
--- /dev/null
@@ -0,0 +1,46 @@
+<?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/>.
+
+/**
+ * Privacy Subsystem implementation for core_availability.
+ *
+ * @package    core_availability
+ * @copyright  2018 Sara Arjona <sara@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_availability\privacy;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Privacy Subsystem for core_availability implementing null_provider.
+ *
+ * @copyright  2018 Sara Arjona <sara@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class provider implements \core_privacy\local\metadata\null_provider {
+
+    /**
+     * Get the language string identifier with the component's language
+     * file to explain why this plugin stores no data.
+     *
+     * @return  string
+     */
+    public static function get_reason() : string {
+        return 'privacy:metadata';
+    }
+}
\ No newline at end of file
diff --git a/cache/classes/privacy/provider.php b/cache/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..20cc83f
--- /dev/null
@@ -0,0 +1,66 @@
+<?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/>.
+
+/**
+ * Privacy Subsystem implementation for core_cache.
+ *
+ * @package    core_cache
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL&n