chore: resolved conflicts

This commit is contained in:
Jannat Patel
2025-11-17 10:12:26 +05:30
80 changed files with 5586 additions and 2780 deletions

View File

@@ -1384,6 +1384,7 @@ def save_role(user, role, value):
doc.save(ignore_permissions=True)
else:
frappe.db.delete("Has Role", {"parent": user, "role": role})
frappe.clear_cache(user=user)
return True

View File

View File

@@ -0,0 +1,8 @@
// Copyright (c) 2025, Frappe and contributors
// For license information, please see license.txt
// frappe.ui.form.on("LMS Coupon", {
// refresh(frm) {
// },
// });

View File

@@ -0,0 +1,190 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "hash",
"creation": "2025-10-11 21:39:11.456420",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"enabled",
"section_break_spfj",
"code",
"expires_on",
"column_break_mptc",
"discount_type",
"percentage_discount",
"fixed_amount_discount",
"section_break_ixxu",
"usage_limit",
"column_break_dcvj",
"redemption_count",
"section_break_ophm",
"applicable_items"
],
"fields": [
{
"fieldname": "code",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Code",
"reqd": 1,
"unique": 1
},
{
"fieldname": "discount_type",
"fieldtype": "Select",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Discount Type",
"options": "Percentage\nFixed Amount",
"reqd": 1
},
{
"fieldname": "expires_on",
"fieldtype": "Date",
"in_standard_filter": 1,
"label": "Expires On"
},
{
"fieldname": "usage_limit",
"fieldtype": "Int",
"in_list_view": 1,
"label": "Usage Limit"
},
{
"fieldname": "applicable_items",
"fieldtype": "Table",
"label": "Applicable Items",
"options": "LMS Coupon Item",
"reqd": 1
},
{
"default": "1",
"fieldname": "enabled",
"fieldtype": "Check",
"in_standard_filter": 1,
"label": "Enabled"
},
{
"fieldname": "column_break_mptc",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_ixxu",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_dcvj",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_ophm",
"fieldtype": "Section Break"
},
{
"depends_on": "eval:doc.discount_type=='Percentage'",
"fieldname": "percentage_discount",
"fieldtype": "Int",
"in_list_view": 1,
"label": "Percentage Discount",
"mandatory_depends_on": "eval:doc.discount_type=='Percentage'"
},
{
"depends_on": "eval:doc.discount_type=='Fixed Amount'",
"fieldname": "fixed_amount_discount",
"fieldtype": "Int",
"in_list_view": 1,
"label": "Fixed Amount Discount",
"mandatory_depends_on": "eval:doc.discount_type=='Fixed Amount'"
},
{
"default": "0",
"fieldname": "redemption_count",
"fieldtype": "Int",
"in_list_view": 1,
"label": "Redemption Count",
"read_only": 1
},
{
"fieldname": "section_break_spfj",
"fieldtype": "Section Break"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-10-27 19:52:11.835042",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Coupon",
"naming_rule": "Random",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Administrator",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Batch Evaluator",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Course Creator",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Moderator",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"rows_threshold_for_grid_search": 20,
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"title_field": "code"
}

View File

@@ -0,0 +1,31 @@
# Copyright (c) 2025, Frappe and contributors
# For license information, please see license.txt
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import cint, nowdate
class LMSCoupon(Document):
def validate(self):
self.convert_to_uppercase()
self.validate_expiry_date()
self.validate_applicable_items()
self.validate_usage_limit()
def convert_to_uppercase(self):
if self.code:
self.code = self.code.strip().upper()
def validate_expiry_date(self):
if self.expires_on and str(self.expires_on) < nowdate():
frappe.throw(_("Expiry date cannot be in the past"))
def validate_applicable_items(self):
if not self.get("applicable_items") or len(self.get("applicable_items")) == 0:
frappe.throw(_("At least one applicable item is required"))
def validate_usage_limit(self):
if self.usage_limit is not None and cint(self.usage_limit) < 0:
frappe.throw(_("Usage limit cannot be negative"))

View File

@@ -0,0 +1,20 @@
# Copyright (c) 2025, Frappe and Contributors
# See license.txt
# import frappe
from frappe.tests import IntegrationTestCase
# On IntegrationTestCase, the doctype test records and all
# link-field test record dependencies are recursively loaded
# Use these module variables to add/remove to/from that list
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
class IntegrationTestLMSCoupon(IntegrationTestCase):
"""
Integration tests for LMSCoupon.
Use this class for testing interactions between multiple components.
"""
pass

View File

@@ -0,0 +1,43 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2025-10-11 21:45:00",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"reference_doctype",
"reference_name"
],
"fields": [
{
"fieldname": "reference_doctype",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Reference DocType",
"options": "\nLMS Course\nLMS Batch",
"reqd": 1
},
{
"fieldname": "reference_name",
"fieldtype": "Dynamic Link",
"in_list_view": 1,
"label": "Reference Name",
"options": "reference_doctype",
"reqd": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-10-12 17:27:14.123811",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Coupon Item",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}

View File

@@ -0,0 +1,9 @@
# Copyright (c) 2025, Frappe and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class LMSCouponItem(Document):
pass

View File

@@ -16,14 +16,18 @@
"payment_received",
"payment_for_certificate",
"payment_details_section",
"currency",
"original_amount",
"discount_amount",
"amount",
"amount_with_gst",
"column_break_yxpl",
"order_id",
"payment_id",
"currency",
"coupon",
"coupon_code",
"billing_details_section",
"address",
"payment_id",
"order_id",
"column_break_monu",
"gstin",
"pan"
@@ -47,6 +51,19 @@
"options": "currency",
"reqd": 1
},
{
"fieldname": "coupon",
"fieldtype": "Link",
"label": "Coupon",
"options": "LMS Coupon"
},
{
"depends_on": "coupon",
"fieldname": "discount_amount",
"fieldtype": "Currency",
"label": "Discount Amount",
"options": "currency"
},
{
"fieldname": "currency",
"fieldtype": "Link",
@@ -117,12 +134,6 @@
"options": "User",
"reqd": 1
},
{
"depends_on": "eval:doc.currency == \"INR\";",
"fieldname": "amount_with_gst",
"fieldtype": "Currency",
"label": "Amount with GST"
},
{
"fieldname": "payment_for_document_type",
"fieldtype": "Select",
@@ -149,6 +160,27 @@
"fieldtype": "Check",
"in_standard_filter": 1,
"label": "Payment for Certificate"
},
{
"depends_on": "coupon",
"fetch_from": "coupon.code",
"fieldname": "coupon_code",
"fieldtype": "Data",
"label": "Coupon Code",
"read_only": 1
},
{
"depends_on": "eval:doc.currency == \"INR\"",
"fieldname": "amount_with_gst",
"fieldtype": "Currency",
"label": "Amount with GST"
},
{
"depends_on": "coupon",
"fieldname": "original_amount",
"fieldtype": "Currency",
"label": "Original Amount",
"options": "currency"
}
],
"index_web_pages_for_search": 1,
@@ -162,7 +194,7 @@
"link_fieldname": "payment"
}
],
"modified": "2025-09-23 11:04:00.462274",
"modified": "2025-11-12 12:39:52.466297",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Payment",

View File

@@ -5,7 +5,7 @@ import frappe
from frappe import _
from frappe.email.doctype.email_template.email_template import get_email_template
from frappe.model.document import Document
from frappe.utils import add_days, nowdate
from frappe.utils import add_days, flt, nowdate
class LMSPayment(Document):

View File

@@ -23,23 +23,38 @@ def get_payment_link(
docname,
title,
amount,
total_amount,
discount_amount,
gst_amount,
currency,
address,
redirect_to,
payment_for_certificate,
coupon_code=None,
coupon=None,
):
payment_gateway = get_payment_gateway()
address = frappe._dict(address)
amount_with_gst = total_amount if total_amount != amount else 0
original_amount = amount
amount -= discount_amount
amount_with_gst = get_amount_with_gst(amount, gst_amount)
payment = record_payment(
address, doctype, docname, amount, currency, amount_with_gst, payment_for_certificate
address,
doctype,
docname,
amount,
original_amount,
currency,
amount_with_gst,
discount_amount,
payment_for_certificate,
coupon_code,
coupon,
)
controller = get_controller(payment_gateway)
payment_details = {
"amount": total_amount,
"amount": amount_with_gst if amount_with_gst else amount,
"title": f"Payment for {doctype} {title} {docname}",
"description": f"{address.billing_name}'s payment for {title}",
"reference_doctype": doctype,
@@ -51,23 +66,41 @@ def get_payment_link(
"redirect_to": redirect_to,
"payment": payment.name,
}
if payment_gateway == "Razorpay":
order = controller.create_order(**payment_details)
payment_details.update({"order_id": order.get("id")})
create_order(payment_gateway, payment_details, controller)
url = controller.get_payment_url(**payment_details)
return url
def create_order(payment_gateway, payment_details, controller):
if payment_gateway != "Razorpay":
return
order = controller.create_order(**payment_details)
payment_details.update({"order_id": order.get("id")})
def get_amount_with_gst(amount, gst_amount):
amount_with_gst = 0
if gst_amount:
amount_with_gst = amount + gst_amount
return amount_with_gst
def record_payment(
address,
doctype,
docname,
amount,
original_amount,
currency,
amount_with_gst=0,
discount_amount=0,
payment_for_certificate=0,
coupon_code=None,
coupon=None,
):
address = frappe._dict(address)
address_name = save_address(address)
@@ -80,6 +113,7 @@ def record_payment(
"address": address_name,
"amount": amount,
"currency": currency,
"discount_amount": discount_amount,
"amount_with_gst": amount_with_gst,
"gstin": address.gstin,
"pan": address.pan,
@@ -89,6 +123,16 @@ def record_payment(
"payment_for_certificate": payment_for_certificate,
}
)
if coupon_code:
payment_doc.update(
{
"coupon": coupon,
"coupon_code": coupon_code,
"discount_amount": discount_amount,
"original_amount": original_amount,
}
)
payment_doc.save(ignore_permissions=True)
return payment_doc

View File

@@ -1587,6 +1587,28 @@ def get_batch_students(batch):
"LMS Batch Enrollment", filters={"batch": batch}, fields=["member", "name"]
)
for student in students_list:
details = get_batch_student_details(student)
calculate_student_progress(batch, details)
students.append(details)
students = sorted(students, key=lambda x: x.progress, reverse=True)
return students
def get_batch_student_details(student):
details = frappe.db.get_value(
"User",
student.member,
["full_name", "email", "username", "last_active", "user_image"],
as_dict=True,
)
details.last_active = format_datetime(details.last_active, "dd MMM YY")
details.name = student.name
details.assessments = frappe._dict()
return details
def calculate_student_progress(batch, details):
batch_courses = frappe.get_all("Batch Course", {"parent": batch}, ["course", "title"])
assessments = frappe.get_all(
"LMS Assessment",
@@ -1594,53 +1616,55 @@ def get_batch_students(batch):
fields=["name", "assessment_type", "assessment_name"],
)
for student in students_list:
courses_completed = 0
assessments_completed = 0
detail = frappe.db.get_value(
"User",
student.member,
["full_name", "email", "username", "last_active", "user_image"],
as_dict=True,
calculate_course_progress(batch_courses, details)
calculate_assessment_progress(assessments, details)
if len(batch_courses) + len(assessments):
details.progress = flt(
(
(details.average_course_progress * len(batch_courses))
+ (details.average_assessments_progress * len(assessments))
)
/ (len(batch_courses) + len(assessments)),
2,
)
detail.last_active = format_datetime(detail.last_active, "dd MMM YY")
detail.name = student.name
detail.courses = frappe._dict()
detail.assessments = frappe._dict()
else:
details.progress = 0
""" Iterate through courses and track their progress """
for course in batch_courses:
progress = frappe.db.get_value(
"LMS Enrollment", {"course": course.course, "member": student.member}, "progress"
)
detail.courses[course.title] = progress
if progress == 100:
courses_completed += 1
""" Iterate through assessments and track their progress """
for assessment in assessments:
title = frappe.db.get_value(assessment.assessment_type, assessment.assessment_name, "title")
assessment_info = has_submitted_assessment(
assessment.assessment_name, assessment.assessment_type, student.member
)
detail.assessments[title] = assessment_info
def calculate_course_progress(batch_courses, details):
course_progress = []
details.courses = frappe._dict()
if assessment_info.result == "Pass":
assessments_completed += 1
for course in batch_courses:
progress = frappe.db.get_value(
"LMS Enrollment", {"course": course.course, "member": details.email}, "progress"
)
details.courses[course.title] = progress
course_progress.append(progress)
detail.courses_completed = courses_completed
detail.assessments_completed = assessments_completed
if len(batch_courses) + len(assessments):
detail.progress = flt(
((courses_completed + assessments_completed) / (len(batch_courses) + len(assessments)) * 100),
2,
)
else:
detail.progress = 0
details.average_course_progress = (
flt(sum(course_progress) / len(batch_courses), 2) if len(batch_courses) else 0
)
students.append(detail)
students = sorted(students, key=lambda x: x.progress, reverse=True)
return students
def calculate_assessment_progress(assessments, details):
assessments_completed = 0
details.assessments = frappe._dict()
for assessment in assessments:
title = frappe.db.get_value(assessment.assessment_type, assessment.assessment_name, "title")
assessment_info = has_submitted_assessment(
assessment.assessment_name, assessment.assessment_type, details.email
)
details.assessments[title] = assessment_info
if assessment_info.result == "Pass":
assessments_completed += 1
details.average_assessments_progress = (
flt((assessments_completed / len(assessments) * 100), 2) if len(assessments) else 0
)
def has_submitted_assessment(assessment, assessment_type, member=None):
@@ -1752,51 +1776,140 @@ def get_discussion_replies(topic):
@frappe.whitelist()
def get_order_summary(doctype, docname, country=None):
if doctype == "LMS Course":
details = frappe.db.get_value(
"LMS Course",
docname,
[
"title",
"name",
"paid_course",
"paid_certificate",
"course_price as amount",
"currency",
"amount_usd",
],
as_dict=True,
)
if not details.paid_course and not details.paid_certificate:
raise frappe.throw(_("This course is free."))
else:
details = frappe.db.get_value(
"LMS Batch",
docname,
["title", "name", "paid_batch", "amount", "currency", "amount_usd"],
as_dict=True,
)
if not details.paid_batch:
raise frappe.throw(_("To join this batch, please contact the Administrator."))
def get_order_summary(doctype, docname, coupon=None, country=None):
details = get_paid_course_details(docname) if doctype == "LMS Course" else get_paid_batch_details(docname)
details.amount, details.currency = check_multicurrency(
details.amount, details.currency, country, details.amount_usd
)
details.original_amount = details.amount
details.original_amount_formatted = fmt_money(details.amount, 0, details.currency)
if details.currency == "INR":
details.amount, details.gst_applied = apply_gst(details.amount, country)
details.gst_amount_formatted = fmt_money(details.gst_applied, 0, details.currency)
adjust_amount_for_coupon(details, coupon, doctype, docname)
get_gst_details(details, country)
details.total_amount = details.amount
details.total_amount_formatted = fmt_money(details.amount, 0, details.currency)
return details
def get_paid_course_details(docname):
details = frappe.db.get_value(
"LMS Course",
docname,
[
"title",
"name",
"paid_course",
"paid_certificate",
"course_price as amount",
"currency",
"amount_usd",
],
as_dict=True,
)
if not details.paid_course and not details.paid_certificate:
raise frappe.throw(_("This course is free."))
return details
def get_paid_batch_details(docname):
details = frappe.db.get_value(
"LMS Batch",
docname,
["title", "name", "paid_batch", "amount", "currency", "amount_usd"],
as_dict=True,
)
if not details.paid_batch:
raise frappe.throw(_("To join this batch, please contact the Administrator."))
return details
def adjust_amount_for_coupon(details, coupon, doctype, docname):
if not coupon:
return
discount_amount, subtotal, coupon_name = apply_coupon(doctype, docname, coupon, details.amount)
details.amount = subtotal
details.discount_amount = discount_amount
details.discount_amount_formatted = fmt_money(discount_amount, 0, details.currency)
details.coupon = coupon_name
def get_gst_details(details, country):
if details.currency != "INR":
return
details.amount, details.gst_applied = apply_gst(details.amount, country)
details.gst_amount_formatted = fmt_money(details.gst_applied, 0, details.currency)
def apply_coupon(doctype, docname, code, base_amount):
coupon_name = frappe.db.exists("LMS Coupon", {"code": code, "enabled": 1})
if not coupon_name:
frappe.throw(_("The coupon code '{0}' is invalid.").format(code))
coupon = frappe.db.get_value(
"LMS Coupon",
coupon_name,
[
"expires_on",
"usage_limit",
"redemption_count",
"discount_type",
"percentage_discount",
"fixed_amount_discount",
"name",
"code",
],
as_dict=True,
)
validate_coupon(code, coupon)
validate_coupon_applicability(doctype, docname, coupon_name)
discount_amount = calculate_discount_amount(base_amount, coupon)
subtotal = max(flt(base_amount) - flt(discount_amount), 0)
return discount_amount, subtotal, coupon_name
def validate_coupon(code, coupon):
if coupon.expires_on and getdate(coupon.expires_on) < getdate():
frappe.throw(_("This coupon has expired."))
if coupon.usage_limit and cint(coupon.redemption_count) >= cint(coupon.usage_limit):
frappe.throw(_("This coupon has reached its maximum usage limit."))
def validate_coupon_applicability(doctype, docname, coupon_name):
applicable_item = frappe.db.exists(
"LMS Coupon Item", {"parent": coupon_name, "reference_doctype": doctype, "reference_name": docname}
)
if not applicable_item:
frappe.throw(
_("This coupon is not applicable to this {0}.").format(
"Course" if doctype == "LMS Course" else "Batch"
)
)
def calculate_discount_amount(base_amount, coupon):
discount_amount = 0
if coupon.discount_type == "Percentage":
discount_amount = (base_amount * coupon.percentage_discount) / 100
elif coupon.discount_type == "Fixed Amount":
discount_amount = base_amount - coupon.fixed_amount_discount
return discount_amount
@frappe.whitelist()
def get_lesson_creation_details(course, chapter, lesson):
chapter_name = frappe.db.get_value("Chapter Reference", {"parent": course, "idx": chapter}, "chapter")
@@ -1843,49 +1956,79 @@ def publish_notifications(doc, method):
def update_payment_record(doctype, docname):
request = frappe.get_all(
request = get_integration_requests(doctype, docname)
if len(request):
data = request[0].data
data = frappe._dict(json.loads(data))
payment_doc = get_payment_doc(data.payment)
update_payment_details(data)
update_coupon_redemption(payment_doc)
if payment_doc.payment_for_certificate:
update_certificate_purchase(docname, data.payment)
elif doctype == "LMS Course":
enroll_in_course(docname, data.payment)
else:
enroll_in_batch(docname, data.payment)
def get_integration_requests(doctype, docname):
return frappe.get_all(
"Integration Request",
{
"reference_doctype": doctype,
"reference_docname": docname,
"owner": frappe.session.user,
},
["data"],
order_by="creation desc",
limit=1,
)
if len(request):
data = frappe.db.get_value("Integration Request", request[0].name, "data")
data = frappe._dict(json.loads(data))
payment_gateway = data.get("payment_gateway")
if payment_gateway == "Razorpay":
payment_id = "razorpay_payment_id"
elif "Stripe" in payment_gateway:
payment_id = "stripe_token_id"
else:
payment_id = "order_id"
def get_payment_doc(payment_name):
return frappe.db.get_value(
"LMS Payment", payment_name, ["name", "coupon", "payment_for_certificate"], as_dict=True
)
def update_payment_details(data):
payment_id = get_payment_id(data)
frappe.db.set_value(
"LMS Payment",
data.payment,
{
"payment_received": 1,
"payment_id": data.get(payment_id),
"order_id": data.get("order_id"),
},
)
def get_payment_id(data):
payment_gateway = data.get("payment_gateway")
if payment_gateway == "Razorpay":
payment_id = "razorpay_payment_id"
elif "Stripe" in payment_gateway:
payment_id = "stripe_token_id"
else:
payment_id = "order_id"
return payment_id
def update_coupon_redemption(payment_doc):
if payment_doc.coupon:
redemption_count = frappe.db.get_value("LMS Coupon", payment_doc.coupon, "redemption_count") or 0
frappe.db.set_value(
"LMS Payment",
data.payment,
{
"payment_received": 1,
"payment_id": data.get(payment_id),
"order_id": data.get("order_id"),
},
"LMS Coupon",
payment_doc.coupon,
"redemption_count",
redemption_count + 1,
)
payment_for_certificate = frappe.db.get_value("LMS Payment", data.payment, "payment_for_certificate")
try:
if payment_for_certificate:
update_certificate_purchase(docname, data.payment)
elif doctype == "LMS Course":
enroll_in_course(docname, data.payment)
else:
enroll_in_batch(docname, data.payment)
except Exception as e:
frappe.log_error(frappe.get_traceback(), _("Enrollment Failed, {0}").format(e))
def enroll_in_course(course, payment_name):