diff --git a/eslint.config.cjs b/eslint.config.cjs index 4b70427a0..32e34594a 100644 --- a/eslint.config.cjs +++ b/eslint.config.cjs @@ -191,7 +191,7 @@ const config = [{ }, }, { - files: ["**/*.esm.js"], + files: ["**/*.js"], languageOptions: { ecmaVersion: 2024, @@ -201,6 +201,11 @@ const config = [{ "setTimeout": "readonly", "clearTimeout": "readonly", "fetch": "readonly", + "location": "readonly", + "sessionStorage": "readonly", + "File": "readonly", + "DataTransfer": "readonly", + "Event": "readonly", } }, }]; diff --git a/interaction_resume/__manifest__.py b/interaction_resume/__manifest__.py index bcff9b38c..6234eaa8a 100644 --- a/interaction_resume/__manifest__.py +++ b/interaction_resume/__manifest__.py @@ -26,6 +26,9 @@ "interaction_resume/static/src/xml/**/*.xml", "interaction_resume/static/src/js/**/*.js", ], + "web.assets_tests": [ + "interaction_resume/static/tests/tours/interaction_resume.js", + ], }, "external_dependencies": { "python": [], diff --git a/interaction_resume/models/abstract_interaction_source.py b/interaction_resume/models/abstract_interaction_source.py index 3b523d81e..227c4c69f 100644 --- a/interaction_resume/models/abstract_interaction_source.py +++ b/interaction_resume/models/abstract_interaction_source.py @@ -53,6 +53,14 @@ def _get_interaction_data(self, partner_id): for rec in self ] + def _interaction_discriminator(self, vals): + """What tells apart the several resume entries built from one record. + + Nothing for a source that builds a single entry per record: the + record it was built from already tells its entry from any other. + """ + return () + def create(self, vals_list): res = super().create(vals_list) for partner in res.mapped("partner_id"): diff --git a/interaction_resume/models/crm_phonecall.py b/interaction_resume/models/crm_phonecall.py index d110ae7d8..a541020e0 100644 --- a/interaction_resume/models/crm_phonecall.py +++ b/interaction_resume/models/crm_phonecall.py @@ -28,6 +28,7 @@ def _get_interaction_data(self, partner_id): "communication_type": "Phone", "subject": rec.name, "body": rec.description or rec.name, + "has_attachment": bool(rec.message_attachment_count), "tracking_status": TRACKING_STATUS_MAPPING.get(rec.state), "user_id": rec.user_id.id, } diff --git a/interaction_resume/models/crm_request.py b/interaction_resume/models/crm_request.py index 7ce80698c..7877c9040 100644 --- a/interaction_resume/models/crm_request.py +++ b/interaction_resume/models/crm_request.py @@ -1,6 +1,6 @@ from datetime import timedelta -from odoo import api, models +from odoo import api, fields, models from odoo.tools.mail import html2plaintext @@ -66,6 +66,14 @@ def _get_interaction_data(self, partner_id): ) return res + def _interaction_discriminator(self, vals): + # A claim yields one entry per message of its thread, plus one for + # the form it came from, all built from the claim itself. + return ( + fields.Datetime.to_datetime(vals.get("date")) or False, + vals.get("subject") or False, + ) + def _get_interaction_partner_domain(self, partner): if not partner.email: return [("partner_id", "=", partner.id)] diff --git a/interaction_resume/models/interaction_resume.py b/interaction_resume/models/interaction_resume.py index be8824015..840e12e2a 100644 --- a/interaction_resume/models/interaction_resume.py +++ b/interaction_resume/models/interaction_resume.py @@ -77,8 +77,7 @@ def open_related_action(self): } def action_refresh(self): - partner = self.mapped("partner_id")[:1] - partner.fetch_interactions() + self.mapped("partner_id")[:1].refresh_interactions() return True def fetch_more(self): @@ -86,37 +85,63 @@ def fetch_more(self): partner.fetch_interactions(page=partner.last_interaction_fetch_page + 1) return True + def _identity_of(self, vals): + """What tells one entry of a resume from another.""" + source = self.env[vals["res_model"]] + return ( + vals.get("partner_id") or False, + source._name, + vals.get("res_id") or False, + ) + source._interaction_discriminator(vals) + + def _identity(self): + self.ensure_one() + return self._identity_of( + { + "partner_id": self.partner_id.id, + "res_model": self.res_model, + "res_id": self.res_id, + "date": self.date, + "subject": self.subject, + } + ) + + def _update_from_source(self, vals): + self.ensure_one() + changed = { + field: value + for field, value in vals.items() + if self._fields[field].convert_to_write(self[field], self) != value + } + if changed: + self.write(changed) + return self + @api.model_create_multi def create(self, vals_list): - # Avoid duplicates - res = self.env[self._name] - for vals in vals_list: - subject = vals.get("subject") - if not subject: - existing_interaction = self.search( - [ - ("partner_id", "=", vals.get("partner_id")), - ("direction", "=", vals.get("direction")), - ("date", "=", vals.get("date")), - ("subject", "=", False), - ], - limit=1, - ) - if not existing_interaction: - res += super().create(vals) - else: - res += existing_interaction - continue - existing_interaction = self.search( + if not vals_list: + return self.browse() + partners = {vals.get("partner_id") for vals in vals_list} + res_models = {vals.get("res_model") for vals in vals_list} + listed = { + entry._identity(): entry + for entry in self.search( [ - ("partner_id", "=", vals.get("partner_id")), - ("direction", "=", vals.get("direction")), - ("date", "=", vals.get("date")), - ("subject", "=", subject), - ], - limit=1, + ("partner_id", "in", list(partners)), + ("res_model", "in", list(res_models)), + ] ) - if not existing_interaction: - existing_interaction = super().create(vals) - res += existing_interaction - return res + } + res = self.browse() + to_create = [] + for vals in vals_list: + identity = self._identity_of(vals) + entry = listed.get(identity) + if entry: + res += entry._update_from_source(vals) + elif identity not in listed: + # Mark it as taken, so that a duplicate later in the same + # batch does not create a second entry for it. + listed[identity] = None + to_create.append(vals) + return res + super().create(to_create) diff --git a/interaction_resume/models/other_interaction.py b/interaction_resume/models/other_interaction.py index 89fb2907e..792479b1b 100644 --- a/interaction_resume/models/other_interaction.py +++ b/interaction_resume/models/other_interaction.py @@ -47,6 +47,7 @@ def _get_interaction_data(self, partner_id): "body": html2plaintext(rec.body).replace("\n\n", "\n"), "subject": rec.subject, "other_type": rec.other_type, + "has_attachment": bool(rec.message_attachment_count), "user_id": rec.create_uid.id, } for rec in self @@ -54,6 +55,6 @@ def _get_interaction_data(self, partner_id): def write(self, vals): res = super().write(vals) - # Refresh interaction resume - self.mapped("partner_id").reset_interactions() + if not self._transient: + self.mapped("partner_id").refresh_interactions() return res diff --git a/interaction_resume/models/res_partner.py b/interaction_resume/models/res_partner.py index 58a48e337..029f4b80d 100644 --- a/interaction_resume/models/res_partner.py +++ b/interaction_resume/models/res_partner.py @@ -84,6 +84,13 @@ def fetch_interactions( self.last_interaction_fetch_page = page return True + def refresh_interactions(self): + """Fetch again the interactions of every page already loaded""" + for partner in self: + for page in range(partner.last_interaction_fetch_page + 1): + partner.fetch_interactions(page=page) + return True + def reset_interactions(self): """Reset the interaction resume for this partner""" self.mapped("interaction_resume_ids").unlink() diff --git a/interaction_resume/static/tests/tours/interaction_resume.js b/interaction_resume/static/tests/tours/interaction_resume.js new file mode 100644 index 000000000..543143f04 --- /dev/null +++ b/interaction_resume/static/tests/tours/interaction_resume.js @@ -0,0 +1,500 @@ +import { registry } from "@web/core/registry"; +import { stepUtils } from "@web_tour/tour_service/tour_utils"; + +const CONTACT_URL_KEY = "interaction_resume.tour_contact_url"; +if (/^\/odoo\/contacts\/\d+$/.test(location.pathname)) { + sessionStorage.setItem(CONTACT_URL_KEY, location.pathname); +} +const CONTACT_URL = sessionStorage.getItem(CONTACT_URL_KEY) || "/odoo/contacts"; + +/** A date in the past, written the way the en_US user interface expects it. */ +const pastDateTime = (daysAgo, hour, minute) => + luxon.DateTime.now() + .minus({ days: daysAgo }) + .set({ hour, minute, second: 0, millisecond: 0 }) + .toFormat("MM/dd/yyyy HH:mm:ss"); + +/** The file the tour attaches to the interaction it logs. */ +const ATTACHMENT_NAME = "letter-of-the-sponsor.txt"; + +/** The interaction resume is on screen and ready to be read. */ +const RESUME_READY = ".o_list_view .o_list_buttons button.btn-refresh"; + +const isIncoming = (direction) => + direction === "in" || direction === "Incoming"; +const arrowOf = (direction) => (isIncoming(direction) ? "down" : "up"); +const colourOf = ({ direction, type }) => { + if (type === "Mass") { + return "text-muted"; + } + if (isIncoming(direction)) { + return "text-danger"; + } + return ["Phone", "SMS"].includes(type) ? "text-info" : "text-success"; +}; + +// Interactions logged by hand from the "Log interaction" action. Together they +// cover both directions, a plain type and a free type, and three send modes. +const LETTER = { + type: "Paper", + direction: "Incoming", + date: pastDateTime(3, 9, 15), + subject: "Handwritten letter received at the office", + body: "The sponsor wrote to us to ask for news of the sponsored child.", + attachment: ATTACHMENT_NAME, +}; +const LETTER_REWRITTEN = `${LETTER.body} They also asked for a photograph.`; +const ANSWER = { + type: "Email", + direction: "Outgoing", + date: pastDateTime(2, 11, 30), + subject: "Answer written about the sponsored child", + body: "We answered the sponsor and told how the sponsored child is doing.", +}; +const VISIT = { + type: "Other", + otherType: "Visit at the office", + direction: "Incoming", + date: pastDateTime(1, 16, 5), + subject: "The sponsor came to visit us", + body: "The sponsor came to the office and we handed over the child folder.", +}; + +const INCOMING_CALL = { + type: "Phone", + direction: "in", + date: pastDateTime(4, 10, 0), + subject: "The sponsor called about the payment date", + body: "The sponsor asked to move the payment to the end of the month.", +}; +const OUTGOING_CALL = { + type: "Phone", + direction: "out", + date: pastDateTime(2, 15, 45), + subject: "We called the sponsor back about the payment date", + body: "We confirmed the payment is now taken on the 25th of each month.", +}; + +// Communications generated for the contact and sent from their form. +const EMAIL_COMMUNICATION = { + type: "Email", + sendMode: "By e-mail", + direction: "Outgoing", + subject: "Confirmation of the new payment date", + body: "Dear sponsor, your payment is now taken on the 25th of each month.", +}; +const PRINTED_COMMUNICATION = { + type: "Paper", + sendMode: "Print report", + direction: "Outgoing", + subject: "Yearly news of the sponsored child", + body: "Dear sponsor, here are the yearly news of the child you support.", +}; + +/** Goes back to the contact the tour was started on. */ +const goToContact = () => [ + stepUtils.goToUrl(CONTACT_URL), + { + content: "The form of the contact is displayed", + trigger: ".o_form_view button[name=open_interaction]", + }, +]; + +/** Picks an entry of the cog menu of the contact. */ +const runContactAction = (label) => [ + { + content: "Open the action menu of the contact", + trigger: ".o_form_view .o_cp_action_menus i.fa-cog", + run: "click", + }, + { + content: `Pick "${label}" in the action menu`, + trigger: `.o-dropdown--menu span:contains("${label}")`, + run: "click", + }, +]; + +/** Logs one interaction through the "Log interaction" wizard. */ +const logInteraction = (interaction) => [ + ...runContactAction("Log interaction"), + { + content: "The wizard that logs an interaction is open", + trigger: ".modal div[name=communication_type] select", + }, + { + content: `The interaction was a ${interaction.type} one`, + trigger: ".modal div[name=communication_type] select", + run: `selectByLabel ${interaction.type}`, + }, + ...(interaction.otherType + ? [ + { + content: "Describe what kind of interaction it was", + trigger: ".modal div[name=other_type] input", + run: `edit ${interaction.otherType}`, + }, + ] + : []), + { + content: `The interaction was ${interaction.direction}`, + trigger: ".modal div[name=direction] select", + run: `selectByLabel ${interaction.direction}`, + }, + { + content: "Change the date and the time of the interaction", + trigger: ".modal div[name=date] input", + run: `edit ${interaction.date}`, + }, + { + content: "Write the subject of the interaction", + trigger: ".modal div[name=subject] input", + run: `edit ${interaction.subject}`, + }, + { + content: "Focus the text of the interaction", + trigger: ".modal div[name=body] .odoo-editor-editable", + run: "click", + }, + { + content: "Write down what was exchanged with the sponsor", + trigger: ".modal div[name=body] .odoo-editor-editable", + run: `editor ${interaction.body}`, + }, + ...(interaction.attachment + ? [ + { + content: "Attach the scan of the letter to the interaction", + trigger: ".modal .oe_fileupload .o_file_input_trigger", + async run() { + // The file input the widget uploads through is hidden, so a tour + // cannot target it: reach it from the button next to it. + const input = this.anchor + .closest(".o_file_input") + .querySelector("input.o_input_file"); + const file = new File( + ["Scan of the letter the sponsor sent us."], + interaction.attachment, + { type: "text/plain" }, + ); + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(file); + input.files = dataTransfer.files; + input.dispatchEvent(new Event("change", { bubbles: true })); + }, + }, + { + content: "The file is attached to the interaction", + trigger: `.modal .o_attachment:contains("${interaction.attachment}")`, + }, + ] + : []), + { + content: "Log the interaction", + trigger: ".modal button[name=log_interaction]", + run: "click", + }, + { + content: "The wizard is closed and the contact is displayed again", + trigger: + "body:not(:has(.modal)) .o_form_view button[name=open_interaction]", + }, +]; + +/** Logs one phone call through the "Log your call" action. */ +const logCall = (call) => [ + ...runContactAction("Log your call"), + { + content: "The form that logs a call is open on a call that was held", + trigger: + ".modal .o_statusbar_status button.o_arrow_button_current:contains(Held)", + }, + { + content: "Write the subject of the call", + trigger: ".modal div[name=name] input", + run: `edit ${call.subject}`, + }, + { + content: "Change the date and the time of the call", + trigger: ".modal div[name=date] input", + run: `edit ${call.date}`, + }, + { + content: `The call was ${call.direction === "in" ? "received" : "made"}`, + trigger: `.modal div[name=direction] input[data-value=${call.direction}]`, + run: "click", + }, + { + content: "Write down what was said during the call", + trigger: ".modal div[name=description] textarea", + run: `edit ${call.body}`, + }, + { + content: "Save the call", + trigger: ".modal-footer .o_form_button_save", + run: "click", + }, + { + content: "The dialog is closed and the contact is displayed again", + trigger: + "body:not(:has(.modal)) .o_form_view button[name=open_interaction]", + }, +]; + +/** Opens the interaction resume from the form of the contact. */ +const openResume = () => [ + stepUtils.autoExpandMoreButtons(), + { + content: "Open the interaction resume of the contact", + trigger: ".o_form_view button[name=open_interaction]", + run: "click", + }, + { + content: "The interaction resume is displayed", + trigger: RESUME_READY, + }, +]; + +/** Asks the interaction resume to fetch the interactions again. */ +const refreshResume = () => [ + { + content: "Refresh the interaction resume", + trigger: RESUME_READY, + run: "click", + }, + { + content: "The interaction resume is displayed again", + trigger: ".o_list_view .o_data_row", + }, +]; + +/** Leaves whatever was opened from the resume and comes back to it. */ +const backToResume = () => [ + { + content: "Go back to the interaction resume", + trigger: ".o_control_panel .breadcrumb-item.o_back_button", + run: "click", + }, + { + content: "The interaction resume is displayed again", + trigger: RESUME_READY, + }, +]; + +/** + * Checks that one interaction is listed, that its colour and its arrow tell + * which way it went, and that its text can be read on its own form. + */ +const checkInResume = (interaction) => [ + { + content: `"${interaction.subject}" is listed in the interaction resume`, + trigger: `.o_data_row:contains("${interaction.subject}")`, + }, + { + content: `Its colour says the interaction was ${interaction.direction}`, + trigger: + `tr.o_data_row.${colourOf(interaction)}` + + `:contains("${interaction.subject}")`, + }, + { + content: `Its arrow says the interaction was ${interaction.direction}`, + trigger: + `.o_data_row:contains("${interaction.subject}") ` + + `button[name=${isIncoming(interaction.direction) ? "in" : "out"}] ` + + `.fa-arrow-${arrowOf(interaction.direction)}`, + }, + { + content: "Open the interaction", + trigger: `.o_data_row:contains("${interaction.subject}") td[name=subject]`, + run: "click", + }, + { + content: "The interaction carries the subject it was logged with", + trigger: `.o_form_view div[name=subject]:contains("${interaction.subject}")`, + }, + { + content: "The text of the interaction can be read", + trigger: `.o_form_view div[name=body]:contains("${interaction.body}")`, + }, + ...backToResume(), +]; + +const checkAttachmentInResume = (withFile, withoutFile) => [ + { + content: `The resume shows that "${withFile.subject}" carries a file`, + trigger: `.o_data_row:contains("${withFile.subject}") button .fa-paperclip`, + }, + ...withoutFile.map((interaction) => ({ + content: `The resume shows no file on "${interaction.subject}"`, + trigger: + `.o_data_row:contains("${interaction.subject}")` + + ":not(:has(.fa-paperclip))", + })), +]; + +/** + * Rewrites the text of an interaction from the resume and comes back to it. + * An entry of the resume is the record it was built from, so it has to be + * still there afterwards, carrying what was just written. + */ +const rewriteFromResume = (interaction, text) => [ + { + content: `Open the entry of "${interaction.subject}"`, + trigger: `.o_data_row:contains("${interaction.subject}") td[name=subject]`, + run: "click", + }, + { + content: "Open the interaction the entry was built from", + trigger: ".o_form_view button[name=open_related_action]", + run: "click", + }, + { + content: "The interaction that was logged is displayed", + trigger: `.o_form_view div[name=subject] input:value("${interaction.subject}")`, + }, + { + content: "Focus the text of the interaction", + trigger: ".o_form_view div[name=body] .odoo-editor-editable", + run: "click", + }, + { + content: "Write down what was left out the first time", + trigger: ".o_form_view div[name=body] .odoo-editor-editable", + run: `editor ${text}`, + }, + ...stepUtils.saveForm(), + { + content: "Go back to the entry of the resume", + trigger: ".o_control_panel .breadcrumb-item.o_back_button", + run: "click", + }, + { + content: "The entry is still there, and carries what was just written", + trigger: `.o_form_view div[name=body]:contains("${text}")`, + }, + ...backToResume(), +]; + +/** Checks that the chatter of the contact holds no note about an interaction. */ +const checkNotLogged = (interactions) => [ + ...goToContact(), + { + content: "The messages of the contact are displayed", + trigger: ".o-mail-Chatter .o-mail-Thread", + }, + ...interactions.map((interaction) => ({ + content: `No note was left about "${interaction.subject}"`, + trigger: `.o-mail-Thread:not(:has(.o-mail-Message:contains("${interaction.subject}")))`, + })), +]; + +/** + * Creates a communication for the contact and sends it. The communications of + * the contact are reached from their form, which opens the very same creation + * form as the Communications menu does. + */ +const sendCommunication = (communication) => [ + ...goToContact(), + stepUtils.autoExpandMoreButtons(), + { + content: "Open the communications of the contact", + trigger: ".o_form_view button.oe_stat_button:contains(Communications)", + run: "click", + }, + { + content: "Create a new communication", + trigger: ".o_control_panel_main_buttons .o_list_button_add", + run: "click", + }, + { + content: "The communication is addressed to the contact", + trigger: ".o_form_view div[name=partner_id] input:not(:value(''))", + }, + { + content: `The communication goes out as "${communication.sendMode}"`, + trigger: ".o_form_view div[name=send_mode] select", + run: `selectByLabel ${communication.sendMode}`, + }, + ...stepUtils.saveForm(), + { + content: "Write the subject of the communication", + trigger: ".o_form_view div[name=subject] input", + run: `edit ${communication.subject}`, + }, + { + content: "Focus the text of the communication", + trigger: ".o_form_view div[name=body_html] .odoo-editor-editable", + run: "click", + }, + { + content: "Write the text of the communication", + trigger: ".o_form_view div[name=body_html] .odoo-editor-editable", + run: `editor ${communication.body}`, + }, + ...stepUtils.saveForm(), + { + content: "Send the communication", + trigger: ".o_form_view .o_form_statusbar button[name=send]:visible", + run: "click", + }, +]; + +registry.category("web_tour.tours").add("interaction_resume_log_interaction", { + steps: () => [ + ...logInteraction(LETTER), + ...logInteraction(ANSWER), + ...logInteraction(VISIT), + ...openResume(), + ...checkInResume(LETTER), + ...checkInResume(ANSWER), + ...checkInResume(VISIT), + ...checkAttachmentInResume(LETTER, [ANSWER, VISIT]), + ...refreshResume(), + ...checkInResume(LETTER), + ...checkInResume(ANSWER), + ...checkInResume(VISIT), + ...checkAttachmentInResume(LETTER, [ANSWER, VISIT]), + // Editing an interaction must not take the resume down with it. + ...rewriteFromResume(LETTER, LETTER_REWRITTEN), + ...checkNotLogged([LETTER, ANSWER, VISIT]), + ], +}); + +registry.category("web_tour.tours").add("interaction_resume_log_call", { + steps: () => [ + ...logCall(INCOMING_CALL), + ...logCall(OUTGOING_CALL), + ...openResume(), + ...refreshResume(), + ...checkInResume(INCOMING_CALL), + ...checkInResume(OUTGOING_CALL), + ...checkNotLogged([INCOMING_CALL, OUTGOING_CALL]), + ], +}); + +registry.category("web_tour.tours").add("interaction_resume_communication", { + steps: () => [ + ...sendCommunication(EMAIL_COMMUNICATION), + { + content: "The communication was sent by e-mail", + trigger: + `.o_data_row:contains("${EMAIL_COMMUNICATION.subject}") ` + + "td[name=state]:contains(Done)", + }, + ...goToContact(), + ...openResume(), + ...checkInResume(EMAIL_COMMUNICATION), + ...sendCommunication(PRINTED_COMMUNICATION), + { + content: "The letter was rendered to a PDF instead of a printer", + trigger: ".modal div[name=letters_data] a.o_form_uri", + }, + { + content: "Close the dialog without throwing the PDF away", + trigger: ".modal-footer button:contains('Close and keep data')", + run: "click", + }, + ...goToContact(), + ...openResume(), + ...checkInResume(PRINTED_COMMUNICATION), + ], +}); diff --git a/interaction_resume/tests/__init__.py b/interaction_resume/tests/__init__.py new file mode 100644 index 000000000..6c63b316e --- /dev/null +++ b/interaction_resume/tests/__init__.py @@ -0,0 +1 @@ +from . import test_interaction_resume diff --git a/interaction_resume/tests/test_interaction_resume.py b/interaction_resume/tests/test_interaction_resume.py new file mode 100644 index 000000000..9e65292ff --- /dev/null +++ b/interaction_resume/tests/test_interaction_resume.py @@ -0,0 +1,303 @@ +############################################################################## +# +# Copyright (C) 2026 Compassion CH (http://www.compassion.ch) +# Releasing children from poverty in Jesus' name +# +# The licence is in the file __manifest__.py +# +############################################################################## +import base64 +from datetime import timedelta +from uuid import uuid4 + +from odoo import fields +from odoo.tests import HttpCase, tagged + +from odoo.addons.base.models.ir_actions_report import IrActionsReport + +_pre_render_qweb_pdf = IrActionsReport._pre_render_qweb_pdf + + +def _force_pdf_rendering(self, report_ref, res_ids=None, data=None): + return _pre_render_qweb_pdf( + self.with_context(force_report_rendering=True), report_ref, res_ids, data + ) + + +ATTACHMENT_NAME = "letter-of-the-sponsor.txt" + +LETTER = { + "subject": "Handwritten letter received at the office", + "body": "The sponsor wrote to us to ask for news of the sponsored child.", + "communication_type": "Paper", + "direction": "in", + "other_type": False, + "attachment": ATTACHMENT_NAME, +} +REWRITTEN_LETTER = LETTER["body"] + " They also asked for a photograph." +ANSWER = { + "subject": "Answer written about the sponsored child", + "body": "We answered the sponsor and told how the sponsored child is doing.", + "communication_type": "Email", + "direction": "out", + "other_type": False, +} +VISIT = { + "subject": "The sponsor came to visit us", + "body": "The sponsor came to the office and we handed over the child folder.", + "communication_type": "Other", + "direction": "in", + "other_type": "Visit at the office", +} + +INCOMING_CALL = { + "subject": "The sponsor called about the payment date", + "body": "The sponsor asked to move the payment to the end of the month.", + "direction": "in", +} +OUTGOING_CALL = { + "subject": "We called the sponsor back about the payment date", + "body": "We confirmed the payment is now taken on the 25th of each month.", + "direction": "out", +} + +EMAIL_COMMUNICATION = { + "subject": "Confirmation of the new payment date", + "body": "Dear sponsor, your payment is now taken on the 25th of each month.", + "send_mode": "digital", + "communication_type": "Email", +} +PRINTED_COMMUNICATION = { + "subject": "Yearly news of the sponsored child", + "body": "Dear sponsor, here are the yearly news of the child you support.", + "send_mode": "physical", + "communication_type": "Paper", +} + + +@tagged("post_install", "-at_install") +class TestInteractionResume(HttpCase): + """End to end tests of the interaction resume of a contact.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.admin = cls.env.ref("base.user_admin") + cls.admin.tour_enabled = False + cls.admin.lang = "en_US" + cls.admin.tz = "Europe/Zurich" + + # Print to a PDF rather than to a printer. + cls.admin.printing_action = "client" + cls.admin.printing_printer_id = False + cls.env["printing.printer"].search([("default", "=", True)]).default = False + cls.report = cls.env.ref("partner_communication.report_a4_communication") + cls.report.printing_printer_id = False + cls.report.property_printing_action_id = False + cls.classPatch(IrActionsReport, "_pre_render_qweb_pdf", _force_pdf_rendering) + + cls.token = uuid4().hex[:12] + cls.partner = cls.env["res.partner"].create( + { + "firstname": "Interaction", + "lastname": f"Resume {cls.token}", + "email": f"interaction.resume+{cls.token}@example.org", + "street": "Rue Galilée 3", + "zip": "1400", + "city": "Yverdon-les-Bains", + "country_id": cls.env.ref("base.ch").id, + "lang": "en_US", + "global_communication_delivery_preference": "digital", + } + ) + cls.contact_url = f"/odoo/contacts/{cls.partner.id}" + + def _only(self, records, field, value): + """The single record of the set carrying that value.""" + match = records.filtered(lambda record: record[field] == value) + self.assertEqual( + len(match), 1, f"'{value}' should match exactly one {records._name}" + ) + return match + + def _resume_of(self, subject): + """The single resume entry of the contact carrying that subject.""" + return self._only(self.partner.interaction_resume_ids, "subject", subject) + + def _assert_logged_in_the_past(self, record): + """The tours change the date of what they log, so it cannot be now.""" + self.assertLess( + record.date, + fields.Datetime.now() - timedelta(hours=12), + "The date typed in the user interface was not kept", + ) + + def _assert_no_note_about(self, *subjects): + bodies = " ".join(self.partner.message_ids.mapped("body")) + for subject in subjects: + self.assertNotIn( + subject, + bodies, + "The interaction should not be logged as a note on the contact", + ) + + def test_log_interaction(self): + """Interactions logged by hand reach the resume and stay readable.""" + self.start_tour( + self.contact_url, + "interaction_resume_log_interaction", + login="admin", + timeout=240, + ) + + interactions = self.env["partner.log.other.interaction"].search( + [("partner_id", "=", self.partner.id)] + ) + self.assertEqual( + len(interactions), 3, "The tour should have logged three interactions" + ) + + for expected in (LETTER, ANSWER, VISIT): + interaction = self._only(interactions, "subject", expected["subject"]) + self.assertEqual( + interaction.communication_type, expected["communication_type"] + ) + self.assertEqual(interaction.direction, expected["direction"]) + self.assertEqual(interaction.other_type, expected["other_type"] or False) + self.assertIn(expected["body"], interaction.body) + self._assert_logged_in_the_past(interaction) + + # The file the tour attached is kept on the interaction itself. + attachments = self.env["ir.attachment"].search( + [ + ("res_model", "=", interaction._name), + ("res_id", "=", interaction.id), + ] + ) + self.assertEqual( + attachments.mapped("name"), + [expected["attachment"]] if expected.get("attachment") else [], + ) + + # The same interaction, as the resume of the contact shows it. + entry = self._resume_of(expected["subject"]) + self.assertEqual(entry.res_model, "partner.log.other.interaction") + self.assertEqual(entry.res_id, interaction.id) + self.assertEqual(entry.direction, expected["direction"]) + self.assertEqual(entry.communication_type, expected["communication_type"]) + self.assertEqual(entry.other_type, expected["other_type"] or False) + self.assertIn(expected["body"], entry.body) + self.assertEqual(entry.date, interaction.date) + self.assertEqual(entry.user_id, self.admin) + self.assertEqual( + entry.has_attachment, + bool(expected.get("attachment")), + "The resume does not say whether the interaction carries a file", + ) + + # Uploading through the wizard must not leave a copy of the file + # behind on the wizard itself. + self.assertFalse( + self.env["ir.attachment"].search_count( + [ + ("res_model", "=", "partner.log.other.interaction.wizard"), + ("name", "=", ATTACHMENT_NAME), + ] + ), + "The attachment was left behind on the wizard", + ) + + letter = self._only(interactions, "subject", LETTER["subject"]) + self.assertIn(REWRITTEN_LETTER, letter.body) + self.assertIn(REWRITTEN_LETTER, self._resume_of(LETTER["subject"]).body) + + self._assert_no_note_about( + LETTER["subject"], ANSWER["subject"], VISIT["subject"] + ) + + def test_log_call(self): + """Calls logged by hand reach the resume, both ways.""" + if not self.env["ir.module.module"].search( + [("name", "=", "crm_compassion"), ("state", "=", "installed")] + ): + self.skipTest("the 'Log your call' action comes with crm_compassion") + + self.start_tour( + self.contact_url, + "interaction_resume_log_call", + login="admin", + timeout=240, + ) + + calls = self.env["crm.phonecall"].search([("partner_id", "=", self.partner.id)]) + self.assertEqual(len(calls), 2, "The tour should have logged two calls") + + for expected in (INCOMING_CALL, OUTGOING_CALL): + call = self._only(calls, "name", expected["subject"]) + self.assertEqual( + call.state, "done", "A logged call is a call that was held" + ) + self.assertEqual(call.direction, expected["direction"]) + self.assertIn(expected["body"], call.description) + self._assert_logged_in_the_past(call) + + entry = self._resume_of(expected["subject"]) + self.assertEqual(entry.res_model, "crm.phonecall") + self.assertEqual(entry.res_id, call.id) + self.assertEqual(entry.direction, expected["direction"]) + self.assertEqual(entry.communication_type, "Phone") + self.assertIn(expected["body"], entry.body) + self.assertEqual(entry.date, call.date) + + self._assert_no_note_about(INCOMING_CALL["subject"], OUTGOING_CALL["subject"]) + + def test_communication(self): + """Communications that are sent reach the resume without a refresh.""" + self.start_tour( + self.contact_url, + "interaction_resume_communication", + login="admin", + timeout=300, + ) + + jobs = self.env["partner.communication.job"].search( + [("partner_id", "=", self.partner.id)] + ) + self.assertEqual( + len(jobs), 2, "The tour should have created two communications" + ) + + for expected in (EMAIL_COMMUNICATION, PRINTED_COMMUNICATION): + job = self._only(jobs, "subject", expected["subject"]) + self.assertEqual(job.send_mode, expected["send_mode"]) + self.assertEqual(job.state, "done", "The communication was not sent") + self.assertTrue(job.sent_date) + self.assertIn(expected["body"], job.body_html) + + entry = self._resume_of(expected["subject"]) + self.assertEqual(entry.res_model, "partner.communication.job") + self.assertEqual(entry.res_id, job.id) + self.assertEqual(entry.direction, "out") + self.assertEqual(entry.communication_type, expected["communication_type"]) + self.assertIn(expected["body"], entry.body) + self.assertEqual(entry.date, job.sent_date) + + # The one sent by e-mail produced a mail addressed to the contact. + email_job = self._only(jobs, "subject", EMAIL_COMMUNICATION["subject"]) + self.assertTrue(email_job.email_id, "Sending the communication made no e-mail") + self.assertEqual(email_job.email_id.state, "sent") + self.assertEqual(email_job.email_id.recipient_ids, self.partner) + self.assertEqual( + self._resume_of(EMAIL_COMMUNICATION["subject"]).email, self.partner.email + ) + + # The printed one was rendered to a PDF rather than sent to a printer. + letter_job = self._only(jobs, "subject", PRINTED_COMMUNICATION["subject"]) + self.assertTrue( + letter_job.printed_pdf_data, "The letter was not rendered to a PDF" + ) + self.assertTrue( + base64.b64decode(letter_job.printed_pdf_data).startswith(b"%PDF"), + "What the letter produced is not a PDF", + ) + self.assertGreaterEqual(letter_job.pdf_page_count, 1) diff --git a/interaction_resume/views/interaction_resume.xml b/interaction_resume/views/interaction_resume.xml index 0a44298b0..b8a4a78d7 100644 --- a/interaction_resume/views/interaction_resume.xml +++ b/interaction_resume/views/interaction_resume.xml @@ -7,9 +7,9 @@ {name}" - ) - link_name = ( - f"{other_interaction.subject} {other_interaction.other_type or ''}".strip() - ) - formatted_message = message_template.format( - model=other_interaction._name, - res_id=other_interaction.id, - name=link_name, - ) - message = self.partner_id.message_post(body=formatted_message) - # Only keep the note within one minute - message.with_delay_sh( - "unlink", - channel="root.partner_communication", - eta=60, - priority=500, - description="Delete new interaction log after 1 minute", - ) self.partner_id.fetch_interactions() - return True + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "type": "success", + "message": _("The interaction has been added to the resume."), + "next": {"type": "ir.actions.act_window_close"}, + }, + } diff --git a/partner_communication/i18n/de.po b/partner_communication/i18n/de.po index 295412aa9..d57840823 100644 --- a/partner_communication/i18n/de.po +++ b/partner_communication/i18n/de.po @@ -1517,6 +1517,24 @@ msgstr "" msgid "The kind of document with this communication can be used" msgstr "Die Art des Dokuments mit dieser Mitteilung kann verwendet werden" +#. module: partner_communication +#: model_terms:ir.ui.view,arch_db:partner_communication.communication_job_form +msgid "This communication has no send mode, so it cannot be sent." +msgstr "" +"Diese Kommunikation hat keinen Sende-Modus und kann daher nicht versendet " +"werden." + +#. module: partner_communication +#: code:addons/partner_communication/models/communication_job.py:0 +#, python-format +msgid "" +"This communication has no send mode, so it cannot be sent. Choose below how " +"it should go out and save." +msgstr "" +"Diese Kommunikation hat keinen Sende-Modus und kann daher nicht versendet " +"werden. Wählen Sie unten aus, wie sie versendet werden soll, und speichern " +"Sie." + #. module: partner_communication #: model:ir.model.fields,help:partner_communication.field_partner_communication_default_config__user_id msgid "This config will only apply for communications from this user" diff --git a/partner_communication/i18n/fr_CH.po b/partner_communication/i18n/fr_CH.po index 750e7bba5..a12d39102 100644 --- a/partner_communication/i18n/fr_CH.po +++ b/partner_communication/i18n/fr_CH.po @@ -1498,6 +1498,24 @@ msgstr "The email address should not be empty if email_only is selected." msgid "The kind of document with this communication can be used" msgstr "The kind of document with this communication can be used" +#. module: partner_communication +#: model_terms:ir.ui.view,arch_db:partner_communication.communication_job_form +msgid "This communication has no send mode, so it cannot be sent." +msgstr "" +"Cette communication n'a pas de mode d'envoi et ne peut donc pas être " +"envoyée." + +#. module: partner_communication +#: code:addons/partner_communication/models/communication_job.py:0 +#, python-format +msgid "" +"This communication has no send mode, so it cannot be sent. Choose below how " +"it should go out and save." +msgstr "" +"Cette communication n'a pas de mode d'envoi et ne peut donc pas être " +"envoyée. Choisissez ci-dessous comment elle doit être envoyée, puis " +"enregistrez." + #. module: partner_communication #: model:ir.model.fields,help:partner_communication.field_partner_communication_default_config__user_id msgid "This config will only apply for communications from this user" diff --git a/partner_communication/i18n/it.po b/partner_communication/i18n/it.po index 7a125dee0..18699deb3 100644 --- a/partner_communication/i18n/it.po +++ b/partner_communication/i18n/it.po @@ -1504,6 +1504,23 @@ msgstr "L'indirizzo e-mail non deve essere vuoto se si seleziona email_only." msgid "The kind of document with this communication can be used" msgstr "Il tipo di documento con questa comunicazione può essere utilizzato" +#. module: partner_communication +#: model_terms:ir.ui.view,arch_db:partner_communication.communication_job_form +msgid "This communication has no send mode, so it cannot be sent." +msgstr "" +"Questa comunicazione non ha una modalità di invio, quindi non può essere " +"inviata." + +#. module: partner_communication +#: code:addons/partner_communication/models/communication_job.py:0 +#, python-format +msgid "" +"This communication has no send mode, so it cannot be sent. Choose below how " +"it should go out and save." +msgstr "" +"Questa comunicazione non ha una modalità di invio, quindi non può essere " +"inviata. Scegliere di seguito come deve essere inviata e salvare." + #. module: partner_communication #: model:ir.model.fields,help:partner_communication.field_partner_communication_default_config__user_id msgid "This config will only apply for communications from this user" diff --git a/partner_communication/models/communication_job.py b/partner_communication/models/communication_job.py index 765e755a3..1ae6d775d 100644 --- a/partner_communication/models/communication_job.py +++ b/partner_communication/models/communication_job.py @@ -573,10 +573,18 @@ def send(self): # If only one job is asked, run synchronously if len(self) == 1: self = self.with_context(queue_job__no_delay=True) + if self.state == "pending" and not self.send_mode: + raise UserError( + _( + "This communication has no send mode, so it cannot be sent." + " Choose below how it should go out and save." + ) + ) # Filter "pending" tasks todo = self.filtered( lambda j: j.state == "pending" + and j.send_mode and not (j.need_call == "before_sending" and j.activity_ids) ) todo.write({"state": "processing"}) diff --git a/partner_communication/views/communication_job_view.xml b/partner_communication/views/communication_job_view.xml index 83f8c7c02..7d95c7f63 100644 --- a/partner_communication/views/communication_job_view.xml +++ b/partner_communication/views/communication_job_view.xml @@ -18,6 +18,15 @@ type="object" class="oe_highlight" invisible="send_mode != 'digital' or state != 'pending' or not id" + /> +