diff --git a/website_sale_slides_multi_qty/README.rst b/website_sale_slides_multi_qty/README.rst new file mode 100644 index 0000000..e04e35f --- /dev/null +++ b/website_sale_slides_multi_qty/README.rst @@ -0,0 +1,110 @@ +============================= +Website Sale Slides Multi Qty +============================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:8e2129116bdf52aaccd45cfaf84303667132f5defc24cb2c03220b6cf08ba4a7 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/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-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=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 `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Tecnativa + +Contributors +------------ + +- [Tecnativa](https://www.tecnativa.com/): + + - 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..859b704 --- /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": "17.0.1.0.0", + "category": "Website/eLearning", + "author": "Tecnativa, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/e-learning", + "license": "AGPL-3", + "depends": ["website_sale_slides_order_line_link", "base_vat"], + "data": [ + "data/mail_template_data.xml", + "views/slide_channel_partner_views.xml", + "views/slide_channel_views.xml", + "views/website_slides_templates_course.xml", + "views/website_slides_templates_homepage.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/__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..1388b9e --- /dev/null +++ b/website_sale_slides_multi_qty/controllers/main.py @@ -0,0 +1,554 @@ +# Copyright 2025 Tecnativa - Pilar Vargas +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +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 + + +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) + # 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): + 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]) + 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=False, + channel_id=False, + category=None, + category_id=False, + tag=None, + page=1, + slide_category=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.id != channel_id: + # If there is no valid participation, we delete the session data. + self._delete_session_data() + res = super().channel( + channel=channel, + channel_id=channel_id, + category=category, + category_id=category_id, + tag=tag, + page=page, + 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( + (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: + 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() + 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() + # 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 == request.env.user.partner_id + ) + 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}") + # 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}" + ) + return res + + 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_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), + 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 + ), + ) + + @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() + 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._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 + + @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_has_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_mark_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_has_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.member_status == "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, + "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 new file mode 100644 index 0000000..e41667b --- /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..9228a36 --- /dev/null +++ b/website_sale_slides_multi_qty/i18n/es.po @@ -0,0 +1,310 @@ +# 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-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" +"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" +" 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" +" 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.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 "Curso" + +#. module: website_sale_slides_multi_qty +#: model_terms:ir.ui.view,arch_db:website_sale_slides_multi_qty.course_join +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_join +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_join +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_join +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_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 "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 +#. odoo-python +#: code:addons/website_sale_slides_multi_qty/controllers/main.py:0 +#, fuzzy, python-format +#| msgid "Identification Number" +msgid "Invalid 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_join +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 +#. odoo-python +#: 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_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" +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_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 "El numero de identificación debe ser único!" + +#. module: website_sale_slides_multi_qty +#: model:ir.model.constraint,message:website_sale_slides_multi_qty.constraint_slide_slide_partner_unique_slide_identification +#, fuzzy +#| msgid "The identification number must be unique per course!" +msgid "The identification number must be unique!" +msgstr "El numero de identificación debe ser único!" + +#. 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" +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" + +#. 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/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..baeb6b0 --- /dev/null +++ b/website_sale_slides_multi_qty/i18n/website_sale_slides_multi_qty.pot @@ -0,0 +1,237 @@ +# 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 17.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-09-24 09:53+0000\n" +"PO-Revision-Date: 2025-09-24 09:53+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" +" 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.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_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_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_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_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_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 "" + +#. 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 +#. 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" +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_join +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 +#. odoo-python +#: 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_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" +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_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" +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 "" + +#. 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 new file mode 100644 index 0000000..a2c75d5 --- /dev/null +++ b/website_sale_slides_multi_qty/models/__init__.py @@ -0,0 +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 new file mode 100644 index 0000000..96b540a --- /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 +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_membership_values(self): + res = super()._compute_membership_values() + 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 = 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) + ) + 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 _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 = {} + 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_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 + ): + 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_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") + identification_number = fields.Char( + help="User's personal identification number", + ) + 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") + 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_link(self): + res = super()._compute_invitation_link() + for record in self: + record.invitation_hash = record._get_invitation_hash() + return res + + @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): + slide_channel_partners = self.filtered(lambda scp: scp.identification_number) + if not slide_channel_partners: + return super()._recompute_completion() + read_group_res = ( + self.env["slide.slide.partner"] + .sudo() + ._read_group( + [ + ("channel_id", "in", self.mapped("channel_id").ids), + ("identification_number", "!=", False), + ("completed", "=", True), + ("slide_id.is_published", "=", True), + ("slide_id.active", "=", True), + ], + ["channel_id", "identification_number"], + aggregates=["__count"], + ) + ) + mapped_data = { + (channel.id, identification_number): count + for channel, identification_number, count in read_group_res + } + for record in slide_channel_partners: + if record.member_status in ("completed", "invited"): + continue + record.completed_slides_count = mapped_data.get( + (record.channel_id.id, record.identification_number), 0 + ) + record.completion = round( + 100.0 + * record.completed_slides_count + / (record.channel_id.total_slides or 1) + ) + 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() + 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..3f18599 --- /dev/null +++ b/website_sale_slides_multi_qty/models/slide_slide.py @@ -0,0 +1,241 @@ +# 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() + + _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" + + 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_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( + [ + { + "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_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") + 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_mark_completed() + + def _action_set_quiz_done(self, completed=True): + points_before = self.env.user.karma + 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 + ): + 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().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/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/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/static/description/icon.png b/website_sale_slides_multi_qty/static/description/icon.png new file mode 100644 index 0000000..3a0328b Binary files /dev/null and b/website_sale_slides_multi_qty/static/description/icon.png differ 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..38bf80e --- /dev/null +++ b/website_sale_slides_multi_qty/static/description/index.html @@ -0,0 +1,457 @@ + + + + + +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

+

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

+

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

+ +
+
+

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..0b0c862 --- /dev/null +++ b/website_sale_slides_multi_qty/static/src/js/slides_access_form.js @@ -0,0 +1,19 @@ +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 () { + $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); + $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/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/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..70f0fba --- /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..7a98bfa --- /dev/null +++ b/website_sale_slides_multi_qty/views/website_slides_templates_course.xml @@ -0,0 +1,108 @@ + + + + + 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 @@ + + + + +