Browse Source

[ADD] privacy_consent: Privacy explicit consent tracking tools

pull/11/head
Jairo Llopis 6 years ago
parent
commit
9e80ce2bae
  1. 1
      privacy/__init__.py
  2. 11
      privacy/__manifest__.py
  3. 10
      privacy/demo/res_users.xml
  4. 1
      privacy/models/__init__.py
  5. 53
      privacy/models/privacy_activity.py
  6. 2
      privacy/readme/CONFIGURATION.rst
  7. 8
      privacy/readme/USAGE.rst
  8. 4
      privacy/security/data_protection.xml
  9. 3
      privacy/security/ir.model.access.csv
  10. 6
      privacy/views/data_protection_menu_view.xml
  11. 127
      privacy/views/privacy_activity_view.xml
  12. 1
      privacy_consent/README.rst
  13. 3
      privacy_consent/__init__.py
  14. 28
      privacy_consent/__manifest__.py
  15. 1
      privacy_consent/controllers/__init__.py
  16. 42
      privacy_consent/controllers/main.py
  17. 23
      privacy_consent/data/ir_actions_server.xml
  18. 16
      privacy_consent/data/ir_cron.xml
  19. 152
      privacy_consent/data/mail.xml
  20. 5
      privacy_consent/models/__init__.py
  21. 36
      privacy_consent/models/mail_mail.py
  22. 30
      privacy_consent/models/mail_template.py
  23. 137
      privacy_consent/models/privacy_activity.py
  24. 207
      privacy_consent/models/privacy_consent.py
  25. 32
      privacy_consent/models/res_partner.py
  26. 3
      privacy_consent/readme/CONTRIBUTORS.rst
  27. 3
      privacy_consent/readme/DESCRIPTION.rst
  28. 15
      privacy_consent/readme/INSTALL.rst
  29. 69
      privacy_consent/readme/USAGE.rst
  30. 3
      privacy_consent/security/ir.model.access.csv
  31. BIN
      privacy_consent/static/description/icon.png
  32. 61
      privacy_consent/templates/form.xml
  33. 88
      privacy_consent/views/privacy_activity.xml
  34. 113
      privacy_consent/views/privacy_consent.xml
  35. 30
      privacy_consent/views/res_partner.xml
  36. 1
      privacy_consent/wizards/__init__.py
  37. 25
      privacy_consent/wizards/mail_compose_message.py

1
privacy/__init__.py

@ -1 +1,2 @@
# -*- coding: utf-8 -*-
from . import models

11
privacy/__manifest__.py

@ -3,17 +3,26 @@
# License AGPL-3 - See http://www.gnu.org/licenses/agpl-3.0.html
{
'name': 'Data Privacy and Protection',
'version': '10.0.1.0.0',
'version': '10.0.2.0.0',
'category': 'Data Protection',
'summary': 'Provides data privacy and protection features '
'to comply to regulations, such as GDPR.',
'author': "Eficent, "
"Tecnativa, "
"Odoo Community Association (OCA)",
'website': 'http://www.github.com/OCA/data-protection',
'license': 'AGPL-3',
'data': [
'security/data_protection.xml',
'security/ir.model.access.csv',
'views/data_protection_menu_view.xml',
'views/privacy_activity_view.xml',
],
'demo': [
'demo/res_users.xml',
],
'depends': [
'mail',
],
'installable': True,
'application': True,

10
privacy/demo/res_users.xml

@ -0,0 +1,10 @@
<?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="base.user_demo" model="res.users">
<field name="groups_id" eval="[(4, ref('group_data_protection_user'))]"/>
</record>
</data>

1
privacy/models/__init__.py

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

53
privacy/models/privacy_activity.py

@ -0,0 +1,53 @@
# -*- 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 PrivacyActivity(models.Model):
_name = "privacy.activity"
_description = "Data processing activities"
_inherit = "mail.thread"
active = fields.Boolean(
default=True,
index=True,
)
name = fields.Char(
index=True,
required=True,
translate=True,
)
description = fields.Html(
translate=True,
help="How is personal data used here? Why? Etc."
)
controller_id = fields.Many2one(
"res.partner",
string="Controller",
required=True,
default=lambda self: self._default_controller_id(),
help="Whoever determines the purposes and means of the processing "
"of personal data.",
)
processor_ids = fields.Many2many(
"res.partner",
"privacy_activity_res_partner_processor_ids",
string="Processors",
help="Whoever processes personal data on behalf of the controller.",
)
subjects_find = fields.Boolean(
"Define subjects",
help="Are affected subjects present in this database?",
)
subjects_domain = fields.Char(
"Subjects filter",
default="[]",
help="Selection filter to find specific subjects included.",
)
@api.model
def _default_controller_id(self):
"""By default it should be the current user's company."""
return self.env.user.company_id

2
privacy/readme/CONFIGURATION.rst

@ -0,0 +1,2 @@
In the "Privacy", open the "Settings" menu to find and enable
the main features available.

8
privacy/readme/USAGE.rst

@ -1,2 +1,6 @@
In the "Data Protection", open the "Settings" menu to find and enable
the main features available.
To define data processing activities:
#. Go to *Privacy > Master Data > Activities* and create one.
#. Define the data processing activity using the provided tools.
Consult your lawyer!

4
privacy/security/data_protection.xml

@ -21,5 +21,9 @@
<field name="category_id" ref="module_category_data_protection"/>
</record>
<record id="base.user_root" model="res.users">
<field name="groups_id" eval="[(4, ref('group_data_protection_manager'))]"/>
</record>
</data>
</odoo>

3
privacy/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
read,Permission to read activities,model_privacy_activity,group_data_protection_user,1,0,0,0
write,Permission to write activities,model_privacy_activity,group_data_protection_manager,1,1,1,1

6
privacy/views/data_protection_menu_view.xml

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2018 Eficent Business and IT Consulting Services S.L.
License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl-3.0) -->
<odoo>
<data>
<record id="action_data_protection_partner_form" model="ir.actions.act_window">
@ -9,9 +8,7 @@
<field name="type">ir.actions.act_window</field>
<field name="res_model">res.partner</field>
<field name="view_type">form</field>
<field name="view_mode">tree,form</field>
<field name="domain">[]</field>
<field name="filter" eval="True"/>
<field name="view_mode">kanban,tree,form</field>
</record>
<menuitem id="parent_menu_data_protection"
@ -52,4 +49,3 @@
/>
</data>
</odoo>

127
privacy/views/privacy_activity_view.xml

@ -0,0 +1,127 @@
<?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="activity_form">
<field name="name">Privacy Activity Form</field>
<field name="model">privacy.activity</field>
<field name="arch" type="xml">
<form>
<header>
<!-- Placeholder for submodules -->
</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>
<div class="oe_title">
<label for="name" class="oe_edit_only"/>
<h1><field name="name"/></h1>
</div>
<group name="basic">
<group name="owners">
<field name="controller_id"/>
<field
name="processor_ids"
widget="many2many_tags"
/>
</group>
<group name="subjects">
<field name="subjects_find"/>
<field
name="subjects_domain"
widget="char_domain"
options='{"model": "res.partner"}'
attrs='{"required": [("subjects_find", "=", True)],
"invisible": [("subjects_find", "=", False)]}'
/>
</group>
</group>
<notebook string="Details" name="advanced">
<page string="Description">
<group>
<field name="description" nolabel="1"/>
</group>
</page>
</notebook>
</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="activity_tree">
<field name="name">Privacy Activity Tree</field>
<field name="model">privacy.activity</field>
<field name="arch" type="xml">
<tree>
<field name="name"/>
<field name="controller_id"/>
<field name="processor_ids"/>
</tree>
</field>
</record>
<record model="ir.ui.view" id="activity_search">
<field name="name">Privacy Activity Search</field>
<field name="model">privacy.activity</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="controller_id"/>
<field name="processor_ids"/>
<separator/>
<filter
string="Archived"
name="inactive"
domain="[('active', '=', False)]"
/>
<separator/>
<group string="Group By" name="groupby">
<filter
name="controller_id_groupby"
string="Controller"
context="{'group_by': 'controller_id'}"
/>
</group>
</search>
</field>
</record>
<record id="activity_action" model="ir.actions.act_window">
<field name="name">Activities</field>
<field name="res_model">privacy.activity</field>
<field name="view_mode">tree,form</field>
<field name="help" type="html">
<p class="oe_view_nocontent_create">
Click to add a data processing activity.
</p><p>
Data processing activities define why, how and what you do
with subjects' personal data.
</p>
</field>
</record>
<menuitem
action="activity_action"
groups="group_data_protection_user"
id="menu_privacy_activity"
parent="menu_data_protection_master_data"
/>
</data>

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

28
privacy_consent/__manifest__.py

@ -0,0 +1,28 @@
# -*- 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 accept inclusion in some activity",
"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

42
privacy_consent/controllers/main.py

@ -0,0 +1,42 @@
# -*- 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()
request.httprequest.environ
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>

152
privacy_consent/data/mail.xml

@ -0,0 +1,152 @@
<?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"/>
</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"/>
</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"/>
</record>
</data>

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

36
privacy_consent/models/mail_mail.py

@ -0,0 +1,36 @@
# -*- 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 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

30
privacy_consent/models/mail_template.py

@ -0,0 +1,30 @@
# -*- 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 placehloder 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="/privacy/consent/accept/">Accept</a> \n'
'<a href="/privacy/consent/reject/">Reject</a>'
))

137
privacy_consent/models/privacy_activity.py

@ -0,0 +1,137 @@
# -*- 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_ids_count = fields.Integer(
"Consents",
compute="_compute_consent_ids_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_ids_count(self):
for one in self:
one.consent_ids_count = len(one.consent_ids)
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", "subjects_find")
def _check_consent_required_subjects_find(self):
for one in self:
if one.consent_required and not one.subjects_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_subjects_find(self):
"""Find subjects automatically if we require their consent."""
if self.consent_required:
self.subjects_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.subjects_domain)
domain += [
("id", "not in", one.mapped("consent_ids.partner_id").ids),
]
# 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",
}

207
privacy_consent/models/privacy_consent.py

@ -0,0 +1,207 @@
# -*- 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 absolute 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_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."""
# We will check if all draft consents change
changed = self.filtered(lambda one: one.state == "draft")
if "accepted" in vals:
# Also check those whose acceptance is going to change
changed |= self.filtered(
lambda one: one.accepted != vals["accepted"]
)
result = super(PrivacyConsent, self).write(vals)
changed._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_ids_count = fields.Integer(
"Consents",
compute="_compute_privacy_consent_ids_count",
help="Privacy consent requests amount",
)
@api.depends("privacy_consent_ids")
def _compute_privacy_consent_ids_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"], self._prefetch) \
.privacy_consent_ids_count = group["__count"]

3
privacy_consent/readme/CONTRIBUTORS.rst

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

3
privacy_consent/readme/DESCRIPTION.rst

@ -0,0 +1,3 @@
This module aims for GDPR compliance, enabling 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.

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 > Configuration > Activitys* and create one.
#. Give it a name, such as *Sending mass mailings to customers*.
#. 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.
#. Hit the button 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
read,Permission to read consents,model_privacy_consent,privacy.group_data_protection_user,1,0,0,0
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

61
privacy_consent/templates/form.xml

@ -0,0 +1,61 @@
<?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">
<!-- 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="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>
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>
<b>You have a new email</b> about this, and we have recorded this action on your side.
</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>
</t>
</template>
</data>

88
privacy_consent/views/privacy_activity.xml

@ -0,0 +1,88 @@
<?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">
<button
attrs='{"invisible": [("consent_required", "=", False)]}'
class="oe_stat_button"
context='{"search_default_activity_id": active_id}'
icon="fa-users"
name="%(consent_action)d"
type="action"
>
<field
name="consent_ids_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>

30
privacy_consent/views/res_partner.xml

@ -0,0 +1,30 @@
<?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="arch" type="xml">
<div name="button_box" position="inside">
<button
attrs='{"invisible": [("privacy_consent_ids_count", "=", 0)]}'
class="oe_stat_button"
context='{"search_default_partner_id": active_id}'
icon="fa-envelope"
name="%(consent_action)d"
type="action"
>
<field
name="privacy_consent_ids_count"
widget="statinfo"
/>
</button>
</div>
</field>
</record>
</data>

1
privacy_consent/wizards/__init__.py

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

25
privacy_consent/wizards/mail_compose_message.py

@ -0,0 +1,25 @@
# -*- 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):
"""Update consent state if needed."""
if (self.env.context.get('active_model') == 'privacy.consent' and
self.env.context.get('active_ids') and
self.env.context.get('mark_consent_sent')):
consents = self.env['privacy.consent'].browse(
self.env.context['active_ids'],
self._prefetch,
)
consents.filtered(lambda one: one.state == "draft") \
.with_context(tracking_disable=True) \
.write({"state": "sent"})
return super(MailComposeMessage, self).send_mail(
auto_commit=auto_commit)
Loading…
Cancel
Save