From 907258b4f454716d5682f65fc73887a208ca6026 Mon Sep 17 00:00:00 2001 From: pilarvargas-tecnativa Date: Wed, 19 Feb 2025 15:37:45 +0100 Subject: [PATCH 1/2] [ADD] website_sale_slides_multi_qty: New module TT54986 --- website_sale_slides_multi_qty/README.rst | 79 +++ website_sale_slides_multi_qty/__init__.py | 2 + website_sale_slides_multi_qty/__manifest__.py | 27 + .../controllers/__init__.py | 1 + .../controllers/main.py | 478 ++++++++++++++++++ .../data/mail_template_data.xml | 66 +++ website_sale_slides_multi_qty/i18n/es.po | 267 ++++++++++ .../i18n/website_sale_slides_multi_qty.pot | 198 ++++++++ .../models/__init__.py | 2 + .../models/slide_channel.py | 278 ++++++++++ .../models/slide_slide.py | 236 +++++++++ .../readme/CONTRIBUTORS.rst | 4 + .../readme/DESCRIPTION.rst | 1 + .../security/ir.model.access.csv | 2 + .../static/description/icon.png | Bin 0 -> 9455 bytes .../static/description/index.html | 426 ++++++++++++++++ .../static/src/js/slides_access_form.js | 17 + .../src/js/slides_course_fullscreen_player.js | 89 ++++ .../views/slide_channel_partner_views.xml | 16 + .../views/slide_channel_views.xml | 14 + .../views/website_slides_templates.xml | 16 + .../views/website_slides_templates_course.xml | 99 ++++ .../website_slides_templates_homepage.xml | 12 + 23 files changed, 2330 insertions(+) create mode 100644 website_sale_slides_multi_qty/README.rst create mode 100644 website_sale_slides_multi_qty/__init__.py create mode 100644 website_sale_slides_multi_qty/__manifest__.py create mode 100644 website_sale_slides_multi_qty/controllers/__init__.py create mode 100644 website_sale_slides_multi_qty/controllers/main.py create mode 100644 website_sale_slides_multi_qty/data/mail_template_data.xml create mode 100644 website_sale_slides_multi_qty/i18n/es.po create mode 100644 website_sale_slides_multi_qty/i18n/website_sale_slides_multi_qty.pot create mode 100644 website_sale_slides_multi_qty/models/__init__.py create mode 100644 website_sale_slides_multi_qty/models/slide_channel.py create mode 100644 website_sale_slides_multi_qty/models/slide_slide.py create mode 100644 website_sale_slides_multi_qty/readme/CONTRIBUTORS.rst create mode 100644 website_sale_slides_multi_qty/readme/DESCRIPTION.rst create mode 100644 website_sale_slides_multi_qty/security/ir.model.access.csv create mode 100644 website_sale_slides_multi_qty/static/description/icon.png create mode 100644 website_sale_slides_multi_qty/static/description/index.html create mode 100644 website_sale_slides_multi_qty/static/src/js/slides_access_form.js create mode 100644 website_sale_slides_multi_qty/static/src/js/slides_course_fullscreen_player.js create mode 100644 website_sale_slides_multi_qty/views/slide_channel_partner_views.xml create mode 100644 website_sale_slides_multi_qty/views/slide_channel_views.xml create mode 100644 website_sale_slides_multi_qty/views/website_slides_templates.xml create mode 100644 website_sale_slides_multi_qty/views/website_slides_templates_course.xml create mode 100644 website_sale_slides_multi_qty/views/website_slides_templates_homepage.xml diff --git a/website_sale_slides_multi_qty/README.rst b/website_sale_slides_multi_qty/README.rst new file mode 100644 index 0000000..9f9808a --- /dev/null +++ b/website_sale_slides_multi_qty/README.rst @@ -0,0 +1,79 @@ +============================= +Website Sale Slides Multi Qty +============================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:3a794fd5342313c42c608fcbc6bb79937d6bfe85b2257ab195a03dcbfa6c5703 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |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 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fe--learning-lightgray.png?logo=github + :target: https://github.com/OCA/e-learning/tree/15.0/website_sale_slides_multi_qty + :alt: OCA/e-learning +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/e-learning-15-0/e-learning-15-0-website_sale_slides_multi_qty + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/e-learning&target_branch=15.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + + + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Tecnativa + +Contributors +~~~~~~~~~~~~ + +* `Tecnativa `__: + + * David Vidal + * Pilar Vargas + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/e-learning `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/website_sale_slides_multi_qty/__init__.py b/website_sale_slides_multi_qty/__init__.py new file mode 100644 index 0000000..91c5580 --- /dev/null +++ b/website_sale_slides_multi_qty/__init__.py @@ -0,0 +1,2 @@ +from . import controllers +from . import models diff --git a/website_sale_slides_multi_qty/__manifest__.py b/website_sale_slides_multi_qty/__manifest__.py new file mode 100644 index 0000000..ee4036c --- /dev/null +++ b/website_sale_slides_multi_qty/__manifest__.py @@ -0,0 +1,27 @@ +# Copyright 2025 Tecnativa - Pilar Vargas +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +{ + "name": "Website Sale Slides Multi Qty", + "version": "15.0.1.0.0", + "category": "Website/eLearning", + "author": "Tecnativa, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/e-learning", + "license": "AGPL-3", + "summary": "", + "depends": ["website_sale_slides_order_line_link"], + "data": [ + "data/mail_template_data.xml", + "security/ir.model.access.csv", + "views/slide_channel_partner_views.xml", + "views/slide_channel_views.xml", + "views/website_slides_templates_course.xml", + "views/website_slides_templates_homepage.xml", + "views/website_slides_templates.xml", + ], + "installable": True, + "assets": { + "web.assets_frontend": [ + "website_sale_slides_multi_qty/static/src/js/*.js", + ], + }, +} diff --git a/website_sale_slides_multi_qty/controllers/__init__.py b/website_sale_slides_multi_qty/controllers/__init__.py new file mode 100644 index 0000000..12a7e52 --- /dev/null +++ b/website_sale_slides_multi_qty/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/website_sale_slides_multi_qty/controllers/main.py b/website_sale_slides_multi_qty/controllers/main.py new file mode 100644 index 0000000..9535a1a --- /dev/null +++ b/website_sale_slides_multi_qty/controllers/main.py @@ -0,0 +1,478 @@ +# Copyright 2025 Tecnativa - Pilar Vargas +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo import _, http +from odoo.http import request + +from odoo.addons.website_slides.controllers.main import WebsiteSlides + + +class WebsiteSaleSlides(WebsiteSlides): + def _set_viewed_slide(self, slide, quiz_attempts_inc=False): + identification_number = request.session.get("identification_number", False) + if ( + request.env.user._is_public() + and slide.channel_id.is_member + and identification_number + ): + slide.action_set_viewed(quiz_attempts_inc=quiz_attempts_inc) + return True + return super()._set_viewed_slide(slide, quiz_attempts_inc=quiz_attempts_inc) + + def _get_slide_detail(self, slide): + values = super()._get_slide_detail(slide) + identification_number = request.session.get("identification_number", False) + # Si el usuario tiene clave, no debe ser considerado público + if identification_number: + values["is_public_user"] = False + # Evitar que intente publicar comentarios si no tiene partner_id + if "message_post_pid" in values: + values["message_post_pid"] = False + return values + + def _get_channel_progress(self, channel, include_quiz=False): + values = super()._get_channel_progress(channel, include_quiz=include_quiz) + identification_number = request.session.get("identification_number", False) + if request.website.is_public_user() and identification_number: + slides = ( + request.env["slide.slide"] + .sudo() + .search([("channel_id", "=", channel.id)]) + ) + slide_partners = ( + request.env["slide.slide.partner"] + .sudo() + .search( + [ + ("channel_id", "=", channel.id), + ( + "partner_id", + "=", + int(request.session.get("invite_partner_id")), + ), + ("identification_number", "=", identification_number), + ("slide_id", "in", slides.ids), + ] + ) + ) + for slide_partner in slide_partners: + values[slide_partner.slide_id.id].update(slide_partner.read()[0]) + if slide_partner.slide_id in values: + values[slide_partner.slide_id].update( + slide_partner.read()[0] + ) # Copia los datos de progreso + # Manejo de intentos y recompensas en quiz + if slide_partner.slide_id.sudo().question_ids: + gains = [ + slide_partner.slide_id.quiz_first_attempt_reward, + slide_partner.slide_id.quiz_second_attempt_reward, + slide_partner.slide_id.quiz_third_attempt_reward, + slide_partner.slide_id.quiz_fourth_attempt_reward, + ] + values[slide_partner.slide_id.id]["quiz_gain"] = ( + gains[slide_partner.quiz_attempts_count] + if slide_partner.quiz_attempts_count < len(gains) + else gains[-1] + ) + return values + + def _check_identification_number(self, identification_number, partner): + # Validate ID depending on the country of the parent partner + if not identification_number or not partner or not partner.sudo().country_id: + return True # Allow if insufficient data + return request.env["res.partner"].simple_vat_check( + partner.country_id.code.upper(), identification_number.strip().upper() + ) + + def _session_data(self): + return { + "invite_hash": request.session.get("invite_hash", False), + "identification_number": request.session.get( + "identification_number", False + ), + "invite_partner_id": int(request.session.get("invite_partner_id", False)), + } + + def _delete_session_data(self): + request.session.pop("invite_hash", None) + request.session.pop("identification_number", None) + request.session.pop("invite_partner_id", None) + + def _set_session_data(self, identification_number, invite_partner_id, invite_hash): + request.session["identification_number"] = identification_number + request.session["invite_partner_id"] = invite_partner_id + request.session["invite_hash"] = invite_hash + + @http.route("/slides/is_public_with_key", type="json", auth="public", website=True) + def session_data(self): + return self._session_data() + + @http.route() + def channel( + self, + channel, + category=None, + tag=None, + page=1, + slide_type=None, + uncategorized=False, + sorting=None, + search=None, + **kw, + ): + session_data = self._session_data() + participation = False + if session_data["invite_hash"]: + participation = ( + request.env["slide.channel.partner"] + .sudo() + .search( + [ + ("invitation_hash", "=", session_data["invite_hash"]), + ( + "identification_number", + "=", + session_data["identification_number"], + ), + ("channel_id", "=", channel.id), + ], + limit=1, + ) + ) + if participation and participation.channel_id != channel: + # If there is no valid participation, we delete the session data. + self._delete_session_data() + res = super().channel( + channel, + category=category, + tag=tag, + page=page, + slide_type=slide_type, + uncategorized=uncategorized, + sorting=sorting, + search=search, + **kw, + ) + res.qcontext["can_enroll"] = self._can_user_register( + channel, request.env.user + ) or bool(kw.get("is_invite", False)) + channel_error = request.session.pop("channel_error", None) + if channel_error: + res.qcontext["channel_error"] = channel_error + show_modal_to_join = request.session.pop("show_modal_to_join", None) + if show_modal_to_join: + res.qcontext["show_modal_to_join"] = show_modal_to_join + return res + + @http.route( + "/slides/channel/join_with_id", + type="http", + auth="public", + methods=["POST"], + website=True, + ) + def slide_channel_join_with_id(self, **kw): + identification_number = kw.get("identification_number", False) + channel_id = int(kw.get("channel_id")) + slide_channel_partner_name = kw.get("slide_channel_partner_name") + slide_channel_partner_email = kw.get("slide_channel_partner_email") + slide_channel_partner_phone = kw.get("slide_channel_partner_phone") + invite_partner_id = int(kw.get("invite_partner_id")) + invite_hash = kw.get("invite_hash") + channel = request.env["slide.channel"].browse(channel_id).exists() + slide_channel_partner = channel.sudo().channel_partner_ids.filtered( + lambda x: x.identification_number == identification_number + ) + target_partner = request.env["res.partner"].browse(invite_partner_id) + parent_channel_partner = channel.sudo().channel_partner_ids.filtered( + lambda x: x.sale_order_line_ids and x.invitation_hash == invite_hash + ) + if not slide_channel_partner: + redirect_url = ( + f"/slides/{channel_id}" + f"/invite?invite_partner_id={invite_partner_id}" + f"&invite_hash={invite_hash}" + ) + if ( + not slide_channel_partner_name + or not slide_channel_partner_email + or not slide_channel_partner_phone + ): + request.session["channel_error"] = _( + "There is no participation for this key" + ) + return request.redirect(redirect_url) + if not self._check_identification_number( + identification_number, target_partner + ): + request.session["channel_error"] = _("Invalid identification number.") + return request.redirect(redirect_url) + self._add_new_member( + channel, + target_partner, + parent_channel_partner, + slide_channel_partner_name=slide_channel_partner_name, + slide_channel_partner_email=slide_channel_partner_email, + slide_channel_partner_phone=slide_channel_partner_phone, + identification_number=identification_number, + is_public_slide_channel_partner=True, + ) + self._set_session_data(identification_number, invite_partner_id, invite_hash) + return request.redirect(f"/slides/{channel_id}") + + @http.route("/slides//join", type="http", auth="user", website=True) + def slide_channel_join_course(self, channel_id, **kwargs): + channel = request.env["slide.channel"].browse(channel_id).exists() + target_partner = request.env.user.partner_id + parent_channel_partners = channel.sudo().channel_partner_ids.filtered( + lambda x: x.available_registrations > 1 + ) + invite_partner_id = kwargs.get("invite_partner_id", False) + for parent_channel_partner in parent_channel_partners: + if parent_channel_partner.partner_id == target_partner: + self._add_new_member(channel, target_partner, parent_channel_partner) + else: + if ( + parent_channel_partner.partner_id.commercial_partner_id + == target_partner.commercial_partner_id + ): + self._add_new_member( + channel, target_partner, parent_channel_partner + ) + if invite_partner_id: + parent_channel_partner = parent_channel_partners.filtered( + lambda x: x.partner_id.id == int(invite_partner_id) and not x.parent_id + ) + self._add_new_member(channel, target_partner, parent_channel_partner) + return request.redirect(f"/slides/{channel_id}") + + @http.route( + "/slides//invite", + type="http", + auth="public", + website=True, + sitemap=False, + ) + def slide_channel_invite(self, channel_id, **kwargs): + self._delete_session_data() + invite_partner_id = kwargs.get("invite_partner_id") or request.session.pop( + "invite_partner_id", None + ) + invite_hash = kwargs.get("invite_hash") or request.session.pop( + "invite_hash", None + ) + if not invite_partner_id or not invite_hash: + return request.redirect("/slides") + channel = request.env["slide.channel"].sudo().browse(channel_id).exists() + partner = request.env.user.partner_id + if not channel or not channel.is_published: + return self._redirect_to_slides_main("no_channel") + # A user is logged + if not request.website.is_public_user(): + enroll = channel.sudo().channel_partner_ids.filtered( + lambda x: x.partner_id == partner + ) + if not partner.id == int(invite_partner_id) and not enroll: + return request.redirect( + f"/slides/{channel_id}?is_invite=1&invite_partner_id={invite_partner_id}" + ) + return request.redirect(f"/slides/{channel_id}") + # No user is logged. + if request.website.is_public_user(): + request.session["invite_partner_id"] = int(invite_partner_id) + request.session["invite_hash"] = invite_hash + request.session["show_modal_to_join"] = True + return request.redirect( + f"/slides/{channel_id}?is_invite=1&invite_partner_id={invite_partner_id}" + ) + + @staticmethod + def _redirect_to_slides_main(invite_error=""): + # Redirige a la página de cursos y muestra un error si corresponde. + if invite_error: + request.session[ + "error_message" + ] = "El curso no se encuentra disponible o no existe." + return request.redirect("/slides") + + def _can_user_register(self, channel, user): + # Check if the user meets the conditions to register for the course. + partner = user.partner_id + enroll = channel.sudo().channel_partner_ids.filtered( + lambda x: x.partner_id == partner and not x.is_public_slide_channel_partner + ) + # If the accessing user is the one who has acquired and does not have a + # sub-participation + if ( + len(enroll) == 1 + and enroll.available_registrations > 1 + and enroll.available_registrations > enroll.used_registrations + ): + return True + # If the accessing user is a contact of the company or of the partner who has + # acquired the shareholding + enroll_comercial = channel.sudo().channel_partner_ids.filtered( + lambda x: x.partner_id.commercial_partner_id + == partner.commercial_partner_id + ) + enroll_comercial_parent = enroll_comercial.filtered("child_channel_partner_ids") + if ( + partner.id not in enroll_comercial.partner_id.ids + and enroll_comercial_parent.available_registrations + > enroll_comercial_parent.used_registrations + ): + return True + return False + + def _add_new_member( + self, channel, target_partner, parent_channel_partner, **kwargs + ): + channel._action_add_members( + target_partner, + parent_channel_partner=parent_channel_partner, + identification_number=kwargs.get("identification_number", False), + slide_channel_partner_name=kwargs.get("slide_channel_partner_name", False), + slide_channel_partner_email=kwargs.get( + "slide_channel_partner_email", False + ), + slide_channel_partner_phone=kwargs.get( + "slide_channel_partner_phone", False + ), + is_public_slide_channel_partner=kwargs.get( + "is_public_slide_channel_partner", False + ), + ) + + # SLIDE.SLIDE UTILS + + @http.route() + def slide_set_completed(self, slide_id): + session_data = self._session_data() or {} + invite_hash = session_data.get("invite_hash", False) + identification_number = session_data.get("identification_number", False) + invite_partner_id = session_data.get("invite_partner_id", False) + if ( + request.website.is_public_user() + and identification_number + and invite_partner_id + and invite_hash + ): + fetch_res = self._fetch_slide(slide_id) + if fetch_res.get("error"): + return fetch_res + self._set_completed_slide(fetch_res["slide"]) + return {"channel_completion": fetch_res["slide"].channel_id.completion} + return super().slide_set_completed(slide_id) + + # QUIZ SECTION + + @http.route() + def slide_quiz_submit(self, slide_id, answer_ids): + session_data = self._session_data() or {} + invite_hash = session_data.get("invite_hash", False) + identification_number = session_data.get("identification_number", False) + invite_partner_id = session_data.get("invite_partner_id", False) + values = super().slide_quiz_submit(slide_id, answer_ids) + if ( + request.website.is_public_user() + and identification_number + and invite_partner_id + and invite_hash + ): + values = {} + fetch_res = self._fetch_slide(slide_id) + if fetch_res.get("error"): + return fetch_res + slide = fetch_res["slide"] + if slide.user_membership_id.sudo().completed: + self._channel_remove_session_answers(slide.channel_id, slide) + return {"error": "slide_quiz_done"} + all_questions = ( + request.env["slide.question"] + .sudo() + .search([("slide_id", "=", slide.id)]) + ) + user_answers = ( + request.env["slide.answer"].sudo().search([("id", "in", answer_ids)]) + ) + if user_answers.mapped("question_id") != all_questions: + return {"error": "slide_quiz_incomplete"} + user_bad_answers = user_answers.filtered( + lambda answer: not answer.is_correct + ) + self._set_viewed_slide(slide, quiz_attempts_inc=True) + quiz_info = self._get_slide_quiz_partner_info(slide, quiz_done=True) + rank_progress = {} + if not user_bad_answers: + rank_progress["previous_rank"] = self._get_rank_values(request.env.user) + slide._action_set_quiz_done() + slide.action_set_completed() + rank_progress["new_rank"] = self._get_rank_values(request.env.user) + rank_progress.update( + { + "description": request.env.user.rank_id.description, + "last_rank": not request.env.user._get_next_rank(), + "level_up": rank_progress["previous_rank"]["lower_bound"] + != rank_progress["new_rank"]["lower_bound"], + } + ) + self._channel_remove_session_answers(slide.channel_id, slide) + values.update( + { + "answers": { + answer.question_id.id: { + "is_correct": answer.is_correct, + "comment": answer.comment, + } + for answer in user_answers + }, + "completed": slide.user_membership_id.sudo().completed, + "channel_completion": slide.channel_id.completion, + "quizKarmaWon": quiz_info["quiz_karma_won"], + "quizKarmaGain": quiz_info["quiz_karma_gain"], + "quizAttemptsCount": quiz_info["quiz_attempts_count"], + "rankProgress": rank_progress, + } + ) + return values + + # PROFILE + + def _prepare_user_slides_profile(self, user): + invite_hash = request.session.get("invite_hash", False) + identification_number = request.session.get("identification_number", False) + invite_partner_id = request.session.get("invite_partner_id", False) + values = super()._prepare_user_slides_profile(user) + if ( + request.website.is_public_user() + and identification_number + and invite_partner_id + and invite_hash + ): + courses = ( + request.env["slide.channel.partner"] + .sudo() + .search( + [ + ( + "partner_id", + "=", + int(invite_partner_id), + ("identification_number", "=", identification_number), + ) + ] + ) + ) + courses_completed = courses.filtered(lambda c: c.completed) + courses_ongoing = courses - courses_completed + values.update( + { + "uid": request.env.user.id, + "user": user, + "main_object": user, + "courses_completed": courses_completed, + "courses_ongoing": courses_ongoing, + "is_profile_page": True, + "badge_category": "slides", + } + ) + return values diff --git a/website_sale_slides_multi_qty/data/mail_template_data.xml b/website_sale_slides_multi_qty/data/mail_template_data.xml new file mode 100644 index 0000000..e4fa405 --- /dev/null +++ b/website_sale_slides_multi_qty/data/mail_template_data.xml @@ -0,0 +1,66 @@ + + + + + Channel: Confirm Access + + Your access to {{ object.channel_id.name }} has been confirmed + + +
+

+ Hello , +

+ +

+ You have successfully acquired + registrations for the course Course Name. +

+

+ To allow participants to access the course, they must complete their registration + using the following link. They can either log in with their Odoo account or create a new participation. +

+

+

+ + + +
+

+
+ +

+ Your access to the course Course Name has been confirmed. +

+

+ You can start learning by clicking the link below: +

+

+

+

+
+

Happy learning!

+
+
+ {{ object.partner_id.lang }} + +
+
diff --git a/website_sale_slides_multi_qty/i18n/es.po b/website_sale_slides_multi_qty/i18n/es.po new file mode 100644 index 0000000..195c878 --- /dev/null +++ b/website_sale_slides_multi_qty/i18n/es.po @@ -0,0 +1,267 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * website_sale_slides_multi_qty +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 15.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-05-06 06:24+0000\n" +"PO-Revision-Date: 2025-05-06 08:25+0200\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Generator: Poedit 3.4.2\n" + +#. module: website_sale_slides_multi_qty +#: model:mail.template,body_html:website_sale_slides_multi_qty.mail_template_slide_channel_confirm +msgid "" +"
\n" +"

\n" +" Hello ,\n" +"

\n" +" \n" +"

\n" +" You have successfully acquired \n" +" registrations for the course Course Name.\n" +"

\n" +"

\n" +" To allow participants to access the course, they " +"must complete their registration\n" +" using the following link. They can either log in " +"with their Odoo account or create a new participation.\n" +"

\n" +"

\n" +"

\n" +" \n" +" \n" +" \n" +"
\n" +"

\n" +"
\n" +" \n" +"

\n" +" Your access to the course Course Name has been confirmed.\n" +"

\n" +"

\n" +" You can start learning by clicking the link below:\n" +"

\n" +"

\n" +"

\n" +"

\n" +"
\n" +"

Happy learning!

\n" +"
\n" +" " +msgstr "" +"
\n" +"

\n" +" Hola ,\n" +"

\n" +" \n" +"

\n" +" Has adquirido con éxito \n" +" inscripciones para el curso Nombre del curso.\n" +"

\n" +"

\n" +" Para que los participantes accedan al curso, deben " +"completar su inscripción\n" +" utilizando el siguiente enlace. Pueden iniciar sesión " +"con su cuenta de Odoo o crear una nueva participación.\n" +"

\n" +"

\n" +"

\n" +" \n" +" \n" +" \n" +"
\n" +"

\n" +"
\n" +" \n" +"

\n" +" Tu acceso al curso Nombre del curso ha sido confirmado.\n" +"

\n" +"

\n" +" Puedes comenzar a aprender haciendo clic en el siguiente enlace:\n" +"

\n" +"

\n" +"

\n" +"

\n" +"
\n" +"

¡Feliz aprendizaje!

\n" +"
\n" +" " + +#. module: website_sale_slides_multi_qty +#: model:ir.model.fields,field_description:website_sale_slides_multi_qty.field_slide_channel_partner__available_registrations +msgid "Available Registrations" +msgstr "Inscripciones disponibles" + +#. module: website_sale_slides_multi_qty +#: model:ir.model,name:website_sale_slides_multi_qty.model_slide_channel_partner +msgid "Channel / Partners (Members)" +msgstr "" + +#. module: website_sale_slides_multi_qty +#: model:mail.template,name:website_sale_slides_multi_qty.mail_template_slide_channel_confirm +msgid "Channel: Confirm Access" +msgstr "Curso: Confirmar acceso" + +#. module: website_sale_slides_multi_qty +#: model:ir.model.fields,field_description:website_sale_slides_multi_qty.field_slide_channel_partner__child_channel_partner_ids +msgid "Child Participation" +msgstr "" + +#. module: website_sale_slides_multi_qty +#: model:ir.model,name:website_sale_slides_multi_qty.model_slide_channel +msgid "Course" +msgstr "Curso" + +#. module: website_sale_slides_multi_qty +#: model_terms:ir.ui.view,arch_db:website_sale_slides_multi_qty.course_sidebar +msgid "Enter your ID" +msgstr "Introduce tu DNI" + +#. module: website_sale_slides_multi_qty +#: model_terms:ir.ui.view,arch_db:website_sale_slides_multi_qty.course_sidebar +msgid "Enter your email" +msgstr "Introduce tu email" + +#. module: website_sale_slides_multi_qty +#: model_terms:ir.ui.view,arch_db:website_sale_slides_multi_qty.course_sidebar +msgid "Enter your name" +msgstr "Introduce tu nombre" + +#. module: website_sale_slides_multi_qty +#: model_terms:ir.ui.view,arch_db:website_sale_slides_multi_qty.course_sidebar +msgid "Enter your phone" +msgstr "Introduce tu teléfono" + +#. module: website_sale_slides_multi_qty +#: model_terms:ir.ui.view,arch_db:website_sale_slides_multi_qty.course_sidebar +msgid "I have already joined" +msgstr "Ya me he unido" + +#. module: website_sale_slides_multi_qty +#: model:ir.model.fields,field_description:website_sale_slides_multi_qty.field_slide_channel_partner__identification_number +#: model:ir.model.fields,field_description:website_sale_slides_multi_qty.field_slide_slide_partner__identification_number +msgid "Identification Number" +msgstr "Número de identificación" + +#. module: website_sale_slides_multi_qty +#: model:ir.model.fields,field_description:website_sale_slides_multi_qty.field_slide_channel_partner__invitation_hash +msgid "Invitation Hash" +msgstr "" + +#. module: website_sale_slides_multi_qty +#: model:ir.model.fields,field_description:website_sale_slides_multi_qty.field_slide_channel_partner__invitation_link +msgid "Invitation Link" +msgstr "" + +#. module: website_sale_slides_multi_qty +#: model:ir.model.fields,field_description:website_sale_slides_multi_qty.field_slide_channel_partner__is_public_slide_channel_partner +msgid "Is Public Slide Channel Partner" +msgstr "" + +#. module: website_sale_slides_multi_qty +#: model_terms:ir.ui.view,arch_db:website_sale_slides_multi_qty.course_sidebar +msgid "Join Course" +msgstr "Unirse al curso" + +#. module: website_sale_slides_multi_qty +#: model:ir.model.fields,field_description:website_sale_slides_multi_qty.field_slide_channel_partner__slide_channel_partner_name +msgid "Name" +msgstr "Nombre" + +#. module: website_sale_slides_multi_qty +#: code:addons/website_sale_slides_multi_qty/models/slide_channel.py:0 +#, python-format +msgid "No registrations available for this course." +msgstr "No hay inscripciones disponibles para este curso" + +#. module: website_sale_slides_multi_qty +#: model:ir.model.fields,field_description:website_sale_slides_multi_qty.field_slide_channel_partner__parent_id +msgid "Parent Participation" +msgstr "" + +#. module: website_sale_slides_multi_qty +#: model:ir.model.fields,field_description:website_sale_slides_multi_qty.field_slide_channel_partner__slide_channel_partner_email +msgid "Participation Email" +msgstr "" + +#. module: website_sale_slides_multi_qty +#: model:ir.model.fields,field_description:website_sale_slides_multi_qty.field_slide_channel_partner__slide_channel_partner_phone +msgid "Phone" +msgstr "Teléfono" + +#. module: website_sale_slides_multi_qty +#: model:ir.model,name:website_sale_slides_multi_qty.model_slide_slide_partner +msgid "Slide / Partner decorated m2m" +msgstr "" + +#. module: website_sale_slides_multi_qty +#: model:ir.model,name:website_sale_slides_multi_qty.model_slide_slide +msgid "Slides" +msgstr "" + +#. module: website_sale_slides_multi_qty +#: model:ir.model.constraint,message:website_sale_slides_multi_qty.constraint_slide_channel_partner_unique_channel_identification +msgid "The identification number must be unique per course!" +msgstr "El numero de identificación debe ser único!" + +#. module: website_sale_slides_multi_qty +#: code:addons/website_sale_slides_multi_qty/controllers/main.py:0 +#, python-format +msgid "There is no participation for this key" +msgstr "No hay ninguna participación para esta identificación" + +#. module: website_sale_slides_multi_qty +#: model:ir.model.fields,field_description:website_sale_slides_multi_qty.field_slide_channel_partner__used_registrations +msgid "Used Registrations" +msgstr "" + +#. module: website_sale_slides_multi_qty +#: model:ir.model.fields,help:website_sale_slides_multi_qty.field_slide_channel_partner__identification_number +msgid "User's personal identification number" +msgstr "Número de identificación personal del usuario" + +#. module: website_sale_slides_multi_qty +#: model:mail.template,subject:website_sale_slides_multi_qty.mail_template_slide_channel_confirm +msgid "Your access to {{ object.channel_id.name }} has been confirmed" +msgstr "Tu acceso a {{ object.channel_id.name }} ha sido confirmado" diff --git a/website_sale_slides_multi_qty/i18n/website_sale_slides_multi_qty.pot b/website_sale_slides_multi_qty/i18n/website_sale_slides_multi_qty.pot new file mode 100644 index 0000000..65123f6 --- /dev/null +++ b/website_sale_slides_multi_qty/i18n/website_sale_slides_multi_qty.pot @@ -0,0 +1,198 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * website_sale_slides_multi_qty +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 15.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-05-06 06:24+0000\n" +"PO-Revision-Date: 2025-05-06 06:24+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_sale_slides_multi_qty +#: model:mail.template,body_html:website_sale_slides_multi_qty.mail_template_slide_channel_confirm +msgid "" +"
\n" +"

\n" +" Hello ,\n" +"

\n" +" \n" +"

\n" +" You have successfully acquired \n" +" registrations for the course Course Name.\n" +"

\n" +"

\n" +" To allow participants to access the course, they must complete their registration\n" +" using the following link. They can either log in with their Odoo account or create a new participation.\n" +"

\n" +"

\n" +"

\n" +" \n" +" \n" +" \n" +"
\n" +"

\n" +"
\n" +" \n" +"

\n" +" Your access to the course Course Name has been confirmed.\n" +"

\n" +"

\n" +" You can start learning by clicking the link below:\n" +"

\n" +"

\n" +"

\n" +"

\n" +"
\n" +"

Happy learning!

\n" +"
\n" +" " +msgstr "" + +#. module: website_sale_slides_multi_qty +#: model:ir.model.fields,field_description:website_sale_slides_multi_qty.field_slide_channel_partner__available_registrations +msgid "Available Registrations" +msgstr "" + +#. module: website_sale_slides_multi_qty +#: model:ir.model,name:website_sale_slides_multi_qty.model_slide_channel_partner +msgid "Channel / Partners (Members)" +msgstr "" + +#. module: website_sale_slides_multi_qty +#: model:mail.template,name:website_sale_slides_multi_qty.mail_template_slide_channel_confirm +msgid "Channel: Confirm Access" +msgstr "" + +#. module: website_sale_slides_multi_qty +#: model:ir.model.fields,field_description:website_sale_slides_multi_qty.field_slide_channel_partner__child_channel_partner_ids +msgid "Child Participation" +msgstr "" + +#. module: website_sale_slides_multi_qty +#: model:ir.model,name:website_sale_slides_multi_qty.model_slide_channel +msgid "Course" +msgstr "" + +#. module: website_sale_slides_multi_qty +#: model_terms:ir.ui.view,arch_db:website_sale_slides_multi_qty.course_sidebar +msgid "Enter your ID" +msgstr "" + +#. module: website_sale_slides_multi_qty +#: model_terms:ir.ui.view,arch_db:website_sale_slides_multi_qty.course_sidebar +msgid "Enter your email" +msgstr "" + +#. module: website_sale_slides_multi_qty +#: model_terms:ir.ui.view,arch_db:website_sale_slides_multi_qty.course_sidebar +msgid "Enter your name" +msgstr "" + +#. module: website_sale_slides_multi_qty +#: model_terms:ir.ui.view,arch_db:website_sale_slides_multi_qty.course_sidebar +msgid "Enter your phone" +msgstr "" + +#. module: website_sale_slides_multi_qty +#: model_terms:ir.ui.view,arch_db:website_sale_slides_multi_qty.course_sidebar +msgid "I have already joined" +msgstr "" + +#. module: website_sale_slides_multi_qty +#: model:ir.model.fields,field_description:website_sale_slides_multi_qty.field_slide_channel_partner__identification_number +#: model:ir.model.fields,field_description:website_sale_slides_multi_qty.field_slide_slide_partner__identification_number +msgid "Identification Number" +msgstr "" + +#. module: website_sale_slides_multi_qty +#: model:ir.model.fields,field_description:website_sale_slides_multi_qty.field_slide_channel_partner__invitation_hash +msgid "Invitation Hash" +msgstr "" + +#. module: website_sale_slides_multi_qty +#: model:ir.model.fields,field_description:website_sale_slides_multi_qty.field_slide_channel_partner__invitation_link +msgid "Invitation Link" +msgstr "" + +#. module: website_sale_slides_multi_qty +#: model:ir.model.fields,field_description:website_sale_slides_multi_qty.field_slide_channel_partner__is_public_slide_channel_partner +msgid "Is Public Slide Channel Partner" +msgstr "" + +#. module: website_sale_slides_multi_qty +#: model_terms:ir.ui.view,arch_db:website_sale_slides_multi_qty.course_sidebar +msgid "Join Course" +msgstr "" + +#. module: website_sale_slides_multi_qty +#: model:ir.model.fields,field_description:website_sale_slides_multi_qty.field_slide_channel_partner__slide_channel_partner_name +msgid "Name" +msgstr "" + +#. module: website_sale_slides_multi_qty +#: code:addons/website_sale_slides_multi_qty/models/slide_channel.py:0 +#, python-format +msgid "No registrations available for this course." +msgstr "" + +#. module: website_sale_slides_multi_qty +#: model:ir.model.fields,field_description:website_sale_slides_multi_qty.field_slide_channel_partner__parent_id +msgid "Parent Participation" +msgstr "" + +#. module: website_sale_slides_multi_qty +#: model:ir.model.fields,field_description:website_sale_slides_multi_qty.field_slide_channel_partner__slide_channel_partner_email +msgid "Participation Email" +msgstr "" + +#. module: website_sale_slides_multi_qty +#: model:ir.model.fields,field_description:website_sale_slides_multi_qty.field_slide_channel_partner__slide_channel_partner_phone +msgid "Phone" +msgstr "" + +#. module: website_sale_slides_multi_qty +#: model:ir.model,name:website_sale_slides_multi_qty.model_slide_slide_partner +msgid "Slide / Partner decorated m2m" +msgstr "" + +#. module: website_sale_slides_multi_qty +#: model:ir.model,name:website_sale_slides_multi_qty.model_slide_slide +msgid "Slides" +msgstr "" + +#. module: website_sale_slides_multi_qty +#: model:ir.model.constraint,message:website_sale_slides_multi_qty.constraint_slide_channel_partner_unique_channel_identification +msgid "The identification number must be unique per course!" +msgstr "" + +#. module: website_sale_slides_multi_qty +#: code:addons/website_sale_slides_multi_qty/controllers/main.py:0 +#, python-format +msgid "There is no participation for this key" +msgstr "" + +#. module: website_sale_slides_multi_qty +#: model:ir.model.fields,field_description:website_sale_slides_multi_qty.field_slide_channel_partner__used_registrations +msgid "Used Registrations" +msgstr "" + +#. module: website_sale_slides_multi_qty +#: model:ir.model.fields,help:website_sale_slides_multi_qty.field_slide_channel_partner__identification_number +msgid "User's personal identification number" +msgstr "" + +#. module: website_sale_slides_multi_qty +#: model:mail.template,subject:website_sale_slides_multi_qty.mail_template_slide_channel_confirm +msgid "Your access to {{ object.channel_id.name }} has been confirmed" +msgstr "" diff --git a/website_sale_slides_multi_qty/models/__init__.py b/website_sale_slides_multi_qty/models/__init__.py new file mode 100644 index 0000000..36d510f --- /dev/null +++ b/website_sale_slides_multi_qty/models/__init__.py @@ -0,0 +1,2 @@ +from . import slide_channel +from . import slide_slide diff --git a/website_sale_slides_multi_qty/models/slide_channel.py b/website_sale_slides_multi_qty/models/slide_channel.py new file mode 100644 index 0000000..73bdb38 --- /dev/null +++ b/website_sale_slides_multi_qty/models/slide_channel.py @@ -0,0 +1,278 @@ +# Copyright 2025 Tecnativa - Pilar Vargas +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models, tools +from odoo.http import request + + +class SlideChannel(models.Model): + _inherit = "slide.channel" + + def _is_public_with_key(self): + if request: + identification_number = request.session.get("identification_number", False) + invite_hash = request.session.get("invite_hash", False) + invite_partner_id = request.session.get("invite_partner_id", False) + return bool( + self.env.user._is_public() + and identification_number + and invite_hash + and invite_partner_id + ) + return False + + def _compute_is_member(self): + res = super()._compute_is_member() + if self._is_public_with_key(): + channel_partner = ( + self.env["slide.channel.partner"] + .sudo() + .search( + [ + ("channel_id", "in", self.ids), + ( + "identification_number", + "=", + request.session.get("identification_number"), + ), + ("invitation_hash", "=", request.session.get("invite_hash")), + ] + ) + ) + if channel_partner: + self.is_member = True + return res + + def _compute_user_statistics(self): + identification_number = request.session.get("identification_number", False) + invite_hash = request.session.get("invite_hash", False) + is_public_with_key = ( + self.env.user._is_public() and identification_number and invite_hash + ) + if is_public_with_key: + current_user_info = ( + self.env["slide.channel.partner"] + .sudo() + .search( + [ + ("channel_id", "in", self.ids), + ("identification_number", "=", identification_number), + ("invitation_hash", "=", invite_hash), + ] + ) + ) + mapped_data = { + info.channel_id.id: (info.completed, info.completed_slides_count) + for info in current_user_info + } + for record in self: + completed, completed_slides_count = mapped_data.get( + record.id, (False, 0) + ) + record.completed = completed + record.completion = ( + 100.0 + if completed + else round( + 100.0 * completed_slides_count / (record.total_slides or 1) + ) + ) + else: + return super()._compute_user_statistics() + + # def _compute_action_rights(self): + + def _action_add_members(self, target_partners, **member_values): + # Create sub-participations + parent_channel_partner = member_values.get("parent_channel_partner", False) + to_create_values = {} + if ( + parent_channel_partner + and parent_channel_partner.available_registrations + > parent_channel_partner.used_registrations + ): + to_create_values = { + "channel_id": self.id, + "partner_id": target_partners.id, + "parent_id": parent_channel_partner.id, + } + # Agregar dinámicamente los valores de member_values + to_create_values.update( + { + key: value + for key, value in member_values.items() + if key != "parent_channel_partner" + } + ) + self.env["slide.channel.partner"].sudo().create(to_create_values) + if ( + parent_channel_partner + and not to_create_values + and parent_channel_partner.available_registrations + <= parent_channel_partner.used_registrations + ): + request.session["channel_error"] = _( + "No registrations available for this course." + ) + return self.env["slide.channel.partner"].sudo() + # After buy a course, send email with token only when confirming the order. + new_target_partners = self.env["res.partner"] + if self.env.context.get("course_sale_order_lines", False): + new_target_partners = target_partners.filtered( + lambda x: x.id not in self.channel_partner_ids.partner_id.ids + ) + res = super()._action_add_members(target_partners, **member_values) + if new_target_partners: + for target in self.channel_partner_ids.filtered( + lambda x: x.partner_id.id in new_target_partners.ids + ): + target._send_confirm_mail() + return res + + +class SlideChannelPartner(models.Model): + _inherit = "slide.channel.partner" + + parent_id = fields.Many2one( + comodel_name="slide.channel.partner", + string="Parent Participation", + ondelete="cascade", + index=True, + ) + child_channel_partner_ids = fields.One2many( + comodel_name="slide.channel.partner", + inverse_name="parent_id", + string="Child Participation", + ) + available_registrations = fields.Integer( + compute="_compute_available_registrations", store=True + ) + used_registrations = fields.Integer( + compute="_compute_used_registrations", store=True + ) + invitation_hash = fields.Char(compute="_compute_invitation", store=True) + invitation_link = fields.Char(compute="_compute_invitation", store=True) + slide_channel_partner_name = fields.Char(string="Name") + slide_channel_partner_email = fields.Char(string="Participation Email") + slide_channel_partner_phone = fields.Char(string="Phone") + identification_number = fields.Char( + help="User's personal identification number", + ) + is_public_slide_channel_partner = fields.Boolean(default=False) + + _sql_constraints = [ + ( + "unique_channel_identification", + "unique(channel_id, identification_number)", + "The identification number must be unique per course!", + ) + ] + + @api.depends("sale_order_line_ids.product_uom_qty") + def _compute_available_registrations(self): + for record in self: + total_qty = sum(record.sale_order_line_ids.mapped("product_uom_qty")) + record.available_registrations = total_qty + + @api.depends("child_channel_partner_ids") + def _compute_used_registrations(self): + for record in self: + record.used_registrations = len(record.child_channel_partner_ids) + + @api.depends("channel_id", "partner_id") + def _compute_invitation(self): + # This sets the url used as hyperlink in the channel invitation email in + # template mail_notification_channel_invite. + # The partner_id is given in the url, as well as a hash based on the partner + # and channel id. + for record in self: + record.invitation_hash = record._get_invitation_hash() + record.invitation_link = ( + f"{record.channel_id.get_base_url()}/slides/{record.channel_id.id}" + f"/invite?invite_partner_id={record.partner_id.id}" + f"&invite_hash={record.invitation_hash}" + ) + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if ( + not vals.get("slide_channel_partner_name", False) + or not vals.get("slide_channel_partner_email", False) + or not vals.get("slide_channel_partner_phone", False) + ): + partner = self.env["res.partner"].browse(vals["partner_id"]) + vals["slide_channel_partner_name"] = partner.name + vals["slide_channel_partner_email"] = partner.email + vals["slide_channel_partner_phone"] = partner.phone or partner.mobile + return super().create(vals_list) + + def _recompute_completion(self): + if not self.channel_id._is_public_with_key(): + return super()._recompute_completion() + identification_number = request.session.get("identification_number", False) + # Obtain progress only from the participation of the user with current password + read_group_res = ( + self.env["slide.slide.partner"] + .sudo() + .read_group( + [ + ("channel_id", "in", self.mapped("channel_id").ids), + ("identification_number", "=", identification_number), + ("completed", "=", True), + ("slide_id.is_published", "=", True), + ("slide_id.active", "=", True), + ], + ["channel_id"], + groupby=["channel_id"], + lazy=False, + ) + ) + mapped_data = { + item["channel_id"][0]: item["__count"] for item in read_group_res + } + # Filter only shares that have an ID number + for record in self.filtered("identification_number"): + if record.identification_number != identification_number: + continue + completed_slides_count = mapped_data.get(record.channel_id.id, 0) + record.completed_slides_count = completed_slides_count + record.completion = ( + 100.0 + if record.completed + else round( + 100.0 + * completed_slides_count + / (record.channel_id.total_slides or 1) + ) + ) + if ( + not record.completed + and record.channel_id.active + and completed_slides_count >= record.channel_id.total_slides + ): + record.completed = True + + def _get_invitation_hash(self): + # Returns the invitation hash of the attendee, used to access courses + # as invited / joined. + self.ensure_one() + token = (self.partner_id.id, self.channel_id.id) + return tools.hmac(self.env(su=True), "website_slides-channel-invite", token) + + def _send_confirm_mail(self): + self.ensure_one() + template = self.env.ref( + "website_sale_slides_multi_qty.mail_template_slide_channel_confirm", + raise_if_not_found=False, + ) + template.send_mail(self.id, force_send=False) + + def _send_completed_mail(self): + # Avoiding duplicate email sending when completing the course + filtered_self = self.filtered( + lambda record: not record.child_channel_partner_ids + ) + if not filtered_self: + return super()._send_completed_mail() + return super(SlideChannelPartner, filtered_self)._send_completed_mail() diff --git a/website_sale_slides_multi_qty/models/slide_slide.py b/website_sale_slides_multi_qty/models/slide_slide.py new file mode 100644 index 0000000..344910b --- /dev/null +++ b/website_sale_slides_multi_qty/models/slide_slide.py @@ -0,0 +1,236 @@ +# Copyright 2025 Tecnativa - Pilar Vargas +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo import fields, models +from odoo.http import request +from odoo.tools import sql + + +class SlideSlidePartner(models.Model): + _inherit = "slide.slide.partner" + + identification_number = fields.Char() + + +class SlideSlide(models.Model): + _inherit = "slide.slide" + + def _is_public_with_key(self): + if request: + identification_number = request.session.get("identification_number", False) + invite_hash = request.session.get("invite_hash", False) + invite_partner_id = request.session.get("invite_partner_id", False) + return bool( + self.env.user._is_public() + and identification_number + and invite_hash + and invite_partner_id + ) + return False + + def _compute_user_membership_id(self): + res = super()._compute_user_membership_id() + if self._is_public_with_key(): + slide_partners = ( + self.env["slide.slide.partner"] + .sudo() + .search( + [ + ("slide_id", "in", self.ids), + ( + "partner_id", + "=", + int(request.session.get("invite_partner_id")), + ), + ( + "identification_number", + "=", + request.session.get("identification_number"), + ), + ] + ) + ) + for record in self: + record.user_membership_id = next( + ( + slide_partner + for slide_partner in slide_partners + if slide_partner.slide_id == record + ), + self.env["slide.slide.partner"], + ) + record.user_vote = record.user_membership_id.vote + return res + + def _action_vote(self, upvote=True): + karma_before = self.env.user.karma + res = super()._action_vote(upvote) + # If the user is public with password, resets the points to the previous value. + if self.env.user._is_public() and any( + slide.user_membership_id.identification_number for slide in self + ): + self.env.user.sudo().write({"karma": karma_before}) + return res + + def _action_set_viewed(self, target_partner, quiz_attempts_inc=False): + if self._is_public_with_key(): + invite_partner_id = request.session.get("invite_partner_id") + identification_number = request.session.get("identification_number") + self_sudo = self.sudo() + SlidePartnerSudo = self.env["slide.slide.partner"].sudo() + existing_sudo = SlidePartnerSudo.search( + [ + ("slide_id", "in", self.ids), + ("partner_id", "=", int(invite_partner_id)), + ( + "identification_number", + "=", + identification_number, + ), + ] + ) + if quiz_attempts_inc and existing_sudo: + sql.increment_field_skiplock(existing_sudo, "quiz_attempts_count") + SlidePartnerSudo.invalidate_cache( + fnames=["quiz_attempts_count"], ids=existing_sudo.ids + ) + for slide in existing_sudo: + if ( + slide.slide_id.slide_type != "quiz" + or not slide.slide_id.question_ids + ): + slide.slide_id.action_set_completed() + new_slides = self_sudo - existing_sudo.mapped("slide_id") + return SlidePartnerSudo.create( + [ + { + "slide_id": new_slide.id, + "channel_id": new_slide.channel_id.id, + "partner_id": int(invite_partner_id), + "quiz_attempts_count": 1 if quiz_attempts_inc else 0, + "vote": 0, + "identification_number": identification_number, + } + for new_slide in new_slides + ] + ) + return super()._action_set_viewed( + target_partner, quiz_attempts_inc=quiz_attempts_inc + ) + + def _action_set_completed(self, target_partner): + if self._is_public_with_key(): + invite_partner_id = request.session.get("invite_partner_id") + identification_number = request.session.get("identification_number") + self_sudo = self.sudo() + SlidePartnerSudo = self.env["slide.slide.partner"].sudo() + existing_sudo = SlidePartnerSudo.search( + [ + ("slide_id", "in", self.ids), + ("partner_id", "=", int(invite_partner_id)), + ( + "identification_number", + "=", + identification_number, + ), + ] + ) + existing_sudo.write({"completed": True}) + new_slides = self_sudo - existing_sudo.mapped("slide_id") + SlidePartnerSudo.create( + [ + { + "slide_id": new_slide.id, + "channel_id": new_slide.channel_id.id, + "partner_id": int(invite_partner_id), + "vote": 0, + "completed": True, + "identification_number": identification_number, + } + for new_slide in new_slides + ] + ) + return True + return super()._action_set_completed(target_partner) + + def _action_set_quiz_done(self): + points_before = self.env.user.karma + res = super()._action_set_quiz_done() + # If the user is public with password, resets the points to the previous value. + if self.env.user._is_public() and any( + slide.user_membership_id.identification_number for slide in self + ): + self.env.user.sudo().write({"karma": points_before}) + return res + + def _compute_quiz_info(self, target_partner, quiz_done=False): + result = super()._compute_quiz_info(target_partner, quiz_done=quiz_done) + if self._is_public_with_key(): + slide_partners = ( + self.env["slide.slide.partner"] + .sudo() + .search( + [ + ("slide_id", "in", self.ids), + ( + "partner_id", + "=", + int(request.session.get("invite_partner_id")), + ), + ( + "identification_number", + "=", + request.session.get("identification_number"), + ), + ] + ) + ) + slide_partners_map = {sp.slide_id.id: sp for sp in slide_partners} + for slide in self: + if not slide.question_ids: + gains = [0] + else: + gains = [ + slide.quiz_first_attempt_reward, + slide.quiz_second_attempt_reward, + slide.quiz_third_attempt_reward, + slide.quiz_fourth_attempt_reward, + ] + result[slide.id] = { + "quiz_karma_max": gains[ + 0 + ], # what could be gained if succeed at first try + "quiz_karma_gain": gains[0], # what would be gained at next test + "quiz_karma_won": 0, # what has been gained + "quiz_attempts_count": 0, # number of attempts + } + slide_partner = slide_partners_map.get(slide.id) + if ( + slide.question_ids + and slide_partner + and slide_partner.quiz_attempts_count + ): + result[slide.id]["quiz_karma_gain"] = ( + gains[slide_partner.quiz_attempts_count] + if slide_partner.quiz_attempts_count < len(gains) + else gains[-1] + ) + result[slide.id][ + "quiz_attempts_count" + ] = slide_partner.quiz_attempts_count + if quiz_done or slide_partner.completed: + result[slide.id]["quiz_karma_won"] = ( + gains[slide_partner.quiz_attempts_count - 1] + if slide_partner.quiz_attempts_count < len(gains) + else gains[-1] + ) + return result + + def _apply_ir_rules(self, query, mode="read"): + if self._is_public_with_key(): + return + return super()._apply_ir_rules(query, mode="read") + + def check_access_rule(self, operation): + if self._is_public_with_key(): + return + return super(SlideSlide, self).check_access_rule(operation) diff --git a/website_sale_slides_multi_qty/readme/CONTRIBUTORS.rst b/website_sale_slides_multi_qty/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000..d908072 --- /dev/null +++ b/website_sale_slides_multi_qty/readme/CONTRIBUTORS.rst @@ -0,0 +1,4 @@ +* `Tecnativa `__: + + * David Vidal + * Pilar Vargas diff --git a/website_sale_slides_multi_qty/readme/DESCRIPTION.rst b/website_sale_slides_multi_qty/readme/DESCRIPTION.rst new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/website_sale_slides_multi_qty/readme/DESCRIPTION.rst @@ -0,0 +1 @@ + diff --git a/website_sale_slides_multi_qty/security/ir.model.access.csv b/website_sale_slides_multi_qty/security/ir.model.access.csv new file mode 100644 index 0000000..01eabbf --- /dev/null +++ b/website_sale_slides_multi_qty/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_slide_slide_public,slide.slide public access,model_slide_slide,base.group_public,1,0,0,0 diff --git a/website_sale_slides_multi_qty/static/description/icon.png b/website_sale_slides_multi_qty/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3a0328b516c4980e8e44cdb63fd945757ddd132d GIT binary patch literal 9455 zcmW++2RxMjAAjx~&dlBk9S+%}OXg)AGE&Cb*&}d0jUxM@u(PQx^-s)697TX`ehR4?GS^qbkof1cslKgkU)h65qZ9Oc=ml_0temigYLJfnz{IDzUf>bGs4N!v3=Z3jMq&A#7%rM5eQ#dc?k~! zVpnB`o+K7|Al`Q_U;eD$B zfJtP*jH`siUq~{KE)`jP2|#TUEFGRryE2`i0**z#*^6~AI|YzIWy$Cu#CSLW3q=GA z6`?GZymC;dCPk~rBS%eCb`5OLr;RUZ;D`}um=H)BfVIq%7VhiMr)_#G0N#zrNH|__ zc+blN2UAB0=617@>_u;MPHN;P;N#YoE=)R#i$k_`UAA>WWCcEVMh~L_ zj--gtp&|K1#58Yz*AHCTMziU1Jzt_jG0I@qAOHsk$2}yTmVkBp_eHuY$A9)>P6o~I z%aQ?!(GqeQ-Y+b0I(m9pwgi(IIZZzsbMv+9w{PFtd_<_(LA~0H(xz{=FhLB@(1&qHA5EJw1>>=%q2f&^X>IQ{!GJ4e9U z&KlB)z(84HmNgm2hg2C0>WM{E(DdPr+EeU_N@57;PC2&DmGFW_9kP&%?X4}+xWi)( z;)z%wI5>D4a*5XwD)P--sPkoY(a~WBw;E~AW`Yue4kFa^LM3X`8x|}ZUeMnqr}>kH zG%WWW>3ml$Yez?i%)2pbKPI7?5o?hydokgQyZsNEr{a|mLdt;X2TX(#B1j35xPnPW z*bMSSOauW>o;*=kO8ojw91VX!qoOQb)zHJ!odWB}d+*K?#sY_jqPdg{Sm2HdYzdEx zOGVPhVRTGPtv0o}RfVP;Nd(|CB)I;*t&QO8h zFfekr30S!-LHmV_Su-W+rEwYXJ^;6&3|L$mMC8*bQptyOo9;>Qb9Q9`ySe3%V$A*9 zeKEe+b0{#KWGp$F+tga)0RtI)nhMa-K@JS}2krK~n8vJ=Ngm?R!9G<~RyuU0d?nz# z-5EK$o(!F?hmX*2Yt6+coY`6jGbb7tF#6nHA zuKk=GGJ;ZwON1iAfG$E#Y7MnZVmrY|j0eVI(DN_MNFJmyZ|;w4tf@=CCDZ#5N_0K= z$;R~bbk?}TpfDjfB&aiQ$VA}s?P}xPERJG{kxk5~R`iRS(SK5d+Xs9swCozZISbnS zk!)I0>t=A<-^z(cmSFz3=jZ23u13X><0b)P)^1T_))Kr`e!-pb#q&J*Q`p+B6la%C zuVl&0duN<;uOsB3%T9Fp8t{ED108<+W(nOZd?gDnfNBC3>M8WE61$So|P zVvqH0SNtDTcsUdzaMDpT=Ty0pDHHNL@Z0w$Y`XO z2M-_r1S+GaH%pz#Uy0*w$Vdl=X=rQXEzO}d6J^R6zjM1u&c9vYLvLp?W7w(?np9x1 zE_0JSAJCPB%i7p*Wvg)pn5T`8k3-uR?*NT|J`eS#_#54p>!p(mLDvmc-3o0mX*mp_ zN*AeS<>#^-{S%W<*mz^!X$w_2dHWpcJ6^j64qFBft-o}o_Vx80o0>}Du;>kLts;$8 zC`7q$QI(dKYG`Wa8#wl@V4jVWBRGQ@1dr-hstpQL)Tl+aqVpGpbSfN>5i&QMXfiZ> zaA?T1VGe?rpQ@;+pkrVdd{klI&jVS@I5_iz!=UMpTsa~mBga?1r}aRBm1WS;TT*s0f0lY=JBl66Upy)-k4J}lh=P^8(SXk~0xW=T9v*B|gzIhN z>qsO7dFd~mgxAy4V?&)=5ieYq?zi?ZEoj)&2o)RLy=@hbCRcfT5jigwtQGE{L*8<@Yd{zg;CsL5mvzfDY}P-wos_6PfprFVaeqNE%h zKZhLtcQld;ZD+>=nqN~>GvROfueSzJD&BE*}XfU|H&(FssBqY=hPCt`d zH?@s2>I(|;fcW&YM6#V#!kUIP8$Nkdh0A(bEVj``-AAyYgwY~jB zT|I7Bf@%;7aL7Wf4dZ%VqF$eiaC38OV6oy3Z#TER2G+fOCd9Iaoy6aLYbPTN{XRPz z;U!V|vBf%H!}52L2gH_+j;`bTcQRXB+y9onc^wLm5wi3-Be}U>k_u>2Eg$=k!(l@I zcCg+flakT2Nej3i0yn+g+}%NYb?ta;R?(g5SnwsQ49U8Wng8d|{B+lyRcEDvR3+`O{zfmrmvFrL6acVP%yG98X zo&+VBg@px@i)%o?dG(`T;n*$S5*rnyiR#=wW}}GsAcfyQpE|>a{=$Hjg=-*_K;UtD z#z-)AXwSRY?OPefw^iI+ z)AXz#PfEjlwTes|_{sB?4(O@fg0AJ^g8gP}ex9Ucf*@_^J(s_5jJV}c)s$`Myn|Kd z$6>}#q^n{4vN@+Os$m7KV+`}c%4)4pv@06af4-x5#wj!KKb%caK{A&Y#Rfs z-po?Dcb1({W=6FKIUirH&(yg=*6aLCekcKwyfK^JN5{wcA3nhO(o}SK#!CINhI`-I z1)6&n7O&ZmyFMuNwvEic#IiOAwNkR=u5it{B9n2sAJV5pNhar=j5`*N!Na;c7g!l$ z3aYBqUkqqTJ=Re-;)s!EOeij=7SQZ3Hq}ZRds%IM*PtM$wV z@;rlc*NRK7i3y5BETSKuumEN`Xu_8GP1Ri=OKQ$@I^ko8>H6)4rjiG5{VBM>B|%`&&s^)jS|-_95&yc=GqjNo{zFkw%%HHhS~e=s zD#sfS+-?*t|J!+ozP6KvtOl!R)@@-z24}`9{QaVLD^9VCSR2b`b!KC#o;Ki<+wXB6 zx3&O0LOWcg4&rv4QG0)4yb}7BFSEg~=IR5#ZRj8kg}dS7_V&^%#Do==#`u zpy6{ox?jWuR(;pg+f@mT>#HGWHAJRRDDDv~@(IDw&R>9643kK#HN`!1vBJHnC+RM&yIh8{gG2q zA%e*U3|N0XSRa~oX-3EAneep)@{h2vvd3Xvy$7og(sayr@95+e6~Xvi1tUqnIxoIH zVWo*OwYElb#uyW{Imam6f2rGbjR!Y3`#gPqkv57dB6K^wRGxc9B(t|aYDGS=m$&S!NmCtrMMaUg(c zc2qC=2Z`EEFMW-me5B)24AqF*bV5Dr-M5ig(l-WPS%CgaPzs6p_gnCIvTJ=Y<6!gT zVt@AfYCzjjsMEGi=rDQHo0yc;HqoRNnNFeWZgcm?f;cp(6CNylj36DoL(?TS7eU#+ z7&mfr#y))+CJOXQKUMZ7QIdS9@#-}7y2K1{8)cCt0~-X0O!O?Qx#E4Og+;A2SjalQ zs7r?qn0H044=sDN$SRG$arw~n=+T_DNdSrarmu)V6@|?1-ZB#hRn`uilTGPJ@fqEy zGt(f0B+^JDP&f=r{#Y_wi#AVDf-y!RIXU^0jXsFpf>=Ji*TeqSY!H~AMbJdCGLhC) zn7Rx+sXw6uYj;WRYrLd^5IZq@6JI1C^YkgnedZEYy<&4(z%Q$5yv#Boo{AH8n$a zhb4Y3PWdr269&?V%uI$xMcUrMzl=;w<_nm*qr=c3Rl@i5wWB;e-`t7D&c-mcQl7x! zZWB`UGcw=Y2=}~wzrfLx=uet<;m3~=8I~ZRuzvMQUQdr+yTV|ATf1Uuomr__nDf=X zZ3WYJtHp_ri(}SQAPjv+Y+0=fH4krOP@S&=zZ-t1jW1o@}z;xk8 z(Nz1co&El^HK^NrhVHa-_;&88vTU>_J33=%{if;BEY*J#1n59=07jrGQ#IP>@u#3A z;!q+E1Rj3ZJ+!4bq9F8PXJ@yMgZL;>&gYA0%_Kbi8?S=XGM~dnQZQ!yBSgcZhY96H zrWnU;k)qy`rX&&xlDyA%(a1Hhi5CWkmg(`Gb%m(HKi-7Z!LKGRP_B8@`7&hdDy5n= z`OIxqxiVfX@OX1p(mQu>0Ai*v_cTMiw4qRt3~NBvr9oBy0)r>w3p~V0SCm=An6@3n)>@z!|o-$HvDK z|3D2ZMJkLE5loMKl6R^ez@Zz%S$&mbeoqH5`Bb){Ei21q&VP)hWS2tjShfFtGE+$z zzCR$P#uktu+#!w)cX!lWN1XU%K-r=s{|j?)Akf@q#3b#{6cZCuJ~gCxuMXRmI$nGtnH+-h z+GEi!*X=AP<|fG`1>MBdTb?28JYc=fGvAi2I<$B(rs$;eoJCyR6_bc~p!XR@O-+sD z=eH`-ye})I5ic1eL~TDmtfJ|8`0VJ*Yr=hNCd)G1p2MMz4C3^Mj?7;!w|Ly%JqmuW zlIEW^Ft%z?*|fpXda>Jr^1noFZEwFgVV%|*XhH@acv8rdGxeEX{M$(vG{Zw+x(ei@ zmfXb22}8-?Fi`vo-YVrTH*C?a8%M=Hv9MqVH7H^J$KsD?>!SFZ;ZsvnHr_gn=7acz z#W?0eCdVhVMWN12VV^$>WlQ?f;P^{(&pYTops|btm6aj>_Uz+hqpGwB)vWp0Cf5y< zft8-je~nn?W11plq}N)4A{l8I7$!ks_x$PXW-2XaRFswX_BnF{R#6YIwMhAgd5F9X zGmwdadS6(a^fjHtXg8=l?Rc0Sm%hk6E9!5cLVloEy4eh(=FwgP`)~I^5~pBEWo+F6 zSf2ncyMurJN91#cJTy_u8Y}@%!bq1RkGC~-bV@SXRd4F{R-*V`bS+6;W5vZ(&+I<9$;-V|eNfLa5n-6% z2(}&uGRF;p92eS*sE*oR$@pexaqr*meB)VhmIg@h{uzkk$9~qh#cHhw#>O%)b@+(| z^IQgqzuj~Sk(J;swEM-3TrJAPCq9k^^^`q{IItKBRXYe}e0Tdr=Huf7da3$l4PdpwWDop%^}n;dD#K4s#DYA8SHZ z&1!riV4W4R7R#C))JH1~axJ)RYnM$$lIR%6fIVA@zV{XVyx}C+a-Dt8Y9M)^KU0+H zR4IUb2CJ{Hg>CuaXtD50jB(_Tcx=Z$^WYu2u5kubqmwp%drJ6 z?Fo40g!Qd<-l=TQxqHEOuPX0;^z7iX?Ke^a%XT<13TA^5`4Xcw6D@Ur&VT&CUe0d} z1GjOVF1^L@>O)l@?bD~$wzgf(nxX1OGD8fEV?TdJcZc2KoUe|oP1#=$$7ee|xbY)A zDZq+cuTpc(fFdj^=!;{k03C69lMQ(|>uhRfRu%+!k&YOi-3|1QKB z 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 Xviia!U7SGha1wx#SCgwmn*{w2TRX*I literal 0 HcmV?d00001 diff --git a/website_sale_slides_multi_qty/static/description/index.html b/website_sale_slides_multi_qty/static/description/index.html new file mode 100644 index 0000000..21066e9 --- /dev/null +++ b/website_sale_slides_multi_qty/static/description/index.html @@ -0,0 +1,426 @@ + + + + + +Website Sale Slides Multi Qty + + + +
+

Website Sale Slides Multi Qty

+ + +

Beta License: AGPL-3 OCA/e-learning Translate me on Weblate Try me on Runboat

+

Table of contents

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Tecnativa
  • +
+
+
+

Contributors

+
    +
  • Tecnativa:
      +
    • David Vidal
    • +
    • Pilar Vargas
    • +
    +
  • +
+
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/e-learning project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/website_sale_slides_multi_qty/static/src/js/slides_access_form.js b/website_sale_slides_multi_qty/static/src/js/slides_access_form.js new file mode 100644 index 0000000..b4a72a8 --- /dev/null +++ b/website_sale_slides_multi_qty/static/src/js/slides_access_form.js @@ -0,0 +1,17 @@ +odoo.define("website_sale_slides_multi_qty.slide_access_form", function () { + "use strict"; + + const toggle_key_access = $("#toggle_key_access"); + const $nameField = $("#slide_channel_partner_name"); + const $emailField = $("#slide_channel_partner_email"); + const $phoneField = $("#slide_channel_partner_phone"); + const $termsCheckbox = $("#terms_and_conditions"); + + toggle_key_access.on("click", function () { + $nameField.addClass("d-none").prop("required", false); + $emailField.addClass("d-none").prop("required", false); + $phoneField.addClass("d-none").prop("required", false); + $termsCheckbox.addClass("d-none").prop("required", false); + toggle_key_access.addClass("d-none"); + }); +}); diff --git a/website_sale_slides_multi_qty/static/src/js/slides_course_fullscreen_player.js b/website_sale_slides_multi_qty/static/src/js/slides_course_fullscreen_player.js new file mode 100644 index 0000000..00c1cc3 --- /dev/null +++ b/website_sale_slides_multi_qty/static/src/js/slides_course_fullscreen_player.js @@ -0,0 +1,89 @@ +odoo.define("website_sale_slides_multi_qty.fullscreen", function (require) { + "use strict"; + + var config = require("web.config"); + var Fullscreen = require("@website_slides/js/slides_course_fullscreen_player")[ + Symbol.for("default") + ]; + + Fullscreen.include({ + init: function () { + this._super.apply(this, arguments); + this.isPublicWithKey = false; + this._fetchSessionData().then((isPublicWithKey) => { + this.isPublicWithKey = isPublicWithKey; + }); + }, + /** + * Checks if the user is public with password by calling the method in the backend. + * + * @private + */ + _fetchSessionData: function () { + return this._rpc({ + route: "/slides/is_public_with_key", + }) + .then((data) => { + return ( + data.invite_hash && + data.identification_number && + data.invite_partner_id + ); + }) + .catch(() => { + return false; + }); + }, + /** + * Override methods to execute logic for public users with passwords + * @override + * @private + */ + _onChangeSlide: function () { + if (this.isPublicWithKey) { + var self = this; + var slide = this.get("slide"); + self._pushUrlState(); + return this._fetchSlideContent() + .then(function () { + // Render content + var websiteName = document.title.split(" | ")[1]; // Get the website name from title + document.title = websiteName + ? slide.name + " | " + websiteName + : slide.name; + if (config.device.size_class < config.device.SIZES.MD) { + self._toggleSidebar(); // Hide sidebar when small device screen + } + return self._renderSlide(); + }) + .then(function () { + if (slide._autoSetDone) { + // No useless RPC call + if (["document", "presentation"].includes(slide.type)) { + // Only set the slide as completed after iFrame is loaded to avoid concurrent execution with 'embedUrl' controller + self.el + .querySelector("iframe.o_wslides_iframe_viewer") + .addEventListener("load", () => + self._setCompleted(slide.id) + ); + } else { + return self._setCompleted(slide.id); + } + } + }); + } + return this._super.apply(this, arguments); + }, + /** + * @override + * @private + */ + _onSlideToComplete: function (ev) { + if (this.isPublicWithKey) { + var slideId = ev.data.id; + this._setCompleted(slideId); + } + return this._super.apply(this, arguments); + }, + }); +}); diff --git a/website_sale_slides_multi_qty/views/slide_channel_partner_views.xml b/website_sale_slides_multi_qty/views/slide_channel_partner_views.xml new file mode 100644 index 0000000..4a0d19f --- /dev/null +++ b/website_sale_slides_multi_qty/views/slide_channel_partner_views.xml @@ -0,0 +1,16 @@ + + + + + slide.channel.partner + + + + + + + + + + diff --git a/website_sale_slides_multi_qty/views/slide_channel_views.xml b/website_sale_slides_multi_qty/views/slide_channel_views.xml new file mode 100644 index 0000000..e0f12ee --- /dev/null +++ b/website_sale_slides_multi_qty/views/slide_channel_views.xml @@ -0,0 +1,14 @@ + + + + + slide.channel + + + + + + diff --git a/website_sale_slides_multi_qty/views/website_slides_templates.xml b/website_sale_slides_multi_qty/views/website_slides_templates.xml new file mode 100644 index 0000000..cca6898 --- /dev/null +++ b/website_sale_slides_multi_qty/views/website_slides_templates.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/website_sale_slides_multi_qty/views/website_slides_templates_course.xml b/website_sale_slides_multi_qty/views/website_slides_templates_course.xml new file mode 100644 index 0000000..ad7608c --- /dev/null +++ b/website_sale_slides_multi_qty/views/website_slides_templates_course.xml @@ -0,0 +1,99 @@ + + + + + diff --git a/website_sale_slides_multi_qty/views/website_slides_templates_homepage.xml b/website_sale_slides_multi_qty/views/website_slides_templates_homepage.xml new file mode 100644 index 0000000..982742d --- /dev/null +++ b/website_sale_slides_multi_qty/views/website_slides_templates_homepage.xml @@ -0,0 +1,12 @@ + + + + + From 7e39c61e8070fbad2b5744697c50313ab537837f Mon Sep 17 00:00:00 2001 From: pilarvargas-tecnativa Date: Mon, 9 Jun 2025 19:34:40 +0200 Subject: [PATCH 2/2] [MIG] website_sale_slides_multi_qty: Migration to version 17.0 TT56328 --- website_sale_slides_multi_qty/README.rst | 57 +++-- website_sale_slides_multi_qty/__manifest__.py | 10 +- .../controllers/main.py | 194 ++++++++++++------ .../data/mail_template_data.xml | 2 +- website_sale_slides_multi_qty/i18n/es.po | 77 +++++-- .../i18n/website_sale_slides_multi_qty.pot | 63 ++++-- .../models/__init__.py | 1 + .../models/sale_order.py | 17 ++ .../models/slide_channel.py | 114 +++++----- .../models/slide_slide.py | 35 ++-- website_sale_slides_multi_qty/pyproject.toml | 3 + .../readme/CONFIGURE.md | 7 + .../readme/CONTRIBUTORS.md | 3 + .../readme/CONTRIBUTORS.rst | 4 - .../readme/DESCRIPTION.md | 4 + .../readme/DESCRIPTION.rst | 1 - website_sale_slides_multi_qty/readme/USAGE.md | 13 ++ .../security/ir.model.access.csv | 2 - .../static/description/index.html | 61 ++++-- .../static/src/js/slides_access_form.js | 4 +- .../js/slides_course_fullscreen_player.esm.js | 84 ++++++++ .../src/js/slides_course_fullscreen_player.js | 89 -------- .../website_sale_slides_multi_qty.esm.js | 36 ++++ ...er_line_multi_qty_join_without_user.esm.js | 30 +++ ...ine_multi_qty_register_without_user.esm.js | 46 +++++ .../tests/__init__.py | 1 + .../test_website_sale_slides_multi_qty.py | 99 +++++++++ .../views/website_slides_templates.xml | 2 +- .../views/website_slides_templates_course.xml | 33 +-- 29 files changed, 788 insertions(+), 304 deletions(-) create mode 100644 website_sale_slides_multi_qty/models/sale_order.py create mode 100644 website_sale_slides_multi_qty/pyproject.toml create mode 100644 website_sale_slides_multi_qty/readme/CONFIGURE.md create mode 100644 website_sale_slides_multi_qty/readme/CONTRIBUTORS.md delete mode 100644 website_sale_slides_multi_qty/readme/CONTRIBUTORS.rst create mode 100644 website_sale_slides_multi_qty/readme/DESCRIPTION.md delete mode 100644 website_sale_slides_multi_qty/readme/DESCRIPTION.rst create mode 100644 website_sale_slides_multi_qty/readme/USAGE.md delete mode 100644 website_sale_slides_multi_qty/security/ir.model.access.csv create mode 100644 website_sale_slides_multi_qty/static/src/js/slides_course_fullscreen_player.esm.js delete mode 100644 website_sale_slides_multi_qty/static/src/js/slides_course_fullscreen_player.js create mode 100644 website_sale_slides_multi_qty/static/tests/tours/website_sale_slides_multi_qty.esm.js create mode 100644 website_sale_slides_multi_qty/static/tests/tours/website_sale_slides_order_line_multi_qty_join_without_user.esm.js create mode 100644 website_sale_slides_multi_qty/static/tests/tours/website_sale_slides_order_line_multi_qty_register_without_user.esm.js create mode 100644 website_sale_slides_multi_qty/tests/__init__.py create mode 100644 website_sale_slides_multi_qty/tests/test_website_sale_slides_multi_qty.py diff --git a/website_sale_slides_multi_qty/README.rst b/website_sale_slides_multi_qty/README.rst index 9f9808a..e04e35f 100644 --- a/website_sale_slides_multi_qty/README.rst +++ b/website_sale_slides_multi_qty/README.rst @@ -7,7 +7,7 @@ Website Sale Slides Multi Qty !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:3a794fd5342313c42c608fcbc6bb79937d6bfe85b2257ab195a03dcbfa6c5703 + !! source digest: sha256:8e2129116bdf52aaccd45cfaf84303667132f5defc24cb2c03220b6cf08ba4a7 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png @@ -17,31 +17,62 @@ Website Sale Slides Multi Qty :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fe--learning-lightgray.png?logo=github - :target: https://github.com/OCA/e-learning/tree/15.0/website_sale_slides_multi_qty + :target: https://github.com/OCA/e-learning/tree/17.0/website_sale_slides_multi_qty :alt: OCA/e-learning .. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png - :target: https://translation.odoo-community.org/projects/e-learning-15-0/e-learning-15-0-website_sale_slides_multi_qty + :target: https://translation.odoo-community.org/projects/e-learning-17-0/e-learning-17-0-website_sale_slides_multi_qty :alt: Translate me on Weblate .. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png - :target: https://runboat.odoo-community.org/builds?repo=OCA/e-learning&target_branch=15.0 + :target: https://runboat.odoo-community.org/builds?repo=OCA/e-learning&target_branch=17.0 :alt: Try me on Runboat |badge1| |badge2| |badge3| |badge4| |badge5| - +This module allows you to order several courses at once. This generates +an invitation link through which other users can request to participate +in the course without having to be registered users. The number of +participants is limited to the number of courses ordered. **Table of contents** .. contents:: :local: +Configuration +============= + +To enable this functionality on a course: + +1. Create or edit a course from the backend. +2. Set the **Enrollment Policy** to **On Payment**. + +This ensures that access to the course is restricted to users who have +completed a valid purchase. + +Usage +===== + +When a user orders a course with a quantity greater than 1, a +participation link will be sent to the customer by email. + +There are two ways to use the participation link: + +- **Registered users** can access the course directly without having to + make a new order. +- **Anonymous users** will be asked to enter identification details, + which will allow them to register and return to the course later. + +Access is limited to the number of places acquired. Once all places have +been used, no additional users will be able to register via the +participation link. + Bug Tracker =========== Bugs are tracked on `GitHub Issues `_. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed -`feedback `_. +`feedback `_. Do not contact contributors directly about support or help with technical issues. @@ -49,20 +80,20 @@ Credits ======= Authors -~~~~~~~ +------- * Tecnativa Contributors -~~~~~~~~~~~~ +------------ -* `Tecnativa `__: +- [Tecnativa](https://www.tecnativa.com/): - * David Vidal - * Pilar Vargas + - David Vidal + - Pilar Vargas Maintainers -~~~~~~~~~~~ +----------- This module is maintained by the OCA. @@ -74,6 +105,6 @@ OCA, or the Odoo Community Association, is a nonprofit organization whose mission is to support the collaborative development of Odoo features and promote its widespread use. -This module is part of the `OCA/e-learning `_ project on GitHub. +This module is part of the `OCA/e-learning `_ project on GitHub. You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/website_sale_slides_multi_qty/__manifest__.py b/website_sale_slides_multi_qty/__manifest__.py index ee4036c..859b704 100644 --- a/website_sale_slides_multi_qty/__manifest__.py +++ b/website_sale_slides_multi_qty/__manifest__.py @@ -2,26 +2,26 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). { "name": "Website Sale Slides Multi Qty", - "version": "15.0.1.0.0", + "version": "17.0.1.0.0", "category": "Website/eLearning", "author": "Tecnativa, Odoo Community Association (OCA)", "website": "https://github.com/OCA/e-learning", "license": "AGPL-3", - "summary": "", - "depends": ["website_sale_slides_order_line_link"], + "depends": ["website_sale_slides_order_line_link", "base_vat"], "data": [ "data/mail_template_data.xml", - "security/ir.model.access.csv", "views/slide_channel_partner_views.xml", "views/slide_channel_views.xml", "views/website_slides_templates_course.xml", "views/website_slides_templates_homepage.xml", - "views/website_slides_templates.xml", ], "installable": True, "assets": { "web.assets_frontend": [ "website_sale_slides_multi_qty/static/src/js/*.js", ], + "web.assets_tests": [ + "website_sale_slides_multi_qty/static/tests/tours/*.js", + ], }, } diff --git a/website_sale_slides_multi_qty/controllers/main.py b/website_sale_slides_multi_qty/controllers/main.py index 9535a1a..1388b9e 100644 --- a/website_sale_slides_multi_qty/controllers/main.py +++ b/website_sale_slides_multi_qty/controllers/main.py @@ -1,7 +1,12 @@ # Copyright 2025 Tecnativa - Pilar Vargas # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from odoo import _, http +import json + +from dateutil.relativedelta import relativedelta + +from odoo import _, fields, http from odoo.http import request +from odoo.tools import consteq from odoo.addons.website_slides.controllers.main import WebsiteSlides @@ -21,12 +26,54 @@ def _set_viewed_slide(self, slide, quiz_attempts_inc=False): def _get_slide_detail(self, slide): values = super()._get_slide_detail(slide) identification_number = request.session.get("identification_number", False) - # Si el usuario tiene clave, no debe ser considerado público - if identification_number: - values["is_public_user"] = False - # Evitar que intente publicar comentarios si no tiene partner_id - if "message_post_pid" in values: - values["message_post_pid"] = False + # Prevent them from attempting to post comments if they do not have a partner_id + if identification_number and "message_post_pid" in values: + values["message_post_pid"] = False + return values + + def _get_slide_quiz_data(self, slide): + if not slide._is_public_with_key(): + return super()._get_slide_quiz_data(slide) + is_designer = request.env.user.has_group("website.group_website_designer") + slides_resources = ( + slide.sudo().slide_resource_ids if slide.channel_id.is_member else [] + ) + values = { + "slide_description": slide.description, + "slide_questions": [ + { + "answer_ids": [ + { + "comment": answer.comment if is_designer else None, + "id": answer.id, + "is_correct": answer.is_correct + if slide.user_has_completed or is_designer + else None, + "text_value": answer.text_value, + } + for answer in question.sudo().answer_ids + ], + "id": question.id, + "question": question.question, + } + for question in slide.question_ids + ], + "slide_resource_ids": [ + { + "display_name": resource.display_name, + "download_url": resource.download_url, + "id": resource.id, + "link": resource.link, + "resource_type": resource.resource_type, + } + for resource in slides_resources + ], + } + if "slide_answer_quiz" in request.session: + slide_answer_quiz = json.loads(request.session["slide_answer_quiz"]) + if str(slide.id) in slide_answer_quiz: + values["session_answers"] = slide_answer_quiz[str(slide.id)] + values.update(self._get_slide_quiz_partner_info(slide)) return values def _get_channel_progress(self, channel, include_quiz=False): @@ -57,10 +104,7 @@ def _get_channel_progress(self, channel, include_quiz=False): for slide_partner in slide_partners: values[slide_partner.slide_id.id].update(slide_partner.read()[0]) if slide_partner.slide_id in values: - values[slide_partner.slide_id].update( - slide_partner.read()[0] - ) # Copia los datos de progreso - # Manejo de intentos y recompensas en quiz + values[slide_partner.slide_id].update(slide_partner.read()[0]) if slide_partner.slide_id.sudo().question_ids: gains = [ slide_partner.slide_id.quiz_first_attempt_reward, @@ -109,11 +153,13 @@ def session_data(self): @http.route() def channel( self, - channel, + channel=False, + channel_id=False, category=None, + category_id=False, tag=None, page=1, - slide_type=None, + slide_category=None, uncategorized=False, sorting=None, search=None, @@ -133,27 +179,31 @@ def channel( "=", session_data["identification_number"], ), - ("channel_id", "=", channel.id), + ("channel_id", "=", channel_id), ], limit=1, ) ) - if participation and participation.channel_id != channel: + if participation and participation.channel_id.id != channel_id: # If there is no valid participation, we delete the session data. self._delete_session_data() res = super().channel( - channel, + channel=channel, + channel_id=channel_id, category=category, + category_id=category_id, tag=tag, page=page, - slide_type=slide_type, + slide_category=slide_category, uncategorized=uncategorized, sorting=sorting, search=search, **kw, ) + channel_rec = channel or request.env["slide.channel"].browse(int(channel_id)) res.qcontext["can_enroll"] = self._can_user_register( - channel, request.env.user + (participation.channel_id if participation else channel_rec), + request.env.user, ) or bool(kw.get("is_invite", False)) channel_error = request.session.pop("channel_error", None) if channel_error: @@ -245,37 +295,25 @@ def slide_channel_join_course(self, channel_id, **kwargs): self._add_new_member(channel, target_partner, parent_channel_partner) return request.redirect(f"/slides/{channel_id}") - @http.route( - "/slides//invite", - type="http", - auth="public", - website=True, - sitemap=False, - ) - def slide_channel_invite(self, channel_id, **kwargs): + @http.route() + def slide_channel_invite(self, channel_id, invite_partner_id, invite_hash): + res = super().slide_channel_invite(channel_id, invite_partner_id, invite_hash) self._delete_session_data() - invite_partner_id = kwargs.get("invite_partner_id") or request.session.pop( - "invite_partner_id", None - ) - invite_hash = kwargs.get("invite_hash") or request.session.pop( - "invite_hash", None - ) - if not invite_partner_id or not invite_hash: - return request.redirect("/slides") - channel = request.env["slide.channel"].sudo().browse(channel_id).exists() - partner = request.env.user.partner_id - if not channel or not channel.is_published: - return self._redirect_to_slides_main("no_channel") # A user is logged if not request.website.is_public_user(): + channel = request.env["slide.channel"].browse(int(channel_id)).exists() + enroll = channel.sudo().channel_partner_ids.filtered( - lambda x: x.partner_id == partner + lambda x: x.partner_id == request.env.user.partner_id ) - if not partner.id == int(invite_partner_id) and not enroll: + if ( + not request.env.user.partner_id.id == int(invite_partner_id) + and not enroll + ): return request.redirect( f"/slides/{channel_id}?is_invite=1&invite_partner_id={invite_partner_id}" ) - return request.redirect(f"/slides/{channel_id}") + # return request.redirect(f"/slides/{channel_id}") # No user is logged. if request.website.is_public_user(): request.session["invite_partner_id"] = int(invite_partner_id) @@ -284,15 +322,7 @@ def slide_channel_invite(self, channel_id, **kwargs): return request.redirect( f"/slides/{channel_id}?is_invite=1&invite_partner_id={invite_partner_id}" ) - - @staticmethod - def _redirect_to_slides_main(invite_error=""): - # Redirige a la página de cursos y muestra un error si corresponde. - if invite_error: - request.session[ - "error_message" - ] = "El curso no se encuentra disponible o no existe." - return request.redirect("/slides") + return res def _can_user_register(self, channel, user): # Check if the user meets the conditions to register for the course. @@ -327,7 +357,7 @@ def _add_new_member( self, channel, target_partner, parent_channel_partner, **kwargs ): channel._action_add_members( - target_partner, + target_partners=target_partner, parent_channel_partner=parent_channel_partner, identification_number=kwargs.get("identification_number", False), slide_channel_partner_name=kwargs.get("slide_channel_partner_name", False), @@ -342,6 +372,47 @@ def _add_new_member( ), ) + @staticmethod + def _get_channel_values_from_invite(channel_id, invite_hash, invite_partner_id): + # Static method overridden to handle sub-participations: + # when multiple participations exist for the same partner, + # only participations without parent_id (main participations) are considered. + channel_sudo = request.env["slide.channel"].browse(channel_id).exists().sudo() + partner_sudo = ( + request.env["res.partner"].browse(invite_partner_id).exists().sudo() + ) + if not partner_sudo or not channel_sudo.is_published: + return { + "invite_error": "no_partner" + if not partner_sudo + else "no_channel" + if not channel_sudo + else "no_rights" + } + # Apply custom logic to consider only participations without a parent_id + # (main participations). + channel_partner_sudo = channel_sudo.channel_partner_all_ids.filtered( + lambda cp: cp.partner_id.id == invite_partner_id and not cp.parent_id + ) + if not channel_partner_sudo: + return {"invite_error": "expired"} + if not consteq(channel_partner_sudo._get_invitation_hash(), invite_hash): + return {"invite_error": "hash_fail"} + if channel_partner_sudo.member_status == "invited": + if ( + not channel_partner_sudo.last_invitation_date + or channel_partner_sudo.last_invitation_date + relativedelta(months=3) + < fields.Datetime.now() + ): + return {"invite_error": "expired"} + return { + "invite_channel": channel_sudo, + "invite_channel_partner": channel_partner_sudo, + "invite_preview": True, + "is_partner_without_user": not partner_sudo.user_ids, + "invite_partner": partner_sudo, + } + # SLIDE.SLIDE UTILS @http.route() @@ -359,8 +430,12 @@ def slide_set_completed(self, slide_id): fetch_res = self._fetch_slide(slide_id) if fetch_res.get("error"): return fetch_res - self._set_completed_slide(fetch_res["slide"]) - return {"channel_completion": fetch_res["slide"].channel_id.completion} + self._slide_mark_completed(fetch_res["slide"]) + next_category = fetch_res["slide"]._get_next_category() + return { + "channel_completion": fetch_res["slide"].channel_id.completion, + "next_category_id": next_category.id if next_category else False, + } return super().slide_set_completed(slide_id) # QUIZ SECTION @@ -383,7 +458,7 @@ def slide_quiz_submit(self, slide_id, answer_ids): if fetch_res.get("error"): return fetch_res slide = fetch_res["slide"] - if slide.user_membership_id.sudo().completed: + if slide.user_has_completed: self._channel_remove_session_answers(slide.channel_id, slide) return {"error": "slide_quiz_done"} all_questions = ( @@ -404,8 +479,7 @@ def slide_quiz_submit(self, slide_id, answer_ids): rank_progress = {} if not user_bad_answers: rank_progress["previous_rank"] = self._get_rank_values(request.env.user) - slide._action_set_quiz_done() - slide.action_set_completed() + slide._action_mark_completed() rank_progress["new_rank"] = self._get_rank_values(request.env.user) rank_progress.update( { @@ -425,7 +499,7 @@ def slide_quiz_submit(self, slide_id, answer_ids): } for answer in user_answers }, - "completed": slide.user_membership_id.sudo().completed, + "completed": slide.user_has_completed, "channel_completion": slide.channel_id.completion, "quizKarmaWon": quiz_info["quiz_karma_won"], "quizKarmaGain": quiz_info["quiz_karma_gain"], @@ -462,7 +536,9 @@ def _prepare_user_slides_profile(self, user): ] ) ) - courses_completed = courses.filtered(lambda c: c.completed) + courses_completed = courses.filtered( + lambda c: c.member_status == "completed" + ) courses_ongoing = courses - courses_completed values.update( { @@ -472,7 +548,7 @@ def _prepare_user_slides_profile(self, user): "courses_completed": courses_completed, "courses_ongoing": courses_ongoing, "is_profile_page": True, - "badge_category": "slides", + "my_profile": True, } ) return values diff --git a/website_sale_slides_multi_qty/data/mail_template_data.xml b/website_sale_slides_multi_qty/data/mail_template_data.xml index e4fa405..e41667b 100644 --- a/website_sale_slides_multi_qty/data/mail_template_data.xml +++ b/website_sale_slides_multi_qty/data/mail_template_data.xml @@ -32,7 +32,7 @@ t-att-href="object.invitation_link" style="background-color: #875A7B; padding: 8px 16px 8px 16px; text-decoration: none; color: #fff; border-radius: 5px; font-size:13px;" > - + Click here to access the course

diff --git a/website_sale_slides_multi_qty/i18n/es.po b/website_sale_slides_multi_qty/i18n/es.po index 195c878..9228a36 100644 --- a/website_sale_slides_multi_qty/i18n/es.po +++ b/website_sale_slides_multi_qty/i18n/es.po @@ -6,8 +6,8 @@ msgid "" msgstr "" "Project-Id-Version: Odoo Server 15.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-05-06 06:24+0000\n" -"PO-Revision-Date: 2025-05-06 08:25+0200\n" +"POT-Creation-Date: 2025-09-24 09:53+0000\n" +"PO-Revision-Date: 2025-09-24 11:56+0200\n" "Last-Translator: \n" "Language-Team: \n" "Language: es\n" @@ -22,14 +22,14 @@ msgstr "" msgid "" "
\n" "

\n" -" Hello ,\n" +" Hello ,\n" "

\n" " \n" "

\n" " You have successfully acquired \n" +"out=\"object.available_registrations\">\n" " registrations for the course Course Name.\n" "

\n" @@ -45,7 +45,7 @@ msgid "" " \n" -" \n" +" Click here to access the course\n" " \n" "
\n" "

\n" @@ -82,16 +82,16 @@ msgstr "" "1\">\n" "

\n" -" Has adquirido con éxito \n" +" Has adquirido con éxito \n" " inscripciones para el curso Nombre del curso.\n" "

\n" "

\n" " Para que los participantes accedan al curso, deben " "completar su inscripción\n" -" utilizando el siguiente enlace. Pueden iniciar sesión " -"con su cuenta de Odoo o crear una nueva participación.\n" +" utilizando el siguiente enlace. Pueden iniciar " +"sesión con su cuenta de Odoo o crear una nueva participación.\n" "

\n" "

\n" "

\n" -" \n" +" Haz clic aquí para acceder al curso\n" " \n" "
\n" "

\n" @@ -111,7 +111,8 @@ msgstr "" "channel_id.name or ''\">Nombre del curso
ha sido confirmado.\n" "

\n" "

\n" -" Puedes comenzar a aprender haciendo clic en el siguiente enlace:\n" +" Puedes comenzar a aprender haciendo clic en el " +"siguiente enlace:\n" "

\n" "

\n" "

\n" "

\n" -" Hello ,\n" +" Hello ,\n" "

\n" " \n" "

\n" -" You have successfully acquired \n" +" You have successfully acquired \n" " registrations for the course Course Name.\n" "

\n" "

\n" @@ -34,7 +34,7 @@ msgid "" "

\n" "

\n" "

\n" @@ -79,33 +79,43 @@ msgstr "" msgid "Child Participation" msgstr "" +#. module: website_sale_slides_multi_qty +#: model:ir.model.constraint,message:website_sale_slides_multi_qty.constraint_slide_slide_partner_slide_partner_uniq +msgid "Constraint disabled: allowing repeated partner on the same slide." +msgstr "" + #. module: website_sale_slides_multi_qty #: model:ir.model,name:website_sale_slides_multi_qty.model_slide_channel msgid "Course" msgstr "" #. module: website_sale_slides_multi_qty -#: model_terms:ir.ui.view,arch_db:website_sale_slides_multi_qty.course_sidebar +#: model_terms:ir.ui.view,arch_db:website_sale_slides_multi_qty.course_join msgid "Enter your ID" msgstr "" #. module: website_sale_slides_multi_qty -#: model_terms:ir.ui.view,arch_db:website_sale_slides_multi_qty.course_sidebar +#: model_terms:ir.ui.view,arch_db:website_sale_slides_multi_qty.course_join msgid "Enter your email" msgstr "" #. module: website_sale_slides_multi_qty -#: model_terms:ir.ui.view,arch_db:website_sale_slides_multi_qty.course_sidebar +#: model_terms:ir.ui.view,arch_db:website_sale_slides_multi_qty.course_join msgid "Enter your name" msgstr "" #. module: website_sale_slides_multi_qty -#: model_terms:ir.ui.view,arch_db:website_sale_slides_multi_qty.course_sidebar +#: model_terms:ir.ui.view,arch_db:website_sale_slides_multi_qty.course_join msgid "Enter your phone" msgstr "" #. module: website_sale_slides_multi_qty -#: model_terms:ir.ui.view,arch_db:website_sale_slides_multi_qty.course_sidebar +#: model_terms:ir.ui.view,arch_db:website_sale_slides_multi_qty.course_join +msgid "I accept the" +msgstr "" + +#. module: website_sale_slides_multi_qty +#: model_terms:ir.ui.view,arch_db:website_sale_slides_multi_qty.course_join msgid "I have already joined" msgstr "" @@ -115,6 +125,13 @@ msgstr "" msgid "Identification Number" msgstr "" +#. module: website_sale_slides_multi_qty +#. odoo-python +#: code:addons/website_sale_slides_multi_qty/controllers/main.py:0 +#, python-format +msgid "Invalid identification number." +msgstr "" + #. module: website_sale_slides_multi_qty #: model:ir.model.fields,field_description:website_sale_slides_multi_qty.field_slide_channel_partner__invitation_hash msgid "Invitation Hash" @@ -131,7 +148,7 @@ msgid "Is Public Slide Channel Partner" msgstr "" #. module: website_sale_slides_multi_qty -#: model_terms:ir.ui.view,arch_db:website_sale_slides_multi_qty.course_sidebar +#: model_terms:ir.ui.view,arch_db:website_sale_slides_multi_qty.course_join msgid "Join Course" msgstr "" @@ -141,6 +158,7 @@ msgid "Name" msgstr "" #. module: website_sale_slides_multi_qty +#. odoo-python #: code:addons/website_sale_slides_multi_qty/models/slide_channel.py:0 #, python-format msgid "No registrations available for this course." @@ -161,6 +179,11 @@ msgstr "" msgid "Phone" msgstr "" +#. module: website_sale_slides_multi_qty +#: model:ir.model,name:website_sale_slides_multi_qty.model_sale_order +msgid "Sales Order" +msgstr "" + #. module: website_sale_slides_multi_qty #: model:ir.model,name:website_sale_slides_multi_qty.model_slide_slide_partner msgid "Slide / Partner decorated m2m" @@ -171,12 +194,23 @@ msgstr "" msgid "Slides" msgstr "" +#. module: website_sale_slides_multi_qty +#: model:ir.model.constraint,message:website_sale_slides_multi_qty.constraint_slide_channel_partner_channel_partner_uniq +msgid "Temporal constraint disabled" +msgstr "" + #. module: website_sale_slides_multi_qty #: model:ir.model.constraint,message:website_sale_slides_multi_qty.constraint_slide_channel_partner_unique_channel_identification msgid "The identification number must be unique per course!" msgstr "" #. module: website_sale_slides_multi_qty +#: model:ir.model.constraint,message:website_sale_slides_multi_qty.constraint_slide_slide_partner_unique_slide_identification +msgid "The identification number must be unique!" +msgstr "" + +#. module: website_sale_slides_multi_qty +#. odoo-python #: code:addons/website_sale_slides_multi_qty/controllers/main.py:0 #, python-format msgid "There is no participation for this key" @@ -196,3 +230,8 @@ msgstr "" #: model:mail.template,subject:website_sale_slides_multi_qty.mail_template_slide_channel_confirm msgid "Your access to {{ object.channel_id.name }} has been confirmed" msgstr "" + +#. module: website_sale_slides_multi_qty +#: model_terms:ir.ui.view,arch_db:website_sale_slides_multi_qty.course_join +msgid "terms and conditions" +msgstr "" diff --git a/website_sale_slides_multi_qty/models/__init__.py b/website_sale_slides_multi_qty/models/__init__.py index 36d510f..a2c75d5 100644 --- a/website_sale_slides_multi_qty/models/__init__.py +++ b/website_sale_slides_multi_qty/models/__init__.py @@ -1,2 +1,3 @@ +from . import sale_order from . import slide_channel from . import slide_slide diff --git a/website_sale_slides_multi_qty/models/sale_order.py b/website_sale_slides_multi_qty/models/sale_order.py new file mode 100644 index 0000000..8169cf7 --- /dev/null +++ b/website_sale_slides_multi_qty/models/sale_order.py @@ -0,0 +1,17 @@ +# Copyright 2025 Tecnativa - Pilar Vargas +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo import models + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + def _verify_updated_quantity(self, order_line, product_id, new_qty, **kwargs): + # Allow adding more than 1 quantity of a course to the cart + res = super()._verify_updated_quantity( + order_line, product_id, new_qty, **kwargs + ) + product = self.env["product.product"].browse(product_id) + if product.detailed_type == "course" and new_qty > 1: + return new_qty, "" + return res diff --git a/website_sale_slides_multi_qty/models/slide_channel.py b/website_sale_slides_multi_qty/models/slide_channel.py index 73bdb38..96b540a 100644 --- a/website_sale_slides_multi_qty/models/slide_channel.py +++ b/website_sale_slides_multi_qty/models/slide_channel.py @@ -1,7 +1,7 @@ # Copyright 2025 Tecnativa - Pilar Vargas # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from odoo import _, api, fields, models, tools +from odoo import _, api, fields, models from odoo.http import request @@ -21,8 +21,8 @@ def _is_public_with_key(self): ) return False - def _compute_is_member(self): - res = super()._compute_is_member() + def _compute_membership_values(self): + res = super()._compute_membership_values() if self._is_public_with_key(): channel_partner = ( self.env["slide.channel.partner"] @@ -61,10 +61,13 @@ def _compute_user_statistics(self): ] ) ) - mapped_data = { - info.channel_id.id: (info.completed, info.completed_slides_count) + mapped_data = dict( + ( + info.channel_id.id, + (info.member_status == "completed", info.completed_slides_count), + ) for info in current_user_info - } + ) for record in self: completed, completed_slides_count = mapped_data.get( record.id, (False, 0) @@ -80,9 +83,13 @@ def _compute_user_statistics(self): else: return super()._compute_user_statistics() - # def _compute_action_rights(self): - - def _action_add_members(self, target_partners, **member_values): + def _action_add_members( + self, + target_partners, + member_status="joined", + raise_on_access=False, + **member_values, + ): # Create sub-participations parent_channel_partner = member_values.get("parent_channel_partner", False) to_create_values = {} @@ -121,7 +128,11 @@ def _action_add_members(self, target_partners, **member_values): new_target_partners = target_partners.filtered( lambda x: x.id not in self.channel_partner_ids.partner_id.ids ) - res = super()._action_add_members(target_partners, **member_values) + res = super()._action_add_members( + target_partners, + member_status=member_status, + raise_on_access=raise_on_access, + ) if new_target_partners: for target in self.channel_partner_ids.filtered( lambda x: x.partner_id.id in new_target_partners.ids @@ -150,8 +161,8 @@ class SlideChannelPartner(models.Model): used_registrations = fields.Integer( compute="_compute_used_registrations", store=True ) - invitation_hash = fields.Char(compute="_compute_invitation", store=True) - invitation_link = fields.Char(compute="_compute_invitation", store=True) + invitation_hash = fields.Char(compute="_compute_invitation_link", store=True) + invitation_link = fields.Char(store=True) slide_channel_partner_name = fields.Char(string="Name") slide_channel_partner_email = fields.Char(string="Participation Email") slide_channel_partner_phone = fields.Char(string="Phone") @@ -161,11 +172,12 @@ class SlideChannelPartner(models.Model): is_public_slide_channel_partner = fields.Boolean(default=False) _sql_constraints = [ + ("channel_partner_uniq", "CHECK (true)", "Temporal constraint disabled"), ( "unique_channel_identification", "unique(channel_id, identification_number)", "The identification number must be unique per course!", - ) + ), ] @api.depends("sale_order_line_ids.product_uom_qty") @@ -180,18 +192,11 @@ def _compute_used_registrations(self): record.used_registrations = len(record.child_channel_partner_ids) @api.depends("channel_id", "partner_id") - def _compute_invitation(self): - # This sets the url used as hyperlink in the channel invitation email in - # template mail_notification_channel_invite. - # The partner_id is given in the url, as well as a hash based on the partner - # and channel id. + def _compute_invitation_link(self): + res = super()._compute_invitation_link() for record in self: record.invitation_hash = record._get_invitation_hash() - record.invitation_link = ( - f"{record.channel_id.get_base_url()}/slides/{record.channel_id.id}" - f"/invite?invite_partner_id={record.partner_id.id}" - f"&invite_hash={record.invitation_hash}" - ) + return res @api.model_create_multi def create(self, vals_list): @@ -208,57 +213,52 @@ def create(self, vals_list): return super().create(vals_list) def _recompute_completion(self): - if not self.channel_id._is_public_with_key(): + slide_channel_partners = self.filtered(lambda scp: scp.identification_number) + if not slide_channel_partners: return super()._recompute_completion() - identification_number = request.session.get("identification_number", False) - # Obtain progress only from the participation of the user with current password read_group_res = ( self.env["slide.slide.partner"] .sudo() - .read_group( + ._read_group( [ ("channel_id", "in", self.mapped("channel_id").ids), - ("identification_number", "=", identification_number), + ("identification_number", "!=", False), ("completed", "=", True), ("slide_id.is_published", "=", True), ("slide_id.active", "=", True), ], - ["channel_id"], - groupby=["channel_id"], - lazy=False, + ["channel_id", "identification_number"], + aggregates=["__count"], ) ) mapped_data = { - item["channel_id"][0]: item["__count"] for item in read_group_res + (channel.id, identification_number): count + for channel, identification_number, count in read_group_res } - # Filter only shares that have an ID number - for record in self.filtered("identification_number"): - if record.identification_number != identification_number: + for record in slide_channel_partners: + if record.member_status in ("completed", "invited"): continue - completed_slides_count = mapped_data.get(record.channel_id.id, 0) - record.completed_slides_count = completed_slides_count - record.completion = ( + record.completed_slides_count = mapped_data.get( + (record.channel_id.id, record.identification_number), 0 + ) + record.completion = round( 100.0 - if record.completed - else round( - 100.0 - * completed_slides_count - / (record.channel_id.total_slides or 1) - ) + * record.completed_slides_count + / (record.channel_id.total_slides or 1) ) - if ( - not record.completed - and record.channel_id.active - and completed_slides_count >= record.channel_id.total_slides - ): - record.completed = True - - def _get_invitation_hash(self): - # Returns the invitation hash of the attendee, used to access courses - # as invited / joined. - self.ensure_one() - token = (self.partner_id.id, self.channel_id.id) - return tools.hmac(self.env(su=True), "website_slides-channel-invite", token) + if not record.channel_id.active: + continue + if record.completion == 100: + record.member_status = "completed" + elif record.completion == 0: + record.member_status = "joined" + else: + record.member_status = "ongoing" + return super( + SlideChannelPartner, + self.filtered(lambda scp: not scp.child_channel_partner_ids) + - slide_channel_partners, + )._recompute_completion() def _send_confirm_mail(self): self.ensure_one() diff --git a/website_sale_slides_multi_qty/models/slide_slide.py b/website_sale_slides_multi_qty/models/slide_slide.py index 344910b..3f18599 100644 --- a/website_sale_slides_multi_qty/models/slide_slide.py +++ b/website_sale_slides_multi_qty/models/slide_slide.py @@ -10,6 +10,19 @@ class SlideSlidePartner(models.Model): identification_number = fields.Char() + _sql_constraints = [ + ( + "slide_partner_uniq", + "CHECK (true)", + "Constraint disabled: allowing repeated partner on the same slide.", + ), + ( + "unique_slide_identification", + "unique(slide_id, identification_number)", + "The identification number must be unique!", + ), + ] + class SlideSlide(models.Model): _inherit = "slide.slide" @@ -89,16 +102,8 @@ def _action_set_viewed(self, target_partner, quiz_attempts_inc=False): ] ) if quiz_attempts_inc and existing_sudo: - sql.increment_field_skiplock(existing_sudo, "quiz_attempts_count") - SlidePartnerSudo.invalidate_cache( - fnames=["quiz_attempts_count"], ids=existing_sudo.ids - ) - for slide in existing_sudo: - if ( - slide.slide_id.slide_type != "quiz" - or not slide.slide_id.question_ids - ): - slide.slide_id.action_set_completed() + sql.increment_fields_skiplock(existing_sudo, "quiz_attempts_count") + existing_sudo.invalidate_recordset(["quiz_attempts_count"]) new_slides = self_sudo - existing_sudo.mapped("slide_id") return SlidePartnerSudo.create( [ @@ -117,7 +122,7 @@ def _action_set_viewed(self, target_partner, quiz_attempts_inc=False): target_partner, quiz_attempts_inc=quiz_attempts_inc ) - def _action_set_completed(self, target_partner): + def _action_mark_completed(self): if self._is_public_with_key(): invite_partner_id = request.session.get("invite_partner_id") identification_number = request.session.get("identification_number") @@ -150,11 +155,11 @@ def _action_set_completed(self, target_partner): ] ) return True - return super()._action_set_completed(target_partner) + return super()._action_mark_completed() - def _action_set_quiz_done(self): + def _action_set_quiz_done(self, completed=True): points_before = self.env.user.karma - res = super()._action_set_quiz_done() + res = super()._action_set_quiz_done(completed=completed) # If the user is public with password, resets the points to the previous value. if self.env.user._is_public() and any( slide.user_membership_id.identification_number for slide in self @@ -233,4 +238,4 @@ def _apply_ir_rules(self, query, mode="read"): def check_access_rule(self, operation): if self._is_public_with_key(): return - return super(SlideSlide, self).check_access_rule(operation) + return super().check_access_rule(operation) diff --git a/website_sale_slides_multi_qty/pyproject.toml b/website_sale_slides_multi_qty/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/website_sale_slides_multi_qty/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/website_sale_slides_multi_qty/readme/CONFIGURE.md b/website_sale_slides_multi_qty/readme/CONFIGURE.md new file mode 100644 index 0000000..7b5d1cc --- /dev/null +++ b/website_sale_slides_multi_qty/readme/CONFIGURE.md @@ -0,0 +1,7 @@ +To enable this functionality on a course: + +1. Create or edit a course from the backend. +2. Set the **Enrollment Policy** to **On Payment**. + +This ensures that access to the course is restricted to users who have +completed a valid purchase. diff --git a/website_sale_slides_multi_qty/readme/CONTRIBUTORS.md b/website_sale_slides_multi_qty/readme/CONTRIBUTORS.md new file mode 100644 index 0000000..684a32e --- /dev/null +++ b/website_sale_slides_multi_qty/readme/CONTRIBUTORS.md @@ -0,0 +1,3 @@ +- \[Tecnativa\](): + - David Vidal + - Pilar Vargas diff --git a/website_sale_slides_multi_qty/readme/CONTRIBUTORS.rst b/website_sale_slides_multi_qty/readme/CONTRIBUTORS.rst deleted file mode 100644 index d908072..0000000 --- a/website_sale_slides_multi_qty/readme/CONTRIBUTORS.rst +++ /dev/null @@ -1,4 +0,0 @@ -* `Tecnativa `__: - - * David Vidal - * Pilar Vargas diff --git a/website_sale_slides_multi_qty/readme/DESCRIPTION.md b/website_sale_slides_multi_qty/readme/DESCRIPTION.md new file mode 100644 index 0000000..6e3d493 --- /dev/null +++ b/website_sale_slides_multi_qty/readme/DESCRIPTION.md @@ -0,0 +1,4 @@ +This module allows you to order several courses at once. This generates +an invitation link through which other users can request to participate +in the course without having to be registered users. The number of +participants is limited to the number of courses ordered. diff --git a/website_sale_slides_multi_qty/readme/DESCRIPTION.rst b/website_sale_slides_multi_qty/readme/DESCRIPTION.rst deleted file mode 100644 index 8b13789..0000000 --- a/website_sale_slides_multi_qty/readme/DESCRIPTION.rst +++ /dev/null @@ -1 +0,0 @@ - diff --git a/website_sale_slides_multi_qty/readme/USAGE.md b/website_sale_slides_multi_qty/readme/USAGE.md new file mode 100644 index 0000000..98b1d7b --- /dev/null +++ b/website_sale_slides_multi_qty/readme/USAGE.md @@ -0,0 +1,13 @@ +When a user orders a course with a quantity greater than 1, a +participation link will be sent to the customer by email. + +There are two ways to use the participation link: + +- **Registered users** can access the course directly without having to + make a new order. +- **Anonymous users** will be asked to enter identification details, + which will allow them to register and return to the course later. + +Access is limited to the number of places acquired. Once all places have +been used, no additional users will be able to register via the +participation link. diff --git a/website_sale_slides_multi_qty/security/ir.model.access.csv b/website_sale_slides_multi_qty/security/ir.model.access.csv deleted file mode 100644 index 01eabbf..0000000 --- a/website_sale_slides_multi_qty/security/ir.model.access.csv +++ /dev/null @@ -1,2 +0,0 @@ -id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink -access_slide_slide_public,slide.slide public access,model_slide_slide,base.group_public,1,0,0,0 diff --git a/website_sale_slides_multi_qty/static/description/index.html b/website_sale_slides_multi_qty/static/description/index.html index 21066e9..38bf80e 100644 --- a/website_sale_slides_multi_qty/static/description/index.html +++ b/website_sale_slides_multi_qty/static/description/index.html @@ -367,41 +367,72 @@

Website Sale Slides Multi Qty

!! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -!! source digest: sha256:3a794fd5342313c42c608fcbc6bb79937d6bfe85b2257ab195a03dcbfa6c5703 +!! source digest: sha256:8e2129116bdf52aaccd45cfaf84303667132f5defc24cb2c03220b6cf08ba4a7 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! --> -

Beta License: AGPL-3 OCA/e-learning Translate me on Weblate Try me on Runboat

+

Beta License: AGPL-3 OCA/e-learning Translate me on Weblate Try me on Runboat

+

This module allows you to order several courses at once. This generates +an invitation link through which other users can request to participate +in the course without having to be registered users. The number of +participants is limited to the number of courses ordered.

Table of contents

+
+

Configuration

+

To enable this functionality on a course:

+
    +
  1. Create or edit a course from the backend.
  2. +
  3. Set the Enrollment Policy to On Payment.
  4. +
+

This ensures that access to the course is restricted to users who have +completed a valid purchase.

+
+
+

Usage

+

When a user orders a course with a quantity greater than 1, a +participation link will be sent to the customer by email.

+

There are two ways to use the participation link:

+
    +
  • Registered users can access the course directly without having to +make a new order.
  • +
  • Anonymous users will be asked to enter identification details, +which will allow them to register and return to the course later.
  • +
+

Access is limited to the number of places acquired. Once all places have +been used, no additional users will be able to register via the +participation link.

+
-

Bug Tracker

+

Bug Tracker

Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed -feedback.

+feedback.

Do not contact contributors directly about support or help with technical issues.

-

Credits

+

Credits

-

Authors

+

Authors

  • Tecnativa
-

Contributors

+

Contributors

-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association @@ -417,7 +448,7 @@

Maintainers

OCA, or the Odoo Community Association, is a nonprofit organization whose mission is to support the collaborative development of Odoo features and promote its widespread use.

-

This module is part of the OCA/e-learning project on GitHub.

+

This module is part of the OCA/e-learning project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

diff --git a/website_sale_slides_multi_qty/static/src/js/slides_access_form.js b/website_sale_slides_multi_qty/static/src/js/slides_access_form.js index b4a72a8..0b0c862 100644 --- a/website_sale_slides_multi_qty/static/src/js/slides_access_form.js +++ b/website_sale_slides_multi_qty/static/src/js/slides_access_form.js @@ -1,10 +1,11 @@ -odoo.define("website_sale_slides_multi_qty.slide_access_form", function () { +odoo.define("website_sale_slides_multi_qty.slide_access_form", [], function () { "use strict"; const toggle_key_access = $("#toggle_key_access"); const $nameField = $("#slide_channel_partner_name"); const $emailField = $("#slide_channel_partner_email"); const $phoneField = $("#slide_channel_partner_phone"); + const $inputTermsCheckbox = $("#accept_terms"); const $termsCheckbox = $("#terms_and_conditions"); toggle_key_access.on("click", function () { @@ -12,6 +13,7 @@ odoo.define("website_sale_slides_multi_qty.slide_access_form", function () { $emailField.addClass("d-none").prop("required", false); $phoneField.addClass("d-none").prop("required", false); $termsCheckbox.addClass("d-none").prop("required", false); + $inputTermsCheckbox.prop("required", false); toggle_key_access.addClass("d-none"); }); }); diff --git a/website_sale_slides_multi_qty/static/src/js/slides_course_fullscreen_player.esm.js b/website_sale_slides_multi_qty/static/src/js/slides_course_fullscreen_player.esm.js new file mode 100644 index 0000000..a716f8d --- /dev/null +++ b/website_sale_slides_multi_qty/static/src/js/slides_course_fullscreen_player.esm.js @@ -0,0 +1,84 @@ +/** @odoo-module **/ +import {SIZES, utils as uiUtils} from "@web/core/ui/ui_service"; + +import Fullscreen from "@website_slides/js/slides_course_fullscreen_player"; + +Fullscreen.include({ + init: function () { + this._super.apply(this, arguments); + this.isPublicWithKey = false; + this._fetchSessionData().then((isPublicWithKey) => { + this.isPublicWithKey = isPublicWithKey; + }); + this.rpc = this.bindService("rpc"); + }, + /** + * Checks if the user is public with password by calling the method in the backend. + * + * @returns {Promise} + * @private + */ + _fetchSessionData: function () { + return this.rpc("/slides/is_public_with_key") + .then((data) => { + return ( + data.invite_hash && + data.identification_number && + data.invite_partner_id + ); + }) + .catch(() => { + return false; + }); + }, + /** + * Override methods to execute logic for public users with passwords + * @override + * @private + */ + _onChangeSlide: function () { + if (this.isPublicWithKey) { + var self = this; + var slide = this.get("slide"); + self._pushUrlState(); + return this._fetchSlideContent() + .then(function () { + var websiteName = document.title.split(" | ")[1]; // Get the website name from title + document.title = websiteName + ? slide.name + " | " + websiteName + : slide.name; + if (uiUtils.getSize() < SIZES.MD) { + self._toggleSidebar(); // Hide sidebar when small device screen + } + return self._renderSlide(); + }) + .then(function () { + if (slide._autoSetDone) { + // No useless RPC call + if (slide.category === "document") { + // Only set the slide as completed after iFrame is loaded to avoid concurrent execution with 'embedUrl' controller + self.el + .querySelector("iframe.o_wslides_iframe_viewer") + .addEventListener("load", () => + self._toggleSlideCompleted(slide) + ); + } else { + return self._toggleSlideCompleted(slide); + } + } + }); + } + return this._super.apply(this, arguments); + }, + // /** + // * @override + // * @private + // */ + // _onSlideToComplete: function (ev) { + // if (this.isPublicWithKey) { + // var slideId = ev.data.id; + // this._setCompleted(slideId); + // } + // return this._super.apply(this, arguments); + // }, +}); diff --git a/website_sale_slides_multi_qty/static/src/js/slides_course_fullscreen_player.js b/website_sale_slides_multi_qty/static/src/js/slides_course_fullscreen_player.js deleted file mode 100644 index 00c1cc3..0000000 --- a/website_sale_slides_multi_qty/static/src/js/slides_course_fullscreen_player.js +++ /dev/null @@ -1,89 +0,0 @@ -odoo.define("website_sale_slides_multi_qty.fullscreen", function (require) { - "use strict"; - - var config = require("web.config"); - var Fullscreen = require("@website_slides/js/slides_course_fullscreen_player")[ - Symbol.for("default") - ]; - - Fullscreen.include({ - init: function () { - this._super.apply(this, arguments); - this.isPublicWithKey = false; - this._fetchSessionData().then((isPublicWithKey) => { - this.isPublicWithKey = isPublicWithKey; - }); - }, - /** - * Checks if the user is public with password by calling the method in the backend. - * - * @private - */ - _fetchSessionData: function () { - return this._rpc({ - route: "/slides/is_public_with_key", - }) - .then((data) => { - return ( - data.invite_hash && - data.identification_number && - data.invite_partner_id - ); - }) - .catch(() => { - return false; - }); - }, - /** - * Override methods to execute logic for public users with passwords - * @override - * @private - */ - _onChangeSlide: function () { - if (this.isPublicWithKey) { - var self = this; - var slide = this.get("slide"); - self._pushUrlState(); - return this._fetchSlideContent() - .then(function () { - // Render content - var websiteName = document.title.split(" | ")[1]; // Get the website name from title - document.title = websiteName - ? slide.name + " | " + websiteName - : slide.name; - if (config.device.size_class < config.device.SIZES.MD) { - self._toggleSidebar(); // Hide sidebar when small device screen - } - return self._renderSlide(); - }) - .then(function () { - if (slide._autoSetDone) { - // No useless RPC call - if (["document", "presentation"].includes(slide.type)) { - // Only set the slide as completed after iFrame is loaded to avoid concurrent execution with 'embedUrl' controller - self.el - .querySelector("iframe.o_wslides_iframe_viewer") - .addEventListener("load", () => - self._setCompleted(slide.id) - ); - } else { - return self._setCompleted(slide.id); - } - } - }); - } - return this._super.apply(this, arguments); - }, - /** - * @override - * @private - */ - _onSlideToComplete: function (ev) { - if (this.isPublicWithKey) { - var slideId = ev.data.id; - this._setCompleted(slideId); - } - return this._super.apply(this, arguments); - }, - }); -}); diff --git a/website_sale_slides_multi_qty/static/tests/tours/website_sale_slides_multi_qty.esm.js b/website_sale_slides_multi_qty/static/tests/tours/website_sale_slides_multi_qty.esm.js new file mode 100644 index 0000000..39c20a0 --- /dev/null +++ b/website_sale_slides_multi_qty/static/tests/tours/website_sale_slides_multi_qty.esm.js @@ -0,0 +1,36 @@ +/** @odoo-module */ +// Copyright 2025 Tecnativa - Pilar Vargas +/* License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). */ + +import {registry} from "@web/core/registry"; + +registry.category("web_tour.tours").add("website_sale_slides_order_line_multi_qty", { + test: true, + url: "/slides", + steps: () => [ + { + content: "Select the course", + trigger: 'a:contains("Test Channel")', + }, + { + content: "Add a course to the cart", + trigger: "a#add_to_cart", + }, + { + content: "Add another course to the cart", + trigger: "a#add_to_cart", + }, + { + content: "Add one more course to the cart", + trigger: "a#add_to_cart", + }, + { + content: "Go to cart", + trigger: "a[href='/shop/cart']", + extra_trigger: "sup.my_cart_quantity:contains('3')", + }, + { + trigger: ".btn:contains('Checkout')", + }, + ], +}); diff --git a/website_sale_slides_multi_qty/static/tests/tours/website_sale_slides_order_line_multi_qty_join_without_user.esm.js b/website_sale_slides_multi_qty/static/tests/tours/website_sale_slides_order_line_multi_qty_join_without_user.esm.js new file mode 100644 index 0000000..4f286c3 --- /dev/null +++ b/website_sale_slides_multi_qty/static/tests/tours/website_sale_slides_order_line_multi_qty_join_without_user.esm.js @@ -0,0 +1,30 @@ +/** @odoo-module */ +// Copyright 2025 Tecnativa - Pilar Vargas +/* License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). */ + +import {registry} from "@web/core/registry"; + +registry + .category("web_tour.tours") + .add("website_sale_slides_order_line_multi_qty_join_without_user", { + test: true, + steps: () => [ + { + content: "It is already joined.", + trigger: "#toggle_key_access", + }, + { + content: "Fill in the identification number", + trigger: 'input[name="identification_number"]', + run: "text BE0477472701", + }, + { + content: "Submit", + trigger: "#join_course_submit", + }, + { + content: "It has successfully accessed", + trigger: ".o_wslides_js_channel_unsubscribe", + }, + ], + }); diff --git a/website_sale_slides_multi_qty/static/tests/tours/website_sale_slides_order_line_multi_qty_register_without_user.esm.js b/website_sale_slides_multi_qty/static/tests/tours/website_sale_slides_order_line_multi_qty_register_without_user.esm.js new file mode 100644 index 0000000..41b58d8 --- /dev/null +++ b/website_sale_slides_multi_qty/static/tests/tours/website_sale_slides_order_line_multi_qty_register_without_user.esm.js @@ -0,0 +1,46 @@ +/** @odoo-module */ +// Copyright 2025 Tecnativa - Pilar Vargas +/* License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). */ + +import {registry} from "@web/core/registry"; + +registry + .category("web_tour.tours") + .add("website_sale_slides_order_line_multi_qty_register_without_user", { + test: true, + steps: () => [ + { + content: "Fill in the name", + trigger: "#slide_channel_partner_name", + run: "text My Test User", + }, + { + content: "Fill in the email", + trigger: "#slide_channel_partner_email", + run: "text testuser@example.com", + }, + { + content: "Fill in the phone", + trigger: "#slide_channel_partner_phone", + run: "text 123456789", + }, + { + content: "Fill in the identification number", + trigger: 'input[name="identification_number"]', + run: "text BE0477472701", + }, + { + content: "We accept the terms", + trigger: "#accept_terms", + run: "click", + }, + { + content: "Submit", + trigger: "#join_course_submit", + }, + { + content: "It has been successfully enrolled.", + trigger: ".o_wslides_js_channel_unsubscribe", + }, + ], + }); diff --git a/website_sale_slides_multi_qty/tests/__init__.py b/website_sale_slides_multi_qty/tests/__init__.py new file mode 100644 index 0000000..efdd209 --- /dev/null +++ b/website_sale_slides_multi_qty/tests/__init__.py @@ -0,0 +1 @@ +from . import test_website_sale_slides_multi_qty diff --git a/website_sale_slides_multi_qty/tests/test_website_sale_slides_multi_qty.py b/website_sale_slides_multi_qty/tests/test_website_sale_slides_multi_qty.py new file mode 100644 index 0000000..8c4b5a2 --- /dev/null +++ b/website_sale_slides_multi_qty/tests/test_website_sale_slides_multi_qty.py @@ -0,0 +1,99 @@ +# Copyright 2025 Tecnativa - Pilar Vargas + +from odoo import Command +from odoo.tests import HttpCase, tagged + +from odoo.addons.website_slides.tests import common + + +@tagged("post_install", "-at_install") +class TestWebsiteSaleSlidesMultiQty(common.SlidesCase, HttpCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course_product = cls.env["product.product"].create( + { + "name": "Course Product", + "standard_price": 100, + "list_price": 150, + "type": "service", + "invoice_policy": "order", + "is_published": True, + } + ) + cls.channel.write({"enroll": "payment", "product_id": cls.course_product.id}) + cls.sale_order = cls.env["sale.order"].create( + { + "partner_id": cls.customer.id, + "order_line": [ + Command.create( + { + "name": cls.course_product.name, + "product_id": cls.course_product.id, + "product_uom_qty": 3, + "price_unit": cls.course_product.list_price, + }, + ) + ], + } + ) + + def test_website_sale_slides_order_line_multi_qty(self): + self.start_tour( + "/slides", + "website_sale_slides_order_line_multi_qty", + login="portal", + step_delay=1000, + ) + + def test_website_sale_slides_multi_qty_send_mail(self): + self.sale_order.action_confirm() + mail = self.env["mail.mail"].search( + [ + ("model", "=", "slide.channel.partner"), + ("res_id", "!=", False), + ("subject", "ilike", "Your access to"), + ("recipient_ids", "in", [self.customer.id]), + ] + ) + self.assertTrue(mail) + + def test_website_sale_slides_multi_qty_participation(self): + self.sale_order.action_confirm() + course_access = self.channel.channel_partner_ids.filtered( + lambda x: x.partner_id == self.customer + ) + self.assertFalse(course_access.parent_id) + self.assertFalse(course_access.child_channel_partner_ids) + self.assertEqual(course_access.available_registrations, 3) + self.assertEqual(course_access.used_registrations, 0) + self.assertTrue(course_access.invitation_hash) + self.assertTrue(course_access.invitation_link) + self.assertEqual(course_access.slide_channel_partner_name, self.customer.name) + self.assertEqual(course_access.slide_channel_partner_email, self.customer.email) + self.assertEqual( + course_access.slide_channel_partner_phone, self.customer.mobile + ) + self.assertFalse(course_access.identification_number) + self.assertFalse(course_access.is_public_slide_channel_partner) + + def test_website_sale_slides_multi_qty_join_without_user(self): + self.sale_order.action_confirm() + course_access = self.channel.channel_partner_ids.filtered( + lambda x: x.partner_id == self.customer + ) + url = ( + f"/slides/{self.channel.id}/invite" + f"?invite_partner_id={self.customer.id}" + f"&invite_hash={course_access._get_invitation_hash()}" + ) + self.start_tour( + url, + "website_sale_slides_order_line_multi_qty_register_without_user", + step_delay=1000, + ) + self.start_tour( + url, + "website_sale_slides_order_line_multi_qty_join_without_user", + step_delay=1000, + ) diff --git a/website_sale_slides_multi_qty/views/website_slides_templates.xml b/website_sale_slides_multi_qty/views/website_slides_templates.xml index cca6898..70f0fba 100644 --- a/website_sale_slides_multi_qty/views/website_slides_templates.xml +++ b/website_sale_slides_multi_qty/views/website_slides_templates.xml @@ -9,7 +9,7 @@ not can_enroll - + not can_enroll diff --git a/website_sale_slides_multi_qty/views/website_slides_templates_course.xml b/website_sale_slides_multi_qty/views/website_slides_templates_course.xml index ad7608c..7a98bfa 100644 --- a/website_sale_slides_multi_qty/views/website_slides_templates_course.xml +++ b/website_sale_slides_multi_qty/views/website_slides_templates_course.xml @@ -2,8 +2,8 @@ -