diff --git a/website_event_questions_multiple/README.rst b/website_event_questions_multiple/README.rst index ec55dea30..67ab17f35 100644 --- a/website_event_questions_multiple/README.rst +++ b/website_event_questions_multiple/README.rst @@ -13,9 +13,9 @@ Questions on Events - Type multiple .. |badge1| image:: https://img.shields.io/badge/maturity-Production%2FStable-green.png :target: https://odoo-community.org/page/development-status :alt: Production/Stable -.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png - :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html - :alt: License: AGPL-3 +.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fevent-lightgray.png?logo=github :target: https://github.com/OCA/event/tree/16.0/website_event_questions_multiple :alt: OCA/event @@ -28,7 +28,7 @@ Questions on Events - Type multiple |badge1| |badge2| |badge3| |badge4| |badge5| -This module allows to add new question type : Selection multiple which allows +This module allows to add new question type : Multiple Selection which allows attendees to select multiple answers to a question. **Table of contents** @@ -39,7 +39,7 @@ attendees to select multiple answers to a question. Configuration ============= -On an event, when creating a new question, you can select new type : Selection multiple +On an event, when creating a new question, you can select new type : Multiple Selection Bug Tracker =========== @@ -58,6 +58,7 @@ Authors ~~~~~~~ * Le Filament +* Odoo S.A. Contributors ~~~~~~~~~~~~ diff --git a/website_event_questions_multiple/__manifest__.py b/website_event_questions_multiple/__manifest__.py index 33a36fb71..62db09293 100644 --- a/website_event_questions_multiple/__manifest__.py +++ b/website_event_questions_multiple/__manifest__.py @@ -4,8 +4,8 @@ "category": "Marketing", "website": "https://github.com/OCA/event", "development_status": "Production/Stable", - "author": "Le Filament, Odoo Community Association (OCA)", - "license": "AGPL-3", + "author": "Le Filament, Odoo Community Association (OCA), Odoo S.A.", + "license": "LGPL-3", "application": False, "depends": ["website_event_questions"], "data": [ @@ -13,6 +13,11 @@ "views/event_questions_views.xml", "views/event_registration_views.xml", ], + "assets": { + "web.assets_frontend": [ + "website_event_questions_multiple/static/src/js/form_validation.esm.js" + ], + }, "installable": True, "auto_install": False, } diff --git a/website_event_questions_multiple/controllers/main.py b/website_event_questions_multiple/controllers/main.py index 09200377d..530cc1b49 100644 --- a/website_event_questions_multiple/controllers/main.py +++ b/website_event_questions_multiple/controllers/main.py @@ -1,6 +1,7 @@ # Copyright 2023 Le Filament (https://le-filament.com) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) +from odoo.exceptions import UserError from odoo.http import request from odoo.addons.website_event_questions.controllers.main import WebsiteEvent @@ -11,33 +12,85 @@ def _process_attendees_form(self, event, form_details): """Process data posted from the attendee details form. Extracts question answers: - For questions of type 'multiple_choice', extracting the suggested answer id""" - registrations = super(WebsiteEvent, self)._process_attendees_form( - event, form_details + registrations = super()._process_attendees_form(event, form_details) + + # list all question_multi_answer with is_mandatory_answer + mandatory_multiple_general_question_answer_count = {} + mandatory_multiple_specific_question_ids = set() + req_filter = ( + lambda q: q.question_type == "multiple_choice" and q.is_mandatory_answer ) + for general_question in event.general_question_ids.filtered(req_filter): + mandatory_multiple_general_question_answer_count[general_question.id] = 0 + for specific_question in event.specific_question_ids.filtered(req_filter): + mandatory_multiple_specific_question_ids.add(specific_question.id) + + mandatory_multiple_specific_question_answer_count = [ + {i: 0 for i in mandatory_multiple_specific_question_ids} + for j in range(len(registrations)) + ] general_answer_ids = [] for key, _value in form_details.items(): - if "question_multi_answer" in key: - dummy, registration_index, question_answer = key.split("-") - question_id, answer_id = question_answer.split("_") - question_sudo = request.env["event.question"].browse(int(question_id)) + # test html input prefix + if key.startswith("question_multi_answer"): + _html_input_prefix, registration_index_str, question_answer = key.split( + "-" + ) + registration_index = int(registration_index_str) + question_id_str, answer_id = question_answer.split("_") + question_id = int(question_id_str) + question_sudo = request.env["event.question"].browse(question_id) answer_sudo = request.env["event.question.answer"].browse( int(answer_id) ) - answer_values = None - if question_sudo.question_type == "multiple_choice": - answer_values = { - "question_id": int(question_id), - "value_text_box": answer_sudo.name, - } - - if answer_values and not int(registration_index): + assert ( + question_sudo.question_type == "multiple_choice" + ) # otherwise, html is malformed + answer_values = { + "question_id": question_id, + "value_text_box": answer_sudo.name, + } + # question with null registration index are general + if registration_index == 0: general_answer_ids.append((0, 0, answer_values)) - elif answer_values: - registrations[int(registration_index) - 1][ - "registration_answer_ids" - ].append((0, 0, answer_values)) + if question_sudo.is_mandatory_answer: + mandatory_multiple_general_question_answer_count[ + question_id + ] += 1 + # question with registration index are specific to one registration + else: + rindex = registration_index - 1 # zero-based array ¹indexing + registrations[rindex]["registration_answer_ids"].append( + (0, 0, answer_values) + ) + if question_sudo.is_mandatory_answer: + mandatory_multiple_specific_question_answer_count[rindex][ + question_id + ] += 1 + + # check that answers contain at least one for mandatory question + # general + for q, c in mandatory_multiple_general_question_answer_count.items(): + if c == 0: + raise UserError( + "Question " + + str(q) + + " is mandatory but did not receive an answer." + ) + # specific + for r, cc in enumerate(mandatory_multiple_specific_question_answer_count): + for q, c in cc.items(): + if c == 0: + raise UserError( + "Question " + + str(q) + + " is mandatory but did not receive an answer in ticket number " + + str(r) + + "." + ) + # append general question to all items for registration in registrations: registration["registration_answer_ids"].extend(general_answer_ids) diff --git a/website_event_questions_multiple/i18n/fr.po b/website_event_questions_multiple/i18n/fr.po new file mode 100644 index 000000000..c7757f8ea --- /dev/null +++ b/website_event_questions_multiple/i18n/fr.po @@ -0,0 +1,36 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * website_event_questions_multiple +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-03-06 11:52+0000\n" +"PO-Revision-Date: 2025-03-06 11:52+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: website_event_questions_multiple +#: model:ir.model,name:website_event_questions_multiple.model_event_question +msgid "Event Question" +msgstr "Question" + +#. module: website_event_questions_multiple +#: model:ir.model.fields.selection,name:website_event_questions_multiple.selection__event_question__question_type__multiple_choice +msgid "Multiple Selection" +msgstr "Sélection multiple" + +#. module: website_event_questions_multiple +#: model:ir.model.fields,field_description:website_event_questions_multiple.field_event_question__question_type +msgid "Question Type" +msgstr "Type de question" + +#. module: website_event_questions_multiple +#: model_terms:ir.ui.view,arch_db:website_event_questions_multiple.registration_event_question +msgid "Please select at least one checkbox." +msgstr "Veuillez cocher au moins une case." diff --git a/website_event_questions_multiple/static/description/index.html b/website_event_questions_multiple/static/description/index.html index 07cc77b75..0b42fe96b 100644 --- a/website_event_questions_multiple/static/description/index.html +++ b/website_event_questions_multiple/static/description/index.html @@ -369,8 +369,8 @@

Questions on Events - Type multiple

!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! !! source digest: sha256:de10ccad1e53385cb3c9aa56bc533e4cd053db148e2d5fec32a0a0e04ac0e690 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! --> -

Production/Stable License: AGPL-3 OCA/event Translate me on Weblate Try me on Runboat

-

This module allows to add new question type : Selection multiple which allows +

Production/Stable License: LGPL-3 OCA/event Translate me on Weblate Try me on Runboat

+

This module allows to add new question type : Multiple Selection which allows attendees to select multiple answers to a question.

Table of contents

@@ -388,7 +388,7 @@

Questions on Events - Type multiple

Configuration

-

On an event, when creating a new question, you can select new type : Selection multiple

+

On an event, when creating a new question, you can select new type : Multiple Selection

Bug Tracker

@@ -404,6 +404,7 @@

Credits

Authors

diff --git a/website_event_questions_multiple/static/src/js/form_validation.esm.js b/website_event_questions_multiple/static/src/js/form_validation.esm.js new file mode 100644 index 000000000..68a181529 --- /dev/null +++ b/website_event_questions_multiple/static/src/js/form_validation.esm.js @@ -0,0 +1,175 @@ +/** @odoo-module **/ + +/** + * @copyright: 2025- Le Filament (https://le-filament.com) + * @copyright: 2004-2015 Odoo S.A. + * @license: LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + */ + +import EventRegistrationForm from "website_event.website_event"; +import ajax from "web.ajax"; +import core from "web.core"; +import publicWidget from "web.public.widget"; + +const _t = core._t; + +// Declare widget +// extends from EventRegistrationForm from Odoo addon website_event/static/src/js/website_event.js +export const EventRegistrationFormWithValidation = EventRegistrationForm.extend({ + // ------------------------------------------------ + // WARNING: code duplication for lack of extensibility + // this is a copy of the on_click function of the parent class + // which is the one opening the modal with these steps: + // 1. when clicking on "Register button" + // 2. look at selection (number of tickets to register) + // 3. disable the register button if no valid answer (e.g. zero tickets) + // 4. make an ajax.jsonRpc call to fetch the modal + // 5. inject the modal inside the page (there can be multiple instances of the modal if the register button is clicked multiple times) + // the only added behavior is to return the modal class to be able to add form validation logic + // return value is a Promise + // eslint is applied + on_click_parent: async function (ev) { + ev.preventDefault(); + ev.stopPropagation(); + var $form = $(ev.currentTarget).closest("form"); + var $button = $(ev.currentTarget).closest('[type="submit"]'); + var post = {}; + $("#registration_form table").siblings(".alert").remove(); + $("#registration_form select").each(function () { + post[$(this).attr("name")] = $(this).val(); + }); + var tickets_ordered = _.some( + _.map(post, function (value) { + return parseInt(value, 10); + }) + ); + if (!tickets_ordered) { + $('
') + .text(_t("Please select at least one ticket.")) + .insertAfter("#registration_form table"); + return new Promise(() => undefined); + } + $button.attr("disabled", true); + var action = $form.data("action") || $form.attr("action"); + var self = this; + return ajax.jsonRpc(action, "call", post).then(async function (modal) { + const tokenObj = await self._recaptcha.getToken( + "website_event_registration" + ); + if (tokenObj.error) { + self.displayNotification({ + type: "danger", + title: _t("Error"), + message: tokenObj.error, + sticky: true, + }); + $button.prop("disabled", false); + return false; + } + var $modal = $(modal); + // Retrocompatibility - REMOVE ME in master / saas-19 + $modal.find(".modal-body > div").removeClass("container"); + $modal.appendTo(document.body); + // "Modal" is coming from bootstrap which does not use ESM modules + // eslint-disable-next-line no-undef + const modalBS = new Modal($modal[0], { + backdrop: "static", + keyboard: false, + }); + modalBS.show(); + $modal.appendTo("body").modal("show"); + $modal.on("click", ".js_goto_event", function () { + $modal.modal("hide"); + $button.prop("disabled", false); + }); + $modal.on("click", ".btn-close", function () { + $button.prop("disabled", false); + }); + $modal.on("submit", "form", function (evt) { + const tokenInput = document.createElement("input"); + tokenInput.setAttribute("name", "recaptcha_token_response"); + tokenInput.setAttribute("type", "hidden"); + tokenInput.setAttribute("value", tokenObj.token); + evt.currentTarget.appendChild(tokenInput); + }); + // THIS IS THE ONLY REAL MODIFICATION + // return the $modal jQuery object + return $modal; + }); + }, + // / ------------------------------------------------ + + /** + * @override + * override the parent method to replace call to the modified function + */ + on_click: async function (ev) { + // Get modal from copy (not super()) + const $modal = await this.on_click_parent(ev); + if ($modal) { + this.add_validation($modal); + } else { + console.log("No modal was added."); + } + }, + + // This is where I add validation to the form in the modal + add_validation: function ($modal) { + console.log("Adding validation to modal."); + + // Prevent default + $modal.on("submit", "form", function (ev) { + console.log("form submitted"); + + // Search all check groups with mandatory answers + $modal + .find("div.form-check-group.is_mandatory_answer") + .each(function (index) { + console.log("testing group", index); + // Count number of checkbox + let checked_count = 0; + $(this) + .find(".form-check-input") + .each(function () { + if ($(this).prop("checked")) checked_count++; + }); + + // If zero, prevent default and display message + if (checked_count === 0) { + console.log("at least one checkbox must be checked"); + $(this).find(".mandatory-message").removeClass("d-none"); + ev.preventDefault(); + ev.stopPropagation(); + } + }); + }); + }, +}); + +// Register widget +// (also copied from parent) +publicWidget.registry.EventRegistrationFormWithValidationInstance = + publicWidget.Widget.extend({ + selector: "#registration_form", + + /** + * @override + */ + start: function () { + console.log("instance start override"); + var def = this._super.apply(this, arguments); + // Here we instantiante child widget + this.instance = new EventRegistrationFormWithValidation(this); + return Promise.all([def, this.instance.attachTo(this.$el)]); + }, + /** + * @override + */ + destroy: function () { + this.instance.setElement(null); + this._super.apply(this, arguments); + this.instance.setElement(this.$el); + }, + }); + +console.log("form validation widget registered"); diff --git a/website_event_questions_multiple/templates/event_template.xml b/website_event_questions_multiple/templates/event_template.xml index 30bb50248..d73501f2f 100644 --- a/website_event_questions_multiple/templates/event_template.xml +++ b/website_event_questions_multiple/templates/event_template.xml @@ -9,18 +9,23 @@ > -
+
+
+ Please select at least one checkbox. +