Browse Source

privacy_consent: Privacy explicit consent tracking tools (#11)

pull/27/head
Jairo Llopis 6 years ago
committed by fkantelberg
parent
commit
aa4bcfb324
  1. 1
      privacy_consent/README.rst
  2. 3
      privacy_consent/__init__.py
  3. 29
      privacy_consent/__manifest__.py
  4. 1
      privacy_consent/controllers/__init__.py
  5. 47
      privacy_consent/controllers/main.py
  6. 23
      privacy_consent/data/ir_actions_server.xml
  7. 16
      privacy_consent/data/ir_cron.xml
  8. 155
      privacy_consent/data/mail.xml
  9. 663
      privacy_consent/i18n/es.po
  10. 5
      privacy_consent/models/__init__.py
  11. 54
      privacy_consent/models/mail_mail.py
  12. 33
      privacy_consent/models/mail_template.py
  13. 144
      privacy_consent/models/privacy_activity.py
  14. 201
      privacy_consent/models/privacy_consent.py
  15. 32
      privacy_consent/models/res_partner.py
  16. 3
      privacy_consent/readme/CONTRIBUTORS.rst
  17. 7
      privacy_consent/readme/DESCRIPTION.rst
  18. 15
      privacy_consent/readme/INSTALL.rst
  19. 69
      privacy_consent/readme/USAGE.rst
  20. 3
      privacy_consent/security/ir.model.access.csv
  21. BIN
      privacy_consent/static/description/icon.png
  22. 63
      privacy_consent/templates/form.xml
  23. 1
      privacy_consent/tests/__init__.py
  24. 247
      privacy_consent/tests/test_consent.py
  25. 89
      privacy_consent/views/privacy_activity.xml
  26. 113
      privacy_consent/views/privacy_consent.xml
  27. 35
      privacy_consent/views/res_partner.xml
  28. 1
      privacy_consent/wizards/__init__.py
  29. 16
      privacy_consent/wizards/mail_compose_message.py

1
privacy_consent/README.rst

@ -0,0 +1 @@

3
privacy_consent/__init__.py

@ -0,0 +1,3 @@
from . import controllers
from . import models
from . import wizards

29
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",
],
}

1
privacy_consent/controllers/__init__.py

@ -0,0 +1 @@
from . import main

47
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/<any(accept,reject):choice>/"
"<int:consent_id>/<token>",
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(),
)

23
privacy_consent/data/ir_actions_server.xml

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2018 Tecnativa - Jairo Llopis
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -->
<data>
<record id="update_opt_out" model="ir.actions.server">
<field name="name">Update partner's opt out</field>
<field name="model_id" ref="model_privacy_consent"/>
<field name="crud_model_id" ref="base.model_res_partner"/>
<field name="state">object_write</field>
<field name="use_write">expression</field>
<field name="write_expression">record.partner_id</field>
</record>
<record id="update_opt_out_line_1" model="ir.server.object.lines">
<field name="server_id" ref="update_opt_out"/>
<field name="col1" ref="mail.field_res_partner_opt_out"/>
<field name="type">equation</field>
<field name="value">not record.accepted</field>
</record>
</data>

16
privacy_consent/data/ir_cron.xml

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2018 Tecnativa - Jairo Llopis
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -->
<data>
<record id="cron_auto_consent" model="ir.cron">
<field name="name">Request automatic data processing consents</field>
<field name="model">privacy.activity</field>
<field name="function">_cron_new_consents</field>
<field name="interval_number">1</field>
<field name="interval_type">work_days</field>
<field name="numbercall">-1</field>
</record>
</data>

155
privacy_consent/data/mail.xml

@ -0,0 +1,155 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2018 Tecnativa - Jairo Llopis
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -->
<data>
<!-- Mail templates -->
<record id="template_consent" model="mail.template">
<field name="name">Personal data processing consent request</field>
<field name="subject">Data processing consent request for ${object.activity_id.display_name|safe}</field>
<field name="model_id" ref="model_privacy_consent"/>
<field name="use_default_to" eval="True"/>
<field name="body_html" type="xml">
<div style="background:#F3F5F6;color:#515166;padding:25px 0px;font-family:Arial,Helvetica,sans-serif;font-size:14px;">
<table style="width:600px;margin:5px auto;">
<tbody>
<tr>
<td>
<a href="/">
<img src="/logo" alt="${object.activity_id.controller_id.display_name|safe}" style="vertical-align:baseline;max-width:100px;"/>
</a>
</td>
</tr>
</tbody>
</table>
<table style="width:600px;margin:0px auto;background:white;border:1px solid #e1e1e1;">
<tbody>
<tr>
<td colspan="2" style="padding:15px 20px 0px 20px; font-size:16px;">
<p>
Hello, ${object.partner_id.name|safe}
</p>
<p>
We contacted you to ask you to give us your explicit consent to include your data in a data processing activity called
<b>${object.activity_id.display_name|safe}</b>, property of
<i>${object.activity_id.controller_id.display_name|safe}</i>
</p>
${object.description or ""}
<p>
% 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:
<b>accepted</b>
% else:
<b>rejected</b>
% endif
such data processing.
</p>
<p>
You can update your preferences below:
</p>
</td>
</tr>
<tr>
<td style="padding:15px 20px 0px 20px; font-size:16px; text-align:right;">
<a href="/privacy/consent/accept/" style="background-color: #449d44; padding: 12px; font-weight: 12px; text-decoration: none; color: #fff; border-radius: 5px; font-size:16px;">
Accept
</a>
</td>
<td style="padding:15px 20px 0px 20px; font-size:16px; text-align:left;">
<a href="/privacy/consent/reject/" style="background-color: #d9534f; padding: 12px; font-weight: 12px; text-decoration: none; color: #fff; border-radius: 5px; font-size:16px;">
Reject
</a>
</td>
</tr>
<tr>
<td colspan="2" style="padding:15px 20px 15px 20px; font-size:16px;">
<p>
If you need further information, please respond to this email and we will attend your request as soon as possible.
</p>
<p>
Thank you!
</p>
</td>
</tr>
</tbody>
</table>
<table style="width:600px;margin:0px auto;text-align:center;">
<tbody>
<tr>
<td style="padding-top:10px;font-size: 12px;">
<p>
Sent by
<a href="/" style="color:#717188;">${object.activity_id.controller_id.display_name|safe}</a>.
</p>
</td>
</tr>
</tbody>
</table>
</div>
</field>
</record>
<!-- Mail subtypes -->
<record id="mt_consent_consent_new" model="mail.message.subtype">
<field name="name">New Consent</field>
<field name="description">Privacy consent request created</field>
<field name="res_model">privacy.consent</field>
<field name="default" eval="False"/>
<field name="hidden" eval="False"/>
<field name="internal" eval="True"/>
</record>
<record id="mt_consent_acceptance_changed" model="mail.message.subtype">
<field name="name">Acceptance Changed by Subject</field>
<field name="description">Acceptance status updated by subject</field>
<field name="res_model">privacy.consent</field>
<field name="default" eval="False"/>
<field name="hidden" eval="False"/>
<field name="internal" eval="True"/>
</record>
<record id="mt_consent_state_changed" model="mail.message.subtype">
<field name="name">State Changed</field>
<field name="description">Privacy consent request state changed</field>
<field name="res_model">privacy.consent</field>
<field name="default" eval="False"/>
<field name="hidden" eval="False"/>
<field name="internal" eval="True"/>
</record>
<record id="mt_activity_consent_new" model="mail.message.subtype">
<field name="name">New Consent</field>
<field name="description">Privacy consent request created</field>
<field name="res_model">privacy.activity</field>
<field name="default" eval="True"/>
<field name="hidden" eval="False"/>
<field name="internal" eval="True"/>
<field name="parent_id" ref="mt_consent_consent_new"/>
<field name="relation_field">activity_id</field>
</record>
<record id="mt_activity_acceptance_changed" model="mail.message.subtype">
<field name="name">Acceptance Changed</field>
<field name="description">Privacy consent request acceptance status changed</field>
<field name="res_model">privacy.activity</field>
<field name="default" eval="True"/>
<field name="hidden" eval="False"/>
<field name="internal" eval="True"/>
<field name="parent_id" ref="mt_consent_acceptance_changed"/>
<field name="relation_field">activity_id</field>
</record>
<record id="mt_activity_state_changed" model="mail.message.subtype">
<field name="name">State Changed</field>
<field name="description">Privacy consent request state changed</field>
<field name="res_model">privacy.activity</field>
<field name="default" eval="False"/>
<field name="hidden" eval="False"/>
<field name="internal" eval="True"/>
<field name="parent_id" ref="mt_consent_state_changed"/>
<field name="relation_field">activity_id</field>
</record>
</data>

663
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 <yajo.sk8@gmail.com>\n"
"Language: es_ES\n"
#. module: privacy_consent
#: model:mail.template,body_html:privacy_consent.template_consent
msgid ""
"<?xml version=\"1.0\"?>\n"
"<div style=\"background:#F3F5F6;color:#515166;padding:25px 0px;font-family:"
"Arial,Helvetica,sans-serif;font-size:14px;\">\n"
" <table style=\"width:600px;margin:5px auto;\">\n"
" <tbody>\n"
" <tr>\n"
" <td>\n"
" <a href=\"/\">\n"
" <img src=\"/logo\" alt=\"${object."
"activity_id.controller_id.display_name|safe}\" style=\"vertical-align:"
"baseline;max-width:100px;\"/>\n"
" </a>\n"
" </td>\n"
" </tr>\n"
" </tbody>\n"
" </table>\n"
" <table style=\"width:600px;margin:0px auto;background:white;"
"border:1px solid #e1e1e1;\">\n"
" <tbody>\n"
" <tr>\n"
" <td colspan=\"2\" style=\"padding:15px 20px 0px "
"20px; font-size:16px;\">\n"
" <p>\n"
" Hello, ${object.partner_id.name|safe}\n"
" </p>\n"
" <p>\n"
" We contacted you to ask you to give us "
"your explicit consent to include your data in a data processing activity "
"called\n"
" <b>${object.activity_id.display_name|"
"safe}</b>, property of\n"
" <i>${object.activity_id.controller_id."
"display_name|safe}</i>\n"
" </p>\n"
" ${object.description or \"\"}\n"
" <p>\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"
" <b>accepted</b>\n"
" % else:\n"
" <b>rejected</b>\n"
" % endif\n"
" such data processing.\n"
" </p>\n"
" <p>\n"
" You can update your preferences below:\n"
" </p>\n"
" </td>\n"
" </tr>\n"
" <tr>\n"
" <td style=\"padding:15px 20px 0px 20px; font-"
"size:16px; text-align:right;\">\n"
" <a href=\"/privacy/consent/accept/\" style="
"\"background-color: #449d44; padding: 12px; font-weight: 12px; text-"
"decoration: none; color: #fff; border-radius: 5px; font-size:16px;\">\n"
" Accept\n"
" </a>\n"
" </td>\n"
" <td style=\"padding:15px 20px 0px 20px; font-"
"size:16px; text-align:left;\">\n"
" <a href=\"/privacy/consent/reject/\" style="
"\"background-color: #d9534f; padding: 12px; font-weight: 12px; text-"
"decoration: none; color: #fff; border-radius: 5px; font-size:16px;\">\n"
" Reject\n"
" </a>\n"
" </td>\n"
" </tr>\n"
" <tr>\n"
" <td colspan=\"2\" style=\"padding:15px 20px 15px "
"20px; font-size:16px;\">\n"
" <p>\n"
" If you need further information, please "
"respond to this email and we will attend your request as soon as possible.\n"
" </p>\n"
" <p>\n"
" Thank you!\n"
" </p>\n"
" </td>\n"
" </tr>\n"
" </tbody>\n"
" </table>\n"
" <table style=\"width:600px;margin:0px auto;text-align:center;"
"\">\n"
" <tbody>\n"
" <tr>\n"
" <td style=\"padding-top:10px;font-size: 12px;"
"\">\n"
" <p>\n"
" Sent by\n"
" <a href=\"/\" style=\"color:#717188;\">"
"${object.activity_id.controller_id.display_name|safe}</a>.\n"
" </p>\n"
" </td>\n"
" </tr>\n"
" </tbody>\n"
" </table>\n"
" </div>\n"
" "
msgstr ""
"<?xml version=\"1.0\"?>\n"
"<div style=\"background:#F3F5F6;color:#515166;padding:25px 0px;font-family:"
"Arial,Helvetica,sans-serif;font-size:14px;\">\n"
" <table style=\"width:600px;margin:5px auto;\">\n"
" <tbody>\n"
" <tr>\n"
" <td>\n"
" <a href=\"/\">\n"
" <img src=\"/logo\" alt=\"${object."
"activity_id.controller_id.display_name|safe}\" style=\"vertical-align:"
"baseline;max-width:100px;\"/>\n"
" </a>\n"
" </td>\n"
" </tr>\n"
" </tbody>\n"
" </table>\n"
" <table style=\"width:600px;margin:0px auto;background:white;"
"border:1px solid #e1e1e1;\">\n"
" <tbody>\n"
" <tr>\n"
" <td colspan=\"2\" style=\"padding:15px 20px 0px "
"20px; font-size:16px;\">\n"
" <p>\n"
" Hola, ${object.partner_id.name|safe}\n"
" </p>\n"
" <p>\n"
" Le hemos contactado para pedirle su "
"consentimiento explícito para incluir sus datos en una actividad de "
"tratamiento llamada\n"
" <b>${object.activity_id.display_name|"
"safe}</b>, propiedad de\n"
" <i>${object.activity_id.controller_id."
"display_name|safe}</i>\n"
" </p>\n"
" ${object.description or \"\"}\n"
" <p>\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"
" <b>ha aceptado</b>\n"
" % else:\n"
" <b>ha rechazado</b>\n"
" % endif\n"
" dicho procesamiento de datos.\n"
" </p>\n"
" <p>\n"
" Puede cambiar sus preferencias aquí "
"abajo:\n"
" </p>\n"
" </td>\n"
" </tr>\n"
" <tr>\n"
" <td style=\"padding:15px 20px 0px 20px; font-"
"size:16px; text-align:right;\">\n"
" <a href=\"/privacy/consent/accept/\" style="
"\"background-color: #449d44; padding: 12px; font-weight: 12px; text-"
"decoration: none; color: #fff; border-radius: 5px; font-size:16px;\">\n"
" Aceptar\n"
" </a>\n"
" </td>\n"
" <td style=\"padding:15px 20px 0px 20px; font-"
"size:16px; text-align:left;\">\n"
" <a href=\"/privacy/consent/reject/\" style="
"\"background-color: #d9534f; padding: 12px; font-weight: 12px; text-"
"decoration: none; color: #fff; border-radius: 5px; font-size:16px;\">\n"
" Rechazar\n"
" </a>\n"
" </td>\n"
" </tr>\n"
" <tr>\n"
" <td colspan=\"2\" style=\"padding:15px 20px 15px "
"20px; font-size:16px;\">\n"
" <p>\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"
" </p>\n"
" <p>\n"
" ¡Gracias!\n"
" </p>\n"
" </td>\n"
" </tr>\n"
" </tbody>\n"
" </table>\n"
" <table style=\"width:600px;margin:0px auto;text-align:center;"
"\">\n"
" <tbody>\n"
" <tr>\n"
" <td style=\"padding-top:10px;font-size: 12px;"
"\">\n"
" <p>\n"
" Enviado por\n"
" <a href=\"/\" style=\"color:#717188;\">"
"${object.activity_id.controller_id.display_name|safe}</a>.\n"
" </p>\n"
" </td>\n"
" </tr>\n"
" </tbody>\n"
" </table>\n"
" </div>\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 <b>accept</b> this processing of my data"
msgstr "<b>Acepto</b> este tratamiento de mis datos"
#. module: privacy_consent
#: model:ir.ui.view,arch_db:privacy_consent.form
msgid "I <b>reject</b> this processing of my data"
msgstr "<b>Rechazo</b> 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"
"<a href=\"%s\">Accept</a>\n"
"<a href=\"%s\">Reject</a>"
msgstr ""
"Faltan los marcadores de posición de los enlaces para el consentimiento. "
"Necesita al menos estos dos enlaces:\n"
"<a href=\"%s\">Aceptar</a>\n"
"<a href=\"%s\">Rechazar</a>"
#. 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,<br/>"
msgstr "Atentamente,<br/>"
#. 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 <b class=\"text-danger\">rejected</b> such processing."
msgstr "Ha <b class=\"text-danger\">rechazado</b> dicho tratamiento."
#. module: privacy_consent
#: model:ir.ui.view,arch_db:privacy_consent.form
msgid "You have <b class=\"text-success\">accepted</b> such processing."
msgstr "Ha <b class=\"text-success\">aceptado</b> dicho tratamiento."

5
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

54
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

33
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"
'<a href="%s">Accept</a>\n'
'<a href="%s">Reject</a>'
) % (
"/privacy/consent/accept/",
"/privacy/consent/reject/",
))

144
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",
}

201
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,
})

32
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"]

3
privacy_consent/readme/CONTRIBUTORS.rst

@ -0,0 +1,3 @@
* `Tecnativa <https://www.tecnativa.com>`_:
* Jairo Llopis

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

15
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

69
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.

3
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

BIN
privacy_consent/static/description/icon.png

After

Width: 128  |  Height: 128  |  Size: 9.2 KiB

63
privacy_consent/templates/form.xml

@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2018 Tecnativa - Jairo Llopis
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -->
<data>
<template id="form" name="Consent response processed">
<!-- Use web.login_layout because it gets automatically wrapped
by website layout if website is installed, and otherwise includes
all possibly needed assets -->
<t t-call="web.login_layout">
<div class="container readable">
<div class="jumbotron">
<h1>Thank you!</h1>
<p>
Hello, <b t-esc="consent.partner_id.display_name"/>
</p>
<p>
We asked you to authorize us to process your data in this data processing activity:
<b t-esc="consent.activity_id.display_name"/>
</p>
<t t-raw="consent.activity_id.description or ''"/>
<p t-if="consent.accepted">
You have <b class="text-success">accepted</b> such processing.
</p>
<p t-else="">
You have <b class="text-danger">rejected</b> such processing.
</p>
<p>
We have recorded this action on your side.
</p>
<p>
If it was a mistake, you can undo it here:
<div class="text-center">
<a
t-if="consent.accepted"
t-att-href="consent._url(False)"
class="btn btn-danger btn-lg"
>
I <b>reject</b> this processing of my data
</a>
<a
t-else=""
t-att-href="consent._url(True)"
class="btn btn-success btn-lg"
>
I <b>accept</b> this processing of my data
</a>
</div>
</p>
<p>
Thanks for your response.
</p>
<p class="text-muted">
Sincerely,<br/>
<i t-raw="consent.activity_id.controller_id.with_context(show_address=True, html_format=True).name_get()[0][1]"/>
</p>
</div>
</div>
</t>
</template>
</data>

1
privacy_consent/tests/__init__.py

@ -0,0 +1 @@
from . import test_consent

247
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 :("

89
privacy_consent/views/privacy_activity.xml

@ -0,0 +1,89 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2018 Tecnativa - Jairo Llopis
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -->
<data>
<record id="activity_form" model="ir.ui.view">
<field name="name">Add consent fields</field>
<field name="model">privacy.activity</field>
<field name="inherit_id" ref="privacy.activity_form"/>
<field name="arch" type="xml">
<div name="button_box" position="inside">
<!-- TODO Change icon to fa-handshake-o in Odoo 11 -->
<button
attrs='{"invisible": [("consent_required", "=", False)]}'
class="oe_stat_button"
context='{"search_default_activity_id": active_id}'
icon="fa-gavel"
name="%(consent_action)d"
type="action"
>
<field
name="consent_count"
widget="statinfo"
/>
</button>
</div>
<notebook name="advanced" position="inside">
<page string="Consent" name="consent">
<group>
<label for="consent_required"/>
<div>
<field name="consent_required" class="oe_inline"/>
<button
attrs='{"invisible": [("consent_required", "!=", "manual")]}'
class="btn-link"
icon="fa-user-plus"
name="action_new_consents"
type="object"
string="Generate missing draft consent requests"
/>
<button
attrs='{"invisible": [("consent_required", "!=", "auto")]}'
class="btn-link"
icon="fa-user-plus"
name="action_new_consents"
type="object"
string="Generate and send missing consent requests"
confirm="This could send many consent emails, are you sure to proceed?"
/>
</div>
</group>
<group
attrs='{"invisible": [("consent_required", "=", False)]}'
>
<group>
<field name="default_consent"/>
<field
name="server_action_id"
groups="base.group_no_one"
/>
</group>
<group>
<field
name="consent_template_default_body_html"
invisible="1"
/>
<field
name="consent_template_default_subject"
invisible="1"
/>
<field
name="consent_template_id"
attrs='{"required": [("consent_required", "=", "auto")]}'
context='{
"default_model": "privacy.consent",
"default_body_html": consent_template_default_body_html,
"default_subject": consent_template_default_subject,
}'
/>
</group>
</group>
</page>
</notebook>
</field>
</record>
</data>

113
privacy_consent/views/privacy_consent.xml

@ -0,0 +1,113 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2018 Tecnativa - Jairo Llopis
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -->
<data>
<record model="ir.ui.view" id="consent_form">
<field name="name">Privacy Consent Form</field>
<field name="model">privacy.consent</field>
<field name="arch" type="xml">
<form>
<header>
<button
type="object"
name="action_manual_ask"
class="oe_highlight"
string="Ask for consent"
/>
<field name="state" widget="statusbar"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button
class="oe_stat_button"
icon="fa-archive"
name="toggle_active"
type="object"
>
<field
name="active"
options='{"terminology": "archive"}'
widget="boolean_button"
/>
</button>
</div>
<group>
<field name="partner_id"/>
<field name="activity_id"/>
<field name="accepted"/>
<field name="last_metadata"/>
</group>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids" widget="mail_followers"/>
<field name="message_ids" widget="mail_thread"/>
</div>
</form>
</field>
</record>
<record model="ir.ui.view" id="consent_tree">
<field name="name">Privacy Consent Tree</field>
<field name="model">privacy.consent</field>
<field name="arch" type="xml">
<tree>
<field name="activity_id"/>
<field name="partner_id"/>
<field name="state"/>
<field name="accepted"/>
</tree>
</field>
</record>
<record model="ir.ui.view" id="consent_search">
<field name="name">Privacy Consent Search</field>
<field name="model">privacy.consent</field>
<field name="arch" type="xml">
<search>
<field name="activity_id"/>
<field name="partner_id"/>
<field name="state"/>
<field name="accepted"/>
<separator/>
<filter
string="Archived"
name="inactive"
domain="[('active', '=', False)]"
/>
<separator/>
<group string="Group By" name="groupby">
<filter
name="activity_id_groupby"
string="Activity"
context="{'group_by': 'activity_id'}"
/>
<filter
name="state_groupby"
string="State"
context="{'group_by': 'state'}"
/>
<filter
name="accepted_groupby"
string="Accepted"
context="{'group_by': 'accepted'}"
/>
</group>
</search>
</field>
</record>
<act_window
id="consent_action"
name="Consents"
res_model="privacy.consent"
/>
<menuitem
action="consent_action"
id="menu_privacy_consent"
parent="privacy.menu_data_protection_master_data"
/>
</data>

35
privacy_consent/views/res_partner.xml

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2018 Tecnativa - Jairo Llopis
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -->
<data>
<record id="view_partner_form" model="ir.ui.view">
<field name="name">Add consent smart button</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field
name="groups_id"
eval="[(4, ref('privacy.group_data_protection_user'))]"
/>
<field name="arch" type="xml">
<div name="button_box" position="inside">
<!-- TODO Change icon to fa-handshake-o in Odoo 11 -->
<button
attrs='{"invisible": [("privacy_consent_count", "=", 0)]}'
class="oe_stat_button"
context='{"search_default_partner_id": active_id}'
icon="fa-gavel"
name="%(consent_action)d"
type="action"
>
<field
name="privacy_consent_count"
widget="statinfo"
/>
</button>
</div>
</field>
</record>
</data>

1
privacy_consent/wizards/__init__.py

@ -0,0 +1 @@
from . import mail_compose_message

16
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)
Loading…
Cancel
Save