diff --git a/privacy_consent/README.rst b/privacy_consent/README.rst new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/privacy_consent/README.rst @@ -0,0 +1 @@ + diff --git a/privacy_consent/__init__.py b/privacy_consent/__init__.py new file mode 100644 index 0000000..ada0b87 --- /dev/null +++ b/privacy_consent/__init__.py @@ -0,0 +1,3 @@ +from . import controllers +from . import models +from . import wizards diff --git a/privacy_consent/__manifest__.py b/privacy_consent/__manifest__.py new file mode 100644 index 0000000..ee84254 --- /dev/null +++ b/privacy_consent/__manifest__.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# Copyright 2018 Tecnativa - Jairo Llopis +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Privacy - Consent", + "summary": "Allow people to explicitly accept or reject inclusion " + "in some activity, GDPR compliant", + "version": "10.0.1.0.0", + "development_status": "Production/Stable", + "category": "Privacy", + "website": "https://github.com/OCA/management-activity", + "author": "Tecnativa, Odoo Community Association (OCA)", + "license": "AGPL-3", + "application": False, + "installable": True, + "depends": [ + "privacy", + ], + "data": [ + "security/ir.model.access.csv", + "data/ir_actions_server.xml", + "data/ir_cron.xml", + "data/mail.xml", + "templates/form.xml", + "views/privacy_consent.xml", + "views/privacy_activity.xml", + "views/res_partner.xml", + ], +} diff --git a/privacy_consent/controllers/__init__.py b/privacy_consent/controllers/__init__.py new file mode 100644 index 0000000..12a7e52 --- /dev/null +++ b/privacy_consent/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/privacy_consent/controllers/main.py b/privacy_consent/controllers/main.py new file mode 100644 index 0000000..8f243d0 --- /dev/null +++ b/privacy_consent/controllers/main.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# Copyright 2018 Tecnativa - Jairo Llopis +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from datetime import datetime + +from werkzeug.exceptions import NotFound + +from odoo.http import Controller, request, route + +from odoo.addons.web.controllers.main import ensure_db + + +class ConsentController(Controller): + @route("/privacy/consent//" + "/", + type="http", auth="none", website=True) + def consent(self, choice, consent_id, token, *args, **kwargs): + """Process user's consent acceptance or rejection.""" + ensure_db() + try: + # If there's a website, we need a user to render the template + request.uid = request.website.user_id.id + except AttributeError: + # If there's no website, the default is OK + pass + consent = request.env["privacy.consent"] \ + .with_context(subject_answering=True) \ + .sudo().browse(consent_id) + if not (consent.exists() and consent._token() == token): + raise NotFound + if consent.partner_id.lang: + consent = consent.with_context(lang=consent.partner_id.lang) + request.context = consent.env.context + consent.action_answer(choice == "accept", self._metadata()) + return request.render("privacy_consent.form", { + "consent": consent, + }) + + def _metadata(self): + return (u"User agent: {}\n" + u"Remote IP: {}\n" + u"Date and time: {:%Y-%m-%d %H:%M:%S}").format( + request.httprequest.environ.get("HTTP_USER_AGENT"), + request.httprequest.environ.get("REMOTE_ADDRESS"), + datetime.now(), + ) diff --git a/privacy_consent/data/ir_actions_server.xml b/privacy_consent/data/ir_actions_server.xml new file mode 100644 index 0000000..58788e6 --- /dev/null +++ b/privacy_consent/data/ir_actions_server.xml @@ -0,0 +1,23 @@ + + + + + + + Update partner's opt out + + + object_write + expression + record.partner_id + + + + + + equation + not record.accepted + + + diff --git a/privacy_consent/data/ir_cron.xml b/privacy_consent/data/ir_cron.xml new file mode 100644 index 0000000..cc4bb38 --- /dev/null +++ b/privacy_consent/data/ir_cron.xml @@ -0,0 +1,16 @@ + + + + + + + Request automatic data processing consents + privacy.activity + _cron_new_consents + 1 + work_days + -1 + + + diff --git a/privacy_consent/data/mail.xml b/privacy_consent/data/mail.xml new file mode 100644 index 0000000..c288e93 --- /dev/null +++ b/privacy_consent/data/mail.xml @@ -0,0 +1,155 @@ + + + + + + + + Personal data processing consent request + Data processing consent request for ${object.activity_id.display_name|safe} + + + +
+ + + + + + +
+ + ${object.activity_id.controller_id.display_name|safe} + +
+ + + + + + + + + + + + + +
+

+ Hello, ${object.partner_id.name|safe} +

+

+ We contacted you to ask you to give us your explicit consent to include your data in a data processing activity called + ${object.activity_id.display_name|safe}, property of + ${object.activity_id.controller_id.display_name|safe} +

+ ${object.description or ""} +

+ % if object.state == "answered": + The last time you answered, you + % elif object.state == "sent": + If you do nothing, we will assume you have + % endif + + % if object.accepted: + accepted + % else: + rejected + % endif + such data processing. +

+

+ You can update your preferences below: +

+
+ + Accept + + + + Reject + +
+

+ If you need further information, please respond to this email and we will attend your request as soon as possible. +

+

+ Thank you! +

+
+ + + + + + +
+

+ Sent by + ${object.activity_id.controller_id.display_name|safe}. +

+
+
+
+
+ + + + New Consent + Privacy consent request created + privacy.consent + + + + + + Acceptance Changed by Subject + Acceptance status updated by subject + privacy.consent + + + + + + State Changed + Privacy consent request state changed + privacy.consent + + + + + + + New Consent + Privacy consent request created + privacy.activity + + + + + activity_id + + + Acceptance Changed + Privacy consent request acceptance status changed + privacy.activity + + + + + activity_id + + + State Changed + Privacy consent request state changed + privacy.activity + + + + + activity_id + + +
diff --git a/privacy_consent/i18n/es.po b/privacy_consent/i18n/es.po new file mode 100644 index 0000000..b830d8e --- /dev/null +++ b/privacy_consent/i18n/es.po @@ -0,0 +1,663 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * privacy_consent +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 10.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2018-07-11 08:38+0000\n" +"PO-Revision-Date: 2018-07-11 11:07+0200\n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Generator: Poedit 2.0.8\n" +"Last-Translator: Jairo Llopis \n" +"Language: es_ES\n" + +#. module: privacy_consent +#: model:mail.template,body_html:privacy_consent.template_consent +msgid "" +"\n" +"
\n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +"
\n" +" \n" +" \"${object."\n" +" \n" +"
\n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +"
\n" +"

\n" +" Hello, ${object.partner_id.name|safe}\n" +"

\n" +"

\n" +" We contacted you to ask you to give us " +"your explicit consent to include your data in a data processing activity " +"called\n" +" ${object.activity_id.display_name|" +"safe}, property of\n" +" ${object.activity_id.controller_id." +"display_name|safe}\n" +"

\n" +" ${object.description or \"\"}\n" +"

\n" +" % if object.state == \"answered\":\n" +" The last time you answered, you\n" +" % elif object.state == \"sent\":\n" +" If you do nothing, we will assume " +"you have\n" +" % endif\n" +"\n" +" % if object.accepted:\n" +" accepted\n" +" % else:\n" +" rejected\n" +" % endif\n" +" such data processing.\n" +"

\n" +"

\n" +" You can update your preferences below:\n" +"

\n" +"
\n" +" \n" +" Accept\n" +" \n" +" \n" +" \n" +" Reject\n" +" \n" +"
\n" +"

\n" +" If you need further information, please " +"respond to this email and we will attend your request as soon as possible.\n" +"

\n" +"

\n" +" Thank you!\n" +"

\n" +"
\n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +"
\n" +"

\n" +" Sent by\n" +" " +"${object.activity_id.controller_id.display_name|safe}.\n" +"

\n" +"
\n" +"
\n" +" " +msgstr "" +"\n" +"
\n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +"
\n" +" \n" +" \"${object."\n" +" \n" +"
\n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +"
\n" +"

\n" +" Hola, ${object.partner_id.name|safe}\n" +"

\n" +"

\n" +" Le hemos contactado para pedirle su " +"consentimiento explícito para incluir sus datos en una actividad de " +"tratamiento llamada\n" +" ${object.activity_id.display_name|" +"safe}, propiedad de\n" +" ${object.activity_id.controller_id." +"display_name|safe}\n" +"

\n" +" ${object.description or \"\"}\n" +"

\n" +" % if object.state == \"answered\":\n" +" Según su última respuesta,\n" +" % elif object.state == \"sent\":\n" +" Si no recibimos respuesta, " +"asumiremos que\n" +" % endif\n" +"\n" +" % if object.accepted:\n" +" ha aceptado\n" +" % else:\n" +" ha rechazado\n" +" % endif\n" +" dicho procesamiento de datos.\n" +"

\n" +"

\n" +" Puede cambiar sus preferencias aquí " +"abajo:\n" +"

\n" +"
\n" +" \n" +" Aceptar\n" +" \n" +" \n" +" \n" +" Rechazar\n" +" \n" +"
\n" +"

\n" +" Si necesita más información, por favor " +"responda a este correo electrónico y atenderemos su solicitud a la mayor " +"brevedad posible.\n" +"

\n" +"

\n" +" ¡Gracias!\n" +"

\n" +"
\n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +"
\n" +"

\n" +" Enviado por\n" +" " +"${object.activity_id.controller_id.display_name|safe}.\n" +"

\n" +"
\n" +"
\n" +" " + +#. module: privacy_consent +#: model:mail.message.subtype,name:privacy_consent.mt_activity_acceptance_changed +msgid "Acceptance Changed" +msgstr "Aceptación cambiada" + +#. module: privacy_consent +#: model:mail.message.subtype,name:privacy_consent.mt_consent_acceptance_changed +msgid "Acceptance Changed by Subject" +msgstr "Aceptación cambiada por el interesado" + +#. module: privacy_consent +#: model:mail.message.subtype,description:privacy_consent.mt_consent_acceptance_changed +msgid "Acceptance status updated by subject" +msgstr "Estado de aceptación modificado por el interesado" + +#. module: privacy_consent +#: model:ir.model.fields,field_description:privacy_consent.field_privacy_consent_accepted +#: model:ir.ui.view,arch_db:privacy_consent.consent_search +msgid "Accepted" +msgstr "Aceptado" + +#. module: privacy_consent +#: model:ir.model.fields,field_description:privacy_consent.field_privacy_activity_default_consent +msgid "Accepted by default" +msgstr "Aceptado por defecto" + +#. module: privacy_consent +#: model:ir.model.fields,field_description:privacy_consent.field_privacy_consent_active +msgid "Active" +msgstr "Activo" + +#. module: privacy_consent +#: model:ir.model.fields,field_description:privacy_consent.field_privacy_consent_activity_id +#: model:ir.ui.view,arch_db:privacy_consent.consent_search +msgid "Activity" +msgstr "Actividad" + +#. module: privacy_consent +#: selection:privacy.consent,state:0 +msgid "Answered" +msgstr "Respondido" + +#. module: privacy_consent +#: model:ir.ui.view,arch_db:privacy_consent.consent_search +msgid "Archived" +msgstr "Archivado" + +#. module: privacy_consent +#: model:ir.ui.view,arch_db:privacy_consent.consent_form +msgid "Ask for consent" +msgstr "Solicitar consentimiento" + +#. module: privacy_consent +#: model:ir.model.fields,field_description:privacy_consent.field_privacy_activity_consent_required +msgid "Ask subjects for consent" +msgstr "Solicitar consentimiento a los interesados" + +#. module: privacy_consent +#: selection:privacy.activity,consent_required:0 +msgid "Automatically" +msgstr "Automáticamente" + +#. module: privacy_consent +#: selection:privacy.consent,state:0 +msgid "Awaiting response" +msgstr "Esperando respuesta" + +#. module: privacy_consent +#: model:ir.ui.view,arch_db:privacy_consent.activity_form +msgid "Consent" +msgstr "Consentimiento" + +#. module: privacy_consent +#: model:ir.model,name:privacy_consent.model_privacy_consent +msgid "Consent of data processing" +msgstr "Consentimiento para tratamiento de datos" + +#. module: privacy_consent +#: model:ir.model.fields,field_description:privacy_consent.field_privacy_activity_consent_template_default_body_html +msgid "Consent template default body html" +msgstr "HTML por defecto para el cuerpo de la plantilla de consentimiento" + +#. module: privacy_consent +#: model:ir.model.fields,field_description:privacy_consent.field_privacy_activity_consent_template_default_subject +msgid "Consent template default subject" +msgstr "HTML por defecto para el asunto de la plantilla de consentimiento" + +#. module: privacy_consent +#: model:ir.actions.act_window,name:privacy_consent.consent_action +#: model:ir.model.fields,field_description:privacy_consent.field_privacy_activity_consent_count +#: model:ir.model.fields,field_description:privacy_consent.field_privacy_activity_consent_ids +#: model:ir.model.fields,field_description:privacy_consent.field_res_partner_privacy_consent_count +#: model:ir.model.fields,field_description:privacy_consent.field_res_users_privacy_consent_count +#: model:ir.ui.menu,name:privacy_consent.menu_privacy_consent +msgid "Consents" +msgstr "Consents" + +#. module: privacy_consent +#: model:ir.model.fields,field_description:privacy_consent.field_privacy_consent_create_uid +msgid "Created by" +msgstr "Creado por" + +#. module: privacy_consent +#: model:ir.model.fields,field_description:privacy_consent.field_privacy_consent_create_date +msgid "Created on" +msgstr "Creado el" + +#. module: privacy_consent +#: model:ir.model,name:privacy_consent.model_privacy_activity +msgid "Data processing activities" +msgstr "Actividades de tratamiento de datos" + +#. module: privacy_consent +#: model:mail.template,subject:privacy_consent.template_consent +msgid "" +"Data processing consent request for ${object.activity_id.display_name|safe}" +msgstr "" +"Solicitud de consentimiento para el tratamiento de datos personales para " +"${object.activity_id.display_name|safe}" + +#. module: privacy_consent +#: model:ir.model.fields,field_description:privacy_consent.field_privacy_consent_display_name +msgid "Display Name" +msgstr "Nombre a mostrar" + +#. module: privacy_consent +#: selection:privacy.consent,state:0 +msgid "Draft" +msgstr "Borrador" + +#. module: privacy_consent +#: sql_constraint:privacy.consent:0 +msgid "Duplicated partner in this data processing activity" +msgstr "Contacto duplicado en esta actividad de tratamiento" + +#. module: privacy_consent +#: model:ir.model,name:privacy_consent.model_mail_template +msgid "Email Templates" +msgstr "Plantillas de correo electrónico" + +#. module: privacy_consent +#: model:ir.model,name:privacy_consent.model_mail_compose_message +msgid "Email composition wizard" +msgstr "Asistente de redacción de correo electrónico" + +#. module: privacy_consent +#: model:ir.model.fields,field_description:privacy_consent.field_privacy_activity_consent_template_id +msgid "Email template" +msgstr "Plantilla de correo electrónico" + +#. module: privacy_consent +#: model:ir.model.fields,help:privacy_consent.field_privacy_activity_consent_template_id +msgid "" +"Email to be sent to subjects to ask for consent. A good template should " +"include details about the current consent request status, how to change it, " +"and where to get more information." +msgstr "" +"Correo electrónico a enviar a los interesados para solicitarles el " +"consentimiento. Una buena plantilla debería incluir detalles sobre el estado " +"actual del consentimiento, cómo cambiarlo, y dónde obtener más información." + +#. module: privacy_consent +#: model:ir.model.fields,help:privacy_consent.field_privacy_activity_consent_required +msgid "" +"Enable if you need to track any kind of consent from the affected subjects" +msgstr "" +"Actívelo si necesita registrar cualquier tipo de consentimiento de los " +"interesados." + +#. module: privacy_consent +#: model:ir.ui.view,arch_db:privacy_consent.activity_form +msgid "Generate and send missing consent requests" +msgstr "Generar y enviar solicitudes de consentimiento faltantes" + +#. module: privacy_consent +#: model:ir.ui.view,arch_db:privacy_consent.activity_form +msgid "Generate missing draft consent requests" +msgstr "Generar borradores de las solicitudes de consentimiento faltantes" + +#. module: privacy_consent +#: code:addons/privacy_consent/models/privacy_activity.py:140 +#, python-format +msgid "Generated consents" +msgstr "Consentimientos generados" + +#. module: privacy_consent +#: model:ir.ui.view,arch_db:privacy_consent.consent_search +msgid "Group By" +msgstr "Agrupar por" + +#. module: privacy_consent +#: model:ir.ui.view,arch_db:privacy_consent.form +msgid "Hello," +msgstr "Hola," + +#. module: privacy_consent +#: model:ir.ui.view,arch_db:privacy_consent.form +msgid "I accept this processing of my data" +msgstr "Acepto este tratamiento de mis datos" + +#. module: privacy_consent +#: model:ir.ui.view,arch_db:privacy_consent.form +msgid "I reject this processing of my data" +msgstr "Rechazo este tratamiento de mis datos" + +#. module: privacy_consent +#: model:ir.model.fields,field_description:privacy_consent.field_privacy_consent_id +msgid "ID" +msgstr "ID" + +#. module: privacy_consent +#: model:ir.ui.view,arch_db:privacy_consent.form +msgid "If it was a mistake, you can undo it here:" +msgstr "Si ha sido un error, puede deshacerlo aquí:" + +#. module: privacy_consent +#: model:ir.model.fields,help:privacy_consent.field_privacy_consent_accepted +msgid "" +"Indicates current acceptance status, which can come from subject's last " +"answer, or from the default specified in the related data processing " +"activity." +msgstr "" +"Indica el estado actual de la aceptación, el cual puede venir de la última " +"respuesta del interesado, o del estado por defecto especificado en la " +"actividad de tratamiento relacionada." + +#. module: privacy_consent +#: model:ir.model.fields,field_description:privacy_consent.field_privacy_consent___last_update +msgid "Last Modified on" +msgstr "Última modificación en" + +#. module: privacy_consent +#: model:ir.model.fields,field_description:privacy_consent.field_privacy_consent_write_uid +msgid "Last Updated by" +msgstr "Última actualización por" + +#. module: privacy_consent +#: model:ir.model.fields,field_description:privacy_consent.field_privacy_consent_write_date +msgid "Last Updated on" +msgstr "Última actualización el" + +#. module: privacy_consent +#: model:ir.model.fields,field_description:privacy_consent.field_privacy_consent_last_metadata +msgid "Last metadata" +msgstr "Últimos metadatos" + +#. module: privacy_consent +#: selection:privacy.activity,consent_required:0 +msgid "Manually" +msgstr "Manualmente" + +#. module: privacy_consent +#: model:ir.model.fields,help:privacy_consent.field_privacy_consent_last_metadata +msgid "Metadata from the last acceptance or rejection by the subject" +msgstr "" +"Metadatos de la última aceptación o denegación por parte del interesado" + +#. module: privacy_consent +#: code:addons/privacy_consent/models/mail_template.py:25 +#, python-format +msgid "" +"Missing privacy consent link placeholders. You need at least these two " +"links:\n" +"Accept\n" +"Reject" +msgstr "" +"Faltan los marcadores de posición de los enlaces para el consentimiento. " +"Necesita al menos estos dos enlaces:\n" +"Aceptar\n" +"Rechazar" + +#. module: privacy_consent +#: model:mail.message.subtype,name:privacy_consent.mt_activity_consent_new +#: model:mail.message.subtype,name:privacy_consent.mt_consent_consent_new +msgid "New Consent" +msgstr "Nuevo consentimiento" + +#. module: privacy_consent +#: model:ir.model,name:privacy_consent.model_mail_mail +msgid "Outgoing Mails" +msgstr "Correos electrónicos salientes" + +#. module: privacy_consent +#: model:ir.model,name:privacy_consent.model_res_partner +msgid "Partner" +msgstr "Contacto" + +#. module: privacy_consent +#: model:mail.message.subtype,description:privacy_consent.mt_activity_acceptance_changed +msgid "Privacy consent request acceptance status changed" +msgstr "" +"El estado de aceptación de la solicitud de consentimiento para el " +"tratamiento de datos ha cambiado" + +#. module: privacy_consent +#: model:mail.message.subtype,description:privacy_consent.mt_activity_consent_new +#: model:mail.message.subtype,description:privacy_consent.mt_consent_consent_new +msgid "Privacy consent request created" +msgstr "" +"La solicitud de consentimiento para el tratamiento de datos ha sido creada" + +#. module: privacy_consent +#: model:mail.message.subtype,description:privacy_consent.mt_activity_state_changed +#: model:mail.message.subtype,description:privacy_consent.mt_consent_state_changed +msgid "Privacy consent request state changed" +msgstr "" +"El estado de la solicitud de consentimiento para el tratamiento de datos ha " +"cambiado" + +#. module: privacy_consent +#: model:ir.model.fields,help:privacy_consent.field_res_partner_privacy_consent_count +#: model:ir.model.fields,help:privacy_consent.field_res_users_privacy_consent_count +msgid "Privacy consent requests amount" +msgstr "Cantidad de solicitudes de consentimiento para el tratamiento de datos" + +#. module: privacy_consent +#: model:ir.model.fields,field_description:privacy_consent.field_res_partner_privacy_consent_ids +#: model:ir.model.fields,field_description:privacy_consent.field_res_users_privacy_consent_ids +msgid "Privacy consents" +msgstr "Consentimientos para el tratamiento de datos" + +#. module: privacy_consent +#: code:addons/privacy_consent/models/privacy_activity.py:100 +#, python-format +msgid "Require consent is available only for subjects in current database." +msgstr "" +"La opción de exigir consentimiento solo está disponible para interesados que " +"se encuentren en esta misma base de datos." + +#. module: privacy_consent +#: model:ir.model.fields,help:privacy_consent.field_privacy_activity_server_action_id +msgid "" +"Run this action when a new consent request is created or its acceptance " +"status is updated." +msgstr "" +"Ejecutar esta acción cuando se cree una nueva solicitud de consentimiento, o " +"cuando su estado de aceptación cambie." + +#. module: privacy_consent +#: model:ir.model.fields,field_description:privacy_consent.field_privacy_activity_server_action_id +msgid "Server action" +msgstr "Acción de servidor" + +#. module: privacy_consent +#: model:ir.model.fields,help:privacy_consent.field_privacy_activity_default_consent +msgid "Should we assume the subject has accepted if we receive no response?" +msgstr "" +"¿Hay que asumir que el interesado ha aceptado si no recibimos respuesta?" + +#. module: privacy_consent +#: model:ir.ui.view,arch_db:privacy_consent.form +msgid "Sincerely,
" +msgstr "Atentamente,
" + +#. module: privacy_consent +#: code:addons/privacy_consent/models/privacy_activity.py:92 +#, python-format +msgid "Specify a mail template to ask automated consent." +msgstr "" +"Especifique una plantilla de correo electrónico para solicitar " +"automáticamente el consentimiento." + +#. module: privacy_consent +#: model:ir.model.fields,field_description:privacy_consent.field_privacy_consent_state +#: model:ir.ui.view,arch_db:privacy_consent.consent_search +msgid "State" +msgstr "Estado" + +#. module: privacy_consent +#: model:mail.message.subtype,name:privacy_consent.mt_activity_state_changed +#: model:mail.message.subtype,name:privacy_consent.mt_consent_state_changed +msgid "State Changed" +msgstr "El estado ha cambiado" + +#. module: privacy_consent +#: model:ir.model.fields,field_description:privacy_consent.field_privacy_consent_partner_id +msgid "Subject" +msgstr "Interesado" + +#. module: privacy_consent +#: model:ir.model.fields,help:privacy_consent.field_privacy_consent_partner_id +msgid "Subject asked for consent." +msgstr "Interesado a quien se le pide el consentimiento." + +#. module: privacy_consent +#: model:ir.ui.view,arch_db:privacy_consent.form +msgid "Thank you!" +msgstr "¡Gracias!" + +#. module: privacy_consent +#: model:ir.ui.view,arch_db:privacy_consent.form +msgid "Thanks for your response." +msgstr "Gracias por su respuesta." + +#. module: privacy_consent +#: model:ir.ui.view,arch_db:privacy_consent.activity_form +msgid "This could send many consent emails, are you sure to proceed?" +msgstr "" +"Esto podría enviar muchos correos electrónicos solicitando consentimiento " +"para el tratamiento de datos, ¿seguro que quiere continuar?" + +#. module: privacy_consent +#: model:ir.actions.server,name:privacy_consent.update_opt_out +msgid "Update partner's opt out" +msgstr "Sincronizar la opción del contacto para recibir o no envíos masivos" + +#. module: privacy_consent +#: model:ir.ui.view,arch_db:privacy_consent.form +msgid "" +"We asked you to authorize us to process your data in this data processing " +"activity:" +msgstr "" +"Le hemos solicitado que nos autorice para procesar sus datos personales en " +"esta actividad de tratamiento:" + +#. module: privacy_consent +#: model:ir.ui.view,arch_db:privacy_consent.form +msgid "We have recorded this action on your side." +msgstr "Hemos registrado esta acción por su parte." + +#. module: privacy_consent +#: model:ir.ui.view,arch_db:privacy_consent.form +msgid "You have rejected such processing." +msgstr "Ha rechazado dicho tratamiento." + +#. module: privacy_consent +#: model:ir.ui.view,arch_db:privacy_consent.form +msgid "You have accepted such processing." +msgstr "Ha aceptado dicho tratamiento." diff --git a/privacy_consent/models/__init__.py b/privacy_consent/models/__init__.py new file mode 100644 index 0000000..e776de3 --- /dev/null +++ b/privacy_consent/models/__init__.py @@ -0,0 +1,5 @@ +from . import mail_mail +from . import mail_template +from . import privacy_activity +from . import privacy_consent +from . import res_partner diff --git a/privacy_consent/models/mail_mail.py b/privacy_consent/models/mail_mail.py new file mode 100644 index 0000000..e53e7fc --- /dev/null +++ b/privacy_consent/models/mail_mail.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +# Copyright 2018 Tecnativa - Jairo Llopis +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + + +class MailMail(models.Model): + _inherit = "mail.mail" + + def _postprocess_sent_message(self, mail_sent=True): + """Write consent status after sending message.""" + if mail_sent and self.env.context.get('mark_consent_sent'): + # Get all mails sent to consents + consent_mails = self.filtered( + lambda one: one.mail_message_id.model == "privacy.consent" + ) + # Get related draft consents + consents = self.env["privacy.consent"].browse( + consent_mails.mapped("mail_message_id.res_id"), + self._prefetch + ).filtered(lambda one: one.state == "draft") + # Set as sent + consents.write({ + "state": "sent", + }) + return super(MailMail, self)._postprocess_sent_message(mail_sent) + + def send_get_mail_body(self, partner=None): + """Replace privacy consent magic links. + + This replacement is done here instead of directly writing it into + the ``mail.template`` to avoid writing the tokeinzed URL + in the mail thread for the ``privacy.consent`` record, + which would enable any reader of such thread to impersonate the + subject and choose in its behalf. + """ + result = super(MailMail, self).send_get_mail_body(partner=partner) + # Avoid polluting other model mails + if self.env.context.get("active_model") != "privacy.consent": + return result + # Tokenize consent links + consent = self.env["privacy.consent"] \ + .browse(self.mail_message_id.res_id) \ + .with_prefetch(self._prefetch) + result = result.replace( + "/privacy/consent/accept/", + consent._url(True), + ) + result = result.replace( + "/privacy/consent/reject/", + consent._url(False), + ) + return result diff --git a/privacy_consent/models/mail_template.py b/privacy_consent/models/mail_template.py new file mode 100644 index 0000000..a5fcec2 --- /dev/null +++ b/privacy_consent/models/mail_template.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Copyright 2018 Tecnativa - Jairo Llopis +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from lxml import html + +from odoo import _, api, models +from odoo.exceptions import ValidationError + + +class MailTemplate(models.Model): + _inherit = "mail.template" + + @api.constrains("body_html", "model") + def _check_consent_links_in_body_html(self): + """Body for ``privacy.consent`` templates needs placeholder links.""" + links = [u"//a[@href='/privacy/consent/{}/']".format(action) + for action in ("accept", "reject")] + for one in self: + if one.model != "privacy.consent": + continue + doc = html.document_fromstring(one.body_html) + for link in links: + if not doc.xpath(link): + raise ValidationError(_( + "Missing privacy consent link placeholders. " + "You need at least these two links:\n" + 'Accept\n' + 'Reject' + ) % ( + "/privacy/consent/accept/", + "/privacy/consent/reject/", + )) diff --git a/privacy_consent/models/privacy_activity.py b/privacy_consent/models/privacy_activity.py new file mode 100644 index 0000000..c7d20c9 --- /dev/null +++ b/privacy_consent/models/privacy_activity.py @@ -0,0 +1,144 @@ +# -*- coding: utf-8 -*- +# Copyright 2018 Tecnativa - Jairo Llopis +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError +from odoo.tools.safe_eval import safe_eval + + +class PrivacyActivity(models.Model): + _inherit = 'privacy.activity' + + server_action_id = fields.Many2one( + "ir.actions.server", + "Server action", + domain=[ + ("model_id.model", "=", "privacy.consent"), + ], + help="Run this action when a new consent request is created or its " + "acceptance status is updated.", + ) + consent_ids = fields.One2many( + "privacy.consent", + "activity_id", + "Consents", + ) + consent_count = fields.Integer( + "Consents", + compute="_compute_consent_count", + ) + consent_required = fields.Selection( + [("auto", "Automatically"), ("manual", "Manually")], + "Ask subjects for consent", + help="Enable if you need to track any kind of consent " + "from the affected subjects", + ) + consent_template_id = fields.Many2one( + "mail.template", + "Email template", + default=lambda self: self._default_consent_template_id(), + domain=[ + ("model", "=", "privacy.consent"), + ], + help="Email to be sent to subjects to ask for consent. " + "A good template should include details about the current " + "consent request status, how to change it, and where to " + "get more information.", + ) + default_consent = fields.Boolean( + "Accepted by default", + help="Should we assume the subject has accepted if we receive no " + "response?", + ) + + # Hidden helpers help user design new templates + consent_template_default_body_html = fields.Text( + compute="_compute_consent_template_defaults", + ) + consent_template_default_subject = fields.Char( + compute="_compute_consent_template_defaults", + ) + + @api.model + def _default_consent_template_id(self): + return self.env.ref("privacy_consent.template_consent", False) + + @api.depends("consent_ids") + def _compute_consent_count(self): + groups = self.env["privacy.consent"].read_group( + [("activity_id", "in", self.ids)], + ["activity_id"], + ["activity_id"], + ) + for group in groups: + self.browse(group["activity_id"][0], self._prefetch) \ + .consent_count = group["activity_id_count"] + + def _compute_consent_template_defaults(self): + """Used in context values, to help users design new templates.""" + template = self._default_consent_template_id() + if template: + self.update({ + "consent_template_default_body_html": template.body_html, + "consent_template_default_subject": template.subject, + }) + + @api.constrains("consent_required", "consent_template_id") + def _check_auto_consent_has_template(self): + """Require a mail template to automate consent requests.""" + for one in self: + if one.consent_required == "auto" and not one.consent_template_id: + raise ValidationError(_( + "Specify a mail template to ask automated consent." + )) + + @api.constrains("consent_required", "subject_find") + def _check_consent_required_subject_find(self): + for one in self: + if one.consent_required and not one.subject_find: + raise ValidationError(_( + "Require consent is available only for subjects " + "in current database." + )) + + @api.model + def _cron_new_consents(self): + """Ask all missing automatic consent requests.""" + automatic = self.search([("consent_required", "=", "auto")]) + automatic.action_new_consents() + + @api.onchange("consent_required") + def _onchange_consent_required_subject_find(self): + """Find subjects automatically if we require their consent.""" + if self.consent_required: + self.subject_find = True + + def action_new_consents(self): + """Generate new consent requests.""" + consents = self.env["privacy.consent"] + # Skip activitys where consent is not required + for one in self.with_context(active_test=False) \ + .filtered("consent_required"): + domain = safe_eval(one.subject_domain) + domain += [ + ("id", "not in", one.mapped("consent_ids.partner_id").ids), + ("email", "!=", False), + ] + # Create missing consent requests + for missing in self.env["res.partner"].search(domain): + consents |= consents.create({ + "partner_id": missing.id, + "accepted": one.default_consent, + "activity_id": one.id, + }) + # Send consent request emails for automatic activitys + consents.action_auto_ask() + # Redirect user to new consent requests generated + return { + "domain": [("id", "in", consents.ids)], + "name": _("Generated consents"), + "res_model": consents._name, + "type": "ir.actions.act_window", + "view_mode": "tree,form", + } diff --git a/privacy_consent/models/privacy_consent.py b/privacy_consent/models/privacy_consent.py new file mode 100644 index 0000000..6dacd97 --- /dev/null +++ b/privacy_consent/models/privacy_consent.py @@ -0,0 +1,201 @@ +# -*- coding: utf-8 -*- +# Copyright 2018 Tecnativa - Jairo Llopis +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import hashlib +import hmac + +from odoo import api, fields, models + + +class PrivacyConsent(models.Model): + _name = 'privacy.consent' + _description = "Consent of data processing" + _inherit = "mail.thread" + _rec_name = "partner_id" + _sql_constraints = [ + ("unique_partner_activity", "UNIQUE(partner_id, activity_id)", + "Duplicated partner in this data processing activity"), + ] + + active = fields.Boolean( + default=True, + index=True, + ) + accepted = fields.Boolean( + track_visibility="onchange", + help="Indicates current acceptance status, which can come from " + "subject's last answer, or from the default specified in the " + "related data processing activity.", + ) + last_metadata = fields.Text( + readonly=True, + track_visibility="onchange", + help="Metadata from the last acceptance or rejection by the subject", + ) + partner_id = fields.Many2one( + "res.partner", + "Subject", + required=True, + readonly=True, + track_visibility="onchange", + help="Subject asked for consent.", + ) + activity_id = fields.Many2one( + "privacy.activity", + "Activity", + readonly=True, + required=True, + track_visibility="onchange", + ) + state = fields.Selection( + selection=[ + ("draft", "Draft"), + ("sent", "Awaiting response"), + ("answered", "Answered"), + ], + default="draft", + readonly=True, + required=True, + track_visibility="onchange", + ) + + def _track_subtype(self, init_values): + """Return specific subtypes.""" + if self.env.context.get("subject_answering"): + return "privacy_consent.mt_consent_acceptance_changed" + if "activity_id" in init_values or "partner_id" in init_values: + return "privacy_consent.mt_consent_consent_new" + if "state" in init_values: + return "privacy_consent.mt_consent_state_changed" + return super(PrivacyConsent, self)._track_subtype(init_values) + + def _token(self): + """Secret token to publicly authenticate this record.""" + secret = self.env["ir.config_parameter"].sudo().get_param( + "database.secret") + params = "{}-{}-{}-{}".format( + self.env.cr.dbname, + self.id, + self.partner_id.id, + self.activity_id.id, + ) + return hmac.new( + secret.encode('utf-8'), + params.encode('utf-8'), + hashlib.sha512, + ).hexdigest() + + def _url(self, accept): + """Tokenized URL to let subject decide consent. + + :param bool accept: + Indicates if you want the acceptance URL, or the rejection one. + """ + return "/privacy/consent/{}/{}/{}?db={}".format( + "accept" if accept else "reject", + self.id, + self._token(), + self.env.cr.dbname, + ) + + def _send_consent_notification(self): + """Send email notification to subject.""" + consents_by_template = {} + for one in self.with_context(tpl_force_default_to=True, + mail_notify_user_signature=False, + mail_auto_subscribe_no_notify=True, + mark_consent_sent=True): + # Group consents by template, to send in batch where possible + template_id = one.activity_id.consent_template_id.id + consents_by_template.setdefault(template_id, one) + consents_by_template[template_id] |= one + # Send emails + for template_id, consents in consents_by_template.items(): + consents.message_post_with_template( + template_id, + # This mode always sends email, regardless of partner's + # notification preferences; we use it here because it's very + # likely that we are asking authorisation to send emails + composition_mode="mass_mail", + ) + + def _run_action(self): + """Execute server action defined in data processing activity.""" + for one in self: + # Always skip draft consents + if one.state == "draft": + continue + action = one.activity_id.server_action_id.with_context( + active_id=one.id, + active_ids=one.ids, + active_model=one._name, + ) + action.run() + + @api.model + def create(self, vals): + """Run server action on create.""" + result = super(PrivacyConsent, self).create(vals) + # Sync the default acceptance status + result.sudo()._run_action() + return result + + def write(self, vals): + """Run server action on update.""" + result = super(PrivacyConsent, self).write(vals) + self._run_action() + return result + + def message_get_suggested_recipients(self): + result = super(PrivacyConsent, self) \ + .message_get_suggested_recipients() + reason = self._fields["partner_id"].string + for one in self: + one._message_add_suggested_recipient( + result, + partner=one.partner_id, + reason=reason, + ) + return result + + def action_manual_ask(self): + """Let user manually ask for consent.""" + return { + "context": { + "default_composition_mode": "mass_mail", + "default_model": self._name, + "default_res_id": self.id, + "default_template_id": self.activity_id.consent_template_id.id, + "default_use_template": True, + "mark_consent_sent": True, + "tpl_force_default_to": True, + }, + "force_email": True, + "res_model": "mail.compose.message", + "target": "new", + "type": "ir.actions.act_window", + "view_mode": "form", + } + + def action_auto_ask(self): + """Automatically ask for consent.""" + templated = self.filtered("activity_id.consent_template_id") + automated = templated.filtered( + lambda one: one.activity_id.consent_required == "auto") + automated._send_consent_notification() + + def action_answer(self, answer, metadata=False): + """Process answer. + + :param bool answer: + Did the subject accept? + + :param str metadata: + Metadata from last user acceptance or rejection request. + """ + self.write({ + "state": "answered", + "accepted": answer, + "last_metadata": metadata, + }) diff --git a/privacy_consent/models/res_partner.py b/privacy_consent/models/res_partner.py new file mode 100644 index 0000000..b97eab5 --- /dev/null +++ b/privacy_consent/models/res_partner.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Copyright 2018 Tecnativa - Jairo Llopis +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class ResPartner(models.Model): + _inherit = "res.partner" + + privacy_consent_ids = fields.One2many( + "privacy.consent", + "partner_id", + "Privacy consents", + ) + privacy_consent_count = fields.Integer( + "Consents", + compute="_compute_privacy_consent_count", + help="Privacy consent requests amount", + ) + + @api.depends("privacy_consent_ids") + def _compute_privacy_consent_count(self): + """Count consent requests.""" + groups = self.env["privacy.consent"].read_group( + [("partner_id", "in", self.ids)], + ["partner_id"], + ["partner_id"], + ) + for group in groups: + self.browse(group["partner_id"][0], self._prefetch) \ + .privacy_consent_count = group["partner_id_count"] diff --git a/privacy_consent/readme/CONTRIBUTORS.rst b/privacy_consent/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000..8c4d968 --- /dev/null +++ b/privacy_consent/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* `Tecnativa `_: + + * Jairo Llopis diff --git a/privacy_consent/readme/DESCRIPTION.rst b/privacy_consent/readme/DESCRIPTION.rst new file mode 100644 index 0000000..dfce06f --- /dev/null +++ b/privacy_consent/readme/DESCRIPTION.rst @@ -0,0 +1,7 @@ +This module allows the user to define a set of subjects (partners) +affected by any data processing activity, and establish +a process to ask them for consent to include them in that activity. + +For those that need explicit consent as a lawfulness base for personal data +processing, as required by GDPR (article 6.1.a), this module provides the +needed tools to automate it. diff --git a/privacy_consent/readme/INSTALL.rst b/privacy_consent/readme/INSTALL.rst new file mode 100644 index 0000000..dabd03d --- /dev/null +++ b/privacy_consent/readme/INSTALL.rst @@ -0,0 +1,15 @@ +You may want to install, along with this module, one of OCA's +``mail_tracking`` module collection, such as ``mail_tracking_mailgun``, so +you can provide more undeniable proof that some consent request was sent, and +to whom. + +However, the most important proof to provide is the answer itself (more than +the question), and this addon provides enough tooling for that. + +Multi-database instances +~~~~~~~~~~~~~~~~~~~~~~~~ + +To enable multi-database support, you must load this addon as a server-wide +addon. Example command to boot Odoo:: + + odoo-bin --load=web,privacy_consent diff --git a/privacy_consent/readme/USAGE.rst b/privacy_consent/readme/USAGE.rst new file mode 100644 index 0000000..68d4aec --- /dev/null +++ b/privacy_consent/readme/USAGE.rst @@ -0,0 +1,69 @@ +New options for data processing activities: + +#. Go to *Privacy > Master Data > Activities* and create one. + +#. Give it a name, such as *Sending mass mailings to customers*. + +#. Go to tab *Consent* and choose one option in *Ask subjects for consent*: + + * *Manual* tells the activity that you will want to create and send the + consent requests manually, and only provides some helpers for you to + be able to batch-generate them. + + * *Automatic* enables this module's full power: send all consent requests + to selected partners automatically, every day and under your demand. + +#. When you do this, all the consent-related options appear. Configure them: + + * A smart button tells you how many consents have been generated, and lets you + access them. + + * Choose one *Email template* to send to subjects. This email itself is what + asks for consent, and it gets recorded, to serve as a proof that it was sent. + The module provides a default template that should be good for most usage + cases; and if you create one directly from that field, some good defaults + are provided for your comfortability. + + * *Subjects filter* defines what partners will be elegible for inclusion in + this data processing activity. + + * You can enable *Accepted by default* if you want to assume subjects + accepted their data processing. You should possibly consult your + lawyer to use this. + + * You can choose a *Server action* (developer mode only) that will + be executed whenever a new non-draft consent request is created, + or when its acceptance status changes. + + This module supplies a server action by default, called + *Update partner's opt out*, that syncs the acceptance status with the + partner's *Elegible for mass mailings* option. + +#. Click on *Generate consent requests* link to create new consent requests. + + * If you chose *Manual* mode, all missing consent request are created as + drafts, and nothing else is done now. + + * If you chose *Automatic* mode, also those requests are sent to subjects + and set as *Sent*. + +#. You will be presented with the list of just-created consent requests. + See below. + +New options for consent requests: + +#. Access the consent requests by either: + + * Generating new consent requests from a data processing activity. + + * Pressing the *Consents* smart button in a data processing activity. + + * Going to *Privacy > Privacy > Consents*. + +#. A consent will include the partner, the activity, the acceptance status, + and the request state. + +#. You can manually ask for consent by pressing the button labeled as + *Ask for consent*. + +#. All consent requests and responses are recorded in the mail thread below. diff --git a/privacy_consent/security/ir.model.access.csv b/privacy_consent/security/ir.model.access.csv new file mode 100644 index 0000000..28285e4 --- /dev/null +++ b/privacy_consent/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +privacy_consent_read,Permission to read consents,model_privacy_consent,privacy.group_data_protection_user,1,0,0,0 +privacy_consent_write,Permission to write consents,model_privacy_consent,privacy.group_data_protection_manager,1,1,1,1 diff --git a/privacy_consent/static/description/icon.png b/privacy_consent/static/description/icon.png new file mode 100644 index 0000000..3a0328b Binary files /dev/null and b/privacy_consent/static/description/icon.png differ diff --git a/privacy_consent/templates/form.xml b/privacy_consent/templates/form.xml new file mode 100644 index 0000000..b0e260b --- /dev/null +++ b/privacy_consent/templates/form.xml @@ -0,0 +1,63 @@ + + + + + + + + diff --git a/privacy_consent/tests/__init__.py b/privacy_consent/tests/__init__.py new file mode 100644 index 0000000..42ba786 --- /dev/null +++ b/privacy_consent/tests/__init__.py @@ -0,0 +1 @@ +from . import test_consent diff --git a/privacy_consent/tests/test_consent.py b/privacy_consent/tests/test_consent.py new file mode 100644 index 0000000..7af0a90 --- /dev/null +++ b/privacy_consent/tests/test_consent.py @@ -0,0 +1,247 @@ +# -*- coding: utf-8 -*- +# Copyright 2018 Tecnativa - Jairo Llopis +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from contextlib import contextmanager + +from odoo.exceptions import ValidationError +from odoo.tests.common import at_install, post_install, HttpCase + + +@at_install(False) +@post_install(True) +class ActivityCase(HttpCase): + def setUp(self): + super(ActivityCase, self).setUp() + # HACK https://github.com/odoo/odoo/issues/12237 + # TODO Remove hack in v12 + self._oldenv = self.env + self.env = self._oldenv(self.cursor()) + # HACK end + self.cron = self.env.ref("privacy_consent.cron_auto_consent") + self.update_opt_out = self.env.ref("privacy_consent.update_opt_out") + self.mt_consent_consent_new = self.env.ref( + "privacy_consent.mt_consent_consent_new") + self.mt_consent_acceptance_changed = self.env.ref( + "privacy_consent.mt_consent_acceptance_changed") + self.mt_consent_state_changed = self.env.ref( + "privacy_consent.mt_consent_state_changed") + # Some partners to ask for consent + self.partners = self.env["res.partner"] + self.partners += self.partners.create({ + "name": "consent-partner-0", + "email": "partner0@example.com", + "notify_email": "none", + "opt_out": False, + }) + self.partners += self.partners.create({ + "name": "consent-partner-1", + "email": "partner1@example.com", + "notify_email": "always", + "opt_out": True, + }) + self.partners += self.partners.create({ + "name": "consent-partner-2", + "email": "partner2@example.com", + "opt_out": False, + }) + # Partner without email, on purpose + self.partners += self.partners.create({ + "name": "consent-partner-3", + "opt_out": True, + }) + # Activity without consent + self.activity_noconsent = self.env["privacy.activity"].create({ + "name": "activity_noconsent", + "description": "I'm activity 1", + }) + # Activity with auto consent, for all partners + self.activity_auto = self.env["privacy.activity"].create({ + "name": "activity_auto", + "description": "I'm activity auto", + "subject_find": True, + "subject_domain": repr([("id", "in", self.partners.ids)]), + "consent_required": "auto", + "default_consent": True, + "server_action_id": self.update_opt_out.id, + }) + # Activity with manual consent, skipping partner 0 + self.activity_manual = self.env["privacy.activity"].create({ + "name": "activity_manual", + "description": "I'm activity 3", + "subject_find": True, + "subject_domain": repr([("id", "in", self.partners[1:].ids)]), + "consent_required": "manual", + "default_consent": False, + "server_action_id": self.update_opt_out.id, + }) + + # HACK https://github.com/odoo/odoo/issues/12237 + # TODO Remove hack in v12 + def tearDown(self): + self.env = self._oldenv + super(ActivityCase, self).tearDown() + + # HACK https://github.com/odoo/odoo/issues/12237 + # TODO Remove hack in v12 + @contextmanager + def release_cr(self): + self.env.cr.release() + yield + self.env.cr.acquire() + + def check_activity_auto_properly_sent(self): + """Check emails sent by ``self.activity_auto``.""" + consents = self.env["privacy.consent"].search([ + ("activity_id", "=", self.activity_auto.id), + ]) + # Check sent mails + for consent in consents: + self.assertEqual(consent.state, "sent") + messages = consent.mapped("message_ids") + self.assertEqual(len(messages), 4) + # 2nd message notifies creation + self.assertEqual( + messages[2].subtype_id, + self.mt_consent_consent_new, + ) + # 3rd message notifies subject + # Placeholder links should be logged + self.assertTrue("/privacy/consent/accept/" in messages[1].body) + self.assertTrue("/privacy/consent/reject/" in messages[1].body) + # Tokenized links shouldn't be logged + self.assertFalse(consent._url(True) in messages[1].body) + self.assertFalse(consent._url(False) in messages[1].body) + # 4th message contains the state change + self.assertEqual( + messages[0].subtype_id, + self.mt_consent_state_changed, + ) + # Partner's opt_out should be synced with default consent + self.assertFalse(consent.partner_id.opt_out) + + def test_default_template(self): + """We have a good mail template by default.""" + good = self.env.ref("privacy_consent.template_consent") + self.assertEqual( + self.activity_noconsent.consent_template_id, + good, + ) + self.assertEqual( + self.activity_noconsent.consent_template_default_body_html, + good.body_html, + ) + self.assertEqual( + self.activity_noconsent.consent_template_default_subject, + good.subject, + ) + + def test_find_subject_if_consent_required(self): + """If user wants to require consent, it needs subjects.""" + # Test the onchange helper + onchange_activity1 = self.env["privacy.activity"].new( + self.activity_noconsent.copy_data()[0]) + self.assertFalse(onchange_activity1.subject_find) + onchange_activity1.consent_required = "auto" + onchange_activity1._onchange_consent_required_subject_find() + self.assertTrue(onchange_activity1.subject_find) + # Test very dumb user that forces an error + with self.assertRaises(ValidationError): + self.activity_noconsent.consent_required = "manual" + + def test_template_required_auto(self): + """Automatic consent activities need a template.""" + self.activity_noconsent.subject_find = True + self.activity_noconsent.consent_template_id = False + self.activity_noconsent.consent_required = "manual" + with self.assertRaises(ValidationError): + self.activity_noconsent.consent_required = "auto" + + def test_generate_manually(self): + """Manually-generated consents work as expected.""" + self.partners.write({"opt_out": False}) + result = self.activity_manual.action_new_consents() + self.assertEqual(result["res_model"], "privacy.consent") + consents = self.env[result["res_model"]].search(result["domain"]) + self.assertEqual(consents.mapped("state"), ["draft"] * 2) + self.assertEqual(consents.mapped("partner_id.opt_out"), [False] * 2) + self.assertEqual(consents.mapped("accepted"), [False] * 2) + self.assertEqual(consents.mapped("last_metadata"), [False] * 2) + # Check sent mails + messages = consents.mapped("message_ids") + self.assertEqual(len(messages), 4) + subtypes = messages.mapped("subtype_id") + self.assertTrue(subtypes & self.mt_consent_consent_new) + self.assertFalse(subtypes & self.mt_consent_acceptance_changed) + self.assertFalse(subtypes & self.mt_consent_state_changed) + # Send one manual request + action = consents[0].action_manual_ask() + self.assertEqual(action["res_model"], "mail.compose.message") + composer = self.env[action["res_model"]] \ + .with_context(active_ids=consents[0].ids, + active_model=consents._name, + **action["context"]).create({}) + composer.onchange_template_id_wrapper() + composer.send_mail() + messages = consents.mapped("message_ids") - messages + self.assertEqual(len(messages), 2) + self.assertEqual(messages[0].subtype_id, self.mt_consent_state_changed) + self.assertEqual(consents.mapped("state"), ["sent", "draft"]) + self.assertEqual(consents.mapped("partner_id.opt_out"), [True, False]) + # Placeholder links should be logged + self.assertTrue("/privacy/consent/accept/" in messages[1].body) + self.assertTrue("/privacy/consent/reject/" in messages[1].body) + # Tokenized links shouldn't be logged + accept_url = consents[0]._url(True) + reject_url = consents[0]._url(False) + self.assertNotIn(accept_url, messages[1].body) + self.assertNotIn(reject_url, messages[1].body) + # Visit tokenized accept URL + with self.release_cr(): + result = self.url_open(accept_url).read() + self.assertIn("accepted", result) + self.assertIn(reject_url, result) + self.assertIn(self.activity_manual.name, result) + self.assertIn(self.activity_manual.description, result) + consents.invalidate_cache() + self.assertEqual(consents.mapped("accepted"), [True, False]) + self.assertTrue(consents[0].last_metadata) + self.assertFalse(consents[0].partner_id.opt_out) + self.assertEqual(consents.mapped("state"), ["answered", "draft"]) + self.assertEqual( + consents[0].message_ids[0].subtype_id, + self.mt_consent_acceptance_changed, + ) + # Visit tokenized reject URL + with self.release_cr(): + result = self.url_open(reject_url).read() + self.assertIn("rejected", result) + self.assertIn(accept_url, result) + self.assertIn(self.activity_manual.name, result) + self.assertIn(self.activity_manual.description, result) + consents.invalidate_cache() + self.assertEqual(consents.mapped("accepted"), [False, False]) + self.assertTrue(consents[0].last_metadata) + self.assertTrue(consents[0].partner_id.opt_out) + self.assertEqual(consents.mapped("state"), ["answered", "draft"]) + self.assertEqual( + consents[0].message_ids[0].subtype_id, + self.mt_consent_acceptance_changed, + ) + self.assertFalse(consents[1].last_metadata) + + def test_generate_automatically(self): + """Automatically-generated consents work as expected.""" + result = self.activity_auto.action_new_consents() + self.assertEqual(result["res_model"], "privacy.consent") + self.check_activity_auto_properly_sent() + + def test_generate_cron(self): + """Cron-generated consents work as expected.""" + self.cron.method_direct_trigger() + self.check_activity_auto_properly_sent() + + def test_mail_template_without_links(self): + """Cannot create mail template without needed links.""" + with self.assertRaises(ValidationError): + self.activity_manual.consent_template_id.body_html = "No links :(" diff --git a/privacy_consent/views/privacy_activity.xml b/privacy_consent/views/privacy_activity.xml new file mode 100644 index 0000000..35c9e5f --- /dev/null +++ b/privacy_consent/views/privacy_activity.xml @@ -0,0 +1,89 @@ + + + + + + + Add consent fields + privacy.activity + + +
+ + +
+ + + + + + + + + + + + + + + + + + +
+
+ +
diff --git a/privacy_consent/views/privacy_consent.xml b/privacy_consent/views/privacy_consent.xml new file mode 100644 index 0000000..5526a03 --- /dev/null +++ b/privacy_consent/views/privacy_consent.xml @@ -0,0 +1,113 @@ + + + + + + + Privacy Consent Form + privacy.consent + +
+
+
+ +
+ +
+ + + + + + +
+
+ + +
+
+
+
+ + + Privacy Consent Tree + privacy.consent + + + + + + + + + + + + Privacy Consent Search + privacy.consent + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/privacy_consent/views/res_partner.xml b/privacy_consent/views/res_partner.xml new file mode 100644 index 0000000..d1173d9 --- /dev/null +++ b/privacy_consent/views/res_partner.xml @@ -0,0 +1,35 @@ + + + + + + + Add consent smart button + res.partner + + + +
+ + +
+
+
+ +
diff --git a/privacy_consent/wizards/__init__.py b/privacy_consent/wizards/__init__.py new file mode 100644 index 0000000..b528d99 --- /dev/null +++ b/privacy_consent/wizards/__init__.py @@ -0,0 +1 @@ +from . import mail_compose_message diff --git a/privacy_consent/wizards/mail_compose_message.py b/privacy_consent/wizards/mail_compose_message.py new file mode 100644 index 0000000..a63ecc1 --- /dev/null +++ b/privacy_consent/wizards/mail_compose_message.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# Copyright 2018 Tecnativa - Jairo Llopis +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, models + + +class MailComposeMessage(models.TransientModel): + _inherit = "mail.compose.message" + + @api.multi + def send_mail(self, auto_commit=False): + """Force auto commit when sending consent emails.""" + if self.env.context.get('mark_consent_sent'): + auto_commit = True + return super(MailComposeMessage, self).send_mail(auto_commit)