Browse Source

[IMP] mass_mailing_custom_unsubscribe: GDPR compliance (#278)

* [IMP] mass_mailing_custom_unsubscribe: GDPR compliance (#267)

- 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/238/merge
David Vidal 7 years ago
committed by Jairo Llopis
parent
commit
0aacb23b87
  1. 5
      mass_mailing_custom_unsubscribe/README.rst
  2. 4
      mass_mailing_custom_unsubscribe/__openerp__.py
  3. 19
      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. 22
      mass_mailing_custom_unsubscribe/models/mail_mass_mailing.py
  7. 55
      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. 1
      mass_mailing_custom_unsubscribe/tests/test_ui.py
  11. 10
      mass_mailing_custom_unsubscribe/tests/test_unsubscription.py
  12. 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
from a different one.
- Know why and when a contact as 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
=============

4
mass_mailing_custom_unsubscribe/__openerp__.py

@ -3,9 +3,9 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{
'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',
'version': '9.0.2.0.0',
'version': '9.0.3.0.0',
'depends': [
'website_mass_mailing',
],

19
mass_mailing_custom_unsubscribe/controllers/main.py

@ -45,6 +45,8 @@ class CustomUnsubscribe(MassMailController):
_logger.debug(
"Called `mailing()` with: %r",
(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._unsubscribe_token(res_id, token)
# Mass mailing list contacts are a special case because they have a
@ -90,12 +92,25 @@ class CustomUnsubscribe(MassMailController):
token, reason_id=None, details=None):
"""Store unsubscription reasons when unsubscribing from RPC."""
# 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:
request.context["default_reason_id"] = int(reason_id)
request.context["default_details"] = details or False
extra_context["default_reason_id"] = int(reason_id)
if details:
extra_context["default_details"] = details
# FIXME Remove token check in version where this is merged:
# https://github.com/odoo/odoo/pull/14385
mailing = request.env['mail.mass_mailing'].sudo().browse(mailing_id)
request.context = dict(request.context, **extra_context)
del request.env
mailing._unsubscribe_token(res_id, token)
_logger.debug(
"Called `unsubscribe()` with: %r",

2
mass_mailing_custom_unsubscribe/data/mail_unsubscription_reason.xml

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

4
mass_mailing_custom_unsubscribe/exceptions.py

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

22
mass_mailing_custom_unsubscribe/models/mail_mass_mailing.py

@ -40,15 +40,25 @@ class MailMassMailing(models.Model):
@api.model
def update_opt_out(self, mailing_id, email, res_ids, value):
"""Save unsubscription reason when opting out from mailing."""
mailing = self.browse(mailing_id)
if value and self.env.context.get("default_reason_id"):
for res_id in res_ids:
self.ensure_one()
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
self.env["mail.unsubscription"].create({
"email": email,
"mass_mailing_id": mailing.id,
"unsubscriber_id": "%s,%d" % (
mailing.mailing_model, int(res_id)),
"mass_mailing_id": self.id,
"unsubscriber_id": "%s,%d" % (one._name, one.id),
"action": action,
})
return super(MailMassMailing, self).update_opt_out(
mailing_id, email, res_ids, value)

55
mass_mailing_custom_unsubscribe/models/mail_unsubscription.py

@ -10,12 +10,22 @@ class MailUnsubscription(models.Model):
_name = "mail.unsubscription"
_inherit = "mail.thread"
_rec_name = "date"
_order = "date DESC"
date = fields.Datetime(
default=lambda self: self._default_date(),
required=True)
email = fields.Char(
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(
"mail.mass_mailing",
"Mass mailing",
@ -23,19 +33,29 @@ class MailUnsubscription(models.Model):
help="Mass mailing from which he was unsubscribed.")
unsubscriber_id = fields.Reference(
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(
"mail.unsubscription.reason",
"Reason",
ondelete="restrict",
required=True,
help="Why the unsubscription was made.")
details = fields.Char(
help="More details on why the unsubscription was made.")
details_required = fields.Boolean(
related="reason_id.details_required")
metadata = fields.Text(
readonly=True,
help="HTTP request metadata used when creating this record.",
)
@api.model
def _default_date(self):
@ -46,6 +66,15 @@ class MailUnsubscription(models.Model):
"""Models that can be linked to a ``mail.mass_mailing``."""
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.constrains("details", "reason_id")
def _check_details_needed(self):
@ -55,6 +84,24 @@ class MailUnsubscription(models.Model):
raise exceptions.DetailsRequiredError(
_("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):
_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";
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({
selector: ".js_unsubscription_reason",
@ -19,7 +19,10 @@ odoo.define("mass_mailing_custom_unsubscribe.require_details",
toggle: function (event) {
this.$details.prop(
"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. */
odoo.define("mass_mailing_custom_unsubscribe.unsubscribe", function (require) {
"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({
selector: "#unsubscribe_form",
start: function (editable_mode) {
start: function () {
this.controller = '/mail/mailing/unsubscribe';
this.$alert = this.$(".alert");
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()
int_val: function (index, element) {
return parseInt($(element).val());
return parseInt($(element).val(), 10);
},
// 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
this.$reasons.toggleClass("hidden", !$disabled.length);
var $radios = this.$reasons.find(":radio");
if (this.$reasons.is(":hidden")) {
// 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
.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 () {
var result = {
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_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(),
};
// Only send reason and details if an unsubscription was found
if (this.$reasons.is(":visible")) {
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();
}
return result;
@ -108,4 +116,6 @@ odoo.define("mass_mailing_custom_unsubscribe.unsubscribe", function (require) {
.addClass("alert-warning");
},
});
return animation.registry.mass_mailing_unsubscribe;
});

1
mass_mailing_custom_unsubscribe/tests/test_ui.py

@ -20,6 +20,7 @@ class UICase(HttpCase):
self.mail_postprocess_patch = mock.patch(
"openerp.addons.mass_mailing.models.mail_mail.MailMail."
"_postprocess_sent_message",
autospec=True,
side_effect=self.extract_url,
)
with self.tempenv() as env:

10
mass_mailing_custom_unsubscribe/tests/test_unsubscription.py

@ -19,3 +19,13 @@ class UnsubscriptionCase(TransactionCase):
self.env.ref(
"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="mass_mailing_id"/>
<field name="unsubscriber_id"/>
<field name="mailing_list_id"/>
<field name="email"/>
<field name="reason_id"/>
<field name="action"/>
<field name="reason_id"
attrs="{'required': [('action', '=', 'unsubscription')]}"/>
<field name="details"
attrs="{'required': [('details_required', '=', True)]}"/>
<field name="details_required" invisible="True"/>
<field name="metadata"/>
</group>
</sheet>
<div class="oe_chatter">
@ -36,11 +40,13 @@
<field name="name">Mail Unsubscription Tree</field>
<field name="model">mail.unsubscription</field>
<field name="arch" type="xml">
<tree>
<tree decoration-warning="action == 'unsubscription'">
<field name="date"/>
<field name="mass_mailing_id"/>
<field name="unsubscriber_id"/>
<field name="mailing_list_id"/>
<field name="email" invisible="True"/>
<field name="action"/>
<field name="reason_id"/>
<field name="details" invisible="True"/>
</tree>
@ -54,6 +60,7 @@
<search>
<field name="mass_mailing_id"/>
<field name="unsubscriber_id"/>
<field name="mailing_list_id"/>
<field name="email"/>
<field name="reason_id"/>
<field name="details"/>
@ -63,6 +70,10 @@
context="{'group_by': 'date:month'}"/>
<filter string="Year"
context="{'group_by': 'date:year'}"/>
<filter string="Action"
context="{'group_by': 'action'}"/>
<filter string="Email"
context="{'group_by': 'email'}"/>
<filter string="Reason"
context="{'group_by': 'reason_id'}"/>
<filter string="Mass mailing"
@ -72,8 +83,32 @@
</field>
</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"
name="Unsubscriptions"
name="(Un)subscriptions"
view_mode="tree,form,pivot,graph"
res_model="mail.unsubscription"/>
<menuitem id="mail_unsubscription_menu"

Loading…
Cancel
Save