Cómo Crear un Autocompletado Seguro y Estricto de Contactos en Odoo v19 (Sin código Python Backend)
En el desarrollo de portales web con Odoo, un desafío técnico recurrente es permitir que los usuarios públicos interactúen con datos alojados en el backend. Un caso de uso muy común en la gestión de eventos, discotecas o restaurantes es capturar qué Relacionador Público (RRPP) trajo a un cliente al momento de llenar un formulario de citas o reservas (website_appointment).
Si intentamos consultar la lista de contactos directamente desde el navegador del cliente mediante JavaScript (llamadas RPC tradicionales), Odoo bloqueará la petición de inmediato con un error de acceso (Access Denied). Esto se debe a las estrictas reglas de control de acceso (ACL) que impiden que un usuario anónimo de internet husmee en nuestra base de datos de clientes.
En este artículo tutorial aprenderás cómo resolver este problema de raíz utilizando únicamente la arquitectura de vistas de Odoo, combinando el poder del motor de plantillas de servidor QWeb Sudo con la agilidad nativa de las listas de datos de HTML5.
La Arquitectura de la Solución: ¿Por qué es Eficiente?
Para lograr un sistema robusto de analítica donde podamos medir qué relacionadores rinden más o traen más reservas, no podemos confiar puramente en que el usuario escriba un nombre de texto libre (ya que errores tipográficos, minúsculas o espacios duplicarían los registros en nuestros reportes). Requerimos tres pilares técnicos:
Inyección en Servidor (QWeb Sudo): Odoo busca los contactos con la etiqueta de RRPP en el servidor antes de enviar el HTML al usuario final, saltándose la restricción de ACL de forma quirúrgica y segura.
Estructura Inmutable (ID de Contacto): Adjuntamos el ID de la base de datos de Odoo al texto visible (ej: Ingrid Claros [ID: 452]). De este modo, los futuros tableros de analítica agruparán los datos por ID numérico, protegiendo el histórico de datos si el RRPP cambia de nombre en el futuro.
Validación Flexible y Case-Insensitive: El formulario permite que el campo quede vacío (si la reserva es orgánica), pero si se escribe texto, obliga mediante JavaScript a que coincida exactamente con un elemento de la lista, sin importar mayúsculas o minúsculas.
Paso a Paso: Implementación del Código en la Vista Base
Para aplicar esta solución de manera directa, debes activar el Modo Desarrollador en Odoo v19, navegar a Ajustes > Técnico > Interfaz de Usuario > Vistas, buscar la vista base clave appointment.appointment_form ("Website Appointment: Your Data") y reemplazar su arquitectura completa con el siguiente código optimizado:
<t name="Website Appointment: Your Data" t-name="appointment.appointment_form">
<t t-set="no_breadcrumbs" t-value="True"/>
<t t-call="portal.portal_layout">
<div id="wrap" class="d-flex">
<t t-set="o_portal_fullwidth_alert" groups="appointment.group_appointment_manager">
<t t-call="appointment.appointment_edit_in_backend"/>
</t>
<div class="container-lg h-100">
<div t-attf-class="row #{'mt-4' if website else ''}">
<div t-attf-class="col-12 col-md-8 #{'p-4' if not website else ''}">
<nav class="d-print-none d-flex justify-content-between mb-4">
<t t-call="appointment.appointment_progress_bar">
<t t-set="step" t-value="30"/>
</t>
</nav>
<div class="oe_structure o_appointment">
<h4 class="pb-3">
<span>Agrega más detalles de la reserva</span>
<t t-if="request.env.user._is_public()">
<span class="mx-1">or</span>
<a class="text-primary text-decoration-none" role="button" t-att-href="login_with_redirect_url">Sign in</a>
</t>
</h4>
<div class="oe_structure"/>
<div class="oe_structure o_appointment_attendee_form">
<div class="d-flex row justify-content-between">
<form class="appointment_submit_form" t-attf-action="/appointment/#{appointment_type.id}/submit?#{keep_query('*')}" method="POST">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<input type="hidden" name="datetime_str" t-att-value="datetime_str"/>
<input type="hidden" name="duration_str" t-att-value="duration_str"/>
<input type="hidden" name="available_resource_ids" t-att-value="available_resource_ids"/>
<input type="hidden" name="asked_capacity" t-att-value="asked_capacity"/>
<div class="row mb-4">
<label class="col-sm-3 col-form-label fw-normal" for="name">Full name*</label>
<div class="col-sm-9">
<input type="char" class="form-control" name="name" required="1" t-att-value="'name' in partner_data and partner_data['name']" placeholder="e.g. John Smith"/>
</div>
</div>
<div class="row mb-4">
<label class="col-sm-3 col-form-label fw-normal" for="email">Email*</label>
<div class="col-sm-9">
<input type="email" class="form-control" name="email" required="1" t-att-value="'email' in partner_data and partner_data['email']" placeholder="e.g. [email protected]"/>
</div>
</div>
<t t-foreach="appointment_type.question_ids" t-as="question">
<div class="row mb-4" t-if="question.question_type!='text'">
<label class="col-sm-3 col-form-label fw-normal" t-attf-for="question_#{question.id}" t-out="' '.join([question.name, '*' if question.question_required else ''])"/>
<div class="col-sm-9">
<t t-if="question.question_type == 'phone'">
<t t-set="isMainPhoneQuestion" t-value="question == main_phone_question"/>
<input type="phone" class="form-control" t-attf-name="question_#{question.id}" t-att-value="partner_data['phone'] if isMainPhoneQuestion and 'phone' in partner_data else ''" t-att-required="question.question_required or None" t-att-placeholder="question.placeholder" t-att-data-is-main-phone-question="isMainPhoneQuestion"/>
</t>
<t t-if="question.question_type == 'char'">
<input type="char" class="form-control" t-attf-name="question_#{question.id}" t-att-required="question.question_required or None" t-att-placeholder="question.placeholder" t-att-data-question-name="question.name"/>
</t>
<t t-if="question.question_type == 'select'">
<select t-attf-name="question_#{question.id}" class="form-select" t-att-required="question.question_required or None" t-att-placeholder="question.placeholder">
<t t-foreach="question.answer_ids or []" t-as="answer">
<option t-att-value="answer.id"><t t-out="answer.name"/></option>
</t>
</select>
</t>
<t t-if="question.question_type == 'radio'">
<div class="checkbox form-control form-check border-0 pb-0" t-foreach="question.answer_ids or []" t-as="answer">
<label>
<input type="radio" t-attf-name="question_#{question.id}" t-att-required="question.question_required or None" t-att-value="answer.id" class="form-check-input me-2"/> <t t-out="answer.name"/>
</label>
</div>
</t>
<t t-if="question.question_type == 'checkbox'">
<div t-attf-class="checkbox-group #{question.question_required and 'required' or ''}">
<div class="checkbox form-control form-check border-0 pb-0" t-foreach="question.answer_ids or []" t-as="answer">
<label>
<input type="checkbox" t-attf-name="question_#{question.id}_answer_#{answer.id}" t-att-value="answer.name" class="form-check-input me-2"/><t t-out="answer.name"/>
</label>
</div>
</div>
</t>
<div t-if="not is_html_empty(question.extra_comment)" t-attf-class="{{'mt-2' if question.question_type in ['checkbox', 'radio'] else 'mt-1'}}">
<div class="form-text" t-field="question.extra_comment"/>
</div>
</div>
</div>
<div class="mb-4" t-if="question.question_type == 'text'">
<label t-attf-for="question_#{question.id}" class="fw-normal mb-1" t-out="' '.join([question.name, '*' if question.question_required else ''])"/>
<div t-if="not is_html_empty(question.extra_comment)" class="mb-3">
<div class="form-text" t-field="question.extra_comment"/>
</div>
<textarea class="form-control mt-1" rows="6" t-att-required="question.question_required or None" t-attf-name="question_#{question.id}" t-att-placeholder="question.placeholder"/>
</div>
</t>
<datalist id="lista_magica_rrpp">
<t t-set="etiquetas_rrpp" t-value="request.env['res.partner.category'].sudo().search(['|', ('name', 'ilike', 'RRPP'), ('name', 'ilike', 'R.R.P.P.')])"/>
<t t-if="etiquetas_rrpp">
<t t-foreach="request.env['res.partner'].sudo().search([('category_id', 'in', etiquetas_rrpp.ids), ('active', '=', True)])" t-as="contacto_rrpp">
<option t-att-value="contacto_rrpp.name + ' [ID: ' + str(contacto_rrpp.id) + ']'"/>
</t>
</t>
</datalist>
<t t-if="appointment_type.allow_guests">
<div class="row mb-4 mt-1">
<label class="col-sm-3 col-form-label fw-normal">Guests</label>
<div class="o_appointment_add_guests col-sm-9">
<button type="button" class="o_appointment_input_guest_add btn btn-link ps-0"><i class="oi oi-plus me-1"/> Add Guests</button>
<textarea type="email" id="o_appointment_input_guest_emails" class="form-control s_website_form_input d-none" name="guest_emails_str" placeholder="e.g. [email protected] e.g. [email protected] ..." rows="5"/>
<button type="button" class="o_appointment_input_guest_cancel btn btn-link d-none ps-0">Cancel</button>
<div class="o_appointment_validation_error alert alert-danger d-none mt-2">
<i class="fa fa-warning me-1"/>
<span class="o_appointment_error_text"/>
</div>
</div>
</div>
</t>
<div class="my-3 pt-3">
<div class="o_not_editable text-end">
<button type="button" class="o_appointment_form_confirm_btn btn btn-primary ms-auto">Confirm Appointment</button>
</div>
</div>
</form>
</div>
</div>
<div class="oe_structure"/>
</div>
</div>
<t t-call="appointment.appointment_details_column">
<t t-set="isDetails" t-value="True"/>
</t>
</div>
</div>
</div>
<script type="text/javascript">
document.addEventListener('DOMContentLoaded', function() {
const inputRrpp = document.querySelector('input[data-question-name="Relacionador"]') ||
document.querySelector('input[placeholder="Nombre RRPP."]');
if (!inputRrpp) return;
inputRrpp.setAttribute('list', 'lista_magica_rrpp');
inputRrpp.setAttribute('autocomplete', 'off');
// Mapeamos las opciones en minúsculas para comparar limpiamente
const opcionesValidas = Array.from(document.querySelectorAll('#lista_magica_rrpp option'))
.map(function(opt) {
return opt.value.trim().toLowerCase();
});
inputRrpp.addEventListener('input', function() {
const textoIngresado = this.value.trim().toLowerCase();
if (textoIngresado === '') {
this.setCustomValidity(''); // Campo vacío permitido (Reserva orgánica)
} else if (opcionesValidas.includes(textoIngresado)) {
this.setCustomValidity(''); // Coincidencia perfecta aceptada
} else {
// Alerta nativa del motor del navegador si escriben cualquier cosa
this.setCustomValidity('Por favor, selecciona un Relacionador válido de la lista desplegable.');
}
});
});
</script>
</t>
</t>
Cómo Extraer el ID en tus Futuros Reportes Técnicos
Una vez que el formulario es enviado, el valor se almacena como texto plano dentro de las respuestas a las preguntas de la cita (modelo calendar.appointment.answer.input). Al momento de diseñar tu reporte personalizado de rendimiento de RRPPs, puedes separar limpiamente el nombre del ID numérico utilizando una sola línea de expresión regular en tu script JavaScript:
// Recuperamos el string de respuesta de Odoo
let stringRespuesta = "Ingrid Claros [ID: 452]";
// Expresión regular para capturar los dígitos dentro de [ID: X]
let matches = stringRespuesta.match(/\[ID:\s*(\d+)\]/i);
let idRelacionador = matches ? parseInt(matches[1]) : null;
console.log(idRelacionador); // Imprime el entero puro: 452
Con este entero idRelacionador, tus análisis comerciales y agrupaciones de KPIs serán un 100% precisas, inmunes a cambios de nombre de personal, eliminando datos duplicados y listos para escalar a cientos de relacionadores en producción.