Browse Source

[IMP] mass_mailing_custom_unsubscribe: GDPR compliance (#267)

* [IMP] mass_mailing_custom_unsubscribe: GDPR compliance

- Record resubscriptions too.
- Record action metadata.
- Make ESLint happy.
- Quick color-based action distinction in tree view.
- Add useful quick groupings.
- Display (un)subscription metadata.
- Pivot & graph views.
pull/279/head
Jairo Llopis 7 years ago
committed by David
parent
commit
6627fc8b4a
  1. 5
      mass_mailing_custom_unsubscribe/README.rst
  2. 4
      mass_mailing_custom_unsubscribe/__manifest__.py
  3. 21
      mass_mailing_custom_unsubscribe/controllers/main.py
  4. 2
      mass_mailing_custom_unsubscribe/data/mail_unsubscription_reason.xml
  5. 4
      mass_mailing_custom_unsubscribe/exceptions.py
  6. 18
      mass_mailing_custom_unsubscribe/models/mail_mass_mailing.py
  7. 57
      mass_mailing_custom_unsubscribe/models/mail_unsubscription.py
  8. 7
      mass_mailing_custom_unsubscribe/static/src/js/require_details.js
  9. 32
      mass_mailing_custom_unsubscribe/static/src/js/unsubscribe.js
  10. 14
      mass_mailing_custom_unsubscribe/tests/test_unsubscription.py
  11. 41
      mass_mailing_custom_unsubscribe/views/mail_unsubscription_view.xml

5
mass_mailing_custom_unsubscribe/README.rst

@ -10,7 +10,10 @@ This addon extends the unsubscription form to let you:
- Choose which mailing lists are not cross-unsubscriptable when unsubscribing - Choose which mailing lists are not cross-unsubscriptable when unsubscribing
from a different one. from a different one.
- Know why and when a contact has been unsubscribed from a mass mailing.
- Know why and when a contact has been subscribed or unsubscribed from a
mass mailing.
- Provide proof on why you are sending mass mailings to a given contact, as
required by the GDPR in Europe.
Configuration Configuration
============= =============

4
mass_mailing_custom_unsubscribe/__manifest__.py

@ -3,9 +3,9 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{ {
'name': "Customizable unsubscription process on mass mailing emails", 'name': "Customizable unsubscription process on mass mailing emails",
"summary": "Know unsubscription reasons, track them",
"summary": "Know and track (un)subscription reasons, GDPR compliant",
'category': 'Marketing', 'category': 'Marketing',
'version': '10.0.1.0.0',
'version': '10.0.2.0.0',
'depends': [ 'depends': [
'website_mass_mailing', 'website_mass_mailing',
], ],

21
mass_mailing_custom_unsubscribe/controllers/main.py

@ -45,6 +45,8 @@ class CustomUnsubscribe(MassMailController):
_logger.debug( _logger.debug(
"Called `mailing()` with: %r", "Called `mailing()` with: %r",
(mailing_id, email, res_id, token, post)) (mailing_id, email, res_id, token, post))
if res_id:
res_id = int(res_id)
mailing = request.env["mail.mass_mailing"].sudo().browse(mailing_id) mailing = request.env["mail.mass_mailing"].sudo().browse(mailing_id)
mailing._unsubscribe_token(res_id, token) mailing._unsubscribe_token(res_id, token)
# Mass mailing list contacts are a special case because they have a # Mass mailing list contacts are a special case because they have a
@ -90,12 +92,21 @@ class CustomUnsubscribe(MassMailController):
token, reason_id=None, details=None): token, reason_id=None, details=None):
"""Store unsubscription reasons when unsubscribing from RPC.""" """Store unsubscription reasons when unsubscribing from RPC."""
# Update request context and reset environment # Update request context and reset environment
environ = request.httprequest.headers.environ
extra_context = {
"default_metadata": "\n".join(
"%s: %s" % (val, environ.get(val)) for val in (
"REMOTE_ADDR",
"HTTP_USER_AGENT",
"HTTP_ACCEPT_LANGUAGE",
)
),
}
if reason_id: if reason_id:
request.context = dict(
request.context,
default_reason_id=int(reason_id),
default_details=details or False,
)
extra_context["default_reason_id"] = int(reason_id)
if details:
extra_context["default_details"] = details
request.context = dict(request.context, **extra_context)
# FIXME Remove token check in version where this is merged: # FIXME Remove token check in version where this is merged:
# https://github.com/odoo/odoo/pull/14385 # https://github.com/odoo/odoo/pull/14385
mailing = request.env['mail.mass_mailing'].sudo().browse(mailing_id) mailing = request.env['mail.mass_mailing'].sudo().browse(mailing_id)

2
mass_mailing_custom_unsubscribe/data/mail_unsubscription_reason.xml

@ -2,7 +2,6 @@
<!-- © 2016 Jairo Llopis <jairo.llopis@tecnativa.com> <!-- © 2016 Jairo Llopis <jairo.llopis@tecnativa.com>
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). --> License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -->
<openerp>
<data noupdate="1"> <data noupdate="1">
<record id="reason_not_interested" <record id="reason_not_interested"
@ -38,4 +37,3 @@
</record> </record>
</data> </data>
</openerp>

4
mass_mailing_custom_unsubscribe/exceptions.py

@ -7,3 +7,7 @@ from openerp import exceptions
class DetailsRequiredError(exceptions.ValidationError): class DetailsRequiredError(exceptions.ValidationError):
pass pass
class ReasonRequiredError(exceptions.ValidationError):
pass

18
mass_mailing_custom_unsubscribe/models/mail_mass_mailing.py

@ -40,14 +40,24 @@ class MailMassMailing(models.Model):
def update_opt_out(self, email, res_ids, value): def update_opt_out(self, email, res_ids, value):
"""Save unsubscription reason when opting out from mailing.""" """Save unsubscription reason when opting out from mailing."""
self.ensure_one() self.ensure_one()
if value and self.env.context.get("default_reason_id"):
for res_id in res_ids:
action = "unsubscription" if value else "subscription"
records = self.env[self.mailing_model].browse(res_ids)
previous = self.env["mail.unsubscription"].search(limit=1, args=[
("mass_mailing_id", "=", self.id),
("email", "=", email),
("action", "=", action),
])
for one in records:
# Store action only when something changed, or there was no
# previous subscription record
if one.opt_out != value or (action == "subscription" and
not previous):
# reason_id and details are expected from the context # reason_id and details are expected from the context
self.env["mail.unsubscription"].create({ self.env["mail.unsubscription"].create({
"email": email, "email": email,
"mass_mailing_id": self.id, "mass_mailing_id": self.id,
"unsubscriber_id": "%s,%d" % (
self.mailing_model, int(res_id)),
"unsubscriber_id": "%s,%d" % (one._name, one.id),
"action": action,
}) })
return super(MailMassMailing, self).update_opt_out( return super(MailMassMailing, self).update_opt_out(
email, res_ids, value) email, res_ids, value)

57
mass_mailing_custom_unsubscribe/models/mail_unsubscription.py

@ -2,7 +2,7 @@
# Copyright 2016 Jairo Llopis <jairo.llopis@tecnativa.com> # Copyright 2016 Jairo Llopis <jairo.llopis@tecnativa.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from openerp import _, api, fields, models
from odoo import _, api, fields, models
from .. import exceptions from .. import exceptions
@ -10,12 +10,22 @@ class MailUnsubscription(models.Model):
_name = "mail.unsubscription" _name = "mail.unsubscription"
_inherit = "mail.thread" _inherit = "mail.thread"
_rec_name = "date" _rec_name = "date"
_order = "date DESC"
date = fields.Datetime( date = fields.Datetime(
default=lambda self: self._default_date(), default=lambda self: self._default_date(),
required=True) required=True)
email = fields.Char( email = fields.Char(
required=True) required=True)
action = fields.Selection(
selection=[
("subscription", "Subscription"),
("unsubscription", "Unsubscription"),
],
required=True,
default="unsubscription",
help="What did the (un)subscriber choose to do.",
)
mass_mailing_id = fields.Many2one( mass_mailing_id = fields.Many2one(
"mail.mass_mailing", "mail.mass_mailing",
"Mass mailing", "Mass mailing",
@ -23,19 +33,29 @@ class MailUnsubscription(models.Model):
help="Mass mailing from which he was unsubscribed.") help="Mass mailing from which he was unsubscribed.")
unsubscriber_id = fields.Reference( unsubscriber_id = fields.Reference(
lambda self: self._selection_unsubscriber_id(), lambda self: self._selection_unsubscriber_id(),
"Unsubscriber",
required=True,
help="Who was unsubscribed.")
"(Un)subscriber",
help="Who was subscribed or unsubscribed.")
mailing_list_id = fields.Many2one(
"mail.mass_mailing.list",
"Mailing list",
ondelete="set null",
compute="_compute_mailing_list_id",
store=True,
help="(Un)subscribed mass mailing list, if any.",
)
reason_id = fields.Many2one( reason_id = fields.Many2one(
"mail.unsubscription.reason", "mail.unsubscription.reason",
"Reason", "Reason",
ondelete="restrict", ondelete="restrict",
required=True,
help="Why the unsubscription was made.") help="Why the unsubscription was made.")
details = fields.Char( details = fields.Char(
help="More details on why the unsubscription was made.") help="More details on why the unsubscription was made.")
details_required = fields.Boolean( details_required = fields.Boolean(
related="reason_id.details_required") related="reason_id.details_required")
metadata = fields.Text(
readonly=True,
help="HTTP request metadata used when creating this record.",
)
@api.model @api.model
def _default_date(self): def _default_date(self):
@ -46,6 +66,15 @@ class MailUnsubscription(models.Model):
"""Models that can be linked to a ``mail.mass_mailing``.""" """Models that can be linked to a ``mail.mass_mailing``."""
return self.env["mail.mass_mailing"]._get_mailing_model() return self.env["mail.mass_mailing"]._get_mailing_model()
@api.multi
@api.constrains("action", "reason_id")
def _check_reason_needed(self):
"""Ensure reason is given for unsubscriptions."""
for one in self:
if one.action == "unsubscription" and not one.reason_id:
raise exceptions.ReasonRequiredError(
_("Please indicate why are you unsubscribing."))
@api.multi @api.multi
@api.constrains("details", "reason_id") @api.constrains("details", "reason_id")
def _check_details_needed(self): def _check_details_needed(self):
@ -55,6 +84,24 @@ class MailUnsubscription(models.Model):
raise exceptions.DetailsRequiredError( raise exceptions.DetailsRequiredError(
_("Please provide details on why you are unsubscribing.")) _("Please provide details on why you are unsubscribing."))
@api.multi
@api.depends("unsubscriber_id")
def _compute_mailing_list_id(self):
"""Get the mass mailing list, if it is possible."""
for one in self:
try:
one.mailing_list_id = one.unsubscriber_id.list_id
except AttributeError:
# Possibly model != mail.mass_mailing.contact; no problem
pass
@api.model
def create(self, vals):
# No reasons for subscriptions
if vals.get("action") == "subscription":
vals = dict(vals, reason_id=False, details=False)
return super(MailUnsubscription, self).create(vals)
class MailUnsubscriptionReason(models.Model): class MailUnsubscriptionReason(models.Model):
_name = "mail.unsubscription.reason" _name = "mail.unsubscription.reason"

7
mass_mailing_custom_unsubscribe/static/src/js/require_details.js

@ -5,7 +5,7 @@ odoo.define("mass_mailing_custom_unsubscribe.require_details",
"use strict"; "use strict";
var animation = require("web_editor.snippets.animation"); var animation = require("web_editor.snippets.animation");
return animation.registry.mass_mailing_custom_unsubscribe_require_details =
animation.registry.mass_mailing_custom_unsubscribe_require_details =
animation.Class.extend({ animation.Class.extend({
selector: ".js_unsubscription_reason", selector: ".js_unsubscription_reason",
@ -19,7 +19,10 @@ odoo.define("mass_mailing_custom_unsubscribe.require_details",
toggle: function (event) { toggle: function (event) {
this.$details.prop( this.$details.prop(
"required", "required",
$(event.target).is("[data-details-required]"));
$(event.target).is("[data-details-required]") &&
$(event.target).is(":visible"));
}, },
}); });
return animation.registry.mass_mailing_custom_unsubscribe_require_details;
}); });

32
mass_mailing_custom_unsubscribe/static/src/js/unsubscribe.js

@ -7,15 +7,15 @@
* that when it gets merged, and remove most of this file. */ * that when it gets merged, and remove most of this file. */
odoo.define("mass_mailing_custom_unsubscribe.unsubscribe", function (require) { odoo.define("mass_mailing_custom_unsubscribe.unsubscribe", function (require) {
"use strict"; "use strict";
var core = require("web.core"),
ajax = require("web.ajax"),
animation = require("web_editor.snippets.animation"),
_t = core._t;
var core = require("web.core");
var ajax = require("web.ajax");
var animation = require("web_editor.snippets.animation");
var _t = core._t;
return animation.registry.mass_mailing_unsubscribe =
animation.registry.mass_mailing_unsubscribe =
animation.Class.extend({ animation.Class.extend({
selector: "#unsubscribe_form", selector: "#unsubscribe_form",
start: function (editable_mode) {
start: function () {
this.controller = '/mail/mailing/unsubscribe'; this.controller = '/mail/mailing/unsubscribe';
this.$alert = this.$(".alert"); this.$alert = this.$(".alert");
this.$email = this.$("input[name='email']"); this.$email = this.$("input[name='email']");
@ -32,7 +32,7 @@ odoo.define("mass_mailing_custom_unsubscribe.unsubscribe", function (require) {
// Helper to get list ids, to use in this.$contacts.map() // Helper to get list ids, to use in this.$contacts.map()
int_val: function (index, element) { int_val: function (index, element) {
return parseInt($(element).val());
return parseInt($(element).val(), 10);
}, },
// Get a filtered array of integer IDs of matching lists // Get a filtered array of integer IDs of matching lists
@ -50,11 +50,17 @@ odoo.define("mass_mailing_custom_unsubscribe.unsubscribe", function (require) {
}); });
// Hide reasons form if you are only subscribing // Hide reasons form if you are only subscribing
this.$reasons.toggleClass("hidden", !$disabled.length); this.$reasons.toggleClass("hidden", !$disabled.length);
var $radios = this.$reasons.find(":radio");
if (this.$reasons.is(":hidden")) { if (this.$reasons.is(":hidden")) {
// Uncheck chosen reason // Uncheck chosen reason
this.$reasons.find(":radio").prop("checked", false)
$radios.prop("checked", false)
// Unrequire specifying a reason
.prop("required", false)
// Remove possible constraints for details // Remove possible constraints for details
.trigger("change"); .trigger("change");
} else {
// Require specifying a reason
$radios.prop("required", true);
} }
}, },
@ -62,16 +68,18 @@ odoo.define("mass_mailing_custom_unsubscribe.unsubscribe", function (require) {
values: function () { values: function () {
var result = { var result = {
email: this.$email.val(), email: this.$email.val(),
mailing_id: parseInt(this.$mailing_id.val()),
mailing_id: parseInt(this.$mailing_id.val(), 10),
opt_in_ids: this.contact_ids(true), opt_in_ids: this.contact_ids(true),
opt_out_ids: this.contact_ids(false), opt_out_ids: this.contact_ids(false),
res_id: parseInt(this.$res_id.val()),
res_id: parseInt(this.$res_id.val(), 10),
token: this.$token.val(), token: this.$token.val(),
}; };
// Only send reason and details if an unsubscription was found // Only send reason and details if an unsubscription was found
if (this.$reasons.is(":visible")) { if (this.$reasons.is(":visible")) {
result.reason_id = parseInt( result.reason_id = parseInt(
this.$reasons.find("[name='reason_id']:checked").val());
this.$reasons.find("[name='reason_id']:checked").val(),
10
);
result.details = this.$details.val(); result.details = this.$details.val();
} }
return result; return result;
@ -108,4 +116,6 @@ odoo.define("mass_mailing_custom_unsubscribe.unsubscribe", function (require) {
.addClass("alert-warning"); .addClass("alert-warning");
}, },
}); });
return animation.registry.mass_mailing_unsubscribe;
}); });

14
mass_mailing_custom_unsubscribe/tests/test_unsubscription.py

@ -2,11 +2,11 @@
# Copyright 2016 Jairo Llopis <jairo.llopis@tecnativa.com> # Copyright 2016 Jairo Llopis <jairo.llopis@tecnativa.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from openerp.tests.common import TransactionCase
from openerp.tests.common import SavepointCase
from .. import exceptions from .. import exceptions
class UnsubscriptionCase(TransactionCase):
class UnsubscriptionCase(SavepointCase):
def test_details_required(self): def test_details_required(self):
"""Cannot create unsubscription without details when required.""" """Cannot create unsubscription without details when required."""
with self.assertRaises(exceptions.DetailsRequiredError): with self.assertRaises(exceptions.DetailsRequiredError):
@ -19,3 +19,13 @@ class UnsubscriptionCase(TransactionCase):
self.env.ref( self.env.ref(
"mass_mailing_custom_unsubscribe.reason_other").id, "mass_mailing_custom_unsubscribe.reason_other").id,
}) })
def test_reason_required(self):
"""Cannot create unsubscription without reason when required."""
with self.assertRaises(exceptions.ReasonRequiredError):
self.env["mail.unsubscription"].create({
"email": "axelor@yourcompany.example.com",
"mass_mailing_id": self.env.ref("mass_mailing.mass_mail_1").id,
"unsubscriber_id":
"res.partner,%d" % self.env.ref("base.res_partner_2").id,
})

41
mass_mailing_custom_unsubscribe/views/mail_unsubscription_view.xml

@ -14,11 +14,15 @@
<field name="date"/> <field name="date"/>
<field name="mass_mailing_id"/> <field name="mass_mailing_id"/>
<field name="unsubscriber_id"/> <field name="unsubscriber_id"/>
<field name="mailing_list_id"/>
<field name="email"/> <field name="email"/>
<field name="reason_id"/>
<field name="action"/>
<field name="reason_id"
attrs="{'required': [('action', '=', 'unsubscription')]}"/>
<field name="details" <field name="details"
attrs="{'required': [('details_required', '=', True)]}"/> attrs="{'required': [('details_required', '=', True)]}"/>
<field name="details_required" invisible="True"/> <field name="details_required" invisible="True"/>
<field name="metadata"/>
</group> </group>
</sheet> </sheet>
<div class="oe_chatter"> <div class="oe_chatter">
@ -36,11 +40,13 @@
<field name="name">Mail Unsubscription Tree</field> <field name="name">Mail Unsubscription Tree</field>
<field name="model">mail.unsubscription</field> <field name="model">mail.unsubscription</field>
<field name="arch" type="xml"> <field name="arch" type="xml">
<tree>
<tree decoration-warning="action == 'unsubscription'">
<field name="date"/> <field name="date"/>
<field name="mass_mailing_id"/> <field name="mass_mailing_id"/>
<field name="unsubscriber_id"/> <field name="unsubscriber_id"/>
<field name="mailing_list_id"/>
<field name="email" invisible="True"/> <field name="email" invisible="True"/>
<field name="action"/>
<field name="reason_id"/> <field name="reason_id"/>
<field name="details" invisible="True"/> <field name="details" invisible="True"/>
</tree> </tree>
@ -54,6 +60,7 @@
<search> <search>
<field name="mass_mailing_id"/> <field name="mass_mailing_id"/>
<field name="unsubscriber_id"/> <field name="unsubscriber_id"/>
<field name="mailing_list_id"/>
<field name="email"/> <field name="email"/>
<field name="reason_id"/> <field name="reason_id"/>
<field name="details"/> <field name="details"/>
@ -63,6 +70,10 @@
context="{'group_by': 'date:month'}"/> context="{'group_by': 'date:month'}"/>
<filter string="Year" <filter string="Year"
context="{'group_by': 'date:year'}"/> context="{'group_by': 'date:year'}"/>
<filter string="Action"
context="{'group_by': 'action'}"/>
<filter string="Email"
context="{'group_by': 'email'}"/>
<filter string="Reason" <filter string="Reason"
context="{'group_by': 'reason_id'}"/> context="{'group_by': 'reason_id'}"/>
<filter string="Mass mailing" <filter string="Mass mailing"
@ -72,8 +83,32 @@
</field> </field>
</record> </record>
<record model="ir.ui.view" id="mail_unsubscription_view_pivot">
<field name="name">Mail Unsubscription Pivot</field>
<field name="model">mail.unsubscription</field>
<field name="arch" type="xml">
<pivot string="(Un)subscriptions">
<field name="reason_id" type="row"/>
<field name="mailing_list_id" type="row"/>
<field name="action" type="col"/>
</pivot>
</field>
</record>
<record model="ir.ui.view" id="mail_unsubscription_view_graph">
<field name="name">Mail Unsubscription Graph</field>
<field name="model">mail.unsubscription</field>
<field name="arch" type="xml">
<graph string="(Un)subscriptions">
<field name="date" type="row"/>
<field name="action" type="col"/>
</graph>
</field>
</record>
<act_window id="mail_unsubscription_action" <act_window id="mail_unsubscription_action"
name="Unsubscriptions"
name="(Un)subscriptions"
view_mode="tree,form,pivot,graph"
res_model="mail.unsubscription"/> res_model="mail.unsubscription"/>
<menuitem id="mail_unsubscription_menu" <menuitem id="mail_unsubscription_menu"

Loading…
Cancel
Save