From 3edefbbf5d2c95099525328202e5671d5e229ece Mon Sep 17 00:00:00 2001 From: Yannick Tivisse Date: Thu, 21 Apr 2022 14:53:31 +0000 Subject: [PATCH] [ADD] privacy: Add privacy lookup / management tool Purpose ======= This commit brings a new tool to retrieve all the related pieces of information about a customer, based on his name and/or email address. This could be useful for users who want to apply the right to be forgotten (for example under the GDPR regulation), or simply to retrieve all the records linked to someone (leads, contracts, sales orders, ...). The tools is actually a wizard, available from the contact/user form view. The 2 main steps are the following: 1/ Lookup: We search all the references of the contact by scrapping the existing models, either if his email address is written on a supposedly email field, or if his name is written into a record rec_name (aka name of a lead), or also if there is a M2O to a res.partner that matches the email address or the name (For readability purposes, we exclude the ondelete=cascade fields as the record will be dropped with the contact itself. 2/ Management: For each one of the occurences, we display a line on the wizard for which we allow 3 actions: Open the record, Archive (if possible), Delete. Each time an action is performed that altered the record itself, a anonymized log (jean.luc@example.com becoming j***.l**@e******.com) is created/adapted for history purposes. Taskid: 2827911 Part-of: odoo/odoo#89312 --- addons/privacy/__init__.py | 5 + addons/privacy/__manifest__.py | 19 + .../privacy/data/ir_actions_server_data.xml | 24 ++ addons/privacy/models/__init__.py | 5 + addons/privacy/models/privacy_log.py | 47 +++ addons/privacy/models/res_partner.py | 17 + addons/privacy/security/ir.model.access.csv | 4 + addons/privacy/tests/__init__.py | 4 + addons/privacy/tests/test_privacy_wizard.py | 152 ++++++++ addons/privacy/views/privacy_log_views.xml | 73 ++++ addons/privacy/wizard/__init__.py | 4 + .../privacy/wizard/privacy_lookup_wizard.py | 329 ++++++++++++++++++ .../wizard/privacy_lookup_wizard_views.xml | 111 ++++++ 13 files changed, 794 insertions(+) create mode 100644 addons/privacy/__init__.py create mode 100644 addons/privacy/__manifest__.py create mode 100644 addons/privacy/data/ir_actions_server_data.xml create mode 100644 addons/privacy/models/__init__.py create mode 100644 addons/privacy/models/privacy_log.py create mode 100644 addons/privacy/models/res_partner.py create mode 100644 addons/privacy/security/ir.model.access.csv create mode 100644 addons/privacy/tests/__init__.py create mode 100644 addons/privacy/tests/test_privacy_wizard.py create mode 100644 addons/privacy/views/privacy_log_views.xml create mode 100644 addons/privacy/wizard/__init__.py create mode 100644 addons/privacy/wizard/privacy_lookup_wizard.py create mode 100644 addons/privacy/wizard/privacy_lookup_wizard_views.xml diff --git a/addons/privacy/__init__.py b/addons/privacy/__init__.py new file mode 100644 index 00000000000..2ae6446f9dc --- /dev/null +++ b/addons/privacy/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models +from . import wizard diff --git a/addons/privacy/__manifest__.py b/addons/privacy/__manifest__.py new file mode 100644 index 00000000000..b557c83b821 --- /dev/null +++ b/addons/privacy/__manifest__.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'Privacy', + 'category': 'Hidden', + 'version': '1.0', + 'description': """""", + 'depends': ['mail'], + 'data': [ + 'wizard/privacy_lookup_wizard_views.xml', + 'views/privacy_log_views.xml', + 'security/ir.model.access.csv', + 'data/ir_actions_server_data.xml', + ], + 'auto_install': True, + 'assets': {}, + 'license': 'LGPL-3', +} diff --git a/addons/privacy/data/ir_actions_server_data.xml b/addons/privacy/data/ir_actions_server_data.xml new file mode 100644 index 00000000000..aa1abe7c4d6 --- /dev/null +++ b/addons/privacy/data/ir_actions_server_data.xml @@ -0,0 +1,24 @@ + + + + Archive Selection + + + list + code + +records.action_archive_all() + + + + + Delete Selection + + + list + code + +records.action_unlink_all() + + + diff --git a/addons/privacy/models/__init__.py b/addons/privacy/models/__init__.py new file mode 100644 index 00000000000..202b5ea8fef --- /dev/null +++ b/addons/privacy/models/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import res_partner +from . import privacy_log diff --git a/addons/privacy/models/privacy_log.py b/addons/privacy/models/privacy_log.py new file mode 100644 index 00000000000..accfab410fd --- /dev/null +++ b/addons/privacy/models/privacy_log.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError + + +class PrivacyLog(models.Model): + _name = 'privacy.log' + _description = 'Privacy Log' + + date = fields.Datetime(default=fields.Datetime.now, required=True) + anonymized_name = fields.Char(required=True) + anonymized_email = fields.Char(required=True) + user_id = fields.Many2one( + 'res.users', string="Handled By", required=True, + default=lambda self: self.env.user) + execution_details = fields.Text() + records_description = fields.Text(string="Found Records") + additional_note = fields.Text() + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + vals['anonymized_name'] = self._anonymize_name(vals['anonymized_name']) + vals['anonymized_email'] = self._anonymize_email(vals['anonymized_email']) + return super().create(vals_list) + + def _anonymize_name(self, label): + if not label: + return '' + if '@' in label: + return self._anonymize_email(label) + return ' '.join(e[0] + '*' * (len(e) - 1) for e in label.split(' ') if e) + + def _anonymize_email(self, label): + def _anonymize_user(label): + return '.'.join(e[0] + '*' * (len(e) - 1) for e in label.split('.') if e) + def _anonymize_domain(label): + if label in ['gmail.com', 'hotmail.com', 'yahoo.com']: # More than half of addresses domains + return label + split_domain = label.split('.') + return '.'.join([e[0] + '*' * (len(e) - 1) for e in split_domain[:-1] if e] + [split_domain[-1]]) + if not label or '@' not in label: + return UserError(_('This email address is not valid (%s)', label)) + user, domain = label.split('@') + return '{}@{}'.format(_anonymize_user(user), _anonymize_domain(domain)) diff --git a/addons/privacy/models/res_partner.py b/addons/privacy/models/res_partner.py new file mode 100644 index 00000000000..5c1cfdc3076 --- /dev/null +++ b/addons/privacy/models/res_partner.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models + + +class ResPartner(models.Model): + _inherit = 'res.partner' + + def action_privacy_lookup(self): + self.ensure_one() + action = self.env['ir.actions.act_window']._for_xml_id('privacy.action_privacy_lookup_wizard') + action['context'] = { + 'default_email': self.email, + 'default_name': self.name, + } + return action diff --git a/addons/privacy/security/ir.model.access.csv b/addons/privacy/security/ir.model.access.csv new file mode 100644 index 00000000000..74e260b8d21 --- /dev/null +++ b/addons/privacy/security/ir.model.access.csv @@ -0,0 +1,4 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +privacy.access_privacy_lookup_wizard,access_privacy_lookup_wizard,privacy.model_privacy_lookup_wizard,base.group_system,1,1,1,1 +privacy.access_privacy_lookup_wizard_line,access_privacy_lookup_wizard_line,privacy.model_privacy_lookup_wizard_line,base.group_system,1,1,1,0 +privacy.access_privacy_log,access_privacy_log,privacy.model_privacy_log,base.group_system,1,1,1,1 \ No newline at end of file diff --git a/addons/privacy/tests/__init__.py b/addons/privacy/tests/__init__.py new file mode 100644 index 00000000000..4031b256e28 --- /dev/null +++ b/addons/privacy/tests/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import test_privacy_wizard diff --git a/addons/privacy/tests/test_privacy_wizard.py b/addons/privacy/tests/test_privacy_wizard.py new file mode 100644 index 00000000000..ef302b7d1b4 --- /dev/null +++ b/addons/privacy/tests/test_privacy_wizard.py @@ -0,0 +1,152 @@ +# # -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests.common import TransactionCase, tagged + + +@tagged('post_install', '-at_install', 'privacy') +class TestPrivacyWizard(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env['res.partner'].create({ + 'name': 'Rintin Tin', + 'email': 'rintin.tin@gmail.com'}) + + def test_wizard(self): + wizard = self.env['privacy.lookup.wizard'].with_context( + default_email=self.partner.email, + default_name=self.partner.name, + ).create({}) + + # Lookup + wizard.action_lookup() + self.assertEqual(len(wizard.line_ids), 1) + self.assertEqual(wizard.line_ids.res_id, self.partner.id) + self.assertEqual(wizard.line_ids.res_model, self.partner._name) + self.assertFalse(wizard.log_id) + + # Archive + wizard.line_ids.is_active = False + wizard.line_ids._onchange_is_active() + self.assertFalse(self.partner.active) + self.assertEqual(wizard.execution_details, 'Archived Contact #%s' % (self.partner.id)) + self.assertTrue(wizard.log_id) + self.assertEqual(wizard.log_id.anonymized_name, 'R***** T**') + self.assertEqual(wizard.log_id.anonymized_email, 'r*****.t**@gmail.com') + self.assertEqual(wizard.log_id.execution_details, 'Archived Contact #%s' % (self.partner.id)) + self.assertEqual(wizard.log_id.records_description, 'Contact (1): #%s' % (self.partner.id)) + + # Delete + wizard.line_ids.action_unlink() + self.assertEqual(wizard.execution_details, 'Deleted Contact #%s' % (self.partner.id)) + self.assertEqual(wizard.log_id.execution_details, 'Deleted Contact #%s' % (self.partner.id)) + + def test_wizard_multi_company(self): + # Check that the record is spotted, even if not available on the Reference field + self.env['ir.rule'].create({ + 'name': 'Multi-Company Rule', + 'model_id': self.env.ref('base.model_res_partner').id, + 'domain_force': "['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]" + }) + company_2 = self.env['res.company'].create({'name': 'Company 2'}) + other_partner = self.env['res.partner'].create({ + 'name': 'Rintin Tin', + 'email': 'rintin.tin@gmail.com', + 'company_id': company_2.id + }) + self.assertNotEqual(self.partner.company_id, other_partner.company_id) + + wizard = self.env['privacy.lookup.wizard'].with_context( + default_email=self.partner.email, + default_name=self.partner.name, + ).with_user(self.env.ref('base.user_admin')).create({}) + + # Lookup + wizard.action_lookup() + self.assertEqual(len(wizard.line_ids), 2) + self.assertEqual(wizard.line_ids[1].resource_ref, self.partner) + self.assertEqual(wizard.line_ids[0].resource_ref, None) + + def test_wizard_direct_reference(self): + bank = self.env['res.bank'].create({ + 'name': 'ING', + 'bic': 'BBRUBEBB', + 'email': 'rintin.tin@gmail.com' + }) + + wizard = self.env['privacy.lookup.wizard'].with_context( + default_email=self.partner.email, + default_name=self.partner.name, + ).create({}) + + # Lookup + wizard.action_lookup() + self.assertEqual(len(wizard.line_ids), 2) + self.assertEqual(wizard.line_ids[0].res_id, self.partner.id) + self.assertEqual(wizard.line_ids[0].res_model, self.partner._name) + + self.assertEqual(wizard.line_ids[1].res_id, bank.id) + self.assertEqual(wizard.line_ids[1].res_model, bank._name) + + def test_wizard_indirect_reference(self): + self.env.company.partner_id = self.partner + + wizard = self.env['privacy.lookup.wizard'].with_context( + default_email=self.partner.email, + default_name=self.partner.name, + ).create({}) + + # Lookup + wizard.action_lookup() + self.assertEqual(len(wizard.line_ids), 2) + self.assertEqual(wizard.line_ids[0].res_id, self.partner.id) + self.assertEqual(wizard.line_ids[0].res_model, self.partner._name) + + self.assertEqual(wizard.line_ids[1].res_id, self.env.company.id) + self.assertEqual(wizard.line_ids[1].res_model, self.env.company._name) + + def test_wizard_indirect_reference_cascade(self): + # Don't retrieve ondelete cascade records + self.env["res.partner.bank"].create({ + 'acc_number': '0123-%s' % self.partner.id, + 'partner_id': self.partner.id, + 'company_id': self.env.company.id + }) + + wizard = self.env['privacy.lookup.wizard'].with_context( + default_email=self.partner.email, + default_name=self.partner.name, + ).create({}) + + # Lookup + wizard.action_lookup() + self.assertEqual(len(wizard.line_ids), 1) + self.assertEqual(wizard.line_ids[0].res_id, self.partner.id) + self.assertEqual(wizard.line_ids[0].res_model, self.partner._name) + + def test_wizard_unique_log(self): + # Check that the log remains unique + self.env['res.partner'].create({ + 'name': 'Rintin Tin', + 'email': 'rintin.tin@gmail.com'}) + + wizard = self.env['privacy.lookup.wizard'].with_context( + default_email=self.partner.email, + default_name=self.partner.name, + ).create({}) + + # Lookup + wizard.action_lookup() + self.assertEqual(len(wizard.line_ids), 2) + + wizard.line_ids[0].is_active = False + wizard.line_ids[0]._onchange_is_active() + wizard.execution_details + self.assertEqual(1, self.env['privacy.log'].search_count([('anonymized_email', '=', 'r*****.t**@gmail.com')])) + + wizard.line_ids[1].is_active = False + wizard.line_ids[1]._onchange_is_active() + wizard.execution_details + self.assertEqual(1, self.env['privacy.log'].search_count([('anonymized_email', '=', 'r*****.t**@gmail.com')])) diff --git a/addons/privacy/views/privacy_log_views.xml b/addons/privacy/views/privacy_log_views.xml new file mode 100644 index 00000000000..aea925db0c3 --- /dev/null +++ b/addons/privacy/views/privacy_log_views.xml @@ -0,0 +1,73 @@ + + + + privacy.log.view.tree + privacy.log + + + + + + + + + + + + + privacy.log.view.form + privacy.log + +
+ +
+

+ +

+
+ + + + + + + + + + + + + + +
+
+
+
+ + + Privacy Logs + privacy.log + tree,form + {'create': False} + + + + Privacy Logs + privacy.log + form + {'create': False} + + + + + +
diff --git a/addons/privacy/wizard/__init__.py b/addons/privacy/wizard/__init__.py new file mode 100644 index 00000000000..17273b6f2b7 --- /dev/null +++ b/addons/privacy/wizard/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import privacy_lookup_wizard diff --git a/addons/privacy/wizard/privacy_lookup_wizard.py b/addons/privacy/wizard/privacy_lookup_wizard.py new file mode 100644 index 00000000000..de207297d67 --- /dev/null +++ b/addons/privacy/wizard/privacy_lookup_wizard.py @@ -0,0 +1,329 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from collections import defaultdict + +from odoo import api, fields, models, tools, _ +from odoo.exceptions import UserError +from odoo.tools import pycompat + + +class PrivacyLookupWizard(models.TransientModel): + _name = 'privacy.lookup.wizard' + _description = 'Privacy Lookup Wizard' + _transient_max_count = 0 + _transient_max_hours = 24 + + name = fields.Char(required=True) + email = fields.Char(required=True) + line_ids = fields.One2many('privacy.lookup.wizard.line', 'wizard_id') + execution_details = fields.Text(compute='_compute_execution_details', store=True) + log_id = fields.Many2one('privacy.log') + records_description = fields.Text(compute='_compute_records_description') + line_count = fields.Integer(compute='_compute_line_count') + + @api.depends('line_ids') + def _compute_line_count(self): + for wizard in self: + wizard.line_count = len(wizard.line_ids) + + def name_get(self): + return [(w.id, _('Privacy Lookup')) for w in self] + + def _get_query_models_blacklist(self): + return [ + # Already Managed + 'res.partner', + 'res.users', + # Ondelete Cascade + 'mail.notification', + 'mail.followers', + 'mail.channel.partner', + # Special case for direct messages + 'mail.message', + ] + + def _get_query(self): + name = "%s" % (self.name.strip()) + email = "%%%s%%" % pycompat.to_text(self.email.strip()) + email_normalized = tools.email_normalize(self.email.strip()) + + # Step 1: Retrieve users/partners liked to email address or name + query = """ + WITH indirect_references AS ( + SELECT id + FROM res_partner + WHERE email_normalized = %s + OR name ilike %s) + SELECT + %s AS res_model_id, + id AS res_id, + active AS is_active + FROM res_partner + WHERE id IN (SELECT id FROM indirect_references) + UNION ALL + SELECT + %s AS res_model_id, + id AS res_id, + active AS is_active + FROM res_users + WHERE ( + (login ilike %s) + OR + (partner_id IN ( + SELECT id + FROM res_partner + WHERE email ilike %s or name ilike %s))) + """ + values = [ + # Indirect references CTE + email_normalized, name, + # Search on res.partner + self.env['ir.model.data']._xmlid_to_res_id('base.model_res_partner'), + # Search on res.users + self.env['ir.model.data']._xmlid_to_res_id('base.model_res_users'), email, email, name, + ] + + # Step 2: Special case for direct messages + query += """ + UNION ALL + SELECT + %s AS res_model_id, + id AS res_id, + True AS is_active + FROM mail_message + WHERE author_id IN (SELECT id FROM indirect_references) + """ + values += [ + self.env['ir.model.data']._xmlid_to_res_id('mail.model_mail_message'), + ] + + # Step 3: Retrieve info on other models + blacklisted_models = self._get_query_models_blacklist() + for model_name in self.env: + if model_name in blacklisted_models: + continue + table_name = model_name.replace('.', '_') + + model = self.env[model_name] + if model._transient or model._transient or not model._auto: + continue + res_model_id = self.env['ir.model'].search([('model', '=', model_name)]).id + has_active = 'active' in model + has_additional_query = False + additional_query = """ + UNION ALL + SELECT + %s AS res_model_id, + id AS res_id, + {active} AS is_active + FROM {table_name} + WHERE + """.format(table_name=table_name, active='active' if has_active else True) + additional_values = [ + res_model_id + ] + + # 3.1 Search Basic Personal Data Records (aka email/name usage) + for field_name in ['email_normalized', 'email', 'email_from', 'company_email']: + if field_name in model and model._fields[field_name].store: + has_additional_query = True + rec_name = model._rec_name or 'name' + is_normalized = field_name == 'email_normalized' or (model_name == 'mailing.trace' and field_name == 'email') + if rec_name in model and model._fields[model._rec_name].type == 'char': + additional_query += """ + {field_name} {search_type} %s OR {rec_name} ilike %s + """.format( + field_name=field_name, + search_type='=' if is_normalized else 'ilike', # Manage Foo Bar + rec_name=rec_name) + additional_values += [email_normalized if is_normalized else email, name] + else: + additional_query += """ + {field_name} {search_type} %s + """.format( + field_name=field_name, + search_type='=' if is_normalized else 'ilike') # Manage Foo Bar + additional_values += [email_normalized if is_normalized else email] + if is_normalized: + break + + # 3.2 Search Indirect Personal Data References (aka partner_id) + partner_fields = [ + field_name for field_name, field in model._fields.items() \ + if field.comodel_name == 'res.partner' and field.store and field.type == 'many2one' and field.ondelete != 'cascade'] + if partner_fields: + for field_name in partner_fields: + additional_query += """ + {or_clause}{table_field_name} in (SELECT id FROM indirect_references)""".format( + or_clause='OR ' if has_additional_query else '', + table_field_name='"%s"."%s"' % (table_name, field_name)) + has_additional_query = True + + if has_additional_query: + query += additional_query + values += additional_values + return query, values + + def action_lookup(self): + self.ensure_one() + query, values = self._get_query() + self.env['base'].flush() + self.env.cr.execute(query, tuple(values)) + results = self.env.cr.dictfetchall() + self.line_ids = [(5, 0, 0)] + [(0, 0, reference) for reference in results] + return self.action_open_lines() + + def _post_log(self): + self.ensure_one() + if not self.log_id and self.execution_details: + self.log_id = self.env['privacy.log'].create({ + 'anonymized_name': self.name, + 'anonymized_email': self.email, + 'execution_details': self.execution_details, + 'records_description': self.records_description, + }) + else: + self.log_id.execution_details = self.execution_details + self.log_id.records_description = self.records_description + + @api.depends('line_ids.execution_details') + def _compute_execution_details(self): + for wizard in self: + wizard.execution_details = '\n'.join(line.execution_details for line in wizard.line_ids if line.execution_details) + wizard._post_log() + + @api.depends('line_ids') + def _compute_records_description(self): + for wizard in self: + if not wizard.line_ids: + wizard.records_description = '' + continue + records_by_model = defaultdict(list) + for line in wizard.line_ids: + records_by_model[line.res_model_id].append(line.res_id) + wizard.records_description = '\n'.join('{model_name} ({count}): {ids_str}'.format( + model_name=model.name if not self.env.user.user_has_groups('base.group_no_one') else '%s - %s' % (model.name, model.model), + count=len(ids), + ids_str=', '.join('#%s' % (rec_id) for rec_id in ids), + ) for model, ids in records_by_model.items()) + + def action_open_lines(self): + self.ensure_one() + action = self.env['ir.actions.act_window']._for_xml_id('privacy.action_privacy_lookup_wizard_line') + action['domain'] = [('wizard_id', '=', self.id)] + return action + + +class PrivacyLookupWizardLine(models.TransientModel): + _name = 'privacy.lookup.wizard.line' + _description = 'Privacy Lookup Wizard Line' + _transient_max_count = 0 + _transient_max_hours = 24 + + @api.model + def _selection_target_model(self): + return [(model.model, model.name) for model in self.env['ir.model'].sudo().search([])] + + wizard_id = fields.Many2one('privacy.lookup.wizard') + res_id = fields.Integer( + string="Resource ID", + required=True) + res_name = fields.Char( + string='Resource name', + compute='_compute_res_name', + store=True) + res_model_id = fields.Many2one( + 'ir.model', + 'Related Document Model', + ondelete='cascade') + res_model = fields.Char( + string='Document Model', + related='res_model_id.model', + store=True, + readonly=True) + resource_ref = fields.Reference( + string='Record', + selection='_selection_target_model', + compute='_compute_resource_ref', + inverse='_set_resource_ref') + has_active = fields.Boolean(compute='_compute_has_active', store=True) + is_active = fields.Boolean() + is_unlinked = fields.Boolean() + execution_details = fields.Char(default='') + + @api.depends('res_model', 'res_id', 'is_unlinked') + def _compute_resource_ref(self): + for line in self: + if line.res_model and line.res_model in self.env and not line.is_unlinked: + # Exclude records that can't be read (eg: multi-company ir.rule) + try: + self.env[line.res_model].browse(line.res_id).check_access_rule('read') + line.resource_ref = '%s,%s' % (line.res_model, line.res_id or 0) + except Exception: + line.resource_ref = None + else: + line.resource_ref = None + + def _set_resource_ref(self): + for line in self: + if line.resource_ref: + line.res_id = line.resource_ref.id + + @api.depends('res_model_id') + def _compute_has_active(self): + for line in self: + if not line.res_model_id: + line.has_active = False + continue + line.has_active = 'active' in self.env[line.res_model] + + @api.depends('res_model', 'res_id') + def _compute_res_name(self): + for line in self: + if not line.res_id or not line.res_model: + continue + record = self.env[line.res_model].sudo().browse(line.res_id) + if not record.exists(): + continue + name = record.name_get() + line.res_name = name[0][1] if name else ('%s/%s') % (line.res_model_id.name, line.res_id) + + @api.onchange('is_active') + def _onchange_is_active(self): + for line in self: + if not line.res_model_id or not line.res_id: + continue + action = _('Unarchived') if line.is_active else _('Archived') + line.execution_details = '%s %s #%s' % (action, line.res_model_id.name, line.res_id) + self.env[line.res_model].sudo().browse(line.res_id).write({'active': line.is_active}) + + def action_unlink(self): + self.ensure_one() + if self.is_unlinked: + raise UserError(_('The record is already unlinked.')) + self.env[self.res_model].sudo().browse(self.res_id).unlink() + self.execution_details = '%s %s #%s' % (_('Deleted'), self.res_model_id.name, self.res_id) + self.is_unlinked = True + + def action_archive_all(self): + for line in self: + if not line.has_active or not line.is_active: + continue + line.is_active = False + line._onchange_is_active() + + def action_unlink_all(self): + for line in self: + if line.is_unlinked: + continue + line.action_unlink() + + def action_open_record(self): + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'view_mode': 'form', + 'res_id': self.res_id, + 'res_model': self.res_model, + } diff --git a/addons/privacy/wizard/privacy_lookup_wizard_views.xml b/addons/privacy/wizard/privacy_lookup_wizard_views.xml new file mode 100644 index 00000000000..e147f80178c --- /dev/null +++ b/addons/privacy/wizard/privacy_lookup_wizard_views.xml @@ -0,0 +1,111 @@ + + + + privacy.lookup.wizard.view.form + privacy.lookup.wizard + +
+
+
+ +
+ +
+ + + + + + + + + + +
+
+
+
+ + + Privacy Lookup + privacy.lookup.wizard + form + + current + + + + privacy.lookup.wizard.line.view.tree + privacy.lookup.wizard.line + + + + + + +