MDL-63496 tool_dataprivacy: Add configuration for per-role retention
authorAndrew Nicols <andrew@nicols.co.uk>
Thu, 4 Oct 2018 00:50:10 +0000 (08:50 +0800)
committerAndrew Nicols <andrew@nicols.co.uk>
Wed, 17 Oct 2018 05:12:18 +0000 (13:12 +0800)
This issue is a part of the MDL-62560 Epic.

admin/tool/dataprivacy/classes/api.php
admin/tool/dataprivacy/classes/external/purpose_exporter.php
admin/tool/dataprivacy/classes/form/purpose.php
admin/tool/dataprivacy/classes/output/expired_contexts_table.php
admin/tool/dataprivacy/editpurpose.php
admin/tool/dataprivacy/lang/en/tool_dataprivacy.php
admin/tool/dataprivacy/templates/purposes.mustache

index 8b2142f..aef98de 100644 (file)
@@ -1210,4 +1210,25 @@ class api {
 
         return true;
     }
+
+    /**
+     * Format the supplied date interval as a retention period.
+     *
+     * @param   \DateInterval   $interval
+     * @return  string
+     */
+    public static function format_retention_period(\DateInterval $interval) : string {
+        // It is one or another.
+        if ($interval->y) {
+            $formattedtime = get_string('numyears', 'moodle', $interval->format('%y'));
+        } else if ($interval->m) {
+            $formattedtime = get_string('nummonths', 'moodle', $interval->format('%m'));
+        } else if ($interval->d) {
+            $formattedtime = get_string('numdays', 'moodle', $interval->format('%d'));
+        } else {
+            $formattedtime = get_string('retentionperiodzero', 'tool_dataprivacy');
+        }
+
+        return $formattedtime;
+    }
 }
index a6519c6..8e24235 100644 (file)
@@ -26,7 +26,6 @@ defined('MOODLE_INTERNAL') || die();
 
 use coding_exception;
 use core\external\persistent_exporter;
-use DateInterval;
 use Exception;
 use renderer_base;
 use tool_dataprivacy\context_instance;
@@ -79,6 +78,9 @@ class purpose_exporter extends persistent_exporter {
                 'multiple' => true,
                 'optional' => true
             ],
+            'roleoverrides' => [
+                'type' => PARAM_TEXT
+            ],
         ];
     }
 
@@ -125,23 +127,14 @@ class purpose_exporter extends persistent_exporter {
 
         $retentionperiod = $this->persistent->get('retentionperiod');
         if ($retentionperiod) {
-            $interval = new DateInterval($retentionperiod);
-
-            // It is one or another.
-            if ($interval->y) {
-                $formattedtime = get_string('numyears', 'moodle', $interval->format('%y'));
-            } else if ($interval->m) {
-                $formattedtime = get_string('nummonths', 'moodle', $interval->format('%m'));
-            } else if ($interval->d) {
-                $formattedtime = get_string('numdays', 'moodle', $interval->format('%d'));
-            } else {
-                $formattedtime = get_string('retentionperiodzero', 'tool_dataprivacy');
-            }
+            $formattedtime = \tool_dataprivacy\api::format_retention_period(new \DateInterval($retentionperiod));
         } else {
             $formattedtime = get_string('retentionperiodnotdefined', 'tool_dataprivacy');
         }
         $values['formattedretentionperiod'] = $formattedtime;
 
+        $values['roleoverrides'] = !empty($this->persistent->get_purpose_overrides());
+
         return $values;
     }
 
index dba39df..72472e5 100644 (file)
@@ -41,6 +41,11 @@ class purpose extends persistent {
      */
     protected static $persistentclass = 'tool_dataprivacy\\purpose';
 
+    /**
+     * @var array The list of current overrides.
+     */
+    protected $existingoverrides = [];
+
     /**
      * Define the form - called by parent constructor
      */
@@ -56,54 +61,373 @@ class purpose extends persistent {
         $mform->setType('description', PARAM_CLEANHTML);
 
         // Field for selecting lawful bases (from GDPR Article 6.1).
-        $lawfulbases = [];
+        $this->add_field($this->get_lawful_base_field());
+        $mform->addRule('lawfulbases', get_string('required'), 'required', null, 'server');
+
+        // Optional field for selecting reasons for collecting sensitive personal data (from GDPR Article 9.2).
+        $this->add_field($this->get_sensitive_base_field());
+
+        $this->add_field($this->get_retention_period_fields());
+        $this->add_field($this->get_protected_field());
+
+        $this->add_override_fields();
+
+        if (!empty($this->_customdata['showbuttons'])) {
+            if (!$this->get_persistent()->get('id')) {
+                $savetext = get_string('add');
+            } else {
+                $savetext = get_string('savechanges');
+            }
+            $this->add_action_buttons(true, $savetext);
+        }
+    }
+
+    /**
+     * Add a fieldset to the current form.
+     *
+     * @param   \stdClass   $data
+     */
+    protected function add_field(\stdClass $data) {
+        foreach ($data->fields as $field) {
+            $this->_form->addElement($field);
+        }
+
+        if (!empty($data->helps)) {
+            foreach ($data->helps as $fieldname => $helpdata) {
+                $help = array_merge([$fieldname], $helpdata);
+                call_user_func_array([$this->_form, 'addHelpButton'], $help);
+            }
+        }
+
+        if (!empty($data->types)) {
+            foreach ($data->types as $fieldname => $type) {
+                $this->_form->setType($fieldname, $type);
+            }
+        }
+
+        if (!empty($data->rules)) {
+            foreach ($data->rules as $fieldname => $ruledata) {
+                $rule = array_merge([$fieldname], $ruledata);
+                call_user_func_array([$this->_form, 'addRule'], $rule);
+            }
+        }
+
+        if (!empty($data->defaults)) {
+            foreach ($data->defaults as $fieldname => $default) {
+                $this->_form($fieldname, $default);
+            }
+        }
+    }
+
+    /**
+     * Handle addition of relevant repeated element fields for role overrides.
+     */
+    protected function add_override_fields() {
+        $purpose = $this->get_persistent();
+
+        if (empty($purpose->get('id'))) {
+            // It is not possible to use repeated elements in a modal form yet.
+            return;
+        }
+
+        $fields = [
+            $this->get_role_override_id('roleoverride_'),
+            $this->get_role_field('roleoverride_'),
+            $this->get_retention_period_fields('roleoverride_'),
+            $this->get_protected_field('roleoverride_'),
+            $this->get_lawful_base_field('roleoverride_'),
+            $this->get_sensitive_base_field('roleoverride_'),
+        ];
+
+        $options = [
+            'type' => [],
+            'helpbutton' => [],
+        ];
+
+        // Start by adding the title.
+        $overrideelements = [
+            $this->_form->createElement('header', 'roleoverride', get_string('roleoverride', 'tool_dataprivacy')),
+            $this->_form->createElement(
+                'static',
+                'roleoverrideoverview',
+                '',
+                get_string('roleoverrideoverview', 'tool_dataprivacy')
+            ),
+        ];
+
+        foreach ($fields as $fielddata) {
+            foreach ($fielddata->fields as $field) {
+                $overrideelements[] = $field;
+            }
+
+            if (!empty($fielddata->helps)) {
+                foreach ($fielddata->helps as $name => $help) {
+                    if (!isset($options[$name])) {
+                        $options[$name] = [];
+                    }
+                    $options[$name]['helpbutton'] = $help;
+                }
+            }
+
+            if (!empty($fielddata->types)) {
+                foreach ($fielddata->types as $name => $type) {
+                    if (!isset($options[$name])) {
+                        $options[$name] = [];
+                    }
+                    $options[$name]['type'] = $type;
+                }
+            }
+
+            if (!empty($fielddata->rules)) {
+                foreach ($fielddata->rules as $name => $rule) {
+                    if (!isset($options[$name])) {
+                        $options[$name] = [];
+                    }
+                    $options[$name]['rule'] = $rule;
+                }
+            }
+
+            if (!empty($fielddata->defaults)) {
+                foreach ($fielddata->defaults as $name => $default) {
+                    if (!isset($options[$name])) {
+                        $options[$name] = [];
+                    }
+                    $options[$name]['default'] = $default;
+                }
+            }
+
+            if (!empty($fielddata->advanceds)) {
+                foreach ($fielddata->advanceds as $name => $advanced) {
+                    if (!isset($options[$name])) {
+                        $options[$name] = [];
+                    }
+                    $options[$name]['advanced'] = $advanced;
+                }
+            }
+        }
+
+        $this->existingoverrides = $purpose->get_purpose_overrides();
+        $existingoverridecount = count($this->existingoverrides);
+
+        $this->repeat_elements(
+                $overrideelements,
+                $existingoverridecount,
+                $options,
+                'overrides',
+                'addoverride',
+                1,
+                get_string('addroleoverride', 'tool_dataprivacy')
+            );
+    }
+
+    /**
+     * Converts fields.
+     *
+     * @param \stdClass $data
+     * @return \stdClass
+     */
+    public function filter_data_for_persistent($data) {
+        $data = parent::filter_data_for_persistent($data);
+
+        $classname = static::$persistentclass;
+        $properties = $classname::properties_definition();
+
+        $data = (object) array_filter((array) $data, function($value, $key) use ($properties) {
+            return isset($properties[$key]);
+        }, ARRAY_FILTER_USE_BOTH);
+
+        return $data;
+    }
+
+    /**
+     * Get the field for the role name.
+     *
+     * @param   string  $prefix The prefix to apply to the field
+     * @return  \stdClass
+     */
+    protected function get_role_override_id(string $prefix = '') : \stdClass {
+        $fieldname = "{$prefix}id";
+
+        $fielddata = (object) [
+            'fields' => [],
+        ];
+
+        $fielddata->fields[] = $this->_form->createElement('hidden', $fieldname);
+        $fielddata->types[$fieldname] = PARAM_INT;
+
+        return $fielddata;
+    }
+
+    /**
+     * Get the field for the role name.
+     *
+     * @param   string  $prefix The prefix to apply to the field
+     * @return  \stdClass
+     */
+    protected function get_role_field(string $prefix = '') : \stdClass {
+        $fieldname = "{$prefix}roleid";
+
+        $fielddata = (object) [
+            'fields' => [],
+            'helps' => [],
+        ];
+
+        $roles = [
+            '' => get_string('none'),
+        ];
+        foreach (role_get_names() as $roleid => $role) {
+            $roles[$roleid] = $role->localname;
+        }
+
+        $fielddata->fields[] = $this->_form->createElement('select', $fieldname, get_string('role'),
+            $roles,
+            [
+                'multiple' => false,
+            ]
+        );
+        $fielddata->helps[$fieldname] = ['role', 'tool_dataprivacy'];
+        $fielddata->defaults[$fieldname] = null;
+
+        return $fielddata;
+    }
+
+    /**
+     * Get the mform field for lawful bases.
+     *
+     * @param   string  $prefix The prefix to apply to the field
+     * @return  \stdClass
+     */
+    protected function get_lawful_base_field(string $prefix = '') : \stdClass {
+        $fieldname = "{$prefix}lawfulbases";
+
+        $data = (object) [
+            'fields' => [],
+        ];
+
+        $bases = [];
         foreach (\tool_dataprivacy\purpose::GDPR_ART_6_1_ITEMS as $article) {
             $key = 'gdpr_art_6_1_' . $article;
-            $lawfulbases[$key] = get_string($key . '_name', 'tool_dataprivacy');
+            $bases[$key] = get_string("{$key}_name", 'tool_dataprivacy');
         }
-        $options = array(
-            'multiple' => true,
+
+        $data->fields[] = $this->_form->createElement('autocomplete', $fieldname, get_string('lawfulbases', 'tool_dataprivacy'),
+            $bases,
+            [
+                'multiple' => true,
+            ]
         );
-        $mform->addElement('autocomplete', 'lawfulbases', get_string('lawfulbases', 'tool_dataprivacy'), $lawfulbases, $options);
-        $mform->addRule('lawfulbases', get_string('required'), 'required', null, 'server');
-        $mform->addHelpButton('lawfulbases', 'lawfulbases', 'tool_dataprivacy');
 
-        // Optional field for selecting reasons for collecting sensitive personal data (from GDPR Article 9.2).
-        $sensitivereasons = [];
+        $data->helps = [
+            $fieldname => ['lawfulbases', 'tool_dataprivacy'],
+        ];
+
+        $data->advanceds = [
+            $fieldname => true,
+        ];
+
+        return $data;
+    }
+
+    /**
+     * Get the mform field for sensitive bases.
+     *
+     * @param   string  $prefix The prefix to apply to the field
+     * @return  \stdClass
+     */
+    protected function get_sensitive_base_field(string $prefix = '') : \stdClass {
+        $fieldname = "{$prefix}sensitivedatareasons";
+
+        $data = (object) [
+            'fields' => [],
+        ];
+
+        $bases = [];
         foreach (\tool_dataprivacy\purpose::GDPR_ART_9_2_ITEMS as $article) {
             $key = 'gdpr_art_9_2_' . $article;
-            $sensitivereasons[$key] = get_string($key . '_name', 'tool_dataprivacy');
+            $bases[$key] = get_string("{$key}_name", 'tool_dataprivacy');
         }
-        $mform->addElement('autocomplete', 'sensitivedatareasons', get_string('sensitivedatareasons', 'tool_dataprivacy'),
-            $sensitivereasons, $options);
-        $mform->addHelpButton('sensitivedatareasons', 'sensitivedatareasons', 'tool_dataprivacy');
 
-        $number = $mform->createElement('text', 'retentionperiodnumber', null, ['size' => 8]);
+        $data->fields[] = $this->_form->createElement(
+            'autocomplete',
+            $fieldname,
+            get_string('sensitivedatareasons', 'tool_dataprivacy'),
+            $bases,
+            [
+                'multiple' => true,
+            ]
+        );
+        $data->helps = [
+            $fieldname => ['sensitivedatareasons', 'tool_dataprivacy'],
+        ];
+
+        $data->advanceds = [
+            $fieldname => true,
+        ];
+
+        return $data;
+    }
+
+    /**
+     * Get the retention period fields.
+     *
+     * @param   string  $prefix The name of the main field, and prefix for the subfields.
+     * @return  \stdClass
+     */
+    protected function get_retention_period_fields(string $prefix = '') : \stdClass {
+        $prefix = "{$prefix}retentionperiod";
+        $data = (object) [
+            'fields' => [],
+            'types' => [],
+        ];
+
+        $number = $this->_form->createElement('text', "{$prefix}number", null, ['size' => 8]);
+        $data->types["{$prefix}number"] = PARAM_INT;
+
         $unitoptions = [
             'Y' => get_string('years'),
             'M' => strtolower(get_string('months')),
             'D' => strtolower(get_string('days'))
         ];
-        $unit = $mform->createElement('select', 'retentionperiodunit', '', $unitoptions);
-        $mform->addGroup(['number' => $number, 'unit' => $unit], 'retentionperiod',
-            get_string('retentionperiod', 'tool_dataprivacy'), null, false);
-        $mform->setType('retentionperiodnumber', PARAM_INT);
+        $unit = $this->_form->createElement('select', "{$prefix}unit", '', $unitoptions);
 
-        $this->_form->addElement('advcheckbox', 'protected', get_string('protected', 'tool_dataprivacy'),
-            get_string('protectedlabel', 'tool_dataprivacy'));
+        $data->fields[] = $this->_form->createElement(
+                'group',
+                $prefix,
+                get_string('retentionperiod', 'tool_dataprivacy'),
+                [
+                    'number' => $number,
+                    'unit' => $unit,
+                ],
+                null,
+                false
+            );
 
-        if (!empty($this->_customdata['showbuttons'])) {
-            if (!$this->get_persistent()->get('id')) {
-                $savetext = get_string('add');
-            } else {
-                $savetext = get_string('savechanges');
-            }
-            $this->add_action_buttons(true, $savetext);
-        }
+        return $data;
     }
 
     /**
-     * Converts fields.
+     * Get the mform field for the protected flag.
+     *
+     * @param   string  $prefix The prefix to apply to the field
+     * @return  \stdClass
+     */
+    protected function get_protected_field(string $prefix = '') : \stdClass {
+        $fieldname = "{$prefix}protected";
+
+        return (object) [
+            'fields' => [
+                $this->_form->createElement(
+                        'advcheckbox',
+                        $fieldname,
+                        get_string('protected', 'tool_dataprivacy'),
+                        get_string('protectedlabel', 'tool_dataprivacy')
+                    ),
+            ],
+        ];
+    }
+
+    /**
+     * Converts data to data suitable for storage.
      *
      * @param \stdClass $data
      * @return \stdClass
@@ -111,7 +435,7 @@ class purpose extends persistent {
     protected static function convert_fields(\stdClass $data) {
         $data = parent::convert_fields($data);
 
-        if (is_array($data->lawfulbases)) {
+        if (!empty($data->lawfulbases) && is_array($data->lawfulbases)) {
             $data->lawfulbases = implode(',', $data->lawfulbases);
         }
         if (!empty($data->sensitivedatareasons) && is_array($data->sensitivedatareasons)) {
@@ -122,6 +446,7 @@ class purpose extends persistent {
         $data->retentionperiod = 'P' . $data->retentionperiodnumber . $data->retentionperiodunit;
         unset($data->retentionperiodnumber);
         unset($data->retentionperiodunit);
+
         return $data;
     }
 
@@ -133,6 +458,16 @@ class purpose extends persistent {
     protected function get_default_data() {
         $data = parent::get_default_data();
 
+        return $this->convert_existing_data_to_values($data);
+    }
+
+    /**
+     * Normalise any values stored in existing data.
+     *
+     * @param   \stdClass $data
+     * @return  \stdClass
+     */
+    protected function convert_existing_data_to_values(\stdClass $data) : \stdClass {
         $data->lawfulbases = explode(',', $data->lawfulbases);
         if (!empty($data->sensitivedatareasons)) {
             $data->sensitivedatareasons = explode(',', $data->sensitivedatareasons);
@@ -146,4 +481,94 @@ class purpose extends persistent {
 
         return $data;
     }
+
+    /**
+     * Fetch the role override data from the list of submitted data.
+     *
+     * @param   \stdClass   $data The complete set of processed data
+     * @return  \stdClass[] The list of overrides
+     */
+    public function get_role_overrides_from_data(\stdClass $data) {
+        $overrides = [];
+        if (!empty($data->overrides)) {
+            $searchkey = 'roleoverride_';
+
+            for ($i = 0; $i < $data->overrides; $i++) {
+                $overridedata = (object) [];
+                foreach ((array) $data as $fieldname => $value) {
+                    if (strpos($fieldname, $searchkey) !== 0) {
+                        continue;
+                    }
+
+                    $overridefieldname = substr($fieldname, strlen($searchkey));
+                    $overridedata->$overridefieldname = $value[$i];
+                }
+
+                if (empty($overridedata->roleid) || empty($overridedata->retentionperiodnumber)) {
+                    // Skip this one.
+                    // There is no value and it will be delete.
+                    continue;
+                }
+
+                $override = static::convert_fields($overridedata);
+
+                $overrides[$i] = $override;
+            }
+        }
+
+        return $overrides;
+    }
+
+    /**
+     * Define extra validation mechanims.
+     *
+     * @param  stdClass $data Data to validate.
+     * @param  array $files Array of files.
+     * @param  array $errors Currently reported errors.
+     * @return array of additional errors, or overridden errors.
+     */
+    protected function extra_validation($data, $files, array &$errors) {
+        $overrides = $this->get_role_overrides_from_data($data);
+
+        // Check role overrides to ensure that:
+        // - roles are unique; and
+        // - specifeid retention periods are numeric.
+        $seenroleids = [];
+        foreach ($overrides as $id => $override) {
+            $override->purposeid = 0;
+            $persistent = new \tool_dataprivacy\purpose_override($override->id, $override);
+
+            if (isset($seenroleids[$persistent->get('roleid')])) {
+                $errors["roleoverride_roleid[{$id}]"] = get_string('duplicaterole');
+            }
+            $seenroleids[$persistent->get('roleid')] = true;
+
+            $errors = array_merge($errors, $persistent->get_errors());
+        }
+
+        return $errors;
+    }
+
+    /**
+     * Load in existing data as form defaults. Usually new entry defaults are stored directly in
+     * form definition (new entry form); this function is used to load in data where values
+     * already exist and data is being edited (edit entry form).
+     *
+     * @param stdClass $data
+     */
+    public function set_data($data) {
+        $purpose = $this->get_persistent();
+
+        $count = 0;
+        foreach ($this->existingoverrides as $override) {
+            $overridedata = $this->convert_existing_data_to_values($override->to_record());
+            foreach ($overridedata as $key => $value) {
+                $keyname = "roleoverride_{$key}[{$count}]";
+                $data->$keyname = $value;
+            }
+            $count++;
+        }
+
+        parent::set_data($data);
+    }
 }
index c6f0e65..312ee6e 100644 (file)
@@ -57,9 +57,15 @@ class expired_contexts_table extends table_sql {
      */
     protected $selectall = true;
 
-    /** @var purpose[] Array of purposes mapped to the contexts. */
+    /** @var purpose[] Array of purposes by their id. */
     protected $purposes = [];
 
+    /** @var purpose[] Map of context => purpose. */
+    protected $purposemap = [];
+
+    /** @var array List of roles. */
+    protected $roles = [];
+
     /**
      * expired_contexts_table constructor.
      *
@@ -77,6 +83,7 @@ class expired_contexts_table extends table_sql {
             'purpose' => get_string('purpose', 'tool_dataprivacy'),
             'category' => get_string('category', 'tool_dataprivacy'),
             'retentionperiod' => get_string('retentionperiod', 'tool_dataprivacy'),
+            'tobedeleted' => get_string('tobedeleted', 'tool_dataprivacy'),
             'timecreated' => get_string('expiry', 'tool_dataprivacy'),
         ];
         $checkboxattrs = [
@@ -93,21 +100,25 @@ class expired_contexts_table extends table_sql {
         $this->no_sorting('purpose');
         $this->no_sorting('category');
         $this->no_sorting('retentionperiod');
+        $this->no_sorting('tobedeleted');
 
         // Make this table sorted by first name by default.
         $this->sortable(true, 'timecreated');
+
+        // We use roles in several places.
+        $this->roles = role_get_names();
     }
 
     /**
      * The context name column.
      *
-     * @param stdClass $data The row data.
+     * @param stdClass $expiredctx The row data.
      * @return string
      * @throws coding_exception
      */
-    public function col_name($data) {
+    public function col_name($expiredctx) {
         global $OUTPUT;
-        $context = context_helper::instance_by_id($data->contextid);
+        $context = context_helper::instance_by_id($expiredctx->get('contextid'));
         $parent = $context->get_parent_context();
         $contextdata = (object)[
             'name' => $context->get_context_name(false, true),
@@ -128,14 +139,14 @@ class expired_contexts_table extends table_sql {
     /**
      * The context information column.
      *
-     * @param stdClass $data The row data.
+     * @param stdClass $expiredctx The row data.
      * @return string
      * @throws coding_exception
      */
-    public function col_info($data) {
+    public function col_info($expiredctx) {
         global $OUTPUT;
 
-        $context = context_helper::instance_by_id($data->contextid);
+        $context = context_helper::instance_by_id($expiredctx->get('contextid'));
 
         $children = $context->get_child_contexts();
         if (empty($children)) {
@@ -156,13 +167,13 @@ class expired_contexts_table extends table_sql {
     /**
      * The category name column.
      *
-     * @param stdClass $data The row data.
+     * @param stdClass $expiredctx The row data.
      * @return mixed
      * @throws coding_exception
      * @throws dml_exception
      */
-    public function col_category($data) {
-        $context = context_helper::instance_by_id($data->contextid);
+    public function col_category($expiredctx) {
+        $context = context_helper::instance_by_id($expiredctx->get('contextid'));
         $category = api::get_effective_context_category($context);
 
         return s($category->get('name'));
@@ -171,12 +182,12 @@ class expired_contexts_table extends table_sql {
     /**
      * The purpose column.
      *
-     * @param stdClass $data The row data.
+     * @param stdClass $expiredctx The row data.
      * @return string
      * @throws coding_exception
      */
-    public function col_purpose($data) {
-        $purpose = $this->purposes[$data->contextid];
+    public function col_purpose($expiredctx) {
+        $purpose = $this->get_purpose_for_expiry($expiredctx);
 
         return s($purpose->get('name'));
     }
@@ -184,42 +195,114 @@ class expired_contexts_table extends table_sql {
     /**
      * The retention period column.
      *
-     * @param stdClass $data The row data.
+     * @param stdClass $expiredctx The row data.
      * @return string
-     * @throws Exception
      */
-    public function col_retentionperiod($data) {
-        global $PAGE;
+    public function col_retentionperiod($expiredctx) {
+        $purpose = $this->get_purpose_for_expiry($expiredctx);
+
+        $expiries = [];
+
+        $expiry = html_writer::tag('dt', get_string('default'), ['class' => 'col-sm-3']);
+        if ($expiredctx->get('defaultexpired')) {
+            $expiries[get_string('default')] = get_string('expiredrolewithretention', 'tool_dataprivacy', (object) [
+                    'retention' => api::format_retention_period(new \DateInterval($purpose->get('retentionperiod'))),
+                ]);
+        } else {
+            $expiries[get_string('default')] = get_string('unexpiredrolewithretention', 'tool_dataprivacy', (object) [
+                    'retention' => api::format_retention_period(new \DateInterval($purpose->get('retentionperiod'))),
+                ]);
+        }
+
+        if (!$expiredctx->is_fully_expired()) {
+            $purposeoverrides = $purpose->get_purpose_overrides();
+
+            foreach ($expiredctx->get('unexpiredroles') as $roleid) {
+                $role = $this->roles[$roleid];
+                $override = $purposeoverrides[$roleid];
+
+                $expiries[$role->localname] = get_string('unexpiredrolewithretention', 'tool_dataprivacy', (object) [
+                        'retention' => api::format_retention_period(new \DateInterval($override->get('retentionperiod'))),
+                    ]);
+            }
+
+            foreach ($expiredctx->get('expiredroles') as $roleid) {
+                $role = $this->roles[$roleid];
+                $override = $purposeoverrides[$roleid];
+
+                $expiries[$role->localname] = get_string('expiredrolewithretention', 'tool_dataprivacy', (object) [
+                        'retention' => api::format_retention_period(new \DateInterval($override->get('retentionperiod'))),
+                    ]);
+            }
+        }
 
-        $purpose = $this->purposes[$data->contextid];
+        $output = array_map(function($rolename, $expiry) {
+            $return = html_writer::tag('dt', $rolename, ['class' => 'col-sm-3']);
+            $return .= html_writer::tag('dd', $expiry, ['class' => 'col-sm-9']);
 
-        $exporter = new purpose_exporter($purpose, ['context' => \context_system::instance()]);
-        $exportedpurpose = $exporter->export($PAGE->get_renderer('core'));
+            return $return;
+        }, array_keys($expiries), $expiries);
 
-        return $exportedpurpose->formattedretentionperiod;
+        return html_writer::tag('dl', implode($output), ['class' => 'row']);
     }
 
     /**
      * The timecreated a.k.a. the context expiry date column.
      *
-     * @param stdClass $data The row data.
+     * @param stdClass $expiredctx The row data.
      * @return string
      */
-    public function col_timecreated($data) {
-        return userdate($data->timecreated);
+    public function col_timecreated($expiredctx) {
+        return userdate($expiredctx->get('timecreated'));
     }
 
     /**
      * Generate the select column.
      *
-     * @param stdClass $data The row data.
+     * @param stdClass $expiredctx The row data.
      * @return string
      */
-    public function col_select($data) {
-        $id = $data->id;
+    public function col_select($expiredctx) {
+        $id = $expiredctx->get('id');
         return html_writer::checkbox('expiredcontext_' . $id, $id, $this->selectall, '', ['class' => 'selectcontext']);
     }
 
+    /**
+     * Formatting for the 'tobedeleted' column which indicates in a friendlier fashion whose data will be removed.
+     *
+     * @param stdClass $expiredctx The row data.
+     * @return string
+     */
+    public function col_tobedeleted($expiredctx) {
+        if ($expiredctx->is_fully_expired()) {
+            return get_string('defaultexpired', 'tool_dataprivacy');
+        }
+
+        $purpose = $this->get_purpose_for_expiry($expiredctx);
+
+        $a = (object) [];
+
+        $expiredroles = [];
+        foreach ($expiredctx->get('expiredroles') as $roleid) {
+            $expiredroles[] = html_writer::tag('li', $this->roles[$roleid]->localname);
+        }
+        $a->expired = html_writer::tag('ul', implode($expiredroles));
+
+        $unexpiredroles = [];
+        foreach ($expiredctx->get('unexpiredroles') as $roleid) {
+            $unexpiredroles[] = html_writer::tag('li', $this->roles[$roleid]->localname);
+        }
+        $a->unexpired = html_writer::tag('ul', implode($unexpiredroles));
+
+        if ($expiredctx->get('defaultexpired')) {
+            return get_string('defaultexpiredexcept', 'tool_dataprivacy', $a);
+        } else if (empty($unexpiredroles)) {
+            return get_string('defaultunexpired', 'tool_dataprivacy', $a);
+        } else {
+            return get_string('defaultunexpiredwithexceptions', 'tool_dataprivacy', $a);
+        }
+    }
+
     /**
      * Query the database for results to display in the table.
      *
@@ -241,17 +324,16 @@ class expired_contexts_table extends table_sql {
         // Only load expired contexts that are awaiting confirmation.
         $expiredcontexts = expired_context::get_records_by_contextlevel($this->contextlevel, expired_context::STATUS_EXPIRED,
             $sort, $this->get_page_start(), $this->get_page_size());
+
         $this->rawdata = [];
+        $contextids = [];
         foreach ($expiredcontexts as $persistent) {
-            $data = $persistent->to_record();
-
-            $context = context_helper::instance_by_id($data->contextid);
-
-            $purpose = api::get_effective_context_purpose($context);
-            $this->purposes[$data->contextid] = $purpose;
-            $this->rawdata[] = $data;
+            $this->rawdata[] = $persistent;
+            $contextids[] = $persistent->get('contextid');
         }
 
+        $this->preload_contexts($contextids);
+
         // Set initial bars.
         if ($useinitialsbar) {
             $this->initialbars($total > $pagesize);
@@ -281,4 +363,48 @@ class expired_contexts_table extends table_sql {
         }
         return '';
     }
+
+    /**
+     * Get the purpose for the specified expired context.
+     *
+     * @param   expired_context $expiredcontext
+     * @return  purpose
+     */
+    protected function get_purpose_for_expiry(expired_context $expiredcontext) : purpose {
+        $context = context_helper::instance_by_id($expiredcontext->get('contextid'));
+
+        if (empty($this->purposemap[$context->id])) {
+            $purpose = api::get_effective_context_purpose($context);
+            $this->purposemap[$context->id] = $purpose->get('id');
+
+            if (empty($this->purposes[$purpose->get('id')])) {
+                $this->purposes[$purpose->get('id')] = $purpose;
+            }
+        }
+
+        return $this->purposes[$this->purposemap[$context->id]];
+    }
+
+    /**
+     * Preload context records given a set of contextids.
+     *
+     * @param   array   $contextids
+     */
+    protected function preload_contexts(array $contextids) {
+        global $DB;
+
+        if (empty($contextids)) {
+            return;
+        }
+
+        $ctxfields = \context_helper::get_preload_record_columns_sql('ctx');
+        list($insql, $inparams) = $DB->get_in_or_equal($contextids, SQL_PARAMS_NAMED);
+        $sql = "SELECT {$ctxfields} FROM {context} ctx WHERE ctx.id {$insql}";
+        $contextlist = $DB->get_recordset_sql($sql, $inparams);
+        foreach ($contextlist as $contextdata) {
+            \context_helper::preload_from_record($contextdata);
+        }
+        $contextlist->close();
+
+    }
 }
index 20ee631..bc79471 100644 (file)
@@ -44,14 +44,47 @@ $form = new \tool_dataprivacy\form\purpose($PAGE->url->out(false),
 $returnurl = new \moodle_url('/admin/tool/dataprivacy/purposes.php');
 if ($form->is_cancelled()) {
     redirect($returnurl);
-} else if ($data = $form->get_data()) {
+} else if ($alldata = $form->get_data()) {
+    $data = $form->filter_data_for_persistent($alldata);
+
     if (empty($data->id)) {
-        \tool_dataprivacy\api::create_purpose($data);
+        $purpose = \tool_dataprivacy\api::create_purpose($data);
         $messagesuccess = get_string('purposecreated', 'tool_dataprivacy');
     } else {
-        \tool_dataprivacy\api::update_purpose($data);
+        $purpose = \tool_dataprivacy\api::update_purpose($data);
         $messagesuccess = get_string('purposeupdated', 'tool_dataprivacy');
     }
+
+    $currentoverrides = [];
+    foreach ($purpose->get_purpose_overrides() as $override) {
+        $currentoverrides[$override->get('id')] = $override;
+    }
+
+    $overrides = $form->get_role_overrides_from_data($alldata);
+    $submittedoverrides = [];
+    $tosave = [];
+
+    foreach ($overrides as $overridedata) {
+        $overridedata->purposeid = $purpose->get('id');
+        $override = new \tool_dataprivacy\purpose_override($overridedata->id, $overridedata);
+
+        $tosave[] = $override;
+
+        if (!empty($overridedata->id)) {
+            $submittedoverrides[$overridedata->id] = true;
+        }
+    }
+
+    foreach ($currentoverrides as $id => $override) {
+        if (!isset($submittedoverrides[$id])) {
+            $override->delete();
+        }
+    }
+
+    foreach ($tosave as $override) {
+        $override->save();
+    }
+
     redirect($returnurl, $messagesuccess, 0, \core\output\notification::NOTIFY_SUCCESS);
 }
 
index 0cbc55f..5206d22 100644 (file)
@@ -198,6 +198,7 @@ $string['nopurposes'] = 'There are no purposes yet';
 $string['nosubjectaccessrequests'] = 'There are no data requests that you need to act on';
 $string['nosystemdefaults'] = 'Site purpose and category have not yet been defined.';
 $string['notset'] = 'Not set (use the default value)';
+$string['notyetexpired'] = '{$a} (not yet expired)';
 $string['overrideinstances'] = 'Reset instances with custom values';
 $string['pluginregistry'] = 'Plugin privacy registry';
 $string['pluginregistrytitle'] = 'Plugin privacy compliance registry';
@@ -266,6 +267,7 @@ $string['retentionperiod'] = 'Retention period';
 $string['retentionperiod_help'] = 'The retention period specifies the length of time that data should be kept for. When the retention period has expired, the data is flagged and listed for deletion, awaiting admin confirmation.';
 $string['retentionperiodnotdefined'] = 'No retention period was defined';
 $string['retentionperiodzero'] = 'No retention period';
+$string['roleoverrides'] = 'Role overrides';
 $string['selectbulkaction'] = 'Please select a bulk action.';
 $string['selectdatarequests'] = 'Please select data requests.';
 $string['selectuserdatarequest'] = 'Select {$a->username}\'s {$a->requesttype} data request.';
@@ -291,3 +293,22 @@ $string['summary'] = 'Registry configuration summary';
 $string['user'] = 'User';
 $string['viewrequest'] = 'View the request';
 $string['visible'] = 'Expand all';
+$string['unexpiredrolewithretention'] = '{$a->retention} (Unexpired)';
+$string['expiredrolewithretention'] = '{$a->retention} (Expired)';
+$string['defaultexpired'] = 'Data for all users';
+$string['defaultexpiredexcept'] = 'Data for all users, except those who hold any of the following roles:<br>
+{$a->unexpired}';
+$string['defaultunexpiredwithexceptions'] = 'Only data for users who hold any of the following roles:<br>
+{$a->expired}
+Unless they also hold any of the following roles:<br>
+{$a->unexpired}';
+$string['defaultunexpired'] = 'Only data for users holding any of the following roles:<br>
+{$a->expired}';
+$string['tobedeleted'] = 'Data to be deleted';
+$string['addroleoverride'] = 'Add role override';
+$string['roleoverride'] = 'Role override';
+$string['role'] = 'Role';
+$string['role_help'] = 'Which role do you wish to apply this override to';
+$string['duplicaterole'] = 'Role already specified';
+$string['purposeoverview'] = 'A purpose describes the intended use and retention policy for stored data. The basis for storing and retaining that data is also described in the purpose.';
+$string['roleoverrideoverview'] = 'The default retention policy can be overridden for specific user roles, allowing you to specify a longer, or a shorter, retention policy. A user is only expired when all of their roles have expired.';
index 6408530..474d4a6 100644 (file)
 }}
 
 {{#navigation}}
-    {{> core/action_link}}
+    <div class="m-b-1">
+        {{> core/action_link}}
+    </div>
 {{/navigation}}
 
-<div data-region="purposes" class="m-t-3 m-b-1">
+<p>
+  {{#str}}purposeoverview, tool_dataprivacy{{/str}}
+</p>
+
+<div data-region="purposes" class="m-b-1">
     <div class="m-y-1">
         <button class="btn btn-secondary" data-add-element="purpose" title="{{#str}}addpurpose, tool_dataprivacy{{/str}}">
             {{#pix}}t/add, moodle, {{#str}}addpurpose, tool_dataprivacy{{/str}}{{/pix}}
@@ -72,6 +78,7 @@
                 <th scope="col">{{#str}}sensitivedatareasons, tool_dataprivacy{{/str}}</th>
                 <th scope="col">{{#str}}retentionperiod, tool_dataprivacy{{/str}}</th>
                 <th scope="col">{{#str}}protected, tool_dataprivacy{{/str}}</th>
+                <th scope="col">{{#str}}roleoverrides, tool_dataprivacy{{/str}}</th>
                 <th scope="col">{{#str}}actions{{/str}}</th>
             </tr>
         </thead>
                         {{#str}}no{{/str}}
                     {{/protected}}
                 </td>
+                <td>
+                    {{#roleoverrides}}
+                        {{#str}}yes{{/str}}
+                    {{/roleoverrides}}
+                    {{^roleoverrides}}
+                        {{#str}}no{{/str}}
+                    {{/roleoverrides}}
+                </td>
                 <td>
                     {{#actions}}
                         {{> core/action_menu}}