diff --git a/partner_communication/README.rst b/partner_communication/README.rst index ecac055bc..f380c24ba 100644 --- a/partner_communication/README.rst +++ b/partner_communication/README.rst @@ -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 @@ -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 =========== diff --git a/partner_communication/__manifest__.py b/partner_communication/__manifest__.py index 44b156191..1749105d3 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.2", + "version": "18.0.1.0.3", "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..14f68feb8 100644 --- a/partner_communication/models/communication_config.py +++ b/partner_communication/models/communication_config.py @@ -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)]) @@ -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", @@ -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 @@ -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: diff --git a/partner_communication/models/communication_job.py b/partner_communication/models/communication_job.py index 8b588ac5f..693aa8c75 100644 --- a/partner_communication/models/communication_job.py +++ b/partner_communication/models/communication_job.py @@ -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) @@ -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 + job.set_attachments() if job.send_mode in ("both", "physical"): job.count_pdf_page() @@ -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): """ @@ -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", ] ) diff --git a/partner_communication/readme/USAGE.md b/partner_communication/readme/USAGE.md new file mode 100644 index 000000000..e3088054a --- /dev/null +++ b/partner_communication/readme/USAGE.md @@ -0,0 +1,60 @@ +## 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. diff --git a/partner_communication/static/description/index.html b/partner_communication/static/description/index.html index 38bcc5aea..0df5d720d 100644 --- a/partner_communication/static/description/index.html +++ b/partner_communication/static/description/index.html @@ -2,19 +2,18 @@
- +