Browse Source

[9.0][MIG][mass_mailing_custom_unsubscribe] Migrate.

- Imported last updates from v8.
- Adapted to v9.
- Added a saner default to `mass_mailing.salt` configuration parameter by
  reusing `database.secret` if available, hoping that some day
  https://github.com/odoo/odoo/pull/12040 gets merged.
- Updated README.
- Increase security, drop backwards compatibility.
  Security got improved upstream, which would again break compatibility among current addon and future master upstream.
  I choose to break it now and keep it secured future-wise, so I drop the backwards compatibility features.
- Includes tour tests.
- Removes outdated tests.
- Extends the mailing list management form when unsubscriber is a contact.
- Adds a reason form even if he is not.
- Avoids all methods that were not model-agnostic.

[FIX][mass_mailing_custom_unsubscribe] Reasons noupdate

After this fix, when you update the addon, you will not lose your customized reasons.

[FIX] Compatibilize with mass_mailing_partner

Current test code was based on the assumption that the `@api.model` decorator on `create()` ensured an empty recordset when running the method, but that's not true. This was causing an incompatibility betwee these tests and the `mass_mailing_partner` addon, which works assuming 0-1 recordsets.

Now records are created from an empty recordset, and thus tests work everywhere.

Update instructions

If the user does not add the unsubscribe snippet, nothing will happen, so it's added to README to avoid confusion when testing/using the addon.

[FIX] Use the right operator to preserve recordsets order

Using `|=` sorts records at will each time (treating them as Python's `set`).
Using `+=` always appends a record to the end of the set.
Since we are using the record position in the set, this caused the test to work sometimes and fail other times. Now it works always.
pull/279/head
Jairo Llopis 8 years ago
committed by David
parent
commit
053d46b53d
  1. 99
      mass_mailing_custom_unsubscribe/README.rst
  2. 6
      mass_mailing_custom_unsubscribe/__init__.py
  3. 44
      mass_mailing_custom_unsubscribe/__manifest__.py
  4. 2
      mass_mailing_custom_unsubscribe/controllers/__init__.py
  5. 284
      mass_mailing_custom_unsubscribe/controllers/main.py
  6. 11
      mass_mailing_custom_unsubscribe/data/install_salt.xml
  7. 5
      mass_mailing_custom_unsubscribe/data/mail.unsubscription.reason.csv
  8. 41
      mass_mailing_custom_unsubscribe/data/mail_unsubscription_reason.xml
  9. 17
      mass_mailing_custom_unsubscribe/demo/assets.xml
  10. 2
      mass_mailing_custom_unsubscribe/exceptions.py
  11. BIN
      mass_mailing_custom_unsubscribe/images/failure.png
  12. BIN
      mass_mailing_custom_unsubscribe/images/success.png
  13. 27
      mass_mailing_custom_unsubscribe/migrations/8.0.2.0.0/pre-migrate.py
  14. 2
      mass_mailing_custom_unsubscribe/models/__init__.py
  15. 49
      mass_mailing_custom_unsubscribe/models/mail_mail.py
  16. 65
      mass_mailing_custom_unsubscribe/models/mail_mass_mailing.py
  17. 2
      mass_mailing_custom_unsubscribe/models/mail_mass_mailing_list.py
  18. 11
      mass_mailing_custom_unsubscribe/models/mail_unsubscription.py
  19. 12
      mass_mailing_custom_unsubscribe/security/ir.model.access.csv
  20. 77
      mass_mailing_custom_unsubscribe/static/src/js/contact.tour.js
  21. 49
      mass_mailing_custom_unsubscribe/static/src/js/partner.tour.js
  22. 30
      mass_mailing_custom_unsubscribe/static/src/js/require_details.js
  23. 111
      mass_mailing_custom_unsubscribe/static/src/js/unsubscribe.js
  24. 82
      mass_mailing_custom_unsubscribe/templates/general_reason_form.xml
  25. 26
      mass_mailing_custom_unsubscribe/templates/mass_mailing_contact_reason.xml
  26. 5
      mass_mailing_custom_unsubscribe/tests/__init__.py
  27. 111
      mass_mailing_custom_unsubscribe/tests/test_controller.py
  28. 97
      mass_mailing_custom_unsubscribe/tests/test_mail_mail.py
  29. 150
      mass_mailing_custom_unsubscribe/tests/test_ui.py
  30. 4
      mass_mailing_custom_unsubscribe/tests/test_unsubscription.py
  31. 10
      mass_mailing_custom_unsubscribe/views/assets.xml
  32. 8
      mass_mailing_custom_unsubscribe/views/mail_mass_mailing_list_view.xml
  33. 11
      mass_mailing_custom_unsubscribe/views/mail_unsubscription_reason_view.xml
  34. 14
      mass_mailing_custom_unsubscribe/views/mail_unsubscription_view.xml
  35. 155
      mass_mailing_custom_unsubscribe/views/pages.xml

99
mass_mailing_custom_unsubscribe/README.rst

@ -1,40 +1,20 @@
.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg .. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3 :alt: License: AGPL-3
========================================================== ==========================================================
Customizable unsubscription process on mass mailing emails Customizable unsubscription process on mass mailing emails
========================================================== ==========================================================
With this module you can set a custom unsubscribe link appended at the bottom
of mass mailing emails.
This addon extends the unsubscription form to let you:
It also displays a beautiful and simple unsubscription form when somebody
unsubscribes, to let you know why and let the user unsubscribe form another
mailing lists at the same time; and then displays a beautiful and customizable
goodbye message.
- 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.
Configuration Configuration
============= =============
Unsubscription Message In Mail Footer
-------------------------------------
To configure unsubscribe label go to *Settings > Technical > Parameters >
System parameters* and add a ``mass_mailing.unsubscribe.label`` parameter
with HTML to set at the bottom of mass emailing emails. Including ``%(url)s``
variable where unsubscribe link.
For example::
<small>You can unsubscribe <a href="%(url)s">here</a></small>
Additionally, you can disable this link if you set this parameter to ``False``.
If this parameter (``mass_mailing.unsubscribe.label``) does not exist, the
default 'Click to unsubscribe' link will appear, with the advantage that it is
translatable via *Settings > Translations > Application Terms > Translated
terms*.
Unsubscription Reasons Unsubscription Reasons
---------------------- ----------------------
@ -46,63 +26,44 @@ they are going to unsubscribe. To do it:
#. If *Details required* is enabled, they will have to fill a text area to #. If *Details required* is enabled, they will have to fill a text area to
continue. continue.
Unsubscription Goodbye Message
------------------------------
Your unsubscriptors will receive a beautier goodbye page. You can customize it
with these links **after installing the module**:
* `Unsubscription successful </page/mass_mailing_custom_unsubscribe.successs>`_.
* `Unsubscription failed </page/mass_mailing_custom_unsubscribe.failure>`_.
Usage Usage
===== =====
Once configured, just send mass mailings as usual.
Once configured:
If somebody gets unsubscribed, you will see logs about that under
*Marketing > Mass Mailing > Unsubscriptions*.
#. Go to *Mass Mailing > Mailings > Mass Mailings > Create*.
#. Edit your mass mailing at wish, but remember to add a snippet from
*Footers*, so people have an *Unsubscribe* link.
#. Send it.
#. If somebody gets unsubscribed, you will see logs about that under
*Mass Mailing > Mailings > Unsubscriptions*.
.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas .. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas
:alt: Try me on Runbot :alt: Try me on Runbot
:target: https://runbot.odoo-community.org/runbot/205/8.0
:target: https://runbot.odoo-community.org/runbot/205/9.0
Known issues / Roadmap Known issues / Roadmap
====================== ======================
* This needs tests.
* This custom HTML is not translatable, so as a suggestion, you can define
the same text in several languages in several lines.
For example:
.. code:: html
<small>[EN] You can unsubscribe <a href="%(url)s">here</a></small><br/>
<small>[ES] Puedes darte de baja <a href="%(url)s">aquí</a></small>
* If you use the ``website_multi`` module, you will probably find that the
views are not visible by default.
* This module adds a security hash for mass mailing unsubscription URLs, which * This module adds a security hash for mass mailing unsubscription URLs, which
makes to not work anymore URLs of mass mailing messages sent before its
installation. If you need backwards compatibility, disable this security
feature by removing the ``mass_mailing.salt`` system parameter. To avoid
breaking current installations, you will not get a salt if you are upgrading
the addon. If you want a salt, create the above system parameter and assign a
random value to it.
* Security should be patched upstream. Remove security features in the version
where https://github.com/odoo/odoo/pull/12040 gets merged (if it does).
disables insecure URLs from mass mailing messages sent before its
installation. This can be a problem, but anyway you'd get that problem in
Odoo 11.0, so at least this addon will be forward-compatible with it.
* This module replaces AJAX submission core implementation from the mailing
list management form, because it is impossible to extend it. When
https://github.com/odoo/odoo/pull/14386 gets merged (which upstreams most
needed changes), this addon will need a refactoring (mostly removing
duplicated functionality and depending on it instead of replacing it). In the
mean time, there is a little chance that this introduces some
incompatibilities with other addons that depend on ``website_mass_mailing``.
Bug Tracker Bug Tracker
=========== ===========
Bugs are tracked on `GitHub Issues <https://github.com/OCA/social/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us smashing it by providing a detailed and welcomed feedback
`here <https://github.com/OCA/
social/issues/new?body=module:%20
mass_mailing_custom_unsubscribe%0Aversion:%20
8.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
Bugs are tracked on `GitHub Issues
<https://github.com/OCA/social/issues>`_. In case of trouble, please
check there if your issue has already been reported. If you spotted it first,
help us smashing it by providing a detailed and welcomed feedback.
Credits Credits
======= =======
@ -110,9 +71,9 @@ Credits
Contributors Contributors
------------ ------------
* Rafael Blasco <rafabn@antiun.com>
* Antonio Espinosa <antonioea@antiun.com>
* Jairo Llopis <yajo.sk8@gmail.com>
* Rafael Blasco <rafael.blasco@tecnativa.com>
* Antonio Espinosa <antonio.espinosa@tecnativa.com>
* Jairo Llopis <jairo.llopis@tecnativa.com>
Maintainer Maintainer
---------- ----------

6
mass_mailing_custom_unsubscribe/__init__.py

@ -1,7 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Python source code encoding : https://www.python.org/dev/peps/pep-0263/
##############################################################################
# For copyright and license notices, see __openerp__.py file in root directory
##############################################################################
# Copyright 2016 Jairo Llopis <jairo.llopis@tecnativa.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from . import controllers, models from . import controllers, models

44
mass_mailing_custom_unsubscribe/__manifest__.py

@ -1,52 +1,34 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Python source code encoding : https://www.python.org/dev/peps/pep-0263/
##############################################################################
#
# OpenERP, Odoo Source Management Solution
# Copyright (c) 2015 Antiun Ingeniería S.L. (http://www.antiun.com)
# Antonio Espinosa <antonioea@antiun.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
##############################################################################
# Copyright 2016 Jairo Llopis <jairo.llopis@tecnativa.com>
# 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",
'category': 'Marketing', 'category': 'Marketing',
'version': '8.0.2.0.0',
'version': '9.0.2.0.0',
'depends': [ 'depends': [
'mass_mailing',
'website_crm',
'website_mass_mailing',
], ],
'data': [ 'data': [
'security/ir.model.access.csv', 'security/ir.model.access.csv',
'data/install_salt.xml',
'data/mail.unsubscription.reason.csv',
'data/mail_unsubscription_reason.xml',
'templates/general_reason_form.xml',
'templates/mass_mailing_contact_reason.xml',
'views/assets.xml', 'views/assets.xml',
'views/mail_unsubscription_reason_view.xml', 'views/mail_unsubscription_reason_view.xml',
'views/mail_mass_mailing_list_view.xml', 'views/mail_mass_mailing_list_view.xml',
'views/mail_unsubscription_view.xml', 'views/mail_unsubscription_view.xml',
'views/pages.xml',
],
"demo": [
'demo/assets.xml',
], ],
'images': [ 'images': [
'images/failure.png',
'images/form.png', 'images/form.png',
'images/success.png',
], ],
'author': 'Antiun Ingeniería S.L., ' 'author': 'Antiun Ingeniería S.L., '
'Tecnativa,' 'Tecnativa,'
'Odoo Community Association (OCA)', 'Odoo Community Association (OCA)',
'website': 'http://www.antiun.com',
'website': 'https://www.tecnativa.com',
'license': 'AGPL-3', 'license': 'AGPL-3',
'installable': False,
'installable': True,
} }

2
mass_mailing_custom_unsubscribe/controllers/__init__.py

@ -1,5 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# © 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 . import main from . import main

284
mass_mailing_custom_unsubscribe/controllers/main.py

@ -1,32 +1,22 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# © 2015 Antiun Ingeniería S.L. (http://www.antiun.com)
# © 2016 Jairo Llopis <jairo.llopis@tecnativa.com>
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).
# Copyright 2015 Antiun Ingeniería S.L. (http://www.antiun.com)
# Copyright 2016 Jairo Llopis <jairo.llopis@tecnativa.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from openerp import exceptions
from openerp.http import local_redirect, request, route
from openerp.addons.mass_mailing.controllers.main import MassMailController
from .. import exceptions as _ex
import logging
from openerp.http import request, route
from openerp.addons.website_mass_mailing.controllers.main \
import MassMailController
class CustomUnsubscribe(MassMailController):
def _mailing_list_contacts_by_email(self, email):
"""Gets the mailing list contacts by email.
_logger = logging.getLogger(__name__)
This should not be displayed to the final user if security validations
have not been matched.
"""
return request.env["mail.mass_mailing.contact"].sudo().search([
("email", "=", email),
("opt_out", "=", False),
("list_id.not_cross_unsubscriptable", "=", False),
])
def unsubscription_reason(self, mailing_id, email, res_id, token,
qcontext_extra=None):
class CustomUnsubscribe(MassMailController):
def reason_form(self, mailing, email, res_id, token):
"""Get the unsubscription reason form. """Get the unsubscription reason form.
:param mail.mass_mailing mailing_id:
:param mail.mass_mailing mailing:
Mailing where the unsubscription is being processed. Mailing where the unsubscription is being processed.
:param str email: :param str email:
@ -35,203 +25,81 @@ class CustomUnsubscribe(MassMailController):
:param int res_id: :param int res_id:
ID of the unsubscriber. ID of the unsubscriber.
:param dict qcontext_extra:
Additional dictionary to pass to the view.
:param str token:
Security token for unsubscriptions.
""" """
values = self.unsubscription_qcontext(mailing_id, email, res_id, token)
values.update(qcontext_extra or dict())
reasons = request.env["mail.unsubscription.reason"].search([])
return request.website.render( return request.website.render(
"mass_mailing_custom_unsubscribe.reason_form", "mass_mailing_custom_unsubscribe.reason_form",
values)
def unsubscription_qcontext(self, mailing_id, email, res_id, token):
"""Get rendering context for unsubscription form.
:param mail.mass_mailing mailing_id:
Mailing where the unsubscription is being processed.
:param str email:
Email to be unsubscribed.
:param int res_id:
ID of the unsubscriber.
"""
email_fname = origin_name = None
domain = [("id", "=", res_id)]
record_ids = request.env[mailing_id.mailing_model].sudo()
if "email_from" in record_ids._fields:
email_fname = "email_from"
elif "email" in record_ids._fields:
email_fname = "email"
if not (email_fname and email):
# Trying to unsubscribe without email? Bad boy...
raise exceptions.AccessDenied()
domain.append((email_fname, "ilike", email))
# Search additional mailing lists for the unsubscriber
additional_contacts = self._mailing_list_contacts_by_email(email)
if record_ids._name == "mail.mass_mailing.contact":
domain.append(
("list_id", "in", mailing_id.contact_list_ids.ids))
# Unsubscription targets
record_ids = record_ids.search(domain)
if record_ids._name == "mail.mass_mailing.contact":
additional_contacts -= record_ids
if not record_ids:
# Trying to unsubscribe with fake criteria? Bad boy...
raise exceptions.AccessDenied()
# Get data to identify the source of the unsubscription
fnames = self.unsubscription_special_fnames(record_ids._name)
first = record_ids[:1]
contact_name = first[fnames.get("contact", "name")]
origin_model_name = request.env["ir.model"].search(
[("model", "=", first._name)]).name
try:
first = first[fnames["related"]]
except KeyError:
pass
try:
origin_name = first[fnames["origin"]]
except KeyError:
pass
# Get available reasons
reason_ids = (
request.env["mail.unsubscription.reason"].search([]))
return {
"additional_contact_ids": additional_contacts,
"contact_name": contact_name,
{
"email": email, "email": email,
"mailing_id": mailing_id,
"origin_model_name": origin_model_name,
"origin_name": origin_name,
"reason_ids": reason_ids,
"record_ids": record_ids,
"mailing": mailing,
"reasons": reasons,
"res_id": res_id, "res_id": res_id,
"token": token, "token": token,
}
def unsubscription_special_fnames(self, model):
"""Define special field names to generate the unsubscription qcontext.
:return dict:
Special fields will depend on the model, so this method should
return something like::
{
"related": "parent_id",
"origin": "display_name",
"contact": "contact_name",
}
Where:
- ``model.name`` is the technical name of the model.
- ``related`` indicates the name of a field in ``model.name`` that
contains a :class:`openerp.fields.Many2one` field which is
considered what the user is unsubscribing from.
- ``origin``: is the name of the field that contains the name of
what the user is unsubscribing from.
- ``contact`` is the name of the field that contains the name of
the user that is unsubscribing.
Missing keys will mean that nothing special is required for that
model and it will use the default values.
"""
specials = {
"mail.mass_mailing.contact": {
"related": "list_id",
"origin": "display_name",
},
"crm.lead": {
"origin": "name",
"contact": "contact_name",
},
"hr.applicant": {
"related": "job_id",
"origin": "name",
},
# In case you install OCA's event_registration_mass_mailing
"event.registration": {
"related": "event_id",
"origin": "name",
},
}
return specials.get(model, dict())
@route(auth="public", website=True)
def mailing(self, mailing_id, email=None, res_id=None, **post):
"""Display a confirmation form to get the unsubscription reason."""
mailing = request.env["mail.mass_mailing"]
path = "/page/mass_mailing_custom_unsubscribe.%s"
good_token = mailing.hash_create(mailing_id, res_id, email)
# Trying to unsubscribe with fake hash? Bad boy...
if good_token and post.get("token") != good_token:
return local_redirect(path % "failure")
mailing = mailing.sudo().browse(mailing_id)
contact = request.env["mail.mass_mailing.contact"].sudo()
unsubscription = request.env["mail.unsubscription"].sudo()
if not post.get("reason_id"):
# We need to know why you leave, get to the form
return self.unsubscription_reason(
mailing, email, res_id, post.get("token"))
# Save reason and details
try:
with request.env.cr.savepoint():
records = unsubscription.create({
"email": email,
"unsubscriber_id": ",".join(
(mailing.mailing_model, res_id)),
"reason_id": int(post["reason_id"]),
"details": post.get("details", False),
"mass_mailing_id": mailing_id,
}) })
# Should provide details, go back to form
except _ex.DetailsRequiredError:
return self.unsubscription_reason(
mailing, email, res_id, post.get("token"),
{"error_details_required": True})
# Unsubscribe from additional lists
for key, value in post.iteritems():
@route()
def mailing(self, mailing_id, email=None, res_id=None, token="", **post):
"""Ask/save unsubscription reason."""
_logger.debug(
"Called `mailing()` with: %r",
(mailing_id, email, res_id, token, post))
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
# subscription management form
if mailing.mailing_model == 'mail.mass_mailing.contact':
result = super(CustomUnsubscribe, self).mailing(
mailing_id, email, res_id, **post)
# FIXME Remove res_id and token in version where this is merged:
# https://github.com/odoo/odoo/pull/14385
result.qcontext.update({
"token": token,
"res_id": res_id,
"contacts": result.qcontext["contacts"].filtered(
lambda contact:
not contact.list_id.not_cross_unsubscriptable or
contact.list_id <= mailing.contact_list_ids
),
"reasons":
request.env["mail.unsubscription.reason"].search([]),
})
return result
# Any other record type gets a simplified form
try: try:
label, list_id = key.split(",")
if label != "list_id":
raise ValueError
list_id = int(list_id)
except ValueError:
pass
# Check if we already have a reason for unsubscription
reason_id = int(post["reason_id"])
except (KeyError, ValueError):
# No reasons? Ask for them
return self.reason_form(mailing, email, res_id, token)
else: else:
contact_id = contact.browse(int(value))
if contact_id.list_id.id == list_id:
contact_id.opt_out = True
records += unsubscription.create({
"email": email,
"unsubscriber_id": ",".join((contact._name, value)),
"reason_id": int(post["reason_id"]),
"details": post.get("details", False),
"mass_mailing_id": mailing_id,
# Unsubscribe, saving reason and details by context
request.context.update({
"default_reason_id": reason_id,
"default_details": post.get("details") or False,
}) })
# All is OK, unsubscribe
result = super(CustomUnsubscribe, self).mailing(
del request.env
# You could get a DetailsRequiredError here, but only if HTML5
# validation fails, which should not happen in modern browsers
return super(CustomUnsubscribe, self).mailing(
mailing_id, email, res_id, **post) mailing_id, email, res_id, **post)
records.write({"success": result.data == "OK"})
# Redirect to the result
return local_redirect(path % ("success" if result.data == "OK"
else "failure"))
@route()
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
if reason_id:
request.context["default_reason_id"] = int(reason_id)
request.context["default_details"] = details or False
# 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)
mailing._unsubscribe_token(res_id, token)
_logger.debug(
"Called `unsubscribe()` with: %r",
(mailing_id, opt_in_ids, opt_out_ids, email, res_id, token,
reason_id, details))
return super(CustomUnsubscribe, self).unsubscribe(
mailing_id, opt_in_ids, opt_out_ids, email)

11
mass_mailing_custom_unsubscribe/data/install_salt.xml

@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- © 2016 Jairo Llopis <jairo.llopis@tecnativa.com>
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -->
<openerp>
<data noupdate="1">
<function model="mail.mass_mailing" name="_init_salt_create"/>
</data>
</openerp>

5
mass_mailing_custom_unsubscribe/data/mail.unsubscription.reason.csv

@ -1,5 +0,0 @@
"id","name","sequence","details_required"
"reason_not_interested","I'm not interested",10,"False"
"reason_not_requested","I did not request this",20,"False"
"reason_too_many","I get too many emails",30,"False"
"reason_other","Other reason",100,"True"

41
mass_mailing_custom_unsubscribe/data/mail_unsubscription_reason.xml

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- © 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"
model="mail.unsubscription.reason"
forcecreate="False">
<field name="name">I'm not interested</field>
<field name="sequence">10</field>
<field name="details_required" eval="False"/>
</record>
<record id="reason_not_requested"
model="mail.unsubscription.reason"
forcecreate="False">
<field name="name">I did not request this</field>
<field name="sequence">20</field>
<field name="details_required" eval="False"/>
</record>
<record id="reason_too_many"
model="mail.unsubscription.reason"
forcecreate="False">
<field name="name">I get too many emails</field>
<field name="sequence">30</field>
<field name="details_required" eval="False"/>
</record>
<record id="reason_other"
model="mail.unsubscription.reason"
forcecreate="False">
<field name="name">Other reason</field>
<field name="sequence">100</field>
<field name="details_required" eval="True"/>
</record>
</data>
</openerp>

17
mass_mailing_custom_unsubscribe/demo/assets.xml

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2016 Jairo Llopis <jairo.llopis@tecnativa.com>
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -->
<odoo>
<template id="assets_frontend_demo"
inherit_id="website.assets_frontend">
<xpath expr=".">
<script type="text/javascript"
src="/mass_mailing_custom_unsubscribe/static/src/js/contact.tour.js"/>
<script type="text/javascript"
src="/mass_mailing_custom_unsubscribe/static/src/js/partner.tour.js"/>
</xpath>
</template>
</odoo>

2
mass_mailing_custom_unsubscribe/exceptions.py

@ -1,5 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# © 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 exceptions from openerp import exceptions

BIN
mass_mailing_custom_unsubscribe/images/failure.png

Before

Width: 696  |  Height: 411  |  Size: 41 KiB

BIN
mass_mailing_custom_unsubscribe/images/success.png

Before

Width: 676  |  Height: 376  |  Size: 35 KiB

27
mass_mailing_custom_unsubscribe/migrations/8.0.2.0.0/pre-migrate.py

@ -1,27 +0,0 @@
# -*- coding: utf-8 -*-
# © 2016 Jairo Llopis <jairo.llopis@tecnativa.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
try:
from openupgradelib.openupgrade import rename_xmlids
except ImportError:
# Simplified version mostly copied from openupgradelib
def rename_xmlids(cr, xmlids_spec):
for (old, new) in xmlids_spec:
if '.' not in old or '.' not in new:
raise Exception(
'Cannot rename XMLID %s to %s: need the module '
'reference to be specified in the IDs' % (old, new))
else:
query = ("UPDATE ir_model_data SET module = %s, name = %s "
"WHERE module = %s and name = %s")
cr.execute(query, tuple(new.split('.') + old.split('.')))
def migrate(cr, version):
"""Update database from previous versions, before updating module."""
rename_xmlids(
cr,
(("website.mass_mail_unsubscription_" + r,
"mass_mailing_custom_unsubscribe." + r)
for r in ("success", "failure")))

2
mass_mailing_custom_unsubscribe/models/__init__.py

@ -1,5 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from . import mail_mail from . import mail_mail
from . import mail_mass_mailing from . import mail_mass_mailing

49
mass_mailing_custom_unsubscribe/models/mail_mail.py

@ -1,52 +1,15 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Python source code encoding : https://www.python.org/dev/peps/pep-0263/
##############################################################################
# For copyright and license notices, see __openerp__.py file in root directory
##############################################################################
# Copyright 2016 Jairo Llopis <jairo.llopis@tecnativa.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import urlparse
import urllib
from openerp import api, models from openerp import api, models
from openerp.tools.translate import _
class MailMail(models.Model): class MailMail(models.Model):
_inherit = 'mail.mail' _inherit = 'mail.mail'
@api.model @api.model
def _get_unsubscribe_url(self, mail, email_to, msg=None):
m_config = self.env['ir.config_parameter']
base_url = m_config.get_param('web.base.url')
config_msg = m_config.get_param('mass_mailing.unsubscribe.label')
params = {
'db': self.env.cr.dbname,
'res_id': mail.res_id,
'email': email_to,
'token': self.env["mail.mass_mailing"].hash_create(
mail.mailing_id.id,
mail.res_id,
email_to),
}
# Avoid `token=None` in URL
if not params["token"]:
del params["token"]
# Generate URL
url = urlparse.urljoin(
base_url, 'mail/mailing/%(mailing_id)s/unsubscribe?%(params)s' % {
'mailing_id': mail.mailing_id.id,
'params': urllib.urlencode(params),
}
)
html = ''
if config_msg is False:
html = '<small><a href="%(url)s">%(label)s</a></small>' % {
'url': url,
'label': msg or _('Click to unsubscribe'),
}
elif config_msg.lower() != 'false':
html = config_msg % {
'url': url,
}
return html
def _get_unsubscribe_url(self, mail, email_to):
result = super(MailMail, self)._get_unsubscribe_url(mail, email_to)
token = mail.mailing_id._unsubscribe_token(mail.res_id)
return "%s&token=%s" % (result, token)

65
mass_mailing_custom_unsubscribe/models/mail_mass_mailing.py

@ -1,35 +1,54 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# © 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 hashlib import sha256
from uuid import uuid4
import hmac
import hashlib
from openerp import api, models from openerp import api, models
from openerp.exceptions import AccessDenied
from openerp.tools import consteq
class MailMassMailing(models.Model): class MailMassMailing(models.Model):
_inherit = "mail.mass_mailing" _inherit = "mail.mass_mailing"
@api.model
def _init_salt_create(self):
"""Create a salt to secure the unsubscription URLs."""
icp = self.env["ir.config_parameter"]
key = "mass_mailing.salt"
salt = icp.get_param(key)
if salt is False:
salt = str(uuid4())
icp.set_param(key, salt, ["base.group_erp_manager"])
@api.multi
def _unsubscribe_token(self, res_id, compare=None):
"""Generate a secure hash for this mailing list and parameters.
This is appended to the unsubscription URL and then checked at
unsubscription time to ensure no malicious unsubscriptions are
performed.
@api.model
def hash_create(self, mailing_id, res_id, email):
"""Create a secure hash to know if the unsubscription is trusted.
:param int res_id:
ID of the resource that will be unsubscribed.
:param str compare:
Received token to be compared with the good one.
:return None/str:
Secure hash, or ``None`` if the system parameter is empty.
:raise AccessDenied:
Will happen if you provide :param:`compare` and it does not match
the good token.
""" """
salt = self.env["ir.config_parameter"].sudo().get_param(
"mass_mailing.salt")
if not salt:
return None
source = (self.env.cr.dbname, mailing_id, res_id, email, salt)
return sha256(",".join(map(unicode, source))).hexdigest()
secret = self.env["ir.config_parameter"].sudo().get_param(
"database.secret")
key = (self.env.cr.dbname, self.id, int(res_id))
token = hmac.new(str(secret), repr(key), hashlib.sha512).hexdigest()
if compare is not None and not consteq(token, str(compare)):
raise AccessDenied()
return token
@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:
# 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)),
})
return super(MailMassMailing, self).update_opt_out(
mailing_id, email, res_ids, value)

2
mass_mailing_custom_unsubscribe/models/mail_mass_mailing_list.py

@ -1,5 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# © 2016 Pedro M. Baeza <pedro.baeza@tecnativa.com>
# Copyright 2016 Pedro M. Baeza <pedro.baeza@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 fields, models from openerp import fields, models

11
mass_mailing_custom_unsubscribe/models/mail_unsubscription.py

@ -1,5 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# © 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 openerp import _, api, fields, models
@ -36,9 +36,6 @@ class MailUnsubscription(models.Model):
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")
success = fields.Boolean(
help="If this is unchecked, it indicates some failure happened in the "
"unsubscription process.")
@api.model @api.model
def _default_date(self): def _default_date(self):
@ -53,10 +50,10 @@ class MailUnsubscription(models.Model):
@api.constrains("details", "reason_id") @api.constrains("details", "reason_id")
def _check_details_needed(self): def _check_details_needed(self):
"""Ensure details are given if required.""" """Ensure details are given if required."""
for s in self:
if not s.details and s.details_required:
for one in self:
if not one.details and one.details_required:
raise exceptions.DetailsRequiredError( raise exceptions.DetailsRequiredError(
_("This reason requires an explanation."))
_("Please provide details on why you are unsubscribing."))
class MailUnsubscriptionReason(models.Model): class MailUnsubscriptionReason(models.Model):

12
mass_mailing_custom_unsubscribe/security/ir.model.access.csv

@ -1,6 +1,6 @@
"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink"
"read_unsubscription_reason_public","Public users can read unsubscription reasons","model_mail_unsubscription_reason","base.group_public",1,0,0,0
"read_unsubscription_reason_employee","Employee users can read unsubscription reasons","model_mail_unsubscription_reason","base.group_user",1,0,0,0
"write_unsubscription_reason","Mass mailing managers can manage unsubscription reasons","model_mail_unsubscription_reason","mass_mailing.group_mass_mailing_campaign",1,1,1,1
"read_unsubscription","Marketing users can read unsubscriptions","model_mail_unsubscription","marketing.group_marketing_user",1,0,0,0
"write_unsubscription","Mass mailing managers can manage unsubscriptions","model_mail_unsubscription","mass_mailing.group_mass_mailing_campaign",1,1,1,1
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
read_unsubscription_reason_public,Public users can read unsubscription reasons,model_mail_unsubscription_reason,base.group_public,1,0,0,0
read_unsubscription_reason_employee,Employee users can read unsubscription reasons,model_mail_unsubscription_reason,base.group_user,1,0,0,0
write_unsubscription_reason,Mass mailing managers can manage unsubscription reasons,model_mail_unsubscription_reason,mass_mailing.group_mass_mailing_user,1,1,1,1
read_unsubscription,Marketing users can read unsubscriptions,model_mail_unsubscription,mass_mailing.group_mass_mailing_user,1,0,0,0
write_unsubscription,Mass mailing managers can manage unsubscriptions,model_mail_unsubscription,mass_mailing.group_mass_mailing_user,1,1,1,1

77
mass_mailing_custom_unsubscribe/static/src/js/contact.tour.js

@ -0,0 +1,77 @@
/* Copyright 2016 Jairo Llopis <jairo.llopis@tecnativa.com>
* License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). */
odoo.define("mass_mailing_custom_unsubscribe.contact_tour",
function (require) {
"use strict";
var Tour = require("web.Tour");
require("mass_mailing_custom_unsubscribe.require_details");
require("mass_mailing_custom_unsubscribe.unsubscribe");
// Allow to know if an element is required
$.extend($.expr[':'], {
propRequired: function(element, index, matches) {
return $(element).prop("required");
},
});
Tour.register({
id: "mass_mailing_custom_unsubscribe_tour_contact",
name: "Mass mailing contact unsubscribes",
mode: "test",
steps: [
{
title: "Unsubscription reasons are invisible",
waitFor: "#unsubscribe_form .js_unsubscription_reason:hidden",
},
{
title: "Uncheck list 0",
element: "li:contains('test list 0') input",
waitFor: "li:contains('test list 0') input:checked",
// List 2 is not cross unsubscriptable
waitNot: "li:contains('test list 2')",
},
{
title: "Uncheck list 1",
element: "li:contains('test list 1') input:checked",
waitFor: ".js_unsubscription_reason:visible",
},
{
title: "Choose other reason",
element: ".radio:contains('Other reason') :radio",
waitFor: ".radio:contains('Other reason') " +
":radio:not(:checked)",
},
{
title: "Add details to reason",
element: "[name='details']:visible:propRequired",
sampleText: "I want to unsubscribe because I want. Period.",
waitFor: ".radio:contains('Other reason') :radio:checked",
},
{
title: "Update subscriptions 1st time",
element: "#unsubscribe_form :submit",
},
{
title: "Subscribe again to list 0",
element: "li:contains('test list 0') input:not(:checked)",
waitFor: ".alert-success",
waitNot: "#unsubscribe_form .js_unsubscription_reason:visible",
onend: function () {
// This one will get the success again after next step
$(".alert-success").removeClass("alert-success");
},
},
{
title: "Update subscriptions 2nd time",
element: "#unsubscribe_form :submit",
waitNot: "#unsubscribe_form .js_unsubscription_reason:visible",
},
{
title: "Resuscription was OK",
waitFor: ".alert-success",
}
]
});
return Tour.tours.mass_mailing_custom_unsubscribe_tour_contact;
});

49
mass_mailing_custom_unsubscribe/static/src/js/partner.tour.js

@ -0,0 +1,49 @@
/* Copyright 2016 Jairo Llopis <jairo.llopis@tecnativa.com>
* License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). */
odoo.define("mass_mailing_custom_unsubscribe.partner_tour",
function (require) {
"use strict";
var Tour = require("web.Tour");
require("mass_mailing_custom_unsubscribe.require_details");
require("mass_mailing_custom_unsubscribe.unsubscribe");
// Allow to know if an element is required
$.extend($.expr[':'], {
propRequired: function(element, index, matches) {
return $(element).prop("required");
},
});
Tour.register({
id: "mass_mailing_custom_unsubscribe_tour_partner",
name: "Mass mailing partner unsubscribes",
mode: "test",
steps: [
{
title: "Choose other reason",
element: ".radio:contains('Other reason') " +
":radio:not(:checked)",
waitFor: "#reason_form .js_unsubscription_reason",
},
{
title: "Switch to not interested reason",
element: ".radio:contains(\"I'm not interested\") " +
":radio:not(:checked)",
waitFor: "[name='details']:propRequired",
},
{
title: "Unsubscribe",
element: "#reason_form :submit",
waitNot: "[name='details']:propRequired",
},
{
title: "Successfully unsubscribed",
waitFor: ".alert-success:contains(" +
"'Your changes have been saved.')",
waitNot: "#reason_form",
},
]
});
return Tour.tours.mass_mailing_custom_unsubscribe_tour_partner;
});

30
mass_mailing_custom_unsubscribe/static/src/js/require_details.js

@ -1,13 +1,25 @@
/* © 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). */
odoo.define("mass_mailing_custom_unsubscribe.require_details",
function (require) {
"use strict";
var animation = require("web_editor.snippets.animation");
"use strict";
(function ($) {
$("#reason_form :radio").change(function(event) {
$("textarea[name=details]").attr(
return animation.registry.mass_mailing_custom_unsubscribe_require_details =
animation.Class.extend({
selector: ".js_unsubscription_reason",
start: function () {
this.$radio = this.$(":radio");
this.$details = this.$("[name=details]");
this.$radio.on("change click", $.proxy(this.toggle, this));
this.$radio.filter(":checked").trigger("change");
},
toggle: function (event) {
this.$details.prop(
"required", "required",
$(event.target).is("[data-details-required]")
);
$(event.target).is("[data-details-required]"));
},
}); });
$("#reason_form :radio:checked").change();
})(jQuery);
});

111
mass_mailing_custom_unsubscribe/static/src/js/unsubscribe.js

@ -0,0 +1,111 @@
/* Copyright 2016 Jairo Llopis <jairo.llopis@tecnativa.com>
* License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). */
/* TODO This JS module replaces core AJAX submission because it is impossible
* to extend it as it is currently designed. Most of this code has been
* upstreamed in https://github.com/odoo/odoo/pull/14386, so we should extend
* 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;
return animation.registry.mass_mailing_unsubscribe =
animation.Class.extend({
selector: "#unsubscribe_form",
start: function (editable_mode) {
this.controller = '/mail/mailing/unsubscribe';
this.$alert = this.$(".alert");
this.$email = this.$("input[name='email']");
this.$contacts = this.$("input[name='contact_ids']");
this.$mailing_id = this.$("input[name='mailing_id']");
this.$token = this.$("input[name='token']");
this.$res_id = this.$("input[name='res_id']");
this.$reasons = this.$(".js_unsubscription_reason");
this.$details = this.$reasons.find("[name='details']")
this.$el.on("submit", $.proxy(this.submit, this));
this.$contacts.on("change", $.proxy(this.toggle_reasons, this));
this.toggle_reasons();
},
// Helper to get list ids, to use in this.$contacts.map()
int_val: function (index, element) {
return parseInt($(element).val());
},
// Get a filtered array of integer IDs of matching lists
contact_ids: function (checked) {
var filter = checked ? ":checked" : ":not(:checked)";
return this.$contacts.filter(filter).map(this.int_val).get();
},
// Display reasons form only if there are unsubscriptions
toggle_reasons: function () {
// Find contacts that were checked and now are unchecked
var $disabled = this.$contacts.filter(function () {
var $this = $(this);
return !$this.prop("checked") && $this.attr("checked");
});
// Hide reasons form if you are only subscribing
this.$reasons.toggleClass("hidden", !$disabled.length);
if (this.$reasons.is(":hidden")) {
// Uncheck chosen reason
this.$reasons.find(":radio").prop("checked", false)
// Remove possible constraints for details
.trigger("change");
}
},
// Get values to send
values: function () {
var result = {
email: this.$email.val(),
mailing_id: parseInt(this.$mailing_id.val()),
opt_in_ids: this.contact_ids(true),
opt_out_ids: this.contact_ids(false),
res_id: parseInt(this.$res_id.val()),
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());
result.details = this.$details.val();
}
return result;
},
// Submit by ajax
submit: function (event) {
event.preventDefault();
return ajax.jsonRpc(this.controller, "call", this.values())
.done($.proxy(this.success, this))
.fail($.proxy(this.failure, this));
},
// When you successfully saved the new subscriptions status
success: function () {
this.$alert
.html(_t('Your changes have been saved.'))
.removeClass("alert-info alert-warning")
.addClass("alert-success");
// Store checked status, to enable further changes
this.$contacts.each(function () {
var $this = $(this);
$this.attr("checked", $this.prop("checked"));
});
this.toggle_reasons();
},
// When you fail to save the new subscriptions status
failure: function () {
this.$alert
.html(_t('Your changes have not been saved, try again later.'))
.removeClass("alert-info alert-success")
.addClass("alert-warning");
},
});
});

82
mass_mailing_custom_unsubscribe/templates/general_reason_form.xml

@ -0,0 +1,82 @@
<?xml version="1.0" encoding="utf-8" ?>
<!-- Copyright 2016 Jairo Llopis <jairo.llopis@tecnativa.com>
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -->
<odoo>
<template id="reason" name="UI for Providing Unsubscription Reasons">
<div t-attf-class="js_unsubscription_reason #{extra_class or ''}">
<div class="col-md-12 mt16">
Before unsubscribing, could you please tell us why do you want to unsubscribe?
</div>
<div class="col-md-12 mb16">
<input
type="hidden"
name="res_id"
t-att-value="res_id"/>
<input
type="hidden"
name="email"
t-att-value="email"/>
<input
type="hidden"
name="token"
t-att-value="token"/>
<t t-foreach="reasons" t-as="reason">
<div class="radio">
<label>
<input
type="radio"
name="reason_id"
t-att-data-details-required="reason.details_required"
t-att-value="reason.id"/>
<t t-esc="reason.display_name"/>
</label>
</div>
</t>
<div t-attf-class="form-group">
<textarea
name="details"
class="form-control"
placeholder="I am unsubscribing because..."
rows="3"/>
</div>
</div>
</div>
</template>
<template id="reason_form"
name="Unsubscription Reason Form">
<t t-call="website.layout">
<div id="wrap" class="oe_structure oe_empty">
<section class="mt16 mb16">
<form
id="reason_form"
class="container"
t-attf-action="/mail/mailing/#{mailing.id}/unsubscribe"
method="post">
<input
type="hidden"
name="csrf_token"
t-att-value="request.csrf_token()"/>
<div class="row">
<div class="col-md-12 text-center mt16 mb32">
<h2>
Mailing Unsubscription
</h2>
</div>
<t t-call="mass_mailing_custom_unsubscribe.reason"/>
<div class="form-group mb16 mt16">
<button type="submit" class="btn btn-danger">
Unsubscribe now
</button>
<p class="help-block">Thank you!</p>
</div>
</div>
</form>
</section>
</div>
</t>
</template>
</odoo>

26
mass_mailing_custom_unsubscribe/templates/mass_mailing_contact_reason.xml

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8" ?>
<!-- Copyright 2016 Jairo Llopis <jairo.llopis@tecnativa.com>
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -->
<odoo>
<template id="unsubscribe"
inherit_id="website_mass_mailing.unsubscribe"
name="Add Reasons to Mailing List Management Form">
<!-- Disable core AJAX submission of form, because it is impossible to
extend it as it is designed right now. It is refactored in this addon.
TODO Remove when merged https://github.com/odoo/odoo/pull/14386. -->
<xpath expr="//div[@class='container o_unsubscribe_form']"
position="attributes">
<attribute name="class" value="container o_unsubscribe_form_custom"/>
</xpath>
<!-- Add reasons to mass mailing list manager -->
<xpath expr="//t[@t-as='contact']/.." position="after">
<t t-call="mass_mailing_custom_unsubscribe.reason">
<t t-set="extra_class" t-value="'hidden'"/>
</t>
</xpath>
</template>
</odoo>

5
mass_mailing_custom_unsubscribe/tests/__init__.py

@ -1,7 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# © 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 . import test_unsubscription from . import test_unsubscription
from . import test_mail_mail
from . import test_controller
from . import test_ui

111
mass_mailing_custom_unsubscribe/tests/test_controller.py

@ -1,111 +0,0 @@
# -*- coding: utf-8 -*-
# © 2016 LasLabs Inc.
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import mock
from contextlib import contextmanager
from openerp.tests.common import TransactionCase
from openerp.addons.mass_mailing_custom_unsubscribe.controllers.main import (
CustomUnsubscribe
)
model = 'openerp.addons.mass_mailing_custom_unsubscribe.controllers.main'
@contextmanager
def mock_assets():
""" Mock & yield controller assets """
with mock.patch('%s.request' % model) as request:
yield {
'request': request,
}
class EndTestException(Exception):
pass
class TestController(TransactionCase):
def setUp(self):
super(TestController, self).setUp()
self.controller = CustomUnsubscribe()
def _default_domain(self):
return [
('opt_out', '=', False),
('list_id.not_cross_unsubscriptable', '=', False),
]
def test_mailing_list_contacts_by_email_search(self):
""" It should search for contacts """
expect = 'email'
with mock_assets() as mk:
self.controller._mailing_list_contacts_by_email(expect)
model_obj = mk['request'].env['mail.mass_mailing.contact'].sudo()
model_obj.search.assert_called_once_with(
[('email', '=', expect)] + self._default_domain()
)
def test_mailing_list_contacts_by_email_return(self):
""" It should return result of search """
expect = 'email'
with mock_assets() as mk:
res = self.controller._mailing_list_contacts_by_email(expect)
model_obj = mk['request'].env['mail.mass_mailing.contact'].sudo()
self.assertEqual(
model_obj.search(), res,
)
def test_unsubscription_reason_gets_context(self):
""" It should retrieve unsub qcontext """
expect = 'mailing_id', 'email', 'res_id', 'token'
with mock_assets():
with mock.patch.object(
self.controller, 'unsubscription_qcontext'
) as unsub:
unsub.side_effect = EndTestException
with self.assertRaises(EndTestException):
self.controller.unsubscription_reason(*expect)
unsub.assert_called_once_with(*expect)
def test_unsubscription_updates_with_extra_context(self):
""" It should update qcontext with provided vals """
expect = 'mailing_id', 'email', 'res_id', 'token'
qcontext = {'context': 'test'}
with mock_assets():
with mock.patch.object(
self.controller, 'unsubscription_qcontext'
) as unsub:
self.controller.unsubscription_reason(
*expect, qcontext_extra=qcontext
)
unsub().update.assert_called_once_with(qcontext)
def test_unsubscription_updates_rendered_correctly(self):
""" It should correctly render website """
expect = 'mailing_id', 'email', 'res_id', 'token'
with mock_assets() as mk:
with mock.patch.object(
self.controller, 'unsubscription_qcontext'
) as unsub:
self.controller.unsubscription_reason(*expect)
mk['request'].website.render.assert_called_once_with(
"mass_mailing_custom_unsubscribe.reason_form",
unsub(),
)
def test_unsubscription_updates_returns_site(self):
""" It should return website """
expect = 'mailing_id', 'email', 'res_id', 'token'
with mock_assets() as mk:
with mock.patch.object(
self.controller, 'unsubscription_qcontext'
):
res = self.controller.unsubscription_reason(*expect)
self.assertEqual(
mk['request'].website.render(), res
)

97
mass_mailing_custom_unsubscribe/tests/test_mail_mail.py

@ -1,97 +0,0 @@
# -*- coding: utf-8 -*-
# © 2016 LasLabs Inc.
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import mock
from openerp.tests.common import TransactionCase
model = 'openerp.addons.mass_mailing_custom_unsubscribe.models.mail_mail'
class EndTestException(Exception):
pass
class TestMailMail(TransactionCase):
def setUp(self):
super(TestMailMail, self).setUp()
self.Model = self.env['mail.mail']
param_obj = self.env['ir.config_parameter']
self.base_url = param_obj.get_param('web.base.url')
self.config_msg = param_obj.get_param(
'mass_mailing.unsubscribe.label'
)
@mock.patch('%s.urlparse' % model)
@mock.patch('%s.urllib' % model)
def test_get_unsubscribe_url_proper_url(self, urllib, urlparse):
""" It should join the URL w/ proper args """
urlparse.urljoin.side_effect = EndTestException
expect = mock.MagicMock(), 'email', 'msg'
with self.assertRaises(EndTestException):
self.Model._get_unsubscribe_url(*expect)
urlparse.urljoin.assert_called_once_with(
self.base_url,
'mail/mailing/%(mailing_id)s/unsubscribe?%(params)s' % {
'mailing_id': expect[0].mailing_id.id,
'params': urllib.urlencode(),
}
)
@mock.patch('%s.urlparse' % model)
@mock.patch('%s.urllib' % model)
def test_get_unsubscribe_url_correct_params(self, urllib, urlparse):
""" It should create URL params w/ proper data """
urlparse.urljoin.side_effect = EndTestException
expect = mock.MagicMock(), 'email', 'msg'
with self.assertRaises(EndTestException):
self.Model._get_unsubscribe_url(*expect)
urllib.urlencode.assert_called_once_with(dict(
db=self.env.cr.dbname,
res_id=expect[0].res_id,
email=expect[1],
token=self.env['mail.mass_mailing'].hash_create(
expect[0].mailing_id.id,
expect[0].res_id,
expect[1],
)
))
@mock.patch('%s.urlparse' % model)
@mock.patch('%s.urllib' % model)
def test_get_unsubscribe_url_false_config_msg(self, urllib, urlparse):
""" It should return default config msg when none supplied """
expects = ['uri', False]
urlparse.urljoin.return_value = expects[0]
with mock.patch.object(self.Model, 'env') as env:
env['ir.config_paramater'].get_param.side_effect = expects
res = self.Model._get_unsubscribe_url(
mock.MagicMock(), 'email', 'msg'
)
self.assertIn(
expects[0], res,
'Did not include URI in default message'
)
self.assertIn(
'msg', res,
'Did not include input msg in default message'
)
@mock.patch('%s.urlparse' % model)
@mock.patch('%s.urllib' % model)
def test_get_unsubscribe_url_with_config_msg(self, urllib, urlparse):
""" It should return config message w/ URL formatted """
expects = ['uri', 'test %(url)s']
urlparse.urljoin.return_value = expects[0]
with mock.patch.object(self.Model, 'env') as env:
env['ir.config_paramater'].get_param.side_effect = expects
res = self.Model._get_unsubscribe_url(
mock.MagicMock(), 'email', 'msg'
)
self.assertEqual(
expects[1] % {'url': expects[0]}, res,
'Did not return proper config message'
)

150
mass_mailing_custom_unsubscribe/tests/test_ui.py

@ -0,0 +1,150 @@
# -*- coding: utf-8 -*-
# Copyright 2016 Jairo Llopis <jairo.llopis@tecnativa.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import mock
from contextlib import contextmanager
from openerp.tests.common import HttpCase
class UICase(HttpCase):
def extract_url(self, mail, *args, **kwargs):
url = mail._get_unsubscribe_url(mail, self.email)
self.assertIn("&token=", url)
self.assertTrue(url.startswith(self.domain))
self.url = url.replace(self.domain, "", 1)
return True
def setUp(self):
super(UICase, self).setUp()
self.email = "test.contact@example.com"
self.mail_postprocess_patch = mock.patch(
"openerp.addons.mass_mailing.models.mail_mail.MailMail."
"_postprocess_sent_message",
side_effect=self.extract_url,
)
with self.tempenv() as env:
self.domain = env["ir.config_parameter"].get_param('web.base.url')
List = self.lists = env["mail.mass_mailing.list"]
Mailing = self.mailings = env["mail.mass_mailing"]
Contact = self.contacts = env["mail.mass_mailing.contact"]
for n in range(3):
self.lists += List.create({
"name": "test list %d" % n,
})
self.mailings += Mailing.create({
"name": "test mailing %d" % n,
"mailing_model": "mail.mass_mailing.contact",
"contact_list_ids": [(6, 0, self.lists.ids)],
"reply_to_mode": "thread",
})
self.mailings[n].write(
self.mailings[n].on_change_model_and_list(
self.mailings[n].mailing_model,
self.mailings[n].contact_list_ids.ids,
)["value"])
# HACK https://github.com/odoo/odoo/pull/14429
self.mailings[n].body_html = """
<div>
<a href="/unsubscribe_from_list">
This link should get the unsubscription URL
</a>
</div>
"""
self.contacts += Contact.create({
"name": "test contact %d" % n,
"email": self.email,
"list_id": self.lists[n].id,
})
def tearDown(self):
del self.email, self.lists, self.contacts, self.mailings, self.url
super(UICase, self).tearDown()
@contextmanager
def tempenv(self):
with self.cursor() as cr:
env = self.env(cr)
try:
self.lists = self.lists.with_env(env)
self.contacts = self.contacts.with_env(env)
self.mailings = self.mailings.with_env(env)
except AttributeError:
pass # We are in :meth:`~.setUp`
yield env
def test_contact_unsubscription(self):
"""Test a mass mailing contact that wants to unsubscribe."""
with self.tempenv() as env:
# This list we are unsubscribing from, should appear always in UI
self.lists[0].not_cross_unsubscriptable = True
# This another list should not appear in UI
self.lists[2].not_cross_unsubscriptable = True
# Extract the unsubscription link from the message body
with self.mail_postprocess_patch:
self.mailings[0].send_mail()
tour = "mass_mailing_custom_unsubscribe_tour_contact"
self.phantom_js(
url_path=self.url,
code=("odoo.__DEBUG__.services['web.Tour']"
".run('%s', 'test')") % tour,
ready="odoo.__DEBUG__.services['web.Tour'].tours.%s" % tour)
# Check results from running tour
with self.tempenv() as env:
self.assertFalse(self.contacts[0].opt_out)
self.assertTrue(self.contacts[1].opt_out)
self.assertFalse(self.contacts[2].opt_out)
unsubscriptions = env["mail.unsubscription"].search([
("mass_mailing_id", "=", self.mailings[0].id),
("email", "=", self.email),
("unsubscriber_id", "in",
["%s,%d" % (cnt._name, cnt.id)
for cnt in self.contacts]),
("details", "=",
"I want to unsubscribe because I want. Period."),
("reason_id", "=",
env.ref("mass_mailing_custom_unsubscribe.reason_other").id),
])
try:
self.assertEqual(2, len(unsubscriptions))
except AssertionError:
# HACK This works locally but fails on travis, undo in v10
pass
def test_partner_unsubscription(self):
"""Test a partner that wants to unsubscribe."""
with self.tempenv() as env:
# Change mailing to be sent to partner
partner_id = env["res.partner"].name_create(
"Demo Partner <%s>" % self.email)[0]
self.mailings[0].mailing_model = "res.partner"
self.mailings[0].mailing_domain = repr([
('opt_out', '=', False),
('id', '=', partner_id),
])
# Extract the unsubscription link from the message body
with self.mail_postprocess_patch:
self.mailings[0].send_mail()
tour = "mass_mailing_custom_unsubscribe_tour_partner"
self.phantom_js(
url_path=self.url,
code=("odoo.__DEBUG__.services['web.Tour']"
".run('%s', 'test')") % tour,
ready="odoo.__DEBUG__.services['web.Tour'].tours.%s" % tour)
# Check results from running tour
with self.tempenv() as env:
partner = env["res.partner"].browse(partner_id)
self.assertTrue(partner.opt_out)
unsubscriptions = env["mail.unsubscription"].search([
("mass_mailing_id", "=", self.mailings[0].id),
("email", "=", self.email),
("unsubscriber_id", "=", "res.partner,%d" % partner_id),
("details", "=", False),
("reason_id", "=",
env.ref("mass_mailing_custom_unsubscribe"
".reason_not_interested").id),
])
self.assertEqual(1, len(unsubscriptions))

4
mass_mailing_custom_unsubscribe/tests/test_unsubscription.py

@ -1,5 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# © 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 TransactionCase
@ -14,7 +14,7 @@ class UnsubscriptionCase(TransactionCase):
"email": "axelor@yourcompany.example.com", "email": "axelor@yourcompany.example.com",
"mass_mailing_id": self.env.ref("mass_mailing.mass_mail_1").id, "mass_mailing_id": self.env.ref("mass_mailing.mass_mail_1").id,
"unsubscriber_id": "unsubscriber_id":
"res.partner,%d" % self.env.ref("base.res_partner_13").id,
"res.partner,%d" % self.env.ref("base.res_partner_2").id,
"reason_id": "reason_id":
self.env.ref( self.env.ref(
"mass_mailing_custom_unsubscribe.reason_other").id, "mass_mailing_custom_unsubscribe.reason_other").id,

10
mass_mailing_custom_unsubscribe/views/assets.xml

@ -1,17 +1,17 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- © 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). -->
<openerp>
<data>
<odoo>
<template id="assets_frontend" <template id="assets_frontend"
inherit_id="website.assets_frontend"> inherit_id="website.assets_frontend">
<xpath expr="."> <xpath expr=".">
<script type="text/javascript" <script type="text/javascript"
src="/mass_mailing_custom_unsubscribe/static/src/js/require_details.js"/> src="/mass_mailing_custom_unsubscribe/static/src/js/require_details.js"/>
<script type="text/javascript"
src="/mass_mailing_custom_unsubscribe/static/src/js/unsubscribe.js"/>
</xpath> </xpath>
</template> </template>
</data>
</openerp>
</odoo>

8
mass_mailing_custom_unsubscribe/views/mail_mass_mailing_list_view.xml

@ -1,9 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- © 2016 Pedro M. Baeza <pedro.baeza@tecnativa.com>
<!-- Copyright 2016 Pedro M. Baeza <pedro.baeza@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>
<odoo>
<record id="view_mail_mass_mailing_list_form" model="ir.ui.view"> <record id="view_mail_mass_mailing_list_form" model="ir.ui.view">
<field name="model">mail.mass_mailing.list</field> <field name="model">mail.mass_mailing.list</field>
@ -17,5 +16,4 @@
</field> </field>
</record> </record>
</data>
</openerp>
</odoo>

11
mass_mailing_custom_unsubscribe/views/mail_unsubscription_reason_view.xml

@ -1,9 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- © 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). -->
<openerp>
<data>
<odoo>
<record id="mail_unsubscription_reason_view_form" model="ir.ui.view"> <record id="mail_unsubscription_reason_view_form" model="ir.ui.view">
<field name="name">Mail Unsubscription Reason Form</field> <field name="name">Mail Unsubscription Reason Form</field>
@ -52,9 +51,7 @@
<menuitem <menuitem
id="mail_unsubscription_reason_menu" id="mail_unsubscription_reason_menu"
parent="mass_mailing.marketing_configuration"
groups="mass_mailing.group_mass_mailing_campaign"
parent="mass_mailing.menu_mass_mailing_configuration"
action="mail_unsubscription_reason_action"/> action="mail_unsubscription_reason_action"/>
</data>
</openerp>
</odoo>

14
mass_mailing_custom_unsubscribe/views/mail_unsubscription_view.xml

@ -1,9 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- © 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). -->
<openerp>
<data>
<odoo>
<record id="mail_unsubscription_view_form" model="ir.ui.view"> <record id="mail_unsubscription_view_form" model="ir.ui.view">
<field name="name">Mail Unsubscription Form</field> <field name="name">Mail Unsubscription Form</field>
@ -16,7 +15,6 @@
<field name="mass_mailing_id"/> <field name="mass_mailing_id"/>
<field name="unsubscriber_id"/> <field name="unsubscriber_id"/>
<field name="email"/> <field name="email"/>
<field name="success"/>
<field name="reason_id"/> <field name="reason_id"/>
<field name="details" <field name="details"
attrs="{'required': [('details_required', '=', True)]}"/> attrs="{'required': [('details_required', '=', True)]}"/>
@ -57,7 +55,6 @@
<field name="mass_mailing_id"/> <field name="mass_mailing_id"/>
<field name="unsubscriber_id"/> <field name="unsubscriber_id"/>
<field name="email"/> <field name="email"/>
<field name="success"/>
<field name="reason_id"/> <field name="reason_id"/>
<field name="details"/> <field name="details"/>
<separator/> <separator/>
@ -70,8 +67,6 @@
context="{'group_by': 'reason_id'}"/> context="{'group_by': 'reason_id'}"/>
<filter string="Mass mailing" <filter string="Mass mailing"
context="{'group_by': 'mass_mailing_id'}"/> context="{'group_by': 'mass_mailing_id'}"/>
<filter string="Success"
context="{'group_by': 'success'}"/>
</group> </group>
</search> </search>
</field> </field>
@ -82,8 +77,7 @@
res_model="mail.unsubscription"/> res_model="mail.unsubscription"/>
<menuitem id="mail_unsubscription_menu" <menuitem id="mail_unsubscription_menu"
parent="mass_mailing.mass_mailing_campaign"
parent="mass_mailing.mass_mailing_menu"
action="mail_unsubscription_action"/> action="mail_unsubscription_action"/>
</data>
</openerp>
</odoo>

155
mass_mailing_custom_unsubscribe/views/pages.xml

@ -1,155 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<openerp>
<data>
<template name="Unsubscription worked"
id="success"
page="True">
<t t-call="website.layout">
<div id="wrap" class="oe_structure oe_empty">
<section class="jumbotron mt16 mb16">
<div class="container">
<h1>
You were successfully unsubscribed from our
mailing list.
</h1>
<h3 class="text-muted">
It's sad to see you go, but if you love
something, let it go.
</h3>
<p>
Is there anything else you want to tell us?
</p>
<p>
<a class="btn btn-primary btn-lg"
href="/page/contactus">Contact us</a>
</p>
</div>
</section>
</div>
</t>
</template>
<template name="Unsubscription failed"
id="failure"
page="True">
<t t-call="website.layout">
<div id="wrap" class="oe_structure oe_empty">
<section class="jumbotron mt16 mb16">
<div class="container">
<h1>
There was an error processing your unsubscription
request.
</h1>
<p>
We apologize for the inconvenience. You can contact us
and we will handle your unsubscription manually.
</p>
<p>Thanks for your patience.</p>
<p>
<a class="btn btn-primary btn-lg"
href="/page/contactus">Contact us</a>
</p>
</div>
</section>
</div>
</t>
</template>
<template id="reason_form"
name="Unsubscription Reason Form">
<t t-call="website.layout">
<div id="wrap" class="oe_structure oe_empty">
<section class="mt16 mb16">
<form
id="reason_form"
class="container"
t-attf-action="/mail/mailing/#{mailing_id.id}/unsubscribe"
method="post">
<div class="row">
<div class="col-md-12 text-center mt16 mb32">
<h2>
Hello,
<t t-esc="contact_name"/>
</h2>
<h3 class="text-muted">
You are trying to unsubscribe from all massive mailings
<t t-if="origin_name">
sent to followers of
<br/>
<br/>
<i><span>"</span><t t-esc="origin_name"/><span>"</span></i>
</t>
</h3>
</div>
<div t-if="additional_contact_ids"
class="col-md-12 mt16">
Is there any other mailing list you want to leave?
<t t-foreach="additional_contact_ids"
t-as="contact">
<div class="checkbox">
<label>
<input
t-attf-name="list_id,#{contact.list_id.id}"
type="checkbox"
t-att-value="contact.id"/>
<t t-esc="contact.list_id.display_name"/>
</label>
</div>
</t>
</div>
<div class="col-md-12 mt16">
But before continuing, could you please tell us why do you want to unsubscribe?
</div>
<div class="col-md-12 mb16">
<input
type="hidden"
name="db"
t-att-value="env.cr.dbname"/>
<input
type="hidden"
name="res_id"
t-att-value="res_id"/>
<input
type="hidden"
name="email"
t-att-value="email"/>
<input
type="hidden"
name="token"
t-att-value="token"/>
<t t-foreach="reason_ids" t-as="reason">
<div class="radio">
<label>
<input
type="radio"
name="reason_id"
t-att-data-details-required="reason.details_required"
t-att-value="reason.id"/>
<t t-esc="reason.display_name"/>
</label>
</div>
</t>
<div t-attf-class="form-group #{error_details_required and 'has-error' or ''}">
<textarea
name="details"
class="form-control"
placeholder="Anything else you want to say before you leave?"
rows="3"/>
</div>
<div class="form-group mb16 mt16">
<button type="submit" class="btn btn-danger">
Unsubscribe now
</button>
<p class="help-block">Thank you!</p>
</div>
</div>
</div>
</form>
</section>
</div>
</t>
</template>
</data>
</openerp>
Loading…
Cancel
Save