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 + + + + + + +