From 727aab24875eb748620d0ad2d239a6cf826875c0 Mon Sep 17 00:00:00 2001 From: "anatoly.miheev" Date: Thu, 10 Apr 2025 21:39:14 +0000 Subject: [PATCH 1/5] [ADD] privacy_partner_to_be_forgotten: GDPR anonymization Add new module for partner data anonymization to comply with GDPR right to erasure requirements: - Anonymize individual contacts by replacing name with initials and "Anonymized" suffix (e.g., "John Doe" -> "JD Anonymized") - Transform email addresses with date stamps for traceability (e.g., "john@example.com" -> "jd_2025-01-15_123@anonymized.oca") - Clear all personal data: phone, address, tax ID, job details - Remove chatter messages and attachments containing personal info - Anonymize linked user accounts and archive them - Preserve company data as it contains public information - Provide confirmation wizard with GDPR compliance warnings - Add security group "Anonymize Contacts" for access control - Log anonymization events for audit trail The module ensures irreversible anonymization while maintaining data relationships and audit requirements for GDPR compliance. Task: 4518 --- privacy_partner_to_be_forgotten/README.rst | 175 +++++ privacy_partner_to_be_forgotten/__init__.py | 5 + .../__manifest__.py | 21 + .../demo/demo_partner_anonymize_data.xml | 138 ++++ .../i18n/privacy_partner_to_be_forgotten.pot | 200 +++++ .../models/__init__.py | 4 + .../models/res_partner.py | 269 +++++++ .../pyproject.toml | 3 + .../readme/CONFIGURE.md | 6 + .../readme/CONTEXT.md | 1 + .../readme/CONTRIBUTORS.md | 4 + .../readme/DESCRIPTION.md | 21 + .../readme/HISTORY.md | 0 .../readme/USAGE.md | 17 + .../security/ir.model.access.csv | 2 + .../security/security.xml | 19 + .../static/description/icon.png | Bin 0 -> 10254 bytes .../static/description/index.html | 529 +++++++++++++ .../tests/__init__.py | 4 + .../tests/test_partner_anonymize.py | 711 ++++++++++++++++++ .../views/res_partner_views.xml | 18 + .../wizards/__init__.py | 4 + .../wizards/partner_anonymize_wizard.py | 65 ++ .../partner_anonymize_wizard_views.xml | 57 ++ 24 files changed, 2273 insertions(+) create mode 100644 privacy_partner_to_be_forgotten/README.rst create mode 100644 privacy_partner_to_be_forgotten/__init__.py create mode 100644 privacy_partner_to_be_forgotten/__manifest__.py create mode 100644 privacy_partner_to_be_forgotten/demo/demo_partner_anonymize_data.xml create mode 100644 privacy_partner_to_be_forgotten/i18n/privacy_partner_to_be_forgotten.pot create mode 100644 privacy_partner_to_be_forgotten/models/__init__.py create mode 100644 privacy_partner_to_be_forgotten/models/res_partner.py create mode 100644 privacy_partner_to_be_forgotten/pyproject.toml create mode 100644 privacy_partner_to_be_forgotten/readme/CONFIGURE.md create mode 100644 privacy_partner_to_be_forgotten/readme/CONTEXT.md create mode 100644 privacy_partner_to_be_forgotten/readme/CONTRIBUTORS.md create mode 100644 privacy_partner_to_be_forgotten/readme/DESCRIPTION.md create mode 100644 privacy_partner_to_be_forgotten/readme/HISTORY.md create mode 100644 privacy_partner_to_be_forgotten/readme/USAGE.md create mode 100644 privacy_partner_to_be_forgotten/security/ir.model.access.csv create mode 100644 privacy_partner_to_be_forgotten/security/security.xml create mode 100644 privacy_partner_to_be_forgotten/static/description/icon.png create mode 100644 privacy_partner_to_be_forgotten/static/description/index.html create mode 100644 privacy_partner_to_be_forgotten/tests/__init__.py create mode 100644 privacy_partner_to_be_forgotten/tests/test_partner_anonymize.py create mode 100644 privacy_partner_to_be_forgotten/views/res_partner_views.xml create mode 100644 privacy_partner_to_be_forgotten/wizards/__init__.py create mode 100644 privacy_partner_to_be_forgotten/wizards/partner_anonymize_wizard.py create mode 100644 privacy_partner_to_be_forgotten/wizards/partner_anonymize_wizard_views.xml diff --git a/privacy_partner_to_be_forgotten/README.rst b/privacy_partner_to_be_forgotten/README.rst new file mode 100644 index 00000000..019f7b55 --- /dev/null +++ b/privacy_partner_to_be_forgotten/README.rst @@ -0,0 +1,175 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +======================================= +Privacy Partner's Right to be Forgotten +======================================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:f374385b6ed7798c0d487c5717e02403fe415e03bc101daca7dd4056884ed3dc + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fdata--protection-lightgray.png?logo=github + :target: https://github.com/OCA/data-protection/tree/17.0/privacy_partner_to_be_forgotten + :alt: OCA/data-protection +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/data-protection-17-0/data-protection-17-0-privacy_partner_to_be_forgotten + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/data-protection&target_branch=17.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +The module allows anonymizing contacts that represent natural persons +(i.e., individuals) when they are no longer needed or when a data +erasure request is received. + +This module provides a method to anonymize all personally identifiable +information (PII) from a ``res.partner`` record: + +- Name (e.g., replaced with initials or a generic label) for individual + contacts +- Email address +- Phone numbers +- Street and address fields +- Tax ID +- Citizen Identification +- Job position and title +- Internal Notes +- Attached images (e.g., avatar) + +The anonymization preserves the partner's link to its parent company if +applicable (e.g., for a B2B contact) but ensures that the individual is +no longer identifiable through the remaining data or relationships. + +Additionally, the module removes all chatter messages (``mail.message``) +and attachments associated with the partner record, as these may contain +personal information, such as communication history, internal notes, or +file uploads. This ensures complete anonymization and supports full GDPR +compliance. + +If the partner is linked to one or more ``res.users`` records (e.g., as +a portal user or employee), the module will anonymize these user +records: + +- The user login and email are replaced with anonymized values, and the + user is archived. + +A log note is added to the partner record chatter to indicate the +anonymization event, supporting traceability. + +**Table of contents** + +.. contents:: + :local: + +Use Cases / Context +=================== + +This module aims to help companies comply with the European Union +General Data Protection Regulation (GDPR) by providing a mechanism to +anonymize personal data in ``res.partner`` records. + +Configuration +============= + +To permit a user to anonymize partner records: +---------------------------------------------- + +1. Go to **Settings** → **Users & Companies** → **Users** +2. Select the user you want to authorize +3. In the **Administration** group, enable the checkbox **"Anonymize + Contacts"** + +This will allow the user to access and execute the anonymization +functionality provided by the module. + +Usage +===== + +To anonymize a partner record: +------------------------------ + +NOTE: **Make sure the user has permission** to anonymize partner +records. Please check the Configuration file for details on how to grant +permission. + +1. Go to the **Contacts App** in Odoo. +2. Select the partner record you want to anonymize. +3. Go to **Actions** → **Anonymize (GDPR)**. +4. A confirmation wizard will appear. Press the **"Confirm"** button to + proceed. + +Once you confirm, Odoo will anonymize the partner's record and all +related child records, including: + +- Chatter messages and attachments +- Linked ``res.users`` (user) records, if applicable + +**Important:** + +- The anonymization operation **cannot be undone**. +- Please be aware that once the data is anonymized, it cannot be + restored, so proceed carefully. +- You carry full responsibility for using the module's features, in line + with GDPR compliance. + +Changelog +========= + + + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Cetmix + +Contributors +------------ + +- `Cetmix `__: + + - Mikhail Lapin + - Anatol Mikheev + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/data-protection `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/privacy_partner_to_be_forgotten/__init__.py b/privacy_partner_to_be_forgotten/__init__.py new file mode 100644 index 00000000..1aba3cfc --- /dev/null +++ b/privacy_partner_to_be_forgotten/__init__.py @@ -0,0 +1,5 @@ +# Copyright (C) 2025 Cetmix OÜ +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +from . import models +from . import wizards diff --git a/privacy_partner_to_be_forgotten/__manifest__.py b/privacy_partner_to_be_forgotten/__manifest__.py new file mode 100644 index 00000000..9a4f7506 --- /dev/null +++ b/privacy_partner_to_be_forgotten/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright (C) 2025 Cetmix OÜ +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +{ + "name": "Privacy Partner's Right to be Forgotten", + "version": "17.0.1.0.0", + "development_status": "Beta", + "category": "Data Protection", + "summary": "Anonymize partner data for GDPR compliance", + "author": "Cetmix, Odoo Community Association (OCA)", + "license": "LGPL-3", + "website": "https://github.com/OCA/data-protection", + "depends": ["contacts"], + "data": [ + "security/security.xml", + "security/ir.model.access.csv", + "views/res_partner_views.xml", + "wizards/partner_anonymize_wizard_views.xml", + ], + "demo": ["demo/demo_partner_anonymize_data.xml"], +} diff --git a/privacy_partner_to_be_forgotten/demo/demo_partner_anonymize_data.xml b/privacy_partner_to_be_forgotten/demo/demo_partner_anonymize_data.xml new file mode 100644 index 00000000..3c8437db --- /dev/null +++ b/privacy_partner_to_be_forgotten/demo/demo_partner_anonymize_data.xml @@ -0,0 +1,138 @@ + + + + + Sodasopa + + +1 123-456-7890 + info@sodasopa.com + 123 Main St + South Park + 80435 + + www.sodasopa.com + US123456789 + + + + Eric Cartman + + +1 123-456-7891 + +1 123-456-7892 + eric@sodasopa.com + 124 Main St + South Park + 80435 + + Manager + + + + Butters Stotch + + +1 123-456-7893 + +1 123-456-7894 + butters@sodasopa.com + 125 Main St + South Park + 80435 + + Assistant + + + + Kenny McCormick + + +1 123-456-7895 + +1 123-456-7896 + kenny@sodasopa.com + 126 Main St + South Park + 80435 + + Intern + + + + Stanley Marsh + +1 123-456-7897 + +1 123-456-7898 + stan@sp.com + 127 Main St + South Park + 80435 + + Student + + + + + + sodasopa + sodasopa + Sodasopa + info@sodasopa.com + + + + + + + eric + eric + Eric Cartman + eric@sodasopa.com + + + + + + + butters + butters + Butters Stotch + butters@sodasopa.com + + + + + + + kenny + kenny + Kenny McCormick + kenny@sodasopa.com + + + + + + + stan + stan + Stanley Marsh + stan@sp.com + + + + diff --git a/privacy_partner_to_be_forgotten/i18n/privacy_partner_to_be_forgotten.pot b/privacy_partner_to_be_forgotten/i18n/privacy_partner_to_be_forgotten.pot new file mode 100644 index 00000000..847a5be8 --- /dev/null +++ b/privacy_partner_to_be_forgotten/i18n/privacy_partner_to_be_forgotten.pot @@ -0,0 +1,200 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * privacy_partner_to_be_forgotten +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: privacy_partner_to_be_forgotten +#. odoo-python +#: code:addons/privacy_partner_to_be_forgotten/models/res_partner.py:0 +#, python-format +msgid "%(company)s Anonymized" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#. odoo-python +#: code:addons/privacy_partner_to_be_forgotten/models/res_partner.py:0 +#, python-format +msgid "%(initials)s Anonymized" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#. odoo-python +#: code:addons/privacy_partner_to_be_forgotten/wizards/partner_anonymize_wizard.py:0 +#, python-format +msgid "%(partner_count)d partner(s) have been anonymized successfully." +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model_terms:ir.ui.view,arch_db:privacy_partner_to_be_forgotten.view_partner_anonymize_wizard_form +msgid "Important:" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model:res.groups,name:privacy_partner_to_be_forgotten.group_partner_anonymize +msgid "Administrator" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#. odoo-python +#: code:addons/privacy_partner_to_be_forgotten/wizards/partner_anonymize_wizard.py:0 +#, python-format +msgid "Anonymization Result" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model:ir.actions.act_window,name:privacy_partner_to_be_forgotten.action_partner_anonymize +msgid "Anonymize (GDPR)" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model:ir.module.category,name:privacy_partner_to_be_forgotten.module_category_anonymize_contacts +msgid "Anonymize Contacts" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model_terms:ir.ui.view,arch_db:privacy_partner_to_be_forgotten.view_partner_anonymize_wizard_form +msgid "Anonymize Partners" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model_terms:ir.ui.view,arch_db:privacy_partner_to_be_forgotten.view_partner_anonymize_wizard_form +msgid "Cancel" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model_terms:ir.ui.view,arch_db:privacy_partner_to_be_forgotten.view_partner_anonymize_wizard_form +msgid "Confirm Anonymization" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model_terms:ir.ui.view,arch_db:privacy_partner_to_be_forgotten.view_partner_anonymize_wizard_form +msgid "Confirmation Required: Anonymization Process" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model:ir.model,name:privacy_partner_to_be_forgotten.model_res_partner +msgid "Contact" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model_terms:ir.ui.view,arch_db:privacy_partner_to_be_forgotten.view_partner_anonymize_wizard_form +msgid "Contact's activity history" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model:ir.model.fields,field_description:privacy_partner_to_be_forgotten.field_partner_anonymize_wizard__create_uid +msgid "Created by" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model:ir.model.fields,field_description:privacy_partner_to_be_forgotten.field_partner_anonymize_wizard__create_date +msgid "Created on" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model:ir.model.fields,field_description:privacy_partner_to_be_forgotten.field_partner_anonymize_wizard__display_name +msgid "Display Name" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model:ir.model.fields,field_description:privacy_partner_to_be_forgotten.field_partner_anonymize_wizard__id +msgid "ID" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model:ir.model.fields,field_description:privacy_partner_to_be_forgotten.field_partner_anonymize_wizard__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model:ir.model.fields,field_description:privacy_partner_to_be_forgotten.field_partner_anonymize_wizard__write_date +msgid "Last Updated on" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model_terms:ir.ui.view,arch_db:privacy_partner_to_be_forgotten.view_partner_anonymize_wizard_form +msgid "Messages and attachments in chat history" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#. odoo-python +#: code:addons/privacy_partner_to_be_forgotten/wizards/partner_anonymize_wizard.py:0 +#, python-format +msgid "No partners selected for anonymization." +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model_terms:ir.ui.view,arch_db:privacy_partner_to_be_forgotten.view_partner_anonymize_wizard_form +msgid "" +"Once the data is anonymized, it cannot be restored. Please " +"proceed carefully." +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model_terms:ir.ui.view,arch_db:privacy_partner_to_be_forgotten.view_partner_anonymize_wizard_form +msgid "" +"Once you confirm, Odoo will anonymize the selected partner(s) and all " +"related records, including:" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model:ir.model.fields,field_description:privacy_partner_to_be_forgotten.field_partner_anonymize_wizard__partner_ids +msgid "Partner" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model:ir.model,name:privacy_partner_to_be_forgotten.model_partner_anonymize_wizard +msgid "Partner Anonymization Wizard" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model_terms:ir.ui.view,arch_db:privacy_partner_to_be_forgotten.view_partner_anonymize_wizard_form +msgid "Personal information (name, email, phone, address)" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model_terms:ir.ui.view,arch_db:privacy_partner_to_be_forgotten.view_partner_anonymize_wizard_form +msgid "The anonymization operation cannot be undone." +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#. odoo-python +#: code:addons/privacy_partner_to_be_forgotten/models/res_partner.py:0 +#, python-format +msgid "This contact has been anonymized on %(date)s by %(user)s" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model_terms:ir.ui.view,arch_db:privacy_partner_to_be_forgotten.view_partner_anonymize_wizard_form +msgid "User account information (if the contact has system access)" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model:res.groups,comment:privacy_partner_to_be_forgotten.group_partner_anonymize +msgid "" +"Users in this group can anonymize contact information for GDPR compliance." +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model_terms:ir.ui.view,arch_db:privacy_partner_to_be_forgotten.view_partner_anonymize_wizard_form +msgid "" +"You carry full responsibility for using the module's " +"features in compliance with GDPR regulations." +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#. odoo-python +#: code:addons/privacy_partner_to_be_forgotten/wizards/partner_anonymize_wizard.py:0 +#, python-format +msgid "You don't have permission to anonymize partners." +msgstr "" diff --git a/privacy_partner_to_be_forgotten/models/__init__.py b/privacy_partner_to_be_forgotten/models/__init__.py new file mode 100644 index 00000000..d9f29ad2 --- /dev/null +++ b/privacy_partner_to_be_forgotten/models/__init__.py @@ -0,0 +1,4 @@ +# Copyright (C) 2025 Cetmix OÜ +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +from . import res_partner diff --git a/privacy_partner_to_be_forgotten/models/res_partner.py b/privacy_partner_to_be_forgotten/models/res_partner.py new file mode 100644 index 00000000..5fd51dd2 --- /dev/null +++ b/privacy_partner_to_be_forgotten/models/res_partner.py @@ -0,0 +1,269 @@ +# Copyright (C) 2025 Cetmix OÜ +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +import logging + +from odoo import _, fields, models +from odoo.tools import DEFAULT_SERVER_DATE_FORMAT + +_logger = logging.getLogger(__name__) + + +class ResPartner(models.Model): + _inherit = "res.partner" + + def _get_partner_initials(self): + """Generate initials from partner name.""" + name_parts = (self.name or "").split() + initials = "".join(part[0].upper() for part in name_parts if part) + return initials or "X" + + def _generate_anonymized_email(self, initials): + """Generate anonymized email address. + + Args: + initials (str): Partner name initials (e.g., "JD" for "John Doe") + + Returns: + str: Anonymized email address in format + "initials_yyyy-mm-dd_id@anonymized.oca" + """ + date_stamp = fields.Date.today().strftime(DEFAULT_SERVER_DATE_FORMAT) + return f"{initials.lower()}_{date_stamp}_{self.id}@anonymized.oca" + + def _prepare_company_anonymized_vals(self, anonymized_name): + """Prepare values for anonymizing company data. + + For companies, we only anonymize the name by adding 'Anonymized' suffix + and set active to False, while preserving all business-critical information. + + Args: + anonymized_name (str): Generated anonymous name for the company + (format: "Company Name Anonymized") + + Returns: + dict: Dictionary of fields to update with anonymized values + """ + return { + "name": anonymized_name, + "active": False, + } + + def _prepare_partner_anonymized_vals(self, anonymized_name, anonymized_email): + """Prepare values for anonymizing partner data. + + Args: + anonymized_name (str): Generated anonymous name for the partner + (format: "XX Anonymized") + anonymized_email (str): Generated anonymous email + (format: "xx_dd.mm.yy_id@anonymized.oca") + + Returns: + dict: Dictionary of fields to update with anonymized or cleared values + """ + return { + "name": anonymized_name, + "email": anonymized_email, + "phone": False, + "mobile": False, + "street": False, + "street2": False, + "city": False, + "state_id": False, + "zip": False, + "country_id": False, + "function": False, + "title": False, + "vat": False, + "ref": False, + "comment": False, + "website": False, + "image_1920": False, + "image_1024": False, + "image_512": False, + "image_256": False, + "image_128": False, + "active": False, + } + + def _prepare_user_anonymized_vals(self, anonymized_email): + """Prepare values for anonymizing related user accounts. + + Args: + anonymized_email (str): Generated anonymous email + (format: "xx_dd.mm.yy_id@anonymized.oca") + + Returns: + dict: Dictionary containing user fields to update + (login, email, active status, signature) + """ + return { + "login": anonymized_email, + "email": anonymized_email, + "active": False, + "signature": False, + } + + def _anonymize_user(self, anonymized_email=False): + """Anonymize related user accounts. + + Args: + anonymized_email (str, optional): Generated anonymous email to be set + for user accounts. Only needed for individual contacts. + """ + if self.user_ids: + if self.is_company: + vals = {"active": False} + else: + vals = self._prepare_user_anonymized_vals(anonymized_email) + self.user_ids.with_context(tracking_disable=True).write(vals) + + def _anonymize_partner(self, anonymized_name, anonymized_email): + """Anonymize partner record.""" + # Setting active_test=True ensures that search() operations filter by + # active=True. This is needed because Odoo prevents archiving users with + # non-archived partners, and the partner archiving check is done via a regular + # search() that doesn't filter archived records when active_test=False + self.with_context( + active_test=True, + tracking_disable=True, + ).write( + self._prepare_partner_anonymized_vals(anonymized_name, anonymized_email) + ) + + def _get_messages_domain(self): + """Prepare domain for searching messages to be deleted during anonymization.""" + return [ + "|", + "|", + "|", + # Messages where this partner is the referenced record + "&", + ("model", "=", "res.partner"), + ("res_id", "=", self.id), + # Messages created by this partner + ("author_id", "=", self.id), + # Messages where this partner is in recipients + ("partner_ids", "in", [self.id]), + # Messages in channels where this partner is a member + "&", + ("model", "=", "discuss.channel"), + ( + "res_id", + "in", + self.env["discuss.channel"] + .search([("channel_member_ids.partner_id", "=", self.id)]) + .ids, + ), + ] + + def _anonymize_partner_messages(self): + """Delete all messages related to this partner.""" + messages = self.env["mail.message"].sudo().search(self._get_messages_domain()) + _logger.info( + "Anonymizing partner %s (ID: %s): Removing %s messages", + self.name, + self.id, + len(messages), + ) + if not messages: + return + + self.env["bus.bus"]._sendone( + self.env.user.partner_id, + "mail.message/delete", + {"message_ids": messages.ids}, + ) + messages.unlink() + + def _get_attachments_domain(self): + """Prepare domain for searching attachments to be deleted during + anonymization. + """ + self.ensure_one() + return [ + ("res_model", "=", "res.partner"), + ("res_id", "=", self.id), + ] + + def _anonymize_partner_attachments(self): + """Delete all attachments related to partner.""" + attachments = ( + self.env["ir.attachment"].sudo().search(self._get_attachments_domain()) + ) + _logger.info( + "Anonymizing partner %s (ID: %s): Removing %s attachments", + self.name, + self.id, + len(attachments), + ) + if not attachments: + return + + updates = [ + (self.env.user.partner_id, "ir.attachment/delete", {"id": attachment.id}) + for attachment in attachments + ] + self.env["bus.bus"]._sendmany(updates) + attachments.unlink() + + def _log_anonymization(self, timestamp): + """Log anonymization action in chatter.""" + message = self.message_post( + body=_( + "This contact has been anonymized on %(date)s by %(user)s", + date=fields.Datetime.to_string(timestamp), + user=self.env.user.name, + ), + subtype_xmlid="mail.mt_note", + ) + + self.env["bus.bus"]._sendone( + self.env.user.partner_id, + "mail.record/insert", + { + "Message": { + "id": message.id, + "body": message.body, + } + }, + ) + + def anonymize_partner_data(self): + """Anonymize partner and all related data. + + Executes anonymization in the following steps: + 1. Generates anonymized identifiers + 2. Anonymizes related user accounts + 3. Anonymizes partner record + 4. Removes related messages + 5. Removes related attachments + 6. Logs the anonymization action + + Note: This operation is irreversible and removes all personal data traces. + """ + self.ensure_one() + now = fields.Datetime.now() + + if self.is_company: + # Company anonymization + anonymized_name = _("%(company)s Anonymized", company=self.name) + self._anonymize_user() + self.with_context(active_test=True, tracking_disable=True).write( + self._prepare_company_anonymized_vals(anonymized_name) + ) + else: + # Individual contact anonymization + initials = self._get_partner_initials() + anonymized_name = _("%(initials)s Anonymized", initials=initials) + anonymized_email = self._generate_anonymized_email(initials) + + self._anonymize_user(anonymized_email) + self._anonymize_partner(anonymized_name, anonymized_email) + + # Common operations for both companies and individuals + self._anonymize_partner_messages() + self._anonymize_partner_attachments() + self._log_anonymization(now) + + return True diff --git a/privacy_partner_to_be_forgotten/pyproject.toml b/privacy_partner_to_be_forgotten/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/privacy_partner_to_be_forgotten/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/privacy_partner_to_be_forgotten/readme/CONFIGURE.md b/privacy_partner_to_be_forgotten/readme/CONFIGURE.md new file mode 100644 index 00000000..ee49e2a9 --- /dev/null +++ b/privacy_partner_to_be_forgotten/readme/CONFIGURE.md @@ -0,0 +1,6 @@ +## To permit a user to anonymize partner records: +1. Go to **Settings** → **Users & Companies** → **Users** +2. Select the user you want to authorize +3. In the **Administration** group, enable the checkbox **"Anonymize Contacts"** + +This will allow the user to access and execute the anonymization functionality provided by the module. diff --git a/privacy_partner_to_be_forgotten/readme/CONTEXT.md b/privacy_partner_to_be_forgotten/readme/CONTEXT.md new file mode 100644 index 00000000..9c1233b0 --- /dev/null +++ b/privacy_partner_to_be_forgotten/readme/CONTEXT.md @@ -0,0 +1 @@ +This module aims to help companies comply with the European Union General Data Protection Regulation (GDPR) by providing a mechanism to anonymize personal data in `res.partner` records. diff --git a/privacy_partner_to_be_forgotten/readme/CONTRIBUTORS.md b/privacy_partner_to_be_forgotten/readme/CONTRIBUTORS.md new file mode 100644 index 00000000..15cdb20b --- /dev/null +++ b/privacy_partner_to_be_forgotten/readme/CONTRIBUTORS.md @@ -0,0 +1,4 @@ +* [Cetmix](https://cetmix.com/): + + * Mikhail Lapin + * Anatol Mikheev diff --git a/privacy_partner_to_be_forgotten/readme/DESCRIPTION.md b/privacy_partner_to_be_forgotten/readme/DESCRIPTION.md new file mode 100644 index 00000000..7bff35ad --- /dev/null +++ b/privacy_partner_to_be_forgotten/readme/DESCRIPTION.md @@ -0,0 +1,21 @@ +The module allows anonymizing contacts that represent natural persons (i.e., individuals) when they are no longer needed or when a data erasure request is received. + +This module provides a method to anonymize all personally identifiable information (PII) from a `res.partner` record: +- Name (e.g., replaced with initials or a generic label) for individual contacts +- Email address +- Phone numbers +- Street and address fields +- Tax ID +- Citizen Identification +- Job position and title +- Internal Notes +- Attached images (e.g., avatar) + +The anonymization preserves the partner's link to its parent company if applicable (e.g., for a B2B contact) but ensures that the individual is no longer identifiable through the remaining data or relationships. + +Additionally, the module removes all chatter messages (`mail.message`) and attachments associated with the partner record, as these may contain personal information, such as communication history, internal notes, or file uploads. This ensures complete anonymization and supports full GDPR compliance. + +If the partner is linked to one or more `res.users` records (e.g., as a portal user or employee), the module will anonymize these user records: +- The user login and email are replaced with anonymized values, and the user is archived. + +A log note is added to the partner record chatter to indicate the anonymization event, supporting traceability. diff --git a/privacy_partner_to_be_forgotten/readme/HISTORY.md b/privacy_partner_to_be_forgotten/readme/HISTORY.md new file mode 100644 index 00000000..e69de29b diff --git a/privacy_partner_to_be_forgotten/readme/USAGE.md b/privacy_partner_to_be_forgotten/readme/USAGE.md new file mode 100644 index 00000000..4e008764 --- /dev/null +++ b/privacy_partner_to_be_forgotten/readme/USAGE.md @@ -0,0 +1,17 @@ +## To anonymize a partner record: + +NOTE: **Make sure the user has permission** to anonymize partner records. Please check the Configuration file for details on how to grant permission. + +1. Go to the **Contacts App** in Odoo. +2. Select the partner record you want to anonymize. +3. Go to **Actions** → **Anonymize (GDPR)**. +4. A confirmation wizard will appear. Press the **"Confirm"** button to proceed. + +Once you confirm, Odoo will anonymize the partner's record and all related child records, including: +- Chatter messages and attachments +- Linked `res.users` (user) records, if applicable + +**Important:** +- The anonymization operation **cannot be undone**. +- Please be aware that once the data is anonymized, it cannot be restored, so proceed carefully. +- You carry full responsibility for using the module's features, in line with GDPR compliance. diff --git a/privacy_partner_to_be_forgotten/security/ir.model.access.csv b/privacy_partner_to_be_forgotten/security/ir.model.access.csv new file mode 100644 index 00000000..54ff05b6 --- /dev/null +++ b/privacy_partner_to_be_forgotten/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_partner_anonymize_wizard,access.partner.anonymize.wizard,model_partner_anonymize_wizard,privacy_partner_to_be_forgotten.group_partner_anonymize,1,1,1,1 diff --git a/privacy_partner_to_be_forgotten/security/security.xml b/privacy_partner_to_be_forgotten/security/security.xml new file mode 100644 index 00000000..cc527c17 --- /dev/null +++ b/privacy_partner_to_be_forgotten/security/security.xml @@ -0,0 +1,19 @@ + + + + + Anonymize Contacts + + 101 + + + + + Administrator + + + Users in this group can anonymize contact information for GDPR compliance. + + diff --git a/privacy_partner_to_be_forgotten/static/description/icon.png b/privacy_partner_to_be_forgotten/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..1dcc49c24f364e9adf0afbc6fc0bac6dbecdeb11 GIT binary patch literal 10254 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 + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Privacy Partner’s Right to be Forgotten

+ +

Beta License: LGPL-3 OCA/data-protection Translate me on Weblate Try me on Runboat

+

The module allows anonymizing contacts that represent natural persons +(i.e., individuals) when they are no longer needed or when a data +erasure request is received.

+

This module provides a method to anonymize all personally identifiable +information (PII) from a res.partner record:

+
    +
  • Name (e.g., replaced with initials or a generic label) for individual +contacts
  • +
  • Email address
  • +
  • Phone numbers
  • +
  • Street and address fields
  • +
  • Tax ID
  • +
  • Citizen Identification
  • +
  • Job position and title
  • +
  • Internal Notes
  • +
  • Attached images (e.g., avatar)
  • +
+

The anonymization preserves the partner’s link to its parent company if +applicable (e.g., for a B2B contact) but ensures that the individual is +no longer identifiable through the remaining data or relationships.

+

Additionally, the module removes all chatter messages (mail.message) +and attachments associated with the partner record, as these may contain +personal information, such as communication history, internal notes, or +file uploads. This ensures complete anonymization and supports full GDPR +compliance.

+

If the partner is linked to one or more res.users records (e.g., as +a portal user or employee), the module will anonymize these user +records:

+
    +
  • The user login and email are replaced with anonymized values, and the +user is archived.
  • +
+

A log note is added to the partner record chatter to indicate the +anonymization event, supporting traceability.

+

Table of contents

+ +
+

Use Cases / Context

+

This module aims to help companies comply with the European Union +General Data Protection Regulation (GDPR) by providing a mechanism to +anonymize personal data in res.partner records.

+
+
+

Configuration

+
+

To permit a user to anonymize partner records:

+
    +
  1. Go to Settings → Users & Companies → Users
  2. +
  3. Select the user you want to authorize
  4. +
  5. In the Administration group, enable the checkbox “Anonymize +Contacts”
  6. +
+

This will allow the user to access and execute the anonymization +functionality provided by the module.

+
+
+
+

Usage

+
+

To anonymize a partner record:

+

NOTE: Make sure the user has permission to anonymize partner +records. Please check the Configuration file for details on how to grant +permission.

+
    +
  1. Go to the Contacts App in Odoo.
  2. +
  3. Select the partner record you want to anonymize.
  4. +
  5. Go to Actions → Anonymize (GDPR).
  6. +
  7. A confirmation wizard will appear. Press the “Confirm” button to +proceed.
  8. +
+

Once you confirm, Odoo will anonymize the partner’s record and all +related child records, including:

+
    +
  • Chatter messages and attachments
  • +
  • Linked res.users (user) records, if applicable
  • +
+

Important:

+
    +
  • The anonymization operation cannot be undone.
  • +
  • Please be aware that once the data is anonymized, it cannot be +restored, so proceed carefully.
  • +
  • You carry full responsibility for using the module’s features, in line +with GDPR compliance.
  • +
+
+
+ +
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Cetmix
  • +
+
+
+

Contributors

+
    +
  • Cetmix:
      +
    • Mikhail Lapin
    • +
    • Anatol Mikheev
    • +
    +
  • +
+
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/data-protection project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+
+ + diff --git a/privacy_partner_to_be_forgotten/tests/__init__.py b/privacy_partner_to_be_forgotten/tests/__init__.py new file mode 100644 index 00000000..2f0eb801 --- /dev/null +++ b/privacy_partner_to_be_forgotten/tests/__init__.py @@ -0,0 +1,4 @@ +# Copyright (C) 2025 Cetmix OÜ +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +from . import test_partner_anonymize diff --git a/privacy_partner_to_be_forgotten/tests/test_partner_anonymize.py b/privacy_partner_to_be_forgotten/tests/test_partner_anonymize.py new file mode 100644 index 00000000..814204bb --- /dev/null +++ b/privacy_partner_to_be_forgotten/tests/test_partner_anonymize.py @@ -0,0 +1,711 @@ +# Copyright (C) 2025 Cetmix OÜ +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +import re +from unittest.mock import patch + +from odoo import fields +from odoo.exceptions import AccessError +from odoo.tests.common import Form, TransactionCase, tagged + + +@tagged("post_install", "-at_install") +class TestPartnerAnonymize(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Create test company + cls.partner_company = cls.env["res.partner"].create( + { + "name": "Sodasopa", + "is_company": True, + "phone": "+1 123-456-7890", + "email": "info@sodasopa.com", + "street": "123 Main St", + "city": "South Park", + "zip": "80435", + "country_id": cls.env.ref("base.us").id, + "website": "www.sodasopa.com", + "vat": "US123456789", + } + ) + + # Create test partners + cls.partner_eric = cls.env["res.partner"].create( + { + "name": "Eric Cartman", + "parent_id": cls.partner_company.id, + "phone": "+1 123-456-7891", + "mobile": "+1 123-456-7892", + "email": "eric@sodasopa.com", + "street": "124 Main St", + "city": "South Park", + "zip": "80435", + "country_id": cls.env.ref("base.us").id, + "function": "Manager", + } + ) + + cls.partner_butters = cls.env["res.partner"].create( + { + "name": "Butters Stotch", + "parent_id": cls.partner_company.id, + "phone": "+1 123-456-7893", + "mobile": "+1 123-456-7894", + "email": "butters@sodasopa.com", + "street": "125 Main St", + "city": "South Park", + "zip": "80435", + "country_id": cls.env.ref("base.us").id, + "function": "Assistant", + } + ) + + cls.partner_kenny = cls.env["res.partner"].create( + { + "name": "Kenny McCormick", + "parent_id": cls.partner_eric.id, + "phone": "+1 123-456-7895", + "mobile": "+1 123-456-7896", + "email": "kenny@sodasopa.com", + "street": "126 Main St", + "city": "South Park", + "zip": "80435", + "country_id": cls.env.ref("base.us").id, + "function": "Intern", + } + ) + + cls.partner_stanley = cls.env["res.partner"].create( + { + "name": "Stanley Marsh", + "phone": "+1 123-456-7897", + "mobile": "+1 123-456-7898", + "email": "stan@sp.com", + "street": "127 Main St", + "city": "South Park", + "zip": "80435", + "country_id": cls.env.ref("base.us").id, + "function": "Student", + } + ) + + # Create test users + cls.user_company = cls.env["res.users"].create( + { + "partner_id": cls.partner_company.id, + "login": "test_sodasopa", + "password": "sodasopa", + "name": "Sodasopa", + "email": "info@sodasopa.com", + "groups_id": [(4, cls.env.ref("base.group_user").id)], + } + ) + + cls.user_eric = cls.env["res.users"].create( + { + "partner_id": cls.partner_eric.id, + "login": "test_eric", + "password": "eric", + "name": "Eric Cartman", + "email": "eric@sodasopa.com", + "groups_id": [(4, cls.env.ref("base.group_user").id)], + } + ) + + cls.user_butters = cls.env["res.users"].create( + { + "partner_id": cls.partner_butters.id, + "login": "test_butters", + "password": "butters", + "name": "Butters Stotch", + "email": "butters@sodasopa.com", + "groups_id": [(4, cls.env.ref("base.group_user").id)], + } + ) + + cls.user_kenny = cls.env["res.users"].create( + { + "partner_id": cls.partner_kenny.id, + "login": "test_kenny", + "password": "kenny", + "name": "Kenny McCormick", + "email": "kenny@sodasopa.com", + "groups_id": [(4, cls.env.ref("base.group_user").id)], + } + ) + + cls.user_stanley = cls.env["res.users"].create( + { + "partner_id": cls.partner_stanley.id, + "login": "test_stan", + "password": "stan", + "name": "Stanley Marsh", + "email": "stan@sp.com", + "groups_id": [(4, cls.env.ref("base.group_user").id)], + } + ) + + cls.test_user = cls.env["res.users"].create( + { + "name": "Test User", + "login": "test_user", + "email": "test_user@example.com", + "groups_id": [ + (4, cls.env.ref("base.group_user").id), + (4, cls.env.ref("base.group_system").id), + (4, cls.env.ref("base.group_partner_manager").id), + ( + 4, + cls.env.ref( + "privacy_partner_to_be_forgotten.group_partner_anonymize" + ).id, + ), + ], + } + ) + + cls.test_user_no_rights = cls.env["res.users"].create( + { + "name": "Test User No Rights", + "login": "test_user_no_rights", + "email": "test_user_no_rights@example.com", + "groups_id": [(4, cls.env.ref("base.group_user").id)], + } + ) + + cls.env["ir.model.access"].create( + { + "name": "access_res_partner_full", + "model_id": cls.env.ref("base.model_res_partner").id, + "perm_read": True, + "perm_write": True, + "perm_create": True, + "perm_unlink": True, + "group_id": cls.env.ref( + "privacy_partner_to_be_forgotten.group_partner_anonymize" + ).id, + } + ) + + cls.message = cls.env["mail.message"].create( + { + "model": "res.partner", + "res_id": cls.partner_eric.id, + "body": "Test message", + "message_type": "comment", + } + ) + + cls.attachment = cls.env["ir.attachment"].create( + { + "name": "Test Attachment", + "res_model": "res.partner", + "res_id": cls.partner_eric.id, + "datas": ( + "R0lGODlhAQABAIAAAP///wAAACwAAAAAAQABAAACAkQBADs=" # Minimal GIF + ), + } + ) + + def _create_anonymize_wizard(self, partner_ids, user): + """Helper method to create and return an anonymization wizard""" + with Form( + self.env["partner.anonymize.wizard"] + .with_user(user) + .with_context(default_partner_ids=partner_ids, active_ids=partner_ids) + ) as wizard: + wizard_id = wizard.save() + + return wizard_id + + def _check_anonymized_partner(self, partner, original_name): + """Helper method to check if a partner is properly anonymized""" + # Check name format (initials + "Anonymized") + initials = "".join(part[0].upper() for part in original_name.split() if part) + self.assertEqual(partner.name, f"{initials} Anonymized") + + # Check email format + email_pattern = ( + rf"^{initials.lower()}_\d{{4}}-\d{{2}}-\d{{2}}_\d+@anonymized\.oca$" + ) + self.assertTrue(re.match(email_pattern, partner.email)) + + self.assertFalse(partner.phone) + self.assertFalse(partner.mobile) + self.assertFalse(partner.street) + self.assertFalse(partner.street2) + self.assertFalse(partner.city) + self.assertFalse(partner.state_id) + self.assertFalse(partner.zip) + self.assertFalse(partner.country_id) + self.assertFalse(partner.function) + self.assertFalse(partner.title) + self.assertFalse(partner.vat) + self.assertFalse(partner.ref) + self.assertFalse(partner.comment) + self.assertFalse(partner.website) + self.assertFalse(partner.image_1920) + self.assertFalse(partner.active) + + return True + + def _check_anonymized_user(self, user, partner): + """Helper method to check if a user is properly anonymized""" + self.assertEqual(user.login, partner.email) + self.assertEqual(user.email, partner.email) + self.assertFalse(user.active) + self.assertIn("Anonymized", user.signature) + + return True + + def _check_anonymized_company(self, company, original_name): + """Helper method to check if a company is properly anonymized""" + # Check name format ("Company Name Anonymized") + self.assertEqual( + company.name, + f"{original_name} Anonymized", + ) + + # Check that business-critical fields are preserved + preserved_fields = [ + "vat", + "country_id", + "state_id", + "zip", + "city", + "street", + "street2", + ] + for field in preserved_fields: + self.assertEqual( + company[field], + self.partner_company[field], + ) + + self.assertFalse(self.partner_company.active) + + return True + + def test_01_anonymize_individual_with_child(self): + """Test Case 1: Anonymize Individual Contact (Parent with Child)""" + eric_name = self.partner_eric.name + butters_name = self.partner_butters.name + + # Set Butters as Eric's child + self.partner_butters.write({"parent_id": self.partner_eric.id}) + + # Verify initial state + self.assertEqual(self.partner_butters.parent_id, self.partner_eric) + + # Anonymize Eric (parent) + wizard = self._create_anonymize_wizard([self.partner_eric.id], self.test_user) + wizard.action_confirm() + + # Check Eric's anonymization + self._check_anonymized_partner(self.partner_eric, eric_name) + self._check_anonymized_user(self.user_eric, self.partner_eric) + + # Check Butters' anonymization (child) + self._check_anonymized_partner(self.partner_butters, butters_name) + self._check_anonymized_user(self.user_butters, self.partner_butters) + + # Check messages and attachments + for partner in [self.partner_eric, self.partner_butters]: + # Check messages (should only have anonymization log) + messages = self.env["mail.message"].search( + [ + ("model", "=", "res.partner"), + ("res_id", "=", partner.id), + ] + ) + self.assertEqual(len(messages), 1) + self.assertIn("anonymized", messages[0].body) + + # Check attachments (should be empty) + attachments = self.env["ir.attachment"].search( + [ + ("res_model", "=", "res.partner"), + ("res_id", "=", partner.id), + ] + ) + self.assertEqual(len(attachments), 0) + + def test_02_anonymize_child_record_only(self): + """Test Case 2: Anonymize Child Record Only""" + + eric_name = self.partner_eric.name + butters_name = self.partner_butters.name + + # Set Butters as Eric's child + self.partner_butters.write({"parent_id": self.partner_eric.id}) + + # Verify initial state + self.assertEqual(self.partner_butters.parent_id, self.partner_eric) + + # Anonymize Butters only + wizard = self._create_anonymize_wizard( + [self.partner_butters.id], self.test_user + ) + wizard.action_confirm() + + # Check that Butters (child) is anonymized + self._check_anonymized_partner(self.partner_butters, butters_name) + self._check_anonymized_user(self.user_butters, self.partner_butters) + + # Check that Eric (parent) is not anonymized + self.assertEqual(self.partner_eric.name, eric_name) + self.assertTrue(self.partner_eric.active) + + # Check messages and attachments for Butters + messages = self.env["mail.message"].search( + [ + ("model", "=", "res.partner"), + ("res_id", "=", self.partner_butters.id), + ] + ) + self.assertEqual(len(messages), 1) # Only anonymization log should remain + self.assertIn("anonymized", messages[0].body) + + attachments = self.env["ir.attachment"].search( + [ + ("res_model", "=", "res.partner"), + ("res_id", "=", self.partner_butters.id), + ] + ) + self.assertEqual(len(attachments), 0) + + def test_03_anonymize_company_with_children(self): + """Test Case 3: Anonymize Parent Company with Child Records""" + + # Setup initial state + company_name = self.partner_company.name + eric_name = self.partner_eric.name + butters_name = self.partner_butters.name + + # Link Eric and Butters to the company + self.partner_eric.write({"parent_id": self.partner_company.id}) + self.partner_butters.write({"parent_id": self.partner_company.id}) + + # Verify initial state + self.assertEqual(self.partner_eric.parent_id, self.partner_company) + self.assertEqual(self.partner_butters.parent_id, self.partner_company) + + # Anonymize company + wizard = self._create_anonymize_wizard( + [self.partner_company.id], self.test_user + ) + wizard.action_confirm() + + # Check company anonymization + self._check_anonymized_company(self.partner_company, company_name) + + # Check Eric's anonymization + self._check_anonymized_partner(self.partner_eric, eric_name) + self._check_anonymized_user(self.user_eric, self.partner_eric) + + # Check Butters' anonymization + self._check_anonymized_partner(self.partner_butters, butters_name) + self._check_anonymized_user(self.user_butters, self.partner_butters) + + # Check messages (should only have anonymization logs) + for partner in [self.partner_company, self.partner_eric, self.partner_butters]: + messages = self.env["mail.message"].search( + [ + ("model", "=", "res.partner"), + ("res_id", "=", partner.id), + ] + ) + self.assertEqual(len(messages), 1) + self.assertIn("anonymized", messages[0].body) + + # Check attachments (should be empty) + attachments = self.env["ir.attachment"].search( + [ + ("res_model", "=", "res.partner"), + ("res_id", "=", partner.id), + ] + ) + self.assertEqual(len(attachments), 0) + + # Verify relationships are preserved + self.assertEqual(self.partner_eric.parent_id, self.partner_company) + self.assertEqual(self.partner_butters.parent_id, self.partner_company) + + def test_04_anonymize_one_child_record(self): + """Test Case 4: Anonymize One of the Child Records""" + # Store original names for later comparison + company_name = self.partner_company.name + eric_name = self.partner_eric.name + butters_name = self.partner_butters.name + + # Set both Eric and Butters as children of the company + self.partner_eric.write({"parent_id": self.partner_company.id}) + self.partner_butters.write({"parent_id": self.partner_company.id}) + + # Verify initial state + self.assertEqual(self.partner_eric.parent_id, self.partner_company) + self.assertEqual(self.partner_butters.parent_id, self.partner_company) + + # Anonymize Butters only + wizard = self._create_anonymize_wizard( + [self.partner_butters.id], self.test_user + ) + wizard.action_confirm() + + # Check that Butters is anonymized + self._check_anonymized_partner(self.partner_butters, butters_name) + self._check_anonymized_user(self.user_butters, self.partner_butters) + + # Check that Eric and Company remain unchanged + self.assertEqual(self.partner_eric.name, eric_name) + self.assertTrue(self.partner_eric.active) + self.assertEqual(self.partner_company.name, company_name) + self.assertTrue(self.partner_company.active) + + # Check messages and attachments for Butters + messages = self.env["mail.message"].search( + [ + ("model", "=", "res.partner"), + ("res_id", "=", self.partner_butters.id), + ] + ) + self.assertEqual(len(messages), 1) # Only anonymization log should remain + self.assertIn("anonymized", messages[0].body) + + attachments = self.env["ir.attachment"].search( + [ + ("res_model", "=", "res.partner"), + ("res_id", "=", self.partner_butters.id), + ] + ) + self.assertEqual(len(attachments), 0) + + def test_05_anonymize_deeply_nested_child(self): + """Test Case 5: Anonymize Deeply Nested Child Record""" + # Store original names for later comparison + company_name = self.partner_company.name + eric_name = self.partner_eric.name + butters_name = self.partner_butters.name + stanley_name = self.partner_stanley.name + + # Set up the hierarchy: + self.partner_eric.write({"parent_id": self.partner_company.id}) + self.partner_butters.write({"parent_id": self.partner_company.id}) + self.partner_stanley.write({"parent_id": self.partner_eric.id}) + + # Verify initial state + self.assertEqual(self.partner_stanley.parent_id, self.partner_eric) + self.assertEqual(self.partner_eric.parent_id, self.partner_company) + self.assertEqual(self.partner_butters.parent_id, self.partner_company) + + # Verify Stanley's initial state + self.assertEqual(self.partner_stanley.name, stanley_name) + self.assertNotIn("Anonymized", stanley_name) + + # Anonymize Stanley (deeply nested child) + wizard = self._create_anonymize_wizard( + [self.partner_stanley.id], self.test_user + ) + wizard.action_confirm() + + # Check that Stanley is anonymized + self._check_anonymized_partner(self.partner_stanley, stanley_name) + self._check_anonymized_user(self.user_stanley, self.partner_stanley) + + # Check that parent (Eric) remains unchanged + self.assertEqual(self.partner_eric.name, eric_name) + self.assertTrue(self.partner_eric.active) + + # Check that company and other child (Butters) remain unchanged + self.assertEqual(self.partner_company.name, company_name) + self.assertTrue(self.partner_company.active) + self.assertEqual(self.partner_butters.name, butters_name) + self.assertTrue(self.partner_butters.active) + + # Check messages and attachments for Stanley + messages = self.env["mail.message"].search( + [ + ("model", "=", "res.partner"), + ("res_id", "=", self.partner_stanley.id), + ] + ) + self.assertEqual(len(messages), 1) # Only anonymization log should remain + self.assertIn("anonymized", messages[0].body) + + attachments = self.env["ir.attachment"].search( + [ + ("res_model", "=", "res.partner"), + ("res_id", "=", self.partner_stanley.id), + ] + ) + self.assertEqual(len(attachments), 0) + + def test_06_anonymize_multiple_partners(self): + """Test anonymizing multiple partners at once""" + # Store original names for later comparison + eric_name = self.partner_eric.name + butters_name = self.partner_butters.name + + # Anonymize both Eric and Butters + wizard = self._create_anonymize_wizard( + [self.partner_eric.id, self.partner_butters.id], self.test_user + ) + wizard.action_confirm() + + # Check that both partners are anonymized + self._check_anonymized_partner(self.partner_eric, eric_name) + self._check_anonymized_user(self.user_eric, self.partner_eric) + + self._check_anonymized_partner(self.partner_butters, butters_name) + self._check_anonymized_user(self.user_butters, self.partner_butters) + + def test_07_validate_access_rights(self): + """Test validation of access rights in _validate_partners_for_anonymization""" + # Create wizard with user that has anonymization rights + wizard = self._create_anonymize_wizard( + [self.partner_stanley.id], self.test_user + ) + + # Try to execute action_confirm with user that doesn't have rights + wizard = wizard.with_user(self.test_user_no_rights) + + # This should raise an AccessError + with self.assertRaises(AccessError) as context: + wizard.action_confirm() + + # Check the error message + self.assertIn( + "You don't have permission to anonymize partners", str(context.exception) + ) + + # Verify the partner is not anonymized + self.assertTrue(self.partner_stanley.active) + self.assertNotIn("Anonymized", self.partner_stanley.name) + + def test_08_no_personal_data_in_chatter_after_anonymization(self): + """Test that after anonymization: + 1. Only anonymization message is present in chatter + 2. No personal data is exposed in tracking values + """ + partner = self.partner_stanley + original_name = partner.name + original_email = partner.email + original_phone = partner.phone + + # Get current user and timestamp before anonymization + anonymizing_user = self.test_user.name + anonymization_datetime = fields.Datetime.now() + + # Anonymize partner + wizard = self._create_anonymize_wizard([partner.id], self.test_user) + wizard.action_confirm() + + # Get all messages after anonymization + messages = self.env["mail.message"].search( + [ + ("model", "=", "res.partner"), + ("res_id", "=", partner.id), + ] + ) + + # Should have exactly one message + self.assertEqual(len(messages), 1) + + # Check message content + message = messages[0] + expected_datetime_str = fields.Datetime.to_string(anonymization_datetime) + self.assertIn(anonymizing_user, message.body) + self.assertIn(expected_datetime_str[:10], message.body) + + # Ensure no personal data is present in the chatter + for sensitive_data in [original_name, original_email, original_phone]: + if sensitive_data: # Skip if field was empty + self.assertNotIn( + sensitive_data, + message.body, + ) + + def test_09_anonymize_partner_attachments(self): + """Test that _anonymize_partner_attachments correctly removes attachments""" + # Create a new attachment for testing + attachment = self.env["ir.attachment"].create( + { + "name": "Test Attachment for Removal", + "res_model": "res.partner", + "res_id": self.partner_kenny.id, + "datas": ( + "R0lGODlhAQABAIAAAP///wAAACwAAAAAAQABAAACAkQBADs=" # Minimal GIF + ), + } + ) + + # Verify attachment exists + attachments_before = self.env["ir.attachment"].search( + [ + ("res_model", "=", "res.partner"), + ("res_id", "=", self.partner_kenny.id), + ] + ) + self.assertEqual(len(attachments_before), 1) + self.assertEqual(attachments_before[0].id, attachment.id) + + self.partner_kenny._anonymize_partner_attachments() + + # Verify attachment was removed + attachments_after = self.env["ir.attachment"].search( + [ + ("res_model", "=", "res.partner"), + ("res_id", "=", self.partner_kenny.id), + ] + ) + self.assertEqual(len(attachments_after), 0) + + def test_10_log_anonymization(self): + """Test that _log_anonymization correctly creates a log message""" + # Get initial message ('Contact created') count + initial_messages = self.env["mail.message"].search( + [ + ("model", "=", "res.partner"), + ("res_id", "=", self.partner_kenny.id), + ] + ) + initial_count = len(initial_messages) + + # Mock the bus.bus._sendone method to check if it's called + original_sendone = self.env["bus.bus"]._sendone + + call_args = [] + + def mock_sendone(self, partner, notification_type, message_content): + call_args.append((partner, notification_type, message_content)) + return original_sendone(partner, notification_type, message_content) + + with patch.object(type(self.env["bus.bus"]), "_sendone", mock_sendone): + timestamp = fields.Datetime.now() + self.partner_kenny._log_anonymization(timestamp) + + # Verify a new message was created + messages_after = self.env["mail.message"].search( + [ + ("model", "=", "res.partner"), + ("res_id", "=", self.partner_kenny.id), + ] + ) + self.assertEqual(len(messages_after), initial_count + 1) + + # Verify message content + latest_message = messages_after.sorted(lambda m: m.id, reverse=True)[0] + self.assertIn("This contact has been anonymized", latest_message.body) + self.assertIn(self.env.user.name, latest_message.body) + self.assertIn( + fields.Datetime.to_string(timestamp)[:10], latest_message.body + ) + + # Verify notification was sent to the current user + self.assertTrue(call_args) + partner, notification_type, message_content = call_args[0] + self.assertEqual(partner, self.env.user.partner_id) + self.assertEqual(notification_type, "mail.record/insert") + self.assertEqual(message_content["Message"]["id"], latest_message.id) diff --git a/privacy_partner_to_be_forgotten/views/res_partner_views.xml b/privacy_partner_to_be_forgotten/views/res_partner_views.xml new file mode 100644 index 00000000..1e1fb74a --- /dev/null +++ b/privacy_partner_to_be_forgotten/views/res_partner_views.xml @@ -0,0 +1,18 @@ + + + + Anonymize (GDPR) + partner.anonymize.wizard + form + new + + form,list + + {'default_partner_ids': active_ids, 'active_test': False} + + diff --git a/privacy_partner_to_be_forgotten/wizards/__init__.py b/privacy_partner_to_be_forgotten/wizards/__init__.py new file mode 100644 index 00000000..663b444b --- /dev/null +++ b/privacy_partner_to_be_forgotten/wizards/__init__.py @@ -0,0 +1,4 @@ +# Copyright (C) 2025 Cetmix OÜ +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +from . import partner_anonymize_wizard diff --git a/privacy_partner_to_be_forgotten/wizards/partner_anonymize_wizard.py b/privacy_partner_to_be_forgotten/wizards/partner_anonymize_wizard.py new file mode 100644 index 00000000..5d99c215 --- /dev/null +++ b/privacy_partner_to_be_forgotten/wizards/partner_anonymize_wizard.py @@ -0,0 +1,65 @@ +# Copyright (C) 2025 Cetmix OÜ +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +from odoo import _, api, fields, models +from odoo.exceptions import AccessError, UserError + + +class PartnerAnonymizeWizard(models.TransientModel): + _name = "partner.anonymize.wizard" + _description = "Partner Anonymization Wizard" + + partner_ids = fields.Many2many( + "res.partner", + required=True, + readonly=True, + ) + + @api.model + def default_get(self, fields_list): + res = super().default_get(fields_list) + active_ids = self._context.get("active_ids") + if "partner_ids" in fields_list and active_ids: + domain = [ + ("id", "child_of", active_ids), + "|", + ("email", "=", False), + ("email", "not like", "%@anonymized.oca"), + ] + all_partners = self.env["res.partner"].search(domain) + res["partner_ids"] = [(6, 0, all_partners.ids)] + return res + + def _validate_partners_for_anonymization(self): + # Check access rights + if not self.env.user.has_group( + "privacy_partner_to_be_forgotten.group_partner_anonymize" + ): + raise AccessError(_("You don't have permission to anonymize partners.")) + + # Check if there are partners to anonymize + if not self.partner_ids: + raise UserError(_("No partners selected for anonymization.")) + + def action_confirm(self): + """Confirm and process partner anonymization for multiple partners""" + self.ensure_one() + self._validate_partners_for_anonymization() + + for partner in self.partner_ids: + partner.anonymize_partner_data() + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Anonymization Result"), + "message": _( + "%(partner_count)d partner(s) have been anonymized successfully." + ) + % {"partner_count": len(self.partner_ids)}, + "sticky": False, + "type": "success", + "next": {"type": "ir.actions.act_window_close"}, + }, + } diff --git a/privacy_partner_to_be_forgotten/wizards/partner_anonymize_wizard_views.xml b/privacy_partner_to_be_forgotten/wizards/partner_anonymize_wizard_views.xml new file mode 100644 index 00000000..f236bc58 --- /dev/null +++ b/privacy_partner_to_be_forgotten/wizards/partner_anonymize_wizard_views.xml @@ -0,0 +1,57 @@ + + + + partner.anonymize.wizard.form + partner.anonymize.wizard + +
+ + + + + + + + + + +
+
+
+
+
+
From 6764479f90dcccf79cc56144f060cb579378557f Mon Sep 17 00:00:00 2001 From: mymage Date: Wed, 6 Aug 2025 11:40:56 +0000 Subject: [PATCH 2/5] Added translation using Weblate (Italian) --- privacy_partner_to_be_forgotten/i18n/it.po | 201 +++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 privacy_partner_to_be_forgotten/i18n/it.po diff --git a/privacy_partner_to_be_forgotten/i18n/it.po b/privacy_partner_to_be_forgotten/i18n/it.po new file mode 100644 index 00000000..a92d7e89 --- /dev/null +++ b/privacy_partner_to_be_forgotten/i18n/it.po @@ -0,0 +1,201 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * privacy_partner_to_be_forgotten +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" + +#. module: privacy_partner_to_be_forgotten +#. odoo-python +#: code:addons/privacy_partner_to_be_forgotten/models/res_partner.py:0 +#, python-format +msgid "%(company)s Anonymized" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#. odoo-python +#: code:addons/privacy_partner_to_be_forgotten/models/res_partner.py:0 +#, python-format +msgid "%(initials)s Anonymized" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#. odoo-python +#: code:addons/privacy_partner_to_be_forgotten/wizards/partner_anonymize_wizard.py:0 +#, python-format +msgid "%(partner_count)d partner(s) have been anonymized successfully." +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model_terms:ir.ui.view,arch_db:privacy_partner_to_be_forgotten.view_partner_anonymize_wizard_form +msgid "Important:" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model:res.groups,name:privacy_partner_to_be_forgotten.group_partner_anonymize +msgid "Administrator" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#. odoo-python +#: code:addons/privacy_partner_to_be_forgotten/wizards/partner_anonymize_wizard.py:0 +#, python-format +msgid "Anonymization Result" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model:ir.actions.act_window,name:privacy_partner_to_be_forgotten.action_partner_anonymize +msgid "Anonymize (GDPR)" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model:ir.module.category,name:privacy_partner_to_be_forgotten.module_category_anonymize_contacts +msgid "Anonymize Contacts" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model_terms:ir.ui.view,arch_db:privacy_partner_to_be_forgotten.view_partner_anonymize_wizard_form +msgid "Anonymize Partners" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model_terms:ir.ui.view,arch_db:privacy_partner_to_be_forgotten.view_partner_anonymize_wizard_form +msgid "Cancel" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model_terms:ir.ui.view,arch_db:privacy_partner_to_be_forgotten.view_partner_anonymize_wizard_form +msgid "Confirm Anonymization" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model_terms:ir.ui.view,arch_db:privacy_partner_to_be_forgotten.view_partner_anonymize_wizard_form +msgid "Confirmation Required: Anonymization Process" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model:ir.model,name:privacy_partner_to_be_forgotten.model_res_partner +msgid "Contact" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model_terms:ir.ui.view,arch_db:privacy_partner_to_be_forgotten.view_partner_anonymize_wizard_form +msgid "Contact's activity history" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model:ir.model.fields,field_description:privacy_partner_to_be_forgotten.field_partner_anonymize_wizard__create_uid +msgid "Created by" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model:ir.model.fields,field_description:privacy_partner_to_be_forgotten.field_partner_anonymize_wizard__create_date +msgid "Created on" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model:ir.model.fields,field_description:privacy_partner_to_be_forgotten.field_partner_anonymize_wizard__display_name +msgid "Display Name" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model:ir.model.fields,field_description:privacy_partner_to_be_forgotten.field_partner_anonymize_wizard__id +msgid "ID" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model:ir.model.fields,field_description:privacy_partner_to_be_forgotten.field_partner_anonymize_wizard__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model:ir.model.fields,field_description:privacy_partner_to_be_forgotten.field_partner_anonymize_wizard__write_date +msgid "Last Updated on" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model_terms:ir.ui.view,arch_db:privacy_partner_to_be_forgotten.view_partner_anonymize_wizard_form +msgid "Messages and attachments in chat history" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#. odoo-python +#: code:addons/privacy_partner_to_be_forgotten/wizards/partner_anonymize_wizard.py:0 +#, python-format +msgid "No partners selected for anonymization." +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model_terms:ir.ui.view,arch_db:privacy_partner_to_be_forgotten.view_partner_anonymize_wizard_form +msgid "" +"Once the data is anonymized, it cannot be restored. Please " +"proceed carefully." +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model_terms:ir.ui.view,arch_db:privacy_partner_to_be_forgotten.view_partner_anonymize_wizard_form +msgid "" +"Once you confirm, Odoo will anonymize the selected partner(s) and all " +"related records, including:" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model:ir.model.fields,field_description:privacy_partner_to_be_forgotten.field_partner_anonymize_wizard__partner_ids +msgid "Partner" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model:ir.model,name:privacy_partner_to_be_forgotten.model_partner_anonymize_wizard +msgid "Partner Anonymization Wizard" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model_terms:ir.ui.view,arch_db:privacy_partner_to_be_forgotten.view_partner_anonymize_wizard_form +msgid "Personal information (name, email, phone, address)" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model_terms:ir.ui.view,arch_db:privacy_partner_to_be_forgotten.view_partner_anonymize_wizard_form +msgid "The anonymization operation cannot be undone." +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#. odoo-python +#: code:addons/privacy_partner_to_be_forgotten/models/res_partner.py:0 +#, python-format +msgid "This contact has been anonymized on %(date)s by %(user)s" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model_terms:ir.ui.view,arch_db:privacy_partner_to_be_forgotten.view_partner_anonymize_wizard_form +msgid "User account information (if the contact has system access)" +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model:res.groups,comment:privacy_partner_to_be_forgotten.group_partner_anonymize +msgid "" +"Users in this group can anonymize contact information for GDPR compliance." +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#: model_terms:ir.ui.view,arch_db:privacy_partner_to_be_forgotten.view_partner_anonymize_wizard_form +msgid "" +"You carry full responsibility for using the module's " +"features in compliance with GDPR regulations." +msgstr "" + +#. module: privacy_partner_to_be_forgotten +#. odoo-python +#: code:addons/privacy_partner_to_be_forgotten/wizards/partner_anonymize_wizard.py:0 +#, python-format +msgid "You don't have permission to anonymize partners." +msgstr "" From 1fc964340f4b15995536cbf51a3cbce5c8896d34 Mon Sep 17 00:00:00 2001 From: michelerusti Date: Fri, 24 Oct 2025 16:53:01 +0200 Subject: [PATCH 3/5] [IMP] partner_to_be_forgotten: pre-commit auto fixes --- privacy_partner_to_be_forgotten/README.rst | 16 +++----- .../__manifest__.py | 2 +- .../static/description/index.html | 40 ++++++++----------- .../partner_anonymize_wizard_views.xml | 22 +++++----- 4 files changed, 36 insertions(+), 44 deletions(-) diff --git a/privacy_partner_to_be_forgotten/README.rst b/privacy_partner_to_be_forgotten/README.rst index 019f7b55..e203691b 100644 --- a/privacy_partner_to_be_forgotten/README.rst +++ b/privacy_partner_to_be_forgotten/README.rst @@ -1,7 +1,3 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - ======================================= Privacy Partner's Right to be Forgotten ======================================= @@ -17,17 +13,17 @@ Privacy Partner's Right to be Forgotten .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png :target: https://odoo-community.org/page/development-status :alt: Beta -.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png +.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html :alt: License: LGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fdata--protection-lightgray.png?logo=github - :target: https://github.com/OCA/data-protection/tree/17.0/privacy_partner_to_be_forgotten + :target: https://github.com/OCA/data-protection/tree/18.0/privacy_partner_to_be_forgotten :alt: OCA/data-protection .. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png - :target: https://translation.odoo-community.org/projects/data-protection-17-0/data-protection-17-0-privacy_partner_to_be_forgotten + :target: https://translation.odoo-community.org/projects/data-protection-18-0/data-protection-18-0-privacy_partner_to_be_forgotten :alt: Translate me on Weblate .. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png - :target: https://runboat.odoo-community.org/builds?repo=OCA/data-protection&target_branch=17.0 + :target: https://runboat.odoo-community.org/builds?repo=OCA/data-protection&target_branch=18.0 :alt: Try me on Runboat |badge1| |badge2| |badge3| |badge4| |badge5| @@ -137,7 +133,7 @@ Bug Tracker Bugs are tracked on `GitHub Issues `_. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed -`feedback `_. +`feedback `_. Do not contact contributors directly about support or help with technical issues. @@ -170,6 +166,6 @@ OCA, or the Odoo Community Association, is a nonprofit organization whose mission is to support the collaborative development of Odoo features and promote its widespread use. -This module is part of the `OCA/data-protection `_ project on GitHub. +This module is part of the `OCA/data-protection `_ project on GitHub. You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/privacy_partner_to_be_forgotten/__manifest__.py b/privacy_partner_to_be_forgotten/__manifest__.py index 9a4f7506..a4629021 100644 --- a/privacy_partner_to_be_forgotten/__manifest__.py +++ b/privacy_partner_to_be_forgotten/__manifest__.py @@ -3,7 +3,7 @@ { "name": "Privacy Partner's Right to be Forgotten", - "version": "17.0.1.0.0", + "version": "18.0.1.0.0", "development_status": "Beta", "category": "Data Protection", "summary": "Anonymize partner data for GDPR compliance", diff --git a/privacy_partner_to_be_forgotten/static/description/index.html b/privacy_partner_to_be_forgotten/static/description/index.html index 7f7eace7..5e02ca40 100644 --- a/privacy_partner_to_be_forgotten/static/description/index.html +++ b/privacy_partner_to_be_forgotten/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +Privacy Partner's Right to be Forgotten -
+
+

Privacy Partner’s Right to be Forgotten

- - -Odoo Community Association - -
-

Privacy Partner’s Right to be Forgotten

-

Beta License: LGPL-3 OCA/data-protection Translate me on Weblate Try me on Runboat

+

Beta License: LGPL-3 OCA/data-protection Translate me on Weblate Try me on Runboat

The module allows anonymizing contacts that represent natural persons (i.e., individuals) when they are no longer needed or when a data erasure request is received.

@@ -432,15 +427,15 @@

Privacy Partner’s Right to be Forgotten

-

Use Cases / Context

+

Use Cases / Context

This module aims to help companies comply with the European Union General Data Protection Regulation (GDPR) by providing a mechanism to anonymize personal data in res.partner records.

-

Configuration

+

Configuration

-

To permit a user to anonymize partner records:

+

To permit a user to anonymize partner records:

  1. Go to Settings → Users & Companies → Users
  2. Select the user you want to authorize
  3. @@ -452,9 +447,9 @@

    To permit a user to anonymize par

-

Usage

+

Usage

-

To anonymize a partner record:

+

To anonymize a partner record:

NOTE: Make sure the user has permission to anonymize partner records. Please check the Configuration file for details on how to grant permission.

@@ -482,26 +477,26 @@

To anonymize a partner record:

-

Bug Tracker

+

Bug Tracker

Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed -feedback.

+feedback.

Do not contact contributors directly about support or help with technical issues.

-

Credits

+

Credits

-

Authors

+

Authors

  • Cetmix
-

Contributors

+

Contributors

-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association @@ -519,11 +514,10 @@

Maintainers

OCA, or the Odoo Community Association, is a nonprofit organization whose mission is to support the collaborative development of Odoo features and promote its widespread use.

-

This module is part of the OCA/data-protection project on GitHub.

+

This module is part of the OCA/data-protection project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

-
diff --git a/privacy_partner_to_be_forgotten/wizards/partner_anonymize_wizard_views.xml b/privacy_partner_to_be_forgotten/wizards/partner_anonymize_wizard_views.xml index f236bc58..2e8f617b 100644 --- a/privacy_partner_to_be_forgotten/wizards/partner_anonymize_wizard_views.xml +++ b/privacy_partner_to_be_forgotten/wizards/partner_anonymize_wizard_views.xml @@ -10,21 +10,23 @@

Once you confirm, Odoo will anonymize the selected partner(s) and all related records, including:

    -
  • Personal information (name, email, phone, address)
  • -
  • User account information (if the contact has system access)
  • -
  • Messages and attachments in chat history
  • -
  • Contact's activity history
  • -
+
  • Personal information (name, email, phone, address)
  • +
  • User account information (if the contact has system access)
  • +
  • Messages and attachments in chat history
  • +
  • Contact's activity history
  • + +

    +

    + Important:

    -

    Important:

    • The anonymization operation cannot be undone.
    • + >cannot be undone.
    • Once the data is anonymized, it cannot be restored. Please proceed carefully.
    • + >cannot be restored. Please proceed carefully.
    • You carry full responsibility for using the module's features in compliance with GDPR regulations.
    • + >carry full responsibility for using the module's features in compliance with GDPR regulations.
    From 490f6267739c4d18077cc7ffff68c8f67bac0284 Mon Sep 17 00:00:00 2001 From: michelerusti Date: Fri, 24 Oct 2025 17:29:37 +0200 Subject: [PATCH 4/5] [18.0][MIG] privacy_partner_to_be_forgotten --- privacy_partner_to_be_forgotten/README.rst | 8 +++- .../i18n/privacy_partner_to_be_forgotten.pot | 9 +---- .../models/res_partner.py | 3 +- .../static/description/index.html | 38 +++++++++++-------- .../tests/test_partner_anonymize.py | 3 +- .../partner_anonymize_wizard_views.xml | 4 +- 6 files changed, 35 insertions(+), 30 deletions(-) diff --git a/privacy_partner_to_be_forgotten/README.rst b/privacy_partner_to_be_forgotten/README.rst index e203691b..702efaaa 100644 --- a/privacy_partner_to_be_forgotten/README.rst +++ b/privacy_partner_to_be_forgotten/README.rst @@ -1,3 +1,7 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + ======================================= Privacy Partner's Right to be Forgotten ======================================= @@ -7,13 +11,13 @@ Privacy Partner's Right to be Forgotten !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:f374385b6ed7798c0d487c5717e02403fe415e03bc101daca7dd4056884ed3dc + !! source digest: sha256:0fbaad7301ee791d18e90adb46e9182cd132f4f46fa82f84920c4179c7e0bbef !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png :target: https://odoo-community.org/page/development-status :alt: Beta -.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png +.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html :alt: License: LGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fdata--protection-lightgray.png?logo=github diff --git a/privacy_partner_to_be_forgotten/i18n/privacy_partner_to_be_forgotten.pot b/privacy_partner_to_be_forgotten/i18n/privacy_partner_to_be_forgotten.pot index 847a5be8..fd24f903 100644 --- a/privacy_partner_to_be_forgotten/i18n/privacy_partner_to_be_forgotten.pot +++ b/privacy_partner_to_be_forgotten/i18n/privacy_partner_to_be_forgotten.pot @@ -4,7 +4,7 @@ # msgid "" msgstr "" -"Project-Id-Version: Odoo Server 17.0\n" +"Project-Id-Version: Odoo Server 18.0\n" "Report-Msgid-Bugs-To: \n" "Last-Translator: \n" "Language-Team: \n" @@ -16,21 +16,18 @@ msgstr "" #. module: privacy_partner_to_be_forgotten #. odoo-python #: code:addons/privacy_partner_to_be_forgotten/models/res_partner.py:0 -#, python-format msgid "%(company)s Anonymized" msgstr "" #. module: privacy_partner_to_be_forgotten #. odoo-python #: code:addons/privacy_partner_to_be_forgotten/models/res_partner.py:0 -#, python-format msgid "%(initials)s Anonymized" msgstr "" #. module: privacy_partner_to_be_forgotten #. odoo-python #: code:addons/privacy_partner_to_be_forgotten/wizards/partner_anonymize_wizard.py:0 -#, python-format msgid "%(partner_count)d partner(s) have been anonymized successfully." msgstr "" @@ -47,7 +44,6 @@ msgstr "" #. module: privacy_partner_to_be_forgotten #. odoo-python #: code:addons/privacy_partner_to_be_forgotten/wizards/partner_anonymize_wizard.py:0 -#, python-format msgid "Anonymization Result" msgstr "" @@ -129,7 +125,6 @@ msgstr "" #. module: privacy_partner_to_be_forgotten #. odoo-python #: code:addons/privacy_partner_to_be_forgotten/wizards/partner_anonymize_wizard.py:0 -#, python-format msgid "No partners selected for anonymization." msgstr "" @@ -170,7 +165,6 @@ msgstr "" #. module: privacy_partner_to_be_forgotten #. odoo-python #: code:addons/privacy_partner_to_be_forgotten/models/res_partner.py:0 -#, python-format msgid "This contact has been anonymized on %(date)s by %(user)s" msgstr "" @@ -195,6 +189,5 @@ msgstr "" #. module: privacy_partner_to_be_forgotten #. odoo-python #: code:addons/privacy_partner_to_be_forgotten/wizards/partner_anonymize_wizard.py:0 -#, python-format msgid "You don't have permission to anonymize partners." msgstr "" diff --git a/privacy_partner_to_be_forgotten/models/res_partner.py b/privacy_partner_to_be_forgotten/models/res_partner.py index 5fd51dd2..356bc456 100644 --- a/privacy_partner_to_be_forgotten/models/res_partner.py +++ b/privacy_partner_to_be_forgotten/models/res_partner.py @@ -204,7 +204,8 @@ def _anonymize_partner_attachments(self): (self.env.user.partner_id, "ir.attachment/delete", {"id": attachment.id}) for attachment in attachments ] - self.env["bus.bus"]._sendmany(updates) + for update in updates: + self.env["bus.bus"]._sendone(*update) attachments.unlink() def _log_anonymization(self, timestamp): diff --git a/privacy_partner_to_be_forgotten/static/description/index.html b/privacy_partner_to_be_forgotten/static/description/index.html index 5e02ca40..b7ff1dce 100644 --- a/privacy_partner_to_be_forgotten/static/description/index.html +++ b/privacy_partner_to_be_forgotten/static/description/index.html @@ -3,7 +3,7 @@ -Privacy Partner's Right to be Forgotten +README.rst -
    -

    Privacy Partner’s Right to be Forgotten

    +
    + + +Odoo Community Association + +
    +

    Privacy Partner’s Right to be Forgotten

    -

    Beta License: LGPL-3 OCA/data-protection Translate me on Weblate Try me on Runboat

    +

    Beta License: LGPL-3 OCA/data-protection Translate me on Weblate Try me on Runboat

    The module allows anonymizing contacts that represent natural persons (i.e., individuals) when they are no longer needed or when a data erasure request is received.

    @@ -427,15 +432,15 @@

    Privacy Partner’s Right to be Forgotten

    -

    Use Cases / Context

    +

    Use Cases / Context

    This module aims to help companies comply with the European Union General Data Protection Regulation (GDPR) by providing a mechanism to anonymize personal data in res.partner records.

    -

    Configuration

    +

    Configuration

    -

    To permit a user to anonymize partner records:

    +

    To permit a user to anonymize partner records:

    1. Go to Settings → Users & Companies → Users
    2. Select the user you want to authorize
    3. @@ -447,9 +452,9 @@

      To permit a user to anonymize par

    -

    Usage

    +

    Usage

    -

    To anonymize a partner record:

    +

    To anonymize a partner record:

    NOTE: Make sure the user has permission to anonymize partner records. Please check the Configuration file for details on how to grant permission.

    @@ -477,10 +482,10 @@

    To anonymize a partner record:

    -

    Bug Tracker

    +

    Bug Tracker

    Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed @@ -488,15 +493,15 @@

    Bug Tracker

    Do not contact contributors directly about support or help with technical issues.

    -

    Credits

    +

    Credits

    -

    Authors

    +

    Authors

    • Cetmix
    -

    Contributors

    +

    Contributors

    -

    Maintainers

    +

    Maintainers

    This module is maintained by the OCA.

    Odoo Community Association @@ -519,5 +524,6 @@

    Maintainers

    +
    diff --git a/privacy_partner_to_be_forgotten/tests/test_partner_anonymize.py b/privacy_partner_to_be_forgotten/tests/test_partner_anonymize.py index 814204bb..91f6471a 100644 --- a/privacy_partner_to_be_forgotten/tests/test_partner_anonymize.py +++ b/privacy_partner_to_be_forgotten/tests/test_partner_anonymize.py @@ -6,7 +6,8 @@ from odoo import fields from odoo.exceptions import AccessError -from odoo.tests.common import Form, TransactionCase, tagged +from odoo.tests import Form +from odoo.tests.common import TransactionCase, tagged @tagged("post_install", "-at_install") diff --git a/privacy_partner_to_be_forgotten/wizards/partner_anonymize_wizard_views.xml b/privacy_partner_to_be_forgotten/wizards/partner_anonymize_wizard_views.xml index 2e8f617b..bb2e5f39 100644 --- a/privacy_partner_to_be_forgotten/wizards/partner_anonymize_wizard_views.xml +++ b/privacy_partner_to_be_forgotten/wizards/partner_anonymize_wizard_views.xml @@ -30,13 +30,13 @@
    - + - +