From 6b3ef0895ce797a53956c6033ee1b2e5af5bbd67 Mon Sep 17 00:00:00 2001 From: loris-fab Date: Wed, 5 Aug 2026 17:40:12 +0200 Subject: [PATCH 1/3] [T3305] FEAT: Track UTM campaigns on communications Campaign analysis only covered digital interactions, while physical mailings appear to be more efficient. Communications can now carry the UTM parameters of the campaign they belong to, including the ones dispatched outside of Odoo. - Add Source, Medium and Campaign on the communication and on its type, the communication starting with the values configured on its type. - Record mailings sent by a printing house by importing the recipient list as a CSV: a communication created as done is never merged into a pending one, and nothing is generated nor sent for it. As a safety net, an import never sends anything on its own, whatever the state of the imported lines. - Move utm_campaign_id down from partner_communication_compassion, where it was declared but referenced nowhere, so the base module carries all three. - Fix a crash when opening the communication creation form: the type has a default value but no partner is selected yet, and build_inform_mode was iterating over the delivery preference of an empty partner. --- partner_communication/README.rst | 79 ++++++++++++ partner_communication/__manifest__.py | 2 +- .../models/communication_config.py | 18 ++- .../models/communication_job.py | 117 ++++++++++++++---- partner_communication/readme/USAGE.md | 47 +++++++ .../static/description/index.html | 112 +++++++++++++++-- .../views/communication_config_view.xml | 5 + .../views/communication_job_view.xml | 30 +++++ .../__manifest__.py | 2 +- .../models/partner_communication.py | 2 - 10 files changed, 371 insertions(+), 43 deletions(-) create mode 100644 partner_communication/readme/USAGE.md diff --git a/partner_communication/README.rst b/partner_communication/README.rst index ecac055bc..4aa95b5ff 100644 --- a/partner_communication/README.rst +++ b/partner_communication/README.rst @@ -32,6 +32,85 @@ efficient. .. contents:: :local: +Usage +===== + +Tracking mailings sent outside of Odoo +-------------------------------------- + +Communications carry three UTM fields (Source, Medium, Campaign) so that +mailings sent outside of Odoo — through a printing house, for instance — +can be analysed together with the digital ones. + +Each communication type carries its own **Campaign Tracking** defaults, +in the *General configuration* of its form. 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 | ++---------------------+-----------------------------------------------+ +| ``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 | ++---------------------+-----------------------------------------------+ +| ``utm_source_id`` | Optional — defaults to the source of the | +| | communication type | ++---------------------+-----------------------------------------------+ +| ``utm_medium_id`` | Optional — defaults to the medium of the | +| | communication type | ++---------------------+-----------------------------------------------+ +| ``utm_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. UTM records given by name must exist +beforehand, and their names must be unique for the same reason. + +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. + +Once imported, the communication list groups by Campaign, Medium and +Source. + Bug Tracker =========== diff --git a/partner_communication/__manifest__.py b/partner_communication/__manifest__.py index b0d020061..44b156191 100644 --- a/partner_communication/__manifest__.py +++ b/partner_communication/__manifest__.py @@ -30,7 +30,7 @@ # pylint: disable=C8101 { "name": "Partner Communication", - "version": "18.0.1.0.1", + "version": "18.0.1.0.2", "category": "Other", "author": "Compassion Switzerland", "license": "AGPL-3", diff --git a/partner_communication/models/communication_config.py b/partner_communication/models/communication_config.py index 2e5a05e58..54d1fd0e1 100644 --- a/partner_communication/models/communication_config.py +++ b/partner_communication/models/communication_config.py @@ -136,6 +136,11 @@ class CommunicationConfig(models.Model): forbid_merging = fields.Boolean( help="If selected, disable the automatic merging of communications", ) + # Values the communications of this type start with. They are only defaults: they + # are copied on the job at creation and can be changed on it afterwards. + utm_source_id = fields.Many2one("utm.source", "Default Source") + utm_medium_id = fields.Many2one("utm.medium", "Default Medium") + utm_campaign_id = fields.Many2one("utm.campaign", "Default Campaign") active = fields.Boolean(default=True) send_from = fields.Selection( [ @@ -291,10 +296,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: diff --git a/partner_communication/models/communication_job.py b/partner_communication/models/communication_job.py index ab5da275e..32db6c8f0 100644 --- a/partner_communication/models/communication_job.py +++ b/partner_communication/models/communication_job.py @@ -185,6 +185,14 @@ class CommunicationJob(models.Model): sms_cost = fields.Float() + # Campaign tracking. Defaults to the values of the communication type, and can then + # be changed on the communication itself. + utm_source_id = fields.Many2one("utm.source", "Source", index="btree_not_null") + utm_medium_id = fields.Many2one("utm.medium", "Medium", index="btree_not_null") + utm_campaign_id = fields.Many2one( + "utm.campaign", "Campaign", index="btree_not_null" + ) + def _compute_ir_attachments(self): for job in self: job.ir_attachment_ids = job.mapped("attachment_ids.attachment_id") @@ -343,36 +351,20 @@ 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. + 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) @@ -399,6 +391,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 + job.set_attachments() if job.send_mode in ("both", "physical"): job.count_pdf_page() @@ -425,6 +422,69 @@ 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: never send anything while creating the communication. + :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() + + return self._merge_into_pending_job(vals, no_send=no_send) + + def _merge_into_pending_job(self, vals, no_send=False): + """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 + :param no_send: don't send the job even if it is set to be sent automatically. + :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 and not no_send: + job.send() + return job + @api.model def _get_dynamic_user(self, config, object_ids_str): """ @@ -484,6 +544,9 @@ def _get_default_vals(self, vals, default_vals=None): "report_id", "need_call", "print_if_not_email", + "utm_source_id", + "utm_medium_id", + "utm_campaign_id", ] ) diff --git a/partner_communication/readme/USAGE.md b/partner_communication/readme/USAGE.md new file mode 100644 index 000000000..b850164ec --- /dev/null +++ b/partner_communication/readme/USAGE.md @@ -0,0 +1,47 @@ +## Tracking mailings sent outside of Odoo + +Communications carry three UTM fields (Source, Medium, Campaign) so that mailings sent +outside of Odoo — through a printing house, for instance — can be analysed together with +the digital ones. + +Each communication type carries its own **Campaign Tracking** defaults, in the *General +configuration* of its form. 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 | +| `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 | +| `utm_source_id` | Optional — defaults to the source of the communication type | +| `utm_medium_id` | Optional — defaults to the medium of the communication type | +| `utm_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. UTM records given by +name must exist beforehand, and their names must be unique for the same reason. + +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. + +Once imported, the communication list groups by Campaign, Medium and Source. diff --git a/partner_communication/static/description/index.html b/partner_communication/static/description/index.html index 38bcc5aea..458b022e8 100644 --- a/partner_communication/static/description/index.html +++ b/partner_communication/static/description/index.html @@ -377,16 +377,112 @@

Partner Communication

Table of contents

+
+

Usage

+
+

Tracking mailings sent outside of Odoo

+

Communications carry three UTM fields (Source, Medium, Campaign) so that +mailings sent outside of Odoo — through a printing house, for instance — +can be analysed together with the digital ones.

+

Each communication type carries its own Campaign Tracking defaults, +in the General configuration of its form. 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:

+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ColumnContent
partner_idPartner reference (the ref field), or the +partner name
config_idName of the communication type
stateDone for a mailing that was already +dispatched
send_modePrint report for a letter (or the +technical value physical)
subjectOptional — a readable label, otherwise the +lines show no subject
utm_source_idOptional — defaults to the source of the +communication type
utm_medium_idOptional — defaults to the medium of the +communication type
utm_campaign_idOptional — defaults to the campaign of the +communication type
sent_dateOptional — 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. UTM records given by name must exist +beforehand, and their names must be unique for the same reason.

+

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.

+

Once imported, the communication list groups by Campaign, Medium and +Source.

+
+
-

Bug Tracker

+

Bug Tracker

Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed @@ -394,15 +490,15 @@

Bug Tracker

Do not contact contributors directly about support or help with technical issues.

-

Credits

+

Credits

-

Authors

+

Authors

  • Compassion Switzerland
-

Maintainers

+

Maintainers

This module is part of the CompassionCH/compassion-modules project on GitHub.

You are welcome to contribute.

diff --git a/partner_communication/views/communication_config_view.xml b/partner_communication/views/communication_config_view.xml index 5fdafe0bd..92b89de37 100644 --- a/partner_communication/views/communication_config_view.xml +++ b/partner_communication/views/communication_config_view.xml @@ -41,6 +41,11 @@ + + + + + + + + + + @@ -217,6 +222,9 @@ + + +