From 79d9f31db79f6bd3194fbbb9d36fe6e3492224f4 Mon Sep 17 00:00:00 2001 From: Jannat Patel Date: Wed, 2 Aug 2023 18:08:42 +0530 Subject: [PATCH 1/8] feat: paid courses --- lms/lms/doctype/lms_course/lms_course.js | 15 ++-- lms/lms/doctype/lms_course/lms_course.json | 79 +++++------------ .../doctype/lms_settings/lms_settings.json | 51 ++++++++--- lms/lms/widgets/CourseCard.html | 30 ++++--- lms/lms/widgets/NoPreviewModal.html | 2 +- lms/patches.txt | 3 +- .../v1_0/paid_certificate_to_paid_course.py | 20 +++++ lms/public/css/style.css | 13 ++- lms/public/js/common_functions.js | 6 +- lms/templates/reviews_cta.html | 2 +- lms/www/batch/learn.html | 2 +- lms/www/courses/course.html | 85 ++----------------- lms/www/courses/course.py | 12 +-- lms/www/courses/index.py | 5 +- 14 files changed, 138 insertions(+), 187 deletions(-) create mode 100644 lms/patches/v1_0/paid_certificate_to_paid_course.py diff --git a/lms/lms/doctype/lms_course/lms_course.js b/lms/lms/doctype/lms_course/lms_course.js index 07bd2d30..71c6be3d 100644 --- a/lms/lms/doctype/lms_course/lms_course.js +++ b/lms/lms/doctype/lms_course/lms_course.js @@ -11,14 +11,6 @@ frappe.ui.form.on("LMS Course", { }; }); - frm.set_query("instructor", "instructors", function () { - return { - filters: { - ignore_user_type: 1, - }, - }; - }); - frm.set_query("course", "related_courses", function () { return { filters: { @@ -29,5 +21,12 @@ frappe.ui.form.on("LMS Course", { }, refresh: (frm) => { frm.add_web_link(`/courses/${frm.doc.name}`, "See on Website"); + + if (!frm.doc.currency) + frappe.db + .get_single_value("LMS Settings", "default_currency") + .then((value) => { + frm.set_value("currency", value); + }); }, }); diff --git a/lms/lms/doctype/lms_course/lms_course.json b/lms/lms/doctype/lms_course/lms_course.json index 11e45195..ba362ff7 100644 --- a/lms/lms/doctype/lms_course/lms_course.json +++ b/lms/lms/doctype/lms_course/lms_course.json @@ -32,19 +32,18 @@ "description", "chapters", "related_courses", + "pricing_section", + "paid_course", + "currency", + "course_price", "certification_section", "enable_certification", "expiry", - "section_break_23", + "max_attempts", + "column_break_rxww", "grant_certificate_after", "evaluator", - "column_break_26", - "max_attempts", - "duration", - "pricing_section", - "paid_certificate", - "currency", - "price_certificate" + "duration" ], "fields": [ { @@ -170,13 +169,6 @@ "fieldname": "column_break_12", "fieldtype": "Column Break" }, - { - "default": "0", - "depends_on": "enable_certification", - "fieldname": "paid_certificate", - "fieldtype": "Check", - "label": "Paid Certificate" - }, { "depends_on": "enable_certification", "fieldname": "grant_certificate_after", @@ -193,20 +185,12 @@ "options": "Course Evaluator" }, { - "depends_on": "eval: doc.grant_certificate_after == \"Evaluation\"", "fieldname": "pricing_section", "fieldtype": "Section Break", "label": "Pricing" }, { - "depends_on": "paid_certificate", - "fieldname": "price_certificate", - "fieldtype": "Currency", - "label": "Certificate Price", - "mandatory_depends_on": "paid_certificate" - }, - { - "depends_on": "paid_certificate", + "depends_on": "paid_course", "fieldname": "currency", "fieldtype": "Link", "label": "Currency", @@ -228,11 +212,21 @@ "options": "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12" }, { - "fieldname": "section_break_23", - "fieldtype": "Section Break" + "default": "0", + "fieldname": "paid_course", + "fieldtype": "Check", + "label": "Paid Course" }, { - "fieldname": "column_break_26", + "depends_on": "paid_course", + "fieldname": "course_price", + "fieldtype": "Currency", + "label": "Course Price", + "option": "currency", + "mandatory_depends_on": "paid_certificate" + }, + { + "fieldname": "column_break_rxww", "fieldtype": "Column Break" } ], @@ -260,39 +254,12 @@ } ], "make_attachments_public": 1, - "modified": "2023-05-11 17:08:19.763405", + "modified": "2023-08-02 12:07:26.354119", "modified_by": "Administrator", "module": "LMS", "name": "LMS Course", "owner": "Administrator", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "select": 1, - "share": 1, - "write": 1 - }, - { - "create": 1, - "email": 1, - "export": 1, - "if_owner": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "All", - "select": 1, - "share": 1, - "write": 1 - } - ], + "permissions": [], "search_fields": "title", "show_title_field_in_link": 1, "sort_field": "creation", diff --git a/lms/lms/doctype/lms_settings/lms_settings.json b/lms/lms/doctype/lms_settings/lms_settings.json index f3ed9073..132cfb5d 100644 --- a/lms/lms/doctype/lms_settings/lms_settings.json +++ b/lms/lms/doctype/lms_settings/lms_settings.json @@ -6,16 +6,21 @@ "engine": "InnoDB", "field_order": [ "default_home", - "send_calendar_invite_for_evaluations", - "allow_student_progress", - "column_break_zdel", - "is_onboarding_complete", "force_profile_completion", - "section_break_szgq", - "search_placeholder", - "portal_course_creation", - "column_break_2", + "is_onboarding_complete", + "column_break_zdel", "livecode_url", + "course_settings_section", + "search_placeholder", + "column_break_iqxy", + "portal_course_creation", + "section_break_szgq", + "send_calendar_invite_for_evaluations", + "column_break_2", + "allow_student_progress", + "payment_section", + "default_currency", + "column_break_cfcv", "signup_settings_tab", "signup_settings_section", "terms_of_use", @@ -37,6 +42,7 @@ "default": "https://livecode.dev.fossunited.org", "fieldname": "livecode_url", "fieldtype": "Data", + "hidden": 1, "label": "LiveCode URL" }, { @@ -163,7 +169,8 @@ }, { "fieldname": "section_break_szgq", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "label": "Class Settings" }, { "fieldname": "signup_settings_tab", @@ -180,12 +187,36 @@ "fieldname": "allow_student_progress", "fieldtype": "Check", "label": "Allow students to see each others progress in class" + }, + { + "fieldname": "payment_section", + "fieldtype": "Section Break", + "label": "Payment" + }, + { + "fieldname": "default_currency", + "fieldtype": "Link", + "label": "Default Currency", + "options": "Currency" + }, + { + "fieldname": "column_break_cfcv", + "fieldtype": "Column Break" + }, + { + "fieldname": "course_settings_section", + "fieldtype": "Section Break", + "label": "Course Settings" + }, + { + "fieldname": "column_break_iqxy", + "fieldtype": "Column Break" } ], "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2023-04-17 12:54:44.706101", + "modified": "2023-08-02 12:30:50.897156", "modified_by": "Administrator", "module": "LMS", "name": "LMS Settings", diff --git a/lms/lms/widgets/CourseCard.html b/lms/lms/widgets/CourseCard.html index 486471c9..e050ec0e 100644 --- a/lms/lms/widgets/CourseCard.html +++ b/lms/lms/widgets/CourseCard.html @@ -59,19 +59,12 @@ {{ frappe.utils.flt(avg_rating, frappe.get_system_settings("float_precision") or 3) }} {% endif %} - - {% if course.paid_certificate %} -
- - - - - {{ format_amount(course.price_certificate, course.currency) }} - -
- {% endif %} -
{{ course.title }}
+ +
+ {{ course.title }} +
+
{{ course.short_introduction }}
@@ -87,7 +80,8 @@ {% endif %} {% if read_only %} diff --git a/lms/lms/widgets/NoPreviewModal.html b/lms/lms/widgets/NoPreviewModal.html index 0d9c5767..c6f06aa5 100644 --- a/lms/lms/widgets/NoPreviewModal.html +++ b/lms/lms/widgets/NoPreviewModal.html @@ -30,7 +30,7 @@ {{ _("Notify me when available") }} {% elif show_start_learing_cta(course, membership) %} - {% endif %} diff --git a/lms/patches.txt b/lms/patches.txt index f0d194df..bcabe386 100644 --- a/lms/patches.txt +++ b/lms/patches.txt @@ -60,4 +60,5 @@ lms.patches.v1_0.create_class_evaluator_role execute:frappe.permissions.reset_perms("LMS Class") execute:frappe.permissions.reset_perms("Course Evaluator") execute:frappe.permissions.reset_perms("LMS Certificate Request") -execute:frappe.permissions.reset_perms("LMS Certificate Evaluation") \ No newline at end of file +execute:frappe.permissions.reset_perms("LMS Certificate Evaluation") +lms.patches.v1_0.paid_certificate_to_paid_course \ No newline at end of file diff --git a/lms/patches/v1_0/paid_certificate_to_paid_course.py b/lms/patches/v1_0/paid_certificate_to_paid_course.py new file mode 100644 index 00000000..88ec708b --- /dev/null +++ b/lms/patches/v1_0/paid_certificate_to_paid_course.py @@ -0,0 +1,20 @@ +import frappe + + +def execute(): + courses = frappe.get_all( + "LMS Course", + {"paid_certificate": ["is", "set"]}, + ["name", "price_certificate", "currency"], + ) + + for course in courses: + frappe.db.set_value( + "LMS Course", + course.name, + { + "paid_course": 1, + "course_price": course.price_certificate, + "currency": course.currency, + }, + ) diff --git a/lms/public/css/style.css b/lms/public/css/style.css index 7037a6f6..4c8bcf68 100644 --- a/lms/public/css/style.css +++ b/lms/public/css/style.css @@ -414,11 +414,13 @@ input[type=checkbox] { } .course-card-footer { - margin-top: auto; + display: flex; + justify-content: space-between; + margin-top: auto; } -.course-card-footer .avatar-group { - display: inherit; +.course-price { + font-weight: 700; } .view-course-link { @@ -551,6 +553,11 @@ input[type=checkbox] { } } +.course-card-instructors { + display: flex; + align-items: center; +} + .course-card-wide-content { display: flex; flex-direction: column; diff --git a/lms/public/js/common_functions.js b/lms/public/js/common_functions.js index 2eeb43ae..b39acd21 100644 --- a/lms/public/js/common_functions.js +++ b/lms/public/js/common_functions.js @@ -2,8 +2,8 @@ frappe.ready(() => { setup_file_size(); pin_header(); - $(".join-batch").click((e) => { - join_course(e); + $(".enroll-in-course").click((e) => { + enroll_in_course(e); }); $(".notify-me").click((e) => { @@ -68,7 +68,7 @@ const file_size = (value) => { return value; }; -const join_course = (e) => { +const enroll_in_course = (e) => { e.preventDefault(); let course = $(e.currentTarget).attr("data-course"); if (frappe.session.user == "Guest") { diff --git a/lms/templates/reviews_cta.html b/lms/templates/reviews_cta.html index 163e1f6c..73bd63a7 100644 --- a/lms/templates/reviews_cta.html +++ b/lms/templates/reviews_cta.html @@ -7,7 +7,7 @@ {{ _("Write a review") }} {% elif show_start_learing_cta(course, membership) %} -
+
{{ _("Start Learning") }}
{% endif %} diff --git a/lms/www/batch/learn.html b/lms/www/batch/learn.html index 23003702..edc10f04 100644 --- a/lms/www/batch/learn.html +++ b/lms/www/batch/learn.html @@ -135,7 +135,7 @@ {{ render_html(lesson) }} {% else %} - {% set course_link = "" + _('here') + "" %} + {% set course_link = "" + _('here') + "" %}
{{ _("There is no preview available for this lesson. Please join the course to access it. diff --git a/lms/www/courses/course.html b/lms/www/courses/course.html index c969dd2e..c5ee4d8d 100644 --- a/lms/www/courses/course.html +++ b/lms/www/courses/course.html @@ -15,7 +15,7 @@ {{ Description(course) }} {{ widgets.CourseOutline(course=course, membership=membership, is_user_interested=is_user_interested) }} {% if course.status == "Approved" and not frappe.utils.cint(course.upcoming) %} - {% include "lms/templates/reviews.html" %} + {% include "lms/templates/reviews.html" %} {% endif %}
@@ -136,18 +136,8 @@ {{ get_lessons(course.name, None, False) }} {{ _("Lessons") }} - {% if course.enable_certification %} -
- - - - {{ _("Get Certified") }} -
- {% endif %} - - {{ SlotModal(course) }} {% endmacro %} @@ -231,8 +221,13 @@ {{ _("Continue Learning") }} + {% elif course.paid_course %} + + {{ _("Buy This Course") }} + + {% elif show_start_learing_cta(course, membership) %} -
+
{{ _("Start Learning") }}
{% endif %} @@ -245,11 +240,6 @@ {{ _("Get Certificate") }} - {% elif eligible_for_evaluation %} - - {{ _("Apply for Certificate") }} - - {% elif course.grant_certificate_after == "Completion" and progress == 100 %}
{{ _("Get Certificate") }} @@ -279,16 +269,6 @@ {{ _("You have opted to be notified for this course. You will receive an email when the course becomes available.") }}
- {% if certificate_request and not certificate %} -

- - {{ _("Evaluation On: ") }} - - {{ _("{0} at {1}").format(frappe.utils.format_date(certificate_request.date, "medium"), - frappe.utils.format_time(certificate_request.start_time, "short")) }} -

- {% endif %} - {% if course.status == "Under Review" and is_instructor(course.name) %}
{{ _("This course is currently under review. Once the review is complete, the System Admins will publish it on the website.") }} @@ -301,54 +281,3 @@

{% endif %} {% endmacro %} - - - -{% macro SlotModal(course) %} - -{% endmacro %} - diff --git a/lms/www/courses/course.py b/lms/www/courses/course.py index 3af669ed..b8e3f5ac 100644 --- a/lms/www/courses/course.py +++ b/lms/www/courses/course.py @@ -52,13 +52,10 @@ def set_course_context(context, course_name): "disable_self_learning", "status", "video_link", - "enable_certification", - "grant_certificate_after", - "paid_certificate", - "price_certificate", + "paid_course", + "course_price", "currency", - "max_attempts", - "duration", + "grant_certificate_after", ], as_dict=True, ) @@ -79,7 +76,7 @@ def set_course_context(context, course_name): frappe.db.get_value( "LMS Course", csr.course, - ["name", "upcoming", "title", "image", "enable_certification"], + ["name", "upcoming", "title", "image"], as_dict=True, ) ) @@ -94,7 +91,6 @@ def set_course_context(context, course_name): context.certificate = is_certified(course.name) eval_details = get_evaluation_details(course.name) context.eligible_for_evaluation = eval_details.eligible - context.certificate_request = eval_details.request context.no_of_attempts = eval_details.no_of_attempts if context.course.upcoming: context.is_user_interested = get_user_interest(context.course.name) diff --git a/lms/www/courses/index.py b/lms/www/courses/index.py index 844d485f..006c7cc3 100644 --- a/lms/www/courses/index.py +++ b/lms/www/courses/index.py @@ -45,9 +45,8 @@ def get_courses(): "title", "short_introduction", "image", - "enable_certification", - "paid_certificate", - "price_certificate", + "paid_course", + "course_price", "currency", ], ) From c284e95dc8ce659f8da83bf2be0a631a34e5f3b4 Mon Sep 17 00:00:00 2001 From: Jannat Patel Date: Thu, 3 Aug 2023 11:46:59 +0530 Subject: [PATCH 2/8] feat: validate razorpay data --- lms/lms/doctype/lms_course/lms_course.py | 43 ++++++ .../doctype/lms_settings/lms_settings.json | 14 +- lms/www/courses/course.html | 4 +- lms/www/courses/course.js | 125 ++---------------- 4 files changed, 69 insertions(+), 117 deletions(-) diff --git a/lms/lms/doctype/lms_course/lms_course.py b/lms/lms/doctype/lms_course/lms_course.py index 057ab74c..d40a9adc 100644 --- a/lms/lms/doctype/lms_course/lms_course.py +++ b/lms/lms/doctype/lms_course/lms_course.py @@ -9,6 +9,8 @@ from frappe.utils import cint from frappe.utils.telemetry import capture from lms.lms.utils import get_chapters, can_create_courses from ...utils import generate_slug, validate_image +from frappe import _ +import razorpay class LMSCourse(Document): @@ -354,3 +356,44 @@ def reorder_chapter(chapter_array): "idx": chapter_array.index(chap) + 1, }, ) + + +@frappe.whitelist() +def buy_course(course): + razorpay_key = frappe.get_single_value("LMS Settings", "razorpay_key") + razorpay_secret = frappe.get_single_value("LMS Settings", "razorpay_secret") + + if not razorpay_key and not razorpay_secret: + frappe.throw( + _( + "There is a problem with the payment gateway. Please contact the Administrator to proceed." + ) + ) + + create_payment_link(razorpay_key, razorpay_secret, course) + + +def create_payment_link(razorpy_key, razorpay_secret, course): + client = razorpay.Client(auth=(razorpy_key, razorpay_secret)) + + course_details = frappe.db.get_value( + "LMS Course", course, ["title", "price_course", "currency"], as_dict=True + ) + user_details = frappe.db.get_value( + "User", frappe.session.user, ["full_name", "email"], as_dict=True + ) + + client.payment_link.create( + { + "amount": course_details.price_course, + "currency": course_details.currency, + "description": "Complete your course purchase", + "customer": { + "name": user_details.full_name, + "email": user_details.email, + }, + "notify": {"sms": True, "email": True}, + "callback_url": "/api/method/lms.lms.doctype.lms_course.lms_course.verify_payment", + "callback_method": "get", + } + ) diff --git a/lms/lms/doctype/lms_settings/lms_settings.json b/lms/lms/doctype/lms_settings/lms_settings.json index 132cfb5d..4c8b4d35 100644 --- a/lms/lms/doctype/lms_settings/lms_settings.json +++ b/lms/lms/doctype/lms_settings/lms_settings.json @@ -19,8 +19,10 @@ "column_break_2", "allow_student_progress", "payment_section", + "razorpay_key", "default_currency", "column_break_cfcv", + "razorpay_secret", "signup_settings_tab", "signup_settings_section", "terms_of_use", @@ -211,12 +213,22 @@ { "fieldname": "column_break_iqxy", "fieldtype": "Column Break" + }, + { + "fieldname": "razorpay_key", + "fieldtype": "Data", + "label": "Razorpay Key" + }, + { + "fieldname": "razorpay_secret", + "fieldtype": "Password", + "label": "Razorpay Secret" } ], "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2023-08-02 12:30:50.897156", + "modified": "2023-08-02 18:59:01.267732", "modified_by": "Administrator", "module": "LMS", "name": "LMS Settings", diff --git a/lms/www/courses/course.html b/lms/www/courses/course.html index c5ee4d8d..6035ea88 100644 --- a/lms/www/courses/course.html +++ b/lms/www/courses/course.html @@ -222,9 +222,9 @@ {% elif course.paid_course %} - + {% elif show_start_learing_cta(course, membership) %}
diff --git a/lms/www/courses/course.js b/lms/www/courses/course.js index 9196bd44..1f78b87b 100644 --- a/lms/www/courses/course.js +++ b/lms/www/courses/course.js @@ -21,24 +21,17 @@ frappe.ready(() => { submit_for_review(e); }); - $("#apply-certificate").click((e) => { - apply_cetificate(e); - }); - - $("#slot-date").on("change", (e) => { - display_slots(e); - }); - - $("#submit-slot").click((e) => { - submit_slot(e); - }); - - $(".close-slot-modal").click((e) => { - close_slot_modal(e); - }); - - $(document).on("click", ".slot", (e) => { - select_slot(e); + $("#buy-course").click((e) => { + e.preventDefault(); + frappe.call({ + method: "lms.lms.doctype.lms_course.lms_course.buy_course", + args: { + course: decodeURIComponent( + $(e.currentTarget).attr("data-course") + ), + }, + callback: (data) => {}, + }); }); }); @@ -162,99 +155,3 @@ const submit_for_review = (e) => { }, }); }; - -const apply_cetificate = (e) => { - $("#slot-modal").modal("show"); -}; - -const submit_slot = (e) => { - e.preventDefault(); - const slot = window.selected_slot; - frappe.call({ - method: "lms.lms.doctype.lms_certificate_request.lms_certificate_request.create_certificate_request", - args: { - course: slot.data("course"), - date: $("#slot-date").val(), - day: slot.data("day"), - start_time: slot.data("start"), - end_time: slot.data("end"), - }, - callback: (data) => { - $("#slot-modal").modal("hide"); - frappe.show_alert( - { - message: __( - "Your slot has been booked. Prepare well for the evaluations." - ), - indicator: "green", - }, - 3 - ); - setTimeout(() => { - window.location.reload(); - }, 3000); - }, - }); -}; - -const display_slots = (e) => { - frappe.call({ - method: "lms.lms.doctype.course_evaluator.course_evaluator.get_schedule", - args: { - course: $(e.currentTarget).data("course"), - date: $(e.currentTarget).val(), - }, - callback: (data) => { - let options = ""; - data.message.forEach((obj) => { - options += ``; - }); - e.preventDefault(); - $("#slot-modal .slots").html(options); - const weekday = [ - "Sunday", - "Monday", - "Tuesday", - "Wednesday", - "Thursday", - "Friday", - "Saturday", - ]; - const day = weekday[new Date($(e.currentTarget).val()).getDay()]; - - $(".slot").addClass("hide"); - $(".slot-label").addClass("hide"); - - if ($(`[data-day='${day}']`).length) { - $(".slot-label").removeClass("hide"); - $(`[data-day='${day}']`).removeClass("hide"); - $("#no-slots-message").addClass("hide"); - } else { - $("#no-slots-message").removeClass("hide"); - } - }, - }); -}; - -const select_slot = (e) => { - $(".slot").removeClass("btn-outline-primary"); - $(e.currentTarget).addClass("btn-outline-primary"); - window.selected_slot = $(e.currentTarget); -}; - -const format_time = (time) => { - let date = moment(new Date()).format("ddd MMM DD YYYY"); - return moment(`${date} ${time}`).format("HH:mm a"); -}; - -const close_slot_modal = (e) => { - $("#slot-date").val(""); - $(".slot-label").addClass("hide"); -}; From 9c021ef3b1c60eafcf650ba910dd970532c5f107 Mon Sep 17 00:00:00 2001 From: Jannat Patel Date: Fri, 4 Aug 2023 19:37:08 +0530 Subject: [PATCH 3/8] feat: razorpay order creation and checkout redirection --- lms/lms/doctype/lms_course/lms_course.py | 56 ++++++++++++++---------- lms/www/courses/course.html | 5 +++ lms/www/courses/course.js | 32 +++++++++----- requirements.txt | 3 +- 4 files changed, 61 insertions(+), 35 deletions(-) diff --git a/lms/lms/doctype/lms_course/lms_course.py b/lms/lms/doctype/lms_course/lms_course.py index d40a9adc..967d8e93 100644 --- a/lms/lms/doctype/lms_course/lms_course.py +++ b/lms/lms/doctype/lms_course/lms_course.py @@ -359,10 +359,35 @@ def reorder_chapter(chapter_array): @frappe.whitelist() -def buy_course(course): - razorpay_key = frappe.get_single_value("LMS Settings", "razorpay_key") - razorpay_secret = frappe.get_single_value("LMS Settings", "razorpay_secret") +def get_payment_options(course): + course_details = frappe.db.get_value( + "LMS Course", course, ["name", "title", "currency", "course_price"], as_dict=True + ) + razorpay_key = frappe.db.get_single_value("LMS Settings", "razorpay_key") + razorpay_secret = frappe.db.get_single_value("LMS Settings", "razorpay_secret") + client = get_client(razorpay_key, razorpay_secret) + order = create_order(client, course_details) + + options = { + "key_id": razorpay_key, + "name": frappe.db.get_single_value("Website Settings", "app_name"), + "description": _("Payment for {0} course").format(course_details["title"]), + "order_id": order["id"], + "amount": order["amount"] * 100, + "currency": order["currency"], + "prefill": { + "name": frappe.db.get_value("User", frappe.session.user, "full_name"), + "email": frappe.session.user, + }, + "callback_url": frappe.utils.get_url( + "/api/method/lms.lms.doctype.lms_course.lms_course.verify_payment" + ), + } + return options + + +def get_client(razorpay_key, razorpay_secret): if not razorpay_key and not razorpay_secret: frappe.throw( _( @@ -370,30 +395,13 @@ def buy_course(course): ) ) - create_payment_link(razorpay_key, razorpay_secret, course) + return razorpay.Client(auth=(razorpay_key, razorpay_secret)) -def create_payment_link(razorpy_key, razorpay_secret, course): - client = razorpay.Client(auth=(razorpy_key, razorpay_secret)) - - course_details = frappe.db.get_value( - "LMS Course", course, ["title", "price_course", "currency"], as_dict=True - ) - user_details = frappe.db.get_value( - "User", frappe.session.user, ["full_name", "email"], as_dict=True - ) - - client.payment_link.create( +def create_order(client, course_details): + return client.order.create( { - "amount": course_details.price_course, + "amount": course_details.course_price * 100, "currency": course_details.currency, - "description": "Complete your course purchase", - "customer": { - "name": user_details.full_name, - "email": user_details.email, - }, - "notify": {"sms": True, "email": True}, - "callback_url": "/api/method/lms.lms.doctype.lms_course.lms_course.verify_payment", - "callback_method": "get", } ) diff --git a/lms/www/courses/course.html b/lms/www/courses/course.html index 6035ea88..ad584ec0 100644 --- a/lms/www/courses/course.html +++ b/lms/www/courses/course.html @@ -281,3 +281,8 @@

{% endif %} {% endmacro %} + +{%- block script %} +{{ super() }} + +{% endblock %} \ No newline at end of file diff --git a/lms/www/courses/course.js b/lms/www/courses/course.js index 1f78b87b..4f26b877 100644 --- a/lms/www/courses/course.js +++ b/lms/www/courses/course.js @@ -22,16 +22,7 @@ frappe.ready(() => { }); $("#buy-course").click((e) => { - e.preventDefault(); - frappe.call({ - method: "lms.lms.doctype.lms_course.lms_course.buy_course", - args: { - course: decodeURIComponent( - $(e.currentTarget).attr("data-course") - ), - }, - callback: (data) => {}, - }); + generate_checkout_link(e); }); }); @@ -155,3 +146,24 @@ const submit_for_review = (e) => { }, }); }; + +generate_checkout_link = (e) => { + e.preventDefault(); + let course = decodeURIComponent($(e.currentTarget).attr("data-course")); + + if (frappe.session.user == "Guest") { + window.location.href = `/login?redirect-to=/courses/${course}`; + return; + } + + frappe.call({ + method: "lms.lms.doctype.lms_course.lms_course.get_payment_options", + args: { + course: course, + }, + callback: (data) => { + let rzp1 = new Razorpay(data.message); + rzp1.open(); + }, + }); +}; diff --git a/requirements.txt b/requirements.txt index d116dede..74bac25a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,5 @@ markdown beautifulsoup4 lxml cairocffi -html2image \ No newline at end of file +html2image +razorpay \ No newline at end of file From 0c14a1ab4ce25d165dccd6db95e0e4eaf155f4bd Mon Sep 17 00:00:00 2001 From: Jannat Patel Date: Fri, 11 Aug 2023 19:45:12 +0530 Subject: [PATCH 4/8] feat: payment verification and membership --- lms/hooks.py | 4 + .../lms_batch_membership.json | 41 ++++++- lms/lms/doctype/lms_course/lms_course.py | 76 +++++++++++-- lms/public/css/style.css | 13 ++- lms/public/js/common_functions.js | 2 +- lms/www/billing/billing.html | 75 +++++++++++++ lms/www/billing/billing.js | 105 ++++++++++++++++++ lms/www/billing/billing.py | 23 ++++ lms/www/courses/course.html | 14 +-- lms/www/courses/course.js | 25 ----- 10 files changed, 327 insertions(+), 51 deletions(-) create mode 100644 lms/www/billing/billing.html create mode 100644 lms/www/billing/billing.js create mode 100644 lms/www/billing/billing.py diff --git a/lms/hooks.py b/lms/hooks.py index 1bb0f1f0..f8796e7e 100644 --- a/lms/hooks.py +++ b/lms/hooks.py @@ -188,6 +188,10 @@ website_route_rules = [ "from_route": "/quiz-submission//", "to_route": "quiz_submission/quiz_submission", }, + { + "from_route": "/billing/", + "to_route": "billing/billing", + }, ] website_redirects = [ diff --git a/lms/lms/doctype/lms_batch_membership/lms_batch_membership.json b/lms/lms/doctype/lms_batch_membership/lms_batch_membership.json index 903f66ce..6aba9d5b 100644 --- a/lms/lms/doctype/lms_batch_membership/lms_batch_membership.json +++ b/lms/lms/doctype/lms_batch_membership/lms_batch_membership.json @@ -12,6 +12,12 @@ "member", "member_name", "member_username", + "billing_information_section", + "address", + "payment_received", + "column_break_rvzn", + "order_id", + "payment_id", "section_break_8", "cohort", "subgroup", @@ -112,11 +118,42 @@ { "fieldname": "section_break_8", "fieldtype": "Section Break" + }, + { + "fieldname": "billing_information_section", + "fieldtype": "Section Break", + "label": "Billing Information" + }, + { + "fieldname": "address", + "fieldtype": "Link", + "label": "Address", + "options": "Address" + }, + { + "default": "0", + "fieldname": "payment_received", + "fieldtype": "Check", + "label": "Payment Received" + }, + { + "fieldname": "column_break_rvzn", + "fieldtype": "Column Break" + }, + { + "fieldname": "order_id", + "fieldtype": "Data", + "label": "Order ID" + }, + { + "fieldname": "payment_id", + "fieldtype": "Data", + "label": "Payment ID" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2022-10-10 12:38:17.839526", + "modified": "2023-08-11 15:39:50.194348", "modified_by": "Administrator", "module": "LMS", "name": "LMS Batch Membership", @@ -141,4 +178,4 @@ "sort_order": "DESC", "states": [], "title_field": "member_name" -} +} \ No newline at end of file diff --git a/lms/lms/doctype/lms_course/lms_course.py b/lms/lms/doctype/lms_course/lms_course.py index 967d8e93..7cc082a4 100644 --- a/lms/lms/doctype/lms_course/lms_course.py +++ b/lms/lms/doctype/lms_course/lms_course.py @@ -359,14 +359,12 @@ def reorder_chapter(chapter_array): @frappe.whitelist() -def get_payment_options(course): +def get_payment_options(course, phone): course_details = frappe.db.get_value( "LMS Course", course, ["name", "title", "currency", "course_price"], as_dict=True ) razorpay_key = frappe.db.get_single_value("LMS Settings", "razorpay_key") - razorpay_secret = frappe.db.get_single_value("LMS Settings", "razorpay_secret") - - client = get_client(razorpay_key, razorpay_secret) + client = get_client() order = create_order(client, course_details) options = { @@ -379,15 +377,32 @@ def get_payment_options(course): "prefill": { "name": frappe.db.get_value("User", frappe.session.user, "full_name"), "email": frappe.session.user, + "contact": phone, }, - "callback_url": frappe.utils.get_url( - "/api/method/lms.lms.doctype.lms_course.lms_course.verify_payment" - ), } return options -def get_client(razorpay_key, razorpay_secret): +def save_address(address): + address = json.loads(address) + address.update( + { + "address_title": frappe.db.get_value("User", frappe.session.user, "full_name"), + "address_type": "Billing", + "is_primary_address": 1, + "email_id": frappe.session.user, + } + ) + doc = frappe.new_doc("Address") + doc.update(address) + doc.save(ignore_permissions=True) + return doc.name + + +def get_client(): + razorpay_key = frappe.db.get_single_value("LMS Settings", "razorpay_key") + razorpay_secret = frappe.db.get_single_value("LMS Settings", "razorpay_secret") + if not razorpay_key and not razorpay_secret: frappe.throw( _( @@ -399,9 +414,48 @@ def get_client(razorpay_key, razorpay_secret): def create_order(client, course_details): - return client.order.create( + try: + return client.order.create( + { + "amount": course_details.course_price * 100, + "currency": course_details.currency, + } + ) + except Exception as e: + frappe.throw( + _("Error during payment: {0}. Please contact the Administrator.").format(e) + ) + + +@frappe.whitelist() +def verify_payment(response, course, address, order_id): + response = json.loads(response) + client = get_client() + client.utility.verify_payment_signature( { - "amount": course_details.course_price * 100, - "currency": course_details.currency, + "razorpay_order_id": order_id, + "razorpay_payment_id": response["razorpay_payment_id"], + "razorpay_signature": response["razorpay_signature"], } ) + + return create_membership(address, response, course) + + +def create_membership(address, response, course): + address_name = save_address(address) + membership = frappe.new_doc("LMS Batch Membership") + + membership.update( + { + "member": frappe.session.user, + "course": course, + "address": address_name, + "payment_received": 1, + "order_id": response["razorpay_order_id"], + "payment_id": response["razorpay_payment_id"], + } + ) + membership.save(ignore_permissions=True) + + return f"/courses/{course}/learn/1.1" diff --git a/lms/public/css/style.css b/lms/public/css/style.css index 6b885abe..c3768a6c 100644 --- a/lms/public/css/style.css +++ b/lms/public/css/style.css @@ -2285,4 +2285,15 @@ select { position: absolute; top: 0; left: 0; -} \ No newline at end of file +} + +.form-section .section-head { + margin-bottom: var(--margin-sm); + font-weight: 700; + color: var(--heading-color); +} + +.form-column:first-child { + padding-right: 1rem !important; +} + diff --git a/lms/public/js/common_functions.js b/lms/public/js/common_functions.js index 9605629f..124c401f 100644 --- a/lms/public/js/common_functions.js +++ b/lms/public/js/common_functions.js @@ -201,7 +201,7 @@ const expand_the_first_chapter = () => { const expand_the_active_chapter = () => { let selector = $(".course-home-headings.title"); - console.log(selector); + if (selector.length && $(".course-details-page").length) { expand_for_course_details(selector); } else if ($(".active-lesson").length) { diff --git a/lms/www/billing/billing.html b/lms/www/billing/billing.html new file mode 100644 index 00000000..373461d9 --- /dev/null +++ b/lms/www/billing/billing.html @@ -0,0 +1,75 @@ +{% extends "lms/templates/lms_base.html" %} +{% block title %} + {{ course.title if course.title else _("New Course") }} +{% endblock %} + + +{% block page_content %} +
+ +
+{% endblock %} + +{% macro Header() %} +
+
+ {{ _("Order Details") }} +
+
+ {{ _("Enter the billing information and complete the payment to purchase this course.") }} +
+
+{% endmacro %} + +{% macro CourseDetails() %} +
+
+
+ {{ _("Course:") }} +
+
+ {{ course.title }} +
+
+ +
+ +
+
+ {{ _("Total Price:") }} +
+
+ + {{ frappe.utils.fmt_money(course.course_price, 2, course.currency) }} +
+
+
+{% endmacro %} + +{% macro BillingDetails() %} +
+
+ {{ _("Billing Details") }} +
+
+ +
+{% endmacro %} + +{%- block script %} +{{ super() }} + + +{% endblock %} diff --git a/lms/www/billing/billing.js b/lms/www/billing/billing.js new file mode 100644 index 00000000..51f57cef --- /dev/null +++ b/lms/www/billing/billing.js @@ -0,0 +1,105 @@ +frappe.ready(() => { + if ($("#billing-form").length) { + setup_billing(); + } + + $(".btn-pay").click((e) => { + generate_payment_link(e); + }); +}); + +const setup_billing = () => { + this.billing = new frappe.ui.FieldGroup({ + fields: [ + { + fieldtype: "Data", + label: __("Address Line 1"), + fieldname: "address_line1", + reqd: 1, + }, + { + fieldtype: "Data", + label: __("Address Line 2"), + fieldname: "address_line2", + }, + { + fieldtype: "Data", + label: __("City/Town"), + fieldname: "city", + reqd: 1, + }, + { + fieldtype: "Data", + label: __("State/Province"), + fieldname: "state", + }, + { + fieldtype: "Column Break", + }, + { + fieldtype: "Link", + label: __("Country"), + fieldname: "country", + options: "Country", + reqd: 1, + }, + { + fieldtype: "Data", + label: __("Postal Code"), + fieldname: "pincode", + reqd: 1, + }, + { + fieldtype: "Data", + label: __("Phone Number"), + fieldname: "phone", + reqd: 1, + }, + ], + body: $("#billing-form").get(0), + }); + this.billing.make(); + $("#billing-form .form-section:last").removeClass("empty-section"); + $("#billing-form .frappe-control").removeClass("hide-control"); + $("#billing-form .form-column").addClass("p-0"); +}; + +const generate_payment_link = (e) => { + address = this.billing.get_values(); + let course = decodeURIComponent($(e.currentTarget).attr("data-course")); + + frappe.call({ + method: "lms.lms.doctype.lms_course.lms_course.get_payment_options", + args: { + course: course, + phone: address.phone, + }, + callback: (data) => { + data.message.handler = (response) => { + handle_success( + response, + course, + address, + data.message.order_id + ); + }; + let rzp1 = new Razorpay(data.message); + rzp1.open(); + }, + }); +}; + +const handle_success = (response, course, address, order_id) => { + frappe.call({ + method: "lms.lms.doctype.lms_course.lms_course.verify_payment", + args: { + response: response, + course: course, + address: address, + order_id: order_id, + }, + callback: (data) => { + window.location.href = data.message; + }, + }); +}; diff --git a/lms/www/billing/billing.py b/lms/www/billing/billing.py new file mode 100644 index 00000000..562e6969 --- /dev/null +++ b/lms/www/billing/billing.py @@ -0,0 +1,23 @@ +import frappe +from frappe import _ + + +def get_context(context): + course_name = frappe.form_dict.course + + if not course_name: + raise ValueError(_("Course is required.")) + + if frappe.session.user == "Guest": + raise frappe.PermissionError(_("You are not allowed to access this page.")) + + membership = frappe.db.exists( + "LMS Batch Membership", {"member": frappe.session.user, "course": course_name} + ) + + if membership: + raise frappe.PermissionError(_("You are already enrolled for this course")) + + context.course = frappe.db.get_value( + "LMS Course", course_name, ["title", "name", "course_price", "currency"], as_dict=True + ) diff --git a/lms/www/courses/course.html b/lms/www/courses/course.html index ad584ec0..450cf640 100644 --- a/lms/www/courses/course.html +++ b/lms/www/courses/course.html @@ -222,9 +222,9 @@ {% elif course.paid_course %} - + {% elif show_start_learing_cta(course, membership) %}
@@ -257,9 +257,6 @@ {% endif %}
- - - {% endmacro %} @@ -280,9 +277,4 @@ {{ _("You have exceeded the maximum number of attempts allowed to appear for evaluations of this course.") }}

{% endif %} -{% endmacro %} - -{%- block script %} -{{ super() }} - -{% endblock %} \ No newline at end of file +{% endmacro %} \ No newline at end of file diff --git a/lms/www/courses/course.js b/lms/www/courses/course.js index 4f26b877..a3f86b84 100644 --- a/lms/www/courses/course.js +++ b/lms/www/courses/course.js @@ -20,10 +20,6 @@ frappe.ready(() => { $("#submit-for-review").click((e) => { submit_for_review(e); }); - - $("#buy-course").click((e) => { - generate_checkout_link(e); - }); }); const hide_wrapped_mentor_cards = () => { @@ -146,24 +142,3 @@ const submit_for_review = (e) => { }, }); }; - -generate_checkout_link = (e) => { - e.preventDefault(); - let course = decodeURIComponent($(e.currentTarget).attr("data-course")); - - if (frappe.session.user == "Guest") { - window.location.href = `/login?redirect-to=/courses/${course}`; - return; - } - - frappe.call({ - method: "lms.lms.doctype.lms_course.lms_course.get_payment_options", - args: { - course: course, - }, - callback: (data) => { - let rzp1 = new Razorpay(data.message); - rzp1.open(); - }, - }); -}; From 2ba85ba6a70c64b007ff75e9ea782b091c1b0dd5 Mon Sep 17 00:00:00 2001 From: Jannat Patel Date: Mon, 14 Aug 2023 19:30:33 +0530 Subject: [PATCH 5/8] feat: paid course from course creation form --- lms/lms/doctype/lms_course/lms_course.py | 9 +++++- lms/lms/utils.py | 2 +- lms/public/css/style.css | 2 +- lms/templates/reviews_cta.html | 2 +- lms/www/billing/__init__.py | 0 lms/www/billing/billing.html | 29 +++++++------------ lms/www/billing/billing.js | 8 ++++- lms/www/courses/course.html | 16 ++++++---- lms/www/courses/course.py | 1 + lms/www/courses/create.html | 37 ++++++++++++++++++++++++ lms/www/courses/create.js | 17 +++++++++++ lms/www/courses/create.py | 5 ++-- 12 files changed, 97 insertions(+), 31 deletions(-) create mode 100644 lms/www/billing/__init__.py diff --git a/lms/lms/doctype/lms_course/lms_course.py b/lms/lms/doctype/lms_course/lms_course.py index 7cc082a4..2d56bd63 100644 --- a/lms/lms/doctype/lms_course/lms_course.py +++ b/lms/lms/doctype/lms_course/lms_course.py @@ -5,7 +5,7 @@ import json import random import frappe from frappe.model.document import Document -from frappe.utils import cint +from frappe.utils import cint, validate_phone_number from frappe.utils.telemetry import capture from lms.lms.utils import get_chapters, can_create_courses from ...utils import generate_slug, validate_image @@ -213,6 +213,9 @@ def save_course( published, upcoming, image=None, + paid_course=False, + course_price=None, + currency=None, ): if not can_create_courses(): return @@ -232,6 +235,9 @@ def save_course( "tags": tags, "published": cint(published), "upcoming": cint(upcoming), + "paid_course": cint(paid_course), + "course_price": course_price, + "currency": currency, } ) doc.save(ignore_permissions=True) @@ -360,6 +366,7 @@ def reorder_chapter(chapter_array): @frappe.whitelist() def get_payment_options(course, phone): + validate_phone_number(phone, True) course_details = frappe.db.get_value( "LMS Course", course, ["name", "title", "currency", "course_price"], as_dict=True ) diff --git a/lms/lms/utils.py b/lms/lms/utils.py index d472e6cf..0758d0c5 100644 --- a/lms/lms/utils.py +++ b/lms/lms/utils.py @@ -475,7 +475,7 @@ def get_evaluation_details(course, member=None): def format_amount(amount, currency): amount_reduced = amount / 1000 if amount_reduced < 1: - return amount + return fmt_money(amount, 0, currency) precision = 0 if amount % 1000 == 0 else 1 return _("{0}k").format(fmt_money(amount_reduced, precision, currency)) diff --git a/lms/public/css/style.css b/lms/public/css/style.css index c3768a6c..823b9e52 100644 --- a/lms/public/css/style.css +++ b/lms/public/css/style.css @@ -421,7 +421,7 @@ input[type=checkbox] { } .course-price { - font-weight: 700; + font-weight: 500; } .view-course-link { diff --git a/lms/templates/reviews_cta.html b/lms/templates/reviews_cta.html index 73bd63a7..dfb06f44 100644 --- a/lms/templates/reviews_cta.html +++ b/lms/templates/reviews_cta.html @@ -2,7 +2,7 @@ {{ _("Write a review") }} -{% elif not is_instructor(course.name) and frappe.session.user == "Guest" %} +{% elif not is_instructor and frappe.session.user == "Guest" %} {{ _("Write a review") }} diff --git a/lms/www/billing/__init__.py b/lms/www/billing/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lms/www/billing/billing.html b/lms/www/billing/billing.html index 373461d9..31e84e04 100644 --- a/lms/www/billing/billing.html +++ b/lms/www/billing/billing.html @@ -15,7 +15,7 @@ {% endblock %} {% macro Header() %} -
+
{{ _("Order Details") }}
@@ -26,25 +26,18 @@ {% endmacro %} {% macro CourseDetails() %} -
-
-
- {{ _("Course:") }} +
+
+
+
+ {{ _("Course Name: ") }} {{ course.title }} +
-
- {{ course.title }} -
-
-
- -
-
- {{ _("Total Price:") }} -
-
- - {{ frappe.utils.fmt_money(course.course_price, 2, course.currency) }} +
+
+ {{ _("Total Price: ") }} {{ frappe.utils.fmt_money(course.course_price, 2, course.currency) }} +
diff --git a/lms/www/billing/billing.js b/lms/www/billing/billing.js index 51f57cef..4767fe03 100644 --- a/lms/www/billing/billing.js +++ b/lms/www/billing/billing.js @@ -99,7 +99,13 @@ const handle_success = (response, course, address, order_id) => { order_id: order_id, }, callback: (data) => { - window.location.href = data.message; + frappe.show_alert({ + message: __("Payment Successful"), + indicator: "green", + }); + setTimeout(() => { + window.location.href = data.message; + }, 2000); }, }); }; diff --git a/lms/www/courses/course.html b/lms/www/courses/course.html index 450cf640..3f3bedbe 100644 --- a/lms/www/courses/course.html +++ b/lms/www/courses/course.html @@ -122,6 +122,10 @@ {{ Notes(course) }} +
+ {{ format_amount(course.course_price, course.currency) }} +
+
@@ -194,18 +198,18 @@ membership.current_lesson else "1.1" if first_lesson_exists(course.name) else None %}
- {% if is_instructor(course.name) and not course.published and course.status != "Under Review" %} + {% if is_instructor and not course.published and course.status != "Under Review" %}
{{ _("Submit for Review") }}
- {% elif is_instructor(course.name) and lesson_index %} + {% elif is_instructor and lesson_index %} {{ _("Checkout Course") }} - {% elif course.upcoming and not is_user_interested and not is_instructor(course.name) %} + {% elif course.upcoming and not is_user_interested and not is_instructor %}
{{ _("Notify me when available") }}
@@ -221,7 +225,7 @@ {{ _("Continue Learning") }} - {% elif course.paid_course %} + {% elif course.paid_course and not is_instructor %} {{ _("Buy This Course") }} @@ -247,7 +251,7 @@ {% endif %} {% endif %} - {% if is_instructor(course.name) or has_course_moderator_role() %} + {% if is_instructor or has_course_moderator_role() %}