From 4ddc0dc511bfb2199bb5af77bd841b2720e4c1c8 Mon Sep 17 00:00:00 2001 From: RPSJR Date: Sun, 22 Feb 2026 20:16:58 -0300 Subject: [PATCH 01/11] [13.0][ADD]Add meta_whatsapp module --- meta_whatsapp/README.rst | 32 ++ meta_whatsapp/__init__.py | 2 + meta_whatsapp/__manifest__.py | 22 ++ meta_whatsapp/models/__init__.py | 3 + meta_whatsapp/models/res_config_settings.py | 25 ++ meta_whatsapp/models/whatsapp_message.py | 257 ++++++++++++++++ meta_whatsapp/models/whatsapp_template.py | 284 ++++++++++++++++++ meta_whatsapp/security/ir.model.access.csv | 5 + .../views/res_config_settings_views.xml | 95 ++++++ meta_whatsapp/views/res_partner_views.xml | 32 ++ .../views/whatsapp_message_views.xml | 125 ++++++++ .../views/whatsapp_template_views.xml | 163 ++++++++++ meta_whatsapp/wizard/__init__.py | 2 + meta_whatsapp/wizard/whatsapp_composer.py | 83 +++++ .../wizard/whatsapp_composer_views.xml | 29 ++ meta_whatsapp/wizard/whatsapp_preview.py | 83 +++++ .../wizard/whatsapp_preview_views.xml | 58 ++++ setup/meta_whatsapp/odoo/addons/meta_whatsapp | 1 + setup/meta_whatsapp/setup.py | 6 + 19 files changed, 1307 insertions(+) create mode 100644 meta_whatsapp/README.rst create mode 100644 meta_whatsapp/__init__.py create mode 100644 meta_whatsapp/__manifest__.py create mode 100644 meta_whatsapp/models/__init__.py create mode 100644 meta_whatsapp/models/res_config_settings.py create mode 100644 meta_whatsapp/models/whatsapp_message.py create mode 100644 meta_whatsapp/models/whatsapp_template.py create mode 100644 meta_whatsapp/security/ir.model.access.csv create mode 100644 meta_whatsapp/views/res_config_settings_views.xml create mode 100644 meta_whatsapp/views/res_partner_views.xml create mode 100644 meta_whatsapp/views/whatsapp_message_views.xml create mode 100644 meta_whatsapp/views/whatsapp_template_views.xml create mode 100644 meta_whatsapp/wizard/__init__.py create mode 100644 meta_whatsapp/wizard/whatsapp_composer.py create mode 100644 meta_whatsapp/wizard/whatsapp_composer_views.xml create mode 100644 meta_whatsapp/wizard/whatsapp_preview.py create mode 100644 meta_whatsapp/wizard/whatsapp_preview_views.xml create mode 120000 setup/meta_whatsapp/odoo/addons/meta_whatsapp create mode 100644 setup/meta_whatsapp/setup.py diff --git a/meta_whatsapp/README.rst b/meta_whatsapp/README.rst new file mode 100644 index 0000000000..c6159e6114 --- /dev/null +++ b/meta_whatsapp/README.rst @@ -0,0 +1,32 @@ +======================= +Meta WhatsApp Connector +======================= + +This module allows you to send WhatsApp messages using the official Meta API (v25.0). + +Features +======== + +* Template synchronization from Meta Business Manager +* Sending template messages with parameters +* Integration with Odoo models (Contacts, Sales, etc.) + +Configuration +============= + +1. Go to Settings > General Settings > WhatsApp. +2. Enter your Meta API credentials. +3. Click on "Sync Templates" to fetch templates from Meta. + +Usage +===== + +1. Go to a Contact or any enabled model. +2. Click on "Send WhatsApp" in the action menu. +3. Select a template and send. + +Authors +======= + +* Your Name +* Odoo Community Association (OCA) diff --git a/meta_whatsapp/__init__.py b/meta_whatsapp/__init__.py new file mode 100644 index 0000000000..9b4296142f --- /dev/null +++ b/meta_whatsapp/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizard diff --git a/meta_whatsapp/__manifest__.py b/meta_whatsapp/__manifest__.py new file mode 100644 index 0000000000..4b9d79f21f --- /dev/null +++ b/meta_whatsapp/__manifest__.py @@ -0,0 +1,22 @@ +{ + "name": "Meta WhatsApp Connector", + "version": "13.0.1.0.0", + "category": "Marketing/WhatsApp", + "summary": "Send WhatsApp messages using Meta API v25.0", + "author": "Odoo Community Association (OCA)", + "website": "https://github.com/OCA/social", + "depends": ["base", "mail", "phone_validation"], + "data": [ + "security/ir.model.access.csv", + "wizard/whatsapp_preview_views.xml", + "views/whatsapp_template_views.xml", + "views/whatsapp_message_views.xml", + "views/res_config_settings_views.xml", + "views/res_partner_views.xml", + "wizard/whatsapp_preview_views.xml", + "wizard/whatsapp_composer_views.xml", + ], + "installable": True, + "application": True, + "license": "LGPL-3", +} diff --git a/meta_whatsapp/models/__init__.py b/meta_whatsapp/models/__init__.py new file mode 100644 index 0000000000..92739f2450 --- /dev/null +++ b/meta_whatsapp/models/__init__.py @@ -0,0 +1,3 @@ +from . import res_config_settings +from . import whatsapp_template +from . import whatsapp_message diff --git a/meta_whatsapp/models/res_config_settings.py b/meta_whatsapp/models/res_config_settings.py new file mode 100644 index 0000000000..2f7c3f1e9f --- /dev/null +++ b/meta_whatsapp/models/res_config_settings.py @@ -0,0 +1,25 @@ +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + meta_api_url = fields.Char( + string="Meta API URL", + config_parameter="meta_whatsapp.api_url", + default="https://graph.facebook.com", + ) + meta_api_version = fields.Char( + string="Meta API Version", + config_parameter="meta_whatsapp.api_version", + default="v25.0", + ) + meta_access_token = fields.Char( + string="Access Token", config_parameter="meta_whatsapp.access_token", + ) + meta_phone_number_id = fields.Char( + string="Phone Number ID", config_parameter="meta_whatsapp.phone_number_id", + ) + meta_waba_id = fields.Char( + string="WhatsApp Business Account ID", config_parameter="meta_whatsapp.waba_id", + ) diff --git a/meta_whatsapp/models/whatsapp_message.py b/meta_whatsapp/models/whatsapp_message.py new file mode 100644 index 0000000000..918c49cbcc --- /dev/null +++ b/meta_whatsapp/models/whatsapp_message.py @@ -0,0 +1,257 @@ +import logging +import re + +import requests + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class WhatsAppMessage(models.Model): + _name = "whatsapp.message" + _description = "WhatsApp Message" + _rec_name = "body" + _order = "id desc" + + body = fields.Text(string="Message Body") + mobile_number = fields.Char(string="Mobile Number", required=True) + partner_id = fields.Many2one("res.partner", string="Contact") + + status = fields.Selection( + [ + ("draft", "Draft"), + ("sending", "Sending"), + ("sent", "Sent"), + ("delivered", "Delivered"), + ("read", "Read"), + ("failed", "Failed"), + ("canceled", "Canceled"), + ], + string="Status", + default="draft", + readonly=True, + ) + + msg_id = fields.Char(string="Message ID", readonly=True) + failure_type = fields.Selection( + [ + ("unknown", "Unknown"), + ("invalid_number", "Invalid Number"), + ("server_error", "Server Error"), + ], + string="Failure Type", + ) + failure_reason = fields.Text(string="Failure Reason", readonly=True) + + template_id = fields.Many2one("whatsapp.template", string="Template") + + res_model = fields.Char(string="Related Model") + res_id = fields.Integer(string="Related Record ID") + + @api.onchange("template_id") + def _onchange_template_id(self): + if self.template_id and not self.body: + self.body = self.template_id.body + + @api.model + def create(self, vals): + if vals.get("template_id") and not vals.get("body"): + template = self.env["whatsapp.template"].browse(vals["template_id"]) + vals["body"] = template.body + return super(WhatsAppMessage, self).create(vals) + + def _get_meta_credentials(self): + """Retrieve Meta API credentials from settings.""" + params = self.env["ir.config_parameter"].sudo() + api_url = (params.get_param("meta_whatsapp.api_url") or "").strip() + api_version = (params.get_param("meta_whatsapp.api_version") or "").strip() + access_token = (params.get_param("meta_whatsapp.access_token") or "").strip() + + # Remove any invisible characters or spaces within the token + if access_token: + # Remove whitespace and common invisible Unicode characters + access_token = "".join(access_token.split()) + if access_token.startswith("Bearer "): + access_token = access_token[7:].strip() + + phone_number_id = (params.get_param("meta_whatsapp.phone_number_id") or "").strip() + + if not all([api_url, api_version, access_token, phone_number_id]): + raise UserError(_("Please configure Meta WhatsApp settings first.")) + + # Remove trailing slash from URL if present + if api_url.endswith("/"): + api_url = api_url[:-1] + + return api_url, api_version, access_token, phone_number_id + + def _sanitize_phone(self, phone): + """Sanitize phone number for Meta API (digits only, no +).""" + if not phone: + return False + return re.sub(r"\D", "", phone) + + def action_send(self): + """Send the message via Meta API.""" + ( + api_url, + api_version, + access_token, + phone_number_id, + ) = self._get_meta_credentials() + + url = f"{api_url}/{api_version}/{phone_number_id}/messages" + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + } + + for msg in self: + msg._send_message(url, headers) + + def _prepare_template_parameters(self, location="body"): + self.ensure_one() + parameters = [] + if not self.template_id: + return parameters + + record = False + if self.res_model and self.res_id: + record = self.env[self.res_model].browse(self.res_id) + + # Filter variables by location and sort them + vars_to_process = self.template_id.variable_ids.filtered( + lambda v: v.location == location + ).sorted("sequence") + + for variable in vars_to_process: + value = "" + if variable.field_type == "text": + value = variable.field_name + elif variable.field_type == "field" and record: + try: + # Evaluate dotted field paths (e.g. partner_id.name) + field_path = variable.field_name.split(".") + val = record + for path in field_path: + if not val: + break + # Handle fields that might be False/None + val = getattr(val, path, "") + + # Formatting based on field type could be added here + value = str(val) if val not in (False, None) else "" + except Exception: + value = "" + + # Ensure we don't send an empty string if it's required + if not value: + value = " " + + parameters.append({"type": "text", "text": value}) + return parameters + + def _send_message(self, url, headers): + self.ensure_one() + if self.status not in ["draft", "failed"]: + return + + clean_phone = self._sanitize_phone(self.mobile_number) + if not clean_phone: + self.write({"status": "failed", "failure_type": "invalid_number"}) + return + + self.write({"status": "sending"}) + + # Build components list dynamically + components = [] + rendered_body = self.template_id.body + rendered_header = self.template_id.header_text + + # Check for Header parameters + header_params = self._prepare_template_parameters(location="header") + if header_params: + components.append({ + "type": "header", + "parameters": header_params + }) + # Replace variables in header preview + for i, p in enumerate(header_params, 1): + placeholder = "{{%s}}" % i + if rendered_header: + rendered_header = rendered_header.replace(placeholder, p["text"]) + + # Check for Body parameters + body_params = self._prepare_template_parameters(location="body") + if body_params: + components.append({ + "type": "body", + "parameters": body_params + }) + # Replace variables in body preview + # Note: body variables usually start from 1 or continue from header + # We need to find all variables to replace them correctly + all_params = header_params + body_params + for i, p in enumerate(all_params, 1): + placeholder = "{{%s}}" % i + if rendered_body: + rendered_body = rendered_body.replace(placeholder, p["text"]) + + # Final rendered text for internal Odoo logs + full_rendered_text = "" + if rendered_header: + full_rendered_text += rendered_header + "\n" + full_rendered_text += rendered_body + + payload = { + "messaging_product": "whatsapp", + "to": clean_phone, + "type": "template", + "template": { + "name": self.template_id.name, + "language": {"code": self.template_id.language}, + "components": components, + }, + } + + try: + response = requests.post(url, headers=headers, json=payload) + data = response.json() + + if response.status_code == 200: + self.write( + { + "status": "sent", + "msg_id": data.get("messages", [{}])[0].get("id"), + "failure_reason": False, + "body": full_rendered_text, + } + ) + else: + error_data = data.get("error", {}) + error_msg = error_data.get("message") or response.text + _logger.error("Meta API Error: %s", error_msg) + self.write( + { + "status": "failed", + "failure_type": "server_error", + "failure_reason": error_msg, + } + ) + except Exception as e: + _logger.exception("Failed to send WhatsApp message") + self.write( + { + "status": "failed", + "failure_type": "unknown", + "failure_reason": str(e) + } + ) + + def action_retry(self): + self.action_send() + + def action_cancel(self): + self.write({"status": "canceled"}) diff --git a/meta_whatsapp/models/whatsapp_template.py b/meta_whatsapp/models/whatsapp_template.py new file mode 100644 index 0000000000..f51ea21e17 --- /dev/null +++ b/meta_whatsapp/models/whatsapp_template.py @@ -0,0 +1,284 @@ +import re +import requests + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + + +class WhatsAppTemplate(models.Model): + _name = "whatsapp.template" + _description = "WhatsApp Template" + + name = fields.Char(string="Name", required=True) + status = fields.Selection( + [ + ("APPROVED", "Approved"), + ("REJECTED", "Rejected"), + ("PENDING", "Pending"), + ("PAUSED", "Paused"), + ("DISABLED", "Disabled"), + ], + string="Status", + default="PENDING", + readonly=True, + ) + + language = fields.Char(string="Language", required=True, default="en_US") + + category = fields.Selection( + [ + ("AUTHENTICATION", "Authentication"), + ("MARKETING", "Marketing"), + ("UTILITY", "Utility"), + ], + string="Category", + ) + + body = fields.Text(string="Body Text", translate=True) + header_type = fields.Selection( + [ + ("TEXT", "Text"), + ("IMAGE", "Image"), + ("VIDEO", "Video"), + ("DOCUMENT", "Document"), + ("LOCATION", "Location"), + ("NONE", "None"), + ], + string="Header Type", + default="NONE", + ) + header_text = fields.Char(string="Header Text") + footer_text = fields.Char(string="Footer Text") + + model_id = fields.Many2one( + "ir.model", + string="Applies to", + domain=[("is_mail_thread", "=", True)], + help="The type of document this template can be used with", + ) + model_model = fields.Char( + related="model_id.model", index=True, store=True, string="Model" + ) + + variable_ids = fields.One2many( + "whatsapp.template.variable", "template_id", string="Variables" + ) + + sidebar_action_id = fields.Many2one( + "ir.actions.act_window", "Sidebar Action", readonly=True, copy=False + ) + + def _get_meta_credentials(self): + """Retrieve Meta API credentials from settings.""" + params = self.env["ir.config_parameter"].sudo() + api_url = (params.get_param("meta_whatsapp.api_url") or "").strip() + api_version = (params.get_param("meta_whatsapp.api_version") or "").strip() + access_token = (params.get_param("meta_whatsapp.access_token") or "").strip() + + # Remove any invisible characters or spaces within the token + if access_token: + # Remove whitespace and common invisible Unicode characters + access_token = "".join(access_token.split()) + if access_token.startswith("Bearer "): + access_token = access_token[7:].strip() + + waba_id = (params.get_param("meta_whatsapp.waba_id") or "").strip() + + if not all([api_url, api_version, access_token, waba_id]): + raise UserError(_("Please configure Meta WhatsApp settings first.")) + + # Remove trailing slash from URL if present + if api_url.endswith("/"): + api_url = api_url[:-1] + + return api_url, api_version, access_token, waba_id + + def action_sync_templates(self): + """Sync templates from Meta Business Account.""" + api_url, api_version, access_token, waba_id = self._get_meta_credentials() + + url = f"{api_url}/{api_version}/{waba_id}/message_templates" + params = { + "access_token": access_token, + "limit": 100, # Get up to 100 templates + } + + try: + response = requests.get(url, params=params, timeout=30) + except Exception as e: + raise UserError(_("Connection Error: %s") % str(e)) + + if response.status_code != 200: + error_msg = response.text + try: + error_data = response.json() + error_msg = error_data.get("error", {}).get("message", error_msg) + except Exception: + pass + raise UserError(_("Meta API Error: %s") % error_msg) + + data = response.json() + + # Get list of active languages in Odoo to avoid ValueError + # We search for all installed languages. + active_langs = [l.code for l in self.env["res.lang"].sudo().search([])] + + for tmpl_data in data.get("data", []): + existing = self.search( + [ + ("name", "=", tmpl_data["name"]), + ("language", "=", tmpl_data["language"]), + ], + limit=1, + ) + + vals = { + "name": tmpl_data["name"], + "status": tmpl_data["status"], + "category": tmpl_data["category"], + "language": tmpl_data["language"], + "body": False, + "header_text": False, + "footer_text": False, + "header_type": "NONE", + } + + # Extended parsing of components to update body/header/footer + body_text = "" + header_text = "" + footer_text = "" + for component in tmpl_data.get("components", []): + ctype = component.get("type", "").upper() + if ctype == "BODY": + body_text = component.get("text", "") + vals["body"] = body_text + elif ctype == "HEADER": + vals["header_type"] = component.get("format", "TEXT") + if vals["header_type"] == "TEXT": + header_text = component.get("text", "") + vals["header_text"] = header_text + elif ctype == "FOOTER": + footer_text = component.get("text", "") + vals["footer_text"] = footer_text + + # Set context language if it is active in Odoo, else use current context + lang_code = tmpl_data["language"] + if lang_code not in active_langs: + # Meta uses underscores (pt_BR), Odoo might use underscores too, + # but sometimes there are slight differences. + # If still not found, try to find the closest match or just skip translation context + lang_code = self.env.context.get("lang") + + ctx = dict(self.env.context, lang=lang_code) + + if existing: + # Update existing record with correct language context + existing.with_context(ctx).write(vals) + template = existing + # Optional: Clear old variables to re-sync them + template.variable_ids.unlink() + else: + # Create new record with correct language context + template = self.with_context(ctx).create(vals) + + # Automatically extract variables {{1}}, {{2}}... from body and header + if header_text: + h_vars = re.findall(r"\{\{(\d+)\}\}", header_text) + for var_num in h_vars: + var_name = "{{%s}}" % var_num + self.env["whatsapp.template.variable"].create( + { + "template_id": template.id, + "name": var_name, + "sequence": int(var_num), + "location": "header", + "field_type": "field", + "field_name": "id", + } + ) + + if body_text: + b_vars = re.findall(r"\{\{(\d+)\}\}", body_text) + for var_num in b_vars: + var_name = "{{%s}}" % var_num + # Check if already created in header (some templates share numbers, but unusual) + existing_var = self.env["whatsapp.template.variable"].search([ + ("template_id", "=", template.id), + ("name", "=", var_name) + ]) + if not existing_var: + self.env["whatsapp.template.variable"].create( + { + "template_id": template.id, + "name": var_name, + "sequence": int(var_num), + "location": "body", + "field_type": "field", + "field_name": "id", + } + ) + + return { + "type": "ir.actions.client", + "tag": "reload", + } + + def action_create_sidebar_action(self): + """Create a sidebar action for the template.""" + self.ensure_one() + if not self.model_id: + return + + act_window = self.env["ir.actions.act_window"].create( + { + "name": _("Send WhatsApp: %s") % self.name, + "res_model": "whatsapp.composer", + "view_mode": "form", + "target": "new", + "context": { + "default_template_id": self.id, + "default_res_model": self.model_id.model, + "default_res_ids": "active_ids", + }, + "binding_model_id": self.model_id.id, + "binding_view_types": "list,form", + } + ) + self.sidebar_action_id = act_window + + def action_unlink_sidebar_action(self): + """Remove the sidebar action.""" + self.ensure_one() + if self.sidebar_action_id: + self.sidebar_action_id.unlink() + + +class WhatsAppTemplateVariable(models.Model): + _name = "whatsapp.template.variable" + _description = "WhatsApp Template Variable" + _order = "sequence, id" + + template_id = fields.Many2one( + "whatsapp.template", string="Template", required=True, ondelete="cascade" + ) + name = fields.Char(string="Parameter", required=True, help="e.g. {{1}}, {{2}}") + sequence = fields.Integer(string="Sequence", default=10) + field_type = fields.Selection( + [("field", "Field"), ("text", "Static Text")], + string="Type", + default="field", + required=True, + ) + location = fields.Selection( + [("header", "Header"), ("body", "Body")], + string="Location", + default="body", + required=True, + ) + field_name = fields.Char( + string="Field / Text", + required=True, + help="Field name (e.g. partner_id.name) or static text", + ) + + demo_value = fields.Char(string="Demo Value", help="Value used for preview/testing") diff --git a/meta_whatsapp/security/ir.model.access.csv b/meta_whatsapp/security/ir.model.access.csv new file mode 100644 index 0000000000..85a5cf32e9 --- /dev/null +++ b/meta_whatsapp/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_whatsapp_template,whatsapp.template,model_whatsapp_template,base.group_user,1,1,1,1 +access_whatsapp_message,whatsapp.message,model_whatsapp_message,base.group_user,1,1,1,1 +access_whatsapp_template_variable,whatsapp.template.variable,model_whatsapp_template_variable,base.group_user,1,1,1,1 +access_whatsapp_composer,whatsapp.composer,model_whatsapp_composer,base.group_user,1,1,1,1 diff --git a/meta_whatsapp/views/res_config_settings_views.xml b/meta_whatsapp/views/res_config_settings_views.xml new file mode 100644 index 0000000000..24000760dd --- /dev/null +++ b/meta_whatsapp/views/res_config_settings_views.xml @@ -0,0 +1,95 @@ + + + + + res.config.settings.view.form.inherit.meta.whatsapp + res.config.settings + + + +
+

Meta WhatsApp Settings

+
+
+
+ API Configuration +
+ Configure your Meta WhatsApp API credentials. +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + Settings + res.config.settings + form + inline + {'module': 'meta_whatsapp'} + + + + + +
+
diff --git a/meta_whatsapp/views/res_partner_views.xml b/meta_whatsapp/views/res_partner_views.xml new file mode 100644 index 0000000000..2556c632d2 --- /dev/null +++ b/meta_whatsapp/views/res_partner_views.xml @@ -0,0 +1,32 @@ + + + + + Send WhatsApp + whatsapp.composer + form + new + + form + + + + res.partner.view.form.inherit.meta.whatsapp + res.partner + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + whatsapp.template.search + whatsapp.template + + + + + + + + + + + + + + + + WhatsApp Templates + whatsapp.template + tree,form + +

+ Create a new WhatsApp template +

+
+
+ + + Sync from Meta + + + list + code + + action = model.action_sync_templates() + + + + + + +
+
diff --git a/meta_whatsapp/wizard/__init__.py b/meta_whatsapp/wizard/__init__.py new file mode 100644 index 0000000000..5b7f38490a --- /dev/null +++ b/meta_whatsapp/wizard/__init__.py @@ -0,0 +1,2 @@ +from . import whatsapp_composer +from . import whatsapp_preview diff --git a/meta_whatsapp/wizard/whatsapp_composer.py b/meta_whatsapp/wizard/whatsapp_composer.py new file mode 100644 index 0000000000..417ee37d86 --- /dev/null +++ b/meta_whatsapp/wizard/whatsapp_composer.py @@ -0,0 +1,83 @@ +from odoo import _, api, fields, models +from odoo.tools.safe_eval import safe_eval + + +class WhatsAppComposer(models.TransientModel): + _name = "whatsapp.composer" + _description = "Send WhatsApp Wizard" + + res_model = fields.Char("Model", required=True) + res_ids = fields.Char("Document IDs", required=True) + + template_id = fields.Many2one("whatsapp.template", string="Template", required=True) + phone = fields.Char( + string="Phone Number", compute="_compute_phone", store=True, readonly=False + ) + body = fields.Text( + string="Message Preview", compute="_compute_body", store=True, readonly=False + ) + + @api.depends("res_model", "res_ids") + def _compute_phone(self): + for record in self: + if record.res_model and record.res_ids: + res_ids = safe_eval(record.res_ids) + if isinstance(res_ids, int): + res_ids = [res_ids] + + # Simple logic to find phone/mobile field + docs = self.env[record.res_model].browse(res_ids) + if docs: + record.phone = getattr( + docs[0], "mobile", getattr(docs[0], "phone", False) + ) + + @api.depends("template_id") + def _compute_body(self): + for record in self: + if record.template_id: + record.body = record.template_id.body + + def action_send_whatsapp(self): + self.ensure_one() + res_ids = safe_eval(self.res_ids) + if isinstance(res_ids, int): + res_ids = [res_ids] + + active_ids = self.env[self.res_model].browse(res_ids) + + messages = [] + for doc in active_ids: + # Logic to resolve phone number + phone = getattr(doc, "mobile", getattr(doc, "phone", False)) + if not phone: + continue + + # Create message record + # In a real implementation, we would render variables here + msg = self.env["whatsapp.message"].create( + { + "body": self.body, + "mobile_number": phone, + "partner_id": doc.id if self.res_model == "res.partner" else False, + "template_id": self.template_id.id, + "res_model": self.res_model, + "res_id": doc.id, + "status": "draft", + } + ) + messages.append(msg) + + # Send immediately for now + for msg in messages: + msg.action_send() + + # Post message to chatter if the model supports it + if msg.res_model and msg.res_id: + record = self.env[msg.res_model].browse(msg.res_id) + if hasattr(record, "message_post"): + # Use the body from the message record which is now rendered + body = _("WhatsApp Message Sent
%s") % msg.body + record.message_post(body=body) + + return {"type": "ir.actions.act_window_close"} diff --git a/meta_whatsapp/wizard/whatsapp_composer_views.xml b/meta_whatsapp/wizard/whatsapp_composer_views.xml new file mode 100644 index 0000000000..cdf43afe95 --- /dev/null +++ b/meta_whatsapp/wizard/whatsapp_composer_views.xml @@ -0,0 +1,29 @@ + + + + + whatsapp.composer.form + whatsapp.composer + +
+ + + + + + + +
+
+
+
+
+
+
diff --git a/meta_whatsapp/wizard/whatsapp_preview.py b/meta_whatsapp/wizard/whatsapp_preview.py new file mode 100644 index 0000000000..43a21a8593 --- /dev/null +++ b/meta_whatsapp/wizard/whatsapp_preview.py @@ -0,0 +1,83 @@ +from odoo import api, fields, models + + +class WhatsAppPreview(models.TransientModel): + _name = "whatsapp.preview" + _description = "WhatsApp Template Preview" + + template_id = fields.Many2one("whatsapp.template", string="Template", required=True, ondelete="cascade") + lang = fields.Selection(lambda self: self.env['res.lang'].get_installed(), string='Template Preview Language') + model_id = fields.Many2one("ir.model", string="Model", related="template_id.model_id") + res_id = fields.Reference( + string="Record", + selection="_selection_target_model", + required=True, + ) + body = fields.Text(string="Body", compute="_compute_preview", readonly=True) + header_text = fields.Char(string="Header", compute="_compute_preview", readonly=True) + footer_text = fields.Char(string="Footer", related="template_id.footer_text", readonly=True) + no_record = fields.Boolean("No Record", compute="_compute_no_record") + + @api.model + def _selection_target_model(self): + models = self.env["ir.model"].search([("is_mail_thread", "=", True)]) + return [(model.model, model.name) for model in models] + + @api.model + def default_get(self, fields): + res = super(WhatsAppPreview, self).default_get(fields) + template_id = res.get("template_id") or self.env.context.get("default_template_id") + if not res.get("res_id") and template_id: + template = self.env["whatsapp.template"].browse(template_id) + if template.model_id: + record = self.env[template.model_id.model].search([], limit=1) + if record: + res["res_id"] = "%s,%s" % (template.model_id.model, record.id) + return res + + @api.depends("model_id") + def _compute_no_record(self): + for preview in self: + preview.no_record = ( + self.env[preview.model_id.model].search_count([]) == 0 + if preview.model_id + else True + ) + + @api.depends("template_id", "res_id") + def _compute_preview(self): + for preview in self: + if not preview.template_id or not preview.res_id: + preview.body = "" + preview.header_text = "" + continue + + template = preview.template_id + record = preview.res_id + + body = template.body or "" + header = template.header_text or "" + + # Simple rendering logic (reusing or centralizing this would be better) + for var in template.variable_ids.sorted("sequence"): + placeholder = var.name + value = "" + if var.field_type == "text": + value = var.field_name + elif var.field_type == "field": + try: + field_path = var.field_name.split(".") + val = record + for path in field_path: + if not val: break + val = getattr(val, path, "") + value = str(val) if val not in (False, None) else "" + except Exception: + value = "[Error]" + + if value: + body = body.replace(placeholder, value) + header = header.replace(placeholder, value) + + preview.body = body + preview.header_text = header diff --git a/meta_whatsapp/wizard/whatsapp_preview_views.xml b/meta_whatsapp/wizard/whatsapp_preview_views.xml new file mode 100644 index 0000000000..78506fa239 --- /dev/null +++ b/meta_whatsapp/wizard/whatsapp_preview_views.xml @@ -0,0 +1,58 @@ + + + + whatsapp.preview.view.form + whatsapp.preview + +
+ + + + + + No records found for this model. + + +
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+
+
+ + + Template Preview + whatsapp.preview + form + new + {'default_template_id': active_id} + +
diff --git a/setup/meta_whatsapp/odoo/addons/meta_whatsapp b/setup/meta_whatsapp/odoo/addons/meta_whatsapp new file mode 120000 index 0000000000..2c213a274a --- /dev/null +++ b/setup/meta_whatsapp/odoo/addons/meta_whatsapp @@ -0,0 +1 @@ +../../../../meta_whatsapp \ No newline at end of file diff --git a/setup/meta_whatsapp/setup.py b/setup/meta_whatsapp/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/meta_whatsapp/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) From 25a3ca701044bf9a668bdbc77f5048705b1ca18d Mon Sep 17 00:00:00 2001 From: RPSJR Date: Sun, 22 Feb 2026 20:22:24 -0300 Subject: [PATCH 02/11] [FIX] meta_whatsapp: move context action buttons to button box to align with Preview button --- .../views/whatsapp_template_views.xml | 38 ++++++++++++------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/meta_whatsapp/views/whatsapp_template_views.xml b/meta_whatsapp/views/whatsapp_template_views.xml index 25d7fc2e46..334034a3de 100644 --- a/meta_whatsapp/views/whatsapp_template_views.xml +++ b/meta_whatsapp/views/whatsapp_template_views.xml @@ -21,20 +21,6 @@
- +
From 32e632fc24a2ecf08e17ba27a9bb59a5af969b94 Mon Sep 17 00:00:00 2001 From: RPSJR Date: Wed, 25 Feb 2026 09:15:50 -0300 Subject: [PATCH 04/11] [REF] meta_whatsapp: move menus to Technical settings and rename Configuration to WhatsApp Accounts --- meta_whatsapp/views/res_config_settings_views.xml | 11 ++--------- meta_whatsapp/views/whatsapp_template_views.xml | 3 ++- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/meta_whatsapp/views/res_config_settings_views.xml b/meta_whatsapp/views/res_config_settings_views.xml index f9769c1c80..0323005e95 100644 --- a/meta_whatsapp/views/res_config_settings_views.xml +++ b/meta_whatsapp/views/res_config_settings_views.xml @@ -91,17 +91,10 @@ - - diff --git a/meta_whatsapp/views/whatsapp_template_views.xml b/meta_whatsapp/views/whatsapp_template_views.xml index fa22de6340..73b4265080 100644 --- a/meta_whatsapp/views/whatsapp_template_views.xml +++ b/meta_whatsapp/views/whatsapp_template_views.xml @@ -153,7 +153,8 @@ Date: Sun, 1 Mar 2026 18:55:26 -0300 Subject: [PATCH 05/11] Add unique constraint for name/language and improve template sync --- meta_whatsapp/models/whatsapp_template.py | 32 +++++++++++++++-------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/meta_whatsapp/models/whatsapp_template.py b/meta_whatsapp/models/whatsapp_template.py index f51ea21e17..92b42eac64 100644 --- a/meta_whatsapp/models/whatsapp_template.py +++ b/meta_whatsapp/models/whatsapp_template.py @@ -9,6 +9,14 @@ class WhatsAppTemplate(models.Model): _name = "whatsapp.template" _description = "WhatsApp Template" + _sql_constraints = [ + ( + "name_lang_unique", + "unique(name, language)", + "Template name and language must be unique!", + ) + ] + name = fields.Char(string="Name", required=True) status = fields.Selection( [ @@ -175,8 +183,6 @@ def action_sync_templates(self): # Update existing record with correct language context existing.with_context(ctx).write(vals) template = existing - # Optional: Clear old variables to re-sync them - template.variable_ids.unlink() else: # Create new record with correct language context template = self.with_context(ctx).create(vals) @@ -186,16 +192,20 @@ def action_sync_templates(self): h_vars = re.findall(r"\{\{(\d+)\}\}", header_text) for var_num in h_vars: var_name = "{{%s}}" % var_num - self.env["whatsapp.template.variable"].create( - { - "template_id": template.id, - "name": var_name, - "sequence": int(var_num), - "location": "header", - "field_type": "field", - "field_name": "id", - } + existing_var = self.env["whatsapp.template.variable"].search( + [("template_id", "=", template.id), ("name", "=", var_name)] ) + if not existing_var: + self.env["whatsapp.template.variable"].create( + { + "template_id": template.id, + "name": var_name, + "sequence": int(var_num), + "location": "header", + "field_type": "field", + "field_name": "id", + } + ) if body_text: b_vars = re.findall(r"\{\{(\d+)\}\}", body_text) From 0628abe7bd73d5f009b3def35e034b542ecfb52e Mon Sep 17 00:00:00 2001 From: RPSJR Date: Sun, 1 Mar 2026 19:58:41 -0300 Subject: [PATCH 06/11] [meta_whatsapp] Implement buttons and media samples support --- meta_whatsapp/__manifest__.py | 2 +- meta_whatsapp/models/whatsapp_template.py | 72 +++++++++++++++++++ meta_whatsapp/security/ir.model.access.csv | 1 + .../views/whatsapp_template_views.xml | 20 ++++++ 4 files changed, 94 insertions(+), 1 deletion(-) diff --git a/meta_whatsapp/__manifest__.py b/meta_whatsapp/__manifest__.py index 4b9d79f21f..624f33d25a 100644 --- a/meta_whatsapp/__manifest__.py +++ b/meta_whatsapp/__manifest__.py @@ -1,6 +1,6 @@ { "name": "Meta WhatsApp Connector", - "version": "13.0.1.0.0", + "version": "13.0.1.1.0", "category": "Marketing/WhatsApp", "summary": "Send WhatsApp messages using Meta API v25.0", "author": "Odoo Community Association (OCA)", diff --git a/meta_whatsapp/models/whatsapp_template.py b/meta_whatsapp/models/whatsapp_template.py index 92b42eac64..d793a591d5 100644 --- a/meta_whatsapp/models/whatsapp_template.py +++ b/meta_whatsapp/models/whatsapp_template.py @@ -56,8 +56,14 @@ class WhatsAppTemplate(models.Model): default="NONE", ) header_text = fields.Char(string="Header Text") + header_media_handle = fields.Char(string="Header Media Handle") + header_media_url = fields.Char(string="Header Media URL") footer_text = fields.Char(string="Footer Text") + button_ids = fields.One2many( + "whatsapp.template.button", "template_id", string="Buttons" + ) + model_id = fields.Many2one( "ir.model", string="Applies to", @@ -149,12 +155,16 @@ def action_sync_templates(self): "header_text": False, "footer_text": False, "header_type": "NONE", + "header_media_handle": False, + "header_media_url": False, } # Extended parsing of components to update body/header/footer body_text = "" header_text = "" footer_text = "" + buttons_data = [] + for component in tmpl_data.get("components", []): ctype = component.get("type", "").upper() if ctype == "BODY": @@ -165,9 +175,35 @@ def action_sync_templates(self): if vals["header_type"] == "TEXT": header_text = component.get("text", "") vals["header_text"] = header_text + elif vals["header_type"] in ["IMAGE", "VIDEO", "DOCUMENT"]: + example = component.get("example", {}) + header_handles = example.get("header_handle", []) + if header_handles: + vals["header_media_handle"] = header_handles[0] + header_urls = example.get("header_url", []) + if header_urls: + vals["header_media_url"] = header_urls[0] + elif ctype == "FOOTER": footer_text = component.get("text", "") vals["footer_text"] = footer_text + elif ctype == "BUTTONS": + for btn in component.get("buttons", []): + btn_type = btn.get("type", "").upper() + btn_vals = { + "type": btn_type, + "text": btn.get("text", ""), + } + if btn_type == "URL": + btn_vals["website_url"] = btn.get("url", "") + if "{{" in btn_vals["website_url"]: + btn_vals["url_type"] = "DYNAMIC" + else: + btn_vals["url_type"] = "STATIC" + elif btn_type == "PHONE_NUMBER": + btn_vals["phone_number"] = btn.get("phone_number", "") + + buttons_data.append(btn_vals) # Set context language if it is active in Odoo, else use current context lang_code = tmpl_data["language"] @@ -187,6 +223,13 @@ def action_sync_templates(self): # Create new record with correct language context template = self.with_context(ctx).create(vals) + # Sync buttons + template.button_ids.unlink() + for i, btn_vals in enumerate(buttons_data): + btn_vals["template_id"] = template.id + btn_vals["sequence"] = i + self.env["whatsapp.template.button"].create(btn_vals) + # Automatically extract variables {{1}}, {{2}}... from body and header if header_text: h_vars = re.findall(r"\{\{(\d+)\}\}", header_text) @@ -292,3 +335,32 @@ class WhatsAppTemplateVariable(models.Model): ) demo_value = fields.Char(string="Demo Value", help="Value used for preview/testing") + + +class WhatsAppTemplateButton(models.Model): + _name = "whatsapp.template.button" + _description = "WhatsApp Template Button" + _order = "sequence, id" + + template_id = fields.Many2one( + "whatsapp.template", string="Template", required=True, ondelete="cascade" + ) + type = fields.Selection( + [ + ("PHONE_NUMBER", "Phone Number"), + ("URL", "URL"), + ("QUICK_REPLY", "Quick Reply"), + ("COPY_CODE", "Copy Code"), + ], + string="Type", + required=True, + ) + text = fields.Char(string="Button Text", required=True) + url_type = fields.Selection( + [("STATIC", "Static"), ("DYNAMIC", "Dynamic")], + string="URL Type", + default="STATIC", + ) + website_url = fields.Char(string="Website URL") + phone_number = fields.Char(string="Phone Number") + sequence = fields.Integer(string="Sequence", default=10) diff --git a/meta_whatsapp/security/ir.model.access.csv b/meta_whatsapp/security/ir.model.access.csv index 85a5cf32e9..43213ea6d5 100644 --- a/meta_whatsapp/security/ir.model.access.csv +++ b/meta_whatsapp/security/ir.model.access.csv @@ -3,3 +3,4 @@ access_whatsapp_template,whatsapp.template,model_whatsapp_template,base.group_us access_whatsapp_message,whatsapp.message,model_whatsapp_message,base.group_user,1,1,1,1 access_whatsapp_template_variable,whatsapp.template.variable,model_whatsapp_template_variable,base.group_user,1,1,1,1 access_whatsapp_composer,whatsapp.composer,model_whatsapp_composer,base.group_user,1,1,1,1 +access_whatsapp_template_button,whatsapp.template.button,model_whatsapp_template_button,base.group_user,1,1,1,1 diff --git a/meta_whatsapp/views/whatsapp_template_views.xml b/meta_whatsapp/views/whatsapp_template_views.xml index 73b4265080..e59e7ad1f5 100644 --- a/meta_whatsapp/views/whatsapp_template_views.xml +++ b/meta_whatsapp/views/whatsapp_template_views.xml @@ -81,10 +81,30 @@ name="header_text" attrs="{'invisible': [('header_type', '!=', 'TEXT')]}" /> + + + + + + + + + + + + + + From 3578fa11036a466aa288e8e19d4587305e1575ca Mon Sep 17 00:00:00 2001 From: RPSJR Date: Sun, 1 Mar 2026 20:14:08 -0300 Subject: [PATCH 07/11] [meta_whatsapp] Implement sending button parameters and media headers --- meta_whatsapp/models/whatsapp_message.py | 74 +++++++++++++---- meta_whatsapp/models/whatsapp_template.py | 27 +++++- meta_whatsapp/security/ir.model.access.csv | 2 + .../views/whatsapp_template_views.xml | 1 + meta_whatsapp/wizard/whatsapp_preview.py | 83 ++++++++++++++----- .../wizard/whatsapp_preview_views.xml | 11 +++ 6 files changed, 163 insertions(+), 35 deletions(-) diff --git a/meta_whatsapp/models/whatsapp_message.py b/meta_whatsapp/models/whatsapp_message.py index 918c49cbcc..6ded498fdd 100644 --- a/meta_whatsapp/models/whatsapp_message.py +++ b/meta_whatsapp/models/whatsapp_message.py @@ -111,7 +111,7 @@ def action_send(self): for msg in self: msg._send_message(url, headers) - def _prepare_template_parameters(self, location="body"): + def _prepare_template_parameters(self, location="body", button_index=0): self.ensure_one() parameters = [] if not self.template_id: @@ -123,7 +123,7 @@ def _prepare_template_parameters(self, location="body"): # Filter variables by location and sort them vars_to_process = self.template_id.variable_ids.filtered( - lambda v: v.location == location + lambda v: v.location == location and (location != "button" or v.button_index == button_index) ).sorted("sequence") for variable in vars_to_process: @@ -150,6 +150,7 @@ def _prepare_template_parameters(self, location="body"): if not value: value = " " + # Button variables are always type 'text' (for URL suffix) parameters.append({"type": "text", "text": value}) return parameters @@ -170,18 +171,37 @@ def _send_message(self, url, headers): rendered_body = self.template_id.body rendered_header = self.template_id.header_text - # Check for Header parameters - header_params = self._prepare_template_parameters(location="header") + # Check for Header parameters (Text or Media) + header_params = [] + if self.template_id.header_type == "TEXT": + header_params = self._prepare_template_parameters(location="header") + elif self.template_id.header_type in ["IMAGE", "VIDEO", "DOCUMENT"]: + # If media header, we need to provide the media URL or handle + # For now, we use the sample URL or handle we synced if available + media_type = self.template_id.header_type.lower() + media_vals = {} + if self.template_id.header_media_url: + media_vals = {"link": self.template_id.header_media_url} + elif self.template_id.header_media_handle: + media_vals = {"id": self.template_id.header_media_handle} + + if media_vals: + header_params.append({ + "type": media_type, + media_type: media_vals + }) + if header_params: components.append({ "type": "header", "parameters": header_params }) - # Replace variables in header preview - for i, p in enumerate(header_params, 1): - placeholder = "{{%s}}" % i - if rendered_header: - rendered_header = rendered_header.replace(placeholder, p["text"]) + # Replace variables in header preview (only for text) + if self.template_id.header_type == "TEXT": + for i, p in enumerate(header_params, 1): + placeholder = "{{%s}}" % i + if rendered_header: + rendered_header = rendered_header.replace(placeholder, p.get("text", "")) # Check for Body parameters body_params = self._prepare_template_parameters(location="body") @@ -191,19 +211,45 @@ def _send_message(self, url, headers): "parameters": body_params }) # Replace variables in body preview - # Note: body variables usually start from 1 or continue from header - # We need to find all variables to replace them correctly - all_params = header_params + body_params - for i, p in enumerate(all_params, 1): + for i, p in enumerate(body_params, 1): placeholder = "{{%s}}" % i if rendered_body: - rendered_body = rendered_body.replace(placeholder, p["text"]) + rendered_body = rendered_body.replace(placeholder, p.get("text", "")) + + # Check for Button parameters (Dynamic URL buttons) + for i, button in enumerate(self.template_id.button_ids): + if button.url_type == "DYNAMIC": + btn_params = self._prepare_template_parameters(location="button", button_index=i) + if btn_params: + components.append({ + "type": "button", + "sub_type": "url", + "index": i, + "parameters": btn_params + }) # Final rendered text for internal Odoo logs full_rendered_text = "" if rendered_header: full_rendered_text += rendered_header + "\n" full_rendered_text += rendered_body + + if self.template_id.footer_text: + full_rendered_text += "\n\n" + self.template_id.footer_text + + for i, button in enumerate(self.template_id.button_ids): + btn_text = button.text + if button.url_type == "DYNAMIC": + btn_params = self._prepare_template_parameters(location="button", button_index=i) + if btn_params: + # In preview, show the first parameter appended to URL + btn_text += " (%s%s)" % (button.website_url or "", btn_params[0].get("text", "")) + elif button.type == "URL": + btn_text += " (%s)" % (button.website_url or "") + elif button.type == "PHONE_NUMBER": + btn_text += " (%s)" % (button.phone_number or "") + + full_rendered_text += "\n\n[Button: %s]" % btn_text payload = { "messaging_product": "whatsapp", diff --git a/meta_whatsapp/models/whatsapp_template.py b/meta_whatsapp/models/whatsapp_template.py index d793a591d5..a7061c5620 100644 --- a/meta_whatsapp/models/whatsapp_template.py +++ b/meta_whatsapp/models/whatsapp_template.py @@ -228,7 +228,29 @@ def action_sync_templates(self): for i, btn_vals in enumerate(buttons_data): btn_vals["template_id"] = template.id btn_vals["sequence"] = i - self.env["whatsapp.template.button"].create(btn_vals) + btn_obj = self.env["whatsapp.template.button"].create(btn_vals) + + # If dynamic URL, create a variable for it + if btn_vals.get("url_type") == "DYNAMIC": + # For dynamic URLs, the {{1}} is always at the end of the URL + # Meta usually provides it as {{1}} in the text or URL + var_name = "{{1}}" # Always 1 for each button + existing_var = self.env["whatsapp.template.variable"].search([ + ("template_id", "=", template.id), + ("name", "=", var_name), + ("location", "=", "button"), + ("button_index", "=", i) + ]) + if not existing_var: + self.env["whatsapp.template.variable"].create({ + "template_id": template.id, + "name": var_name, + "sequence": i, # Button index + "location": "button", + "button_index": i, + "field_type": "field", + "field_name": "id", + }) # Automatically extract variables {{1}}, {{2}}... from body and header if header_text: @@ -323,11 +345,12 @@ class WhatsAppTemplateVariable(models.Model): required=True, ) location = fields.Selection( - [("header", "Header"), ("body", "Body")], + [("header", "Header"), ("body", "Body"), ("button", "Button")], string="Location", default="body", required=True, ) + button_index = fields.Integer(string="Button Index", default=0) field_name = fields.Char( string="Field / Text", required=True, diff --git a/meta_whatsapp/security/ir.model.access.csv b/meta_whatsapp/security/ir.model.access.csv index 43213ea6d5..f6f08f8b97 100644 --- a/meta_whatsapp/security/ir.model.access.csv +++ b/meta_whatsapp/security/ir.model.access.csv @@ -4,3 +4,5 @@ access_whatsapp_message,whatsapp.message,model_whatsapp_message,base.group_user, access_whatsapp_template_variable,whatsapp.template.variable,model_whatsapp_template_variable,base.group_user,1,1,1,1 access_whatsapp_composer,whatsapp.composer,model_whatsapp_composer,base.group_user,1,1,1,1 access_whatsapp_template_button,whatsapp.template.button,model_whatsapp_template_button,base.group_user,1,1,1,1 +access_whatsapp_preview,whatsapp.preview,model_whatsapp_preview,base.group_user,1,1,1,1 +access_whatsapp_preview_button,whatsapp.preview.button,model_whatsapp_preview_button,base.group_user,1,1,1,1 diff --git a/meta_whatsapp/views/whatsapp_template_views.xml b/meta_whatsapp/views/whatsapp_template_views.xml index e59e7ad1f5..c750572c11 100644 --- a/meta_whatsapp/views/whatsapp_template_views.xml +++ b/meta_whatsapp/views/whatsapp_template_views.xml @@ -111,6 +111,7 @@ + diff --git a/meta_whatsapp/wizard/whatsapp_preview.py b/meta_whatsapp/wizard/whatsapp_preview.py index 43a21a8593..efcfc7e97b 100644 --- a/meta_whatsapp/wizard/whatsapp_preview.py +++ b/meta_whatsapp/wizard/whatsapp_preview.py @@ -15,8 +15,12 @@ class WhatsAppPreview(models.TransientModel): ) body = fields.Text(string="Body", compute="_compute_preview", readonly=True) header_text = fields.Char(string="Header", compute="_compute_preview", readonly=True) + header_media_url = fields.Char(string="Header Media URL", compute="_compute_preview", readonly=True) footer_text = fields.Char(string="Footer", related="template_id.footer_text", readonly=True) no_record = fields.Boolean("No Record", compute="_compute_no_record") + preview_button_ids = fields.One2many( + "whatsapp.preview.button", "preview_id", string="Buttons", compute="_compute_preview" + ) @api.model def _selection_target_model(self): @@ -50,6 +54,8 @@ def _compute_preview(self): if not preview.template_id or not preview.res_id: preview.body = "" preview.header_text = "" + preview.header_media_url = "" + preview.preview_button_ids = [(5, 0, 0)] continue template = preview.template_id @@ -57,27 +63,66 @@ def _compute_preview(self): body = template.body or "" header = template.header_text or "" + header_media_url = template.header_media_url or "" # Simple rendering logic (reusing or centralizing this would be better) - for var in template.variable_ids.sorted("sequence"): - placeholder = var.name - value = "" - if var.field_type == "text": - value = var.field_name - elif var.field_type == "field": - try: - field_path = var.field_name.split(".") - val = record - for path in field_path: - if not val: break - val = getattr(val, path, "") - value = str(val) if val not in (False, None) else "" - except Exception: - value = "[Error]" - - if value: - body = body.replace(placeholder, value) - header = header.replace(placeholder, value) + # We must use the same logic as in whatsapp_message to be consistent + + # 1. Header Variables (Text Only) + if template.header_type == "TEXT": + h_vars = template.variable_ids.filtered(lambda v: v.location == "header").sorted("sequence") + for var in h_vars: + value = self._get_var_value(var, record) + header = header.replace(var.name, value) + + # 2. Body Variables + b_vars = template.variable_ids.filtered(lambda v: v.location == "body").sorted("sequence") + for var in b_vars: + value = self._get_var_value(var, record) + body = body.replace(var.name, value) preview.body = body preview.header_text = header + preview.header_media_url = header_media_url + + # 3. Button Preview + buttons_vals = [] + for i, button in enumerate(template.button_ids): + text = button.text + if button.url_type == "DYNAMIC": + btn_vars = template.variable_ids.filtered(lambda v: v.location == "button" and v.button_index == i).sorted("sequence") + if btn_vars: + value = self._get_var_value(btn_vars[0], record) + text += " (%s%s)" % (button.website_url or "", value) + elif button.type == "URL": + text += " (%s)" % (button.website_url or "") + elif button.type == "PHONE_NUMBER": + text += " (%s)" % (button.phone_number or "") + + buttons_vals.append((0, 0, {"text": text})) + + preview.preview_button_ids = buttons_vals + + def _get_var_value(self, var, record): + value = "" + if var.field_type == "text": + value = var.field_name + elif var.field_type == "field": + try: + field_path = var.field_name.split(".") + val = record + for path in field_path: + if not val: break + val = getattr(val, path, "") + value = str(val) if val not in (False, None) else "" + except Exception: + value = "[Error]" + return value + + +class WhatsAppPreviewButton(models.TransientModel): + _name = "whatsapp.preview.button" + _description = "WhatsApp Preview Button" + + preview_id = fields.Many2one("whatsapp.preview", string="Preview") + text = fields.Char(string="Button Content") diff --git a/meta_whatsapp/wizard/whatsapp_preview_views.xml b/meta_whatsapp/wizard/whatsapp_preview_views.xml index 78506fa239..41b87dad1f 100644 --- a/meta_whatsapp/wizard/whatsapp_preview_views.xml +++ b/meta_whatsapp/wizard/whatsapp_preview_views.xml @@ -30,6 +30,9 @@ >
+
+ +
@@ -39,6 +42,14 @@ > +
+ + + + + +
+