Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[IMP] mass_mailing_custom_unsubscribe: GDPR compliance #267

Merged
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 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
=============
Expand Down
4 changes: 2 additions & 2 deletions mass_mailing_custom_unsubscribe/__manifest__.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': '10.0.1.0.0',
'version': '10.0.2.0.0',
'depends': [
'website_mass_mailing',
],
Expand Down
21 changes: 16 additions & 5 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 @@ -90,12 +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 = 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:
# 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
18 changes: 14 additions & 4 deletions mass_mailing_custom_unsubscribe/models/mail_mass_mailing.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,24 @@ def _unsubscribe_token(self, res_id, compare=None):
def update_opt_out(self, email, res_ids, value):
"""Save unsubscription reason when opting out from mailing."""
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
self.env["mail.unsubscription"].create({
"email": email,
"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(
email, res_ids, value)
57 changes: 52 additions & 5 deletions mass_mailing_custom_unsubscribe/models/mail_unsubscription.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,40 +2,60 @@
# Copyright 2016 Jairo Llopis <jairo.llopis@tecnativa.com>
# 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


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