feat: class billing

This commit is contained in:
Jannat Patel
2023-08-22 18:34:42 +05:30
parent ac64e59c43
commit 04ed7f412f
22 changed files with 663 additions and 244 deletions

View File

@@ -189,9 +189,13 @@ website_route_rules = [
"to_route": "quiz_submission/quiz_submission", "to_route": "quiz_submission/quiz_submission",
}, },
{ {
"from_route": "/billing/<course>", "from_route": "/billing/<module>/<modulename>",
"to_route": "billing/billing", "to_route": "billing/billing",
}, },
{
"from_route": "/classes/details/<classname>",
"to_route": "classes/class_details",
},
] ]
website_redirects = [ website_redirects = [
@@ -249,6 +253,7 @@ jinja = {
"lms.lms.utils.can_create_courses", "lms.lms.utils.can_create_courses",
"lms.lms.utils.get_telemetry_boot_info", "lms.lms.utils.get_telemetry_boot_info",
"lms.lms.utils.is_onboarding_complete", "lms.lms.utils.is_onboarding_complete",
"lms.www.utils.is_student",
], ],
"filters": [], "filters": [],
} }

View File

@@ -10,10 +10,13 @@
"student", "student",
"student_name", "student_name",
"username", "username",
"column_break_zvlp",
"address", "address",
"column_break_zvlp",
"amount", "amount",
"currency" "currency",
"order_id",
"payment_id",
"payment_received"
], ],
"fields": [ "fields": [
{ {
@@ -59,12 +62,28 @@
"fieldtype": "Link", "fieldtype": "Link",
"label": "Currency", "label": "Currency",
"options": "Currency" "options": "Currency"
},
{
"fieldname": "order_id",
"fieldtype": "Data",
"label": "Order ID"
},
{
"fieldname": "payment_id",
"fieldtype": "Data",
"label": "Payment ID"
},
{
"default": "0",
"fieldname": "payment_received",
"fieldtype": "Check",
"label": "Payment Received"
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2023-08-18 19:19:34.701473", "modified": "2023-08-22 10:41:40.577437",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "Class Student", "name": "Class Student",

View File

@@ -19,11 +19,16 @@
"category", "category",
"column_break_flwy", "column_break_flwy",
"seat_count", "seat_count",
"paid_class",
"section_break_6", "section_break_6",
"description", "description",
"prerequisite",
"students", "students",
"courses", "courses",
"section_break_gsac",
"paid_class",
"column_break_iens",
"amount",
"currency",
"section_break_ubxi", "section_break_ubxi",
"custom_component", "custom_component",
"assessment_tab", "assessment_tab",
@@ -89,6 +94,7 @@
}, },
{ {
"default": "0", "default": "0",
"description": "Students will be enrolled in a paid class once they complete the payment",
"fieldname": "paid_class", "fieldname": "paid_class",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Paid Class" "label": "Paid Class"
@@ -158,11 +164,39 @@
"fieldname": "schedule_tab", "fieldname": "schedule_tab",
"fieldtype": "Tab Break", "fieldtype": "Tab Break",
"label": "Schedule" "label": "Schedule"
},
{
"fieldname": "section_break_gsac",
"fieldtype": "Section Break",
"label": "Pricing"
},
{
"fieldname": "column_break_iens",
"fieldtype": "Column Break"
},
{
"depends_on": "paid_class",
"fieldname": "amount",
"fieldtype": "Currency",
"label": "Amount"
},
{
"depends_on": "paid_class",
"fieldname": "currency",
"fieldtype": "Link",
"label": "Currency",
"options": "Currency"
},
{
"fieldname": "prerequisite",
"fieldtype": "Small Text",
"label": "Prerequisite",
"reqd": 1
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2023-08-10 12:54:44.351907", "modified": "2023-08-22 11:53:22.248596",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "LMS Class", "name": "LMS Class",
@@ -192,18 +226,6 @@
"role": "Moderator", "role": "Moderator",
"share": 1, "share": 1,
"write": 1 "write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Class Evaluator",
"share": 1,
"write": 1
} }
], ],
"sort_field": "modified", "sort_field": "modified",

View File

@@ -185,42 +185,6 @@ def authenticate():
return response.json()["access_token"] return response.json()["access_token"]
@frappe.whitelist()
def create_class(
title,
start_date,
end_date,
description=None,
seat_count=0,
start_time=None,
end_time=None,
medium="Online",
category=None,
name=None,
):
frappe.only_for("Moderator")
if name:
class_details = frappe.get_doc("LMS Class", name)
else:
class_details = frappe.get_doc({"doctype": "LMS Class"})
class_details.update(
{
"title": title,
"start_date": start_date,
"end_date": end_date,
"description": description,
"seat_count": seat_count,
"start_time": start_time,
"end_time": end_time,
"medium": medium,
"category": category,
}
)
class_details.save()
return class_details
@frappe.whitelist() @frappe.whitelist()
def fetch_lessons(courses): def fetch_lessons(courses):
lessons = [] lessons = []

View File

@@ -5,12 +5,11 @@ import json
import random import random
import frappe import frappe
from frappe.model.document import Document from frappe.model.document import Document
from frappe.utils import cint, validate_phone_number from frappe.utils import cint
from frappe.utils.telemetry import capture from frappe.utils.telemetry import capture
from lms.lms.utils import get_chapters, can_create_courses from lms.lms.utils import get_chapters, can_create_courses
from ...utils import generate_slug, validate_image from ...utils import generate_slug, validate_image
from frappe import _ from frappe import _
import razorpay
class LMSCourse(Document): class LMSCourse(Document):
@@ -362,115 +361,3 @@ def reorder_chapter(chapter_array):
"idx": chapter_array.index(chap) + 1, "idx": chapter_array.index(chap) + 1,
}, },
) )
@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
)
razorpay_key = frappe.db.get_single_value("LMS Settings", "razorpay_key")
client = get_client()
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,
"contact": phone,
},
}
return options
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(
_(
"There is a problem with the payment gateway. Please contact the Administrator to proceed."
)
)
return razorpay.Client(auth=(razorpay_key, razorpay_secret))
def create_order(client, course_details):
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(
{
"razorpay_order_id": order_id,
"razorpay_payment_id": response["razorpay_payment_id"],
"razorpay_signature": response["razorpay_signature"],
}
)
return create_membership(address, response, course, client)
def create_membership(address, response, course, client):
try:
address_name = save_address(address)
membership = frappe.new_doc("LMS Batch Membership")
payment = client.payment.fetch(response["razorpay_payment_id"])
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"],
"amount": payment["amount"] / 100,
"currency": payment["currency"],
}
)
membership.save(ignore_permissions=True)
return f"/courses/{course}/learn/1.1"
except Exception as e:
frappe.throw(
_("Error during payment: {0}. Please contact the Administrator.").format(e)
)

View File

@@ -1,6 +1,8 @@
import re import re
import string import string
import frappe import frappe
import json
import razorpay
from frappe import _ from frappe import _
from frappe.desk.doctype.dashboard_chart.dashboard_chart import get_result from frappe.desk.doctype.dashboard_chart.dashboard_chart import get_result
from frappe.desk.doctype.notification_log.notification_log import make_notification_logs from frappe.desk.doctype.notification_log.notification_log import make_notification_logs
@@ -13,9 +15,9 @@ from frappe.utils import (
format_date, format_date,
get_datetime, get_datetime,
getdate, getdate,
validate_phone_number,
) )
from frappe.utils.dateutils import get_period from frappe.utils.dateutils import get_period
from lms.lms.md import find_macros, markdown_to_html from lms.lms.md import find_macros, markdown_to_html
RE_SLUG_NOTALLOWED = re.compile("[^a-z0-9]+") RE_SLUG_NOTALLOWED = re.compile("[^a-z0-9]+")
@@ -825,3 +827,165 @@ def get_upcoming_evals(student, courses):
evals.course_title = frappe.db.get_value("LMS Course", evals.course, "title") evals.course_title = frappe.db.get_value("LMS Course", evals.course, "title")
evals.evaluator_name = frappe.db.get_value("User", evals.evaluator, "full_name") evals.evaluator_name = frappe.db.get_value("User", evals.evaluator, "full_name")
return upcoming_evals return upcoming_evals
@frappe.whitelist()
def get_payment_options(doctype, docname, phone):
if not frappe.db.exists(doctype, docname):
frappe.throw(_("Invalid document provided."))
validate_phone_number(phone, True)
if doctype == "LMS Course":
details = frappe.db.get_value(
"LMS Course",
docname,
["name", "title", "paid_course", "currency", "course_price as amount"],
as_dict=True,
)
if not details.paid_course:
frappe.throw(_("This course is free."))
else:
details = frappe.db.get_value(
"LMS Class",
docname,
["name", "title", "paid_class", "currency", "amount"],
as_dict=True,
)
if not details.paid_class:
frappe.throw(_("To join this class, please contact the Administrator."))
razorpay_key = frappe.db.get_single_value("LMS Settings", "razorpay_key")
client = get_client()
order = create_order(client, details.amount, details.currency)
options = {
"key_id": razorpay_key,
"name": frappe.db.get_single_value("Website Settings", "app_name"),
"description": _("Payment for {0} course").format(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,
"contact": phone,
},
}
return options
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(
_(
"There is a problem with the payment gateway. Please contact the Administrator to proceed."
)
)
return razorpay.Client(auth=(razorpay_key, razorpay_secret))
def create_order(client, amount, currency):
try:
return client.order.create(
{
"amount": amount * 100,
"currency": currency,
}
)
except Exception as e:
frappe.throw(
_("Error during payment: {0}. Please contact the Administrator.").format(e)
)
@frappe.whitelist()
def verify_payment(response, doctype, docname, address, order_id):
response = json.loads(response)
client = get_client()
client.utility.verify_payment_signature(
{
"razorpay_order_id": order_id,
"razorpay_payment_id": response["razorpay_payment_id"],
"razorpay_signature": response["razorpay_signature"],
}
)
if doctype == "LMS Course":
return create_membership(address, response, docname, client)
else:
return add_student_to_class(address, response, docname, client)
def create_membership(address, response, course, client):
try:
address_name = save_address(address)
payment = client.payment.fetch(response["razorpay_payment_id"])
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"],
"amount": payment["amount"] / 100,
"currency": payment["currency"],
}
)
membership.save(ignore_permissions=True)
return f"/courses/{course}/learn/1.1"
except Exception as e:
frappe.throw(
_("Error during payment: {0}. Please contact the Administrator.").format(e)
)
def add_student_to_class(address, response, classname, client):
try:
address_name = save_address(address)
payment = client.payment.fetch(response["razorpay_payment_id"])
student = frappe.new_doc("Class Student")
student.update(
{
"student": frappe.session.user,
"parent": classname,
"parenttype": "LMS Class",
"parentfield": "students",
"address": address_name,
"amount": payment["amount"] / 100,
"currency": payment["currency"],
"payment_received": 1,
"order_id": response["razorpay_order_id"],
"payment_id": response["razorpay_payment_id"],
}
)
student.save(ignore_permissions=True)
return f"/classes/{classname}"
except Exception as e:
frappe.throw(
_("Error during payment: {0}. Please contact the Administrator.").format(e)
)

View File

@@ -2302,3 +2302,6 @@ select {
padding-right: 1rem !important; padding-right: 1rem !important;
} }
.class-overlay {
top: 30%;
}

View File

@@ -104,11 +104,18 @@
<circle cx="12" cy="12" r="10"></circle> <circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline> <polyline points="12 6 12 12 16 14"></polyline>
</svg> </svg>
<svg width="20" height="20" viewBox="0 0 20 20" id="icon-success" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="20" height="20" viewBox="0 0 20 20" id="icon-success" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10 18.75C14.8325 18.75 18.75 14.8325 18.75 10C18.75 5.16751 14.8325 1.25 10 1.25C5.16751 1.25 1.25 5.16751 1.25 10C1.25 14.8325 5.16751 18.75 10 18.75ZM13.966 7.48104C14.1856 7.21471 14.1477 6.8208 13.8813 6.60122C13.615 6.38164 13.2211 6.41954 13.0015 6.68587L8.68984 11.9155L7.01289 9.74823C6.80165 9.47524 6.40911 9.42517 6.13611 9.6364C5.86311 9.84764 5.81304 10.2402 6.02428 10.5132L8.18004 13.2993C8.29633 13.4495 8.47467 13.5388 8.66468 13.5417C8.85468 13.5447 9.0357 13.461 9.15658 13.3144L13.966 7.48104Z" fill="#171717"/> <path fill-rule="evenodd" clip-rule="evenodd" d="M10 18.75C14.8325 18.75 18.75 14.8325 18.75 10C18.75 5.16751 14.8325 1.25 10 1.25C5.16751 1.25 1.25 5.16751 1.25 10C1.25 14.8325 5.16751 18.75 10 18.75ZM13.966 7.48104C14.1856 7.21471 14.1477 6.8208 13.8813 6.60122C13.615 6.38164 13.2211 6.41954 13.0015 6.68587L8.68984 11.9155L7.01289 9.74823C6.80165 9.47524 6.40911 9.42517 6.13611 9.6364C5.86311 9.84764 5.81304 10.2402 6.02428 10.5132L8.18004 13.2993C8.29633 13.4495 8.47467 13.5388 8.66468 13.5417C8.85468 13.5447 9.0357 13.461 9.15658 13.3144L13.966 7.48104Z" fill="#171717"/>
</svg> </svg>
<svg width="16" height="16" id="icon-drag" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="16" height="16" id="icon-drag" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 3C4 3.82843 4.67157 4.5 5.5 4.5C6.32843 4.5 7 3.82843 7 3C7 2.17157 6.32843 1.5 5.5 1.5C4.67157 1.5 4 2.17157 4 3ZM5.5 9.5C4.67157 9.5 4 8.82843 4 8C4 7.17157 4.67157 6.5 5.5 6.5C6.32843 6.5 7 7.17157 7 8C7 8.82843 6.32843 9.5 5.5 9.5ZM5.5 14.5C4.67157 14.5 4 13.8284 4 13C4 12.1716 4.67157 11.5 5.5 11.5C6.32843 11.5 7 12.1716 7 13C7 13.8284 6.32843 14.5 5.5 14.5ZM9 3C9 3.82843 9.67157 4.5 10.5 4.5C11.3284 4.5 12 3.82843 12 3C12 2.17157 11.3284 1.5 10.5 1.5C9.67157 1.5 9 2.17157 9 3ZM10.5 9.5C9.67157 9.5 9 8.82843 9 8C9 7.17157 9.67157 6.5 10.5 6.5C11.3284 6.5 12 7.17157 12 8C12 8.82843 11.3284 9.5 10.5 9.5ZM10.5 14.5C9.67157 14.5 9 13.8284 9 13C9 12.1716 9.67157 11.5 10.5 11.5C11.3284 11.5 12 12.1716 12 13C12 13.8284 11.3284 14.5 10.5 14.5Z" fill="#171717"/> <path fill-rule="evenodd" clip-rule="evenodd" d="M4 3C4 3.82843 4.67157 4.5 5.5 4.5C6.32843 4.5 7 3.82843 7 3C7 2.17157 6.32843 1.5 5.5 1.5C4.67157 1.5 4 2.17157 4 3ZM5.5 9.5C4.67157 9.5 4 8.82843 4 8C4 7.17157 4.67157 6.5 5.5 6.5C6.32843 6.5 7 7.17157 7 8C7 8.82843 6.32843 9.5 5.5 9.5ZM5.5 14.5C4.67157 14.5 4 13.8284 4 13C4 12.1716 4.67157 11.5 5.5 11.5C6.32843 11.5 7 12.1716 7 13C7 13.8284 6.32843 14.5 5.5 14.5ZM9 3C9 3.82843 9.67157 4.5 10.5 4.5C11.3284 4.5 12 3.82843 12 3C12 2.17157 11.3284 1.5 10.5 1.5C9.67157 1.5 9 2.17157 9 3ZM10.5 9.5C9.67157 9.5 9 8.82843 9 8C9 7.17157 9.67157 6.5 10.5 6.5C11.3284 6.5 12 7.17157 12 8C12 8.82843 11.3284 9.5 10.5 9.5ZM10.5 14.5C9.67157 14.5 9 13.8284 9 13C9 12.1716 9.67157 11.5 10.5 11.5C11.3284 11.5 12 12.1716 12 13C12 13.8284 11.3284 14.5 10.5 14.5Z" fill="#171717"/>
</svg> </svg>
<svg xmlns="http://www.w3.org/2000/svg" id="icon-clock" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="feather feather-clock">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -320,6 +320,41 @@ const open_class_dialog = () => {
default: class_info && class_info.description, default: class_info && class_info.description,
reqd: 1, reqd: 1,
}, },
{
fieldtype: "Small Text",
label: __("Prerequisite"),
fieldname: "prerequisite",
default: class_info && class_info.prerequisite,
reqd: 1,
},
{
fieldtype: "Section Break",
label: __("Pricing"),
fieldname: "pricing",
},
{
fieldtype: "Check",
label: __("Paid Class"),
fieldname: "paid_class",
default: class_info && class_info.paid_class,
},
{
fieldtype: "Currency",
label: __("Amount"),
fieldname: "amount",
default: class_info && class_info.amount,
mandatory_depends_on: "paid_class",
depends_on: "paid_class",
},
{
fieldtype: "Link",
label: __("Currency"),
fieldname: "currency",
options: "Currency",
default: class_info && class_info.currency,
mandatory_depends_on: "paid_class",
depends_on: "paid_class",
},
], ],
primary_action_label: __("Save"), primary_action_label: __("Save"),
primary_action: (values) => { primary_action: (values) => {
@@ -330,19 +365,19 @@ const open_class_dialog = () => {
}; };
const save_class = (values) => { const save_class = (values) => {
let method, args;
if (class_info) {
method = "frappe.client.save";
args = Object.assign(class_info, values);
} else {
method = "frappe.client.insert";
args = values;
args.doctype = "LMS Class";
}
frappe.call({ frappe.call({
method: "lms.lms.doctype.lms_class.lms_class.create_class", method: method,
args: { args: {
title: values.title, doc: args,
start_date: values.start_date,
end_date: values.end_date,
description: values.description,
seat_count: values.seat_count,
start_time: values.start_time,
end_time: values.end_time,
medium: values.medium,
category: values.category,
name: class_info && class_info.name,
}, },
callback: (r) => { callback: (r) => {
if (r.message) { if (r.message) {
@@ -353,7 +388,7 @@ const save_class = (values) => {
indicator: "green", indicator: "green",
}); });
this.class_dialog.hide(); this.class_dialog.hide();
window.location.href = `/classes/${r.message.name}`; window.location.href = `/classes/details/${r.message.name}`;
} }
}, },
}); });

View File

@@ -1,6 +1,6 @@
{% extends "lms/templates/lms_base.html" %} {% extends "lms/templates/lms_base.html" %}
{% block title %} {% block title %}
{{ course.title if course.title else _("New Course") }} {{ title }} {{ _("Billing") }}
{% endblock %} {% endblock %}
@@ -8,7 +8,7 @@
<div class="common-page-style"> <div class="common-page-style">
<div class="container form-width common-card-style column-card px-0 h-0 mt-8"> <div class="container form-width common-card-style column-card px-0 h-0 mt-8">
{{ Header() }} {{ Header() }}
{{ CourseDetails() }} {{ Details() }}
{{ BillingDetails() }} {{ BillingDetails() }}
</div> </div>
</div> </div>
@@ -20,23 +20,24 @@
{{ _("Order Details") }} {{ _("Order Details") }}
</div> </div>
<div> <div>
{{ _("Enter the billing information and complete the payment to purchase this course.") }} {{ _("Enter the billing information and complete the payment to purchase this {0}.").format(module) }}
</div> </div>
</div> </div>
{% endmacro %} {% endmacro %}
{% macro CourseDetails() %} {% macro Details() %}
<div class="px-4 pt-5 border-top"> <div class="px-4 pt-5 border-top">
<div class=""> <div class="">
<div class="flex mb-2"> <div class="flex mb-2">
<div class="field-label"> <div class="field-label">
{{ _("Course Name: ") }} {{ course.title }} {% set label = "Course Name" if module == "course" else "Class Name" %}
{{ _(label) }} : {{ title }}
</div> </div>
</div> </div>
<div class="flex"> <div class="flex">
<div class="field-label"> <div class="field-label">
{{ _("Total Price: ") }} {{ frappe.utils.fmt_money(course.course_price, 2, course.currency) }} {{ _("Total Price: ") }} {{ frappe.utils.fmt_money(amount, 2, currency) }}
</div> </div>
</div> </div>
</div> </div>
@@ -49,7 +50,7 @@
{{ _("Billing Details") }} {{ _("Billing Details") }}
</div> </div>
<div id="billing-form"></div> <div id="billing-form"></div>
<button class="btn btn-primary btn-md btn-pay" data-course="{{ course.name | urlencode }}"> <button class="btn btn-primary btn-md btn-pay" data-doctype="{{ doctype }}" data-name="{{ docname | urlencode }}">
{{ "Proceed to Payment" }} {{ "Proceed to Payment" }}
</button> </button>
</div> </div>

View File

@@ -1,6 +1,8 @@
frappe.ready(() => { frappe.ready(() => {
if ($("#billing-form").length) { if ($("#billing-form").length) {
setup_billing(); frappe.require("controls.bundle.js", () => {
setup_billing();
});
} }
$(".btn-pay").click((e) => { $(".btn-pay").click((e) => {
@@ -66,19 +68,22 @@ const setup_billing = () => {
const generate_payment_link = (e) => { const generate_payment_link = (e) => {
address = this.billing.get_values(); address = this.billing.get_values();
let course = decodeURIComponent($(e.currentTarget).attr("data-course")); let doctype = $(e.currentTarget).attr("data-doctype");
let docname = decodeURIComponent($(e.currentTarget).attr("data-name"));
frappe.call({ frappe.call({
method: "lms.lms.doctype.lms_course.lms_course.get_payment_options", method: "lms.lms.utils.get_payment_options",
args: { args: {
course: course, doctype: doctype,
docname: docname,
phone: address.phone, phone: address.phone,
}, },
callback: (data) => { callback: (data) => {
data.message.handler = (response) => { data.message.handler = (response) => {
handle_success( handle_success(
response, response,
course, doctype,
docname,
address, address,
data.message.order_id data.message.order_id
); );
@@ -89,12 +94,13 @@ const generate_payment_link = (e) => {
}); });
}; };
const handle_success = (response, course, address, order_id) => { const handle_success = (response, doctype, docname, address, order_id) => {
frappe.call({ frappe.call({
method: "lms.lms.doctype.lms_course.lms_course.verify_payment", method: "lms.lms.utils.verify_payment",
args: { args: {
response: response, response: response,
course: course, doctype: doctype,
docname: docname,
address: address, address: address,
order_id: order_id, order_id: order_id,
}, },

View File

@@ -3,21 +3,66 @@ from frappe import _
def get_context(context): def get_context(context):
course_name = frappe.form_dict.course module = frappe.form_dict.module
docname = frappe.form_dict.modulename
if not course_name:
raise ValueError(_("Course is required."))
if frappe.session.user == "Guest": if frappe.session.user == "Guest":
raise frappe.PermissionError(_("You are not allowed to access this page.")) raise frappe.PermissionError(_("You are not allowed to access this page."))
membership = frappe.db.exists( if module not in ["course", "class"]:
"LMS Batch Membership", {"member": frappe.session.user, "course": course_name} raise ValueError(_("Module is incorrect."))
)
if membership: doctype = "LMS Course" if module == "course" else "LMS Class"
raise frappe.PermissionError(_("You are already enrolled for this course")) context.module = module
context.docname = docname
context.doctype = doctype
context.course = frappe.db.get_value( if not frappe.db.exists(doctype, docname):
"LMS Course", course_name, ["title", "name", "course_price", "currency"], as_dict=True print(doctype, docname)
) raise ValueError(_("Module Name is incorrect or does not exist."))
if doctype == "LMS Course":
membership = frappe.db.exists(
"LMS Batch Membership", {"member": frappe.session.user, "course": docname}
)
if membership:
raise frappe.PermissionError(_("You are already enrolled for this course"))
else:
membership = frappe.db.exists(
"Class Student", {"student": frappe.session.user, "parent": docname}
)
if membership:
raise frappe.PermissionError(_("You are already enrolled for this class"))
if doctype == "LMS Course":
course = frappe.db.get_value(
"LMS Course",
docname,
["title", "name", "paid_course", "course_price", "currency"],
as_dict=True,
)
if not course.paid_course:
raise frappe.PermissionError(_("This course is free."))
context.title = course.title
context.amount = course.course_price
context.currency = course.currency
else:
class_info = frappe.db.get_value(
"LMS Class",
docname,
["title", "name", "paid_class", "amount", "currency"],
as_dict=True,
)
if not class_info.paid_class:
raise frappe.PermissionError(
_("To join this class, please contact the Administrator.")
)
context.title = class_info.title
context.amount = class_info.amount
context.currency = class_info.currency

View File

@@ -85,12 +85,6 @@
{% macro ClassSections(class_info, class_courses, class_students, flow) %} {% macro ClassSections(class_info, class_courses, class_students, flow) %}
<div class="mt-4"> <div class="mt-4">
{% if is_moderator %}
<button class="btn btn-default btn-sm pull-right" id="create-class">
{{ _("Edit") }}
</button>
{% endif %}
<ul class="nav lms-nav" id="classes-tab"> <ul class="nav lms-nav" id="classes-tab">
{% if is_student %} {% if is_student %}
@@ -610,17 +604,16 @@
<script> <script>
frappe.boot.user = { frappe.boot.user = {
"can_create": [], "can_create": [],
"can_select": ["User", "LMS Category", "LMS Assignment", "LMS Quiz"], "can_select": ["User", "LMS Assignment", "LMS Quiz"],
"can_read": ["User", "LMS Category", "LMS Assignment", "LMS Quiz"] "can_read": ["User", "LMS Assignment", "LMS Quiz"]
}; };
frappe.boot.single_types = [] frappe.boot.single_types = []
let class_info = {{ class_info | json }};
</script> </script>
{% else %} {% else %}
<script> <script>
frappe.boot.user = { frappe.boot.user= {
"can_create": [], "can_create": [],
"can_select": ["LMS Course"], "can_select": ["LMS Course"],
"can_read": ["LMS Course"] "can_read": ["LMS Course"]
@@ -629,5 +622,4 @@
</script> </script>
{% endif %} {% endif %}
{{ include_script('controls.bundle.js') }}
{% endblock %} {% endblock %}

View File

@@ -1,6 +1,11 @@
frappe.ready(() => { frappe.ready(() => {
let self = this; let self = this;
if ($("#live-class-form").length) {
frappe.require("controls.bundle.js", () => {
make_live_class_form();
});
}
$(".btn-add-student").click((e) => { $(".btn-add-student").click((e) => {
show_student_modal(e); show_student_modal(e);
}); });
@@ -9,10 +14,6 @@ frappe.ready(() => {
remove_student(e); remove_student(e);
}); });
if ($("#live-class-form").length) {
make_live_class_form();
}
$("#open-class-modal").click((e) => { $("#open-class-modal").click((e) => {
e.preventDefault(); e.preventDefault();
$("#live-class-modal").modal("show"); $("#live-class-modal").modal("show");
@@ -25,7 +26,6 @@ frappe.ready(() => {
$(".btn-add-course").click((e) => { $(".btn-add-course").click((e) => {
show_course_modal(e); show_course_modal(e);
}); });
$(".btn-remove-course").click((e) => { $(".btn-remove-course").click((e) => {
remove_course(e); remove_course(e);
}); });
@@ -309,6 +309,12 @@ const show_course_modal = () => {
fieldname: "course", fieldname: "course",
reqd: 1, reqd: 1,
}, },
{
fieldtype: "Link",
options: "Course Evaluator",
label: __("Course Evaluator"),
fieldname: "evaluator",
},
], ],
primary_action_label: __("Add"), primary_action_label: __("Add"),
primary_action(values) { primary_action(values) {

View File

@@ -1,7 +1,7 @@
from frappe import _ from frappe import _
import frappe import frappe
from frappe.utils import getdate, cint from frappe.utils import getdate, cint
from lms.www.utils import get_assessments from lms.www.utils import get_assessments, is_student
from lms.lms.utils import ( from lms.lms.utils import (
has_course_moderator_role, has_course_moderator_role,
has_course_evaluator_role, has_course_evaluator_role,
@@ -36,6 +36,10 @@ def get_context(context):
"start_time", "start_time",
"end_time", "end_time",
"category", "category",
"paid_class",
"amount",
"currency",
"prerequisite",
], ],
as_dict=True, as_dict=True,
) )
@@ -67,7 +71,7 @@ def get_context(context):
context.class_students = get_class_student_details( context.class_students = get_class_student_details(
class_students, class_courses, context.assessments class_students, class_courses, context.assessments
) )
context.is_student = is_student(class_students) context.is_student = is_student(class_name)
if not context.is_student and not context.is_moderator and not context.is_evaluator: if not context.is_student and not context.is_moderator and not context.is_evaluator:
raise frappe.PermissionError(_("You don't have permission to access this page.")) raise frappe.PermissionError(_("You don't have permission to access this page."))
@@ -205,11 +209,6 @@ def sort_students(class_students):
return class_students return class_students
def is_student(class_students):
students = [student.student for student in class_students]
return frappe.session.user in students
def get_scheduled_flow(class_name): def get_scheduled_flow(class_name):
chapters = [] chapters = []

View File

@@ -0,0 +1,214 @@
{% extends "lms/templates/lms_base.html" %}
{% block title %}
{{ _(class_info.title) }}
{% endblock %}
{% block page_content %}
<div class="common-page-style lms-page-style">
{{ ClassHeader(class_info) }}
<div class="container">
{{ CourseHeaderOverlay(class_info) }}
<div class="pt-10">
{{ Prerequisites(class_info) }}
{{ CourseList(class_info) }}
</div>
</div>
</div>
{% endblock %}
{% macro ClassHeader(class_info) %}
<div class="course-head-container">
<div class="container">
<div class="course-card-wide">
{{ BreadCrumb(class_info) }}
{{ ClassHeaderDetails(class_info) }}
</div>
</div>
</div>
{% endmacro %}
{% macro BreadCrumb(class_info) %}
<article class="mb-8">
<a class="dark-links" href="/classes">
{{ _("All Classes") }}
</a>
<img class="" src="/assets/lms/icons/chevron-right.svg">
<span class="breadcrumb-destination">
{{ _("Class Details") }}
</span>
</article>
{% endmacro %}
{% macro ClassHeaderDetails(class_info) %}
<div class="class-details" data-class="{{ class_info.name }}">
<div class="flex align-center">
<span>
{{ class_info.courses | length }} {{ _("Courses") }}
</span>
<span class="px-2"> · </span>
<span>
{{ class_info.students | length }} {{ _("Students") }}
</span>
</div>
<div class="page-title">
{{ class_info.title }}
</div>
<div class="">
{{ class_info.description }}
</div>
<div class="mt-8">
<svg class="icon icon-sm">
<use href="#icon-calendar"></use>
</svg>
<span>
{{ frappe.utils.format_date(class_info.start_date, "long") }} -
</span>
<span>
{{ frappe.utils.format_date(class_info.end_date, "long") }}
</span>
</div>
{% if class_info.start_time and class_info.end_time %}
<div class="mt-1">
<svg class="icon icon-sm">
<use href="#icon-clock"></use>
</svg>
<span>
{{ frappe.utils.format_time(class_info.start_time, "hh:mm a") }} -
</span>
<span>
{{ frappe.utils.format_time(class_info.end_time, "hh:mm a") }}
</span>
</div>
{% endif %}
</div>
{% endmacro %}
{% macro CourseHeaderOverlay(class_info) %}
<div class="course-overlay-card class-overlay">
<div class="course-overlay-content">
{% if class_info.paid_class %}
<div class="bold-heading">
{{ frappe.utils.fmt_money(class_info.amount, 0, class_info.currency) }}
</div>
{% endif %}
<div class="vertically-center mt-2">
<svg class="icon icon-md mr-1">
<use href="#icon-education"></use>
</svg>
{{ class_info.courses | length }} {{ _("Courses") }}
</div>
<div class="vertically-center mt-2">
<svg class="icon icon-md mr-1">
<use class="" href="#icon-users">
</svg>
{{ class_info.students | length }} {{ _("Students") }}
</div>
<div class="mt-2">
<svg class="icon icon-sm">
<use href="#icon-calendar"></use>
</svg>
<span>
{{ frappe.utils.format_date(class_info.start_date, "long") }} -
</span>
<span>
{{ frappe.utils.format_date(class_info.end_date, "long") }}
</span>
</div>
{% if class_info.start_time and class_info.end_time %}
<div class="mt-2">
<svg class="icon icon-sm">
<use href="#icon-clock"></use>
</svg>
<span>
{{ frappe.utils.format_time(class_info.start_time, "hh:mm a") }} -
</span>
<span>
{{ frappe.utils.format_time(class_info.end_time, "hh:mm a") }}
</span>
</div>
{% endif %}
<div class="mt-2">
{% if is_moderator or is_evaluator or is_student %}
<a class="btn btn-primary wide-button" href="/classes/{{ class_info.name }}">
{{ _("Checkout Class") }}
</a>
{% elif class_info.paid_class %}
<a class="btn btn-primary wide-button" href="/billing/class/{{ class_info.name }}">
{{ _("Register Now") }}
</a>
{% else %}
<div class="alert alert-info">
{{ _("To join this class, please contact the Administrator.") }}
</div>
{% endif %}
</div>
{% if is_moderator %}
<div class="mt-2">
<div class="btn btn-secondary wide-button" id="create-class">
{{ _("Edit Class") }}
</div>
</div>
{% endif %}
</div>
</div>
{% endmacro %}
{% macro Prerequisites(class_info) %}
<div class="course-description-section w-50">
<div class="page-title">
{{ _("Prerequisite Knowledge") }}
</div>
<div class="mt-2">
{{ class_info.prerequisite }}
</div>
</div>
{% endmacro %}
{% macro CourseList(class_info) %}
<div>
<div class="page-title">
{{ _("Courses") }}
</div>
{% if class_info.courses | length %}
<div class="cards-parent mt-2">
{% for course in class_info.courses %}
<div class="h-100">
{{ widgets.CourseCard(course=course, read_only=False) }}
<button class="btn icon-btn btn-default btn-block btn-remove-course" data-course="{{ course.name }}">
<svg class="icon icon-sm">
<use href="#icon-delete"></use>
</svg>
</button>
</div>
{% endfor %}
</div>
{% else %}
<div class="">
{{ _("No courses") }}
</div>
{% endif %}
</div>
{% endmacro %}
{%- block script %}
{{ super() }}
{% if is_moderator %}
<script>
let class_info = {{ class_info | json }};
</script>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,3 @@
frappe.ready(() => {
frappe.require("controls.bundle.js");
});

View File

@@ -0,0 +1,21 @@
import frappe
from lms.lms.utils import has_course_moderator_role, has_course_evaluator_role
from lms.www.utils import is_student
def get_context(context):
context.no_cache = 1
class_name = frappe.form_dict["classname"]
context.class_info = frappe.get_doc("LMS Class", class_name)
for course in context.class_info.courses:
course.update(
frappe.db.get_value(
"LMS Course", course.course, ["name", "short_introduction", "image"], as_dict=1
)
)
context.is_moderator = has_course_moderator_role()
context.is_evaluator = has_course_evaluator_role()
context.is_student = is_student(class_name)

View File

@@ -43,7 +43,7 @@
{% if is_moderator %} {% if is_moderator %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" data-toggle="tab" href="#past"> <a class="nav-link" data-toggle="tab" href="#past">
{{ _("Past Classes") }} {{ _("Archived") }}
<span class="course-list-count"> <span class="course-list-count">
{{ past_classes | length }} {{ past_classes | length }}
</span> </span>
@@ -54,7 +54,7 @@
{% if frappe.session.user != "Guest" %} {% if frappe.session.user != "Guest" %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" data-toggle="tab" href="#my-class"> <a class="nav-link" data-toggle="tab" href="#my-class">
{{ _("My Classes") }} {{ _("Enrolled") }}
<span class="course-list-count"> <span class="course-list-count">
{{ my_classes | length }} {{ my_classes | length }}
</span> </span>
@@ -68,18 +68,18 @@
<div class="tab-content"> <div class="tab-content">
<div class="tab-pane active" id="upcoming" role="tabpanel" aria-labelledby="upcoming"> <div class="tab-pane active" id="upcoming" role="tabpanel" aria-labelledby="upcoming">
{{ ClassCards(upcoming_classes) }} {{ ClassCards(upcoming_classes, show_price=True) }}
</div> </div>
{% if is_moderator %} {% if is_moderator %}
<div class="tab-pane" id="past" role="tabpanel" aria-labelledby="past"> <div class="tab-pane" id="past" role="tabpanel" aria-labelledby="past">
{{ ClassCards(past_classes) }} {{ ClassCards(past_classes, show_price=False) }}
</div> </div>
{% endif %} {% endif %}
{% if frappe.session.user != "Guest" %} {% if frappe.session.user != "Guest" %}
<div class="tab-pane" id="my-class" role="tabpanel" aria-labelledby="my-classes"> <div class="tab-pane" id="my-class" role="tabpanel" aria-labelledby="my-classes">
{{ ClassCards(my_classes) }} {{ ClassCards(my_classes, show_price=False) }}
</div> </div>
{% endif %} {% endif %}
@@ -87,7 +87,7 @@
</article> </article>
{% endmacro %} {% endmacro %}
{% macro ClassCards(classes) %} {% macro ClassCards(classes, show_price=False) %}
<div class="lms-card-parent"> <div class="lms-card-parent">
{% for class in classes %} {% for class in classes %}
{% set course_count = frappe.db.count("Class Course", {"parent": class.name}) %} {% set course_count = frappe.db.count("Class Course", {"parent": class.name}) %}
@@ -105,6 +105,12 @@
</div> </div>
{% endif %} {% endif %}
{% if show_price and class.paid_class %}
<div class="bold-heading">
{{ frappe.utils.fmt_money(class.amount, 0, class.currency) }}
</div>
{% endif %}
<div class="mt-auto mb-1"> <div class="mt-auto mb-1">
<svg class="icon icon-sm"> <svg class="icon icon-sm">
<use href="#icon-calendar"></use> <use href="#icon-calendar"></use>
@@ -131,7 +137,11 @@
{{ student_count }} {{ _("Students") }} {{ student_count }} {{ _("Students") }}
</div> </div>
<a class="stretched-link" href="/classes/{{ class.name }}"></a> {% if is_student(class.name) %}
<a class="stretched-link" href="/classes/{{ class.name }}"></a>
{% else %}
<a class="stretched-link" href="/classes/details/{{ class.name }}"></a>
{% endif %}
</div> </div>
{% endfor %} {% endfor %}
</div> </div>

View File

@@ -1,11 +1,12 @@
import frappe import frappe
from frappe.utils import getdate from frappe.utils import getdate
from lms.lms.utils import has_course_moderator_role from lms.lms.utils import has_course_moderator_role, has_course_evaluator_role
def get_context(context): def get_context(context):
context.no_cache = 1 context.no_cache = 1
context.is_moderator = has_course_moderator_role() context.is_moderator = has_course_moderator_role()
context.is_evaluator = has_course_evaluator_role()
classes = frappe.get_all( classes = frappe.get_all(
"LMS Class", "LMS Class",
fields=[ fields=[
@@ -15,6 +16,8 @@ def get_context(context):
"start_date", "start_date",
"end_date", "end_date",
"paid_class", "paid_class",
"amount",
"currency",
"seat_count", "seat_count",
], ],
) )

View File

@@ -10,8 +10,8 @@
{{ CourseHomeHeader(course) }} {{ CourseHomeHeader(course) }}
<div class="course-home-page"> <div class="course-home-page">
<div class="container"> <div class="container">
{{ CourseHeaderOverlay(course) }}
<div class="course-body-container"> <div class="course-body-container">
{{ CourseHeaderOverlay(course) }}
{{ Description(course) }} {{ Description(course) }}
{{ widgets.CourseOutline(course=course, membership=membership, is_user_interested=is_user_interested) }} {{ widgets.CourseOutline(course=course, membership=membership, is_user_interested=is_user_interested) }}
{% if course.status == "Approved" and not frappe.utils.cint(course.upcoming) %} {% if course.status == "Approved" and not frappe.utils.cint(course.upcoming) %}
@@ -41,7 +41,7 @@
{% macro BreadCrumb(course) %} {% macro BreadCrumb(course) %}
<div class="breadcrumb"> <div class="breadcrumb">
<a class="dark-links" href="/courses">{{ _("All Courses") }}</a> <a class="dark-links" href="/courses">{{ _("All Courses") }}</a>
<img class="ml-1 mr-1" src="/assets/lms/icons/chevron-right.svg"> <img class="" src="/assets/lms/icons/chevron-right.svg">
<span class="breadcrumb-destination">{{ course.title if course.title else _("New Course") }}</span> <span class="breadcrumb-destination">{{ course.title if course.title else _("New Course") }}</span>
</div> </div>
{% endmacro %} {% endmacro %}
@@ -57,8 +57,8 @@
{% endfor %} {% endfor %}
</div> </div>
<div id="title" {% if course.name %} data-course="{{ course.name | urlencode }}" {% endif %} class="page-title"> <div id="title" class="page-title">
{% if course.title %} {{ course.title }} {% endif %} {{ course.title }}
</div> </div>
<div id="intro"> <div id="intro">
@@ -228,7 +228,7 @@
</a> </a>
{% elif course.paid_course and not is_instructor %} {% elif course.paid_course and not is_instructor %}
<a class="btn btn-primary wide-button" href="/billing/{{ course.name | urlencode }}"> <a class="btn btn-primary wide-button" href="/billing/course/{{ course.name | urlencode }}">
{{ _("Buy This Course") }} {{ _("Buy This Course") }}
</a> </a>

View File

@@ -128,3 +128,16 @@ def get_quiz_details(assessment, member):
existing_submission[0].name if len(existing_submission) else "new-submission" existing_submission[0].name if len(existing_submission) else "new-submission"
) )
assessment.url = f"/quiz-submission/{assessment.assessment_name}/{submission_name}" assessment.url = f"/quiz-submission/{assessment.assessment_name}/{submission_name}"
def is_student(class_name, member=None):
if not member:
member = frappe.session.user
return frappe.db.exists(
"Class Student",
{
"student": member,
"parent": class_name,
},
)