Skip to content

Commit

Permalink
[IMP] mass_mailing_custom_unsubscribe: GDPR compliance (#267)
Browse files Browse the repository at this point in the history
* [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.
  • Loading branch information
yajo authored and chienandalu committed May 24, 2018
1 parent 5a833b5 commit 2267575
Show file tree
Hide file tree
Showing 11 changed files with 174 additions and 40 deletions.
5 changes: 4 additions & 1 deletion mass_mailing_custom_unsubscribe/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
=============
Expand Down
4 changes: 2 additions & 2 deletions mass_mailing_custom_unsubscribe/__openerp__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
],
Expand Down
28 changes: 21 additions & 7 deletions mass_mailing_custom_unsubscribe/controllers/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ def mailing(self, mailing_id, email=None, res_id=None, token="", **post):
_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
Expand Down Expand Up @@ -75,11 +77,11 @@ def mailing(self, mailing_id, email=None, res_id=None, token="", **post):
return self.reason_form(mailing, email, res_id, token)
else:
# Unsubscribe, saving reason and details by context
request.context.update({
"default_reason_id": reason_id,
"default_details": post.get("details") or False,
})
del request.env
request.context = dict(
request.context,
default_reason_id=reason_id,
default_details=post.get("details") or False,
)
# You could get a DetailsRequiredError here, but only if HTML5
# validation fails, which should not happen in modern browsers
return super(CustomUnsubscribe, self).mailing(
Expand All @@ -90,9 +92,21 @@ def unsubscribe(self, mailing_id, opt_in_ids, opt_out_ids, email, res_id,
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
request.context = dict(request.context, **extra_context)
# 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -38,4 +37,3 @@
</record>

</data>
</openerp>
4 changes: 4 additions & 0 deletions mass_mailing_custom_unsubscribe/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,7 @@

class DetailsRequiredError(exceptions.ValidationError):
pass


class ReasonRequiredError(exceptions.ValidationError):
pass
22 changes: 16 additions & 6 deletions mass_mailing_custom_unsubscribe/models/mail_mass_mailing.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,25 @@ def _unsubscribe_token(self, res_id, compare=None):
@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 changes: 51 additions & 4 deletions mass_mailing_custom_unsubscribe/models/mail_unsubscription.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,32 +10,52 @@ 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",
required=True,
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):
Expand All @@ -46,6 +66,15 @@ def _selection_unsubscriber_id(self):
"""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):
Expand All @@ -55,6 +84,24 @@ def _check_details_needed(self):
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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",

Expand All @@ -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 changes: 21 additions & 11 deletions mass_mailing_custom_unsubscribe/static/src/js/unsubscribe.js
Original file line number Diff line number Diff line change
Expand Up @@ -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']");
Expand All @@ -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
Expand All @@ -50,28 +50,36 @@ 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);
}
},

// Get values to send
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;
Expand Down Expand Up @@ -108,4 +116,6 @@ odoo.define("mass_mailing_custom_unsubscribe.unsubscribe", function (require) {
.addClass("alert-warning");
},
});

return animation.registry.mass_mailing_unsubscribe;
});
14 changes: 12 additions & 2 deletions mass_mailing_custom_unsubscribe/tests/test_unsubscription.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
# Copyright 2016 Jairo Llopis <jairo.llopis@tecnativa.com>
# 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


class UnsubscriptionCase(TransactionCase):
class UnsubscriptionCase(SavepointCase):
def test_details_required(self):
"""Cannot create unsubscription without details when required."""
with self.assertRaises(exceptions.DetailsRequiredError):
Expand All @@ -19,3 +19,13 @@ def test_details_required(self):
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,
})
Loading

0 comments on commit 2267575

Please sign in to comment.