Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 95 additions & 1 deletion partner_communication/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Partner Communication
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:4ab6ac3d709e050b5b05223900a63985bf3602dd9f6d83da92ff0df0feab456b
!! source digest: sha256:9b0868bd802a6d20510757fdbbdd52fb70a6bfc091936d41f147ac219a7a4afc
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
Expand All @@ -32,6 +32,100 @@ efficient.
.. contents::
:local:

Usage
=====

Tracking mailings sent outside of Odoo
--------------------------------------

Communications carry the UTM fields of Odoo (Source, Medium, Campaign)
so that mailings sent outside of Odoo — through a printing house, for
instance — can be analysed together with the digital ones. The source of
a communication is its type, so only Medium and Campaign are shown and
configured.

A communication type is itself the UTM **source** of its communications.
Its default **medium** and **campaign** are set under *Campaign
Tracking* in the *General configuration* of its form, and can be refined
per language or per user in its *Custom configuration*. A communication
starts with the values of its type, and they can then be changed on the
communication itself.

A communication **created directly in the Done state** records a mailing
that was already dispatched: Odoo generates nothing and sends nothing
for it, and it is not merged into a pending communication. Its sending
date is filled in automatically when it is not given. Such a
communication keeps no content, since what was printed did not come from
Odoo.

Importing a recipient list
~~~~~~~~~~~~~~~~~~~~~~~~~~

To record a mailing that has already been dispatched, import the
recipient list sent to the printer from *Contacts → Partner
Communication → Communication Jobs*, with the standard **Import
records** button. Useful columns:

+-----------------+---------------------------------------------------+
| Column | Content |
+=================+===================================================+
| ``partner_id`` | Partner reference (the ``ref`` field), or the |
| | partner name |
+-----------------+---------------------------------------------------+
| ``config_id`` | Name of the communication type, see below for a |
| | new one |
+-----------------+---------------------------------------------------+
| ``state`` | ``Done`` for a mailing that was already |
| | dispatched |
+-----------------+---------------------------------------------------+
| ``send_mode`` | ``Print report`` for a letter (or the technical |
| | value ``physical``) |
+-----------------+---------------------------------------------------+
| ``subject`` | Optional — a readable label, otherwise the lines |
| | show no subject |
+-----------------+---------------------------------------------------+
| ``medium_id`` | Optional — defaults to the medium of the |
| | communication type |
+-----------------+---------------------------------------------------+
| ``campaign_id`` | Optional — defaults to the campaign of the |
| | communication type |
+-----------------+---------------------------------------------------+
| ``sent_date`` | Optional — dispatch date, defaults to the date of |
| | the import |
+-----------------+---------------------------------------------------+

An **empty cell is not the same as a missing column**: it sets the field
to empty instead of falling back on the default of the communication
type. To rely on the defaults, leave the column out of the file
entirely.

Prefer the partner **reference** over the name: a name is matched
through ``name_search``, which silently picks the first record when
several partners share it. Medium and campaign given by name must exist
beforehand, and their names must be unique for the same reason.

A mailing may deserve a **type of its own**, for instance a magazine
issue. Give its name in the ``config_id`` column and set the *Create new
values* option on that column in the import dialog: the type is created
for partners, printed, and **archived right away**, so that a one-off
mailing does not pile up in the types offered when creating a
communication. It remains visible in the history and groupings of
communications, and further imports with the same name reuse it.
Reactivate it from the archived types if it turns out to be recurring.
Without that option, a type that does not exist stops the import.

Without the ``state`` column, the lines are imported as regular pending
communications, ready to be sent by Odoo. As a safety net, an import
never sends anything on its own, whatever the state — importing a
recipient list is meant to record mailings, and a communication type set
to send automatically would otherwise dispatch the whole file. For the
same reason an imported line is never merged into an existing
communication, which may already be waiting to be sent: every line keeps
its own record.

Once imported, the communication list groups by Campaign and Medium, on
top of the existing grouping by communication type.

Bug Tracker
===========

Expand Down
2 changes: 1 addition & 1 deletion partner_communication/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
# pylint: disable=C8101
{
"name": "Partner Communication",
"version": "18.0.1.0.2",
"version": "18.0.1.0.3",
"category": "Other",
"author": "Compassion Switzerland",
"license": "AGPL-3",
Expand Down
63 changes: 58 additions & 5 deletions partner_communication/models/communication_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,15 @@

class CommunicationDefaults(models.AbstractModel):
"""Abstract class to share config settings between communication config
and communication job."""
and communication job.

It carries the UTM fields (source_id, medium_id, campaign_id) of utm.mixin: a
communication starts with the values of its type, or of the default config that
applies, and they can then be changed on the communication itself.
"""

_name = "partner.communication.defaults"
_inherit = "utm.mixin"
_description = "Communication Defaults"

user_id = fields.Many2one("res.users", "From", domain=[("share", "=", False)])
Expand Down Expand Up @@ -90,11 +96,14 @@ class CommunicationConfig(models.Model):
##########################################################################
# FIELDS #
##########################################################################
# Also the source_id of utm.mixin: the communication type is itself the UTM
# source of its communications.
source_id = fields.Many2one(
"utm.source",
"UTM Source",
required=True,
ondelete="restrict",
help="The communications of this type are tracked with this source.",
)
model_id = fields.Many2one(
"ir.model",
Expand Down Expand Up @@ -200,6 +209,45 @@ def _validate_attachment_function(self):
##########################################################################
# PUBLIC METHODS #
##########################################################################
@api.model
def name_create(self, name):
"""Create a communication type from its name alone.

This is what the import of communications calls when the file names a type
that does not exist yet and the "Create new values" option is set on the
column. Such a type only records a mailing dispatched outside of Odoo: it
applies to partners and is printed. The default implementation cannot be
used, since the display name of a type is its UTM source.

During an import, the type is created archived: a one-off mailing must not
pile up in the list of types offered when creating a communication. It stays
usable for the following lines and imports of the same name, which find it
here, and in the history and groupings of communications.
"""
name = (name or "").strip()
config = self.with_context(active_test=False).search(
[("name", "=", name)], limit=1
)
if config:
return config.id, config.display_name

# A UTM source of that name may already exist without a type. Reuse it:
# creating a source with the same name would number it "name [2]".
source = self.env["utm.source"].search([("name", "=", name)], limit=1)
vals = {"source_id": source.id} if source else {"name": name}
vals.update(
{
"model_id": self.env.ref("base.model_res_partner").id,
"send_mode": "physical",
"report_id": self.env.ref(
"partner_communication.report_a4_communication"
).id,
"active": not self.env.context.get("import_file"),
}
)
config = self.create(vals)
return config.id, config.display_name

def write(self, vals):
"""
Override write to handle email_template_id changes efficiently and
Expand Down Expand Up @@ -291,10 +339,15 @@ def build_inform_mode(
"""
send_priority = self._get_send_priority(partner, print_if_not_email)
if communication_send_mode != "partner_preference":
partner_mode = getattr(
partner,
send_mode_pref_field or "global_communication_delivery_preference",
partner.global_communication_delivery_preference,
partner_mode = (
getattr(
partner,
send_mode_pref_field or "global_communication_delivery_preference",
partner.global_communication_delivery_preference,
)
# An empty partner recordset (in the creation form, the config has a
# default value but no partner is selected yet) has no preference.
or "none"
)
auto_mode = self._get_auto_mode(partner_mode, communication_send_mode)
if communication_send_mode == partner_mode:
Expand Down
114 changes: 87 additions & 27 deletions partner_communication/models/communication_job.py
Original file line number Diff line number Diff line change
Expand Up @@ -345,36 +345,21 @@ def create(self, vals_list):
"""If a pending communication for same partner exists,
add the object_ids to it. Otherwise, create a new communication.
opt-out partners won't create any communication.

A communication created as done only records a mailing that was already
dispatched, outside of Odoo for instance: it is never merged, and nothing is
generated nor sent for it.
"""
updated = self.browse()
# A CSV import (`import_file` is set by base_import) never sends anything, even
# for pending communications: importing a recipient list is meant to record
# mailings, and a communication type set to send automatically would otherwise
# dispatch the whole file. It never merges either, so that it cannot touch a
# communication that already has a send task queued.
no_send = bool(self.env.context.get("import_file"))
for vals in vals_list.copy():
# Object ids accept lists, integer or string values. It should contain
# a comma separated list of integers
object_ids = vals.get("object_ids")
if isinstance(object_ids, list):
vals["object_ids"] = ",".join(map(str, object_ids))
elif object_ids:
vals["object_ids"] = str(object_ids)
else:
vals["object_ids"] = str(vals["partner_id"])

same_job_search = [
("partner_id", "=", vals.get("partner_id")),
("config_id", "=", vals.get("config_id")),
(
"config_id",
"!=",
self.env.ref("partner_communication.default_communication").id,
),
("state", "in", ["pending", "failure"]),
] + self.env.context.get("same_job_search", [])
job = self.search(same_job_search, limit=1)

if job and not job.config_id.forbid_merging:
job.object_ids = job.object_ids + "," + vals["object_ids"]
job.refresh_text()
if job.auto_send:
job.send()
job = self._prepare_create_vals(vals, no_send=no_send)
if job:
updated += job
vals_list.remove(vals)

Expand All @@ -401,6 +386,11 @@ def create(self, vals_list):
):
job.auto_send = send_mode[1]

if job.state == "done":
# The communication is only recorded for tracking purposes: skip
# attachments and PDF rendering, and never call nor send anything.
continue
Comment thread
loris-fab marked this conversation as resolved.

job.set_attachments()
if job.send_mode in ("both", "physical"):
job.count_pdf_page()
Expand All @@ -427,6 +417,73 @@ def create(self, vals_list):

return updated + created

def _prepare_create_vals(self, vals, no_send=False):
"""Normalise the values of a communication about to be created, and merge them
into an existing pending communication when possible.
:param vals: dict: record values, updated in place
:param no_send: nothing may be sent because of this creation, so never merge:
an existing communication may already have a send task queued,
which would then deliver these values along with it.
:return: the job the values were merged into, empty recordset if none.
"""
# Object ids accept lists, integer or string values. It should contain
# a comma separated list of integers
object_ids = vals.get("object_ids")
if isinstance(object_ids, list):
vals["object_ids"] = ",".join(map(str, object_ids))
elif object_ids:
vals["object_ids"] = str(object_ids)
else:
vals["object_ids"] = str(vals["partner_id"])

if no_send:
vals["auto_send"] = False

if "state" in vals and not vals["state"]:
# An empty cell in a CSV sets the field to False instead of leaving it out:
# fall back on the default state rather than create a stateless job.
del vals["state"]

if vals.get("state") == "done":
# The communication only records a mailing that was already dispatched,
# outside of Odoo for instance: it is never merged, and nothing may be
# generated nor sent for it.
if not vals.get("sent_date"):
vals["sent_date"] = fields.Datetime.now()
vals["auto_send"] = False
return self.browse()

if no_send:
return self.browse()

return self._merge_into_pending_job(vals)

def _merge_into_pending_job(self, vals):
"""Look for a pending communication of the same partner and type in which the
values being created can be merged, and merge them into it.
:param vals: dict: record values
:return: the job the values were merged into, empty recordset if none was found.
"""
same_job_search = [
("partner_id", "=", vals.get("partner_id")),
("config_id", "=", vals.get("config_id")),
(
"config_id",
"!=",
self.env.ref("partner_communication.default_communication").id,
),
("state", "in", ["pending", "failure"]),
] + self.env.context.get("same_job_search", [])
job = self.search(same_job_search, limit=1)
if not job or job.config_id.forbid_merging:
return self.browse()

job.object_ids = job.object_ids + "," + vals["object_ids"]
job.refresh_text()
if job.auto_send:
job.send()
return job

@api.model
def _get_dynamic_user(self, config, object_ids_str):
"""
Expand Down Expand Up @@ -486,6 +543,9 @@ def _get_default_vals(self, vals, default_vals=None):
"report_id",
"need_call",
"print_if_not_email",
"source_id",
"medium_id",
"campaign_id",
]
)

Expand Down
Loading
Loading