From a8a2ec3074dd781ae685c9b06ff79ca514ce7a4e Mon Sep 17 00:00:00 2001 From: Alexandre Philibert Date: Tue, 8 Sep 2026 11:01:01 +0200 Subject: [PATCH] [T3328] FIX: Assign support request with either an existing partner or a new one --- my_compassion/__manifest__.py | 2 + .../static/tests/tours/contact_us.js | 139 ++++++++++++++++++ my_compassion/tests/__init__.py | 1 + my_compassion/tests/test_contact_us.py | 92 ++++++++++++ website_crm_request/__init__.py | 1 + website_crm_request/__manifest__.py | 2 +- website_crm_request/models/__init__.py | 1 + website_crm_request/models/crm_claim.py | 43 ++++++ website_crm_request/tests/__init__.py | 1 + .../tests/test_website_form_partner.py | 111 ++++++++++++++ 10 files changed, 392 insertions(+), 1 deletion(-) create mode 100644 my_compassion/static/tests/tours/contact_us.js create mode 100644 my_compassion/tests/test_contact_us.py create mode 100644 website_crm_request/models/__init__.py create mode 100644 website_crm_request/models/crm_claim.py create mode 100644 website_crm_request/tests/__init__.py create mode 100644 website_crm_request/tests/test_website_form_partner.py diff --git a/my_compassion/__manifest__.py b/my_compassion/__manifest__.py index 0c09c1702..f589ff050 100644 --- a/my_compassion/__manifest__.py +++ b/my_compassion/__manifest__.py @@ -118,6 +118,7 @@ "auth_signup", "http_routing", "website", + "website_crm_request", # the /contactus form posts a crm.claim "auth_signup_verify_email", # OCA/server-auth "theme_compassion_2025", "utm", @@ -178,6 +179,7 @@ ], "web.assets_tests": [ "my_compassion/static/src/js/tours/donation_tour.js", + "my_compassion/static/tests/tours/contact_us.js", ], }, "demo": [], diff --git a/my_compassion/static/tests/tours/contact_us.js b/my_compassion/static/tests/tours/contact_us.js new file mode 100644 index 000000000..50663dc28 --- /dev/null +++ b/my_compassion/static/tests/tours/contact_us.js @@ -0,0 +1,139 @@ +import {animationFrame, click} from "@odoo/hoot-dom"; +import {registry} from "@web/core/registry"; +import {stepUtils} from "@web_tour/tour_service/tour_utils"; + +const TOKEN_KEY = "my_compassion.contact_us_tour_token"; +const fromUrl = new URLSearchParams(location.search).get("tour_token"); +if (fromUrl) { + sessionStorage.setItem(TOKEN_KEY, fromUrl); +} +const token = sessionStorage.getItem(TOKEN_KEY) || ""; + +const KNOWN = { + firstname: "Camille", + lastname: "Rochat", + phone: "0041216541288", + email: `camille.rochat+${token}@example.org`, + subject: `Change of payment date - ${token}`, + message: "I would like to change the date my monthly payment is taken.", +}; + +const UNKNOWN = { + firstname: "Lukas", + lastname: "Baumann", + phone: "0041313017654", + email: `lukas.baumann+${token}@example.org`, + subject: `Sponsoring a child - ${token}`, + message: "I would like to sponsor a child and do not know where to start.", +}; + +const sendMessage = (contact) => [ + { + content: "Choose a title", + trigger: "#contactus_form select[name=title]", + run: "selectByIndex 1", + }, + { + content: "Fill in the first name", + trigger: "#contactus_form_firstname", + run: `edit ${contact.firstname}`, + }, + { + content: "Fill in the last name", + trigger: "#contactus_form_lastname", + run: `edit ${contact.lastname}`, + }, + { + content: "Fill in the phone number", + trigger: "#contactus_form_phone_number", + run: `edit ${contact.phone}`, + }, + { + content: "Fill in the e-mail address", + trigger: "#contactus_form_email", + run: `edit ${contact.email}`, + }, + { + content: "Fill in the subject of the message", + trigger: "#contactus_subject", + run: `edit ${contact.subject}`, + }, + { + content: "Write the message", + trigger: "#contactus_message", + run: `edit ${contact.message}`, + }, + { + content: "Send the message", + trigger: "#contactus_form .s_website_form_send", + run: "click", + expectUnloadPage: true, + }, + { + content: "The message went through", + trigger: "h5:contains('Your message has been sent successfully')", + }, +]; + +registry.category("web_tour.tours").add("contact_us", { + url: "/contactus", + steps: () => [ + { + content: "The MyCompassion contact form is displayed", + trigger: "#contactus_form_top form#contactus_form select[name=title]", + }, + ...sendMessage(KNOWN), + stepUtils.goToUrl("/contactus"), + ...sendMessage(UNKNOWN), + stepUtils.goToUrl("/odoo"), + ...stepUtils.goToAppSteps("crm_request.support_root", "Open the Support app"), + { + content: "Drop the default filters, so that every request is listed", + trigger: ".o_control_panel .o_searchview", + async run() { + let facet = document.querySelector(".o_searchview_facet .o_facet_remove"); + while (facet) { + await click(facet, {interactive: false}); + await animationFrame(); + facet = document.querySelector(".o_searchview_facet .o_facet_remove"); + } + }, + }, + { + content: "No search filter is left", + trigger: ".o_searchview:not(:has(.o_searchview_facet))", + }, + { + content: "The message of the known contact reached the Support", + trigger: `.o_kanban_record:contains("${KNOWN.subject}")`, + }, + { + content: "Open the request of the contact Odoo did not know", + trigger: `.o_kanban_record:contains("${UNKNOWN.subject}") h4 a`, + run: "click", + }, + { + content: "The request carries the message that was written", + trigger: `.o_form_view [name=description] textarea:value("${UNKNOWN.message}")`, + }, + { + content: "Focus the contact of the request", + trigger: ".o_form_view [name=partner_id] input", + run: "click", + }, + { + content: "Open the contact the request is associated with", + trigger: ".o_form_view [name=partner_id] button.o_external_button", + run: "click", + }, + { + content: "The contact holds the name that was filled in the form", + trigger: + `.o_form_view [name=lastname]:visible ` + `input:value("${UNKNOWN.lastname}")`, + }, + { + content: "The contact holds the e-mail address that was filled in the form", + trigger: `.o_form_view [name=email] input:value("${UNKNOWN.email}")`, + }, + ], +}); diff --git a/my_compassion/tests/__init__.py b/my_compassion/tests/__init__.py index f7043f97f..eaf1c67cc 100644 --- a/my_compassion/tests/__init__.py +++ b/my_compassion/tests/__init__.py @@ -1,5 +1,6 @@ from . import ( test_child_timeline, + test_contact_us, test_digital_fixit, test_digital_seam, test_donation_flow, diff --git a/my_compassion/tests/test_contact_us.py b/my_compassion/tests/test_contact_us.py new file mode 100644 index 000000000..20181bdcf --- /dev/null +++ b/my_compassion/tests/test_contact_us.py @@ -0,0 +1,92 @@ +############################################################################## +# +# Copyright (C) 2026 Compassion CH (http://www.compassion.ch) +# Releasing children from poverty in Jesus' name +# +# The licence is in the file __manifest__.py +# +############################################################################## +from uuid import uuid4 + +from odoo.tests import HttpCase, tagged + + +@tagged("post_install", "-at_install") +class TestContactUs(HttpCase): + @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.website = cls.env.ref("my_compassion.my2_website") + cls.website.domain = cls.base_url() + + cls.title = cls.env["res.partner.title"].search( + [("is_shown_on_public_forms", "=", True)], order="shortcut asc", limit=1 + ) + assert cls.title, "the database needs a title published on the public forms" + + cls.token = uuid4().hex[:12] + cls.known_email = f"camille.rochat+{cls.token}@example.org" + cls.unknown_email = f"lukas.baumann+{cls.token}@example.org" + + cls.known_partner = cls.env["res.partner"].create( + { + "firstname": "Camille", + "lastname": "Rochat", + "email": cls.known_email, + } + ) + + def test_contact_us(self): + claim_obj = self.env["crm.claim"] + last_id = claim_obj.search([], order="id desc", limit=1).id + + self.start_tour( + f"/contactus?tour_token={self.token}", + "contact_us", + login="admin", + timeout=180, + ) + + requests = claim_obj.search([("id", ">", last_id)], order="id") + self.assertEqual( + len(requests), 2, "The tour should have sent two messages to the Support" + ) + known_request, unknown_request = requests + + # The message of the contact Odoo already had. + self.assertEqual(known_request.name, f"Change of payment date - {self.token}") + self.assertEqual(known_request.email_from, self.known_email) + self.assertEqual(known_request.partner_phone, "0041216541288") + self.assertIn( + "I would like to change the date my monthly payment is taken.", + known_request.description, + ) + self.assertEqual( + known_request.partner_id, + self.known_partner, + "The request should be attached to the contact that already existed", + ) + + # The message of the contact Odoo had to create. + self.assertEqual(unknown_request.name, f"Sponsoring a child - {self.token}") + self.assertEqual(unknown_request.email_from, self.unknown_email) + self.assertEqual(unknown_request.partner_phone, "0041313017654") + self.assertIn( + "I would like to sponsor a child and do not know where to start.", + unknown_request.description, + ) + + new_partner = unknown_request.partner_id + self.assertTrue( + new_partner, "The second request should have created a new contact" + ) + # The contact holds what the form was filled with. + self.assertEqual(new_partner.firstname, "Lukas") + self.assertEqual(new_partner.lastname, "Baumann") + self.assertEqual(new_partner.email, self.unknown_email) + self.assertEqual(new_partner.title, self.title) + self.assertEqual(new_partner.phone, "0041313017654") diff --git a/website_crm_request/__init__.py b/website_crm_request/__init__.py index e69de29bb..0650744f6 100644 --- a/website_crm_request/__init__.py +++ b/website_crm_request/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/website_crm_request/__manifest__.py b/website_crm_request/__manifest__.py index b321db9d9..9d275e621 100644 --- a/website_crm_request/__manifest__.py +++ b/website_crm_request/__manifest__.py @@ -29,7 +29,7 @@ # pylint: disable=C8101 { "name": "Website CRM Request", - "version": "18.0.1.0.0", + "version": "18.0.1.1.0", "category": "Website", "author": "Compassion CH", "license": "AGPL-3", diff --git a/website_crm_request/models/__init__.py b/website_crm_request/models/__init__.py new file mode 100644 index 000000000..29fa1b4dc --- /dev/null +++ b/website_crm_request/models/__init__.py @@ -0,0 +1 @@ +from . import crm_claim diff --git a/website_crm_request/models/crm_claim.py b/website_crm_request/models/crm_claim.py new file mode 100644 index 000000000..c9dd6bff2 --- /dev/null +++ b/website_crm_request/models/crm_claim.py @@ -0,0 +1,43 @@ +from odoo import models +from odoo.tools.mail import email_normalize + + +class CrmClaim(models.Model): + _inherit = "crm.claim" + + def website_form_input_filter(self, request, values): + """Attach the request to the contact that wrote it, creating one when + no contact holds the address. + """ + email = email_normalize(values.get("email_from") or "") + if not email: + return values + + params = request.params + firstname = (params.get("firstname") or "").strip() + lastname = (params.get("lastname") or "").strip() + partner_obj = self.env["res.partner"].sudo() + partner = partner_obj.search([("email", "=ilike", email)], limit=1) + if not partner and (firstname or lastname): + partner = ( + self.env["res.partner.match"] + .sudo() + ._create_partner( + { + "firstname": firstname, + "lastname": lastname, + "email": email, + "phone": values.get("partner_phone") or False, + "title": self._website_form_title(params.get("title")), + } + ) + ) + + if partner: + values["partner_id"] = partner.id + values["language"] = partner.lang + return values + + def _website_form_title(self, shortcut): + title_obj = self.env["res.partner.title"].sudo() + return shortcut and title_obj.search([("shortcut", "=", shortcut)], limit=1).id diff --git a/website_crm_request/tests/__init__.py b/website_crm_request/tests/__init__.py new file mode 100644 index 000000000..d7ae34dfd --- /dev/null +++ b/website_crm_request/tests/__init__.py @@ -0,0 +1 @@ +from . import test_website_form_partner diff --git a/website_crm_request/tests/test_website_form_partner.py b/website_crm_request/tests/test_website_form_partner.py new file mode 100644 index 000000000..70410ccbe --- /dev/null +++ b/website_crm_request/tests/test_website_form_partner.py @@ -0,0 +1,111 @@ +# Copyright (C) 2026 Compassion CH +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from types import SimpleNamespace +from uuid import uuid4 + +from odoo.tests import TransactionCase, tagged + + +@tagged("post_install", "-at_install") +class TestWebsiteFormPartner(TransactionCase): + """A request sent from a website form gets its contact the way the mail + gateway gives one to a request that arrives by e-mail. + + The two paths a contact form takes are covered end to end by + my_compassion's contact_us tour; what is checked here is what it must not + do when the form says too little, or says too much. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.token = uuid4().hex[:12] + + def _submit(self, email, **params): + """Run the filter on what the website form controller would hand it: + the whitelisted values, and the raw parameters on the request. + """ + values = { + "name": f"Subject {self.token}", + "email_from": email, + "description": "Message", + "partner_phone": params.pop("partner_phone", False), + } + request = SimpleNamespace(params=dict(params, email_from=email)) + return self.env["crm.claim"].website_form_input_filter(request, values) + + def test_matches_the_contact_holding_the_address(self): + partner = self.env["res.partner"].create( + { + "firstname": "Web", + "lastname": f"Form {self.token}", + "email": f"web.form.{self.token}@example.org", + "lang": "en_US", + } + ) + values = self._submit(partner.email, firstname="Someone", lastname="Else") + self.assertEqual( + values["partner_id"], + partner.id, + "The name typed in the form must not outweigh the address it was " + "sent from", + ) + self.assertEqual( + values["language"], + partner.lang, + "The request needs a language for the Reply button to work", + ) + + def test_creates_the_contact_of_an_unknown_address(self): + title = self.env["res.partner.title"].search( + [("is_shown_on_public_forms", "=", True)], order="shortcut asc", limit=1 + ) + self.assertTrue(title, "the database needs a title for the public forms") + email = f"web.form.new.{self.token}@example.org" + + values = self._submit( + email, + firstname="Web", + lastname=f"Newcomer {self.token}", + title=title.shortcut, + partner_phone="0041244342124", + ) + + partner = self.env["res.partner"].browse(values["partner_id"]) + self.assertEqual(partner.firstname, "Web") + self.assertEqual(partner.lastname, f"Newcomer {self.token}") + self.assertEqual(partner.email, email) + self.assertEqual(partner.title, title) + self.assertEqual(partner.phone, "0041244342124") + + def test_leaves_the_request_alone_when_nobody_signed_it(self): + values = self._submit(f"web.form.anon.{self.token}@example.org") + self.assertNotIn( + "partner_id", + values, + "A contact needs a name: an unassigned request is better than a " + "nameless contact", + ) + + def test_matches_one_of_our_own_addresses(self): + # crm_request keeps Compassion's own addresses out of the rule the + # mail gateway uses, because our address on a forwarded mail is not + # its author. On a form the visitor typed the address themselves, so + # it is matched like any other - and never duplicated. + ours = self.env["res.partner"].create( + { + "firstname": "Compassion", + "lastname": f"Support {self.token}", + "email": f"support.{self.token}@compassion.ch", + } + ) + values = self._submit( + ours.email, firstname="Compassion", lastname=f"Support {self.token}" + ) + self.assertEqual(values["partner_id"], ours.id) + self.assertEqual( + self.env["res.partner"].search_count([("email", "=ilike", ours.email)]), + 1, + "The address Odoo already holds must not be given a second contact", + )